"""pools.json 冻结/解冻序列化 pair 字段回归测试。

pools.json 是训练主回路真正读回题目的地方——若 _q_to_dict/_dict_to_q 不序列化
pair 四字段（pair_id/question_role/flip_axis/unit_id），孪生对一旦冻结再 load_pools
就退化成孤儿 single，配对指标（collapse 等）全部失真。本测试锁死：

- pair 冻结进 pools.json 再 load_pools，四字段不丢；
- per_category 的 categories 块以 unit 原子记 train/val（两 pair 成员同 key）；
- 旧 JSON（无这些字段）用 .get 兼容不崩、默认退化为 single。
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING

from app.harness.pools import (
    PerCategoryPoolStrategy,
    Pools,
    load_pools,
    save_pools,
)
from core.types import GeneratedQuestion, PoolConfig

if TYPE_CHECKING:
    from pathlib import Path


def _pair(pid: str) -> tuple[GeneratedQuestion, GeneratedQuestion]:
    """构造一个合法孪生对（original + mirror），共享 pair_id / flip_axis。

    参数:
        pid: 该孪生对的共享标识。

    返回:
        (original, mirror) 两条题目，unit_id 由 __post_init__ 回填为 pid。
    """
    base = {
        "video_id": "v",
        "task_type": "Action Reasoning",
        "question": "?",
        "options": ("A. a", "B. b", "C. c", "D. d"),
        "answer": "A",
        "source_nodes": ("n",),
        "difficulty": "hard",
        "pair_id": pid,
        "flip_axis": "before_after",
    }
    original = GeneratedQuestion(question_id=f"{pid}_o", question_role="pair_original", **base)
    mirror = GeneratedQuestion(question_id=f"{pid}_m", question_role="pair_mirror", **base)
    return original, mirror


def test_pair_fields_survive_freeze_thaw(tmp_path: Path) -> None:
    """pair 冻结进 pools.json 再 load_pools，四字段逐一不丢。"""
    o, m = _pair("p00")
    single = GeneratedQuestion(
        question_id="s0",
        video_id="v",
        task_type="Action Reasoning",
        question="?",
        options=("A. a", "B. b", "C. c", "D. d"),
        answer="B",
        source_nodes=("n",),
        difficulty="medium",
    )
    pools = Pools(
        diagnosis=[o, m],
        validation=[single],
        test=[],
        baseline_run_id="run_001",
        baseline_val_accuracy=0.0,
        correctness={},
    )
    pools_path = tmp_path / "pools.json"
    save_pools(pools, pools_path)
    restored = load_pools(pools_path)

    ro, rm = restored.diagnosis
    assert ro.pair_id == "p00"
    assert rm.pair_id == "p00"
    assert ro.question_role == "pair_original"
    assert rm.question_role == "pair_mirror"
    assert ro.flip_axis == "before_after"
    assert rm.flip_axis == "before_after"
    assert ro.unit_id == "p00"
    assert rm.unit_id == "p00"

    # single 侧默认值也需正确往返
    rs = restored.validation[0]
    assert rs.pair_id is None
    assert rs.question_role == "single"
    assert rs.flip_axis is None
    assert rs.unit_id == "s0"


def test_per_category_categories_record_pair_atomically(tmp_path: Path) -> None:
    """per_category 的 categories 块以 unit 原子记：两 pair 成员必落同一 train/val key。"""
    questions: list[GeneratedQuestion] = []
    correctness: dict[str, bool] = {}
    for i in range(15):
        o, m = _pair(f"p{i:02d}")
        questions.extend((o, m))
        # 部分对错混合以触发分层划分而非退化随机
        val = i < 9
        correctness[o.question_id] = val
        correctness[m.question_id] = val

    config = PoolConfig(
        task_types=("Action Reasoning",),
        seed=42,
        baseline_run_id="baseline_v2",
        diag_size=0,
        diag_correct_ratio=0.0,
        val_size=0,
        val_correct_ratio=0.0,
        test_size=0,
        eval_min_per_class=0,
        train_ratio=2 / 3,
        test_questions_dir=None,
    )
    pools = PerCategoryPoolStrategy().build(questions, correctness, config)
    pools_path = tmp_path / "pools.json"
    save_pools(pools, pools_path, split_mode="per_category", config=config)

    data = json.loads(pools_path.read_text(encoding="utf-8"))
    cat = data["categories"]["Action Reasoning"]
    train_ids = set(cat["train"])
    val_ids = set(cat["val"])

    # 每个 pair 的两个成员必须同在 train 或同在 val，绝不被劈开
    for i in range(15):
        o_id, m_id = f"p{i:02d}_o", f"p{i:02d}_m"
        in_train = {o_id, m_id} <= train_ids
        in_val = {o_id, m_id} <= val_ids
        assert in_train ^ in_val, f"pair p{i:02d} 被 categories 劈开: train={o_id in train_ids},{m_id in train_ids} val={o_id in val_ids},{m_id in val_ids}"


def test_old_json_without_pair_fields_defaults_single(tmp_path: Path) -> None:
    """旧 JSON（无 pair 四字段）用 .get 兼容加载不崩，退化为 single。"""
    old_format = {
        "baseline_run_id": "run_old",
        "baseline_val_accuracy": 0.5,
        "correctness": {},
        "diagnosis": [
            {
                "question_id": "q_old",
                "video_id": "v",
                "task_type": "Action Reasoning",
                "question": "?",
                "options": ["A. a", "B. b", "C. c", "D. d"],
                "answer": "A",
                "source_nodes": ["n"],
                "difficulty": "medium",
                "family": None,
                "skill_target": None,
                "difficulty_steps": None,
            }
        ],
        "validation": [],
        "test": [],
    }
    pools_path = tmp_path / "pools.json"
    pools_path.write_text(json.dumps(old_format), encoding="utf-8")

    restored = load_pools(pools_path)
    q = restored.diagnosis[0]
    assert q.pair_id is None
    assert q.question_role == "single"
    assert q.flip_axis is None
    # unit_id 缺省 → __post_init__ 回填为 question_id
    assert q.unit_id == "q_old"
