"""Tests #5-6 from BUILD_SPEC EP001-render-rootcause-fix.

Test #5 — Phase 2 — dispatch_payload._collect_reference_images ships the
canonical character + location ref set (hero + turn views per character),
including for video_i2v.

Test #6 — Phase 3 — video_i2v payload carries `start_frame` as a Path
(not `image` base64).
"""


from __future__ import annotations

from pathlib import Path
from unittest.mock import patch

from recoil.pipeline._lib.dispatch_payload import (
    _collect_reference_images,
    build_dispatch_payload,
)
from recoil.pipeline._lib.plan_loader import (
    CanonicalShot,
    CharacterEntry,
)
from recoil.core.ref_types import RefAsset, ReferenceBundle


def _character_bundle(
    tmp_path: Path,
    subject: str = "jade",
    views: tuple[str, ...] = ("front", "profile", "back"),
) -> ReferenceBundle:
    subject = subject.lower()
    assets = [
        RefAsset(
            path=tmp_path / f"{subject}_hero.png",
            role="identity",
            subject=subject,
            kind="identity",
            is_hero=True,
        )
    ]
    assets.extend(
        RefAsset(
            path=tmp_path / f"{subject}_{view}.png",
            role="identity",
            subject=subject,
            kind="turn",
            view=view,
        )
        for view in views
    )
    return ReferenceBundle(tuple(assets))


def _shot_with_jade(tmp_path: Path) -> CanonicalShot:
    return CanonicalShot(
        shot_id="EP001_SH02",
        scene_index=1,
        sequence_id=None,
        pipeline="video",
        previs_model="gemini-3.1-flash-image-preview",
        video_model="seeddance-2.0",
        location_id="int_sadie_apartment",
        characters=[CharacterEntry(char_id="JADE", wardrobe_phase_id="p1")],
        shot_type="MS",
        duration_s=5.0,
        is_env_only=False,
        has_dialogue=False,
        aspect_ratio="9:16",
        raw={"use_refs": True},
    )


# ── Test #5 — full canonical ref set ships, including for video_i2v ──

def test_dispatch_payload_ships_canonical_refs(tmp_path: Path):
    """SYNTHESIS §8 test #5.

    For an EP001-SH02-shaped shot with one character (Jade, phase p1),
    _collect_reference_images returns hero + front + profile + back
    for both video_i2v and r2v_multi modalities.
    """
    shot = _shot_with_jade(tmp_path)

    fake_char_bundle = _character_bundle(tmp_path, "jade")
    fake_loc_refs = {"hero": tmp_path / "loc_hero.png"}

    with patch(
        "recoil.pipeline._lib.dispatch_payload.resolve_character_bundle",
        return_value=fake_char_bundle,
    ), patch(
        "recoil.pipeline._lib.dispatch_payload.resolve_location_refs",
        return_value=fake_loc_refs,
    ):
        for modality in ("video_i2v", "r2v_multi"):
            refs, manifest = _collect_reference_images(
                shot,
                project="tartarus",
                modality=modality,
                batch_shots=[shot] if modality == "r2v_multi" else None,
            )
            assert len(refs) >= 5, f"{modality}: expected ≥5 refs, got {len(refs)}"
            joined = " ".join(refs)
            for angle in ("hero", "front", "profile", "back"):
                assert angle in joined, (
                    f"{modality}: missing {angle} in refs: {refs}"
                )
            # Location ref included.
            assert "loc_hero" in joined, f"{modality}: missing location ref"
            # Two-pass ordering: heroes first, then location, then angles.
            assert manifest.get("identity_1") == 1
            assert manifest.get("scene_1") == 2


def test_dispatch_payload_caps_at_9_refs(tmp_path: Path):
    """Two-character r2v_multi must cap at 9 total refs."""
    sadie = CanonicalShot(
        shot_id="EP001_SH04",
        scene_index=1,
        sequence_id=None,
        pipeline="video",
        previs_model=None,
        video_model="seeddance-2.0",
        location_id="int_sadie_apartment",
        characters=[
            CharacterEntry(char_id="JADE"),
            CharacterEntry(char_id="SADIE"),
        ],
        shot_type="TWO-SHOT",
        duration_s=5.0,
        is_env_only=False,
        has_dialogue=True,
        aspect_ratio="9:16",
        raw={},
    )

    with patch(
        "recoil.pipeline._lib.dispatch_payload.resolve_character_bundle",
        side_effect=lambda paths, cid, phase=None, max_turn_views=3: _character_bundle(
            tmp_path, cid
        ),
    ), patch(
        "recoil.pipeline._lib.dispatch_payload.resolve_location_refs",
        return_value={"hero": tmp_path / "loc.png"},
    ):
        refs, manifest = _collect_reference_images(
            sadie, project="tartarus", modality="r2v_multi", batch_shots=[sadie],
        )
        assert len(refs) == 9
        # Two-pass ordering: heroes(1,2), location(3), then angles.
        assert manifest["identity_1"] == 1
        assert manifest["identity_2"] == 2
        assert manifest["scene_1"] == 3


def test_dispatch_payload_skips_empty_char_id(tmp_path: Path):
    shot = _shot_with_jade(tmp_path)
    shot.characters.insert(0, CharacterEntry(char_id=""))
    with patch(
        "recoil.pipeline._lib.dispatch_payload.resolve_character_bundle",
        return_value=_character_bundle(tmp_path, "jade", views=()),
    ), patch(
        "recoil.pipeline._lib.dispatch_payload.resolve_location_refs",
        return_value={},
    ):
        refs, manifest = _collect_reference_images(
            shot, project="tartarus", modality="video_i2v",
        )
        # Only the non-empty character id was collected.
        assert len(refs) == 1
        assert manifest == {"identity_1": 1}


# ── Test #6 — video_i2v payload uses `start_frame` as a Path ─────────

def test_video_i2v_uses_start_frame_key(tmp_path: Path):
    """SYNTHESIS §8 test #6.

    A video_i2v dispatch payload built via build_dispatch_payload must:
      - have payload["start_frame"] set and be an instance of Path
      - NOT have an "image" key (legacy base64 path is gone)
      - have payload["start_frame"] point at an existing file
    """
    # Fake start frame on disk.
    start_frame = tmp_path / "previs" / "EP001_SH02_take1.png"
    start_frame.parent.mkdir(parents=True)
    start_frame.write_bytes(b"fake_png_bytes")

    shot = _shot_with_jade(tmp_path)
    shot.raw["use_refs"] = True

    fake_char_bundle = _character_bundle(tmp_path, "jade")

    with patch(
        "recoil.pipeline._lib.dispatch_payload.resolve_character_bundle",
        return_value=fake_char_bundle,
    ), patch(
        "recoil.pipeline._lib.dispatch_payload.resolve_location_refs",
        return_value={"hero": tmp_path / "loc.png"},
    ), patch(
        "recoil.pipeline._lib.dispatch_payload._resolve_start_frame",
        return_value=start_frame,
    ), patch(
        "recoil.pipeline._lib.dispatch_payload.load_project_config",
        return_value={},
    ), patch(
        "recoil.pipeline._lib.dispatch_payload.get_builder",
        return_value=lambda shot_raw, bible, cfg: "fake prompt",
    ):
        payload = build_dispatch_payload(
            shot=shot,
            project="tartarus",
            modality="video_i2v",
            model_override="seeddance-2.0",
            episode="ep_001",
        )

    assert "start_frame" in payload, f"missing start_frame; keys: {sorted(payload.keys())}"
    assert isinstance(payload["start_frame"], str), (
        f"start_frame must be str (json-safe), got {type(payload['start_frame']).__name__}"
    )
    assert Path(payload["start_frame"]).exists()
    assert "image" not in payload, "legacy 'image' key must be gone"
    assert "reference_images" in payload
    assert len(payload["reference_images"]) >= 5


# ── Phase 2 Bug P — ref_manifest threading into r2v_multi builders ────


def test_ref_manifest_threaded_to_builder(tmp_path: Path, monkeypatch):
    """Phase 2 Bug P: build_dispatch_payload must thread the ref_manifest
    captured from _collect_reference_images into the r2v_multi builder's
    kwargs. Without this, builders fall back to literal `@Image{identity_1}`
    placeholders which the audit gate's assertion #3 rejects.
    """
    captured: dict = {}

    def fake_collect(
        shot_,
        project,
        modality,
        batch_shots=None,
        board_gated=False,
        board_ref_path=None,
    ):
        return (["x.png"], {"identity_1": 1, "scene_1": 2})

    def fake_builder(*args, **kwargs):
        captured["args"] = args
        captured["kwargs"] = kwargs
        return "stub prompt"

    monkeypatch.setattr(
        "recoil.pipeline._lib.dispatch_payload._collect_reference_images",
        fake_collect,
    )
    monkeypatch.setattr(
        "recoil.pipeline._lib.dispatch_payload.get_builder",
        lambda *a, **k: fake_builder,
    )
    monkeypatch.setattr(
        "recoil.pipeline._lib.dispatch_payload.load_project_config",
        lambda *a, **k: {},
    )

    shot = _shot_with_jade(tmp_path)
    batch = [shot, shot]
    build_dispatch_payload(
        shot=shot,
        project="tartarus",
        modality="r2v_multi",
        model_override="seeddance-2.0",
        episode="ep_001",
        batch_shots=batch,
    )

    assert captured["kwargs"].get("ref_manifest") == {
        "identity_1": 1, "scene_1": 2,
    }, (
        f"ref_manifest not threaded: kwargs={captured['kwargs']!r}"
    )


# ── R4 Phase 5 Bug I(B) revert — audio DEFAULT-ON for dialogue (§27) ──


def _dialogue_shot(tmp_path: Path, *, has_dialogue: bool) -> CanonicalShot:
    """Minimal shot for I(B) tests — no refs, just toggles has_dialogue."""
    return CanonicalShot(
        shot_id="EP001_VO01",
        scene_index=1,
        sequence_id=None,
        pipeline="video",
        previs_model=None,
        video_model="seeddance-2.0",
        location_id="int_sadie_apartment",
        characters=[CharacterEntry(char_id="JADE")],
        shot_type="MS",
        duration_s=5.0,
        is_env_only=False,
        has_dialogue=has_dialogue,
        aspect_ratio="9:16",
        raw={},
    )


def _ib_patches(tmp_path: Path):
    """Common patch set for Bug I(B) tests — fakes ref resolution and
    builder so we can exercise the audio_flag block end-to-end."""
    return (
        patch(
            "recoil.pipeline._lib.dispatch_payload._collect_reference_images",
            return_value=(["x.png"], {"identity_1": 1}),
        ),
        patch(
            "recoil.pipeline._lib.dispatch_payload._resolve_start_frame",
            return_value=None,
        ),
        patch(
            "recoil.pipeline._lib.dispatch_payload.load_project_config",
            return_value={},
        ),
        patch(
            "recoil.pipeline._lib.dispatch_payload.get_builder",
            return_value=lambda *a, **k: "stub prompt",
        ),
    )


def test_dialogue_default_audio_on(tmp_path: Path):
    """R4 revert (was test_dialogue_disables_audio): has_dialogue=True with no
    override now keeps audio ON. CP-8 lipsync stacks on top, not replaces.
    Per pipeline-learnings §27 + JT 2026-05-16."""
    shot = _dialogue_shot(tmp_path, has_dialogue=True)
    p1, p2, p3, p4 = _ib_patches(tmp_path)
    with p1, p2, p3, p4:
        payload = build_dispatch_payload(
            shot=shot,
            project="tartarus",
            modality="video_i2v",
            model_override="seeddance-2.0",
            episode="ep_001",
        )
    assert payload["generate_audio"] is True


def test_dialogue_audio_explicit_override_off(tmp_path: Path):
    """Explicit generate_audio=False still wins as the manual escape hatch —
    flips the R3 test which checked generate_audio=True overriding the
    now-removed dialogue-off short-circuit."""
    shot = _dialogue_shot(tmp_path, has_dialogue=True)
    p1, p2, p3, p4 = _ib_patches(tmp_path)
    with p1, p2, p3, p4:
        payload = build_dispatch_payload(
            shot=shot,
            project="tartarus",
            modality="video_i2v",
            model_override="seeddance-2.0",
            episode="ep_001",
            generate_audio=False,
        )
    assert payload["generate_audio"] is False


def test_no_dialogue_audio_defaults_on(tmp_path: Path):
    """Unchanged from R3—no dialogue, no override -> audio ON.
    NARRATIVE_DEFAULT_GENERATE_AUDIO=True holds for non-dialogue shots."""
    shot = _dialogue_shot(tmp_path, has_dialogue=False)
    p1, p2, p3, p4 = _ib_patches(tmp_path)
    with p1, p2, p3, p4:
        payload = build_dispatch_payload(
            shot=shot,
            project="tartarus",
            modality="video_i2v",
            model_override="seeddance-2.0",
            episode="ep_001",
        )
    assert payload["generate_audio"] is True


def test_vo_default_audio_on(tmp_path: Path):
    """R4 revert (was test_vo_disables_audio): VO-flavored dialogue shot
    (has_dialogue=True + is_voiceover) also defaults to audio ON. Same §27
    rule—CP-8 stacks on top of the in-pass track."""
    shot = _dialogue_shot(tmp_path, has_dialogue=True)
    shot.raw["is_voiceover"] = True
    p1, p2, p3, p4 = _ib_patches(tmp_path)
    with p1, p2, p3, p4:
        payload = build_dispatch_payload(
            shot=shot,
            project="tartarus",
            modality="video_i2v",
            model_override="seeddance-2.0",
            episode="ep_001",
        )
    assert payload["generate_audio"] is True


# ── Phase 8 Bug Q — single-source editorial timestamps ────────────────


def _shot_with_duration(tmp_path: Path, shot_id: str, dur: float) -> CanonicalShot:
    """Heterogeneous-duration shot for the cumulative-sum test."""
    return CanonicalShot(
        shot_id=shot_id,
        scene_index=1,
        sequence_id=None,
        pipeline="video",
        previs_model=None,
        video_model="seeddance-2.0",
        location_id="int_sadie_apartment",
        characters=[CharacterEntry(char_id="JADE", wardrobe_phase_id="p1")],
        shot_type="MS",
        duration_s=dur,
        is_env_only=False,
        has_dialogue=False,
        aspect_ratio="9:16",
        raw={"routing_data": {"target_editorial_duration_s": dur}},
    )


_UNCLAMPED_PROFILE = {
    # Keep keys the rest of build_dispatch_payload reads happy; the only
    # fields that affect this test are min/max duration.
    "min_duration_seconds": 0,
    "max_duration_seconds": 60,
    "supports_negative_prompt": False,
}


def test_segments_timestamps_match_editorial_durations(tmp_path: Path):
    """Phase 8 Bug Q: heterogeneous durations [3.0, 4.5, 7.5] must produce
    expected_segment_timestamps derived from the cumulative-sum
    [0.0, 3.0, 7.5, 15.0] — NOT from even-division of the total.

    The payload tuple shape is preserved for step_runner /
    r2v_multi_runner: [(0.0, 3.0), (3.0, 7.5), (7.5, 15.0)]. Profile
    duration clamps are mocked off so the test exercises the timestamp
    derivation, not the min/max clamping.
    """
    shots = [
        _shot_with_duration(tmp_path, "EP001_SH01", 3.0),
        _shot_with_duration(tmp_path, "EP001_SH02", 4.5),
        _shot_with_duration(tmp_path, "EP001_SH03", 7.5),
    ]

    with patch(
        "recoil.pipeline._lib.dispatch_payload._collect_reference_images",
        return_value=(["x.png"], {"identity_1": 1}),
    ), patch(
        "recoil.pipeline._lib.dispatch_payload._resolve_start_frame",
        return_value=None,
    ), patch(
        "recoil.pipeline._lib.dispatch_payload.load_project_config",
        return_value={},
    ), patch(
        "recoil.pipeline._lib.dispatch_payload.get_builder",
        return_value=lambda *a, **k: "stub prompt",
    ), patch(
        "recoil.pipeline._lib.dispatch_payload.get_profile",
        return_value=_UNCLAMPED_PROFILE,
    ):
        payload = build_dispatch_payload(
            shot=shots[0],
            project="tartarus",
            modality="r2v_multi",
            model_override="seeddance-2.0",
            episode="ep_001",
            batch_shots=shots,
        )

    assert payload["expected_segment_timestamps"] == [
        (0.0, 3.0), (3.0, 7.5), (7.5, 15.0),
    ], (
        f"timestamps not cumulative-sum derived: "
        f"{payload['expected_segment_timestamps']!r}"
    )


def test_segments_timestamps_threaded_to_builder(tmp_path: Path):
    """Phase 8 Bug Q: build_dispatch_payload must thread the
    start-of-segment list (cumulative_starts without the trailing endpoint)
    into the r2v_multi prompt builder's kwargs — same source as the payload
    timestamps, no second timing model.
    """
    captured: dict = {}

    def fake_builder(*args, **kwargs):
        captured["kwargs"] = kwargs
        return "stub prompt"

    shots = [
        _shot_with_duration(tmp_path, "EP001_SH01", 3.0),
        _shot_with_duration(tmp_path, "EP001_SH02", 4.5),
        _shot_with_duration(tmp_path, "EP001_SH03", 7.5),
    ]

    with patch(
        "recoil.pipeline._lib.dispatch_payload._collect_reference_images",
        return_value=(["x.png"], {"identity_1": 1}),
    ), patch(
        "recoil.pipeline._lib.dispatch_payload._resolve_start_frame",
        return_value=None,
    ), patch(
        "recoil.pipeline._lib.dispatch_payload.load_project_config",
        return_value={},
    ), patch(
        "recoil.pipeline._lib.dispatch_payload.get_builder",
        return_value=fake_builder,
    ), patch(
        "recoil.pipeline._lib.dispatch_payload.get_profile",
        return_value=_UNCLAMPED_PROFILE,
    ):
        build_dispatch_payload(
            shot=shots[0],
            project="tartarus",
            modality="r2v_multi",
            model_override="seeddance-2.0",
            episode="ep_001",
            batch_shots=shots,
        )

    assert captured["kwargs"].get("segment_timestamps") == [0.0, 3.0, 7.5], (
        f"segment_timestamps not threaded to builder: "
        f"kwargs={captured.get('kwargs')!r}"
    )
