"""Pipeline plan tests — Codex APPROVED Q1-Q5 + slot_role 추가.

dry-run, no API, no DB write.
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

_REPO_ROOT = Path(__file__).resolve().parents[3]
_SCRIPTS = _REPO_ROOT / "backend" / "scripts"
if str(_SCRIPTS) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS))

import experiment_rooftop_spatial_bg as bg  # noqa: E402
import experiment_rooftop_spatial_pipeline_plan as pp  # noqa: E402


def _shot(scene: int, shot: int, summary: str, description: str,
          visible: list[str] | None = None) -> bg.ShotMeta:
    return bg.ShotMeta(
        still_id=f"st-{scene}-{shot}", scene_index=scene, shot_index=shot,
        scene_summary=summary, shot_description=description,
        visible_short_ids=visible or ["L05"],
        dependent_scene_id=None, raw_visible_json="[]",
    )


def _bible_minimal() -> dict:
    return {
        "sub_spaces": [
            {"name": "거실", "evidence_quotes": ["거실의 햇살"], "layout_notes": "",
             "confidence_band": "trusted"},
            {"name": "수리영의 방", "evidence_quotes": ["방의 침대"], "layout_notes": "",
             "confidence_band": "trusted"},
            {"name": "민숙의 방 (안방)", "evidence_quotes": ["안방"], "layout_notes": "",
             "confidence_band": "trusted"},
            {"name": "주방 영역", "evidence_quotes": ["싱크대"], "layout_notes": "",
             "confidence_band": "weak"},
            {"name": "욕실", "evidence_quotes": ["거울"], "layout_notes": "",
             "confidence_band": "trusted"},
        ],
        "layout_relations": [
            {"from_sub_space": "거실", "to_sub_space": "수리영의 방",
             "relation": "방문을 통해 인접", "evidence": "방문", "confidence_band": "trusted"},
            {"from_sub_space": "거실", "to_sub_space": "욕실",
             "relation": "문 연결", "evidence": "욕실 문", "confidence_band": "weak"},
        ],
        "doors_windows": [
            {"kind": "현관 철문", "location": "거실 경계",
             "evidence": "철문", "confidence_band": "trusted"},
        ],
        "kitchen": {"present": True, "evidence": "싱크대", "confidence_band": "weak"},
        "bathroom": {"present": True, "evidence": "거울", "confidence_band": "trusted"},
        "furniture": [
            {"name": "식탁", "sub_space": "거실", "evidence": "식탁",
             "confidence_band": "trusted"},
            {"name": "침대", "sub_space": "수리영의 방", "evidence": "침대",
             "confidence_band": "trusted"},
        ],
        "materials": [
            {"where": "바닥", "material": "장판", "evidence": "바닥",
             "confidence_band": "weak"},
        ],
        "condition_age": {"summary": "오래된", "evidence": "낡은",
                          "confidence_band": "trusted"},
        "socioeconomic_tone": {"summary": "서민적", "evidence": "옥탑방",
                               "confidence_band": "trusted"},
        "scale_density": {"summary": "좁고 한정", "evidence": "옥탑방",
                          "confidence_band": "trusted"},
        "state_variants": [
            {"state": "normal", "evidence": "햇살", "confidence_band": "trusted"},
            {"state": "corpse_marks", "evidence": "시신", "confidence_band": "trusted"},
            {"state": "cleaned", "evidence": "정돈", "confidence_band": "trusted"},
            {"state": "vandalized", "evidence": "어지럽힘", "confidence_band": "trusted"},
        ],
        "required_visual_cues": ["작은 창문", "낡은 현관 철문", "벽면 붉은 원"],
        "forbidden_luxury_cues": ["대리석 아일랜드", "샹들리에"],
        "unknowns": ["정확한 평수"],
        "inference_limits": ["옥상 외부와 내부 연결성"],
    }


# ---------------------------------------------------------------------------
# Stage A — 5 cards transform
# ---------------------------------------------------------------------------
def test_identity_card_majority_confidence() -> None:
    b = _bible_minimal()
    card = pp.build_identity_card(b)
    assert card.name == "L05 옥탑방 내부"
    # 3 trusted bands → majority = trusted (weakest first → trusted, weak 등 없으므로 trusted)
    assert card.confidence_majority == "trusted"
    assert len(card.evidence_quotes) >= 1


def test_topology_card_includes_bible_sub_spaces_and_external_nodes() -> None:
    b = _bible_minimal()
    card = pp.build_topology_card(b)
    node_labels = {n.label for n in card.nodes}
    assert "거실" in node_labels
    assert "수리영의 방" in node_labels
    assert "민숙의 방 (안방)" in node_labels
    assert "욕실" in node_labels
    # external (현관 또는 rooftop_outer) 추가
    node_ids = {n.id for n in card.nodes}
    assert "rooftop_outer" in node_ids
    # edges: bible 2 + 외부 (현관→rooftop_outer) = 3
    assert len(card.edges) >= 2


def test_design_inference_card_includes_required_cues_as_direct() -> None:
    b = _bible_minimal()
    items = pp.build_design_inference_card(b)
    direct = [d for d in items if d.confidence == "direct"]
    assert direct, "required_visual_cues 는 direct 로 emit 되어야"
    # window 카테고리 매핑 확인
    window_items = [d for d in items if d.category == "window"]
    assert window_items


def test_forbidden_drift_card_includes_bible_and_deterministic() -> None:
    b = _bible_minimal()
    items = pp.build_forbidden_drift_card(b)
    cues = {f.cue for f in items}
    # bible 의 2개 + deterministic 6개 = 8
    assert "대리석 아일랜드" in cues
    assert "샹들리에" in cues
    assert "marble" in cues  # deterministic luxury
    assert "chandelier" in cues


def test_state_overlay_card_4_states() -> None:
    b = _bible_minimal()
    states = pp.build_state_overlay_card(b)
    state_names = {s.state for s in states}
    assert {"normal", "corpse_marks", "cleaned", "vandalized"}.issubset(state_names)
    # corpse_marks 의 environmental_marks 에 "NO human figure" 명시
    corpse = next(s for s in states if s.state == "corpse_marks")
    assert any("NO" in m or "human" in m.lower() for m in corpse.environmental_marks)


# ---------------------------------------------------------------------------
# Stage B — Camera slot clustering deterministic
# ---------------------------------------------------------------------------
def _make_4_shot_plans() -> list[bg.ShotPlan]:
    shots = [
        _shot(5, 2, "햇살 옥탑방 거실", "식탁 위 응시"),                          # main_room table_close
        _shot(12, 6, "어두운 옥탑방 안 수리영의 방", "수리영의 방 침대 옆 바닥"),  # bedroom suriyeong
        _shot(18, 9, "아침 욕실 거울", "거울 속 욕실 벽면"),                      # bathroom mirror_close
        _shot(27, 1, "해질 옥탑방 거실 혜수", "거실에 선 혜수 전신"),             # main_room eye_level_wide
    ]
    return bg.build_shot_plans(shots)


def test_camera_slots_cluster_known_cases() -> None:
    plans = _make_4_shot_plans()
    topo = pp.build_topology_card(_bible_minimal())
    slots = pp.build_camera_slots(plans, topo)
    by_id = {s.slot_id: s for s in slots}
    # 4 가 다른 slot 일 것
    assert "slot_main_room__eye_level_table_close" in by_id
    assert "slot_bedroom__eye_level_doorway_wide__수리영의_방" in by_id
    assert "slot_bathroom__eye_level_mirror_close" in by_id
    assert "slot_main_room__eye_level_wide" in by_id


def test_camera_slot_role_enum() -> None:
    plans = _make_4_shot_plans()
    topo = pp.build_topology_card(_bible_minimal())
    slots = pp.build_camera_slots(plans, topo)
    for s in slots:
        assert s.slot_role in pp.SLOT_ROLES
    # 4 shot 모두 single → shot_specific
    for s in slots:
        if s.slot_role != "rejected_manual_review":
            assert s.slot_role == "shot_specific"


# ---------------------------------------------------------------------------
# Stage C — Base plate plan
# ---------------------------------------------------------------------------
def test_base_plate_plan_recommends_count_with_reason() -> None:
    plans = _make_4_shot_plans()
    bible = _bible_minimal()
    topo = pp.build_topology_card(bible)
    slots = pp.build_camera_slots(plans, topo)
    cards = {
        "identity": pp.build_identity_card(bible),
        "topology": topo,
        "design_inference": pp.build_design_inference_card(bible),
        "forbidden_drift": pp.build_forbidden_drift_card(bible),
        "state_overlay": pp.build_state_overlay_card(bible),
    }
    specs, summary, _unmatched = pp.build_base_plate_plan(slots, cards)
    assert summary["recommended_base_plate_count"] == len(specs)
    assert "delta" in summary
    assert summary["current_base_plate_count"] == 4
    # 모든 spec 에 must_show + must_not_show 채워짐
    for spec in specs:
        assert spec.must_show
        assert spec.must_not_show


def test_base_plate_plan_excludes_rejected_slots() -> None:
    # fallback_unknown slot 1개를 가짜로 만든다.
    plan = bg.ShotPlan(
        shot=_shot(99, 1, "모호한 씬", "텍스트만 있음"),
        sub_space="manual_review_needed",
        state="normal", time="day",
        camera_slot="fallback_unknown",
        manual_review_needed=True,
        classification_notes=["fake"],
    )
    bible = _bible_minimal()
    topo = pp.build_topology_card(bible)
    slots = pp.build_camera_slots([plan], topo)
    cards = {
        "identity": pp.build_identity_card(bible),
        "topology": topo,
        "design_inference": pp.build_design_inference_card(bible),
        "forbidden_drift": pp.build_forbidden_drift_card(bible),
        "state_overlay": pp.build_state_overlay_card(bible),
    }
    specs, summary, _unmatched = pp.build_base_plate_plan(slots, cards)
    # rejected slot 만 있으면 spec 0
    assert specs == []
    assert summary["recommended_base_plate_count"] == 0


# ---------------------------------------------------------------------------
# Stage D — Shot payload
# ---------------------------------------------------------------------------
def test_shot_payload_contains_visible_entities_and_placeholder_enum() -> None:
    plans = _make_4_shot_plans()
    # plans[0] = S5_S2 — character C04 추가 가정
    plans[0].shot.visible_short_ids = ["C04", "L05"]
    bible = _bible_minimal()
    topo = pp.build_topology_card(bible)
    slots = pp.build_camera_slots(plans, topo)
    cards = {
        "identity": pp.build_identity_card(bible),
        "topology": topo,
        "design_inference": pp.build_design_inference_card(bible),
        "forbidden_drift": pp.build_forbidden_drift_card(bible),
        "state_overlay": pp.build_state_overlay_card(bible),
    }
    specs, _, _ = pp.build_base_plate_plan(slots, cards)
    payloads = pp.build_shot_background_payloads(
        plans, slots, specs, cards["state_overlay"]
    )
    s5 = next(p for p in payloads if p.shot_id == "S5_S2")
    assert s5.visible_entity_short_ids == ["C04", "L05"]
    # C04 = character → placeholder enum 중 하나
    c04_marker = next(m for m in s5.entity_layout_markers if m.entity_id == "C04")
    assert c04_marker.kind == "character"
    assert c04_marker.render_as_placeholder in pp.PLACEHOLDER_KINDS
    # close-up slot 이면 none, 아니면 translucent_shape
    if "close" in s5.camera_slot_id:
        assert c04_marker.render_as_placeholder == "none"
    else:
        # S5_S2 = main_room eye_level_table_close (close → none)
        # 단 test 환경에 따라 다를 수 있음 — enum 만 검증.
        pass


def test_shot_payload_zone_enum() -> None:
    plans = _make_4_shot_plans()
    plans[0].shot.visible_short_ids = ["C04"]
    bible = _bible_minimal()
    topo = pp.build_topology_card(bible)
    slots = pp.build_camera_slots(plans, topo)
    cards = {
        "identity": pp.build_identity_card(bible),
        "topology": topo,
        "design_inference": pp.build_design_inference_card(bible),
        "forbidden_drift": pp.build_forbidden_drift_card(bible),
        "state_overlay": pp.build_state_overlay_card(bible),
    }
    specs, _, _ = pp.build_base_plate_plan(slots, cards)
    payloads = pp.build_shot_background_payloads(plans, slots, specs, cards["state_overlay"])
    for pl in payloads:
        for m in pl.entity_layout_markers:
            assert m.approximate_zone in pp.ZONE_GRID_9


def test_payload_i2i_dict_keys() -> None:
    plans = _make_4_shot_plans()
    bible = _bible_minimal()
    topo = pp.build_topology_card(bible)
    slots = pp.build_camera_slots(plans, topo)
    cards = {
        "identity": pp.build_identity_card(bible),
        "topology": topo,
        "design_inference": pp.build_design_inference_card(bible),
        "forbidden_drift": pp.build_forbidden_drift_card(bible),
        "state_overlay": pp.build_state_overlay_card(bible),
    }
    specs, _, _ = pp.build_base_plate_plan(slots, cards)
    payloads = pp.build_shot_background_payloads(plans, slots, specs, cards["state_overlay"])
    for pl in payloads:
        payload = pl.i2i_background_prompt_payload
        for key in ("preserve_layout", "may_change_state_overlay",
                    "must_not_change", "remove_placeholder_instruction"):
            assert key in payload


# ---------------------------------------------------------------------------
# Stage E — HTML
# ---------------------------------------------------------------------------
def test_render_html_contains_7_sections(tmp_path) -> None:
    plans = _make_4_shot_plans()
    bible = _bible_minimal()
    topo = pp.build_topology_card(bible)
    slots = pp.build_camera_slots(plans, topo)
    cards = {
        "identity": pp.build_identity_card(bible),
        "topology": topo,
        "design_inference": pp.build_design_inference_card(bible),
        "forbidden_drift": pp.build_forbidden_drift_card(bible),
        "state_overlay": pp.build_state_overlay_card(bible),
    }
    specs, summary, _unmatched = pp.build_base_plate_plan(slots, cards)
    payloads = pp.build_shot_background_payloads(plans, slots, specs, cards["state_overlay"])
    run_meta = {"run_id": "html_test", "serve_info": None}
    pp.render_html(
        tmp_path, bible=bible, cards=cards, slots=slots,
        base_plates=specs, base_summary=summary, payloads=payloads,
        existing_plates={}, asset_rel=None, run_meta=run_meta,
    )
    html = (tmp_path / "index.html").read_text(encoding="utf-8")
    for header in (
        "1. Source grounding summary", "2. Inference cards",
        "2-A. set_identity", "2-B. set_topology",
        "2-C. production_design_inference", "2-D. forbidden_drift",
        "2-E. state_overlay", "3. Camera slot plan", "4. Base plate plan",
        "5. Shot background payloads", "6. Entity layout markers",
        "7. Open questions",
    ):
        assert header in html, f"HTML missing {header!r}"


def test_render_html_inline_assets_when_copied(tmp_path) -> None:
    # 가짜 base plate copy
    asset_dir = tmp_path / "base_plate_assets" / "fake_run"
    asset_dir.mkdir(parents=True)
    (asset_dir / "main_room__eye_level_wide.png").write_bytes(b"PNG")
    existing = {"main_room__eye_level_wide": {
        "png_path": asset_dir / "main_room__eye_level_wide.png",
        "lvm_card": {"overall_confidence_band": "trusted",
                     "fixed_objects": [{"label": "wall"}]},
        "plate_id": "base__main_room__eye_level_wide",
    }}
    bible = _bible_minimal()
    topo = pp.build_topology_card(bible)
    slots = []
    cards = {
        "identity": pp.build_identity_card(bible),
        "topology": topo,
        "design_inference": pp.build_design_inference_card(bible),
        "forbidden_drift": pp.build_forbidden_drift_card(bible),
        "state_overlay": pp.build_state_overlay_card(bible),
    }
    specs, summary, _unmatched = pp.build_base_plate_plan(slots, cards)
    pp.render_html(
        tmp_path, bible=bible, cards=cards, slots=slots, base_plates=specs,
        base_summary=summary, payloads=[], existing_plates=existing,
        asset_rel="base_plate_assets/fake_run", run_meta={"run_id": "ix"},
    )
    html = (tmp_path / "index.html").read_text(encoding="utf-8")
    assert 'src="base_plate_assets/fake_run/' in html
    assert 'src="../' not in html


# ---------------------------------------------------------------------------
# Enums sanity
# ---------------------------------------------------------------------------
def test_constants_enum_lengths() -> None:
    assert len(pp.ZONE_GRID_9) == 9
    assert pp.PLACEHOLDER_KINDS == ["dotted_box", "translucent_shape", "color_box", "none"]
    assert pp.SLOT_ROLES == ["shared_base", "shot_specific", "rejected_manual_review"]


# ---------------------------------------------------------------------------
# Codex Blocking 1 — must_show plate scope
# ---------------------------------------------------------------------------
def test_design_inference_applies_to_nodes_filled() -> None:
    b = _bible_minimal()
    items = pp.build_design_inference_card(b)
    # required_visual_cues 의 "작은 창문" → applies_to_nodes 비어있을 수도 ("거실" 못 잡으면).
    # 최소한 furniture 의 sub_space 가 채워지면 OK.
    bed_items = [d for d in items if "침대" in d.description]
    assert bed_items
    assert any(bi.applies_to_nodes for bi in bed_items), (
        "bible.furniture 의 sub_space 가 applies_to_nodes 로 매핑되어야"
    )


def test_must_show_plate_scoped_not_globally_copied() -> None:
    """Codex Blocking 1: 두 plate 의 must_show 가 동일하지 않아야 (각자 scope)."""
    # main_room shot + bathroom shot 으로 2 plate 만들기.
    shots = [
        _shot(5, 2, "햇살 옥탑방 거실", "식탁 위 응시"),
        _shot(18, 9, "아침 욕실 거울", "거울 속 욕실 벽면"),
    ]
    plans = bg.build_shot_plans(shots)
    bible = _bible_minimal()
    topo = pp.build_topology_card(bible)
    slots = pp.build_camera_slots(plans, topo)
    cards = {
        "identity": pp.build_identity_card(bible),
        "topology": topo,
        "design_inference": pp.build_design_inference_card(bible),
        "forbidden_drift": pp.build_forbidden_drift_card(bible),
        "state_overlay": pp.build_state_overlay_card(bible),
    }
    specs, _summary, _unmatched = pp.build_base_plate_plan(slots, cards)
    by_node = {tuple(sp.covers_nodes): sp for sp in specs}
    # 거실 plate 와 욕실 plate 의 must_show 는 동일하지 않아야.
    living = next(sp for sp in specs if "거실" in sp.covers_nodes)
    bathroom = next(sp for sp in specs if "욕실" in sp.covers_nodes)
    assert set(living.must_show) != set(bathroom.must_show), (
        "must_show 가 plate scope 없이 전역 복사되면 안 됨"
    )


# ---------------------------------------------------------------------------
# Codex Blocking 2 — bedroom split
# ---------------------------------------------------------------------------
def test_bedroom_slot_splits_suriyeong_vs_minsook() -> None:
    """수리영 명시 shot vs 민숙 명시 shot 별도 slot. 둘 다 명시되면 unresolved.
    bg.classify_sub_space 가 'bedroom' 으로 분류하려면 침대/침실 키워드 필요.
    """
    shots = [
        _shot(99, 1, "수리영 방", "수리영의 방 침대 위 클로즈"),  # 수리영 only → 수리영의_방
        _shot(99, 2, "민숙 침실", "민숙이 침대 옆에 서 있다"),     # 민숙 only → 민숙의_방_안방
        _shot(99, 3, "방 두 명", "수리영과 민숙이 침대 옆에서"),    # 둘 다 → unresolved
    ]
    plans = bg.build_shot_plans(shots)
    topo = pp.build_topology_card(_bible_minimal())
    slots = pp.build_camera_slots(plans, topo)
    bedroom_slots = [s for s in slots if "bedroom" in s.slot_id]
    # 3 shot → 3 다른 slot (수리영_방 / 민숙_방 / unresolved) 또는 비슷한 분리.
    assert any("수리영의_방" in s.slot_id for s in bedroom_slots)
    assert any("민숙의_방_안방" in s.slot_id for s in bedroom_slots)
    assert any(s.multi_node_unresolved for s in bedroom_slots)
    # unresolved slot 은 rejected_manual_review.
    unresolved = [s for s in bedroom_slots if s.multi_node_unresolved]
    for s in unresolved:
        assert s.slot_role == "rejected_manual_review"
        assert s.unresolved_candidates


# ---------------------------------------------------------------------------
# Codex Important 1 — source_evidence linkage
# ---------------------------------------------------------------------------
def test_design_inference_source_evidence_ids_filled(tmp_path) -> None:
    """source_evidence 가 매칭되면 source_evidence_ids 채워짐."""
    fake_evidence = [
        {"source": "episode", "keyword": "침대", "char_offset": "100", "ev_id": "ev_episode_100"},
        {"source": "planning_doc", "keyword": "옥탑방", "char_offset": "50", "ev_id": "ev_planning_doc_50"},
    ]
    b = _bible_minimal()
    items = pp.build_design_inference_card(b, source_evidence=fake_evidence)
    bed_item = next(d for d in items if "침대" in d.description)
    assert "ev_episode_100" in bed_item.source_evidence_ids, (
        "침대 description 에 침대 keyword evidence 가 연결되어야"
    )


def test_load_source_evidence_assigns_ev_id(tmp_path) -> None:
    """source_evidence TSV 로드 시 stable ev_id 부여."""
    tsv = tmp_path / "source_evidence.tsv"
    tsv.write_text(
        "source\tkeyword\tcategory\tcandidate_field\tchar_offset\tconfidence\tquote\tnotes\n"
        "episode\t침대\tfixture\tfurniture.bed\t100\tstrong\t침대 위\t\n",
        encoding="utf-8",
    )
    rows = pp.load_source_evidence(tmp_path)
    assert rows
    assert rows[0]["ev_id"] == "ev_episode_100"


# ---------------------------------------------------------------------------
# Codex Important 2 — confidence_majority Counter mode
# ---------------------------------------------------------------------------
def test_confidence_majority_mixed_picks_mode_not_weakest() -> None:
    b = {
        "condition_age": {"summary": "x", "evidence": "x", "confidence_band": "trusted"},
        "socioeconomic_tone": {"summary": "x", "evidence": "x", "confidence_band": "trusted"},
        "scale_density": {"summary": "x", "evidence": "x", "confidence_band": "weak"},
        "sub_spaces": [], "layout_relations": [], "doors_windows": [],
        "kitchen": {}, "bathroom": {}, "furniture": [], "materials": [],
        "state_variants": [], "required_visual_cues": [],
        "forbidden_luxury_cues": [], "unknowns": [], "inference_limits": [],
    }
    card = pp.build_identity_card(b)
    # 2 trusted + 1 weak → mode = trusted (이전 buggy 구현은 weak 반환).
    assert card.confidence_majority == "trusted"


def test_confidence_majority_tie_picks_weakest() -> None:
    b = {
        "condition_age": {"summary": "x", "evidence": "x", "confidence_band": "trusted"},
        "socioeconomic_tone": {"summary": "x", "evidence": "x", "confidence_band": "weak"},
        "scale_density": {"summary": "x", "evidence": "x", "confidence_band": "unknown"},
        "sub_spaces": [], "layout_relations": [], "doors_windows": [],
        "kitchen": {}, "bathroom": {}, "furniture": [], "materials": [],
        "state_variants": [], "required_visual_cues": [],
        "forbidden_luxury_cues": [], "unknowns": [], "inference_limits": [],
    }
    card = pp.build_identity_card(b)
    # 1+1+1 tie → weakest = unknown
    assert card.confidence_majority == "unknown"


# ---------------------------------------------------------------------------
# Codex MINOR — estimate-only no webserver
# ---------------------------------------------------------------------------
def test_load_source_evidence_handles_missing_file(tmp_path) -> None:
    assert pp.load_source_evidence(tmp_path) == []


# ---------------------------------------------------------------------------
# Codex 새 BLOCKING — slot_id helper single source
# ---------------------------------------------------------------------------
def test_slot_id_helper_single_source() -> None:
    """build_camera_slots 와 build_shot_background_payloads 가 동일 helper 사용."""
    assert hasattr(pp, "_slot_key_for_plan")
    assert hasattr(pp, "_slot_id_from_parts")
    # main_room 케이스
    sid = pp._slot_id_from_parts("main_room", "eye_level_wide", "", False)
    assert sid == "slot_main_room__eye_level_wide"
    # bedroom resolved
    sid = pp._slot_id_from_parts("bedroom", "eye_level_doorway_wide", "수리영의_방", False)
    assert sid == "slot_bedroom__eye_level_doorway_wide__수리영의_방"
    # bedroom unresolved
    sid = pp._slot_id_from_parts("bedroom", "eye_level_doorway_wide", "", True)
    assert sid == "slot_bedroom__eye_level_doorway_wide__unresolved"


def test_payload_camera_slot_id_matches_slot_plan() -> None:
    """모든 payload 의 camera_slot_id 는 실제 slot plan 의 slot_id 집합에 존재."""
    plans = _make_4_shot_plans()
    plans[0].shot.visible_short_ids = ["C04", "L05"]
    bible = _bible_minimal()
    topo = pp.build_topology_card(bible)
    slots = pp.build_camera_slots(plans, topo)
    cards = {
        "identity": pp.build_identity_card(bible),
        "topology": topo,
        "design_inference": pp.build_design_inference_card(bible),
        "forbidden_drift": pp.build_forbidden_drift_card(bible),
        "state_overlay": pp.build_state_overlay_card(bible),
    }
    specs, _summary, _unmatched = pp.build_base_plate_plan(slots, cards)
    payloads = pp.build_shot_background_payloads(plans, slots, specs, cards["state_overlay"])
    slot_ids = {s.slot_id for s in slots}
    for pl in payloads:
        assert pl.camera_slot_id in slot_ids, (
            f"payload {pl.shot_id}.camera_slot_id={pl.camera_slot_id!r} "
            f"가 slot plan 에 없음 — slot_id helper 일관성 깨짐"
        )


def test_payload_eligible_main_room_gets_base_plate() -> None:
    """main_room shot 은 base_plate_id 채워져야."""
    plans = _make_4_shot_plans()
    bible = _bible_minimal()
    topo = pp.build_topology_card(bible)
    slots = pp.build_camera_slots(plans, topo)
    cards = {
        "identity": pp.build_identity_card(bible),
        "topology": topo,
        "design_inference": pp.build_design_inference_card(bible),
        "forbidden_drift": pp.build_forbidden_drift_card(bible),
        "state_overlay": pp.build_state_overlay_card(bible),
    }
    specs, _, _ = pp.build_base_plate_plan(slots, cards)
    payloads = pp.build_shot_background_payloads(plans, slots, specs, cards["state_overlay"])
    s5_s2 = next(p for p in payloads if p.shot_id == "S5_S2")
    assert s5_s2.base_plate_id, "S5_S2 main_room shot 은 base_plate_id 가 비어 있으면 안 됨"
    assert s5_s2.base_plate_id.startswith("bp_main_room__")


def test_payload_resolved_bedroom_gets_base_plate() -> None:
    """수리영 명시 bedroom shot 은 bp_bedroom__...__수리영의_방 으로 연결."""
    shots = [_shot(14, 4, "수리영 방", "수리영의 방 침대 옆 바닥")]
    plans = bg.build_shot_plans(shots)
    bible = _bible_minimal()
    topo = pp.build_topology_card(bible)
    slots = pp.build_camera_slots(plans, topo)
    cards = {
        "identity": pp.build_identity_card(bible),
        "topology": topo,
        "design_inference": pp.build_design_inference_card(bible),
        "forbidden_drift": pp.build_forbidden_drift_card(bible),
        "state_overlay": pp.build_state_overlay_card(bible),
    }
    specs, _, _ = pp.build_base_plate_plan(slots, cards)
    payloads = pp.build_shot_background_payloads(plans, slots, specs, cards["state_overlay"])
    s14 = payloads[0]
    assert "수리영의_방" in s14.base_plate_id, (
        f"수리영 명시 bedroom shot 의 base_plate_id={s14.base_plate_id!r} 가 "
        "수리영의_방 으로 연결되어야"
    )


def test_payload_rejected_slot_has_empty_base_plate() -> None:
    """unresolved bedroom / manual_review_needed shot 은 base_plate_id 비어 있음."""
    shots = [
        _shot(12, 6, "어두운 옥탑방 안 시신", "방 안 침대 옆 시신"),  # bedroom unresolved
        _shot(99, 1, "모호한 씬", "텍스트만"),                        # manual_review
    ]
    plans = bg.build_shot_plans(shots)
    bible = _bible_minimal()
    topo = pp.build_topology_card(bible)
    slots = pp.build_camera_slots(plans, topo)
    cards = {
        "identity": pp.build_identity_card(bible),
        "topology": topo,
        "design_inference": pp.build_design_inference_card(bible),
        "forbidden_drift": pp.build_forbidden_drift_card(bible),
        "state_overlay": pp.build_state_overlay_card(bible),
    }
    specs, _, _ = pp.build_base_plate_plan(slots, cards)
    payloads = pp.build_shot_background_payloads(plans, slots, specs, cards["state_overlay"])
    for pl in payloads:
        assert pl.base_plate_id == "", (
            f"rejected slot 의 payload {pl.shot_id} 는 base_plate_id 가 비어야 (got {pl.base_plate_id!r})"
        )
