"""tests/unit/test_harness_validate.py — app/harness/validate.py 的单元测试。

覆盖：数据类型字段、materialize 物化与清理、async validate_skill_local
（accept/reject/prefix 校验/INFRA 护栏/缓存命中/最后一块终态）。
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import pytest

from app.harness.gate_ladder import BaselineCache, skill_hash
from app.harness.inference import PREDICTIONS_SCHEMA, InferenceResult
from app.harness.log import HarnessLog
from app.harness.validate import (
    Probation,
    ValidationOutcome,
    materialize_candidate_skill,
    validate_skill_local,
)
from core.evolution import GateParams, RejectedEdit
from core.types import GeneratedQuestion

if TYPE_CHECKING:
    from pathlib import Path

# ---------------------------------------------------------------------------
# 辅助工具
# ---------------------------------------------------------------------------

_DEFAULT_GATE_PARAMS = GateParams(
    e_confirm=20.0,
    e_provisional=3.0,
    w_net_min=2,
    delta_min=0.05,
    lambda_dir=-2.0,
    e_rollback=10.0,
)


def _make_questions(
    n: int,
    task_type: str = "temporal",
    prefix: str = "q",
) -> list[GeneratedQuestion]:
    """生成 n 个测试用 GeneratedQuestion。"""
    return [
        GeneratedQuestion(
            question_id=f"{prefix}{i}",
            video_id=f"v{i}",
            task_type=task_type,
            question=f"Question {i}?",
            options=("A", "B", "C", "D"),
            answer="A",
            source_nodes=(),
            difficulty="easy",
        )
        for i in range(n)
    ]


def _setup_workspace(tmp_path: Path) -> Path:
    """在 tmp_path 下构建最小 workspace 结构。"""
    skills_dir = tmp_path / "skills" / "v1"
    skills_dir.mkdir(parents=True)
    (skills_dir / "temporal.md").write_text("baseline skill content", encoding="utf-8")
    return tmp_path


def _make_log(workspace: Path, run_id: str = "test_master") -> HarnessLog:
    """创建 HarnessLog 并初始化 predictions 表。"""
    db_path = workspace / "harness.db"
    log = HarnessLog(str(db_path), run_id)
    log.create_table("predictions", PREDICTIONS_SCHEMA)
    return log


def _insert_predictions(
    log: HarnessLog,
    run_id: str,
    correctness: dict[str, bool],
    answer: str = "A",
) -> None:
    """向 predictions 表插入指定 run_id 的逐题预测记录。

    通过在 record 中显式传入 run_id 覆盖 log 的默认 run_id。
    """
    for qid, correct in correctness.items():
        prediction = answer if correct else "Z"
        log.insert(
            "predictions",
            {
                "run_id": run_id,
                "video_id": "v0",
                "question_id": qid,
                "task_type": "temporal",
                "prediction": prediction,
                "answer": answer,
                "evidence": "",
                "reasoning": "",
                "steps_used": 1,
                "prompt_tokens": 10,
                "completion_tokens": 10,
                "stop_reason": "completed",
                "steps_json": "[]",
            },
        )


def _make_mock_run_inference(
    log: HarnessLog,
    baseline_correctness: dict[str, bool],
    candidate_correctness: dict[str, bool],
    error_count: int = 0,
):
    """构建 mock RunInferenceFn。

    根据 run_id 中的 arm 标记（_base / _cand）决定使用基线或候选对错映射，
    将预测写入 log 的同一 DB，返回 InferenceResult。
    """
    call_log: list[dict[str, Any]] = []

    async def mock_fn(
        questions: list[GeneratedQuestion],
        *,
        run_id: str,
        skills_dir: Path,
    ) -> InferenceResult:
        is_baseline = run_id.endswith("_base")
        correctness = baseline_correctness if is_baseline else candidate_correctness

        call_log.append({"run_id": run_id, "skills_dir": skills_dir, "n": len(questions)})
        per_q = {q.question_id: correctness.get(q.question_id, False) for q in questions}
        _insert_predictions(log, run_id, per_q)

        correct = sum(per_q.values())
        total = len(questions)
        stop_counts: dict[str, int] = {"completed": total - error_count}
        if error_count > 0:
            stop_counts["error"] = error_count
        return InferenceResult(
            run_id=run_id,
            accuracy=correct / total if total else 0.0,
            total=total,
            correct=correct,
            per_task_type={},
            steps_mean=1.0,
            token_usage={"prompt_tokens": 10, "completion_tokens": 10},
            stop_reason_counts=stop_counts,
        )

    return mock_fn, call_log


# ===========================================================================
# 数据类型测试
# ===========================================================================


class TestValidationOutcomeFields:
    """ValidationOutcome 数据类型字段完整性测试。"""

    def test_validation_outcome_fields(self) -> None:
        """所有字段可构造、默认值合理。"""
        outcome = ValidationOutcome(
            action="accept_confirmed",
            accepted=True,
            stop_reason="confirmed",
            e_value=25.0,
            w=5,
            l=1,
            n_used=10,
            delta_hat=0.4,
            delta_shrunk=0.3,
            baseline_acc=0.6,
            candidate_acc=0.9,
        )
        assert outcome.action == "accept_confirmed"
        assert outcome.accepted is True
        assert outcome.stop_reason == "confirmed"
        assert outcome.e_value == 25.0
        assert outcome.w == 5
        assert outcome.l == 1
        assert outcome.n_used == 10
        assert outcome.delta_hat == 0.4
        assert outcome.delta_shrunk == 0.3
        assert outcome.baseline_acc == 0.6
        assert outcome.candidate_acc == 0.9
        assert outcome.improvements == []
        assert outcome.regressions == []
        assert outcome.persistent_fails == []
        assert outcome.stable_successes == []
        assert outcome.candidate_correctness == {}
        assert outcome.evidence_rows == []


class TestProbationFields:
    """Probation 数据类型字段完整性测试。"""

    def test_probation_fields(self) -> None:
        """所有字段可构造、pending_edits 默认空列表。"""
        prob = Probation(
            task_type="temporal",
            anchor_skills_version="v1",
            target_file="temporal.md",
            correctness_snapshot={"q0": True, "q1": False},
            opened_step=5,
        )
        assert prob.task_type == "temporal"
        assert prob.anchor_skills_version == "v1"
        assert prob.target_file == "temporal.md"
        assert prob.correctness_snapshot == {"q0": True, "q1": False}
        assert prob.opened_step == 5
        assert prob.pending_edits == []

    def test_probation_with_pending_edits(self) -> None:
        """pending_edits 可附加 RejectedEdit。"""
        edit = RejectedEdit(
            target_file="temporal.md",
            target_type="skill",
            change_summary="bad change",
            delta=-0.1,
            source_version="v2",
            epoch=1,
        )
        prob = Probation(
            task_type="temporal",
            anchor_skills_version="v1",
            target_file="temporal.md",
            correctness_snapshot={},
            opened_step=3,
            pending_edits=[edit],
        )
        assert len(prob.pending_edits) == 1
        assert prob.pending_edits[0].change_summary == "bad change"


# ===========================================================================
# materialize 测试
# ===========================================================================


class TestMaterializeCandidateSkill:
    """materialize_candidate_skill 物化与清理测试。"""

    def test_materialize_candidate_skill(self, tmp_path: Path) -> None:
        """正常物化：基线目录被复制，target_file 被覆写为候选内容。"""
        workspace = _setup_workspace(tmp_path)
        cand_dir = materialize_candidate_skill(
            workspace, "v1", "temporal.md", "candidate skill content"
        )
        try:
            assert cand_dir.exists()
            assert cand_dir.parent == workspace / ".cand_tmp"
            assert (cand_dir / "temporal.md").read_text(encoding="utf-8") == (
                "candidate skill content"
            )
        finally:
            import shutil

            shutil.rmtree(cand_dir)

    def test_materialize_cleanup_on_failure(self, tmp_path: Path) -> None:
        """基线目录不存在时 OSError，临时目录被清理。"""
        workspace = tmp_path / "ws"
        workspace.mkdir()
        # 不创建 skills/v1，copytree 应失败
        with pytest.raises(OSError):
            materialize_candidate_skill(workspace, "v1", "temporal.md", "content")
        # .cand_tmp 可能存在但内部应被清理
        cand_tmp = workspace / ".cand_tmp"
        if cand_tmp.exists():
            remaining = list(cand_tmp.iterdir())
            assert remaining == [], f"临时目录未被清理: {remaining}"


# ===========================================================================
# async 验证测试
# ===========================================================================


@pytest.mark.asyncio
async def test_validate_skill_local_accept(tmp_path: Path) -> None:
    """候选全对、基线全错 → 高 e 值 → accept_confirmed。"""
    workspace = _setup_workspace(tmp_path)
    log = _make_log(workspace)
    questions = _make_questions(6)
    cache = BaselineCache(workspace / "baseline_cache.json")

    # 基线全错，候选全对 → W=6, L=0 → E=18.14 → CONFIRMED（e_confirm=15）
    baseline_correct = {f"q{i}": False for i in range(6)}
    candidate_correct = {f"q{i}": True for i in range(6)}
    mock_fn, call_log = _make_mock_run_inference(log, baseline_correct, candidate_correct)

    # e_confirm=15 使 E=18.14 超过阈值触发 CONFIRMED
    accept_params = GateParams(
        e_confirm=15.0,
        e_provisional=3.0,
        w_net_min=2,
        delta_min=0.05,
        lambda_dir=-2.0,
        e_rollback=10.0,
    )

    try:
        outcome = await validate_skill_local(
            workspace_dir=workspace,
            base_skills_version="v1",
            task_type="temporal",
            target_file="temporal.md",
            candidate_content="improved skill",
            base_skill_content="baseline skill content",
            ladder_items=questions,
            gate_params=accept_params,
            gate_block=6,
            gate_n_max=20,
            gate_guard_err=0.5,
            baseline_cache=cache,
            prompts_version="p1",
            run_inference=mock_fn,
            log=log,
            gate_run_prefix="step1_gate_test",
        )

        assert outcome.accepted is True
        assert outcome.action == "accept_confirmed"
        assert outcome.stop_reason == "confirmed"
        assert outcome.w == 6
        assert outcome.l == 0
        assert outcome.n_used == 6
        assert outcome.candidate_acc == 1.0
        assert outcome.baseline_acc == 0.0
        assert len(outcome.evidence_rows) == 6
        # 终态证据行携带 stop_reason
        assert outcome.evidence_rows[-1]["stop_reason"] == "confirmed"
        # 候选临时目录应被清理
        cand_tmp = workspace / ".cand_tmp"
        if cand_tmp.exists():
            assert list(cand_tmp.iterdir()) == []
    finally:
        log.close()


@pytest.mark.asyncio
async def test_validate_skill_local_reject(tmp_path: Path) -> None:
    """候选全错、基线全对 → L 高 → 方向拒绝。"""
    workspace = _setup_workspace(tmp_path)
    log = _make_log(workspace)
    questions = _make_questions(6)
    cache = BaselineCache(workspace / "baseline_cache.json")

    # 基线全对，候选全错 → W=0, L=6 → 方向拒绝
    baseline_correct = {f"q{i}": True for i in range(6)}
    candidate_correct = {f"q{i}": False for i in range(6)}
    mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct)

    try:
        outcome = await validate_skill_local(
            workspace_dir=workspace,
            base_skills_version="v1",
            task_type="temporal",
            target_file="temporal.md",
            candidate_content="bad skill",
            base_skill_content="baseline skill content",
            ladder_items=questions,
            gate_params=_DEFAULT_GATE_PARAMS,
            gate_block=6,
            gate_n_max=20,
            gate_guard_err=0.5,
            baseline_cache=cache,
            prompts_version="p1",
            run_inference=mock_fn,
            log=log,
            gate_run_prefix="step1_gate_test",
        )

        assert outcome.accepted is False
        assert outcome.action == "reject"
        assert outcome.stop_reason == "directional"
        assert outcome.w == 0
        assert outcome.l == 6
    finally:
        log.close()


@pytest.mark.asyncio
async def test_gate_prefix_must_contain_gate(tmp_path: Path) -> None:
    """gate_run_prefix 不含 '_gate_' 时抛 ValueError。"""
    workspace = _setup_workspace(tmp_path)
    log = _make_log(workspace)
    questions = _make_questions(4)
    cache = BaselineCache(workspace / "baseline_cache.json")

    async def noop_fn(questions, *, run_id, skills_dir):
        raise AssertionError("不应被调用")

    try:
        with pytest.raises(ValueError, match="_gate_"):
            await validate_skill_local(
                workspace_dir=workspace,
                base_skills_version="v1",
                task_type="temporal",
                target_file="temporal.md",
                candidate_content="content",
                base_skill_content="baseline",
                ladder_items=questions,
                gate_params=_DEFAULT_GATE_PARAMS,
                gate_block=4,
                gate_n_max=20,
                gate_guard_err=0.5,
                baseline_cache=cache,
                prompts_version="p1",
                run_inference=noop_fn,
                log=log,
                gate_run_prefix="step1_no_marker",
            )
    finally:
        log.close()


@pytest.mark.asyncio
async def test_infra_guard_threshold(tmp_path: Path) -> None:
    """推理错误率超阈值时抛 RuntimeError。"""
    workspace = _setup_workspace(tmp_path)
    log = _make_log(workspace)
    # 需要 >=10 题次才触发 INFRA 护栏
    questions = _make_questions(6)
    cache = BaselineCache(workspace / "baseline_cache.json")

    baseline_correct = {f"q{i}": False for i in range(6)}
    candidate_correct = {f"q{i}": False for i in range(6)}
    # 每次 run_inference 报 error_count=5，两侧各 5 → 10/12 > 0.5
    mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct, error_count=5)

    try:
        with pytest.raises(RuntimeError, match="错误率过高"):
            await validate_skill_local(
                workspace_dir=workspace,
                base_skills_version="v1",
                task_type="temporal",
                target_file="temporal.md",
                candidate_content="content",
                base_skill_content="baseline skill content",
                ladder_items=questions,
                gate_params=_DEFAULT_GATE_PARAMS,
                gate_block=6,
                gate_n_max=20,
                gate_guard_err=0.5,
                baseline_cache=cache,
                prompts_version="p1",
                run_inference=mock_fn,
                log=log,
                gate_run_prefix="step1_gate_test",
            )
    finally:
        log.close()


@pytest.mark.asyncio
async def test_baseline_cache_hit(tmp_path: Path) -> None:
    """基线缓存全命中时不发起基线侧推理。"""
    workspace = _setup_workspace(tmp_path)
    log = _make_log(workspace)
    questions = _make_questions(4)
    cache = BaselineCache(workspace / "baseline_cache.json")

    s_hash = skill_hash("baseline skill content")
    # 预填充缓存：全部题目基线全错
    for q in questions:
        cache.put("temporal", s_hash, "p1", q.question_id, False)

    # 候选全对 → accept
    candidate_correct = {f"q{i}": True for i in range(4)}
    baseline_correct = {f"q{i}": False for i in range(4)}
    mock_fn, call_log = _make_mock_run_inference(log, baseline_correct, candidate_correct)

    try:
        outcome = await validate_skill_local(
            workspace_dir=workspace,
            base_skills_version="v1",
            task_type="temporal",
            target_file="temporal.md",
            candidate_content="improved skill",
            base_skill_content="baseline skill content",
            ladder_items=questions,
            gate_params=_DEFAULT_GATE_PARAMS,
            gate_block=4,
            gate_n_max=20,
            gate_guard_err=0.5,
            baseline_cache=cache,
            prompts_version="p1",
            run_inference=mock_fn,
            log=log,
            gate_run_prefix="step1_gate_test",
        )

        # 只有候选侧调用了 run_inference（_cand），基线侧全命中不调用
        base_calls = [c for c in call_log if c["run_id"].endswith("_base")]
        cand_calls = [c for c in call_log if c["run_id"].endswith("_cand")]
        assert len(base_calls) == 0, "基线缓存全命中不应发起推理"
        assert len(cand_calls) == 1
        assert outcome.accepted is True
    finally:
        log.close()


@pytest.mark.asyncio
async def test_last_block_terminal(tmp_path: Path) -> None:
    """单块 + n_remaining=0 → 终态判定（provisional 或 inertia），非 continue。"""
    workspace = _setup_workspace(tmp_path)
    log = _make_log(workspace)
    # 4 题，gate_block=4 → 一块走完，n_remaining=0
    questions = _make_questions(4)
    cache = BaselineCache(workspace / "baseline_cache.json")

    # 两题翻转（W=2, L=0），但 e_confirm=20 难以达到 → provisional 或 inertia
    baseline_correct = {"q0": False, "q1": False, "q2": True, "q3": True}
    candidate_correct = {"q0": True, "q1": True, "q2": True, "q3": True}
    mock_fn, _ = _make_mock_run_inference(log, baseline_correct, candidate_correct)

    try:
        outcome = await validate_skill_local(
            workspace_dir=workspace,
            base_skills_version="v1",
            task_type="temporal",
            target_file="temporal.md",
            candidate_content="candidate skill",
            base_skill_content="baseline skill content",
            ladder_items=questions,
            gate_params=_DEFAULT_GATE_PARAMS,
            gate_block=4,
            gate_n_max=4,
            gate_guard_err=0.5,
            baseline_cache=cache,
            prompts_version="p1",
            run_inference=mock_fn,
            log=log,
            gate_run_prefix="step1_gate_test",
        )

        # n_remaining=0 → 不可能是 continue
        assert outcome.stop_reason in (
            "confirmed",
            "provisional",
            "inertia",
            "directional",
            "futility",
        )
        assert outcome.n_used == 4
        # 终态行标记 stop_reason
        assert outcome.evidence_rows[-1]["stop_reason"] != ""
    finally:
        log.close()
