"""Phase 3 gate metric computation and snapshot persistence."""

from __future__ import annotations

import json
import sqlite3
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator


_GATE_THRESHOLDS: dict[str, float | int] = {
    "identity_coverage_min": 0.80,
    "critical_alerts_to_actions_min": 0.70,
    "overall_alerts_to_actions_min": 0.40,
    "override_rate_max": 0.05,
    "sev1_incidents_max": 0,
}

_CRITICAL_SEVERITIES = {"critical", "sev1", "p0"}
_APPROVAL_ACTIONS = {"approve", "approve_http", "reject", "reject_http"}
_REJECT_ACTIONS = {"reject", "reject_http"}


def _utc_now() -> datetime:
    return datetime.now(timezone.utc)


def _parse_iso(value: Any) -> datetime | None:
    if not isinstance(value, str) or not value.strip():
        return None
    raw = value.strip()
    if raw.endswith("Z"):
        raw = raw[:-1] + "+00:00"
    try:
        parsed = datetime.fromisoformat(raw)
    except Exception:
        return None
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    return parsed.astimezone(timezone.utc)


def _iso(value: datetime) -> str:
    return value.astimezone(timezone.utc).isoformat()


def _safe_int(value: Any, default: int = 0) -> int:
    try:
        return int(value)
    except Exception:
        return default


def _normalize_record_id(value: Any) -> str:
    if value in (None, ""):
        return ""
    return str(value).strip()


def _is_identity_record_candidate(value: str) -> bool:
    if not value:
        return False
    if value.startswith("#"):
        return False
    if "@" in value:
        return False
    if ":" in value:
        return False
    if "/" in value or "\\" in value:
        return False
    if any(ch.isspace() for ch in value):
        return False
    return 3 <= len(value) <= 64


def _extract_record_ids_from_rows(rows: Any) -> set[str]:
    extracted: set[str] = set()
    if not isinstance(rows, list):
        return extracted
    for row in rows:
        if not isinstance(row, dict):
            continue
        record_id = _normalize_record_id(row.get("record_id"))
        if record_id:
            extracted.add(record_id)
    return extracted


def _extract_step_record_ids(step: dict[str, Any]) -> set[str]:
    args = step.get("args")
    if not isinstance(args, dict):
        return set()
    record_ids: set[str] = set()
    raw_record_ids = args.get("record_ids")
    if isinstance(raw_record_ids, list):
        for item in raw_record_ids:
            record_id = _normalize_record_id(item)
            if record_id:
                record_ids.add(record_id)
    record_ids.update(_extract_record_ids_from_rows(args.get("records")))
    return record_ids


def _extract_change_targets(change: dict[str, Any]) -> set[str]:
    targets = change.get("targets")
    if not isinstance(targets, list):
        return set()
    values: set[str] = set()
    for target in targets:
        normalized = _normalize_record_id(target)
        if normalized:
            values.add(normalized)
    return values


def _step_matches_change(step: dict[str, Any], step_targets: set[str], change: dict[str, Any]) -> bool:
    if str(step.get("tool") or "") != str(change.get("tool") or ""):
        return False
    change_targets = _extract_change_targets(change)
    if step_targets and change_targets:
        return bool(step_targets.intersection(change_targets))
    return True


def _match_executed_steps(
    plan_payload: dict[str, Any] | None,
    receipt_payload: dict[str, Any] | None,
) -> tuple[dict[str, set[str]], set[str]]:
    by_scout: dict[str, set[str]] = {}
    touched_records: set[str] = set()
    if not isinstance(plan_payload, dict) or not isinstance(receipt_payload, dict):
        return by_scout, touched_records

    steps = plan_payload.get("steps")
    changes = receipt_payload.get("changes")
    if not isinstance(steps, list) or not isinstance(changes, list):
        return by_scout, touched_records

    available_changes = [change for change in changes if isinstance(change, dict)]
    for step in steps:
        if not isinstance(step, dict):
            continue
        args = step.get("args")
        if not isinstance(args, dict):
            continue
        scout_name = str(args.get("scout_name") or "").strip()
        if not scout_name:
            continue
        step_targets = _extract_step_record_ids(step)

        match_index = -1
        for index, change in enumerate(available_changes):
            if _step_matches_change(step, step_targets, change):
                match_index = index
                break
        if match_index < 0:
            continue
        available_changes.pop(match_index)

        scoped = by_scout.setdefault(scout_name, set())
        if step_targets:
            scoped.update(step_targets)
            for target in step_targets:
                if _is_identity_record_candidate(target):
                    touched_records.add(target)
        else:
            scoped.add(f"step:{str(step.get('step_id') or '').strip() or 'unknown'}")

    return by_scout, touched_records


def _finding_severity(finding: dict[str, Any]) -> str:
    severity = str(finding.get("severity") or "").strip().lower()
    if severity:
        return severity
    evidence = finding.get("evidence")
    if isinstance(evidence, list):
        for row in evidence:
            if not isinstance(row, dict):
                continue
            row_severity = str(row.get("severity") or "").strip().lower()
            if row_severity:
                return row_severity
    return "medium"


def _extract_scan_alerts(scan_payload: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
    alerts_by_scout: dict[str, dict[str, Any]] = {}
    if not isinstance(scan_payload, dict):
        return alerts_by_scout
    findings = scan_payload.get("findings")
    if not isinstance(findings, list):
        return alerts_by_scout

    for finding in findings:
        if not isinstance(finding, dict):
            continue
        scout_name = str(finding.get("scout_name") or "").strip()
        if not scout_name:
            continue
        findings_count = _safe_int(finding.get("findings_count"), default=0)
        if findings_count <= 0:
            continue
        severity = _finding_severity(finding)
        evidence_rows = finding.get("evidence")
        evidence_record_ids: set[str] = set()
        if isinstance(evidence_rows, list):
            for row in evidence_rows:
                if not isinstance(row, dict):
                    continue
                record_id = _normalize_record_id(row.get("record_id"))
                if record_id:
                    evidence_record_ids.add(record_id)

        current = alerts_by_scout.setdefault(
            scout_name,
            {
                "alerts": 0,
                "critical_alerts": 0,
                "record_ids": set(),
            },
        )
        current["alerts"] += findings_count
        if severity in _CRITICAL_SEVERITIES:
            current["critical_alerts"] += findings_count
        current["record_ids"].update(evidence_record_ids)

    return alerts_by_scout


def _load_json(path: Path) -> dict[str, Any] | None:
    if not path.exists():
        return None
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return None
    return payload if isinstance(payload, dict) else None


@dataclass(frozen=True)
class _RunBundle:
    run_id: str
    scan: dict[str, Any] | None
    plan: dict[str, Any] | None
    receipt: dict[str, Any] | None
    timestamp: datetime


def _bundle_timestamp(
    *,
    scan_payload: dict[str, Any] | None,
    receipt_payload: dict[str, Any] | None,
    run_dir: Path,
) -> datetime:
    completed_at = _parse_iso((receipt_payload or {}).get("completed_at"))
    if completed_at is not None:
        return completed_at
    ended_at = _parse_iso((scan_payload or {}).get("ended_at"))
    if ended_at is not None:
        return ended_at
    started_at = _parse_iso((scan_payload or {}).get("started_at"))
    if started_at is not None:
        return started_at
    return datetime.fromtimestamp(run_dir.stat().st_mtime, tz=timezone.utc)


def _load_recent_run_bundles(state_dir: Path, *, window_start: datetime) -> list[_RunBundle]:
    receipts_dir = state_dir / "receipts"
    if not receipts_dir.exists():
        return []

    bundles: list[_RunBundle] = []
    for run_dir in receipts_dir.iterdir():
        if not run_dir.is_dir():
            continue
        scan_payload = _load_json(run_dir / "scan.json")
        plan_payload = _load_json(run_dir / "plan.json")
        receipt_payload = _load_json(run_dir / "receipt.json")
        if scan_payload is None and receipt_payload is None:
            continue
        timestamp = _bundle_timestamp(
            scan_payload=scan_payload,
            receipt_payload=receipt_payload,
            run_dir=run_dir,
        )
        if timestamp < window_start:
            continue
        bundles.append(
            _RunBundle(
                run_id=run_dir.name,
                scan=scan_payload,
                plan=plan_payload,
                receipt=receipt_payload,
                timestamp=timestamp,
            )
        )

    bundles.sort(key=lambda item: item.timestamp, reverse=True)
    return bundles


def _count_linked_records(platform_db_path: Path, *, record_ids: set[str]) -> int:
    if not record_ids or not platform_db_path.exists():
        return 0
    with sqlite3.connect(platform_db_path) as conn:
        conn.row_factory = sqlite3.Row
        table_row = conn.execute(
            """
            SELECT name
            FROM sqlite_master
            WHERE type = 'table' AND name = 'entity_links'
            LIMIT 1
            """
        ).fetchone()
        if table_row is None:
            return 0

        matched: set[str] = set()
        candidates = sorted(record_ids)
        chunk_size = 250
        for start in range(0, len(candidates), chunk_size):
            chunk = candidates[start : start + chunk_size]
            placeholders = ", ".join(["?"] * len(chunk))
            rows = conn.execute(
                f"""
                SELECT DISTINCT source_record_id
                FROM entity_links
                WHERE source_record_id IN ({placeholders})
                """,
                tuple(chunk),
            ).fetchall()
            for row in rows:
                value = _normalize_record_id(row["source_record_id"])
                if value:
                    matched.add(value)
        return len(matched)


def _override_stats(approvals_db_path: Path, *, window_start: datetime) -> tuple[int, int]:
    if not approvals_db_path.exists():
        return 0, 0
    with sqlite3.connect(approvals_db_path) as conn:
        conn.row_factory = sqlite3.Row
        table_row = conn.execute(
            """
            SELECT name
            FROM sqlite_master
            WHERE type = 'table' AND name = 'approval_tokens'
            LIMIT 1
            """
        ).fetchone()
        if table_row is None:
            return 0, 0

        rows = conn.execute(
            """
            SELECT metadata_json
            FROM approval_tokens
            WHERE consumed_at IS NOT NULL AND consumed_at >= ?
            """,
            (_iso(window_start),),
        ).fetchall()

    decisions = 0
    overrides = 0
    for row in rows:
        metadata_json = row["metadata_json"]
        if not isinstance(metadata_json, str) or not metadata_json.strip():
            continue
        try:
            metadata = json.loads(metadata_json)
        except Exception:
            continue
        if not isinstance(metadata, dict):
            continue
        action = str(metadata.get("action") or "").strip().lower()
        if action not in _APPROVAL_ACTIONS:
            continue
        decisions += 1
        if action in _REJECT_ACTIONS:
            overrides += 1
    return decisions, overrides


def _sev1_incidents(platform_db_path: Path, runtime_dir: Path, *, window_start: datetime) -> int:
    total = 0
    incidents_path = runtime_dir / "module_incidents.json"
    if incidents_path.exists():
        try:
            payload = json.loads(incidents_path.read_text(encoding="utf-8"))
        except Exception:
            payload = {}
        if isinstance(payload, dict):
            sev1_count = payload.get("sev1_incidents")
            if isinstance(sev1_count, int) and sev1_count > 0:
                total += sev1_count

    if not platform_db_path.exists():
        return total

    with sqlite3.connect(platform_db_path) as conn:
        conn.row_factory = sqlite3.Row
        table_row = conn.execute(
            """
            SELECT name
            FROM sqlite_master
            WHERE type = 'table' AND name = 'audit_events'
            LIMIT 1
            """
        ).fetchone()
        if table_row is None:
            return total

        rows = conn.execute(
            """
            SELECT event_type, summary, detail_json
            FROM audit_events
            WHERE created_at >= ?
            """,
            (_iso(window_start),),
        ).fetchall()
        for row in rows:
            event_type = str(row["event_type"] or "").strip().lower()
            summary = str(row["summary"] or "").strip().lower()
            detail_json = str(row["detail_json"] or "").strip().lower()
            if "sev1" in event_type or "sev1" in summary or "\"sev1\"" in detail_json:
                total += 1
    return total


def _gate_check(
    *,
    value: float | int | None,
    threshold: float | int,
    comparator: str,
) -> bool:
    if value is None:
        return False
    if comparator == ">=":
        return float(value) >= float(threshold)
    if comparator == "<=":
        return float(value) <= float(threshold)
    raise ValueError(f"Unsupported comparator: {comparator}")


def compute_gate_metrics(
    state_dir: Path | str,
    *,
    lookback_days: int = 30,
    now: datetime | None = None,
) -> dict[str, Any]:
    resolved_state_dir = Path(state_dir).resolve()
    bounded_lookback = max(1, min(int(lookback_days), 3650))
    end_at = now.astimezone(timezone.utc) if now is not None else _utc_now()
    start_at = end_at - timedelta(days=bounded_lookback)

    bundles = _load_recent_run_bundles(resolved_state_dir, window_start=start_at)
    overall_alerts = 0
    overall_actioned = 0
    critical_alerts = 0
    critical_actioned = 0
    touched_record_ids: set[str] = set()

    for bundle in bundles:
        alerts_by_scout = _extract_scan_alerts(bundle.scan)
        executed_by_scout, touched = _match_executed_steps(bundle.plan, bundle.receipt)
        touched_record_ids.update(touched)
        for scout_name, scout_alerts in alerts_by_scout.items():
            alerts = _safe_int(scout_alerts.get("alerts"), default=0)
            critical = _safe_int(scout_alerts.get("critical_alerts"), default=0)
            overall_alerts += alerts
            critical_alerts += critical

            finding_records = scout_alerts.get("record_ids")
            if not isinstance(finding_records, set):
                finding_records = set()
            executed_targets = executed_by_scout.get(scout_name, set())

            if finding_records:
                matched = len(finding_records.intersection(executed_targets))
            else:
                matched = len(executed_targets)

            actioned = min(alerts, matched)
            critical_actioned_for_scout = min(critical, matched)
            overall_actioned += actioned
            critical_actioned += critical_actioned_for_scout

    platform_db_path = resolved_state_dir / "db" / "platform.sqlite"
    linked_records = _count_linked_records(platform_db_path, record_ids=touched_record_ids)

    approvals_db_path = resolved_state_dir / "db" / "approval_tokens.sqlite"
    approval_decisions, overrides = _override_stats(
        approvals_db_path,
        window_start=start_at,
    )

    sev1_incidents = _sev1_incidents(
        platform_db_path,
        resolved_state_dir / "runtime",
        window_start=start_at,
    )

    identity_coverage_value = (
        (linked_records / len(touched_record_ids)) if touched_record_ids else None
    )
    critical_ratio_value = (critical_actioned / critical_alerts) if critical_alerts > 0 else None
    overall_ratio_value = (overall_actioned / overall_alerts) if overall_alerts > 0 else None
    override_rate_value = (overrides / approval_decisions) if approval_decisions > 0 else None

    identity_check = {
        "value": round(identity_coverage_value, 6) if identity_coverage_value is not None else None,
        "threshold": _GATE_THRESHOLDS["identity_coverage_min"],
        "comparator": ">=",
        "passed": _gate_check(
            value=identity_coverage_value,
            threshold=_GATE_THRESHOLDS["identity_coverage_min"],
            comparator=">=",
        ),
        "recent_records": len(touched_record_ids),
        "covered_records": linked_records,
    }
    critical_check = {
        "value": round(critical_ratio_value, 6) if critical_ratio_value is not None else None,
        "threshold": _GATE_THRESHOLDS["critical_alerts_to_actions_min"],
        "comparator": ">=",
        "passed": _gate_check(
            value=critical_ratio_value,
            threshold=_GATE_THRESHOLDS["critical_alerts_to_actions_min"],
            comparator=">=",
        ),
        "critical_alerts": critical_alerts,
        "critical_actioned": critical_actioned,
    }
    overall_check = {
        "value": round(overall_ratio_value, 6) if overall_ratio_value is not None else None,
        "threshold": _GATE_THRESHOLDS["overall_alerts_to_actions_min"],
        "comparator": ">=",
        "passed": _gate_check(
            value=overall_ratio_value,
            threshold=_GATE_THRESHOLDS["overall_alerts_to_actions_min"],
            comparator=">=",
        ),
        "alerts": overall_alerts,
        "actioned": overall_actioned,
    }
    override_check = {
        "value": round(override_rate_value, 6) if override_rate_value is not None else None,
        "threshold": _GATE_THRESHOLDS["override_rate_max"],
        "comparator": "<=",
        "passed": _gate_check(
            value=override_rate_value,
            threshold=_GATE_THRESHOLDS["override_rate_max"],
            comparator="<=",
        ),
        "approvals": approval_decisions,
        "overrides": overrides,
    }
    sev1_check = {
        "value": sev1_incidents,
        "threshold": _GATE_THRESHOLDS["sev1_incidents_max"],
        "comparator": "<=",
        "passed": _gate_check(
            value=sev1_incidents,
            threshold=_GATE_THRESHOLDS["sev1_incidents_max"],
            comparator="<=",
        ),
    }

    checks = {
        "identity_coverage": identity_check,
        "critical_alerts_to_actions": critical_check,
        "overall_alerts_to_actions": overall_check,
        "override_rate": override_check,
        "sev1_incidents": sev1_check,
    }
    gate_passed = all(bool(check.get("passed")) for check in checks.values())

    return {
        "generated_at": _iso(end_at),
        "window": {
            "start_at": _iso(start_at),
            "end_at": _iso(end_at),
            "lookback_days": bounded_lookback,
            "day_key": end_at.date().isoformat(),
            "runs_observed": len(bundles),
        },
        "checks": checks,
        "gate_passed": gate_passed,
    }


class GateMetricStore:
    """Persistence for daily gate metric snapshots."""

    def __init__(self, db_path: Path | str):
        self.db_path = Path(db_path).resolve()
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self._init_db()

    @contextmanager
    def _connect(self) -> Iterator[sqlite3.Connection]:
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        conn.execute("PRAGMA journal_mode=WAL")
        conn.execute("PRAGMA busy_timeout=5000")
        try:
            yield conn
            conn.commit()
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()

    def _init_db(self) -> None:
        with self._connect() as conn:
            conn.execute(
                """
                CREATE TABLE IF NOT EXISTS gate_metric_snapshots (
                    snapshot_id TEXT PRIMARY KEY,
                    day_key TEXT NOT NULL,
                    lookback_days INTEGER NOT NULL,
                    generated_at TEXT NOT NULL,
                    gate_passed INTEGER NOT NULL,
                    payload_json TEXT NOT NULL,
                    UNIQUE(day_key, lookback_days)
                )
                """
            )
            conn.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_gate_metric_snapshots_generated
                ON gate_metric_snapshots(generated_at DESC)
                """
            )

    def save_snapshot(self, payload: dict[str, Any]) -> str:
        window = payload.get("window") if isinstance(payload.get("window"), dict) else {}
        day_key = str(window.get("day_key") or "").strip() or _utc_now().date().isoformat()
        lookback_days = _safe_int(window.get("lookback_days"), default=30)
        generated_at = str(payload.get("generated_at") or "").strip() or _iso(_utc_now())
        snapshot_id = f"gate:{day_key}:{lookback_days}"
        gate_passed = 1 if bool(payload.get("gate_passed")) else 0
        payload_json = json.dumps(payload, sort_keys=True, ensure_ascii=True)
        with self._connect() as conn:
            conn.execute(
                """
                INSERT INTO gate_metric_snapshots (
                    snapshot_id,
                    day_key,
                    lookback_days,
                    generated_at,
                    gate_passed,
                    payload_json
                )
                VALUES (?, ?, ?, ?, ?, ?)
                ON CONFLICT(day_key, lookback_days) DO UPDATE SET
                    snapshot_id = excluded.snapshot_id,
                    generated_at = excluded.generated_at,
                    gate_passed = excluded.gate_passed,
                    payload_json = excluded.payload_json
                """,
                (
                    snapshot_id,
                    day_key,
                    lookback_days,
                    generated_at,
                    gate_passed,
                    payload_json,
                ),
            )
        return snapshot_id

    def list_snapshots(
        self,
        *,
        lookback_days: int | None = None,
        limit: int = 30,
    ) -> list[dict[str, Any]]:
        bounded_limit = max(1, min(int(limit), 365))
        with self._connect() as conn:
            if lookback_days is None:
                rows = conn.execute(
                    """
                    SELECT snapshot_id, day_key, lookback_days, generated_at, gate_passed, payload_json
                    FROM gate_metric_snapshots
                    ORDER BY generated_at DESC
                    LIMIT ?
                    """,
                    (bounded_limit,),
                ).fetchall()
            else:
                rows = conn.execute(
                    """
                    SELECT snapshot_id, day_key, lookback_days, generated_at, gate_passed, payload_json
                    FROM gate_metric_snapshots
                    WHERE lookback_days = ?
                    ORDER BY generated_at DESC
                    LIMIT ?
                    """,
                    (int(lookback_days), bounded_limit),
                ).fetchall()

        snapshots: list[dict[str, Any]] = []
        for row in rows:
            try:
                payload = json.loads(row["payload_json"] or "{}")
            except Exception:
                payload = {}
            snapshots.append(
                {
                    "snapshot_id": row["snapshot_id"],
                    "day_key": row["day_key"],
                    "lookback_days": int(row["lookback_days"] or 0),
                    "generated_at": row["generated_at"],
                    "gate_passed": bool(row["gate_passed"]),
                    "payload": payload if isinstance(payload, dict) else {},
                }
            )
        return snapshots

    def latest_snapshot(self, *, lookback_days: int | None = None) -> dict[str, Any] | None:
        snapshots = self.list_snapshots(lookback_days=lookback_days, limit=1)
        return snapshots[0] if snapshots else None


def collect_gate_metrics(
    state_dir: Path | str,
    *,
    lookback_days: int = 30,
    now: datetime | None = None,
) -> dict[str, Any]:
    resolved_state_dir = Path(state_dir).resolve()
    metrics = compute_gate_metrics(
        resolved_state_dir,
        lookback_days=lookback_days,
        now=now,
    )
    store = GateMetricStore(resolved_state_dir / "db" / "gate_metrics.sqlite")
    snapshot_id = store.save_snapshot(metrics)
    return {
        "snapshot_id": snapshot_id,
        "current": metrics,
        "latest_snapshot": store.latest_snapshot(lookback_days=lookback_days),
        "history": store.list_snapshots(lookback_days=lookback_days, limit=30),
    }


__all__ = [
    "GateMetricStore",
    "collect_gate_metrics",
    "compute_gate_metrics",
]
