"""
QppgMonitor — Tier 1: Agent Monitoring SDK
==========================================

Drop-in wrapper around any LLM ReAct agent that scores every response
in real-time, fires alerts when risk exceeds a threshold, and accumulates
stats for dashboard/reporting.

Usage:
    from qppg_service import QppgMonitor

    monitor = QppgMonitor(threshold=0.65)

    # After each agent run:
    alert = monitor.track(question, steps, final_answer, finished=True)
    if alert:
        print(f"HIGH RISK: {alert.risk_score:.2f} — {alert.recommendation}")

    # Periodic stats:
    print(monitor.get_stats())
    monitor.export_report()

Zero cold-start: uses behavioral signals (SC2 step count) from the first query.
Calibration improves cross-domain accuracy after ~5 logged chains.
"""

from __future__ import annotations

import asyncio
import contextvars
import functools
import json
import time
import csv
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional

from .label_free_scorer import LabelFreeScorer, LabelFreeResult

if TYPE_CHECKING:
    from .alerting import WebhookAlerter

import re as _re

_PII_PATTERNS = [
    (_re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'), '[EMAIL]'),
    (_re.compile(r'\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b'), '[PHONE]'),
    (_re.compile(r'\b(?:\d{4}[-\s]?){3}\d{4}\b'), '[CARD]'),
    (_re.compile(r'\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b'), '[SSN]'),
]


def _default_pii_scrub(text: str) -> str:
    """Regex-based PII scrubber. Replaces email, phone, credit card, SSN patterns."""
    for pattern, replacement in _PII_PATTERNS:
        text = pattern.sub(replacement, text)
    return text


# ── Context variables for @monitor.watch decorator ───────────────────────────

#: Active step list for the current @monitor.watch frame; None = no active context.
_watch_steps: contextvars.ContextVar[list[dict] | None] = contextvars.ContextVar(
    "qppg_watch_steps", default=None
)


def add_step(
    thought: str = "",
    action_type: str = "Search",
    action_arg: str = "",
    observation: str = "",
) -> None:
    """Register one ReAct step inside a @monitor.watch-decorated function.

    Call this after every tool call so the monitor can score the chain when
    the decorated function returns.

    Example::

        @monitor.watch(domain="prod")
        async def run_agent(question: str) -> str:
            obs = await search_tool(question)
            add_step(thought="Searching...", action_type="Search",
                     action_arg=question, observation=obs)
            answer = await llm(obs)
            add_step(thought="Answering.", action_type="Finish",
                     action_arg=answer, observation="")
            return answer
    """
    steps = _watch_steps.get()
    if steps is not None:
        steps.append({
            "thought":     thought,
            "action_type": action_type,
            "action_arg":  action_arg,
            "observation": observation,
        })


@dataclass
class QppgAlert:
    """Fired when agent risk exceeds threshold."""
    timestamp:      float
    question:       str
    final_answer:   str
    risk_score:     float
    needs_review:   bool
    behavioral_score: float
    step_count:     int
    finished:       bool
    recommendation: str
    result:         LabelFreeResult = field(repr=False)


@dataclass
class MonitorStats:
    n_tracked:    int
    n_alerts:     int
    alert_rate:   float
    avg_risk:     float
    avg_steps:    float
    p95_risk:     float
    high_risk_q:  list[str]   # questions with highest risk


class QppgMonitor:
    """
    Real-time agent monitor.  Wraps a LabelFreeScorer and accumulates
    per-query history for reporting and calibration.

    Parameters
    ----------
    threshold       : risk score that triggers an alert (default 0.65)
    alert_callback  : optional callable(QppgAlert) called on every alert
    scorer          : pre-built LabelFreeScorer; if None a new one is created
    auto_calibrate  : if True, each logged chain is automatically fed into
                      scorer.calibrate() once n_logged >= min_cal_size
    min_cal_size    : minimum chains before auto-calibration triggers (default 50).
                      Values below 50 will be rejected by LabelFreeScorer — raise
                      this default only if you understand GMM instability risks.
    domain          : optional string tag for multi-domain logging
    """

    def __init__(
        self,
        threshold: float = 0.65,
        alert_callback: Optional[Callable[[QppgAlert], None]] = None,
        scorer: Optional[LabelFreeScorer] = None,
        auto_calibrate: bool = True,
        min_cal_size: int = 50,
        domain: str = "default",
        *,
        db_path: Optional[str] = None,
        recal_every: int = 100,
        model_name: str = "",
        check_drift: bool = False,
        alerter: Optional["WebhookAlerter"] = None,
        online_decay: float = 0.2,
        pii_scrubber: Optional[Callable[[str], str]] = None,
        enable_domain_threshold_adaptation: bool = False,
        domain_adapt_every: int = 50,
    ):
        self.threshold      = threshold
        self.alert_callback = alert_callback
        self.scorer         = scorer or LabelFreeScorer(review_threshold=threshold)
        self.auto_calibrate = auto_calibrate
        self.min_cal_size   = min_cal_size
        self.domain         = domain
        self.recal_every    = recal_every
        self.model_name     = model_name
        self.check_drift    = check_drift
        self._alerter       = alerter
        self._pii_scrubber  = pii_scrubber
        # Online GMM decay (exp69): 0<decay<1 blends new batch with old pool
        # decay=0.2 → best retention (0.694) vs catastrophic forgetting at 0.8
        # decay=1.0 → full retrain (legacy behavior)
        self.online_decay   = max(0.0, min(1.0, online_decay))

        # RL auto-adaptation: accumulate labeled chains, trigger every N
        self.enable_domain_threshold_adaptation = enable_domain_threshold_adaptation
        self.domain_adapt_every = max(1, domain_adapt_every)
        self._labeled_pool: list[dict] = []     # chains with correctness labels
        self._n_since_last_domain_adapt = 0
        self._taxonomy: Optional["FailureTaxonomist"] = None
        if enable_domain_threshold_adaptation:
            from .failure_taxonomy import FailureTaxonomist
            self._taxonomy = FailureTaxonomist(experimental=False)

        self._history: list[dict] = []
        self._calibration_pool: list[dict] = []
        self._calibrated        = False
        self._n_cal_used        = 0
        self._n_since_last_drift_check = 0

        # SQLite persistence (optional)
        self._store = None
        if db_path is not None:
            from .store import ChainStore
            self._store = ChainStore(db_path)

        # Drift detector (optional)
        self._drift_detector = None
        if check_drift:
            from .drift import DriftDetector
            self._drift_detector = DriftDetector()

    # ── Public API ────────────────────────────────────────────────────────────

    def track(
        self,
        question: str,
        steps: list[dict],
        final_answer: str,
        finished: bool = True,
        metadata: Optional[dict] = None,
    ) -> Optional[QppgAlert]:
        """
        Score a completed agent run.  Returns a QppgAlert if risk >= threshold,
        else None.

        Parameters
        ----------
        question     : the original user question
        steps        : list of step dicts from the agent (same format as qppg_service)
        final_answer : the agent's final answer string
        finished     : True if agent called Finish[], False if it hit max steps
        metadata     : optional dict stored in history (run_id, user_id, etc.)
        """
        question     = self._scrub(question)
        final_answer = self._scrub(final_answer)
        # Scrub step observations (where PII most likely appears)
        steps = [
            {**s, "observation": self._scrub(s.get("observation", ""))}
            if "observation" in s else s
            for s in steps
        ]
        result = self.scorer.score(question, steps, final_answer, finished)
        step_count = sum(1 for s in steps if s.get("action_type") == "Search")

        entry = {
            "timestamp":       time.time(),
            "question":        question,
            "final_answer":    final_answer,
            "step_count":      step_count,
            "finished":        finished,
            "risk_score":      result.risk_score,
            "behavioral_score": result.behavioral_score,
            "needs_review":    result.needs_review,
            "domain":          self.domain,
            "metadata":        metadata or {},
            # store minimal chain for auto-calibration
            "_steps":          steps,
        }
        self._history.append(entry)
        self._calibration_pool.append({"question": question, "steps": steps})

        # Persist to SQLite if store configured
        if self._store is not None:
            self._store.add_chain(
                self.domain,
                {"question": question, "steps": steps,
                 "final_answer": final_answer, "finished": finished},
                risk_score=result.risk_score,
                alert=result.needs_review,
                failure_mode=result.failure_mode or "",
                model_name=self.model_name,
            )

        # Auto-calibrate once we have enough chains
        if (self.auto_calibrate and not self._calibrated
                and len(self._calibration_pool) >= self.min_cal_size):
            self._do_calibrate()

        # Progressive re-calibration every recal_every new chains
        if (self.auto_calibrate and self._calibrated
                and (len(self._calibration_pool) - self._n_cal_used) >= self.recal_every):
            self._do_calibrate()

        # Drift check every 50 tracked queries (if enabled and store available)
        self._n_since_last_drift_check += 1
        if (self.check_drift and self._drift_detector is not None
                and self._store is not None
                and self._n_since_last_drift_check >= 50):
            self._n_since_last_drift_check = 0
            drift = self._drift_detector.check(self.domain, self._store)
            if drift and self.alert_callback:
                # Re-use alert_callback with a sentinel alert for drift
                self.alert_callback(drift)  # type: ignore[arg-type]
            if drift and self._alerter is not None:
                self._alerter.fire_drift(drift)

        # Webhook: notify on alert-rate spike using last 100 chains
        if result.needs_review and self._alerter is not None:
            recent_n      = min(100, len(self._history))
            recent_alerts = sum(1 for e in self._history[-100:] if e.get("needs_review"))
            self._alerter.fire_alert_rate(self.domain, recent_alerts / recent_n, recent_n)

        if result.needs_review:
            alert = QppgAlert(
                timestamp        = entry["timestamp"],
                question         = question,
                final_answer     = final_answer,
                risk_score       = result.risk_score,
                needs_review     = True,
                behavioral_score = result.behavioral_score,
                step_count       = step_count,
                finished         = finished,
                recommendation   = self._make_recommendation(result, step_count, finished),
                result           = result,
            )
            if self.alert_callback:
                self.alert_callback(alert)
            return alert

        return None

    async def track_async(
        self,
        question: str,
        steps: list[dict],
        final_answer: str,
        finished: bool = True,
        metadata: Optional[dict] = None,
    ) -> Optional[QppgAlert]:
        """
        Async wrapper around :meth:`track`.

        Runs the synchronous scorer in a thread-pool executor so it does not
        block the event loop.  All other semantics are identical to ``track()``.

        Example
        -------
        .. code-block:: python

            import asyncio
            from qppg_service import QppgMonitor

            monitor = QppgMonitor(threshold=0.65)

            async def score_agent_run(question, steps, answer):
                alert = await monitor.track_async(question, steps, answer)
                if alert:
                    print(f"ALERT: {alert.risk_score:.2f}")

            asyncio.run(score_agent_run(...))
        """
        loop = asyncio.get_event_loop()
        fn = functools.partial(
            self.track, question, steps, final_answer, finished, metadata
        )
        return await loop.run_in_executor(None, fn)

    def track_batch(
        self,
        chains: list[dict],
        metadata_list: Optional[list[dict]] = None,
    ) -> list[Optional[QppgAlert]]:
        """
        Score a batch of chains at once.  Each chain dict must have keys:
        question, steps, final_answer, finished (optional).
        """
        alerts = []
        for i, chain in enumerate(chains):
            meta = metadata_list[i] if metadata_list else None
            alert = self.track(
                chain["question"],
                chain["steps"],
                chain.get("final_answer", ""),
                chain.get("finished", True),
                metadata=meta,
            )
            alerts.append(alert)
        return alerts

    def calibrate(self, chains: list[dict]) -> dict:
        """Manually provide calibration chains (no labels needed)."""
        result = self.scorer.calibrate(chains)
        self._calibrated = True
        self._n_cal_used = len(self._calibration_pool)
        return result

    def add_label(
        self,
        question: str,
        steps: list[dict],
        final_answer: str,
        correct: bool,
        domain: Optional[str] = None,
    ) -> Optional[dict]:
        """
        Add a ground-truth correctness label for a completed chain.

        Labels accumulate in an internal buffer.  When
        ``enable_domain_threshold_adaptation=True``, calling this method
        every ``domain_adapt_every`` new labels triggers an automatic
        re-fit of the FailureTaxonomist domain thresholds via
        ``fit_domain_thresholds()``.

        Parameters
        ----------
        question     : original question string
        steps        : agent step list
        final_answer : agent's final answer
        correct      : True if the agent's answer was correct
        domain       : domain tag (e.g. "hotpotqa"); defaults to self.domain

        Returns
        -------
        dict or None — summary from ``fit_domain_thresholds()`` if a
        threshold update was triggered, else None.
        """
        entry = {
            "question":     question,
            "steps":        steps,
            "final_answer": final_answer,
            "correct":      correct,
            "domain":       domain or self.domain,
        }
        self._labeled_pool.append(entry)
        self._n_since_last_domain_adapt += 1

        if (self.enable_domain_threshold_adaptation
                and self._taxonomy is not None
                and self._n_since_last_domain_adapt >= self.domain_adapt_every):
            return self._maybe_adapt_thresholds()
        return None

    def _maybe_adapt_thresholds(self) -> dict:
        """Re-fit FailureTaxonomist domain thresholds on accumulated labels."""
        self._n_since_last_domain_adapt = 0
        summary = self._taxonomy.fit_domain_thresholds(  # type: ignore[union-attr]
            self._labeled_pool
        )
        return summary

    def get_stats(self) -> MonitorStats:
        """Return aggregated monitoring statistics."""
        import numpy as np

        if not self._history:
            return MonitorStats(0, 0, 0.0, 0.0, 0.0, 0.0, [])

        risks  = [e["risk_score"]  for e in self._history]
        steps  = [e["step_count"]  for e in self._history]
        alerts = [e for e in self._history if e["needs_review"]]

        # Top 3 highest-risk questions
        sorted_h = sorted(self._history, key=lambda e: e["risk_score"], reverse=True)
        top3_q   = [e["question"][:80] + "…" if len(e["question"]) > 80
                    else e["question"]
                    for e in sorted_h[:3]]

        return MonitorStats(
            n_tracked   = len(self._history),
            n_alerts    = len(alerts),
            alert_rate  = len(alerts) / len(self._history),
            avg_risk    = float(np.mean(risks)),
            avg_steps   = float(np.mean(steps)),
            p95_risk    = float(np.percentile(risks, 95)),
            high_risk_q = top3_q,
        )

    def export_report(self) -> str:
        """Return a formatted text report of monitoring statistics."""
        s = self.get_stats()
        cal_status = (f"calibrated (n={self._n_cal_used} chains)"
                      if self._calibrated else
                      f"behavioral-only ({len(self._calibration_pool)}/{self.min_cal_size} chains for cal)")
        lines = [
            "=" * 60,
            "  QPPG Agent Monitor Report",
            "=" * 60,
            f"  Domain          : {self.domain}",
            f"  Scorer status   : {cal_status}",
            f"  Alert threshold : {self.threshold:.2f}",
            "",
            f"  Queries tracked : {s.n_tracked}",
            f"  Alerts fired    : {s.n_alerts}  ({s.alert_rate:.1%} of traffic)",
            f"  Avg risk score  : {s.avg_risk:.3f}",
            f"  P95 risk score  : {s.p95_risk:.3f}",
            f"  Avg search steps: {s.avg_steps:.1f}",
            "",
        ]
        if s.high_risk_q:
            lines.append("  Highest-risk queries:")
            for i, q in enumerate(s.high_risk_q, 1):
                lines.append(f"    {i}. {q}")
        lines.append("=" * 60)
        return "\n".join(lines)

    def export_csv(self, path: str | Path) -> None:
        """Export full history to CSV (for downstream analysis)."""
        path = Path(path)
        path.parent.mkdir(parents=True, exist_ok=True)
        fields = ["timestamp", "question", "final_answer", "step_count",
                  "finished", "risk_score", "behavioral_score", "needs_review", "domain"]
        with path.open("w", newline="") as f:
            w = csv.DictWriter(f, fieldnames=fields)
            w.writeheader()
            for e in self._history:
                w.writerow({k: e[k] for k in fields})

    def export_json(self, path: str | Path) -> None:
        """Export full history to JSON."""
        path = Path(path)
        path.parent.mkdir(parents=True, exist_ok=True)
        exportable = [{k: v for k, v in e.items() if k != "_steps"}
                      for e in self._history]
        path.write_text(json.dumps(exportable, indent=2))

    def generate_report(self, days: int = 7) -> dict:
        """
        Generate a reliability report over the last ``days`` days.

        Returns a dict with:
          - period_start, period_end: ISO timestamps
          - total_chains, alert_count, alert_rate
          - avg_risk, p95_risk
          - per_day: list of dicts (date, n, alerts, avg_risk)
          - trend: "rising" | "falling" | "stable"
          - calibration_state: "fitted" | "behavioral_only"

        Uses the in-memory history.  No database required.
        """
        import numpy as _np
        from datetime import datetime, timezone, timedelta

        now_ts    = time.time()
        cutoff_ts = now_ts - days * 86400.0
        period    = [e for e in self._history if e.get("timestamp", 0.0) >= cutoff_ts]

        if not period:
            return {
                "period_start": datetime.fromtimestamp(cutoff_ts, tz=timezone.utc).isoformat(),
                "period_end":   datetime.fromtimestamp(now_ts, tz=timezone.utc).isoformat(),
                "days":         days,
                "total_chains": 0,
                "alert_count":  0,
                "alert_rate":   0.0,
                "avg_risk":     None,
                "p95_risk":     None,
                "per_day":      [],
                "trend":        "stable",
                "calibration_state": "fitted" if self._calibrated else "behavioral_only",
            }

        risks   = [e["risk_score"] for e in period]
        alerts  = [e for e in period if e.get("needs_review", False)]

        # Per-day breakdown
        per_day: list[dict] = []
        for day_offset in range(days - 1, -1, -1):
            day_start = now_ts - (day_offset + 1) * 86400.0
            day_end   = now_ts - day_offset * 86400.0
            day_entries = [e for e in period
                           if day_start <= e.get("timestamp", 0.0) < day_end]
            date_str = datetime.fromtimestamp(day_start, tz=timezone.utc).strftime("%Y-%m-%d")
            per_day.append({
                "date":     date_str,
                "n":        len(day_entries),
                "alerts":   sum(1 for e in day_entries if e.get("needs_review", False)),
                "avg_risk": round(float(_np.mean([e["risk_score"] for e in day_entries])), 4)
                            if day_entries else None,
            })

        # Trend: compare first half vs second half avg_risk
        mid = len(risks) // 2
        if mid >= 3:
            first_half_avg  = float(_np.mean(risks[:mid]))
            second_half_avg = float(_np.mean(risks[mid:]))
            delta = second_half_avg - first_half_avg
            if delta > 0.05:
                trend = "rising"
            elif delta < -0.05:
                trend = "falling"
            else:
                trend = "stable"
        else:
            trend = "stable"

        return {
            "period_start":      datetime.fromtimestamp(cutoff_ts, tz=timezone.utc).isoformat(),
            "period_end":        datetime.fromtimestamp(now_ts, tz=timezone.utc).isoformat(),
            "days":              days,
            "total_chains":      len(period),
            "alert_count":       len(alerts),
            "alert_rate":        round(len(alerts) / len(period), 4),
            "avg_risk":          round(float(_np.mean(risks)), 4),
            "p95_risk":          round(float(_np.percentile(risks, 95)), 4),
            "per_day":           per_day,
            "trend":             trend,
            "calibration_state": "fitted" if self._calibrated else "behavioral_only",
        }

    def track_step(
        self,
        chain_id: str,
        step_idx: int,
        step_risk: float,
        cusum_threshold: float = 0.3,
        cusum_k: float = 0.1,
    ) -> dict | None:
        """Track a single step's risk and detect intra-chain drift via CUSUM.

        Args:
            chain_id: unique identifier for the chain being tracked.
            step_idx: index of the current step (0-based).
            step_risk: risk score for this step in [0, 1].
            cusum_threshold: CUSUM detection threshold (h parameter).
            cusum_k: CUSUM allowance (k parameter).
        Returns:
            None if no drift, dict(drift_detected=True, step_idx=..., chain_id=...) if drift.
        """
        if not hasattr(self, "_step_cusum_state"):
            self._step_cusum_state: dict = {}

        if chain_id not in self._step_cusum_state:
            self._step_cusum_state[chain_id] = {
                "S_pos": 0.0, "S_neg": 0.0, "baseline": None
            }

        state = self._step_cusum_state[chain_id]

        if state["baseline"] is None:
            state["baseline"] = step_risk
            return None

        baseline = state["baseline"]
        deviation = step_risk - baseline
        state["S_pos"] = max(0.0, state["S_pos"] + deviation - cusum_k)
        state["S_neg"] = max(0.0, state["S_neg"] - deviation - cusum_k)

        if state["S_pos"] > cusum_threshold or state["S_neg"] > cusum_threshold:
            state["S_pos"] = 0.0
            state["S_neg"] = 0.0
            return {"drift_detected": True, "step_idx": step_idx, "chain_id": chain_id}

        return None

    def reset(self) -> None:
        """Clear history (keeps scorer calibration)."""
        self._history.clear()
        self._calibration_pool.clear()

    def watch(
        self,
        domain: str | None = None,
        finished: bool = True,
    ) -> Callable:
        """Decorator that automatically tracks an agent function's output.

        The decorated function must accept ``question: str`` as its first
        positional argument and return the final answer string.  Inside the
        function, call :func:`add_step` after each tool invocation so the
        monitor has the full reasoning chain.

        Parameters
        ----------
        domain   : override the monitor's default domain for this function.
        finished : treat the chain as a completed Finish[] call (default True).

        Example::

            from qppg_service import QppgMonitor, add_step

            monitor = QppgMonitor(threshold=0.65)

            @monitor.watch(domain="prod")
            async def run_agent(question: str) -> str:
                obs = await search(question)
                add_step(thought="Searching.", action_type="Search",
                         action_arg=question, observation=obs)
                answer = synthesise(obs)
                add_step(thought="Done.", action_type="Finish",
                         action_arg=answer, observation="")
                return answer

            # Alert is stored in run_agent.last_alert after each call.
        """
        def decorator(fn: Callable) -> Callable:
            if asyncio.iscoroutinefunction(fn):
                @functools.wraps(fn)
                async def async_wrapper(question: str, *args, **kwargs):
                    steps: list[dict] = []
                    token = _watch_steps.set(steps)
                    try:
                        final_answer = await fn(question, *args, **kwargs)
                    finally:
                        _watch_steps.reset(token)
                    _domain = domain or self.domain
                    old_domain, self.domain = self.domain, _domain
                    try:
                        alert = self.track(
                            question, steps, str(final_answer), finished
                        )
                    finally:
                        self.domain = old_domain
                    async_wrapper.last_alert = alert
                    return final_answer

                async_wrapper.last_alert = None
                return async_wrapper
            else:
                @functools.wraps(fn)
                def sync_wrapper(question: str, *args, **kwargs):
                    steps: list[dict] = []
                    token = _watch_steps.set(steps)
                    try:
                        final_answer = fn(question, *args, **kwargs)
                    finally:
                        _watch_steps.reset(token)
                    _domain = domain or self.domain
                    old_domain, self.domain = self.domain, _domain
                    try:
                        alert = self.track(
                            question, steps, str(final_answer), finished
                        )
                    finally:
                        self.domain = old_domain
                    sync_wrapper.last_alert = alert
                    return final_answer

                sync_wrapper.last_alert = None
                return sync_wrapper

        return decorator

    # ── Internal helpers ──────────────────────────────────────────────────────

    def _scrub(self, text: str) -> str:
        """Apply PII scrubber if configured, else apply default regex scrubber."""
        if self._pii_scrubber is not None:
            return self._pii_scrubber(text)
        return _default_pii_scrub(text)

    def _do_calibrate(self) -> None:
        """
        (Re-)calibrate the scorer.

        First calibration: full fit on all available chains.
        Subsequent re-calibrations: online GMM blending (exp69) when
        online_decay < 1.0.  Decay-weighted sampling of old pool prevents
        catastrophic forgetting (0.694 retention at decay=0.2, exp69).
        """
        import numpy as np

        pool = self._calibration_pool
        # If SQLite store available, use persisted chains (capped at 1000)
        if self._store is not None:
            stored = self._store.get_calibration_pool(self.domain, n=1000)
            if stored:
                pool = stored

        if self._calibrated and 0.0 < self.online_decay < 1.0:
            pool = self._blend_pool_online(pool)

        self.scorer.calibrate(pool)
        self._calibrated = True
        self._n_cal_used = len(self._calibration_pool)
        if self._store is not None:
            auroc = self.scorer.status().get("expected_auroc", "unknown")
            try:
                auroc_f = float(str(auroc).split()[0].strip("~"))
            except (ValueError, IndexError):
                auroc_f = 0.0
            self._store.log_calibration(self.domain, len(pool), auroc_f)

    def _blend_pool_online(self, pool: list[dict]) -> list[dict]:
        """
        Return a decay-weighted blend of old and new chains (exp69, decay=0.2).

        New chains (last recal_every) get weight 1.0; older chains are
        down-weighted exponentially.  The blended set is capped at
        4 * recal_every chains to bound calibration cost.
        """
        import numpy as np

        n_new = min(self.recal_every, len(pool))
        new_batch = pool[-n_new:]
        old_pool  = pool[:-n_new]

        if not old_pool:
            return new_batch

        n_old = len(old_pool)
        # age=1 for most recent old chain, age=n_old for oldest
        ages = np.arange(n_old, 0, -1, dtype=float)
        weights = self.online_decay ** (ages / max(self.recal_every, 1))
        weights /= weights.sum()

        # Sample min(3*recal_every, n_old) old chains with decay weights
        n_sample = min(3 * self.recal_every, n_old)
        rng = np.random.default_rng(42)
        idx = rng.choice(n_old, size=n_sample, replace=False, p=weights)
        sampled_old = [old_pool[i] for i in sorted(idx)]

        return sampled_old + new_batch

    @staticmethod
    def _make_recommendation(result: LabelFreeResult, step_count: int, finished: bool) -> str:
        """Human-readable recommendation based on risk signals."""
        parts = []
        if not finished:
            parts.append("agent hit step limit — consider increasing max_steps")
        if step_count >= 5:
            parts.append(f"agent took {step_count} search steps (high) — likely struggling")
        if result.behavioral_score > 0.7:
            parts.append("high behavioral risk — review reasoning chain")
        if not parts:
            parts.append("general uncertainty — manual review recommended")
        return "; ".join(parts)

    def register_chain_for_labeling(self, chain_id: str, risk_score: float) -> None:
        """Register a chain as a candidate for active labeling (uncertainty sampling)."""
        if not hasattr(self, "_uncertainty_buffer"):
            self._uncertainty_buffer: list[dict] = []
        self._uncertainty_buffer.append({"chain_id": chain_id, "risk": risk_score})

    def get_labeling_queue(self, n: int = 10) -> list[dict]:
        """Return the n most uncertain chains (closest to decision boundary 0.5).

        Returns list of dicts with chain_id and risk, sorted by uncertainty desc.
        """
        buffer = getattr(self, "_uncertainty_buffer", [])
        sorted_by_uncertainty = sorted(buffer, key=lambda x: abs(x["risk"] - 0.5))
        return sorted_by_uncertainty[:n]

    def submit_label(self, chain_id: str, label: int, risk_score: float) -> None:
        """Submit a human label for a chain. label=1 = failure, 0 = success."""
        if not hasattr(self, "_labeled_buffer"):
            self._labeled_buffer: list[dict] = []
        self._labeled_buffer.append({
            "chain_id": chain_id,
            "label": label,
            "risk_score": risk_score,
        })
        if hasattr(self, "_uncertainty_buffer"):
            self._uncertainty_buffer = [
                x for x in self._uncertainty_buffer if x["chain_id"] != chain_id
            ]

    def transfer_calibration_from(
        self,
        source_monitor,
        alpha: float = 0.3,
    ) -> None:
        """Transfer isotonic calibration from a source domain monitor.

        Blends source calibration with identity: blended = alpha*source + (1-alpha)*identity.
        When target has no local data, provides a warm-start calibration.

        Args:
            source_monitor: another QppgMonitor instance with calibration data.
            alpha: weight for source calibration (0=ignore, 1=copy source entirely).
        """
        source_x = getattr(source_monitor, "_isotonic_x", None)
        source_y = getattr(source_monitor, "_isotonic_y", None)

        if source_x is None or source_y is None:
            return

        blended_y = [
            alpha * sy + (1 - alpha) * sx
            for sx, sy in zip(source_x, source_y)
        ]
        self._transferred_calibration = {
            "x": list(source_x),
            "y": blended_y,
            "alpha": alpha,
            "source_domain": getattr(source_monitor, "domain_name", "unknown"),
        }


# ── Online RL: contextual bandit for signal weight adaptation ─────────────────

class OnlineBanditWeights:
    """
    Online RL (contextual bandit) for adapting signal weights from human feedback.

    Exp95 finding: Sonnet judge alone achieves ~0.97 optimal weight; SC_OLD and
    other signals shrink toward 0 when optimized offline. This bandit handles the
    online adaptation case where production feedback gradually shifts domain distribution.

    Usage (in production after deployment):
        bandit = OnlineBanditWeights(signal_names=["sc_old", "judge"])

        # After each alert, when a human reviews and confirms/denies:
        bandit.record_feedback(
            signal_scores=np.array([sc_score, judge_score]),
            was_true_positive=True,   # human confirmed this was a real failure
        )

        # To score a new chain using adapted weights:
        combined_risk = bandit.score(np.array([sc_score, judge_score]))
    """

    def __init__(
        self,
        signal_names: list[str] | None = None,
        n_signals: int = 2,
        lr: float = 0.02,
        seed: int = 42,
    ):
        import numpy as np
        self._np = np
        if signal_names is not None:
            n_signals = len(signal_names)
        self.signal_names = signal_names or [f"signal_{i}" for i in range(n_signals)]
        self.weights = np.ones(n_signals, dtype=float)
        self.lr = lr
        self.n_updates = 0
        self._reward_history: list[float] = []
        rng = np.random.default_rng(seed)
        self._rng = rng

    def score(self, signal_scores) -> float:
        """Return weighted combination of signal scores (inference)."""
        np = self._np
        signal_scores = np.asarray(signal_scores, dtype=float)
        w = np.abs(self.weights) / (np.abs(self.weights).sum() + 1e-9)
        return float(signal_scores @ w)

    def record_feedback(self, signal_scores, was_true_positive: bool) -> None:
        """
        REINFORCE-style update: reward=+1 for true positive (real failure caught),
        reward=-1 for false positive (correct chain flagged in error).

        Increase weights for signals that contributed to correct alerts;
        decrease weights that drove false positive alerts.
        """
        np = self._np
        signal_scores = np.asarray(signal_scores, dtype=float)
        reward = 1.0 if was_true_positive else -1.0
        w = np.abs(self.weights) / (np.abs(self.weights).sum() + 1e-9)
        score = max(float(signal_scores @ w), 1e-6)
        grad = reward * signal_scores / score
        self.weights += self.lr * grad
        self.weights = np.maximum(self.weights, 0.01)  # keep non-negative
        self.n_updates += 1
        self._reward_history.append(reward)

    def get_normalized_weights(self) -> dict[str, float]:
        """Return current weights normalized to sum=1."""
        np = self._np
        w = np.abs(self.weights) / np.abs(self.weights).sum()
        return {name: round(float(wi), 4) for name, wi in zip(self.signal_names, w)}

    def to_dict(self) -> dict:
        return {
            "weights": self.get_normalized_weights(),
            "n_updates": self.n_updates,
            "cumulative_reward": float(sum(self._reward_history)),
            "recent_precision": (
                float(sum(1 for r in self._reward_history[-20:] if r > 0) /
                      max(1, len(self._reward_history[-20:])))
            ),
        }
