"""Tests for lib.previz_context — 8-slot architecture, turnaround selection, system instruction split."""

import copy
import json
from unittest.mock import patch

import pytest

from recoil.pipeline._lib.previz_context import (
    extract_mentioned_props,
    resolve_expression_ref,
    scope_bible_to_shot,
    select_previz_turnaround,
    build_behavioral_preamble,
    build_generative_directive,
    build_system_instruction,
    build_previz_context,
)


# ── Fixtures ─────────────────────────────────────────────────────────


@pytest.fixture
def prop_ref_tree(tmp_path):
    """Create a realistic prop ref directory with images.

    Creates files under tmp_path/_default/output/refs/props/... so that
    patching projects_root()=tmp_path with DEFAULT_PROJECT='_default'
    makes project_refs_dir() resolve here.
    """
    props_root = tmp_path / "_default" / "output" / "refs" / "props"

    # THE_PACKAGE — has default_front + other angles
    pkg_dir = props_root / "THE_PACKAGE"
    pkg_dir.mkdir(parents=True)
    (pkg_dir / "THE_PACKAGE_default_front.png").write_bytes(b"front")
    (pkg_dir / "THE_PACKAGE_default_3q.png").write_bytes(b"3q")
    (pkg_dir / "THE_PACKAGE_default_side.png").write_bytes(b"side")

    # THE_KNIFE — has images but no _default_front
    knife_dir = props_root / "THE_KNIFE"
    knife_dir.mkdir(parents=True)
    (knife_dir / "THE_KNIFE_hero.jpg").write_bytes(b"knife")

    # EMPTY_PROP — directory exists but no images
    empty_dir = props_root / "EMPTY_PROP"
    empty_dir.mkdir(parents=True)
    (empty_dir / "notes.txt").write_text("not an image")

    # Plant the data-root sentinel so ProjectPaths.for_project() (via
    # projects_root() -> _assert_data_root) accepts this tmp dir as the
    # projects root when RECOIL_PROJECTS_ROOT=str(tmp_path).
    (tmp_path / ".recoil-data-root").write_text("recoil-data-root\n")

    return tmp_path


@pytest.fixture
def casting_state_with_props(tmp_path):
    """Create a casting_state.json with a prop ref entry."""
    state_dir = tmp_path / "testproj" / "state" / "visual"
    state_dir.mkdir(parents=True)
    casting = {
        "characters": {},
        "props": {
            "THE_AMULET": {"ref_path": "output/refs/props/THE_AMULET/amulet_front.png"}
        },
    }
    state_path = state_dir / "casting_state.json"
    state_path.write_text(json.dumps(casting), encoding="utf-8")
    # Plant the data-root sentinel (see prop_ref_tree) so ProjectPaths
    # accepts this tmp dir as the projects root.
    (tmp_path / ".recoil-data-root").write_text("recoil-data-root\n")
    return tmp_path


# ── Tests ────────────────────────────────────────────────────────────


def _make_shot(prop_ids):
    """Helper: build a minimal shot dict with props."""
    props = [{"prop_id": pid} for pid in prop_ids]
    return {"asset_data": {"props": props}}


class TestExtractMentionedProps:
    """Tests for extract_mentioned_props()."""

    def test_empty_props_returns_empty(self):
        shot = {"asset_data": {"props": []}}
        assert extract_mentioned_props(shot) == []

    def test_no_asset_data_returns_empty(self):
        assert extract_mentioned_props({}) == []

    def test_prefers_default_front(self, prop_ref_tree):
        shot = _make_shot(["THE_PACKAGE"])
        front_path = (
            prop_ref_tree
            / "_default"
            / "output"
            / "refs"
            / "props"
            / "THE_PACKAGE"
            / "THE_PACKAGE_default_front.png"
        )
        with (
            patch.dict(
                "os.environ", {"RECOIL_PROJECTS_ROOT": str(prop_ref_tree)}, clear=False
            ),
            patch("recoil.core.paths.DEFAULT_PROJECT", "_default"),
            patch.dict(
                "os.environ", {"RECOIL_PROJECTS_ROOT": str(prop_ref_tree)}, clear=False
            ),
            patch("recoil.pipeline._lib.previz_context.DEFAULT_PROJECT", "_default"),
            patch(
                "recoil.core.ref_resolver.resolve_prop_refs",
                return_value={"hero": front_path},
            ),
        ):
            result = extract_mentioned_props(shot)
        assert len(result) == 1
        assert result[0]["prop_id"] == "THE_PACKAGE"
        assert result[0]["ref_path"].name == "THE_PACKAGE_default_front.png"

    def test_falls_back_to_first_image(self, prop_ref_tree):
        shot = _make_shot(["THE_KNIFE"])
        knife_path = (
            prop_ref_tree
            / "_default"
            / "output"
            / "refs"
            / "props"
            / "THE_KNIFE"
            / "THE_KNIFE_hero.jpg"
        )
        with (
            patch.dict(
                "os.environ", {"RECOIL_PROJECTS_ROOT": str(prop_ref_tree)}, clear=False
            ),
            patch("recoil.core.paths.DEFAULT_PROJECT", "_default"),
            patch.dict(
                "os.environ", {"RECOIL_PROJECTS_ROOT": str(prop_ref_tree)}, clear=False
            ),
            patch("recoil.pipeline._lib.previz_context.DEFAULT_PROJECT", "_default"),
            patch(
                "recoil.core.ref_resolver.resolve_prop_refs",
                return_value={"hero": knife_path},
            ),
        ):
            result = extract_mentioned_props(shot)
        assert len(result) == 1
        assert result[0]["prop_id"] == "THE_KNIFE"
        assert result[0]["ref_path"].name == "THE_KNIFE_hero.jpg"

    def test_skips_prop_with_no_images(self, prop_ref_tree):
        shot = _make_shot(["EMPTY_PROP"])
        with (
            patch.dict(
                "os.environ", {"RECOIL_PROJECTS_ROOT": str(prop_ref_tree)}, clear=False
            ),
            patch("recoil.core.paths.DEFAULT_PROJECT", "_default"),
            patch.dict(
                "os.environ", {"RECOIL_PROJECTS_ROOT": str(prop_ref_tree)}, clear=False
            ),
            patch("recoil.pipeline._lib.previz_context.DEFAULT_PROJECT", "_default"),
        ):
            result = extract_mentioned_props(shot)
        assert result == []

    def test_skips_nonexistent_prop(self, prop_ref_tree):
        shot = _make_shot(["DOES_NOT_EXIST"])
        with (
            patch.dict(
                "os.environ", {"RECOIL_PROJECTS_ROOT": str(prop_ref_tree)}, clear=False
            ),
            patch("recoil.core.paths.DEFAULT_PROJECT", "_default"),
            patch.dict(
                "os.environ", {"RECOIL_PROJECTS_ROOT": str(prop_ref_tree)}, clear=False
            ),
            patch("recoil.pipeline._lib.previz_context.DEFAULT_PROJECT", "_default"),
        ):
            result = extract_mentioned_props(shot)
        assert result == []

    def test_multiple_props_mixed(self, prop_ref_tree):
        """Returns only props that have refs; skips those without."""
        shot = _make_shot(["THE_PACKAGE", "DOES_NOT_EXIST", "THE_KNIFE"])
        front_path = (
            prop_ref_tree
            / "_default"
            / "output"
            / "refs"
            / "props"
            / "THE_PACKAGE"
            / "THE_PACKAGE_default_front.png"
        )
        knife_path = (
            prop_ref_tree
            / "_default"
            / "output"
            / "refs"
            / "props"
            / "THE_KNIFE"
            / "THE_KNIFE_hero.jpg"
        )

        def _mock_resolve(refs_root, prop_id):
            if prop_id == "THE_PACKAGE":
                return {"hero": front_path}
            elif prop_id == "THE_KNIFE":
                return {"hero": knife_path}
            return {}

        with (
            patch.dict(
                "os.environ", {"RECOIL_PROJECTS_ROOT": str(prop_ref_tree)}, clear=False
            ),
            patch("recoil.core.paths.DEFAULT_PROJECT", "_default"),
            patch.dict(
                "os.environ", {"RECOIL_PROJECTS_ROOT": str(prop_ref_tree)}, clear=False
            ),
            patch("recoil.pipeline._lib.previz_context.DEFAULT_PROJECT", "_default"),
            patch(
                "recoil.core.ref_resolver.resolve_prop_refs",
                side_effect=_mock_resolve,
            ),
        ):
            result = extract_mentioned_props(shot)
        assert len(result) == 2
        ids = [r["prop_id"] for r in result]
        assert ids == ["THE_PACKAGE", "THE_KNIFE"]

    def test_string_prop_entries(self, prop_ref_tree):
        """Props can be plain strings instead of dicts."""
        shot = {"asset_data": {"props": ["the_package"]}}
        front_path = (
            prop_ref_tree
            / "_default"
            / "output"
            / "refs"
            / "props"
            / "THE_PACKAGE"
            / "THE_PACKAGE_default_front.png"
        )
        with (
            patch.dict(
                "os.environ", {"RECOIL_PROJECTS_ROOT": str(prop_ref_tree)}, clear=False
            ),
            patch("recoil.core.paths.DEFAULT_PROJECT", "_default"),
            patch.dict(
                "os.environ", {"RECOIL_PROJECTS_ROOT": str(prop_ref_tree)}, clear=False
            ),
            patch("recoil.pipeline._lib.previz_context.DEFAULT_PROJECT", "_default"),
            patch(
                "recoil.core.ref_resolver.resolve_prop_refs",
                return_value={"hero": front_path},
            ),
        ):
            result = extract_mentioned_props(shot)
        assert len(result) == 1
        assert result[0]["prop_id"] == "THE_PACKAGE"

    def test_casting_state_fallback(self, casting_state_with_props):
        """Falls back to ref_resolver when prop ref exists."""
        # Create the amulet file in the project's refs dir
        amulet_ref = (
            casting_state_with_props
            / "testproj"
            / "output"
            / "refs"
            / "props"
            / "THE_AMULET"
        )
        amulet_ref.mkdir(parents=True)
        amulet_file = amulet_ref / "amulet_front.png"
        amulet_file.write_bytes(b"amulet")

        shot = _make_shot(["THE_AMULET"])
        with (
            patch.dict(
                "os.environ",
                {"RECOIL_PROJECTS_ROOT": str(casting_state_with_props)},
                clear=False,
            ),
            patch.dict(
                "os.environ",
                {"RECOIL_PROJECTS_ROOT": str(casting_state_with_props)},
                clear=False,
            ),
            patch(
                "recoil.core.ref_resolver.resolve_prop_refs",
                return_value={"hero": amulet_file},
            ),
        ):
            result = extract_mentioned_props(shot, project="testproj")

        assert len(result) == 1
        assert result[0]["prop_id"] == "THE_AMULET"


# ── scope_bible_to_shot tests ────────────────────────────────────────

SAMPLE_BIBLE = {
    "characters": {
        "TORCH": {"display_name": "Torch", "visual_description": "Tall, scarred"},
        "KAEL": {"display_name": "Kael", "visual_description": "Short, wiry"},
        "MARA": {"display_name": "Mara", "visual_description": "Silver hair"},
    },
    "locations": {
        "INT_CARGO_BAY": {"description": "Dim cargo hold", "mood": "tense"},
        "EXT_DOCKING_RING": {"description": "Exterior docking", "mood": "cold"},
        "INT_BRIDGE": {"description": "Ship bridge", "mood": "clinical"},
    },
    "props": {
        "THE_PACKAGE": {"description": "Mysterious crate"},
        "DATAPAD": {"description": "Holographic tablet"},
    },
}


class TestScopeBibleToShot:
    """Tests for scope_bible_to_shot()."""

    def test_filters_characters_and_location(self):
        asset_data = {
            "characters": [
                {"char_id": "TORCH", "screen_position": "left"},
                {"char_id": "KAEL", "screen_position": "right"},
            ],
            "location_id": "INT_CARGO_BAY",
        }
        result = scope_bible_to_shot(SAMPLE_BIBLE, asset_data)

        assert set(result["characters"].keys()) == {"TORCH", "KAEL"}
        assert "MARA" not in result["characters"]
        assert set(result["locations"].keys()) == {"INT_CARGO_BAY"}
        assert "EXT_DOCKING_RING" not in result["locations"]
        # Props pass through unchanged
        assert set(result["props"].keys()) == {"THE_PACKAGE", "DATAPAD"}

    def test_case_insensitive_char_matching(self):
        asset_data = {
            "characters": [{"char_id": "torch"}],
            "location_id": "INT_CARGO_BAY",
        }
        result = scope_bible_to_shot(SAMPLE_BIBLE, asset_data)
        assert "TORCH" in result["characters"]

    def test_case_insensitive_location_matching(self):
        asset_data = {
            "characters": [],
            "location_id": "int_cargo_bay",
        }
        result = scope_bible_to_shot(SAMPLE_BIBLE, asset_data)
        assert "INT_CARGO_BAY" in result["locations"]

    def test_string_character_entries(self):
        """Characters can be plain strings, not just dicts."""
        asset_data = {
            "characters": ["TORCH", "MARA"],
            "location_id": "INT_BRIDGE",
        }
        result = scope_bible_to_shot(SAMPLE_BIBLE, asset_data)
        assert set(result["characters"].keys()) == {"TORCH", "MARA"}
        assert "KAEL" not in result["characters"]

    def test_does_not_mutate_input(self):
        original = copy.deepcopy(SAMPLE_BIBLE)
        asset_data = {
            "characters": [{"char_id": "TORCH"}],
            "location_id": "INT_CARGO_BAY",
        }
        result = scope_bible_to_shot(SAMPLE_BIBLE, asset_data)
        # Mutate the result and verify original is untouched
        result["characters"]["TORCH"]["display_name"] = "MUTATED"
        assert SAMPLE_BIBLE == original

    def test_empty_bible_returns_empty(self):
        assert scope_bible_to_shot({}, {"characters": [{"char_id": "TORCH"}]}) == {}
        assert scope_bible_to_shot(None, {"characters": []}) == {}

    def test_empty_asset_data_returns_full_copy(self):
        result = scope_bible_to_shot(SAMPLE_BIBLE, {})
        assert set(result["characters"].keys()) == {"TORCH", "KAEL", "MARA"}
        assert set(result["locations"].keys()) == {
            "INT_CARGO_BAY",
            "EXT_DOCKING_RING",
            "INT_BRIDGE",
        }

    def test_none_asset_data_returns_full_copy(self):
        result = scope_bible_to_shot(SAMPLE_BIBLE, None)
        assert set(result["characters"].keys()) == {"TORCH", "KAEL", "MARA"}

    def test_missing_location_id_keeps_no_locations(self):
        asset_data = {"characters": [{"char_id": "TORCH"}]}
        result = scope_bible_to_shot(SAMPLE_BIBLE, asset_data)
        assert result["locations"] == {}

    def test_no_matching_characters(self):
        asset_data = {
            "characters": [{"char_id": "NOBODY"}],
            "location_id": "INT_CARGO_BAY",
        }
        result = scope_bible_to_shot(SAMPLE_BIBLE, asset_data)
        assert result["characters"] == {}
        assert "INT_CARGO_BAY" in result["locations"]

    def test_preserves_extra_top_level_keys(self):
        bible_with_extra = {**SAMPLE_BIBLE, "metadata": {"version": 3}}
        asset_data = {
            "characters": [{"char_id": "TORCH"}],
            "location_id": "INT_CARGO_BAY",
        }
        result = scope_bible_to_shot(bible_with_extra, asset_data)
        assert result["metadata"] == {"version": 3}


# ── select_previz_turnaround tests ──────────────────────────────────


def _make_full_shot(
    shot_id="EP001_SC01_SH001",
    shot_type="MS",
    characters=None,
    subject_line="",
    action_line="",
):
    """Helper: build a shot dict with prompt_data and asset_data."""
    if characters is None:
        characters = [{"char_id": "TORCH", "screen_position": "center"}]
    return {
        "shot_id": shot_id,
        "prompt_data": {
            "shot_type": shot_type,
            "prompt_skeleton": {
                "subject_line": subject_line,
                "action_line": action_line,
            },
        },
        "asset_data": {
            "characters": characters,
            "location_id": "INT_CARGO_BAY",
        },
    }


class TestSelectPrevizTurnaround:
    """Tests for select_previz_turnaround()."""

    def test_default_returns_front(self):
        shot = _make_full_shot(shot_type="MS", subject_line="Torch stares at the wall")
        assert select_previz_turnaround(shot, "TORCH") == "front"

    def test_ecu_returns_front(self):
        shot = _make_full_shot(shot_type="ECU", subject_line="Torch's eyes widen")
        assert select_previz_turnaround(shot, "TORCH") == "front"

    def test_ots_shoulder_char_first(self):
        """First character in OTS is shoulder (nearer camera) -> three_quarter_back."""
        shot = _make_full_shot(
            shot_type="OTS",
            characters=[
                {"char_id": "TORCH", "screen_position": "left"},
                {"char_id": "KAEL", "screen_position": "right"},
            ],
            subject_line="Torch and Kael talk",
        )
        assert select_previz_turnaround(shot, "TORCH") == "three_quarter_back"

    def test_ots_face_char_second(self):
        """Second character in OTS is face (facing camera) -> three_quarter."""
        shot = _make_full_shot(
            shot_type="OTS",
            characters=[
                {"char_id": "TORCH", "screen_position": "left"},
                {"char_id": "KAEL", "screen_position": "right"},
            ],
            subject_line="Torch and Kael talk",
        )
        assert select_previz_turnaround(shot, "KAEL") == "three_quarter"

    def test_ots_parsed_from_subject_line(self):
        """'over TORCH's shoulder' in subject_line identifies TORCH as shoulder."""
        shot = _make_full_shot(
            shot_type="OTS",
            characters=[
                {"char_id": "KAEL", "screen_position": "left"},
                {"char_id": "TORCH", "screen_position": "right"},
            ],
            subject_line="Over torch's shoulder, Kael reacts",
        )
        # TORCH is named as shoulder -> three_quarter_back
        assert select_previz_turnaround(shot, "TORCH") == "three_quarter_back"
        # KAEL is the face -> three_quarter
        assert select_previz_turnaround(shot, "KAEL") == "three_quarter"

    def test_back_keywords(self):
        shot = _make_full_shot(subject_line="Torch walking away from the camera")
        assert select_previz_turnaround(shot, "TORCH") == "back"

    def test_behind_keyword(self):
        shot = _make_full_shot(subject_line="Shot from behind Torch")
        assert select_previz_turnaround(shot, "TORCH") == "back"

    def test_profile_keyword(self):
        shot = _make_full_shot(subject_line="Torch in profile, looking left")
        assert select_previz_turnaround(shot, "TORCH") == "side"

    def test_side_view_keyword(self):
        shot = _make_full_shot(subject_line="Side view of Torch at the console")
        assert select_previz_turnaround(shot, "TORCH") == "side"


# ── build_behavioral_preamble tests ─────────────────────────────────


class TestBuildBehavioralPreamble:
    """Tests for build_behavioral_preamble()."""

    def test_contains_constraints(self):
        shot = _make_full_shot()
        with patch(
            "recoil.pipeline._lib.previz_context.load_prompt_file", return_value=""
        ):
            preamble = build_behavioral_preamble(shot)
        assert "COMPOSITIONAL CONSTRAINTS" in preamble
        assert "DO NOT" in preamble
        assert "HAIR" in preamble

    def test_contains_camera_direction_guard(self):
        shot = _make_full_shot()
        with patch(
            "recoil.pipeline._lib.previz_context.load_prompt_file", return_value=""
        ):
            preamble = build_behavioral_preamble(shot)
        # Guard uses positive gaze anchoring (per Gemini recommendation)
        assert (
            "candid" in preamble.lower()
            or "gaze" in preamble.lower()
            or "observational" in preamble.lower()
        )

    def test_skips_camera_guard_for_env(self):
        shot = _make_full_shot()
        shot["routing_data"] = {"is_env_only": True}
        with patch(
            "recoil.pipeline._lib.previz_context.load_prompt_file", return_value=""
        ):
            preamble = build_behavioral_preamble(shot)
        assert "CAMERA DIRECTION" not in preamble

    def test_skips_camera_guard_for_toward_camera(self):
        shot = _make_full_shot()
        shot["spatial_data"] = {"screen_direction": "toward-camera"}
        with patch(
            "recoil.pipeline._lib.previz_context.load_prompt_file", return_value=""
        ):
            preamble = build_behavioral_preamble(shot)
        assert "CAMERA DIRECTION" not in preamble

    def test_does_not_contain_your_task(self):
        """Preamble should NOT contain generative directives."""
        shot = _make_full_shot()
        with patch(
            "recoil.pipeline._lib.previz_context.load_prompt_file", return_value=""
        ):
            preamble = build_behavioral_preamble(shot)
        assert "YOUR TASK:" not in preamble

    def test_does_not_contain_step_instructions(self):
        """STEP 1/2 belong in the generative directive, not preamble."""
        shot = _make_full_shot()
        with patch(
            "recoil.pipeline._lib.previz_context.load_prompt_file", return_value=""
        ):
            preamble = build_behavioral_preamble(shot)
        assert "STEP 1:" not in preamble
        assert "STEP 2:" not in preamble


# ── build_generative_directive tests ────────────────────────────────


class TestBuildGenerativeDirective:
    """Tests for build_generative_directive()."""

    def test_contains_your_task(self):
        shot = _make_full_shot(shot_id="EP001_SC01_SH003")
        directive = build_generative_directive(shot)
        assert "YOUR TASK: Generate shot EP001_SC01_SH003" in directive

    def test_contains_shot_spec(self):
        shot = _make_full_shot(shot_type="CU", subject_line="Torch looks up")
        directive = build_generative_directive(shot)
        assert "Type: CU" in directive
        assert "Action: Torch looks up" in directive

    def test_contains_shot_type_override(self):
        shot = _make_full_shot(shot_type="WS")
        directive = build_generative_directive(shot)
        assert "[SHOT_TYPE_OVERRIDE] WS" in directive

    def test_override_is_at_end(self):
        shot = _make_full_shot(shot_type="ECU")
        directive = build_generative_directive(shot)
        # [SHOT_TYPE_OVERRIDE] should be the last line
        last_line = directive.strip().split("\n")[-1]
        assert "[SHOT_TYPE_OVERRIDE]" in last_line

    def test_contains_step_instructions(self):
        shot = _make_full_shot()
        directive = build_generative_directive(shot)
        assert "STEP 1:" in directive
        assert "STEP 2:" in directive

    def test_contains_dialogue_when_present(self):
        shot = _make_full_shot()
        shot["dialogue"] = "I won't let you do this."
        directive = build_generative_directive(shot)
        assert "I won't let you do this." in directive

    def test_env_only_shot(self):
        shot = _make_full_shot()
        shot["routing_data"] = {"is_env_only": True}
        directive = build_generative_directive(shot)
        assert "ENVIRONMENT ONLY" in directive

    def test_does_not_contain_constraints(self):
        """Generative directive should NOT contain behavioral constraints."""
        shot = _make_full_shot()
        directive = build_generative_directive(shot)
        assert "COMPOSITIONAL CONSTRAINTS" not in directive
        assert "HAIR & APPEARANCE LOCK" not in directive


# ── build_system_instruction backward compat ────────────────────────


class TestBuildSystemInstruction:
    """Tests for build_system_instruction() backward compatibility."""

    def test_contains_both_preamble_and_directive(self):
        shot = _make_full_shot(shot_id="EP001_SC01_SH005")
        with patch(
            "recoil.pipeline._lib.previz_context.load_prompt_file", return_value=""
        ):
            combined = build_system_instruction(shot)
        # Preamble content
        assert "COMPOSITIONAL CONSTRAINTS" in combined
        # Directive content
        assert "YOUR TASK: Generate shot EP001_SC01_SH005" in combined
        assert "[SHOT_TYPE_OVERRIDE]" in combined


# ── build_previz_context 8-slot ordering tests ──────────────────────


class TestBuildPrevizContext8Slot:
    """Tests for build_previz_context() 8-slot ordering."""

    def _make_all_shots(self):
        """Create 5 shots for sequence testing."""
        shots = []
        for i in range(1, 6):
            shots.append(
                {
                    "shot_id": f"EP001_SC01_SH{i:03d}",
                    "prompt_data": {
                        "shot_type": "MS",
                        "prompt_skeleton": {"subject_line": f"Shot {i} action"},
                    },
                    "asset_data": {
                        "characters": [{"char_id": "TORCH"}],
                        "location_id": "INT_CARGO_BAY",
                    },
                }
            )
        return shots

    @patch(
        "recoil.pipeline._lib.previz_context.resolve_previz_character_refs",
        return_value=[],
    )
    @patch("recoil.pipeline._lib.previz_context.resolve_location_refs", return_value=[])
    @patch(
        "recoil.pipeline._lib.previz_context.extract_mentioned_props", return_value=[]
    )
    @patch("recoil.pipeline._lib.previz_context.load_prompt_file", return_value="")
    def test_slot_ordering_text_first_and_last(self, _lp, _emp, _rlr, _rpcr):
        """Slot [1] is behavioral preamble, slot [8] is generative directive."""
        shot = _make_full_shot(shot_id="EP001_SC01_SH003")
        all_shots = self._make_all_shots()
        parts = build_previz_context(shot, all_shots, bible=SAMPLE_BIBLE, episode=1)

        # First part should be text (behavioral preamble)
        assert parts[0][1] == "text"
        assert "previz cinematographer" in parts[0][2]
        assert "COMPOSITIONAL CONSTRAINTS" in parts[0][2]

        # Last part should be text (generative directive)
        assert parts[-1][1] == "text"
        assert "YOUR TASK:" in parts[-1][2]
        assert "[SHOT_TYPE_OVERRIDE]" in parts[-1][2]

    @patch(
        "recoil.pipeline._lib.previz_context.resolve_previz_character_refs",
        return_value=[],
    )
    @patch("recoil.pipeline._lib.previz_context.resolve_location_refs", return_value=[])
    @patch(
        "recoil.pipeline._lib.previz_context.extract_mentioned_props", return_value=[]
    )
    @patch("recoil.pipeline._lib.previz_context.load_prompt_file", return_value="")
    def test_bible_is_scoped(self, _lp, _emp, _rlr, _rpcr):
        """Bible in context should be scoped to shot characters/location."""
        shot = _make_full_shot(
            shot_id="EP001_SC01_SH003",
            characters=[{"char_id": "TORCH", "screen_position": "center"}],
        )
        shot["asset_data"]["location_id"] = "INT_CARGO_BAY"
        all_shots = self._make_all_shots()
        parts = build_previz_context(shot, all_shots, bible=SAMPLE_BIBLE, episode=1)

        # Find the bible text part (slot [2] -- starts with "# VISUAL BIBLE")
        bible_parts = [
            p
            for p in parts
            if p[1] == "text" and (p[2] or "").startswith("# VISUAL BIBLE")
        ]
        assert len(bible_parts) == 1
        bible_text = bible_parts[0][2]

        # Should include TORCH but not KAEL or MARA
        assert "Torch" in bible_text
        assert "Kael" not in bible_text
        assert "Mara" not in bible_text

        # Should include INT_CARGO_BAY but not others
        assert "INT_CARGO_BAY" in bible_text
        assert "EXT_DOCKING_RING" not in bible_text

    @patch(
        "recoil.pipeline._lib.previz_context.resolve_previz_character_refs",
        return_value=[],
    )
    @patch("recoil.pipeline._lib.previz_context.resolve_location_refs", return_value=[])
    @patch(
        "recoil.pipeline._lib.previz_context.extract_mentioned_props", return_value=[]
    )
    @patch("recoil.pipeline._lib.previz_context.load_prompt_file", return_value="")
    def test_3_shot_window(self, _lp, _emp, _rlr, _rpcr):
        """Shot sequence should only include N-1, current, N+1."""
        shot = _make_full_shot(shot_id="EP001_SC01_SH003")
        all_shots = self._make_all_shots()
        parts = build_previz_context(shot, all_shots, bible=None, episode=1)

        # Find the sequence text part (slot [3] -- starts with "## EPISODE SHOT SEQUENCE")
        seq_parts = [
            p
            for p in parts
            if p[1] == "text" and (p[2] or "").startswith("## EPISODE SHOT SEQUENCE")
        ]
        assert len(seq_parts) == 1
        seq_text = seq_parts[0][2]

        # Should include SH002 (N-1), SH003 (current), SH004 (N+1)
        assert "SH002" in seq_text
        assert "SH003" in seq_text
        assert "SH004" in seq_text
        # Should NOT include SH001 or SH005
        assert "SH001" not in seq_text
        assert "SH005" not in seq_text

    @patch(
        "recoil.pipeline._lib.previz_context.resolve_previz_character_refs",
        return_value=[],
    )
    @patch("recoil.pipeline._lib.previz_context.resolve_location_refs", return_value=[])
    @patch(
        "recoil.pipeline._lib.previz_context.extract_mentioned_props", return_value=[]
    )
    @patch("recoil.pipeline._lib.previz_context.load_prompt_file", return_value="")
    def test_uses_previz_character_refs_not_all(self, _lp, _emp, _rlr, rpcr_mock):
        """build_previz_context uses resolve_previz_character_refs, not resolve_all_character_refs."""
        shot = _make_full_shot()
        all_shots = [shot]
        build_previz_context(shot, all_shots, bible=None, episode=1)
        # resolve_previz_character_refs should have been called
        rpcr_mock.assert_called_once()

    @patch(
        "recoil.pipeline._lib.previz_context.resolve_previz_character_refs",
        return_value=[],
    )
    @patch("recoil.pipeline._lib.previz_context.resolve_location_refs", return_value=[])
    @patch(
        "recoil.pipeline._lib.previz_context.extract_mentioned_props", return_value=[]
    )
    @patch("recoil.pipeline._lib.previz_context.load_prompt_file", return_value="")
    def test_minimal_context_has_preamble_and_directive(self, _lp, _emp, _rlr, _rpcr):
        """Even with no bible/refs, we get preamble + directive."""
        shot = _make_full_shot()
        parts = build_previz_context(shot, [], bible=None, episode=None)

        # Should have at least 2 parts: preamble and directive
        assert len(parts) >= 2
        assert parts[0][1] == "text"  # preamble
        assert parts[-1][1] == "text"  # directive


# ── resolve_expression_ref tests ─────────────────────────────────────


class TestResolveExpressionRef:
    """Tests for resolve_expression_ref()."""

    def _make_shot_with_emotion(self, emotion_line=None, char_emotion_keyword=None):
        """Helper: build a shot dict with optional emotion data."""
        shot = {
            "prompt_data": {
                "shot_type": "MS",
                "prompt_skeleton": {},
            },
            "asset_data": {
                "characters": [{"char_id": "TORCH", "screen_position": "center"}],
                "location_id": "INT_CARGO_BAY",
            },
        }
        if emotion_line:
            shot["prompt_data"]["prompt_skeleton"]["emotion_line"] = emotion_line
        if char_emotion_keyword:
            shot["asset_data"]["characters"][0]["emotion_keyword"] = (
                char_emotion_keyword
            )
        return shot

    @staticmethod
    def _patch_project(tmp_path):
        """Redirect PIPELINE_ROOT to tmp_path so the SHARED expression ref
        library (PIPELINE_ROOT/assets/expressions/) resolves under tmp."""
        from contextlib import ExitStack

        stack = ExitStack()
        stack.enter_context(
            patch(
                "recoil.pipeline._lib.previz_context.PIPELINE_ROOT", tmp_path
            )
        )
        return stack

    def _expr_dir(self, tmp_path):
        """Return and create the SHARED expression refs dir
        (PIPELINE_ROOT/assets/expressions/) under the patched PIPELINE_ROOT."""
        d = tmp_path / "assets" / "expressions"
        d.mkdir(parents=True, exist_ok=True)
        return d

    def test_resolves_anger_emotion(self, tmp_path):
        """Maps 'rage' to anger_active (default intensity band)."""
        expr_dir = self._expr_dir(tmp_path)
        (expr_dir / "anger_active.png").write_bytes(b"anger_ref")

        shot = self._make_shot_with_emotion(char_emotion_keyword="rage")
        with self._patch_project(tmp_path):
            result = resolve_expression_ref(shot)

        assert result is not None
        assert result[0] == b"anger_ref"
        assert result[1] == "image/png"
        assert "anger" in result[2]

    def test_resolves_sadness_from_emotion_line(self, tmp_path):
        """Parses 'quiet sadness' -> sadness_subtle (quiet => low/subtle band)."""
        expr_dir = self._expr_dir(tmp_path)
        (expr_dir / "sadness_subtle.png").write_bytes(b"sad_ref")

        shot = self._make_shot_with_emotion(emotion_line="quiet sadness")
        with self._patch_project(tmp_path):
            result = resolve_expression_ref(shot)

        assert result is not None
        assert result[0] == b"sad_ref"
        assert "sadness" in result[2]
        assert "subtle" in result[2]

    def test_returns_none_for_neutral(self):
        """No emotion data = no ref."""
        shot = self._make_shot_with_emotion()
        result = resolve_expression_ref(shot)
        assert result is None

    def test_returns_none_when_file_missing(self, tmp_path):
        """Correct emotion parsed but ref file doesn't exist on disk."""
        shot = self._make_shot_with_emotion(emotion_line="intense determination")
        with self._patch_project(tmp_path):
            result = resolve_expression_ref(shot)
        assert result is None

    def test_intensity_mapping(self, tmp_path):
        """'intense determination' -> determination_extreme (high/extreme band)."""
        expr_dir = self._expr_dir(tmp_path)
        (expr_dir / "determination_extreme.png").write_bytes(b"det_ref")

        shot = self._make_shot_with_emotion(emotion_line="intense determination")
        with self._patch_project(tmp_path):
            result = resolve_expression_ref(shot)

        assert result is not None
        assert result[0] == b"det_ref"
        assert "determination" in result[2]
        assert "extreme" in result[2]

    def test_char_emotion_keyword_takes_priority(self, tmp_path):
        """asset_data emotion_keyword overrides emotion_line."""
        expr_dir = self._expr_dir(tmp_path)
        (expr_dir / "anger_active.png").write_bytes(b"anger_ref")
        (expr_dir / "joy_active.png").write_bytes(b"joy_ref")

        shot = self._make_shot_with_emotion(
            emotion_line="warm happiness",
            char_emotion_keyword="angry",
        )
        with self._patch_project(tmp_path):
            result = resolve_expression_ref(shot)

        assert result is not None
        # Should pick anger (from emotion_keyword), not joy (from emotion_line)
        assert result[0] == b"anger_ref"
        assert "anger" in result[2]
