"""still_recipe — 참조 조립·프롬프트 조립 결정론 로직 테스트."""
from __future__ import annotations

from pathlib import Path

from app.modules.pipeline.still_recipe import (
    build_still_prompt,
    build_still_refs,
    ve_ids_for_shot,
)


PLATE = Path("/tmp/plate.png")
CONTI = Path("/tmp/conti.png")
PREV = Path("/tmp/prev_sel.png")


def test_refs_bgonly_plate_only():
    """bgonly 기본(계획 부재 fail-safe): caller 가 prev_sel 을 주지 않아
    플레이트 강제 — 엔티티 미첨부."""
    refs = build_still_refs(
        bg_only=True, plate=PLATE, conti=CONTI, prev_sel=None,
        char_refs=[("남자", b"c")], prop_refs=[("사진", b"p")],
    )
    assert len(refs) == 1
    assert refs[0][0].startswith("LOCATION PHOTOGRAPH")
    assert refs[0][1] is PLATE


def test_refs_bgonly_share_plan_prev_wins():
    """E2E9 육안 #2 (2026-07-19 사용자 확정): 배경 공유 계획이 prev 를
    지휘한 bgonly 샷 — prev 스틸이 배경 단일 권위, 플레이트 미첨부,
    refs=[prev]만 (엔티티 없음)."""
    refs = build_still_refs(
        bg_only=True, plate=PLATE, conti=CONTI, prev_sel=PREV,
        char_refs=[("남자", b"c")], prop_refs=[("사진", b"p")],
    )
    assert len(refs) == 1
    assert refs[0][0].startswith("PREVIOUS SHOT STILL")
    assert refs[0][1] is PREV


def test_refs_prev_uses_sel_and_no_conti():
    """E2E6 ⑦: prev 샷=prev 가 배경 SOT — 플레이트·콘티 미첨부,
    refs=[prev+엔티티]."""
    refs = build_still_refs(
        bg_only=False, plate=PLATE, conti=CONTI, prev_sel=PREV,
        char_refs=[("남자", b"c")], prop_refs=[],
    )
    labels = [r[0] for r in refs]
    assert labels[0].startswith("PREVIOUS SHOT STILL")
    assert not any(l.startswith("LOCATION PHOTOGRAPH") for l in labels)
    assert not any(l.startswith("LAYOUT SKETCH") for l in labels)
    assert "CHARACTER REFERENCE — 남자" in labels[1]
    assert len(refs) == 2


def test_refs_normal_plate_conti_entities():
    refs = build_still_refs(
        bg_only=False, plate=PLATE, conti=CONTI, prev_sel=None,
        char_refs=[("남자", b"c")], prop_refs=[("사진", b"p")],
    )
    labels = [r[0] for r in refs]
    assert labels[0].startswith("LOCATION PHOTOGRAPH")
    assert labels[1].startswith("LAYOUT SKETCH")
    assert "CHARACTER REFERENCE — 남자" in labels[2]
    assert "PROP REFERENCE — 사진" in labels[3]


class _FakeRecords:
    def __init__(self):
        self.data = {}
        self.saves = 0

    def save(self):
        self.saves += 1


def test_run_branch_select_gen_fix_same_callable_executes():
    """Codex 74ebf365 B1: branch gen callable 1회 생성 — gen/fix 동일
    객체, critique OFF=fix None. 실행 경로 자체를 잠근다(NameError 류
    실행 차단 재발 방지)."""
    from app.modules.pipeline.still_recipe import run_branch_select

    records = _FakeRecords()
    records.data["k1"] = {"prior": True}
    captured = {}

    def fake_run(**kw):
        captured.update(kw)
        kw["persist_record_fn"]({"done": True})
        return ("/tmp/sel.png", {"selected": "A"})

    made = []

    def make_gen(bt):
        made.append(bt)
        return lambda *a: None

    sel, rec = run_branch_select(
        branch_tag="still_S1sh1_ab_conti", branch_refs=[("L", b"x")],
        rec_key="k1", out_stem="/tmp/stem", prompt="P",
        records=records, make_gen_fn=make_gen,
        judge_fn=lambda *a: None, critique_fn=lambda *a: None,
        roll_count=3, critique_enabled=True,
        judge_texts={"fix_head": "H", "fix_tail": "T", "fix_label": "L"},
        extra_fingerprint={"judge_pack": "v"},
        run_fn=fake_run,
    )
    assert made == ["still_S1sh1_ab_conti"]  # gen 1회 생성
    assert captured["gen_fn"] is captured["fix_gen_fn"]  # 동일 callable
    assert captured["record"] == {"prior": True}
    assert records.data["k1"] == {"done": True} and records.saves == 1
    assert rec["selected"] == "A"

    captured.clear()
    run_branch_select(
        branch_tag="still_S1sh1", branch_refs=[], rec_key="k2",
        out_stem="/tmp/stem2", prompt="P", records=records,
        make_gen_fn=make_gen, judge_fn=lambda *a: None,
        critique_fn=lambda *a: None, roll_count=3,
        critique_enabled=False,
        judge_texts={"fix_head": "H", "fix_tail": "T", "fix_label": "L"},
        extra_fingerprint={}, run_fn=fake_run,
    )
    assert captured["fix_gen_fn"] is None
    assert captured["critique_fn"] is None


def test_winner_exact_multiroll_tag():
    """Codex 74ebf365 H2: fix 존재=_fix, 아니면 selected 롤 — 생성 순서
    무관(selected=A 인데 C 가 최신이어도 A 태그)."""
    from app.modules.pipeline.still_recipe import winner_exact_multiroll_tag

    assert winner_exact_multiroll_tag(
        "still_S1sh1_ab_conti", {"selected": "A"}
    ) == "still_S1sh1_ab_conti_a"
    assert winner_exact_multiroll_tag(
        "still_S1sh1", {"selected": "C", "fix_prompt": "..."}
    ) == "still_S1sh1_fix"
    assert winner_exact_multiroll_tag("still_S1sh1", {}) == ""


def test_resolver_exact_miss_returns_none():
    """Codex 74ebf365 H2: multiroll_tag 지정 시 exact miss=None —
    broad still_id fallback 으로 패자/다른 롤 거짓 링크 금지."""
    from app.services.scene_persistence_service import (
        ScenePersistenceService,
    )

    class _FakeQ:
        def __init__(self, first_result, on_filter=None):
            self._first = first_result
            self._on_filter = on_filter

        def filter(self, *a):
            return self._on_filter if self._on_filter is not None else self

        def order_by(self, *a):
            return self

        def first(self):
            return self._first

    class _FakeDB:
        def __init__(self, exact_first, broad_first):
            exact = _FakeQ(exact_first)
            self._base = _FakeQ(broad_first, on_filter=exact)

        def query(self, *a):
            return _FakeQ(None, on_filter=self._base)

    svc = object.__new__(ScenePersistenceService)
    svc._project_id = "p1"
    # exact miss + broad 존재 → None (fallback 금지)
    svc._db = _FakeDB(exact_first=None, broad_first=("LOSER",))
    assert svc._resolve_generation_call_id(
        "s1", "e1", operation_type="still_recipe_roll",
        multiroll_tag="still_S1sh1_a",
    ) is None
    # exact hit → 그 값
    svc._db = _FakeDB(exact_first=("EXACT",), broad_first=("LOSER",))
    assert svc._resolve_generation_call_id(
        "s1", "e1", operation_type="still_recipe_roll",
        multiroll_tag="still_S1sh1_a",
    ) == "EXACT"
    # multiroll_tag 미지정 = legacy broad 유지
    svc._db = _FakeDB(exact_first=None, broad_first=("BROAD",))
    assert svc._resolve_generation_call_id(
        "s1", "e1", operation_type="still_recipe_roll",
    ) == "BROAD"


def test_locked_pose_short_ids_prev_coverage_rule():
    """E2E6 ④: 정본이 현재+prev 샷 모두 커버 & short_id 해소 시만 제외."""
    from app.modules.pipeline.still_recipe import locked_pose_short_ids

    canon = [
        {"character_short_id": "C07", "shots": ["S12sh6", "S12sh9"]},
        # prev 미커버 — 제외 대상 아님
        {"character_short_id": "C02", "shots": ["S12sh9"]},
        # short_id 미해소(None) — 참조 유지(안전측)
        {"character_short_id": None, "shots": ["S12sh6", "S12sh9"]},
    ]
    assert locked_pose_short_ids(canon, "S12sh9", "S12sh6") == {"C07"}
    # prev 없음 → 빈 집합
    assert locked_pose_short_ids(canon, "S12sh9", None) == set()
    assert locked_pose_short_ids([], "S12sh9", "S12sh6") == set()


def test_ve_fallback_scene_union():
    ve = {(1, 1): ["C01"], (1, 2): [], (1, 3): ["P02"], (2, 1): ["C09"]}
    assert ve_ids_for_shot(ve, (1, 2)) == ["C01", "P02"]  # 같은 씬 합집합
    assert ve_ids_for_shot(ve, (1, 1)) == ["C01"]  # 자체 VE 우선


def _prompt(**over):
    kw = dict(
        shot_desc="남자가 문을 연다",
        place_text="허름한 방.",
        time_of_day_en="night",
        world_anchor="",
        bg_only=False,
        prev_used=False,
        prev_usage_en="",
        pose_clauses=[],
        movement_en="",
        figures_en="",
        carried_en="",
        char_names=["남자 (male, 40s, stocky)"],
    )
    kw.update(over)
    return build_still_prompt(**kw)


def _text_policy_clause() -> str:
    """표기 정책 절의 첫 문장 — 현행 팩에서 읽는다(문안 개정에 안 깨진다)."""
    from app.modules.prompt_loader import load_prompt
    from app.modules.pipeline.still_recipe import (
        TEXT_POLICY_PROMPT_VERSION, resolve_prompt_version)
    body = load_prompt(
        "still_recipe", "no_text_none",
        version=resolve_prompt_version(TEXT_POLICY_PROMPT_VERSION)).strip()
    return body.split("\n")[0][:60]


def test_prompt_normal_order_and_people_traits():
    p = _prompt(pose_clauses=["POSE X"], carried_en="grips a photo")
    idx = {
        "head": p.index("Create ONE FINAL photorealistic"),
        "tod": p.index("TIME OF DAY (lock): night."),
        "shot": p.index("SHOT TEXT (authoritative, Korean): 남자가 문을 연다"),
        "loc": p.index("LOCATION (lock): 허름한 방."),
        "loc_photo": p.index("LOCATION PHOTOGRAPH shows the exact spot."),
        "pose": p.index("POSE X"),
        "realize": p.index("REALIZE FIGURATIVE LANGUAGE"),
        "expr": p.index("EXPRESSIONS ARE ACTED"),
        "prop": p.index("PROPS FACE THE RIGHT WAY"),
        "carried": p.index("CARRIED STATE (persist exactly"),
        "people": p.index("PEOPLE: the SHOT TEXT alone decides"),
        # ★문안을 **팩에서 읽어** 찾는다 (2026-09-20). 종전에는 절의 첫
        #  문장을 여기 적어 두어, 표기 정책 문안을 고칠 때마다 순서 검사가
        #  같이 깨졌다 — 이 시험이 잠그는 것은 **절의 자리**이지 문장이 아니다.
        "notext": p.index(_text_policy_clause()),
    }
    order = ["head", "tod", "shot", "loc", "loc_photo", "pose", "realize",
             "expr", "prop", "carried", "people", "notext"]
    assert sorted(idx, key=idx.get) == order
    assert "남자 (male, 40s, stocky)" in p
    assert "THIS SHOT CONTINUES" not in p


def test_prompt_prev_continues_and_usage():
    p = _prompt(
        prev_used=True,
        prev_usage_en="TAKE the room look. EXCLUDE the door.",
    )
    assert "PREVIOUS SHOT STILL shows this exact place." in p
    assert "THIS SHOT CONTINUES THE PREVIOUS SHOT" in p
    assert "PREVIOUS STILL USAGE (follow exactly" in p
    assert "TAKE the room look. EXCLUDE the door." in p


def test_prompt_pose_canon_injects_gravity_contract():
    """E2E 육안 피드백: 자세 정본 샷엔 사후 중력 순응 절 동반 주입."""
    p = _prompt(pose_clauses=["POSE X"])
    assert "IMMOBILE BODIES OBEY GRAVITY" in p
    assert p.index("POSE X") < p.index("IMMOBILE BODIES OBEY GRAVITY")
    # 정본 없는 샷엔 미주입
    assert "IMMOBILE BODIES" not in _prompt(pose_clauses=[])


def test_prompt_bgonly_no_people_no_pose():
    """배경 전용 + 인물 배정 **없음** = NO PEOPLE + 자세 정본 절 생략."""
    p = _prompt(bg_only=True, pose_clauses=["POSE X"], char_names=[])
    assert "NO PEOPLE IN THIS SHOT" in p
    assert "POSE X" not in p  # 배경 전용 = 자세 정본 절 생략
    assert "PEOPLE: the SHOT TEXT alone decides" not in p


def test_prompt_bgonly_does_not_override_assigned_characters():
    """★인물 배정이 있으면 bg_only 가 그것을 이기지 못한다 (2026-08-06).

    실측 근거: S3sh6 은 visible_entities 에 인물이 배정돼 있었는데
    shot_ref_classify 가 person_visible=false 로 분류해 NO PEOPLE 이 나갔고,
    세 후보 모두 사람 없이 그려진 뒤 수정이 그 자리를 서양 남자로 채웠다.
    두 신호가 모순일 때는 삭제를 강제하지 않고 **누구인지만** 못 박는다.
    """
    p = _prompt(bg_only=True, pose_clauses=["POSE X"], char_names=["남자"])
    assert "NO PEOPLE IN THIS SHOT" not in p
    assert "PEOPLE: the SHOT TEXT alone decides" in p
    assert "남자" in p
    # 배경 전용이라는 사실 자체는 유지 — 자세 정본 절은 여전히 생략한다.
    assert "POSE X" not in p


def test_no_people_clause_permits_the_hand_that_holds():
    """무인 조항이 손까지 지우지 않는다 — 소지품 인서트에서 물건이 뜬다.

    실측 근거: S42sh4 는 폰이 손도 지지물도 없이 떠 있었고, 네 판정 모델이
    모두 "닿은 지지물 0"으로 확인했다. 원인은 조항의 "any body part" 금지였다.
    """
    p = _prompt(bg_only=True, char_names=[])
    assert "NO PEOPLE IN THIS SHOT" in p
    assert "any body part" not in p          # 신체 부위 일괄 금지 제거
    assert "render the hand" in p            # 연출이 요구하는 손은 허용
    assert "no living person appears in frame" in p  # 사람 금지는 유지


def test_prompt_no_chars_fallback():
    p = _prompt(char_names=[])
    assert "No people appear unless the shot text itself says so." in p


def test_prompt_world_anchor_injected():
    p = _prompt(world_anchor=" — contemporary urban Korea, 2026")
    assert (
        "film still of the moment below — contemporary urban Korea, 2026."
        in p
    )


def test_outfit_assignments_production_shape_selects_composite():
    """Codex 2차 리뷰 B1: VE 에 outlook 없음 + assignment 는 short id 인
    production shape 에서 composite 키가 선택되는지 잠금."""
    import json

    from app.modules.pipeline.still_recipe import (
        extract_outfit_assignments,
        resolve_char_ref_key,
    )

    entity_lookup = {
        "char-uuid-1": {"short_id": "C06", "entity_type": "character"},
        "outlook-uuid-1": {"short_id": "O06", "entity_type": "outlook"},
    }
    short = {v["short_id"]: k for k, v in entity_lookup.items()}

    def norm(v):
        return v if v in entity_lookup else short.get(v)

    raw = json.dumps([
        {"variant_label": "var_1",
         "outfit_assignments": [{"character_id": "C06", "outlook_id": "O06"}]},
        {"variant_label": "var_2",
         "outfit_assignments": [{"character_id": "C06", "outlook_id": "O06"}]},
    ])
    outfit = extract_outfit_assignments(raw, norm)
    assert outfit == {"char-uuid-1": "outlook-uuid-1"}

    smap = {
        "char-uuid-1": b"base-face",
        "composite:char-uuid-1:outlook-uuid-1": b"composite-bytes",
    }
    key = resolve_char_ref_key(
        eid="char-uuid-1", sid="C06", state_sids={},
        outlook_by_eid=outfit, scene_ref_image_map=smap,
    )
    assert key == "composite:char-uuid-1:outlook-uuid-1"
    # state variant 가 있으면 composite 보다 우선
    smap["state_variant:char-uuid-1:dead"] = b"dead"
    key2 = resolve_char_ref_key(
        eid="char-uuid-1", sid="C06",
        state_sids={"C06": {"key": "state_variant:char-uuid-1:dead"}},
        outlook_by_eid=outfit, scene_ref_image_map=smap,
    )
    assert key2 == "state_variant:char-uuid-1:dead"


def test_outfit_assignments_invalid_fail_closed():
    """3차 리뷰 M3: 손상/미해결 SOT = __invalid__ (base 조용한 degrade 금지)."""
    import json

    from app.modules.pipeline.still_recipe import extract_outfit_assignments

    lookup_short = {"C06": "char-uuid", "O06": "outlook-uuid"}

    def norm(v):
        return lookup_short.get(v)

    # malformed JSON
    out = extract_outfit_assignments("{not-json", norm)
    assert "__invalid__" in out and "json_decode_error" in out["__invalid__"]
    # unknown character_id
    raw = json.dumps([
        {"outfit_assignments": [{"character_id": "C99", "outlook_id": "O06"}]}
    ])
    out = extract_outfit_assignments(raw, norm)
    assert "__invalid__" in out and "character_id" in out["__invalid__"]
    # unknown outlook_id
    raw = json.dumps([
        {"outfit_assignments": [{"character_id": "C06", "outlook_id": "O99"}]}
    ])
    out = extract_outfit_assignments(raw, norm)
    assert "__invalid__" in out and "outlook_id" in out["__invalid__"]
    # 배정 자체가 없음 = 정상 빈 dict (invalid 아님)
    assert extract_outfit_assignments(
        json.dumps([{"variant_label": "v1"}]), norm
    ) == {}
    # 4차 M2: 구조 위반 = __invalid__ (AttributeError 로 에피소드 중단 금지)
    assert "__invalid__" in extract_outfit_assignments(
        json.dumps({"not": "a list"}), norm
    )
    assert "__invalid__" in extract_outfit_assignments(
        json.dumps("just a string"), norm
    )
    assert "__invalid__" in extract_outfit_assignments(
        json.dumps(["not-a-dict-variation"]), norm
    )
    assert "__invalid__" in extract_outfit_assignments(
        json.dumps([{"outfit_assignments": ["not-a-dict"]}]), norm
    )
    assert "__invalid__" in extract_outfit_assignments(
        json.dumps([{"outfit_assignments": "not-a-list"}]), norm
    )
    # assignment 객체는 있으나 양 ID 가 빈 경우 = SOT 손상 (배정 없음 아님)
    out = extract_outfit_assignments(
        json.dumps([{"outfit_assignments": [{}]}]), norm
    )
    assert "__invalid__" in out and "empty assignment" in out["__invalid__"]


def test_outfit_assignments_conflict_fail_closed():
    import json

    from app.modules.pipeline.still_recipe import extract_outfit_assignments

    raw = json.dumps([
        {"outfit_assignments": [{"character_id": "C06", "outlook_id": "O06"}]},
        {"outfit_assignments": [{"character_id": "C06", "outlook_id": "O07"}]},
    ])
    lookup = {"c": {"short_id": "C06"}, "o6": {"short_id": "O06"},
              "o7": {"short_id": "O07"}}
    short = {v["short_id"]: k for k, v in lookup.items()}
    out = extract_outfit_assignments(raw, lambda v: short.get(v))
    assert "__conflict__" in out  # 임의 first 금지


def test_partition_lineage_excludes_plate_conti_from_reference():
    """Codex 2차 리뷰 H5: plate/conti/prev 는 input 채널에만 —
    reference(entity 구조 lineage)에 섞이지 않는다."""
    from app.modules.pipeline.still_recipe import partition_lineage_ids

    refs = [
        {"asset_id": "a-plate", "role": "location_plate"},
        {"asset_id": "a-conti", "role": "conti_light"},
        {"asset_id": "a-prev", "role": "prev_still"},
        {"asset_id": "a-char", "role": "character_ref"},
        {"asset_id": "a-prop", "role": "prop_ref"},
    ]
    input_ids, entity_ids = partition_lineage_ids(refs)
    assert input_ids == ["a-plate", "a-conti", "a-prev", "a-char", "a-prop"]
    assert entity_ids == ["a-char", "a-prop"]


def test_recipe_service_fail_closed_on_missing_upstream(tmp_path):
    """Codex 1차 리뷰 BLOCKING-1: 필수 상류 CP 누락/비완료 = 조용한 degrade
    금지 — 명시 에러."""
    import json

    import pytest as _pytest

    from app.core.errors import AppError
    from app.services.still_recipe_service import _require_cp_data

    with _pytest.raises(AppError):
        _require_cp_data(str(tmp_path), "p1", "ep1", "shot_ref_classify")

    cp_dir = tmp_path / "p1" / "checkpoints" / "episodes" / "ep1" / "shot_ref_classify"
    cp_dir.mkdir(parents=True)
    (cp_dir / "manifest.json").write_text(
        json.dumps({"status": "running", "data": {"shots": {}}}),
        encoding="utf-8",
    )
    with _pytest.raises(AppError):
        _require_cp_data(str(tmp_path), "p1", "ep1", "shot_ref_classify")

    (cp_dir / "manifest.json").write_text(
        json.dumps({"status": "completed", "data": {"shots": {"S1sh1": {}}}}),
        encoding="utf-8",
    )
    data = _require_cp_data(str(tmp_path), "p1", "ep1", "shot_ref_classify")
    assert data["shots"]


def test_scene_image_step_config_hash_stamped_only_when_on(monkeypatch):
    """레시피 OFF = base hash byte-identical, v1 = 별도 스탬프.

    ★zoom 축을 **고정한다** (2026-08-27, #92). zoom bbox 모델 스탬프가
     조기 return 앞에 있어서(그 경로가 zoom 이 실제로 도는 자리다),
     플래그를 안 고정하면 이 시험이 재려던 축과 무관한 이유로 빨강이
     된다. 계약은 그대로다 — 재는 축만 고정한다.
    """
    from app.core.config import settings
    from app.core.step_runner import compute_config_hash
    from app.core.steps.image_steps import SceneImagePipelineStep

    monkeypatch.setattr(
        settings, "zoom_continuity_anchor_enabled", False, raising=False)

    step = object.__new__(SceneImagePipelineStep)  # __init__ 우회 (순수 hash 검증)
    step.project_config = {"some": "config"}

    # 환경 핀 — 운영 .env(카나리아 표적 씬·판정 플래그)가 켜져 있어도 이
    # 시험의 "전부 OFF = base" 전제가 흔들리지 않게 명시로 끈다.
    monkeypatch.setattr(settings, "scene_image_target_scenes", "")
    monkeypatch.setattr(settings, "multiroll_qk_judge_enabled", False)
    monkeypatch.setattr(settings, "still_identity_ref_role_enabled", False)
    monkeypatch.setattr(settings, "still_recipe_mode", "off")
    assert (
        SceneImagePipelineStep._config_hash(step)
        == compute_config_hash({"some": "config"})
    )

    monkeypatch.setattr(settings, "still_recipe_mode", "v1")
    stamped = SceneImagePipelineStep._config_hash(step)
    assert stamped != compute_config_hash({"some": "config"})


SKETCH = Path("/tmp/lane_sketch.png")
SEED = Path("/tmp/seed_sel.png")


def test_refs_lane_sketch_seed_prev_entities():
    """Stage D lane 샷: [스케치+seed]+prev+엔티티 — 플레이트/콘티 0,
    prev 가 있어도 스케치 유지(설계 v2)."""
    import pytest

    refs = build_still_refs(
        bg_only=False, plate=PLATE, conti=CONTI, prev_sel=PREV,
        char_refs=[("남자", b"c")], prop_refs=[("사진", b"p")],
        lane_sketch=SKETCH, lane_seed=SEED, prompt_version="2",
    )
    labels = [r[0] for r in refs]
    assert labels[0].startswith("STORYBOARD SKETCH")
    assert refs[0][1] is SKETCH
    assert labels[1].startswith("STRUCTURE LOOK")
    assert labels[2].startswith("PREVIOUS SHOT STILL")
    assert "CHARACTER REFERENCE — 남자" in labels[3]
    assert "PROP REFERENCE — 사진" in labels[4]
    assert not any(l.startswith("LOCATION PHOTOGRAPH") for l in labels)
    assert not any(l.startswith("LAYOUT SKETCH") for l in labels)
    # 레인1(seed 없음): 스케치+엔티티만
    refs1 = build_still_refs(
        bg_only=False, plate=None, conti=None, prev_sel=None,
        char_refs=[("남자", b"c")], prop_refs=[],
        lane_sketch=SKETCH, prompt_version="2",
    )
    labels1 = [r[0] for r in refs1]
    assert labels1[0].startswith("STORYBOARD SKETCH")
    assert not any(l.startswith("STRUCTURE LOOK") for l in labels1)
    # bgonly lane: 스케치+seed 만 (구조물 룩 유지, 인물 유도 참조 0)
    refs_bg = build_still_refs(
        bg_only=True, plate=None, conti=None, prev_sel=PREV,
        char_refs=[("남자", b"c")], prop_refs=[],
        lane_sketch=SKETCH, lane_seed=SEED, prompt_version="2",
    )
    assert [r[0].split(" — ")[0] for r in refs_bg] == [
        "STORYBOARD SKETCH", "STRUCTURE LOOK"]
    # v1 팩에 lane 요청 = fail-closed
    with pytest.raises(ValueError):
        build_still_refs(
            bg_only=False, plate=None, conti=None, prev_sel=None,
            char_refs=[], prop_refs=[], lane_sketch=SKETCH,
        )
    # 기존 조립(v1) byte-identical — lane 인자 미지정
    base = build_still_refs(
        bg_only=False, plate=PLATE, conti=CONTI, prev_sel=None,
        char_refs=[], prop_refs=[],
    )
    assert [r[0].split(" — ")[0] for r in base] == [
        "LOCATION PHOTOGRAPH", "LAYOUT SKETCH"]


def test_refs_lane_direct_seed_seed_only():
    """E2E6 ③ direct_seed(seed_only): 스케치 없이 [seed+엔티티],
    bg_only=[seed]만. prev_only 는 caller 가 non-lane prev 경로로 호출."""
    from app.modules.pipeline.still_recipe import LANE_PROMPT_VERSION

    SEED = Path("/tmp/seed.png")
    refs = build_still_refs(
        bg_only=False, plate=None, conti=None, prev_sel=None,
        char_refs=[("남자", b"c")], prop_refs=[],
        lane_sketch=None, lane_seed=SEED, lane_direct_seed=True,
        prompt_version=LANE_PROMPT_VERSION,
    )
    labels = [r[0] for r in refs]
    assert labels[0].startswith("STRUCTURE LOOK")
    assert not any("STORYBOARD" in l for l in labels)
    assert "CHARACTER REFERENCE — 남자" in labels[1]

    refs_bg = build_still_refs(
        bg_only=True, plate=None, conti=None, prev_sel=None,
        char_refs=[("남자", b"c")], prop_refs=[],
        lane_sketch=None, lane_seed=SEED, lane_direct_seed=True,
        prompt_version=LANE_PROMPT_VERSION,
    )
    assert len(refs_bg) == 1
    assert refs_bg[0][0].startswith("STRUCTURE LOOK")


def test_prompt_lane_location_authority():
    """Codex Stage D HIGH-3: lane 샷 프롬프트가 존재하지 않는 LOCATION
    PHOTOGRAPH 를 참조하지 않는다 — 실재 참조에 맞는 authority 분리."""
    import pytest

    def _p(**over):
        kw = dict(
            shot_desc="남자가 문을 연다",
            place_text="EXT. SAMPLE",
            time_of_day_en="day",
            bg_only=False,
            prev_used=False,
        )
        kw.update(over)
        return build_still_prompt(**kw)

    # lane1(사진 참조 0): 스케치=배치 SOT + 텍스트 저작 명시
    p1 = _p(lane_ref_mode="sketch", prompt_version="3")
    assert "LOCATION PHOTOGRAPH" not in p1
    assert "STORYBOARD SKETCH" in p1
    assert "No location photograph is attached" in p1
    # lane2: STRUCTURE LOOK 사진=구조물 SOT
    p2 = _p(lane_ref_mode="seed", prompt_version="3")
    assert "LOCATION PHOTOGRAPH" not in p2
    assert "STRUCTURE LOOK" in p2
    # lane + prev: prev 문장이 location authority (기존 계약)
    p3 = _p(lane_ref_mode="seed", prev_used=True, prompt_version="3")
    assert "PREVIOUS SHOT STILL shows this exact place" in p3
    assert "STRUCTURE LOOK photograph shows" not in p3
    # 비 lane 기존 조립 byte-identical (v1)
    base_old = _p()
    assert "the attached LOCATION PHOTOGRAPH shows the exact spot." in (
        base_old)
    # v1/v2 에 lane_ref_mode = fail-closed
    with pytest.raises(ValueError):
        _p(lane_ref_mode="sketch")
    with pytest.raises(ValueError):
        _p(lane_ref_mode="sketch", prompt_version="2")
    with pytest.raises(ValueError):
        _p(lane_ref_mode="teleport", prompt_version="3")


def test_scene_pipeline_hash_stamps_lane_pack_on_only(monkeypatch):
    """Codex Stage D HIGH-4: SceneImagePipelineStep hash — lane ON 시
    flag+lane 팩 스탬프, OFF 는 기존 값 byte-identical (clean-skip 차단)."""
    from app.core.config import settings
    from app.core.steps.image_steps import SceneImagePipelineStep

    step = object.__new__(SceneImagePipelineStep)
    step.project_config = {"some": "config"}

    # 환경 핀 — 운영 .env(카나리아 표적 씬·판정 플래그) 무관하게 lane
    # 스탬프만 갈라 보이도록 나머지 조건부 스탬프를 명시로 끈다.
    monkeypatch.setattr(settings, "scene_image_target_scenes", "")
    monkeypatch.setattr(settings, "multiroll_qk_judge_enabled", False)
    monkeypatch.setattr(settings, "still_identity_ref_role_enabled", False)
    monkeypatch.setattr(settings, "still_recipe_mode", "v1")
    monkeypatch.setattr(
        settings, "outdoor_lane_pipe_enabled", False, raising=False)
    monkeypatch.setattr(
        settings, "outdoor_lane_plan_enabled", False, raising=False)
    h_off = SceneImagePipelineStep._config_hash(step)

    monkeypatch.setattr(
        settings, "outdoor_lane_pipe_enabled", True, raising=False)
    monkeypatch.setattr(
        settings, "outdoor_lane_plan_enabled", True, raising=False)
    h_on = SceneImagePipelineStep._config_hash(step)
    assert h_on != h_off
    # recipe off 면 lane 무관 base 그대로 (기존 계약 보존)
    # ★zoom 축을 고정한다 (2026-08-27, #92) — zoom bbox 모델 스탬프가
    #  조기 return 앞에 있어서, 안 고정하면 이 시험이 재려던 축과 무관한
    #  이유로 빨강이 된다. 계약은 그대로다.
    monkeypatch.setattr(
        settings, "zoom_continuity_anchor_enabled", False, raising=False)
    monkeypatch.setattr(settings, "still_recipe_mode", "off")
    from app.core.step_runner import compute_config_hash

    assert SceneImagePipelineStep._config_hash(step) == (
        compute_config_hash({"some": "config"}))


def test_scene_pipeline_hash_stamps_lane_bgfirst_pack(monkeypatch):
    """2026-07-27 리뷰 지적: lane 체인 분기가 불리언만 찍으면 lane 전용
    팩(bg_fill 스템+장소·world 사실 절) 상향이 hash 에 잡히지 않는다.

    스텝은 CP 가 clean 이면 **실행 여부 자체를 먼저** 접고 넘어가므로
    (step_runner 의 clean-skip), 이미 still_recipe 를 완주한 프로젝트는
    팩을 올려도 새 계약을 영영 못 본다 — 실행 이후에나 작동하는
    extra_fingerprint 로는 못 막는 구멍이다. 형제 분기(bgfirst_full_*)와
    동형으로 팩·계약 버전을 병행 스탬프한다.
    """
    from app.core.config import settings
    from app.core.steps.image_steps import SceneImagePipelineStep
    from app.modules.pipeline import still_recipe as sr_mod

    step = object.__new__(SceneImagePipelineStep)
    step.project_config = {"some": "config"}

    monkeypatch.setattr(settings, "still_recipe_mode", "v1")
    for _on in ("still_bgfirst_enabled", "still_bgfirst_full_enabled"):
        monkeypatch.setattr(settings, _on, True, raising=False)
    monkeypatch.setattr(
        settings, "still_lane_prev_bgfirst_enabled", False, raising=False)

    # ★2026-08-27 (감사 P0-A, Codex 재리뷰): **두 축을 먼저 분리한다.**
    #  `CHAIN_BG_LOCATION_PROMPT_VERSION` 과 `BGFIRST_LANE_PROMPT_VERSION`
    #  이 지금 **우연히 같은 값("12")** 이라, chain_bg 스탬프가 bgfirst
    #  범위로 올라간 뒤로는 lane 팩 map 만 바꿔도 chain_bg 쪽이 함께
    #  움직여 이 시험이 「lane 팩이 접혔다」로 오독한다. 이 파일의 형제
    #  시험(`…stamps_chain_bg_location_pack`) 주석이 경고한 바로 그
    #  우연이다 — chain_bg 를 다른 값으로 고정해 lane 축만 잰다.
    monkeypatch.setattr(
        sr_mod, "CHAIN_BG_LOCATION_PROMPT_VERSION", "1", raising=False)
    h_off = SceneImagePipelineStep._config_hash(step)

    # OFF 경로는 lane 팩을 아예 소비하지 않는다 (기존 조합 보존)
    with monkeypatch.context() as m:
        m.setitem(sr_mod.PROMPT_VERSION_MAP,
                  sr_mod.BGFIRST_LANE_PROMPT_VERSION, "SAMPLE_FIXTURE_PACK")
        assert SceneImagePipelineStep._config_hash(step) == h_off

    monkeypatch.setattr(
        settings, "still_lane_prev_bgfirst_enabled", True, raising=False)
    h_on = SceneImagePipelineStep._config_hash(step)
    assert h_on != h_off

    # 핵심: 플래그를 켠 채 팩만 재발행해도 hash 가 움직인다
    with monkeypatch.context() as m:
        m.setitem(sr_mod.PROMPT_VERSION_MAP,
                  sr_mod.BGFIRST_LANE_PROMPT_VERSION, "SAMPLE_FIXTURE_PACK")
        assert SceneImagePipelineStep._config_hash(step) != h_on
    # 계약 버전 상향도 마찬가지 (팩 디렉토리는 그대로인 전환)
    with monkeypatch.context() as m:
        m.setattr(sr_mod, "BGFIRST_LANE_CONTRACT_VERSION",
                  "SAMPLE_FIXTURE_CONTRACT")
        assert SceneImagePipelineStep._config_hash(step) != h_on


def test_scene_pipeline_hash_stamps_chain_bg_location_pack(monkeypatch):
    """2026-07-27 리뷰 I-4: 체인 Step2 LOCATION 스템 selector 는 샷 팩과
    **의도적으로 분리된** 독립 축이다 — 지금 값이 우연히
    BGFIRST_LANE_PROMPT_VERSION 과 같아 같은 디렉토리로 풀릴 뿐,
    lane 팩 스탬프가 이 스템을 덮어 준다는 보장이 없다.

    이 스템만 새 팩에 발행하고 selector 를 올리면(분리의 존재 이유가
    바로 그것이다) 이미 완주한 프로젝트는 config_hash 가 그대로라
    clean-skip 으로 새 계약을 영영 못 본다 — 이 프로젝트가 이미 겪은
    dormant 팩 결함과 같은 계열. selector 를 hash 에 명시 스탬프한다.
    """
    from app.core.config import settings
    from app.core.steps.image_steps import SceneImagePipelineStep
    from app.modules.pipeline import still_recipe as sr_mod

    step = object.__new__(SceneImagePipelineStep)
    step.project_config = {"some": "config"}

    monkeypatch.setattr(settings, "still_recipe_mode", "v1")
    monkeypatch.setattr(
        settings, "still_lane_prev_bgfirst_enabled", False, raising=False)

    # ★2026-08-27 (감사 P0-A, Codex 재리뷰): **OFF 경계가 바뀌었다.**
    #  종전 전제는 「lane_prev 가 꺼지면 이 스템을 안 쓴다」였는데, 이번
    #  판이 서비스 재조립 조건을 `bgfirst_used` 로 넓혀 **ordinary
    #  bgfirst 도 lane_prev 없이 이 스템을 소비**한다. 그래서 스탬프를
    #  bgfirst 범위로 올렸고, 진짜 byte-identical 경계는
    #  `still_bgfirst_enabled=False` 다.
    for _off in ("still_bgfirst_enabled", "still_bgfirst_full_enabled"):
        monkeypatch.setattr(settings, _off, False, raising=False)
    h_off = SceneImagePipelineStep._config_hash(step)

    # bgfirst 자체가 꺼지면 이 스템을 아무도 안 읽는다 (byte-identical)
    with monkeypatch.context() as m:
        m.setattr(sr_mod, "CHAIN_BG_LOCATION_PROMPT_VERSION", "7")
        assert SceneImagePipelineStep._config_hash(step) == h_off

    for _on in ("still_bgfirst_enabled", "still_bgfirst_full_enabled"):
        monkeypatch.setattr(settings, _on, True, raising=False)
    h_on = SceneImagePipelineStep._config_hash(step)

    # ★lane_prev 가 **꺼져 있어도** 이 selector 만 올리면 hash 가 움직인다
    #  — ordinary bgfirst 가 이 스템을 실제로 소비하기 때문이다.
    with monkeypatch.context() as m:
        m.setattr(sr_mod, "CHAIN_BG_LOCATION_PROMPT_VERSION", "7")
        assert SceneImagePipelineStep._config_hash(step) != h_on

    monkeypatch.setattr(
        settings, "still_lane_prev_bgfirst_enabled", True, raising=False)
    h_on = SceneImagePipelineStep._config_hash(step)

    # 핵심: lane 팩·계약을 그대로 둔 채 **이 selector 만** 올려도 hash 가
    # 움직인다 (= 우연한 동일 값에 기대지 않는다)
    with monkeypatch.context() as m:
        m.setattr(sr_mod, "CHAIN_BG_LOCATION_PROMPT_VERSION", "7")
        assert sr_mod.BGFIRST_LANE_PROMPT_VERSION == "12"
        assert SceneImagePipelineStep._config_hash(step) != h_on


def test_chain_bg_location_pack_in_run_fingerprint_source_contract():
    """런 지문(extra_fingerprint)에도 같은 스탬프 — config_hash 는 스텝
    재실행 여부, extra_fingerprint 는 샷 산출 재사용 여부를 가른다."""
    import inspect

    from app.services import still_recipe_service

    src = inspect.getsource(still_recipe_service)
    assert 'extra_fingerprint["chain_bg_location_pack"]' in src


# ── R1 (2026-07-16): 콘티형 샷 플레이트 권위 소비 계약 ────────────────────


def test_plate_authority_entry_wins_and_matches_conti(tmp_path):
    from app.modules.pipeline.still_recipe import (
        resolve_conti_plate_authority,
    )

    chosen = tmp_path / "L04B02.png"
    chosen.write_bytes(b"SAMPLE")
    plate, rec = resolve_conti_plate_authority(
        authority_entry={
            "plate_path": str(chosen), "record": {"chosen": "L04B02"},
        },
        conti_plate_path=str(chosen),
        current_plate=tmp_path / "L04B01.png",
    )
    assert plate == chosen
    assert rec == {"chosen": "L04B02"}


def test_plate_authority_missing_file_fail_closed(tmp_path):
    import pytest

    from app.modules.pipeline.still_recipe import (
        resolve_conti_plate_authority,
    )

    with pytest.raises(ValueError, match="결손"):
        resolve_conti_plate_authority(
            authority_entry={"plate_path": str(tmp_path / "gone.png")},
            conti_plate_path=None,
            current_plate=None,
        )


def test_plate_authority_conti_mismatch_fail_closed(tmp_path):
    import pytest

    from app.modules.pipeline.still_recipe import (
        resolve_conti_plate_authority,
    )

    chosen = tmp_path / "L04B02.png"
    chosen.write_bytes(b"SAMPLE")
    with pytest.raises(ValueError, match="불일치"):
        resolve_conti_plate_authority(
            authority_entry={"plate_path": str(chosen)},
            conti_plate_path=str(tmp_path / "L04B01.png"),
            current_plate=None,
        )


def test_plate_authority_absent_keeps_consistent_assignment(tmp_path):
    from app.modules.pipeline.still_recipe import (
        resolve_conti_plate_authority,
    )

    assigned = tmp_path / "L04B01.png"
    assigned.write_bytes(b"SAMPLE")
    plate, rec = resolve_conti_plate_authority(
        authority_entry=None,
        conti_plate_path=str(assigned),
        current_plate=assigned,
    )
    assert plate == assigned
    assert rec is None
    # 콘티 기록 없음(콘티 생략 샷)도 배정 유지
    plate2, _ = resolve_conti_plate_authority(
        authority_entry=None, conti_plate_path=None, current_plate=assigned,
    )
    assert plate2 == assigned


def test_plate_authority_absent_conti_divergence_fail_closed(tmp_path):
    import pytest

    from app.modules.pipeline.still_recipe import (
        resolve_conti_plate_authority,
    )

    old_plate = tmp_path / "old.png"
    old_plate.write_bytes(b"SAMPLE")
    with pytest.raises(ValueError, match="불일치"):
        resolve_conti_plate_authority(
            authority_entry=None,
            conti_plate_path=str(old_plate),
            current_plate=tmp_path / "new.png",
        )


# ── 2026-07-16 복잡 구조물=A/B: v4 structure_seed 조립·관할 계약 ─────────


def _touch2(tmp_path, name):
    p = tmp_path / name
    p.write_bytes(b"SAMPLE")
    return p


def test_refs_v4_plate_and_seed_coexist_order(tmp_path):
    from app.modules.pipeline.still_recipe import build_still_refs

    plate = _touch2(tmp_path, "plate.png")
    seed = _touch2(tmp_path, "seed.png")
    conti = _touch2(tmp_path, "conti.png")
    refs = build_still_refs(
        bg_only=False, plate=plate, conti=conti, prev_sel=None,
        char_refs=[("SAMPLE_CHAR", "char.png")], prop_refs=[],
        structure_seed=seed, prompt_version="4",
    )
    labels = [lab for lab, _ in refs]
    srcs = [src for _, src in refs]
    assert srcs[:3] == [plate, seed, conti]
    assert labels[0].startswith("LOCATION PHOTOGRAPH")
    assert labels[1].startswith("STRUCTURE LOOK")
    assert labels[2].startswith("LAYOUT SKETCH")


def test_refs_v4_bg_only_includes_seed(tmp_path):
    from app.modules.pipeline.still_recipe import build_still_refs

    plate = _touch2(tmp_path, "plate.png")
    seed = _touch2(tmp_path, "seed.png")
    refs = build_still_refs(
        bg_only=True, plate=plate, conti=None, prev_sel=None,
        char_refs=[("SAMPLE_CHAR", "char.png")], prop_refs=[],
        structure_seed=seed, prompt_version="4",
    )
    # bg_only 조기 return 전에 seed 포함 (Codex R6) — 엔티티는 제외
    assert [src for _, src in refs] == [plate, seed]


def test_refs_structure_seed_guards(tmp_path):
    import pytest

    from app.modules.pipeline.still_recipe import build_still_refs

    seed = _touch2(tmp_path, "seed.png")
    prev = _touch2(tmp_path, "prev.png")
    # v4 밖 팩 = ValueError
    with pytest.raises(ValueError, match="v4 전용"):
        build_still_refs(
            bg_only=False, plate=None, conti=None, prev_sel=None,
            char_refs=[], prop_refs=[], structure_seed=seed,
            prompt_version="1",
        )
    # prev 샷 부착 금지 (권위 이중화)
    with pytest.raises(ValueError, match="prev"):
        build_still_refs(
            bg_only=False, plate=None, conti=None, prev_sel=prev,
            char_refs=[], prop_refs=[], structure_seed=seed,
            prompt_version="4",
        )
    # lane 참조와 상호 배타
    with pytest.raises(ValueError, match="배타"):
        build_still_refs(
            bg_only=False, plate=None, conti=None, prev_sel=None,
            char_refs=[], prop_refs=[], structure_seed=seed,
            lane_sketch=_touch2(tmp_path, "sk.png"),
            prompt_version="4",
        )


def test_ab_branch_refs_differ_only_by_conti(tmp_path):
    from app.modules.pipeline.still_recipe import build_ab_branch_refs

    plate = _touch2(tmp_path, "plate.png")
    seed = _touch2(tmp_path, "seed.png")
    conti = _touch2(tmp_path, "conti.png")
    refs_a, refs_b = build_ab_branch_refs(
        plate=plate, conti=conti,
        char_refs=[("SAMPLE_CHAR", "c.png")],
        prop_refs=[("SAMPLE_PROP", "p.png")],
        structure_seed=seed, prompt_version="4",
    )
    # A=B+콘티 — 콘티 항목 제거 시 완전 동일 (라벨·순서·seed 포함)
    assert [r for r in refs_a if r[1] != conti] == refs_b
    assert any(src == seed for _, src in refs_b)  # B 에도 seed


def test_prompt_v4_structure_look_clause():
    import pytest

    from app.modules.pipeline.still_recipe import build_still_prompt

    out = build_still_prompt(
        shot_desc="SAMPLE", place_text="SAMPLE place.",
        time_of_day_en="night", bg_only=False, prev_used=False,
        char_names=["SAMPLE_CHAR"],
        structure_seed_attached=True, prompt_version="4",
    )
    assert "STRUCTURE LOOK AUTHORITY" in out
    # 관할: 구조물=seed 우선 / 주변·시간·조명=플레이트 우선
    assert "STRUCTURE LOOK photo wins" in out
    assert "LOCATION PHOTOGRAPH wins" in out
    # v1 팩에서는 구조 절 사용 불가
    with pytest.raises(ValueError, match="v4 전용"):
        build_still_prompt(
            shot_desc="S", place_text="P", time_of_day_en="",
            bg_only=False, prev_used=False,
            structure_seed_attached=True, prompt_version="1",
        )
    # lane_ref_mode 와 동시 사용 불가 (v4=비 lane 팩 가드가 선행 —
    # 어느 가드든 fail-closed 면 계약 성립)
    with pytest.raises(ValueError, match="lane|배타"):
        build_still_prompt(
            shot_desc="S", place_text="P", time_of_day_en="",
            bg_only=False, prev_used=False, lane_ref_mode="sketch",
            structure_seed_attached=True, prompt_version="4",
        )


def test_prompt_v1_assembly_unchanged_without_seed():
    """v1 조립 byte-identical 잠금 — structure_seed 미사용 시 신규 인자
    default 가 기존 출력에 영향 0."""
    from app.modules.pipeline.still_recipe import build_still_prompt

    kw = dict(
        shot_desc="SAMPLE", place_text="SAMPLE place.",
        time_of_day_en="day", bg_only=False, prev_used=False,
        char_names=["SAMPLE_CHAR"], prompt_version="1",
    )
    assert build_still_prompt(**kw) == build_still_prompt(
        **kw, structure_seed_attached=False)
    assert "STRUCTURE LOOK AUTHORITY" not in build_still_prompt(**kw)


def test_plate_authority_absent_conti_is_sot_when_mapping_missing(tmp_path):
    """배치 리뷰 BLOCKING-2: 권위·현재 배정이 없어도 콘티가 그려진
    플레이트가 SOT — (None, None) 조용한 통과 금지."""
    import pytest

    from app.modules.pipeline.still_recipe import (
        resolve_conti_plate_authority,
    )

    conti_plate = tmp_path / "L04B01.png"
    conti_plate.write_bytes(b"SAMPLE")
    plate, rec = resolve_conti_plate_authority(
        authority_entry=None,
        conti_plate_path=str(conti_plate),
        current_plate=None,
    )
    assert plate == conti_plate
    assert rec is None
    # 콘티 플레이트 파일 결손 = fail-closed
    with pytest.raises(ValueError, match="결손"):
        resolve_conti_plate_authority(
            authority_entry=None,
            conti_plate_path=str(tmp_path / "gone.png"),
            current_plate=None,
        )


def test_ab_branch_refs_require_plate_for_complex(tmp_path):
    """배치 리뷰 BLOCKING-2: 복잡 구조물 A/B 에서 plate=None 이면
    seed-only 비교 — 조립 단계에서 차단."""
    import pytest

    from app.modules.pipeline.still_recipe import build_ab_branch_refs

    seed = tmp_path / "seed.png"
    seed.write_bytes(b"SAMPLE")
    conti = tmp_path / "conti.png"
    conti.write_bytes(b"SAMPLE")
    with pytest.raises(ValueError, match="플레이트 필수"):
        build_ab_branch_refs(
            plate=None, conti=conti, char_refs=[], prop_refs=[],
            structure_seed=seed, prompt_version="4",
        )


def test_plate_flow_mode_truth_table():
    """재리뷰 HIGH-2: 스틸 플레이트 처리 모드 caller 조건 잠금 —
    flag ON 콘티형 샷은 plate mapping 이 비어도 reconcile,
    complex 는 flag 무관 reconcile, flag OFF 일반 샷=legacy(none)."""
    from app.modules.pipeline.still_recipe import plate_flow_mode

    base = dict(bg_only=False, prev_used=False, lane_used=False,
                is_map_plate=False, plate_present=True)
    # flag ON 콘티형 — plate 유무와 무관하게 reconcile
    assert plate_flow_mode(
        plate_select_on=True, complex_ab=False, **base) == "reconcile"
    assert plate_flow_mode(
        plate_select_on=True, complex_ab=False,
        **{**base, "plate_present": False}) == "reconcile"
    # complex — flag OFF 여도 reconcile (mapping 소실 포함)
    assert plate_flow_mode(
        plate_select_on=False, complex_ab=True,
        **{**base, "plate_present": False}) == "reconcile"
    # flag OFF 일반 샷 = legacy 유지
    assert plate_flow_mode(
        plate_select_on=False, complex_ab=False, **base) == "none"
    # bg_only = flag ON + plate 실재 시에만 즉석 판정
    assert plate_flow_mode(
        plate_select_on=True, complex_ab=False,
        **{**base, "bg_only": True}) == "bg_late_select"
    assert plate_flow_mode(
        plate_select_on=True, complex_ab=False,
        **{**base, "bg_only": True, "plate_present": False}) == "none"
    assert plate_flow_mode(
        plate_select_on=False, complex_ab=True,
        **{**base, "bg_only": True}) == "none"
    # prev/lane/맵 플레이트 샷 = 비대상
    assert plate_flow_mode(
        plate_select_on=True, complex_ab=True,
        **{**base, "prev_used": True}) == "none"
    assert plate_flow_mode(
        plate_select_on=True, complex_ab=False,
        **{**base, "lane_used": True}) == "none"
    assert plate_flow_mode(
        plate_select_on=True, complex_ab=False,
        **{**base, "is_map_plate": True}) == "none"


# ── seed-bg 승격 (2026-07-17 Codex 합의) — v5 단일 권위 조립 ─────────────


def test_refs_v5_seed_bg_single_authority(tmp_path):
    """(g) seed-bg 는 LOCATION 자리에 정확히 1회 — plate/STRUCTURE LOOK
    동시 부착 금지 (동일 seed 중복 ref 0)."""
    import pytest

    from app.modules.pipeline.still_recipe import build_still_refs

    seed = _touch2(tmp_path, "seed.png")
    conti = _touch2(tmp_path, "conti.png")
    refs = build_still_refs(
        bg_only=False, plate=None, conti=conti, prev_sel=None,
        char_refs=[("SAMPLE_CHAR", "c.png")], prop_refs=[],
        seed_bg=seed, prompt_version="5",
    )
    srcs = [s for _, s in refs]
    assert srcs[:2] == [seed, conti]
    assert srcs.count(seed) == 1
    assert refs[0][0].startswith("LOCATION STRUCTURE PHOTOGRAPH")
    # bg_only = seed-bg 만
    refs_bg = build_still_refs(
        bg_only=True, plate=None, conti=None, prev_sel=None,
        char_refs=[("SAMPLE_CHAR", "c.png")], prop_refs=[],
        seed_bg=seed, prompt_version="5",
    )
    assert [s for _, s in refs_bg] == [seed]
    # plate/structure_seed 와 동시 부착 = ValueError
    with pytest.raises(ValueError, match="단일 권위"):
        build_still_refs(
            bg_only=False, plate=_touch2(tmp_path, "p.png"), conti=None,
            prev_sel=None, char_refs=[], prop_refs=[],
            seed_bg=seed, prompt_version="5",
        )
    with pytest.raises(ValueError, match="단일 권위"):
        build_still_refs(
            bg_only=False, plate=None, conti=None, prev_sel=None,
            char_refs=[], prop_refs=[],
            seed_bg=seed, structure_seed=seed, prompt_version="5",
        )
    # prev 샷 부착 금지 / v5 밖 팩 금지
    with pytest.raises(ValueError, match="prev"):
        build_still_refs(
            bg_only=False, plate=None, conti=None,
            prev_sel=_touch2(tmp_path, "prev.png"),
            char_refs=[], prop_refs=[], seed_bg=seed, prompt_version="5",
        )
    with pytest.raises(ValueError, match="v5 전용"):
        build_still_refs(
            bg_only=False, plate=None, conti=None, prev_sel=None,
            char_refs=[], prop_refs=[], seed_bg=seed, prompt_version="4",
        )


def test_ab_branch_refs_seed_bg_differ_only_by_conti(tmp_path):
    """(a) seed-bg A/B: 두 브랜치는 콘티 1장만 다름 + LOCATION 권위 필수."""
    import pytest

    from app.modules.pipeline.still_recipe import build_ab_branch_refs

    seed = _touch2(tmp_path, "seed.png")
    conti = _touch2(tmp_path, "conti.png")
    refs_a, refs_b = build_ab_branch_refs(
        plate=None, conti=conti, char_refs=[("SAMPLE_CHAR", "c.png")],
        prop_refs=[], seed_bg=seed, prompt_version="5",
    )
    assert [r for r in refs_a if r[1] != conti] == refs_b
    assert [s for _, s in refs_b].count(seed) == 1
    # plate 도 seed_bg 도 없으면 ValueError
    with pytest.raises(ValueError, match="LOCATION 권위"):
        build_ab_branch_refs(
            plate=None, conti=conti, char_refs=[], prop_refs=[],
            prompt_version="1",
        )


def test_prompt_v5_seed_bg_location_lock():
    """(조건 3) v5 seed-bg location lock — 단일 권위 절, tie-break 절
    비적용, 타 모드와 상호 배타."""
    import pytest

    from app.modules.pipeline.still_recipe import build_still_prompt

    out = build_still_prompt(
        shot_desc="SAMPLE", place_text="SAMPLE place.",
        time_of_day_en="day", bg_only=False, prev_used=False,
        char_names=["SAMPLE_CHAR"], seed_bg_mode=True, prompt_version="5",
    )
    assert "LOCATION STRUCTURE PHOTOGRAPH is the single authority" in out
    assert "STRUCTURE LOOK AUTHORITY" not in out  # tie-break 절 비적용
    with pytest.raises(ValueError, match="v5 전용"):
        build_still_prompt(
            shot_desc="S", place_text="P", time_of_day_en="",
            bg_only=False, prev_used=False, seed_bg_mode=True,
            prompt_version="1",
        )
    with pytest.raises(ValueError, match="상호 배타"):
        build_still_prompt(
            shot_desc="S", place_text="P", time_of_day_en="",
            bg_only=False, prev_used=False, seed_bg_mode=True,
            structure_seed_attached=True, prompt_version="5",
        )


# ── run_branch_select — still-variants kwargs pass-through (Task3) ────


def _branch_kwargs(tmp_path, run_fn):
    return dict(
        branch_tag="still_t", branch_refs=[("R", b"x")], rec_key="t",
        out_stem=tmp_path / "t", prompt="P",
        records=type("R", (), {
            "data": {}, "save": lambda self: None})(),
        make_gen_fn=lambda bt: (lambda *a, **k: None),
        judge_fn=lambda *a, **k: {}, critique_fn=lambda *a, **k: {},
        roll_count=3, critique_enabled=False,
        judge_texts={"fix_head": "", "fix_tail": "", "fix_label": ""},
        extra_fingerprint={}, run_fn=run_fn,
    )


def test_run_branch_select_default_omits_new_kwargs(tmp_path):
    """OFF 경로=신규 kwargs 자체 미전달 (Codex 설계 리뷰 R6/R7 조건)."""
    from app.modules.pipeline.still_recipe import run_branch_select

    seen = {}

    def run_fn(**kw):
        seen.update(kw)
        return tmp_path / "t_sel.png", {}

    run_branch_select(**_branch_kwargs(tmp_path, run_fn))
    for key in ("roll_prompts", "roll_refs", "parallel_rolls", "judge_flip",
                "flip_priority", "critique_selected_prompt_only",
                "judge_prompt_header",
                # 참조 선별(2026-08-19) — 꺼짐이면 세 키 다 전달 자체를
                # 생략해야 기존 호출·지문이 1비트도 안 움직인다.
                "fix_ref_gate", "fix_missing_head", "fix_missing_tail"):
        assert key not in seen


def test_run_branch_select_forwards_variant_kwargs(tmp_path):
    from app.modules.pipeline.still_recipe import run_branch_select

    seen = {}

    def run_fn(**kw):
        seen.update(kw)
        return tmp_path / "t_sel.png", {}

    rp = {"A": "p1", "B": "p2"}
    rr = {"A": [("R", b"x")], "B": [("R", b"x")]}
    run_branch_select(
        **_branch_kwargs(tmp_path, run_fn),
        roll_prompts=rp, roll_refs=rr, parallel_rolls=True,
        judge_flip=True, flip_priority=["A", "B"],
        critique_selected_prompt_only=True, judge_prompt_header="HDR",
    )
    assert seen["roll_prompts"] == rp
    assert seen["roll_refs"] == rr
    assert seen["parallel_rolls"] is True
    assert seen["judge_flip"] is True
    assert seen["flip_priority"] == ["A", "B"]
    assert seen["critique_selected_prompt_only"] is True
    assert seen["judge_prompt_header"] == "HDR"


def test_still_variants_flag_default_off():
    from app.core.config import settings

    assert settings.still_variants_enabled is False


def test_still_variants_service_and_hash_wiring_source_contract():
    """서비스·hash 배선 실재 — inspect 소스 계약 (E2E6 ⑧ 교훈: 서비스
    내부 closure 는 모듈 헬퍼+소스 계약으로 잠근다)."""
    import inspect

    from app.core.steps import image_steps
    from app.services import still_recipe_service

    svc = inspect.getsource(still_recipe_service)
    assert "author_still_variants" in svc
    assert "build_ab_roll_refs" in svc
    assert "winner_uses_conti" in svc
    assert "judge_flip=True" in svc
    assert "critique_selected_prompt_only=True" in svc
    assert "parallel_rolls=True" in svc
    # 변형 모드에서 conti_ab outer 결정 미기록 — 4택1 record 가 대체
    steps = inspect.getsource(image_steps)
    assert "still_variants_enabled" in steps
    assert "still_variants_pack" in steps
    assert "still_variants_contract" in steps
    assert "still_variants_author_model_physical" in steps


# ── 리뷰 2차 반영 (critique count·물리 모델 스탬프·prompt_used) ───────


def test_effective_prompt_used_prefers_selected_variant():
    """리뷰 NARROW-4: variants 모드=선정 변형 전문이 provenance,
    legacy/결손=base 유지."""
    from app.modules.pipeline.still_recipe import effective_prompt_used

    rec = {"selected": "B", "roll_prompts": {"A": "VAR-A", "B": "VAR-B"}}
    assert effective_prompt_used(rec, "BASE") == "VAR-B"
    assert effective_prompt_used({"selected": "B"}, "BASE") == "BASE"
    assert effective_prompt_used(
        {"roll_prompts": {"A": "VAR-A"}}, "BASE") == "BASE"


def test_variants_critique_and_judge_model_wiring_source_contract():
    """리뷰 HIGH-2·HIGH-3: 후보수별 critique 계약 + 판정 물리 모델 스탬프
    실재 — inspect 소스 계약."""
    import inspect

    from app.core.steps import image_steps
    from app.services import still_recipe_service

    svc = inspect.getsource(still_recipe_service)
    assert "critique_fn_by_count" in svc
    assert "still_variants_judge_model_physical" in svc
    assert "effective_prompt_used" in svc
    steps = inspect.getsource(image_steps)
    assert "still_variants_judge_model_physical" in steps


# ── 재설계 B-3: 배경 공유 계획 = prev/배경 결정 상위 권위 ─────────────


def test_apply_share_plan_prev_overrides():
    from app.modules.pipeline.still_recipe import apply_share_plan_prev

    # 계획 없음 = classify 판정 유지
    assert apply_share_plan_prev("S1sh2", None) == "S1sh2"
    # background = prev 미사용 (classify prev 무시)
    assert apply_share_plan_prev(
        "S1sh2", {"ref_plan": "background"}) is None
    # prev = 계획 앵커로 override
    assert apply_share_plan_prev(
        None, {"ref_plan": "prev", "prev_anchor_tag": "S1sh1"}) == "S1sh1"
    # prev 인데 앵커 결손 = ValueError (조용한 강등 금지)
    import pytest as _pytest
    with _pytest.raises(ValueError):
        apply_share_plan_prev("S1sh2", {"ref_plan": "prev"})


def test_share_plan_service_wiring_source_contract():
    import inspect

    from app.services import still_recipe_service

    src = inspect.getsource(still_recipe_service)
    assert "background_share_plan" in src
    assert "apply_share_plan_prev" in src


# ── fix1 (2026-07-19): CAMERA/FRAME 절 — staging 구도·스케일 계약 주입 ──


_STAGING_FIXTURE = {
    "camera_direction": "SAMPLE 눈높이 측면, 인물=좌하, 카메라=우상",
    "framing_scale": "medium",
    "frame_spatial_contract": {
        "reason": "required_background_position",
        "constraints": [
            {"target_kind": "character", "target_id": "C01",
             "label": "SAMPLE_CHAR", "screen_zone": "middle_left",
             "depth_plane": "midground",
             "gesture_action": "looks_toward",
             "gesture_target_label": "SAMPLE_DEVICE"},
            {"target_kind": "prop", "target_id": "P01",
             "label": "SAMPLE_DEVICE", "screen_zone": "upper_right",
             "depth_plane": "background", "gesture_action": "none",
             "gesture_target_label": ""},
        ],
    },
    "key_bg_elements": [
        {"element": "SAMPLE_WALL_UNIT", "state": "mounted high",
         "orientation": "lens angled down toward the entrance",
         "camera_use": "focal point",
         "directionality_class": "directional_3d"},
    ],
}


def test_camera_frame_clause_renders_staging_contract():
    from app.modules.pipeline.still_recipe import build_camera_frame_clause

    out = build_camera_frame_clause(_STAGING_FIXTURE)
    assert out.startswith("CAMERA & FRAME")
    assert "SAMPLE 눈높이 측면" in out
    assert "medium shot" in out
    # fsc 존/깊이 enum → NL 구절 렌더
    assert "SAMPLE_CHAR in the middle-left of the frame, midground" in out
    assert ("SAMPLE_DEVICE in the upper-right of the frame, background"
            in out)
    assert "looks toward SAMPLE_DEVICE" in out
    # key_bg_elements orientation/state/camera_use
    assert "SAMPLE_WALL_UNIT (mounted high)" in out
    assert "lens angled down toward the entrance" in out
    assert "used as focal point" in out
    # 팩 스템의 스케일 계약 문구
    assert "never enlarge a background object" in out


def test_camera_frame_clause_fail_safe_empty():
    from app.modules.pipeline.still_recipe import build_camera_frame_clause

    assert build_camera_frame_clause(None) == ""
    assert build_camera_frame_clause({}) == ""
    # 계약 밖 enum·결손 필드 = 라인 생략 (보강 절 — fail-safe)
    assert build_camera_frame_clause(
        {"framing_scale": "NOT_AN_ENUM",
         "frame_spatial_contract": {"constraints": [
             {"label": "X", "screen_zone": "nowhere",
              "depth_plane": "midground"}]},
         "key_bg_elements": [{"state": "no element name"}]}
    ) == ""


def test_prompt_camera_frame_insertion_and_identity():
    import pytest

    from app.modules.pipeline.still_recipe import (
        build_camera_frame_clause,
        build_still_prompt,
    )

    kw = dict(
        shot_desc="SAMPLE", place_text="SAMPLE place.",
        time_of_day_en="day", bg_only=False, prev_used=False,
        char_names=["SAMPLE_CHAR"], prompt_version="1",
    )
    clause = build_camera_frame_clause(_STAGING_FIXTURE)
    out = build_still_prompt(**kw, camera_frame_en=clause)
    # LOCATION 다음·REALIZE 앞 삽입
    assert out.index("LOCATION (lock)") < out.index("CAMERA & FRAME")
    assert out.index("CAMERA & FRAME") < out.index("REALIZE")
    # 비면 조립 byte-identical (기존 팩 무변경 잠금)
    assert build_still_prompt(**kw) == build_still_prompt(
        **kw, camera_frame_en="")
    assert "CAMERA & FRAME" not in build_still_prompt(**kw)
    # lane 샷은 마커 스케치=배치 SOT — 상호 배타
    with pytest.raises(ValueError, match="상호 배타"):
        build_still_prompt(
            shot_desc="S", place_text="P", time_of_day_en="",
            bg_only=False, prev_used=False, lane_ref_mode="sketch",
            camera_frame_en=clause, prompt_version="3",
        )


def test_run_branch_select_forwards_fix_rejudge_fn(tmp_path):
    """E2E10 fix②: fix_rejudge_fn 제공 시만 run_fn 에 전달 (미제공=생략)."""
    from app.modules.pipeline.still_recipe import run_branch_select

    seen = {}

    def run_fn(**kw):
        seen.update(kw)
        return tmp_path / "t_sel.png", {}

    run_branch_select(**_branch_kwargs(tmp_path, run_fn))
    assert "fix_rejudge_fn" not in seen

    def rejudge(*a, **k):
        return {}

    run_branch_select(
        **_branch_kwargs(tmp_path, run_fn), fix_rejudge_fn=rejudge)
    assert seen["fix_rejudge_fn"] is rejudge


# ── 참조 선별 세 키의 전달 (2026-08-19) ──────────────────────────────


def test_run_branch_select_forwards_fix_ref_gate_and_missing_texts(tmp_path):
    """켜짐이면 세 키가 **실행 몸통**까지 그대로 간다.

    중간에 한 키만 빠져도 화면은 조용히 종전대로 나온다 — 선별은
    켜졌는데 문안이 없으면 「없는 것을 새로 넣어라」 절이 사라지고,
    반대로 문안만 가고 `fix_ref_gate` 가 빠지면 참조가 전부 붙는다.
    두 경우 다 기록에는 「선별 켜짐」으로 남아 오독된다.
    """
    from app.modules.pipeline.still_recipe import run_branch_select

    seen = {}

    def run_fn(**kw):
        seen.update(kw)
        return tmp_path / "t_sel.png", {}

    run_branch_select(
        **_branch_kwargs(tmp_path, run_fn),
        fix_ref_gate=True,
        fix_missing_texts={"missing_head": "ADD WHAT IS MISSING:",
                           "missing_tail": "These are not in the photo."},
    )
    assert seen["fix_ref_gate"] is True
    assert seen["fix_missing_head"] == "ADD WHAT IS MISSING:"
    assert seen["fix_missing_tail"] == "These are not in the photo."


def test_run_branch_select_gate_without_texts_sends_empty_clause(tmp_path):
    """문안을 못 읽었어도 선별 자체는 켜진다 — 절만 빈다(지어내지 않는다).

    이 갈래가 없으면 팩 로드가 실패한 날 `KeyError`/`None` 이 흘러
    선별이 통째로 죽는다.
    """
    from app.modules.pipeline.still_recipe import run_branch_select

    seen = {}

    def run_fn(**kw):
        seen.update(kw)
        return tmp_path / "t_sel.png", {}

    run_branch_select(**_branch_kwargs(tmp_path, run_fn), fix_ref_gate=True)
    assert seen["fix_ref_gate"] is True
    assert seen["fix_missing_head"] == ""
    assert seen["fix_missing_tail"] == ""


def test_multiroll_select_actually_accepts_the_three_keys():
    """전달 이름이 **받는 쪽**에 실재하는가 — 흉내 run_fn 만 보면 못 잡는다.

    위 두 시험의 run_fn 은 `**kw` 라 무슨 이름이든 삼킨다. 기본 run_fn
    (`run_multiroll_select`)의 서명이 이 셋을 안 받으면 프로덕션에서는
    TypeError 로 죽는데 시험은 통과한다.
    """
    import inspect

    from app.modules.pipeline.multiroll_select import run_multiroll_select

    params = inspect.signature(run_multiroll_select).parameters
    for key in ("fix_ref_gate", "fix_missing_head", "fix_missing_tail"):
        assert key in params, key
    # 기본값=꺼짐/빈 문안 — 미전달 호출이 종전과 같아야 한다.
    assert params["fix_ref_gate"].default is False
    assert params["fix_missing_head"].default == ""
    assert params["fix_missing_tail"].default == ""


def test_service_hands_the_gate_and_texts_to_run_branch_select():
    """서비스 → run_branch_select 배선 실재 — 소스 계약.

    (실행 경로 확인은 tests/services/test_still_cine_stage.py 가
    서비스를 실제로 돌려서 본다. 여기서는 두 인자가 호출에 실려 있는지
    만 잠근다 — 서비스 안 closure 라 단위로 부를 수 없다.)
    """
    import inspect

    from app.services import still_recipe_service

    src = inspect.getsource(still_recipe_service)
    assert "fix_ref_gate=fix_ref_gate_on" in src
    assert "fix_missing_texts=fix_missing_texts" in src
    assert "load_fix_missing_texts" in src


# ── LIGHTING & MOOD 절 (E2E10 fix⑤) ──────────────────────────────────


def _lighting_base_kwargs():
    return dict(
        shot_desc="한 남자가 서 있다", place_text="a seaside road.",
        time_of_day_en="dusk", bg_only=False, prev_used=False,
    )


def test_lighting_mood_clause_renders_from_staging():
    from app.modules.pipeline.still_recipe import build_lighting_mood_clause

    clause = build_lighting_mood_clause(
        {"lighting_mood": "Low dusk light, desaturated."})
    assert "LIGHTING & MOOD" in clause
    assert "Low dusk light, desaturated." in clause
    assert "MATERIAL REALISM" in clause


def test_lighting_mood_clause_failsafe_empty():
    from app.modules.pipeline.still_recipe import build_lighting_mood_clause

    assert build_lighting_mood_clause(None) == ""
    assert build_lighting_mood_clause({}) == ""
    assert build_lighting_mood_clause({"lighting_mood": "  "}) == ""
    assert build_lighting_mood_clause("not-a-dict") == ""


def test_still_prompt_lighting_empty_is_byte_identical():
    base = build_still_prompt(**_lighting_base_kwargs())
    with_empty = build_still_prompt(
        **_lighting_base_kwargs(), lighting_mood_en="")
    assert base == with_empty


def test_still_prompt_lighting_inserted_after_camera_frame():
    p = build_still_prompt(
        **_lighting_base_kwargs(),
        camera_frame_en="CAMERA / FRAME:\n- CAMERA: side view",
        lighting_mood_en="LIGHTING & MOOD:\n- X",
        prompt_version="1",
    )
    assert p.index("CAMERA / FRAME") < p.index("LIGHTING & MOOD")
    assert p.index("LOCATION (lock)") < p.index("LIGHTING & MOOD")


def test_still_prompt_lighting_applies_to_bg_only():
    p = build_still_prompt(
        **{**_lighting_base_kwargs(), "bg_only": True},
        lighting_mood_en="LIGHTING & MOOD:\n- X",
    )
    assert "LIGHTING & MOOD" in p


def test_bgfirst_bg_prompt_lighting_param():
    from app.modules.pipeline.still_recipe import build_bgfirst_bg_prompt

    kw = dict(shot_desc="S", place_text="P.", time_of_day_en="dusk")
    base = build_bgfirst_bg_prompt(**kw)
    assert base == build_bgfirst_bg_prompt(**kw, lighting_mood_en="")
    p = build_bgfirst_bg_prompt(
        **kw, lighting_mood_en="LIGHTING & MOOD:\n- X")
    assert p.index("TIME OF DAY") < p.index("LIGHTING & MOOD")


# ── BGFIRST full (E2E10 fix③④) — eligibility 확장·groupbg 헬퍼 ────────


def test_bgfirst_eligible_full_includes_complex_and_seed():
    from app.modules.pipeline.still_recipe import bgfirst_eligible_full

    # complex/seed 부착 여부와 무관 — 콘티 실재 비(prev/bgonly/lane) 샷
    assert bgfirst_eligible_full(
        conti_present=True, bg_only=False, prev_used=False, lane_used=False)


def test_bgfirst_eligible_full_excludes_prev_bgonly_lane_noconti():
    from app.modules.pipeline.still_recipe import bgfirst_eligible_full

    base = dict(conti_present=True, bg_only=False,
                prev_used=False, lane_used=False)
    for kw in ({"conti_present": False}, {"bg_only": True},
               {"prev_used": True}, {"lane_used": True}):
        assert not bgfirst_eligible_full(**{**base, **kw})


def test_groupbg_prompt_no_people_and_no_shot_specific():
    from app.modules.pipeline.still_recipe import build_groupbg_prompt

    p = build_groupbg_prompt(
        place_text="a seaside road with a small shelter.",
        time_of_day_en="dusk",
        world_anchor=" — contemporary Korea, 2026",
    )
    assert "NO PEOPLE" in p
    assert "SHOT TEXT" not in p  # 그룹 공용 배경 — 샷 특정 정보 금지
    assert "a seaside road with a small shelter." in p
    assert "dusk" in p
    assert "contemporary Korea" in p
    assert "16:9" in p


def test_groupbg_require_input_ids_fail_closed():
    import pytest

    from app.modules.pipeline.still_recipe import groupbg_require_input_ids

    assert groupbg_require_input_ids(conti_asset_id="u1") == ["u1"]
    with pytest.raises(ValueError):
        groupbg_require_input_ids(conti_asset_id=None)
    with pytest.raises(ValueError):
        groupbg_require_input_ids(conti_asset_id="")


def test_bgfirst_seed_clause_and_input_ids_with_seed():
    import pytest

    from app.modules.pipeline.still_recipe import (
        bgfirst_require_input_ids,
        build_bgfirst_seed_clause,
    )

    clause = build_bgfirst_seed_clause()
    assert "STRUCTURE LOOK" in clause and "THIRD" in clause
    # seed 없음=기존 2 UUID byte-identical
    assert bgfirst_require_input_ids(
        conti_asset_id="c", plate_asset_id="p", plate_path="x",
    ) == ["c", "p"]
    # seed 있음=3 UUID (미해결=fail-closed)
    assert bgfirst_require_input_ids(
        conti_asset_id="c", plate_asset_id="p", plate_path="x",
        seed_asset_id="s", seed_attached=True,
    ) == ["c", "p", "s"]
    with pytest.raises(ValueError):
        bgfirst_require_input_ids(
            conti_asset_id="c", plate_asset_id="p", plate_path="x",
            seed_asset_id=None, seed_attached=True,
        )


# ── 연기·형상 계약 (E2E11 fix④⑤, 팩 v10) ─────────────────────────────


def test_conduct_clauses_injected_when_version_set():
    p = build_still_prompt(
        **_lighting_base_kwargs(), conduct_version="10")
    assert "NATURAL PERFORMANCE" in p
    assert "DRAWN MARKS KEEP THEIR SHAPE" in p
    assert p.index("PROPS FACE") < p.index("NATURAL PERFORMANCE")


def test_conduct_bgonly_gets_drawn_mark_only():
    p = build_still_prompt(
        **{**_lighting_base_kwargs(), "bg_only": True},
        conduct_version="10")
    assert "NATURAL PERFORMANCE" not in p  # 인물 유도 방지
    assert "DRAWN MARKS KEEP THEIR SHAPE" in p


def test_conduct_empty_is_byte_identical():
    assert build_still_prompt(**_lighting_base_kwargs()) == \
        build_still_prompt(**_lighting_base_kwargs(), conduct_version="")


# ── identity 참조 역할 한정 절 (2026-08-12 차렷/증명사진 대응, 팩 v16) ──


def test_identity_role_clause_injected_after_people():
    from app.modules.pipeline.still_recipe import (
        build_identity_ref_role_clause,
    )

    clause = build_identity_ref_role_clause(True)
    assert "CHARACTER REFERENCE ROLE" in clause
    assert "never copy" in clause
    p = build_still_prompt(
        **_lighting_base_kwargs(), char_names=["김철수 (30대)"],
        identity_role_en=clause)
    assert "CHARACTER REFERENCE ROLE" in p
    # 인물 절(PEOPLE) 바로 뒤 — 사람 규정과 참조 관할이 붙어 있어야 한다
    assert p.index("PEOPLE:") < p.index("CHARACTER REFERENCE ROLE")


def test_identity_role_empty_is_byte_identical():
    assert build_still_prompt(**_lighting_base_kwargs()) == \
        build_still_prompt(**_lighting_base_kwargs(), identity_role_en="")


def test_identity_role_builder_empty_without_char_refs():
    from app.modules.pipeline.still_recipe import (
        build_identity_ref_role_clause,
    )

    assert build_identity_ref_role_clause(False) == ""


def test_identity_role_condition_mirrors_build_still_refs():
    """서비스 절 조건(char_refs AND not bg_only)은 build_still_refs 가
    캐릭터 참조를 싣는 조건의 미러다 — refs 실동으로 그 미러를 잠근다.
    (참조가 실리지 않는 샷에 절이 나가면 존재하지 않는 이미지를 가리키는
    거짓 문장이 된다.)"""
    from app.modules.pipeline.still_recipe import build_still_refs

    char_refs = [("김철수", b"fake-image-bytes")]
    # 캐릭터 참조가 실리는 조건 — 절이 나가는 조건과 동일해야 한다
    refs = build_still_refs(
        bg_only=False, plate=None, conti=None, prev_sel=None,
        char_refs=char_refs, prop_refs=[],
    )
    assert any("김철수" in label for label, _ in refs)
    # bg_only 샷은 캐릭터 참조가 실리지 않는다 → 절도 나가면 안 된다
    refs_bg = build_still_refs(
        bg_only=True, plate=None, conti=None, prev_sel=None,
        char_refs=char_refs, prop_refs=[],
    )
    assert not any("김철수" in label for label, _ in refs_bg)
    # ★의도된 예외 (Codex HIGH-4): bg_only+handled_by 는 HAND OWNER
    # REFERENCE 1장을 싣지만 이 절의 대상이 아니다 — 절의 지시 대상인
    # "CHARACTER REFERENCE" 라벨이 그 샷에 없다(손 라벨은 자체 역할
    # 한정 내장). 절 조건(char_refs AND not bg_only)은 여기서 거짓.
    refs_hand = build_still_refs(
        bg_only=True, plate=None, conti=None, prev_sel=None,
        char_refs=char_refs, prop_refs=[], handled_by="김철수가 쥐고 있다",
    )
    hand_labels = [label for label, _ in refs_hand]
    assert any(lb.startswith("HAND OWNER REFERENCE") for lb in hand_labels)
    assert not any(
        lb.startswith("CHARACTER REFERENCE") for lb in hand_labels)


def test_bgfirst_bg_prompt_v10_keeps_animals_and_scale():
    from app.modules.pipeline.still_recipe import build_bgfirst_bg_prompt

    kw = dict(shot_desc="S", place_text="P.", time_of_day_en="dusk")
    p7 = build_bgfirst_bg_prompt(**kw)  # default v7 — 불변
    p10 = build_bgfirst_bg_prompt(**kw, prompt_version="10")
    assert "inherent occupants" not in p7
    assert "inherent occupants" in p10  # 상주 동물·물품 유지
    assert "HUMAN-SCALE CALIBRATION" in p10


def test_groupbg_prompt_v10_keeps_animals():
    from app.modules.pipeline.still_recipe import build_groupbg_prompt

    p = build_groupbg_prompt(
        place_text="an animal shelter kennel area.",
        time_of_day_en="day", world_anchor="")
    assert "inherent occupants" in p
    assert "HUMAN-SCALE CALIBRATION" in p
    assert "NO PEOPLE" in p


# ── E2E11 ③: groupbg 장소 근거 강화 (v11) ───────────────────────────


def test_groupbg_prompt_v11_location_detail_and_evidence():
    from app.modules.pipeline.still_recipe import build_groupbg_prompt

    p = build_groupbg_prompt(
        place_text="the center of an outdoor investigation site.",
        time_of_day_en="day",
        world_anchor="",
        prompt_version="11",
        location_detail_en=(
            "SAMPLE site: a cordoned outdoor area "
            "(특징: yellow tape; equipment cases)"
        ),
        scene_evidence=("경찰이 현장을 감식한다.", "  ", "라인을 친다."),
    )
    assert "LOCATION DETAIL" in p
    assert "SAMPLE site: a cordoned outdoor area" in p
    assert "SCENE EVIDENCE" in p
    assert "- 경찰이 현장을 감식한다." in p
    assert "- 라인을 친다." in p
    # 공백 인용은 제외
    assert "\n-  " not in p
    # 순간 연출 미묘사 계약 + 절 순서(LOCATION→DETAIL→EVIDENCE→TIME)
    assert "do NOT depict the momentary actions" in p
    assert (p.index("THE LOCATION") < p.index("LOCATION DETAIL")
            < p.index("SCENE EVIDENCE") < p.index("TIME OF DAY (lock):"))
    # Codex HIGH-3: DETAIL=evidence 강등 — 영구 물리 특징만 승계, 조명/
    # 시간/순간 상태/주관 인상은 고정 사실 승격 금지(TIME lock 우선)
    assert "evidence, not a staging order" in p
    assert "ONLY its enduring physical features" in p
    assert "Do NOT treat any lighting, weather or time-of-day wording" in p


def test_groupbg_prompt_v11_empty_extras_omit_clauses():
    from app.modules.pipeline.still_recipe import build_groupbg_prompt

    p = build_groupbg_prompt(
        place_text="a seaside road.",
        time_of_day_en="dusk",
        world_anchor="",
        prompt_version="11",
        location_detail_en="   ",
        scene_evidence=("", "  "),
    )
    assert "LOCATION DETAIL" not in p
    assert "SCENE EVIDENCE" not in p
    assert "NO PEOPLE" in p and "16:9" in p


def test_groupbg_prompt_v10_ignores_extras_byte_identical():
    from app.modules.pipeline.still_recipe import build_groupbg_prompt

    kw = dict(
        place_text="a seaside road.", time_of_day_en="dusk",
        world_anchor="", prompt_version="10",
    )
    base = build_groupbg_prompt(**kw)
    with_extras = build_groupbg_prompt(
        **kw,
        location_detail_en="SAMPLE detail",
        scene_evidence=("SAMPLE quote",),
    )
    assert base == with_extras
    assert "LOCATION DETAIL" not in with_extras


def test_still_recipe_v11_pack_copy_integrity():
    """v11 팩 = v10 전체 사본 + groupbg 근거 헤더 2종(신규)."""
    from pathlib import Path

    from app.modules.pipeline.still_recipe import resolve_prompt_version

    repo = Path(__file__).resolve().parents[3]
    base = repo / "prompts" / "_base" / "still_recipe"
    v10 = base / resolve_prompt_version("10")
    v11 = base / resolve_prompt_version("11")
    for f in v10.glob("*.md"):
        assert (v11 / f.name).read_text(encoding="utf-8") == f.read_text(
            encoding="utf-8"), f"{f.name} 이 v10 과 다름"
    for stem in ("groupbg_detail_head.md", "groupbg_evidence_head.md"):
        assert (v11 / stem).is_file(), f"{stem} 결손"


def test_groupbg_context_sig_detects_drift():
    """Codex NARROW-4: 근거(loc 상세/evidence) drift → 지문 변화,
    공백 인용은 지문 무영향."""
    from app.modules.pipeline.still_recipe import groupbg_context_sig

    base = groupbg_context_sig(
        location_detail_en="SAMPLE place: cordoned area",
        scene_evidence=("quote A", "quote B"),
    )
    assert base == groupbg_context_sig(
        location_detail_en="SAMPLE place: cordoned area",
        scene_evidence=("quote A", "  ", "quote B", ""),
    )
    assert base != groupbg_context_sig(
        location_detail_en="SAMPLE place: cordoned area, yellow tape",
        scene_evidence=("quote A", "quote B"),
    )
    assert base != groupbg_context_sig(
        location_detail_en="SAMPLE place: cordoned area",
        scene_evidence=("quote A",),
    )

def test_decide_groupbg_reuse_all_members_compare_canonical_fp():
    """Codex NARROW-4+3차: meta drift(context_sig)·파일 결손=즉시 재생성,
    meta 일치=**모든 멤버**(follower 포함)가 canonical origin 지문 대조 —
    'origin completed + follower pending + meta 동일 + origin place/
    time/콘티 bytes drift → regenerate' 를 잠근다."""
    from app.modules.pipeline.still_recipe import decide_groupbg_reuse

    old_meta = {"pack": "11.x", "contract": "bgfirst_full_v3",
                "context_sig": "aaaa"}
    prev_rec = {"origin_tag": "S3sh3", "meta": dict(old_meta),
                "input_fingerprint": "fp-old"}

    def _boom():
        raise AssertionError("meta 불일치 경로는 지문을 계산하지 않는다")

    # 근거 drift(context_sig) → 지문 계산 없이 재생성
    assert decide_groupbg_reuse(
        prev_rec=prev_rec, meta={**old_meta, "context_sig": "bbbb"},
        file_ok=True, fingerprint_fn=_boom,
    ) == (True, None)
    # 파일 결손 → 재생성
    assert decide_groupbg_reuse(
        prev_rec=prev_rec, meta=dict(old_meta),
        file_ok=False, fingerprint_fn=_boom,
    ) == (True, None)
    # meta 동일 + canonical 지문 동일 → 재사용 (origin/follower 공통)
    assert decide_groupbg_reuse(
        prev_rec=prev_rec, meta=dict(old_meta),
        file_ok=True, fingerprint_fn=lambda: "fp-old",
    ) == (False, "fp-old")
    # meta 동일 + canonical 지문 drift(origin 의 현재 place/time/콘티
    # bytes 변화) → follower 방문에서도 재생성 (3차 NARROW 봉합)
    assert decide_groupbg_reuse(
        prev_rec=prev_rec, meta=dict(old_meta),
        file_ok=True, fingerprint_fn=lambda: "fp-new",
    ) == (True, "fp-new")


def test_resolve_groupbg_canonical_origin_contract():
    """Codex 재리뷰 NARROW-1+3차: canonical origin=현재 상류 SOT 기준 —
    origin_tag 보존, follower 값 미사용, 해석 실패=lookup fail-closed
    전파."""
    import pytest

    from app.modules.pipeline.still_recipe import (
        resolve_groupbg_canonical_origin,
    )

    cur = {"place_text": "FOLLOWER place", "time_of_day_en": "night",
           "conti_path": "/f/conti_follower.png",
           "conti_asset_id": "conti-follower"}

    # 자기 자신이 origin(최초 생성/재도달) — 현재 입력이 canonical
    o = resolve_groupbg_canonical_origin(
        prev_rec={}, tag="S3sh3", current_inputs=dict(cur),
        origin_lookup_fn=lambda t: (_ for _ in ()).throw(
            AssertionError("자기 origin 은 lookup 미호출")),
    )
    assert o["origin_tag"] == "S3sh3"
    assert o["place_text"] == "FOLLOWER place"

    # follower — lookup 이 반환한 origin 의 **현재** 입력 사용, follower
    # 값 무시, origin_tag 보존
    def _lookup(t):
        assert t == "S3sh3"
        return {"place_text": "ORIGIN place NOW", "time_of_day_en": "dusk",
                "conti_path": "/o/conti_origin.png",
                "conti_asset_id": "conti-origin"}

    f = resolve_groupbg_canonical_origin(
        prev_rec={"origin_tag": "S3sh3"}, tag="S9sh1",
        current_inputs=dict(cur), origin_lookup_fn=_lookup,
    )
    assert f == {"place_text": "ORIGIN place NOW", "time_of_day_en": "dusk",
                 "conti_path": "/o/conti_origin.png",
                 "conti_asset_id": "conti-origin", "origin_tag": "S3sh3"}

    # origin 해석 실패 — lookup 의 fail-closed ValueError 그대로 전파
    def _fail(t):
        raise ValueError(f"groupbg origin {t} 콘티 미해결 — fail-closed")

    with pytest.raises(ValueError, match="fail-closed"):
        resolve_groupbg_canonical_origin(
            prev_rec={"origin_tag": "S3sh3"}, tag="S9sh1",
            current_inputs=dict(cur), origin_lookup_fn=_fail,
        )


def test_resolve_groupbg_canonical_origin_group_sig_fail_closed():
    """Codex 4차 NARROW-1: share 그룹 재구성(group_sig 변경) 시 과거
    origin 보존/조용 승격 금지 — fail-closed(record 리셋 요구), old
    origin lookup/생성 0회."""
    import pytest

    from app.modules.pipeline.still_recipe import (
        resolve_groupbg_canonical_origin,
    )

    cur = {"place_text": "P", "time_of_day_en": "day",
           "conti_path": "/f/c.png", "conti_asset_id": "aid-b"}

    def _no_lookup(t):
        raise AssertionError("group_sig 변경 시 old origin lookup 금지")

    old_sig = {"key": "감식 현장", "tags": ["S3sh3", "S9sh1"]}
    prev = {"origin_tag": "S3sh3", "meta": {"group_sig": old_sig}}

    # old origin(A=S3sh3) 이탈: [A,B]→[B,C]
    with pytest.raises(ValueError, match="group_sig 변경"):
        resolve_groupbg_canonical_origin(
            prev_rec=prev, tag="S9sh1", current_inputs=dict(cur),
            origin_lookup_fn=_no_lookup,
            current_group_sig={"key": "감식 현장",
                              "tags": ["S9sh1", "S12sh1"]},
        )
    # 더 이른 멤버 추가: [A,B]→[N,A,B] — fresh origin 이 달라지므로 동일
    with pytest.raises(ValueError, match="group_sig 변경"):
        resolve_groupbg_canonical_origin(
            prev_rec=prev, tag="S9sh1", current_inputs=dict(cur),
            origin_lookup_fn=_no_lookup,
            current_group_sig={"key": "감식 현장",
                              "tags": ["S1sh1", "S3sh3", "S9sh1"]},
        )
    # 방어: origin_tag ∉ 현재 tags (prev meta 결손 record 손상 시나리오)
    with pytest.raises(ValueError, match="현재 그룹 tags"):
        resolve_groupbg_canonical_origin(
            prev_rec={"origin_tag": "S3sh3"}, tag="S9sh1",
            current_inputs=dict(cur), origin_lookup_fn=_no_lookup,
            current_group_sig={"key": "감식 현장",
                              "tags": ["S9sh1", "S12sh1"]},
        )
    # group_sig 동일 → 정상 (follower 는 lookup 현재값)
    ok = resolve_groupbg_canonical_origin(
        prev_rec=prev, tag="S9sh1", current_inputs=dict(cur),
        origin_lookup_fn=lambda t: {
            "place_text": "O", "time_of_day_en": "dusk",
            "conti_path": "/o/c.png", "conti_asset_id": "aid-a"},
        current_group_sig=dict(old_sig),
    )
    assert ok["origin_tag"] == "S3sh3" and ok["place_text"] == "O"


def test_validate_groupbg_conti_source_fail_closed(tmp_path):
    """Codex 4차 NARROW-2: 생성 전 콘티 소스 검증 — 파일 실재·비어있지
    않음·asset UUID 필수 (부재/디렉터리/0-byte/UUID 결손=fail-closed)."""
    import pytest

    from app.modules.pipeline.still_recipe import (
        validate_groupbg_conti_source,
    )

    good = tmp_path / "conti.png"
    good.write_bytes(b"\x89PNG ok")
    assert validate_groupbg_conti_source(
        conti_path=good, conti_asset_id="aid-1", origin_tag="S3sh3",
    ) == good
    # 부재 / None
    for bad in (tmp_path / "missing.png", None, ""):
        with pytest.raises(ValueError, match="콘티 파일 무효"):
            validate_groupbg_conti_source(
                conti_path=bad, conti_asset_id="aid-1", origin_tag="S3sh3",
            )
    # 디렉터리
    d = tmp_path / "dir"
    d.mkdir()
    with pytest.raises(ValueError, match="콘티 파일 무효"):
        validate_groupbg_conti_source(
            conti_path=d, conti_asset_id="aid-1", origin_tag="S3sh3",
        )
    # 0-byte
    z = tmp_path / "zero.png"
    z.write_bytes(b"")
    with pytest.raises(ValueError, match="콘티 파일 무효"):
        validate_groupbg_conti_source(
            conti_path=z, conti_asset_id="aid-1", origin_tag="S3sh3",
        )
    # asset UUID 결손
    for aid in (None, "", "  "):
        with pytest.raises(ValueError, match="asset UUID 결손"):
            validate_groupbg_conti_source(
                conti_path=good, conti_asset_id=aid, origin_tag="S3sh3",
            )


# ── 케이스1 스펙 E·F (2026-07-25 사용자 확정): lane·prev 체인 편입 ────


def test_bgfirst_eligible_full_chain_includes_lane_and_prev():
    from app.modules.pipeline.still_recipe import bgfirst_eligible_full

    base = dict(conti_present=True, bg_only=False)
    for kw in ({"prev_used": True, "lane_used": False},
               {"prev_used": False, "lane_used": True},
               {"prev_used": True, "lane_used": True}):
        assert not bgfirst_eligible_full(**base, **kw)
        assert bgfirst_eligible_full(**base, **kw, chain_lane_prev=True)


def test_bgfirst_eligible_full_chain_still_excludes_bgonly_and_noconti():
    from app.modules.pipeline.still_recipe import bgfirst_eligible_full

    # 콘티 자체가 없는 샷은 편입 ON 이어도 대상 아님
    assert not bgfirst_eligible_full(
        conti_present=False, bg_only=False, prev_used=True,
        lane_used=False, chain_lane_prev=True)
    assert not bgfirst_eligible_full(
        conti_present=True, bg_only=True, prev_used=False,
        lane_used=True, chain_lane_prev=True)


def test_bgfirst_winner_lineage_prev_authority():
    from app.modules.pipeline.still_recipe import bgfirst_winner_lineage

    # 체인 승 = final refs 가 [bg, conti, entities] — 권위 종류 무관
    assert bgfirst_winner_lineage(
        chain_won=True, authority_kind="prev",
        structure_seed_attached=False) == ["conti", "bgfirst_bg"]
    # 무콘티(현행 prev 경로) 승 = prev 스틸 직접 참조
    assert bgfirst_winner_lineage(
        chain_won=False, authority_kind="prev",
        structure_seed_attached=False) == ["prev"]


def test_bgfirst_winner_lineage_canon_master_authority():
    """lane 체인: 콘티가 본 장소 실사가 위치 권위 — 무콘티 승 시 직접
    참조, 체인 승 시엔 Step1 중간 배경이 그 edge 를 소유(직접 첨부 X)."""
    from app.modules.pipeline.still_recipe import bgfirst_winner_lineage

    assert bgfirst_winner_lineage(
        chain_won=False, authority_kind="canon_master",
        structure_seed_attached=False) == ["canon_master"]
    assert bgfirst_winner_lineage(
        chain_won=True, authority_kind="canon_master",
        structure_seed_attached=False) == ["conti", "bgfirst_bg"]
    import pytest as _pytest

    with _pytest.raises(ValueError):
        bgfirst_winner_lineage(
            chain_won=False, authority_kind="unknown_kind",
            structure_seed_attached=False)


# ── 좁고 복잡한 실내의 기하 권위 (2026-08-07, 팩 v15) ──────────────────
#
# 실물 대조가 근거다: 자동차 캐빈 샷의 배경 산출은 핸들 1개·좌석 배치가
# 정확한데 인물 삽입 뒤 림이 이중이 됐다. 프롬프트가 그렇게 시키고 있었다.
# 여기서 잠그는 것은 **무엇이 프롬프트에 실리는가**뿐 — 그림이 나아지는지는
# 육안이 판정한다.


def test_geom_authority_replaces_stage_head():
    from app.modules.pipeline.still_recipe import build_bgfirst_final_prompt

    base = "BASE PROMPT"
    off = build_bgfirst_final_prompt(base)
    on = build_bgfirst_final_prompt(base, geom_authority=True)
    assert off != on
    # 구 계약의 그 한 문장이 사라진다
    assert "Ignore the sketch's background lines" in off
    assert "Ignore the sketch's background lines" not in on
    # 세고 유지하라는 행동 지시로 대체된다
    assert "count what the background shows" in on
    assert "covering is not redrawing" in on
    # base 전문은 두 경로 모두 보존
    assert base in off and base in on


def test_mannequin_wins_over_geom_authority():
    """마네킹 교체는 배경을 고쳐 쓰는 계약이라 '그대로 두라'와 양립 못 한다."""
    from app.modules.pipeline.still_recipe import build_bgfirst_final_prompt

    from app.modules.pipeline.still_recipe import BGFIRST_LANE_PROMPT_VERSION

    kw = dict(prompt_version=BGFIRST_LANE_PROMPT_VERSION, mannequin=True)
    assert (build_bgfirst_final_prompt("B", **kw, geom_authority=True)
            == build_bgfirst_final_prompt("B", **kw))


def test_geom_authority_swaps_sketch_label(tmp_path):
    from app.modules.pipeline.still_recipe import build_bgfirst_refs

    bg, conti = tmp_path / "bg.png", tmp_path / "c.png"
    for p in (bg, conti):
        p.write_bytes(b"x")
    off = build_bgfirst_refs(bg=bg, conti=conti, char_refs=[], prop_refs=[])
    on = build_bgfirst_refs(bg=bg, conti=conti, char_refs=[], prop_refs=[],
                            geom_authority=True)
    assert "people placement only" in off[1][0]
    assert "people placement AND the structure" in on[1][0]
    # 참조 순서·개수는 그대로 — 라벨만 갈아 끼운다
    assert [p for _, p in off] == [p for _, p in on]


def test_prev_shot_gets_conti_only_under_geom_authority(tmp_path):
    """prev 가 있으면 콘티를 버리던 분기 — 복잡 실내에서만 둘 다 싣는다."""
    from app.modules.pipeline.still_recipe import build_still_refs

    prev, conti = tmp_path / "p.png", tmp_path / "c.png"
    for p in (prev, conti):
        p.write_bytes(b"x")
    common = dict(bg_only=False, plate=None, conti=conti, prev_sel=prev,
                  char_refs=[], prop_refs=[])
    off = build_still_refs(**common)
    on = build_still_refs(**common, geom_authority=True)
    assert [p for _, p in off] == [prev]                 # 콘티가 버려진다
    assert [p for _, p in on] == [prev, conti]           # 둘 다
    assert "PREVIOUS SHOT STILL" in on[0][0]
    # ★표식을 v34 문구로 옮긴다 (감사 1-D 잔여). 옛 표식
    #  "which seat each body occupies" 는 탈것 전용이라 걷었다 —
    #  이 시험이 지키는 것은 **콘티가 기하 계약을 달고 붙는가**이지
    #  그 문구 자체가 아니다.
    assert "which fixed fittings this space has" in on[1][0]


def test_geom_authority_without_conti_is_noop(tmp_path):
    """콘티가 없으면 판별이 참이어도 조립이 달라지지 않는다."""
    from app.modules.pipeline.still_recipe import build_still_refs

    prev = tmp_path / "p.png"
    prev.write_bytes(b"x")
    common = dict(bg_only=False, plate=None, conti=None, prev_sel=prev,
                  char_refs=[], prop_refs=[])
    assert build_still_refs(**common) == build_still_refs(
        **common, geom_authority=True)


def test_conti_targets_includes_prev_shots_when_geom():
    from app.modules.pipeline.shot_conti_light import conti_targets

    shots = {
        "S1sh1": {"person_visible": True, "prev": None},
        "S1sh2": {"person_visible": True, "prev": "S1sh1"},
        "S1sh3": {"person_visible": False, "prev": None},
    }
    assert conti_targets(shots) == ["S1sh1"]
    assert conti_targets(shots, {"S1sh2"}) == ["S1sh1", "S1sh2"]
    # 인물 없는 샷은 판별이 참이어도 대상이 아니다
    assert conti_targets(shots, {"S1sh3"}) == ["S1sh1"]


# ── 손에 든 물건 (2026-08-07 사용자 지적) ──────────────────────────────
#
# "전화기나 기타 손에 잡고 있는 것들은 손까지 묘사하는 것은 가능하게 해야
# 할 듯해." 실측: 무인 조항과 소지품 조항이 33샷 중 27샷에 함께 나갔고
# 완성본에서 휴대폰이 손 없이 떴다. 팩 v13 이 무인 조항에 손 예외를 넣어
# "손을 그려라"까지 갔지만 그 손이 **누구 손인지**는 아무 데도 없었다 —
# bgonly 샷은 캐릭터 참조를 전부 떼기 때문이다.


def _hand_prompt(**over):
    from app.modules.pipeline.still_recipe import build_still_prompt

    kw = dict(shot_desc="어떤 순간", place_text="어떤 곳", world_anchor="W",
              time_of_day_en="day", bg_only=True, prev_used=False)
    kw.update(over)
    return build_still_prompt(**kw)


def test_handled_object_clause_names_the_hand_owner():
    p = _hand_prompt(handled_by="정인우")
    assert "THE HAND THAT IS DOING THIS" in p
    assert p.count("정인우") >= 2          # 소유자를 절 안에서 지목
    assert "belong to that person and to no one else" in p
    # 프레이밍은 물건에 머문다 — 전신을 끌어들이지 않는다
    assert "the framing stays on the object" in p


def test_no_handler_keeps_prompt_identical():
    assert _hand_prompt() == _hand_prompt(handled_by="")
    assert "THE HAND THAT IS DOING THIS" not in _hand_prompt()


def test_handled_clause_coexists_with_no_people(tmp_path):
    """무인 조항과 함께 나가도 된다 — 무인 조항 자체가 손을 예외로 둔다."""
    p = _hand_prompt(handled_by="정인우", char_names=[])
    assert "NO PEOPLE IN THIS SHOT" in p
    assert "THE HAND THAT IS DOING THIS" in p
    # 무인 조항의 손 예외가 실재해야 둘이 싸우지 않는다
    assert "render the hand" in p


def test_bgonly_refs_carry_only_the_handler(tmp_path):
    from app.modules.pipeline.still_recipe import build_still_refs

    a, b = tmp_path / "a.png", tmp_path / "b.png"
    for p in (a, b):
        p.write_bytes(b"x")
    refs = build_still_refs(
        bg_only=True, plate=None, conti=None, prev_sel=None,
        char_refs=[("정인우", a), ("김지후", b)], prop_refs=[],
        handled_by="정인우의 핸드폰")
    assert [p for _, p in refs] == [a]          # 그 사람 것만, 전원이 아니다
    assert "HAND OWNER REFERENCE — 정인우" in refs[0][0]
    assert "Do NOT bring their face, body or clothing" in refs[0][0]


def test_bgonly_refs_skip_when_no_name_matches(tmp_path):
    """엉뚱한 사람의 손을 주느니 주지 않는다."""
    from app.modules.pipeline.still_recipe import build_still_refs

    a = tmp_path / "a.png"
    a.write_bytes(b"x")
    refs = build_still_refs(
        bg_only=True, plate=None, conti=None, prev_sel=None,
        char_refs=[("김지후", a)], prop_refs=[], handled_by="정인우의 핸드폰")
    assert refs == []


def test_classify_v4_asks_who_handles_the_object():
    from app.modules.pipeline.shot_ref_classify import (
        resolve_prompt_version,
    )
    from app.modules.prompt_loader import load_prompt

    sysmsg = load_prompt(
        "shot_ref_classify", "bgonly_system",
        version=resolve_prompt_version("4"))
    assert "handled_by" in sysmsg
    # 화면 속 이미지와 화면을 든 손은 다른 물음이라는 것이 이 팩의 요점
    assert "다른 물음이다" in sysmsg
    assert "person_visible 이 true 든 false 든" in sysmsg


# ── 배선이 실제로 프로덕션에 닿는가 (2026-08-07 Codex 리뷰) ────────────
#
# 리뷰가 잡은 것은 문안이 아니라 **도달 불가**였다: geom_authority 인자는
# 생겼는데 프로덕션 호출 어디도 넘기지 않아, 그 상태로 다시 그리면 의도한
# 수정 없이 비용만 나간다. 순수 함수 테스트로는 안 잡히므로 호출부를 핀한다.


def _svc_src() -> str:
    import inspect

    from app.services import still_recipe_service

    return inspect.getsource(still_recipe_service)


def test_service_derives_geom_authority_from_classification():
    src = _svc_src()
    # 판별은 LLM 이 하고 코드는 그 값을 읽기만 한다 — 어휘 판단 금지 원칙
    assert 'cls.get("confined_structure")' in src
    # 2026-08-11 confined fp: 분류 파생은 유지하되 confined fp 활성 샷은
    # geom 계약을 겹치지 않는다(fp+장면 설명이 그 자리의 권위).
    assert "geom_authority = (bool(cls.get(\"confined_structure\"))" in src
    assert "not _confined_active)" in src
    # lane 은 마커 스케치가 이미 배치 SOT — 두 권위를 겹치지 않는다
    assert "and not lane_used" in src


def test_service_passes_geom_authority_to_all_three_assemblers():
    src = _svc_src()
    assert src.count("geom_authority=geom_authority") >= 2
    assert "geom_authority=geom_authority and not lane_chain" in src


def test_service_passes_handled_by():
    src = _svc_src()
    assert 'cls.get("handled_by")' in src
    assert "handled_by=handled_by" in src


def test_conti_targets_reads_classification_directly():
    """호출측이 따로 넘기지 않아도 판별이 반영돼야 한다 — 누락 불가 형태."""
    from app.modules.pipeline.shot_conti_light import conti_targets

    shots = {
        "S1sh1": {"person_visible": True, "prev": None},
        "S1sh2": {"person_visible": True, "prev": "S1sh1",
                  "confined_structure": True},
        "S1sh3": {"person_visible": True, "prev": "S1sh1",
                  "confined_structure": False},
    }
    # geom_tags 를 **주지 않아도** 좁고 복잡한 실내 샷이 대상에 든다
    assert conti_targets(shots) == ["S1sh1", "S1sh2"]


def test_outer_hash_carries_still_only_judge_pack_and_geom():
    """전역 팩을 읽으면 스틸만 올렸을 때 hash 가 안 움직여 clean skip 된다."""
    import inspect

    from app.core.steps import image_steps

    src = inspect.getsource(image_steps)
    assert "STILL_JUDGE_PACK_VERSION as _still_judge_sel" in src
    assert "_judge_pack_resolved(_still_judge_sel)" in src
    assert "recipe_geom_authority_pack" in src
    # camera_frame 축이 lighting·conduct 와 나란히 있어야 한다
    assert "still_recipe_camera_frame_enabled" in src
    assert "recipe_camera_frame_pack" in src
