"""build_scene_attached_refs helper — Task 1 of single-vs-batch reference contract fix.

spec: docs/superpowers/specs/2026-05-08-single-batch-reference-contract-design.md §4.1
plan: docs/superpowers/plans/2026-05-08-single-batch-reference-contract-implementation.md Task 1

배치 generate_images 의 ref 빌드 블록(coordinator.py:178-326) 동등 보존:
chain_bg insert (5a) / close skip + prev_shot try (5b) / chain 부재 + prev_shot try (5c) /
entity-only fallback (5d) — 분기 명시.

helper signature v2 (audit IMPORTANT 1 반영):
- state_variant_sids 는 helper 내부 detect / best_prev_bytes + bytes_source_kind
  는 coordinator 가 resolve (provenance annotator, spec
  docs/superpowers/specs/2026-05-15-zoom-in-detail-source-provenance-design.md §3)
- cached_style_context + cached_entity_text_map 은 caller 가 episode 단위 build 후 전달
"""
from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from app.services.prompt_service import make_labeled_ref_payload


def _make_helper_inputs(
    *,
    has_chain_bg: bool,
    is_close: bool,
    has_prev_shot: bool,
    tmp_path: Path,
):
    """helper input dict 빌드 — 분기별 fixture."""
    # Area #11 v1 W3: chain_bg 의 bg_id 필드 — production fail-fast guard
    # (chain_bg insert 분기에서 bg_id None/missing 시 RefRoleError raise, No
    # Silent Fallback gate). fixture 도 production contract 정합.
    bc_bg = (
        {"label": "BACKGROUND chain reference", "image_bytes": b"bg_bytes", "bg_id": "B99"}
        if has_chain_bg else None
    )

    # mock SceneReferenceService — 4 method 결과 정의
    ref_svc = MagicMock()
    # build_scene_ref_image_map: location 제외 결과
    ref_svc.build_scene_ref_image_map = MagicMock(return_value={"c01": b"face"})
    # detect_state_variant_sids: 본 fixture 는 state variant 없음
    ref_svc.detect_state_variant_sids = MagicMock(return_value={})
    # resolve_refs_for_prompt / resolve_refs_for_prompt_set: character composite ref 1개
    # Area #11 v1 W3: 2-tuple → LabeledRefPayload (4 parallel list, W2 cascade).
    # Fix A (2026-05-10): coordinator 가 resolve_refs_for_prompt_set 사용 —
    # legacy single-prompt API 도 fixture 호환 위해 양쪽 동일 mock 노출.
    _resolve_return = make_labeled_ref_payload(
        labeled_refs=[("character C01O02 in outfit", b"composite")],
        ref_roles=["character_ref"],
        ref_role_metadata=[{}],
        attached_meta=[("character_outlook", "C01O02")],
    )
    ref_svc.resolve_refs_for_prompt = MagicMock(return_value=_resolve_return)
    ref_svc.resolve_refs_for_prompt_set = MagicMock(return_value=_resolve_return)
    # build_prev_shot_background_ref: prev_shot ref 또는 None
    # Area #11 v1 W3: 3-tuple → 5-tuple (label, bytes, loc_id, ref_role, ref_role_metadata).
    ref_svc.build_prev_shot_background_ref = MagicMock(
        return_value=("previous shot SAME ROOM", b"prev_shot_bytes", "L99", "previous_shot_same_room", {})
        if has_prev_shot else None
    )

    still = MagicMock()
    still.camera_json = "{}"
    still.scene_index = 8
    still.still_index = 0
    still.shot_index = 4

    still_data = {
        "scene_index": 8,
        "shot_index": 4,
        "still_frame_prompt": "test prompt",
        "beat_title": "test beat",
        "dependent_scene_id": None,
    }

    visible_entities = [
        {"id": "c01", "short_id": "C01", "entity_type": "character", "name": "C1"},
    ]

    return {
        "still": still,
        "episode_id": "ep_test",
        "still_data": still_data,
        "visible_entities": visible_entities,
        "ref_image_map": {"c01": b"face"},
        "cached_style_context": "",
        "cached_entity_text_map": {"C01": "Korean young woman"},
        "scene_paths_by_index_by_id": {},
        "location_scene_history": {},
        "background_chain_bg_map": {"8_4": bc_bg} if bc_bg else {},
        "dep_detail_map": {},
        "staging": {
            "camera_direction": "extreme close-up" if is_close else "medium shot",
            "framing_scale": "close" if is_close else "medium",
        },
        "entity_lookup": {
            "c01": {"id": "c01", "short_id": "C01", "entity_type": "character", "name": "C1"},
        },
        "project_id": "p_test",
        "project_config": {},
        "reference_svc": ref_svc,
        "stills": [still],
    }


@pytest.fixture(autouse=True)
def _patch_helper_dependencies(tmp_path):
    """coordinator 모듈의 외부 dependency mock — load_shot_t2i_variations,
    _build_final_scene_prompt, _build_image_index_helper, _rewrite_t2i_helper.

    Task 1 helper 의 직접 의존성만 mock — reference_svc 는 input fixture 로 주입.
    """
    with patch(
        "app.services.scene_generation_coordinator.load_shot_t2i_variations",
        # Area #5 W3: producer (scene_detail v26) emit `reference_phrase_kinds` per
        # variation. fixture default = empty list (non-phantom intent).
        return_value=[{"t2i_prompt": "Photorealistic still. C01O02 walks.", "reference_phrase_kinds": []}],
    ), patch(
        "app.services.scene_generation_coordinator._build_final_scene_prompt",
        # Area #11 v1 W3: signature (var_t2i, payload, style_ctx, **kw) — payload kwarg cascade.
        side_effect=lambda var_t2i, payload, style_ctx, **kw: f"FINAL[{var_t2i}]",
    ), patch(
        "app.services.scene_generation_coordinator._build_image_index_helper",
        # Area #11 v1 W3: 4-tuple → 6-tuple (labeled_refs, sid_to_img, sid_info,
        # ref_roles, ref_role_metadata, attached_meta) — 3 parallel list passthrough.
        side_effect=lambda labeled_refs, entity_lookup, *, ref_roles=None, ref_role_metadata=None, attached_meta=None: (
            labeled_refs, {}, {},
            list(ref_roles or []),
            list(ref_role_metadata or []),
            list(attached_meta or []),
        ),
    ), patch(
        "app.services.scene_generation_coordinator._rewrite_t2i_helper",
        side_effect=lambda var_t2i, sid_to_img, sid_info: var_t2i,
    ), patch(
        # FINDING 11: build_scene_attached_refs 가 'character' over-declaration
        # normalization helper 호출 전 render_prompt_card 를 resolve. 분기 fixture
        # 의 rpk default 는 [] 이므로 빈 required_refs RPC 로 helper no-op.
        "app.services.scene_generation_coordinator.lookup_render_prompt_card",
        return_value={"asset_requirements": {"required_refs": []}},
    ):
        yield


def test_helper_optionc_threads_staging_with_force_character_names(tmp_path):
    """W21B-W8 option C — guide 샷(forced_character_names 존재)에서 helper 가
    resolve_refs_for_prompt_set 에 **staging 과 force_character_names 를 함께** 넘긴다.
    staging 누락 시 staged-character backstop(`if staging:`)이 휴면해 강제 attach 가
    발화하지 않으므로 두 인자가 같이 가야 한다 (Codex BLOCKING 1 회귀 가드)."""
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=True, tmp_path=tmp_path,
    )
    guide_ctx = {(8, 4): {
        "mode": "continuity_anchor", "anchor_source": [8, 3],
        "location_id": "L99", "group_id": "oslcg-l99-s8",
        "forced_character_names": ["C1"],
    }}
    with patch(
        "app.core.steps.outdoor_site_layout_step.load_composition_guide_context",
        return_value=guide_ctx,
    ):
        build_scene_attached_refs(**inputs)

    _call = inputs["reference_svc"].resolve_refs_for_prompt_set.call_args
    assert _call.kwargs.get("force_character_names") == {"C1"}
    # staging 이 None 이 아닌 실제 staging 으로 전달돼야 backstop 이 깬다
    assert _call.kwargs.get("staging") is inputs["staging"]


def test_helper_optionc_no_staging_thread_without_force(tmp_path):
    """option C byte-identical 가드 — guide 부재(force 없음) 샷은 staging 을 resolve
    에 넘기지 않는다(None) = backstop 휴면 = default 경로 불변."""
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=True, tmp_path=tmp_path,
    )
    with patch(
        "app.core.steps.outdoor_site_layout_step.load_composition_guide_context",
        return_value={},
    ):
        build_scene_attached_refs(**inputs)

    _call = inputs["reference_svc"].resolve_refs_for_prompt_set.call_args
    assert _call.kwargs.get("staging") is None
    assert _call.kwargs.get("force_character_names") is None


def test_helper_5a_chain_bg_inserts_when_not_close_framing(tmp_path):
    """5a: chain_bg + NOT close → labeled_refs[0] == BACKGROUND chain reference.

    배치 line 285 동등.
    """
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=True, is_close=False, has_prev_shot=False, tmp_path=tmp_path,
    )
    full_prompt, labeled_refs, _attached_meta, _phrase_kinds = build_scene_attached_refs(**inputs)  # Area #5 W3: 4-tuple

    # 5a: chain_bg 가 첫 번째 ref
    assert labeled_refs[0][0] == "BACKGROUND chain reference"
    # character composite 도 보존
    assert any("character C01O02 in outfit" == lbl for lbl, _ in labeled_refs)
    # prev_shot_ref 호출 안 됨 (chain_bg insert 분기)
    assert inputs["reference_svc"].build_prev_shot_background_ref.call_count == 0


def test_helper_5b_chain_bg_skipped_close_framing_falls_back_to_prev_shot(tmp_path):
    """5b: chain_bg 있음 + close → chain skip + prev_shot try.

    배치 line 292-325 동등 (Phase 9.1 close framing skip 정책).
    """
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=True, is_close=True, has_prev_shot=True, tmp_path=tmp_path,
    )
    full_prompt, labeled_refs, _attached_meta, _phrase_kinds = build_scene_attached_refs(**inputs)  # Area #5 W3: 4-tuple

    # 5b: chain_bg 가 아니라 prev_shot 이 첫 번째
    assert labeled_refs[0][0] == "previous shot SAME ROOM"
    # chain_bg label 은 미포함 (skip)
    assert not any("BACKGROUND chain reference" == lbl for lbl, _ in labeled_refs)
    # prev_shot_ref 호출 됨
    assert inputs["reference_svc"].build_prev_shot_background_ref.call_count == 1


def test_helper_5c_no_chain_bg_falls_back_to_prev_shot(tmp_path):
    """5c: chain_bg 부재 → prev_shot try.

    배치 line 304-325 동등 (chain 없음 outdoor/저빈도 location).
    """
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=True, tmp_path=tmp_path,
    )
    full_prompt, labeled_refs, _attached_meta, _phrase_kinds = build_scene_attached_refs(**inputs)  # Area #5 W3: 4-tuple

    # prev_shot 이 첫 번째
    assert labeled_refs[0][0] == "previous shot SAME ROOM"
    assert inputs["reference_svc"].build_prev_shot_background_ref.call_count == 1


def test_helper_5e_dep_scene_continuity_beats_bg_map(tmp_path):
    """5e (W2, 2026-06-11 fresh full E2E S12 실측): dep_scene continuity 가 있는
    shot 은 bg map(빈 establishing plate/chain bg)이 있어도 prev_shot(dep) 우선.

    시신 자세·핏자국·직전 동작 같은 dynamic state 는 prev_shot 프레임에만 있다 —
    빈 space plate 가 그 슬롯을 대체하면 연속성이 끊긴다 (S12 sh7↔sh12 자세 불일치).
    우선순위: dep_scene continuity > bg map > location_history.
    """
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=True, is_close=False, has_prev_shot=True, tmp_path=tmp_path,
    )
    dep_png = tmp_path / "dep_scene.png"
    dep_png.write_bytes(b"dep_frame_bytes")
    inputs["still_data"]["dependent_scene_id"] = "dep_still_1"
    inputs["scene_paths_by_index_by_id"] = {"dep_still_1": dep_png}
    # W2-B: dep 우선은 dynamic-state usage 에만 — fixture 에 zoom 명시
    inputs["dep_detail_map"] = {"8_4": {
        "ref_usage": "zoom_in_detail", "ignore_elements": "", "keep_elements": [],
    }}

    full_prompt, labeled_refs, _attached_meta, _phrase_kinds = build_scene_attached_refs(**inputs)

    # bg map 이 아니라 prev_shot(dep) 이 첫 번째
    assert labeled_refs[0][0] == "previous shot SAME ROOM"
    assert not any("BACKGROUND chain reference" == lbl for lbl, _ in labeled_refs)
    assert inputs["reference_svc"].build_prev_shot_background_ref.call_count == 1
    # dep_scene bytes 가 provenance 로 전달됨
    _, call_kwargs = inputs["reference_svc"].build_prev_shot_background_ref.call_args
    assert call_kwargs["bytes_source_kind"] == "dep_scene"
    assert call_kwargs["best_prev_bytes"] == b"dep_frame_bytes"


def test_waive_required_background_no_plate_sentinel():
    """W1-A (2026-06-11 S10 실측): space_set_bg no-plate sentinel 이 있는 shot 은
    검증용 RPC '사본'에서 required background 만 제거 — space policy ↔
    render_prompt_card required 의 runtime reconciliation (validator 약화 아님).
    persisted RPC(원본 dict)는 불변."""
    from app.services.scene_generation_coordinator import (
        waive_required_background_if_no_plate,
    )
    rpc = {"asset_requirements": {
        "required_refs": [
            {"kind": "background", "id": "L04B03", "policy": "required"},
            {"kind": "character", "id": "C09", "policy": "required"},
        ],
        "readiness_policy": "block_if_missing",
    }}
    bg_map = {"10_3": {
        "source": "space_set_bg_no_plate",
        "suppress_background_required": True,
        "reason": "connector_no_plate",
    }}
    out = waive_required_background_if_no_plate(
        rpc, bg_map, {"scene_index": 10, "shot_index": 3},
    )
    kinds = [e["kind"] for e in out["asset_requirements"]["required_refs"]]
    assert kinds == ["character"]
    # character 가 남으므로 readiness 유지
    assert out["asset_requirements"]["readiness_policy"] == "block_if_missing"
    # 원본 불변 (deepcopy)
    assert len(rpc["asset_requirements"]["required_refs"]) == 2


def test_waive_required_background_clears_readiness_when_empty():
    """W1-A 경계: waiver 로 required 가 전부 비면 block_if_missing 도 해제 —
    validator step5 drift 검사와 모순 방지."""
    from app.services.scene_generation_coordinator import (
        waive_required_background_if_no_plate,
    )
    rpc = {"asset_requirements": {
        "required_refs": [{"kind": "background", "id": "L04B03", "policy": "required"}],
        "readiness_policy": "block_if_missing",
    }}
    bg_map = {"10_5": {
        "source": "space_set_bg_no_plate",
        "suppress_background_required": True,
        "reason": "unassigned",
    }}
    out = waive_required_background_if_no_plate(
        rpc, bg_map, {"scene_index": 10, "shot_index": 5},
    )
    assert out["asset_requirements"]["required_refs"] == []
    assert out["asset_requirements"]["readiness_policy"] is None


def test_waive_required_background_dep_continuity_flag():
    """W2-A (2026-06-11 S12 sh12 실측): dep continuity 우선으로 bg map 을 skip 한
    shot(`_dep_continuity_bg_waiver`)도 required background waiver — 미렌더 bg
    (L06 cascade)는 prev_shot lineage lookup 불가라 validator 가 차단했었다."""
    from app.services.scene_generation_coordinator import (
        waive_required_background_if_no_plate,
    )
    rpc = {"asset_requirements": {
        "required_refs": [
            {"kind": "background", "id": "L06B06", "policy": "required"},
            {"kind": "character_outlook", "id": "C09O01", "policy": "required"},
        ],
    }}
    out = waive_required_background_if_no_plate(
        rpc, {}, {"scene_index": 12, "shot_index": 12,
                  "_dep_continuity_bg_waiver": True},
    )
    kinds = [e["kind"] for e in out["asset_requirements"]["required_refs"]]
    assert kinds == ["character_outlook"]
    # 원본 불변
    assert len(rpc["asset_requirements"]["required_refs"]) == 2


def test_waive_required_background_noop_without_sentinel():
    """W1-A 경계: sentinel 없으면 rpc 그대로 (identity — 사본도 안 만듦)."""
    from app.services.scene_generation_coordinator import (
        waive_required_background_if_no_plate,
    )
    rpc = {"asset_requirements": {
        "required_refs": [{"kind": "background", "id": "L01B01", "policy": "required"}],
    }}
    out = waive_required_background_if_no_plate(
        rpc, {"1_1": {"image_bytes": b"X", "bg_id": "L01B01", "label": "bg"}},
        {"scene_index": 1, "shot_index": 1},
    )
    assert out is rpc


def test_helper_5e_atmosphere_dep_does_not_beat_bg_map(tmp_path):
    """W2-B 경계 (재생성 육안 실측): mood 만 제공하는 atmosphere_reference dep 는
    환경 정체성 bg map 을 밀어내지 않는다 — 밀어내면 모델이 환경을 발명한다
    (실측: 벽에 anchoring 안 된 떠 있는 유리판)."""
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=True, is_close=False, has_prev_shot=True, tmp_path=tmp_path,
    )
    dep_png = tmp_path / "dep_scene.png"
    dep_png.write_bytes(b"dep_frame_bytes")
    inputs["still_data"]["dependent_scene_id"] = "dep_still_1"
    inputs["scene_paths_by_index_by_id"] = {"dep_still_1": dep_png}
    inputs["dep_detail_map"] = {"8_4": {
        "ref_usage": "atmosphere_reference", "ignore_elements": "", "keep_elements": [],
    }}

    _fp, labeled_refs, _m, _p = build_scene_attached_refs(**inputs)

    # bg map 이 첫 번째 ref 유지 (dep 는 양보)
    assert labeled_refs[0][0] == "BACKGROUND chain reference"
    assert inputs["reference_svc"].build_prev_shot_background_ref.call_count == 0


def test_helper_w3_dep_bytes_realigned_to_dep_detail_target(tmp_path):
    """W3 (2026-06-11 S29 실측): dependent_scene_id(shot_dependency 산출)와
    dep_detail(shot_dependency_t2i)의 타깃이 다른 샷이면, 지시문 공급원인
    dep_detail 타깃 still 의 PNG 를 bytes 로 우선 — bytes↔지시 불일치가
    'SAME FRAME' 프레임 복제를 유발했다 (sh11: bytes=sh4, 지시=sh7)."""
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=True, tmp_path=tmp_path,
    )
    wrong_png = tmp_path / "wrong_dep.png"; wrong_png.write_bytes(b"WRONG_FRAME")
    right_png = tmp_path / "right_dep.png"; right_png.write_bytes(b"RIGHT_FRAME")
    inputs["still_data"]["dependent_scene_id"] = "still_wrong"
    inputs["scene_paths_by_index_by_id"] = {
        "still_wrong": wrong_png, "still_right": right_png,
    }
    # dep_detail 이 (8,7) 타깃 명시 — stills 에서 still_right 로 resolve
    inputs["dep_detail_map"] = {"8_4": {
        "ref_usage": "zoom_in_detail", "ignore_elements": "", "keep_elements": [],
        "dep_scene_index": 8, "dep_shot_index": 7,
    }}
    right_still = MagicMock()
    right_still.scene_index = 8
    right_still.shot_index = 7
    right_still.id = "still_right"
    inputs["stills"] = [inputs["stills"][0], right_still]

    build_scene_attached_refs(**inputs)

    _, call_kwargs = inputs["reference_svc"].build_prev_shot_background_ref.call_args
    assert call_kwargs["best_prev_bytes"] == b"RIGHT_FRAME"
    assert call_kwargs["dep_scene_id"] == "still_right"
    assert call_kwargs["bytes_source_kind"] == "dep_scene"


def test_helper_w3_dep_bytes_falls_back_when_target_png_missing(tmp_path):
    """W3 경계: dep_detail 타깃 still 의 PNG 가 없으면 기존 dependent_scene_id
    경로 그대로 (backward-compat, silent 변화 0)."""
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=True, tmp_path=tmp_path,
    )
    legacy_png = tmp_path / "legacy_dep.png"; legacy_png.write_bytes(b"LEGACY_FRAME")
    inputs["still_data"]["dependent_scene_id"] = "still_legacy"
    inputs["scene_paths_by_index_by_id"] = {"still_legacy": legacy_png}
    inputs["dep_detail_map"] = {"8_4": {
        "ref_usage": "zoom_in_detail", "ignore_elements": "", "keep_elements": [],
        "dep_scene_index": 8, "dep_shot_index": 7,  # 타깃 still PNG 미존재
    }}

    build_scene_attached_refs(**inputs)

    _, call_kwargs = inputs["reference_svc"].build_prev_shot_background_ref.call_args
    assert call_kwargs["best_prev_bytes"] == b"LEGACY_FRAME"
    assert call_kwargs["dep_scene_id"] == "still_legacy"


def test_helper_5e_close_framing_log_still_prevails_over_dep(tmp_path):
    """5e 경계: close + dep 동시면 기존 close skip 의미 유지 — 둘 다 bg map skip
    이므로 결과 동일(prev_shot), 회귀 방지용 고정."""
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=True, is_close=True, has_prev_shot=True, tmp_path=tmp_path,
    )
    dep_png = tmp_path / "dep_scene.png"
    dep_png.write_bytes(b"dep_frame_bytes")
    inputs["still_data"]["dependent_scene_id"] = "dep_still_1"
    inputs["scene_paths_by_index_by_id"] = {"dep_still_1": dep_png}
    inputs["dep_detail_map"] = {"8_4": {
        "ref_usage": "zoom_in_detail", "ignore_elements": "", "keep_elements": [],
    }}

    _fp, labeled_refs, _m, _p = build_scene_attached_refs(**inputs)
    assert labeled_refs[0][0] == "previous shot SAME ROOM"
    assert not any("BACKGROUND chain reference" == lbl for lbl, _ in labeled_refs)


def test_helper_5d_entity_only_when_no_bg_no_prev_shot(tmp_path):
    """5d: chain_bg 없음 + prev_shot 도 없음 → entity-only fallback (silent skip 아님).

    배치 line 320-325 동등 (Codex review C3 fallback 보존).
    """
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=False, tmp_path=tmp_path,
    )
    full_prompt, labeled_refs, _attached_meta, _phrase_kinds = build_scene_attached_refs(**inputs)  # Area #5 W3: 4-tuple

    # BACKGROUND / previous shot 모두 미포함
    assert not any("BACKGROUND" in lbl or "previous shot" in lbl for lbl, _ in labeled_refs)
    # character composite 만 (entity-only)
    assert labeled_refs == [("character C01O02 in outfit", b"composite")]
    # prev_shot_ref 호출은 됐으나 None 반환
    assert inputs["reference_svc"].build_prev_shot_background_ref.call_count == 1


def test_helper_reraises_ref_contract_error_from_prev_shot(tmp_path):
    """RefContractError 는 entity-only fallback 으로 삼키지 않고 propagate."""
    from app.core.ref_contract_validator import RefContractError
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=False, tmp_path=tmp_path,
    )
    inputs["reference_svc"].build_prev_shot_background_ref.side_effect = (
        RefContractError("close_ref_usage_violation: synthetic")
    )

    with pytest.raises(RefContractError) as excinfo:
        build_scene_attached_refs(**inputs)
    assert "close_ref_usage_violation" in str(excinfo.value)


def test_helper_swallows_non_contract_prev_shot_exception(tmp_path):
    """Non-contract prev_shot errors remain entity-only fallback."""
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=False, tmp_path=tmp_path,
    )
    inputs["reference_svc"].build_prev_shot_background_ref.side_effect = (
        ValueError("synthetic non-contract")
    )

    _full_prompt, labeled_refs, attached_meta, _phrase_kinds = build_scene_attached_refs(**inputs)  # Area #5 W3: 4-tuple
    assert labeled_refs == [("character C01O02 in outfit", b"composite")]
    assert attached_meta == [("character_outlook", "C01O02")]


def test_batch_path_reraises_ref_contract_error_from_prev_shot(tmp_path):
    """_generate_scene_in_loop batch path 도 RefContractError 를 reraise.

    FINDING 9 Cat3: rpc resolution 블록이 resolver 호출 이전으로 relocate 되며
    _generate_scene_in_loop 가 prev_shot 도달 전에 lookup_render_prompt_card
    를 거친다 — _project_id 세팅 + lookup_render_prompt_card patch 필요
    (test intent = prev_shot RefContractError reraise 검증, 불변).
    """
    from app.core.ref_contract_validator import RefContractError
    from app.services.scene_generation_coordinator import SceneGenerationCoordinator

    coord = SceneGenerationCoordinator.__new__(SceneGenerationCoordinator)
    coord._project_id = "p_test"
    coord._reference_svc = MagicMock()
    coord._reference_svc.build_scene_ref_image_map.return_value = {}
    coord._reference_svc.detect_state_variant_sids.return_value = {}
    coord._reference_svc.resolve_refs_for_prompt_set.return_value = make_labeled_ref_payload(labeled_refs=[], ref_roles=[], ref_role_metadata=[], attached_meta=[])
    coord._reference_svc.build_prev_shot_background_ref.side_effect = (
        RefContractError("close_ref_usage_violation: synthetic")
    )

    stills = [{
        "scene_index": 8,
        "shot_index": 4,
        "visible_entities_json": "[]",
        "still_frame_prompt": "test prompt",
        "t2i_variations": [{"t2i_prompt": "test prompt"}],
        "dependent_scene_id": None,
    }]

    with patch(
        "app.services.scene_generation_coordinator.lookup_render_prompt_card",
        return_value={"asset_requirements": {"required_refs": []}},
    ), pytest.raises(RefContractError) as excinfo:
        coord._generate_scene_in_loop(
            si=0,
            stills=stills,
            entity_lookup={},
            scene_paths_by_index_by_id={},
            location_scene_history={},
            staging_map={"8_4": {"camera_direction": "medium", "framing_scale": "medium"}},
            scene_ref_image_map={},
            background_chain_bg_map={},
            dep_detail_map={},
            gemini_client=MagicMock(),
            sanitizer=MagicMock(),
            validator=None,
            scene_dir=tmp_path,
            cached_style_context="",
            cached_entity_text_map={},
            world_guide={},
            episode_id="ep_test",
        )
    assert "close_ref_usage_violation" in str(excinfo.value)


def test_helper_state_variant_detected_internally(tmp_path):
    """v2 audit IMPORTANT 1: state_variant_sids 가 helper 내부 detect.

    caller 가 state_variant_sids input 안 줌 — helper 가 reference_svc.detect_state_variant_sids 호출.
    """
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=False, tmp_path=tmp_path,
    )
    # state_variant_sids 가 helper input signature 에 없음 — 내부 detect_state_variant_sids 호출 검증
    build_scene_attached_refs(**inputs)
    assert inputs["reference_svc"].detect_state_variant_sids.call_count == 1


def test_coordinator_emits_bytes_source_kind_location_history(tmp_path):
    """coordinator 가 location_history fallback bytes 결정 시 bytes_source_kind='location_history'
    명시 emit. dep_scene 도 동일 — coordinator = provenance annotator
    (spec docs/superpowers/specs/2026-05-15-zoom-in-detail-source-provenance-design.md §3 §6).
    """
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    # location_scene_history 에 location bytes 있는 case
    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=True, tmp_path=tmp_path,
    )
    # visible_entities 에 location 추가 → location_scene_history 매칭
    inputs["visible_entities"].append(
        {"id": "l01", "short_id": "L01", "entity_type": "location", "name": "loc1"}
    )
    inputs["entity_lookup"]["l01"] = inputs["visible_entities"][-1]
    inputs["location_scene_history"] = {"l01": (b"prev_loc_bytes", {"meta": "x"})}

    build_scene_attached_refs(**inputs)
    # coordinator 가 best_prev_bytes + bytes_source_kind 명시 전달 검증
    call_kwargs = inputs["reference_svc"].build_prev_shot_background_ref.call_args.kwargs
    assert call_kwargs.get("best_prev_bytes") == b"prev_loc_bytes"
    assert call_kwargs.get("bytes_source_kind") == "location_history"  # 신규 검증 (spec §6)


def test_helper_recovers_bc_key_from_still_when_still_data_omits_indices(tmp_path):
    """Regression — 단건 regen path 결함 (2026-05-09):

    scene_image_service.generate_single_scene_image 가 빌드하는 still_data 에
    scene_index/shot_index 가 누락되어 있을 때, helper 는 still 객체에서 fallback 해
    _bc_key 를 정확히 계산해야 한다.

    fallback 없으면 _bc_key="0_0" 으로 chain_bg miss → attached_meta 에 background
    누락 → D5 validator 가 단건 regen path 만 422 ref_contract.violation 으로 차단
    (batch path 는 still_data 에 scene_index 명시되어 있어 영향 없음).
    """
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=True, is_close=False, has_prev_shot=False, tmp_path=tmp_path,
    )
    # production scene_image_service still_data shape 재현 — scene_index/shot_index 누락
    inputs["still_data"].pop("scene_index", None)
    inputs["still_data"].pop("shot_index", None)
    # still 객체에는 정확한 indices (DB ORM 에서 읽힘)
    inputs["still"].scene_index = 8
    inputs["still"].shot_index = 4
    # bg_map 의 entry 에 bg_id 명시 (attached_meta SOT)
    inputs["background_chain_bg_map"]["8_4"]["bg_id"] = "bg_target"

    full_prompt, labeled_refs, attached_meta, _phrase_kinds = build_scene_attached_refs(**inputs)  # Area #5 W3: 4-tuple

    # chain_bg ref 가 still fallback 으로 정상 첨부 (RED: fallback 없으면 fail)
    assert labeled_refs[0][0] == "BACKGROUND chain reference"
    assert ("background", "bg_target") in attached_meta


def test_helper_cached_style_context_used_no_rebuild(tmp_path):
    """v2 audit IMPORTANT 1: cached_style_context + cached_entity_text_map caller 전달.

    helper 안 _get_style_context / build_entity_text_map 호출 안 함 (회귀 방지).
    """
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=False, tmp_path=tmp_path,
    )
    inputs["cached_style_context"] = "PROVIDED_BY_CALLER"
    inputs["cached_entity_text_map"] = {"C01": "PROVIDED_TEXT_MAP"}

    full_prompt, _refs, _meta, _phrase_kinds = build_scene_attached_refs(**inputs)  # Area #5 W3: 4-tuple

    # _build_final_scene_prompt 가 cached_style_context 받음 (mock side_effect 가 var_t2i echo)
    # build_entity_text_map 호출 안 됨 (helper 내부 build 안 함)
    # ref_svc.build_entity_text_map 호출 0
    assert inputs["reference_svc"].build_entity_text_map.call_count == 0


# ──────────────────────────────────────────────────────────────────────
# 2026-05-15 zoom_in_detail source provenance hardening (spec §7 #9a + #9b)
# coordinator single/batch callsite 가 dep_scene path 결정 시
# bytes_source_kind="dep_scene" emit 검증.
# ──────────────────────────────────────────────────────────────────────


def test_helper_emits_dep_scene_bytes_source_kind_when_dep_path_read_success(tmp_path):
    """spec §7 #9a — single callsite (build_scene_attached_refs):
    dep_scene_id + path 존재 → bytes_source_kind='dep_scene' emit.
    """
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    dep_path = tmp_path / "dep_scene.png"
    dep_path.write_bytes(b"DEP_SCENE_BYTES")

    inputs = _make_helper_inputs(
        has_chain_bg=False,
        is_close=False,
        has_prev_shot=True,
        tmp_path=tmp_path,
    )
    # dep_scene path override
    inputs["still_data"]["dependent_scene_id"] = "S0_Shot1"
    inputs["scene_paths_by_index_by_id"] = {"S0_Shot1": dep_path}

    build_scene_attached_refs(**inputs)

    call_kwargs = inputs["reference_svc"].build_prev_shot_background_ref.call_args.kwargs
    assert call_kwargs.get("bytes_source_kind") == "dep_scene"
    assert call_kwargs.get("best_prev_bytes") == b"DEP_SCENE_BYTES"


def test_batch_emits_dep_scene_bytes_source_kind_when_dep_path_read_success(tmp_path):
    """spec §7 #9b — batch callsite (_generate_scene_in_loop):
    dep_scene_id + path 존재 → bytes_source_kind='dep_scene' emit.

    test_batch_path_reraises_ref_contract_error_from_prev_shot 패턴 따름 —
    prev_shot mock 을 RefContractError side_effect 로 두어 prev_shot 호출
    직후 _generate_scene_in_loop 가 멈춘다. FINDING 9 Cat3 relocation 이후
    rpc resolution 이 resolver 호출 이전에 일어나므로 _project_id 세팅 +
    lookup_render_prompt_card patch 가 필요 — 본 test scope 는 여전히
    prev_shot 호출 시점 call_args.kwargs 검증만.
    """
    from app.core.ref_contract_validator import RefContractError
    from app.services.scene_generation_coordinator import SceneGenerationCoordinator

    dep_path = tmp_path / "dep_scene.png"
    dep_path.write_bytes(b"DEP_SCENE_BYTES")

    coord = SceneGenerationCoordinator.__new__(SceneGenerationCoordinator)
    coord._project_id = "p_test"
    coord._reference_svc = MagicMock()
    coord._reference_svc.build_scene_ref_image_map.return_value = {}
    coord._reference_svc.detect_state_variant_sids.return_value = {}
    coord._reference_svc.resolve_refs_for_prompt_set.return_value = make_labeled_ref_payload(labeled_refs=[], ref_roles=[], ref_role_metadata=[], attached_meta=[])
    # prev_shot 호출 직후 stop 위해 RefContractError side_effect — 본 test 의 scope =
    # call_args.kwargs["bytes_source_kind"] 검증만.
    coord._reference_svc.build_prev_shot_background_ref.side_effect = (
        RefContractError("synthetic_for_call_args_inspection")
    )

    stills = [{
        "scene_index": 8,
        "shot_index": 4,
        "visible_entities_json": "[]",
        "still_frame_prompt": "test prompt",
        "t2i_variations": [{"t2i_prompt": "test prompt"}],
        "dependent_scene_id": "S0_Shot1",
    }]

    with patch(
        "app.services.scene_generation_coordinator.lookup_render_prompt_card",
        return_value={"asset_requirements": {"required_refs": []}},
    ), pytest.raises(RefContractError, match="synthetic_for_call_args_inspection"):
        coord._generate_scene_in_loop(
            si=0,
            stills=stills,
            entity_lookup={},
            scene_paths_by_index_by_id={"S0_Shot1": dep_path},
            location_scene_history={},
            staging_map={"8_4": {"camera_direction": "medium", "framing_scale": "medium"}},
            scene_ref_image_map={},
            background_chain_bg_map={},
            dep_detail_map={},
            gemini_client=MagicMock(),
            sanitizer=MagicMock(),
            validator=None,
            scene_dir=tmp_path,
            cached_style_context="",
            cached_entity_text_map={},
            world_guide={},
            episode_id="ep_test",
        )

    # raise 직전 helper 가 호출됨 — call_args 검증
    call_kwargs = coord._reference_svc.build_prev_shot_background_ref.call_args.kwargs
    assert call_kwargs.get("bytes_source_kind") == "dep_scene"
    assert call_kwargs.get("best_prev_bytes") == b"DEP_SCENE_BYTES"


def test_build_scene_attached_refs_missing_reference_phrase_kinds_raises(tmp_path):
    """Area #5 W3 §4.3 (Codex iter 2 BLOCKING fix) — producer 가 sidecar emit
    하지 않은 stale v25 cp 가 들어오면 RefContractError fail-fast.

    No Silent Fallback gate — `.get(..., [])` 흡수 금지. producer (scene_detail
    v26 W1) 의 schema 의무이며 caller side default 폐기.
    """
    from app.core.ref_contract_validator import RefContractError
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=False, tmp_path=tmp_path,
    )
    # stale v25 cp simulation — t2i_variations[0] 에 reference_phrase_kinds key 없음
    with patch(
        "app.services.scene_generation_coordinator.load_shot_t2i_variations",
        return_value=[{"t2i_prompt": "Photorealistic still. C01O02 walks."}],  # NO sidecar
    ):
        with pytest.raises(RefContractError, match="reference_phrase_kinds"):
            build_scene_attached_refs(**inputs)


def test_build_scene_attached_refs_non_list_reference_phrase_kinds_raises(tmp_path):
    """Area #5 W3 §4.3 (Codex iter 3 IMPORTANT fix) — producer 가 reference_phrase_kinds
    를 non-list (dict / str) 로 emit 하면 RefContractError fail-fast (malformed).

    list(raw) 변환이 validator 의 "must be list" fail-fast 를 우회하던 결함
    (e.g. list({"character": True}) == ["character"] → passes validator). caller-side
    isinstance gate 추가로 해결.
    """
    from app.core.ref_contract_validator import RefContractError
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=False, tmp_path=tmp_path,
    )
    # dict malformed sidecar
    with patch(
        "app.services.scene_generation_coordinator.load_shot_t2i_variations",
        return_value=[{"t2i_prompt": "x", "reference_phrase_kinds": {"character": True}}],
    ):
        with pytest.raises(RefContractError, match="malformed"):
            build_scene_attached_refs(**inputs)


def test_build_scene_attached_refs_none_reference_phrase_kinds_raises(tmp_path):
    """Area #5 W3 §4.3 (Codex iter 2 BLOCKING fix) — producer 가 reference_phrase_kinds
    를 None 으로 emit 하면 RefContractError fail-fast (malformed)."""
    from app.core.ref_contract_validator import RefContractError
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=False, tmp_path=tmp_path,
    )
    with patch(
        "app.services.scene_generation_coordinator.load_shot_t2i_variations",
        return_value=[{"t2i_prompt": "x", "reference_phrase_kinds": None}],
    ):
        with pytest.raises(RefContractError, match="None"):
            build_scene_attached_refs(**inputs)


def test_variation_path_missing_reference_phrase_kinds_typed_failure(tmp_path):
    """Area #5 W3 §4.3 (Codex iter 2 BLOCKING fix) — _generate_scene_in_loop
    의 variation 루프가 still_data['t2i_variations'][i] 에 reference_phrase_kinds
    가 누락된 stale variation 을 만나면 typed failure dict (Fix C parity) 로
    var_results 에 누적 후 caller 가 cp.failed 직렬화 가능.

    No Silent Fallback gate — variation path 도 helper side 와 동일하게
    `var.get(..., [])` 흡수 금지.
    """
    from app.services.scene_generation_coordinator import SceneGenerationCoordinator

    coord = SceneGenerationCoordinator.__new__(SceneGenerationCoordinator)
    coord._db = MagicMock()
    coord._project_id = "p_test"
    coord._reference_svc = MagicMock()
    coord._reference_svc.build_scene_ref_image_map.return_value = {}
    coord._reference_svc.detect_state_variant_sids.return_value = {}
    coord._reference_svc.resolve_refs_for_prompt_set.return_value = make_labeled_ref_payload(labeled_refs=[], ref_roles=[], ref_role_metadata=[], attached_meta=[])
    coord._reference_svc.build_prev_shot_background_ref.return_value = None

    # still.t2i_variations 에 stale variation 1개 (reference_phrase_kinds 누락) +
    # 정상 variation 1개 (sidecar=[]) — pre-submit 분기 명시 검증.
    stills = [{
        "scene_index": 8,
        "shot_index": 4,
        "visible_entities_json": "[]",
        "still_frame_prompt": "test prompt",
        "t2i_variations": [
            {"t2i_prompt": "stale prompt", "theme": "stale_theme"},  # NO sidecar — pre-submit failure
            {"t2i_prompt": "ok prompt", "theme": "ok_theme", "reference_phrase_kinds": []},
        ],
        "render_prompt_card": {"asset_requirements": {"required_refs": []}},
    }]

    # _generate_variation_in_loop / _build_final_scene_prompt / settings — minimal
    # mocks. ok variation 은 executor 진입 후 _generate_variation_in_loop 가 짝퉁 return
    # 으로 succeed (REF 검증 path 우회).
    with patch(
        "app.services.scene_generation_coordinator.lookup_render_prompt_card",
        return_value={"asset_requirements": {"required_refs": []}},
    ), patch(
        "app.services.scene_generation_coordinator._build_final_scene_prompt",
        side_effect=lambda var, refs, sctx, **kw: f"FINAL[{var}]",
    ), patch.object(
        SceneGenerationCoordinator, "_generate_variation_in_loop",
        return_value={"id": "ok_var_id", "ok": True},  # ok variation succeeds
    ), patch(
        "app.services.scene_generation_coordinator.settings"
    ) as mock_settings:
        mock_settings.projects_dir = str(tmp_path)
        mock_settings.scene_variation_count = 2

        si_returned, var_results, _ve, _loc = coord._generate_scene_in_loop(
            si=0,
            stills=stills,
            entity_lookup={},
            scene_paths_by_index_by_id={},
            location_scene_history={},
            staging_map={"8_4": {"camera_direction": "medium", "framing_scale": "medium"}},
            scene_ref_image_map={},
            background_chain_bg_map={},
            dep_detail_map={},
            gemini_client=MagicMock(),
            sanitizer=MagicMock(),
            validator=None,
            scene_dir=tmp_path,
            cached_style_context="",
            cached_entity_text_map={},
            world_guide={},
            episode_id="ep_test",
        )

    # var_results 안에 typed failure dict 가 누적되어 있어야 함 (cp.failed 직렬화 parity).
    failure_dicts = [r for r in var_results if r.get("_failure_reason") == "REF_CONTRACT_VIOLATION"]
    assert len(failure_dicts) >= 1, (
        f"variation path pre-submit 가 missing sidecar 를 typed failure 로 변환하지 "
        f"못함 — var_results={var_results!r}"
    )
    assert any(
        "reference_phrase_kinds" in r.get("_failure_detail", "")
        for r in failure_dicts
    ), (
        f"_failure_detail 안 'reference_phrase_kinds' missing reason 부재 — "
        f"failure_dicts={failure_dicts!r}"
    )


def test_variation_path_non_list_reference_phrase_kinds_typed_failure(tmp_path):
    """Area #5 W3 §4.3 (Codex iter 3 IMPORTANT fix) — variation 의
    reference_phrase_kinds 가 non-list malformed 이면 typed failure dict 누적.

    list(raw) 변환 우회 결함 차단 — isinstance gate caller-side 명시.
    """
    from app.services.scene_generation_coordinator import SceneGenerationCoordinator

    coord = SceneGenerationCoordinator.__new__(SceneGenerationCoordinator)
    coord._db = MagicMock()
    coord._project_id = "p_test"
    coord._reference_svc = MagicMock()
    coord._reference_svc.build_scene_ref_image_map.return_value = {}
    coord._reference_svc.detect_state_variant_sids.return_value = {}
    coord._reference_svc.resolve_refs_for_prompt_set.return_value = make_labeled_ref_payload(labeled_refs=[], ref_roles=[], ref_role_metadata=[], attached_meta=[])
    coord._reference_svc.build_prev_shot_background_ref.return_value = None

    stills = [{
        "scene_index": 8,
        "shot_index": 4,
        "visible_entities_json": "[]",
        "still_frame_prompt": "test prompt",
        "t2i_variations": [
            {"t2i_prompt": "malformed prompt", "theme": "malformed",
             "reference_phrase_kinds": {"character": True}},  # non-list malformed
            {"t2i_prompt": "ok prompt", "theme": "ok", "reference_phrase_kinds": []},
        ],
        "render_prompt_card": {"asset_requirements": {"required_refs": []}},
    }]

    with patch(
        "app.services.scene_generation_coordinator.lookup_render_prompt_card",
        return_value={"asset_requirements": {"required_refs": []}},
    ), patch(
        "app.services.scene_generation_coordinator._build_final_scene_prompt",
        side_effect=lambda var, refs, sctx, **kw: f"FINAL[{var}]",
    ), patch.object(
        SceneGenerationCoordinator, "_generate_variation_in_loop",
        return_value={"id": "ok_var_id", "ok": True},
    ), patch(
        "app.services.scene_generation_coordinator.settings"
    ) as mock_settings:
        mock_settings.projects_dir = str(tmp_path)
        mock_settings.scene_variation_count = 2

        _si, var_results, _ve, _loc = coord._generate_scene_in_loop(
            si=0, stills=stills, entity_lookup={},
            scene_paths_by_index_by_id={}, location_scene_history={},
            staging_map={"8_4": {"camera_direction": "medium", "framing_scale": "medium"}},
            scene_ref_image_map={}, background_chain_bg_map={}, dep_detail_map={},
            gemini_client=MagicMock(), sanitizer=MagicMock(), validator=None,
            scene_dir=tmp_path, cached_style_context="", cached_entity_text_map={},
            world_guide={}, episode_id="ep_test",
        )

    # malformed variation 의 typed failure dict (must be list)
    failure_dicts = [r for r in var_results if r.get("_failure_reason") == "REF_CONTRACT_VIOLATION"]
    assert any(
        "malformed" in r.get("_failure_detail", "")
        for r in failure_dicts
    ), (
        f"variation path non-list malformed sidecar 가 typed failure dict 로 "
        f"변환되지 않음 — failure_dicts={failure_dicts!r}"
    )


# ---------------------------------------------------------------------------
# FINDING 11 — single-path 'character' over-declaration normalization wiring.
# build_scene_attached_refs 가 helper 호출 전 render_prompt_card 를 resolve 하여
# required_refs SOT 를 확보하는지 검증 (Codex per-wave NEEDS_REVISION_NARROW_1).
# ---------------------------------------------------------------------------


def test_single_path_keeps_character_when_lookup_rpc_has_character(tmp_path):
    """still_data 에 RPC 가 없고 lookup_render_prompt_card 가 required character
    를 반환하면 'character' 는 보존 — helper 가 RPC SOT 를 보고 판단해야 한다."""
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=False, tmp_path=tmp_path,
    )
    inputs["still_data"].pop("render_prompt_card", None)
    with patch(
        "app.services.scene_generation_coordinator.load_shot_t2i_variations",
        return_value=[{
            "t2i_prompt": "Photorealistic still. A figure stands in shadow.",
            "reference_phrase_kinds": ["character"],
        }],
    ), patch(
        "app.services.scene_generation_coordinator.lookup_render_prompt_card",
        return_value={"asset_requirements": {
            "required_refs": [{"kind": "character", "id": "C04"}],
        }},
    ):
        _fp, _refs, _meta, phrase_kinds = build_scene_attached_refs(**inputs)
    assert phrase_kinds == ["character"]


def test_single_path_strips_character_when_lookup_rpc_background_only(tmp_path):
    """still_data 에 RPC 가 없고 lookup 이 background-only required_refs 를
    반환하며 prompt 에 ID-form 이 없으면 'character' 만 strip, 'background' 보존."""
    from app.services.scene_generation_coordinator import build_scene_attached_refs

    inputs = _make_helper_inputs(
        has_chain_bg=False, is_close=False, has_prev_shot=False, tmp_path=tmp_path,
    )
    inputs["still_data"].pop("render_prompt_card", None)
    with patch(
        "app.services.scene_generation_coordinator.load_shot_t2i_variations",
        return_value=[{
            "t2i_prompt": "Photorealistic still. A figure stands in shadow.",
            "reference_phrase_kinds": ["background", "character"],
        }],
    ), patch(
        "app.services.scene_generation_coordinator.lookup_render_prompt_card",
        return_value={"asset_requirements": {
            "required_refs": [{"kind": "background", "id": "L03B03"}],
        }},
    ):
        _fp, _refs, _meta, phrase_kinds = build_scene_attached_refs(**inputs)
    assert phrase_kinds == ["background"]
