"""Phase 2 — Bug U tests for prompt_engine r2v_multi.

Bug U: prompts containing character proper nouns 5+ times trip fal's
content-policy filter. Option C — zero proper nouns + diegetic role
descriptors. Identity is bound via the @ImageN ref token.

Bug P side-check: with `ref_manifest=None`, the builder must NOT emit
literal `@Image{identity_N}` placeholders — fallback is the literal
`@Image1` token.
"""

from __future__ import annotations

from recoil.pipeline._lib.prompt_engine import (
    _build_camera_line_plan,
    build_seeddance_r2v_prompt_multi,
)


def _make_jade_shots(n: int) -> list[dict]:
    """Build n minimal r2v_multi shot dicts with Jade (protagonist) acting.

    The shots use the canonical r2v_multi shot dict shape consumed by
    build_seeddance_r2v_prompt_multi: prompt_data + asset_data +
    routing_data, plus cinematography (empty).
    """
    shots = []
    for i in range(n):
        shots.append({
            "shot_id": f"EP001_SH0{i + 1}",
            "prompt_data": {
                "shot_type": "MS",
                "focal_length": "50mm",
                "camera_movement": "static",
                "prompt_skeleton": {
                    "action_line": f"Jade walks toward the camera ({i + 1}).",
                    "emotion_line": "Jade is determined.",
                },
            },
            "asset_data": {
                "characters": [
                    {"char_id": "JADE", "role": "protagonist",
                     "wardrobe_phase_id": "p1"},
                ],
                "location_id": "int_corridor",
            },
            "routing_data": {"target_editorial_duration_s": 3},
            "cinematography": {},
        })
    return shots


def _bible() -> dict:
    return {
        "characters": {
            "JADE": {
                "display_name": "Jade",
                "role": "protagonist",
                "visual_description": "young woman in dark jacket",
            },
        },
        "locations": {
            "int_corridor": {
                "display_name": "corridor",
                "spatial_description": "narrow concrete corridor",
            },
        },
        "global_defaults": {},
    }


def _project_config() -> dict:
    return {}


# ── Bug U — zero proper nouns ─────────────────────────────────────────


def test_r2v_multi_zero_proper_nouns():
    """Multi-shot prompt for a Jade-bound batch contains zero `Jade`s and
    zero `@Image{` literal placeholders; `@Image1` IS present (anchor)."""
    shots = _make_jade_shots(4)
    prompt = build_seeddance_r2v_prompt_multi(
        shots=shots,
        bible=_bible(),
        project_config=_project_config(),
        episode=1,
        ref_manifest={"identity_1": 1, "scene_1": 2},
    )
    assert prompt.count("Jade") == 0, (
        f"Jade appears {prompt.count('Jade')} times in prompt:\n{prompt}"
    )
    assert "@Image{" not in prompt, (
        f"placeholder `@Image{{...}}` literal in prompt:\n{prompt}"
    )
    assert "@Image1" in prompt, (
        f"`@Image1` anchor missing from prompt:\n{prompt}"
    )


def test_r2v_multi_anchor_no_name():
    """Anchor line reads `@Image1 is the protagonist character ...` —
    never `@Image1 is Jade — character.`
    Phase 2: complete manifest with scene_1 — assert location token is @Image2."""
    prompt = build_seeddance_r2v_prompt_multi(
        shots=_make_jade_shots(2),
        bible=_bible(),
        project_config=_project_config(),
        episode=1,
        ref_manifest={"identity_1": 1, "scene_1": 2},
    )
    assert "@Image1 is the protagonist character" in prompt, (
        f"anchor line missing in prompt:\n{prompt}"
    )
    assert "is Jade — character" not in prompt
    assert "is Jade" not in prompt
    # Phase 2: location declared at @Image2 (scene_1 -> index 2) via token only
    assert "@Image2 is the location reference." in prompt, (
        f"location token @Image2 not in prompt:\n{prompt}"
    )


def test_r2v_multi_fallback_no_manifest():
    """ref_manifest=None must use literal `@Image1`, never `@Image{identity_1}`."""
    prompt = build_seeddance_r2v_prompt_multi(
        shots=_make_jade_shots(2),
        bible=_bible(),
        project_config=_project_config(),
        episode=1,
        ref_manifest=None,
    )
    assert "@Image{" not in prompt, (
        f"placeholder `@Image{{...}}` literal in prompt:\n{prompt}"
    )
    assert "@Image1" in prompt


def test_r2v_multi_location_names_preserved():
    """Current contract: the location is declared via its @ImageN token only
    ('@Image2 is the location reference.') — the display_name is not emitted in
    the declaration. Only character proper nouns are stripped."""
    prompt = build_seeddance_r2v_prompt_multi(
        shots=_make_jade_shots(2),
        bible=_bible(),
        project_config=_project_config(),
        episode=1,
        ref_manifest={"identity_1": 1, "scene_1": 2},
    )
    assert "@Image2 is the location reference." in prompt, (
        f"location not declared via @Image2 token:\n{prompt}"
    )


def test_r2v_multi_cut_to_uses_image_token():
    """`Cut to ...` transitions use the @ImageN token, never the character
    display_name."""
    prompt = build_seeddance_r2v_prompt_multi(
        shots=_make_jade_shots(3),
        bible=_bible(),
        project_config=_project_config(),
        episode=1,
        ref_manifest={"identity_1": 1, "scene_1": 2},
    )
    # At least one "Cut to" transition for shots 2 and 3.
    assert "Cut to @Image" in prompt, (
        f"Cut-to transition missing @ImageN token:\n{prompt}"
    )
    assert "Cut to Jade" not in prompt


# ── Bug S — format_mode default flipped to shot_label ─────────────────


def test_format_mode_default_shot_label():
    """Bug S side-benefit lock — Phase 2 flips the default from "timeline"
    to "shot_label" so Phase 8 (Bug Q) has the same timestamp model on
    both sides. Phase 8 verifies-then-skips if this is already done."""
    import inspect
    sig = inspect.signature(build_seeddance_r2v_prompt_multi)
    assert sig.parameters["format_mode"].default == "shot_label", (
        f"format_mode default is {sig.parameters['format_mode'].default!r}; "
        f"expected 'shot_label' (Bug S)"
    )


# ── Phase 8 Bug Q — prompt rides the SAME timestamps as the payload ──


def test_prompt_segments_match_payload_timestamps():
    """Phase 8 Bug Q: when segment_timestamps is supplied (the same list
    build_dispatch_payload derives for expected_segment_timestamps), the
    timeline-format prompt's editorial cuts contain the literal start times
    [0s], [3s]/[3.0s], [7s]/[7.5s] — NOT evenly-divided fractions.
    """
    shots = _make_jade_shots(3)
    prompt = build_seeddance_r2v_prompt_multi(
        shots=shots,
        bible=_bible(),
        project_config=_project_config(),
        episode=1,
        ref_manifest={"identity_1": 1, "scene_1": 2},
        segment_timestamps=[0.0, 3.0, 7.5],
        format_mode="timeline",
    )
    # First segment starts at 0.
    assert ("[0s]" in prompt) or ("[0.0s]" in prompt), (
        f"Shot 1 timestamp marker missing:\n{prompt}"
    )
    # Second segment starts at 3.0.
    assert ("[3s]" in prompt) or ("[3.0s]" in prompt), (
        f"Shot 2 timestamp marker missing:\n{prompt}"
    )
    # Third segment starts at 7.5.
    assert ("[7s]" in prompt) or ("[7.5s]" in prompt), (
        f"Shot 3 timestamp marker missing:\n{prompt}"
    )


# ── Phase 9 Bug T — focal length opt-in via PROMPT_BIBLE flag ─────────


def _camera_prompt_data() -> dict:
    """Minimal prompt_data block for _build_camera_line_plan."""
    return {
        "shot_type": "MS",
        "focal_length": "35mm",
        "camera_movement": "static",
    }


def test_focal_length_omitted_by_default(monkeypatch):
    """With include_focal_length: false (the new default), the camera line
    must not contain a `mm` token — focal length and framing are independent
    per pipeline-learnings §28."""
    from recoil.pipeline._lib import bible_loader

    # Force the global default ON for the test, regardless of bible state.
    monkeypatch.setattr(
        bible_loader,
        "get_global_defaults",
        lambda: {"include_focal_length": False},
    )
    # Also stub get_model_rules to avoid per-model override interference.
    monkeypatch.setattr(bible_loader, "get_model_rules", lambda _name: None)

    line = _build_camera_line_plan(_camera_prompt_data(), model_id="seeddance-2.0")
    assert "mm" not in line, f"focal-length token leaked into camera line: {line!r}"
    # framing token still present.
    assert "medium shot" in line.lower()


def test_focal_length_included_when_opted_in(monkeypatch):
    """Per-model opt-in (or global flip) restores the `{focal}mm` token."""
    from recoil.pipeline._lib import bible_loader

    monkeypatch.setattr(
        bible_loader,
        "get_global_defaults",
        lambda: {"include_focal_length": True},
    )
    monkeypatch.setattr(bible_loader, "get_model_rules", lambda _name: None)

    line = _build_camera_line_plan(_camera_prompt_data(), model_id="seeddance-2.0")
    assert "35mm" in line, f"focal-length token missing from opt-in camera line: {line!r}"


def test_focal_length_per_model_override_wins(monkeypatch):
    """`<model>.prompt.include_focal_length: true` overrides global False."""
    from recoil.pipeline._lib import bible_loader

    monkeypatch.setattr(
        bible_loader,
        "get_global_defaults",
        lambda: {"include_focal_length": False},
    )
    monkeypatch.setattr(
        bible_loader,
        "get_model_rules",
        lambda name: (
            {"prompt": {"include_focal_length": True}} if name == "veo-3.1" else None
        ),
    )

    line_on = _build_camera_line_plan(_camera_prompt_data(), model_id="veo-3.1")
    line_off = _build_camera_line_plan(_camera_prompt_data(), model_id="seeddance-2.0")
    assert "35mm" in line_on
    assert "mm" not in line_off


def test_prompt_segments_fallback_without_timestamps():
    """When segment_timestamps is None, the builder falls back to the
    legacy per-shot accumulation from target_editorial_duration_s — a
    backwards-compat guard for direct callers (Workflow, CLI) that don't
    flow through build_dispatch_payload yet.
    """
    shots = _make_jade_shots(2)
    prompt = build_seeddance_r2v_prompt_multi(
        shots=shots,
        bible=_bible(),
        project_config=_project_config(),
        episode=1,
        ref_manifest={"identity_1": 1, "scene_1": 2},
        segment_timestamps=None,
        format_mode="timeline",
    )
    # _make_jade_shots gives each shot duration_s=3 → starts [0, 3].
    assert "[0s]" in prompt
    assert "[3s]" in prompt


# ──────────────────────────────────────────────────────────────────────────
# R4 Phase 3 (A2) — single-shot i2v Option C strip coverage
# ──────────────────────────────────────────────────────────────────────────


def test_strip_character_names_helper():
    from recoil.pipeline._lib.prompt_engine import _strip_character_names
    text = "Jade walks to the cable. Wren watches."
    chars = [
        {"char_id": "JADE", "role": "protagonist"},
        {"char_id": "WREN", "role": "antagonist"},
    ]
    bible = {
        "JADE": {"display_name": "Jade"},
        "WREN": {"display_name": "Wren"},
    }
    out = _strip_character_names(text, chars, bible)
    assert "Jade" not in out and "Wren" not in out
    assert "the protagonist" in out and "the subject" in out


def test_strip_character_names_handles_bare_strings():
    """A2 helper must tolerate list[str] (some callers pass bare char_ids)."""
    from recoil.pipeline._lib.prompt_engine import _strip_character_names
    out = _strip_character_names("Jade walks.", ["JADE"], {"JADE": {"display_name": "Jade"}})
    assert "Jade" not in out
    assert "the subject" in out


def test_strip_character_names_noop_when_no_chars():
    from recoil.pipeline._lib.prompt_engine import _strip_character_names
    text = "Jade walks."
    assert _strip_character_names(text, [], {}) == text
    assert _strip_character_names("", [{"char_id": "JADE"}], {}) == ""


def test_seeddance_i2v_strips_proper_nouns():
    from recoil.pipeline._lib.prompt_engine import build_seeddance_i2v_prompt
    shot = {
        "asset_data": {
            "characters": [{"char_id": "JADE", "role": "protagonist"}],
        },
        "routing_data": {"start_frame_path": "/tmp/start.png"},
        "prompt_data": {
            "shot_type": "MCU",
            "prompt_skeleton": {"action_line": "Jade's lips curl into a wry grin."},
            "kinetic_action": "",
        },
    }
    bible = {"characters": {"JADE": {"display_name": "Jade"}}}
    prompt = build_seeddance_i2v_prompt(shot=shot, bible=bible, project_config={})
    assert "Jade" not in prompt


def test_kling_i2v_strips_proper_nouns():
    from recoil.pipeline._lib.prompt_engine import build_kling_i2v_prompt
    shot = {
        "asset_data": {
            "characters": [{"char_id": "WREN", "role": "antagonist"}],
        },
        "prompt_data": {
            "shot_type": "MCU",
            "prompt_skeleton": {"action_line": "Wren releases the throat."},
            "kinetic_action": "",
            "camera_movement": "static",
        },
    }
    bible = {"characters": {"WREN": {"display_name": "Wren"}}}
    prompt = build_kling_i2v_prompt(shot, bible=bible)
    assert "Wren" not in prompt


# ──────────────────────────────────────────────────────────────────────────
# R4 Phase 7 (B2) — per-segment proper-noun scoping in r2v_multi
# ──────────────────────────────────────────────────────────────────────────


def test_per_segment_strip_removes_non_focus_character():
    """B2 fix: PASS_014 SH30_31 (focus=JADE) prompt was referencing WREN.
    After R4, WREN gets stripped from JADE-only segments."""
    shots = [
        # Segment 0 — JADE + WREN local roster, action mentions both.
        {
            "shot_id": "EP001_SH30",
            "asset_data": {
                "characters": [
                    {"char_id": "JADE", "role": "protagonist"},
                    {"char_id": "WREN", "role": "antagonist"},
                ],
                "location_id": "int_corridor",
            },
            "prompt_data": {
                "shot_type": "MS",
                "focal_length": "50mm",
                "camera_movement": "static",
                "prompt_skeleton": {
                    "action_line": "Jade walks past Wren toward the cable.",
                    "emotion_line": "",
                },
            },
            "routing_data": {"target_editorial_duration_s": 3},
            "cinematography": {},
        },
        # Segment 1 — JADE only locally, BUT pass roster still includes WREN.
        # Without the B2 strip the segment text would still drop "Wren" via
        # leakage from upstream prompt construction (e.g. action_line bleed).
        {
            "shot_id": "EP001_SH31",
            "asset_data": {
                "characters": [{"char_id": "JADE", "role": "protagonist"}],
                "location_id": "int_corridor",
            },
            "prompt_data": {
                "shot_type": "MS",
                "focal_length": "50mm",
                "camera_movement": "static",
                "prompt_skeleton": {
                    # Cross-segment leak — Wren named in a JADE-only segment.
                    "action_line": "Jade kneels while Wren watches from above.",
                    "emotion_line": "",
                },
            },
            "routing_data": {"target_editorial_duration_s": 3},
            "cinematography": {},
        },
    ]
    bible = {
        "characters": {
            "JADE": {"display_name": "Jade", "role": "protagonist"},
            "WREN": {"display_name": "Wren", "role": "antagonist"},
        },
        "locations": {
            "int_corridor": {"display_name": "Interior Corridor"},
        },
    }
    prompt = build_seeddance_r2v_prompt_multi(
        shots=shots, bible=bible, project_config={}, episode=1,
        ref_manifest={"identity_1": 1, "identity_2": 2, "scene_1": 3},
    )
    # Pass-level anchor uses "the protagonist" / role descriptors — no
    # character display_name should survive in the final prompt.
    assert "Jade" not in prompt, prompt[:500]
    assert "Wren" not in prompt, prompt[:500]


def test_per_segment_strip_preserves_location_names():
    """B2 scope boundary: the strip is character-scoped — locations survive."""
    shots = [
        {
            "shot_id": "EP001_SH40",
            "asset_data": {
                "characters": [{"char_id": "JADE", "role": "protagonist"}],
                "location_id": "int_bridge",
            },
            "prompt_data": {
                "shot_type": "MS",
                "focal_length": "50mm",
                "camera_movement": "static",
                "prompt_skeleton": {
                    "action_line": "Jade enters the bridge.",
                    "emotion_line": "",
                },
            },
            "routing_data": {"target_editorial_duration_s": 3},
            "cinematography": {},
        },
    ]
    bible = {
        "characters": {"JADE": {"display_name": "Jade", "role": "protagonist"}},
        "locations": {"int_bridge": {"display_name": "Bridge"}},
    }
    prompt = build_seeddance_r2v_prompt_multi(
        shots=shots, bible=bible, project_config={}, episode=1,
        ref_manifest={"identity_1": 1, "scene_1": 2},
    )
    # Location name allowed to survive even after the strip: the bound action
    # line ("@Image1 enters the bridge.") retains the location word.
    assert "the bridge" in prompt, prompt[:500]
    # Character name still scrubbed.
    assert "Jade" not in prompt, prompt[:500]


def test_per_segment_strip_keeps_focus_character_anchor_intact():
    """Segment-0's local roster has both JADE and WREN; segment-1 has only
    JADE. The B2 strip must NOT scrub JADE from segment-1 (since JADE IS
    in segment-1's local roster) and must NOT scrub WREN from segment-0
    (since WREN IS in segment-0's local roster) — only cross-segment leaks
    get stripped. Validated by ensuring the pass-level @ImageN anchor lines
    survive intact regardless of which segment names a character."""
    shots = [
        {
            "shot_id": "EP001_SH50",
            "asset_data": {
                "characters": [
                    {"char_id": "JADE", "role": "protagonist"},
                    {"char_id": "WREN", "role": "antagonist"},
                ],
                "location_id": "int_corridor",
            },
            "prompt_data": {
                "shot_type": "MS",
                "focal_length": "50mm",
                "camera_movement": "static",
                "prompt_skeleton": {
                    "action_line": "Two figures pace the corridor.",
                    "emotion_line": "",
                },
            },
            "routing_data": {"target_editorial_duration_s": 3},
            "cinematography": {},
        },
        {
            "shot_id": "EP001_SH51",
            "asset_data": {
                "characters": [{"char_id": "JADE", "role": "protagonist"}],
                "location_id": "int_corridor",
            },
            "prompt_data": {
                "shot_type": "MS",
                "focal_length": "50mm",
                "camera_movement": "static",
                "prompt_skeleton": {
                    "action_line": "The protagonist exits alone.",
                    "emotion_line": "",
                },
            },
            "routing_data": {"target_editorial_duration_s": 3},
            "cinematography": {},
        },
    ]
    bible = {
        "characters": {
            "JADE": {"display_name": "Jade", "role": "protagonist"},
            "WREN": {"display_name": "Wren", "role": "antagonist"},
        },
        "locations": {"int_corridor": {"display_name": "Interior Corridor"}},
    }
    prompt = build_seeddance_r2v_prompt_multi(
        shots=shots, bible=bible, project_config={}, episode=1,
        ref_manifest={"identity_1": 1, "identity_2": 2, "scene_1": 3},
    )
    # @ImageN anchor lines must survive — they use role descriptors, no names.
    assert "the protagonist character" in prompt
    # Identity tokens still emitted.
    assert "@Image1" in prompt


# R6 Phase 9 (c3) — gaze cue emission tests.

def test_seeddance_i2v_gaze_cue_emitted_when_has_dialogue():
    """The gaze cue must be appended after the dialogue clause when
    dialogue_list + has_dialogue_flag + line_text are all truthy.
    """
    from recoil.pipeline._lib.prompt_engine import build_seeddance_i2v_prompt
    shot = {
        "shot_id": "EP001_SH10",
        "asset_data": {
            "characters": [{"char_id": "JADE", "role": "protagonist"}],
        },
        "routing_data": {
            "start_frame_path": "/tmp/start.png",
            "has_dialogue": True,
            "target_editorial_duration_s": 3,
        },
        "prompt_data": {
            "shot_type": "MCU",
            "prompt_skeleton": {"action_line": "The subject inhales slowly."},
            "kinetic_action": "",
            "camera_movement": "static",
        },
        "audio_data": {"dialogue": [{"text": "Daddy needs a new pair of lungs."}]},
    }
    bible = {"characters": {"JADE": {"display_name": "Jade"}}}
    prompt = build_seeddance_i2v_prompt(
        shot=shot, bible=bible, project_config={},
    )
    assert "The subject speaks:" in prompt, "dialogue clause missing"
    assert (
        "Subject looks off-camera, eyeline three-quarters away from lens."
        in prompt
    ), "gaze cue missing"


def test_seeddance_i2v_gaze_cue_absent_when_no_dialogue():
    """No gaze cue when has_dialogue_flag is False (or dialogue_list is empty)."""
    from recoil.pipeline._lib.prompt_engine import build_seeddance_i2v_prompt
    shot = {
        "shot_id": "EP001_SH10",
        "asset_data": {
            "characters": [{"char_id": "JADE", "role": "protagonist"}],
        },
        "routing_data": {
            "start_frame_path": "/tmp/start.png",
            "has_dialogue": False,
            "target_editorial_duration_s": 3,
        },
        "prompt_data": {
            "shot_type": "MCU",
            "prompt_skeleton": {"action_line": "The subject inhales slowly."},
            "kinetic_action": "",
            "camera_movement": "static",
        },
        "audio_data": {"dialogue": []},
    }
    bible = {"characters": {"JADE": {"display_name": "Jade"}}}
    prompt = build_seeddance_i2v_prompt(
        shot=shot, bible=bible, project_config={},
    )
    assert "The subject speaks:" not in prompt
    assert "eyeline three-quarters" not in prompt


# ──────────────────────────────────────────────────────────────────────────
# Phase 2 — Cluster A pinning tests (A2/A3/A4/off-frame/multi-char)
# ──────────────────────────────────────────────────────────────────────────

def _make_two_char_shot(action_line: str, emotion_line: str = "") -> dict:
    """Synthetic 2-char shot dict with proper action_line for multi-char tests."""
    return {
        "shot_id": "EP001_SH01",
        "prompt_data": {
            "shot_type": "MS",
            "focal_length": "50mm",
            "camera_movement": "static",
            "prompt_skeleton": {
                "action_line": action_line,
                "emotion_line": emotion_line,
            },
        },
        "asset_data": {
            "characters": [
                {"char_id": "JADE", "role": "protagonist"},
                {"char_id": "WREN", "role": "antagonist"},
            ],
            "location_id": "int_corridor",
        },
        "routing_data": {"target_editorial_duration_s": 3},
        "cinematography": {},
    }


def _make_three_char_shot(action_line: str) -> dict:
    """Synthetic 3-char shot dict."""
    return {
        "shot_id": "EP001_SH01",
        "prompt_data": {
            "shot_type": "MS",
            "focal_length": "50mm",
            "camera_movement": "static",
            "prompt_skeleton": {"action_line": action_line, "emotion_line": ""},
        },
        "asset_data": {
            "characters": [
                {"char_id": "JADE", "role": "protagonist"},
                {"char_id": "WREN", "role": "antagonist"},
                {"char_id": "KIRA", "role": "antagonist"},
            ],
            "location_id": "int_corridor",
        },
        "routing_data": {"target_editorial_duration_s": 3},
        "cinematography": {},
    }


def _two_char_bible() -> dict:
    return {
        "characters": {
            "JADE": {
                "display_name": "Jade",
                "role": "protagonist",
                "visual_description": "young woman in dark jacket",
            },
            "WREN": {
                "display_name": "Wren",
                "role": "antagonist",
                "visual_description": "tall man in grey coat",
            },
        },
        "locations": {
            "int_corridor": {
                "display_name": "corridor",
                "spatial_description": "narrow concrete corridor",
            },
        },
        "global_defaults": {},
    }


def _three_char_bible() -> dict:
    b = _two_char_bible()
    b["characters"]["KIRA"] = {
        "display_name": "Kira",
        "role": "antagonist",
        "visual_description": "fierce woman in red",
    }
    return b


# ── A2: char with no role + no visual_description ────────────────────────


def test_a2_no_role_no_visual_desc_anchor_line():
    """A2: a char entry with NO role and NO visual_description in the bible
    produces the anchor line `@Image1 is the protagonist character.`
    (no `— character` duplicate suffix, no traits clause).
    """
    shot = {
        "shot_id": "EP001_SH01",
        "prompt_data": {
            "shot_type": "MS",
            "focal_length": "50mm",
            "camera_movement": "static",
            "prompt_skeleton": {"action_line": "JADE walks forward.", "emotion_line": ""},
        },
        "asset_data": {
            "characters": [{"char_id": "JADE"}],  # no role key
            "location_id": "int_corridor",
        },
        "routing_data": {"target_editorial_duration_s": 3},
        "cinematography": {},
    }
    bible = {
        "characters": {"JADE": {}},  # empty bible entry — no role, no visual_description
        "locations": {
            "int_corridor": {
                "display_name": "corridor",
                "spatial_description": "narrow concrete corridor",
            }
        },
        "global_defaults": {},
    }
    prompt = build_seeddance_r2v_prompt_multi(
        shots=[shot],
        bible=bible,
        project_config={},
        episode=1,
        ref_manifest={"identity_1": 1, "scene_1": 2},
    )
    # Anchor line must be the bare role descriptor, no `— character` suffix.
    assert "@Image1 is the protagonist character." in prompt, (
        f"A2: anchor line missing or malformed:\n{prompt}"
    )
    assert "— character" not in prompt, (
        f"A2: duplicate `— character` suffix present:\n{prompt}"
    )
    assert "is Jade" not in prompt


# ── A1: 2-char action → @Image1 and @Image2 tokens ───────────────────────


def test_a1_two_char_action_replaces_both_names():
    """A1: a 2-char shot with action_line `JADE grabs WREN's throat` emits
    `@Image1 grabs @Image2's throat.` — both proper nouns replaced, possessive
    clitic remains attached, zero bare names survive.
    """
    shot = _make_two_char_shot("JADE grabs WREN's throat.")
    prompt = build_seeddance_r2v_prompt_multi(
        shots=[shot],
        bible=_two_char_bible(),
        project_config={},
        episode=1,
        ref_manifest={"identity_1": 1, "identity_2": 2, "scene_1": 3},
    )
    assert "@Image1" in prompt, f"A1: @Image1 missing:\n{prompt}"
    assert "@Image2" in prompt, f"A1: @Image2 missing:\n{prompt}"
    # Possessive clitic must stay attached to the token
    assert "@Image2's" in prompt, (
        f"A1: possessive `@Image2's` not found (clitic detached):\n{prompt}"
    )
    # Zero bare character names
    assert "Jade" not in prompt, f"A1: bare name `Jade` survived:\n{prompt}"
    assert "Wren" not in prompt, f"A1: bare name `Wren` survived:\n{prompt}"
    assert "JADE" not in prompt
    assert "WREN" not in prompt


# ── A1: 3-char → @Image3 present ─────────────────────────────────────────


def test_a1_three_char_action_emits_image3():
    """A1: a 3-char shot emits @Image3 for the third character."""
    shot = _make_three_char_shot("JADE grabs WREN while KIRA watches.")
    prompt = build_seeddance_r2v_prompt_multi(
        shots=[shot],
        bible=_three_char_bible(),
        project_config={},
        episode=1,
        ref_manifest={"identity_1": 1, "identity_2": 2, "identity_3": 3, "scene_1": 4},
    )
    assert "@Image3" in prompt, f"A1: @Image3 missing for 3-char shot:\n{prompt}"
    assert "Jade" not in prompt
    assert "Wren" not in prompt
    assert "Kira" not in prompt


# ── Off-frame regression guard ────────────────────────────────────────────


def test_off_frame_name_collapses_to_the_subject():
    """Off-frame regression guard: a character named in action_line but NOT in
    s_chars (len(s_chars) >= 2) with len(s_chars) >= 2.

    Off-frame collapse (Phase 1 + harness debug fix): the off-frame fallback in
    _render_action_with_tokens collapses ANY bible character not in s_chars to
    "the subject"/"the protagonist". KIRA is in the bible but not in
    s_chars=[JADE, WREN], so KIRA must NOT survive as a bare proper noun —
    leaking it would be a content-policy + audit-#4 regression.

    Setup: s_chars = [JADE, WREN]; action mentions KIRA (not in s_chars).
    """
    shot = _make_two_char_shot("JADE pins KIRA against the wall.")
    # KIRA is in the bible but NOT in this shot's s_chars
    bible = _three_char_bible()
    prompt = build_seeddance_r2v_prompt_multi(
        shots=[shot],
        bible=bible,
        project_config={},
        episode=1,
        ref_manifest={"identity_1": 1, "identity_2": 2, "scene_1": 3},
    )
    # Off-frame name must collapse — never leak as a bare proper noun.
    assert "KIRA" not in prompt, (
        f"Off-frame KIRA leaked as a bare name (content-policy regression):\n{prompt}"
    )
    assert "Kira" not in prompt, f"Off-frame KIRA leaked (title-case):\n{prompt}"
    # Collapsed to a generic role token, not a name.
    assert ("the subject" in prompt) or ("the protagonist" in prompt), (
        f"Off-frame KIRA should collapse to a role token:\n{prompt}"
    )
    # In-frame characters are replaced with tokens
    assert "@Image1" in prompt, f"@Image1 missing for in-frame JADE:\n{prompt}"
    assert "WREN" not in prompt, f"In-frame WREN should be replaced:\n{prompt}"


# ── Single-char snapshot unchanged ───────────────────────────────────────


def test_single_char_snapshot_unchanged():
    """Single-char r2v_multi path is the existing _render_action_no_proper_nouns
    path — it produces `the protagonist` substitution and `@Image1` anchor.
    Verify this snapshot is NOT broken by Phase 2 changes.
    """
    shots = _make_jade_shots(2)
    prompt = build_seeddance_r2v_prompt_multi(
        shots=shots,
        bible=_bible(),
        project_config=_project_config(),
        episode=1,
        ref_manifest={"identity_1": 1, "scene_1": 2},
    )
    # Anchor line present
    assert "@Image1 is the protagonist character" in prompt
    # No bare character name
    assert "Jade" not in prompt
    # Location token present (@Image2 = scene_1 index 2)
    assert "@Image2" in prompt
    # Action replaced (single-char path uses _render_action_no_proper_nouns)
    assert "the protagonist" in prompt


# ── emotion_line survives with brief_declarations=True ───────────────────


def test_emotion_line_survives_brief_declarations():
    """A4: emotion_line is emitted even when brief_declarations=True (the
    default). The old gate `and not brief_declarations` was dropped in Phase 1.
    """
    shot = _make_two_char_shot("JADE walks.", emotion_line="JADE feels tense.")
    prompt = build_seeddance_r2v_prompt_multi(
        shots=[shot],
        bible=_two_char_bible(),
        project_config={},
        episode=1,
        ref_manifest={"identity_1": 1, "identity_2": 2, "scene_1": 3},
        brief_declarations=True,  # default — emotion must still survive
    )
    # Emotion content must appear (proper noun stripped to role descriptor)
    assert "tense" in prompt, f"A4: emotion_line content missing:\n{prompt}"
    # No bare name in the emotion clause
    assert "Jade" not in prompt
    assert "JADE" not in prompt


# ── A3: manifest missing scene_1 + location set → no location token ───────


def test_a3_manifest_missing_scene1_no_location_token():
    """A3: when ref_manifest has no scene_1 key and the shot has a location_id,
    - the location declaration line is OMITTED from Zone A
    - no `in @ImageN` clause in Zone B
    - no `@Image{...}` literal placeholder in the prompt
    This verifies the _resolve_scene_token(None) path silently omits location.
    """
    shot = {
        "shot_id": "EP001_SH01",
        "prompt_data": {
            "shot_type": "MS",
            "focal_length": "50mm",
            "camera_movement": "static",
            "prompt_skeleton": {"action_line": "JADE walks forward.", "emotion_line": ""},
        },
        "asset_data": {
            "characters": [{"char_id": "JADE", "role": "protagonist"}],
            "location_id": "int_corridor",
        },
        "routing_data": {"target_editorial_duration_s": 3},
        "cinematography": {},
    }
    bible = {
        "characters": {
            "JADE": {
                "display_name": "Jade",
                "role": "protagonist",
                "visual_description": "young woman in dark jacket",
            }
        },
        "locations": {
            "int_corridor": {
                "display_name": "corridor",
                "spatial_description": "narrow concrete corridor",
            }
        },
        "global_defaults": {},
    }
    prompt = build_seeddance_r2v_prompt_multi(
        shots=[shot],
        bible=bible,
        project_config={},
        episode=1,
        ref_manifest={"identity_1": 1},  # NO scene_1
    )
    # No location declaration in Zone A
    assert "is corridor" not in prompt, (
        f"A3: location declaration appeared despite missing scene_1:\n{prompt}"
    )
    # No in-shot location clause
    assert "in @Image" not in prompt, (
        f"A3: `in @ImageN` clause appeared despite missing scene_1:\n{prompt}"
    )
    # No literal placeholder token
    assert "@Image{" not in prompt, (
        f"A3: unhydrated @Image{{...}} literal appeared:\n{prompt}"
    )
