"""Simple statistical anomaly detection for daily operational metrics."""

from __future__ import annotations

import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from statistics import mean, pstdev
from typing import Any

from .gate_metrics import GateMetricStore
from .modules import infer_module_write_class_from_tool

_EXCEPTION_OUTCOMES = {
    "approval_required",
    "denied",
    "failed",
    "partial_failure",
    "budget_exceeded",
    "precheck_failed",
    "invariant_failed",
    "entity_budget_exhausted",
}


@dataclass
class AnomalyAlert:
    metric_name: str
    current_value: float
    baseline_mean: float
    baseline_stddev: float
    deviation_factor: float
    direction: str
    message: str


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 _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


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


def _append_metric(daily: dict[str, dict[str, float]], day_key: str, metric_name: str, value: float) -> None:
    bucket = daily.setdefault(day_key, {})
    bucket[metric_name] = float(bucket.get(metric_name, 0.0) + float(value))


def _collect_daily_metrics(
    state_dir: Path,
    *,
    lookback_days: int,
) -> dict[str, dict[str, float]]:
    today = datetime.now(timezone.utc).date()
    start_date = today - timedelta(days=max(lookback_days, 1) + 1)
    daily: dict[str, dict[str, float]] = {}

    receipts_dir = state_dir / "receipts"
    if receipts_dir.exists():
        for run_dir in receipts_dir.iterdir():
            if not run_dir.is_dir():
                continue
            scan_payload = _load_json(run_dir / "scan.json")
            receipt_payload = _load_json(run_dir / "receipt.json")
            if scan_payload is None and receipt_payload is None:
                continue
            ts = _run_timestamp(scan_payload, receipt_payload, run_dir)
            day = ts.date()
            if day < start_date:
                continue
            day_key = day.isoformat()

            findings = scan_payload.get("findings") if isinstance(scan_payload, dict) else None
            if isinstance(findings, list):
                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 = finding.get("findings_count")
                    if isinstance(findings_count, (int, float)):
                        count = max(int(findings_count), 0)
                    else:
                        evidence = finding.get("evidence")
                        count = len(evidence) if isinstance(evidence, list) else 0
                    _append_metric(daily, day_key, f"scout_findings:{scout_name}", float(count))

            if isinstance(receipt_payload, dict):
                outcome = str(receipt_payload.get("outcome") or "").strip().lower()
                if outcome in _EXCEPTION_OUTCOMES:
                    _append_metric(daily, day_key, "exception_receipts", 1.0)
                changes = receipt_payload.get("changes")
                if isinstance(changes, list):
                    for change in changes:
                        if not isinstance(change, dict):
                            continue
                        tool_name = str(change.get("tool") or "").strip()
                        if not tool_name:
                            continue
                        module_id, _write_class = infer_module_write_class_from_tool(tool_name)
                        if module_id:
                            _append_metric(daily, day_key, f"module_executions:{module_id}", 1.0)

    pending_dir = state_dir / "pending"
    pending_count = 0
    if pending_dir.exists():
        pending_count = len([path for path in pending_dir.iterdir() if path.is_dir()])
    _append_metric(daily, today.isoformat(), "pending_queue_depth", float(pending_count))

    # Merge any explicit daily metrics persisted in gate snapshots if present.
    try:
        snapshots = GateMetricStore(state_dir / "db" / "gate_metrics.sqlite").list_snapshots(
            lookback_days=None,
            limit=max(lookback_days + 2, 30),
        )
    except Exception:
        snapshots = []
    for snapshot in snapshots:
        day_key = str(snapshot.get("day_key") or "").strip()
        if not day_key:
            continue
        try:
            day_value = datetime.fromisoformat(day_key).date()
        except Exception:
            continue
        if day_value < start_date:
            continue
        payload = snapshot.get("payload") if isinstance(snapshot.get("payload"), dict) else {}
        metric_payload = payload.get("daily_metrics") if isinstance(payload.get("daily_metrics"), dict) else {}
        for metric_name, metric_value in metric_payload.items():
            if not isinstance(metric_name, str) or not isinstance(metric_value, (int, float)):
                continue
            _append_metric(daily, day_key, metric_name, float(metric_value))

    return daily


def detect_anomalies(
    state_dir: Path,
    *,
    lookback_days: int = 14,
    threshold_stddevs: float = 2.0,
) -> list[AnomalyAlert]:
    """Compare today's values against rolling baselines and emit simple alerts."""

    resolved_state = Path(state_dir).resolve()
    bounded_lookback = max(1, min(int(lookback_days), 120))
    threshold = max(float(threshold_stddevs), 0.1)
    daily = _collect_daily_metrics(resolved_state, lookback_days=bounded_lookback)
    if len(daily) < 3:
        return []

    today_key = max(daily.keys())
    today_metrics = daily.get(today_key, {})
    if not isinstance(today_metrics, dict) or not today_metrics:
        return []

    alerts: list[AnomalyAlert] = []
    for metric_name, current_raw in today_metrics.items():
        if not isinstance(current_raw, (int, float)):
            continue
        baseline_values: list[float] = []
        for day_key, day_metrics in daily.items():
            if day_key >= today_key or not isinstance(day_metrics, dict):
                continue
            value = day_metrics.get(metric_name)
            if isinstance(value, (int, float)):
                baseline_values.append(float(value))
        if len(baseline_values) < 3:
            continue

        current_value = float(current_raw)
        baseline_mean = float(mean(baseline_values))
        baseline_stddev = float(pstdev(baseline_values))

        if baseline_stddev <= 0.0:
            if abs(current_value - baseline_mean) <= 0.0:
                continue
            deviation_factor = float("inf")
        else:
            deviation_factor = abs(current_value - baseline_mean) / baseline_stddev
        if deviation_factor < threshold:
            continue

        direction = "above" if current_value >= baseline_mean else "below"
        if deviation_factor == float("inf"):
            deviation_display = "infinite"
        else:
            deviation_display = f"{deviation_factor:.2f}"
        message = (
            f"{metric_name} is {direction} baseline by {deviation_display} stddev "
            f"(current={current_value:.2f}, baseline={baseline_mean:.2f}, stddev={baseline_stddev:.2f})."
        )
        alerts.append(
            AnomalyAlert(
                metric_name=metric_name,
                current_value=current_value,
                baseline_mean=baseline_mean,
                baseline_stddev=baseline_stddev,
                deviation_factor=deviation_factor,
                direction=direction,
                message=message,
            )
        )

    alerts.sort(
        key=lambda alert: (
            1 if alert.deviation_factor == float("inf") else 0,
            alert.deviation_factor if alert.deviation_factor != float("inf") else 0.0,
        ),
        reverse=True,
    )
    return alerts


__all__ = ["AnomalyAlert", "detect_anomalies"]
