"""离线诊断编排集成测试（LLM 类：MD 产出到 tests/outputs/）。

用 fake run_diagnosis + fake deps 覆盖编排契约，不实际调 LLM/VLM：
    1. 续跑幂等 —— 已落盘题跳过；第二次 remaining 为空则直接早返回、不调用
       run_diagnosis。
    2. 投影正确 —— defect→T2、lapse→T1，evolution_target 由 error_type 派生。
    3. 三桶去重 —— 同题同时在 degraded 与 attributions 时按 degraded>infra>
       attribution 优先级只落 1 行、tier=uncertain。

测试结束把编排过程（remaining、各 tier 计数、投影样例）写入
tests/outputs/test_baseline_diagnosis/<test>_<固定 ts>.md（CLAUDE.md §4.6）。
"""

from __future__ import annotations

from pathlib import Path

import pytest

from adapters.baseline_diagnosis_store import SqliteDiagnosisSignalStore
from app.harness.baseline_diagnosis import DiagnosisDeps, run_baseline_diagnosis

# 固定时间戳：库代码不用 datetime.now，测试传入固定值保证 MD 可复现。
_FIXED_TS = "20260715_000000"
_OUTPUT_DIR = Path(__file__).resolve().parents[1] / "outputs" / "test_baseline_diagnosis"


class _FakeRunLog:
    """内层 RunLog 假实现：predictions/traces 均返回空，编排不真正诊断。"""

    async def get_predictions(self, run_id, *, question_ids=None):
        return []

    async def get_traces(self, run_id, *, question_ids=None):
        return []


class _FakeLLM: ...


class _FakeSkillStore: ...


def _mk_q(qid):
    """构造最小可用 GeneratedQuestion（补齐必填 source_nodes/difficulty）。"""
    from core.types import GeneratedQuestion

    return GeneratedQuestion(
        question_id=qid,
        video_id="v",
        task_type="Counting Problem",
        question="",
        options=("A", "B", "C", "D"),
        answer="A",
        source_nodes=(),
        difficulty="easy",
    )


def _deps(monkeypatch, calls):
    """构造 DiagnosisDeps 并 monkeypatch run_diagnosis 记录每次 question_ids。"""

    async def fake_run_diagnosis(
        run_id,
        questions,
        tree_data,
        llm,
        run_log,
        skill_store,
        prompts,
        *,
        concurrency,
        question_ids=None,
        task_types=None,
        only_incorrect=False,
    ):
        calls.append(tuple(question_ids or []))
        from core.evolution.types import DiagnosisResult, ErrorAttribution

        # 仅对本次传入的题产出归因，续跑时空列表 → 无归因。
        attributions = []
        if "q1" in (question_ids or []):
            attributions.append(ErrorAttribution("q1", "search_failure", None, "defect"))
        if "q2" in (question_ids or []):
            attributions.append(ErrorAttribution("q2", "mixed", None, "lapse"))
        return DiagnosisResult(
            run_id=run_id,
            error_attributions=attributions,
            infra_question_ids=[],
            degraded_question_ids=[],
        )

    monkeypatch.setattr("app.harness.baseline_diagnosis.run_diagnosis", fake_run_diagnosis)
    return DiagnosisDeps(
        run_log=_FakeRunLog(),
        llm=_FakeLLM(),
        skill_store=_FakeSkillStore(),
        prompts=object(),
        tree_data={},
        concurrency=2,
    )


def _write_md(test_name: str, lines: list[str]) -> Path:
    """把编排过程写入 tests/outputs 下的 MD（人类可读结构化）。"""
    _OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    path = _OUTPUT_DIR / f"{test_name}_{_FIXED_TS}.md"
    path.write_text("\n".join(lines), encoding="utf-8")
    return path


@pytest.mark.asyncio
async def test_resume_skips_done(tmp_path, monkeypatch):
    """续跑幂等 + 投影正确：首次全诊断落库，第二次无剩余；tier 投影符合分层。"""
    calls: list[tuple[str, ...]] = []
    deps = _deps(monkeypatch, calls)
    store = SqliteDiagnosisSignalStore(str(tmp_path / "h.db"))
    q_by_id = {"q1": _mk_q("q1"), "q2": _mk_q("q2")}

    # 第一次：两题都是剩余，全部诊断落库。
    await run_baseline_diagnosis(
        baseline_run_id="infer_adhoc",
        diag_fingerprint="fp",
        wrong_ids=["q1", "q2"],
        questions=q_by_id,
        store=store,
        deps=deps,
    )
    assert store.done_question_ids("infer_adhoc", "fp") == {"q1", "q2"}
    assert calls[0] == ("q1", "q2")

    # 投影正确性：q1 defect→T2、q2 lapse→T1，evolution_target 由 error_type 派生。
    rows = {r.question_id: r for r in store.load("infer_adhoc", "fp")}
    assert rows["q1"].tier == "T2"
    assert rows["q1"].cause_category == "defect"
    assert rows["q1"].evolution_target == "skill"  # search_failure → skill
    assert rows["q1"].error_type == "search_failure"
    assert rows["q1"].infra is False
    assert rows["q1"].degraded is False
    assert rows["q2"].tier == "T1"
    assert rows["q2"].cause_category == "lapse"
    assert rows["q2"].evolution_target == "system"  # mixed → system

    # 第二次：两题已完成 → remaining 为空。编排按规约直接 return（续跑幂等，
    # 不浪费 LLM 诊断调用），故不会向 run_diagnosis 新增调用。
    n_calls_before = len(calls)
    await run_baseline_diagnosis(
        baseline_run_id="infer_adhoc",
        diag_fingerprint="fp",
        wrong_ids=["q1", "q2"],
        questions=q_by_id,
        store=store,
        deps=deps,
    )
    assert len(calls) == n_calls_before  # 第二次无剩余 → 未触发诊断

    md_path = _write_md(
        "test_resume_skips_done",
        [
            "# 离线诊断编排：续跑幂等 + 投影正确",
            "",
            "## 任务描述",
            "对 infer_adhoc 的错题跑离线诊断，投影为逐题信号行并落库；验证续跑幂等。",
            "",
            "## run_diagnosis 每次收到的 question_ids",
            f"- 第 1 次: {calls[0]}",
            "- 第 2 次: 未触发（remaining 为空，编排直接 return）",
            "",
            "## 落库信号投影样例",
            "| question_id | tier | cause_category | error_type | evolution_target |",
            "|---|---|---|---|---|",
            f"| q1 | {rows['q1'].tier} | {rows['q1'].cause_category} | "
            f"{rows['q1'].error_type} | {rows['q1'].evolution_target} |",
            f"| q2 | {rows['q2'].tier} | {rows['q2'].cause_category} | "
            f"{rows['q2'].error_type} | {rows['q2'].evolution_target} |",
            "",
            "## tier 计数",
            "- T2: 1（defect，可训练核心）",
            "- T1: 1（lapse，低信号）",
            "",
            "## 结论",
            "首次两题全落库，第二次 remaining 为空 → 续跑幂等成立；tier/evolution_target 投影正确。",
        ],
    )
    assert md_path.exists()
    store.close()


@pytest.mark.asyncio
async def test_infra_and_degraded_projection(tmp_path, monkeypatch):
    """INFRA→T0、degraded→uncertain 的投影：对应字段置位、error_type/target 为 None。"""
    calls: list[tuple[str, ...]] = []

    async def fake_run_diagnosis(
        run_id,
        questions,
        tree_data,
        llm,
        run_log,
        skill_store,
        prompts,
        *,
        concurrency,
        question_ids=None,
        task_types=None,
        only_incorrect=False,
    ):
        calls.append(tuple(question_ids or []))
        from core.evolution.types import DiagnosisResult

        return DiagnosisResult(
            run_id=run_id,
            error_attributions=[],
            infra_question_ids=["q3"],
            degraded_question_ids=["q4"],
        )

    monkeypatch.setattr("app.harness.baseline_diagnosis.run_diagnosis", fake_run_diagnosis)
    deps = DiagnosisDeps(
        run_log=_FakeRunLog(),
        llm=_FakeLLM(),
        skill_store=_FakeSkillStore(),
        prompts=object(),
        tree_data={},
        concurrency=2,
    )
    store = SqliteDiagnosisSignalStore(str(tmp_path / "h.db"))
    q_by_id = {"q3": _mk_q("q3"), "q4": _mk_q("q4")}

    await run_baseline_diagnosis(
        baseline_run_id="infer_adhoc",
        diag_fingerprint="fp",
        wrong_ids=["q3", "q4"],
        questions=q_by_id,
        store=store,
        deps=deps,
    )

    rows = {r.question_id: r for r in store.load("infer_adhoc", "fp")}
    assert rows["q3"].tier == "T0"
    assert rows["q3"].infra is True
    assert rows["q3"].error_type is None
    assert rows["q3"].evolution_target is None
    assert rows["q4"].tier == "uncertain"
    assert rows["q4"].degraded is True
    assert rows["q4"].error_type is None
    assert store.done_question_ids("infer_adhoc", "fp") == {"q3", "q4"}

    md_path = _write_md(
        "test_infra_and_degraded_projection",
        [
            "# 离线诊断编排：INFRA / degraded 投影",
            "",
            "## 落库信号投影样例",
            "| question_id | tier | infra | degraded | error_type | evolution_target |",
            "|---|---|---|---|---|---|",
            f"| q3 | {rows['q3'].tier} | {rows['q3'].infra} | {rows['q3'].degraded} | "
            f"{rows['q3'].error_type} | {rows['q3'].evolution_target} |",
            f"| q4 | {rows['q4'].tier} | {rows['q4'].infra} | {rows['q4'].degraded} | "
            f"{rows['q4'].error_type} | {rows['q4'].evolution_target} |",
            "",
            "## 结论",
            "INFRA→T0（infra 置位）、degraded→uncertain（degraded 置位），error_type/target 均 None。",
        ],
    )
    assert md_path.exists()
    store.close()


@pytest.mark.asyncio
async def test_degraded_overrides_attribution(tmp_path, monkeypatch):
    """三桶去重：同题同时在 degraded 与 attributions 时只落 1 行且 tier=uncertain。

    judge 解析失败会生成 degraded metrics，若仍是错题还会建 attribution，
    故同一 question_id 可同时出现在两桶。按 degraded>infra>attribution 优先级，
    该题必须只落 1 行、tier=uncertain（degraded 置位），attribution 行被跳过。
    """
    calls: list[tuple[str, ...]] = []

    async def fake_run_diagnosis(
        run_id,
        questions,
        tree_data,
        llm,
        run_log,
        skill_store,
        prompts,
        *,
        concurrency,
        question_ids=None,
        task_types=None,
        only_incorrect=False,
    ):
        calls.append(tuple(question_ids or []))
        from core.evolution.types import DiagnosisResult, ErrorAttribution

        # q5 同时出现在 attributions（judge 解析失败仍建归因）与 degraded_question_ids。
        return DiagnosisResult(
            run_id=run_id,
            error_attributions=[ErrorAttribution("q5", "reasoning_failure", None, "defect")],
            infra_question_ids=[],
            degraded_question_ids=["q5"],
        )

    monkeypatch.setattr("app.harness.baseline_diagnosis.run_diagnosis", fake_run_diagnosis)
    deps = DiagnosisDeps(
        run_log=_FakeRunLog(),
        llm=_FakeLLM(),
        skill_store=_FakeSkillStore(),
        prompts=object(),
        tree_data={},
        concurrency=2,
    )
    store = SqliteDiagnosisSignalStore(str(tmp_path / "h.db"))
    q_by_id = {"q5": _mk_q("q5")}

    await run_baseline_diagnosis(
        baseline_run_id="infer_adhoc",
        diag_fingerprint="fp",
        wrong_ids=["q5"],
        questions=q_by_id,
        store=store,
        deps=deps,
    )

    all_rows = store.load("infer_adhoc", "fp")
    assert len(all_rows) == 1  # 只落 1 行（无同 PK 双写）
    row = all_rows[0]
    assert row.question_id == "q5"
    assert row.tier == "uncertain"  # degraded 优先级最高
    assert row.degraded is True
    assert row.error_type is None  # 走 degraded 投影而非 attribution
    assert row.evolution_target is None

    md_path = _write_md(
        "test_degraded_overrides_attribution",
        [
            "# 离线诊断编排：三桶优先级去重",
            "",
            "## 任务描述",
            "q5 同时出现在 error_attributions（defect）与 degraded_question_ids，"
            "验证按 degraded>infra>attribution 优先级只落 1 行。",
            "",
            "## 落库结果",
            "| question_id | 落库行数 | tier | degraded | error_type |",
            "|---|---|---|---|---|",
            f"| q5 | {len(all_rows)} | {row.tier} | {row.degraded} | {row.error_type} |",
            "",
            "## 结论",
            "degraded 优先级最高，attribution 行被跳过 → 每题恰写一行、counts 恰计一次。",
        ],
    )
    assert md_path.exists()
    store.close()
