"""CE-Gate 信息量阶梯与基线缓存（unit 粒度，核心算法保真 #5）。

阶梯（每题型一条）：gate 的出题顺序表，键为 **unit_id**（single 题 unit_id
等于 question_id，AR pair 折叠为一个单元、unit_id 等于共享 pair_id）。冷启动
（FRESH）用种子基线的**单元级**对错两档粗排（错 unit 高优先 2:1 交错 + 全错
unit 的 probe_quota 探针插尾）；epoch >=1 用非 gate run 观测**折叠成单元观测**后做
gamma-EMA 更新 p_hat，按信息量 p_hat(1-p_hat) 降序、剔 p_hat 不在 [p_low, p_high]。
单元错 = 该单元任一成员错（AR pair 双向 AND）。防泄露铁律：gate 内 rollout 永不
回流 p_hat（调用方以 run_id 含 "_gate_" 过滤观测源），本迁移不改此过滤。

持久化门控：gate_pools.json 带 schema_version（当前 = 2，unit 键）。旧版无
schema_version（v1、qid 键）加载时**直接报错**，拒绝静默混用 qid/unit 键。

BaselineCache：基线侧单元级对错缓存，键 = (task_type, skill_hash,
prompts_version, unit_id) 内容寻址、无显式失效。JSON 持久化到 workspace，
供 resume 后合法复用已冻结阶梯上的新鲜 draw。
"""

from __future__ import annotations

import hashlib
import json
import os
import random
from dataclasses import dataclass
from typing import TYPE_CHECKING

from loguru import logger

from app.harness.question_units import build_units

if TYPE_CHECKING:
    from pathlib import Path

    from core.types import GeneratedQuestion, QuestionUnit

# gate_pools.json 结构版本。v1（隐式、无此字段）为逐题 qid 键的存量格式；
# v2 起改为 unit_id 键。load 时严格校验，不匹配即报错（不静默迁移/混用）。
SCHEMA_VERSION = 2


def skill_hash(content: str) -> str:
    """对 skill 正文取 sha1 摘要，作缓存键的内容维度。

    参数:
        content: skill 文件全文（基线侧为解析后生效文件的正文）。

    返回:
        sha1 十六进制摘要。
    """
    return hashlib.sha1(content.encode("utf-8")).hexdigest()


@dataclass
class LadderEntry:
    """阶梯单元：题目单元与其估计答对率。

    字段:
        unit_id: 单元唯一标识（single 等于 question_id，AR pair 等于共享 pair_id）。
        p_hat: 估计答对率。冷启动为 Beta(1,1) 平滑的单次观测后验均值
            （错=1/3、对=2/3），此后经 gamma-EMA 更新。
    """

    unit_id: str
    p_hat: float


def _unit_correct(unit: QuestionUnit, correctness: dict[str, bool]) -> bool:
    """单元级正确性：AR pair 双向 AND，single 即单题；单元错 = 任一成员错。

    参数:
        unit: 目标单元。
        correctness: question_id -> 是否答对（缺项按未答对处理，与迁移前
            correctness.get(qid, False) 的默认语义一致，不改判定）。

    返回:
        单元内所有成员均答对时 True，否则 False。
    """
    return all(correctness.get(q.question_id, False) for q in unit.questions)


def build_cold_entries(
    units: list[QuestionUnit],
    correctness: dict[str, bool],
    probe_quota: float,
    seed: int,
) -> list[LadderEntry]:
    """冷启动排序（unit 粒度）：错 unit 高优先 2:1 交错 + 全错 unit 探针插尾。

    参数:
        units: 该题型的全部候选单元（已排除 test 池；AR pair 已折叠成单元）。
        correctness: question_id -> 种子基线是否答对（900 题全量逐题对错）。
            单元级对错由 _unit_correct 折叠（任一成员错 → 单元错）。
        probe_quota: 从错 unit 中随机抽出插到梯尾的探针比例（防"解锁新能力"盲区）。
        seed: 洗牌种子，保证确定性重建。

    返回:
        排序后的 LadderEntry 列表（键=unit_id；p_hat 用 Beta(1,1) 平滑：错=1/3、
        对=2/3，与 warm 阶段 gamma-EMA / 信息量排序自然衔接）。

    关键实现细节:
        与逐题版**同公式、同比例、同顺序**，仅把调度粒度从题换成单元：错 unit、
        对 unit 各自固定种子洗牌 -> 按 probe_quota 从错 unit 抽探针 -> 剩余按
        错错对 2:1 交错（一方耗尽后顺排另一方）-> 探针追加尾部。
    """
    rng = random.Random(seed)
    wrong = [u for u in units if not _unit_correct(u, correctness)]
    right = [u for u in units if _unit_correct(u, correctness)]
    rng.shuffle(wrong)
    rng.shuffle(right)

    n_probe = int(len(wrong) * probe_quota)
    probes, wrong_main = wrong[:n_probe], wrong[n_probe:]

    interleaved: list[QuestionUnit] = []
    wi, ri = 0, 0
    while wi < len(wrong_main) or ri < len(right):
        for _ in range(2):
            if wi < len(wrong_main):
                interleaved.append(wrong_main[wi])
                wi += 1
        if ri < len(right):
            interleaved.append(right[ri])
            ri += 1
    interleaved.extend(probes)

    def _p0(u: QuestionUnit) -> float:
        return 2 / 3 if _unit_correct(u, correctness) else 1 / 3

    return [LadderEntry(u.unit_id, _p0(u)) for u in interleaved]


def order_ladder(entries: list[LadderEntry], p_low: float, p_high: float) -> list[LadderEntry]:
    """warm 排序：剔 p_hat 不在 [p_low, p_high] 的零信息题，按信息量 p_hat(1-p_hat) 降序。

    参数:
        entries: 待排序的阶梯单元。
        p_low / p_high: p_hat 保留区间。

    返回:
        过滤并排序后的新列表（稳定排序，同信息量保持原相对序）。
    """
    kept = [e for e in entries if p_low <= e.p_hat <= p_high]
    return sorted(kept, key=lambda e: e.p_hat * (1 - e.p_hat), reverse=True)


@dataclass
class GatePools:
    """全部题型的阶梯容器，含构建种子与数据指纹（确定性重建凭据）。

    字段:
        entries: task_type -> 冷启动序 LadderEntry 列表（warm 排序在取用时做，
            保持存储序稳定、避免每次更新重写全表顺序）。
        seed: 冷启动洗牌种子。
        fingerprint: 构建输入指纹（基线 run_id + 题集 hash 等），resume 校验用。
    """

    entries: dict[str, list[LadderEntry]]
    seed: int
    fingerprint: str

    def ladder_for(
        self,
        task_type: str,
        exclude_units: set[str],
        p_low: float,
        p_high: float,
        cold: bool,
    ) -> list[str]:
        """取该题型的 gate 出题序（unit_id 列表），排除本 step 进化案例包所在单元。

        参数:
            task_type: 目标题型。
            exclude_units: 本 step 案例包（failure/success cases）所在单元的
                unit_id，防止在"刚学的那道题"上自测。按 **unit** 排除：命中单元
                整体剔除，避免只排 AR pair 半个成员而向 gate 池灌入半个 pair。
            p_low / p_high: warm 阶段的 p_hat 保留区间。
            cold: True 表示尚无 epoch 级观测（epoch 1），用冷启动存储序；
                False 走 order_ladder 信息量排序。

        返回:
            排除后的有序 unit_id 列表。

        异常:
            ValueError: 该题型无阶梯（冷启动构建缺失），或该题型阶梯为空。
        """
        if task_type not in self.entries:
            raise ValueError(f"task_type={task_type} 无阶梯，冷启动构建缺失该题型")
        pool = self.entries[task_type]
        if not pool:
            raise ValueError(f"task_type={task_type} 阶梯为空，无可出题目")
        ordered = pool if cold else order_ladder(pool, p_low, p_high)
        return [e.unit_id for e in ordered if e.unit_id not in exclude_units]

    def update_probs(
        self,
        per_q_observations: dict[str, bool],
        units_by_id: dict[str, QuestionUnit],
        gamma: float,
    ) -> None:
        """gamma-EMA 更新 p_hat：先把逐题观测折叠成单元观测，再按 unit_id 匹配更新。

        p_hat <- gamma * p_hat + (1-gamma) * unit_obs。只更新"整个单元都被观测到"
        的单元；单元观测 = 成员逐题对错的 AND（任一成员错 → 单元错）。折叠是必需的：
        AR pair 的 unit_id 是 pair_id，若直接按 unit_id 去逐题观测里匹配将永不命中、
        导致 gamma-EMA 停摆（核心算法保真 #5）。

        参数:
            per_q_observations: question_id -> 本 epoch 非 gate run 的最新逐题对错。
                调用方必须已按 run_id 过滤掉 gate 内 rollout（防泄露铁律）。
            units_by_id: unit_id -> QuestionUnit，用于把逐题观测折叠成单元观测。
            gamma: EMA 衰减系数。

        关键实现细节:
            单元只有在其**全部**成员都出现在 per_q_observations 时才更新；半观测
            （AR pair 只见一半）跳过，避免用不完整证据污染 p_hat。
        """
        for entries in self.entries.values():
            for e in entries:
                unit = units_by_id.get(e.unit_id)
                if unit is None:
                    continue
                if not all(q.question_id in per_q_observations for q in unit.questions):
                    continue
                unit_correct = all(per_q_observations[q.question_id] for q in unit.questions)
                obs = 1.0 if unit_correct else 0.0
                e.p_hat = gamma * e.p_hat + (1 - gamma) * obs

    def save(self, path: Path) -> None:
        """原子写 gate_pools.json（.tmp 再 replace），落 schema_version + unit_id 键。

        参数:
            path: 目标 JSON 路径。
        """
        payload = {
            "schema_version": SCHEMA_VERSION,
            "seed": self.seed,
            "fingerprint": self.fingerprint,
            "entries": {
                t: [{"unit_id": e.unit_id, "p_hat": e.p_hat} for e in es]
                for t, es in self.entries.items()
            },
        }
        tmp = path.with_suffix(".json.tmp")
        tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
        os.replace(tmp, path)

    @classmethod
    def load(cls, path: Path) -> GatePools:
        """从 gate_pools.json 恢复；schema_version 不匹配直接报错（不静默混用）。

        参数:
            path: gate_pools.json 路径。

        返回:
            复活的 GatePools。

        异常:
            RuntimeError: 缺 schema_version（存量 v1、qid 键）或版本不等于
                SCHEMA_VERSION——拒绝把 qid 键当 unit 键静默复用，须 FRESH 重建。
        """
        d = json.loads(path.read_text(encoding="utf-8"))
        version = d.get("schema_version")
        if version != SCHEMA_VERSION:
            raise RuntimeError(
                f"gate_pools.json schema_version={version!r} 与当前 {SCHEMA_VERSION} 不符"
                f"（存量 qid 键池不可当 unit 键复用），请删除后 FRESH 重建: {path}"
            )
        return cls(
            entries={
                t: [LadderEntry(x["unit_id"], x["p_hat"]) for x in es]
                for t, es in d["entries"].items()
            },
            seed=d["seed"],
            fingerprint=d["fingerprint"],
        )


def build_or_load_gate_pools(
    workspace_dir: Path,
    questions: list[GeneratedQuestion],
    test_qids: set[str],
    baseline_correctness: dict[str, bool],
    task_types: list[str],
    probe_quota: float,
    seed: int,
    baseline_run_id: str,
) -> GatePools:
    """gate 阶梯获取入口：gate_pools.json 存在且指纹一致则加载，否则冷启动构建。

    参数:
        workspace_dir: workspace 根目录（gate_pools.json 落其下）。
        questions: benchmark 全量题（900 题）。
        test_qids: held-out test 池题目 id（阶梯题源必须排除）。
        baseline_correctness: 种子基线 900 题全量对错（从基线 run 的 db 读）。
        task_types: 参与进化的题型列表。
        probe_quota: 冷启动探针比例。
        seed: 冷启动洗牌种子。
        baseline_run_id: 指纹成分。

    返回:
        GatePools。

    关键实现细节:
        指纹 = sha1(baseline_run_id|全 qid|seed|probe_quota|task_types|test_qids)。
        指纹不一致（题集/基线/参数变了）直接报错——FRESH 语义下不该发生，
        防御性拒绝而非静默重建。
    """
    joined = ",".join(sorted(q.question_id for q in questions))
    fp_src = (
        f"{baseline_run_id}|{joined}|{seed}|{probe_quota}"
        f"|{','.join(sorted(task_types))}|{','.join(sorted(test_qids))}"
    )
    fingerprint = hashlib.sha1(fp_src.encode()).hexdigest()
    path = workspace_dir / "gate_pools.json"
    if path.exists():
        pools = GatePools.load(path)
        if pools.fingerprint != fingerprint:
            raise RuntimeError(
                f"gate_pools.json 指纹不一致（题集或基线变更），拒绝静默重建: {path}"
            )
        return pools

    entries: dict[str, list[LadderEntry]] = {}
    for t in task_types:
        units = _task_units_excluding_test(questions, t, test_qids)
        if not units:
            raise ValueError(f"task_type={t} 无非 test 单元，无法建阶梯")
        entries[t] = build_cold_entries(units, baseline_correctness, probe_quota, seed)
        logger.info("gate 阶梯[{}]: {} 单元（冷启动）", t, len(entries[t]))
    pools = GatePools(entries=entries, seed=seed, fingerprint=fingerprint)
    pools.save(path)
    return pools


def _task_units_excluding_test(
    questions: list[GeneratedQuestion], task_type: str, test_qids: set[str]
) -> list[QuestionUnit]:
    """取某题型的非 test 候选单元：先按 unit 折叠，再整体排除含 test 成员的单元。

    先折叠后排除保证 AR pair 不被拆半（否则半个 pair 交给下游会触发 build_units 的
    孤儿 fail-fast）；single 单元等价于逐题排除（核心算法保真 #5）。

    参数:
        questions: benchmark 全量题。
        task_type: 目标题型。
        test_qids: held-out test 池题目 id。

    返回:
        该题型下不含任何 test 成员的候选单元列表。
    """
    pool = [q for q in questions if q.task_type == task_type]
    return [
        u for u in build_units(pool) if all(q.question_id not in test_qids for q in u.questions)
    ]


class BaselineCache:
    """基线侧单元级对错缓存（内容寻址，JSON 持久化）。

    键 = (task_type, skill_hash, prompts_version, unit_id)：任何影响该题型
    有效 skill 的变化（含共享 default-strategy.md 被他类 accept 改写）
    都使 skill_hash 变化、缓存自然 miss；prompts 版本变化同理。unit_id 维度
    使 single 题以自身 question_id、AR pair 以共享 pair_id 寻址，缓存单元级
    对错（pair 双向 AND 折叠后一个布尔）。
    """

    def __init__(self, path: Path) -> None:
        """加载或初始化缓存文件。

        参数:
            path: 缓存 JSON 路径（workspace/baseline_cache.json）。
        """
        self._path = path
        self._store: dict[str, bool] = {}
        if path.exists():
            self._store = json.loads(path.read_text(encoding="utf-8"))

    @staticmethod
    def _key(task_type: str, s_hash: str, prompts_version: str, unit_id: str) -> str:
        """拼缓存键（四维内容寻址，第四维为 unit_id）。"""
        return f"{task_type}|{s_hash}|{prompts_version}|{unit_id}"

    def get(self, task_type: str, s_hash: str, prompts_version: str, unit_id: str) -> bool | None:
        """读缓存；未命中返回 None。

        参数:
            task_type: 题型。
            s_hash: 基线侧生效 skill 文件的内容哈希。
            prompts_version: 当前 prompts 版本。
            unit_id: 单元 id（single=question_id，AR pair=pair_id）。

        返回:
            缓存的单元级对错；未命中 None。
        """
        return self._store.get(self._key(task_type, s_hash, prompts_version, unit_id))

    def put(
        self, task_type: str, s_hash: str, prompts_version: str, unit_id: str, correct: bool
    ) -> None:
        """写缓存并落盘（原子写，gate 频度低、全量重写成本可忽略）。

        参数:
            task_type / s_hash / prompts_version / unit_id: 缓存键四维。
            correct: 基线侧该单元对错（AR pair 双向 AND 折叠后一个布尔）。

        关键实现细节:
            先盘后存：新条目先原子落盘（tmp 写 + os.replace）成功后才更新
            内存，磁盘写失败时内存与磁盘一致（均无新条目），无分裂窗口。
        """
        updated = {
            **self._store,
            self._key(task_type, s_hash, prompts_version, unit_id): correct,
        }
        tmp = self._path.with_suffix(".json.tmp")
        tmp.write_text(json.dumps(updated, ensure_ascii=False), encoding="utf-8")
        os.replace(tmp, self._path)
        self._store = updated
