"""pair_atomic_writer 纯 helper 单元测试。

覆盖三件核心行为：
- PairPendingBuffer：single 直通成单元、pair 按 pair_id 收齐才 emit、
  未配对孤儿可查询、绑定不一致（unit_hash 语义代偿）拒绝。
- write_accepted：全量 tmp + os.replace 原子写，落盘无残留 .tmp。
- read_accepted：build_units + validate_units 剔除磁盘悬挂孤儿（warn+drop）。

测试用真实 GeneratedQuestion 构造（single + 合法 AR pair + 故意缺 mirror 的孤儿），
跑完整 add → write → read 往返。
"""

from __future__ import annotations

import json

import pytest
from loguru import logger

from app.harness.pools import _q_to_dict
from app.question_gen.pair_atomic_writer import (
    PairPendingBuffer,
    read_accepted,
    write_accepted,
)
from core.types import GeneratedQuestion


def _make_single(qid: str = "s1") -> GeneratedQuestion:
    """构造一条真实 single 题（pair_id=None，question_role='single'）。"""
    return GeneratedQuestion(
        question_id=qid,
        video_id="vid_A",
        task_type="Action Reasoning",
        question=f"{qid} 问题文本？",
        options=("A. 甲", "B. 乙", "C. 丙", "D. 丁"),
        answer="B",
        source_nodes=("L2_0", "L3_1"),
        difficulty="medium",
    )


def _make_pair(
    pair_id: str = "p1",
    *,
    video_id: str = "vid_B",
    flip_axis: str = "before_after",
) -> tuple[GeneratedQuestion, GeneratedQuestion]:
    """构造一对合法 AR 孪生对（original + mirror，共享 pair_id/flip_axis）。"""
    original = GeneratedQuestion(
        question_id=f"{pair_id}_o",
        video_id=video_id,
        task_type="Action Reasoning",
        question="事件 X 发生在事件 Y 之前吗？",
        options=("A. 是", "B. 否", "C. 无关", "D. 无法判断"),
        answer="A",
        source_nodes=("L2_2",),
        difficulty="hard",
        pair_id=pair_id,
        question_role="pair_original",
        flip_axis=flip_axis,
    )
    mirror = GeneratedQuestion(
        question_id=f"{pair_id}_m",
        video_id=video_id,
        task_type="Action Reasoning",
        question="事件 Y 发生在事件 X 之前吗？",
        options=("A. 是", "B. 否", "C. 无关", "D. 无法判断"),
        answer="B",
        source_nodes=("L2_2",),
        difficulty="hard",
        pair_id=pair_id,
        question_role="pair_mirror",
        flip_axis=flip_axis,
    )
    return original, mirror


def test_single_add_emits_unit_immediately() -> None:
    """single 题 add 后立即返回 kind='single' 单元，不进 pending。"""
    buf = PairPendingBuffer()
    unit = buf.add(_make_single())
    assert unit is not None
    assert unit.kind == "single"
    assert unit.size == 1
    assert buf.pending_orphans() == []


def test_pair_emits_only_when_both_arrived() -> None:
    """pair 首个成员进 pending 返回 None，第二个到齐才 emit 一个 pair 单元。"""
    buf = PairPendingBuffer()
    original, mirror = _make_pair()

    assert buf.add(original) is None
    assert len(buf.pending_orphans()) == 1

    unit = buf.add(mirror)
    assert unit is not None
    assert unit.kind == "pair"
    assert unit.size == 2
    assert buf.pending_orphans() == []


def test_pair_completes_regardless_of_arrival_order() -> None:
    """mirror 先到、original 后到也能正确成对。"""
    buf = PairPendingBuffer()
    original, mirror = _make_pair()

    assert buf.add(mirror) is None
    unit = buf.add(original)
    assert unit is not None and unit.kind == "pair" and unit.size == 2


def test_dangling_original_stays_in_pending() -> None:
    """只落 original 未落 mirror → 悬挂在 pending，供调用方批次末尾检测。"""
    buf = PairPendingBuffer()
    original, _ = _make_pair()
    assert buf.add(original) is None
    orphans = buf.pending_orphans()
    assert len(orphans) == 1
    assert orphans[0].question_id == original.question_id


def test_inconsistent_pair_binding_rejected_and_partner_recoverable() -> None:
    """两成员 flip_axis 不一致 → 拒绝；且校验失败后首成员仍留 pending 可取回。"""
    buf = PairPendingBuffer()
    original, _ = _make_pair(flip_axis="before_after")
    _, bad_mirror = _make_pair(flip_axis="left_right")

    buf.add(original)
    with pytest.raises(ValueError, match="绑定不一致"):
        buf.add(bad_mirror)

    # Important: 先校验后删除——raise 后首成员未被驱逐，调用方仍能检测到悬挂项
    orphans = buf.pending_orphans()
    assert len(orphans) == 1
    assert orphans[0].question_id == original.question_id


def test_same_role_pair_rejected() -> None:
    """同 pair_id 两成员角色相同（两个 original）→ fail-fast 拒绝。"""
    buf = PairPendingBuffer()
    o1, _ = _make_pair()
    o2, _ = _make_pair()
    buf.add(o1)
    with pytest.raises(ValueError):
        buf.add(o2)


def test_write_read_roundtrip_keeps_pair_and_single(tmp_path) -> None:
    """add → write_accepted → read_accepted 往返：single 直通、pair 不拆、内容等价。"""
    buf = PairPendingBuffer()
    single = _make_single()
    original, mirror = _make_pair()

    units = []
    for q in (single, original, mirror):
        emitted = buf.add(q)
        if emitted is not None:
            units.append(emitted)
    assert buf.pending_orphans() == []
    assert len(units) == 2  # 1 single + 1 pair

    out = tmp_path / "accepted" / "bank.json"
    write_accepted(out, units)

    assert out.exists()
    assert not out.with_suffix(".tmp").exists()  # 原子写无残留

    loaded = read_accepted(out)
    kinds = sorted(u.kind for u in loaded)
    assert kinds == ["pair", "single"]

    loaded_pair = next(u for u in loaded if u.kind == "pair")
    assert loaded_pair.size == 2
    assert {q.question_id for q in loaded_pair.questions} == {
        original.question_id,
        mirror.question_id,
    }
    assert {q.flip_axis for q in loaded_pair.questions} == {"before_after"}


def test_read_drops_disk_dangling_orphan(tmp_path) -> None:
    """磁盘上某 pair 只有 original（缺 mirror）→ read_accepted warn+drop，不 raise。"""
    single = _make_single()
    original, _ = _make_pair()
    # 手工写入"只落 P 未落 Q"的记录：single + 孤零 original
    records = [_q_to_dict(single), _q_to_dict(original)]
    out = tmp_path / "bank.json"
    out.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")

    # loguru 不走标准 logging，用项目既定 sink 捕获模式（见 test_inference_pair_aggregate）
    captured: list[str] = []
    sink_id = logger.add(captured.append, level="WARNING", format="{message}")
    try:
        loaded = read_accepted(out)
    finally:
        logger.remove(sink_id)

    assert len(loaded) == 1
    assert loaded[0].kind == "single"
    assert loaded[0].questions[0].question_id == single.question_id
    # warn+drop 契约的 "warn" 半：悬挂孤儿必须告警，不静默丢弃
    assert any("悬挂孤儿" in msg for msg in captured), "悬挂孤儿未告警（静默丢弃）"


def test_read_raises_on_disk_corrupt_duplicate_role(tmp_path) -> None:
    """磁盘上同 pair_id 出现两个 pair_original（角色重复）→ 数据损坏，fail-fast raise。"""
    o1, _ = _make_pair()
    o2, _ = _make_pair()
    # 同 pair_id、两条都是 pair_original（size==2 但角色非法）→ 结构损坏
    dup = GeneratedQuestion(
        question_id="p1_o2",
        video_id=o2.video_id,
        task_type=o2.task_type,
        question="重复 original",
        options=o2.options,
        answer=o2.answer,
        source_nodes=o2.source_nodes,
        difficulty=o2.difficulty,
        pair_id=o1.pair_id,
        question_role="pair_original",
        flip_axis=o1.flip_axis,
    )
    records = [_q_to_dict(o1), _q_to_dict(dup)]
    out = tmp_path / "corrupt.json"
    out.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")

    with pytest.raises(ValueError, match="结构损坏"):
        read_accepted(out)


def test_read_raises_on_disk_binding_mismatch(tmp_path) -> None:
    """磁盘上合法成对但 flip_axis 绑定不一致 → 显式 raise（不依赖会被 -O 剥除的 assert）。"""
    original, _ = _make_pair(pair_id="p1", flip_axis="before_after")
    _, mirror = _make_pair(pair_id="p1", flip_axis="left_right")
    # 直接写盘绕过 write_accepted 的 from_pair 组装，模拟外部篡改的不一致成对
    records = [_q_to_dict(original), _q_to_dict(mirror)]
    out = tmp_path / "mismatch.json"
    out.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")

    with pytest.raises(ValueError, match="绑定不一致"):
        read_accepted(out)


def test_write_accepted_rejects_malformed_unit(tmp_path) -> None:
    """validate_units 闸门：结构非法的 pair 单元（size≠2）落盘前 fail-fast。"""
    from core.types import QuestionUnit

    original, _ = _make_pair()
    bad_unit = QuestionUnit("pair", "p1", "Action Reasoning", (original,))
    with pytest.raises(ValueError):
        write_accepted(tmp_path / "bad.json", [bad_unit])
