"""Phase B 独立后置对抗过滤层 — 作弊者门 + 配对翻转门。

在 Phase A 产物 accepted_questions.json 之上，用完整 inference agent 揪残余
shortcut：作弊门（agent 秒杀=太简单，剔除）+ 翻转门（agent 答案须随问题翻转）。
不改 Phase A 状态机；过滤进度存独立 adversarial_verdicts 表。

设计: research-wiki/designs/2026-07-14-adversarial-question-gen-phaseB-design.md
"""

from __future__ import annotations

import dataclasses
import enum
import hashlib
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING, Protocol, overload

from json_repair import repair_json
from loguru import logger

from core.types import GeneratedQuestion

if TYPE_CHECKING:
    from collections.abc import Callable

    import numpy as np

    from app.question_gen.adversarial_config import AdversarialFilterConfig
    from app.question_gen.pipeline_v2 import PipelineConfig
    from app.question_gen.run_store import QuestionGenStore
    from app.question_gen.sampler_v2 import MaterialContext
    from app.tree.index import TreeIndex
    from core.protocols import LLMProvider, VLMProvider

_VALID_LETTERS = ("A", "B", "C", "D")


class FlipDecision(enum.Enum):
    """翻转门判定结果。"""

    PASSED = "passed"
    FILTERED_NO_FLIP = "filtered_no_flip"
    FLIP_SKIPPED = "flip_skipped"


@overload
def question_hash(question: GeneratedQuestion) -> str: ...


@overload
def question_hash(question: str, options: tuple[str, ...], answer: str) -> str: ...


def question_hash(
    question: GeneratedQuestion | str,
    options: tuple[str, ...] | None = None,
    answer: str | None = None,
) -> str:
    """题 payload（question+options+answer）的稳定 hash，防 JSON 变动误用旧 verdict。

    两种等价调用形态：整题 `question_hash(q)` 或散参 `question_hash(题面, 选项, 答案)`；
    前者按题面/选项/答案拆解后走同一路径，保证与散参形态哈希一致。

    参数:
        question: 整条 GeneratedQuestion，或题目文本字符串。
        options: 选项元组（散参形态必传）。
        answer: 正确答案字母（散参形态必传）。

    返回:
        16 位十六进制摘要。

    异常:
        TypeError: 传入题面字符串却缺 options / answer（散参形态参数不全）。
    """
    if isinstance(question, GeneratedQuestion):
        return question_hash(question.question, question.options, question.answer)
    if options is None or answer is None:
        raise TypeError("散参形态 question_hash 需同时传入 (question, options, answer)")
    payload = json.dumps(
        {
            "question": question,
            "options": list(options),
            "answer": answer,
        },
        ensure_ascii=False,
        sort_keys=True,
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]


def agent_config_fingerprint(*, skill_mode: str, max_steps: int, model: str) -> str:
    """agent 配置指纹（skill_mode/max_steps/model），变化则该题 verdict 作废。"""
    raw = f"{skill_mode}|{max_steps}|{model}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]


def canonical_answer_text(options: tuple[str, ...], letter: str | None) -> str | None:
    """把 agent 预测的选项字母映射为选项规范化文本；非法/越界返回 None。

    镜像题选项会重洗牌，字母无语义，必须按选项文本比较。防御性：空串、多字符、
    非 A-D 字母、越界一律返回 None，下游据此保守 flip_skipped，绝不崩溃、绝不误杀。

    参数:
        options: 选项元组。
        letter: agent 预测字母（大小写不敏感，须为单个 A-D），其余一律无效。

    返回:
        去掉 "X. " 前缀的选项文本；无效时 None。
    """
    if not isinstance(letter, str):
        return None
    s = letter.strip().upper()
    if s not in _VALID_LETTERS:
        return None
    idx = ord(s) - ord("A")
    if idx >= len(options):
        return None
    opt = options[idx]
    prefix = f"{s}. "
    return opt[len(prefix) :] if opt.startswith(prefix) else opt


def judge_flip(*, p_text: str | None, q_text: str | None) -> FlipDecision:
    """按 canonical 文本判翻转：任一无效→skipped；不同→passed；相同→filtered。

    参数:
        p_text: 原题 P 的 agent 所选 canonical 文本。
        q_text: 镜像题 Q 的 agent 所选 canonical 文本。

    返回:
        FlipDecision。
    """
    if p_text is None or q_text is None:
        return FlipDecision.FLIP_SKIPPED
    if p_text.strip() != q_text.strip():
        return FlipDecision.PASSED
    return FlipDecision.FILTERED_NO_FLIP


class AgentRunner(Protocol):
    """完整 inference agent 试答端口 — Phase B 只依赖此接口（便于 mock）。

    实现见 Task 10 的 _RealAgentRunner（复用 run_inference + RunLogImpl）。
    """

    model: str
    skill_mode: str  # 从首次定义即入 Protocol，保证 Task 6/8/10 指纹口径一致

    async def predict(
        self,
        questions: list[GeneratedQuestion],
        *,
        max_steps: int,
        run_id: str,
    ) -> dict[str, str | None]:
        """跑完整 agent，返回 question_id → 预测答案字母（无预测为 None）。"""
        ...


def _cheat_hash(question: GeneratedQuestion) -> str:
    """计算作弊门题面 hash（question/options/answer），供两门与终判统一续跑主键。"""
    return question_hash(question)


def _recover_survivors(
    questions: list[GeneratedQuestion],
    store: QuestionGenStore,
    cfg_fp: str,
) -> list[GeneratedQuestion]:
    """从已落 cheat verdict 恢复"agent 答错"的题（续跑，不重跑 agent）。"""
    survivors: list[GeneratedQuestion] = []
    for q in questions:
        rows = store._conn.execute(
            "SELECT agent_correct FROM adversarial_verdicts "
            "WHERE question_id=? AND question_hash=? AND stage='cheat' AND agent_config=?",
            (q.question_id, _cheat_hash(q), cfg_fp),
        ).fetchall()
        if rows and rows[0][0] == 0:
            survivors.append(q)
    return survivors


async def run_cheater_gate(
    questions: list[GeneratedQuestion],
    *,
    agent: AgentRunner,
    store: QuestionGenStore,
    config: AdversarialFilterConfig,
    round_no: int,
    run_id: str,
) -> list[GeneratedQuestion]:
    """作弊门：完整 agent 试答；答对→filtered_too_easy，答错→cheat passed 待翻转。

    续跑：已在当前 hash+config 有 cheat verdict 的题跳过重跑。agent_config 变
    化时先作废该题旧 verdict。预测立即落表（崩溃不丢）。

    参数:
        questions: 待判定的 AR 题列表。
        agent: 完整 agent 试答端口。
        store: verdict 持久化。
        config: 过滤配置（提供 max_steps）。
        round_no: 当前轮次。
        run_id: agent 推理 run 标识。

    返回:
        agent 答错的题（进翻转门）——含"已完成续跑恢复的存活者"与"本轮新判答错者"
        两部分合并。答错题的 cheat 预测字母已落 adversarial_verdicts 表
        （stage='cheat'），翻转门经 `_read_cheat_prediction` 从表读取复用（不重跑）。
    """
    cfg_fp = agent_config_fingerprint(
        skill_mode=agent.skill_mode,
        max_steps=config.adversarial_agent_max_steps,
        model=agent.model,
    )
    todo: list[GeneratedQuestion] = []
    completed: list[GeneratedQuestion] = []
    for q in questions:
        h = _cheat_hash(q)
        store.invalidate_stale_config(q.question_id, cfg_fp)
        if "cheat" in store.completed_stages(q.question_id, h, cfg_fp):
            completed.append(q)
        else:
            todo.append(q)

    # C1: 无条件先从已完成题恢复存活者（agent 答错），再对未判题跑 agent 追加。
    # 两者都流向翻转门——绝不因 todo 非空而丢掉已完成的存活者（混合续跑正确性）。
    survivors: list[GeneratedQuestion] = _recover_survivors(completed, store, cfg_fp)

    if todo:
        preds = await agent.predict(
            todo, max_steps=config.adversarial_agent_max_steps, run_id=run_id
        )
        for q in todo:
            pred = preds.get(q.question_id)
            correct = pred is not None and pred.strip().upper() == q.answer.strip().upper()
            verdict = "filtered_too_easy" if correct else "passed"
            store.record_verdict(
                question_id=q.question_id,
                question_hash=_cheat_hash(q),
                stage="cheat",
                round=round_no,
                agent_prediction=pred,
                agent_correct=correct,
                verdict=verdict,
                pair_id=None,
                agent_config=cfg_fp,
            )
            if not correct:
                survivors.append(q)
    logger.info(
        "作弊门: {} 题（续跑复用 {}，新判 {}）→ 存活 {}",
        len(questions),
        len(completed),
        len(todo),
        len(survivors),
    )
    return survivors


_PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "store" / "prompts" / "question_gen"


def _rebuild_material(tree: TreeIndex, source_nodes: tuple[str, ...]) -> MaterialContext:
    """从 source_nodes 重建镜像生成所需素材（字幕 + 帧）。

    复用 sampler_v2 的采集辅助；anchor/cross_l2_texts 镜像生成不需要，置空。

    参数:
        tree: 三层树索引（Phase B 持树）。
        source_nodes: 原题来源节点 ID 元组。

    返回:
        仅含 subtitle_sentences / frame_paths 的 MaterialContext。
    """
    from app.question_gen.sampler_v2 import (
        MaterialContext as _MaterialContext,
    )
    from app.question_gen.sampler_v2 import (
        _collect_frame_paths,
        _collect_subtitle_sentences,
    )

    # 保序去重：source_nodes 若同时含父子节点，两者子树重叠会重复采集，去冗余再喂 VLM。
    subtitles = list(dict.fromkeys(_collect_subtitle_sentences(tree, source_nodes)))
    frames: list[str] = []
    for nid in source_nodes:
        frames.extend(_collect_frame_paths(tree, nid))
    frames = list(dict.fromkeys(frames))
    return _MaterialContext(
        anchor=None,  # 镜像 prompt 不用 anchor
        source_nodes=source_nodes,
        subtitle_sentences=subtitles,
        frame_paths=frames,
        cross_l2_texts=[],
    )


def _parse_mirror(raw: str) -> dict | None:
    """解析 VLM 镜像响应；{"mirror": null} 或解析失败 → None。

    参数:
        raw: VLM 原始文本响应。

    返回:
        mirror 字典；null / 非法结构 / 解析失败一律 None。
    """
    content = raw.strip()
    if "```" in content:
        for part in content.split("```"):
            s = part.strip()
            if s.startswith("json"):
                s = s[4:].strip()
            if s.startswith("{"):
                content = s
                break
    try:
        data = json.loads(repair_json(content, return_objects=False))
    except (json.JSONDecodeError, TypeError, ValueError):
        # 畸形 VLM 响应绝不中断本轮：解析失败 → None（上游按 flip_skipped 处理，设计 §4.2）
        return None
    if not isinstance(data, dict):
        return None
    mirror = data.get("mirror")
    return mirror if isinstance(mirror, dict) else None


def _existing_frames(frame_paths: list[str]) -> list[str]:
    """过滤掉磁盘上不存在的帧路径，保序返回有效帧列表（防失效帧喂给 VLM）。"""
    return [p for p in frame_paths if Path(p).exists()]


def _build_mirror_question(question: GeneratedQuestion, mirror: dict) -> GeneratedQuestion | None:
    """从解析出的 mirror dict 构造镜像题；字段缺失/类型错误 → None。"""
    try:
        options = tuple(str(o) for o in mirror["options"])
        answer = str(mirror["answer"]).strip().upper()
        m_question = str(mirror["question"])
    except (KeyError, TypeError):
        return None
    return GeneratedQuestion(
        question_id=f"{question.question_id}_mirror",
        video_id=question.video_id,
        task_type=question.task_type,
        question=m_question,
        options=options,
        answer=answer,
        source_nodes=question.source_nodes,
        difficulty=question.difficulty,
        sub_pattern=question.sub_pattern,
    )


async def generate_mirror_question(
    question: GeneratedQuestion,
    *,
    flip_axis: str,
    vlm: VLMProvider,
    material: MaterialContext,
    session_id: str,
) -> GeneratedQuestion | None:
    """VLM 生成翻转 flip_axis 的镜像题；正解 canonical 与原题相同则返 None。

    参数:
        question: 原题。
        flip_axis: 翻转轴（"before/after" | "first/last"）。
        vlm: VLM 端口。
        material: 重建素材（frame_paths / subtitles）。
        session_id: 遥测会话 ID。

    返回:
        镜像 GeneratedQuestion（question_id 加 "_mirror" 后缀，不进题库）；
        素材为空 / null / 正解相同 / 解析失败均返回 None（上层记 flip_skipped）。
    """
    # 素材防御：无字幕 AND 无有效帧 → 不调 VLM，直接 flip_skipped（避免空素材空转 VLM）。
    valid_frames = _existing_frames(list(material.frame_paths))
    if not material.subtitle_sentences and not valid_frames:
        logger.warning(
            "镜像素材为空（source_nodes 可能缺失或帧失效），跳过: {}", question.question_id
        )
        return None
    system = (_PROMPTS_DIR / "ar_mirror_question.md").read_text(encoding="utf-8")
    subs = "\n".join(f"  - {s}" for s in material.subtitle_sentences)
    user = (
        f"## Original Question\n{question.question}\n"
        "## Options\n" + "\n".join(question.options) + "\n"
        f"## Correct Answer\n{question.answer}\n"
        f"## Flip Axis\n{flip_axis}\n"
        f"## Subtitles\n{subs}\n"
    )
    messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
    resp = await vlm.chat_with_images(messages, valid_frames, session_id=session_id)
    mirror = _parse_mirror(resp.content)
    if mirror is None:
        return None
    mirror_q = _build_mirror_question(question, mirror)
    if mirror_q is None:
        return None
    # 镜像正解字面校验：canonical(P) 必须 != canonical(Q)（按选项文本比较，非字母）
    p_text = canonical_answer_text(question.options, question.answer)
    q_text = canonical_answer_text(mirror_q.options, mirror_q.answer)
    if p_text is None or q_text is None or p_text.strip() == q_text.strip():
        logger.info(
            "镜像正解与原题正解无区分（或无效），跳过翻转门: question_id={}",
            question.question_id,
        )
        return None
    return mirror_q


def _read_cheat_prediction(
    store: QuestionGenStore, q: GeneratedQuestion, cfg_fp: str
) -> str | None:
    """从 adversarial_verdicts 读作弊门落库的 P 预测字母（C2：绝不重跑 agent）。

    按 (question_id, 当前题面 hash, stage='cheat', 当前 agent_config) 定位那条
    由 `run_cheater_gate` 写入的预测；无匹配行返回 None。
    """
    row = store._conn.execute(
        "SELECT agent_prediction FROM adversarial_verdicts "
        "WHERE question_id=? AND question_hash=? AND stage='cheat' AND agent_config=?",
        (q.question_id, _cheat_hash(q), cfg_fp),
    ).fetchone()
    return row[0] if row else None


def _persist_flip(
    store: QuestionGenStore,
    q: GeneratedQuestion,
    decision: FlipDecision,
    mirror_pred: str | None,
    round_no: int,
    cfg_fp: str,
    pair_id: str,
) -> None:
    """写 flip_original + flip_mirror 两条 verdict，并按判定改写原题 cheat 行。

    flip_original 复用 P 的作弊门预测；flip_mirror 记镜像预测；两者同 pair_id 关联。
    FILTERED_NO_FLIP 时把 cheat 行 verdict 改判 filtered_no_flip（与终判排除双保险）；
    passed / flip_skipped 时 cheat 行保持 passed。
    """
    h = _cheat_hash(q)
    p_pred = _read_cheat_prediction(store, q, cfg_fp)
    store.record_verdict(
        question_id=q.question_id,
        question_hash=h,
        stage="flip_original",
        round=round_no,
        agent_prediction=p_pred,
        agent_correct=None,
        verdict=decision.value,
        pair_id=pair_id,
        agent_config=cfg_fp,
    )
    store.record_verdict(
        question_id=q.question_id,
        question_hash=h,
        stage="flip_mirror",
        round=round_no,
        agent_prediction=mirror_pred,
        agent_correct=None,
        verdict=decision.value,
        pair_id=pair_id,
        agent_config=cfg_fp,
    )
    if decision is FlipDecision.FILTERED_NO_FLIP:
        store.record_verdict(
            question_id=q.question_id,
            question_hash=h,
            stage="cheat",
            round=round_no,
            agent_prediction=p_pred,
            agent_correct=False,
            verdict="filtered_no_flip",
            pair_id=None,
            agent_config=cfg_fp,
        )


async def _judge_one_flip(
    q: GeneratedQuestion,
    flip_axis: str | None,
    *,
    agent: AgentRunner,
    vlm: VLMProvider,
    trees: dict[str, TreeIndex],
    store: QuestionGenStore,
    cfg_fp: str,
    config: AdversarialFilterConfig,
    run_id: str,
    session_id: str,
) -> tuple[FlipDecision, str | None]:
    """跑单题翻转判定，返回 (decision, 镜像预测字母)。

    原题 P 预测**只从 adversarial_verdicts 表读作弊门落的行**（不重跑 agent），
    故 `store` 与 `cfg_fp` 必传（C2：按 (question_id, question_hash, stage='cheat',
    agent_config) 定位那条预测）。镜像造不出 / 素材缺失 → FLIP_SKIPPED（不跑 agent）。
    """
    tree = trees.get(q.video_id)
    if tree is None or flip_axis is None:
        return FlipDecision.FLIP_SKIPPED, None
    material = _rebuild_material(tree, q.source_nodes)
    mirror = await generate_mirror_question(
        q, flip_axis=flip_axis, vlm=vlm, material=material, session_id=session_id
    )
    if mirror is None:
        return FlipDecision.FLIP_SKIPPED, None
    preds = await agent.predict(
        [mirror], max_steps=config.adversarial_agent_max_steps, run_id=f"{run_id}_mirror"
    )
    q_pred = preds.get(mirror.question_id)
    p_pred = _read_cheat_prediction(store, q, cfg_fp)  # 复用作弊门 P 预测（不重跑）
    p_text = canonical_answer_text(q.options, p_pred)
    q_text = canonical_answer_text(mirror.options, q_pred)
    return judge_flip(p_text=p_text, q_text=q_text), q_pred


async def run_flip_gate(
    survivors: list[GeneratedQuestion],
    *,
    agent: AgentRunner,
    vlm: VLMProvider,
    store: QuestionGenStore,
    trees: dict[str, TreeIndex],
    config: AdversarialFilterConfig,
    round_no: int,
    run_id: str,
    session_id: str,
) -> list[GeneratedQuestion]:
    """翻转门：不支持 flip 的终判 passed；支持的按 canonical 翻转判定。

    P 预测复用作弊门落表结果（不重跑）；仅新跑镜像 Q。任一无效 / 镜像失败 →
    flip_skipped（保留题，只经作弊门，不误杀）。镜像题只用于判定，不进题库。

    参数:
        survivors: 作弊门存活（agent 答错）的题列表。
        agent: 完整 agent 试答端口（仅对镜像 Q 调用）。
        vlm: 镜像题生成 VLM 端口。
        store: verdict 持久化（含作弊门 P 预测来源）。
        trees: video_id → 三层树索引（重建镜像素材用）。
        config: 过滤配置（提供 max_steps）。
        round_no: 当前轮次（构造 pair_id）。
        run_id: agent 推理 run 标识。
        session_id: VLM 遥测会话 ID。

    返回:
        终判 verdict∈{passed, flip_skipped} 的题（filtered_no_flip 被剔除）。
    """
    from app.question_gen.strategy_action_recognition import _AR_PATTERN_BY_NAME

    cfg_fp = agent_config_fingerprint(
        skill_mode=agent.skill_mode,
        max_steps=config.adversarial_agent_max_steps,
        model=agent.model,
    )
    kept: list[GeneratedQuestion] = []
    for q in survivors:
        sp = _AR_PATTERN_BY_NAME.get(q.sub_pattern or "")
        if sp is None or not sp.supports_flip:
            kept.append(q)  # cheat 已记 passed，无需改写
            continue
        decision, mirror_pred = await _judge_one_flip(
            q,
            sp.flip_axis,
            agent=agent,
            vlm=vlm,
            trees=trees,
            store=store,
            cfg_fp=cfg_fp,
            config=config,
            run_id=run_id,
            session_id=session_id,
        )
        pair_id = f"{q.question_id}::{round_no}"
        _persist_flip(store, q, decision, mirror_pred, round_no, cfg_fp, pair_id)
        if decision is not FlipDecision.FILTERED_NO_FLIP:
            kept.append(q)  # passed 或 flip_skipped 都保留
    logger.info("翻转门: {} 存活 → 保留 {}", len(survivors), len(kept))
    return kept


def _question_to_record(q: GeneratedQuestion) -> dict:
    """把题目序列化为最终题库 JSON 记录（字段与 loader.load_benchmark 读回口径一致）。"""
    return {
        "question_id": q.question_id,
        "video_id": q.video_id,
        "task_type": q.task_type,
        "question": q.question,
        "options": list(q.options),
        "answer": q.answer,
        "source_nodes": list(q.source_nodes),
        "difficulty": q.difficulty,
        "family": q.family,
        "skill_target": q.skill_target,
        "difficulty_steps": q.difficulty_steps,
        "sub_pattern": q.sub_pattern,
    }


def write_final_bank(
    out_path: Path,
    store: QuestionGenStore,
    questions_by_id: dict[str, GeneratedQuestion],
    cfg_fp: str,
) -> list[dict]:
    """按终判 passed 全量重建最终题库 JSON（镜像题绝不入库）。

    终判集合来自 `final_passed_question_ids`（当前 hash+config 下 cheat=passed 且
    无 filtered_no_flip 行）。镜像题不在 questions_by_id 中，天然被排除。

    原子性：先写同目录 `.tmp`，再 `os.replace` 覆盖目标，保证任何时刻读到的
    final JSON 都是完整的（迭代循环每轮全量重写，崩溃可从 verdicts 表重建）。

    参数:
        out_path: 输出 JSON 路径。
        store: verdict 来源。
        questions_by_id: question_id → 原题（仅 Phase A 产物，不含镜像）。
        cfg_fp: 当前 agent 配置指纹。

    返回:
        写入的记录列表（保持 questions_by_id 的插入顺序）。
    """
    hash_by_qid = {qid: _cheat_hash(q) for qid, q in questions_by_id.items()}
    passed = store.final_passed_question_ids(hash_by_qid, cfg_fp)
    records = [
        _question_to_record(questions_by_id[qid]) for qid in questions_by_id if qid in passed
    ]
    out_path.parent.mkdir(parents=True, exist_ok=True)
    tmp = out_path.with_suffix(".tmp")
    tmp.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
    os.replace(str(tmp), str(out_path))
    logger.info("final 题库全量重写: {} 题 → {}", len(records), out_path)
    return records


def _report_difficulty(store: QuestionGenStore, *, round_no: int, threshold: float) -> float:
    """记录并按阈值告警本轮 agent 正确率（作弊门聚合）。

    正确率来自 `cheat_agent_accuracy`（stage='cheat' 的 agent_correct 平均），高于
    阈值说明 agent 太容易答对 = 出题偏简单，据此告警提示提高难度。

    参数:
        store: verdict 来源。
        round_no: 当前轮次。
        threshold: 正确率告警阈值（超过即 WARNING）。

    返回:
        本轮 agent 正确率。
    """
    acc = store.cheat_agent_accuracy(round_no)
    logger.info("难度报告 round={}: agent 正确率={:.2%}", round_no, acc)
    if acc > threshold:
        logger.warning(
            "出题太简单: round={} agent 正确率={:.2%} > 阈值 {:.2%}",
            round_no,
            acc,
            threshold,
        )
    return acc


class BackfillFn(Protocol):
    """补生成回调 — 缺额驱动，返回新增 AR 题。

    真实实现由 Task 10 用 run_pipeline_v2 组装（传 initial_used_node_ids /
    initial_embed_pool / seq_offset 防与已有题碰撞）；单测可注入返回定制新题的假实现。
    """

    async def __call__(
        self,
        deficit: int,
        round_no: int,
        existing: dict[str, GeneratedQuestion],
    ) -> list[GeneratedQuestion]:
        """按缺额补生成新题。

        参数:
            deficit: 目标缺额（target - 当前终判 passed 数）。
            round_no: 当前轮次。
            existing: 已有全部原题（供实现计算 used_node_ids / seq_offset）。

        返回:
            新增 AR 题列表（长度应等于 deficit）。
        """
        ...


async def run_adversarial_rounds(
    initial_questions: list[GeneratedQuestion],
    *,
    agent: AgentRunner,
    vlm: VLMProvider,
    store: QuestionGenStore,
    trees: dict[str, TreeIndex],
    config: AdversarialFilterConfig,
    final_path: Path,
    target: int,
    backfill: BackfillFn,
    session_id: str,
) -> None:
    """两门 + 补生成迭代主循环，每轮全量重写 final 并做难度报告。

    每轮：作弊门 → 翻转门 → 全量重写 `accepted_questions_final.json`（原子）→ 难度报告。
    缺额 deficit = target - 终判 passed 数；deficit≤0 或已达轮次上限即停止（不无限循环），
    否则调 `backfill` 补生成 deficit 题并只对新题重跑两门（已判题走续跑，不重跑）。

    参数:
        initial_questions: 首轮 AR 题（来自 accepted_questions.json 过滤）。
        agent: 完整 agent 试答端口。
        vlm: 镜像题生成 VLM 端口。
        store: verdict 持久化。
        trees: video_id → 三层树索引（重建镜像素材用）。
        config: 过滤配置（轮次上限 / max_steps / 难度阈值）。
        final_path: 最终题库 JSON 路径（每轮全量原子重写）。
        target: 目标 passed 题数（缺额 = target - passed，锁定为首轮 AR 题数）。
        backfill: 补生成回调 (deficit, round_no, existing) → 新增题；Task 10 用
                  run_pipeline_v2 实现，单测可 mock。
        session_id: 遥测会话 ID（派生各门 run_id）。
    """
    cfg_fp = agent_config_fingerprint(
        skill_mode=agent.skill_mode,
        max_steps=config.adversarial_agent_max_steps,
        model=agent.model,
    )
    all_questions: dict[str, GeneratedQuestion] = {q.question_id: q for q in initial_questions}
    pending = list(initial_questions)
    passed_now = 0
    for round_no in range(config.adversarial_max_rounds):
        survivors = await run_cheater_gate(
            pending,
            agent=agent,
            store=store,
            config=config,
            round_no=round_no,
            run_id=f"{session_id}_cheat_{round_no}",
        )
        await run_flip_gate(
            survivors,
            agent=agent,
            vlm=vlm,
            store=store,
            trees=trees,
            config=config,
            round_no=round_no,
            run_id=f"{session_id}_flip_{round_no}",
            session_id=session_id,
        )
        passed_now = len(write_final_bank(final_path, store, all_questions, cfg_fp))
        _report_difficulty(store, round_no=round_no, threshold=config.difficulty_warn_threshold)
        deficit = target - passed_now
        if deficit <= 0 or round_no + 1 >= config.adversarial_max_rounds:
            break
        new_qs = await backfill(deficit, round_no, all_questions)
        # 真实 backfill 经 run_pipeline_v2 只返回过门的题：部分题被 gate 拒 = 常态欠产。
        # 容忍 len(new_qs) <= deficit——欠产先并入，deficit 下轮重算继续补，max_rounds 兜底
        # 防死循环。多产（> deficit）才是契约被破坏的 bug，保留 sanity 断言。
        assert len(new_qs) <= deficit, (
            f"backfill 多产: 需 {deficit} 实得 {len(new_qs)}（超额 = 契约被破坏）"
        )
        if len(new_qs) < deficit:
            logger.warning(
                "backfill 欠产: 需 {} 实得 {}，本轮先并入，后续轮次继续补",
                deficit,
                len(new_qs),
            )
        for q in new_qs:
            all_questions[q.question_id] = q
        pending = new_qs  # 只对新补的题重新过滤（已判题走续跑）
    logger.info("对抗过滤结束: final={} 题（target={}）", passed_now, target)


# ---------------------------------------------------------------------------
# Task 10: 真实 agent 装配 — _RealAgentRunner（run_inference + RunLogImpl 回读）
# ---------------------------------------------------------------------------


class _RealAgentRunner:
    """AgentRunner 实现 — 复用 run_inference + RunLogImpl 读回预测。

    每次 predict 开一次 HarnessLog（按 run_id 幂等 upsert），跑完整 agent 推理把
    prediction 落 predictions 表，再用只读 RunLogImpl 按 (run_id, question_ids) 读回。
    prediction 是 submit_answer 的答案字母（A/B/C/D）；agent 报错/未提交时该行
    prediction 为 None，据此保守处理（不误判）。

    参数:
        llm: 推理 LLMProvider（共享注入）。
        tool_dispatch_fn: InferenceDepsRouter.create_dispatch() 返回的调度闭包。
        prompt_builder: InferenceDepsRouter.create_prompt_builder() 返回的构建闭包。
        db_path: HarnessLog / RunLogImpl 的 sqlite 路径。
        concurrency: run_inference 并发数（asyncio.Semaphore 容量）。
        skill_mode: skill 模式（"auto"/"manual"/"none"）——同时是指纹来源，须与
            router 的 skill_mode 一致，保证续跑/失效口径稳定。
        model: 推理模型名——指纹来源之一。
    """

    def __init__(
        self,
        *,
        llm: LLMProvider,
        tool_dispatch_fn: Callable[..., object],
        prompt_builder: Callable[[GeneratedQuestion], tuple[str, str]],
        db_path: str,
        concurrency: int,
        skill_mode: str,
        model: str,
    ) -> None:
        self._llm = llm
        self._dispatch = tool_dispatch_fn
        self._builder = prompt_builder
        self._db_path = db_path
        self._concurrency = concurrency
        self.skill_mode = skill_mode
        self.model = model

    async def predict(
        self,
        questions: list[GeneratedQuestion],
        *,
        max_steps: int,
        run_id: str,
    ) -> dict[str, str | None]:
        """跑完整 agent，回读 predictions 表，返回 question_id → 预测字母（无预测 None）。

        参数:
            questions: 待推理题目列表。
            max_steps: AgentLoop 单题最大步数。
            run_id: 本次推理 run 标识（predictions 表按此过滤回读）。

        返回:
            question_id → 预测答案字母（缺失/未提交为 None）。
        """
        from app.harness.inference import run_inference
        from app.harness.log import HarnessLog, RunLogImpl

        with HarnessLog(self._db_path, run_id) as log:
            await run_inference(
                questions,
                llm=self._llm,
                tool_dispatch_fn=self._dispatch,
                prompt_builder=self._builder,
                log=log,
                run_id=run_id,
                concurrency=self._concurrency,
                max_steps=max_steps,
                skill_mode=self.skill_mode,
            )
        rows = await RunLogImpl(self._db_path).get_predictions(
            run_id, question_ids=[q.question_id for q in questions]
        )
        # 契约：每个入参 qid 都必须有键。先全填 None，再用回读行覆盖——回读缺行
        # （agent 未落库/异常）的 qid 保持 None，不静默丢键。
        preds: dict[str, str | None] = {q.question_id: None for q in questions}
        for r in rows:
            preds[r["question_id"]] = r["prediction"]
        return preds


# ---------------------------------------------------------------------------
# Task 10: 真实 backfill 装配 — 缺额驱动 run_pipeline_v2
# ---------------------------------------------------------------------------


def _max_existing_seq(existing: dict[str, GeneratedQuestion]) -> int:
    """从已有题 question_id（格式 "{video_id}_{task_type}_{seq:04d}"）取最大 seq。

    只解析末段为纯数字的 id；无可解析 id → 0（新 run 从 seq_offset+1 起编，防撞。）
    """
    max_seq = 0
    for qid in existing:
        tail = qid.rsplit("_", 1)[-1]
        if tail.isdigit():
            max_seq = max(max_seq, int(tail))
    return max_seq


def build_backfill(
    *,
    trees: dict[str, TreeIndex],
    vlm: VLMProvider,
    llm: LLMProvider,
    embed_fn: Callable[[str], np.ndarray],
    store: QuestionGenStore,
    pipeline_config: PipelineConfig,
    filter_task_types: tuple[str, ...],
) -> BackfillFn:
    """组装真实 backfill 回调 — 缺额驱动 run_pipeline_v2，返回新增 AR 题。

    闭包捕获 run_pipeline_v2 全部依赖。每轮按缺额 deficit 用 dataclasses.replace 生成
    per_type=deficit 的新 config（PipelineConfig frozen，绝不原地改），并传：
      - initial_used_node_ids = 已有题 source_nodes 并集（避开已用节点）；
      - initial_embed_pool = 已有题 embedding（跨 run 去重）；
      - seq_offset = 已有题最大 seq（新题续编，防撞 question_id）。
    仅补 filter_task_types（当前锁定为 AR 单类），返回 PipelineResult.accepted。
    session/run_id 由 run_pipeline_v2 内部自生成，故本层不接受 session_id。

    参数:
        trees: video_id → 三层树索引（生成素材来源）。
        vlm: VLM 端口。
        llm: LLM 端口（门控用）。
        embed_fn: 文本嵌入函数。
        store: 出题持久化。
        pipeline_config: ar30 原配置（除 per_type 外全部继承）。
        filter_task_types: 补生成的题型（仅这些）。

    返回:
        BackfillFn 闭包。
    """
    from app.question_gen.pipeline_v2 import run_pipeline_v2

    video_ids = list(trees.keys())

    async def _backfill(
        deficit: int,
        round_no: int,
        existing: dict[str, GeneratedQuestion],
    ) -> list[GeneratedQuestion]:
        """按缺额补生成 deficit 道 AR 题。"""
        run_cfg = dataclasses.replace(pipeline_config, per_type=deficit)
        used_node_ids: set[str] = set()
        for q in existing.values():
            used_node_ids.update(q.source_nodes)
        embed_pool = [embed_fn(q.question).flatten() for q in existing.values()]
        seq_offset = _max_existing_seq(existing)
        logger.info(
            "backfill round={}: deficit={}, seq_offset={}, used_nodes={}",
            round_no,
            deficit,
            seq_offset,
            len(used_node_ids),
        )
        result = await run_pipeline_v2(
            video_ids=video_ids,
            trees=trees,
            vlm=vlm,
            llm=llm,
            embed_fn=embed_fn,
            store=store,
            config=run_cfg,
            task_types=list(filter_task_types),
            initial_used_node_ids=used_node_ids,
            initial_embed_pool=embed_pool,
            seq_offset=seq_offset,
        )
        return result.accepted

    return _backfill


# ---------------------------------------------------------------------------
# Task 10: 顶层入口 — run_adversarial_filter
# ---------------------------------------------------------------------------


def _load_filter_questions(
    accepted_path: Path, filter_task_types: tuple[str, ...]
) -> list[GeneratedQuestion]:
    """从 accepted_questions.json 读题并过滤到 filter_task_types（只读，不改 Phase A）。

    accepted_questions.json 是 Phase A 产物（JSON 列表）；仅 filter_task_types 的题
    进 agent 门，其余原样留在 Phase A 文件中（本层不触碰）。

    参数:
        accepted_path: accepted_questions.json 路径。
        filter_task_types: 需过滤的题型集合。

    返回:
        过滤后的题目列表（保持文件内顺序）。

    异常:
        FileNotFoundError: 文件不存在。
        ValueError: JSON 结构非列表。
    """
    if not accepted_path.exists():
        raise FileNotFoundError(f"accepted_questions.json 不存在: {accepted_path}")
    raw = json.loads(accepted_path.read_text(encoding="utf-8"))
    if not isinstance(raw, list):
        raise ValueError(f"accepted_questions.json 顶层结构应为列表: {accepted_path}")
    types = set(filter_task_types)
    questions: list[GeneratedQuestion] = []
    for item in raw:
        if item.get("task_type") not in types:
            continue
        questions.append(
            GeneratedQuestion(
                question_id=item["question_id"],
                video_id=item["video_id"],
                task_type=item["task_type"],
                question=item["question"],
                options=tuple(item["options"]),
                answer=item["answer"],
                source_nodes=tuple(item.get("source_nodes", ())),
                difficulty=item.get("difficulty", "medium"),
                family=item.get("family"),
                skill_target=item.get("skill_target"),
                difficulty_steps=item.get("difficulty_steps"),
                sub_pattern=item.get("sub_pattern"),
            )
        )
    return questions


async def run_adversarial_filter(
    *,
    accepted_path: Path,
    final_path: Path,
    agent: AgentRunner,
    vlm: VLMProvider,
    trees: dict[str, TreeIndex],
    store: QuestionGenStore,
    filter_config: AdversarialFilterConfig,
    backfill: BackfillFn,
    session_id: str,
) -> None:
    """Phase B 顶层入口：读 accepted_questions.json 过滤 AR，跑两门 + 补生成迭代。

    路径隔离：只处理 filter_task_types 的题，accepted_questions.json 只读，Phase A
    状态机不受影响。target 锁定为首轮过滤出的 AR 题数（缺额 = target - passed）。

    参数:
        accepted_path: Phase A 产物 accepted_questions.json 路径（只读）。
        final_path: 最终题库 JSON 路径（每轮全量原子重写）。
        agent: 完整 agent 试答端口（Task 10 的 _RealAgentRunner，单测可 mock）。
        vlm: 镜像题生成 VLM 端口。
        trees: video_id → 三层树索引（重建镜像素材 / 补生成用）。
        store: verdict 持久化。
        filter_config: 后置对抗过滤配置（题型 / 轮次 / max_steps / 难度阈值）。
        backfill: 补生成回调（CLI 用 build_backfill 组装真实实现，单测可 mock）。
        session_id: 遥测会话 ID（派生各门 run_id）。
    """
    initial_questions = _load_filter_questions(accepted_path, filter_config.filter_task_types)
    target = len(initial_questions)
    logger.info(
        "对抗过滤启动: 过滤题型={}, 首轮 AR 题数={}",
        filter_config.filter_task_types,
        target,
    )
    await run_adversarial_rounds(
        initial_questions,
        agent=agent,
        vlm=vlm,
        store=store,
        trees=trees,
        config=filter_config,
        final_path=final_path,
        target=target,
        backfill=backfill,
        session_id=session_id,
    )
