"""v0.65.0 Part A — Judge calibration tests (TDD-first).

Covers SCOPE/CJE-style bidirectional pairwise judging, position-bias offset
fitting, and conformal abstention thresholds. Refusal to use uncalibrated
judges in production scoring is wired through a runtime gate.
"""
from __future__ import annotations

import math

import pytest

from soup_cli.eval.calibrate import (
    JudgeCalibrationReport,
    PairwiseJudgement,
    classify_kl_delta,
    conformal_threshold,
    ensure_judge_calibrated,
    fit_position_bias,
    kl_divergence,
    run_pairwise_calibration,
)

# ─── PairwiseJudgement frozen dataclass ───


class TestPairwiseJudgement:
    def test_frozen(self):
        j = PairwiseJudgement(
            prompt_id="p1", first_winner="a", second_winner="a", oracle="a",
        )
        with pytest.raises(Exception):
            j.first_winner = "b"  # type: ignore[misc]

    def test_invalid_winner_first(self):
        with pytest.raises(ValueError, match="first_winner"):
            PairwiseJudgement(
                prompt_id="p", first_winner="X", second_winner="a", oracle="a",
            )

    def test_invalid_winner_second(self):
        with pytest.raises(ValueError, match="second_winner"):
            PairwiseJudgement(
                prompt_id="p", first_winner="a", second_winner="Z", oracle="a",
            )

    def test_invalid_oracle(self):
        with pytest.raises(ValueError, match="oracle"):
            PairwiseJudgement(
                prompt_id="p", first_winner="a", second_winner="b", oracle="x",
            )

    def test_null_byte_prompt_id(self):
        with pytest.raises(ValueError, match="null"):
            PairwiseJudgement(
                prompt_id="p\x00", first_winner="a", second_winner="b", oracle="a",
            )

    def test_empty_prompt_id(self):
        with pytest.raises(ValueError, match="prompt_id"):
            PairwiseJudgement(
                prompt_id="", first_winner="a", second_winner="a", oracle="a",
            )

    def test_accepts_tie(self):
        j = PairwiseJudgement(
            prompt_id="p", first_winner="tie", second_winner="tie", oracle="tie",
        )
        assert j.first_winner == "tie"


# ─── fit_position_bias ───


class TestFitPositionBias:
    def test_no_bias_when_consistent(self):
        judgements = [
            PairwiseJudgement(prompt_id=f"p{i}", first_winner="a",
                              second_winner="a", oracle="a")
            for i in range(10)
        ]
        bias = fit_position_bias(judgements)
        assert bias == 0.0

    def test_position_bias_when_first_always_wins(self):
        # Judge always picks the FIRST slot regardless of swap:
        #   arrangement 1: a, b -> judge picks "a" (first slot)
        #   arrangement 2 (swapped): b, a -> judge picks "b" (first slot)
        judgements = []
        for i in range(10):
            oracle = "a" if i % 2 == 0 else "b"
            judgements.append(PairwiseJudgement(
                prompt_id=f"p{i}",
                first_winner="a",
                second_winner="b",
                oracle=oracle,
            ))
        bias = fit_position_bias(judgements)
        # judge always picks the first slot → strong positive position bias
        assert bias > 0.5

    def test_empty_judgements(self):
        with pytest.raises(ValueError, match="judgements"):
            fit_position_bias([])

    def test_non_iterable(self):
        with pytest.raises(TypeError):
            fit_position_bias(42)  # type: ignore[arg-type]

    def test_returns_finite(self):
        judgements = [
            PairwiseJudgement(prompt_id=f"p{i}",
                              first_winner="a", second_winner="b", oracle="a")
            for i in range(5)
        ]
        bias = fit_position_bias(judgements)
        assert math.isfinite(bias)

    def test_bias_in_range(self):
        # Position-bias coefficient should be in [-1, 1].
        judgements = [
            PairwiseJudgement(prompt_id=f"p{i}",
                              first_winner="a", second_winner="b", oracle="a")
            for i in range(10)
        ]
        bias = fit_position_bias(judgements)
        assert -1.0 <= bias <= 1.0


# ─── conformal_threshold ───


class TestConformalThreshold:
    def test_basic(self):
        # 100 scores 0.0..0.99; alpha=0.1 means we keep top 90%, so threshold
        # is the 10th percentile.
        scores = [i / 100.0 for i in range(100)]
        t = conformal_threshold(scores, alpha=0.1)
        # Should be roughly 0.10 (10th percentile).
        assert 0.05 <= t <= 0.15

    def test_alpha_zero_keeps_all(self):
        scores = [0.5, 0.6, 0.7]
        t = conformal_threshold(scores, alpha=0.0)
        assert t == min(scores)

    def test_alpha_one_keeps_none(self):
        scores = [0.5, 0.6, 0.7]
        t = conformal_threshold(scores, alpha=1.0)
        assert t == max(scores)

    def test_invalid_alpha_negative(self):
        with pytest.raises(ValueError, match="alpha"):
            conformal_threshold([0.5], alpha=-0.1)

    def test_invalid_alpha_above_one(self):
        with pytest.raises(ValueError, match="alpha"):
            conformal_threshold([0.5], alpha=1.1)

    def test_invalid_alpha_nan(self):
        with pytest.raises(ValueError, match="alpha"):
            conformal_threshold([0.5], alpha=float("nan"))

    def test_invalid_alpha_bool(self):
        with pytest.raises(ValueError, match="alpha"):
            conformal_threshold([0.5], alpha=True)

    def test_empty_scores(self):
        with pytest.raises(ValueError, match="scores"):
            conformal_threshold([], alpha=0.1)

    def test_non_finite_score(self):
        with pytest.raises(ValueError, match="finite"):
            conformal_threshold([0.5, float("inf")], alpha=0.1)

    def test_score_out_of_range_high(self):
        with pytest.raises(ValueError, match="range"):
            conformal_threshold([0.5, 1.5], alpha=0.1)

    def test_score_out_of_range_low(self):
        with pytest.raises(ValueError, match="range"):
            conformal_threshold([-0.1, 0.5], alpha=0.1)


# ─── JudgeCalibrationReport ───


class TestJudgeCalibrationReport:
    def test_frozen(self):
        r = JudgeCalibrationReport(
            position_bias=0.05,
            conformal_threshold=0.3,
            agreement_rate=0.8,
            num_pairs=10,
            calibrated=True,
        )
        with pytest.raises(Exception):
            r.position_bias = 0.0  # type: ignore[misc]

    def test_invalid_bias(self):
        with pytest.raises(ValueError, match="position_bias"):
            JudgeCalibrationReport(
                position_bias=2.0,
                conformal_threshold=0.3,
                agreement_rate=0.8,
                num_pairs=10,
                calibrated=True,
            )

    def test_invalid_threshold(self):
        with pytest.raises(ValueError, match="conformal_threshold"):
            JudgeCalibrationReport(
                position_bias=0.0,
                conformal_threshold=2.0,
                agreement_rate=0.8,
                num_pairs=10,
                calibrated=True,
            )

    def test_invalid_agreement(self):
        with pytest.raises(ValueError, match="agreement_rate"):
            JudgeCalibrationReport(
                position_bias=0.0,
                conformal_threshold=0.3,
                agreement_rate=1.5,
                num_pairs=10,
                calibrated=True,
            )

    def test_invalid_num_pairs(self):
        with pytest.raises(ValueError, match="num_pairs"):
            JudgeCalibrationReport(
                position_bias=0.0,
                conformal_threshold=0.3,
                agreement_rate=0.8,
                num_pairs=-1,
                calibrated=True,
            )

    def test_bool_num_pairs(self):
        with pytest.raises(ValueError, match="num_pairs"):
            JudgeCalibrationReport(
                position_bias=0.0,
                conformal_threshold=0.3,
                agreement_rate=0.8,
                num_pairs=True,  # type: ignore[arg-type]
                calibrated=True,
            )

    def test_non_bool_calibrated(self):
        with pytest.raises(ValueError, match="calibrated"):
            JudgeCalibrationReport(
                position_bias=0.0,
                conformal_threshold=0.3,
                agreement_rate=0.8,
                num_pairs=10,
                calibrated="yes",  # type: ignore[arg-type]
            )


# ─── run_pairwise_calibration ───


class TestRunPairwiseCalibration:
    def test_perfect_calibration(self):
        judgements = [
            PairwiseJudgement(
                prompt_id=f"p{i}", first_winner="a",
                second_winner="a", oracle="a",
            ) for i in range(10)
        ]
        scores = [0.9] * 10
        report = run_pairwise_calibration(judgements, scores=scores, alpha=0.1)
        assert report.agreement_rate == 1.0
        assert report.calibrated is True
        assert report.num_pairs == 10

    def test_returns_report(self):
        judgements = [
            PairwiseJudgement(
                prompt_id=f"p{i}", first_winner="a",
                second_winner="a", oracle="a",
            ) for i in range(20)
        ]
        scores = [0.5 + i * 0.02 for i in range(20)]
        r = run_pairwise_calibration(judgements, scores=scores, alpha=0.1)
        assert isinstance(r, JudgeCalibrationReport)

    def test_length_mismatch(self):
        judgements = [
            PairwiseJudgement(
                prompt_id="p", first_winner="a",
                second_winner="a", oracle="a",
            )
        ]
        with pytest.raises(ValueError, match="length"):
            run_pairwise_calibration(judgements, scores=[0.5, 0.6], alpha=0.1)

    def test_too_few_pairs(self):
        # Need a minimum sample to compute conformal threshold meaningfully.
        with pytest.raises(ValueError, match="pairs"):
            run_pairwise_calibration([], scores=[], alpha=0.1)

    def test_too_many_pairs(self):
        judgements = [
            PairwiseJudgement(
                prompt_id=f"p{i}", first_winner="a",
                second_winner="a", oracle="a",
            ) for i in range(50_001)
        ]
        scores = [0.5] * 50_001
        with pytest.raises(ValueError, match="cap"):
            run_pairwise_calibration(judgements, scores=scores, alpha=0.1)


# ─── ensure_judge_calibrated production gate ───


class TestEnsureJudgeCalibrated:
    def test_passes_calibrated(self):
        r = JudgeCalibrationReport(
            position_bias=0.05, conformal_threshold=0.3,
            agreement_rate=0.85, num_pairs=20, calibrated=True,
        )
        # Should not raise.
        ensure_judge_calibrated(r)

    def test_refuses_uncalibrated(self):
        r = JudgeCalibrationReport(
            position_bias=0.05, conformal_threshold=0.3,
            agreement_rate=0.85, num_pairs=20, calibrated=False,
        )
        with pytest.raises(RuntimeError, match="calibrat"):
            ensure_judge_calibrated(r)

    def test_refuses_none(self):
        with pytest.raises(RuntimeError, match="calibrat"):
            ensure_judge_calibrated(None)

    def test_refuses_low_agreement(self):
        r = JudgeCalibrationReport(
            position_bias=0.0, conformal_threshold=0.3,
            agreement_rate=0.5, num_pairs=20, calibrated=True,
        )
        with pytest.raises(RuntimeError, match="agreement"):
            ensure_judge_calibrated(r, min_agreement=0.7)

    def test_refuses_high_bias(self):
        r = JudgeCalibrationReport(
            position_bias=0.4, conformal_threshold=0.3,
            agreement_rate=0.9, num_pairs=20, calibrated=True,
        )
        with pytest.raises(RuntimeError, match="bias"):
            ensure_judge_calibrated(r, max_bias=0.2)

    def test_non_report_type(self):
        with pytest.raises(TypeError):
            ensure_judge_calibrated("calibrated")  # type: ignore[arg-type]


# ─── back-compat: existing kl_divergence + classify_kl_delta still work ───


class TestBackCompat:
    def test_kl_divergence_still_works(self):
        assert kl_divergence([1.0, 0.0], [1.0, 0.0]) == 0.0

    def test_classify_kl_delta_still_works(self):
        assert classify_kl_delta(0.0) == "OK"
        assert classify_kl_delta(0.1) == "MINOR"
        assert classify_kl_delta(0.3) == "MAJOR"


# ─── No heavy top-level imports ───


class TestSourceWiring:
    def test_no_heavy_imports(self):
        from pathlib import Path
        src = Path(__file__).resolve().parent.parent / "soup_cli" / "eval" / "calibrate.py"
        text = src.read_text(encoding="utf-8")
        # Should not import torch, transformers, peft at module scope.
        forbidden_imports = (
            "import torch\n",
            "import transformers\n",
            "from torch",
            "from transformers",
        )
        for forbidden in forbidden_imports:
            assert forbidden not in text, f"Found heavy top-level import: {forbidden!r}"
