"""scene_checkpoint_loaders 단위 테스트 — W5 F22 Phase B.11.

체크포인트 JSON을 파싱하여 generate_images가 쓰는 map으로 정규화하는
module-level loader 3종의 동작을 검증한다.
"""
from __future__ import annotations

import json
from pathlib import Path

import pytest

from app.services.scene_checkpoint_loaders import (
    load_background_chain_bg_map,
    load_shot_dependency_map,
    load_shot_staging_map,
    load_shot_t2i_variations,
    load_space_set_bg_map,
)


@pytest.fixture
def project_layout(tmp_path: Path):
    """임시 프로젝트 디렉토리 + episode checkpoint 스텁."""
    proj_id = "pid"
    ep_id = "ep1"
    ep_dir = tmp_path / proj_id / "checkpoints" / "episodes" / ep_id
    ep_dir.mkdir(parents=True)
    return str(tmp_path), proj_id, ep_id, ep_dir


@pytest.fixture
def background_chain_on(monkeypatch):
    """P0-2: background_chain_enabled=True로 강제. toggle gating은 별도 테스트."""
    from app.core.config import settings
    monkeypatch.setattr(settings, "background_chain_enabled", True)


# ──────────────────────────────────────────────────────────────────────
# load_shot_staging_map
# ──────────────────────────────────────────────────────────────────────


def test_load_shot_staging_map_missing_returns_empty(project_layout):
    projects_dir, pid, eid, _ = project_layout
    assert load_shot_staging_map(projects_dir, pid, eid) == {}


def test_load_shot_staging_map_indexes_by_scene_shot(project_layout):
    projects_dir, pid, eid, ep_dir = project_layout
    cp = ep_dir / "shot_staging" / "manifest.json"
    cp.parent.mkdir(parents=True)
    cp.write_text(json.dumps({
        "data": {"shots": [
            {"scene_index": 5, "shot_index": 1, "x": "A"},
            {"scene_index": 12, "shot_index": 3, "x": "B"},
        ]},
    }))

    m = load_shot_staging_map(projects_dir, pid, eid)
    assert m["5_1"] == {"scene_index": 5, "shot_index": 1, "x": "A"}
    assert m["12_3"]["x"] == "B"


def test_load_shot_staging_map_malformed_returns_empty(project_layout):
    projects_dir, pid, eid, ep_dir = project_layout
    cp = ep_dir / "shot_staging" / "manifest.json"
    cp.parent.mkdir(parents=True)
    cp.write_text("{not valid json")

    assert load_shot_staging_map(projects_dir, pid, eid) == {}


# ──────────────────────────────────────────────────────────────────────
# load_shot_dependency_map
# ──────────────────────────────────────────────────────────────────────


def test_load_shot_dependency_map_prefers_t2i_variant(project_layout):
    projects_dir, pid, eid, ep_dir = project_layout
    for name, ref_usage in [("shot_dependency", "base"), ("shot_dependency_t2i", "preferred")]:
        p = ep_dir / name / "manifest.json"
        p.parent.mkdir(parents=True)
        p.write_text(json.dumps({"data": {"dependencies": [{
            "scene_index": 1, "shot_index": 2,
            "location_refs": [{"ref_usage": ref_usage, "ignore_elements": "", "keep_elements": []}],
        }]}}))

    m = load_shot_dependency_map(projects_dir, pid, eid)
    assert m["1_2"]["ref_usage"] == "preferred"


def test_load_shot_dependency_map_legacy_removal_instruction(project_layout):
    projects_dir, pid, eid, ep_dir = project_layout
    p = ep_dir / "shot_dependency" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"dependencies": [{
        "scene_index": 1, "shot_index": 1,
        "location_refs": [{"ref_usage": "x", "removal_instruction": "legacy text", "keep_elements": []}],
    }]}}))

    m = load_shot_dependency_map(projects_dir, pid, eid)
    assert m["1_1"]["ignore_elements"] == "legacy text"


def test_load_shot_dependency_map_only_first_location_ref(project_layout):
    projects_dir, pid, eid, ep_dir = project_layout
    p = ep_dir / "shot_dependency" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"dependencies": [{
        "scene_index": 1, "shot_index": 1,
        "location_refs": [
            {"ref_usage": "first", "keep_elements": [{"label": "a", "kind": "environment", "subject_kind": "non_human_visual_element"}]},
            {"ref_usage": "second", "keep_elements": [{"label": "b", "kind": "environment", "subject_kind": "non_human_visual_element"}]},
        ],
    }]}}))

    m = load_shot_dependency_map(projects_dir, pid, eid)
    assert m["1_1"]["ref_usage"] == "first"


# ──────────────────────────────────────────────────────────────────────
# load_background_chain_bg_map — PR #5
# ──────────────────────────────────────────────────────────────────────


def test_load_background_chain_bg_map_missing_checkpoint_returns_empty(project_layout):
    """on_demand step이라 체크포인트가 없을 수 있다 — 빈 dict 반환."""
    projects_dir, pid, eid, _ = project_layout
    assert load_background_chain_bg_map(projects_dir, pid, eid) == {}


def test_load_background_chain_bg_map_happy_path(project_layout, tmp_path, background_chain_on):
    projects_dir, pid, eid, ep_dir = project_layout
    img = tmp_path / "node_root.png"
    img.write_bytes(b"PNG_BG")

    p = ep_dir / "background_chain_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"locations": {
        "L01": {
            "shot_backgrounds": [
                {
                    "scene_index": 5, "shot_index": 2,
                    "shot_label": "S05_Shot2",
                    "node_id": "interior_main_room_day",
                    "image_path": str(img),
                },
            ],
        },
    }}}))

    m = load_background_chain_bg_map(projects_dir, pid, eid)
    assert "5_2" in m
    assert m["5_2"]["image_bytes"] == b"PNG_BG"
    assert "interior_main_room_day" in m["5_2"]["label"]
    assert "L01" in m["5_2"]["label"]


def test_load_background_chain_bg_map_skips_missing_files(project_layout, tmp_path, background_chain_on):
    projects_dir, pid, eid, ep_dir = project_layout
    real_img = tmp_path / "real.png"; real_img.write_bytes(b"R")
    p = ep_dir / "background_chain_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"locations": {
        "L01": {"shot_backgrounds": [
            {"scene_index": 1, "shot_index": 1, "node_id": "n1",
             "image_path": str(real_img)},
            {"scene_index": 1, "shot_index": 2, "node_id": "n2",
             "image_path": str(tmp_path / "vanished.png")},  # 실재 X
            {"scene_index": 1, "shot_index": 3, "node_id": "n3",
             "image_path": ""},  # 빈 path
        ]},
    }}}))

    m = load_background_chain_bg_map(projects_dir, pid, eid)
    assert set(m.keys()) == {"1_1"}


def test_load_background_chain_bg_map_first_match_wins(project_layout, tmp_path, background_chain_on):
    """동일 (si, shi)가 두 노드에 등장하면 첫 매칭 유지 (edge case 방어)."""
    projects_dir, pid, eid, ep_dir = project_layout
    a = tmp_path / "a.png"; a.write_bytes(b"A")
    b = tmp_path / "b.png"; b.write_bytes(b"B")
    p = ep_dir / "background_chain_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"locations": {
        "L01": {"shot_backgrounds": [
            {"scene_index": 9, "shot_index": 1, "node_id": "first", "image_path": str(a)},
        ]},
        "L02": {"shot_backgrounds": [
            {"scene_index": 9, "shot_index": 1, "node_id": "later", "image_path": str(b)},
        ]},
    }}}))

    m = load_background_chain_bg_map(projects_dir, pid, eid)
    # first wins — 어느 location이 first인지는 dict iteration 순서 의존이지만
    # 결과는 무조건 한 entry, 둘 중 하나
    assert len(m) == 1
    assert m["9_1"]["image_bytes"] in (b"A", b"B")


def test_load_background_chain_bg_map_malformed_returns_empty(project_layout, background_chain_on):
    projects_dir, pid, eid, ep_dir = project_layout
    p = ep_dir / "background_chain_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text("{broken json")
    assert load_background_chain_bg_map(projects_dir, pid, eid) == {}


def test_load_background_chain_bg_map_toggle_off_ignores_checkpoint(project_layout, tmp_path, monkeypatch):
    """P0-2 회귀 가드 (Codex H1): background_chain_enabled=False면 체크포인트 무시.

    code default는 False — pydantic Field default로 검증 (.env override 영향 격리).
    실행 환경 토글은 monkeypatch로 강제 off.
    """
    from app.core.config import settings, Settings
    assert Settings.model_fields["background_chain_enabled"].default is False, \
        "code default는 False여야 함 — 회귀 0건 보장"
    monkeypatch.setattr(settings, "background_chain_enabled", False)

    projects_dir, pid, eid, ep_dir = project_layout
    img = tmp_path / "node.png"; img.write_bytes(b"PNG")
    p = ep_dir / "background_chain_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"locations": {
        "L01": {"shot_backgrounds": [
            {"scene_index": 1, "shot_index": 1, "node_id": "n1", "image_path": str(img)},
        ]},
    }}}))
    # toggle off → 체크포인트 있어도 빈 dict
    assert load_background_chain_bg_map(projects_dir, pid, eid) == {}


def _write_space_set_bg_cp(ep_dir, tmp_path, *, gid="G1", status="ok",
                           shot_plate_map=None, plate_bytes=b"SPACE_PLATE",
                           diagnostics=None):
    """space_set_bg checkpoint + assets png 스텁. shot_plate_map None 이면 기본 1 entry.

    diagnostics: shot_assign_diagnostics list (W1 no-plate 억제 테스트용, 기본 없음).
    """
    assets = tmp_path / "ssb_assets" / gid
    assets.mkdir(parents=True, exist_ok=True)
    (assets / "bg_hub_room.png").write_bytes(plate_bytes)
    if shot_plate_map is None:
        shot_plate_map = {
            "5_2": {"space": "hub room", "plate_key": "hub_room",
                    "plate_png": "bg_hub_room.png", "plate_kind": "marked_indoor",
                    "shot_id": "S5_Shot2", "basis": "table seen"},
        }
    group = {"status": status, "assets_dir": str(assets),
             "shot_plate_map": shot_plate_map}
    if diagnostics is not None:
        group["shot_assign_diagnostics"] = diagnostics
    p = ep_dir / "space_set_bg" / "manifest.json"
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(json.dumps({"data": {"groups": {gid: group}}}))
    return assets


@pytest.fixture
def space_set_bg_on(monkeypatch):
    from app.core.config import settings
    monkeypatch.setattr(settings, "space_set_bg_enabled", True, raising=False)


def test_load_space_set_bg_map_flag_off_returns_empty(project_layout, tmp_path, monkeypatch):
    """opt-in flag OFF(default) → 체크포인트 있어도 빈 dict (byte-identical 보존)."""
    from app.core.config import settings
    monkeypatch.setattr(settings, "space_set_bg_enabled", False, raising=False)
    projects_dir, pid, eid, ep_dir = project_layout
    _write_space_set_bg_cp(ep_dir, tmp_path)
    assert load_space_set_bg_map(projects_dir, pid, eid) == {}


def test_load_space_set_bg_map_happy_path(project_layout, tmp_path, space_set_bg_on):
    projects_dir, pid, eid, ep_dir = project_layout
    _write_space_set_bg_cp(ep_dir, tmp_path)
    m = load_space_set_bg_map(projects_dir, pid, eid)
    assert set(m.keys()) == {"5_2"}
    e = m["5_2"]
    assert e["image_bytes"] == b"SPACE_PLATE"
    # synthetic bg_id — 실 catalog bg_id 가장 금지 (Codex C 반려 사유)
    assert e["bg_id"] == "space_set_bg:G1:hub_room"
    # label 에 source 구분 명시 (background chain 과 로그/프롬프트에서 구분)
    assert "space set background ref" in e["label"]
    assert "hub room" in e["label"] and "G1" in e["label"]
    assert e["source"] == "space_set_bg" and e["group_id"] == "G1"


def test_load_space_set_bg_map_skips_not_ok_group_and_missing_png(
    project_layout, tmp_path, space_set_bg_on,
):
    projects_dir, pid, eid, ep_dir = project_layout
    assets = _write_space_set_bg_cp(ep_dir, tmp_path, shot_plate_map={
        "1_1": {"space": "hub room", "plate_key": "hub_room",
                "plate_png": "bg_hub_room.png", "plate_kind": "marked_indoor"},
        "1_2": {"space": "gone room", "plate_key": "gone_room",
                "plate_png": "bg_gone.png", "plate_kind": "marked_indoor"},  # png 실재 X
        "1_3": {"space": "hub room", "plate_key": "hub_room",
                "plate_png": "", "plate_kind": "marked_indoor"},             # 빈 png
    })
    assert (assets / "bg_hub_room.png").exists()
    m = load_space_set_bg_map(projects_dir, pid, eid)
    assert set(m.keys()) == {"1_1"}
    # error group 은 통째 무시
    _write_space_set_bg_cp(ep_dir, tmp_path, status="error")
    assert load_space_set_bg_map(projects_dir, pid, eid) == {}


def test_load_background_chain_bg_map_space_overlay_wins(
    project_layout, tmp_path, background_chain_on, space_set_bg_on,
):
    """key 충돌 시 space plate 우선 merge (Codex 합의 — overlay 마지막 update)."""
    projects_dir, pid, eid, ep_dir = project_layout
    chain_img = tmp_path / "chain.png"; chain_img.write_bytes(b"CHAIN")
    p = ep_dir / "background_chain_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"locations": {
        "L01": {"shot_backgrounds": [
            {"scene_index": 5, "shot_index": 2, "node_id": "n1", "image_path": str(chain_img)},
            {"scene_index": 7, "shot_index": 1, "node_id": "n2", "image_path": str(chain_img)},
        ]},
    }}}))
    _write_space_set_bg_cp(ep_dir, tmp_path)   # space plate = 5_2 만
    m = load_background_chain_bg_map(projects_dir, pid, eid)
    assert m["5_2"]["image_bytes"] == b"SPACE_PLATE"          # 충돌 키 → space 우선
    assert m["5_2"]["source"] == "space_set_bg"
    assert m["7_1"]["image_bytes"] == b"CHAIN"                # 비충돌 키 → 기존 유지


def test_no_plate_keys_flag_off_or_missing_returns_empty(
    project_layout, tmp_path, monkeypatch,
):
    """W1: flag OFF / checkpoint 부재 → 빈 set (기존 동작 보존)."""
    from app.services.scene_checkpoint_loaders import load_space_set_bg_no_plate_keys
    from app.core.config import settings
    projects_dir, pid, eid, ep_dir = project_layout
    monkeypatch.setattr(settings, "space_set_bg_enabled", False, raising=False)
    _write_space_set_bg_cp(ep_dir, tmp_path, diagnostics=[
        {"reason": "unassigned", "scene": 10, "shot": 5},
    ])
    assert load_space_set_bg_no_plate_keys(projects_dir, pid, eid) == {}
    monkeypatch.setattr(settings, "space_set_bg_enabled", True, raising=False)
    (ep_dir / "space_set_bg" / "manifest.json").unlink()
    assert load_space_set_bg_no_plate_keys(projects_dir, pid, eid) == {}


def test_no_plate_keys_collects_unassigned_and_connector_only(
    project_layout, tmp_path, space_set_bg_on,
):
    """W1: reason ∈ {unassigned, connector_no_plate} 만 수집 (key→reason 맵) —
    다른 reason/결손 필드 무시."""
    from app.services.scene_checkpoint_loaders import load_space_set_bg_no_plate_keys
    projects_dir, pid, eid, ep_dir = project_layout
    _write_space_set_bg_cp(ep_dir, tmp_path, diagnostics=[
        {"reason": "unassigned", "scene": 10, "shot": 5},
        {"reason": "connector_no_plate", "scene": 10, "shot": 3},
        {"reason": "assign_call_failed", "scene": 11, "shot": 1},  # 다른 reason — 제외
        {"reason": "unassigned", "scene": None, "shot": 2},        # 결손 — 제외
    ])
    assert load_space_set_bg_no_plate_keys(projects_dir, pid, eid) == {
        "10_5": "unassigned", "10_3": "connector_no_plate",
    }


def test_no_plate_keys_collects_plate_action_no_plate(
    project_layout, tmp_path, space_set_bg_on,
):
    """Phase 3 (Codex 리뷰 BLOCKING 1): plate_action=no_plate 진단(LLM 의 명시적
    'plate 참조 부적합' 정책)도 no-plate key 로 수집 — 빠지면 legacy chain bg 가
    도로 붙어 W1-B 억제 정책과 충돌."""
    from app.services.scene_checkpoint_loaders import load_space_set_bg_no_plate_keys
    projects_dir, pid, eid, ep_dir = project_layout
    _write_space_set_bg_cp(ep_dir, tmp_path, diagnostics=[
        {"reason": "plate_action_no_plate", "scene": 12, "shot": 4,
         "space": "hub room", "basis": "extreme close-up"},
    ])
    assert load_space_set_bg_no_plate_keys(projects_dir, pid, eid) == {
        "12_4": "plate_action_no_plate",
    }


def test_chain_bg_suppressed_for_plate_action_no_plate(
    project_layout, tmp_path, background_chain_on, space_set_bg_on,
):
    """Phase 3: plate_action_no_plate = connector 와 같은 suppression 계열 —
    명시적 정책 판정이므로 legacy chain bg 를 sentinel 로 대체 + required waiver
    (unassigned 의 'legacy 유지' 분기와 다름 — Codex 리뷰 합의)."""
    projects_dir, pid, eid, ep_dir = project_layout
    chain_img = tmp_path / "chain.png"; chain_img.write_bytes(b"CHAIN")
    p = ep_dir / "background_chain_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"locations": {
        "L01": {"shot_backgrounds": [
            {"scene_index": 12, "shot_index": 4, "node_id": "n_ext", "image_path": str(chain_img)},
        ]},
    }}}))
    _write_space_set_bg_cp(ep_dir, tmp_path, diagnostics=[
        {"reason": "plate_action_no_plate", "scene": 12, "shot": 4,
         "space": "hub room", "basis": "extreme close-up"},
    ])
    m = load_background_chain_bg_map(projects_dir, pid, eid)
    assert m["12_4"]["source"] == "space_set_bg_no_plate"
    assert m["12_4"]["suppress_background_required"] is True
    assert m["12_4"]["reason"] == "plate_action_no_plate"
    assert "image_bytes" not in m["12_4"]


def test_load_space_set_bg_map_reads_derived_plate_png(
    project_layout, tmp_path, space_set_bg_on,
):
    """Phase 3 (Codex 리뷰 MINOR 1, acceptance 핵심 경로 잠금): spm 의 plate_png 가
    derived png 를 가리키면 loader 는 canonical 이 아닌 ★derived bytes★ 를 주입."""
    projects_dir, pid, eid, ep_dir = project_layout
    assets = _write_space_set_bg_cp(ep_dir, tmp_path, shot_plate_map={
        "1_5": {"space": "hub room", "plate_key": "hub_room",
                "plate_png": "bg_derived_1_5.png", "plate_kind": "marked_indoor",
                "shot_id": "S1_Shot5", "basis": "window detail",
                "plate_action": "derive_from_base",
                "derive_instruction": "camera one meter from the window",
                "canonical_plate_key": "hub_room",
                "canonical_plate_png": "bg_hub_room.png"},
    })
    (assets / "bg_derived_1_5.png").write_bytes(b"DERIVED_PLATE")
    m = load_space_set_bg_map(projects_dir, pid, eid)
    e = m["1_5"]
    assert e["image_bytes"] == b"DERIVED_PLATE"
    assert e["bg_id"] == "space_set_bg:G1:hub_room"   # synthetic bg_id 계약 유지
    assert e["source"] == "space_set_bg"


def test_chain_bg_suppressed_for_no_plate_diagnosed_shots(
    project_layout, tmp_path, background_chain_on, space_set_bg_on,
):
    """W1 (2026-06-11 fresh full E2E S10 실측): space_set_bg 가
    unassigned/connector_no_plate 로 진단한 shot 은 legacy chain bg fallback 억제.

    S10 실측: 실내/문앞 샷에 옥상 '외부' establishing chain bg 가 'use as-is' 주입
    → 카메라가 밖에 갇혀 문틈/유리 평면 발명. space policy 가 'plate 없음/불확실'
    이라 판정한 shot 을 legacy bg 가 덮으면 안 된다.
    """
    projects_dir, pid, eid, ep_dir = project_layout
    chain_img = tmp_path / "chain.png"; chain_img.write_bytes(b"CHAIN")
    p = ep_dir / "background_chain_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"locations": {
        "L01": {"shot_backgrounds": [
            {"scene_index": 10, "shot_index": 3, "node_id": "n_ext", "image_path": str(chain_img)},
            {"scene_index": 10, "shot_index": 5, "node_id": "n_ext", "image_path": str(chain_img)},
            {"scene_index": 9, "shot_index": 1, "node_id": "n_ok", "image_path": str(chain_img)},
        ]},
    }}}))
    _write_space_set_bg_cp(ep_dir, tmp_path, diagnostics=[
        {"reason": "connector_no_plate", "scene": 10, "shot": 3},
        {"reason": "unassigned", "scene": 10, "shot": 5},
    ])  # spm 기본 entry = 5_2 (space plate)
    m = load_background_chain_bg_map(projects_dir, pid, eid)
    # W1-B: 사유별 분기 —
    # connector_no_plate = sentinel 대체 (전이부는 plate 생략 정책 + waiver 신호,
    # Codex 권장: pop 으로 signal 을 잃지 말 것).
    assert m["10_3"]["source"] == "space_set_bg_no_plate"
    assert m["10_3"]["suppress_background_required"] is True
    assert m["10_3"]["reason"] == "connector_no_plate"
    assert "image_bytes" not in m["10_3"]
    # unassigned(증거 부족) + legacy entry 존재 = legacy bg 유지 — bg 를 통째로
    # 빼면 모델이 환경을 발명한다 (실측: 없는 지붕창). 카메라 오염은 W1 의
    # environment-identity 역할 문구가 차단.
    assert m["10_5"]["image_bytes"] == b"CHAIN"
    assert m["10_5"].get("source") != "space_set_bg_no_plate"
    # 진단 안 된 chain bg + space plate 는 보존
    assert m["9_1"]["image_bytes"] == b"CHAIN"
    assert m["5_2"]["source"] == "space_set_bg"


def test_chain_bg_unassigned_without_legacy_gets_sentinel(
    project_layout, tmp_path, background_chain_on, space_set_bg_on,
):
    """W1-B 경계: unassigned 인데 legacy entry 도 없으면 sentinel (부착할 것이
    없으므로 required waiver 만 — entity/prev_shot fallback 으로 진행)."""
    projects_dir, pid, eid, ep_dir = project_layout
    _write_space_set_bg_cp(ep_dir, tmp_path, diagnostics=[
        {"reason": "unassigned", "scene": 27, "shot": 2},
    ])
    m = load_background_chain_bg_map(projects_dir, pid, eid)
    assert m["27_2"]["source"] == "space_set_bg_no_plate"
    assert m["27_2"]["suppress_background_required"] is True
    assert "image_bytes" not in m["27_2"]


def test_load_background_chain_bg_map_space_overlay_respects_chain_gate(
    project_layout, tmp_path, monkeypatch, space_set_bg_on,
):
    """background_chain_enabled=False 면 space overlay 도 주입 안 함 (기존 gate 의미 유지 — Codex 합의)."""
    from app.core.config import settings
    monkeypatch.setattr(settings, "background_chain_enabled", False)
    projects_dir, pid, eid, ep_dir = project_layout
    _write_space_set_bg_cp(ep_dir, tmp_path)
    assert load_background_chain_bg_map(projects_dir, pid, eid) == {}


def test_load_background_chain_bg_map_read_failure_isolates_to_one_entry(
    project_layout, tmp_path, monkeypatch, background_chain_on,
):
    """exists() 와 read_bytes() 사이 race로 read 실패해도 다른 entry는 보존
    (Claude PR #5 Issue 1 회귀 가드)."""
    projects_dir, pid, eid, ep_dir = project_layout
    img_a = tmp_path / "a.png"; img_a.write_bytes(b"A")
    img_b = tmp_path / "b.png"; img_b.write_bytes(b"B")

    p = ep_dir / "background_chain_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"locations": {
        "L01": {"shot_backgrounds": [
            {"scene_index": 1, "shot_index": 1, "node_id": "n1", "image_path": str(img_a)},
            {"scene_index": 2, "shot_index": 1, "node_id": "n2", "image_path": str(img_b)},
        ]},
    }}}))

    # 두 번째 read_bytes만 OSError로 실패시킴 (file이 사라진 것처럼)
    real_read = Path.read_bytes
    call = {"n": 0}

    def _patched(self, *a, **kw):
        if str(self).endswith("b.png"):
            raise OSError("simulated vanished file")
        return real_read(self, *a, **kw)

    monkeypatch.setattr(Path, "read_bytes", _patched)

    m = load_background_chain_bg_map(projects_dir, pid, eid)
    # 1번 shot은 보존, 2번 shot만 skip
    assert "1_1" in m
    assert "2_1" not in m
    assert m["1_1"]["image_bytes"] == b"A"


def test_load_background_chain_bg_map_phase7_groups_shape(
    project_layout, tmp_path, background_chain_on,
):
    """Phase 7 (T20-B3): background_render의 data.groups[bg_id] shape를 read한다.

    각 ok 그룹의 shot_ids("Sxx_Shotyy")를 'xx_yy' key로 펼친다. PNG 파일은 한 번만 read.
    """
    projects_dir, pid, eid, ep_dir = project_layout
    img = tmp_path / "phase7.png"
    img.write_bytes(b"PNG_PHASE7")

    p = ep_dir / "background_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"groups": {
        "cb_living_day": {
            "status": "ok",
            "location_id": "L01",
            "png_path": str(img),
            "shot_ids": ["S05_Shot1", "S05_Shot2"],
            "t2i_prompt": "...",
            "shot_guides": [],
            "parent_id": "",
            "ref_used": "fp_only",
        },
        "cb_failed": {
            "status": "failed",  # status != ok → skip
            "location_id": "L02",
            "png_path": "",
            "shot_ids": ["S06_Shot1"],
        },
    }}}))

    m = load_background_chain_bg_map(projects_dir, pid, eid)
    assert set(m.keys()) == {"5_1", "5_2"}
    assert m["5_1"]["image_bytes"] == b"PNG_PHASE7"
    assert m["5_2"]["image_bytes"] == b"PNG_PHASE7"
    assert "cb_living_day" in m["5_1"]["label"]
    assert "L01" in m["5_1"]["label"]


def test_load_background_chain_bg_map_phase7_priority_over_phase5(
    project_layout, tmp_path, background_chain_on,
):
    """Phase 7과 Phase 5 체크포인트가 동시 존재 시 Phase 7 우선 (같은 shot key는 Phase 7 보존)."""
    projects_dir, pid, eid, ep_dir = project_layout
    img_p7 = tmp_path / "p7.png"; img_p7.write_bytes(b"P7")
    img_p5 = tmp_path / "p5.png"; img_p5.write_bytes(b"P5")

    # Phase 7
    p7 = ep_dir / "background_render" / "manifest.json"
    p7.parent.mkdir(parents=True)
    p7.write_text(json.dumps({"data": {"groups": {
        "cb_a": {
            "status": "ok", "location_id": "L01", "png_path": str(img_p7),
            "shot_ids": ["S01_Shot1"],
        },
    }}}))

    # Phase 5 (legacy data.groups shape)
    p5 = ep_dir / "background_chain_render" / "manifest.json"
    p5.parent.mkdir(parents=True)
    p5.write_text(json.dumps({"data": {"groups": {
        "old_a": {
            "status": "ok", "location_id": "L01", "png_path": str(img_p5),
            "shot_ids": ["S01_Shot1", "S02_Shot1"],
        },
    }}}))

    m = load_background_chain_bg_map(projects_dir, pid, eid)
    # 1_1은 Phase 7 보존, 2_1은 Phase 5에서만 옴
    assert m["1_1"]["image_bytes"] == b"P7"
    assert m["2_1"]["image_bytes"] == b"P5"


def test_load_background_chain_bg_map_phase7_invalid_shot_id_skipped(
    project_layout, tmp_path, background_chain_on,
):
    """Phase 7: shot_id 형식이 'Sxx_Shotyy' 아니면 skip (다른 entries는 보존)."""
    projects_dir, pid, eid, ep_dir = project_layout
    img = tmp_path / "p7.png"; img.write_bytes(b"X")

    p = ep_dir / "background_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"groups": {
        "cb_x": {
            "status": "ok", "location_id": "L01", "png_path": str(img),
            "shot_ids": ["weird_id", "S03_Shot4"],
        },
    }}}))

    m = load_background_chain_bg_map(projects_dir, pid, eid)
    assert set(m.keys()) == {"3_4"}


# ──────────────────────────────────────────────────────────────────────
# D5 (2026-05-09): bg_map entry 가 bg_id + location_id 보존
# spec §4.2.1 — P1 강화 (caller 가 라벨 파싱 안 하도록)
# ──────────────────────────────────────────────────────────────────────


def test_load_background_chain_bg_map_d5_phase7_entry_includes_bg_id_and_location_id(
    project_layout, tmp_path, background_chain_on,
):
    """D5 AC-13: Phase 7 entry 가 bg_id + location_id 보존.

    spec §4.2.1 — chain_bg attached_meta 를 라벨 파싱 없이 만들기 위해 entry 가
    source-of-truth (bg_id, location_id) 를 보존해야 함. 라벨 파싱은 P1 위반.
    """
    projects_dir, pid, eid, ep_dir = project_layout
    img = tmp_path / "phase7_d5.png"
    img.write_bytes(b"PNG_P7_D5")

    p = ep_dir / "background_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"groups": {
        "bg_kitchen_morning": {
            "status": "ok",
            "location_id": "L05",
            "png_path": str(img),
            "shot_ids": ["S05_Shot3"],
        },
    }}}))

    m = load_background_chain_bg_map(projects_dir, pid, eid)
    assert "5_3" in m
    entry = m["5_3"]
    # 기존 contract 보존
    assert entry["image_bytes"] == b"PNG_P7_D5"
    assert "label" in entry
    # D5 신규 contract — P1 source-of-truth
    assert entry["bg_id"] == "bg_kitchen_morning", \
        "D5 §4.2.1: bg_map entry must include bg_id (source-of-truth, not parsed from label)"
    assert entry["location_id"] == "L05", \
        "D5 §4.2.1: bg_map entry must include location_id (source-of-truth)"


def test_load_background_chain_bg_map_d5_phase4_legacy_entry_includes_bg_id_and_location_id(
    project_layout, tmp_path, background_chain_on,
):
    """D5 AC-13 (Phase 4 LEGACY): data.locations[].shot_backgrounds[] shape 도
    동일 contract — bg_id (=node_id) + location_id 보존.
    """
    projects_dir, pid, eid, ep_dir = project_layout
    img = tmp_path / "phase4_d5.png"; img.write_bytes(b"P4")

    p = ep_dir / "background_chain_render" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"locations": {
        "L08": {
            "shot_backgrounds": [
                {
                    "scene_index": 7, "shot_index": 2,
                    "shot_label": "S07_Shot2",
                    "node_id": "interior_office_day",
                    "image_path": str(img),
                },
            ],
        },
    }}}))

    m = load_background_chain_bg_map(projects_dir, pid, eid)
    assert "7_2" in m
    entry = m["7_2"]
    # D5 신규 contract — Phase 4 LEGACY 도 동일
    assert entry["bg_id"] == "interior_office_day", \
        "D5 §4.2.1: legacy entry bg_id (=node_id) 보존"
    assert entry["location_id"] == "L08", \
        "D5 §4.2.1: legacy entry location_id 보존"


def test_load_background_chain_bg_map_d5_invariant_all_entries_have_bg_id_and_location_id(
    project_layout, tmp_path, background_chain_on,
):
    """D5 §4.2.1 invariant: 모든 entry 가 bg_id + location_id 키 보유.

    Phase 7 + Phase 4 LEGACY 혼합 환경에서도 contract 일관 — caller 가 entry shape
    분기 검사 없이 entry["bg_id"] / entry["location_id"] 직접 read 가능해야 함.
    """
    projects_dir, pid, eid, ep_dir = project_layout
    img_p7 = tmp_path / "p7m.png"; img_p7.write_bytes(b"P7M")
    img_p4 = tmp_path / "p4m.png"; img_p4.write_bytes(b"P4M")

    # Phase 7
    p7 = ep_dir / "background_render" / "manifest.json"
    p7.parent.mkdir(parents=True)
    p7.write_text(json.dumps({"data": {"groups": {
        "bg_a": {
            "status": "ok", "location_id": "L01",
            "png_path": str(img_p7), "shot_ids": ["S01_Shot1"],
        },
    }}}))

    # Phase 4 (다른 shot key 라 conflict 없음)
    p4 = ep_dir / "background_chain_render" / "manifest.json"
    p4.parent.mkdir(parents=True)
    p4.write_text(json.dumps({"data": {"locations": {
        "L09": {"shot_backgrounds": [
            {"scene_index": 9, "shot_index": 1, "node_id": "legacy_node",
             "image_path": str(img_p4)},
        ]},
    }}}))

    m = load_background_chain_bg_map(projects_dir, pid, eid)
    assert set(m.keys()) == {"1_1", "9_1"}
    for key, entry in m.items():
        assert "bg_id" in entry, f"D5 invariant: entry[{key!r}] missing 'bg_id'"
        assert "location_id" in entry, f"D5 invariant: entry[{key!r}] missing 'location_id'"
        assert entry["bg_id"], f"D5 invariant: entry[{key!r}] bg_id non-empty"
        assert entry["location_id"], f"D5 invariant: entry[{key!r}] location_id non-empty"


# ──────────────────────────────────────────────────────────────────────
# load_shot_t2i_variations — W5 F22 Phase B.21.1
# ──────────────────────────────────────────────────────────────────────


def test_t2i_variations_from_camera_json_shortcut(project_layout):
    """camera_json에 t2i_variations가 있으면 체크포인트 조회 없이 반환."""
    projects_dir, pid, eid, _ = project_layout
    camera = json.dumps({"t2i_variations": [{"t2i_prompt": "CAM_1"}, {"t2i_prompt": "CAM_2"}]})
    result = load_shot_t2i_variations(
        projects_dir, pid, eid,
        camera_json=camera,
        scene_index=1, still_index=5, shot_index=1,
    )
    assert len(result) == 2
    assert result[0]["t2i_prompt"] == "CAM_1"


def test_t2i_variations_empty_camera_no_checkpoint(project_layout):
    """camera_json 비어있고 scene_detail 체크포인트도 없으면 빈 리스트."""
    projects_dir, pid, eid, _ = project_layout
    result = load_shot_t2i_variations(
        projects_dir, pid, eid,
        camera_json="{}",
        scene_index=1, still_index=None, shot_index=1,
    )
    assert result == []


def test_t2i_variations_checkpoint_shot_match(project_layout):
    """체크포인트에서 scene_index + _shot_index 일치 샷의 variations 반환."""
    projects_dir, pid, eid, ep_dir = project_layout
    cp = ep_dir / "scene_detail" / "manifest.json"
    cp.parent.mkdir(parents=True)
    cp.write_text(json.dumps({
        "data": {
            "scenes": [
                {"scene_index": 1, "_shot_index": 1, "t2i_variations": [{"t2i_prompt": "S1_SH1"}]},
                {"scene_index": 1, "_shot_index": 2, "t2i_variations": [{"t2i_prompt": "S1_SH2"}]},
                {"scene_index": 2, "_shot_index": 1, "t2i_variations": [{"t2i_prompt": "S2_SH1"}]},
            ]
        }
    }))
    result = load_shot_t2i_variations(
        projects_dir, pid, eid,
        camera_json=None,
        scene_index=1, still_index=None, shot_index=2,
    )
    # G3.1: 옛 cp (4 evidence 필드 부재) 는 normalize 가 lazy backfill →
    # confidence='legacy' 마킹. t2i_prompt 본 데이터는 보존.
    assert len(result) == 1
    assert result[0]["t2i_prompt"] == "S1_SH2"
    assert result[0]["confidence"] == "legacy"


def test_t2i_variations_checkpoint_scene_only_fallback(project_layout):
    """shot_index None이면 scene_index 단독 매칭 (_shot_index 무시)."""
    projects_dir, pid, eid, ep_dir = project_layout
    cp = ep_dir / "scene_detail" / "manifest.json"
    cp.parent.mkdir(parents=True)
    cp.write_text(json.dumps({
        "data": {
            "scenes": [
                {"scene_index": 3, "t2i_variations": [{"t2i_prompt": "SCENE3"}]},
            ]
        }
    }))
    result = load_shot_t2i_variations(
        projects_dir, pid, eid,
        camera_json=None,
        scene_index=3, still_index=None, shot_index=None,
    )
    assert len(result) == 1
    assert result[0]["t2i_prompt"] == "SCENE3"
    assert result[0]["confidence"] == "legacy"  # G3.1 lazy backfill marker


def test_t2i_variations_scene_index_falsy_falls_back_to_still_index(project_layout):
    """scene_index=0 (falsy)면 still_index 사용 (원본 'or' 동작 보존)."""
    projects_dir, pid, eid, ep_dir = project_layout
    cp = ep_dir / "scene_detail" / "manifest.json"
    cp.parent.mkdir(parents=True)
    cp.write_text(json.dumps({
        "data": {
            "scenes": [
                {"scene_index": 7, "t2i_variations": [{"t2i_prompt": "IDX_7"}]},
            ]
        }
    }))
    result = load_shot_t2i_variations(
        projects_dir, pid, eid,
        camera_json=None,
        scene_index=0, still_index=7, shot_index=None,
    )
    assert len(result) == 1
    assert result[0]["t2i_prompt"] == "IDX_7"
    assert result[0]["confidence"] == "legacy"  # G3.1 lazy backfill marker


def test_t2i_variations_invalid_camera_json_propagates(project_layout):
    """camera_json 파싱 실패는 caller로 propagate (원본 동작 보존)."""
    projects_dir, pid, eid, _ = project_layout
    with pytest.raises(json.JSONDecodeError):
        load_shot_t2i_variations(
            projects_dir, pid, eid,
            camera_json="not json {",
            scene_index=1, still_index=None, shot_index=None,
        )


def test_t2i_variations_broken_checkpoint_returns_empty(project_layout):
    """체크포인트 파싱 실패 시 빈 리스트."""
    projects_dir, pid, eid, ep_dir = project_layout
    cp = ep_dir / "scene_detail" / "manifest.json"
    cp.parent.mkdir(parents=True)
    cp.write_text("garbage")
    result = load_shot_t2i_variations(
        projects_dir, pid, eid,
        camera_json=None,
        scene_index=1, still_index=None, shot_index=None,
    )
    assert result == []


def test_t2i_variations_mixed_ordering_picks_right_scene_and_shot(project_layout):
    """여러 씬/샷이 섞인 상태에서 지정된 (scene, shot)만 정확히 매칭."""
    projects_dir, pid, eid, ep_dir = project_layout
    cp = ep_dir / "scene_detail" / "manifest.json"
    cp.parent.mkdir(parents=True)
    cp.write_text(json.dumps({
        "data": {
            "scenes": [
                {"scene_index": 2, "_shot_index": 3, "t2i_variations": [{"t2i_prompt": "S2_3"}]},
                {"scene_index": 5, "_shot_index": 1, "t2i_variations": [{"t2i_prompt": "S5_1"}]},
                {"scene_index": 5, "_shot_index": 2, "t2i_variations": [{"t2i_prompt": "S5_2"}]},
                {"scene_index": 5, "_shot_index": 3, "t2i_variations": [{"t2i_prompt": "S5_3"}]},
                {"scene_index": 9, "_shot_index": 1, "t2i_variations": [{"t2i_prompt": "S9_1"}]},
            ]
        }
    }))
    # scene=5, shot=2만 정확히 매칭되어야 함
    result = load_shot_t2i_variations(
        projects_dir, pid, eid,
        camera_json=None,
        scene_index=5, still_index=None, shot_index=2,
    )
    assert len(result) == 1
    assert result[0]["t2i_prompt"] == "S5_2"
    assert result[0]["confidence"] == "legacy"  # G3.1 lazy backfill marker
    # scene=5, shot=3 — 다른 타깃
    result2 = load_shot_t2i_variations(
        projects_dir, pid, eid,
        camera_json=None,
        scene_index=5, still_index=None, shot_index=3,
    )
    assert len(result2) == 1
    assert result2[0]["t2i_prompt"] == "S5_3"
    assert result2[0]["confidence"] == "legacy"  # G3.1 lazy backfill marker


# ─────────────────────────────────────────────
# Area D-next — keep_elements shape validation (Task 2)
# ─────────────────────────────────────────────

from app.core.errors import AppError


def _write_dep_cp(tmp_path, project_id, episode_id, *, keep_elements):
    cp_dir = (
        tmp_path / "projects" / project_id / "checkpoints" / "episodes"
        / episode_id / "shot_dependency_t2i"
    )
    cp_dir.mkdir(parents=True, exist_ok=True)
    cp_path = cp_dir / "manifest.json"
    payload = {
        "data": {
            "dependencies": [{
                "scene_index": 1,
                "shot_index": 2,
                "location_refs": [{
                    "scene_index": 1,
                    "shot_index": 1,
                    "ref_usage": "exact_background",
                    "ignore_elements": "",
                    "keep_elements": keep_elements,
                }],
            }],
        },
    }
    cp_path.write_text(json.dumps(payload), encoding="utf-8")
    return cp_path


def test_c1_loader_rejects_legacy_string_entry(tmp_path):
    from app.services.scene_checkpoint_loaders import load_shot_dependency_map

    _write_dep_cp(tmp_path, "p1", "ep1", keep_elements=["wooden bench"])
    with pytest.raises(AppError) as excinfo:
        load_shot_dependency_map(str(tmp_path / "projects"), "p1", "ep1")
    assert excinfo.value.code == "step.scene_checkpoint_loaders.keep_elements_legacy_str"


def test_c2_loader_rejects_malformed_dict(tmp_path):
    from app.services.scene_checkpoint_loaders import load_shot_dependency_map

    _write_dep_cp(tmp_path, "p1", "ep1", keep_elements=[{"kind": "environment", "subject_kind": "non_human_visual_element"}])
    with pytest.raises(AppError) as excinfo:
        load_shot_dependency_map(str(tmp_path / "projects"), "p1", "ep1")
    assert excinfo.value.code == "step.scene_checkpoint_loaders.keep_elements_entry_invalid"


def test_c3_loader_rejects_unknown_kind(tmp_path):
    from app.services.scene_checkpoint_loaders import load_shot_dependency_map

    _write_dep_cp(tmp_path, "p1", "ep1", keep_elements=[
        {"label": "x", "kind": "unknown_kind", "subject_kind": "non_human_visual_element"},
    ])
    with pytest.raises(AppError) as excinfo:
        load_shot_dependency_map(str(tmp_path / "projects"), "p1", "ep1")
    assert excinfo.value.code == "step.scene_checkpoint_loaders.keep_elements_entry_invalid"


def test_c3b_loader_rejects_non_string_label(tmp_path):
    """Area D-next C3b (v3 Codex I-4 신규) — label 이 str 이 아니면 AppError."""
    from app.services.scene_checkpoint_loaders import load_shot_dependency_map

    _write_dep_cp(tmp_path, "p1", "ep1", keep_elements=[
        {"label": 12345, "kind": "environment", "subject_kind": "non_human_visual_element"},
    ])
    with pytest.raises(AppError) as excinfo:
        load_shot_dependency_map(str(tmp_path / "projects"), "p1", "ep1")
    assert excinfo.value.code == "step.scene_checkpoint_loaders.keep_elements_entry_invalid"


def test_c3c_loader_rejects_missing_keep_elements_key(tmp_path):
    """Area D-next C3c (v3 Codex I-4 신규) — location_ref 에 keep_elements
    key 자체가 없으면 silent empty fallback 차단 + AppError."""
    from app.services.scene_checkpoint_loaders import load_shot_dependency_map

    cp_dir = (
        tmp_path / "projects" / "p1" / "checkpoints" / "episodes"
        / "ep1" / "shot_dependency_t2i"
    )
    cp_dir.mkdir(parents=True, exist_ok=True)
    cp_path = cp_dir / "manifest.json"
    # keep_elements key 누락
    payload = {
        "data": {
            "dependencies": [{
                "scene_index": 1,
                "shot_index": 2,
                "location_refs": [{
                    "scene_index": 1,
                    "shot_index": 1,
                    "ref_usage": "exact_background",
                    "ignore_elements": "",
                    # keep_elements key 의도적으로 missing
                }],
            }],
        },
    }
    cp_path.write_text(json.dumps(payload), encoding="utf-8")

    with pytest.raises(AppError) as excinfo:
        load_shot_dependency_map(str(tmp_path / "projects"), "p1", "ep1")
    assert excinfo.value.code == "step.scene_checkpoint_loaders.keep_elements_entry_invalid"


def test_c4_loader_accepts_valid_dict_entries(tmp_path):
    """Area D-next-min — enum 2종 (environment / static_prop) entry 만 통과.
    legacy v6 immobilized_character 는 c4b (신규) 에서 거부 검증."""
    from app.services.scene_checkpoint_loaders import load_shot_dependency_map

    valid_keep = [
        {"label": "wooden bench against the wall", "kind": "environment", "subject_kind": "non_human_visual_element"},
        {"label": "broken vase on the floor", "kind": "static_prop", "subject_kind": "non_human_visual_element"},
        {"label": "warm ceiling lamp", "kind": "environment", "subject_kind": "non_human_visual_element"},
    ]
    _write_dep_cp(tmp_path, "p1", "ep1", keep_elements=valid_keep)
    dep_map = load_shot_dependency_map(str(tmp_path / "projects"), "p1", "ep1")
    assert "1_2" in dep_map
    assert dep_map["1_2"]["keep_elements"] == valid_keep


def test_c4b_loader_rejects_legacy_v6_immobilized_character(tmp_path):
    """Area D-next-min (신규) — legacy v6 kind=immobilized_character entry 가
    L2 loader 에서 fail-fast 거부. operator force re-run 의무."""
    from app.services.scene_checkpoint_loaders import load_shot_dependency_map

    legacy_v6_keep = [
        {"label": "wooden bench", "kind": "environment", "subject_kind": "non_human_visual_element"},  # OK
        {"label": "the dead detective face-down", "kind": "immobilized_character", "subject_kind": "non_human_visual_element"},  # REJECT (kind)
    ]
    _write_dep_cp(tmp_path, "p1", "ep1", keep_elements=legacy_v6_keep)
    with pytest.raises(AppError) as excinfo:
        load_shot_dependency_map(str(tmp_path / "projects"), "p1", "ep1")
    assert excinfo.value.code == "step.scene_checkpoint_loaders.keep_elements_entry_invalid"
    assert "immobilized_character" in str(excinfo.value.message)


def test_c5_loader_broad_except_does_not_absorb_apperror(tmp_path):
    """Codex C1 추가 - broad except 가 AppError 흡수 안 함 (silent fallback 0)."""
    from app.services.scene_checkpoint_loaders import load_shot_dependency_map

    _write_dep_cp(tmp_path, "p1", "ep1", keep_elements=["legacy string"])
    with pytest.raises(AppError):
        load_shot_dependency_map(str(tmp_path / "projects"), "p1", "ep1")


def test_c6_loader_warning_on_json_parse_failure(tmp_path, caplog):
    """JSON parse 실패는 기존 경로 유지 (warning + empty)."""
    from app.services.scene_checkpoint_loaders import load_shot_dependency_map
    import logging

    cp_dir = (
        tmp_path / "projects" / "p1" / "checkpoints" / "episodes"
        / "ep1" / "shot_dependency_t2i"
    )
    cp_dir.mkdir(parents=True, exist_ok=True)
    (cp_dir / "manifest.json").write_text("not valid json {{{", encoding="utf-8")

    caplog.set_level(logging.WARNING, logger="app.services.scene_checkpoint_loaders")
    dep_map = load_shot_dependency_map(str(tmp_path / "projects"), "p1", "ep1")
    assert dep_map == {}
    assert any("Failed to parse shot_dependency checkpoint" in rec.message for rec in caplog.records)


def test_base_shot_dependency_without_t2i_annotation_reads_empty_keep_elements(project_layout):
    """★실측 2026-09-02 stage2a(still_recipe off): t2i CP 가 없어 기본 CP 로 내려왔는데
    location_ref 에 keep_elements 가 없다고 섰다 — 기본(코드 스텝) CP 엔 그 칸이 원래 없다."""
    projects_dir, pid, eid, ep_dir = project_layout
    p = ep_dir / "shot_dependency" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"dependencies": [{
        "scene_index": 1, "shot_index": 2,
        "location_refs": [{"scene_index": 1, "shot_index": 1, "score": 0,
                           "shared_entities": ["C01"], "extra_entities": ["LP01"]}],
    }]}}))
    m = load_shot_dependency_map(projects_dir, pid, eid)
    assert m["1_2"]["keep_elements"] == []
    assert (m["1_2"]["dep_scene_index"], m["1_2"]["dep_shot_index"]) == (1, 1)
    assert m["1_2"]["t2i_annotated"] is False


def test_t2i_shot_dependency_without_keep_elements_still_fails_fast(project_layout):
    """★양성 대조 — t2i CP(schema v7)에서 빠진 것은 그대로 선다."""
    from app.core.errors import AppError

    projects_dir, pid, eid, ep_dir = project_layout
    p = ep_dir / "shot_dependency_t2i" / "manifest.json"
    p.parent.mkdir(parents=True)
    p.write_text(json.dumps({"data": {"dependencies": [{
        "scene_index": 1, "shot_index": 2,
        "location_refs": [{"scene_index": 1, "shot_index": 1, "ref_usage": "x"}],
    }]}}))
    with pytest.raises(AppError):
        load_shot_dependency_map(projects_dir, pid, eid)
