"""app/harness/batching.py 的单元测试。

覆盖 FFD + round-robin mini-batch 构建的核心场景：
确定性、小类不拆、大类 round-robin、正确率混合、空错题、参数校验、
correctness False vs None 精确匹配。
"""

from __future__ import annotations

import random

import pytest

from app.harness.batching import (
    _select_mixed_by_task_type,
    _validate_params,
    build_batches,
)
from app.harness.question_units import build_units
from core.types import GeneratedQuestion

# ---------------------------------------------------------------------------
# 辅助构造
# ---------------------------------------------------------------------------


def _make_q(
    qid: str,
    task_type: str = "default",
    video_id: str = "v1",
) -> GeneratedQuestion:
    """构造最小 GeneratedQuestion 用于测试。"""
    return GeneratedQuestion(
        question_id=qid,
        video_id=video_id,
        task_type=task_type,
        question=f"question_{qid}",
        options=("A. a", "B. b", "C. c", "D. d"),
        answer="A",
        source_nodes=("n1",),
        difficulty="medium",
    )


# ---------------------------------------------------------------------------
# test_build_batches_deterministic
# ---------------------------------------------------------------------------


class TestBuildBatchesDeterministic:
    """相同输入 + 相同 seed 产出完全一致的切分。"""

    def test_same_seed_same_result(self) -> None:
        items = [_make_q(f"q{i}", task_type=f"type_{i % 3}") for i in range(20)]
        correctness = {f"q{i}": False for i in range(20)}
        r1 = build_batches(items, correctness, batch_size=5, min_class_per_batch=2, seed=42)
        r2 = build_batches(items, correctness, batch_size=5, min_class_per_batch=2, seed=42)
        assert r1 == r2

    def test_different_seed_may_differ(self) -> None:
        """不同 seed 结果应不同（极大概率，用大量样本保证）。"""
        items = [_make_q(f"q{i}", task_type=f"type_{i % 5}") for i in range(50)]
        correctness = {f"q{i}": False for i in range(50)}
        r1 = build_batches(items, correctness, batch_size=10, min_class_per_batch=3, seed=1)
        r2 = build_batches(items, correctness, batch_size=10, min_class_per_batch=3, seed=99)
        # 至少 batch 内容不同（不比较结构，只比较 selected_count 一致性）
        assert r1[1] == r2[1]  # 总题数一致
        # 但 batch 内部排列几乎必然不同
        flat1 = [q.question_id for b in r1[0] for q in b]
        flat2 = [q.question_id for b in r2[0] for q in b]
        assert flat1 != flat2


# ---------------------------------------------------------------------------
# test_small_class_not_split
# ---------------------------------------------------------------------------


class TestSmallClassNotSplit:
    """小类（≤ min_class_per_batch）整组不拆，锁在同一 batch。"""

    def test_small_group_stays_together(self) -> None:
        # 2 道题属于 small_type（≤ min_class=3），应在同一 batch
        items = [
            _make_q("s1", task_type="small_type"),
            _make_q("s2", task_type="small_type"),
            # 大类 8 道题
            *[_make_q(f"big{i}", task_type="big_type") for i in range(8)],
        ]
        correctness = {q.question_id: False for q in items}
        batches, count = build_batches(
            items, correctness, batch_size=5, min_class_per_batch=3, seed=0
        )
        assert count == 10
        # 找到包含 small_type 的 batch
        small_batch = [b for b in batches if any(q.task_type == "small_type" for q in b)]
        assert len(small_batch) == 1  # 整组在同一个 batch
        small_ids = {q.question_id for q in small_batch[0] if q.task_type == "small_type"}
        assert small_ids == {"s1", "s2"}


# ---------------------------------------------------------------------------
# test_large_class_round_robin
# ---------------------------------------------------------------------------


class TestLargeClassRoundRobin:
    """大类样本 round-robin 散布到多个 batch，不集中于单一 batch。"""

    def test_large_group_distributed(self) -> None:
        # 12 道大类题，batch_size=4，min_class=2 → 大类 > 2 → round-robin
        items = [_make_q(f"q{i}", task_type="large_type") for i in range(12)]
        correctness = {q.question_id: False for q in items}
        batches, count = build_batches(
            items, correctness, batch_size=4, min_class_per_batch=2, seed=7
        )
        assert count == 12
        assert len(batches) >= 3  # ceil(12/4) = 3
        # 每个 batch 不超过 batch_size
        for b in batches:
            assert len(b) <= 4


# ---------------------------------------------------------------------------
# test_correct_ratio_mixing
# ---------------------------------------------------------------------------


class TestCorrectRatioMixing:
    """correct_ratio > 0 时混入正确题。"""

    def test_mixed_includes_correct(self) -> None:
        items = [
            _make_q("e1", task_type="t1"),
            _make_q("e2", task_type="t1"),
            _make_q("c1", task_type="t1"),
            _make_q("c2", task_type="t1"),
            _make_q("c3", task_type="t1"),
        ]
        correctness = {"e1": False, "e2": False, "c1": True, "c2": True, "c3": True}
        batches, count = build_batches(
            items,
            correctness,
            batch_size=10,
            min_class_per_batch=2,
            seed=0,
            correct_ratio=0.5,
        )
        # correct_ratio=0.5 → 错:正 = 1:1 → 2 错 + 2 正 = 4 题
        assert count == 4
        all_ids = {q.question_id for b in batches for q in b}
        assert {"e1", "e2"}.issubset(all_ids)  # 错题全部
        correct_in = all_ids - {"e1", "e2"}
        assert len(correct_in) == 2  # 采样 2 个正确题
        assert correct_in.issubset({"c1", "c2", "c3"})

    def test_ratio_zero_pure_errors(self) -> None:
        items = [
            _make_q("e1", task_type="t1"),
            _make_q("c1", task_type="t1"),
        ]
        correctness = {"e1": False, "c1": True}
        batches, count = build_batches(
            items,
            correctness,
            batch_size=10,
            min_class_per_batch=2,
            seed=0,
            correct_ratio=0.0,
        )
        assert count == 1
        assert batches[0][0].question_id == "e1"


# ---------------------------------------------------------------------------
# test_no_wrong_answers_empty
# ---------------------------------------------------------------------------


class TestNoWrongAnswersEmpty:
    """无错题时返回空列表。"""

    def test_all_correct_returns_empty(self) -> None:
        items = [_make_q(f"q{i}") for i in range(5)]
        correctness = {f"q{i}": True for i in range(5)}
        batches, count = build_batches(
            items,
            correctness,
            batch_size=3,
            min_class_per_batch=1,
            seed=0,
        )
        assert batches == []
        assert count == 0

    def test_empty_items_returns_empty(self) -> None:
        batches, count = build_batches(
            [],
            {},
            batch_size=3,
            min_class_per_batch=1,
            seed=0,
        )
        assert batches == []
        assert count == 0


# ---------------------------------------------------------------------------
# test_validate_params_strict
# ---------------------------------------------------------------------------


class TestValidateParamsStrict:
    """参数校验：batch_size < 1、min_class < 1、min_class >= batch_size 都报错。"""

    def test_batch_size_zero(self) -> None:
        with pytest.raises(ValueError, match="batch_size 必须 >= 1"):
            _validate_params(0, 1)

    def test_batch_size_negative(self) -> None:
        with pytest.raises(ValueError, match="batch_size 必须 >= 1"):
            _validate_params(-1, 1)

    def test_min_class_zero(self) -> None:
        with pytest.raises(ValueError, match="min_class_per_batch 必须 >= 1"):
            _validate_params(5, 0)

    def test_min_class_equals_batch_size(self) -> None:
        with pytest.raises(ValueError, match="min_class_per_batch 必须严格 < batch_size"):
            _validate_params(5, 5)

    def test_min_class_exceeds_batch_size(self) -> None:
        with pytest.raises(ValueError, match="min_class_per_batch 必须严格 < batch_size"):
            _validate_params(3, 5)

    def test_valid_params_no_error(self) -> None:
        _validate_params(5, 3)  # 不抛异常


# ---------------------------------------------------------------------------
# test_correctness_false_vs_none
# ---------------------------------------------------------------------------


class TestCorrectnessFalseVsNone:
    """correctness.get(qid) is False 精确匹配：None（未知题）不算错题。"""

    def test_none_excluded_from_errors(self) -> None:
        items = [
            _make_q("wrong", task_type="t1"),
            _make_q("right", task_type="t1"),
            _make_q("unknown", task_type="t1"),
        ]
        # wrong=False（错题），right=True（正确题），unknown 不在 correctness（None）
        correctness: dict[str, bool] = {"wrong": False, "right": True}
        batches, count = build_batches(
            items,
            correctness,
            batch_size=10,
            min_class_per_batch=2,
            seed=0,
            correct_ratio=0.0,
        )
        # 仅 wrong 进入 batch，unknown 不算错题
        assert count == 1
        assert batches[0][0].question_id == "wrong"

    def test_explicit_false_only(self) -> None:
        """直接测试 _select_mixed_by_task_type 内部逻辑（single 单元粒度）。"""
        items = [
            _make_q("f1", task_type="t1"),
            _make_q("n1", task_type="t1"),  # None（未知）
            _make_q("t1", task_type="t1"),  # True（正确）
        ]
        correctness: dict[str, bool] = {"f1": False, "t1": True}
        rng = random.Random(0)
        result = _select_mixed_by_task_type(build_units(items), correctness, 0.0, rng)
        assert "t1" in result
        assert len(result["t1"]) == 1
        assert result["t1"][0].unit_id == "f1"

    def test_none_not_treated_as_correct(self) -> None:
        """None（未知）不进正确组，不被 correct_ratio 采样（single 单元粒度）。"""
        items = [
            _make_q("err", task_type="t1"),
            _make_q("unk", task_type="t1"),
        ]
        correctness: dict[str, bool] = {"err": False}
        rng = random.Random(0)
        result = _select_mixed_by_task_type(build_units(items), correctness, 0.5, rng)
        # 只有 err 一题错题，unk 不在 correctness 中 → get 返回 None → 不进 correct 组
        assert len(result["t1"]) == 1  # 只有错题，无正确题可混入
