"""
cortex/api/routes/fleet.py — Fleet/Aegis inbound webhook routes.

Handles:
  POST /api/v1/fleet/sentry  — Sentry issue/event_alert webhooks with full
                               categorization, dedup, and fleet announce.
  GET  /api/v1/fleet/status  — Fleet health status (future expansion).

Auth: The /fleet/sentry endpoint uses HMAC-SHA256 signature verification via
the ``Sentry-Hook-Signature`` header.  The secret is read from the
``AEGIS_SENTRY_SECRET`` env var (falls back to SENTRY_WEBHOOK_SECRET for
backwards compat).  An empty secret disables verification (dev mode).

Other fleet routes use the standard ``verify_api_key`` dependency.

Issues: #829, #1071
"""
from __future__ import annotations

import hashlib
import hmac
import json
import logging
import os
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import urlparse

from fastapi import APIRouter, Header, HTTPException, Request

logger = logging.getLogger(__name__)
router = APIRouter()

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_DEDUP_WINDOW_HOURS = 4          # suppress same issue_id for 4 hours
_SEVERITY_ORDER = {"info": 0, "warning": 1, "critical": 2}


# ---------------------------------------------------------------------------
# Route factory
# ---------------------------------------------------------------------------

def _make_routes(verify_api_key) -> None:  # noqa: C901  (function length OK — routes need context)
    """Register fleet routes onto the module-level router."""

    # Read secrets once at registration time; env vars are stable after boot.
    _sentry_secret: str = (
        os.getenv("AEGIS_SENTRY_SECRET")
        or os.getenv("SENTRY_WEBHOOK_SECRET")
        or ""
    )
    _fleet_url: str = os.getenv("AEGIS_FLEET_URL", "http://localhost:18801")
    _fleet_secret: str = os.getenv("FLEET_SECRET", "")

    # ------------------------------------------------------------------ #
    # POST /api/v1/fleet/sentry
    # ------------------------------------------------------------------ #

    @router.post("/api/v1/fleet/sentry", tags=["Fleet", "Aegis"])
    async def sentry_webhook(
        request: Request,
        sentry_hook_signature: Optional[str] = Header(
            None, alias="Sentry-Hook-Signature"
        ),
        sentry_hook_resource: Optional[str] = Header(
            None, alias="Sentry-Hook-Resource"
        ),
    ):
        """
        Receive a Sentry webhook, categorize the error, and announce to Eva.

        Supports:
        - ``issue`` resource (action: created / regressed / escalating)
        - ``event_alert`` resource

        Authentication: HMAC-SHA256 via ``Sentry-Hook-Signature``.
        No Cortex API key required (Sentry doesn't send one).
        """
        # ── 1. Read raw body ─────────────────────────────────────────────────
        body = await request.body()

        # ── 2. Verify HMAC signature ─────────────────────────────────────────
        if _sentry_secret:
            if not sentry_hook_signature:
                raise HTTPException(
                    status_code=401, detail="Missing Sentry-Hook-Signature header"
                )
            expected = hmac.new(
                _sentry_secret.encode("utf-8"),
                body,
                hashlib.sha256,
            ).hexdigest()
            if not hmac.compare_digest(expected, sentry_hook_signature.lower()):
                logger.warning(
                    "Sentry webhook signature mismatch — possible spoofing attempt"
                )
                raise HTTPException(status_code=401, detail="Invalid signature")

        # ── 3. Parse JSON ────────────────────────────────────────────────────
        try:
            data = json.loads(body)
        except json.JSONDecodeError as exc:
            raise HTTPException(status_code=400, detail=f"Invalid JSON: {exc}")

        resource = sentry_hook_resource or data.get("type", "unknown")
        action = data.get("action", "unknown")

        # ── 4. Extract issue / event data ────────────────────────────────────
        issue_data = data.get("data", {}).get("issue")
        event_data = data.get("data", {}).get("event")  # event_alert format

        if not issue_data and not event_data:
            logger.info(
                "Sentry webhook: resource=%s action=%s — no issue/event data, skipping",
                resource,
                action,
            )
            return {"status": "ok", "action": "skipped"}

        # Normalise event_alert → issue_data shape
        if event_data and not issue_data:
            issue_data = _normalise_event_alert(event_data)

        # ── 5. Build SentryEvent ─────────────────────────────────────────────
        from cortex.aegis.categorizer import SentryEvent, categorize

        exc_values = (
            issue_data.get("exception", {}).get("values", [])
            if isinstance(issue_data.get("exception"), dict)
            else []
        )
        exc_info = exc_values[0] if exc_values else {}
        metadata = issue_data.get("metadata") or {}

        error_class: str = (
            exc_info.get("type")
            or metadata.get("type")
            or (issue_data.get("title", "UnknownError").split(":")[0].strip())
        )
        error_message: str = str(
            exc_info.get("value")
            or metadata.get("value")
            or ""
        )

        # In-app stack frames (last 5)
        raw_frames = exc_info.get("stacktrace", {}).get("frames", [])
        in_app_frames = [f for f in raw_frames if f.get("in_app", False)][-5:]

        # Endpoint (URL path from request context)
        req_ctx = issue_data.get("request") or {}
        raw_url = req_ctx.get("url", "") if isinstance(req_ctx, dict) else ""
        endpoint = _extract_path(raw_url)

        # Tags → flat dict
        tags: dict = _parse_tags(issue_data.get("tags", []))

        first_seen = _parse_dt(issue_data.get("firstSeen", ""))
        last_seen = _parse_dt(issue_data.get("lastSeen", ""))

        event = SentryEvent(
            error_class=error_class,
            error_message=error_message,
            culprit=issue_data.get("culprit", ""),
            endpoint=endpoint,
            sentry_level=issue_data.get("level", "error"),
            substatus=issue_data.get("substatus", "new"),
            first_seen=first_seen,
            last_seen=last_seen,
            count=_safe_int(issue_data.get("count", 1)),
            issue_id=str(issue_data.get("id", "")),
            short_id=issue_data.get("shortId", ""),
            sentry_url=(
                issue_data.get("web_url", "")
                or issue_data.get("url", "")
            ),
            release=tags.get("release", ""),
            environment=tags.get("environment", "production"),
            stack_frames=in_app_frames,
        )

        # ── 6. Deploy correlation ────────────────────────────────────────────
        last_deploy = await _get_last_deploy_time()

        # ── 7. Categorize ────────────────────────────────────────────────────
        alert = categorize(event, last_deploy)

        # ── 8. Deduplication check ───────────────────────────────────────────
        if await _is_duplicate(event.issue_id, alert.severity):
            logger.info(
                "Deduped Sentry alert: %s (%s)", event.short_id, alert.category
            )
            return {"status": "ok", "action": "deduped"}

        # ── 9. Persist to aegis.db ───────────────────────────────────────────
        await _record_event(event, alert)

        # ── 10. Fleet announce → Eva ─────────────────────────────────────────
        logger.info(
            "Sentry alert: [%s] %s %s — %s",
            event.short_id,
            alert.severity.upper(),
            alert.category,
            alert.summary[:80],
        )
        await _fleet_announce(alert, event.short_id, _fleet_url, _fleet_secret)

        return {
            "status": "ok",
            "category": alert.category,
            "severity": alert.severity,
            "short_id": event.short_id,
        }

    # ------------------------------------------------------------------ #
    # GET /api/v1/fleet/status
    # ------------------------------------------------------------------ #

    @router.get("/api/v1/fleet/status", tags=["Fleet"])
    async def fleet_status(_=None):
        """Fleet health status placeholder (future expansion)."""
        return {"status": "ok", "components": []}


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

def _normalise_event_alert(event_data: dict) -> dict:
    """Convert an event_alert ``event`` dict to issue_data shape."""
    issue_url = event_data.get("issue_url", "")
    # Attempt to extract an issue ID from the URL
    parts = [p for p in issue_url.rstrip("/").split("/") if p]
    issue_id = parts[-1] if parts else ""

    return {
        "id": event_data.get("issue_id", issue_id),
        "shortId": issue_id,
        "title": event_data.get("title", "Unknown error"),
        "culprit": event_data.get("culprit", ""),
        "level": event_data.get("level", "error"),
        "status": "unresolved",
        "substatus": "new",
        "count": "1",
        "firstSeen": event_data.get("datetime", datetime.now(timezone.utc).isoformat()),
        "lastSeen": event_data.get("datetime", datetime.now(timezone.utc).isoformat()),
        "metadata": {},
        "tags": event_data.get("tags", []),
        "web_url": event_data.get("web_url", ""),
        "exception": event_data.get("exception", {}),
        "request": event_data.get("request", {}),
    }


def _extract_path(url: str) -> str:
    """Extract URL path component from a full URL."""
    if not url:
        return "unknown"
    try:
        path = urlparse(url).path
        return path or url[:100]
    except Exception:
        return url[:100]


def _parse_dt(s: str) -> datetime:
    """Parse an ISO datetime string; fall back to now(UTC) on failure."""
    if not s:
        return datetime.now(timezone.utc)
    try:
        return datetime.fromisoformat(s.replace("Z", "+00:00"))
    except Exception:
        return datetime.now(timezone.utc)


def _parse_tags(tags_raw) -> dict:
    """Normalise Sentry tags from list-of-dicts or list-of-pairs."""
    result: dict = {}
    if not isinstance(tags_raw, list):
        return result
    for t in tags_raw:
        if isinstance(t, dict):
            result[t.get("key", "")] = t.get("value", "")
        elif isinstance(t, (list, tuple)) and len(t) == 2:
            result[str(t[0])] = str(t[1])
    return result


def _safe_int(val, default: int = 1) -> int:
    try:
        return int(val)
    except (TypeError, ValueError):
        return default


def _get_aegis_db_path() -> Optional[str]:
    """Return the aegis.db path based on env, or None if unavailable."""
    data_dir = os.getenv("CORTEX_DATA_DIR") or os.getenv("DATA_DIR")
    if data_dir:
        return os.path.join(data_dir, "aegis.db")
    # Fallback: same directory as cortex.db if set
    cortex_db = os.getenv("CORTEX_DB_PATH")
    if cortex_db:
        return os.path.join(os.path.dirname(cortex_db), "aegis.db")
    return None


def _init_aegis_db(conn: sqlite3.Connection) -> None:
    """Create aegis.db tables if they don't exist."""
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS sentry_events (
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
            issue_id        TEXT NOT NULL,
            short_id        TEXT,
            category        TEXT NOT NULL,
            severity        TEXT NOT NULL,
            error_class     TEXT,
            summary         TEXT,
            endpoint        TEXT,
            count           INTEGER DEFAULT 1,
            first_seen      TEXT,
            last_seen       TEXT,
            sentry_url      TEXT,
            environment     TEXT DEFAULT 'production',
            release         TEXT,
            deploy_correlated INTEGER DEFAULT 0,
            announced_at    TEXT,
            created_at      TEXT DEFAULT (datetime('now'))
        );

        CREATE INDEX IF NOT EXISTS idx_sentry_issue_id
            ON sentry_events(issue_id);
        CREATE INDEX IF NOT EXISTS idx_sentry_created
            ON sentry_events(created_at);

        CREATE TABLE IF NOT EXISTS deploy_log (
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
            version         TEXT,
            environment     TEXT DEFAULT 'production',
            deployed_at     TEXT NOT NULL,
            deployed_by     TEXT DEFAULT 'fly-deploy',
            notes           TEXT
        );

        CREATE INDEX IF NOT EXISTS idx_deploy_time
            ON deploy_log(deployed_at);

        CREATE TABLE IF NOT EXISTS alert_state (
            issue_id        TEXT PRIMARY KEY,
            last_announced  TEXT,
            last_severity   TEXT,
            announce_count  INTEGER DEFAULT 0
        );
    """)
    conn.commit()


async def _get_last_deploy_time() -> Optional[datetime]:
    """Query aegis.db for the most recent deploy timestamp."""
    db_path = _get_aegis_db_path()
    if not db_path:
        return None
    try:
        conn = sqlite3.connect(db_path)
        _init_aegis_db(conn)
        cur = conn.execute(
            "SELECT deployed_at FROM deploy_log ORDER BY deployed_at DESC LIMIT 1"
        )
        row = cur.fetchone()
        conn.close()
        if row:
            return _parse_dt(row[0])
        return None
    except Exception as exc:
        logger.debug("aegis.db deploy_log query failed: %s", exc)
        return None


async def _is_duplicate(issue_id: str, severity: str) -> bool:
    """
    Return True if this issue_id was already announced within the dedup window
    AND the severity hasn't escalated.
    """
    if not issue_id:
        return False
    db_path = _get_aegis_db_path()
    if not db_path:
        return False
    try:
        conn = sqlite3.connect(db_path)
        _init_aegis_db(conn)
        cur = conn.execute(
            "SELECT last_announced, last_severity FROM alert_state WHERE issue_id = ?",
            (issue_id,),
        )
        row = cur.fetchone()
        conn.close()

        if row is None:
            return False  # Never announced → not a duplicate

        last_announced_str, last_severity = row
        if not last_announced_str:
            return False

        last_announced = _parse_dt(last_announced_str)
        now = datetime.now(timezone.utc)
        window = timedelta(hours=_DEDUP_WINDOW_HOURS)

        # Within dedup window?
        if now - last_announced > window:
            return False  # Window expired → allow re-announce

        # Has severity escalated?
        old_level = _SEVERITY_ORDER.get(last_severity or "info", 0)
        new_level = _SEVERITY_ORDER.get(severity, 0)
        if new_level > old_level:
            return False  # Escalation → always announce

        return True  # Same or lower severity within window → suppress

    except Exception as exc:
        logger.debug("aegis.db dedup check failed: %s", exc)
        return False


async def _record_event(event, alert) -> None:
    """Persist the Sentry event and update alert_state in aegis.db."""
    db_path = _get_aegis_db_path()
    if not db_path:
        return
    now_iso = datetime.now(timezone.utc).isoformat()
    try:
        conn = sqlite3.connect(db_path)
        _init_aegis_db(conn)
        conn.execute(
            """
            INSERT INTO sentry_events
                (issue_id, short_id, category, severity, error_class, summary,
                 endpoint, count, first_seen, last_seen, sentry_url, environment,
                 release, deploy_correlated, announced_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                event.issue_id,
                event.short_id,
                alert.category,
                alert.severity,
                alert.error_class,
                alert.summary,
                alert.endpoint,
                event.count,
                event.first_seen.isoformat(),
                event.last_seen.isoformat(),
                event.sentry_url,
                event.environment,
                event.release,
                int(alert.deploy_correlated),
                now_iso,
            ),
        )
        conn.execute(
            """
            INSERT INTO alert_state (issue_id, last_announced, last_severity, announce_count)
            VALUES (?, ?, ?, 1)
            ON CONFLICT(issue_id) DO UPDATE SET
                last_announced  = excluded.last_announced,
                last_severity   = excluded.last_severity,
                announce_count  = announce_count + 1
            """,
            (event.issue_id, now_iso, alert.severity),
        )
        conn.commit()
        conn.close()
    except Exception as exc:
        logger.warning("aegis.db write failed (non-fatal): %s", exc)


async def _fleet_announce(
    alert,
    short_id: str,
    fleet_url: str,
    fleet_secret: str,
) -> None:
    """POST the categorized alert to the fleet announce endpoint."""
    payload = {
        "source": "aegis-sentry",
        "type": "alert",
        "severity": alert.severity,
        "category": alert.category,
        "error_class": alert.error_class,
        "summary": alert.summary,
        "endpoint": alert.endpoint,
        "count_1h": alert.count_1h,
        "first_seen": alert.first_seen,
        "sentry_url": alert.sentry_url,
        "suggested_playbook": alert.suggested_playbook,
        "deploy_correlated": alert.deploy_correlated,
        "short_id": short_id,
    }

    body_bytes = json.dumps(payload).encode("utf-8")

    headers: dict = {"Content-Type": "application/json"}
    if fleet_secret:
        sig = hmac.new(
            fleet_secret.encode("utf-8"),
            body_bytes,
            hashlib.sha256,
        ).hexdigest()
        headers["X-Fleet-HMAC"] = sig

    target_url = fleet_url.rstrip("/") + "/announce"

    try:
        import aiohttp  # optional dep — skip announce if unavailable

        timeout = aiohttp.ClientTimeout(total=10)
        async with aiohttp.ClientSession() as session:
            resp = await session.post(
                target_url,
                data=body_bytes,
                headers=headers,
                timeout=timeout,
            )
            if resp.status >= 400:
                logger.warning(
                    "Fleet announce returned %s for %s", resp.status, short_id
                )
            else:
                logger.debug(
                    "Fleet announce OK (%s) for %s", resp.status, short_id
                )
    except ImportError:
        logger.warning(
            "aiohttp not available — fleet announce skipped for %s", short_id
        )
    except Exception as exc:
        # Fleet announce failure is non-fatal — Sentry webhook must still 200 OK
        logger.error("Fleet announce failed for %s: %s", short_id, exc, exc_info=True)
