from __future__ import annotations

import json
from pathlib import Path

import pytest

from recoil.core.paths import ProjectPaths
from recoil.core.ref_types import RefAsset, ReferenceBundle
from recoil.pipeline._lib import board_builder as bb
from recoil.pipeline._lib.prompt_engine import render_prop_invariant
from recoil.pipeline.core.persistence import save_scene, scene_path
from recoil.pipeline.core.receipts import GenerationReceipt
from recoil.pipeline.core.registry import MODALITY_STORYBOARD, RunResult
from recoil.pipeline.core.take import Beat, Scene


PROJECT = "fixture_project"
BATCH_ID = "EP001_CONT_004"
SCENE_ID = "BATCH_004"


@pytest.fixture()
def project_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ProjectPaths:
    paths = ProjectPaths(project_root=tmp_path / PROJECT)
    paths.project_root.mkdir(parents=True)
    monkeypatch.setattr(
        ProjectPaths,
        "for_project",
        classmethod(lambda cls, project=None: paths),
    )
    return paths


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


@pytest.fixture(autouse=True)
def _patch_settings(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(bb, "derive_settings", _settings_passthrough)


def _receipt(success: bool, *, error: str | None = None) -> GenerationReceipt:
    return GenerationReceipt(
        receipt_id="rcpt_carrier_failclosed",
        modality=MODALITY_STORYBOARD,
        caller_id="board_builder",
        project=PROJECT,
        episode=1,
        shot_id=BATCH_ID,
        timestamp_utc="2026-06-21T00:00:00Z",
        run_result=RunResult(
            id="run_carrier_failclosed",
            modality=MODALITY_STORYBOARD,
            output_path="/tmp/board.png" if success else None,
            metadata={},
            success=success,
            error=error,
        ),
    )


def _carrier_bible() -> dict:
    return {
        "project": PROJECT,
        "total_episodes": 1,
        "characters": {"JADE": {"visual_description": "Lean salvager."}},
        "props": {
            "debt_counter": {
                "attached_to": "JADE",
                "is_permanent_attachment": True,
                "description": "amber readout worn on the left wrist",
            },
            "loose_meter": {
                "description": "portable handheld meter",
            },
        },
    }


def _write_bible(paths: ProjectPaths, bible: dict | None = None) -> None:
    paths.global_bible_path.parent.mkdir(parents=True, exist_ok=True)
    paths.global_bible_path.write_text(
        json.dumps(bible if bible is not None else _carrier_bible()),
        encoding="utf-8",
    )


def _write_invalid_bible(paths: ProjectPaths) -> None:
    paths.global_bible_path.parent.mkdir(parents=True, exist_ok=True)
    paths.global_bible_path.write_text("{not valid json", encoding="utf-8")


def _raw_wrapped_shot(
    *,
    props: list[dict] | None = None,
    characters: list[dict] | None = None,
    intent: str = "Jade notices an amber readout pulsing near the hatch.",
) -> dict:
    return {
        "raw": {
            "shot_id": "EP001_SH10",
            "scene_index": 1,
            "duration_s": 1.0,
            "intent": intent,
            "asset_data": {
                "props": props or [],
                "characters": characters or [],
                "location_id": None,
            },
            "spatial_data": {},
        }
    }


def _write_batch_scene(
    shots: list[dict],
    *,
    board: dict | None = None,
    shared_characters: list[str] | None = None,
) -> Path:
    beat = Beat(
        beat_id=SCENE_ID,
        beat_metadata={
            "scene_id": SCENE_ID,
            "modality": "r2v_multi",
            "shot": shots[0],
            "batch_shots": shots,
            "batch_summary": {
                "shared_characters": shared_characters or [],
                "shared_location_id": None,
            },
        },
        board=board,
    )
    scene = Scene(
        scene_id=SCENE_ID,
        beats=[beat],
        scene_metadata={"episode": "ep_001", "project": PROJECT},
    )
    path = scene_path(PROJECT, "ep_001", SCENE_ID)
    save_scene(scene, path)
    return path


def _expected_invariant() -> str:
    return render_prop_invariant(
        "debt_counter",
        "JADE",
        "amber readout worn on the left wrist",
    )


def _prompt_with_invariant() -> str:
    return f"STORY BEATS:\n1. a beat\n\n{_expected_invariant()}"


def _prompt_missing_invariant() -> str:
    return (
        "REFERENCE MAPPING: @Image1 is the debt_counter prop identity.\n"
        "JADE stands center.\n"
        "STORY BEATS:\n"
        "1. a beat"
    )


def _builder_returning(prompt: str):
    def builder(*_args, **_kwargs):
        return prompt

    return builder


def _counting_builder_factory(*, first_prompt: str, second_prompt: str):
    calls = {"n": 0}

    def builder(*_args, **_kwargs):
        calls["n"] += 1
        return first_prompt if calls["n"] == 1 else second_prompt

    return builder


def _write_approved_finish_scene(paths: ProjectPaths, *, carrier: bool = True) -> None:
    shot = _raw_wrapped_shot(
        props=[],
        characters=[{"char_id": "JADE"}] if carrier else [],
        intent="Jade checks the amber readout on her wrist.",
    )
    segments = [
        {
            "shot_id": "EP001_SH10",
            "start_s": 0.0,
            "end_s": 1.0,
            "duration_s": 1.0,
            "intent": "Jade checks the amber readout on her wrist.",
            "sublocation": None,
        }
    ]
    source_sha256 = bb.compute_source_sha256(segments, version=2)
    artifact_rel = "prep/ep_001/storyboards/EP001_CONT_004_v03.png"
    artifact = paths.project_root / artifact_rel
    artifact.parent.mkdir(parents=True, exist_ok=True)
    artifact.write_bytes(b"\x89PNG\r\n")
    Path(f"{artifact}.json").write_text(
        json.dumps(
            {
                "kind": "storyboard",
                "batch_id": BATCH_ID,
                "version": 3,
                "source_sha256": source_sha256,
                "fingerprint_version": 2,
                "identity_refs": [],
                "sublocation_refs": [],
                "prop_refs": [],
            }
        ),
        encoding="utf-8",
    )
    board = {
        "status": "approved",
        "artifact": artifact_rel,
        "source_sha256": source_sha256,
        "fingerprint_version": 2,
        "approved_by": "director",
        "updated_at": "2026-06-21T00:00:00Z",
    }
    _write_batch_scene([shot], board=board)


def _write_jade_refs(paths: ProjectPaths, monkeypatch: pytest.MonkeyPatch) -> None:
    front = paths.project_root / "assets" / "char" / "jade" / "base" / "jade_front.png"
    profile = paths.project_root / "assets" / "char" / "jade" / "base" / "jade_profile.png"
    front.parent.mkdir(parents=True, exist_ok=True)
    front.write_bytes(b"front")
    profile.write_bytes(b"profile")

    def fake_bundle(_paths, char_id, phase=None):
        return ReferenceBundle(
            (
                RefAsset(
                    path=front,
                    role="identity",
                    subject=char_id,
                    kind="turn",
                    view="front",
                ),
                RefAsset(
                    path=profile,
                    role="identity",
                    subject=char_id,
                    kind="turn",
                    view="profile",
                ),
            )
        )

    monkeypatch.setattr(bb, "resolve_character_bundle", fake_bundle)


def _disable_worn_prop_auto_inject(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setitem(
        bb._dispatch_payload._project_config_cache,
        PROJECT,
        {"worn_prop_auto_inject": False},
    )


def test_pencil_primary_raises_when_tokens_present_but_invariant_absent(
    project_paths: ProjectPaths,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    _write_bible(project_paths)
    _write_batch_scene(
        [_raw_wrapped_shot(props=[{"prop_id": "debt_counter"}], characters=[])],
    )
    monkeypatch.setattr(
        bb,
        "get_builder",
        lambda _model, _modality: _builder_returning(_prompt_missing_invariant()),
    )

    with pytest.raises(bb.BoardBuilderError, match="carrier"):
        bb.build_and_dispatch_board(
            PROJECT,
            1,
            BATCH_ID,
            step_runner=object(),
            dry_run=True,
        )


def test_pencil_refusal_rebuild_raises_before_second_dispatch(
    project_paths: ProjectPaths,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    _write_bible(project_paths)
    _write_batch_scene(
        [_raw_wrapped_shot(props=[{"prop_id": "debt_counter"}], characters=[])],
    )
    builder = _counting_builder_factory(
        first_prompt=_prompt_with_invariant(),
        second_prompt=_prompt_missing_invariant(),
    )
    monkeypatch.setattr(bb, "get_builder", lambda _model, _modality: builder)
    monkeypatch.setattr(bb, "board_fallback_model", lambda _primary: "fallback-model")
    dispatch_calls = {"n": 0}

    def fake_dispatch(_modality, _payload, *, context):
        dispatch_calls["n"] += 1
        return _receipt(False, error="image content policy refusal")

    monkeypatch.setattr(bb, "dispatch", fake_dispatch)

    with pytest.raises(bb.BoardBuilderError, match="carrier"):
        bb.build_and_dispatch_board(
            PROJECT,
            1,
            BATCH_ID,
            step_runner=object(),
            board_model="gpt-image-2",
        )

    assert dispatch_calls["n"] == 1


def test_finish_primary_raises_when_invariant_absent(
    project_paths: ProjectPaths,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    _write_bible(project_paths)
    _write_approved_finish_scene(project_paths)
    monkeypatch.setattr(
        bb,
        "get_builder",
        lambda _model, _modality: _builder_returning(_prompt_missing_invariant()),
    )
    monkeypatch.setattr(
        bb,
        "dispatch",
        lambda *_args, **_kwargs: pytest.fail("dispatch should not run"),
    )

    with pytest.raises(bb.BoardBuilderError, match="carrier"):
        bb.render_board_finish(
            PROJECT, 1, BATCH_ID, step_runner=object(), expected_version=1
        )


def test_finish_refusal_rebuild_raises_before_second_dispatch(
    project_paths: ProjectPaths,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    _write_bible(project_paths)
    _write_approved_finish_scene(project_paths)
    builder = _counting_builder_factory(
        first_prompt=_prompt_with_invariant(),
        second_prompt=_prompt_missing_invariant(),
    )
    monkeypatch.setattr(bb, "get_builder", lambda _model, _modality: builder)
    monkeypatch.setattr(bb, "board_fallback_model", lambda _primary: "fallback-model")
    dispatch_calls = {"n": 0}

    def fake_dispatch(_modality, _payload, *, context):
        dispatch_calls["n"] += 1
        return _receipt(False, error="image content policy refusal")

    monkeypatch.setattr(bb, "dispatch", fake_dispatch)

    with pytest.raises(bb.BoardBuilderError, match="carrier"):
        bb.render_board_finish(
            PROJECT,
            1,
            BATCH_ID,
            step_runner=object(),
            expected_version=1,
            board_model="gpt-image-2",
        )

    assert dispatch_calls["n"] == 1


def test_assert_carrier_facts_present_requires_full_invariant() -> None:
    fact: bb.CarrierFact = {
        "prop_id": "debt_counter",
        "carrier": "JADE",
        "description": "amber readout worn on the left wrist",
    }
    expected = render_prop_invariant(
        fact["prop_id"],
        fact["carrier"],
        fact["description"],
    )

    with pytest.raises(bb.BoardBuilderError, match="carrier"):
        bb._assert_carrier_facts_present(
            "REFERENCE MAPPING: debt_counter belongs to JADE.",
            [fact],
        )

    bb._assert_carrier_facts_present(f"STORY BEATS:\n{expected}", [fact])
    bb._assert_carrier_facts_present("STORY BEATS:\nno carrier props", [])


def test_bible_boundary_pencil_absent_tolerated_invalid_raises_via_carrier_read(
    project_paths: ProjectPaths,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    _disable_worn_prop_auto_inject(monkeypatch)
    _write_batch_scene([_raw_wrapped_shot(props=[], characters=[])])

    result = bb.build_and_dispatch_board(
        PROJECT,
        1,
        BATCH_ID,
        step_runner=object(),
        dry_run=True,
    )
    assert "prompt" in result

    _write_invalid_bible(project_paths)
    _write_jade_refs(project_paths, monkeypatch)
    _write_batch_scene(
        [_raw_wrapped_shot(props=[], characters=[{"char_id": "JADE"}])],
        shared_characters=["JADE"],
    )

    with pytest.raises(bb.BoardBuilderError):
        bb.build_and_dispatch_board(
            PROJECT,
            1,
            BATCH_ID,
            step_runner=object(),
            dry_run=True,
        )


def test_bible_boundary_finish_invalid_raises_before_dispatch_via_carrier_read(
    project_paths: ProjectPaths,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    _write_invalid_bible(project_paths)
    _write_approved_finish_scene(project_paths)
    dispatch_calls = {"n": 0}

    def fake_dispatch(_modality, _payload, *, context):
        dispatch_calls["n"] += 1
        return _receipt(True)

    monkeypatch.setattr(bb, "dispatch", fake_dispatch)

    with pytest.raises(bb.BoardBuilderError):
        bb.render_board_finish(
            PROJECT, 1, BATCH_ID, step_runner=object(), expected_version=1
        )

    assert dispatch_calls["n"] == 0


def test_real_builders_accept_correctly_injected_carrier_prompts(
    project_paths: ProjectPaths,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    _write_bible(project_paths)
    _write_batch_scene(
        [_raw_wrapped_shot(props=[{"prop_id": "debt_counter"}], characters=[])],
    )

    result = bb.build_and_dispatch_board(
        PROJECT,
        1,
        BATCH_ID,
        step_runner=object(),
        dry_run=True,
    )
    assert _expected_invariant() in result["prompt"]

    _write_approved_finish_scene(project_paths)
    prompts: list[str] = []

    def fake_dispatch(_modality, payload, *, context):
        prompts.append(payload["prompt"])
        return _receipt(True)

    monkeypatch.setattr(bb, "dispatch", fake_dispatch)

    finish_result = bb.render_board_finish(
        PROJECT, 1, BATCH_ID, step_runner=object(), expected_version=1
    )

    assert finish_result["success"] is True
    assert len(prompts) == 1
    assert _expected_invariant() in prompts[0]


def test_no_carrier_case_leaves_guard_inert_on_both_entry_points(
    project_paths: ProjectPaths,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    _write_bible(
        project_paths,
        {
            "props": {
                "loose_meter": {
                    "description": "portable handheld meter",
                }
            }
        },
    )
    _write_batch_scene([_raw_wrapped_shot(props=[], characters=[])])

    result = bb.build_and_dispatch_board(
        PROJECT,
        1,
        BATCH_ID,
        step_runner=object(),
        dry_run=True,
    )
    assert "prompt" in result

    _write_approved_finish_scene(project_paths, carrier=False)
    dispatch_calls = {"n": 0}

    def fake_dispatch(_modality, _payload, *, context):
        dispatch_calls["n"] += 1
        return _receipt(True)

    monkeypatch.setattr(bb, "dispatch", fake_dispatch)

    finish_result = bb.render_board_finish(
        PROJECT, 1, BATCH_ID, step_runner=object(), expected_version=1
    )

    assert finish_result["success"] is True
    assert dispatch_calls["n"] == 1
