"""Tests for v0.45.0 — Task 1: GMM min-pool guard coverage."""
import sys
import warnings
from pathlib import Path

import pytest

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


def _make_fake_chains(n: int):
    """Return n minimal chain dicts for LabelFreeScorer."""
    return [
        {
            "question": f"q{i}",
            "steps": [{"thought": "t", "action": "Search", "observation": "o"}],
            "final_answer": f"a{i}",
            "finished": True,
        }
        for i in range(n)
    ]


class TestGMMMinPoolGuard:
    """GMM min-pool guard: calibrate() must refuse to fit below min_calibration."""

    def test_gmm_minpool_guard_insufficient_returns_calibrated_false(self):
        """calibrate() with < 50 chains must return calibrated=False."""
        from qppg_service.label_free_scorer import LabelFreeScorer
        scorer = LabelFreeScorer()
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", UserWarning)
            result = scorer.calibrate(_make_fake_chains(49))
        assert result["calibrated"] is False

    def test_gmm_minpool_guard_insufficient_n_chains_in_result(self):
        """calibrate() with < 50 chains must report n_chains in return dict."""
        from qppg_service.label_free_scorer import LabelFreeScorer
        scorer = LabelFreeScorer()
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", UserWarning)
            result = scorer.calibrate(_make_fake_chains(30))
        assert result["n_chains"] == 30

    def test_gmm_minpool_guard_insufficient_emits_warning(self):
        """calibrate() with < 50 chains must emit a UserWarning."""
        from qppg_service.label_free_scorer import LabelFreeScorer
        scorer = LabelFreeScorer()
        with pytest.warns(UserWarning, match="minimum is 50"):
            scorer.calibrate(_make_fake_chains(49))

    def test_gmm_minpool_guard_insufficient_does_not_set_n_cal(self):
        """calibrate() with < 50 chains must leave _n_cal at 0 (no partial state)."""
        from qppg_service.label_free_scorer import LabelFreeScorer
        scorer = LabelFreeScorer()
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", UserWarning)
            scorer.calibrate(_make_fake_chains(49))
        assert scorer._n_cal == 0

    def test_gmm_minpool_guard_custom_min_calibration(self):
        """Custom min_calibration=20 guard triggers below 20 chains."""
        from qppg_service.label_free_scorer import LabelFreeScorer
        scorer = LabelFreeScorer(min_calibration=20)
        # 19 chains should fail
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", UserWarning)
            result = scorer.calibrate(_make_fake_chains(19))
        assert result["calibrated"] is False
        assert result["n_chains"] == 19

    def test_gmm_minpool_guard_custom_min_calibration_passes(self):
        """Custom min_calibration=20: exactly 20 chains should NOT early-return."""
        from qppg_service.label_free_scorer import LabelFreeScorer
        scorer = LabelFreeScorer(min_calibration=20)
        # 20 chains — should attempt calibration (will fail due to no embedder,
        # but should NOT return the early-exit dict)
        try:
            result = scorer.calibrate(_make_fake_chains(20))
            # If it returns without error, it must not be the early-exit dict
            assert result.get("calibrated") is not False, (
                "Guard should not fire at n=min_calibration; calibration should proceed"
            )
        except (ImportError, OSError, RuntimeError):
            # sentence-transformers not available in CI — skip this assertion path
            pass

    def test_gmm_minpool_guard_boundary_exactly_50(self):
        """Exactly 50 chains must pass the guard (not return calibrated=False early)."""
        from qppg_service.label_free_scorer import LabelFreeScorer
        scorer = LabelFreeScorer()
        # Should NOT early-exit with calibrated=False; may raise on missing embedder
        try:
            result = scorer.calibrate(_make_fake_chains(50))
            # If it succeeded, verify it's not an early-exit response
            assert result.get("calibrated") is not False, (
                "Guard triggered incorrectly at n=50 — should only fire for n < 50"
            )
        except (ImportError, OSError, RuntimeError):
            # sentence-transformers not available in CI — skip this assertion path
            pass

    def test_calibration_state_behavioral_only_after_failed_calibrate(self):
        """After calibrate() with insufficient data, score() returns behavioral_only state."""
        from unittest.mock import MagicMock
        import numpy as np
        from qppg_service.label_free_scorer import LabelFreeScorer

        scorer = LabelFreeScorer()
        # Patch the embedder to avoid needing sentence-transformers
        mock_embedder = MagicMock()
        mock_embedder.encode.return_value = np.zeros((1, 384), dtype=np.float32)
        scorer._embedder = mock_embedder

        # Calibrate with insufficient chains (early exit, _n_cal stays 0)
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", UserWarning)
            scorer.calibrate(_make_fake_chains(49))

        # Score a chain — should return behavioral_only since _n_cal == 0
        steps = [{"thought": "t", "action_type": "Search",
                  "action_arg": "query", "observation": "obs"}]
        result = scorer.score("test question", steps, "answer", True)
        assert result.calibration_state == "behavioral_only"


# ---------------------------------------------------------------------------
# Task 5: A2A 2-tier degradation
# ---------------------------------------------------------------------------

def test_a2a_2tier_without_judge():
    """Without API key, generate_trust_object must use 2-tier routing."""
    from llm_guard import AgentGuard

    guard = AgentGuard(api_key=None)  # no judge
    result = guard.generate_trust_object(
        question="Who wrote Hamlet?",
        steps=[{"thought": "t", "action": "Search[Hamlet]", "observation": "Shakespeare"}],
        final_answer="William Shakespeare",
    )
    assert result.routing_mode == "behavioral_only_2tier", (
        f"Expected behavioral_only_2tier, got {result.routing_mode}"
    )
    valid_tiers = {"PROCEED", "ESCALATE"}
    assert result.confidence_tier in valid_tiers, (
        f"Expected tier in {valid_tiers}, got {result.confidence_tier}"
    )


def test_a2a_2tier_proceed_below_threshold():
    """Low-risk chain (confident short answer) must map to PROCEED in 2-tier mode."""
    from llm_guard import AgentGuard

    guard = AgentGuard(api_key=None)
    result = guard.generate_trust_object(
        question="What is 2+2?",
        steps=[{"thought": "arithmetic", "action": "Calculate[2+2]", "observation": "4"}],
        final_answer="4",
    )
    assert result.routing_mode == "behavioral_only_2tier"
    # In 2-tier mode, only PROCEED and ESCALATE are valid tiers
    assert result.confidence_tier in {"PROCEED", "ESCALATE"}
    # risk_score must still be populated
    assert 0.0 <= result.risk_score <= 1.0


def test_a2a_full_4tier_routing_mode_field_default():
    """A2ATrustObject routing_mode field defaults to full_4tier."""
    from llm_guard.trust.trust_object import A2ATrustObject

    trust = A2ATrustObject(
        answer="Shakespeare",
        risk_score=0.2,
        confidence_tier="HIGH",
        failure_mode=None,
        step_count=1,
        judge_label="GOOD",
        downstream_hint="proceed",
        should_rewrite=False,
    )
    assert trust.routing_mode == "full_4tier"


def test_a2a_2tier_routing_mode_no_high_medium_low():
    """In behavioral_only_2tier mode, confidence_tier must NOT be HIGH/MEDIUM/LOW."""
    from llm_guard import AgentGuard

    guard = AgentGuard(api_key=None)
    result = guard.generate_trust_object(
        question="Who wrote Hamlet?",
        steps=[{"thought": "t", "action": "Search[Hamlet]", "observation": "Shakespeare"}],
        final_answer="William Shakespeare",
    )
    assert result.routing_mode == "behavioral_only_2tier"
    # 4-tier labels must NOT appear
    for four_tier_label in ("HIGH", "MEDIUM", "LOW"):
        assert result.confidence_tier != four_tier_label, (
            f"2-tier mode must not emit 4-tier label '{four_tier_label}'"
        )


def test_selfhealer_unvalidated_returns_no_action_validated():
    """Unvalidated modes must return NO_ACTION_VALIDATED (not NO_ACTION)."""
    import warnings
    from qppg_service.self_healer import SelfHealer
    try:
        from qppg_service.failure_taxonomy import FailureMode
        failure = FailureMode(
            primary_mode="CONFLICTING_EVIDENCE",
            all_modes=["CONFLICTING_EVIDENCE"],
            confidence=0.8,
            explanation="test",
        )
    except Exception as e:
        import pytest
        pytest.skip(f"FailureMode import failed: {e}")

    healer = SelfHealer()  # experimental=False by default
    with warnings.catch_warnings(record=True) as w:
        warnings.simplefilter("always")
        result = healer.suggest(failure, question="q?", steps=[])
    assert result.action_type == "NO_ACTION", (
        f"Expected NO_ACTION for unvalidated mode, got '{result.action_type}'"
    )
    assert any("not validated" in str(x.message).lower() or "experimental" in str(x.message).lower() for x in w), (
        f"Expected UserWarning about validation, got: {[str(x.message) for x in w]}"
    )


def test_selfhealer_validated_modes_unaffected():
    """RETRIEVAL_FAILURE and EXCESSIVE_SEARCH must still work normally."""
    from qppg_service.self_healer import SelfHealer
    try:
        from qppg_service.failure_taxonomy import FailureMode
        failure = FailureMode(
            primary_mode="RETRIEVAL_FAILURE",
            all_modes=["RETRIEVAL_FAILURE"],
            confidence=0.9,
            explanation="test",
        )
    except Exception as e:
        import pytest
        pytest.skip(f"FailureMode import failed: {e}")
    healer = SelfHealer()
    result = healer.suggest(failure, question="q?", steps=[{"thought": "t", "action": "Search[x]", "observation": "obs"}])
    assert result.action_type not in {"NO_ACTION_VALIDATED", "NO_ACTION"}, (
        f"RETRIEVAL_FAILURE should return an action, not {result.action_type}"
    )


def test_selfhealer_experimental_true_enables_unvalidated():
    """With experimental=True, unvalidated modes should work normally (not return NO_ACTION_VALIDATED)."""
    from qppg_service.self_healer import SelfHealer
    try:
        from qppg_service.failure_taxonomy import FailureMode
        failure = FailureMode(
            primary_mode="CONFLICTING_EVIDENCE",
            all_modes=["CONFLICTING_EVIDENCE"],
            confidence=0.8,
            explanation="test",
        )
    except Exception as e:
        import pytest
        pytest.skip(f"FailureMode import failed: {e}")
    healer = SelfHealer(experimental=True)
    result = healer.suggest(failure, question="q?", steps=[])
    assert result.action_type != "NO_ACTION_VALIDATED", (
        f"experimental=True should bypass the gate, got {result.action_type}"
    )


# ---------------------------------------------------------------------------
# Task 7: Feature 2 — Enable Risk Score CI by default
# ---------------------------------------------------------------------------

def test_score_chain_ci_enabled_by_default():
    """score_chain() must return non-zero ci_hi by default."""
    from llm_guard import AgentGuard
    guard = AgentGuard()
    # Seed calibration pool so Wilson CI has enough data
    for _ in range(30):
        guard.update_isotonic(score=0.3, label=0)
    result = guard.score_chain(
        question="Who wrote Hamlet?",
        steps=[{"thought": "t", "action": "Search[Hamlet]", "observation": "Shakespeare"}],
        final_answer="William Shakespeare",
    )
    # CI fields should be populated (non-zero, valid range)
    assert hasattr(result, "ci_lo"), "ChainTrustResult must have ci_lo field"
    assert hasattr(result, "ci_hi"), "ChainTrustResult must have ci_hi field"
    assert result.ci_lo >= 0.0, f"ci_lo={result.ci_lo} should be >= 0"
    assert result.ci_hi <= 1.0, f"ci_hi={result.ci_hi} should be <= 1"
    assert result.ci_hi >= result.ci_lo, "ci_hi must be >= ci_lo"
    assert result.ci_hi > 0.0, "ci_hi should be > 0 (CI enabled by default)"


def test_score_with_ptrue_has_return_ci_param():
    """score_with_ptrue() must accept return_ci parameter."""
    from llm_guard import AgentGuard
    import inspect
    guard = AgentGuard()
    sig = inspect.signature(guard.score_with_ptrue)
    assert "return_ci" in sig.parameters, (
        "score_with_ptrue() must have a return_ci parameter"
    )


# ---------------------------------------------------------------------------
# Task 8: Feature 1 — Retrieval Quality REST endpoint
# ---------------------------------------------------------------------------

def test_retrieval_quality_endpoint_schema():
    """POST /v1/chains/retrieval-quality must return per_step list and mean_sim."""
    from fastapi.testclient import TestClient
    from app.main import app
    client = TestClient(app)
    resp = client.post("/v1/chains/retrieval-quality", json={
        "question": "Who wrote Hamlet?",
        "steps": [
            {"thought": "t", "action": "Search[Hamlet]",
             "observation": "Shakespeare wrote Hamlet."},
        ],
    })
    assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
    data = resp.json()
    assert "per_step" in data, f"Response must contain per_step, got: {list(data.keys())}"
    assert "mean_sim" in data, f"Response must contain mean_sim, got: {list(data.keys())}"
    assert isinstance(data["per_step"], list), "per_step must be a list"


# ---------------------------------------------------------------------------
# Task 9: Feature 3 — generate_report() + weekly scheduler
# ---------------------------------------------------------------------------

def test_generate_report_returns_required_keys():
    """generate_report() must return dict with all required keys."""
    from llm_guard import AgentGuard
    guard = AgentGuard()
    # Seed with synthetic data
    for score, label in [(0.2, 0), (0.8, 1), (0.3, 0), (0.7, 1), (0.4, 0)] * 6:
        guard.update_isotonic(score=score, label=label)
    report = guard.generate_report()
    required_keys = {"alert_rate", "failure_distribution", "drift_delta",
                     "top_failure_modes", "n_chains", "report_date"}
    missing = required_keys - set(report.keys())
    assert not missing, f"Report missing keys: {missing}"
    assert isinstance(report["alert_rate"], float), "alert_rate must be float"
    assert isinstance(report["n_chains"], int), "n_chains must be int"
    assert isinstance(report["drift_delta"], float), "drift_delta must be float"
    assert isinstance(report["top_failure_modes"], list), "top_failure_modes must be list"


def test_generate_report_empty_guard():
    """generate_report() on empty guard must return zero values without error."""
    from llm_guard import AgentGuard
    guard = AgentGuard()
    report = guard.generate_report()
    assert report["n_chains"] == 0
    assert report["alert_rate"] == 0.0


def test_report_endpoint_returns_200():
    """GET /v1/report must return 200 with required keys."""
    from fastapi.testclient import TestClient
    from app.main import app
    client = TestClient(app)
    resp = client.get("/v1/report")
    assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
    data = resp.json()
    assert "alert_rate" in data
    assert "n_chains" in data


# ---------------------------------------------------------------------------
# Task 10: Feature 4 — Threshold Auto-Tuner REST endpoint
# ---------------------------------------------------------------------------

def test_step_budget_endpoint():
    """GET /v1/config/step-budget must return recommended_max_steps int in [3,8]."""
    from fastapi.testclient import TestClient
    from app.main import app
    client = TestClient(app)
    resp = client.get("/v1/config/step-budget")
    assert resp.status_code == 200, f"Expected 200: {resp.text}"
    data = resp.json()
    assert "recommended_max_steps" in data, f"Missing recommended_max_steps, got: {list(data.keys())}"
    assert isinstance(data["recommended_max_steps"], int)
    assert 3 <= data["recommended_max_steps"] <= 8, (
        f"Budget must be in [3,8], got {data['recommended_max_steps']}"
    )


def test_autotune_endpoint_returns_threshold():
    """POST /v1/calibration/autotune must return a threshold float."""
    from fastapi.testclient import TestClient
    from app.main import app
    client = TestClient(app)
    labeled_chains = (
        [{"score": 0.2, "label": 0} for _ in range(20)] +
        [{"score": 0.8, "label": 1} for _ in range(20)]
    )
    resp = client.post("/v1/calibration/autotune", json={
        "labeled_chains": labeled_chains,
        "target_precision": 0.85,
    })
    assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
    data = resp.json()
    assert "threshold" in data, f"Response must contain 'threshold', got: {list(data.keys())}"
    if data["threshold"] is not None:
        assert 0.0 <= data["threshold"] <= 1.0, "Threshold must be in [0,1]"


def test_batch_submit_returns_batch_id():
    """POST /v1/chains/batch must return a batch_id string."""
    from fastapi.testclient import TestClient
    from app.main import app
    client = TestClient(app)
    chains = [
        {
            "question": f"q{i}",
            "steps": [{"thought": "t", "action": f"Search[q{i}]",
                       "observation": f"obs{i}"}],
            "final_answer": f"a{i}",
        }
        for i in range(3)
    ]
    resp = client.post("/v1/chains/batch", json={"chains": chains})
    assert resp.status_code == 200, f"Expected 200: {resp.text}"
    data = resp.json()
    assert "batch_id" in data, f"Response must contain batch_id, got: {list(data.keys())}"
    assert isinstance(data["batch_id"], str)
    assert data["batch_id"]  # non-empty


def test_mcp_tools_listing_endpoint():
    """GET /v1/mcp/tools must return a list with at least retrieval_audit."""
    from fastapi.testclient import TestClient
    from app.main import app
    client = TestClient(app)
    resp = client.get("/v1/mcp/tools")
    assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
    data = resp.json()
    assert "tools" in data, f"Response must have 'tools', got: {list(data.keys())}"
    assert isinstance(data["tools"], list)
    tool_names = [t["name"] for t in data["tools"]]
    assert "retrieval_audit" in tool_names, (
        f"retrieval_audit not in {tool_names}"
    )
