"""v2 出题管线编排 — 重出循环 + 重量抽检 + 并发控制。

完整编排流程:
1. 基于视频列表和任务类型分配 slot
2. 每个 slot 执行 generate → postprocess → gates → 重出循环
3. 去重检测（embedding cosine similarity）
4. 接受题目的随机子集做重量抽检（盲 Agent 试答）
5. 记录全部结果到 QuestionGenStore

典型调用::

    result = await run_pipeline_v2(
        video_ids=["v1", "v2"],
        trees={"v1": tree1, "v2": tree2},
        vlm=vlm_client,
        llm=llm_client,
        embed_fn=embed_fn,
        store=store,
        config=config,
        task_types=TASK_TYPES,
    )
"""

from __future__ import annotations

import asyncio
import json
import random
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

import numpy as np
import yaml
from loguru import logger

from app.question_gen.gates import GateReport, GateResult, GateVerdict, run_gates
from app.question_gen.generator_v2 import CandidateQuestion, generate_one_v2
from app.question_gen.postprocess import run_postprocess
from app.question_gen.sampler_v2 import sample_material_v2
from app.question_gen.strategy import get_strategy
from core.types import GeneratedQuestion

if TYPE_CHECKING:
    from collections.abc import Callable

    from app.question_gen.run_store import QuestionGenStore
    from app.question_gen.sampler_v2 import MaterialContext
    from app.question_gen.strategy import TaskTypeStrategy
    from app.tree.index import TreeIndex
    from core.protocols import LLMProvider, VLMProvider


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


@dataclass(frozen=True)
class SlotAssignment:
    """出题 slot 分配 — 一个 slot 对应一道待生成的题目。

    属性:
        slot_id: slot 唯一标识。
        video_id: 分配到的视频 ID。
        task_type: 任务类型。
        seq: slot 序号。
    """

    slot_id: str
    video_id: str
    task_type: str
    seq: int


@dataclass
class PipelineResult:
    """管线运行结果。

    属性:
        accepted: 最终通过的题目列表。
        rejected_count: 被拒绝（含重出耗尽）的题目数。
        heavy_sampled: 重量抽检结果列表 — (question_id, difficulty_steps)。
    """

    accepted: list[GeneratedQuestion]
    rejected_count: int
    heavy_sampled: list[tuple[str, int]]


@dataclass(frozen=True)
class PipelineConfig:
    """管线配置。

    属性:
        per_type: 每种 task_type 生成的题目数。
        retry_limit: 单 slot 最大重出次数。
        heavy_sample_rate: 重量抽检采样比例 [0.0, 1.0]。
        dedup_threshold: 去重余弦相似度阈值。
        concurrency: 并发 slot 数上限。
        seed: 随机种子。
        output_dir: 输出目录。
        candidate_pool_size: grounded selector 首轮候选干扰项数 N。
        selector_delta_low: 干扰项视觉分与正解的最小差（区间上界）。
        selector_delta_high: 干扰项视觉分与正解的最大差（区间下界）。
    """

    per_type: int
    retry_limit: int
    heavy_sample_rate: float
    dedup_threshold: float
    concurrency: int
    seed: int
    output_dir: Path
    candidate_pool_size: int = 24
    selector_delta_low: float = 0.05
    selector_delta_high: float = 0.35


# ---------------------------------------------------------------------------
# 配置加载
# ---------------------------------------------------------------------------


def load_pipeline_config(yaml_path: Path) -> PipelineConfig:
    """从 YAML 文件加载管线配置。

    读取 question_gen_v2 区段，映射为 PipelineConfig dataclass。

    参数:
        yaml_path: YAML 配置文件路径。

    返回:
        PipelineConfig 实例。

    异常:
        FileNotFoundError: 文件不存在。
        KeyError: 缺少 question_gen_v2 区段。
    """
    if not yaml_path.exists():
        msg = f"配置文件不存在: {yaml_path}"
        raise FileNotFoundError(msg)

    with yaml_path.open(encoding="utf-8") as f:
        raw = yaml.safe_load(f)

    if "question_gen_v2" not in raw:
        msg = f"配置文件缺少 'question_gen_v2' 区段: {yaml_path}"
        raise KeyError(msg)

    section = raw["question_gen_v2"]

    return PipelineConfig(
        per_type=int(section["per_type"]),
        retry_limit=int(section["retry_limit"]),
        heavy_sample_rate=float(section["heavy_sample_rate"]),
        dedup_threshold=float(section["dedup_threshold"]),
        concurrency=int(section["concurrency"]),
        seed=int(section["seed"]),
        output_dir=Path(section["output_dir"]),
        candidate_pool_size=int(section.get("candidate_pool_size", 24)),
        selector_delta_low=float(section.get("selector_delta_low", 0.05)),
        selector_delta_high=float(section.get("selector_delta_high", 0.35)),
    )


# ---------------------------------------------------------------------------
# Slot 分配
# ---------------------------------------------------------------------------


def _assign_slots(
    video_ids: list[str],
    task_types: list[str],
    per_type: int,
    seq_offset: int = 0,
) -> list[SlotAssignment]:
    """将出题目标分配为具体 slot 列表。

    总 slot 数 = len(task_types) * per_type。
    在视频间 round-robin 分配。不再选择 family — strategy 在处理时查找。

    参数:
        video_ids: 视频 ID 列表。
        task_types: 任务类型列表。
        per_type: 每种 task_type 的目标题数。
        seq_offset: 全局序号起始偏移（补生成续编，默认 0）。

    返回:
        SlotAssignment 列表。
    """
    slots: list[SlotAssignment] = []
    global_seq = seq_offset

    for task_type in task_types:
        for i in range(per_type):
            video_id = video_ids[i % len(video_ids)]
            global_seq += 1
            slot_id = f"{task_type}_{global_seq:04d}"
            slots.append(
                SlotAssignment(
                    slot_id=slot_id,
                    video_id=video_id,
                    task_type=task_type,
                    seq=global_seq,
                )
            )

    return slots


# ---------------------------------------------------------------------------
# 去重检测
# ---------------------------------------------------------------------------


def _is_duplicate(
    question_text: str,
    embed_pool: list[np.ndarray],
    embed_fn: Callable[[str], np.ndarray],
    threshold: float,
) -> bool:
    """检测题目文本是否与已接受题库重复。

    计算题目 embedding 与 embed_pool 中所有向量的余弦相似度，
    若最大相似度超过阈值则视为重复。

    参数:
        question_text: 待检测的题目文本。
        embed_pool: 已接受题目的 embedding 向量列表。
        embed_fn: 文本到向量的映射函数。
        threshold: 余弦相似度阈值。

    返回:
        True 表示重复。
    """
    if not embed_pool:
        return False

    query_vec = embed_fn(question_text).flatten()
    query_norm = np.linalg.norm(query_vec)
    if query_norm == 0:
        return False

    for pool_vec in embed_pool:
        pool_norm = np.linalg.norm(pool_vec)
        if pool_norm == 0:
            continue
        similarity = float(np.dot(query_vec, pool_vec) / (query_norm * pool_norm))
        if similarity > threshold:
            return True

    return False


# ---------------------------------------------------------------------------
# CandidateQuestion → GeneratedQuestion 转换
# ---------------------------------------------------------------------------


def _to_generated_question(
    candidate: CandidateQuestion,
    *,
    family: str,
    options: tuple[str, ...] | None = None,
    answer: str | None = None,
    sub_pattern: str | None = None,
) -> GeneratedQuestion:
    """将 CandidateQuestion 转换为 GeneratedQuestion。

    参数:
        candidate: 门控通过的候选题目。
        family: 问题家族名称（如 "RETRIEVAL"）。
        options: 洗牌后的选项元组（若为 None 则使用 candidate 原始选项）。
        answer: 重映射后的答案字母（若为 None 则使用 candidate 原始答案）。
        sub_pattern: 出题子模式标识（AR 特化策略使用，None 表示无）。

    返回:
        GeneratedQuestion 实例（difficulty_steps 初始为 None）。
    """
    return GeneratedQuestion(
        question_id=candidate.question_id,
        video_id=candidate.video_id,
        task_type=candidate.task_type,
        question=candidate.question,
        options=options if options is not None else candidate.options,
        answer=answer if answer is not None else candidate.answer,
        source_nodes=candidate.source_nodes,
        difficulty=candidate.difficulty,
        family=family,
        skill_target=candidate.skill_target,
        difficulty_steps=None,
        sub_pattern=sub_pattern,
    )


def _extract_correct_text(options: tuple[str, ...], answer: str) -> str:
    """从四选项中取正解文本（去掉 "X. " 字母前缀）。

    参数:
        options: 选项元组，格式 ("A. ...", "B. ...", ...)。
        answer: 正解字母（大小写不敏感）。

    返回:
        正解选项去前缀后的文本。

    异常:
        ValueError: answer 对应索引超出选项范围。
    """
    idx = ord(answer.strip().upper()) - ord("A")
    if not 0 <= idx < len(options):
        msg = f"answer '{answer}' 超出选项范围 (n={len(options)})"
        raise ValueError(msg)
    opt = options[idx]
    prefix = f"{answer.strip().upper()}. "
    return opt[len(prefix) :] if opt.startswith(prefix) else opt


def _replace_candidate_options(
    candidate: CandidateQuestion, options: tuple[str, ...], answer: str
) -> CandidateQuestion:
    """用 selector 重组的选项/答案替换候选（CandidateQuestion frozen）。

    参数:
        candidate: 原候选题目。
        options: grounded selector 重组后的四选项。
        answer: 重组后的正解字母（恒 "A"）。

    返回:
        仅替换 options/answer、其余字段照搬的新 CandidateQuestion。
    """
    return CandidateQuestion(
        question_id=candidate.question_id,
        video_id=candidate.video_id,
        task_type=candidate.task_type,
        skill_target=candidate.skill_target,
        question=candidate.question,
        options=options,
        answer=answer,
        source_nodes=candidate.source_nodes,
        difficulty=candidate.difficulty,
        subtitle_sentences=candidate.subtitle_sentences,
        frame_paths=candidate.frame_paths,
    )


async def _apply_grounded_selector(
    candidate: CandidateQuestion,
    strategy: TaskTypeStrategy,
    material: MaterialContext,
    vlm: VLMProvider,
    config: PipelineConfig,
    store: QuestionGenStore,
    item_id: str,
    slot_id: str,
    attempt: int,
    *,
    session_id: str,
) -> tuple[CandidateQuestion | None, str | None]:
    """对 AR 候选跑 grounded selector，落观测，返回 (candidate, reject_reason)。

    完整拥有 Phase 3.5 的分流控制流，使调用方仅需单一失败分支：
      - 非 AR 策略（`uses_grounded_selector` 为假）：直接放行，返回 (candidate, None)。
      - 成功：返回 (重组后的 candidate, None)。
      - selector 异常（`build_grounded_options` 内部抛 ValueError/FileNotFoundError）：
        仅置 reason，不落 rejected，返回 (None, "selector_error: ...")。
      - hard-fail：落 observation + mark_item_rejected，返回 (None, reason)。

    observation 无论成败都落库（供 EOB 退化观测与调参）。

    参数:
        candidate: 待重组的候选题目。
        strategy: 题型策略（决定是否走 grounded 路径）。
        material: 采样素材（提供 frame_paths / subtitles）。
        vlm: VLM 调用端口。
        config: 管线配置（提供 selector 三参）。
        store: 日志记录器（落 selector 观测与拒绝态）。
        item_id: 当前 item 的唯一 ID。
        slot_id: slot 标识（日志）。
        attempt: 当前重出轮次（日志）。
        session_id: 遥测会话 ID。

    返回:
        (candidate, None) 放行；(None, reject_reason) 要求调用方重出。
    """
    if not strategy.uses_grounded_selector:
        return candidate, None

    from app.question_gen.distractor_selector import SelectorConfig, build_grounded_options

    correct_text = _extract_correct_text(candidate.options, candidate.answer)
    selector_cfg = SelectorConfig(
        candidate_pool_size=config.candidate_pool_size,
        delta_low=config.selector_delta_low,
        delta_high=config.selector_delta_high,
    )
    try:
        outcome = await build_grounded_options(
            vlm=vlm,
            question=candidate.question,
            correct_text=correct_text,
            material=material,
            config=selector_cfg,
            session_id=session_id,
        )
    except (ValueError, FileNotFoundError, OSError, Exception) as e:  # noqa: BLE001
        # slot 级隔离：selector 会多次调 VLM，任何异常（含 VLM 网络/超时/熔断
        # CircuitOpenError/StreamLivenessTimeout 等非 ValueError 类型）都降级为
        # selector_error 重出该 slot，绝不穿透 asyncio.gather 崩掉整批生成。
        # 与相邻 Phase 2 生成失败的宽捕获口径一致；异常路径无 observation 可落，
        # 但仍 mark_item_rejected（该 attempt 的 item 是死记录，重出新建 item_id）。
        reason = f"selector_error: {e}"
        logger.warning("slot {} selector 异常 (attempt {}): {}", slot_id, attempt, e)
        store.mark_item_rejected(item_id, reason)
        return None, reason

    # observation 始终落库（含 hard-fail），供 EOB 退化观测与调参
    store.update_selector_scores(item_id, json.dumps(outcome.observation, ensure_ascii=False))
    if outcome.hard_fail:
        reason = "grounded 干扰项不足（selector 硬失败）"
        store.mark_item_rejected(item_id, reason)
        logger.info("slot {} selector 硬失败 (attempt {})", slot_id, attempt)
        return None, reason

    return _replace_candidate_options(candidate, outcome.options, outcome.answer), None


# ---------------------------------------------------------------------------
# 单 Slot 处理（重出循环）
# ---------------------------------------------------------------------------


async def _process_one_slot(
    slot: SlotAssignment,
    tree: TreeIndex,
    vlm: VLMProvider,
    llm: LLMProvider,
    embed_fn: Callable[[str], np.ndarray],
    embed_pool: list[np.ndarray],
    store: QuestionGenStore,
    config: PipelineConfig,
    used_node_ids: set[str],
    rng: random.Random,
    sem: asyncio.Semaphore,
    *,
    session_id: str,
    run_id: str,
    all_trees: dict[str, TreeIndex] | None = None,
) -> GeneratedQuestion | None:
    """处理单个 slot 的完整重出循环。

    流程:
    1. 采样素材
    2. 调用 VLM 生成候选题
    3. 记录到 store
    4. 后处理（洗牌、检测）
    5. verbatim 检查
    6. 四门质量检查
    7. 去重检测
    8. 通过 → 接受；失败 → 重出（最多 retry_limit 次）

    参数:
        slot: slot 分配信息。
        tree: 视频树索引。
        vlm: VLM 调用端口。
        llm: LLM 调用端口。
        embed_fn: embedding 函数。
        embed_pool: 已接受题目的 embedding 池。
        store: 日志记录器。
        config: 管线配置。
        used_node_ids: 已用节点 ID 集合。
        rng: 随机数生成器。
        sem: 并发信号量。
        session_id: 会话 ID。
        run_id: 批次 ID。

    返回:
        GeneratedQuestion（通过全部检查）或 None（重出耗尽）。
    """
    async with sem:
        strategy = get_strategy(slot.task_type)
        sub_pattern = strategy.select_sub_pattern(rng)

        prev_reason: str | None = None
        current_tree = tree
        current_video_id = slot.video_id

        for attempt in range(1, config.retry_limit + 1):
            # 每次重试都换视频（连续失败即切换）
            if attempt > 1 and all_trees:
                alt_ids = [v for v in all_trees if v != current_video_id]
                if alt_ids:
                    current_video_id = rng.choice(alt_ids)
                    current_tree = all_trees[current_video_id]
                    logger.info(
                        "slot {} 连续 {} 次失败，换视频 {} 重试",
                        slot.slot_id,
                        attempt - 1,
                        current_video_id,
                    )

            # Phase 1: 采样素材（sub_pattern 可覆盖 level 和 constraint）
            level = (
                sub_pattern.sampling_level_override
                if sub_pattern and sub_pattern.sampling_level_override is not None
                else strategy.sampling_level
            )
            constraint = (
                sub_pattern.constraint_override
                if sub_pattern and sub_pattern.constraint_override is not None
                else strategy.sampling_constraint
            )
            try:
                material = sample_material_v2(
                    tree=current_tree,
                    task_type=slot.task_type,
                    used_node_ids=used_node_ids,
                    rng=rng,
                    level=level,
                    constraint=constraint,
                )
            except (RuntimeError, KeyError) as e:
                logger.warning(
                    "slot {} 采样失败 (attempt {}/{}): {}",
                    slot.slot_id,
                    attempt,
                    config.retry_limit,
                    e,
                )
                continue

            # Phase 2: 生成候选题
            try:
                candidate = await generate_one_v2(
                    vlm=vlm,
                    tree=current_tree,
                    material=material,
                    task_type=slot.task_type,
                    seq=slot.seq,
                    video_id=current_video_id,
                    prompt_template=strategy.prompt_template,
                    strategy_name=strategy.strategy_name,
                    skill_target=strategy.skill_target,
                    reject_reason=prev_reason,
                    sub_pattern_instruction=sub_pattern.instruction if sub_pattern else None,
                    session_id=session_id,
                )
            except (ValueError, FileNotFoundError, OSError, Exception) as e:
                logger.warning(
                    "slot {} 生成失败 (attempt {}/{}): {}",
                    slot.slot_id,
                    attempt,
                    config.retry_limit,
                    e,
                )
                continue

            # Phase 3: 记录到 store
            item_id = f"{slot.slot_id}_att{attempt}_{uuid.uuid4().hex[:8]}"
            store.record_item(
                item_id=item_id,
                run_id=run_id,
                slot_id=slot.slot_id,
                video_id=current_video_id,
                family=strategy.strategy_name,
                task_type=slot.task_type,
                skill_target=strategy.skill_target,
                attempt=attempt,
                question_text=candidate.question,
                sub_pattern=sub_pattern.name if sub_pattern else None,
            )

            # Phase 3.5: grounded selector（仅 AR 路径；helper 内部完成分流与落库）
            candidate, selector_reason = await _apply_grounded_selector(
                candidate,
                strategy,
                material,
                vlm,
                config,
                store,
                item_id,
                slot.slot_id,
                attempt,
                session_id=session_id,
            )
            if selector_reason is not None:
                prev_reason = selector_reason
                continue

            # Phase 4: 后处理
            source_texts = list(material.subtitle_sentences)
            pp = run_postprocess(
                question_text=candidate.question,
                options=candidate.options,
                answer=candidate.answer,
                source_texts=source_texts,
                rng=rng,
            )

            # Phase 5: verbatim 短路检查
            if pp.verbatim_ratio > 0.5:
                logger.info(
                    "slot {} verbatim 过高 ({:.3f}), attempt {}/{}",
                    slot.slot_id,
                    pp.verbatim_ratio,
                    attempt,
                    config.retry_limit,
                )
                prev_reason = f"verbatim_ratio={pp.verbatim_ratio:.3f} exceeds 0.5"
                # 仍需记录门控结果
                skip_result = GateResult(
                    verdict=GateVerdict.SKIP,
                    reason="skipped due to verbatim",
                    raw_response="",
                )
                verbatim_report = GateReport(
                    key_verify=GateResult(
                        verdict=GateVerdict.FAIL,
                        reason=prev_reason,
                        raw_response="",
                    ),
                    blind_answer=skip_result,
                    multi_true=skip_result,
                    leak_test=skip_result,
                )
                store.update_gates(item_id, verbatim_report)
                continue

            # Phase 6: 四门质量检查（key_verify 使用 VLM 看帧+文本）
            try:
                report = await run_gates(
                    candidate=candidate,
                    tree=current_tree,
                    llm=llm,
                    leak_probe_template=strategy.leak_probe_template,
                    postprocess=pp,
                    vlm=vlm,
                    session_id=session_id,
                )
            except Exception as e:
                logger.warning(
                    "slot {} 门控调用异常 (attempt {}/{}): {}",
                    slot.slot_id,
                    attempt,
                    config.retry_limit,
                    e,
                )
                prev_reason = f"gate_error: {e}"
                continue
            store.update_gates(item_id, report)

            if not report.passed:
                prev_reason = report.reject_reason
                logger.info(
                    "slot {} 门控失败 (attempt {}/{}): {}",
                    slot.slot_id,
                    attempt,
                    config.retry_limit,
                    prev_reason,
                )
                continue

            # 题型专属额外 gate（仅标准门通过后才执行）
            extra_results = strategy.extra_gates(candidate)
            if any(r.verdict == GateVerdict.FAIL for r in extra_results):
                prev_reason = "; ".join(
                    r.reason for r in extra_results if r.verdict == GateVerdict.FAIL
                )
                logger.info(
                    "slot {} 额外 gate 失败 (attempt {}/{}): {}",
                    slot.slot_id,
                    attempt,
                    config.retry_limit,
                    prev_reason,
                )
                continue

            # Phase 7: 去重检测
            if _is_duplicate(candidate.question, embed_pool, embed_fn, config.dedup_threshold):
                prev_reason = "duplicate detected by embedding similarity"
                store.mark_item_rejected(item_id, prev_reason)
                logger.info(
                    "slot {} 重复题被拒绝 (attempt {}/{})",
                    slot.slot_id,
                    attempt,
                    config.retry_limit,
                )
                continue

            # Phase 8: 通过全部检查 → 接受（使用洗牌后的选项和答案）
            result = _to_generated_question(
                candidate,
                family=strategy.strategy_name,
                options=pp.options,
                answer=pp.answer,
                sub_pattern=sub_pattern.name if sub_pattern else None,
            )
            # 将题目 embedding 加入池（flatten 确保 1D）
            embed_pool.append(embed_fn(candidate.question).flatten())
            # 标记使用的节点
            used_node_ids.update(candidate.source_nodes)

            logger.info(
                "slot {} 接受: question_id={}, attempt={}/{}",
                slot.slot_id,
                result.question_id,
                attempt,
                config.retry_limit,
            )
            return result

        # 重出耗尽
        logger.warning("slot {} 重出耗尽 ({} 次)", slot.slot_id, config.retry_limit)
        return None


# ---------------------------------------------------------------------------
# 重量抽检
# ---------------------------------------------------------------------------


async def _heavy_check_one(
    question: GeneratedQuestion,
    tree: TreeIndex,
    llm: LLMProvider,
    *,
    session_id: str,
) -> int:
    """盲 Agent 试答 — 计算推理步数作为难度指标。

    让 LLM 在不访问树的情况下尝试回答问题，
    统计其推理步骤数作为 difficulty_steps。

    参数:
        question: 待检测的题目。
        tree: 视频树索引（本函数不使用，保留接口一致性）。
        llm: LLM 调用端口。
        session_id: 会话 ID。

    返回:
        推理步数（int）。解析失败时返回默认值 1。
    """
    prompt = (
        "You are a blind test agent. Answer this multiple-choice question "
        "WITHOUT any video context. Think step by step.\n\n"
        f"Question: {question.question}\n"
        f"Options:\n" + "\n".join(question.options) + "\n\n"
        "Respond with ONLY a JSON object:\n"
        '{"steps": [{"thought": "..."}], "answer": "A|B|C|D"}'
    )

    response = await llm.chat(
        [{"role": "user", "content": prompt}],
        session_id=session_id,
    )

    # 解析步骤数
    try:
        import json

        content = response.content.strip()
        if "```" in content:
            parts = content.split("```")
            for part in parts:
                stripped = part.strip()
                if stripped.startswith("json"):
                    stripped = stripped[4:].strip()
                if stripped.startswith("{"):
                    content = stripped
                    break

        data = json.loads(content)
        steps = data.get("steps", [])
        return max(len(steps), 1)
    except (json.JSONDecodeError, TypeError, AttributeError):
        logger.debug("heavy_check 响应解析失败，返回默认步数 1")
        return 1


# ---------------------------------------------------------------------------
# 管线辅助函数
# ---------------------------------------------------------------------------

_DEFAULT_TASK_TYPES: list[str] = [
    "Action Recognition",
    "Action Reasoning",
    "Attribute Perception",
    "Counting Problem",
    "Information Synopsis",
    "Object Recognition",
    "Object Reasoning",
    "OCR Problems",
    "Spatial Perception",
    "Spatial Reasoning",
    "Temporal Perception",
    "Temporal Reasoning",
]


def _get_git_sha() -> str:
    """获取当前 Git HEAD 短 SHA。

    返回:
        短 SHA 字符串；获取失败时返回 "unknown"。
    """
    import subprocess

    try:
        return subprocess.check_output(
            ["git", "rev-parse", "--short", "HEAD"],
            text=True,
            timeout=5,
        ).strip()
    except (subprocess.SubprocessError, FileNotFoundError):
        return "unknown"


def _filter_pending_slots(
    slots: list[SlotAssignment],
    progress: dict[str, str],
) -> list[SlotAssignment]:
    """过滤出未完成的 slot（跳过 progress 中已记录的）。

    参数:
        slots: 全部 slot 列表。
        progress: 已完成 slot 映射 {slot_id → "accepted"|"rejected"}。

    返回:
        待处理的 slot 列表。
    """
    pending = [s for s in slots if s.slot_id not in progress]
    logger.info(
        "待处理 slots: {} / {} (已跳过 {})",
        len(pending),
        len(slots),
        len(slots) - len(pending),
    )
    return pending


async def _run_heavy_sampling(
    accepted: list[GeneratedQuestion],
    trees: dict[str, TreeIndex],
    store: QuestionGenStore,
    llm: LLMProvider,
    config: PipelineConfig,
    *,
    session_id: str,
) -> list[tuple[str, int]]:
    """对接受题目按比例随机抽检，计算难度步数。

    参数:
        accepted: 已接受的题目列表。
        trees: video_id → TreeIndex 映射。
        store: 日志记录器。
        llm: LLM 调用端口。
        config: 管线配置（含 heavy_sample_rate 和 seed）。
        session_id: 会话 ID。

    返回:
        (question_id, difficulty_steps) 元组列表。
    """
    if config.heavy_sample_rate <= 0 or not accepted:
        return []

    sample_count = max(1, int(len(accepted) * config.heavy_sample_rate))
    sample_count = min(sample_count, len(accepted))
    heavy_rng = random.Random(config.seed + 1)
    sampled_questions = heavy_rng.sample(accepted, sample_count)

    logger.info("重量抽检: {} / {} 题", len(sampled_questions), len(accepted))

    heavy_tasks = []
    for q in sampled_questions:
        tree = trees.get(q.video_id)
        if tree is None:
            continue
        heavy_tasks.append(_heavy_check_one(q, tree, llm, session_id=session_id))

    heavy_results = await asyncio.gather(*heavy_tasks)

    heavy_sampled: list[tuple[str, int]] = []
    for q, steps in zip(sampled_questions, heavy_results, strict=True):
        heavy_sampled.append((q.question_id, steps))
        cursor = store._conn.execute(
            "SELECT item_id FROM question_gen_items "
            "WHERE slot_id LIKE ? AND final_status='accepted' LIMIT 1",
            (f"%{q.question_id.split('_')[-1]}%",),
        )
        row = cursor.fetchone()
        if row:
            store.update_difficulty(row[0], steps)

    return heavy_sampled


# ---------------------------------------------------------------------------
# 管线主入口
# ---------------------------------------------------------------------------


async def run_pipeline_v2(
    video_ids: list[str],
    trees: dict[str, TreeIndex],
    vlm: VLMProvider,
    llm: LLMProvider,
    embed_fn: Callable[[str], np.ndarray],
    store: QuestionGenStore,
    config: PipelineConfig,
    *,
    task_types: list[str] | None = None,
    progress: dict[str, str] | None = None,
    on_accept: Callable[[GeneratedQuestion], None] | None = None,
    initial_used_node_ids: set[str] | None = None,
    initial_embed_pool: list[np.ndarray] | None = None,
    seq_offset: int = 0,
) -> PipelineResult:
    """v2 出题管线主入口 — 编排全部 slot 的生成、检查与抽检。

    流程:
    1. 分配 slot
    2. 跳过 progress 中已完成的 slot
    3. 信号量限流并发处理每个 slot
    4. 对接受题目按 heavy_sample_rate 随机抽检
    5. 更新 store 统计
    6. 返回 PipelineResult

    参数:
        video_ids: 视频 ID 列表。
        trees: video_id → TreeIndex 映射。
        vlm: VLM 调用端口。
        llm: LLM 调用端口。
        embed_fn: embedding 函数。
        store: 日志记录器。
        config: 管线配置。
        task_types: 任务类型列表（默认使用 12 类标准集）。
        progress: 已完成 slot 映射 {slot_id → "accepted"|"rejected"}。
        on_accept: 每接受一题时的回调（用于实时持久化，防崩溃丢数据）。
        initial_used_node_ids: 补生成时继承的已用节点集合（避开已用节点，默认空）。
        initial_embed_pool: 补生成时继承的已接受题 embedding 池（跨 run 去重，默认空）。
        seq_offset: 全局序号起始偏移（补生成续编 seq，防撞 question_id，默认 0）。

    返回:
        PipelineResult 实例。
    """
    task_types = task_types or _DEFAULT_TASK_TYPES
    progress = progress or {}
    rng = random.Random(config.seed)

    # Phase 1: 分配 slot + 创建 run 记录
    slots = _assign_slots(video_ids, task_types, config.per_type, seq_offset=seq_offset)
    logger.info(
        "管线启动: {} slots, {} 视频, {} 任务类型", len(slots), len(video_ids), len(task_types)
    )

    run_id = uuid.uuid4().hex
    store.record_run_start(run_id, _get_git_sha(), str(config))

    # Phase 2: 过滤已完成 slot
    pending_slots = _filter_pending_slots(slots, progress)

    # Phase 3: 并发处理
    sem = asyncio.Semaphore(config.concurrency)
    # 复制传入容器，避免补生成 run 就地改动调用方对象（不传=空初始化=现状）
    embed_pool: list[np.ndarray] = list(initial_embed_pool) if initial_embed_pool else []
    used_node_ids: set[str] = set(initial_used_node_ids) if initial_used_node_ids else set()
    session_id = f"pipeline_v2_{run_id[:8]}"

    async def _process_wrapper(slot: SlotAssignment) -> GeneratedQuestion | None:
        tree = trees.get(slot.video_id)
        if tree is None:
            logger.warning("slot {} 对应视频 {} 的树不存在，跳过", slot.slot_id, slot.video_id)
            return None
        result = await _process_one_slot(
            slot=slot,
            tree=tree,
            vlm=vlm,
            llm=llm,
            embed_fn=embed_fn,
            embed_pool=embed_pool,
            store=store,
            config=config,
            used_node_ids=used_node_ids,
            rng=rng,
            sem=sem,
            session_id=session_id,
            run_id=run_id,
            all_trees=trees,
        )
        if result is not None and on_accept is not None:
            on_accept(result)
        return result

    results = await asyncio.gather(*[_process_wrapper(s) for s in pending_slots])

    # Phase 4: 统计 + 重量抽检
    accepted: list[GeneratedQuestion] = [r for r in results if r is not None]
    rejected_count = len(pending_slots) - len(accepted)
    logger.info("管线生成完成: accepted={}, rejected={}", len(accepted), rejected_count)

    heavy_sampled = await _run_heavy_sampling(
        accepted, trees, store, llm, config, session_id=session_id
    )

    # Phase 5: 更新 run 统计
    from app.question_gen.run_store import RunStats

    stats = RunStats(
        total_slots=len(slots),
        accepted=len(accepted),
        rejected=rejected_count,
        heavy_sampled=len(heavy_sampled),
    )
    store.record_run_end(run_id, "completed", stats)

    return PipelineResult(
        accepted=accepted,
        rejected_count=rejected_count,
        heavy_sampled=heavy_sampled,
    )
