"""
CalibrationConvergenceMonitor — Rolling stability monitor for calibration quality (Patent 6 / P6).

Ported from Patent 6 six-agent architecture design.
Replaces the fixed n≥20 rule in GuardPhaseController with an adaptive,
evidence-based convergence criterion: calibration has converged when
precision@FPR10 is stable (std < threshold) over a rolling window.

Algorithm:
  1. After each calibration round, record precision@FPR10 (or any scalar metric)
  2. Maintain a rolling window of the last `window` rounds
  3. Mark as converged when window is full AND std(window) < stability_threshold
  4. GuardPhaseController uses is_converged() instead of n >= 20

Usage::

    monitor = CalibrationConvergenceMonitor(window=10, stability_threshold=0.02)

    # After each round of label collection + calibration:
    precision = measure_precision_at_fpr10(guard, val_chains, val_labels)
    monitor.record_round(precision)

    if monitor.is_converged():
        # Stop collecting labels, switch to guard mode
        phase_controller.force_guard_phase()
    elif monitor.should_collect_more_labels():
        # Keep collecting
        ...

    status = monitor.status()
    print(status['n_rounds'], status['current_std'], status['converged'])
Purpose:    Replaces the fixed n≥20 label-collection rule with an evidence-based criterion: convergence is declared when precision@FPR10 standard deviation drops below a threshold over a rolling window.
Exports:    CalibrationConvergenceMonitor
Added:      v0.44.0
Status:     Stable
"""

from __future__ import annotations

from collections import deque
from dataclasses import dataclass, field
from typing import Deque, List, Optional

import numpy as np


@dataclass
class ConvergenceStatus:
    """Snapshot of CalibrationConvergenceMonitor state."""
    n_rounds: int
    """Total rounds recorded so far."""
    window_values: List[float]
    """Metric values in current rolling window."""
    current_std: float
    """Std dev of window_values. 0.0 if window not yet full."""
    current_mean: float
    """Mean of window_values. 0.0 if window not yet full."""
    converged: bool
    """True when window is full and std < stability_threshold."""
    rounds_until_window_full: int
    """How many more rounds needed before convergence can trigger."""


class CalibrationConvergenceMonitor:
    """
    Rolling stability monitor for calibration quality.

    Emits is_converged()=True when a calibration metric (e.g. precision@FPR10)
    has been stable (low std dev) over a sliding window of recent rounds.

    Parameters
    ----------
    window : int
        Number of rounds to include in the rolling window. Default 10.
    stability_threshold : float
        Maximum std dev of the rolling window to declare convergence.
        Default 0.02 (2 percentage points). Lower = stricter.
    metric_name : str
        Name of the metric being tracked. Stored in status for logging.
    """

    def __init__(
        self,
        window: int = 5,
        stability_threshold: float = 0.05,
        metric_name: str = "precision_at_fpr10",
    ) -> None:
        if window < 2:
            raise ValueError("window must be >= 2")
        if stability_threshold <= 0:
            raise ValueError("stability_threshold must be > 0")
        self.window = window
        self.stability_threshold = stability_threshold
        self.metric_name = metric_name
        self._history: List[float] = []
        self._rolling: Deque[float] = deque(maxlen=window)

    def record_round(self, metric_value: float) -> None:
        """
        Record the result of one calibration round.

        Parameters
        ----------
        metric_value : float
            Scalar metric value (e.g. precision@FPR10 from 0 to 1).
        """
        self._history.append(float(metric_value))
        self._rolling.append(float(metric_value))

    def is_converged(self) -> bool:
        """
        Return True if calibration has converged.

        Convergence requires:
          - At least `window` rounds recorded
          - std(window) < stability_threshold
        """
        if len(self._rolling) < self.window:
            return False
        return float(np.std(list(self._rolling), ddof=0)) < self.stability_threshold

    def should_collect_more_labels(self) -> bool:
        """Return True if calibration has NOT converged (more data needed)."""
        return not self.is_converged()

    def current_std(self) -> float:
        """Current std dev of rolling window. Returns 0.0 if window not full."""
        if len(self._rolling) < self.window:
            return 0.0
        return float(np.std(list(self._rolling), ddof=0))

    def current_mean(self) -> float:
        """Current mean of rolling window. Returns 0.0 if window not full."""
        if len(self._rolling) < 2:
            return 0.0
        return float(np.mean(list(self._rolling)))

    def reset(self) -> None:
        """Clear all recorded rounds. Use when switching to a new calibration regime."""
        self._history.clear()
        self._rolling.clear()

    def status(self) -> ConvergenceStatus:
        """Return current convergence status as a dataclass."""
        window_vals = list(self._rolling)
        n = len(self._rolling)
        std = self.current_std() if n >= self.window else 0.0
        mean = self.current_mean() if n >= 2 else 0.0
        return ConvergenceStatus(
            n_rounds=len(self._history),
            window_values=window_vals,
            current_std=std,
            current_mean=mean,
            converged=self.is_converged(),
            rounds_until_window_full=max(0, self.window - n),
        )

    def __repr__(self) -> str:
        s = self.status()
        return (
            f"CalibrationConvergenceMonitor("
            f"n_rounds={s.n_rounds}, "
            f"std={s.current_std:.4f}, "
            f"threshold={self.stability_threshold}, "
            f"converged={s.converged})"
        )
