"""
tests/test_v055.py
==================
Test suite for llm-guard-kit v0.55.0 additions:

1. SelfHealer: CONFLICTING_EVIDENCE + ANSWER_UNSUPPORTED repair strategies
2. MechanismFeatureExtractor: fit_domain_calibration() + predict_domain()
3. QppgMonitor: add_label() + RL auto-trigger + track_async()
4. FailureTaxonomist: README note consistency (production modes)
"""
import asyncio
import sys
from pathlib import Path
from unittest.mock import MagicMock

import pytest

QPPG_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(QPPG_ROOT))


# ══════════════════════════════════════════════════════════════════════════════
# Helpers
# ══════════════════════════════════════════════════════════════════════════════

def _make_failure_mode(mode: str, signals: dict = None):
    """Create a minimal FailureMode-like object for testing."""
    from qppg_service.failure_taxonomy import FailureMode
    fm = FailureMode.__new__(FailureMode)
    fm.primary_mode  = mode
    fm.explanation   = f"Test: {mode}"
    fm.confidence    = 0.8
    fm.signals       = signals or {}
    fm.secondary_modes = []
    return fm


def _make_steps(n_search: int = 2, mean_sim: float = 0.2):
    steps = []
    for i in range(n_search):
        steps.append({
            "action_type": "Search",
            "action_arg": f"query {i}",
            "observation": f"observation text for step {i}" * 5,
        })
    steps.append({"action_type": "Finish", "action_arg": "final answer", "observation": ""})
    return steps


# ══════════════════════════════════════════════════════════════════════════════
# 1. SelfHealer new repair strategies
# ══════════════════════════════════════════════════════════════════════════════

class TestSelfHealerConflictingEvidence:
    """Tests for RESOLVE_CONFLICT repair action (CONFLICTING_EVIDENCE mode)."""

    def test_experimental_false_returns_no_action(self):
        from qppg_service.self_healer import SelfHealer, NO_ACTION
        healer = SelfHealer(experimental=False)
        fm = _make_failure_mode("CONFLICTING_EVIDENCE", {"obs_contradiction": 0.25})
        with pytest.warns(UserWarning, match="not been externally validated"):
            action = healer.suggest(fm, "What is the capital of France?", _make_steps())
        assert action.action_type == NO_ACTION

    def test_experimental_true_returns_resolve_conflict(self):
        from qppg_service.self_healer import SelfHealer, RESOLVE_CONFLICT
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("CONFLICTING_EVIDENCE", {"obs_contradiction": 0.25})
        action = healer.suggest(fm, "What is the capital of France?", _make_steps())
        assert action.action_type == RESOLVE_CONFLICT

    def test_resolve_conflict_prompt_mentions_contradiction(self):
        from qppg_service.self_healer import SelfHealer
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("CONFLICTING_EVIDENCE", {"obs_contradiction": 0.30})
        action = healer.suggest(fm, "Who discovered penicillin?", _make_steps())
        assert "contradictory" in action.prompt_injection.lower() or "conflict" in action.prompt_injection.lower()
        assert "0.30" in action.prompt_injection

    def test_resolve_conflict_urgency_medium(self):
        from qppg_service.self_healer import SelfHealer
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("CONFLICTING_EVIDENCE", {"obs_contradiction": 0.15})
        action = healer.suggest(fm, "Who wrote Hamlet?", _make_steps())
        assert action.urgency == "MEDIUM"

    def test_resolve_conflict_failure_mode_set(self):
        from qppg_service.self_healer import SelfHealer
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("CONFLICTING_EVIDENCE")
        action = healer.suggest(fm, "Test?", _make_steps())
        assert action.failure_mode == "CONFLICTING_EVIDENCE"


class TestSelfHealerAnswerUnsupported:
    """Tests for ANCHOR_TO_EVIDENCE repair action (ANSWER_UNSUPPORTED mode)."""

    def test_experimental_false_returns_no_action(self):
        from qppg_service.self_healer import SelfHealer, NO_ACTION
        healer = SelfHealer(experimental=False)
        fm = _make_failure_mode("ANSWER_UNSUPPORTED", {"answer_sim": 0.10, "answer_gap": 0.85})
        with pytest.warns(UserWarning, match="not been externally validated"):
            action = healer.suggest(fm, "Where was Einstein born?", _make_steps(), "Paris")
        assert action.action_type == NO_ACTION

    def test_experimental_true_returns_anchor_to_evidence(self):
        from qppg_service.self_healer import SelfHealer, ANCHOR_TO_EVIDENCE
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("ANSWER_UNSUPPORTED", {"answer_sim": 0.10, "answer_gap": 0.85})
        action = healer.suggest(fm, "Where was Einstein born?", _make_steps(), "Paris")
        assert action.action_type == ANCHOR_TO_EVIDENCE

    def test_anchor_to_evidence_includes_answer_and_question(self):
        from qppg_service.self_healer import SelfHealer
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("ANSWER_UNSUPPORTED", {"answer_sim": 0.12, "answer_gap": 0.90})
        action = healer.suggest(fm, "What is the speed of light?", _make_steps(), "42 km/h")
        assert "42 km/h" in action.prompt_injection
        assert "speed of light" in action.prompt_injection.lower()

    def test_anchor_to_evidence_urgency_high(self):
        from qppg_service.self_healer import SelfHealer
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("ANSWER_UNSUPPORTED", {"answer_sim": 0.05, "answer_gap": 0.95})
        action = healer.suggest(fm, "Q?", _make_steps(), "A")
        assert action.urgency == "HIGH"

    def test_anchor_to_evidence_zero_signals_graceful(self):
        """Should not crash if signals dict is empty."""
        from qppg_service.self_healer import SelfHealer, ANCHOR_TO_EVIDENCE
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("ANSWER_UNSUPPORTED")  # no signals
        action = healer.suggest(fm, "Q?", _make_steps(), "A")
        assert action.action_type == ANCHOR_TO_EVIDENCE
        assert "0.00" in action.prompt_injection  # default values shown


class TestSelfHealerExperimentalOtherModes:
    """Tests for other experimental modes (PREMATURE_STOP, INSUFFICIENT_EVIDENCE)."""

    def test_premature_stop_returns_additional_search_urgent(self):
        from qppg_service.self_healer import SelfHealer, ADDITIONAL_SEARCH
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("PREMATURE_STOP", {"n_search_steps": 1})
        action = healer.suggest(fm, "Multi-hop: who is the mother of X?", _make_steps(1))
        assert action.action_type == ADDITIONAL_SEARCH
        assert action.urgency == "HIGH"

    def test_insufficient_evidence_returns_additional_search_not_urgent(self):
        from qppg_service.self_healer import SelfHealer, ADDITIONAL_SEARCH
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("INSUFFICIENT_EVIDENCE", {"n_search_steps": 2})
        action = healer.suggest(fm, "What year did X happen?", _make_steps(2))
        assert action.action_type == ADDITIONAL_SEARCH
        assert action.urgency == "MEDIUM"

    def test_unknown_experimental_mode_returns_no_action_with_warning(self):
        from qppg_service.self_healer import SelfHealer, NO_ACTION
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("FUTURE_MODE_XYZ")
        with pytest.warns(UserWarning, match="no repair strategy"):
            action = healer.suggest(fm, "Q?", _make_steps())
        assert action.action_type == NO_ACTION

    def test_action_constants_exported(self):
        from qppg_service.self_healer import (
            REPHRASE_QUERY, CONSOLIDATE, VERIFY_ANSWER, ADDITIONAL_SEARCH,
            FORCE_FINISH, RESOLVE_CONFLICT, ANCHOR_TO_EVIDENCE, NO_ACTION,
        )
        assert RESOLVE_CONFLICT   == "RESOLVE_CONFLICT"
        assert ANCHOR_TO_EVIDENCE == "ANCHOR_TO_EVIDENCE"


# ══════════════════════════════════════════════════════════════════════════════
# 2. MechanismFeatureExtractor domain calibration
# ══════════════════════════════════════════════════════════════════════════════

class TestMFEDomainCalibration:
    """Tests for fit_domain_calibration() and predict_domain()."""

    @pytest.fixture(scope="class")
    def mfe(self):
        from llm_guard.features.mechanism_layer import MechanismFeatureExtractor
        return MechanismFeatureExtractor()

    def _make_pairs(self, n: int = 30):
        """Generate synthetic labeled pairs."""
        pairs = []
        labels = []
        for i in range(n):
            if i % 2 == 0:
                # Correct: answer semantically related to question
                pairs.append({"question": "What is the capital of France?",
                               "answer": "Paris is the capital city of France.",
                               "context": "France is a country in Western Europe."})
                labels.append(0)  # correct
            else:
                # Failure: answer completely unrelated
                pairs.append({"question": "What is the capital of France?",
                               "answer": "The stock market closed higher today.",
                               "context": ""})
                labels.append(1)  # failure
        return pairs, labels

    def test_fit_domain_calibration_returns_self(self, mfe):
        pairs, labels = self._make_pairs(20)
        result = mfe.fit_domain_calibration(pairs, labels)
        assert result is mfe

    def test_domain_clf_set_after_fit(self, mfe):
        pairs, labels = self._make_pairs(20)
        mfe.fit_domain_calibration(pairs, labels)
        assert mfe._domain_clf is not None
        assert mfe._domain_clf_n == 20

    def test_predict_domain_returns_float_in_unit_interval(self, mfe):
        pairs, labels = self._make_pairs(20)
        mfe.fit_domain_calibration(pairs, labels)
        score = mfe.predict_domain("What is the capital of France?", "Paris", "France is in Europe")
        assert isinstance(score, float)
        assert 0.0 <= score <= 1.0

    def test_predict_domain_unfitted_uses_heuristic(self):
        from llm_guard.features.mechanism_layer import MechanismFeatureExtractor
        fresh_mfe = MechanismFeatureExtractor()
        score = fresh_mfe.predict_domain("Is the sky blue?", "Yes, it is blue.", "")
        # Should not crash; should return a float in [0,1]
        assert isinstance(score, float)
        assert 0.0 <= score <= 1.0

    def test_fit_raises_on_length_mismatch(self, mfe):
        with pytest.raises(ValueError, match="same length"):
            mfe.fit_domain_calibration(
                [{"question": "Q?", "answer": "A."}],
                [0, 1],  # mismatched
            )

    def test_fit_raises_on_too_few_samples(self, mfe):
        with pytest.raises(ValueError, match="At least 10"):
            mfe.fit_domain_calibration(
                [{"question": "Q?", "answer": "A."}] * 5,
                [0] * 5,
            )

    def test_fit_domain_calibration_discriminates(self, mfe):
        """After calibration, related answers should score lower than random noise."""
        pairs, labels = self._make_pairs(40)
        mfe.fit_domain_calibration(pairs, labels)
        related_score = mfe.predict_domain(
            "What is the capital of France?",
            "Paris is the capital of France.",
            "France is in Europe."
        )
        noise_score = mfe.predict_domain(
            "What is the capital of France?",
            "The economy grew by 3% last quarter according to analysts.",
            ""
        )
        # noise (off-topic) answer should have higher failure probability
        assert noise_score >= related_score  # noise should score higher

    def test_predict_domain_no_context(self, mfe):
        """predict_domain should work with empty context string."""
        pairs, labels = self._make_pairs(20)
        mfe.fit_domain_calibration(pairs, labels)
        score = mfe.predict_domain("What is the answer?", "42")
        assert isinstance(score, float)


# ══════════════════════════════════════════════════════════════════════════════
# 3. QppgMonitor: add_label + RL auto-trigger + track_async
# ══════════════════════════════════════════════════════════════════════════════

class TestQppgMonitorAddLabel:
    """Tests for add_label() method and RL auto-trigger."""

    @pytest.fixture
    def monitor(self):
        from qppg_service.monitor import QppgMonitor
        return QppgMonitor(threshold=0.65, auto_calibrate=False)

    @pytest.fixture
    def monitor_with_adaptation(self):
        from qppg_service.monitor import QppgMonitor
        return QppgMonitor(
            threshold=0.65,
            auto_calibrate=False,
            enable_domain_threshold_adaptation=True,
            domain_adapt_every=5,
        )

    def test_add_label_accumulates_in_pool(self, monitor):
        steps = _make_steps(2)
        monitor.add_label("Q?", steps, "A.", correct=True)
        monitor.add_label("Q2?", steps, "A2.", correct=False)
        assert len(monitor._labeled_pool) == 2

    def test_add_label_stores_correct_field(self, monitor):
        steps = _make_steps(1)
        monitor.add_label("Q?", steps, "A.", correct=True, domain="hotpotqa")
        entry = monitor._labeled_pool[0]
        assert entry["correct"] is True
        assert entry["domain"] == "hotpotqa"

    def test_add_label_default_domain(self, monitor):
        steps = _make_steps(1)
        monitor.domain = "my_domain"
        monitor.add_label("Q?", steps, "A.", correct=False)
        assert monitor._labeled_pool[0]["domain"] == "my_domain"

    def test_add_label_returns_none_when_adaptation_disabled(self, monitor):
        steps = _make_steps(1)
        result = monitor.add_label("Q?", steps, "A.", correct=True)
        assert result is None

    def test_add_label_no_auto_trigger_below_threshold(self, monitor_with_adaptation):
        steps = _make_steps(1)
        # domain_adapt_every=5, add 4 labels — should not trigger
        for i in range(4):
            monitor_with_adaptation.add_label(f"Q{i}?", steps, f"A{i}.", correct=bool(i % 2))
        assert monitor_with_adaptation._n_since_last_domain_adapt == 4

    def test_add_label_triggers_at_threshold(self, monitor_with_adaptation):
        """At domain_adapt_every=5, the 5th label should trigger fit_domain_thresholds."""
        steps = _make_steps(2)
        # Add 4 chains (4 labels)
        for i in range(4):
            monitor_with_adaptation.add_label(
                f"Q{i}?", steps, f"A{i}.",
                correct=(i % 2 == 0),
                domain="hotpotqa",
            )
        assert monitor_with_adaptation._n_since_last_domain_adapt == 4

        # 5th label triggers adaptation
        # fit_domain_thresholds requires 50 chains per domain; with 5 it returns 'skipped'
        result = monitor_with_adaptation.add_label(
            "Q5?", steps, "A5.", correct=True, domain="hotpotqa"
        )
        # Counter reset
        assert monitor_with_adaptation._n_since_last_domain_adapt == 0
        # Result is a summary dict (may be empty or skipped due to min chains)
        assert result is not None and isinstance(result, dict)

    def test_taxonomy_initialized_when_adaptation_enabled(self, monitor_with_adaptation):
        assert monitor_with_adaptation._taxonomy is not None


class TestQppgMonitorTrackAsync:
    """Tests for track_async()."""

    def test_track_async_returns_none_for_low_risk(self):
        from qppg_service.monitor import QppgMonitor
        monitor = QppgMonitor(threshold=0.65, auto_calibrate=False)
        steps = [
            {"action_type": "Search", "action_arg": "Paris capital France",
             "observation": "Paris is the capital of France."},
            {"action_type": "Finish", "action_arg": "Paris", "observation": ""},
        ]

        async def run():
            return await monitor.track_async(
                "What is the capital of France?", steps, "Paris", finished=True
            )

        result = asyncio.run(run())
        # Low-risk chain should return None (no alert)
        assert result is None

    def test_track_async_result_consistent_with_sync(self):
        """track_async should produce the same result as track for the same input."""
        from qppg_service.monitor import QppgMonitor
        monitor = QppgMonitor(threshold=0.65, auto_calibrate=False)
        steps = [
            {"action_type": "Search", "action_arg": "q1", "observation": "o1"},
            {"action_type": "Search", "action_arg": "q2", "observation": "o2"},
            {"action_type": "Finish", "action_arg": "ans", "observation": ""},
        ]
        question = "What is X?"
        answer = "X is Y."

        sync_alert = monitor.track(question, steps, answer, finished=True)

        async def run_async():
            return await monitor.track_async(question, steps, answer, finished=True)

        async_alert = asyncio.run(run_async())

        # Both should either be None or both be QppgAlert
        assert type(sync_alert) == type(async_alert)

    def test_track_async_is_coroutine(self):
        """track_async must return a coroutine (awaitable)."""
        import inspect
        from qppg_service.monitor import QppgMonitor
        monitor = QppgMonitor(threshold=0.65, auto_calibrate=False)
        coro = monitor.track_async("Q?", [], "A.", finished=True)
        assert inspect.iscoroutine(coro)
        # Clean up the coroutine
        coro.close()

    def test_track_async_metadata_passed(self):
        from qppg_service.monitor import QppgMonitor
        monitor = QppgMonitor(threshold=0.65, auto_calibrate=False)
        steps = [{"action_type": "Finish", "action_arg": "X", "observation": ""}]
        metadata = {"run_id": "test-123"}

        async def run():
            return await monitor.track_async(
                "Q?", steps, "X", finished=True, metadata=metadata
            )

        asyncio.run(run())
        # Check metadata is in history
        assert monitor._history[-1]["metadata"]["run_id"] == "test-123"


# ══════════════════════════════════════════════════════════════════════════════
# 4. FailureTaxonomist production modes
# ══════════════════════════════════════════════════════════════════════════════

class TestFailureTaxonomistProductionModes:
    """Smoke tests that production mode includes LOW_RISK and EXCESSIVE_SEARCH."""

    def test_production_mode_emits_excessive_search(self):
        """EXCESSIVE_SEARCH should fire in production mode (non-experimental)."""
        from qppg_service.failure_taxonomy import FailureTaxonomist
        tx = FailureTaxonomist(experimental=False)
        steps = [
            {"action_type": "Search", "action_arg": f"query {i}",
             "observation": f"obs {i}" * 3}
            for i in range(6)  # 6 > 4 threshold
        ]
        steps.append({"action_type": "Finish", "action_arg": "ans", "observation": ""})
        fm = tx.classify("Who wrote Hamlet?", steps, "Shakespeare", finished=True)
        assert fm.primary_mode in ("EXCESSIVE_SEARCH", "LOW_RISK", "RETRIEVAL_FAILURE")

    def test_production_mode_emits_low_risk_for_clean_chain(self):
        """A clean short chain should be classified as LOW_RISK."""
        from qppg_service.failure_taxonomy import FailureTaxonomist
        tx = FailureTaxonomist(experimental=False)
        steps = [
            {"action_type": "Search", "action_arg": "capital France",
             "observation": "Paris is the capital of France."},
            {"action_type": "Finish", "action_arg": "Paris", "observation": ""},
        ]
        fm = tx.classify("What is the capital of France?", steps, "Paris", finished=True)
        assert fm.primary_mode == "LOW_RISK"

    def test_experimental_mode_has_more_modes_available(self):
        """Experimental mode should provide modes that production doesn't."""
        from qppg_service.failure_taxonomy import FailureTaxonomist
        tx_prod = FailureTaxonomist(experimental=False)
        tx_exp  = FailureTaxonomist(experimental=True)

        # Both should be able to produce LOW_RISK
        steps = [
            {"action_type": "Search", "action_arg": "q",
             "observation": "clear relevant result"},
            {"action_type": "Finish", "action_arg": "ans", "observation": ""},
        ]
        fm_prod = tx_prod.classify("Q?", steps, "ans", finished=True)
        fm_exp  = tx_exp.classify("Q?",  steps, "ans", finished=True)

        # Both produce a valid primary_mode
        valid_modes = {
            "LOW_RISK", "RETRIEVAL_FAILURE", "EXCESSIVE_SEARCH",
            "CONFLICTING_EVIDENCE", "INSUFFICIENT_EVIDENCE",
            "ANSWER_UNSUPPORTED", "PREMATURE_STOP",
        }
        assert fm_prod.primary_mode in valid_modes
        assert fm_exp.primary_mode  in valid_modes

    def test_signals_include_new_v054_keys(self):
        """v0.54 signals (answer_sim, obs_contradiction, is_multi_hop) should be present."""
        from qppg_service.failure_taxonomy import FailureTaxonomist
        tx = FailureTaxonomist(experimental=True)
        steps = [
            {"action_type": "Search", "action_arg": "query",
             "observation": "relevant observation text here"},
            {"action_type": "Finish", "action_arg": "answer", "observation": ""},
        ]
        fm = tx.classify("What is X?", steps, "answer", finished=True)
        assert "answer_sim"       in fm.signals
        assert "obs_contradiction" in fm.signals
        assert "is_multi_hop"     in fm.signals


# ══════════════════════════════════════════════════════════════════════════════
# 5. SelfHealer to_dict includes new action types
# ══════════════════════════════════════════════════════════════════════════════

class TestSelfHealerToDict:
    def test_resolve_conflict_to_dict(self):
        from qppg_service.self_healer import SelfHealer
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("CONFLICTING_EVIDENCE", {"obs_contradiction": 0.2})
        action = healer.suggest(fm, "Q?", _make_steps())
        d = action.to_dict()
        assert d["action_type"] == "RESOLVE_CONFLICT"
        assert d["failure_mode"] == "CONFLICTING_EVIDENCE"
        assert "urgency" in d

    def test_anchor_to_evidence_to_dict(self):
        from qppg_service.self_healer import SelfHealer
        healer = SelfHealer(experimental=True)
        fm = _make_failure_mode("ANSWER_UNSUPPORTED", {"answer_sim": 0.1, "answer_gap": 0.9})
        action = healer.suggest(fm, "Q?", _make_steps(), "the answer")
        d = action.to_dict()
        assert d["action_type"] == "ANCHOR_TO_EVIDENCE"
        assert d["urgency"] == "HIGH"
