"""结果驱动视频级切分的自包含两阶段 CLI 入口。

把整条离线管线的编排从 shell 搬进 Python：一次调用内联串起
    Phase 1 离线诊断（run_baseline_diagnosis，LLM 重活，断点续跑幂等）→
    Phase 2 冻结切分（build_split，纯 code-controlled，产出 pools.json + manifest）→
    McNemar 功效护栏（validation 池错题数达阈校验）。

复现锚点约定（C-2）：
    - pools.json 的内容（+ seed + diag_fingerprint）是切分的**复现锚点**——相同输入
      产出字节级相同的 pools.json 与 pools_sha256。
    - manifest 的 generated_at 是**溯源元数据**，非复现锚点：真实运行默认盖真实 UTC
      now（记录本次切分何时产出），但可用 `--generated-at <ISO>` 显式固定，以对
      manifest 做字节级复现比对。write_manifest 库内不调 datetime.now，时间戳一律由
      本 CLI 传入。

设计要点：
    - 诊断口径指纹 = (诊断 prompt 版本, 模型名, git 短 SHA) 三分量合成，隔离不同
      诊断配置的信号；换 prompt / 模型 / 代码实现即换指纹，旧信号不被覆盖。
    - 真实依赖组装参考 app/harness/runner.py::_run_diagnosis：GovernedLLMClient
      (search llm, thinking=True) + RunLogImpl(harness.db) + VersionedSkillStore +
      DiagnosePrompts(项目根 prompts/) + tree_data={}（由诊断管线内部按需加载）。
    - 缺 .env / config 关键项一律 fail loud（P5），绝不静默兜底。
    - `--dry-run` 用假 deps 跑通两阶段 wiring 不真调 LLM，打印将执行的步骤 + 指纹，
      用于校验装配正确性（对齐 CLAUDE.md §2.5 smoke test）。

编排函数（run_pipeline）通过依赖注入接收 DiagnosisDeps / signal_store / wrong_ids /
questions / canonical_preds，便于单测用假实现替换、不触真实 LLM 与 harness.db。
其中 canonical_preds 供 Phase 0 补 INFRA / 空预测错题的 T0 信号（这些题不进诊断）。
"""

from __future__ import annotations

import argparse
import asyncio
import datetime
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any

import yaml
from loguru import logger

from app.harness.baseline_diagnosis import DiagnosisDeps, run_baseline_diagnosis
from app.harness.build_split import (
    SplitBuildConfig,
    SplitBuildResult,
    build_split,
    load_canonical_predictions,
)
from app.harness.split_selection import diag_fingerprint
from app.question_gen.loader import load_benchmark
from core.evolution.types import DiagnosisSignalRow

if TYPE_CHECKING:
    from app.harness.pools import Pools
    from core.evolution.protocols import DiagnosisSignalStore
    from core.types import GeneratedQuestion

# 与 core.evolution.diagnose._INFRA_STOP_REASONS 对齐：执行/解析层失败排除出可诊断错题。
_INFRA_STOP_REASONS: frozenset[str] = frozenset({"error", "parse_error"})

# 工程路径默认值（少变；可经 CLI 单次覆盖）。诊断信号表建在 harness.db。
_DEFAULT_HARNESS_DB = Path("workspaces/default/harness.db")
_DEFAULT_QUESTIONS_DIR = Path("store/questions/benchmarks/Video-MME")
_DEFAULT_OUT_DIR = Path("workspaces/video-split")


# ---------------------------------------------------------------------------
# 配置解析（fail loud）
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class VideoSplitConfig:
    """结果驱动视频级切分的科研旋钮快照（从 config/video_split.yaml 解析）。

    字段:
        baseline_run_id: 基线 run 标识（错题诊断与切分依据）。
        n_trainval: trainval 目标视频数（多样性阶段填充上限）。
        epsilon: test 相对全局最大允许分布偏差（题型 / 难度两维）。
        report_floor: per-type 报告门限，题数 ≥ 此值的 task_type 才入 ε 约束。
        val_wrong_min: validation 池最少错题数（McNemar 功效阈；0=不检查）。
        val_ratio: validation 占 trainval 视频组总数的比例。
        seed: 贪心选择器预洗牌 + 视频组题级切分种子。
        floor_k: 各高信号 task_type 的 T2 defect 下限（硬约束）。
        prompt_version: 诊断 prompt 版本标识（指纹分量）。
        model: 执行诊断的模型名（指纹分量）。
    """

    baseline_run_id: str
    n_trainval: int
    epsilon: float
    report_floor: int
    val_wrong_min: int
    val_ratio: float
    seed: int
    floor_k: dict[str, int]
    prompt_version: str
    model: str


def _require(section: dict[str, Any], keys: tuple[str, ...], where: str) -> None:
    """校验 section 含全部必填键，缺任一即 fail loud（P5，不静默兜底）。

    参数:
        section: 待校验的配置子字典。
        keys: 必填键元组。
        where: 出错信息中标注的段名（如 "video_split"）。

    异常:
        SystemExit: 存在缺失键。
    """
    missing = [k for k in keys if k not in section]
    if missing:
        raise SystemExit(f"config {where} 段缺关键项 {missing}，无法运行（P5 fail loud）")


def parse_config(raw: dict[str, Any]) -> VideoSplitConfig:
    """把 yaml 原始字典解析为 VideoSplitConfig，缺关键项 fail loud。

    参数:
        raw: yaml.safe_load 的顶层字典，需含 video_split / diag 两段。

    返回:
        VideoSplitConfig 冻结快照。

    异常:
        SystemExit: 缺 video_split / diag 段或段内关键项。
    """
    if "video_split" not in raw or "diag" not in raw:
        raise SystemExit("config 缺 video_split / diag 段，无法运行（P5 fail loud）")
    vs = raw["video_split"]
    dg = raw["diag"]
    _require(
        vs,
        (
            "baseline_run_id",
            "n_trainval",
            "epsilon",
            "report_floor",
            "val_wrong_min",
            "val_ratio",
            "seed",
            "floor_k",
        ),
        "video_split",
    )
    _require(dg, ("prompt_version", "model"), "diag")
    return VideoSplitConfig(
        baseline_run_id=vs["baseline_run_id"],
        n_trainval=vs["n_trainval"],
        epsilon=vs["epsilon"],
        report_floor=vs["report_floor"],
        val_wrong_min=vs["val_wrong_min"],
        val_ratio=vs["val_ratio"],
        seed=vs["seed"],
        floor_k=dict(vs["floor_k"]),
        prompt_version=dg["prompt_version"],
        model=dg["model"],
    )


def load_config(config_path: Path) -> VideoSplitConfig:
    """读取并解析 video_split yaml 配置文件（缺文件 / 关键项 fail loud）。

    参数:
        config_path: yaml 配置路径。

    返回:
        VideoSplitConfig。

    异常:
        SystemExit: 文件不存在或缺关键项。
    """
    if not config_path.exists():
        raise SystemExit(f"config 文件不存在: {config_path}（P5 fail loud）")
    raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
    return parse_config(raw)


def git_short_sha() -> str:
    """取当前 git 短 SHA 作为诊断口径指纹的代码分量（诊断代码变则指纹变）。

    返回:
        git rev-parse --short HEAD 输出（去空白）。

    异常:
        SystemExit: 非 git 仓库或 git 不可用（fail loud，指纹不可缺分量）。
    """
    try:
        out = subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            capture_output=True,
            text=True,
            check=True,
        )
    except (subprocess.CalledProcessError, FileNotFoundError) as exc:
        raise SystemExit(f"无法获取 git 短 SHA 作为诊断代码版本: {exc}（P5 fail loud）") from exc
    sha = out.stdout.strip()
    if not sha:
        raise SystemExit("git rev-parse --short HEAD 返回空，诊断指纹缺代码分量（P5 fail loud）")
    return sha


# ---------------------------------------------------------------------------
# 真实依赖组装（参考 runner.py::_run_diagnosis）
# ---------------------------------------------------------------------------


class _DiagLLMSettings:
    """诊断 LLM 的工程配置（从 .env 读取 search llm 凭证 + 韧性旋钮）。

    仅承载诊断所需字段（搜索 LLM = 诊断 judge），不复用 main.InfraSettings 以免
    构造整套适配器（embed / vlm）的重活；缺关键凭证 fail loud。
    """

    def __init__(self) -> None:
        from pydantic_settings import BaseSettings, SettingsConfigDict

        class _Settings(BaseSettings):
            model_config = SettingsConfigDict(env_file=".env", extra="ignore")

            search_llm_model: str = ""
            search_llm_base_url: str = ""
            search_llm_api_key: str = ""
            redis_url: str = ""
            redis_cache_ttl: int = 86400
            llm_timeout: float = 300.0
            llm_max_retries: int = 3
            llm_retry_base_delay: float = 20.0
            llm_retry_max_delay: float = 120.0
            llm_circuit_breaker_threshold: int = 48
            llm_circuit_breaker_cooldown: float = 60.0
            llm_ttft_timeout: float = 30.0
            llm_inter_token_timeout: float = 15.0

        self._s = _Settings()

    def __getattr__(self, name: str) -> Any:
        return getattr(self._s, name)


def _build_redis_cache(settings: Any) -> Any | None:
    """按 .env redis_url 构建响应缓存（不可用则降级 None，与 main 一致）。"""
    if not settings.redis_url:
        return None
    try:
        import redis.asyncio as aioredis

        from adapters.redis_cache import RedisResponseCache

        redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
        ttl_s = settings.redis_cache_ttl if settings.redis_cache_ttl > 0 else None
        return RedisResponseCache(redis=redis_client, ttl_s=ttl_s)
    except Exception:
        logger.warning("Redis 缓存不可用，诊断降级为无缓存模式")
        return None


def build_diagnosis_deps(
    *, harness_db: Path, concurrency: int, expected_model: str
) -> DiagnosisDeps:
    """组装 Phase 1 诊断的真实依赖束（GovernedLLMClient + RunLogImpl + prompts）。

    与 runner.py::_run_diagnosis 对齐：search LLM（thinking=True）作诊断 judge，
    RunLogImpl 只读读取 harness.db 的 predictions/traces，VersionedSkillStore 读技能，
    DiagnosePrompts 从项目根 prompts/ 加载，tree_data={} 由诊断管线内部按需加载。

    参数:
        harness_db: harness.db 路径（诊断读预测 + 信号落库同库）。
        concurrency: 诊断并发上限。
        expected_model: config.diag.model（诊断口径指纹的模型分量）。必须与 .env
            SEARCH_LLM_MODEL 一致——指纹里的 model 与实际诊断所用 model 不一致会让
            信号以错误模型指纹落库，破坏可复现 / resume / 口径隔离，故此处 fail loud。

    返回:
        DiagnosisDeps 冻结依赖束。

    异常:
        SystemExit: .env 缺 search LLM 凭证（model / base_url / api_key 任一为空），
            或 config.diag.model 与 .env SEARCH_LLM_MODEL 不一致（指纹漂移防护）。
    """
    from adapters.breaker import CircuitBreaker
    from adapters.llm import GovernedLLMClient
    from adapters.telemetry import SQLiteTelemetryRecorder
    from app.harness.log import RunLogImpl
    from app.harness.workspace import VersionedSkillStore

    settings = _DiagLLMSettings()
    if not (
        settings.search_llm_model and settings.search_llm_base_url and settings.search_llm_api_key
    ):
        raise SystemExit(
            "诊断 LLM 凭证缺失：.env 需配置 SEARCH_LLM_MODEL / SEARCH_LLM_BASE_URL / "
            "SEARCH_LLM_API_KEY（P5 fail loud，不静默兜底）"
        )
    if expected_model != settings.search_llm_model:
        raise SystemExit(
            "诊断模型指纹漂移：config.diag.model="
            f"{expected_model!r} 与 .env SEARCH_LLM_MODEL={settings.search_llm_model!r} "
            "不一致；指纹里的 model 必须等于实际诊断所用 model（P5 fail loud，"
            "请对齐 config/video_split.yaml diag.model 与 .env SEARCH_LLM_MODEL）"
        )

    telemetry_db = Path("logs/telemetry.db")
    telemetry_db.parent.mkdir(parents=True, exist_ok=True)
    telemetry = SQLiteTelemetryRecorder(telemetry_db)
    breaker = CircuitBreaker(
        fail_threshold=max(settings.llm_circuit_breaker_threshold, 1),
        cooldown_s=settings.llm_circuit_breaker_cooldown,
    )
    llm = GovernedLLMClient(
        model=settings.search_llm_model,
        base_url=settings.search_llm_base_url,
        api_key=settings.search_llm_api_key,
        provider=settings.search_llm_model.split("-")[0],
        thinking=True,
        breaker=breaker,
        cache=_build_redis_cache(settings),
        telemetry=telemetry,
        timeout_s=settings.llm_timeout,
        ttft_timeout_s=settings.llm_ttft_timeout,
        inter_token_timeout_s=settings.llm_inter_token_timeout,
        max_retries=settings.llm_max_retries,
        retry_base_delay_s=settings.llm_retry_base_delay,
        retry_max_delay_s=settings.llm_retry_max_delay,
    )
    return DiagnosisDeps(
        run_log=RunLogImpl(str(harness_db)),
        llm=llm,
        skill_store=VersionedSkillStore(_diagnosis_skills_dir()),
        prompts=_load_diagnose_prompts(),
        tree_data={},
        concurrency=concurrency,
    )


def _diagnosis_skills_dir() -> Path:
    """诊断用技能目录：种子 store 的当前技能版本（诊断读技能遵从判定）。

    诊断只读技能内容判断"是否遵从技能"，用 store 种子 v1 即可（与基线 run 一致）。
    """
    return Path("store/skills/v1")


def _load_diagnose_prompts() -> Any:
    """加载诊断模板束（从项目根 prompts/ 读取；与 runner._load_diagnose_prompts 一致）。"""
    from core.evolution.types import DiagnosePrompts

    def _read(name: str) -> str:
        p = Path("prompts") / name
        return p.read_text(encoding="utf-8") if p.exists() else ""

    return DiagnosePrompts(
        defect_vs_lapse=_read("defect_vs_lapse.md"),
        reasoning_sub=_read("reasoning_sub.md"),
        span_eval_system=_read("span_eval_system.md"),
        span_eval_user=_read("span_eval_user.md"),
        missed_nodes=_read("missed_nodes.md"),
        skill_adherence=_read("skill_adherence.md"),
        confirmation_bias=_read("confirmation_bias.md"),
        evidence_sufficiency=_read("evidence_sufficiency.md"),
    )


def select_diagnosable_wrong_ids(preds: list[dict]) -> list[str]:
    """从 canonical 预测筛出可诊断错题 question_id（保序）。

    可诊断错题判据：预测非空 且 stop_reason 非 INFRA（error / parse_error）且
    归一后预测 != 答案。INFRA / 空预测错题不进 wrong_ids——它们改由
    persist_infra_t0_rows 直接落 T0（run_diagnosis 内部也会二次排除同类题）。

    参数:
        preds: load_canonical_predictions 产出的 canonical 预测行（已按 qid 去重）。

    返回:
        可诊断错题 question_id 列表（保 preds 顺序）。
    """
    wrong_ids: list[str] = []
    for pred in preds:
        prediction = (pred["prediction"] or "").strip()
        if not prediction or pred["stop_reason"] in _INFRA_STOP_REASONS:
            continue
        if not pred["correct"]:
            wrong_ids.append(pred["question_id"])
    return wrong_ids


def persist_infra_t0_rows(
    store: DiagnosisSignalStore,
    preds: list[dict],
    baseline_run_id: str,
    diag_fingerprint: str,
) -> int:
    """把非正确且 INFRA / 空预测的错题以 T0 信号行 upsert 落库（幂等）。

    这些题（stop_reason ∈ {error, parse_error} 或预测为空）从不进入 run_diagnosis
    （筛选时被前置排除），故其 T0 信号必须在此单独补齐——否则 signal store 缺这些行，
    tier 分布 / manifest 不完整（计划要求 4 个 INFRA 空预测错题 → T0）。

    投影口径与 baseline_diagnosis 的 INFRA 投影一致：infra=True、tier="T0"、
    error_type / cause_category / evolution_target 均 None、degraded=False；
    video_id / task_type 从 canonical 预测取。store.upsert 按主键
    (question_id, baseline_run_id, diag_fingerprint) 幂等，重复调用零副作用。

    参数:
        store: 诊断信号存储端口（与诊断落库同一 store）。
        preds: load_canonical_predictions 产出的 canonical 预测行。
        baseline_run_id: 基线 run 标识（信号行主键之一）。
        diag_fingerprint: 诊断口径指纹（信号行主键之一）。

    返回:
        落库的 T0 行数（供日志）。
    """
    count = 0
    for pred in preds:
        prediction = (pred["prediction"] or "").strip()
        is_infra_or_empty = pred["stop_reason"] in _INFRA_STOP_REASONS or not prediction
        if pred["correct"] or not is_infra_or_empty:
            continue
        store.upsert(
            DiagnosisSignalRow(
                question_id=pred["question_id"],
                video_id=pred["video_id"],
                baseline_run_id=baseline_run_id,
                diag_fingerprint=diag_fingerprint,
                task_type=pred["task_type"],
                error_type=None,
                cause_category=None,
                tier="T0",
                evolution_target=None,
                degraded=False,
                infra=True,
                session_id=None,
            )
        )
        count += 1
    return count


def load_questions_by_id(questions_dir: Path) -> dict[str, GeneratedQuestion]:
    """加载 benchmark 全部题并建 question_id → GeneratedQuestion 映射。

    覆盖 wrong_ids 与 run_diagnosis 返回的全部 infra/degraded 题（取 video_id/task_type）。

    参数:
        questions_dir: benchmark 题库目录。

    返回:
        question_id → GeneratedQuestion 映射。
    """
    return {q.question_id: q for q in load_benchmark(questions_dir)}


# ---------------------------------------------------------------------------
# McNemar 功效护栏
# ---------------------------------------------------------------------------


def check_mcnemar_power(pools: Pools, val_wrong_min: int) -> int:
    """校验 validation 池错题数达 McNemar 功效阈，不足即 fail loud。

    build_split 按契约用 split_by_video_assignment(val_wrong_min=0)（不破契约），
    故功效护栏在 capstone 层单独核验：val 错题数 < 阈 → 验证信号不足以支撑可靠比较。

    参数:
        pools: 冻结三池（含 validation 与 correctness）。
        val_wrong_min: 最少错题数阈（0 = 不检查）。

    返回:
        validation 池实际错题数（供日志）。

    异常:
        SystemExit: val_wrong_min > 0 且 val 错题数 < 阈（P5 fail loud，不静默放行）。
    """
    val_wrong = sum(1 for q in pools.validation if not pools.correctness[q.question_id])
    if val_wrong_min > 0 and val_wrong < val_wrong_min:
        raise SystemExit(
            f"validation 池错题数 {val_wrong} < val_wrong_min={val_wrong_min}，"
            "验证信号不足以支撑可靠比较（McNemar 检验功效不够）。"
            "请放大 val_ratio / 调整旋钮后重跑，勿静默放行。"
        )
    return val_wrong


# ---------------------------------------------------------------------------
# 两阶段编排（依赖注入，便于单测）
# ---------------------------------------------------------------------------


async def run_pipeline(
    *,
    config: VideoSplitConfig,
    fingerprint: str,
    diagnosis_deps: DiagnosisDeps,
    signal_store: DiagnosisSignalStore,
    wrong_ids: list[str],
    questions: dict[str, GeneratedQuestion],
    canonical_preds: list[dict],
    harness_db: Path,
    questions_dir: Path,
    out_dir: Path,
    generated_at: str,
) -> SplitBuildResult:
    """内联三阶段：Phase 0 INFRA T0 补录 → Phase 1 诊断 → Phase 2 冻结切分 → McNemar 护栏。

    参数:
        config: 科研旋钮快照。
        fingerprint: 诊断口径指纹（已合成，作诊断信号主键之一）。
        diagnosis_deps: Phase 1 诊断依赖束（真实或假实现）。
        signal_store: 诊断信号存储端口（Phase 0/1 写、Phase 2 读）。
        wrong_ids: 待诊断的可诊断错题 question_id 列表。
        questions: question_id → GeneratedQuestion 映射。
        canonical_preds: canonical 预测行（Phase 0 从中筛 INFRA / 空预测错题补 T0）。
        harness_db: harness.db 路径（Phase 2 读 canonical 预测）。
        questions_dir: benchmark 题库目录（Phase 2 加载题库切池）。
        out_dir: 冻结产物目录（pools.json + split_manifest.json）。
        generated_at: 生成时间戳（ISO 字符串，由调用方传入；见模块 C-2 复现锚点约定）。

    返回:
        SplitBuildResult（冻结三池 + manifest + assignment）。
    """
    # Phase 0: INFRA / 空预测错题补 T0（这些题不进诊断，须单独落库保证 tier 分布/manifest 完整）。
    n_t0 = persist_infra_t0_rows(signal_store, canonical_preds, config.baseline_run_id, fingerprint)
    logger.info("Phase 0 INFRA T0 补录：落库 {} 行（INFRA / 空预测错题不进诊断）", n_t0)

    # Phase 1: 离线诊断（断点续跑幂等：done_question_ids 已完成题跳过）。
    logger.info(
        "Phase 1 离线诊断：baseline={} 待诊断错题 {} 题", config.baseline_run_id, len(wrong_ids)
    )
    await run_baseline_diagnosis(
        baseline_run_id=config.baseline_run_id,
        diag_fingerprint=fingerprint,
        wrong_ids=wrong_ids,
        questions=questions,
        store=signal_store,
        deps=diagnosis_deps,
    )

    # Phase 2: 冻结切分（读诊断信号 → 贪心选择 → 视频组原子切三池 → 冻结 + 六条断言）。
    out_dir.mkdir(parents=True, exist_ok=True)
    logger.info("Phase 2 冻结切分：out={}", out_dir)
    result = build_split(
        db_path=harness_db,
        baseline_run_id=config.baseline_run_id,
        signal_store=signal_store,
        diag_fingerprint=fingerprint,
        questions_dir=questions_dir,
        config=SplitBuildConfig(
            n_trainval=config.n_trainval,
            floor_k=config.floor_k,
            epsilon=config.epsilon,
            report_floor=config.report_floor,
            select_seed=config.seed,
            val_ratio=config.val_ratio,
            split_seed=config.seed,
        ),
        out_path=out_dir / "pools.json",
        manifest_path=out_dir / "split_manifest.json",
        generated_at=generated_at,
    )

    # McNemar 功效护栏（build_split 契约外的 capstone 层校验）。
    val_wrong = check_mcnemar_power(result.pools, config.val_wrong_min)
    logger.info(
        "切分冻结完成：pools={} manifest={} val错题={}/{}(阈)",
        out_dir / "pools.json",
        out_dir / "split_manifest.json",
        val_wrong,
        config.val_wrong_min,
    )
    return result


# ---------------------------------------------------------------------------
# 真实执行 / dry-run 入口
# ---------------------------------------------------------------------------


def _resolve_paths(args: argparse.Namespace) -> tuple[Path, Path, Path]:
    """解析 harness_db / questions_dir / out_dir（CLI 覆盖默认工程路径）。"""
    harness_db = args.harness_db or _DEFAULT_HARNESS_DB
    questions_dir = args.questions_dir or _DEFAULT_QUESTIONS_DIR
    out_dir = args.out_dir or _DEFAULT_OUT_DIR
    return harness_db, questions_dir, out_dir


def _execute_real(config: VideoSplitConfig, fingerprint: str, args: argparse.Namespace) -> None:
    """真实执行两阶段管线：组装真实 deps、读错题、跑诊断 + 冻结切分。"""
    harness_db, questions_dir, out_dir = _resolve_paths(args)
    if not harness_db.exists():
        raise SystemExit(f"harness.db 不存在: {harness_db}（P5 fail loud）")
    canonical_preds = load_canonical_predictions(harness_db, config.baseline_run_id)
    wrong_ids = select_diagnosable_wrong_ids(canonical_preds)
    questions = load_questions_by_id(questions_dir)
    deps = build_diagnosis_deps(
        harness_db=harness_db, concurrency=args.concurrency, expected_model=config.model
    )

    # generated_at：默认盖真实 UTC now（溯源用），--generated-at 可显式固定以复现（C-2）。
    generated_at = args.generated_at or datetime.datetime.now(datetime.UTC).isoformat()

    from adapters.baseline_diagnosis_store import SqliteDiagnosisSignalStore

    store = SqliteDiagnosisSignalStore(str(harness_db))
    try:
        asyncio.run(
            run_pipeline(
                config=config,
                fingerprint=fingerprint,
                diagnosis_deps=deps,
                signal_store=store,
                wrong_ids=wrong_ids,
                questions=questions,
                canonical_preds=canonical_preds,
                harness_db=harness_db,
                questions_dir=questions_dir,
                out_dir=out_dir,
                generated_at=generated_at,
            )
        )
    finally:
        store.close()


class _DryRunLLM:
    """dry-run 假 LLM：被真实调用即报错，保证不真调 LLM。"""

    async def complete(self, *args: Any, **kwargs: Any) -> Any:
        raise AssertionError("dry-run 不应真调 LLM.complete")


class _DryRunLog:
    """dry-run 假 RunLog：predictions/traces 均返回空，诊断不真正执行。"""

    async def get_predictions(self, run_id: str, *, question_ids: list[str] | None = None) -> list:
        return []

    async def get_traces(self, run_id: str, *, question_ids: list[str] | None = None) -> list:
        return []


def _execute_dry_run(config: VideoSplitConfig, fingerprint: str, args: argparse.Namespace) -> None:
    """dry-run：用假 deps 跑通 Phase 1 wiring（空错题 → 诊断早返回），打印步骤 + 指纹。

    Phase 2 build_split 需真实诊断信号方能冻结，dry-run 不真实冻结，仅打印其计划；
    Phase 1 用空 wrong_ids 走 run_baseline_diagnosis 早返回路径，验证装配可调用而不触 LLM。
    """
    harness_db, questions_dir, out_dir = _resolve_paths(args)
    logger.info("=== dry-run：校验两阶段装配（不真调 LLM / 不冻结产物）===")
    logger.info(
        "诊断口径指纹 diag_fingerprint={} (prompt={} model={})",
        fingerprint,
        config.prompt_version,
        config.model,
    )
    logger.info(
        "解析路径：harness_db={} questions_dir={} out_dir={}", harness_db, questions_dir, out_dir
    )
    logger.info(
        "旋钮：n_trainval={} epsilon={} report_floor={} val_ratio={} seed={} "
        "val_wrong_min={} floor_k={}",
        config.n_trainval,
        config.epsilon,
        config.report_floor,
        config.val_ratio,
        config.seed,
        config.val_wrong_min,
        config.floor_k,
    )

    fake_deps = DiagnosisDeps(
        run_log=_DryRunLog(),
        llm=_DryRunLLM(),
        skill_store=object(),
        prompts=object(),
        tree_data={},
        concurrency=args.concurrency,
    )

    from adapters.baseline_diagnosis_store import SqliteDiagnosisSignalStore

    dry_db = out_dir / "_dry_run_signals.db"
    dry_db.parent.mkdir(parents=True, exist_ok=True)
    store = SqliteDiagnosisSignalStore(str(dry_db))
    try:
        # Phase 0 装配：用一条假 INFRA 空预测走通 persist_infra_t0_rows（不触 LLM）。
        fake_infra_preds = [
            {
                "question_id": "_dry_infra",
                "video_id": "_dry_v",
                "task_type": "Counting Problem",
                "prediction": "",
                "answer": "A",
                "stop_reason": "error",
                "correct": False,
            }
        ]
        n_t0 = persist_infra_t0_rows(store, fake_infra_preds, config.baseline_run_id, fingerprint)
        logger.info("Phase 0 装配 OK：persist_infra_t0_rows 落 {} 行 INFRA T0（假数据）", n_t0)
        logger.info("Phase 1 装配 OK：run_baseline_diagnosis 以空错题走早返回路径（不触 LLM）")
        asyncio.run(
            run_baseline_diagnosis(
                baseline_run_id=config.baseline_run_id,
                diag_fingerprint=fingerprint,
                wrong_ids=[],
                questions={},
                store=store,
                deps=fake_deps,
            )
        )
    finally:
        store.close()
        dry_db.unlink(missing_ok=True)
    logger.info(
        "Phase 2 装配 OK：真实执行将调 build_split 冻结 pools.json + manifest（dry-run 跳过）"
    )
    logger.info("=== dry-run 通过：两阶段装配可调用，指纹已算出 ===")


def build_arg_parser() -> argparse.ArgumentParser:
    """构建 CLI 参数解析器。"""
    parser = argparse.ArgumentParser(description="结果驱动视频级切分两阶段 CLI（诊断 → 冻结切分）")
    parser.add_argument("--config", type=Path, default=Path("config/video_split.yaml"))
    parser.add_argument("--dry-run", action="store_true", dest="dry_run")
    parser.add_argument("--gpu", type=str, default=None, help="可选：设置 CUDA_VISIBLE_DEVICES")
    parser.add_argument("--concurrency", type=int, default=8, help="诊断并发上限")
    parser.add_argument("--harness-db", type=Path, default=None, dest="harness_db")
    parser.add_argument("--questions-dir", type=Path, default=None, dest="questions_dir")
    parser.add_argument("--out-dir", type=Path, default=None, dest="out_dir")
    parser.add_argument(
        "--generated-at",
        type=str,
        default=None,
        dest="generated_at",
        help=(
            "manifest generated_at 时间戳（ISO 字符串）；默认盖真实 UTC now（溯源元数据）。"
            "复现锚点是 pools.json 内容 + seed + fingerprint；generated_at 可显式传入以"
            "对 manifest 做字节级复现比对。"
        ),
    )
    return parser


def main(argv: list[str] | None = None) -> None:
    """CLI 入口：解析参数 → 载配置 → 算指纹 → dry-run 或真实两阶段执行。

    参数:
        argv: 可选参数列表（默认 sys.argv[1:]），便于测试注入。
    """
    from dotenv import load_dotenv

    load_dotenv()
    args = build_arg_parser().parse_args(argv)
    if args.gpu is not None:
        os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu
        logger.info("CUDA_VISIBLE_DEVICES={}", args.gpu)

    config = load_config(args.config)
    fingerprint = diag_fingerprint(config.prompt_version, config.model, git_short_sha())

    if args.dry_run:
        _execute_dry_run(config, fingerprint, args)
        return
    _execute_real(config, fingerprint, args)


if __name__ == "__main__":
    main()
