from __future__ import annotations

import json
import sys
from pathlib import Path

import pytest

from recoil.core.paths import ProjectPaths
from recoil.pipeline._lib import board_builder as bb
from recoil.pipeline._lib.board_builder import compute_source_sha256
from recoil.pipeline._lib import derivation_manifest
from recoil.pipeline._lib.derivation_sha import shotset_hash
from recoil.pipeline._lib import story_gate as sg
from recoil.pipeline.cli import generate
from recoil.pipeline.core.persistence import load_scene, save_scene, scene_path
from recoil.pipeline.core.receipts import GenerationReceipt, utc_now_iso8601
from recoil.pipeline.core.registry import MODALITY_STORYBOARD, RunResult
from recoil.pipeline.core.take import Beat, Scene


BATCH_ID = "EP001_CONT_004"
SCENE_ID = "BATCH_004"
PROJECT = "fixture"


@pytest.fixture(autouse=True)
def _projects_root(tmp_path, monkeypatch):
    root = tmp_path / "projects"
    root.mkdir()
    (root / ".recoil-data-root").touch()
    project_root = root / PROJECT
    project_root.mkdir()
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(root))
    return project_root


def _shot(intent: str = "Beat 10 action.") -> dict:
    return {
        "shot_id": "EP001_SH10",
        "scene_index": 1,
        "duration_s": 1.0,
        "intent": intent,
        "asset_data": {"characters": [], "location_id": None},
        "spatial_data": {},
    }


def _source_sha(intent: str = "Beat 10 action.", *, version: int = 1) -> str:
    return compute_source_sha256(
        [
            {
                "shot_id": "EP001_SH10",
                "start_s": 0.0,
                "end_s": 1.0,
                "duration_s": 1.0,
                "intent": intent,
                "sublocation": None,
            }
        ],
        version=version,
    )


def _grouping(shot_ids: list[str] | None = None) -> dict:
    ids = list(shot_ids or ["EP001_SH10"])
    return {
        "strategy": "continuity",
        "ordinal": 4,
        "shot_ids": ids,
        "source_pass_id": None,
        "shotset_hash": shotset_hash(ids),
    }


def _save_board_scene(
    *,
    source_sha256: str | None = None,
    status: str = "proposed",
    story_gate: dict | None = None,
    include_grouping: bool = True,
    photoreal_artifact: str | None = None,
) -> Path:
    paths = ProjectPaths.for_project(PROJECT)
    artifact_rel = "prep/ep_001/storyboards/EP001_CONT_004_v01.png"
    artifact_path = paths.project_root / artifact_rel
    artifact_path.parent.mkdir(parents=True, exist_ok=True)
    artifact_path.write_bytes(b"\x89PNG\r\n" + (b"0" * 2048))
    shot = _shot()
    beat = Beat(
        beat_id=SCENE_ID,
        beat_metadata={
            "scene_id": SCENE_ID,
            "modality": "r2v_multi",
            "shot": shot,
            "batch_shots": [shot],
            **({"grouping": _grouping(["EP001_SH10"])} if include_grouping else {}),
        },
        board={
            "status": status,
            "artifact": artifact_rel,
            "source_sha256": source_sha256 or _source_sha(),
            "approved_by": None,
            "updated_at": utc_now_iso8601(),
        },
    )
    if story_gate is not None:
        beat.board["story_gate"] = story_gate
    if photoreal_artifact is not None:
        beat.board["photoreal_artifact"] = photoreal_artifact
    scene = Scene(scene_id=SCENE_ID, beats=[beat])
    path = scene_path(PROJECT, "ep_001", SCENE_ID)
    save_scene(scene, path)
    return path


def _save_unboarded_scene(*, intent: str = "Beat 10 action.") -> Path:
    shot = _shot(intent)
    beat = Beat(
        beat_id=SCENE_ID,
        beat_metadata={
            "scene_id": SCENE_ID,
            "modality": "r2v_multi",
            "shot": shot,
            "batch_shots": [shot],
            "grouping": _grouping(["EP001_SH10"]),
        },
        board=None,
    )
    scene = Scene(scene_id=SCENE_ID, beats=[beat])
    path = scene_path(PROJECT, "ep_001", SCENE_ID)
    save_scene(scene, path)
    return path


def _storyboard_receipt(success: bool = True) -> GenerationReceipt:
    return GenerationReceipt(
        receipt_id="rcpt_board",
        modality=MODALITY_STORYBOARD,
        caller_id="board_builder",
        project=PROJECT,
        episode=1,
        shot_id=BATCH_ID,
        timestamp_utc="2026-06-14T00:00:00Z",
        run_result=RunResult(
            id="run_board",
            modality=MODALITY_STORYBOARD,
            output_path="/tmp/board.png" if success else None,
            metadata={},
            success=success,
            error=None if success else "board failed",
        ),
    )


def _dispatch_writing_storyboard(modality, payload, *, context):
    storyboards_dir = Path(payload["save_dir"])
    storyboards_dir.mkdir(parents=True, exist_ok=True)
    png_path = storyboards_dir / f"{payload['filename_stem']}.png"
    png_path.write_bytes(b"\x89PNG\r\n" + (b"0" * 2048))
    Path(f"{png_path}.json").write_text(
        json.dumps(payload["sidecar_extra"]),
        encoding="utf-8",
    )
    return _storyboard_receipt()


def _settings_passthrough(segments, **_kwargs):
    return [dict(segment, setting=f"Setting {i}") for i, segment in enumerate(segments, start=1)]


def _build_born_v2_board(monkeypatch, *, intent: str = "Beat 10 action.") -> tuple[Path, dict]:
    scene_file = _save_unboarded_scene(intent=intent)
    monkeypatch.setattr(bb, "derive_settings", _settings_passthrough)
    monkeypatch.setattr(bb, "dispatch", _dispatch_writing_storyboard)
    result = bb.build_and_dispatch_board(
        PROJECT,
        1,
        BATCH_ID,
        step_runner=object(),
    )
    assert result["fingerprint_version"] == 2
    assert load_scene(scene_file).beats[0].board["fingerprint_version"] == 2
    return scene_file, result


def _set_scene_intent(scene_file: Path, intent: str) -> None:
    scene = load_scene(scene_file)
    beat = scene.beats[0]
    beat.beat_metadata["shot"]["intent"] = intent
    beat.beat_metadata["batch_shots"][0]["intent"] = intent
    save_scene(scene, scene_file)


def _set_scene_duration(scene_file: Path, duration_s: float) -> None:
    scene = load_scene(scene_file)
    beat = scene.beats[0]
    beat.beat_metadata["shot"]["duration_s"] = duration_s
    beat.beat_metadata["batch_shots"][0]["duration_s"] = duration_s
    save_scene(scene, scene_file)


def _attach_existing_photoreal(scene_file: Path) -> str:
    scene = load_scene(scene_file)
    photoreal = "prep/ep_001/storyboards/EP001_CONT_004_v01_photoreal.png"
    photoreal_path = ProjectPaths.for_project(PROJECT).project_root / photoreal
    photoreal_path.parent.mkdir(parents=True, exist_ok=True)
    photoreal_path.write_bytes(b"\x89PNG\r\n" + (b"1" * 2048))
    scene.beats[0].board["photoreal_artifact"] = photoreal
    save_scene(scene, scene_file)
    return photoreal


@pytest.fixture
def no_grouping_board_scene() -> Path:
    return _save_board_scene(include_grouping=False)


@pytest.fixture(autouse=True)
def _mock_board_finish(monkeypatch):
    """Approve now mints a photoreal finish (REC-149). These CLI tests assert the
    approve/reject label + persist behavior, not finish rendering — mock the finish
    to a success no-op so approve stays exit 0. Finish is covered by
    test_board_finish.py."""
    monkeypatch.setattr(
        generate,
        "render_board_finish",
        _successful_finish,
    )
    monkeypatch.setattr(
        generate, "_build_step_runner_for_episode", lambda *a, **k: "runner"
    )


def _successful_finish(project, episode, batch, **_kwargs):
    artifact = "prep/ep_001/storyboards/EP001_CONT_004_v01_photoreal.png"
    artifact_path = ProjectPaths.for_project(project).project_root / artifact
    artifact_path.parent.mkdir(parents=True, exist_ok=True)
    artifact_path.write_bytes(b"\x89PNG\r\n" + (b"1" * 2048))
    return {"success": True, "artifact": artifact}


def _run_cli(monkeypatch, capsys, *args: str) -> tuple[int, dict]:
    monkeypatch.setattr(sys, "argv", ["generate.py", *args])
    code = generate.main()
    captured = capsys.readouterr()
    assert captured.out
    # The approve flow may prepend a "photoreal finish: ..." announce line
    # before the JSON result (REC-149). Parse the last JSON object from stdout.
    payload = None
    for line in captured.out.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            payload = json.loads(line)
        except json.JSONDecodeError:
            continue
    assert payload is not None, captured.out
    return code, payload


def _labels_path() -> Path:
    return (
        ProjectPaths.for_project(PROJECT)
        .episode_storyboards_dir(1)
        / "story_gate_labels.jsonl"
    )


def _label_rows() -> list[dict]:
    return [
        json.loads(line)
        for line in _labels_path().read_text(encoding="utf-8").splitlines()
        if line.strip()
    ]


def _decision(
    *,
    decision: str = "approve",
    batch: str = BATCH_ID,
    reason: str | None = None,
    route: str | None = None,
) -> tuple[int, dict]:
    return generate._run_board_decision(
        project=PROJECT,
        episode=1,
        batch=batch,
        decision=decision,
        reason=reason,
        route=route,
    )


def _board_record() -> dict:
    key = shotset_hash(["EP001_SH10"])
    manifest = derivation_manifest.load(PROJECT, 1)
    return manifest["execution"]["boards"][key]


def _stamp_board_record(record: dict) -> None:
    derivation_manifest.stamp_board(PROJECT, 1, shotset_hash(["EP001_SH10"]), record)


def test_approve_board_flips_proposed_to_approved_and_persists(monkeypatch, capsys):
    scene_file = _save_board_scene()
    expected_key = shotset_hash(["EP001_SH10"])

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--approve-board",
        BATCH_ID,
    )

    assert code == 0
    assert payload["status"] == "approved"
    assert payload["approved_by"] == "JT"
    persisted = load_scene(scene_file).beats[0].board
    assert persisted["status"] == "approved"
    assert persisted["approved_by"] == "JT"
    assert persisted["artifact"] == payload["artifact"]
    assert persisted["source_sha256"] == payload["source_sha256"]
    assert persisted["photoreal_artifact"] == payload["photoreal_artifact"]
    assert set(persisted) >= {
        "status",
        "artifact",
        "source_sha256",
        "approved_by",
        "updated_at",
        "photoreal_artifact",
    }

    boards = derivation_manifest.load(PROJECT, 1)["execution"]["boards"]
    assert list(boards) == [expected_key]
    record = boards[expected_key]
    assert record["status"] == "approved"
    assert record["artifact"] == payload["artifact"]
    assert record["source_sha256"] == payload["source_sha256"]
    assert record["covered_shot_ids"] == ["EP001_SH10"]
    assert record["fingerprint_version"] == 1
    assert record["photoreal_artifact"] == payload["photoreal_artifact"]
    assert record["approved_by"] == "JT"


def test_approve_board_appends_one_label_row_with_artifact_and_sha(monkeypatch, capsys):
    _save_board_scene()

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--approve-board",
        BATCH_ID,
    )

    assert code == 0
    rows = _label_rows()
    assert len(rows) == 1
    assert rows[0]["schema_version"] == 1
    assert rows[0]["project"] == PROJECT
    assert rows[0]["episode"] == 1
    assert rows[0]["batch_id"] == BATCH_ID
    assert rows[0]["decision"] == "approve"
    assert rows[0]["decision_by"] == "JT"
    assert rows[0]["artifact"] == payload["artifact"]
    assert rows[0]["source_sha256"] == payload["source_sha256"]
    assert rows[0]["reason"] is None
    assert rows[0]["route_hint"] is None
    assert rows[0]["judge"] is None


def test_approve_board_stale_hash_exits_2_without_state_change(monkeypatch, capsys):
    scene_file = _save_board_scene(source_sha256="stale")
    before = scene_file.read_text(encoding="utf-8")

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--approve-board",
        BATCH_ID,
    )

    assert code == 2
    assert payload["message"] == (
        "board stale (segments changed since generation) — regenerate with --storyboard"
    )
    assert scene_file.read_text(encoding="utf-8") == before
    assert load_scene(scene_file).beats[0].board["status"] == "proposed"


def test_approve_board_born_v2_prose_reroll_is_not_stale(monkeypatch, capsys):
    scene_file, result = _build_born_v2_board(
        monkeypatch,
        intent="Jade studies the pod seal.",
    )
    photoreal = _attach_existing_photoreal(scene_file)
    _set_scene_intent(scene_file, "Jade reconsiders the pod seal.")
    before_board = dict(load_scene(scene_file).beats[0].board)
    monkeypatch.setattr(
        generate,
        "render_board_finish",
        lambda *a, **k: pytest.fail("existing finish should skip dispatch"),
    )

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--approve-board",
        BATCH_ID,
    )

    assert code == 0
    assert "error" not in payload
    assert payload["artifact"] == result["artifact"]
    assert payload["source_sha256"] == result["source_sha256"]
    persisted = load_scene(scene_file).beats[0].board
    assert persisted["status"] == "approved"
    assert persisted["artifact"] == before_board["artifact"]
    assert persisted["source_sha256"] == before_board["source_sha256"]
    assert persisted["fingerprint_version"] == 2
    assert persisted["photoreal_artifact"] == photoreal


def test_approve_board_born_v2_structural_change_is_stale(monkeypatch, capsys):
    scene_file, _result = _build_born_v2_board(
        monkeypatch,
        intent="Jade studies the pod seal.",
    )
    _set_scene_duration(scene_file, 1.25)
    before = scene_file.read_text(encoding="utf-8")
    monkeypatch.setattr(
        generate,
        "render_board_finish",
        lambda *a, **k: pytest.fail("stale board must not dispatch finish"),
    )

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--approve-board",
        BATCH_ID,
    )

    assert code == 2
    assert payload["error"] == "board_stale"
    assert scene_file.read_text(encoding="utf-8") == before
    assert load_scene(scene_file).beats[0].board["status"] == "proposed"


def test_approve_board_legacy_v1_absent_version_still_matches(monkeypatch, capsys):
    scene_file = _save_board_scene()
    assert "fingerprint_version" not in load_scene(scene_file).beats[0].board

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--approve-board",
        BATCH_ID,
    )

    assert code == 0
    assert payload["status"] == "approved"
    assert "error" not in payload
    assert "fingerprint_version" not in load_scene(scene_file).beats[0].board


def test_approve_board_ssot_first_stamp_failure_aborts_before_persist(monkeypatch):
    scene_file = _save_board_scene()
    scene = load_scene(scene_file)
    scene.beats[0].beat_metadata["grouping"]["shotset_hash"] = shotset_hash(["EP001_SH99"])
    save_scene(scene, scene_file)
    before = scene_file.read_bytes()
    finish_calls = []

    def finish(*_args, **_kwargs):
        finish_calls.append(True)
        return {"success": True, "artifact": "should/not/run.png"}

    monkeypatch.setattr(generate, "render_board_finish", finish)

    with pytest.raises(RuntimeError, match="shotset_hash mismatch"):
        _decision(decision="approve")

    assert scene_file.read_bytes() == before
    assert load_scene(scene_file).beats[0].board["status"] == "proposed"
    assert not _labels_path().exists()
    assert finish_calls == []


def test_approve_board_missing_hash_integrity_aborts_without_partial_ssot(
    no_grouping_board_scene,
):
    before = no_grouping_board_scene.read_bytes()

    with pytest.raises(RuntimeError, match="no derivable shotset_hash"):
        _decision(decision="approve")

    assert no_grouping_board_scene.read_bytes() == before
    assert load_scene(no_grouping_board_scene).beats[0].board["status"] == "proposed"
    assert derivation_manifest.load(PROJECT, 1)["execution"]["boards"] == {}


def test_approve_board_dangling_photoreal_not_written_to_ssot(monkeypatch, capsys):
    stale_photoreal = "prep/ep_001/storyboards/missing_photoreal.png"
    _save_board_scene(photoreal_artifact=stale_photoreal)
    monkeypatch.setattr(
        generate,
        "render_board_finish",
        lambda *a, **k: {"success": False, "error": "finish failed"},
    )

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--approve-board",
        BATCH_ID,
    )

    assert code == 1
    assert payload["error"] == "board_finish_failed"
    record = _board_record()
    assert record["status"] == "approved"
    assert record["photoreal_artifact"] is None
    assert record["photoreal_artifact"] != stale_photoreal


def test_reject_board_flips_proposed_to_rejected_and_persists(monkeypatch, capsys):
    scene_file = _save_board_scene()
    monkeypatch.setattr(
        generate,
        "render_board_finish",
        lambda *a, **k: pytest.fail("reject must not dispatch photoreal finish"),
    )

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--reject-board",
        BATCH_ID,
    )

    assert code == 0
    assert payload["status"] == "rejected"
    assert payload["approved_by"] == "JT"
    assert load_scene(scene_file).beats[0].board["status"] == "rejected"
    record = _board_record()
    assert record["status"] == "rejected"
    assert record["photoreal_artifact"] is None


def test_reject_board_appends_reason_route_and_story_gate_projection(monkeypatch, capsys):
    projection = {
        "mode": "shadow",
        "route": "board_problem",
        "severity": "HARD",
        "confidence": 0.91,
        "summary": "target behind actor",
        "judge_model": "claude-opus-4-8",
        "prompt_version": "story_gate_rubric_v1",
        "verdict_path": "prep/ep_001/storyboards/EP001_CONT_004_v01.verdict.json",
        "verdict_hash": "a" * 64,
        "updated_at": utc_now_iso8601(),
    }
    _save_board_scene(story_gate=projection)

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--reject-board",
        BATCH_ID,
        "--reason",
        "object is behind Jade",
        "--route",
        "board_problem",
    )

    assert code == 0
    rows = _label_rows()
    assert len(rows) == 1
    assert rows[0]["decision"] == "reject"
    assert rows[0]["artifact"] == payload["artifact"]
    assert rows[0]["source_sha256"] == payload["source_sha256"]
    assert rows[0]["reason"] == "object is behind Jade"
    assert rows[0]["route_hint"] == "board_problem"
    assert rows[0]["judge"]["verdict_hash"] == "a" * 64
    assert rows[0]["judge"]["route"] == "board_problem"


def test_revalidate_board_reblesses_structural_drift_without_spend(monkeypatch, capsys):
    scene_file, result = _build_born_v2_board(
        monkeypatch,
        intent="Jade studies the pod seal.",
    )

    approve_code, approve_payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--approve-board",
        BATCH_ID,
    )
    assert approve_code == 0
    approved_record = dict(_board_record())
    assert approved_record["status"] == "approved"
    assert approved_record["artifact"] == result["artifact"]
    assert approved_record["photoreal_artifact"] == approve_payload["photoreal_artifact"]

    _set_scene_duration(scene_file, 1.25)
    monkeypatch.setattr(
        generate,
        "render_board_finish",
        lambda *a, **k: pytest.fail("revalidate must not dispatch finish"),
    )

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--revalidate-board",
        BATCH_ID,
    )

    assert code == 0
    assert payload == {"revalidated": True, "spend": 0}
    record = _board_record()
    assert record["status"] == "approved"
    assert record["artifact"] == approved_record["artifact"]
    assert record["photoreal_artifact"] == approved_record["photoreal_artifact"]
    assert record["fingerprint_version"] == approved_record["fingerprint_version"] == 2
    # REC-231: revalidation must re-stamp scene_version so the spend discriminator still
    # version-checks the record (a versionless re-stamp would re-open the cross-version
    # board spend hole). It matches the active version the approve also stamped.
    assert record["scene_version"] is not None
    assert record["scene_version"] == approved_record["scene_version"]
    assert record["source_sha256"] != approved_record["source_sha256"]
    assert record["source_sha256"] == compute_source_sha256(
        [
            {
                "shot_id": "EP001_SH10",
                "start_s": 0.0,
                "end_s": 1.25,
                "duration_s": 1.25,
                "intent": "Jade studies the pod seal.",
                "sublocation": None,
            }
        ],
        version=2,
    )
    persisted = load_scene(scene_file)
    assert persisted.locked is True
    assert persisted.beats[0].board == bb.board_record_to_cache(record)


def test_revalidate_board_no_prior_approval_refuses_without_scene_change(
    monkeypatch,
    capsys,
):
    scene_file = _save_unboarded_scene()
    before = scene_file.read_bytes()

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--revalidate-board",
        BATCH_ID,
    )

    assert code == 2
    assert payload["error"] == "no_prior_approval"
    assert scene_file.read_bytes() == before
    assert derivation_manifest.load(PROJECT, 1)["execution"]["boards"] == {}


def test_revalidate_board_rejected_record_refuses_without_approval_bypass(
    monkeypatch,
    capsys,
):
    scene_file = _save_board_scene()

    reject_code, reject_payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--reject-board",
        BATCH_ID,
    )
    assert reject_code == 0
    assert reject_payload["status"] == "rejected"
    before = scene_file.read_bytes()

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--revalidate-board",
        BATCH_ID,
    )

    assert code == 2
    assert payload["error"] == "not_previously_approved"
    assert payload["message"] == (
        "revalidate only re-blesses a previously APPROVED board; "
        "rejected/proposed records require --approve-board"
    )
    assert _board_record()["status"] == "rejected"
    assert scene_file.read_bytes() == before


def test_revalidate_board_v2_record_empty_cache_gets_v2_hash(
    monkeypatch,
    capsys,
):
    scene_file = _save_unboarded_scene()
    record = {
        "status": "approved",
        "artifact": "prep/ep_001/storyboards/EP001_CONT_004_v01.png",
        "photoreal_artifact": "prep/ep_001/storyboards/EP001_CONT_004_v01_photoreal.png",
        "source_sha256": "stale",
        "fingerprint_version": 2,
        "plan_structural_sha_at_approval": "plan-sha",
        "covered_shot_ids": ["EP001_SH10"],
        "approved_by": "JT",
        "updated_at": "2026-06-14T00:00:00Z",
        "needs_revalidation": True,
    }
    _stamp_board_record(record)
    monkeypatch.setattr(
        generate,
        "render_board_finish",
        lambda *a, **k: pytest.fail("revalidate must not dispatch finish"),
    )

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--revalidate-board",
        BATCH_ID,
    )

    assert code == 0
    assert payload["spend"] == 0
    updated = _board_record()
    assert updated["source_sha256"] == _source_sha(version=2)
    assert updated["source_sha256"] != _source_sha(version=1)
    assert "needs_revalidation" not in updated
    assert load_scene(scene_file).beats[0].board == bb.board_record_to_cache(updated)


def test_revalidate_board_reason_route_invalid(monkeypatch, capsys):
    monkeypatch.setattr(
        sys,
        "argv",
        [
            "generate.py",
            "--project",
            PROJECT,
            "--episode",
            "1",
            "--revalidate-board",
            BATCH_ID,
            "--reason",
            "not a decision",
        ],
    )

    with pytest.raises(SystemExit) as exc:
        generate.main()

    assert exc.value.code == 2
    captured = capsys.readouterr()
    assert "--reason/--route are valid only" in captured.err


def test_board_label_append_ioerror_does_not_change_decision_result(
    monkeypatch,
    capsys,
):
    scene_file = _save_board_scene()

    def fail_append(_path, _record):
        raise IOError("disk full")

    monkeypatch.setattr(sg, "jsonl_append_locked", fail_append)

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--approve-board",
        BATCH_ID,
    )

    assert code == 0
    assert payload["status"] == "approved"
    assert load_scene(scene_file).beats[0].board["status"] == "approved"


def test_board_route_invalid_value_errors(monkeypatch, capsys):
    _save_board_scene()
    monkeypatch.setattr(
        sys,
        "argv",
        [
            "generate.py",
            "--project",
            PROJECT,
            "--episode",
            "1",
            "--reject-board",
            BATCH_ID,
            "--route",
            "prompt_problem",
        ],
    )

    with pytest.raises(SystemExit) as exc:
        generate.main()

    assert exc.value.code == 2
    captured = capsys.readouterr()
    assert "invalid choice" in captured.err


def test_board_reason_route_without_decision_errors(monkeypatch, capsys):
    monkeypatch.setattr(
        sys,
        "argv",
        [
            "generate.py",
            "--project",
            PROJECT,
            "--episode",
            "1",
            "--storyboard",
            BATCH_ID,
            "--reason",
            "not a decision",
            "--route",
            "other",
        ],
    )

    with pytest.raises(SystemExit) as exc:
        generate.main()

    assert exc.value.code == 2
    captured = capsys.readouterr()
    assert "--reason/--route are valid only" in captured.err


def test_storyboard_dry_run_prints_estimate_json_with_zero_writes(monkeypatch, capsys):
    calls = {}

    def fake_build(project, episode, batch_id, *, step_runner, dry_run):
        calls["args"] = (project, episode, batch_id)
        calls["step_runner"] = step_runner
        calls["dry_run"] = dry_run
        return {
            "prompt": "draw panels",
            "refs": [],
            "size_override": "1728x1536",
            "panels": [{"segment_id": "EP001_SH10", "setting": None}],
            "estimated_cost_usd": 0.41,
        }

    monkeypatch.setattr(generate, "build_and_dispatch_board", fake_build)
    monkeypatch.setattr(
        generate,
        "_build_step_runner_for_episode",
        lambda *args, **kwargs: pytest.fail("dry-run must not build StepRunner"),
    )

    code, payload = _run_cli(
        monkeypatch,
        capsys,
        "--project",
        PROJECT,
        "--episode",
        "1",
        "--storyboard",
        BATCH_ID,
        "--dry-run",
    )

    assert code == 0
    assert payload["estimated_cost_usd"] == 0.41
    assert calls == {
        "args": (PROJECT, 1, BATCH_ID),
        "step_runner": None,
        "dry_run": True,
    }


def test_storyboard_auto_reroll_plumbs_flag_and_attempt_budget(monkeypatch, capsys):
    calls = {}

    def fake_build(project, episode, batch_id, *, step_runner, max_attempts):
        calls["args"] = (project, episode, batch_id)
        calls["step_runner"] = step_runner
        calls["max_attempts"] = max_attempts
        return {
            "success": True,
            "artifact": "prep/ep_001/storyboards/EP001_CONT_004_v02.png",
            "attempts": 2,
            "reroll_lineage": [],
            "stopped_reason": None,
        }

    monkeypatch.setattr(generate, "build_with_auto_reroll", fake_build)
    monkeypatch.setattr(generate, "build_and_dispatch_board", lambda *a, **k: pytest.fail())
    monkeypatch.setattr(generate, "_build_step_runner_for_episode", lambda *a, **k: "runner")
    monkeypatch.setattr(
        sys,
        "argv",
        [
            "generate.py",
            "--project",
            PROJECT,
            "--episode",
            "1",
            "--storyboard",
            BATCH_ID,
            "--auto-reroll",
            "--max-board-attempts",
            "4",
        ],
    )

    code = generate.main()
    captured = capsys.readouterr()
    lines = captured.out.strip().splitlines()
    payload = json.loads(lines[-1])

    assert code == 0
    assert lines[0] == "auto-reroll: up to 4 board attempts, ~$0.41 each"
    assert payload["attempts"] == 2
    assert calls == {
        "args": (PROJECT, 1, BATCH_ID),
        "step_runner": "runner",
        "max_attempts": 4,
    }


def test_auto_reroll_without_storyboard_errors(monkeypatch, capsys):
    monkeypatch.setattr(
        sys,
        "argv",
        [
            "generate.py",
            "--project",
            PROJECT,
            "--episode",
            "1",
            "--auto-reroll",
        ],
    )

    with pytest.raises(SystemExit) as exc:
        generate.main()

    assert exc.value.code == 2
    captured = capsys.readouterr()
    assert "--auto-reroll is valid only with --storyboard" in captured.err


def test_auto_reroll_script_problem_surface_block_names_playbook(monkeypatch, capsys):
    verdict_rel = "prep/ep_001/storyboards/EP001_CONT_004_v01.verdict.json"
    verdict_path = ProjectPaths.for_project(PROJECT).project_root / verdict_rel
    verdict_path.parent.mkdir(parents=True, exist_ok=True)
    verdict_path.write_text(
        json.dumps(
            {
                "text_stageability": {
                    "findings": [
                        {
                            "beat_index": 1,
                            "check": "causal_setup_present",
                            "passed": False,
                            "severity": "HARD",
                            "reason": "The script never establishes why Jade grabs the pod.",
                            "suggested_script_question": "What causes Jade to grab the pod?",
                        }
                    ]
                },
                "panels": [],
                "transitions": [],
                "routing": {"class": "script_problem", "confidence": 0.95},
            }
        ),
        encoding="utf-8",
    )

    def fake_build(_project, _episode, _batch_id, *, step_runner, max_attempts):
        return {
            "success": True,
            "artifact": "prep/ep_001/storyboards/EP001_CONT_004_v01.png",
            "attempts": 1,
            "story_gate": {
                "route": "script_problem",
                "verdict_path": verdict_rel,
            },
            "reroll_lineage": [
                {
                    "attempt": 1,
                    "artifact": "prep/ep_001/storyboards/EP001_CONT_004_v01.png",
                    "route": "script_problem",
                    "hard_fails": 1,
                    "soft_fails": 0,
                }
            ],
            "stopped_reason": "non_rerollable_route:script_problem",
        }

    monkeypatch.setattr(generate, "build_with_auto_reroll", fake_build)
    monkeypatch.setattr(generate, "_build_step_runner_for_episode", lambda *a, **k: "runner")
    monkeypatch.setattr(
        sys,
        "argv",
        [
            "generate.py",
            "--project",
            PROJECT,
            "--episode",
            "1",
            "--storyboard",
            BATCH_ID,
            "--auto-reroll",
        ],
    )

    code = generate.main()
    captured = capsys.readouterr()

    assert code == 0
    assert "StoryGate stop-and-surface" in captured.out
    assert "route: script_problem" in captured.out
    assert "What causes Jade to grab the pod?" in captured.out
    assert "recoil/docs/DIRECTOR_NOTES_PLAYBOOK.md chain B" in captured.out
