"""sentinel/detection.py — Three-arm detection engine.

ARM 1: REACTIVE  — User explicitly signals a problem (complaint regex).
ARM 2: PROACTIVE — Heuristic self-diagnosis (H1–H6, no LLM calls).
ARM 3: STRUCTURAL— Pattern-match from the generalized rule registry.

Design constraints:
  KV₃: Fire-and-forget — every arm swallows exceptions.
  KV₇: Arms share no mutable state; each reads independently.
  Cost: Total proactive budget ~35 token-equivalent computation, 0 LLM calls.
"""

from __future__ import annotations

import logging
import re
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING

from sentinel.taxonomy import (
    ALL_TAXONOMY,
    HEDGING_PHRASES,
    PROACTIVE_SIGNALS,
    REACTIVE_TRIGGERS,
    SYCOPHANCY_PHRASES,
)
from sentinel.types import (
    CONFIDENCE_CEILING,
    NOISE_FLOOR,
    ConfidenceBasis,
    DetectionArm,
    PatternRule,
    SentinelSignal,
    Severity,
)

if TYPE_CHECKING:
    from fw_server.session import SessionLedger

logger = logging.getLogger(__name__)


# ═══════════════════════════════════════════════════════════════════════
# ARM 1: REACTIVE
# ═══════════════════════════════════════════════════════════════════════

class ReactiveArm:
    """Detects explicit user complaints via regex pattern matching.

    Input:  User message text.
    Output: SentinelSignal with arm=REACTIVE, or None.
    Cost:   0 tokens (pure regex on user input).
    """

    def scan(self, user_msg: str) -> Optional[SentinelSignal]:
        """Scan user message for complaint phrases.

        Fire-and-forget: never raises (KV₃).
        """
        try:
            if not user_msg or not isinstance(user_msg, str):
                return None

            msg_lower = user_msg.lower()

            for pattern, tax_id in REACTIVE_TRIGGERS:
                try:
                    if re.search(pattern, msg_lower, re.IGNORECASE):
                        name = ALL_TAXONOMY.get(tax_id, "Unknown")
                        snippet = user_msg[:200]
                        return SentinelSignal(
                            taxonomy_id=tax_id,
                            arm=DetectionArm.REACTIVE,
                            confidence=0.80,
                            basis=ConfidenceBasis.HEURISTIC,
                            severity=Severity.WARNING,
                            context_snippet=snippet,
                            suggested_label=name,
                        )
                except re.error:
                    continue

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


# ═══════════════════════════════════════════════════════════════════════
# ARM 2: PROACTIVE
# ═══════════════════════════════════════════════════════════════════════

class ProactiveArm:
    """Heuristic self-diagnosis on the AI's own output.

    Six checks (H1–H6), each cheap string/count operations.
    Total budget: ~35 token-equivalent computation, 0 LLM calls.

    Input:  AI draft response + SessionLedger state + turn number.
    Output: SentinelSignal with arm=PROACTIVE, or None.
    """

    def scan(
        self,
        ai_response: str,
        session_ledger: Optional["SessionLedger"] = None,
        turn_number: int = 0,
    ) -> Optional[SentinelSignal]:
        """Run all 6 heuristic checks, return highest-confidence signal.

        Fire-and-forget: never raises (KV₃).
        """
        try:
            if not ai_response or not isinstance(ai_response, str):
                return None

            signals: List[SentinelSignal] = []

            # H1: Repetition check
            sig = self._h1_repetition(ai_response, session_ledger)
            if sig:
                signals.append(sig)

            # H2: Constraint amnesia check
            sig = self._h2_constraint_amnesia(ai_response, session_ledger)
            if sig:
                signals.append(sig)

            # H3: Hedging density check
            sig = self._h3_hedging_density(ai_response)
            if sig:
                signals.append(sig)

            # H4: Sycophancy check
            sig = self._h4_sycophancy(ai_response, session_ledger)
            if sig:
                signals.append(sig)

            # H5: Turn-count degradation model
            sig = self._h5_turn_degradation(turn_number, session_ledger)
            if sig:
                signals.append(sig)

            # H6: Task state check
            sig = self._h6_task_state(ai_response, session_ledger)
            if sig:
                signals.append(sig)

            if not signals:
                return None

            # Return the highest-confidence signal
            return max(signals, key=lambda s: s.confidence)

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

    # ── H1: Repetition Check ────────────────────────────────────────

    def _h1_repetition(
        self,
        ai_response: str,
        ledger: Optional["SessionLedger"],
    ) -> Optional[SentinelSignal]:
        """Compare response to prior 3 responses using 4-gram Jaccard similarity.

        If Jaccard > 0.6 → FM-3 (Mode Collapse).
        """
        try:
            if not ledger or ledger.call_count < 1:
                return None

            current_grams = self._get_ngrams(ai_response, 4)
            if not current_grams:
                return None

            # Check last 3 calls for prior responses
            prior_responses = self._extract_prior_responses(ledger, 3)
            if not prior_responses:
                return None

            max_jaccard = 0.0
            for prior in prior_responses:
                prior_grams = self._get_ngrams(prior, 4)
                if prior_grams:
                    jaccard = self._jaccard_similarity(current_grams, prior_grams)
                    max_jaccard = max(max_jaccard, jaccard)

            if max_jaccard > 0.6:
                conf = min(0.3 + max_jaccard * 0.3, CONFIDENCE_CEILING)
                return SentinelSignal(
                    taxonomy_id="FM-3",
                    arm=DetectionArm.PROACTIVE,
                    confidence=conf,
                    basis=ConfidenceBasis.HEURISTIC,
                    severity=Severity.WARNING,
                    context_snippet=f"4-gram Jaccard similarity: {max_jaccard:.2f}",
                    suggested_label="Mode Collapse — repetitive response",
                )
            return None
        except Exception:
            return None

    # ── H2: Constraint Amnesia Check ─────────────────────────────────

    def _h2_constraint_amnesia(
        self,
        ai_response: str,
        ledger: Optional["SessionLedger"],
    ) -> Optional[SentinelSignal]:
        """Count framework-relevant terms in P1–P4 responses.

        If count is 0 and it should be a framework response → CD-5 (Prompt Drift).
        """
        try:
            if not ledger or ledger.call_count < 1:
                return None

            # Check if the session has prior framework engagement
            anomalies = ledger.get_anomaly_history()
            if not anomalies and ledger.call_count < 2:
                return None  # Too early to judge

            # Count framework terms
            fw_terms = [
                r"\bAX\d+\b", r"\bKV[₁₂₃₄₅₆₇₈]\b", r"\bT\d+\b",
                r"\bP[0-4]\b", r"\bTasdik\b", r"\bTasavvur\b",
                r"\bİlmelyakîn\b", r"\bHakkalyakîn\b",
                r"\bholografik\b", r"\bmereoloji\b", r"\bbileshke\b",
                r"\bkavaid\b", r"\bfidelity\b", r"\bconvergence\b",
            ]
            count = 0
            for term in fw_terms:
                try:
                    if re.search(term, ai_response, re.IGNORECASE):
                        count += 1
                except re.error:
                    continue

            if count == 0 and ledger.call_count >= 3:
                return SentinelSignal(
                    taxonomy_id="CD-5",
                    arm=DetectionArm.PROACTIVE,
                    confidence=0.40,
                    basis=ConfidenceBasis.HEURISTIC,
                    severity=Severity.INFO,
                    context_snippet="0 framework terms in response",
                    suggested_label="Prompt Drift — framework terms absent",
                )
            return None
        except Exception:
            return None

    # ── H3: Hedging Density Check ────────────────────────────────────

    def _h3_hedging_density(
        self,
        ai_response: str,
    ) -> Optional[SentinelSignal]:
        """Count hedging phrases per sentence.

        If ratio > 0.4 → FM-1 warning (Confabulation risk).
        """
        try:
            sentences = [s.strip() for s in re.split(r'[.!?]+', ai_response) if s.strip()]
            if not sentences:
                return None

            hedging_count = 0
            for phrase in HEDGING_PHRASES:
                hedging_count += ai_response.lower().count(phrase.lower())

            ratio = hedging_count / max(len(sentences), 1)

            if ratio > 0.4:
                conf = min(0.30 + ratio * 0.2, CONFIDENCE_CEILING)
                return SentinelSignal(
                    taxonomy_id="FM-1",
                    arm=DetectionArm.PROACTIVE,
                    confidence=conf,
                    basis=ConfidenceBasis.HEURISTIC,
                    severity=Severity.INFO,
                    context_snippet=f"Hedging ratio: {ratio:.2f} ({hedging_count} phrases / {len(sentences)} sentences)",
                    suggested_label="Confabulation risk — high hedging density",
                )
            return None
        except Exception:
            return None

    # ── H4: Sycophancy Check ─────────────────────────────────────────

    def _h4_sycophancy(
        self,
        ai_response: str,
        ledger: Optional["SessionLedger"],
    ) -> Optional[SentinelSignal]:
        """Detect agreement phrases followed by contradiction of prior values.

        If found → FM-2 (Sycophancy).
        """
        try:
            response_lower = ai_response.lower()
            syc_count = sum(
                1 for phrase in SYCOPHANCY_PHRASES
                if phrase.lower() in response_lower
            )

            if syc_count == 0:
                return None

            # Check if there's a prior computed value that's being contradicted
            # Simple heuristic: sycophancy detected if 3+ agreement phrases
            if syc_count >= 3:
                return SentinelSignal(
                    taxonomy_id="FM-2",
                    arm=DetectionArm.PROACTIVE,
                    confidence=min(0.30 + syc_count * 0.1, CONFIDENCE_CEILING),
                    basis=ConfidenceBasis.HEURISTIC,
                    severity=Severity.WARNING,
                    context_snippet=f"{syc_count} agreement phrases detected",
                    suggested_label="Sycophancy — excessive agreement",
                )
            return None
        except Exception:
            return None

    # ── H5: Turn-Count Degradation Model ─────────────────────────────

    def _h5_turn_degradation(
        self,
        turn_number: int,
        ledger: Optional["SessionLedger"],
    ) -> Optional[SentinelSignal]:
        """After turn N, compute expected degradation probability.

        p_degradation(turn) = 1 - (1 / (1 + 0.05 * turn))
        If p > 0.3 and no refresh has occurred → CD-1 warning.
        """
        try:
            if turn_number < 5:
                return None

            p = 1.0 - (1.0 / (1.0 + 0.05 * turn_number))

            if p > 0.3:
                conf = min(p * 0.5, CONFIDENCE_CEILING)
                return SentinelSignal(
                    taxonomy_id="CD-1",
                    arm=DetectionArm.PROACTIVE,
                    confidence=conf,
                    basis=ConfidenceBasis.HEURISTIC,
                    severity=Severity.INFO,
                    context_snippet=f"Turn {turn_number}, p_degradation={p:.2f}",
                    suggested_label="Context Rot — late-turn degradation risk",
                )
            return None
        except Exception:
            return None

    # ── H6: Task State Check ─────────────────────────────────────────

    def _h6_task_state(
        self,
        ai_response: str,
        ledger: Optional["SessionLedger"],
    ) -> Optional[SentinelSignal]:
        """Check for task state drift — addressing completed or skipping in-progress.

        Simplified: checks if response addresses a tool that already ran.
        """
        try:
            if not ledger or ledger.call_count < 2:
                return None

            # Check if AI is re-running a tool that already completed
            tool_seq = [c.tool_name for c in ledger.calls]
            if len(tool_seq) != len(set(tool_seq)):
                # Duplicate tool calls — possible FM-8
                from collections import Counter
                dupes = [t for t, c in Counter(tool_seq).items() if c > 2]
                if dupes:
                    return SentinelSignal(
                        taxonomy_id="FM-8",
                        arm=DetectionArm.PROACTIVE,
                        confidence=0.35,
                        basis=ConfidenceBasis.HEURISTIC,
                        severity=Severity.INFO,
                        context_snippet=f"Tool(s) called 3+ times: {', '.join(dupes)}",
                        suggested_label="State Drift — repeated tool calls",
                    )
            return None
        except Exception:
            return None

    # ── Utility Methods ──────────────────────────────────────────────

    @staticmethod
    def _get_ngrams(text: str, n: int) -> set:
        """Extract character n-grams from text."""
        text = text.lower().strip()
        if len(text) < n:
            return set()
        return {text[i:i + n] for i in range(len(text) - n + 1)}

    @staticmethod
    def _jaccard_similarity(set_a: set, set_b: set) -> float:
        """Compute Jaccard similarity between two sets."""
        if not set_a and not set_b:
            return 0.0
        intersection = len(set_a & set_b)
        union = len(set_a | set_b)
        return intersection / union if union > 0 else 0.0

    @staticmethod
    def _extract_prior_responses(
        ledger: "SessionLedger", n: int
    ) -> List[str]:
        """Extract prior AI response snippets from session ledger.

        Looks at the 'extra' dict of call records for response content.
        """
        responses: List[str] = []
        try:
            calls = ledger.calls
            for call in reversed(calls[-n:]):
                extra = getattr(call, "extra", {}) or {}
                resp = extra.get("ai_response", "") or extra.get("response", "")
                if resp:
                    responses.append(str(resp))
        except Exception:
            pass
        return responses


# ═══════════════════════════════════════════════════════════════════════
# ARM 3: STRUCTURAL
# ═══════════════════════════════════════════════════════════════════════

class StructuralArm:
    """Pattern-match current context against generalized rules from the registry.

    Input:  User message + AI response + list of PatternRules.
    Output: SentinelSignal with arm=STRUCTURAL, or None.

    Cost: regex per rule, bounded by MAX_PATTERN_RULES (100).
    """

    def scan(
        self,
        user_msg: str,
        ai_response: str,
        rules: List[PatternRule],
        anomaly_history: Optional[List[str]] = None,
    ) -> Optional[SentinelSignal]:
        """Match against all active pattern rules.

        Returns highest-confidence match, or None.
        Fire-and-forget: never raises (KV₃).
        """
        try:
            if not rules:
                return None

            combined = f"{user_msg} {ai_response}".lower()
            best_rule: Optional[PatternRule] = None
            best_conf: float = 0.0

            for rule in rules:
                if not rule.active:
                    continue
                if rule.precision() < 0.5:
                    continue  # Too many false positives

                matched = 0
                for trigger in rule.trigger_heuristics:
                    try:
                        if re.search(trigger, combined, re.IGNORECASE):
                            matched += 1
                    except re.error:
                        continue

                if matched == 0:
                    continue

                # Confidence = base * trigger_ratio * precision + modifier
                trigger_ratio = matched / max(len(rule.trigger_heuristics), 1)
                conf = min(
                    0.5 * trigger_ratio * rule.precision() + rule.confidence_modifier,
                    CONFIDENCE_CEILING,
                )

                # Recurrence boost: if same taxonomy appeared in anomaly history
                if anomaly_history and rule.taxonomy_ids:
                    for tax_id in rule.taxonomy_ids:
                        if any(tax_id in a for a in anomaly_history):
                            conf = min(conf + 0.1, CONFIDENCE_CEILING)
                            break

                if conf > best_conf:
                    best_rule = rule
                    best_conf = conf

            if best_rule and best_conf > NOISE_FLOOR:
                # Update hit count on the matched rule
                best_rule.hit_count += 1
                best_rule.last_hit = datetime.now(timezone.utc).isoformat()

                tax_id = best_rule.taxonomy_ids[0] if best_rule.taxonomy_ids else "FM-11"
                return SentinelSignal(
                    taxonomy_id=tax_id,
                    arm=DetectionArm.STRUCTURAL,
                    confidence=best_conf,
                    basis=ConfidenceBasis.PATTERN_MATCH,
                    severity=Severity.WARNING,
                    context_snippet=f"Pattern: {best_rule.name}",
                    suggested_label=best_rule.name,
                    metadata={"pattern_rule_id": best_rule.id},
                )
            return None

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