"""Anomaly detection on Tollgate audit event streams.

Consumes audit events and computes baseline call frequency per
(agent, tool) pair. Detects anomalies using z-score deviation on
sliding windows.

This is a complementary layer that plugs into Tollgate's AuditSink
protocol. It can operate as both a real-time detector (via the AuditSink
interface) and a batch analyzer (via ``analyze()``).

Usage:

    from tollgate.anomaly_detector import AnomalyDetector

    detector = AnomalyDetector(
        window_seconds=300,      # 5-minute sliding window
        z_score_threshold=3.0,   # Alert at 3 standard deviations
        min_samples=10,          # Need 10+ data points before alerting
    )

    # Use as an AuditSink (real-time)
    composite = CompositeAuditSink([jsonl_sink, detector])
    tower = ControlTower(..., audit=composite)

    # Or analyze batch data
    alerts = detector.analyze()
"""

import logging
import math
import time
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any

from .types import AuditEvent, Outcome

logger = logging.getLogger("tollgate.anomaly_detector")


@dataclass
class AnomalyAlert:
    """An anomaly alert generated by the detector."""

    alert_type: str  # "rate_spike", "error_burst", "unusual_tool", "deny_surge"
    agent_id: str
    tool: str | None
    severity: str  # "low", "medium", "high", "critical"
    message: str
    z_score: float | None = None
    current_rate: float = 0.0
    baseline_rate: float = 0.0
    timestamp: float = 0.0

    def to_dict(self) -> dict[str, Any]:
        return {
            "alert_type": self.alert_type,
            "agent_id": self.agent_id,
            "tool": self.tool,
            "severity": self.severity,
            "message": self.message,
            "z_score": self.z_score,
            "current_rate": self.current_rate,
            "baseline_rate": self.baseline_rate,
            "timestamp": self.timestamp,
        }


class _SlidingWindow:
    """A time-based sliding window counter."""

    __slots__ = ("_timestamps", "_window_seconds")

    def __init__(self, window_seconds: float):
        self._timestamps: list[float] = []
        self._window_seconds = window_seconds

    def add(self, ts: float | None = None):
        self._timestamps.append(ts or time.time())

    def _prune(self, now: float):
        cutoff = now - self._window_seconds
        # Binary search would be faster but list is typically small
        self._timestamps = [t for t in self._timestamps if t > cutoff]

    def count(self, now: float | None = None) -> int:
        now = now or time.time()
        self._prune(now)
        return len(self._timestamps)

    def rate(self, now: float | None = None) -> float:
        """Events per second in the current window."""
        now = now or time.time()
        self._prune(now)
        if not self._timestamps:
            return 0.0
        return len(self._timestamps) / self._window_seconds


class _BaselineTracker:
    """Tracks historical rates to compute mean and standard deviation."""

    def __init__(self, max_samples: int = 100):
        self._samples: list[float] = []
        self._max_samples = max_samples

    def add_sample(self, rate: float):
        self._samples.append(rate)
        if len(self._samples) > self._max_samples:
            self._samples.pop(0)

    @property
    def count(self) -> int:
        return len(self._samples)

    @property
    def mean(self) -> float:
        if not self._samples:
            return 0.0
        return sum(self._samples) / len(self._samples)

    @property
    def std_dev(self) -> float:
        if len(self._samples) < 2:
            return 0.0
        m = self.mean
        variance = sum((x - m) ** 2 for x in self._samples) / (len(self._samples) - 1)
        return math.sqrt(variance)

    def z_score(self, value: float) -> float | None:
        """Compute z-score for a value. Returns None if insufficient data."""
        if self.count < 2 or self.std_dev == 0:
            return None
        return (value - self.mean) / self.std_dev


class AnomalyDetector:
    """Real-time anomaly detection on Tollgate audit streams.

    Implements the AuditSink protocol so it can be used directly with
    CompositeAuditSink. Detects:

    - **Rate spikes**: Unusual call frequency per (agent, tool)
    - **Error bursts**: Sudden increase in FAILED outcomes
    - **Deny surges**: Unusual number of BLOCKED/DENIED outcomes
    - **Unusual tools**: Agent calling tools they haven't used before

    Args:
        window_seconds: Sliding window duration for rate calculation.
        z_score_threshold: Number of standard deviations to trigger an alert.
        min_samples: Minimum baseline samples before alerting.
        alert_callback: Optional callback invoked for each alert.
        baseline_interval: Seconds between baseline rate samples.
    """

    def __init__(
        self,
        *,
        window_seconds: float = 300.0,
        z_score_threshold: float = 3.0,
        min_samples: int = 10,
        alert_callback: Any | None = None,
        baseline_interval: float = 60.0,
    ):
        self._window_seconds = window_seconds
        self._z_threshold = z_score_threshold
        self._min_samples = min_samples
        self._alert_callback = alert_callback
        self._baseline_interval = baseline_interval

        # Per (agent, tool) sliding windows
        self._call_windows: dict[str, _SlidingWindow] = defaultdict(
            lambda: _SlidingWindow(window_seconds)
        )
        # Per agent error windows
        self._error_windows: dict[str, _SlidingWindow] = defaultdict(
            lambda: _SlidingWindow(window_seconds)
        )
        # Per agent deny windows
        self._deny_windows: dict[str, _SlidingWindow] = defaultdict(
            lambda: _SlidingWindow(window_seconds)
        )

        # Baseline trackers
        self._call_baselines: dict[str, _BaselineTracker] = defaultdict(
            _BaselineTracker
        )
        self._error_baselines: dict[str, _BaselineTracker] = defaultdict(
            _BaselineTracker
        )
        self._deny_baselines: dict[str, _BaselineTracker] = defaultdict(
            _BaselineTracker
        )

        # Track known tool usage per agent
        self._known_tools: dict[str, set[str]] = defaultdict(set)

        # Last baseline sample timestamp
        self._last_baseline_sample: float = 0.0

        # Accumulated alerts
        self._alerts: list[AnomalyAlert] = []

    def _key(self, agent_id: str, tool: str) -> str:
        return f"{agent_id}:{tool}"

    def emit(self, event: AuditEvent):
        """AuditSink protocol — process an audit event."""
        now = time.time()
        agent_id = event.agent.agent_id
        tool = event.tool_request.tool
        key = self._key(agent_id, tool)

        # Record in sliding windows
        self._call_windows[key].add(now)

        if event.outcome == Outcome.FAILED:
            self._error_windows[agent_id].add(now)

        if event.outcome in (Outcome.BLOCKED, Outcome.APPROVAL_DENIED):
            self._deny_windows[agent_id].add(now)

        # Check for unusual tool usage
        if tool not in self._known_tools[agent_id]:
            if self._known_tools[agent_id]:  # Has history
                alert = AnomalyAlert(
                    alert_type="unusual_tool",
                    agent_id=agent_id,
                    tool=tool,
                    severity="medium",
                    message=(
                        f"Agent '{agent_id}' called tool '{tool}' for the first "
                        f"time (known tools: {sorted(self._known_tools[agent_id])})"
                    ),
                    timestamp=now,
                )
                self._fire_alert(alert)
            self._known_tools[agent_id].add(tool)

        # Periodically sample baselines
        if now - self._last_baseline_sample >= self._baseline_interval:
            self._sample_baselines(now)
            self._last_baseline_sample = now

        # Check for anomalies
        self._check_rate_anomaly(agent_id, tool, now)
        self._check_error_anomaly(agent_id, now)
        self._check_deny_anomaly(agent_id, now)

    def _sample_baselines(self, now: float):
        """Sample current rates into baseline trackers."""
        for key, window in self._call_windows.items():
            rate = window.rate(now)
            self._call_baselines[key].add_sample(rate)

        for agent_id, window in self._error_windows.items():
            rate = window.rate(now)
            self._error_baselines[agent_id].add_sample(rate)

        for agent_id, window in self._deny_windows.items():
            rate = window.rate(now)
            self._deny_baselines[agent_id].add_sample(rate)

    def _check_rate_anomaly(self, agent_id: str, tool: str, now: float):
        key = self._key(agent_id, tool)
        baseline = self._call_baselines[key]

        if baseline.count < self._min_samples:
            return

        current_rate = self._call_windows[key].rate(now)
        z = baseline.z_score(current_rate)

        if z is not None and z > self._z_threshold:
            severity = "high" if z > self._z_threshold * 2 else "medium"
            alert = AnomalyAlert(
                alert_type="rate_spike",
                agent_id=agent_id,
                tool=tool,
                severity=severity,
                message=(
                    f"Rate spike for {agent_id}/{tool}: "
                    f"{current_rate:.2f}/s (baseline: {baseline.mean:.2f}/s, "
                    f"z-score: {z:.1f})"
                ),
                z_score=z,
                current_rate=current_rate,
                baseline_rate=baseline.mean,
                timestamp=now,
            )
            self._fire_alert(alert)

    def _check_error_anomaly(self, agent_id: str, now: float):
        baseline = self._error_baselines[agent_id]

        if baseline.count < self._min_samples:
            return

        current_rate = self._error_windows[agent_id].rate(now)
        z = baseline.z_score(current_rate)

        if z is not None and z > self._z_threshold:
            severity = "high" if z > self._z_threshold * 2 else "medium"
            alert = AnomalyAlert(
                alert_type="error_burst",
                agent_id=agent_id,
                tool=None,
                severity=severity,
                message=(
                    f"Error burst for {agent_id}: "
                    f"{current_rate:.2f}/s (baseline: {baseline.mean:.2f}/s, "
                    f"z-score: {z:.1f})"
                ),
                z_score=z,
                current_rate=current_rate,
                baseline_rate=baseline.mean,
                timestamp=now,
            )
            self._fire_alert(alert)

    def _check_deny_anomaly(self, agent_id: str, now: float):
        baseline = self._deny_baselines[agent_id]

        if baseline.count < self._min_samples:
            return

        current_rate = self._deny_windows[agent_id].rate(now)
        z = baseline.z_score(current_rate)

        if z is not None and z > self._z_threshold:
            severity = "critical" if z > self._z_threshold * 2 else "high"
            alert = AnomalyAlert(
                alert_type="deny_surge",
                agent_id=agent_id,
                tool=None,
                severity=severity,
                message=(
                    f"Deny surge for {agent_id}: "
                    f"{current_rate:.2f}/s (baseline: {baseline.mean:.2f}/s, "
                    f"z-score: {z:.1f})"
                ),
                z_score=z,
                current_rate=current_rate,
                baseline_rate=baseline.mean,
                timestamp=now,
            )
            self._fire_alert(alert)

    def _fire_alert(self, alert: AnomalyAlert):
        """Record and dispatch an alert."""
        self._alerts.append(alert)
        logger.warning("Anomaly detected: %s", alert.message)

        if self._alert_callback is not None:
            try:
                self._alert_callback(alert)
            except Exception:
                logger.exception("Alert callback failed")

    def get_alerts(self, since: float | None = None) -> list[AnomalyAlert]:
        """Get accumulated alerts, optionally filtered by timestamp."""
        if since is None:
            return list(self._alerts)
        return [a for a in self._alerts if a.timestamp >= since]

    def get_stats(self) -> dict[str, Any]:
        """Get current detector statistics for monitoring."""
        now = time.time()
        stats: dict[str, Any] = {
            "total_alerts": len(self._alerts),
            "agents_tracked": len(self._known_tools),
            "tools_per_agent": {
                agent: len(tools) for agent, tools in self._known_tools.items()
            },
            "current_rates": {},
        }

        for key, window in self._call_windows.items():
            stats["current_rates"][key] = {
                "rate": window.rate(now),
                "count": window.count(now),
            }

        return stats

    def clear(self):
        """Reset all state."""
        self._call_windows.clear()
        self._error_windows.clear()
        self._deny_windows.clear()
        self._call_baselines.clear()
        self._error_baselines.clear()
        self._deny_baselines.clear()
        self._known_tools.clear()
        self._alerts.clear()
        self._last_baseline_sample = 0.0
