"""
tests/test_sentinel_engine.py — Unit tests for sentinel.engine (SentinelEngine).

Coverage:
  - Singleton (get_sentinel / reset_sentinel)
  - scan() — 3-arm merge, returns Optional[SentinelSignal]
  - proactive_scan() — fire-and-forget wrapper
  - report_incident() — creates Incident + pattern generalisation
  - should_show_picker() — anti-annoyance (MIN_TURNS_BETWEEN_PICKER)
  - get_picker_options() — returns List[Dict[str,str]]
  - _merge_signals — convergence boost, returns SINGLE SentinelSignal
  - export_state / import_state round-trip
  - get_stats() — 'incident_count' key
"""

from __future__ import annotations

import unittest
from unittest.mock import MagicMock

from sentinel.engine import SentinelEngine, get_sentinel, reset_sentinel
from sentinel.types import (
    CONFIDENCE_CEILING,
    ConfidenceBasis,
    DetectionArm,
    Incident,
    SentinelSignal,
    Severity,
)
from sentinel.taxonomy import ALL_TAXONOMY


class TestSingleton(unittest.TestCase):
    """get_sentinel / reset_sentinel."""

    def test_singleton_identity(self):
        reset_sentinel()
        s1 = get_sentinel()
        s2 = get_sentinel()
        self.assertIs(s1, s2)

    def test_reset_creates_new(self):
        s1 = get_sentinel()
        reset_sentinel()
        s2 = get_sentinel()
        self.assertIsNot(s1, s2)


class TestScan(unittest.TestCase):
    """SentinelEngine.scan() coordination."""

    def setUp(self):
        reset_sentinel()
        self.engine = get_sentinel()

    def _mock_ledger(self, call_count=0):
        ledger = MagicMock()
        ledger.call_count = call_count
        ledger.calls = []
        ledger.get_anomaly_history.return_value = []
        return ledger

    def test_scan_clean_text(self):
        # call_count=0 → avoids H2 (constraint amnesia) trigger
        result = self.engine.scan(
            user_msg="Hello world",
            ai_response="Normal helpful response with no issues.",
            session_ledger=self._mock_ledger(call_count=0),
            turn_number=1,
        )
        # Returns Optional[SentinelSignal]
        self.assertTrue(result is None or isinstance(result, SentinelSignal))

    def test_scan_with_reactive_trigger(self):
        result = self.engine.scan(
            user_msg="that's wrong, you're making things up",
            ai_response="I apologize for the confusion.",
            session_ledger=self._mock_ledger(call_count=0),
            turn_number=1,
        )
        if result is not None:
            self.assertIsInstance(result, SentinelSignal)
            self.assertIn(result.taxonomy_id, ALL_TAXONOMY)
            self.assertLessEqual(result.confidence, CONFIDENCE_CEILING)

    def test_scan_without_ledger(self):
        """scan() must work even without a session ledger."""
        result = self.engine.scan(
            user_msg="test",
            ai_response="test",
            session_ledger=None,
            turn_number=1,
        )
        self.assertTrue(result is None or isinstance(result, SentinelSignal))


class TestProactiveScan(unittest.TestCase):
    """SentinelEngine.proactive_scan() for dusun.py Step 7.5."""

    def setUp(self):
        reset_sentinel()
        self.engine = get_sentinel()

    def _mock_ledger(self, call_count=0):
        ledger = MagicMock()
        ledger.call_count = call_count
        ledger.calls = []
        ledger.get_anomaly_history.return_value = []
        return ledger

    def test_returns_signal_or_none(self):
        result = self.engine.proactive_scan(
            ai_response="Clean analysis response.",
            session_ledger=self._mock_ledger(call_count=0),
            turn_number=3,
        )
        self.assertTrue(result is None or isinstance(result, SentinelSignal))

    def test_fire_and_forget(self):
        """proactive_scan must never raise, even with bad inputs."""
        result = self.engine.proactive_scan(
            ai_response="test",
            session_ledger=None,
            turn_number=0,
        )
        self.assertTrue(result is None or isinstance(result, SentinelSignal))


class TestReportIncident(unittest.TestCase):
    """SentinelEngine.report_incident()."""

    def setUp(self):
        reset_sentinel()
        self.engine = get_sentinel()

    def test_creates_incident(self):
        incident = self.engine.report_incident(
            taxonomy_id="CD-1",
            description="Context window exhausted",
        )
        self.assertIsInstance(incident, Incident)
        self.assertEqual(incident.taxonomy_id, "CD-1")
        self.assertTrue(incident.id.startswith("INC-"))

    def test_increments_count(self):
        self.assertEqual(self.engine.incident_count, 0)
        self.engine.report_incident(taxonomy_id="FM-1", description="test")
        self.assertEqual(self.engine.incident_count, 1)
        self.engine.report_incident(taxonomy_id="FM-2", description="test2")
        self.assertEqual(self.engine.incident_count, 2)

    def test_confidence_with_taxonomy_id(self):
        """taxonomy_id given → confidence = 0.85."""
        inc = self.engine.report_incident(taxonomy_id="CD-1", description="test")
        self.assertEqual(inc.confidence, 0.85)

    def test_confidence_without_taxonomy_id(self):
        """taxonomy_id empty → confidence = 0.75, falls back to FM-11."""
        inc = self.engine.report_incident(description="test")
        self.assertEqual(inc.confidence, 0.75)


class TestPickerLogic(unittest.TestCase):
    """should_show_picker() anti-annoyance."""

    def setUp(self):
        reset_sentinel()
        self.engine = get_sentinel()

    def test_show_initially(self):
        """Picker should show on turn 1 (last_picker_turn starts at -MIN_TURNS)."""
        self.assertTrue(self.engine.should_show_picker(turn_number=1))

    def test_get_picker_options_non_empty(self):
        options = self.engine.get_picker_options()
        self.assertIsInstance(options, list)
        self.assertGreater(len(options), 0)

    def test_picker_options_format(self):
        """Each option is a dict with 'id' and 'label' keys."""
        options = self.engine.get_picker_options()
        for opt in options:
            self.assertIsInstance(opt, dict)
            self.assertIn("id", opt)
            self.assertIn("label", opt)
            self.assertIn(opt["id"], ALL_TAXONOMY)


class TestMergeSignals(unittest.TestCase):
    """_merge_signals convergence logic."""

    def setUp(self):
        reset_sentinel()
        self.engine = get_sentinel()

    def test_convergence_boost(self):
        """If 2+ arms agree on same taxonomy_id, confidence gets boosted."""
        sig1 = SentinelSignal(
            taxonomy_id="CD-1",
            arm=DetectionArm.REACTIVE,
            confidence=0.5,
            basis=ConfidenceBasis.HEURISTIC,
        )
        sig2 = SentinelSignal(
            taxonomy_id="CD-1",
            arm=DetectionArm.PROACTIVE,
            confidence=0.6,
            basis=ConfidenceBasis.HEURISTIC,
        )
        merged = self.engine._merge_signals([sig1, sig2])
        # _merge_signals returns a SINGLE SentinelSignal (not list)
        self.assertIsInstance(merged, SentinelSignal)
        # Merged confidence should be higher than individual
        self.assertGreater(merged.confidence, 0.5)
        # But still under ceiling
        self.assertLessEqual(merged.confidence, CONFIDENCE_CEILING)

    def test_different_taxonomy_picks_best(self):
        """With different taxonomy_ids, merge picks the highest confidence."""
        sig1 = SentinelSignal(
            taxonomy_id="CD-1",
            arm=DetectionArm.REACTIVE,
            confidence=0.5,
            basis=ConfidenceBasis.HEURISTIC,
        )
        sig2 = SentinelSignal(
            taxonomy_id="FM-1",
            arm=DetectionArm.PROACTIVE,
            confidence=0.6,
            basis=ConfidenceBasis.HEURISTIC,
        )
        merged = self.engine._merge_signals([sig1, sig2])
        # Returns single SentinelSignal — the one with highest confidence
        self.assertIsInstance(merged, SentinelSignal)

    def test_single_signal_passthrough(self):
        """Single signal → returned as-is."""
        sig = SentinelSignal(
            taxonomy_id="CD-1",
            arm=DetectionArm.REACTIVE,
            confidence=0.5,
            basis=ConfidenceBasis.HEURISTIC,
        )
        merged = self.engine._merge_signals([sig])
        self.assertIsInstance(merged, SentinelSignal)
        self.assertEqual(merged.taxonomy_id, "CD-1")


class TestExportImport(unittest.TestCase):
    """State persistence round-trip."""

    def setUp(self):
        reset_sentinel()
        self.engine = get_sentinel()

    def test_empty_export(self):
        state = self.engine.export_state()
        self.assertIsInstance(state, dict)

    def test_roundtrip(self):
        self.engine.report_incident(taxonomy_id="CD-1", description="lost context")
        self.engine.report_incident(taxonomy_id="FM-3", description="hallucinated")
        state = self.engine.export_state()

        reset_sentinel()
        engine2 = get_sentinel()
        engine2.import_state(state)
        self.assertEqual(engine2.incident_count, 2)


class TestGetStats(unittest.TestCase):
    """get_stats() diagnostic report."""

    def setUp(self):
        reset_sentinel()
        self.engine = get_sentinel()

    def test_stats_structure(self):
        stats = self.engine.get_stats()
        self.assertIn("incident_count", stats)
        self.assertEqual(stats["incident_count"], 0)

    def test_stats_after_incidents(self):
        self.engine.report_incident(taxonomy_id="CD-1", description="a")
        stats = self.engine.get_stats()
        self.assertEqual(stats["incident_count"], 1)


if __name__ == "__main__":
    unittest.main()
