"""REC-213 C4 (wardrobe) — a board prop the bible marks `attached_to` a character
PRESENT in the shot is WORN: rendered as PROSE on the carrier, NO standalone ref.
Non-worn props unchanged; carrier-absent KEEPS the ref (never vanish a prop);
malformed bible fails LOUD; the photoreal finish re-emits the worn prose."""
from __future__ import annotations

import json
import sys
from pathlib import Path

import pytest
from PIL import Image

_REPO_ROOT = Path(__file__).resolve().parents[4]
if str(_REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(_REPO_ROOT))

from recoil.core.paths import ProjectPaths  # noqa: E402
from recoil.pipeline._lib import board_builder as bb  # noqa: E402
from recoil.pipeline._lib.board_builder import (  # noqa: E402
    BoardBuilderError,
    _append_prop_refs,
    _character_descriptions,
)

_PROP = "debt_counter"
_DESC = "Amber-glowing wrist device strapped tight to the left wrist, LCD counter display."


def _proj(tmp_path) -> ProjectPaths:
    return ProjectPaths(project_root=tmp_path / "proj")


def _write_registry(paths: ProjectPaths, *slugs: str) -> None:
    reg = paths.assets_dir / "prop" / "prop.json"
    reg.parent.mkdir(parents=True, exist_ok=True)
    reg.write_text(json.dumps({s: {} for s in slugs}))


def _write_bible(paths: ProjectPaths, props: dict, characters: dict | None = None) -> None:
    p = paths.global_bible_path
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(json.dumps({"props": props, "characters": characters or {}}))


def _write_prop_pool_ref(paths: ProjectPaths, slug: str) -> Path:
    d = paths.pool_dir("prop", slug, "identity", look="base")
    d.mkdir(parents=True, exist_ok=True)
    png = d / f"{slug}_identity_v01.png"
    Image.new("RGB", (8, 8)).save(png)
    return png


def _seg(text: str) -> dict:
    return {"prompt": text, "sublocation": "corridor"}


# ── 1: worn + carrier in shot -> suppressed + prose ──────────────────────
def test_worn_prop_carrier_in_shot_suppressed_and_prose(tmp_path):
    paths = _proj(tmp_path)
    _write_registry(paths, _PROP)
    _write_bible(paths, {_PROP: {"attached_to": "JADE", "description": _DESC}},
                 {"JADE": {"visual_description": "Lean salvager in a worn flight suit."}})
    _write_prop_pool_ref(paths, _PROP)  # a ref EXISTS — but worn must suppress it
    refs: list[str] = []
    ref_layout = {"identity_refs": {"JADE": (1, 2)}}
    _append_prop_refs(paths, [_seg("she checks her debt counter")], refs, ref_layout)

    assert not any(_PROP in r for r in refs), refs                 # NO standalone ref
    assert "prop_refs" not in ref_layout                           # not in prop_refs
    assert ref_layout["worn_props"]["JADE"] == [_PROP]             # recorded under the carrier
    char_descs = _character_descriptions(paths, {"JADE": (1, 2)}, ref_layout["worn_props"])
    assert _DESC in char_descs["JADE"] and "debt counter" in char_descs["JADE"]


# ── 2: non-worn prop -> standalone ref unchanged ─────────────────────────
def test_non_worn_prop_unchanged(tmp_path):
    paths = _proj(tmp_path)
    _write_registry(paths, _PROP)
    _write_bible(paths, {_PROP: {"attached_to": None, "description": _DESC}})
    png = _write_prop_pool_ref(paths, _PROP)
    refs: list[str] = []
    ref_layout = {"identity_refs": {"JADE": (1, 2)}}
    _append_prop_refs(paths, [_seg("the debt counter sits on a crate")], refs, ref_layout)

    assert str(png) in refs                                        # standalone ref kept
    assert ref_layout["prop_refs"][_PROP] == 1
    assert "worn_props" not in ref_layout


# ── 3: worn but carrier ABSENT -> KEEPS ref (never vanish) ───────────────
def test_worn_prop_carrier_absent_KEEPS_ref(tmp_path):
    paths = _proj(tmp_path)
    _write_registry(paths, _PROP)
    _write_bible(paths, {_PROP: {"attached_to": "VAREK", "description": _DESC}})
    png = _write_prop_pool_ref(paths, _PROP)
    refs: list[str] = []
    ref_layout = {"identity_refs": {"JADE": (1, 2)}}  # VAREK NOT in the shot
    _append_prop_refs(paths, [_seg("the debt counter glows")], refs, ref_layout)

    assert str(png) in refs                                        # FAIL-SAFE: ref kept
    assert ref_layout["prop_refs"][_PROP] == 1
    assert "worn_props" not in ref_layout


# ── 4: worn prose appended AFTER _first_sentence (not clipped) ───────────
def test_worn_prose_not_clipped_by_first_sentence(tmp_path):
    paths = _proj(tmp_path)
    _write_bible(paths, {_PROP: {"attached_to": "JADE", "description": _DESC}},
                 {"JADE": {"visual_description": "First sentence. Second sentence. Third."}})
    char_descs = _character_descriptions(paths, {"JADE": (1, 2)}, {"JADE": [_PROP]})
    assert char_descs["JADE"].startswith("First sentence.")        # base = first sentence only
    assert _DESC in char_descs["JADE"]                             # worn prose still present (appended after)


# ── 5: bible load is LOUD on malformed; absent is fine ───────────────────
def test_bible_load_failure_is_loud(tmp_path):
    paths = _proj(tmp_path)
    _write_registry(paths, _PROP)
    _write_prop_pool_ref(paths, _PROP)
    # 5a malformed JSON -> BoardBuilderError
    paths.global_bible_path.parent.mkdir(parents=True, exist_ok=True)
    paths.global_bible_path.write_text("{ not json")
    with pytest.raises(BoardBuilderError):
        _append_prop_refs(paths, [_seg("debt counter")], [], {"identity_refs": {"JADE": (1, 2)}})
    # 5b shape-malformed (props is a list) -> BoardBuilderError
    paths.global_bible_path.write_text(json.dumps({"props": ["not", "a", "dict"]}))
    with pytest.raises(BoardBuilderError):
        _append_prop_refs(paths, [_seg("debt counter")], [], {"identity_refs": {"JADE": (1, 2)}})
    # 5b2 PRESENT but explicit null props -> BoardBuilderError (NOT fail-open to standalone ref)
    paths.global_bible_path.write_text(json.dumps({"props": None}))
    with pytest.raises(BoardBuilderError):
        _append_prop_refs(paths, [_seg("debt counter")], [], {"identity_refs": {"JADE": (1, 2)}})
    # 5c ABSENT bible -> no raise, standalone ref kept (current behavior)
    paths.global_bible_path.unlink()
    refs: list[str] = []
    _append_prop_refs(paths, [_seg("debt counter")], refs, {"identity_refs": {"JADE": (1, 2)}})
    assert any(_PROP in r for r in refs)


# ── 6: photoreal finish re-emits worn prose END-TO-END via REAL render_board_finish ──
def test_finish_payload_carries_worn_prose(tmp_path, monkeypatch):
    """Drive the REAL render_board_finish (board_builder.py:595) via fake dispatch and
    assert the worn prose (_DESC) reaches the CAPTURED finish payload prompt. This exercises
    the live integration: _finish_refs_from_sidecar restore -> _character_descriptions (:678)
    -> finish prompt builder (:681) -> payload (:705) -> dispatch (:735). A regression that
    drops worn_props anywhere INSIDE render_board_finish would be caught here (the prior
    prompt-builder-direct version was false-green)."""
    from recoil.pipeline.core.registry import MODALITY_STORYBOARD, RunResult
    from recoil.pipeline.core.receipts import GenerationReceipt

    paths = ProjectPaths(project_root=tmp_path / "fixture_project")
    paths.project_root.mkdir(parents=True)
    monkeypatch.setattr(ProjectPaths, "for_project",
                        classmethod(lambda cls, project=None: paths))
    # bible: JADE desc + debt_counter WORN (attached_to=JADE) with the distinctive prose
    paths.global_bible_path.parent.mkdir(parents=True, exist_ok=True)
    paths.global_bible_path.write_text(json.dumps({
        "characters": {"JADE": {"visual_description": "Lean salvager."}},
        "props": {_PROP: {"attached_to": "JADE", "description": _DESC}},
    }))
    # the approved pencil artifact file must exist on disk
    pencil_rel = "prep/ep_001/storyboards/BATCH_004_v01_pencil.png"
    pencil_path = paths.project_root / pencil_rel
    pencil_path.parent.mkdir(parents=True, exist_ok=True)
    Image.new("RGB", (10, 10)).save(pencil_path)
    # the BOARD sidecar that suppressed the standalone debt_counter ref and carried worn_props
    # (2 identity refs/char so the finish offset math lands JADE in identity_refs):
    board_sidecar = {"identity_refs": ["assets/char/jade/base/jade-front.png",
                                       "assets/char/jade/base/jade-profile.png"],
                     "worn_props": {"JADE": [_PROP]}}

    class _Beat:
        board = None
        beat_metadata = {}

    class _Prim:
        timing_segments = [{"shot_id": "EP001_SH10", "start_s": 0, "end_s": 1,
                            "duration_s": 1, "intent": "Jade.", "sublocation": "pod_platform"}]

    # monkeypatch render_board_finish's pre-flight gates to reach char_descs->prompt->dispatch:
    monkeypatch.setattr(bb, "load_scene_active", lambda *a, **k: object())
    monkeypatch.setattr(bb, "_single_r2v_multi_beat", lambda scene: _Beat())
    monkeypatch.setattr(bb, "resolve_board_for_spend",
                        lambda project, episode, b: (True, {"artifact": pencil_rel,
                                                            "source_sha256": "x",
                                                            "fingerprint_version": 1}))
    monkeypatch.setattr(bb, "_load_generation_sidecar", lambda p: board_sidecar)
    monkeypatch.setattr(bb, "_board_version_from_artifact_or_sidecar", lambda art, sc: 1)
    monkeypatch.setattr(bb, "_primitive_from_beat", lambda project, batch_id, b: _Prim())
    monkeypatch.setattr(bb, "compute_source_sha256", lambda segs, version=1: "x")  # match -> not stale
    monkeypatch.setattr(bb, "_derive_board_settings",
                        lambda paths, prim: [{"shot_id": "EP001_SH10", "intent": "Jade.",
                                              "setting": "A.", "sublocation": "pod_platform",
                                              "start_s": 0, "end_s": 1, "duration_s": 1}])
    monkeypatch.setattr(bb, "select_board_model", lambda **k: "gpt-image-2")  # has a storyboard_finish builder
    monkeypatch.setattr(bb, "_shared_characters", lambda b, prim: ["JADE"])
    monkeypatch.setattr(bb, "_resolve_sidecar_ref", lambda p, ref: Path("/tmp") / Path(ref).name)
    monkeypatch.setattr(bb, "_scene_context_for_batch", lambda paths, episode, b: "")

    captured: dict = {}

    def fake_dispatch(modality, payload, *, context):
        captured["payload"] = payload
        return GenerationReceipt(
            receipt_id="r", modality=MODALITY_STORYBOARD, caller_id="board_builder",
            project="fixture_project", episode=1, shot_id="EP001_CONT_004",
            timestamp_utc="2026-06-11T00:00:00Z",
            run_result=RunResult(id="r", modality=MODALITY_STORYBOARD,
                                 output_path="/tmp/b.png", metadata={}, success=True, error=None))

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

    bb.render_board_finish(
        "fixture_project",
        1,
        "EP001_CONT_004",
        step_runner=object(),
        expected_version=1,
    )

    payload = captured["payload"]
    # (1) the worn prose reached the FINISH prompt through the REAL render_board_finish:
    assert _DESC.lower() in payload["prompt"].lower(), payload["prompt"][:800]
    # (2) no debt_counter standalone ref in the finish reference set (pencil composition + identity only):
    assert not any("debt_counter" in str(r) for r in payload["reference_images"]), payload["reference_images"]


# ── 6b: invalid-UTF8 bible fails LOUD (UnicodeDecodeError wrapped) ────────
def test_bible_invalid_utf8_is_loud(tmp_path):
    paths = _proj(tmp_path)
    _write_registry(paths, _PROP)
    _write_prop_pool_ref(paths, _PROP)
    paths.global_bible_path.parent.mkdir(parents=True, exist_ok=True)
    paths.global_bible_path.write_bytes(b"\xff\xfe not utf8 \x80\x81")  # invalid UTF-8
    with pytest.raises(BoardBuilderError):
        _append_prop_refs(paths, [_seg("debt counter")], [], {"identity_refs": {"JADE": (1, 2)}})


# ── 7: prop-as-subject close-up SHOULD keep ref — DEFERRED (xfail) ───────
@pytest.mark.xfail(reason="deferred: prop-as-subject override (close-up OF the prop) — future brick", strict=False)
def test_prop_subject_closeup_keeps_ref(tmp_path):
    paths = _proj(tmp_path)
    _write_registry(paths, _PROP)
    _write_bible(paths, {_PROP: {"attached_to": "JADE", "description": _DESC}})
    png = _write_prop_pool_ref(paths, _PROP)
    refs: list[str] = []
    # a close-up OF the debt_counter (prop is the subject) — should keep the ref
    # even though it's worn. C4 always suppresses; this documents the gap.
    _append_prop_refs(paths, [_seg("extreme close-up of the debt counter")], refs,
                      {"identity_refs": {"JADE": (1, 2)}, "prop_is_subject": True})
    assert str(png) in refs   # FAILS today (C4 suppresses) — xfail marks the deferred edge


# ── ACCEPTANCE: build_and_dispatch_board live-path (no spend) ────────────
def test_acceptance_board_payload_suppresses_ref_and_carries_prose(tmp_path, monkeypatch):
    """The original failing case, end-to-end (no spend): a corridor board with JADE
    + debt_counter in frame → the dispatched payload has NO debt_counter standalone
    ref, and the worn prose reaches the actual board prompt."""
    from recoil.core.ref_types import RefAsset, ReferenceBundle
    from recoil.pipeline.core.registry import MODALITY_STORYBOARD, RunResult
    from recoil.pipeline.core.take import Beat, Scene
    from recoil.pipeline.core.persistence import scene_path, save_scene

    paths = ProjectPaths(project_root=tmp_path / "fixture_project")
    paths.project_root.mkdir(parents=True)
    monkeypatch.setattr(ProjectPaths, "for_project",
                        classmethod(lambda cls, project=None: paths))
    shots = [{"shot_id": "EP001_SH10", "scene_index": 1, "duration_s": 1.0,
              "intent": "Jade checks her debt counter.",
              "asset_data": {"characters": [{"char_id": "JADE", "wardrobe_phase_id": "p1"}],
                             "location_id": "int_lab"},
              "spatial_data": {"sublocation": "pod_platform"}}]
    beat = Beat(beat_id="BATCH_004", beat_metadata={"scene_id": "BATCH_004",
                "modality": "r2v_multi", "shot": shots[0], "batch_shots": shots,
                "batch_summary": {"shared_characters": [], "shared_location_id": "int_lab"}}, board=None)
    save_scene(Scene(scene_id="BATCH_004", beats=[beat],
                     scene_metadata={"episode": "ep_001", "project": "fixture_project"}),
               scene_path("fixture_project", "ep_001", "BATCH_004"))
    # bible: JADE desc + debt_counter WORN (attached_to=JADE) with a description for the prose
    paths.global_bible_path.parent.mkdir(parents=True, exist_ok=True)
    paths.global_bible_path.write_text(json.dumps({
        "characters": {"JADE": {"visual_description": "Lean salvager."}},
        "props": {_PROP: {"attached_to": "JADE", "description": _DESC}},
    }))
    _write_registry(paths, _PROP)
    _write_prop_pool_ref(paths, _PROP)  # a ref EXISTS — worn must suppress it
    char_dir = paths.project_root / "assets" / "char" / "jade" / "base"
    char_dir.mkdir(parents=True, exist_ok=True)
    front = char_dir / "jade_front_base_v01.png"
    profile = char_dir / "jade_profile_base_v01.png"
    Image.new("RGB", (8, 8)).save(front)
    Image.new("RGB", (8, 8)).save(profile)
    subloc = paths.asset_look_dir("loc", "int_lab", "base") / "sublocations" / "sublocation_pod_platform_v01.png"
    subloc.parent.mkdir(parents=True, exist_ok=True)
    Image.new("RGB", (8, 8)).save(subloc)

    monkeypatch.setattr(bb, "derive_settings",
                        lambda segments, **_k: [dict(s, setting="Jade checks her debt counter") for s in segments])
    monkeypatch.setattr(bb, "resolve_character_bundle",
                        lambda paths, char_id, phase=None: 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, "sublocation_ref", lambda paths, lid, name: subloc)
    monkeypatch.setattr(bb, "validate_ref_file", lambda path: None)

    captured = {}

    def fake_dispatch(modality, payload, *, context):
        from recoil.pipeline.core.receipts import GenerationReceipt
        captured["payload"] = payload
        sd = Path(payload["save_dir"])
        sd.mkdir(parents=True, exist_ok=True)
        png = sd / f"{payload['filename_stem']}.png"
        Image.new("RGB", (20, 20)).save(png)
        Path(f"{png}.json").write_text(json.dumps(payload["sidecar_extra"]))
        return GenerationReceipt(receipt_id="r", modality=MODALITY_STORYBOARD, caller_id="board_builder",
            project="fixture_project", episode=1, shot_id="EP001_CONT_004", timestamp_utc="2026-06-11T00:00:00Z",
            run_result=RunResult(id="r", modality=MODALITY_STORYBOARD, output_path="/tmp/b.png", metadata={}, success=True, error=None))

    monkeypatch.setattr(bb, "dispatch", fake_dispatch)
    bb.build_and_dispatch_board("fixture_project", 1, "EP001_CONT_004", step_runner=object())

    payload = captured["payload"]
    # (1) NO debt_counter standalone ref in the dispatched reference set:
    assert not any(_PROP in str(r) or "debt_counter" in str(r) for r in payload["reference_images"]), payload["reference_images"]
    # (2) the worn prose from the BIBLE reached the actual board PROMPT — assert the DISTINCTIVE bible
    #     description text (_DESC), NOT the generic "debt counter" phrase that the fixture injects into the
    #     intent/setting (asserting "debt counter" would false-green even if the bible prose never threaded
    #     through character_descriptions):
    assert _DESC.lower() in payload["prompt"].lower(), payload["prompt"][:800]
