"""sentinel/engine.py — SentinelEngine: 3-arm coordinator and singleton.

Coordinates Reactive, Proactive, and Structural arms.
Owns the IncidentRegistry and delegates to PatternEngine + CoArticulator.
Provides the ``get_sentinel()`` singleton accessor.

Design constraints:
  KV₃: Fire-and-forget — scan() never raises.
  KV₇: Arms share no mutable state; signals merged by confidence-weighted voting.
  KV₄: Merged confidence < 1.0.
"""

from __future__ import annotations

import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, TYPE_CHECKING

from sentinel.detection import ProactiveArm, ReactiveArm, StructuralArm
from sentinel.registry import IncidentRegistry
from sentinel.taxonomy import ALL_TAXONOMY, PICKER_OPTIONS
from sentinel.types import (
    CONFIDENCE_CEILING,
    NOISE_FLOOR,
    MAX_PATTERN_RULES,
    ConfidenceBasis,
    DetectionArm,
    Incident,
    PatternRule,
    SentinelSignal,
    SentinelState,
    Severity,
)

if TYPE_CHECKING:
    from fw_server.session import SessionLedger
    from sentinel.articulation import CoArticulator
    from sentinel.patterns import PatternEngine

logger = logging.getLogger(__name__)


# ── Anti-annoyance constants ─────────────────────────────────────────

MIN_TURNS_BETWEEN_PICKER = 5


class SentinelEngine:
    """Coordinates the three detection arms.

    Calling convention: each arm is called independently (KV₇).
    Results are merged by confidence-weighted voting.
    """

    def __init__(self) -> None:
        self._reactive = ReactiveArm()
        self._proactive = ProactiveArm()
        self._structural = StructuralArm()
        self._registry = IncidentRegistry()
        self._pattern_engine: Optional["PatternEngine"] = None
        self._co_articulator: Optional["CoArticulator"] = None
        self._pattern_rules: List[PatternRule] = []
        self._last_picker_turn: int = -MIN_TURNS_BETWEEN_PICKER
        self._current_turn: int = 0

    # ── Configuration ────────────────────────────────────────────────

    def set_pattern_engine(self, engine: "PatternEngine") -> None:
        """Attach the pattern generalization engine (Phase 2)."""
        self._pattern_engine = engine

    def set_co_articulator(self, articulator: "CoArticulator") -> None:
        """Attach the co-articulation module (Phase 3)."""
        self._co_articulator = articulator

    # ── Core API ─────────────────────────────────────────────────────

    def scan(
        self,
        user_msg: str,
        ai_response: str,
        session_ledger: Optional["SessionLedger"] = None,
        turn_number: int = 0,
    ) -> Optional[SentinelSignal]:
        """Run all three arms and return the highest-confidence signal.

        Called AFTER dusun() T1 Boot but BEFORE response is finalized.
        Fire-and-forget: exceptions in any arm are swallowed (KV₃).

        Returns None if no arm detects anything above the noise floor.
        """
        try:
            self._current_turn = turn_number
            signals: List[SentinelSignal] = []

            # Arm 1: Reactive (user complaint detection)
            sig = self._reactive.scan(user_msg)
            if sig:
                signals.append(sig)

            # Arm 2: Proactive (self-diagnosis heuristics)
            sig = self._proactive.scan(ai_response, session_ledger, turn_number)
            if sig:
                signals.append(sig)

            # Arm 3: Structural (pattern registry match)
            anomaly_history = None
            if session_ledger:
                try:
                    anomaly_history = session_ledger.get_anomaly_history()
                except Exception:
                    pass
            sig = self._structural.scan(
                user_msg, ai_response, self._pattern_rules, anomaly_history,
            )
            if sig:
                signals.append(sig)

            if not signals:
                return None

            # Convergence: independent arms agreeing → boost confidence
            best = self._merge_signals(signals)
            return best if best.confidence > NOISE_FLOOR else None

        except Exception:
            logger.debug("SentinelEngine.scan swallowed error", exc_info=True)
            return None

    def proactive_scan(
        self,
        ai_response: str,
        session_ledger: Optional["SessionLedger"] = None,
        turn_number: int = 0,
    ) -> Optional[SentinelSignal]:
        """Run only the proactive arm.  Used for Step 7.5 in dusun()."""
        try:
            self._current_turn = turn_number
            return self._proactive.scan(ai_response, session_ledger, turn_number)
        except Exception:
            logger.debug("SentinelEngine.proactive_scan swallowed", exc_info=True)
            return None

    def report_incident(
        self,
        taxonomy_id: str = "",
        description: str = "",
        signal: Optional[SentinelSignal] = None,
        turn_number: int = 0,
    ) -> Optional[Incident]:
        """Record a user-classified incident.

        Called when user selects from picker or provides free text.
        Returns the created Incident (or None on failure).
        """
        try:
            tax_id = taxonomy_id or (signal.taxonomy_id if signal else "FM-11")
            if tax_id not in ALL_TAXONOMY:
                tax_id = "FM-11"  # Default to Polanyi's Paradox

            inc = Incident(
                id=self._registry.next_incident_id(),
                taxonomy_id=tax_id,
                arm=DetectionArm.REACTIVE if not signal else signal.arm,
                confidence=0.85 if taxonomy_id else 0.75,
                basis=ConfidenceBasis.USER_CONFIRMED,
                severity=Severity.WARNING,
                context_snippet=signal.context_snippet if signal else "",
                user_description=description,
                session_turn=turn_number or self._current_turn,
            )
            self._registry.record(inc)

            # Pattern generalization (Phase 2)
            if self._pattern_engine:
                try:
                    rule = self._pattern_engine.generalize(inc)
                    if rule:
                        inc.generalized = True
                        inc.pattern_rule_id = rule.id
                        if rule not in self._pattern_rules:
                            self._pattern_rules.append(rule)
                except Exception:
                    logger.debug("Pattern generalization swallowed", exc_info=True)

            # Co-articulation (Phase 3)
            if self._co_articulator:
                try:
                    self._co_articulator.update_from_incident(inc)
                except Exception:
                    logger.debug("Co-articulation update swallowed", exc_info=True)

            return inc

        except Exception:
            logger.debug("SentinelEngine.report_incident swallowed", exc_info=True)
            return None

    def should_show_picker(self, turn_number: int) -> bool:
        """Whether the picker should be shown (anti-annoyance gate)."""
        return (turn_number - self._last_picker_turn) >= MIN_TURNS_BETWEEN_PICKER

    def mark_picker_shown(self, turn_number: int) -> None:
        """Record that the picker was shown at this turn."""
        self._last_picker_turn = turn_number

    def get_picker_options(self) -> List[Dict[str, str]]:
        """Return picker options, possibly enhanced by co-articulation."""
        try:
            if self._co_articulator:
                enhanced = self._co_articulator.get_enhanced_picker_options()
            else:
                enhanced = list(PICKER_OPTIONS)

            return [
                {"id": tax_id, "label": text}
                for tax_id, text in enhanced
            ]
        except Exception:
            return [
                {"id": tax_id, "label": text}
                for tax_id, text in PICKER_OPTIONS
            ]

    # ── Query API ────────────────────────────────────────────────────

    @property
    def registry(self) -> IncidentRegistry:
        """Access the incident registry (read-only intent)."""
        return self._registry

    @property
    def incident_count(self) -> int:
        """Total incidents recorded."""
        return self._registry.incident_count

    @property
    def pattern_rules(self) -> List[PatternRule]:
        """All active pattern rules."""
        return list(self._pattern_rules)

    def get_stats(self) -> Dict[str, Any]:
        """Return diagnostic statistics."""
        return {
            "incident_count": self._registry.incident_count,
            "pattern_rule_count": len(self._pattern_rules),
            "active_rule_count": sum(1 for r in self._pattern_rules if r.active),
            "frequency_report": self._registry.get_frequency_report(),
            "recurrence_patterns": self._registry.get_recurrence_patterns(),
            "current_turn": self._current_turn,
            "last_picker_turn": self._last_picker_turn,
        }

    # ── Signal Merge ─────────────────────────────────────────────────

    def _merge_signals(self, signals: List[SentinelSignal]) -> SentinelSignal:
        """Merge multiple signals using confidence-weighted voting.

        If multiple arms agree on the same taxonomy_id:
          - Confidence = average + 0.1 boost (capped at CONFIDENCE_CEILING)
          - Basis = COMBINED
          - Severity escalated: 2 agree → WARNING, 3 agree → CRITICAL
        """
        # Group by taxonomy_id
        groups: Dict[str, List[SentinelSignal]] = {}
        for s in signals:
            groups.setdefault(s.taxonomy_id, []).append(s)

        best: Optional[SentinelSignal] = None
        best_score = 0.0

        for tax_id, group in groups.items():
            if len(group) >= 2:
                avg_conf = min(
                    sum(s.confidence for s in group) / len(group) + 0.1,
                    CONFIDENCE_CEILING,
                )
                merged = SentinelSignal(
                    taxonomy_id=tax_id,
                    arm=DetectionArm.STRUCTURAL,  # Combined signals
                    confidence=avg_conf,
                    basis=ConfidenceBasis.COMBINED,
                    severity=Severity.WARNING if len(group) == 2 else Severity.CRITICAL,
                    context_snippet=group[0].context_snippet,
                    suggested_label=group[0].suggested_label,
                )
                if avg_conf > best_score:
                    best = merged
                    best_score = avg_conf
            else:
                s = group[0]
                if s.confidence > best_score:
                    best = s
                    best_score = s.confidence

        # guaranteed non-None because signals is non-empty
        assert best is not None
        return best

    # ── Persistence ──────────────────────────────────────────────────

    def export_state(self) -> Dict[str, Any]:
        """Export full sentinel state for persistence."""
        state = SentinelState(
            incidents=[i.to_dict() for i in self._registry.incidents],
            pattern_rules=[r.to_dict() for r in self._pattern_rules],
            co_suggestions=(
                [s.to_dict() for s in self._co_articulator.suggestions]
                if self._co_articulator else []
            ),
            session_stats=self.get_stats(),
            saved_at=datetime.now(timezone.utc).isoformat(),
        )
        return state.to_dict()

    def import_state(self, data: Dict[str, Any]) -> None:
        """Restore from persisted state.  KV₃: corrupt data silently skipped."""
        try:
            version = data.get("schema_version", 1)
            if version != 1:
                logger.warning("Unsupported sentinel schema version: %s", version)
                return

            # Restore registry
            registry_data = {
                "schema_version": 1,
                "incidents": data.get("incidents", []),
                "seq": len(data.get("incidents", [])),
            }
            self._registry.import_state(registry_data)

            # Restore pattern rules
            self._pattern_rules = []
            for raw in data.get("pattern_rules", []):
                try:
                    self._pattern_rules.append(PatternRule.from_dict(raw))
                except Exception:
                    continue

            # Restore co-articulation state
            if self._co_articulator and data.get("co_suggestions"):
                try:
                    self._co_articulator.import_state(data["co_suggestions"])
                except Exception:
                    pass

            # Restore stats
            stats = data.get("session_stats", {})
            self._last_picker_turn = stats.get("last_picker_turn", -MIN_TURNS_BETWEEN_PICKER)
            self._current_turn = stats.get("current_turn", 0)

        except Exception:
            logger.debug("SentinelEngine.import_state swallowed error", exc_info=True)


# ── Singleton ────────────────────────────────────────────────────────

_sentinel_instance: Optional[SentinelEngine] = None


def get_sentinel() -> SentinelEngine:
    """Return the global SentinelEngine singleton.

    Lazy-initialized on first call.  Same pattern as ``get_ledger()``.
    """
    global _sentinel_instance
    if _sentinel_instance is None:
        _sentinel_instance = SentinelEngine()
        # Attach optional modules
        try:
            from sentinel.patterns import PatternEngine
            _sentinel_instance.set_pattern_engine(PatternEngine())
        except Exception:
            logger.debug("PatternEngine not available", exc_info=True)
        try:
            from sentinel.articulation import CoArticulator
            _sentinel_instance.set_co_articulator(CoArticulator())
        except Exception:
            logger.debug("CoArticulator not available", exc_info=True)
    return _sentinel_instance


def reset_sentinel() -> None:
    """Reset the singleton.  Used in testing."""
    global _sentinel_instance
    _sentinel_instance = None
