"""REC-235 Phase 3 — pure compositing renderer acceptance tests.

``board_builder.render_composition_view`` composites a ``CompositionManifest`` into a
strip/grid PNG by resolving each member atom-version to its EXISTING artifact (Phase 0
``resolve_atom_version``) and pasting the panels into the ``GRID_LAYOUTS`` geometry —
**ZERO generation, ZERO model/dispatch call**. The load-bearing milestone: reorder the
manifest + re-render → a recomposited strip in the new order, with no generation.

Grounds against the REAL flow — writes actual distinct-color panel PNGs at the artifact
paths a persisted atom-version resolves to (mirroring the ``_take``/``save_scene`` idiom of
``test_readmodel_atom``), so ``resolve_atom_version`` returns those paths and the composited
pixels are non-vacuously the real panels.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest
from PIL import Image

from recoil.api.schemas.composition import CompositionManifest
from recoil.pipeline._lib import board_builder as bb
from recoil.pipeline.core.persistence import save_scene, scene_path
from recoil.pipeline.core.receipts import GenerationReceipt
from recoil.pipeline.core.registry import RunResult
from recoil.pipeline.core.take import Beat, Scene, Take
from recoil.pipeline.core.workflow import Workflow, WorkflowStep
from recoil.workspace.tests.test_readmodel_board import _make_project

_RED = (255, 0, 0)
_BLUE = (0, 0, 255)


def _receipt(output_path: str) -> GenerationReceipt:
    rr = RunResult(id="rr", modality="video_i2v", output_path=output_path, success=True)
    return GenerationReceipt(
        receipt_id="rcpt",
        modality="video_i2v",
        caller_id="test",
        project=None,
        episode=None,
        shot_id=None,
        timestamp_utc="2026-06-23T00:00:00Z",
        run_result=rr,
    )


def _take(beat_id: str, idx: int, output_path: str | None) -> Take:
    """A Take whose terminal workflow step carries a receipt output (or none)."""
    step = WorkflowStep(step_id="video", modality="video_i2v", payload={})
    if output_path is not None:
        step.receipt = _receipt(output_path)
        step.status = "succeeded"
    wf = Workflow(workflow_id=f"{beat_id}_wf{idx}", steps=[step])
    return Take(
        take_id=f"{beat_id}_take_{idx}",
        take_index=idx,
        workflow=wf,
        status="succeeded" if output_path is not None else "pending",
    )


def _beat(beat_id: str, output_path: str | None) -> Beat:
    take = _take(beat_id, 0, output_path)
    return Beat(
        beat_id=beat_id,
        takes=[take],
        primary_take_id=take.take_id,
        beat_metadata={"shot": {}},
    )


def _write_panel(project_root: Path, rel: str, color: tuple[int, int, int]) -> None:
    """Write a real small solid-color PNG at the project-relative artifact path."""
    abs_path = project_root / rel
    abs_path.parent.mkdir(parents=True, exist_ok=True)
    Image.new("RGB", (24, 24), color).save(abs_path)


def _persist_panels(tmp_path, monkeypatch):
    """Persist a Scene with two real-artifact beats (RED @SH02, BLUE @SH03) + one
    artifact-less beat (@SH04, a take with no receipt → artifact resolves to ``None``).

    Returns ``(project, project_root, urn_red, urn_blue, urn_missing)``.
    """
    project, project_root, _scenes, _storyboards = _make_project(tmp_path, monkeypatch)
    _write_panel(project_root, "renders/ep_001/EP001_SH02/t0.png", _RED)
    _write_panel(project_root, "renders/ep_001/EP001_SH03/t0.png", _BLUE)
    scene = Scene(
        scene_id="BATCH_001",
        beats=[
            _beat("EP001_SH02", "renders/ep_001/EP001_SH02/t0.png"),
            _beat("EP001_SH03", "renders/ep_001/EP001_SH03/t0.png"),
            _beat("EP001_SH04", None),  # take with no receipt → artifact resolves to None
        ],
    )
    save_scene(scene, scene_path(project, 1, "BATCH_001"))
    return (
        project,
        project_root,
        "atom:EP001/beat/EP001_SH02@t0",
        "atom:EP001/beat/EP001_SH03@t0",
        "atom:EP001/beat/EP001_SH04@t0",
    )


def _cell_geometry(slots: int) -> tuple[int, int, int, int]:
    """(width, height, cell_w, cell_h) for a GRID_LAYOUTS slot count — derived, not magic."""
    size_override, cols, rows = bb.GRID_LAYOUTS[slots]
    width, height = (int(x) for x in size_override.split("x"))
    return width, height, width // cols, height // rows


def test_render_composites_existing_artifacts_into_grid(tmp_path, monkeypatch):
    project, project_root, urn_red, urn_blue, _missing = _persist_panels(tmp_path, monkeypatch)
    manifest = CompositionManifest(kind="CONT", members=[urn_red, urn_blue], layout={"slots": 4})

    rel = bb.render_composition_view(
        manifest, project, out_path="prep/ep_001/storyboards/comp.png"
    )

    out = project_root / rel
    assert out.exists()
    img = Image.open(out)
    img.load()
    width, height, cell_w, cell_h = _cell_geometry(4)
    # Size matches GRID_LAYOUTS[4] size_override ("1728x3072").
    assert img.size == (width, height)
    rgb = img.convert("RGB")
    # cell-0 (top-left) is the FIRST member (RED); cell-1 (top-right) the SECOND (BLUE).
    assert rgb.getpixel((cell_w // 2, cell_h // 2)) == _RED
    assert rgb.getpixel((cell_w + cell_w // 2, cell_h // 2)) == _BLUE


def test_default_layout_small_board_renders_on_grid(tmp_path, monkeypatch):
    """Fix (review LOW): a 2-member board with the schema-DEFAULT empty layout must render —
    slots default to the production 4/6 rule, not len(members) (which would be off-grid {2})."""
    project, project_root, urn_red, urn_blue, _missing = _persist_panels(tmp_path, monkeypatch)
    # No "slots" in layout → default path. Pre-fix this raised 'cannot composite off-grid' (slots=2).
    manifest = CompositionManifest(kind="CONT", members=[urn_red, urn_blue])
    rel = bb.render_composition_view(
        manifest, project, out_path="prep/ep_001/storyboards/comp_default.png"
    )
    img = Image.open(project_root / rel).convert("RGB")
    img.load()
    width, height, cell_w, cell_h = _cell_geometry(4)  # 2 members → default slots=4
    assert img.size == (width, height)
    assert img.getpixel((cell_w // 2, cell_h // 2)) == _RED
    assert img.getpixel((cell_w + cell_w // 2, cell_h // 2)) == _BLUE


def test_reorder_re_render_fires_zero_generation(tmp_path, monkeypatch):
    """The milestone: reorder members + re-render → new-order strip, ZERO generation."""
    project, project_root, urn_red, urn_blue, _missing = _persist_panels(tmp_path, monkeypatch)

    def _boom(*args, **kwargs):
        raise AssertionError("render_composition_view must NEVER call a generation entrypoint")

    # Any generation/model/dispatch entrypoint the renderer's module exposes → raise.
    # render succeeding while these are poisoned IS the zero-generation proof.
    monkeypatch.setattr(bb, "build_and_dispatch_board", _boom)
    monkeypatch.setattr(bb, "render_board_finish", _boom)
    monkeypatch.setattr(bb, "dispatch", _boom)

    # Reversed order: BLUE is now first → cell-0 must become BLUE, cell-1 RED.
    reordered = CompositionManifest(
        kind="CONT", members=[urn_blue, urn_red], layout={"slots": 4}
    )
    rel = bb.render_composition_view(
        reordered, project, out_path="prep/ep_001/storyboards/comp_rev.png"
    )

    img = Image.open(project_root / rel).convert("RGB")
    img.load()
    _w, _h, cell_w, cell_h = _cell_geometry(4)
    assert img.getpixel((cell_w // 2, cell_h // 2)) == _BLUE  # reversed first member → cell-0
    assert img.getpixel((cell_w + cell_w // 2, cell_h // 2)) == _RED  # second member → cell-1


def test_missing_member_artifact_raises_naming_urn(tmp_path, monkeypatch):
    project, _root, urn_red, _blue, urn_missing = _persist_panels(tmp_path, monkeypatch)
    manifest = CompositionManifest(
        kind="CONT", members=[urn_red, urn_missing], layout={"slots": 4}
    )

    with pytest.raises(bb.BoardBuilderError, match=re.escape(urn_missing)):
        bb.render_composition_view(manifest, project, out_path="prep/ep_001/storyboards/x.png")


def test_slots_not_in_grid_layouts_raises(tmp_path, monkeypatch):
    project, _root, urn_red, urn_blue, _missing = _persist_panels(tmp_path, monkeypatch)
    # slots=3 is NOT a GRID_LAYOUTS key (1/4/6) — the renderer is the layout-vocab boundary;
    # the schema accepted the dict.
    assert 3 not in bb.GRID_LAYOUTS
    manifest = CompositionManifest(
        kind="CONT", members=[urn_red, urn_blue], layout={"slots": 3}
    )

    with pytest.raises(bb.BoardBuilderError):
        bb.render_composition_view(manifest, project, out_path="prep/ep_001/storyboards/x.png")


def test_more_members_than_slots_raises(tmp_path, monkeypatch):
    project, _root, *_urns = _persist_panels(tmp_path, monkeypatch)
    # 5 members but slots=4 → off-grid; rejected BEFORE any artifact resolution.
    members = [f"atom:EP001/beat/EP001_SH{n:02d}@t0" for n in range(5)]
    manifest = CompositionManifest(kind="CONT", members=members, layout={"slots": 4})

    # `match=` pins the exception to the members>slots guard specifically. Without it the test
    # would pass VACUOUSLY: the unpersisted SH00/SH01 members also raise BoardBuilderError (missing
    # artifact), so a bare `raises` passes even if the members<=slots guard is deleted. With `match=`
    # this test FAILS unless the guard fires (verified by counterexample: removing the guard makes the
    # missing-artifact message surface instead, which does not match).
    with pytest.raises(bb.BoardBuilderError, match=r"members but layout slots="):
        bb.render_composition_view(manifest, project, out_path="prep/ep_001/storyboards/x.png")


def test_out_path_escape_raises_valid_writes_relative(tmp_path, monkeypatch):
    project, project_root, urn_red, urn_blue, _missing = _persist_panels(tmp_path, monkeypatch)
    manifest = CompositionManifest(
        kind="CONT", members=[urn_red, urn_blue], layout={"slots": 4}
    )

    # (a) absolute path OUTSIDE the project root → reject.
    outside = tmp_path / "outside.png"
    with pytest.raises(bb.BoardBuilderError):
        bb.render_composition_view(manifest, project, out_path=str(outside))

    # (b) a relative '..' escape → reject.
    with pytest.raises(bb.BoardBuilderError):
        bb.render_composition_view(manifest, project, out_path="prep/../../escape.png")

    # (c) a valid project-relative out_path → writes the PNG, returns POSIX project-relative.
    rel = bb.render_composition_view(
        manifest, project, out_path="prep/ep_001/storyboards/ok.png"
    )
    assert rel == "prep/ep_001/storyboards/ok.png"
    assert not Path(rel).is_absolute()
    assert (project_root / rel).exists()
