"""video_split_cli 两阶段编排 CLI 单元测试。

覆盖：
    - 配置解析正确 + 缺关键项 fail loud（SystemExit）；
    - 指纹计算在 main 中被调用（diag_fingerprint 收到 prompt/model/git-sha 三分量）；
    - run_pipeline 两阶段按序触发（Phase 1 诊断 → Phase 2 build_split）；
    - McNemar 功效护栏 fail loud；
    - --dry-run 用假 deps 跑通装配、不真调 LLM（Phase 1 空错题早返回）。

不真跑全量诊断：诊断与 build_split 均以 monkeypatch / 假 deps 替换。
"""

from __future__ import annotations

import asyncio
from types import SimpleNamespace

import pytest

from app.harness import video_split_cli as cli

_RAW_OK = {
    "video_split": {
        "baseline_run_id": "infer_adhoc",
        "n_trainval": 100,
        "epsilon": 0.1,
        "report_floor": 27,
        "val_wrong_min": 20,
        "val_ratio": 0.3,
        "seed": 7,
        "floor_k": {"Counting Problem": 3},
    },
    "diag": {"prompt_version": "diagnose_v1", "model": "deepseek-v4-pro"},
}


def _config(**overrides) -> cli.VideoSplitConfig:
    """构造一个可用 VideoSplitConfig，overrides 覆盖单字段。"""
    base = {
        "baseline_run_id": "infer_adhoc",
        "n_trainval": 100,
        "epsilon": 0.1,
        "report_floor": 27,
        "val_wrong_min": 0,
        "val_ratio": 0.3,
        "seed": 7,
        "floor_k": {"Counting Problem": 3},
        "prompt_version": "diagnose_v1",
        "model": "deepseek-v4-pro",
    }
    base.update(overrides)
    return cli.VideoSplitConfig(**base)


def test_parse_config_ok():
    """完整 yaml 解析为 VideoSplitConfig，各旋钮逐一还原。"""
    cfg = cli.parse_config(_RAW_OK)
    assert cfg.baseline_run_id == "infer_adhoc"
    assert cfg.n_trainval == 100
    assert cfg.epsilon == 0.1
    assert cfg.report_floor == 27
    assert cfg.val_wrong_min == 20
    assert cfg.val_ratio == 0.3
    assert cfg.seed == 7
    assert cfg.floor_k == {"Counting Problem": 3}
    assert cfg.prompt_version == "diagnose_v1"
    assert cfg.model == "deepseek-v4-pro"


def test_parse_config_missing_video_split_key_fails_loud():
    """video_split 段缺关键项 → SystemExit（P5 fail loud）。"""
    raw = {"video_split": dict(_RAW_OK["video_split"]), "diag": dict(_RAW_OK["diag"])}
    del raw["video_split"]["n_trainval"]
    with pytest.raises(SystemExit):
        cli.parse_config(raw)


def test_parse_config_missing_diag_section_fails_loud():
    """缺 diag 段 → SystemExit。"""
    with pytest.raises(SystemExit):
        cli.parse_config({"video_split": dict(_RAW_OK["video_split"])})


def test_load_config_missing_file_fails_loud(tmp_path):
    """config 文件不存在 → SystemExit。"""
    with pytest.raises(SystemExit):
        cli.load_config(tmp_path / "nope.yaml")


def test_check_mcnemar_power_below_threshold_fails_loud():
    """val 错题数 < 阈 → SystemExit（功效不足）。"""
    q = SimpleNamespace(question_id="q1")
    pools = SimpleNamespace(validation=[q], correctness={"q1": False})
    with pytest.raises(SystemExit):
        cli.check_mcnemar_power(pools, val_wrong_min=5)


def test_check_mcnemar_power_zero_threshold_skips():
    """val_wrong_min=0 → 不检查，返回实际错题数。"""
    q = SimpleNamespace(question_id="q1")
    pools = SimpleNamespace(validation=[q], correctness={"q1": True})
    assert cli.check_mcnemar_power(pools, val_wrong_min=0) == 0


def test_run_pipeline_orders_three_phases(monkeypatch, tmp_path):
    """run_pipeline 先补 Phase 0 INFRA T0、再 Phase 1 诊断、后 Phase 2 build_split（按序）。"""
    calls: list[str] = []

    class _SpyStore:
        def __init__(self):
            self.t0_rows: list = []

        def upsert(self, row):
            calls.append("t0_upsert")
            self.t0_rows.append(row)

    async def fake_diag(**kwargs):
        calls.append("diagnosis")
        assert kwargs["diag_fingerprint"] == "fp"
        assert kwargs["wrong_ids"] == ["q1"]

    def fake_build_split(**kwargs):
        calls.append("build_split")
        assert kwargs["diag_fingerprint"] == "fp"
        pools = SimpleNamespace(validation=[], correctness={})
        return SimpleNamespace(pools=pools, manifest={}, assignment={})

    monkeypatch.setattr(cli, "run_baseline_diagnosis", fake_diag)
    monkeypatch.setattr(cli, "build_split", fake_build_split)

    # 一条 INFRA 空预测错题 → Phase 0 应补一行 T0（在诊断/切分之前）。
    canonical_preds = [
        {
            "question_id": "q_infra",
            "video_id": "v9",
            "task_type": "Counting Problem",
            "prediction": "",
            "answer": "A",
            "stop_reason": "error",
            "correct": False,
        }
    ]
    store = _SpyStore()

    result = asyncio.run(
        cli.run_pipeline(
            config=_config(),
            fingerprint="fp",
            diagnosis_deps=object(),
            signal_store=store,
            wrong_ids=["q1"],
            questions={},
            canonical_preds=canonical_preds,
            harness_db=tmp_path / "h.db",
            questions_dir=tmp_path,
            out_dir=tmp_path / "out",
            generated_at="2026-07-15T00:00:00Z",
        )
    )
    assert calls == ["t0_upsert", "diagnosis", "build_split"]  # Phase 0 先于诊断与切分
    assert len(store.t0_rows) == 1
    assert store.t0_rows[0].tier == "T0" and store.t0_rows[0].infra is True
    assert result.pools.validation == []


def test_select_diagnosable_wrong_ids_excludes_infra_and_correct():
    """可诊断错题筛选：排除 INFRA / 空预测 / 正确题，保留非空非 INFRA 错题（保序）。"""
    preds = [
        {"question_id": "ok", "stop_reason": "finished", "prediction": "B", "correct": True},
        {"question_id": "wrong", "stop_reason": "finished", "prediction": "C", "correct": False},
        {"question_id": "infra", "stop_reason": "error", "prediction": "", "correct": False},
        {"question_id": "parse", "stop_reason": "parse_error", "prediction": "x", "correct": False},
        {"question_id": "empty", "stop_reason": "finished", "prediction": "", "correct": False},
    ]
    assert cli.select_diagnosable_wrong_ids(preds) == ["wrong"]


def test_persist_infra_t0_rows_persists_only_infra_or_empty_wrong(tmp_path):
    """INFRA / 空预测错题落 T0（infra=True，error_type/target=None）；正确 / 可诊断错题不落。"""
    from adapters.baseline_diagnosis_store import SqliteDiagnosisSignalStore

    preds = [
        {
            "question_id": "ok",
            "video_id": "v1",
            "task_type": "Counting Problem",
            "prediction": "A",
            "answer": "A",
            "stop_reason": "finished",
            "correct": True,
        },
        {
            "question_id": "diag_wrong",
            "video_id": "v2",
            "task_type": "Counting Problem",
            "prediction": "C",
            "answer": "A",
            "stop_reason": "finished",
            "correct": False,
        },
        {
            "question_id": "infra_err",
            "video_id": "v3",
            "task_type": "OCR Problems",
            "prediction": "",
            "answer": "A",
            "stop_reason": "error",
            "correct": False,
        },
        {
            "question_id": "parse_err",
            "video_id": "v4",
            "task_type": "Counting Problem",
            "prediction": "",
            "answer": "A",
            "stop_reason": "parse_error",
            "correct": False,
        },
    ]
    store = SqliteDiagnosisSignalStore(str(tmp_path / "h.db"))
    n = cli.persist_infra_t0_rows(store, preds, "infer_adhoc", "fp")
    assert n == 2  # 只有两条 INFRA 空预测错题
    rows = {r.question_id: r for r in store.load("infer_adhoc", "fp")}
    assert set(rows) == {"infra_err", "parse_err"}
    for r in rows.values():
        assert r.tier == "T0" and r.infra is True
        assert r.error_type is None and r.evolution_target is None and r.cause_category is None
        assert r.degraded is False

    # 幂等：重复调用同 PK 覆盖，行数不变。
    assert cli.persist_infra_t0_rows(store, preds, "infer_adhoc", "fp") == 2
    assert len(store.load("infer_adhoc", "fp")) == 2
    store.close()


def test_dry_run_computes_fingerprint_without_llm(monkeypatch, tmp_path, capsys):
    """--dry-run：diag_fingerprint 被调用、Phase 1 走空错题早返回、不真调 LLM。"""
    fp_calls: list[tuple[str, str, str]] = []

    def spy_fingerprint(prompt_version, model, code_version):
        fp_calls.append((prompt_version, model, code_version))
        return "dryfp"

    monkeypatch.setattr(cli, "diag_fingerprint", spy_fingerprint)
    monkeypatch.setattr(cli, "git_short_sha", lambda: "abc123")

    # 用真实 config/video_split.yaml，out-dir 指向 tmp（dry-run 会建临时信号库再清理）。
    cli.main(["--dry-run", "--config", "config/video_split.yaml", "--out-dir", str(tmp_path)])

    assert len(fp_calls) == 1
    prompt_version, model, sha = fp_calls[0]
    assert sha == "abc123"
    assert prompt_version == "diagnose_v1"
    assert model == "deepseek-v4-pro"
    # dry-run 不留下临时信号库
    assert not (tmp_path / "_dry_run_signals.db").exists()


def test_build_diagnosis_deps_model_mismatch_fails_loud(monkeypatch, tmp_path):
    """config.diag.model 与 .env SEARCH_LLM_MODEL 不一致 → fail loud（指纹漂移防护）。"""

    class _FakeSettings:
        search_llm_model = "actual-model-in-env"
        search_llm_base_url = "https://api.example"
        search_llm_api_key = "sk-xxx"

    monkeypatch.setattr(cli, "_DiagLLMSettings", lambda: _FakeSettings())
    with pytest.raises(SystemExit) as exc:
        cli.build_diagnosis_deps(
            harness_db=tmp_path / "h.db",
            concurrency=2,
            expected_model="deepseek-v4-pro",  # 与 env 不一致
        )
    # 报错须同时暴露两个值，便于人对齐
    msg = str(exc.value)
    assert "deepseek-v4-pro" in msg
    assert "actual-model-in-env" in msg


def test_build_diagnosis_deps_missing_credentials_fails_loud(monkeypatch, tmp_path):
    """.env 缺 search LLM 凭证 → fail loud（先于模型一致性校验）。"""

    class _EmptySettings:
        search_llm_model = ""
        search_llm_base_url = ""
        search_llm_api_key = ""

    monkeypatch.setattr(cli, "_DiagLLMSettings", lambda: _EmptySettings())
    with pytest.raises(SystemExit):
        cli.build_diagnosis_deps(
            harness_db=tmp_path / "h.db", concurrency=2, expected_model="deepseek-v4-pro"
        )


def test_git_short_sha_nonempty():
    """仓库内 git_short_sha 返回非空短 SHA。"""
    sha = cli.git_short_sha()
    assert sha
    assert len(sha) >= 4
