"""accepted 题库的 pair 原子成对落盘 helper（纯件，不依赖 pipeline）。

三件可复用纯件供 Phase 2 新 pipeline 的 on_accept 回调复用：``PairPendingBuffer``
（按 pair_id 收齐才 emit 单元）、``write_accepted``（tmp + os.replace 原子写）、
``read_accepted``（聚合成单元并剔除磁盘悬挂孤儿）。

wiring 归属：本模块**只是纯件**，不接任何生成侧回调；真正的 on_accept wiring
**见 Phase 2**。当前项目真实 accepted 写入点是 ``adversarial_filter.write_final_bank``
（Phase 2 待重建的旧代码），本模块沿用其 tmp + os.replace 模式但不 import/不改动它。

unit_hash 校验：``QuestionUnit.unit_hash`` 目前恒为 ""（填充是 Phase 2 的事），且
``GeneratedQuestion`` 不携带 unit_hash 字段。故把"unit_hash 不一致→拒"落地为**同
pair_id 两成员的绑定一致性校验**（video_id / task_type / flip_axis），不一致即
fail-fast raise，语义等价——只有 payload 绑定一致的孪生对才允许聚合。
"""

from __future__ import annotations

import json
import os
from collections import defaultdict
from typing import TYPE_CHECKING

from loguru import logger

from core.types import GeneratedQuestion, QuestionUnit

if TYPE_CHECKING:
    from pathlib import Path


def _check_pair_binding(first: GeneratedQuestion, second: GeneratedQuestion) -> None:
    """校验同 pair_id 两成员的绑定一致性，不一致 fail-fast（unit_hash 语义代偿）。

    参数 first/second 为先后到达的孪生对成员。video_id / task_type / flip_axis 任一
    不一致即 raise ValueError——绑定不一致的两条题目不构成同一 payload 的孪生对，
    拒绝聚合而非静默兜底。
    """
    mismatches: list[str] = []
    if first.video_id != second.video_id:
        mismatches.append(f"video_id: {first.video_id} != {second.video_id}")
    if first.task_type != second.task_type:
        mismatches.append(f"task_type: {first.task_type} != {second.task_type}")
    if first.flip_axis != second.flip_axis:
        mismatches.append(f"flip_axis: {first.flip_axis} != {second.flip_axis}")
    if mismatches:
        raise ValueError(
            f"pair {first.pair_id} 两成员绑定不一致（" + "；".join(mismatches) + "），拒绝聚合"
        )


class PairPendingBuffer:
    """按 pair_id 收齐孪生对才 emit 单元的有状态缓冲器。

    喂题接口 ``add`` 逐条消费题目：single 立即 emit ``QuestionUnit.from_single``；
    pair 成员先缓存，等同一 pair_id 的 original+mirror 都到齐才 emit 一个 pair 单元
    （复用 ``QuestionUnit.from_pair`` 走 fail-fast 校验）。批次末尾用
    ``pending_orphans`` 检测"只落 P 未落 Q"的悬挂项。
    """

    def __init__(self) -> None:
        """pending 以 pair_id 索引首个到达的孪生对成员，等伙伴到齐再 emit。"""
        self._pending: dict[str, GeneratedQuestion] = {}

    def add(self, q: GeneratedQuestion) -> QuestionUnit | None:
        """喂入一条题目 q，返回本次凑齐的单元或 None（pair 尚未配齐）。

        single 立即返回 kind="single" 单元；pair 首个成员缓存并返回 None，第二个
        成员到齐后返回 kind="pair" 单元。同 pair_id 两成员绑定不一致或角色非法（如
        两个 original）即 raise ValueError。

        关键实现:配齐后**先校验、成功组装出 unit 才从 pending 删除**——若
        ``_check_pair_binding`` / ``_order_pair`` raise，首成员仍留在 pending，调用方
        ``pending_orphans`` 可取回被拒的悬挂成员。绑定校验用显式 ValueError（不依赖
        会被 ``-O`` 剥除的 assert）。
        """
        if not q.pair_id:
            return QuestionUnit.from_single(q)

        partner = self._pending.get(q.pair_id)
        if partner is None:
            self._pending[q.pair_id] = q
            return None

        _check_pair_binding(partner, q)
        original, mirror = _order_pair(partner, q)
        unit = QuestionUnit.from_pair(original, mirror)
        del self._pending[q.pair_id]
        return unit

    def pending_orphans(self) -> list[GeneratedQuestion]:
        """返回仍未配齐的悬挂成员，供调用方在批次末尾检测"只落 P 未落 Q"。"""
        return list(self._pending.values())


def _order_pair(
    a: GeneratedQuestion, b: GeneratedQuestion
) -> tuple[GeneratedQuestion, GeneratedQuestion]:
    """按 question_role 把两成员 a/b 定序为 (original, mirror)。

    两成员不构成恰好 1 original + 1 mirror（角色缺失或重复）即 raise ValueError，
    防 next(...) 静默 StopIteration。
    """
    originals = [q for q in (a, b) if q.question_role == "pair_original"]
    mirrors = [q for q in (a, b) if q.question_role == "pair_mirror"]
    if len(originals) != 1 or len(mirrors) != 1:
        raise ValueError(
            f"pair {a.pair_id} 角色非法：original={len(originals)} mirror={len(mirrors)}，"
            "需各恰好 1 条"
        )
    return originals[0], mirrors[0]


def _validate_disk_pair(pair_id: str, group: list[GeneratedQuestion]) -> list[GeneratedQuestion]:
    """三态判定磁盘上同 pair_id 分组，区分悬挂孤儿（drop）与结构损坏（raise）。

    - 恰好 1 original + 1 mirror：合法孪生对，额外做 ``_check_pair_binding`` 显式绑定
      校验（video_id/task_type/flip_axis，-O 下仍生效），返回其两题。
    - size==1（只落 P 未落 Q）：业务上合法的悬挂孤儿，warn + drop，返回 []。
    - 其余（size>2 超员、或 size==2 角色重复/缺角色）：数据损坏/外部篡改，按 P5
      fail-loud，raise ValueError（含 pair_id 与成员构成），绝不静默吞。
    """
    originals = sum(1 for q in group if q.question_role == "pair_original")
    mirrors = sum(1 for q in group if q.question_role == "pair_mirror")
    if len(group) == 2 and originals == 1 and mirrors == 1:
        _check_pair_binding(group[0], group[1])
        return group
    if len(group) == 1:
        logger.warning("磁盘悬挂孤儿 pair {}：仅 1 成员（缺伙伴），warn+drop 该 unit", pair_id)
        return []
    raise ValueError(
        f"磁盘 pair {pair_id} 结构损坏：成员数={len(group)}"
        f"（original={originals} mirror={mirrors}），需恰好 1 original + 1 mirror"
    )


def _keep_complete_disk_pairs(questions: list[GeneratedQuestion]) -> list[GeneratedQuestion]:
    """筛选磁盘读回的题目：single 全保留、pair 按 ``_validate_disk_pair`` 三态处理。

    悬挂孤儿 warn+drop、结构损坏/绑定不一致 raise ValueError、合法成对保留后交给
    build_units（single 全保留）。
    """
    by_pair: dict[str, list[GeneratedQuestion]] = defaultdict(list)
    singles: list[GeneratedQuestion] = []
    for q in questions:
        if q.pair_id:
            by_pair[q.pair_id].append(q)
        else:
            singles.append(q)

    kept_pairs = [q for pid, grp in by_pair.items() for q in _validate_disk_pair(pid, grp)]
    return singles + kept_pairs


def write_accepted(path: Path, units: list[QuestionUnit]) -> None:
    """把 units 全量原子写到 path（tmp + os.replace），孪生对两题相邻落盘。

    parent 不存在则自动创建；任一 pair 单元结构非法（size≠2）落盘前 fail-fast raise。

    关键实现:沿用 ``write_final_bank`` 的原子写模式（先写同目录 ``.tmp`` 再
    ``os.replace`` 覆盖，保证读到的 JSON 恒完整）。序列化复用 T9 pools.py 的
    ``_q_to_dict``（唯一 GeneratedQuestion↔dict schema，含 pair 四字段），函数内
    import 规避 app.question_gen↔app.harness 循环依赖。
    """
    from app.harness.pools import _q_to_dict
    from app.harness.question_units import flatten_units, validate_units

    validate_units(units)
    records = [_q_to_dict(q) for q in flatten_units(units)]

    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".tmp")
    tmp.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
    os.replace(str(tmp), str(path))
    logger.info("accepted 题库全量原子写: {} 单元 / {} 题 → {}", len(units), len(records), path)


def read_accepted(path: Path) -> list[QuestionUnit]:
    """读回 path 的 accepted JSON → 聚合为单元列表。

    磁盘是外部输入，按 P5 全量校验后再用：``_keep_complete_disk_pairs`` 对 pair 分组三态处理
    ——"只落 P 未落 Q"的悬挂孤儿 warn+drop（不进结果、不 raise）；结构损坏（超员/
    角色重复）或绑定不一致（video_id/task_type/flip_axis）显式 raise ValueError（不
    依赖 build_units 内会被 ``-O`` 剥除的 assert）。single 全保留。

    关键实现:反序列化复用 T9 pools.py 的 ``_dict_to_q``（pair 四字段 .get 兼容），
    函数内 import 规避循环依赖。sift 后 ``build_units`` 聚合、``validate_units`` 二次
    防御闸门。
    """
    from app.harness.pools import _dict_to_q
    from app.harness.question_units import build_units, validate_units

    raw = json.loads(path.read_text(encoding="utf-8"))
    questions = [_dict_to_q(d) for d in raw]
    kept = _keep_complete_disk_pairs(questions)
    return validate_units(build_units(kept))
