"""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 enum
import hashlib
import json
from pathlib import Path
from typing import TYPE_CHECKING, Protocol

from json_repair import repair_json
from loguru import logger

from core.types import GeneratedQuestion

if TYPE_CHECKING:
    from app.question_gen.adversarial_config import AdversarialFilterConfig
    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 VLMProvider

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


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

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


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

    参数:
        question: 题目文本。
        options: 选项元组。
        answer: 正确答案字母。

    返回:
        16 位十六进制摘要。
    """
    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.question, question.options, question.answer)


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,
    )

    subtitles = _collect_subtitle_sentences(tree, source_nodes)
    frames: list[str] = []
    for nid in source_nodes:
        frames.extend(_collect_frame_paths(tree, nid))
    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


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。
    """
    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, list(material.frame_paths), session_id=session_id
    )
    mirror = _parse_mirror(resp.content)
    if mirror is None:
        return 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
    mirror_q = 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,
    )
    # 镜像正解字面校验：canonical(P) 必须 != canonical(Q)（按选项文本比较，非字母）
    p_text = canonical_answer_text(question.options, question.answer)
    q_text = canonical_answer_text(mirror_q.options, answer)
    if p_text is None or q_text is None or p_text.strip() == q_text.strip():
        return None
    return mirror_q
