"""gate_ladder.py 单元化迁移测试（核心算法保真 #5）。

验证 gate 信息量阶梯从"逐题"迁移到"unit"粒度后，冷启动 2:1 错优先交错、
gamma-EMA、Beta 先验、schema_version 门控等语义"只换键、不改公式/比例/顺序"：

(a) LadderEntry 按 unit_id 键（AR pair 折叠为一个阶梯单元）；
(b) 冷启动"错优先 2:1"以 unit 为单位，unit 错 = P 或 Q 任一错；
(c) update_probs 观测先折叠成 unit 再匹配（防按 qid 匹配失效致 EMA 停摆）；
(d) GatePools.save/load 带 schema_version，存量无版本 json 明确报错（不静默混用）；
(e) BaselineCache 键含 unit_id（pair 的 unit_id 与成员 qid 不同）。
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING

import pytest

from app.harness.gate_ladder import (
    SCHEMA_VERSION,
    BaselineCache,
    GatePools,
    LadderEntry,
    build_cold_entries,
)
from app.harness.question_units import build_units
from core.types import GeneratedQuestion

if TYPE_CHECKING:
    from pathlib import Path


# ── 构造工具 ──────────────────────────────────────────────────────────


def _single(qid: str, task_type: str = "AR") -> GeneratedQuestion:
    """构造 single 题（unit_id 回填为 question_id）。"""
    return GeneratedQuestion(
        question_id=qid,
        video_id="v1",
        task_type=task_type,
        question="dummy",
        options=("A", "B", "C", "D"),
        answer="A",
        source_nodes=("n1",),
        difficulty="easy",
    )


def _pair(pair_id: str, task_type: str = "AR") -> list[GeneratedQuestion]:
    """构造一条 AR 孪生对（original + mirror），共享 pair_id 即 unit_id。"""
    base = {
        "video_id": "v1",
        "task_type": task_type,
        "question": "dummy",
        "options": ("A", "B", "C", "D"),
        "answer": "A",
        "source_nodes": ("n1",),
        "difficulty": "easy",
        "pair_id": pair_id,
        "flip_axis": "before_after",
    }
    return [
        GeneratedQuestion(question_id=f"{pair_id}_o", question_role="pair_original", **base),
        GeneratedQuestion(question_id=f"{pair_id}_m", question_role="pair_mirror", **base),
    ]


# ── (a) LadderEntry 按 unit_id ─────────────────────────────────────────


class TestLadderEntryKeyedByUnit:
    """LadderEntry 以 unit_id 为键；AR pair 折叠为一个阶梯单元。"""

    def test_entry_has_unit_id(self) -> None:
        """LadderEntry 暴露 unit_id 字段。"""
        e = LadderEntry("u1", 0.5)
        assert e.unit_id == "u1"

    def test_pair_collapses_to_single_entry(self) -> None:
        """一条孪生对（2 题）在阶梯中只产生 1 个 unit 条目（键=pair_id）。"""
        units = build_units(_pair("pr1"))
        correctness = {"pr1_o": True, "pr1_m": True}
        entries = build_cold_entries(units, correctness, probe_quota=0.0, seed=1)
        assert len(entries) == 1
        assert entries[0].unit_id == "pr1"


# ── (b) 冷启动 2:1 错优先 + unit 错 = P 或 Q 任一错 ───────────────────────


class TestColdStartUnit:
    """冷启动排序在 unit 粒度保持 2:1 交错与 Beta 先验；unit 错 = 任一成员错。"""

    def test_unit_wrong_if_any_member_wrong(self) -> None:
        """pair 中任一成员错 → 该 unit 判错（p_hat=1/3）；全对才判对（2/3）。"""
        units = build_units(_pair("wrong") + _pair("right"))
        # wrong: original 对、mirror 错 → unit 错；right: 两题均对 → unit 对
        correctness = {
            "wrong_o": True,
            "wrong_m": False,
            "right_o": True,
            "right_m": True,
        }
        entries = build_cold_entries(units, correctness, probe_quota=0.0, seed=3)
        p_by_unit = {e.unit_id: e.p_hat for e in entries}
        assert p_by_unit["wrong"] == pytest.approx(1 / 3)
        assert p_by_unit["right"] == pytest.approx(2 / 3)

    def test_two_to_one_interleave_over_units(self) -> None:
        """6 错 unit + 3 对 unit（含 pair），probe_quota=0 → 交错序 W W R W W R W W R。

        交错是 unit 粒度：pair 折叠成一个 unit 参与交错，序列长度为 9（unit 数），
        而非 18（题数），证明 2:1 比例语义只换键不改。
        """
        wrong_units_q: list[GeneratedQuestion] = []
        for i in range(6):
            wrong_units_q += _pair(f"w{i}")  # 6 个 pair unit
        right_units_q = [_single(f"r{i}") for i in range(3)]  # 3 个 single unit
        units = build_units(wrong_units_q + right_units_q)

        correctness: dict[str, bool] = {}
        for i in range(6):
            correctness[f"w{i}_o"] = True
            correctness[f"w{i}_m"] = False  # 任一错 → unit 错
        for i in range(3):
            correctness[f"r{i}"] = True

        entries = build_cold_entries(units, correctness, probe_quota=0.0, seed=42)
        assert len(entries) == 9
        # unit 错 = p_hat≈1/3；unit 对 = p_hat≈2/3
        pattern = ["W" if e.p_hat < 0.5 else "R" for e in entries]
        assert pattern == ["W", "W", "R", "W", "W", "R", "W", "W", "R"]

    def test_probe_at_tail_unit(self) -> None:
        """probe_quota>0 时从错 unit 抽探针追加梯尾（按 unit 抽，非按题）。"""
        wrong_q: list[GeneratedQuestion] = []
        for i in range(10):
            wrong_q += _pair(f"w{i}")
        right_q = [_single(f"r{i}") for i in range(2)]
        units = build_units(wrong_q + right_q)
        correctness = {}
        for i in range(10):
            correctness[f"w{i}_o"] = False
            correctness[f"w{i}_m"] = False
        for i in range(2):
            correctness[f"r{i}"] = True

        entries = build_cold_entries(units, correctness, probe_quota=0.3, seed=7)
        # 10 错 unit * 0.3 = 3 个探针 unit 在尾部，均为错 unit
        tail = entries[-3:]
        for e in tail:
            assert e.p_hat < 0.5


# ── (c) update_probs 折叠成 unit 再匹配 ──────────────────────────────────


class TestUpdateProbsFold:
    """gamma-EMA 更新前先把逐题观测折叠成 unit 观测（AR pair 双向 AND）。"""

    def _pools_and_units(self) -> tuple[GatePools, dict]:
        """构造含一个 pair unit + 一个 single unit 的池与 unit 索引。"""
        units = build_units(_pair("pr1") + [_single("s1")])
        units_by_id = {u.unit_id: u for u in units}
        entries = {"AR": [LadderEntry("pr1", 0.5), LadderEntry("s1", 0.5)]}
        return GatePools(entries=entries, seed=0, fingerprint="x"), units_by_id

    def test_pair_updates_after_fold(self) -> None:
        """pair 两成员均观测 → 折叠成 unit 观测 → EMA 更新（不停摆）。"""
        pools, units_by_id = self._pools_and_units()
        # pair 两题均对 → unit 对(1.0)；single 错(0.0)
        per_q = {"pr1_o": True, "pr1_m": True, "s1": False}
        pools.update_probs(per_q, units_by_id, gamma=0.8)
        p = {e.unit_id: e.p_hat for e in pools.entries["AR"]}
        assert p["pr1"] == pytest.approx(0.8 * 0.5 + 0.2 * 1.0)  # 0.6
        assert p["s1"] == pytest.approx(0.8 * 0.5 + 0.2 * 0.0)  # 0.4

    def test_pair_wrong_if_any_member_wrong(self) -> None:
        """pair 任一成员错 → unit 观测为错(0.0)，EMA 向下。"""
        pools, units_by_id = self._pools_and_units()
        per_q = {"pr1_o": True, "pr1_m": False, "s1": True}
        pools.update_probs(per_q, units_by_id, gamma=0.8)
        p = {e.unit_id: e.p_hat for e in pools.entries["AR"]}
        assert p["pr1"] == pytest.approx(0.8 * 0.5 + 0.2 * 0.0)  # 0.4

    def test_partial_pair_observation_skips_update(self) -> None:
        """pair 只观测到半个成员 → 无法折叠 → 该 unit p_hat 不变（不半 pair 污染）。"""
        pools, units_by_id = self._pools_and_units()
        per_q = {"pr1_o": True}  # 缺 mirror
        pools.update_probs(per_q, units_by_id, gamma=0.8)
        p = {e.unit_id: e.p_hat for e in pools.entries["AR"]}
        assert p["pr1"] == pytest.approx(0.5)  # 未更新

    def test_qid_keyed_observation_does_not_match_pair(self) -> None:
        """若观测里错用 pair_id 之外的裸 qid 键、且未提供 unit 折叠，pair 不应被误更新。

        证明"必须折叠"：单 single unit 用其自身 qid 可更新，pair 需 unit 折叠。
        """
        pools, units_by_id = self._pools_and_units()
        # 只给 single 的观测，pair 两成员均无观测
        per_q = {"s1": True}
        pools.update_probs(per_q, units_by_id, gamma=0.8)
        p = {e.unit_id: e.p_hat for e in pools.entries["AR"]}
        assert p["pr1"] == pytest.approx(0.5)  # pair 未观测 → 不变
        assert p["s1"] == pytest.approx(0.8 * 0.5 + 0.2 * 1.0)  # single 更新


# ── (d) schema_version 门控 ─────────────────────────────────────────────


class TestSchemaVersion:
    """GatePools.save/load 带 schema_version；存量无版本 json 明确报错。"""

    def test_save_writes_schema_version_and_unit_id(self, tmp_path: Path) -> None:
        """save 落盘含 schema_version 且 entries 用 unit_id 键。"""
        entries = {"AR": [LadderEntry("pr1", 0.33), LadderEntry("s1", 0.67)]}
        pools = GatePools(entries=entries, seed=1, fingerprint="fp")
        path = tmp_path / "gate_pools.json"
        pools.save(path)

        raw = json.loads(path.read_text(encoding="utf-8"))
        assert raw["schema_version"] == SCHEMA_VERSION
        assert raw["entries"]["AR"][0]["unit_id"] == "pr1"
        assert "question_id" not in raw["entries"]["AR"][0]

    def test_save_load_roundtrip(self, tmp_path: Path) -> None:
        """save → load 往返保真（unit_id + p_hat + seed + fingerprint）。"""
        entries = {"AR": [LadderEntry("pr1", 0.4)]}
        pools = GatePools(entries=entries, seed=9, fingerprint="fp2")
        path = tmp_path / "gate_pools.json"
        pools.save(path)
        loaded = GatePools.load(path)
        assert loaded.seed == 9
        assert loaded.fingerprint == "fp2"
        assert loaded.entries["AR"][0].unit_id == "pr1"
        assert loaded.entries["AR"][0].p_hat == pytest.approx(0.4)

    def test_load_legacy_without_schema_version_raises(self, tmp_path: Path) -> None:
        """存量无 schema_version（旧 qid 键）→ 明确报错，不静默混用。"""
        legacy = {
            "seed": 1,
            "fingerprint": "fp",
            "entries": {"AR": [{"question_id": "q1", "p_hat": 0.5}]},
        }
        path = tmp_path / "gate_pools.json"
        path.write_text(json.dumps(legacy), encoding="utf-8")
        with pytest.raises(RuntimeError, match="schema_version"):
            GatePools.load(path)

    def test_load_wrong_schema_version_raises(self, tmp_path: Path) -> None:
        """schema_version 不匹配 → 明确报错。"""
        bad = {
            "schema_version": SCHEMA_VERSION + 99,
            "seed": 1,
            "fingerprint": "fp",
            "entries": {"AR": [{"unit_id": "pr1", "p_hat": 0.5}]},
        }
        path = tmp_path / "gate_pools.json"
        path.write_text(json.dumps(bad), encoding="utf-8")
        with pytest.raises(RuntimeError, match="schema_version"):
            GatePools.load(path)


# ── (e) BaselineCache 键含 unit_id ─────────────────────────────────────


class TestBaselineCacheUnitKey:
    """BaselineCache 以 unit_id 为第四维；pair 的 unit_id 与成员 qid 区分。"""

    def test_unit_id_key_distinct_from_member_qid(self, tmp_path: Path) -> None:
        """pair unit_id（=pair_id）与其成员 qid 是不同缓存键。"""
        cache = BaselineCache(tmp_path / "baseline_cache.json")
        cache.put("AR", "h1", "v1", "pr1", True)  # unit_id=pair_id
        assert cache.get("AR", "h1", "v1", "pr1") is True
        # 成员 qid 不是同一键 → miss
        assert cache.get("AR", "h1", "v1", "pr1_o") is None
        assert cache.get("AR", "h1", "v1", "pr1_m") is None


# ── ladder_for 排除按 unit ─────────────────────────────────────────────


class TestLadderForExcludeUnit:
    """ladder_for 返回 unit_id 序，按 unit 排除（防半 pair 灌入）。"""

    def test_exclude_units_filters_whole_unit(self) -> None:
        """exclude_units 命中的 unit 被整体排除，返回 unit_id 列表。"""
        entries = {"AR": [LadderEntry("pr1", 0.5), LadderEntry("s1", 0.4), LadderEntry("s2", 0.6)]}
        pools = GatePools(entries=entries, seed=0, fingerprint="x")
        result = pools.ladder_for("AR", exclude_units={"pr1"}, p_low=0.0, p_high=1.0, cold=True)
        assert "pr1" not in result
        assert "s1" in result
        assert "s2" in result
