"""SceneReferenceService 단위 테스트 — W5 F22 Phase B.8.

SceneImageService에서 분리된 visible-entity/reference-image 해결 로직을 검증.
"""
from __future__ import annotations

import json
from unittest.mock import MagicMock

import pytest

from app.services.scene_reference_service import SceneReferenceService


@pytest.fixture
def svc() -> SceneReferenceService:
    instance = SceneReferenceService.__new__(SceneReferenceService)
    instance._db = MagicMock()
    instance._project_id = "p1"
    return instance


# ──────────────────────────────────────────────────────────────────────
# get_visible_entities
# ──────────────────────────────────────────────────────────────────────


def test_build_entity_text_map_basic_short_ids(svc):
    """short_id 기반 fallback: description → name — t2i_prompt 미사용 (2026-07-02 계약)."""
    # CharacterOutlook 조회는 빈 리스트로 (composite 키 생성 없음)
    svc._db.query.return_value.filter.return_value.all.return_value = []

    entities = [
        {"id": "e1", "short_id": "C01", "t2i_prompt": "REF_GEN_ONLY portrait prompt",
         "description": "korean woman, 30s"},
        {"id": "e2", "short_id": "L05", "description": "traditional kitchen"},
        {"id": "e3", "short_id": "P03", "name": "wooden cane"},
        {"id": "e4", "short_id": "", "t2i_prompt": "skipped"},  # no short_id → skip
    ]
    result = svc.build_entity_text_map(entities, episode_id="ep1")

    assert result["C01"] == "korean woman, 30s"
    assert result["L05"] == "traditional kitchen"
    assert result["P03"] == "wooden cane"
    assert "" not in result


def test_build_entity_text_map_never_uses_t2i_prompt(svc):
    """★계약: ref 생성용 t2i_prompt 는 in-scene 치환 텍스트로 절대 미사용.

    (필드 선택 순서 검증 — 문자열 의미 판정 아님.) t2i_prompt 만 있고
    description/name 이 비면 빈 문자열로 남는다(스타일 래퍼 스플라이스 금지).
    """
    svc._db.query.return_value.filter.return_value.all.return_value = []

    entities = [
        {"id": "e1", "short_id": "P01", "t2i_prompt": "REF_GEN_ONLY product shot",
         "description": "generic modern smartphone", "name": "휴대전화"},
        {"id": "e2", "short_id": "P02", "t2i_prompt": "REF_GEN_ONLY product shot",
         "name": "메모지"},
        {"id": "e3", "short_id": "P03", "t2i_prompt": "REF_GEN_ONLY product shot"},
    ]
    result = svc.build_entity_text_map(entities, episode_id="ep1")

    assert result["P01"] == "generic modern smartphone"  # description-first
    assert result["P02"] == "메모지"                      # name fallback
    assert result["P03"] == ""                            # t2i_prompt 로 안 떨어짐
    assert all("REF_GEN_ONLY" not in v for v in result.values())


def test_build_entity_text_map_composite_keys_from_outlook(svc):
    """CharacterOutlook 링크 → C##O## 합성 키 생성 (B.16, description-first)."""
    co = MagicMock()
    co.character_id = "c_uuid"
    co.outlook_id = "o_uuid"

    outfit = MagicMock()
    outfit.id = "o_uuid"
    outfit.short_id = "O02"
    outfit.t2i_prompt = "REF_GEN_ONLY outfit sheet"
    outfit.description = "green hanbok"
    outfit.name = None

    # 첫 query: CharacterOutlook.filter().all() → [co]
    # 둘째 query: EntityCanon.filter().first() → outfit
    outer_query = MagicMock()
    outer_query.filter.return_value = outer_query
    outer_query.all.return_value = [co]
    outer_query.first.return_value = outfit
    svc._db.query.return_value = outer_query

    entities = [
        {"id": "c_uuid", "short_id": "C01", "t2i_prompt": "REF_GEN_ONLY portrait",
         "description": "korean woman"},
    ]
    result = svc.build_entity_text_map(entities, episode_id="ep1")

    assert result["C01"] == "korean woman"
    assert result["C01O02"] == "korean woman, wearing green hanbok"


def test_build_entity_text_map_exception_suppressed(svc):
    """CharacterOutlook 쿼리 예외 → warning + 기본 short_id map만 반환 (B.16)."""
    svc._db.query.side_effect = RuntimeError("db down")

    entities = [{"id": "e1", "short_id": "C01", "name": "X"}]
    # Must not raise
    result = svc.build_entity_text_map(entities, episode_id="ep1")
    assert result == {"C01": "X"}


def test_load_entity_reference_images_maps_primary_bytes(svc, tmp_path):
    """primary reference 이미지 bytes를 entity_id로 매핑 (W5 F22 Phase B.12)."""
    img1 = tmp_path / "e1.png"
    img1.write_bytes(b"E1_BYTES")
    img2 = tmp_path / "e2.png"
    img2.write_bytes(b"E2_BYTES")

    primary_e1 = MagicMock()
    primary_e1.file_path = str(img1)
    primary_e2 = MagicMock()
    primary_e2.file_path = str(img2)

    # 각 entity에 대해 별도 query → first() 반환값 설정
    call_results = iter([primary_e1, primary_e2])
    query = MagicMock()
    query.filter.return_value = query
    query.order_by.return_value = query
    query.first.side_effect = lambda: next(call_results)
    svc._db.query.return_value = query

    result = svc.load_entity_reference_images([{"id": "e1"}, {"id": "e2"}])
    assert result == {"e1": b"E1_BYTES", "e2": b"E2_BYTES"}


def test_load_entity_reference_images_skips_missing_file(svc, tmp_path):
    """file_path가 존재하지 않으면 skip (not raise)."""
    primary = MagicMock()
    primary.file_path = str(tmp_path / "nonexistent.png")
    query = MagicMock()
    query.filter.return_value = query
    query.order_by.return_value = query
    query.first.return_value = primary
    svc._db.query.return_value = query

    result = svc.load_entity_reference_images([{"id": "e1"}])
    assert result == {}


def test_load_entity_reference_images_skips_missing_primary(svc):
    """primary asset 자체가 없으면 skip."""
    query = MagicMock()
    query.filter.return_value = query
    query.order_by.return_value = query
    query.first.return_value = None
    svc._db.query.return_value = query

    result = svc.load_entity_reference_images([{"id": "e1"}, {"id": "e2"}])
    assert result == {}


def test_get_visible_entities_none_returns_empty(svc):
    assert svc.get_visible_entities(None) == []
    assert svc.get_visible_entities("") == []


def test_get_visible_entities_invalid_json_returns_empty(svc):
    assert svc.get_visible_entities("not json {") == []


def test_get_visible_entities_parses_list_of_ids(svc):
    """ID 리스트 형식의 JSON 파싱."""
    entity = MagicMock()
    entity.id = "e1"
    entity.name = "Char1"
    entity.entity_type = "character"
    entity.short_id = "C01"
    entity.description = "desc"
    entity.stable_traits = '{"age":30}'

    svc._db.query.return_value.filter.return_value.first.return_value = entity

    result = svc.get_visible_entities('["e1"]')
    assert len(result) == 1
    assert result[0]["id"] == "e1"
    assert result[0]["name"] == "Char1"
    assert result[0]["short_id"] == "C01"


def test_get_visible_entities_parses_list_of_dicts(svc):
    """dict 리스트 형식 ({id: xxx}) 파싱."""
    entity = MagicMock()
    entity.id = "e1"
    entity.name = "Char1"
    entity.entity_type = "character"
    entity.short_id = "C01"
    entity.description = ""
    entity.stable_traits = None

    svc._db.query.return_value.filter.return_value.first.return_value = entity

    result = svc.get_visible_entities('[{"id": "e1"}]')
    assert len(result) == 1
    assert result[0]["stable_traits"] == "{}"  # None → default "{}"


# ──────────────────────────────────────────────────────────────────────
# get_reference_image_map
# ──────────────────────────────────────────────────────────────────────


def test_get_reference_image_map_empty_list(svc):
    assert svc.get_reference_image_map([]) == {}


def test_get_reference_image_map_missing_primary_fallback(svc, tmp_path):
    """primary 없으면 최신 reference로 fallback."""
    fp = tmp_path / "ref.png"
    fp.write_bytes(b"img_bytes")

    fallback_img = MagicMock(file_path=str(fp))

    call_count = {"n": 0}

    def _side_effect(*args, **kwargs):
        mock = MagicMock()
        call_count["n"] += 1
        if call_count["n"] == 1:
            mock.first.return_value = None
            return mock
        mock.order_by.return_value.first.return_value = fallback_img
        return mock

    svc._db.query.return_value.filter.side_effect = _side_effect

    result = svc.get_reference_image_map([{"id": "e1"}])
    assert result == {"e1": b"img_bytes"}


# ──────────────────────────────────────────────────────────────────────
# load_episode_entity_lookup — W5 F22 Phase B.21.2
# ──────────────────────────────────────────────────────────────────────


def test_load_episode_entity_lookup_basic(svc):
    """EntityEpisodeLink → EntityCanon 조회 후 6필드 dict 매핑."""
    # ★정본 `active_episode_canon_ids` 는 `EntityEpisodeLink.canon_id`
    #  **칼럼**을 조회하므로 행이 아니라 1튜플이 온다 (2026-09-04).
    link1 = ("e1",)
    link2 = ("e2",)

    ent1 = MagicMock()
    ent1.id = "e1"
    ent1.name = "Char1"
    ent1.entity_type = "character"
    ent1.short_id = "C01"
    ent1.description = "desc1"
    ent1.t2i_prompt = "prompt1"

    ent2 = MagicMock()
    ent2.id = "e2"
    ent2.name = "Loc1"
    ent2.entity_type = "location"
    ent2.short_id = "L05"
    ent2.description = "desc2"
    ent2.t2i_prompt = "prompt2"

    from app.models.project import EntityCanon, EntityEpisodeLink

    link_query = MagicMock()
    link_query.filter.return_value = link_query
    link_query.all.return_value = [link1, link2]

    canon_query = MagicMock()
    canon_query.filter.return_value = canon_query
    canon_query.all.return_value = [ent1, ent2]

    def _query(model):
        # 정본은 `EntityEpisodeLink.canon_id` 를, 이 서비스는 `EntityCanon` 을 묻는다.
        if model is EntityEpisodeLink or model is EntityEpisodeLink.canon_id:
            return link_query
        if model is EntityCanon:
            return canon_query
        return MagicMock()
    svc._db.query.side_effect = _query

    result = svc.load_episode_entity_lookup("ep1")
    assert set(result.keys()) == {"e1", "e2"}
    assert result["e1"] == {
        "id": "e1", "name": "Char1", "entity_type": "character",
        "short_id": "C01", "description": "desc1", "t2i_prompt": "prompt1",
    }
    # stable_traits 필드 제외 확인
    assert "stable_traits" not in result["e1"]


def test_load_episode_entity_lookup_null_fields_become_empty_str(svc):
    """EntityCanon의 short_id/description/t2i_prompt가 None이면 빈 문자열로."""
    link = MagicMock(canon_id="eX")
    ent = MagicMock()
    ent.id = "eX"
    ent.name = "X"
    ent.entity_type = "character"
    ent.short_id = None
    ent.description = None
    ent.t2i_prompt = None

    from app.models.project import EntityCanon, EntityEpisodeLink

    link_q = MagicMock()
    link_q.filter.return_value = link_q
    link_q.all.return_value = [link]

    canon_q = MagicMock()
    canon_q.filter.return_value = canon_q
    canon_q.all.return_value = [ent]

    def _query(model):
        if model is EntityEpisodeLink:
            return link_q
        return canon_q
    svc._db.query.side_effect = _query

    result = svc.load_episode_entity_lookup("ep1")
    assert result["eX"]["short_id"] == ""
    assert result["eX"]["description"] == ""
    assert result["eX"]["t2i_prompt"] == ""


def test_load_episode_entity_lookup_empty_episode_returns_empty_dict(svc):
    """에피소드에 연결된 EntityEpisodeLink 없으면 EntityCanon 조회 스킵 + 빈 dict."""
    from app.models.project import EntityCanon, EntityEpisodeLink

    link_q = MagicMock()
    link_q.filter.return_value = link_q
    link_q.all.return_value = []

    canon_q = MagicMock()
    canon_q.filter.return_value = canon_q
    canon_q.all.return_value = []

    canon_query_called = {"n": 0}

    def _query(model):
        if model is EntityEpisodeLink:
            return link_q
        if model is EntityCanon:
            canon_query_called["n"] += 1
            return canon_q
        return MagicMock()
    svc._db.query.side_effect = _query

    result = svc.load_episode_entity_lookup("ep1")
    assert result == {}
    # 링크가 없으면 EntityCanon 조회 자체를 스킵하는지 검증
    assert canon_query_called["n"] == 0


# ──────────────────────────────────────────────────────────────────────
# build_custom_labeled_refs — W5 F22 Phase B.21.3
# ──────────────────────────────────────────────────────────────────────


def test_build_custom_labeled_refs_character_label(svc):
    """character entity_type → 'character identity' 라벨."""
    visible = [{"id": "c1", "entity_type": "character"}]
    refs = {"c1": b"CHAR_BYTES"}
    result = svc.build_custom_labeled_refs(visible, refs)
    assert result == [("character identity", b"CHAR_BYTES")]


def test_build_custom_labeled_refs_prop_label(svc):
    """character 아닌 타입 (prop/object) → 'object appearance' 라벨."""
    visible = [{"id": "p1", "entity_type": "prop"}]
    refs = {"p1": b"PROP_BYTES"}
    result = svc.build_custom_labeled_refs(visible, refs)
    assert result == [("object appearance", b"PROP_BYTES")]


def test_build_custom_labeled_refs_excludes_location(svc):
    """location entity는 제외."""
    visible = [
        {"id": "c1", "entity_type": "character"},
        {"id": "l1", "entity_type": "location"},
    ]
    refs = {"c1": b"A", "l1": b"B"}
    result = svc.build_custom_labeled_refs(visible, refs)
    assert result == [("character identity", b"A")]


def test_build_custom_labeled_refs_skip_when_no_bytes(svc):
    """ref_image_map에 bytes 없는 엔티티는 skip."""
    visible = [
        {"id": "c1", "entity_type": "character"},
        {"id": "c2", "entity_type": "character"},
    ]
    refs = {"c1": b"ONLY_C1"}
    result = svc.build_custom_labeled_refs(visible, refs)
    assert result == [("character identity", b"ONLY_C1")]


def test_build_custom_labeled_refs_empty_type_defaults_to_object(svc):
    """entity_type 키 없음 → 기본 'object appearance' (character 아니므로)."""
    visible = [{"id": "x1"}]  # no entity_type
    refs = {"x1": b"DATA"}
    result = svc.build_custom_labeled_refs(visible, refs)
    assert result == [("object appearance", b"DATA")]


def test_build_custom_labeled_refs_attaches_chain_bg_first(svc):
    """Phase 3 정련 (2026-06-11 S10 sh5 v2 실측): custom_prompt 경로도 bg map
    (space plate/chain) ref 를 첫 ref 로 부착 — 없으면 모델이 환경(벽 재질 등)을
    텍스트만으로 발명한다. 자동 경로(build_scene_attached_refs 5a)와 정합."""
    visible = [{"id": "c1", "entity_type": "character"}]
    refs = {"c1": b"CHAR_BYTES"}
    bg_entry = {"image_bytes": b"PLATE", "label": "space set background ref (x)",
                "bg_id": "space_set_bg:G1:k", "source": "space_set_bg"}
    result = svc.build_custom_labeled_refs(visible, refs, chain_bg_entry=bg_entry)
    assert result[0] == ("space set background ref (x)", b"PLATE")
    assert result[1] == ("character identity", b"CHAR_BYTES")


def test_build_custom_labeled_refs_skips_sentinel_bg_entry(svc):
    """no-plate sentinel(image_bytes 없음 — W1-B suppression 신호)은 부착 금지."""
    sentinel = {"source": "space_set_bg_no_plate",
                "suppress_background_required": True, "reason": "connector_no_plate"}
    result = svc.build_custom_labeled_refs([], {}, chain_bg_entry=sentinel)
    assert result == []


def test_build_custom_labeled_refs_none_bg_entry_keeps_legacy_behavior(svc):
    """chain_bg_entry 미전달/None = 기존 동작 그대로 (회귀 0)."""
    visible = [{"id": "c1", "entity_type": "character"}]
    refs = {"c1": b"A"}
    assert svc.build_custom_labeled_refs(visible, refs, chain_bg_entry=None) == [
        ("character identity", b"A")
    ]


def test_build_custom_labeled_refs_exception_returns_empty(svc, caplog):
    """visible_entities 구조가 깨져 있으면 warning + 빈 리스트."""
    import logging
    # id 키 없는 dict → {e["id"]: e} KeyError
    visible = [{"wrong_key": "x"}]
    refs = {}
    with caplog.at_level(logging.WARNING):
        result = svc.build_custom_labeled_refs(visible, refs)
    assert result == []
    assert any("labeled_refs 구성 실패" in msg for msg in caplog.messages)


def test_build_custom_labeled_refs_preserves_order(svc):
    """visible_entities 순서대로 labeled_refs 생성."""
    visible = [
        {"id": "c1", "entity_type": "character"},
        {"id": "p1", "entity_type": "prop"},
        {"id": "c2", "entity_type": "character"},
    ]
    refs = {"c1": b"A", "p1": b"B", "c2": b"C"}
    result = svc.build_custom_labeled_refs(visible, refs)
    assert result == [
        ("character identity", b"A"),
        ("object appearance", b"B"),
        ("character identity", b"C"),
    ]


# ──────────────────────────────────────────────────────────────────────
# detect_state_variant_sids — W5 F22 Phase B.22.1
# ──────────────────────────────────────────────────────────────────────


def test_detect_state_variant_sids_none_staging_returns_empty(svc):
    """staging이 None이면 빈 dict."""
    result = svc.detect_state_variant_sids(
        visible_entities=[{"id": "c1", "short_id": "C01", "name": "X"}],
        entity_lookup={},
        scene_ref_image_map={},
        staging=None,
    )
    assert result == {}


def test_detect_state_variant_sids_no_matching_gaze_returns_empty(svc):
    """subject_state 가 immobilized (unconscious/dead/severely_injured) 아닌 경우 빈 dict."""
    # Area #2 W5: v13 3 field shape — alive 는 immobilized 아님.
    staging = {"character_angles": [{"character": "X", "gaze_direction_kind": "camera", "subject_state": "alive"}]}
    result = svc.detect_state_variant_sids(
        visible_entities=[{"id": "c1", "short_id": "C01", "name": "X"}],
        entity_lookup={},
        scene_ref_image_map={"state_variant:c1:dead": b"x"},
        staging=staging,
    )
    assert result == {}


def test_detect_state_variant_sids_dead_character_with_ref_matched(svc):
    """subject_state=dead + ref_image_map에 매칭 키 있음 → short_id 매핑."""
    # Area #2 W5: v13 3 field shape.
    staging = {"character_angles": [{"character": "Minsuk", "gaze_direction_kind": "closed_eyes", "subject_state": "dead"}]}
    visible = [{"id": "c_uuid_1", "short_id": "C01", "name": "Minsuk"}]
    refs = {"state_variant:c_uuid_1:dead": b"DEAD_REF"}

    result = svc.detect_state_variant_sids(
        visible_entities=visible,
        entity_lookup={},
        scene_ref_image_map=refs,
        staging=staging,
    )
    assert result == {"C01": {"key": "state_variant:c_uuid_1:dead", "state": "dead"}}


def test_detect_state_variant_sids_unconscious_severely_injured_both_recognized(svc):
    """unconscious / severely_injured 둘 다 인식."""
    # Area #2 W5: v13 3 field shape.
    staging = {"character_angles": [
        {"character": "A", "gaze_direction_kind": "closed_eyes", "subject_state": "unconscious"},
        {"character": "B", "gaze_direction_kind": "closed_eyes", "subject_state": "severely_injured"},
    ]}
    visible = [
        {"id": "a_id", "short_id": "C01", "name": "A"},
        {"id": "b_id", "short_id": "C02", "name": "B"},
    ]
    refs = {
        "state_variant:a_id:unconscious": b"A_REF",
        "state_variant:b_id:severely_injured": b"B_REF",
    }
    result = svc.detect_state_variant_sids(visible, {}, refs, staging)
    assert result["C01"]["state"] == "unconscious"
    assert result["C02"]["state"] == "severely_injured"


def test_detect_state_variant_sids_identity_variant_family_bridge(svc):
    """★identity-variant aware (2026-07-02, S12 시신 실측 재현).

    staging 이름=base(C05) 표기 / VE 에는 variant EntityCanon(C16)만 실재 /
    state variant 자산 소유자는 base uuid → family 브리지로 매칭·키 해결.
    """
    staging = {"character_angles": [
        {"character": "Base-Woman", "gaze_direction_kind": "closed_eyes",
         "subject_state": "dead"}]}
    visible = [{"id": "c16_uuid", "short_id": "C16", "name": "Base-Woman (변형)"}]
    entity_lookup = {
        "c05_uuid": {"short_id": "C05", "name": "Base-Woman"},
        "c16_uuid": {"short_id": "C16", "name": "Base-Woman (변형)"},
    }
    refs = {"state_variant:c05_uuid:dead": b"DEAD_REF"}
    fam = {"C05": {"C05", "C16"}, "C16": {"C05", "C16"}}

    # family 없으면 기존 동작 = 매칭 실패
    assert svc.detect_state_variant_sids(visible, entity_lookup, refs, staging) == {}

    result = svc.detect_state_variant_sids(
        visible, entity_lookup, refs, staging, identity_family_by_sid=fam)
    assert result == {"C16": {"key": "state_variant:c05_uuid:dead", "state": "dead"}}


def test_detect_state_variant_sids_family_ambiguous_name_not_applied(svc):
    """동명 2+ 후보면 family 브리지 미적용 (기존 보수 계약 유지)."""
    staging = {"character_angles": [
        {"character": "Twin", "gaze_direction_kind": "closed_eyes",
         "subject_state": "dead"}]}
    visible = [{"id": "c16_uuid", "short_id": "C16", "name": "Twin (변형)"}]
    entity_lookup = {
        "a_uuid": {"short_id": "C05", "name": "Twin"},
        "b_uuid": {"short_id": "C07", "name": "Twin"},
        "c16_uuid": {"short_id": "C16", "name": "Twin (변형)"},
    }
    refs = {"state_variant:a_uuid:dead": b"X"}
    fam = {"C05": {"C05", "C16"}, "C16": {"C05", "C16"}}
    result = svc.detect_state_variant_sids(
        visible, entity_lookup, refs, staging, identity_family_by_sid=fam)
    assert result == {}


def test_detect_state_variant_sids_no_ref_in_map_skipped(svc):
    """scene_ref_image_map에 state_variant 키 없으면 매핑 스킵."""
    # Area #2 W5: v13 3 field shape.
    staging = {"character_angles": [{"character": "X", "gaze_direction_kind": "closed_eyes", "subject_state": "dead"}]}
    visible = [{"id": "x_id", "short_id": "C01", "name": "X"}]
    result = svc.detect_state_variant_sids(visible, {}, {}, staging)
    assert result == {}  # ref 없음 → skip


def test_detect_state_variant_sids_entity_lookup_sid_to_uuid_fallback(svc):
    """visible에 short_id 없는 캐릭터는 entity_lookup에서 sid→uuid 매핑 가능."""
    # Area #2 W5: v13 3 field shape.
    staging = {"character_angles": [{"character": "NPC", "gaze_direction_kind": "closed_eyes", "subject_state": "dead"}]}
    # visible에는 NPC 있지만 short_id 비어있음 (매칭 실패 케이스)
    visible = [{"id": "npc_id", "short_id": "", "name": "NPC"}]
    # entity_lookup에 NPC의 short_id 정보가 있어도, visible의 short_id가 비면 매칭 안됨
    entity_lookup = {"npc_id": {"id": "npc_id", "short_id": "C99"}}
    refs = {"state_variant:npc_id:dead": b"X"}

    result = svc.detect_state_variant_sids(visible, entity_lookup, refs, staging)
    # visible의 short_id가 빈 문자열이므로 for 루프 내 조건(ve.get("short_id")) 불통 → 스킵
    assert result == {}


# ──────────────────────────────────────────────────────────────────────
# build_prev_shot_background_ref — W5 F22 Phase B.22.2
# ──────────────────────────────────────────────────────────────────────


def test_prev_shot_ref_none_when_no_bytes(svc):
    """best_prev_bytes가 None이면 None 반환."""
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=None,
        bytes_source_kind="none",  # spec §5 — bytes None 시 "none" enum
        still_data={"scene_index": 1, "shot_index": 1},
        visible_entities=[], current_location_ids=[],
        dep_scene_id=None, stills=[], location_scene_history={},
        dep_detail_map={}, staging=None, state_variant_sids={},
        entity_lookup={},
    )
    assert result is None


def test_prev_shot_ref_zoom_in_detail_label(svc):
    """ref_usage=zoom_in_detail → SAME FRAME ZOOMED label + ignore/keep 붙음.

    2026-05-15 zoom_in_detail source provenance hardening — Layer 3 통과 위해
    bytes_source_kind="dep_scene" + dep_scene_id + matching stills 명시.
    """
    dep_map = {"3_2": {
        "ref_usage": "zoom_in_detail",
        "ignore_elements": "ignore Y",
        "keep_elements": [{"label": "prop_A", "kind": "static_prop", "subject_kind": "non_human_visual_element"}],
    }}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="dep_scene",  # spec §6 — zoom_in_detail Layer 3
        still_data={"scene_index": 3, "shot_index": 2},
        visible_entities=[], current_location_ids=[],
        dep_scene_id="S0_Shot1",  # Layer 2.5 통과
        stills=[{"id": "S0_Shot1", "visible_entities_json": "[]"}],
        location_scene_history={},
        dep_detail_map=dep_map,
        # framing_scale enum SOT v1 — matrix v1 (spec §4.9). zoom_in_detail
        # 라벨 분기 intent 유지 → close + zoom_in_detail = allow.
        staging={"framing_scale": "close", "camera_direction": "close-up"},
        state_variant_sids={},
        entity_lookup={},
    )
    assert result is not None
    label, img, _loc_id, _ref_role, _ref_role_metadata = result  # Area #11 v1 W2: 5-tuple
    assert img == b"PREV"
    # W3 (2026-06-11): exact-frame 절대 지시 완화 — SAME MOMENT continuity 문구
    # (S29 sh11 프레임 복제 실측 fix). ignore/keep 합성은 불변.
    assert "SAME MOMENT, zoomed-in reframing" in label
    assert "reuse this exact frame" not in label
    assert "ignore Y" in label
    assert "Keep: prop_A" in label


def test_prev_shot_ref_zoom_downgraded_when_new_subjects(svc):
    """W3 (2026-06-11 fresh full E2E S29 실측): zoom_in_detail 인데 현재 샷에
    dep 프레임에 없던 캐릭터가 등장하면 '같은 프레임 재구도' 성립 불가 →
    continuity 로 강등 + metadata 에 diagnostic 보존.

    실측(S29 sh11): dep 프레임(인물 없는 빈 실내 wide)에 없는 두 캐릭터를
    그려야 하는 클로즈업인데 exact-frame 지시가 프레임 통째 복제를 유발했다.
    """
    dep_map = {"29_11": {
        "ref_usage": "zoom_in_detail", "ignore_elements": "",
        "keep_elements": [],
    }}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="dep_scene",
        still_data={"scene_index": 29, "shot_index": 11},
        visible_entities=[{"id": "uuid-c24", "entity_type": "character"}],
        current_location_ids=[],
        dep_scene_id="dep-still",
        stills=[{"id": "dep-still", "visible_entities_json": "[]"}],  # dep 프레임에 캐릭터 0
        location_scene_history={},
        dep_detail_map=dep_map,
        staging={"framing_scale": "close", "camera_direction": "close-up"},
        state_variant_sids={},
        entity_lookup={"uuid-c24": {
            "id": "uuid-c24", "entity_type": "character", "short_id": "C24",
        }},
    )
    assert result is not None
    label, _img, _loc, ref_role, meta = result
    # zoom 강등 → continuity role (environment-only 라벨)
    assert ref_role == "previous_shot_continuity"
    assert "SAME MOMENT" not in label
    assert meta["zoom_downgraded_new_subjects"] == ["C24"]
    assert meta["ref_usage"] == "atmosphere_reference"


def test_prev_shot_ref_zoom_not_downgraded_when_subjects_match(svc):
    """W3 경계: dep 프레임에 이미 있던 캐릭터만 나오면 zoom 유지 (강등 X)."""
    dep_map = {"12_12": {
        "ref_usage": "zoom_in_detail", "ignore_elements": "",
        "keep_elements": [],
    }}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="dep_scene",
        still_data={"scene_index": 12, "shot_index": 12},
        visible_entities=[{"id": "uuid-c08", "entity_type": "character"}],
        current_location_ids=[],
        dep_scene_id="dep-still",
        stills=[{"id": "dep-still",
                 "visible_entities_json": '[{"id": "uuid-c08"}]'}],
        location_scene_history={},
        dep_detail_map=dep_map,
        staging={"framing_scale": "close", "camera_direction": "close-up"},
        state_variant_sids={},
        entity_lookup={"uuid-c08": {
            "id": "uuid-c08", "entity_type": "character", "short_id": "C08",
        }},
    )
    assert result is not None
    label, _img, _loc, ref_role, meta = result
    assert ref_role == "previous_shot_same_frame_zoomed"
    assert "SAME MOMENT, zoomed-in reframing" in label
    assert "zoom_downgraded_new_subjects" not in meta


def test_prev_shot_ref_zoom_immobilized_subjects_metadata(svc):
    """feedback6-C (2026-06-11 B-run S12 sh7↔sh12 실측): zoom 프레임의
    immobilized 피사체(staging.character_angles[].subject_state enum SOT)를
    metadata 로 emit → consumer 가 image1 pose-lock 지시를 렌더.

    실측(S12): sh12(줌인) 본문이 시신 pose 를 재서술해 sh7(줌아웃)의 자세와
    어긋남 — 죽은 사람이 움직였다. mobile 피사체는 W3 완화 유지."""
    dep_map = {"12_12": {
        "ref_usage": "zoom_in_detail", "ignore_elements": "",
        "keep_elements": [],
    }}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="dep_scene",
        still_data={"scene_index": 12, "shot_index": 12},
        visible_entities=[{"id": "uuid-c08", "entity_type": "character"}],
        current_location_ids=[],
        dep_scene_id="dep-still",
        stills=[{"id": "dep-still",
                 "visible_entities_json": '[{"id": "uuid-c08"}]'}],
        location_scene_history={},
        dep_detail_map=dep_map,
        staging={
            "framing_scale": "close", "camera_direction": "close-up",
            "character_angles": [{
                "character": "X", "gaze_direction_kind": "closed_eyes",
                "subject_state": "dead",
            }],
        },
        state_variant_sids={},
        entity_lookup={"uuid-c08": {
            "id": "uuid-c08", "entity_type": "character", "short_id": "C08",
        }},
    )
    assert result is not None
    _label, _img, _loc, ref_role, meta = result
    assert ref_role == "previous_shot_same_frame_zoomed"
    assert meta["immobilized_subjects"] == [{"character": "X", "state": "dead"}]


def test_prev_shot_ref_zoom_alive_subjects_no_immobilized_metadata(svc):
    """feedback6-C 경계: alive 피사체만 있으면 immobilized_subjects 미발화."""
    dep_map = {"12_12": {
        "ref_usage": "zoom_in_detail", "ignore_elements": "",
        "keep_elements": [],
    }}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="dep_scene",
        still_data={"scene_index": 12, "shot_index": 12},
        visible_entities=[{"id": "uuid-c08", "entity_type": "character"}],
        current_location_ids=[],
        dep_scene_id="dep-still",
        stills=[{"id": "dep-still",
                 "visible_entities_json": '[{"id": "uuid-c08"}]'}],
        location_scene_history={},
        dep_detail_map=dep_map,
        staging={
            "framing_scale": "close", "camera_direction": "close-up",
            "character_angles": [{
                "character": "X", "gaze_direction_kind": "at_camera",
                "subject_state": "alive",
            }],
        },
        state_variant_sids={},
        entity_lookup={"uuid-c08": {
            "id": "uuid-c08", "entity_type": "character", "short_id": "C08",
        }},
    )
    assert result is not None
    *_rest, meta = result
    assert "immobilized_subjects" not in meta


def test_prev_shot_ref_zoom_prop_missing_in_dep_diagnostic(svc):
    """feedback6-C (S12 실측): 줌인 샷 VE 의 prop 이 dep 프레임 VE 에 없으면
    '같은 순간' 모순이 작문 단계에서 baked in — 강등 없이 diagnostic metadata
    로 보존 (W3 캐릭터 강등과 달리 prop 줌인은 continuity 유지가 맞다)."""
    dep_map = {"12_12": {
        "ref_usage": "zoom_in_detail", "ignore_elements": "",
        "keep_elements": [],
    }}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="dep_scene",
        still_data={"scene_index": 12, "shot_index": 12},
        visible_entities=[
            {"id": "uuid-c08", "entity_type": "character"},
            {"id": "uuid-p02", "entity_type": "prop"},
        ],
        current_location_ids=[],
        dep_scene_id="dep-still",
        stills=[{"id": "dep-still",
                 "visible_entities_json": '[{"id": "uuid-c08"}]'}],  # prop 부재
        location_scene_history={},
        dep_detail_map=dep_map,
        staging={"framing_scale": "close", "camera_direction": "close-up"},
        state_variant_sids={},
        entity_lookup={
            "uuid-c08": {"id": "uuid-c08", "entity_type": "character",
                         "short_id": "C08"},
            "uuid-p02": {"id": "uuid-p02", "entity_type": "prop",
                         "short_id": "P02"},
        },
    )
    assert result is not None
    _label, _img, _loc, ref_role, meta = result
    # prop 부재는 강등 사유 아님 — zoom 유지
    assert ref_role == "previous_shot_same_frame_zoomed"
    assert meta["zoom_props_missing_in_dep"] == ["P02"]


def test_prev_shot_ref_zoom_prop_in_dep_no_diagnostic(svc):
    """feedback6-C 경계: prop 이 dep 프레임에도 있으면 diagnostic 미발화."""
    dep_map = {"12_12": {
        "ref_usage": "zoom_in_detail", "ignore_elements": "",
        "keep_elements": [],
    }}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="dep_scene",
        still_data={"scene_index": 12, "shot_index": 12},
        visible_entities=[
            {"id": "uuid-c08", "entity_type": "character"},
            {"id": "uuid-p02", "entity_type": "prop"},
        ],
        current_location_ids=[],
        dep_scene_id="dep-still",
        stills=[{"id": "dep-still",
                 "visible_entities_json":
                     '[{"id": "uuid-c08"}, {"id": "uuid-p02"}]'}],
        location_scene_history={},
        dep_detail_map=dep_map,
        staging={"framing_scale": "close", "camera_direction": "close-up"},
        state_variant_sids={},
        entity_lookup={
            "uuid-c08": {"id": "uuid-c08", "entity_type": "character",
                         "short_id": "C08"},
            "uuid-p02": {"id": "uuid-p02", "entity_type": "prop",
                         "short_id": "P02"},
        },
    )
    assert result is not None
    *_rest, meta = result
    assert "zoom_props_missing_in_dep" not in meta


def test_prev_shot_ref_atmosphere_reference_no_ignore(svc):
    """atmosphere_reference는 ignore/remove 안붙음."""
    dep_map = {"1_1": {"ref_usage": "atmosphere_reference", "ignore_elements": "SHOULD_NOT_APPEAR"}}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="location_history",  # spec §6 — non-zoom_in_detail Layer 3 trigger X
        still_data={"scene_index": 1, "shot_index": 1},
        visible_entities=[], current_location_ids=["loc1"],
        dep_scene_id=None, stills=[],
        location_scene_history={"loc1": (None, {"id": "S0", "visible_entities_json": "[]"})},  # Layer 2.6
        dep_detail_map=dep_map,
        # framing_scale enum SOT v1 — matrix v1. atmosphere_reference 분기
        # intent 유지 → close 외 (medium) 로 보강 (close + atmosphere 는 raise).
        staging={"framing_scale": "medium", "camera_direction": "wide"},
        state_variant_sids={},
        entity_lookup={},
    )
    label, _, _, _ref_role, _ref_role_metadata = result  # Area #11 v1 W2: 5-tuple
    assert "DIFFERENT ROOM/ANGLE" in label
    assert "SHOULD_NOT_APPEAR" not in label


def test_prev_shot_ref_exact_background_with_keep(svc):
    """exact_background + keep_elements 붙음."""
    dep_map = {"1_1": {
        "ref_usage": "exact_background",
        "keep_elements": [{"label": "furniture", "kind": "environment", "subject_kind": "non_human_visual_element"}],
    }}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="location_history",
        still_data={"scene_index": 1, "shot_index": 1},
        visible_entities=[], current_location_ids=["loc1"],
        dep_scene_id=None, stills=[],
        location_scene_history={"loc1": (None, {"id": "S0", "visible_entities_json": "[]"})},
        dep_detail_map=dep_map,
        # framing_scale enum SOT v1 — matrix v1. exact_background 분기 intent
        # 유지 → medium 으로 보강 (close + exact_background 는 raise).
        staging={"framing_scale": "medium", "camera_direction": "wide"},
        state_variant_sids={},
        entity_lookup={},
    )
    label, _, _, _ref_role, _ref_role_metadata = result  # Area #11 v1 W2: 5-tuple
    assert "SAME ROOM" in label
    assert "Keep: furniture" in label


def test_prev_shot_ref_fallback_no_dead_chars(svc):
    """ref_usage 없음 + staging의 dead 없음 → 기본 fallback label."""
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="location_history",
        still_data={"scene_index": 1, "shot_index": 1},
        visible_entities=[], current_location_ids=["loc1"],
        dep_scene_id=None, stills=[],
        location_scene_history={"loc1": (None, {"id": "S0", "visible_entities_json": "[]"})},
        dep_detail_map={},
        # framing_scale enum SOT v1 — matrix v1. ref_usage 없는 fallback 분기
        # intent 유지 → medium 으로 보강 (close + empty = raise).
        staging={"framing_scale": "medium", "camera_direction": "wide"},
        state_variant_sids={},
        entity_lookup={},
    )
    label, _, _, _ref_role, _ref_role_metadata = result  # Area #11 v1 W2: 5-tuple
    assert "Ignore all people" in label
    # Area #2 W5: 새 중립 wording assertion. immobilized 분기 진입 안 함.
    assert "Keep registered immobilized-state figures" not in label


def test_prev_shot_ref_fallback_with_dead_chars(svc):
    """ref_usage 없음 + staging에 immobilized char → Keep registered immobilized-state figures 문구 포함.

    Area #2 W5: v13 3 field shape + label 중립화 (\"dead/unconscious bodies\" → \"registered immobilized-state figures\").
    """
    # framing_scale enum SOT v1 — matrix v1. ref_usage='' fallback 의도
    # 유지 → medium (close 면 raise). character_angles immobilized char 보존.
    staging = {
        "framing_scale": "medium",
        "camera_direction": "wide",
        "character_angles": [{"character": "X", "gaze_direction_kind": "closed_eyes", "subject_state": "dead"}],
    }
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="location_history",
        still_data={"scene_index": 1, "shot_index": 1},
        visible_entities=[], current_location_ids=["loc1"],
        dep_scene_id=None, stills=[],
        location_scene_history={"loc1": (None, {"id": "S0", "visible_entities_json": "[]"})},
        dep_detail_map={}, staging=staging, state_variant_sids={},
        entity_lookup={},
    )
    label, _, _, _ref_role, _ref_role_metadata = result  # Area #11 v1 W2: 5-tuple
    assert "Keep registered immobilized-state figures" in label


def test_prev_shot_ref_remove_hints_computation(svc):
    """prev에 있던 character가 현재에 없으면 remove_hints → 'Also ignore: name'."""
    prev_still = {
        "id": "prev_s",
        "visible_entities_json": '[{"id": "ent_gone"}, {"id": "ent_current"}]',
    }
    stills = [prev_still]
    visible = [{"id": "ent_current"}]  # ent_gone 없음
    entity_lookup = {
        "ent_gone": {"id": "ent_gone", "entity_type": "character", "name": "GoneChar", "short_id": "C99"},
        "ent_current": {"id": "ent_current", "entity_type": "character", "name": "Current"},
    }
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="dep_scene",  # spec §6 — dep_scene_id="prev_s" + stills 매칭
        still_data={"scene_index": 1, "shot_index": 1},
        visible_entities=visible,
        current_location_ids=[],
        dep_scene_id="prev_s",
        stills=stills,
        location_scene_history={},
        dep_detail_map={},
        # framing_scale enum SOT v1 — matrix v1. ref_usage='' fallback 분기
        # intent 유지 → medium 으로 보강 (close + empty = raise).
        staging={"framing_scale": "medium", "camera_direction": "wide"},
        state_variant_sids={},
        entity_lookup=entity_lookup,
    )
    label, _, _, _ref_role, _ref_role_metadata = result  # Area #11 v1 W2: 5-tuple
    assert "Also ignore: GoneChar" in label


def test_prev_shot_ref_state_variant_excluded_from_remove_hints(svc):
    """state_variant에 포함된 short_id는 remove_hints에서 제외."""
    prev_still = {
        "id": "prev_s",
        "visible_entities_json": '[{"id": "ent_dead"}]',
    }
    stills = [prev_still]
    entity_lookup = {
        "ent_dead": {"id": "ent_dead", "entity_type": "character", "name": "DeadChar", "short_id": "C99"},
    }
    state_variant_sids = {"C99": {"key": "state_variant:ent_dead:dead", "state": "dead"}}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="dep_scene",
        still_data={"scene_index": 1, "shot_index": 1},
        visible_entities=[],  # 현재에 없음
        current_location_ids=[],
        dep_scene_id="prev_s",
        stills=stills,
        location_scene_history={},
        dep_detail_map={},
        # framing_scale enum SOT v1 — matrix v1. ref_usage='' fallback 분기
        # intent 유지 → medium 으로 보강.
        staging={"framing_scale": "medium", "camera_direction": "wide"},
        state_variant_sids=state_variant_sids,
        entity_lookup=entity_lookup,
    )
    label, _, _, _ref_role, _ref_role_metadata = result  # Area #11 v1 W2: 5-tuple
    # DeadChar는 state_variant에 있으므로 remove_hints에서 제외 → "Also ignore" 없음
    assert "Also ignore: DeadChar" not in label


def test_prev_shot_ref_location_history_fallback_when_no_dep_scene(svc, tmp_path):
    """dep_scene_id 없으면 location_scene_history에서 첫 매칭."""
    prev_still = {"id": "loc_prev", "visible_entities_json": '[]'}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="location_history",  # spec §6 — coordinator 가 location_history fallback bytes 결정한 case
        still_data={"scene_index": 1, "shot_index": 1},
        visible_entities=[],
        current_location_ids=["loc_a"],
        dep_scene_id=None,
        stills=[],
        location_scene_history={"loc_a": (b"X", prev_still)},
        dep_detail_map={},
        # framing_scale enum SOT v1 — matrix v1. ref_usage='' fallback 분기
        # intent 유지 → medium 으로 보강.
        staging={"framing_scale": "medium", "camera_direction": "wide"},
        state_variant_sids={},
        entity_lookup={},
    )
    label, img, loc_id, _ref_role, _ref_role_metadata = result  # Area #11 v1 W2: 5-tuple
    assert img == b"PREV"  # best_prev_bytes 그대로 반환
    assert loc_id == "loc_a"  # D5: location_scene_history 매칭된 loc
    # prev_still이 location_history에서 로드되어 처리됨 (remove_hints는 빈 visible_entities_json이라 빈 리스트)


def test_prev_shot_ref_invalid_prev_visible_json_safe(svc):
    """prev_still.visible_entities_json이 깨져 있어도 예외 없이 빈 리스트 처리."""
    prev_still = {"id": "prev", "visible_entities_json": "not json {"}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="dep_scene",
        still_data={"scene_index": 1, "shot_index": 1},
        visible_entities=[], current_location_ids=[],
        dep_scene_id="prev",
        stills=[prev_still],
        location_scene_history={},
        dep_detail_map={},
        # framing_scale enum SOT v1 — matrix v1. ref_usage='' fallback 분기
        # → medium 보강 (json safety intent 유지).
        staging={"framing_scale": "medium", "camera_direction": "wide"},
        state_variant_sids={}, entity_lookup={},
    )
    # 예외 없이 결과 반환
    assert result is not None


# ──────────────────────────────────────────────────────────────────────
# Area #11 v1 W2 (2026-05-18+): build_prev_shot_background_ref 5-tuple return
# (label, bytes, loc_id, ref_role, ref_role_metadata). Codex iter 4 W3 review
# stale wording cleanup — D5 spec §4.2.2 의 3-tuple contract 가 Area #11 W2
# atomic switch 로 5-tuple 로 확장됨 (ref_usage → ref_role enum 매핑).
# ──────────────────────────────────────────────────────────────────────


def test_prev_shot_ref_area11_return_is_5tuple(svc):
    """Area #11 v1 W2: helper return 이 (label, bytes, loc_id, ref_role, ref_role_metadata) 5-tuple."""
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="location_history",
        still_data={"scene_index": 1, "shot_index": 1},
        visible_entities=[],
        current_location_ids=["L05"],
        dep_scene_id=None, stills=[],
        location_scene_history={"L05": (b"PREV_X", {"id": "p1", "visible_entities_json": "[]"})},
        dep_detail_map={},
        # framing_scale enum SOT v1 — matrix v1. 5-tuple shape verify intent
        # 유지 → medium 으로 보강 (close + empty = raise).
        staging={"framing_scale": "medium", "camera_direction": "wide"},
        state_variant_sids={}, entity_lookup={},
    )
    assert result is not None
    assert isinstance(result, tuple) and len(result) == 5, \
        "Area #11 v1 W2: helper must return (label, bytes, loc_id, ref_role, ref_role_metadata) 5-tuple"
    label, image_bytes, loc_id, _ref_role, _ref_role_metadata = result
    assert isinstance(label, str)
    assert isinstance(image_bytes, bytes)
    assert isinstance(loc_id, str), "loc_id must be str (empty string allowed when not determinable)"


def test_prev_shot_ref_d5_loc_id_from_location_history_match(svc):
    """D5: loc_id 결정 — current_location_ids 와 location_scene_history 매칭된 loc."""
    prev_still = {"id": "p1", "visible_entities_json": "[]"}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="location_history",
        still_data={"scene_index": 5, "shot_index": 3},
        visible_entities=[],
        current_location_ids=["L05", "L08"],
        dep_scene_id=None, stills=[],
        location_scene_history={"L05": (b"X", prev_still)},  # L05 first match
        dep_detail_map={},
        # framing_scale enum SOT v1 — matrix v1. loc_id history match intent
        # 유지 → medium 보강.
        staging={"framing_scale": "medium", "camera_direction": "wide"},
        state_variant_sids={}, entity_lookup={},
    )
    _, _, loc_id, _ref_role, _ref_role_metadata = result
    assert loc_id == "L05", "loc_id 가 location_scene_history 첫 매칭이어야 함"


def test_prev_shot_ref_d5_loc_id_from_dep_scene_priority(svc):
    """D5 우선순위: dep_scene_id 의 location > location_scene_history 매칭.

    dep_scene 의 visible_entities_json 안 entity_type='location' 첫 항을 loc_id 로 사용.
    """
    dep_still = {
        "id": "dep_s",
        "visible_entities_json": json.dumps([
            {"id": "L99_uuid", "entity_type": "location"},
        ]),
    }
    entity_lookup = {"L99_uuid": {"id": "L99_uuid", "entity_type": "location", "short_id": "L99"}}
    prev_still_history = {"id": "p_other", "visible_entities_json": "[]"}
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="dep_scene",  # spec §6 — dep_scene priority intent
        still_data={"scene_index": 5, "shot_index": 3},
        visible_entities=[],
        current_location_ids=["L05"],
        dep_scene_id="dep_s",
        stills=[dep_still],
        location_scene_history={"L05": (b"X", prev_still_history)},  # 사용 안 됨 (§4.5 selection: dep_scene 분기)
        dep_detail_map={},
        # framing_scale enum SOT v1 — matrix v1. loc_id dep priority intent
        # 유지 → medium 보강.
        staging={"framing_scale": "medium", "camera_direction": "wide"},
        state_variant_sids={},
        entity_lookup=entity_lookup,
    )
    _, _, loc_id, _ref_role, _ref_role_metadata = result
    # dep_scene 의 location entity short_id 가 우선
    assert loc_id == "L99", \
        f"dep_scene_id 의 location 이 우선이어야 함 — got {loc_id!r}"


def test_prev_shot_ref_d5_loc_id_empty_when_not_determinable(svc):
    """D5 P2 strict: loc_id 결정 실패 시 빈 문자열. silent forgery 차단 — validator 가
    empty loc_id 를 어떤 required bg_id 도 만족시키지 못하게 처리.

    2026-05-15 zoom_in_detail source provenance hardening — Layer 2/2.5/2.6 통과
    위해 bytes_source_kind="dep_scene" + dep_scene_id + stills 명시. dep_scene 의
    prev_still 에 location entity 없으면 loc_id_from_dep 빈 string (intent 보존).
    """
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind="dep_scene",
        still_data={"scene_index": 1, "shot_index": 1},
        visible_entities=[],
        current_location_ids=[],  # loc 없음
        dep_scene_id="dep_s_no_loc",
        stills=[{"id": "dep_s_no_loc", "visible_entities_json": "[]"}],  # no location entity in prev_still
        location_scene_history={},  # history 매칭 없음
        dep_detail_map={},
        # framing_scale enum SOT v1 — matrix v1. loc_id empty intent 유지
        # → medium 보강.
        staging={"framing_scale": "medium", "camera_direction": "wide"},
        state_variant_sids={}, entity_lookup={},
    )
    # bytes 가 None 아니라서 helper 는 정상 progress — loc_id 결정 source 만 부재
    assert result is not None
    _, _, loc_id, _ref_role, _ref_role_metadata = result
    assert loc_id == "", "P2: loc_id 결정 실패 시 빈 문자열 (sentinel forgery 금지)"


def test_prev_shot_ref_d5_none_when_no_bytes_3tuple_signature_unaffected(svc):
    """D5: best_prev_bytes None 시 None 반환은 그대로 (5-tuple shape 도입과 무관, Area #11 v1 W2)."""
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=None,
        bytes_source_kind="none",
        still_data={"scene_index": 1, "shot_index": 1},
        visible_entities=[], current_location_ids=[],
        dep_scene_id=None, stills=[], location_scene_history={},
        dep_detail_map={}, staging=None, state_variant_sids={}, entity_lookup={},
    )
    assert result is None


# ──────────────────────────────────────────────────────────────────────
# D5 T2 (2026-05-09): resolve_refs_for_prompt 가 attached_meta 평행 리스트 반환
# spec §4.2 + §4.3 — 8 append site 의 (kind, id) 매핑 검증.
# P1 (라벨 추론 X) + P2 (fallback meta 위조 X).
# ──────────────────────────────────────────────────────────────────────


def test_resolve_refs_d5_returns_labeled_and_meta_tuple(svc):
    """Area #11 v1 W2: resolve_refs_for_prompt 는 LabeledRefPayload 반환."""
    from app.services.prompt_service import LabeledRefPayload
    visible = [
        {"id": "c1", "short_id": "C01", "name": "수리영", "entity_type": "character"},
    ]
    entity_lookup = {"c1": visible[0], "o2": {"id": "o2", "short_id": "O02", "entity_type": "outlook"}}
    scene_ref_map = {"composite:c1:o2": b"comp"}
    result = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O02 walks past the storefront.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup=entity_lookup,
        state_variant_sids={},
    )
    assert isinstance(result, LabeledRefPayload), \
        "Area #11 v1 W2: resolve_refs_for_prompt 가 LabeledRefPayload 반환"
    # invariant 1: length match (4 parallel list)
    assert len(result.labeled_refs) == len(result.attached_meta), \
        "Area #11 v1 W2 invariant: labeled_refs 와 attached_meta length 일치"
    assert len(result.labeled_refs) == len(result.ref_roles)
    assert len(result.labeled_refs) == len(result.ref_role_metadata)


def test_resolve_refs_force_character_names_overrides_generic_policy(svc):
    """W21B-W8 option C — composition continuity group 멤버에서 forced staged
    character 는 ``generic_descriptor_allowed`` 정책이어도 base identity ref 가
    attach 된다 (force_character_names 미전달 시엔 기존대로 skip). 강제 범위는 step
    이 this-shot ∩ anchor_source staged 교집합으로 좁혀 산출 — 여기선 계약만 검증."""
    visible = [{"id": "c1", "short_id": "C01", "name": "X", "entity_type": "character"}]
    entity_lookup = {"c1": visible[0]}
    scene_ref_map = {"c1": b"base"}
    staging = {
        "character_angles": [{"character": "X"}],
        "subject_reference_policy": [
            {"subject_id": "C01", "policy": "generic_descriptor_allowed"}],
    }
    # 프롬프트에 C01 토큰 없음 → 토큰 경로 미부착. 정책 generic → 기본은 skip.
    base = svc.resolve_refs_for_prompt(
        t2i_prompt="a figure recedes down the road.", visible_entities=visible,
        scene_ref_image_map=scene_ref_map, entity_lookup=entity_lookup,
        staging=staging,
    )
    assert ("character", "C01") not in base.attached_meta  # generic skip 유지

    forced = svc.resolve_refs_for_prompt(
        t2i_prompt="a figure recedes down the road.", visible_entities=visible,
        scene_ref_image_map=scene_ref_map, entity_lookup=entity_lookup,
        staging=staging, force_character_names={"X"},
    )
    assert ("character", "C01") in forced.attached_meta  # forced → attach
    _idx = forced.attached_meta.index(("character", "C01"))
    assert forced.ref_role_metadata[_idx].get(
        "outdoor_continuity_forced_character_ref") is True
    assert forced.ref_role_metadata[_idx].get("reason") == \
        "staged_character_in_continuity_group"


def test_resolve_refs_d5_composite_meta_outlook_kind(svc):
    """D5 line 417: composite (C##O##) → ('character_outlook', sid)."""
    visible = [{"id": "c1", "short_id": "C01", "name": "X", "entity_type": "character"}]
    entity_lookup = {
        "c1": visible[0],
        "o2": {"id": "o2", "short_id": "O02", "entity_type": "outlook"},
    }
    scene_ref_map = {"composite:c1:o2": b"comp"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O02 walks.", visible_entities=visible,
        scene_ref_image_map=scene_ref_map, entity_lookup=entity_lookup,
    )
    _, meta = _payload.labeled_refs, _payload.attached_meta
    assert ("character_outlook", "C01O02") in meta


def test_resolve_refs_d5_o00_base_meta_character_kind(svc):
    """D5 line 408: O00 (Null Outlook) → ('character', char_sid). outlook 만족 X (P2)."""
    visible = [{"id": "c1", "short_id": "C01", "name": "X", "entity_type": "character"}]
    entity_lookup = {"c1": visible[0]}
    scene_ref_map = {"c1": b"base"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O00 walks.", visible_entities=visible,
        scene_ref_image_map=scene_ref_map, entity_lookup=entity_lookup,
    )
    _, meta = _payload.labeled_refs, _payload.attached_meta
    # P2: O00 base 는 character_outlook 위조 안 함
    assert ("character", "C01") in meta
    assert ("character_outlook", "C01O00") not in meta


def test_resolve_refs_d5_composite_missing_falls_back_to_base_meta_p2(svc):
    """D5 line 424 (P2 strict): composite 부재 시 base ref 가 ('character', sid).

    composite 가 없어 C01 base 로 fallback 되더라도 character_outlook 위조 금지.
    이게 S8 production 결함의 직접 fix — outlook 요구를 base 가 만족 X.
    """
    visible = [{"id": "c1", "short_id": "C01", "name": "X", "entity_type": "character"}]
    entity_lookup = {
        "c1": visible[0],
        "o2": {"id": "o2", "short_id": "O02", "entity_type": "outlook"},
    }
    # composite 부재, base 만 존재
    scene_ref_map = {"c1": b"base"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O02 walks.", visible_entities=visible,
        scene_ref_image_map=scene_ref_map, entity_lookup=entity_lookup,
    )
    _, meta = _payload.labeled_refs, _payload.attached_meta
    # P2 strict — composite 미존재 → base attach but separate kind
    assert ("character", "C01") in meta
    assert ("character_outlook", "C01O02") not in meta


def test_resolve_refs_d5_state_variant_meta_separate_kind(svc):
    """D5 line 391: state_variant → ('character_state', 'C01:state'). outlook 만족 X."""
    visible = [{"id": "c1", "short_id": "C01", "name": "X", "entity_type": "character"}]
    entity_lookup = {"c1": visible[0]}
    sv_key = "state_variant:c1:unconscious"
    scene_ref_map = {sv_key: b"sv"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O02 lies.", visible_entities=visible,
        scene_ref_image_map=scene_ref_map, entity_lookup=entity_lookup,
        state_variant_sids={"C01": {"key": sv_key, "state": "unconscious"}},
    )
    _, meta = _payload.labeled_refs, _payload.attached_meta
    # P2: state_variant 가 character_outlook 만족 X
    assert ("character_state", "C01:unconscious") in meta
    assert ("character_outlook", "C01O02") not in meta


def test_resolve_refs_d5_prop_meta_prop_kind(svc):
    """D5 line 496: prop → ('prop', P##).

    Area D-min: required_refs SOT 로 전환 — 옛 'Character holds 스마트폰.' text match
    가정은 폐기. visible_entities 등재 + required_refs(kind='prop') 가 attach 결정.
    """
    visible = [{"id": "p1", "short_id": "P03", "name": "스마트폰", "entity_type": "prop"}]
    entity_lookup = {"p1": visible[0]}
    scene_ref_map = {"p1": b"prop"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="Character holds 스마트폰.", visible_entities=visible,
        scene_ref_image_map=scene_ref_map, entity_lookup=entity_lookup,
        required_refs=[{"kind": "prop", "id": "P03", "policy": "required"}],
    )
    _, meta = _payload.labeled_refs, _payload.attached_meta
    assert ("prop", "P03") in meta


def test_resolve_refs_d5_legacy_pattern_meta_uses_entity_lookup_short_id_sot(svc):
    """D5 I5 (Claude): legacy [[name]+[outlook]] 의 char_sid 추출이 entity_lookup
    의 short_id 단일 SOT — _sid_to_uuid_inv 같은 fallback 없이.
    """
    visible = [
        {"id": "c1", "short_id": "C01", "name": "수리영", "entity_type": "character"},
        {"id": "o2", "short_id": "O02", "name": "casual", "entity_type": "outlook"},
    ]
    entity_lookup = {"c1": visible[0], "o2": visible[1]}
    scene_ref_map = {"composite:c1:o2": b"comp"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="[[수리영]+[casual]] walks.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup=entity_lookup,
    )
    _, meta = _payload.labeled_refs, _payload.attached_meta
    # legacy composite → ('character_outlook', 'C01O02') (entity_lookup SOT)
    assert ("character_outlook", "C01O02") in meta


def test_resolve_refs_d5_legacy_pattern_unknown_short_id_skipped_p1(svc):
    """D5 P1 SOT: entity_lookup 에 short_id 부재면 ref + meta 동시 skip
    (length match invariant 보존, silent forgery 차단).
    """
    visible = [
        # short_id 부재 (legacy 데이터 결손)
        {"id": "c_unknown", "name": "수리영", "entity_type": "character"},
    ]
    entity_lookup = {"c_unknown": visible[0]}
    scene_ref_map = {"c_unknown": b"base"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="[[수리영]+[미지정]] walks.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup=entity_lookup,
    )
    labeled_refs, meta = _payload.labeled_refs, _payload.attached_meta
    # P1: short_id SOT 부재 → ref + meta 동시 skip
    assert len(labeled_refs) == len(meta), "invariant 1 보존"
    # short_id 결손 시 attach 거부 (silent fallback 차단)
    assert all(kind != "character" or sid for kind, sid in meta), \
        "P1: char_sid 빈 string meta 금지"


def test_resolve_refs_d5_length_match_invariant_across_all_paths(svc):
    """D5 invariant 1: 모든 append site 가 labeled_refs + attached_meta 동시 add.
    composite + base fallback + prop 혼합 시나리오에서 length 일치 검증.

    Area D-min: prop attach 는 required_refs SOT — 옛 phone/shot_description 매칭
    가정은 폐기.
    """
    visible = [
        {"id": "c1", "short_id": "C01", "name": "X", "entity_type": "character"},
        {"id": "p1", "short_id": "P03", "name": "phone", "entity_type": "prop"},
    ]
    entity_lookup = {
        "c1": visible[0], "p1": visible[1],
        "o2": {"id": "o2", "short_id": "O02", "entity_type": "outlook"},
    }
    # composite 존재 + prop 존재
    scene_ref_map = {"composite:c1:o2": b"comp", "p1": b"prop"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O02 holds phone.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup=entity_lookup,
        required_refs=[{"kind": "prop", "id": "P03", "policy": "required"}],
    )
    labeled_refs, meta = _payload.labeled_refs, _payload.attached_meta
    assert len(labeled_refs) == len(meta) == 2
    # 매핑 완전성 (P1 — 모든 ref 가 source-of-truth meta)
    kinds = {kind for kind, _ in meta}
    assert kinds == {"character_outlook", "prop"}


# ──────────────────────────────────────────────────────────────────────
# get_ref_image_map_excluding_locations — W5 F22 Phase B.23.3
# ──────────────────────────────────────────────────────────────────────


def test_get_ref_image_map_excluding_locations_filters_location_eids(svc, monkeypatch):
    """entity_type=='location'인 eid는 최종 map에서 제외."""
    # get_reference_image_map을 fake로 대체
    fake_all = {"c1": b"A", "l1": b"B", "p1": b"C"}
    monkeypatch.setattr(svc, "get_reference_image_map", lambda ve: fake_all)

    visible = [
        {"id": "c1", "entity_type": "character"},
        {"id": "l1", "entity_type": "location"},
        {"id": "p1", "entity_type": "prop"},
    ]
    result = svc.get_ref_image_map_excluding_locations(visible)
    assert result == {"c1": b"A", "p1": b"C"}
    assert "l1" not in result


def test_get_ref_image_map_excluding_locations_no_locations(svc, monkeypatch):
    """location 엔티티 없으면 전체 map 그대로 반환."""
    fake_all = {"c1": b"A", "p1": b"B"}
    monkeypatch.setattr(svc, "get_reference_image_map", lambda ve: fake_all)

    visible = [
        {"id": "c1", "entity_type": "character"},
        {"id": "p1", "entity_type": "prop"},
    ]
    result = svc.get_ref_image_map_excluding_locations(visible)
    assert result == fake_all


def test_get_ref_image_map_excluding_locations_empty_input(svc, monkeypatch):
    """visible_entities 비어있으면 빈 dict."""
    monkeypatch.setattr(svc, "get_reference_image_map", lambda ve: {})
    assert svc.get_ref_image_map_excluding_locations([]) == {}


def test_get_ref_image_map_excluding_locations_missing_entity_type_treated_non_location(svc, monkeypatch):
    """entity_type이 없는 visible entry는 location으로 간주되지 않음 (safe get)."""
    fake_all = {"c1": b"A", "x1": b"B"}
    monkeypatch.setattr(svc, "get_reference_image_map", lambda ve: fake_all)

    visible = [
        {"id": "c1", "entity_type": "character"},
        {"id": "x1"},  # entity_type 누락
    ]
    result = svc.get_ref_image_map_excluding_locations(visible)
    # x1은 location이 아니므로 유지
    assert "x1" in result


# ──────────────────────────────────────────────────────────────────────
# Area D-min: prop attach = required_refs 단일 SOT
# ──────────────────────────────────────────────────────────────────────


def test_resolve_refs_prop_attach_0_when_p_id_in_t2i_no_required_refs(svc):
    """D-min T1: t2i_prompt 에 P## 박혀 있어도 required_refs 없으면 attach 안 됨.

    legacy text matching (a) P## word-boundary 분기 폐기 회귀 가드.
    Area B reference_required=false 면 required_refs 미발생 → contract absence.
    """
    visible = [{"id": "p1", "short_id": "P02", "name": "여행 가방", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="A worn P02 sits beside the door.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
    )
    out, meta = _payload.labeled_refs, _payload.attached_meta
    assert not any("P02" in label for label, _ in out)
    assert ("prop", "P02") not in meta
    assert len(out) == len(meta)  # invariant 1


def test_resolve_refs_prop_attach_when_required_refs_even_without_p_id_in_t2i(svc):
    """D-min T2: required_refs(kind='prop') 있으면 t2i_prompt 에 P## 없어도 attach.

    Area B producer SOT (render_contracts → required_refs) 가 단일 결정자.
    Patch A Tier 2 forced attach 분기 유지 확인.
    """
    visible = [{"id": "p1", "short_id": "P02", "name": "여행 가방", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="A worn travel bag sits beside the door.",  # P## 없음
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
        required_refs=[{"kind": "prop", "id": "P02", "policy": "required"}],
    )
    out, meta = _payload.labeled_refs, _payload.attached_meta
    assert any("P02" in label for label, _ in out)
    assert ("prop", "P02") in meta
    assert len(out) == len(meta)


def test_resolve_refs_prop_attach_0_for_substring_path_deleted(svc):
    """D-min T3: prop name 'cup' 이 visible 에 있고 t2i 에 'cup' word 있어도 attach 0.

    legacy text matching (b) prop_name word-boundary/CJK substring 폐기 회귀 가드.
    legacy 가 차단하던 path (cup ⊂ cupboard) 도, legacy 가 attach 하던 path
    (cup 단독 word) 도, 모두 required_refs SOT 로 통일된다.
    """
    visible = [{"id": "p1", "short_id": "P02", "name": "cup", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="The character lifts the cup to drink.",  # cup 단독 word
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
    )
    out, meta = _payload.labeled_refs, _payload.attached_meta
    # legacy 였으면 attach 됐을 케이스 — required_refs 없으므로 attach 0.
    assert not any("P02" in label for label, _ in out)
    assert ("prop", "P02") not in meta


def test_resolve_refs_prop_attach_0_for_cjk_substring_path_deleted(svc):
    """D-min T4: 한국어 prop name 이 t2i 에 substring 등장해도 required_refs 없으면 attach 0.

    legacy text matching CJK substring 분기 (line 577-579 prop_name in _haystack) 폐기 검증.
    """
    visible = [{"id": "p1", "short_id": "P02", "name": "여행 가방", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="A 여행 가방 sits beside the door.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
    )
    out, meta = _payload.labeled_refs, _payload.attached_meta
    assert not any("P02" in label for label, _ in out)
    assert ("prop", "P02") not in meta


def test_resolve_refs_prop_skip_when_no_match(svc):
    """D-min: required_refs 없으면 어떤 prop 도 attach 안 됨 (contract absence).

    옛: short_id/name/description 매칭 실패 → skip 의도
    새: required_refs 단일 SOT — 매칭 가설 자체 폐기, 동일 동작 (attach 0).
    """
    visible = [{"id": "p1", "short_id": "P02", "name": "여행 가방", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="A nondescript object on the floor.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
    )
    out, _meta = _payload.labeled_refs, _payload.attached_meta
    assert not any("P02" in label for label, _ in out)


def test_resolve_refs_prop_skip_english_substring_false_positive(svc):
    """D-min: legacy substring false-positive ('cup' ⊂ 'cupboard') 가 의도된 skip 인지 검증.

    옛: word-boundary regex 가 차단
    새: required_refs 가 SOT — 매칭 가설 자체 폐기. 동일하게 attach 0 (다른 이유).
    """
    visible = [{"id": "p1", "short_id": "P02", "name": "cup", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="The character opens the cupboard quietly.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
    )
    out, _meta = _payload.labeled_refs, _payload.attached_meta
    assert not any("P02" in label for label, _ in out)


# ──────────────────────────────────────────────────────────────────────
# Area D-min: T5-T8 invariant + cascade tests
# ──────────────────────────────────────────────────────────────────────


def test_resolve_refs_prop_dedup_when_required_refs_repeated(svc):
    """D-min T5: required_refs 가 같은 P## 를 중복 포함해도 single attach (dedup)."""
    visible = [{"id": "p1", "short_id": "P02", "name": "여행 가방", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="A travel bag sits by the door.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
        required_refs=[
            {"kind": "prop", "id": "P02", "policy": "required"},
            {"kind": "prop", "id": "P02", "policy": "required"},  # 중복
        ],
    )
    out, meta = _payload.labeled_refs, _payload.attached_meta
    p02_count = sum(1 for label, _ in out if "P02" in label)
    assert p02_count == 1
    assert meta.count(("prop", "P02")) == 1
    assert len(out) == len(meta)


def test_resolve_refs_length_invariant_across_branches(svc):
    """D-min T6: (labeled_refs, attached_meta) length 일치 invariant — char + prop 혼합."""
    visible = [
        {"id": "c1", "short_id": "C01", "name": "X", "entity_type": "character"},
        {"id": "p1", "short_id": "P02", "name": "trinket", "entity_type": "prop"},
    ]
    scene_ref_map = {"c1": b"CHAR_PNG", "p1": b"PROP_PNG"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O00 holds a trinket.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={"c1": {"short_id": "C01", "entity_type": "character", "name": "X"}},
        required_refs=[{"kind": "prop", "id": "P02", "policy": "required"}],
    )
    out, meta = _payload.labeled_refs, _payload.attached_meta
    assert len(out) == len(meta)
    assert len(out) == 2  # character C01 + prop P02


def test_resolve_refs_for_prompt_set_forwards_required_refs(svc):
    """D-min T7: resolve_refs_for_prompt_set 가 required_refs forwarding 만으로 동일 동작."""
    visible = [{"id": "p1", "short_id": "P02", "name": "여행 가방", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    _payload = svc.resolve_refs_for_prompt_set(
        t2i_prompts=["A bag sits.", "The bag stays."],
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
        required_refs=[{"kind": "prop", "id": "P02", "policy": "required"}],
    )
    out, meta = _payload.labeled_refs, _payload.attached_meta
    assert any("P02" in label for label, _ in out)
    assert ("prop", "P02") in meta


def test_resolve_refs_for_prompt_rejects_shot_description_kwarg(svc):
    """D-min T8: shot_description kwarg 제거 후 caller 가 그 kwarg 로 부르면 TypeError.

    signature cascade 회귀 가드 — 미래에 누군가 shot_description= 부활시키면 즉시 차단.
    """
    visible = []
    scene_ref_map = {}
    with pytest.raises(TypeError, match="shot_description"):
        svc.resolve_refs_for_prompt(
            t2i_prompt="x",
            visible_entities=visible,
            scene_ref_image_map=scene_ref_map,
            entity_lookup={},
            shot_description="legacy kwarg",  # type: ignore[call-arg]
        )


def test_resolve_refs_for_prompt_set_rejects_shot_description_kwarg(svc):
    """D-min T8b: set wrapper 도 동일 cascade 가드."""
    with pytest.raises(TypeError, match="shot_description"):
        svc.resolve_refs_for_prompt_set(
            t2i_prompts=["x"],
            visible_entities=[],
            scene_ref_image_map={},
            entity_lookup={},
            shot_description="legacy kwarg",  # type: ignore[call-arg]
        )


# ──────────────────────────────────────────────────────────────────────
# T5 D5 AC-10 — label 형식 변경 0 (Gemini 송신 payload 무변경)
# ──────────────────────────────────────────────────────────────────────


def test_AC10_image_index_rewrite_format_unchanged():
    """Inc2-a (메타-A): legacy fallback (sidecar 부재) — char ref 라벨이 식별
    descriptor 로, 이름·t2i_prompt(passport)·'Image N' prefix 누출 0.

    (이전엔 'Image N (character reference): {name} — {t2i_prompt}' 였으나 메타-A 로
    생성용 passport·고유명사를 식별 라벨에서 제거. meta 평행 통과는 불변.)
    """
    from app.services.scene_reference_service import build_image_index

    labeled_refs = [("character C01 identity", b"x")]
    attached_meta = [("character", "C01")]
    entity_lookup = {
        "c1": {"id": "c1", "short_id": "C01", "name": "수리영",
               "t2i_prompt": "Passport-style ID photo, plain white background",
               "description": "a young Korean woman with long dark hair"},
    }
    indexed, sid_to_img, _sid_info, _ref_roles, _ref_role_metadata, indexed_meta = build_image_index(
        labeled_refs, entity_lookup, attached_meta=attached_meta,
    )
    assert indexed[0][0] == "character appearance reference: a young Korean woman with long dark hair"
    assert "수리영" not in indexed[0][0]            # 이름 누출 0
    assert "Passport" not in indexed[0][0]          # t2i 생성지시 누출 0
    assert not indexed[0][0].startswith("Image")    # 이중 prefix 제거
    assert indexed[0][1] == b"x"
    assert indexed_meta == attached_meta            # meta 평행 통과 (변형 0)
    assert sid_to_img.get("C01") == 1


def test_AC10_image_index_object_label_format_unchanged():
    """Inc2-a: object ref 라벨이 식별 descriptor — 이름·t2i_prompt 누출 0."""
    from app.services.scene_reference_service import build_image_index

    labeled_refs = [("object P03", b"y")]
    attached_meta = [("prop", "P03")]
    entity_lookup = {
        "p1": {"id": "p1", "short_id": "P03", "name": "phone",
               "t2i_prompt": "product photo of a smartphone",
               "description": "a slim black smartphone"},
    }
    indexed, sid_to_img, _sid_info, _ref_roles, _ref_role_metadata, indexed_meta = build_image_index(
        labeled_refs, entity_lookup, attached_meta=attached_meta,
    )
    assert indexed[0][0] == "object appearance reference: a slim black smartphone"
    assert "phone" not in indexed[0][0].replace("smartphone", "")  # 이름 'phone' 누출 0
    assert "product photo" not in indexed[0][0]
    assert indexed_meta == attached_meta
    assert sid_to_img.get("P03") == 1


def test_AC10_image_index_passthrough_label_format_unchanged():
    """Inc2-a: char/object 패턴 미매치 ref 는 raw label 그대로 (prefix 없음 —
    prompt_service 가 'Reference image N:' 단일 prefix SOT)."""
    from app.services.scene_reference_service import build_image_index

    labeled_refs = [
        ("background chain ref (bg_supermarket for L09)", b"bg"),
        ("previous shot at same location — wide framing", b"prev"),
    ]
    attached_meta = [
        ("background", "bg_supermarket"),
        ("background_prev_shot", "L09"),
    ]
    indexed, _sid_to_img, _sid_info, _ref_roles, _ref_role_metadata, indexed_meta = build_image_index(
        labeled_refs, {}, attached_meta=attached_meta,
    )
    assert indexed[0][0] == "background chain ref (bg_supermarket for L09)"
    assert indexed[1][0] == "previous shot at same location — wide framing"
    assert not indexed[0][0].startswith("Image")
    assert indexed_meta == attached_meta


def test_inc2a_sidecar_primary_char_descriptor():
    """Inc2-a (Codex 핵심): ref_roles/ref_role_metadata sidecar 가 primary SOT —
    description 우선, 이름·passport·prefix 0."""
    from app.services.scene_reference_service import build_image_index

    labeled_refs = [("character C01 identity", b"x")]
    entity_lookup = {
        "c1": {"id": "c1", "short_id": "C01", "name": "수리영",
               "t2i_prompt": "Passport-style ID photo, plain white background, A Korean woman",
               "description": "a young Korean woman with long dark hair"},
    }
    indexed, sid_to_img, *_ = build_image_index(
        labeled_refs, entity_lookup,
        ref_roles=["character_ref"], ref_role_metadata=[{"sid": "C01"}],
        attached_meta=[("character", "C01")],
    )
    assert indexed[0][0] == "character appearance reference: a young Korean woman with long dark hair"
    assert "수리영" not in indexed[0][0]
    assert "Passport" not in indexed[0][0] and "plain white" not in indexed[0][0]
    assert sid_to_img["C01"] == 1


def test_inc2a_state_variant_label_not_overwritten_by_passport():
    """Inc2-a 클러스터 A1: character_state_ref 라벨이 passport 로 덮이지 않고
    state + descriptor 보존."""
    from app.services.scene_reference_service import build_image_index

    labeled_refs = [("character C04 — dead state reference", b"x")]
    entity_lookup = {
        "c4": {"id": "c4", "short_id": "C04", "name": "민숙",
               "t2i_prompt": "Passport-style ID photo, plain white background",
               "description": "Korean woman in her 40s with dark hair"},
    }
    indexed, sid_to_img, *_ = build_image_index(
        labeled_refs, entity_lookup,
        ref_roles=["character_state_ref"],
        ref_role_metadata=[{"sid": "C04", "state": "dead"}],
        attached_meta=[("character_state", "C04:dead")],
    )
    assert indexed[0][0] == "state-specific character appearance reference (dead): Korean woman in her 40s with dark hair"
    assert "민숙" not in indexed[0][0]
    assert "Passport" not in indexed[0][0]
    assert sid_to_img["C04"] == 1


def test_inc2a_composite_outfit_maps_base_and_full_sid():
    """Inc2-a: outfit_ref_inline composite → sid_to_img 가 composite + base 둘 다
    매핑 (rewrite_t2i_with_image_refs 가 bare/composite 토큰 모두 치환)."""
    from app.services.scene_reference_service import build_image_index

    labeled_refs = [("character C08O06 in outfit", b"x")]
    entity_lookup = {"c8": {"id": "c8", "short_id": "C08", "description": "a man in a dark suit"}}
    indexed, sid_to_img, *_ = build_image_index(
        labeled_refs, entity_lookup,
        ref_roles=["outfit_ref_inline"],
        ref_role_metadata=[{"sid": "C08O06", "outfit_kind": "composite"}],
        attached_meta=[("character_outlook", "C08O06")],
    )
    assert indexed[0][0] == "character appearance reference: a man in a dark suit"
    assert sid_to_img["C08O06"] == 1 and sid_to_img["C08"] == 1


def test_inc2a_descriptor_falls_back_to_visual_traits_then_generic():
    """Inc2-a: description 부재 → visual_traits join → generic (t2i_prompt 절대 미사용)."""
    from app.services.scene_reference_service import build_image_index

    # visual_traits fallback
    lr = [("character C01 identity", b"x")]
    el = {"c1": {"id": "c1", "short_id": "C01", "name": "X",
                 "t2i_prompt": "Passport-style ID photo",
                 "visual_traits": ["tall man", "short hair", "scar on cheek"]}}
    indexed, *_ = build_image_index(
        lr, el, ref_roles=["character_ref"], ref_role_metadata=[{"sid": "C01"}],
        attached_meta=[("character", "C01")])
    assert indexed[0][0] == "character appearance reference: tall man, short hair, scar on cheek"
    assert "Passport" not in indexed[0][0]
    # generic fallback (description / visual_traits 모두 부재)
    el2 = {"c1": {"id": "c1", "short_id": "C01", "name": "X", "t2i_prompt": "Passport-style ID photo"}}
    indexed2, *_ = build_image_index(
        lr, el2, ref_roles=["character_ref"], ref_role_metadata=[{"sid": "C01"}],
        attached_meta=[("character", "C01")])
    assert indexed2[0][0] == "character appearance reference"
    assert "Passport" not in indexed2[0][0]


def test_AC15_build_custom_labeled_refs_returns_2_tuple_unchanged(svc):
    """AC-15: custom_prompt 경로 D5 미변경 — build_custom_labeled_refs 가 여전히
    list[tuple[str, bytes]] (meta 평행 리스트 X).

    spec §3 + F7 후속 — operator 가 custom_prompt 사용 시 D5 enforce 안 됨을
    인지해야 함. 이 test 는 D5 가 custom_prompt 경로를 변경하지 않았다는
    영구 contract 명시.
    """
    visible = [
        {"id": "c1", "short_id": "C01", "name": "Su-ri", "entity_type": "character"},
        {"id": "p1", "short_id": "P01", "name": "phone", "entity_type": "prop"},
    ]
    ref_image_map = {"c1": b"char_bytes", "p1": b"prop_bytes"}
    result = svc.build_custom_labeled_refs(visible, ref_image_map)
    # 2-tuple 만 반환 (meta 평행 X)
    assert isinstance(result, list)
    for item in result:
        assert isinstance(item, tuple)
        assert len(item) == 2  # AC-15 invariant — D5 후에도 2-tuple
        assert isinstance(item[0], str)
        assert isinstance(item[1], bytes)


def test_AC10_image_index_meta_passthrough_independent_of_label_change():
    """AC-10 invariant: label 변경이 meta 에 절대 영향 X (P1 강화).

    동일 attached_meta 가 다른 raw label / entity_lookup 조합에서도 동일 결과.
    """
    from app.services.scene_reference_service import build_image_index

    attached_meta = [("character_outlook", "C01O02"), ("background", "bg_kitchen")]
    indexed_a, _, _, _ref_roles, _ref_role_metadata, meta_a = build_image_index(
        [("character C01O02 in outfit", b"a"), ("background ref", b"b")],
        {"c1": {"id": "c1", "short_id": "C01", "name": "n1", "t2i_prompt": "p1"}},
        attached_meta=attached_meta,
    )
    indexed_b, _, _, _ref_roles, _ref_role_metadata, meta_b = build_image_index(
        [("character C01O02 reference", b"a"), ("scene background", b"b")],
        {"c1": {"id": "c1", "short_id": "C01", "name": "n2", "t2i_prompt": "p2"}},
        attached_meta=attached_meta,
    )
    # 두 호출 모두 meta 동일 (passthrough — 변형 X)
    assert meta_a == attached_meta
    assert meta_b == attached_meta
    assert meta_a == meta_b


# ──────────────────────────────────────────────────────────────────────
# 2026-05-10 24-shot deterministic ref-contract fail fix — Fix A
# resolve_refs_for_prompt_set: target_variations[*].t2i_prompt union
# 으로 attached_meta + labeled_refs build. coordinator 가 first_t2i 만
# 보던 결함의 producer-side fix.
# ──────────────────────────────────────────────────────────────────────


def test_resolve_refs_for_prompt_set_unions_outlook_ids_across_variations(svc):
    """variation[0]=C01O02, variation[1]=C01O01 두 prompt 의 ID union 으로
    attached_meta build. 어느 한 variation 의 outlook 도 누락되지 않아야 한다."""
    visible = [{"id": "c1", "short_id": "C01", "name": "X", "entity_type": "character"}]
    entity_lookup = {
        "c1": visible[0],
        "o1": {"id": "o1", "short_id": "O01", "entity_type": "outlook"},
        "o2": {"id": "o2", "short_id": "O02", "entity_type": "outlook"},
    }
    scene_ref_map = {
        "composite:c1:o1": b"comp_o1",
        "composite:c1:o2": b"comp_o2",
    }

    _payload = svc.resolve_refs_for_prompt_set(
        t2i_prompts=[
            "C01O02 walks past the door.",
            "C01O01 stops by the kitchen counter.",
        ],
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup=entity_lookup,
    )
    labeled_refs, meta = _payload.labeled_refs, _payload.attached_meta

    meta_set = set(meta)
    assert ("character_outlook", "C01O01") in meta_set, (
        f"variation[1] 의 C01O01 outlook 이 attached_meta 에 누락 — Fix A 결함. "
        f"got meta={meta}"
    )
    assert ("character_outlook", "C01O02") in meta_set
    # invariant 1 보존
    assert len(labeled_refs) == len(meta)


def test_resolve_refs_for_prompt_set_dedup_same_outlook(svc):
    """두 variation 이 같은 outlook 사용 → ref 1번만 attach (dedup)."""
    visible = [{"id": "c1", "short_id": "C01", "name": "X", "entity_type": "character"}]
    entity_lookup = {
        "c1": visible[0],
        "o2": {"id": "o2", "short_id": "O02", "entity_type": "outlook"},
    }
    scene_ref_map = {"composite:c1:o2": b"comp"}

    _payload = svc.resolve_refs_for_prompt_set(
        t2i_prompts=["C01O02 walks.", "C01O02 sits."],
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup=entity_lookup,
    )
    labeled_refs, meta = _payload.labeled_refs, _payload.attached_meta
    # 같은 ref 두 번 attach 하지 않음
    assert meta.count(("character_outlook", "C01O02")) == 1
    assert len(labeled_refs) == 1


def test_resolve_refs_for_prompt_set_empty_list_returns_empty(svc):
    """빈 prompts list 는 빈 ref/meta 반환 (caller 가 inputs 보장 안 한 경우 가드)."""
    _payload = svc.resolve_refs_for_prompt_set(
        t2i_prompts=[],
        visible_entities=[],
        scene_ref_image_map={},
        entity_lookup={},
    )
    labeled_refs, meta = _payload.labeled_refs, _payload.attached_meta
    assert labeled_refs == []
    assert meta == []


def test_resolve_refs_for_prompt_set_single_prompt_matches_legacy(svc):
    """단일 prompt 의 result 가 legacy resolve_refs_for_prompt 와 동일해야 한다 (회귀 가드)."""
    visible = [{"id": "c1", "short_id": "C01", "name": "X", "entity_type": "character"}]
    entity_lookup = {
        "c1": visible[0],
        "o2": {"id": "o2", "short_id": "O02", "entity_type": "outlook"},
    }
    scene_ref_map = {"composite:c1:o2": b"comp"}

    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O02 walks.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup=entity_lookup,
    )
    legacy_refs, legacy_meta = _payload.labeled_refs, _payload.attached_meta
    _payload = svc.resolve_refs_for_prompt_set(
        t2i_prompts=["C01O02 walks."],
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup=entity_lookup,
    )
    set_refs, set_meta = _payload.labeled_refs, _payload.attached_meta
    assert legacy_refs == set_refs
    assert legacy_meta == set_meta


# =========================================================================
# Patch A Task 4.5: Tier 2 — required_refs(kind='prop') forced attach
# synthetic fixtures only — P91~P99
# =========================================================================
class TestTier2ForcedPropAttach:
    def _make_service(self):
        instance = SceneReferenceService.__new__(SceneReferenceService)
        instance._db = MagicMock()
        instance._db.query.return_value.filter.return_value.all.return_value = []
        instance._project_id = "proj_synthetic_a"
        return instance

    def test_required_prop_attached_even_when_prompt_missing_p_id(self):
        """Tier 2: required_refs (prop, P91) 가 emit 됐고 visible_entities +
        scene_ref_image_map 에 P91 가 있으면, t2i_prompt 에 P91 ID 없어도 강제 attach."""
        svc = self._make_service()
        visible = [
            {"short_id": "P91", "id": "uuid_P91", "name": "photo_prop_a",
             "entity_type": "prop"},
        ]
        scene_ref_image_map = {"uuid_P91": b"\x89PNG_P91"}
        entity_lookup = {"uuid_P91": {"short_id": "P91", "entity_type": "prop",
                                       "name": "photo_prop_a"}}
        required_refs = [{"kind": "prop", "id": "P91", "policy": "required"}]
        _payload = svc.resolve_refs_for_prompt(
            t2i_prompt="a photograph rests on the console",  # P91 ID 없음
            visible_entities=visible,
            scene_ref_image_map=scene_ref_image_map,
            entity_lookup=entity_lookup,
            required_refs=required_refs,
        )
        labeled_refs, attached_meta = _payload.labeled_refs, _payload.attached_meta
        assert ("prop", "P91") in attached_meta
        assert any("P91" in label for label, _ in labeled_refs)

    def test_required_prop_attached_once_when_id_also_in_prompt(self):
        """required_refs 에 P91 가 있고 prompt 에 P91 ID 도 등장하면 정확히 1번 attach.

        Area D-min: required_refs(kind='prop') 가 prop attach 의 단일 SOT —
        prompt 안 P## 등장은 더 이상 attach 신호 아님. 동일 P## 가 prompt 에
        추가로 보여도 single attach 보장 (intra-loop dedup)."""
        svc = self._make_service()
        visible = [
            {"short_id": "P91", "id": "uuid_P91", "name": "photo_prop_a",
             "entity_type": "prop"},
        ]
        scene_ref_image_map = {"uuid_P91": b"\x89PNG_P91"}
        entity_lookup = {"uuid_P91": {"short_id": "P91", "entity_type": "prop",
                                       "name": "photo_prop_a"}}
        required_refs = [{"kind": "prop", "id": "P91", "policy": "required"}]
        _payload = svc.resolve_refs_for_prompt(
            t2i_prompt="P91 lies flat on the console",  # P91 ID 등장 — D-min 후 attach 신호 아님
            visible_entities=visible,
            scene_ref_image_map=scene_ref_image_map,
            entity_lookup=entity_lookup,
            required_refs=required_refs,
        )
        labeled_refs, attached_meta = _payload.labeled_refs, _payload.attached_meta
        # 정확히 1번 attach (required_refs SOT 단일 경로, dedup 보장)
        p91_meta_count = sum(1 for k, i in attached_meta
                             if k == "prop" and i == "P91")
        assert p91_meta_count == 1
        assert len(labeled_refs) == 1

    def test_no_required_refs_results_in_attach_0(self):
        """required_refs 인자 None → prop attach 0.

        Area D-min: required_refs 부재 = contract absence — prop attach 의
        유일한 신호가 없음. legacy text matching 분기는 폐기됐으므로 prompt 안
        prop name 이 있든 없든 attach 0."""
        svc = self._make_service()
        visible = [
            {"short_id": "P91", "id": "uuid_P91", "name": "photo_prop_a",
             "entity_type": "prop"},
        ]
        scene_ref_image_map = {"uuid_P91": b"\x89PNG_P91"}
        entity_lookup = {"uuid_P91": {"short_id": "P91", "entity_type": "prop",
                                       "name": "photo_prop_a"}}
        # required_refs=None 케이스 — D-min 후 contract absence
        _payload = svc.resolve_refs_for_prompt(
            t2i_prompt="a photograph rests on the console",
            visible_entities=visible,
            scene_ref_image_map=scene_ref_image_map,
            entity_lookup=entity_lookup,
            required_refs=None,
        )
        labeled_refs, attached_meta = _payload.labeled_refs, _payload.attached_meta
        # contract absence → prop attach 0 (legacy text matching 폐기)
        assert ("prop", "P91") not in attached_meta
        assert labeled_refs == []

    def test_required_prop_visible_but_ref_image_missing_skips(self):
        """required_refs (prop, P91) 있지만 scene_ref_image_map 에 P91 ref image 없으면
        skip (resolver 가 ref 강제 attach 불가). Tier 3 validator 가 missing 으로 fail-fast."""
        svc = self._make_service()
        visible = [
            {"short_id": "P91", "id": "uuid_P91", "name": "photo_prop_a",
             "entity_type": "prop"},
        ]
        scene_ref_image_map = {}  # ← P91 ref 없음
        entity_lookup = {"uuid_P91": {"short_id": "P91", "entity_type": "prop",
                                       "name": "photo_prop_a"}}
        required_refs = [{"kind": "prop", "id": "P91", "policy": "required"}]
        _payload = svc.resolve_refs_for_prompt(
            t2i_prompt="a photograph rests on the console",
            visible_entities=visible,
            scene_ref_image_map=scene_ref_image_map,
            entity_lookup=entity_lookup,
            required_refs=required_refs,
        )
        labeled_refs, attached_meta = _payload.labeled_refs, _payload.attached_meta
        # ref image 부재 → Tier 2 가 attach 못 함. labeled_refs / attached_meta 모두 빈 채.
        # (Tier 3 validate_attached_refs 가 image stage 직전 fail-fast.)
        assert ("prop", "P91") not in attached_meta
        assert labeled_refs == []


# ──────────────────────────────────────────────────────────────────────
# Area D-min closure canary — resolver contract
# spec §7 acceptance 7: required_refs 있음 → attach, 없음 + P##/name 있음 → attach 0.
# ──────────────────────────────────────────────────────────────────────


def test_d_min_canary_required_refs_present_attaches(svc):
    """D-min canary: required_refs 있음 + visible 등재 + ref image 있음 → attach."""
    visible = [
        {"id": "p1", "short_id": "P02", "name": "잔", "entity_type": "prop"},
        {"id": "p2", "short_id": "P05", "name": "table", "entity_type": "prop"},
    ]
    scene_ref_map = {"p1": b"P02_PNG", "p2": b"P05_PNG"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="Random text without P-ids or names.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
        required_refs=[
            {"kind": "prop", "id": "P02", "policy": "required"},
            {"kind": "prop", "id": "P05", "policy": "required"},
        ],
    )
    out, meta = _payload.labeled_refs, _payload.attached_meta
    assert any("P02" in label for label, _ in out)
    assert any("P05" in label for label, _ in out)
    assert ("prop", "P02") in meta
    assert ("prop", "P05") in meta


def test_d_min_canary_no_required_refs_but_p_id_in_t2i_attaches_0(svc):
    """D-min canary: required_refs 없음 + t2i 에 P## 와 prop name 둘 다 있음 → attach 0."""
    visible = [{"id": "p1", "short_id": "P02", "name": "잔", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"P02_PNG"}
    _payload = svc.resolve_refs_for_prompt(
        t2i_prompt="The 잔 sits on P02 corner — both signals present.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
        # required_refs intentionally absent
    )
    out, meta = _payload.labeled_refs, _payload.attached_meta
    assert not any("P02" in label for label, _ in out)
    assert ("prop", "P02") not in meta


# ─────────────────────────────────────────────
# Area D-next — Consumer enum check + regex/helper 폐기 (Task 3)
# ─────────────────────────────────────────────

import inspect as _inspect_d_next
import pytest as _pytest_d_next

from app.core.errors import AppError as _AppError_d_next


def test_d1_legacy_regex_and_helper_removed_from_production():
    """Area D-next D1 — production grep:
    - _KEEP_ELEMENT_PERSON_TOKENS 0 hits
    - _assert_keep_elements_are_environment_only 0 hits
    """
    from app.services import scene_reference_service
    src = _inspect_d_next.getsource(scene_reference_service)
    assert "_KEEP_ELEMENT_PERSON_TOKENS" not in src
    assert "_assert_keep_elements_are_environment_only" not in src


def _call_build_ref(
    svc,
    dep_detail_map,
    *,
    bytes_source_kind,  # required, no default — caller 가 fixture context 와 일치하게 명시 의무
    dep_scene_id=None,
    stills=None,
    current_location_ids=None,
    location_scene_history=None,
):
    """Area D-next v3 (Codex I-2 흡수) — build_prev_shot_background_ref 의
    실제 keyword-only signature 전수 (기존 test pattern test_scene_reference_
    service.py:519 참조). return = Optional[Tuple[str, bytes, str]] 또는 None.

    framing_scale enum SOT v1 (spec §4.9) — matrix v1 도입 후 staging 필수.
    Area D-next keep_elements 검증 intent (D2/D3 라벨 / D4-D6 invalid kind
    AppError) 유지 → medium 으로 보강 (close 면 RefContractError 가 keep
    검증 보다 먼저 raise 됨).

    2026-05-15 zoom_in_detail source provenance hardening (spec §5):
    bytes_source_kind kwarg 필수 (no default). caller 가 fixture context 에
    일치하게 명시 의무:
    - "dep_scene" → dep_scene_id + stills 도 명시 의무 (Layer 2.5)
    - "location_history" → current_location_ids + location_scene_history 도
      매칭 가능하게 명시 의무 (Layer 2.6, default fixture 자동 제공)
    - "none" → best_prev_bytes=None 과 동기 (Layer 1 early return)
    """
    # location_history 기본값 — D2/D4/D5/D6 류 exact_background test 가 location 경로 사용.
    if current_location_ids is None and bytes_source_kind == "location_history":
        current_location_ids = ["loc1"]
    if location_scene_history is None and bytes_source_kind == "location_history":
        location_scene_history = {"loc1": (None, {"id": "S0_HIST", "visible_entities_json": "[]"})}

    return svc.build_prev_shot_background_ref(
        best_prev_bytes=b"PREV",
        bytes_source_kind=bytes_source_kind,
        still_data={"scene_index": 1, "shot_index": 2},
        visible_entities=[],
        current_location_ids=current_location_ids or [],
        dep_scene_id=dep_scene_id,
        stills=stills or [],
        location_scene_history=location_scene_history or {},
        dep_detail_map=dep_detail_map,
        staging={"framing_scale": "medium", "camera_direction": "wide"},
        state_variant_sids={},
        entity_lookup={},
    )


def _make_svc():
    from app.services.scene_reference_service import SceneReferenceService
    svc = SceneReferenceService.__new__(SceneReferenceService)
    svc._db = None
    svc._project_id = "test"
    return svc


def test_d2_exact_background_dict_shape_label_concat():
    """Area D-next D2 — exact_background ref_usage 의 dict shape keep_elements
    가 'Keep: <label1>, <label2>' label 로 합성."""
    dep_detail_map = {
        "1_2": {
            "ref_usage": "exact_background",
            "ignore_elements": "Ignore the standing man.",
            "keep_elements": [
                {"label": "wooden bench", "kind": "environment", "subject_kind": "non_human_visual_element"},
                {"label": "broken vase", "kind": "static_prop", "subject_kind": "non_human_visual_element"},
            ],
        },
    }
    result = _call_build_ref(_make_svc(), dep_detail_map, bytes_source_kind="location_history")
    assert result is not None, "build_prev_shot_background_ref returned None"
    label, _img, _loc_id, _ref_role, _ref_role_metadata = result
    assert "Keep: wooden bench, broken vase" in label
    assert "Ignore the standing man" in label


def test_d3_zoom_in_detail_dict_shape_label_concat():
    """Area D-next-min D3 — zoom_in_detail 의 dict shape keep_elements 도 동일.
    enum 2종 (environment / static_prop) 만 — 인물 묘사는 별도 layer 책임."""
    dep_detail_map = {
        "1_2": {
            "ref_usage": "zoom_in_detail",
            "ignore_elements": "",
            "keep_elements": [
                {"label": "the same wooden floor with cracked tiles", "kind": "environment", "subject_kind": "non_human_visual_element"},
                {"label": "the same broken glass on the floor", "kind": "static_prop", "subject_kind": "non_human_visual_element"},
            ],
        },
    }
    # zoom_in_detail → dep_scene path 필수 (Layer 3)
    result = _call_build_ref(
        _make_svc(), dep_detail_map,
        bytes_source_kind="dep_scene",
        dep_scene_id="S0_Shot1",
        stills=[{"id": "S0_Shot1", "visible_entities_json": "[]"}],
    )
    assert result is not None
    label, _img, _loc_id, _ref_role, _ref_role_metadata = result
    assert "Keep: the same wooden floor with cracked tiles, the same broken glass on the floor" in label


def test_d4_unknown_kind_raises_apperror():
    """Area D-next D4 — unknown kind → AppError."""
    dep_detail_map = {
        "1_2": {
            "ref_usage": "exact_background",
            "ignore_elements": "",
            "keep_elements": [{"label": "x", "kind": "unknown_kind", "subject_kind": "non_human_visual_element"}],
        },
    }
    with _pytest_d_next.raises(_AppError_d_next) as excinfo:
        _call_build_ref(_make_svc(), dep_detail_map, bytes_source_kind="location_history")
    assert excinfo.value.code == "step.scene_reference.keep_elements_kind_invalid"


def test_d5_malformed_dict_raises_apperror():
    """Area D-next D5 — label/kind key 누락 dict → AppError."""
    dep_detail_map = {
        "1_2": {
            "ref_usage": "exact_background",
            "ignore_elements": "",
            "keep_elements": [{"kind": "environment", "subject_kind": "non_human_visual_element"}],  # label missing
        },
    }
    with _pytest_d_next.raises(_AppError_d_next) as excinfo:
        _call_build_ref(_make_svc(), dep_detail_map, bytes_source_kind="location_history")
    assert excinfo.value.code == "step.scene_reference.keep_elements_kind_invalid"


def test_d5b_non_string_label_raises_apperror():
    """Area D-next D5b (v3 Codex I-4 신규) — label 이 str 이 아니면 AppError."""
    dep_detail_map = {
        "1_2": {
            "ref_usage": "exact_background",
            "ignore_elements": "",
            "keep_elements": [{"label": 12345, "kind": "environment", "subject_kind": "non_human_visual_element"}],
        },
    }
    with _pytest_d_next.raises(_AppError_d_next) as excinfo:
        _call_build_ref(_make_svc(), dep_detail_map, bytes_source_kind="location_history")
    assert excinfo.value.code == "step.scene_reference.keep_elements_kind_invalid"


def test_d6_legacy_v6_immobilized_character_rejected():
    """Area D-next-min D6 (supersedes Area D-next D6) — legacy v6 kind=
    immobilized_character entry 가 scene_reference (L3 consumer) 에서도 fail-fast
    거부. 책임 경계 회복: character state 는 별도 layer (scene_consistency /
    character_state_variant / semantic_contract_router) 가 처리, keep_elements
    는 비인물 (environment / static_prop) 만."""
    dep_detail_map = {
        "1_2": {
            "ref_usage": "exact_background",
            "ignore_elements": "",
            "keep_elements": [
                {"label": "dead detective face-down on the floor",
                 "kind": "immobilized_character",
                 "subject_kind": "non_human_visual_element"},
            ],
        },
    }
    with _pytest_d_next.raises(_AppError_d_next) as excinfo:
        _call_build_ref(_make_svc(), dep_detail_map, bytes_source_kind="location_history")
    assert excinfo.value.code == "step.scene_reference.keep_elements_kind_invalid"
    assert "immobilized_character" in str(excinfo.value.message)
