# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT
"""FastAPI application for the CI relay service.

Receives GH Actions workflow completion callbacks, validates bearer token,
applies rate limiting and idempotency, resolves PR numbers for push-triggered
workflows, and publishes events to Kafka.

See OMN-2826 Phase 2a for specification.
"""

from __future__ import annotations

import logging
import os
import subprocess
from typing import Any  # any-ok: external API boundary

from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from omniclaude.hooks.topics import TopicBase
from omniclaude.lib.utils.sanitize import sanitize_log_input
from omniclaude.services.ci_relay.models import CICallbackPayload, PRStatusEvent
from omniclaude.services.ci_relay.rate_limiter import RateLimiter

logger = logging.getLogger(__name__)

# Topic for PR status events
PR_STATUS_TOPIC = TopicBase.GITHUB_PR_STATUS

# Module-level rate limiter (single instance per process)
_rate_limiter = RateLimiter()


def _reset_rate_limiter() -> None:
    """Reset the rate limiter. For testing only."""
    global _rate_limiter  # noqa: PLW0603
    _rate_limiter = RateLimiter()


# Security scheme
_bearer_scheme = HTTPBearer()


def _get_expected_token() -> str:
    """Read the expected bearer token from environment."""
    token = os.environ.get("CI_CALLBACK_TOKEN", "")
    if not token:
        raise RuntimeError(
            "CI_CALLBACK_TOKEN environment variable is not set. "
            "The relay cannot authenticate incoming requests."
        )
    return token


async def _verify_bearer(
    credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme),
) -> str:
    """Verify the bearer token matches CI_CALLBACK_TOKEN.

    Args:
        credentials: HTTP Bearer credentials from the request.

    Returns:
        The verified token string.

    Raises:
        HTTPException: If the token is missing or invalid.
    """
    expected = _get_expected_token()
    if credentials.credentials != expected:
        raise HTTPException(status_code=401, detail="Invalid bearer token")
    return credentials.credentials


def _resolve_pr_from_sha(repo: str, sha: str) -> int | None:
    """Resolve PR number from a commit SHA via GitHub API.

    Used when the callback has ``pr=0`` (push-triggered workflow).
    Results are cached by the caller for 5 minutes.

    Args:
        repo: Full repo slug (e.g. ``OmniNode-ai/omniclaude``).
        sha: Commit SHA to look up.

    Returns:
        PR number if found, None otherwise.
    """
    try:
        result = subprocess.run(
            [
                "gh",
                "api",
                f"repos/{repo}/commits/{sha}/pulls",
                "--jq",
                ".[0].number",
            ],
            capture_output=True,
            text=True,
            timeout=15,
            check=False,
        )
        if result.returncode == 0 and result.stdout.strip():
            return int(result.stdout.strip())
    except (subprocess.TimeoutExpired, ValueError, OSError) as exc:
        logger.warning("PR resolution failed for %s/%s: %s", repo, sha, exc)
    return None


# In-memory PR resolution cache: (repo, sha) -> (pr_number, timestamp)
_pr_cache: dict[tuple[str, str], tuple[int | None, float]] = {}
_PR_CACHE_TTL = 300  # 5 minutes


def _resolve_pr_cached(repo: str, sha: str) -> int | None:
    """Resolve PR number with 5-minute cache."""
    import time

    cache_key = (repo, sha)
    now = time.monotonic()

    cached = _pr_cache.get(cache_key)
    if cached is not None:
        pr_num, cached_at = cached
        if now - cached_at < _PR_CACHE_TTL:
            return pr_num

    pr_num = _resolve_pr_from_sha(repo, sha)
    _pr_cache[cache_key] = (pr_num, now)
    return pr_num


async def _publish_to_kafka(event: PRStatusEvent) -> None:
    """Publish a PRStatusEvent to Kafka.

    Uses the ONEX event bus infrastructure. Falls back to logging if
    Kafka is unavailable.

    Args:
        event: The event to publish.
    """
    try:
        # Import here to avoid hard dependency on Kafka at module load
        from omniclaude.services.ci_relay.publisher import publish_event

        await publish_event(PR_STATUS_TOPIC, event)
    except ImportError:
        logger.warning(
            "Kafka publisher not available. Event logged but not published: %s",
            event.dedupe_key,
        )
    except Exception:
        logger.exception("Failed to publish event %s to Kafka", event.dedupe_key)


def create_app() -> FastAPI:
    """Create and configure the CI relay FastAPI application.

    Returns:
        Configured FastAPI application instance.
    """
    app = FastAPI(
        title="CI Relay",
        description=(
            "Receives GitHub Actions workflow completion callbacks and "
            "publishes PR status events to Kafka."
        ),
        version="1.0.0",
    )

    @app.get("/health")
    async def health() -> dict[str, str]:
        """Health check endpoint."""
        return {"status": "ok", "service": "ci-relay"}

    @app.post("/callback")
    async def receive_callback(
        payload: CICallbackPayload,
        request: Request,
        _token: str = Depends(_verify_bearer),
    ) -> dict[str, Any]:  # ONEX_EXCLUDE: dict_str_any - external/untyped API boundary
        """Receive a GH Actions workflow completion callback.

        Validates bearer token, applies rate limiting and idempotency,
        optionally resolves PR number, then publishes to Kafka.

        Args:
            payload: The callback payload from GH Actions.
            request: The FastAPI request object.
            _token: Verified bearer token (injected by dependency).

        Returns:
            Dict with status and event details.

        Raises:
            HTTPException: On rate limit or auth failure.
        """
        dedupe_key = f"{payload.repo}:{payload.sha}:{payload.run_id}"

        # Sanitize user-controlled fields before logging to prevent log injection
        safe_repo = sanitize_log_input(payload.repo)
        safe_sha = sanitize_log_input(payload.sha)
        safe_conclusion = sanitize_log_input(payload.conclusion)
        safe_dedupe_key = sanitize_log_input(dedupe_key)

        # Check repo rate limit
        if not _rate_limiter.check_repo_rate(payload.repo):
            logger.warning(
                "Rate limited: repo=%s dedupe_key=%s",
                safe_repo,
                safe_dedupe_key,
            )
            raise HTTPException(
                status_code=429,
                detail=f"Rate limit exceeded for repo {safe_repo}",
            )

        # Check idempotency (dedupe within 1 hour)
        if not _rate_limiter.check_dedupe(dedupe_key):
            logger.info("Duplicate dropped: %s", safe_dedupe_key)
            return {
                "status": "duplicate",
                "dedupe_key": dedupe_key,
                "message": "Event already processed within deduplication window",
            }

        # Check per-sha notification rate limit
        if not _rate_limiter.check_sha_notification(
            payload.repo, payload.sha, payload.conclusion
        ):
            logger.info(
                "SHA notification suppressed: repo=%s sha=%s conclusion=%s",
                safe_repo,
                safe_sha,
                safe_conclusion,
            )
            return {
                "status": "suppressed",
                "dedupe_key": dedupe_key,
                "message": (
                    "Notification suppressed: same (repo, sha) within "
                    "cooldown and conclusion unchanged"
                ),
            }

        # Resolve PR number if pr=0 (push-triggered workflow)
        resolved_pr: int | None = None
        if payload.pr == 0:
            resolved_pr = _resolve_pr_cached(payload.repo, payload.sha)
            if resolved_pr is not None:
                logger.info(
                    "Resolved PR for push-triggered workflow: repo=%s sha=%s -> PR #%d",
                    safe_repo,
                    safe_sha,
                    resolved_pr,
                )

        # Build event
        event = PRStatusEvent.from_callback(
            payload,
            resolved_pr=resolved_pr,
            trace={
                "source": "ci-relay",
                "client_ip": request.client.host if request.client else "unknown",
            },
        )

        # Publish to Kafka
        await _publish_to_kafka(event)

        logger.info(
            "Published PR status event: repo=%s pr=%d conclusion=%s dedupe_key=%s",
            sanitize_log_input(event.repo),
            event.resolved_pr or event.pr,
            sanitize_log_input(event.conclusion),
            sanitize_log_input(event.dedupe_key),
        )

        return {
            "status": "published",
            "dedupe_key": event.dedupe_key,
            "message_id": event.message_id,
            "pr": event.resolved_pr or event.pr,
            "conclusion": event.conclusion,
        }

    return app
