"""Tests for v0.44.0 engineering hardening."""
import pytest
import sys
from pathlib import Path

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


class TestAdversarialDetectorGate:
    def test_init_without_experimental_raises(self):
        from llm_guard import AdversarialChainDetector
        with pytest.raises(RuntimeError, match="experimental=True"):
            AdversarialChainDetector()

    def test_init_with_experimental_true_succeeds(self):
        from llm_guard import AdversarialChainDetector
        det = AdversarialChainDetector(experimental=True)
        assert not det.is_fitted

    def test_load_default_without_experimental_raises(self):
        from llm_guard import AdversarialChainDetector
        with pytest.raises(RuntimeError, match="experimental=True"):
            AdversarialChainDetector.load_default()

    def test_load_default_with_experimental_true_succeeds(self):
        import warnings
        from llm_guard import AdversarialChainDetector
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", UserWarning)
            det = AdversarialChainDetector.load_default(experimental=True)
        assert det.is_fitted


class TestFailureTaxonomistSimplified:
    QUESTION = "When was the Battle of Hastings?"
    STEPS = [
        {
            "thought": "I should search for this.",
            "action_type": "Search",
            "action_arg": "Battle of Hastings 1066",
            "observation": "The Battle of Hastings was in 1066.",
        },
        {
            "thought": "I have the answer.",
            "action_type": "Finish",
            "action_arg": "1066",
            "observation": "",
        },
    ]

    def test_non_experimental_primary_mode_only_two_values(self):
        from qppg_service import FailureTaxonomist
        tx = FailureTaxonomist()  # experimental=False by default
        result = tx.classify(self.QUESTION, self.STEPS, "1066", True)
        # Production-validated modes (v0.55: EXCESSIVE_SEARCH + LOW_RISK added)
        assert result.primary_mode in {"RETRIEVAL_FAILURE", "EXCESSIVE_SEARCH", "LOW_RISK", "OTHER"}

    def test_non_experimental_all_modes_only_two_values(self):
        from qppg_service import FailureTaxonomist
        tx = FailureTaxonomist()
        result = tx.classify(self.QUESTION, self.STEPS, "1066", True)
        for mode in result.all_modes:
            assert mode in {"RETRIEVAL_FAILURE", "EXCESSIVE_SEARCH", "LOW_RISK", "OTHER"}

    def test_experimental_exposes_full_taxonomy(self):
        from qppg_service import FailureTaxonomist
        tx = FailureTaxonomist(experimental=True)
        # Force EXCESSIVE_SEARCH: 6 search steps > _STEP_HIGH_THRESHOLD=4
        many_steps = [
            {"thought": f"Search {i}", "action_type": "Search",
             "action_arg": f"query {i}", "observation": f"result {i}"}
            for i in range(6)
        ]
        result = tx.classify(self.QUESTION, many_steps, "1066", True)
        possible_modes = {
            "RETRIEVAL_FAILURE", "EXCESSIVE_SEARCH", "CONFLICTING_EVIDENCE",
            "INSUFFICIENT_EVIDENCE", "ANSWER_UNSUPPORTED", "PREMATURE_STOP", "LOW_RISK"
        }
        assert result.primary_mode in possible_modes

    def test_signals_preserved_in_non_experimental(self):
        from qppg_service import FailureTaxonomist
        tx = FailureTaxonomist()
        result = tx.classify(self.QUESTION, self.STEPS, "1066", True)
        assert "mean_sim" in result.signals
        assert "n_search_steps" in result.signals

    def test_simplified_mode_property_consistent(self):
        from qppg_service import FailureTaxonomist
        tx = FailureTaxonomist()
        result = tx.classify(self.QUESTION, self.STEPS, "1066", True)
        # simplified_mode returns validated modes or OTHER (v0.55: 3 validated modes)
        assert result.simplified_mode in {"RETRIEVAL_FAILURE", "EXCESSIVE_SEARCH", "LOW_RISK", "OTHER"}
        # In non-experimental mode, primary_mode == simplified_mode
        assert result.primary_mode == result.simplified_mode


class TestChainTrustResultJudgeModel:
    QUESTION = "When was the Battle of Hastings?"
    STEPS = [
        {"thought": "Search.", "action_type": "Search",
         "action_arg": "Battle Hastings",
         "observation": "Battle of Hastings was fought in 1066."},
        {"thought": "Done.", "action_type": "Finish",
         "action_arg": "1066", "observation": ""},
    ]
    ANSWER = "1066"

    def test_score_chain_judge_model_is_none(self):
        from llm_guard import AgentGuard
        guard = AgentGuard()  # no judge
        result = guard.score_chain(self.QUESTION, self.STEPS, self.ANSWER)
        assert result.judge_model is None

    def test_judge_model_field_exists_on_result(self):
        from llm_guard import AgentGuard
        guard = AgentGuard()
        result = guard.score_chain(self.QUESTION, self.STEPS, self.ANSWER)
        assert hasattr(result, "judge_model")

    def test_judge_model_is_optional_str(self):
        from llm_guard import AgentGuard
        guard = AgentGuard()
        result = guard.score_chain(self.QUESTION, self.STEPS, self.ANSWER)
        assert result.judge_model is None or isinstance(result.judge_model, str)


class TestMultiTurnResultQualityPreference:
    def _make_conv(self):
        return [
            {"role": "user", "content": "What is 2 + 2?"},
            {"role": "assistant", "content": "2 + 2 equals 4. This is basic arithmetic and a fundamental math fact."},
        ]

    def test_quality_preference_field_exists(self):
        from llm_guard import MultiTurnGuard
        import warnings
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", UserWarning)
            guard = MultiTurnGuard()
        result = guard.score_turn(self._make_conv(), use_ptrue=False)
        assert hasattr(result, "quality_preference")

    def test_quality_preference_is_valid_string(self):
        from llm_guard import MultiTurnGuard
        import warnings
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", UserWarning)
            guard = MultiTurnGuard()
        result = guard.score_turn(self._make_conv(), use_ptrue=False)
        assert result.quality_preference in {"excellent", "good", "degraded", "poor"}

    def test_empty_conversation_quality_preference(self):
        from llm_guard import MultiTurnGuard
        import warnings
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", UserWarning)
            guard = MultiTurnGuard()
        result = guard.score_turn([], use_ptrue=False)
        # Empty conversation: quality_preference is "" (no turns to score)
        assert result.quality_preference == ""

    def test_quality_preference_thresholds(self):
        """Verify mapping: high mean_q → excellent/good, low → degraded/poor."""
        from llm_guard.multi_turn.multi_turn_guard import MultiTurnGuard
        import warnings
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", UserWarning)
            guard = MultiTurnGuard()
        # Heavy hedging + short answer → lexical quality < 0.4 → poor
        bad_conv = [
            {"role": "user", "content": "Explain quantum physics."},
            {"role": "assistant", "content": "I am not sure"},
        ]
        result_bad = guard.score_turn(bad_conv, use_ptrue=False)
        assert result_bad.quality_preference in {"degraded", "poor"}


class TestBatchHandleDataclass:
    def test_batch_handle_fields_exist(self):
        from llm_guard import BatchHandle
        h = BatchHandle(
            batch_id="msgbatch_test123",
            custom_ids=["cid1", "cid2"],
            n_requests=2,
        )
        assert h.batch_id == "msgbatch_test123"
        assert h.n_requests == 2
        assert h._pending_bases == []
        assert h._pending_weights == []

    def test_score_with_ptrue_no_api_key_returns_chain_result_not_batch(self):
        """Without API key, score_with_ptrue(batch_mode=True) falls back to behavioral."""
        from llm_guard import AgentGuard, ChainTrustResult
        guard = AgentGuard()  # no api_key
        steps = [
            {"thought": "Search for answer", "action_type": "Search",
             "action_arg": "test query", "observation": "Some result."},
            {"thought": "Found it.", "action_type": "Finish",
             "action_arg": "answer", "observation": ""},
        ]
        result = guard.score_with_ptrue("test question", steps, "answer", batch_mode=True)
        # Without Anthropic key, falls back to behavioral ChainTrustResult
        assert isinstance(result, ChainTrustResult)


class TestSmartRouting:
    _steps = [
        {"thought": "Check", "action_type": "Finish",
         "action_arg": "Paris", "observation": ""},
    ]

    def test_judge_skipped_reason_field_exists(self):
        from llm_guard import ChainTrustResult
        from dataclasses import fields
        field_names = {f.name for f in fields(ChainTrustResult)}
        assert "judge_skipped_reason" in field_names

    def test_smart_routing_no_api_skips_no_reason_set(self):
        """Without API key, smart_routing=True still returns result (behavioral path)."""
        from llm_guard import AgentGuard
        guard = AgentGuard()
        result = guard.score_with_ptrue(
            "capital of France?", self._steps, "Paris", smart_routing=True
        )
        # behavioral-only fallback: judge_skipped_reason not set by smart routing
        # because API path is not entered (no key)
        assert result.judge_skipped_reason is None or isinstance(result.judge_skipped_reason, str)

    def test_chain_trust_result_default_skipped_reason_none(self):
        from llm_guard import AgentGuard
        guard = AgentGuard()
        result = guard.score_chain("test?", self._steps, "answer")
        assert result.judge_skipped_reason is None


@pytest.mark.skip(reason="DensityMatrixScorer: gate FAIL v0.44 — not exported from llm_guard (unit tests in test_density_matrix_scorer.py)")
class TestDensityMatrixScorer:
    _steps = [
        {"thought": "Search for Einstein", "action_type": "Search",
         "action_arg": "Einstein birth year", "observation": "Born 1879."},
        {"thought": "Born 1879, older than Bohr 1885.", "action_type": "Finish",
         "action_arg": "Einstein", "observation": ""},
    ]

    def test_import(self):
        from llm_guard import DensityMatrixScorer, DensityMatrixResult
        scorer = DensityMatrixScorer()
        assert not scorer.is_fitted

    _labeled = [
        {"question": "Who was older?", "steps": [
            {"thought": "Search for Einstein", "action_type": "Search",
             "action_arg": "Einstein birth year", "observation": "Born 1879."},
            {"thought": "Einstein older than Bohr 1885.", "action_type": "Finish",
             "action_arg": "Einstein", "observation": ""},
        ], "final_answer": "Einstein", "correct": True},
        {"question": "Who was older?", "steps": [
            {"thought": "Bohr is older.", "action_type": "Finish",
             "action_arg": "Bohr", "observation": ""},
        ], "final_answer": "Bohr", "correct": False},
    ]

    def test_zero_shot_score(self):
        from llm_guard import DensityMatrixScorer
        scorer = DensityMatrixScorer()
        result = scorer.score("Who was older?", self._steps, "Einstein")
        assert 0.0 <= result.risk_score <= 1.0
        assert hasattr(result, "sc_old_risk")
        assert not scorer.is_fitted

    def test_empty_chain_score(self):
        from llm_guard import DensityMatrixScorer
        scorer = DensityMatrixScorer()
        result = scorer.score("test?", [], "answer")
        assert result.risk_score == 0.5

    def test_features_dim(self):
        from llm_guard import DensityMatrixScorer
        scorer = DensityMatrixScorer()
        feats = scorer.features(self._steps)
        assert feats.shape == (138,)  # 136 upper-tri + 2 scalars

    def test_fit_changes_is_fitted(self):
        from llm_guard import DensityMatrixScorer
        scorer = DensityMatrixScorer()
        scorer.fit(self._labeled * 3)
        assert scorer.is_fitted

    def test_fitted_score_returns_result(self):
        from llm_guard import DensityMatrixScorer
        scorer = DensityMatrixScorer()
        scorer.fit(self._labeled * 3)
        result = scorer.score("Who was older?", self._steps, "Einstein")
        assert scorer.is_fitted
        assert 0.0 <= result.risk_score <= 1.0
        assert hasattr(result, "spectral_gap")


class TestCalibrationConvergenceMonitor:
    def test_import(self):
        from llm_guard import CalibrationConvergenceMonitor, ConvergenceStatus
        monitor = CalibrationConvergenceMonitor()
        assert not monitor.is_converged()

    def test_not_converged_until_window_full(self):
        from llm_guard import CalibrationConvergenceMonitor
        monitor = CalibrationConvergenceMonitor(window=5, stability_threshold=0.05)
        for _ in range(4):
            monitor.record_round(0.80)
        assert not monitor.is_converged()

    def test_converges_when_stable(self):
        from llm_guard import CalibrationConvergenceMonitor
        monitor = CalibrationConvergenceMonitor(window=5, stability_threshold=0.05)
        for _ in range(5):
            monitor.record_round(0.80)
        assert monitor.is_converged()

    def test_not_converged_when_unstable(self):
        from llm_guard import CalibrationConvergenceMonitor
        monitor = CalibrationConvergenceMonitor(window=5, stability_threshold=0.02)
        for v in [0.5, 0.8, 0.5, 0.8, 0.5]:
            monitor.record_round(v)
        assert not monitor.is_converged()

    def test_status_fields(self):
        from llm_guard import CalibrationConvergenceMonitor
        monitor = CalibrationConvergenceMonitor(window=3)
        for v in [0.7, 0.71, 0.70]:
            monitor.record_round(v)
        status = monitor.status()
        assert status.n_rounds == 3
        assert len(status.window_values) == 3

    def test_reset_clears_state(self):
        from llm_guard import CalibrationConvergenceMonitor
        monitor = CalibrationConvergenceMonitor(window=3)
        for _ in range(3):
            monitor.record_round(0.8)
        assert monitor.is_converged()
        monitor.reset()
        assert not monitor.is_converged()


class TestEnergyAwareCalibrator:
    def test_import(self):
        from llm_guard import EnergyAwareCalibrator
        cal = EnergyAwareCalibrator()
        assert not cal.is_fitted

    def test_threshold_high_energy(self):
        from llm_guard import EnergyAwareCalibrator
        cal = EnergyAwareCalibrator(high_threshold=0.80, low_threshold=0.50)
        assert abs(cal.threshold(1.0) - 0.80) < 1e-6

    def test_threshold_low_energy(self):
        from llm_guard import EnergyAwareCalibrator
        cal = EnergyAwareCalibrator(high_threshold=0.80, low_threshold=0.50)
        assert abs(cal.threshold(0.0) - 0.50) < 1e-6

    def test_threshold_midpoint(self):
        from llm_guard import EnergyAwareCalibrator
        cal = EnergyAwareCalibrator(high_threshold=0.80, low_threshold=0.50)
        assert abs(cal.threshold(0.5) - 0.65) < 1e-5

    def test_calibrate_returns_result(self):
        from llm_guard import EnergyAwareCalibrator, EnergyCalibrationResult
        cal = EnergyAwareCalibrator()
        result = cal.calibrate(0.75, energy=0.5)
        assert isinstance(result, EnergyCalibrationResult)
        assert result.energy_regime == "medium"

    def test_energy_regime_high(self):
        from llm_guard import EnergyAwareCalibrator
        cal = EnergyAwareCalibrator()
        r = cal.calibrate(0.5, energy=0.9)
        assert r.energy_regime == "high"

    def test_energy_regime_low(self):
        from llm_guard import EnergyAwareCalibrator
        cal = EnergyAwareCalibrator()
        r = cal.calibrate(0.5, energy=0.1)
        assert r.energy_regime == "low"

    def test_invalid_thresholds_raises(self):
        from llm_guard import EnergyAwareCalibrator
        with pytest.raises(ValueError):
            EnergyAwareCalibrator(high_threshold=0.3, low_threshold=0.8)


class TestContinualScorer:
    _runs = [
        {
            "question": "Who was born first?",
            "steps": [
                {"thought": "Search", "action_type": "Search",
                 "action_arg": "birth year", "observation": "Born 1879."},
                {"thought": "Done.", "action_type": "Finish",
                 "action_arg": "Einstein", "observation": ""},
            ],
            "final_answer": "Einstein",
            "correct": True,
        },
        {
            "question": "Capital of France?",
            "steps": [
                {"thought": "Paris.", "action_type": "Finish",
                 "action_arg": "Paris", "observation": ""},
            ],
            "final_answer": "London",
            "correct": False,
        },
    ] * 12  # 24 runs for min threshold

    def test_import(self):
        from llm_guard import ContinualScorer
        scorer = ContinualScorer()
        assert not scorer.is_fitted

    def test_fit_sets_is_fitted(self):
        from llm_guard import ContinualScorer
        scorer = ContinualScorer()
        scorer.fit(self._runs)
        assert scorer.is_fitted

    def test_score_after_fit(self):
        from llm_guard import ContinualScorer
        scorer = ContinualScorer()
        scorer.fit(self._runs)
        risk, uncertainty = scorer.score("test?", self._runs[0]["steps"], "answer")
        assert 0.0 <= risk <= 1.0
        assert 0.0 <= uncertainty <= 1.0

    def test_partial_fit_no_cpc(self):
        from llm_guard import ContinualScorer
        scorer = ContinualScorer()
        scorer.fit(self._runs)
        scorer.partial_fit(self._runs[:5], cpc_protect=False)
        assert scorer.is_fitted

    def test_partial_fit_with_cpc_protect_no_torch_graceful(self):
        """partial_fit with cpc_protect=True should not raise even without torch."""
        from llm_guard import ContinualScorer
        scorer = ContinualScorer(lambda_ewc=100.0)
        scorer.fit(self._runs)
        # Without anchor, auto_anchor attempts to set it; gracefully falls back
        scorer.partial_fit(self._runs[:5], cpc_protect=True, auto_anchor=True)
        assert scorer.is_fitted
