"""Drift detection: statistical changepoint analysis on quality, cost, and latency.

Deterministic — no LLM required. Uses CUSUM and rolling window comparison
to detect when metrics shift significantly.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any

from bandito.models import get_eval_score


@dataclass
class DriftEvent:
    """A detected drift event."""
    metric: str
    severity: str  # "warning", "critical"
    direction: str  # "up", "down"
    change_pct: float  # percentage point change
    change_abs: float  # absolute change
    baseline_value: float | None
    current_value: float | None
    detected_at: str  # ISO date when drift was detected
    window_start: str  # ISO date of the affected window
    trace_count: int
    explanation: str


@dataclass
class MetricWindow:
    """A time window with aggregated metrics."""
    window_start: datetime
    window_end: datetime
    trace_ids: list[str]
    quality: float | None  # pass rate (0-1), None if no evals
    avg_latency: float | None
    avg_cost: float | None
    error_rate: float | None


@dataclass
class DriftResult:
    """Full drift analysis output."""
    project: str
    trace_count: int
    period_days: int
    has_evaluations: bool
    drift_events: list[DriftEvent] = field(default_factory=list)
    overall_quality: float | None = None
    overall_latency_avg: float | None = None
    overall_cost_avg: float | None = None
    stable_windows: int = 0
    last_stable_date: str | None = None

    def to_dict(self) -> dict[str, Any]:
        return {
            "project": self.project,
            "trace_count": self.trace_count,
            "period_days": self.period_days,
            "has_evaluations": self.has_evaluations,
            "drift_events": [
                {
                    "metric": e.metric,
                    "severity": e.severity,
                    "direction": e.direction,
                    "change_pct": round(e.change_pct, 4),
                    "change_abs": round(e.change_abs, 4) if e.change_abs is not None else None,
                    "baseline_value": round(e.baseline_value, 4) if e.baseline_value is not None else None,
                    "current_value": round(e.current_value, 4) if e.current_value is not None else None,
                    "detected_at": e.detected_at,
                    "window_start": e.window_start,
                    "trace_count": e.trace_count,
                    "explanation": e.explanation,
                }
                for e in self.drift_events
            ],
            "overall": {
                "quality": round(self.overall_quality, 4) if self.overall_quality is not None else None,
                "latency_avg": round(self.overall_latency_avg, 3) if self.overall_latency_avg is not None else None,
                "cost_avg": round(self.overall_cost_avg, 6) if self.overall_cost_avg is not None else None,
            },
            "stable_windows": self.stable_windows,
            "last_stable_date": self.last_stable_date,
        }


def run_drift(
    project: str,
    traces: list[dict[str, Any]],
    evaluations: dict[str, dict[str, Any]] | None = None,
    *,
    window_days: int = 1,
    min_window_traces: int = 5,
    significance_threshold: float = 0.15,
) -> DriftResult:
    """Run drift analysis on traces.

    Args:
        project: Project name
        traces: List of trace dicts (must have timestamps)
        evaluations: Dict of trace_id -> eval dict
        window_days: Bucket traces into N-day windows (default: 1)
        min_window_traces: Min traces per window to include (default: 5)
        significance_threshold: Minimum change to flag as drift (default: 15%)

    Returns:
        DriftResult with detected drift events
    """
    evaluations = evaluations or {}

    if len(traces) < 10:
        return DriftResult(project=project, trace_count=len(traces), period_days=0, has_evaluations=False)

    # Sort traces by timestamp
    sorted_traces = sorted(
        traces,
        key=lambda t: _parse_ts(t.get("timestamp") or t.get("started_at") or ""),
    )

    # Build time windows
    windows = _build_windows(sorted_traces, evaluations, window_days, min_window_traces)

    if len(windows) < 2:
        return DriftResult(
            project=project,
            trace_count=len(traces),
            period_days=_compute_period_days(sorted_traces),
            has_evaluations=False,
        )

    # Detect drift in each metric
    drift_events: list[DriftEvent] = []
    now = datetime.now(timezone.utc)

    for metric in ["quality", "latency", "cost"]:
        events = _detect_metric_drift(
            windows, metric, significance_threshold, now, min_window_traces
        )
        drift_events.extend(events)

    # Compute overall stats
    overall_quality = _compute_overall_quality(traces, evaluations)
    overall_latency = _compute_overall_latency(traces)
    overall_cost = _compute_overall_cost(traces)

    # Find last stable window
    stable_windows, last_stable_date = _find_stable_windows(windows)

    return DriftResult(
        project=project,
        trace_count=len(traces),
        period_days=_compute_period_days(sorted_traces),
        has_evaluations=overall_quality is not None,
        drift_events=drift_events,
        overall_quality=overall_quality,
        overall_latency_avg=overall_latency,
        overall_cost_avg=overall_cost,
        stable_windows=stable_windows,
        last_stable_date=last_stable_date,
    )


def _build_windows(
    traces: list[dict[str, Any]],
    evaluations: dict[str, dict[str, Any]],
    window_days: int,
    min_window_traces: int,
) -> list[MetricWindow]:
    """Bucket traces into time windows and compute metrics per window."""
    if not traces:
        return []

    start_ts = _parse_ts(traces[0].get("timestamp") or traces[0].get("started_at") or "")
    end_ts = _parse_ts(traces[-1].get("timestamp") or traces[-1].get("ended_at") or "")

    if start_ts is None or end_ts is None:
        return []

    window_delta = timedelta(days=window_days)
    windows: dict[str, list[dict[str, Any]]] = {}

    for trace in traces:
        ts = _parse_ts(trace.get("timestamp") or trace.get("started_at") or "")
        if ts is None:
            continue
        window_start = ts.replace(hour=0, minute=0, second=0, microsecond=0)
        window_start = window_start - (window_start - start_ts.replace(hour=0)) % window_delta
        key = window_start.isoformat()
        windows.setdefault(key, []).append(trace)

    result = []
    for window_start_str, window_traces in sorted(windows.items()):
        if len(window_traces) < min_window_traces:
            continue
        window_start = datetime.fromisoformat(window_start_str)
        window_end = window_start + window_delta

        trace_ids = [t.get("trace_id", "") for t in window_traces]
        scores = []
        for t in window_traces:
            tid = t.get("trace_id", "")
            ev = evaluations.get(tid)
            score = get_eval_score(ev) if ev else None
            if score is not None:
                scores.append(score)

        quality = sum(scores) / len(scores) if scores else None
        avg_latency = _window_avg_latency(window_traces)
        avg_cost = _window_avg_cost(window_traces)
        error_rate = sum(1 for t in window_traces if t.get("status") == "error") / len(window_traces)

        result.append(MetricWindow(
            window_start=window_start,
            window_end=window_end,
            trace_ids=trace_ids,
            quality=quality,
            avg_latency=avg_latency,
            avg_cost=avg_cost,
            error_rate=error_rate,
        ))

    return result


def _detect_metric_drift(
    windows: list[MetricWindow],
    metric: str,
    threshold: float,
    now: datetime,
    min_traces: int,
) -> list[DriftEvent]:
    """Detect drift using CUSUM-like approach on rolling baseline."""
    events = []

    if metric == "quality":
        values = [w.quality for w in windows]
        direction_concern = "down"
        name = "quality"
        unit = "%"
    elif metric == "latency":
        values = [w.avg_latency for w in windows]
        direction_concern = "up"
        name = "latency"
        unit = "s"
    elif metric == "cost":
        values = [w.avg_cost for w in windows]
        direction_concern = "up"
        name = "cost"
        unit = "$"
    else:
        return events

    # Filter to windows with data for this metric
    valid_windows = [(w, v) for w, v in zip(windows, values) if v is not None]
    if len(valid_windows) < 3:
        return events

    # Use first half as baseline, compare to second half
    mid = len(valid_windows) // 2
    baseline_windows = valid_windows[:mid]
    recent_windows = valid_windows[mid:]

    baseline_vals = [v for _, v in baseline_windows]
    recent_vals = [v for _, v in recent_windows]

    if not baseline_vals or not recent_vals:
        return events

    baseline_mean = sum(baseline_vals) / len(baseline_vals)
    recent_mean = sum(recent_vals) / len(recent_vals)

    if baseline_mean == 0:
        return events

    change_pct = (recent_mean - baseline_mean) / baseline_mean
    change_abs = recent_mean - baseline_mean

    # For quality, drift is concerning if it drops
    is_concerning = (
        (direction_concern == "down" and change_pct < -threshold) or
        (direction_concern == "up" and change_pct > threshold)
    )

    if is_concerning:
        severity = "critical" if abs(change_pct) > threshold * 2 else "warning"
        direction = "down" if change_pct < 0 else "up"

        recent_window = recent_windows[-1][0]
        explanation = _build_explanation(name, change_pct, change_abs, unit, recent_mean, baseline_mean)

        events.append(DriftEvent(
            metric=name,
            severity=severity,
            direction=direction,
            change_pct=change_pct,
            change_abs=change_abs,
            baseline_value=baseline_mean,
            current_value=recent_mean,
            detected_at=now.isoformat(),
            window_start=recent_window.window_start.isoformat(),
            trace_count=len(recent_window.trace_ids),
            explanation=explanation,
        ))

    # Also check for sudden shifts within recent windows using CUSUM-lite
    cusum_events = _cusum_detection(
        valid_windows, metric, threshold, now, min_traces, unit
    )
    events.extend(cusum_events)

    return events


def _cusum_detection(
    windows_with_vals: list[tuple[MetricWindow, float]],
    metric: str,
    threshold: float,
    now: datetime,
    min_traces: int,
    unit: str,
) -> list[DriftEvent]:
    """Detect sudden shifts using simplified CUSUM."""
    events = []

    if len(windows_with_vals) < 4:
        return events

    # Use first N windows to establish baseline
    baseline_size = max(2, len(windows_with_vals) // 4)
    baseline_vals = [v for _, v in windows_with_vals[:baseline_size]]

    if not baseline_vals:
        return events

    baseline_mean = sum(baseline_vals) / len(baseline_vals)
    baseline_std = _std(baseline_vals) if len(baseline_vals) > 1 else 0.01

    if baseline_std == 0:
        baseline_std = 0.01

    target = threshold * abs(baseline_mean) if baseline_mean != 0 else threshold

    for window, value in windows_with_vals[baseline_size:]:
        cusum = abs(value - baseline_mean) - target / 2

        if cusum > baseline_std * 3:  # 3 sigma event
            change_pct = (value - baseline_mean) / baseline_mean if baseline_mean != 0 else 0
            severity = "critical" if abs(change_pct) > threshold * 2 else "warning"
            direction = "down" if value < baseline_mean else "up"

            events.append(DriftEvent(
                metric=metric,
                severity=severity,
                direction=direction,
                change_pct=change_pct,
                change_abs=value - baseline_mean,
                baseline_value=baseline_mean,
                current_value=value,
                detected_at=now.isoformat(),
                window_start=window.window_start.isoformat(),
                trace_count=len(window.trace_ids),
                explanation=_build_explanation(metric, change_pct, value - baseline_mean, unit, value, baseline_mean),
            ))
            break

    return events


def _build_explanation(
    metric: str,
    change_pct: float,
    change_abs: float,
    unit: str,
    current: float,
    baseline: float,
) -> str:
    """Build human-readable explanation of drift."""
    direction = "dropped" if change_pct < 0 else "increased"
    pct_abs = abs(change_pct) * 100

    if metric == "quality":
        return (
            f"Pass rate {direction} from {baseline:.0%} to {current:.0%} "
            f"({pct_abs:.0f}pp change)"
        )
    elif metric == "latency":
        return (
            f"Latency {direction} from {baseline:.2f}s to {current:.2f}s "
            f"({pct_abs:.0f}% change)"
        )
    elif metric == "cost":
        return (
            f"Cost {direction} from ${baseline:.4f} to ${current:.4f} "
            f"({pct_abs:.0f}% change)"
        )
    return f"{metric} shifted by {pct_abs:.0f}%"


def _find_stable_windows(windows: list[MetricWindow]) -> tuple[int, str | None]:
    """Count consecutive windows with similar quality (no drift)."""
    if not windows:
        return 0, None

    stable_count = 0
    last_stable = None

    for window in reversed(windows):
        if window.quality is not None:
            last_stable = window.window_start.isoformat()
            break

    if last_stable:
        # Count how many recent windows have quality within 10% of baseline
        baseline = None
        for w in windows:
            if w.quality is not None:
                baseline = w.quality
                break

        if baseline:
            for w in reversed(windows):
                if w.quality is None:
                    continue
                if abs(w.quality - baseline) / baseline < 0.10:
                    stable_count += 1
                else:
                    break

    return stable_count, last_stable


def _compute_overall_quality(
    traces: list[dict[str, Any]],
    evaluations: dict[str, dict[str, Any]],
) -> float | None:
    """Compute overall pass rate."""
    scores = []
    for t in traces:
        tid = t.get("trace_id", "")
        ev = evaluations.get(tid)
        score = get_eval_score(ev) if ev else None
        if score is not None:
            scores.append(score)
    return sum(scores) / len(scores) if scores else None


def _compute_overall_latency(traces: list[dict[str, Any]]) -> float | None:
    """Compute average latency."""
    latencies = []
    for t in traces:
        lat = t.get("latency")
        if lat is not None:
            latencies.append(lat)
    return sum(latencies) / len(latencies) if latencies else None


def _compute_overall_cost(traces: list[dict[str, Any]]) -> float | None:
    """Compute average cost."""
    costs = []
    for t in traces:
        c = t.get("total_cost")
        if c is not None and c > 0:
            costs.append(c)
    return sum(costs) / len(costs) if costs else None


def _window_avg_latency(traces: list[dict[str, Any]]) -> float | None:
    """Compute average latency for a window of traces."""
    latencies = []
    for t in traces:
        lat = t.get("latency")
        if lat is not None:
            latencies.append(lat)
    return sum(latencies) / len(latencies) if latencies else None


def _window_avg_cost(traces: list[dict[str, Any]]) -> float | None:
    """Compute average cost for a window of traces."""
    costs = []
    for t in traces:
        c = t.get("total_cost")
        if c is not None and c > 0:
            costs.append(c)
    return sum(costs) / len(costs) if costs else None


def _compute_period_days(traces: list[dict[str, Any]]) -> int:
    """Compute the date range in days."""
    if len(traces) < 2:
        return 0
    first = _parse_ts(traces[0].get("timestamp") or traces[0].get("started_at") or "")
    last = _parse_ts(traces[-1].get("timestamp") or traces[-1].get("ended_at") or "")
    if first is None or last is None:
        return 0
    delta = last - first
    return max(1, delta.days)


def _parse_ts(raw: str | None) -> datetime | None:
    """Parse ISO timestamp."""
    if not raw:
        return None
    try:
        return datetime.fromisoformat(raw.replace("Z", "+00:00"))
    except (ValueError, TypeError, AttributeError):
        return None


def _std(values: list[float]) -> float:
    """Compute standard deviation."""
    if len(values) < 2:
        return 0.0
    mean = sum(values) / len(values)
    variance = sum((x - mean) ** 2 for x in values) / (len(values) - 1)
    return variance ** 0.5
