"""SQLite 出题管线日志记录器 — 记录批次运行与逐题门判定。

写入频率低（每批次 ~240 题），使用同步 sqlite3 即可。
schema 定义参照 research-wiki/schemas/question-gen-runs.md 和 question-gen-items.md。
"""

from __future__ import annotations

import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Protocol, runtime_checkable

from loguru import logger

if TYPE_CHECKING:
    from pathlib import Path


# ---------------------------------------------------------------------------
# GateReport Protocol（Task 6 尚未实现，此处声明 duck-type 接口）
# ---------------------------------------------------------------------------


@runtime_checkable
class _GateResultLike(Protocol):
    """单门判定结果的最小接口。"""

    @property
    def verdict(self) -> object:
        """PASS / FAIL / SKIP 枚举值，.value 为小写字符串。"""
        ...

    @property
    def reason(self) -> str:
        """判定理由。"""
        ...


@runtime_checkable
class GateReportLike(Protocol):
    """GateReport 的最小 duck-type 接口。"""

    @property
    def key_verify(self) -> _GateResultLike: ...

    @property
    def blind_answer(self) -> _GateResultLike: ...

    @property
    def multi_true(self) -> _GateResultLike: ...

    @property
    def leak_test(self) -> _GateResultLike: ...

    @property
    def passed(self) -> bool: ...

    @property
    def reject_reason(self) -> str | None: ...


# ---------------------------------------------------------------------------
# 数据类
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class RunStats:
    """批次运行的统计摘要。

    Attributes
    ----------
    total_slots : int
        目标题数（slot 总数）。
    accepted : int
        最终通过门判定的题数。
    rejected : int
        最终被拒绝的题数。
    heavy_sampled : int
        进行重量抽检的题数。
    """

    total_slots: int
    accepted: int
    rejected: int
    heavy_sampled: int


# ---------------------------------------------------------------------------
# DDL
# ---------------------------------------------------------------------------

# DEPRECATED(v3): v2 生成落库，Phase 2 生成替换后删；过渡期与 v3 表并存
_DDL_RUNS = """
CREATE TABLE IF NOT EXISTS question_gen_runs (
    run_id          TEXT PRIMARY KEY,
    git_sha         TEXT NOT NULL,
    config_snapshot TEXT,
    started_at      TEXT NOT NULL,
    ended_at        TEXT,
    status          TEXT NOT NULL DEFAULT 'running',
    total_slots     INTEGER,
    accepted        INTEGER,
    rejected        INTEGER,
    heavy_sampled   INTEGER
);
"""

# DEPRECATED(v3): v2 生成落库，Phase 2 生成替换后删；过渡期与 v3 表并存
_DDL_ITEMS = """
CREATE TABLE IF NOT EXISTS question_gen_items (
    item_id           TEXT PRIMARY KEY,
    run_id            TEXT NOT NULL REFERENCES question_gen_runs(run_id),
    slot_id           TEXT NOT NULL,
    video_id          TEXT NOT NULL,
    family            TEXT NOT NULL,
    task_type         TEXT NOT NULL,
    skill_target      TEXT NOT NULL,
    attempt           INTEGER NOT NULL,
    question_text     TEXT NOT NULL,
    sub_pattern       TEXT,
    gate_key_verify   TEXT,
    gate_blind_answer TEXT,
    gate_multi_true   TEXT,
    gate_leak_test    TEXT,
    gate_reject_reason TEXT,
    final_status      TEXT NOT NULL DEFAULT 'pending',
    difficulty_steps  INTEGER,
    selector_scores   TEXT,
    created_at        TEXT NOT NULL DEFAULT (datetime('now'))
);
"""

_DDL_INDEXES = [
    "CREATE INDEX IF NOT EXISTS idx_qgr_status ON question_gen_runs(status);",
    "CREATE INDEX IF NOT EXISTS idx_qgi_run ON question_gen_items(run_id);",
    "CREATE INDEX IF NOT EXISTS idx_qgi_slot ON question_gen_items(slot_id);",
    "CREATE INDEX IF NOT EXISTS idx_qgi_status ON question_gen_items(final_status);",
    "CREATE INDEX IF NOT EXISTS idx_qgi_family ON question_gen_items(family);",
    "CREATE INDEX IF NOT EXISTS idx_qgi_task_type ON question_gen_items(task_type);",
]

# DEPRECATED(v3): v2 生成落库，Phase 2 生成替换后删；过渡期与 v3 表并存
_DDL_VERDICTS = """
CREATE TABLE IF NOT EXISTS adversarial_verdicts (
    question_id      TEXT NOT NULL,
    question_hash    TEXT NOT NULL,
    stage            TEXT NOT NULL,
    round            INTEGER NOT NULL,
    agent_prediction TEXT,
    agent_correct    INTEGER,
    verdict          TEXT NOT NULL,
    pair_id          TEXT,
    agent_config     TEXT NOT NULL,
    created_at       TEXT NOT NULL DEFAULT (datetime('now')),
    PRIMARY KEY (question_id, question_hash, stage)
);
"""

_DDL_VERDICTS_INDEXES = [
    "CREATE INDEX IF NOT EXISTS idx_av_qid ON adversarial_verdicts(question_id);",
    "CREATE INDEX IF NOT EXISTS idx_av_verdict ON adversarial_verdicts(verdict);",
    "CREATE INDEX IF NOT EXISTS idx_av_round ON adversarial_verdicts(round);",
]


# ---------------------------------------------------------------------------
# v3 出题领域观测表 DDL（facts/unit_verdict/collapse_metrics/quarantine/resume_state）
#
# 时间戳边界（Codex I-4）：v3 新表的 ts 列一律由 insert 方法外部传入，DDL 中不设
# DEFAULT (datetime('now'))、方法体不调 datetime.now()，以保证观测幂等可复现——
# 断点续跑重放时不因进程内时钟漂移产生脏数据。resume_state 无 ts 列（schema 未列）。
# ---------------------------------------------------------------------------

_DDL_FACTS = """
CREATE TABLE IF NOT EXISTS facts (
    fact_id            TEXT PRIMARY KEY,
    video_id           TEXT,
    segment_id         TEXT,
    subject            TEXT,
    action             TEXT,
    object             TEXT,
    frame_ids          TEXT,
    polarity           TEXT,
    fact_type          TEXT,
    difficulty_tier    INTEGER,
    verifier_refs      TEXT,
    cross_agree        INTEGER CHECK(cross_agree IN (0, 1)),
    negative_at_target TEXT,
    session_id         TEXT,
    ts                 TEXT
);
"""

_DDL_UNIT_VERDICT = """
CREATE TABLE IF NOT EXISTS unit_verdict (
    unit_id      TEXT NOT NULL,
    pair_id      TEXT,
    sub_pattern  TEXT,
    stage        INTEGER NOT NULL CHECK(stage BETWEEN 1 AND 6),
    verdict      TEXT CHECK(verdict IN ('pass', 'fail', 'abstain')),
    reason       TEXT,
    metric_value REAL,
    model        TEXT,
    session_id   TEXT,
    ts           TEXT,
    PRIMARY KEY (unit_id, stage)
);
"""

_DDL_COLLAPSE_METRICS = """
CREATE TABLE IF NOT EXISTS collapse_metrics (
    pair_id                 TEXT PRIMARY KEY,
    text_only_acc           REAL,
    single_frame_acc        REAL,
    placebo_drop            REAL,
    majority_vote_hit       REAL,
    slot_chi2               REAL,
    distractor_min_dist     REAL,
    multiformat_consistency REAL,
    subtitle_answerability  REAL,
    ts                      TEXT
);
"""

_DDL_QUARANTINE = """
CREATE TABLE IF NOT EXISTS quarantine (
    content_fingerprint TEXT PRIMARY KEY,
    sub_pattern         TEXT,
    quarantine_reason   TEXT,
    round_no            INTEGER,
    ts                  TEXT
);
"""

_DDL_RESUME_STATE = """
CREATE TABLE IF NOT EXISTS resume_state (
    unit_id            TEXT PRIMARY KEY,
    status             TEXT CHECK(status IN ('pending', 'accepted', 'rejected')),
    config_fingerprint TEXT,
    seq_offset         INTEGER
);
"""

_DDL_V3_TABLES = [
    _DDL_FACTS,
    _DDL_UNIT_VERDICT,
    _DDL_COLLAPSE_METRICS,
    _DDL_QUARANTINE,
    _DDL_RESUME_STATE,
]

_DDL_V3_INDEXES = [
    "CREATE INDEX IF NOT EXISTS idx_facts_video ON facts(video_id);",
    "CREATE INDEX IF NOT EXISTS idx_facts_session ON facts(session_id);",
    # unit_verdict(unit_id) 无需独立索引：主键 (unit_id, stage) 的左前缀已覆盖 unit_id 查询。
    "CREATE INDEX IF NOT EXISTS idx_uv_sub_pattern ON unit_verdict(sub_pattern);",
    "CREATE INDEX IF NOT EXISTS idx_quar_sub_pattern ON quarantine(sub_pattern);",
    "CREATE INDEX IF NOT EXISTS idx_resume_status ON resume_state(status);",
]


# ---------------------------------------------------------------------------
# Store 实现
# ---------------------------------------------------------------------------


class QuestionGenStore:
    """SQLite 出题管线日志记录器。

    记录每次出题批次的元数据（run）和每题的门判定结果（item）。
    使用同步 sqlite3，写入频率低无需异步。

    Parameters
    ----------
    db_path : Path
        SQLite 数据库文件路径。父目录必须存在。
    """

    def __init__(self, db_path: Path) -> None:
        self._db_path = db_path
        self._conn = sqlite3.connect(
            str(db_path),
            check_same_thread=False,
            timeout=10.0,
        )
        self._conn.execute("PRAGMA journal_mode=WAL")
        self._conn.execute("PRAGMA busy_timeout=5000")
        self._conn.execute("PRAGMA foreign_keys=ON")
        self._init_schema()
        logger.debug("QuestionGenStore 已初始化: {}", db_path)

    def _init_schema(self) -> None:
        """幂等创建表和索引。多次调用安全。"""
        self._conn.execute(_DDL_RUNS)
        self._conn.execute(_DDL_ITEMS)
        for idx_sql in _DDL_INDEXES:
            self._conn.execute(idx_sql)
        self._conn.execute(_DDL_VERDICTS)
        for idx_sql in _DDL_VERDICTS_INDEXES:
            self._conn.execute(idx_sql)
        for ddl in _DDL_V3_TABLES:
            self._conn.execute(ddl)
        for idx_sql in _DDL_V3_INDEXES:
            self._conn.execute(idx_sql)
        self._conn.commit()

        # 幂等迁移：为已有表加 sub_pattern / selector_scores 列
        cols = {r[1] for r in self._conn.execute("PRAGMA table_info(question_gen_items)")}
        if "sub_pattern" not in cols:
            self._conn.execute("ALTER TABLE question_gen_items ADD COLUMN sub_pattern TEXT")
            self._conn.commit()
        if "selector_scores" not in cols:
            self._conn.execute("ALTER TABLE question_gen_items ADD COLUMN selector_scores TEXT")
            self._conn.commit()

    def record_run_start(self, run_id: str, git_sha: str, config_snapshot: str) -> None:
        """记录批次开始。

        Parameters
        ----------
        run_id : str
            批次唯一标识（UUID）。
        git_sha : str
            当前代码版本 HEAD commit。
        config_snapshot : str
            科研配置快照（JSON 序列化字符串）。
        """
        now = datetime.now(tz=UTC).isoformat(timespec="seconds")
        self._conn.execute(
            """
            INSERT INTO question_gen_runs (run_id, git_sha, config_snapshot, started_at, status)
            VALUES (?, ?, ?, ?, 'running')
            """,
            (run_id, git_sha, config_snapshot, now),
        )
        self._conn.commit()
        logger.info("出题批次已开始: run_id={}", run_id)

    def record_run_end(self, run_id: str, status: str, stats: RunStats) -> None:
        """记录批次结束，更新状态与统计。

        Parameters
        ----------
        run_id : str
            批次唯一标识。
        status : str
            最终状态（completed / failed）。
        stats : RunStats
            批次统计摘要。
        """
        now = datetime.now(tz=UTC).isoformat(timespec="seconds")
        cursor = self._conn.execute(
            """
            UPDATE question_gen_runs
            SET ended_at=?, status=?, total_slots=?, accepted=?, rejected=?, heavy_sampled=?
            WHERE run_id=?
            """,
            (
                now,
                status,
                stats.total_slots,
                stats.accepted,
                stats.rejected,
                stats.heavy_sampled,
                run_id,
            ),
        )
        self._conn.commit()
        if cursor.rowcount == 0:
            raise ValueError(f"run_id 不存在: {run_id}")
        logger.info(
            "出题批次已结束: run_id={}, status={}, accepted={}/{}",
            run_id,
            status,
            stats.accepted,
            stats.total_slots,
        )

    def record_item(
        self,
        item_id: str,
        run_id: str,
        slot_id: str,
        video_id: str,
        family: str,
        task_type: str,
        skill_target: str,
        attempt: int,
        question_text: str,
        sub_pattern: str | None = None,
    ) -> None:
        """记录一道新生成的题目（初始状态 pending）。

        Parameters
        ----------
        item_id : str
            题目唯一 ID（每轮独立）。
        run_id : str
            关联的批次 ID。
        slot_id : str
            逻辑 slot 标识（同 slot 多次重出共享）。
        video_id : str
            视频 ID。
        family : str
            题族（retrieval/reasoning/enumeration/visual/spatial）。
        task_type : str
            Video-MME 12 类主标签。
        skill_target : str
            M1-M5 + 题族子标签。
        attempt : int
            当前重出轮次（1-based）。
        question_text : str
            题目文本。
        sub_pattern : str | None
            子模式标识（如有）。
        """
        now = datetime.now(tz=UTC).isoformat(timespec="seconds")
        self._conn.execute(
            """
            INSERT INTO question_gen_items
            (item_id, run_id, slot_id, video_id, family, task_type,
             skill_target, attempt, question_text, sub_pattern, created_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                item_id,
                run_id,
                slot_id,
                video_id,
                family,
                task_type,
                skill_target,
                attempt,
                question_text,
                sub_pattern,
                now,
            ),
        )
        self._conn.commit()

    def update_gates(self, item_id: str, report: GateReportLike) -> None:
        """更新门判定结果及 final_status。

        Parameters
        ----------
        item_id : str
            题目唯一 ID。
        report : GateReportLike
            门判定报告（duck-type，需有 key_verify/blind_answer/multi_true/leak_test
            属性，每个属性具有 .verdict.value 和 .reason；以及 passed/reject_reason 属性）。
        """
        final_status = "accepted" if report.passed else "rejected"
        cursor = self._conn.execute(
            """
            UPDATE question_gen_items
            SET gate_key_verify=?, gate_blind_answer=?, gate_multi_true=?,
                gate_leak_test=?, gate_reject_reason=?, final_status=?
            WHERE item_id=?
            """,
            (
                report.key_verify.verdict.value,
                report.blind_answer.verdict.value,
                report.multi_true.verdict.value,
                report.leak_test.verdict.value,
                report.reject_reason,
                final_status,
                item_id,
            ),
        )
        self._conn.commit()
        if cursor.rowcount == 0:
            raise ValueError(f"item_id 不存在: {item_id}")

    def mark_item_rejected(self, item_id: str, reason: str) -> None:
        """将已记录的 item 标记为 rejected（用于门控外的拒绝场景，如去重）。

        Parameters
        ----------
        item_id : str
            题目唯一 ID。
        reason : str
            拒绝原因描述。
        """
        cursor = self._conn.execute(
            "UPDATE question_gen_items SET final_status='rejected', gate_reject_reason=? "
            "WHERE item_id=?",
            (reason, item_id),
        )
        self._conn.commit()
        if cursor.rowcount == 0:
            raise ValueError(f"item_id 不存在: {item_id}")

    def update_difficulty(self, item_id: str, difficulty_steps: int) -> None:
        """更新重量抽检产出的 Agent 步数。

        Parameters
        ----------
        item_id : str
            题目唯一 ID。
        difficulty_steps : int
            Agent 完成该题所需步数。
        """
        cursor = self._conn.execute(
            "UPDATE question_gen_items SET difficulty_steps=? WHERE item_id=?",
            (difficulty_steps, item_id),
        )
        self._conn.commit()
        if cursor.rowcount == 0:
            raise ValueError(f"item_id 不存在: {item_id}")

    def record_verdict(
        self,
        *,
        question_id: str,
        question_hash: str,
        stage: str,
        round: int,  # noqa: A002 — 与设计列名一致，仅 kwargs 传入无遮蔽风险
        agent_prediction: str | None,
        agent_correct: bool | None,
        verdict: str,
        pair_id: str | None,
        agent_config: str,
    ) -> None:
        """写入一条 agent 门判定（同 (question_id, question_hash, stage) upsert）。

        每次写入立即 commit，保证崩溃安全（进程中断最多丢失当前未提交的一条）。

        Parameters
        ----------
        question_id, question_hash, stage : str
            续跑主键三元组（stage ∈ cheat|flip_original|flip_mirror）。
        round : int
            过滤轮次。
        agent_prediction : str | None
            agent 预测答案字母。
        agent_correct : bool | None
            作弊门是否答对（翻转门 stage 可为 None）。
        verdict : str
            passed | filtered_too_easy | filtered_no_flip | flip_skipped。
        pair_id : str | None
            关联原题与镜像题。
        agent_config : str
            agent 配置指纹（skill_mode/max_steps/model）。
        """
        self._conn.execute(
            """
            INSERT INTO adversarial_verdicts
                (question_id, question_hash, stage, round, agent_prediction,
                 agent_correct, verdict, pair_id, agent_config)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(question_id, question_hash, stage) DO UPDATE SET
                round=excluded.round,
                agent_prediction=excluded.agent_prediction,
                agent_correct=excluded.agent_correct,
                verdict=excluded.verdict,
                pair_id=excluded.pair_id,
                agent_config=excluded.agent_config,
                created_at=datetime('now')
            """,
            (
                question_id,
                question_hash,
                stage,
                round,
                agent_prediction,
                None if agent_correct is None else int(agent_correct),
                verdict,
                pair_id,
                agent_config,
            ),
        )
        self._conn.commit()

    def completed_stages(self, question_id: str, question_hash: str, agent_config: str) -> set[str]:
        """返回该题在当前 hash+config 下已完成的 stage 集合（续跑用）。

        Parameters
        ----------
        question_id : str
            题目唯一标识。
        question_hash : str
            当前题面指纹；hash 不匹配的旧行视为未完成，需重跑试答。
        agent_config : str
            当前 agent 配置指纹。

        Returns
        -------
        set[str]
            已完成的 stage 名称集合。
        """
        rows = self._conn.execute(
            "SELECT stage FROM adversarial_verdicts "
            "WHERE question_id=? AND question_hash=? AND agent_config=?",
            (question_id, question_hash, agent_config),
        ).fetchall()
        return {r[0] for r in rows}

    def invalidate_stale_config(self, question_id: str, agent_config: str) -> None:
        """agent_config 变化时，删除该题所有非当前 config 的旧 verdict。

        Parameters
        ----------
        question_id : str
            题目唯一标识。
        agent_config : str
            当前 agent 配置指纹；保留该 config 行，其余全部删除。
        """
        self._conn.execute(
            "DELETE FROM adversarial_verdicts WHERE question_id=? AND agent_config!=?",
            (question_id, agent_config),
        )
        self._conn.commit()

    def cheat_agent_accuracy(self, round_no: int) -> float:
        """某轮作弊门 agent 正确率（agent_correct 聚合），无数据返 0.0。

        Parameters
        ----------
        round_no : int
            过滤轮次。

        Returns
        -------
        float
            该轮 stage='cheat' 的 agent_correct 平均值；无数据时返回 0.0。
        """
        row = self._conn.execute(
            "SELECT AVG(agent_correct) FROM adversarial_verdicts WHERE stage='cheat' AND round=?",
            (round_no,),
        ).fetchone()
        return float(row[0]) if row and row[0] is not None else 0.0

    def final_passed_question_ids(self, hash_by_qid: dict[str, str], agent_config: str) -> set[str]:
        """在当前 hash+config 下通过两门的 question_id 集合（final JSON 全量重建用）。

        终判规则（防 stale 泄漏）：仅当该题在 **当前 question_hash + 当前
        agent_config** 下同时满足——存在 stage='cheat' 且 verdict='passed'
        （agent 答错=不太简单），且不存在任何 stage 的 verdict='filtered_no_flip'
        （未被翻转门剔除）——才计入 final-passed。stale hash / stale config 的旧行
        因不匹配传入的 (qid, hash, config) 天然被排除，绝不泄漏进最终题库。

        Parameters
        ----------
        hash_by_qid : dict[str, str]
            question_id → 当前 question_hash 映射（来自本轮 all_questions）。
        agent_config : str
            当前 agent 配置指纹。

        Returns
        -------
        set[str]
            终判 passed 的 question_id 集合。
        """
        passed: set[str] = set()
        for qid, qhash in hash_by_qid.items():
            rows = self._conn.execute(
                "SELECT stage, verdict FROM adversarial_verdicts "
                "WHERE question_id=? AND question_hash=? AND agent_config=?",
                (qid, qhash, agent_config),
            ).fetchall()
            if not rows:
                continue
            cheat_passed = any(stage == "cheat" and verdict == "passed" for stage, verdict in rows)
            no_flip = any(verdict == "filtered_no_flip" for _, verdict in rows)
            if cheat_passed and not no_flip:
                passed.add(qid)
        return passed

    def update_selector_scores(self, item_id: str, selector_scores_json: str) -> None:
        """写入 grounded selector 打分观测（JSON 字符串）。

        Parameters
        ----------
        item_id : str
            题目唯一 ID。
        selector_scores_json : str
            观测 JSON：correct_score / chosen / pool_size / anneal_rounds / hard_fail。

        Raises
        ------
        ValueError
            item_id 不存在时抛出。
        """
        cursor = self._conn.execute(
            "UPDATE question_gen_items SET selector_scores=? WHERE item_id=?",
            (selector_scores_json, item_id),
        )
        self._conn.commit()
        if cursor.rowcount == 0:
            raise ValueError(f"item_id 不存在: {item_id}")

    def get_run_stats(self, run_id: str) -> RunStats:
        """查询批次统计摘要。

        Parameters
        ----------
        run_id : str
            批次唯一标识。

        Returns
        -------
        RunStats
            该批次的统计数据。

        Raises
        ------
        ValueError
            run_id 不存在时抛出。
        """
        row = self._conn.execute(
            "SELECT total_slots, accepted, rejected, heavy_sampled "
            "FROM question_gen_runs WHERE run_id=?",
            (run_id,),
        ).fetchone()
        if row is None:
            raise ValueError(f"run_id 不存在: {run_id}")
        return RunStats(
            total_slots=row[0] or 0,
            accepted=row[1] or 0,
            rejected=row[2] or 0,
            heavy_sampled=row[3] or 0,
        )

    def load_progress(self) -> dict[str, str]:
        """加载已接受 slot 的进度映射（用于断点续跑）。

        从最近一次 running 状态的批次中，只读取 accepted 的 slot。
        rejected 的 slot 不纳入 progress，以便重跑时重新尝试。

        Returns
        -------
        dict[str, str]
            {slot_id: "accepted"} 映射。无进度时返回空 dict。
        """
        row = self._conn.execute(
            "SELECT run_id FROM question_gen_runs WHERE status='running' "
            "ORDER BY started_at DESC LIMIT 1",
        ).fetchone()

        if row is None:
            return {}

        run_id = row[0]
        rows = self._conn.execute(
            "SELECT DISTINCT slot_id FROM question_gen_items "
            "WHERE run_id=? AND final_status='accepted'",
            (run_id,),
        ).fetchall()

        return {row[0]: "accepted" for row in rows}

    # -----------------------------------------------------------------------
    # v3 出题领域观测写入（ts 一律外部传入，禁进程内 now，保幂等可复现）
    # -----------------------------------------------------------------------

    def insert_fact(
        self,
        *,
        fact_id: str,
        video_id: str,
        segment_id: str,
        subject: str,
        action: str,
        object: str,  # noqa: A002 — 与设计列名 object 一致，仅 kwargs 传入无遮蔽风险
        frame_ids: str,
        polarity: str,
        fact_type: str,
        difficulty_tier: int,
        verifier_refs: str,
        cross_agree: int,
        negative_at_target: str,
        session_id: str,
        ts: str,
    ) -> None:
        """写入一条帧感知抽取 Fact（facts 表，主键 fact_id）。

        Parameters
        ----------
        fact_id : str
            Fact 唯一标识（UUID）。
        video_id, segment_id : str
            溯源：所属视频与片段。
        subject, action, object : str
            结构化绑定三元组。
        frame_ids : str
            感知所用帧 ID 的 JSON 数组字符串。
        polarity : str
            事实极性（真/假）。
        fact_type : str
            事实类型（binding/state/manner/order/evidence）。
        difficulty_tier : int
            感知难度分层。
        verifier_refs : str
            双 VLM（qwen/MiniMax）各自裁决的 JSON 字符串。
        cross_agree : int
            双 VLM 是否一致（0/1）。
        negative_at_target : str
            目标点为假的核实结果。
        session_id : str
            epoch/step 关联 ID。
        ts : str
            观测时间戳 ISO 字符串，由调用方外部传入（禁进程内 now）。
        """
        self._conn.execute(
            """
            INSERT INTO facts
                (fact_id, video_id, segment_id, subject, action, object, frame_ids,
                 polarity, fact_type, difficulty_tier, verifier_refs, cross_agree,
                 negative_at_target, session_id, ts)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                fact_id,
                video_id,
                segment_id,
                subject,
                action,
                object,
                frame_ids,
                polarity,
                fact_type,
                difficulty_tier,
                verifier_refs,
                cross_agree,
                negative_at_target,
                session_id,
                ts,
            ),
        )
        self._conn.commit()
        logger.debug("facts 已写入: fact_id={}", fact_id)

    def insert_unit_verdict(
        self,
        *,
        unit_id: str,
        pair_id: str | None,
        sub_pattern: str,
        stage: int,
        verdict: str,
        reason: str,
        metric_value: float | None,
        model: str | None,
        session_id: str,
        ts: str,
    ) -> None:
        """写入六层验证某一层的裁决（unit_verdict 表，主键 (unit_id, stage)）。

        同一 (unit_id, stage) 重跑时以 upsert 覆盖旧行——每 unit 每层仅保留最新一条，
        便于断点续跑重放而不残留过期裁决。

        覆盖语义：ON CONFLICT DO UPDATE 用 ``excluded.*`` **整行覆盖**全部非键列
        （含可空的 metric_value/model 及 ts）——只保留每层最新完整裁决，不支持部分字段
        更新。调用方每次必须传完整行，否则会用 metric_value=None/model=None 误抹先前非空值。

        Parameters
        ----------
        unit_id : str
            pair/single 单元标识。
        pair_id : str | None
            所属 pair 关联标识（single 单元可为 None）。
        sub_pattern : str
            6 子模式之一。
        stage : int
            验证层号（1-6）。
        verdict : str
            该层裁决（pass/fail/abstain）。
        reason : str
            拒因描述。
        metric_value : float | None
            该层量化值（无量化值时为 None）。
        model : str | None
            裁判模型名（无模型判定时为 None）。
        session_id : str
            epoch/step 关联 ID。
        ts : str
            观测时间戳 ISO 字符串，由调用方外部传入（禁进程内 now）。
        """
        self._conn.execute(
            """
            INSERT INTO unit_verdict
                (unit_id, pair_id, sub_pattern, stage, verdict, reason,
                 metric_value, model, session_id, ts)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(unit_id, stage) DO UPDATE SET
                pair_id=excluded.pair_id,
                sub_pattern=excluded.sub_pattern,
                verdict=excluded.verdict,
                reason=excluded.reason,
                metric_value=excluded.metric_value,
                model=excluded.model,
                session_id=excluded.session_id,
                ts=excluded.ts
            """,
            (
                unit_id,
                pair_id,
                sub_pattern,
                stage,
                verdict,
                reason,
                metric_value,
                model,
                session_id,
                ts,
            ),
        )
        self._conn.commit()
        logger.debug("unit_verdict 已写入: unit_id={}, stage={}", unit_id, stage)

    def insert_collapse_metrics(
        self,
        *,
        pair_id: str,
        text_only_acc: float,
        single_frame_acc: float,
        placebo_drop: float,
        majority_vote_hit: float,
        slot_chi2: float,
        distractor_min_dist: float,
        multiformat_consistency: float,
        subtitle_answerability: float,
        ts: str,
    ) -> None:
        """写入配对坍缩度量（collapse_metrics 表，主键 pair_id）。

        同一 pair_id 重算时以 upsert 覆盖旧行——每 pair 仅保留最新一组度量。

        覆盖语义：ON CONFLICT DO UPDATE 用 ``excluded.*`` **整行覆盖**全部非键列（含 ts），
        不支持部分字段更新；调用方每次必须传完整度量行。

        Parameters
        ----------
        pair_id : str
            配对唯一标识。
        text_only_acc, single_frame_acc, placebo_drop : float
            模型探针类度量。
        majority_vote_hit, slot_chi2, distractor_min_dist : float
            纯结构类度量。
        multiformat_consistency, subtitle_answerability : float
            探针类度量。
        ts : str
            观测时间戳 ISO 字符串，由调用方外部传入（禁进程内 now）。
        """
        self._conn.execute(
            """
            INSERT INTO collapse_metrics
                (pair_id, text_only_acc, single_frame_acc, placebo_drop,
                 majority_vote_hit, slot_chi2, distractor_min_dist,
                 multiformat_consistency, subtitle_answerability, ts)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(pair_id) DO UPDATE SET
                text_only_acc=excluded.text_only_acc,
                single_frame_acc=excluded.single_frame_acc,
                placebo_drop=excluded.placebo_drop,
                majority_vote_hit=excluded.majority_vote_hit,
                slot_chi2=excluded.slot_chi2,
                distractor_min_dist=excluded.distractor_min_dist,
                multiformat_consistency=excluded.multiformat_consistency,
                subtitle_answerability=excluded.subtitle_answerability,
                ts=excluded.ts
            """,
            (
                pair_id,
                text_only_acc,
                single_frame_acc,
                placebo_drop,
                majority_vote_hit,
                slot_chi2,
                distractor_min_dist,
                multiformat_consistency,
                subtitle_answerability,
                ts,
            ),
        )
        self._conn.commit()
        logger.debug("collapse_metrics 已写入: pair_id={}", pair_id)

    def quarantine(
        self,
        *,
        content_fingerprint: str,
        sub_pattern: str,
        quarantine_reason: str,
        round_no: int,
        ts: str,
    ) -> None:
        """将失败题的内容指纹写入隔离区黑名单（quarantine 表，主键 content_fingerprint）。

        同一 content_fingerprint 重复调用以 upsert 覆盖——保证语义相同题面只占一行，
        补构造前查此表当黑名单去重。覆盖语义：ON CONFLICT DO UPDATE 用 ``excluded.*``
        **整行覆盖**全部非键列（含 ts），不支持部分字段更新。

        Parameters
        ----------
        content_fingerprint : str
            题面语义内容指纹（去重键）。
        sub_pattern : str
            题目所属子模式。
        quarantine_reason : str
            隔离原因。
        round_no : int
            隔离发生的过滤轮次。
        ts : str
            观测时间戳 ISO 字符串，由调用方外部传入（禁进程内 now）。
        """
        self._conn.execute(
            """
            INSERT INTO quarantine
                (content_fingerprint, sub_pattern, quarantine_reason, round_no, ts)
            VALUES (?, ?, ?, ?, ?)
            ON CONFLICT(content_fingerprint) DO UPDATE SET
                sub_pattern=excluded.sub_pattern,
                quarantine_reason=excluded.quarantine_reason,
                round_no=excluded.round_no,
                ts=excluded.ts
            """,
            (content_fingerprint, sub_pattern, quarantine_reason, round_no, ts),
        )
        self._conn.commit()
        logger.debug("quarantine 已写入: fingerprint={}", content_fingerprint)

    def upsert_resume_state(
        self,
        *,
        unit_id: str,
        status: str,
        config_fingerprint: str,
        seq_offset: int,
    ) -> None:
        """写入/更新断点续跑状态（resume_state 表，主键 unit_id）。

        同一 unit_id 覆盖更新——每单元仅保留最新续跑状态。config_fingerprint 变化时
        由调用方据此判定旧进度作废并重跑。本表无 ts 列（schema 未定义），但方法体仍禁
        进程内 now，保持 v3 观测一致的幂等可复现语义。

        覆盖语义：ON CONFLICT DO UPDATE 用 ``excluded.*`` **整行覆盖**全部非键列，不支持
        部分字段更新；调用方每次必须传完整状态行。

        Parameters
        ----------
        unit_id : str
            单元唯一标识。
        status : str
            续跑状态（pending/accepted/rejected）。
        config_fingerprint : str
            求解器/裁判 config 指纹，变更时旧进度作废。
        seq_offset : int
            补构造续编偏移，防止编号相撞。
        """
        self._conn.execute(
            """
            INSERT INTO resume_state
                (unit_id, status, config_fingerprint, seq_offset)
            VALUES (?, ?, ?, ?)
            ON CONFLICT(unit_id) DO UPDATE SET
                status=excluded.status,
                config_fingerprint=excluded.config_fingerprint,
                seq_offset=excluded.seq_offset
            """,
            (unit_id, status, config_fingerprint, seq_offset),
        )
        self._conn.commit()
        logger.debug("resume_state 已写入: unit_id={}, status={}", unit_id, status)

    def close(self) -> None:
        """关闭数据库连接。"""
        self._conn.close()
        logger.debug("QuestionGenStore 已关闭")
