"""still_recipe BGFIRST2 체인 헬퍼 결정론 테스트 (이식 ②③)."""
import json
from pathlib import Path
from typing import Optional

import pytest

from app.modules.pipeline.still_recipe import (
    BGFIRST_PROMPT_VERSION,
    bgfirst_eligible,
    build_bgfirst_bg_prompt,
    build_bgfirst_final_prompt,
    build_bgfirst_refs,
    load_bgfirst_judge_header,
    resolve_prompt_version,
)


@pytest.fixture(autouse=True)
def _no_paid_side_calls(monkeypatch):
    """이 파일의 모든 시험에서 유료 곁가지 호출을 끈다 (2026-08-20).

    이 둘을 안 끄면 하네스가 기계의 `.env`(둘 다 켜짐)를 물어 걷기가
    **실제 유료 LLM 호출**을 내보낸다 — 시험을 돌릴 때마다 돈이 나가고,
    실패 메시지에는 그 사실이 안 드러난다. 이 파일의 시험들은 간판·시대를
    겨누지 않으므로 기준선에서 끈다.
    감시: `.venv/bin/python -m pytest ... -p tests.netprobe`
    """
    from app.core.config import settings

    for off in ("signage_author_enabled", "era_research_enabled"):
        monkeypatch.setattr(settings, off, False, raising=False)


def test_pack_seven_registered():
    assert resolve_prompt_version(BGFIRST_PROMPT_VERSION).startswith("7.")


def _elig(**over):
    kw = dict(
        conti_present=True,
        bg_only=False,
        prev_used=False,
        lane_used=False,
        complex_ab=False,
        structure_seed_attached=False,
        seed_bg_attached=False,
    )
    kw.update(over)
    return bgfirst_eligible(**kw)


def test_eligible_plain_conti_shot_only():
    assert _elig() is True
    # 비콘티/기존 유지 계약 — 각 단독 위반으로 전부 제외
    assert _elig(conti_present=False) is False
    assert _elig(bg_only=True) is False
    assert _elig(prev_used=True) is False
    assert _elig(lane_used=True) is False
    assert _elig(complex_ab=True) is False
    assert _elig(structure_seed_attached=True) is False
    assert _elig(seed_bg_attached=True) is False


def test_bg_prompt_assembly_matches_canonical_order():
    p = build_bgfirst_bg_prompt(
        shot_desc="샷 원문",
        place_text="storefront exterior wall",
        time_of_day_en="dusk",
        camera_frame_en="CAM-CLAUSE",
    )
    parts = p.split("\n\n")
    assert parts[0].startswith(
        "Create the EMPTY BACKGROUND PLATE for one film shot — NO PEOPLE")
    assert "RE-PROJECT" in parts[0]
    # 팩 텍스트 assert 는 공백 정규화 후에만 한다 — 줄바꿈 위치는 재래핑
    # 으로 언제든 움직이고, 그때 의미가 하나도 안 바뀐 채 초록 테스트가
    # 깨진다(구판의 `or` 우회는 뒷항이 항상 참이라 사실상 무검사였다).
    assert "the sketch's camera wins" in " ".join(parts[0].split())
    assert parts[1] == "SHOT TEXT this background must serve (Korean): 샷 원문"
    assert parts[2] == "LOCATION (lock): storefront exterior wall"
    assert parts[3] == "TIME OF DAY (lock): dusk."
    assert parts[4] == "CAM-CLAUSE"
    assert parts[5].startswith("Render ONE photorealistic empty location")


def test_bg_prompt_camera_optional():
    p = build_bgfirst_bg_prompt(
        shot_desc="샷", place_text="골목", time_of_day_en="dawn")
    assert "CAM-CLAUSE" not in p
    assert len(p.split("\n\n")) == 5


def test_final_prompt_wraps_base():
    fin = build_bgfirst_final_prompt("BASE-STILL-PROMPT")
    head, base = fin.split("\n\n", 1)[0], fin.rsplit("\n\n", 1)[-1]
    assert head.startswith("Stage the shot.")
    assert fin.endswith("BASE-STILL-PROMPT")
    assert "No sketch lines or arrows may remain." in " ".join(fin.split())


def test_refs_order_and_labels(tmp_path):
    bg = tmp_path / "bg.png"
    conti = tmp_path / "conti.png"
    refs = build_bgfirst_refs(
        bg=bg, conti=conti,
        char_refs=[("수리영", b"c")], prop_refs=[("자전거", b"p")],
    )
    labels = [lab for lab, _ in refs]
    assert labels[0] == "SHOT BACKGROUND"
    assert labels[1] == "LAYOUT SKETCH (people placement only)"
    # 엔티티 라벨=기본 팩(v1) char/prop 포맷과 동일 (무콘티 후보와 일치)
    assert "수리영" in labels[2]
    assert "자전거" in labels[3]
    assert refs[0][1] is bg and refs[1][1] is conti


def test_entity_labels_match_noconti_branch(tmp_path):
    """체인 후보(A)와 무콘티 후보(B)의 엔티티 라벨이 정확히 일치해야
    비교가 순수하다 (build_ab_branch_refs B 브랜치와 대조)."""
    from app.modules.pipeline.still_recipe import build_ab_branch_refs

    plate = tmp_path / "plate.png"
    conti = tmp_path / "conti.png"
    bg = tmp_path / "bg.png"
    chain = build_bgfirst_refs(
        bg=bg, conti=conti, char_refs=[("수리영", b"c")],
        prop_refs=[("자전거", b"p")],
    )
    _, refs_b = build_ab_branch_refs(
        plate=plate, conti=conti, char_refs=[("수리영", b"c")],
        prop_refs=[("자전거", b"p")], prompt_version="1",
    )
    chain_entity_labels = [lab for lab, _ in chain[2:]]
    b_entity_labels = [
        lab for lab, _ in refs_b
        if "수리영" in lab or "자전거" in lab
    ]
    assert chain_entity_labels == b_entity_labels


def test_judge_header_is_neutral():
    h = load_bgfirst_judge_header()
    assert "generated from this" not in h
    assert h.startswith("THE BRIEF")


def test_unknown_pack_selector_raises():
    with pytest.raises(ValueError):
        build_bgfirst_final_prompt("x", prompt_version="99")


# ── Codex 리뷰 반영 (2026-07-20) ─────────────────────────────────────


def test_conti_defect_matrix(tmp_path):
    from app.modules.pipeline.still_recipe import bgfirst_conti_defect

    good = tmp_path / "conti.png"
    good.write_bytes(b"c")
    entry_ok = {"image_path": str(good), "asset_id": "a1",
                "error": None, "skipped_reason": None}
    assert bgfirst_conti_defect(entry_ok, good) is None
    # 생성 실패 — legacy 하강 금지 사유
    assert "실패" in bgfirst_conti_defect(
        {**entry_ok, "error": "boom"}, good)
    # 미생성(skip)
    assert "skipped" in bgfirst_conti_defect(
        {**entry_ok, "skipped_reason": "no_plate"}, good)
    # 파일 결손 (entry 는 정상인데 파일 소실)
    assert "결손" in bgfirst_conti_defect(entry_ok, tmp_path / "gone.png")
    assert "결손" in bgfirst_conti_defect(entry_ok, None)
    # asset_id 결손 — lineage 없이 진행 금지
    assert "asset_id" in bgfirst_conti_defect(
        {**entry_ok, "asset_id": None}, good)
    # entry 자체 부재
    assert bgfirst_conti_defect(None, None) is not None


class _FakeAsset:
    def __init__(self, aid, rel):
        self.id = aid
        self.file_path = rel
        self.generation_model = None
        self.prompt_used = None
        self.annotations = []


def _fake_registry(existing=None):
    store = {a.file_path: a for a in (existing or [])}
    created = []

    def find(rel):
        return store.get(rel)

    def new(*, rel, asset_type, model, prompt):
        a = _FakeAsset(f"new-{len(created)}", rel)
        a.asset_type = asset_type
        a.generation_model = model
        a.prompt_used = prompt
        created.append(a)
        store[rel] = a
        return a

    def annotate(asset, *, role, input_ids, meta):
        asset.annotations.append(
            {"role": role, "input_ids": list(input_ids), "meta": meta})

    return store, created, find, new, annotate


def test_register_bg_asset_fresh_creates_with_inputs(tmp_path):
    from app.modules.pipeline.still_recipe import register_bgfirst_bg_asset

    bg = tmp_path / "S1sh2__bgfirst_bg.png"
    _store, created, find, new, annotate = _fake_registry()
    asset = register_bgfirst_bg_asset(
        bg_path=bg, prompt="P", input_ids=["conti-a", "plate-a"],
        rel_fn=lambda p: "rel/" + Path(p).name,
        find_asset_by_rel=find, new_asset=new, annotate_fn=annotate,
    )
    assert created == [asset]
    assert asset.asset_type == "bgfirst_bg"
    assert asset.is_intermediate is True  # 재리뷰 2: 중간물 표시
    ann = asset.annotations[-1]
    assert ann["role"] == "bgfirst_bg"
    assert ann["input_ids"] == ["conti-a", "plate-a"]


def test_register_bg_asset_upserts_existing_lineage(tmp_path):
    from app.modules.pipeline.still_recipe import register_bgfirst_bg_asset

    bg = tmp_path / "S1sh2__bgfirst_bg.png"
    rel = "rel/" + bg.name
    prior = _FakeAsset("old-id", rel)
    prior.is_intermediate = False  # 구 row — true 로 정규화돼야 한다
    _store, created, find, new, annotate = _fake_registry([prior])
    asset = register_bgfirst_bg_asset(
        bg_path=bg, prompt="P2", input_ids=["conti-b", "plate-b"],
        rel_fn=lambda p: "rel/" + Path(p).name,
        find_asset_by_rel=find, new_asset=new, annotate_fn=annotate,
    )
    # idempotent — 기존 row 재사용 + lineage/provenance 현재 값 갱신
    assert asset is prior and created == []
    assert asset.prompt_used == "P2"
    assert asset.is_intermediate is True  # 재리뷰 2: existing 정규화
    ann = asset.annotations[-1]
    assert ann["input_ids"] == ["conti-b", "plate-b"]


def test_require_input_ids_fail_closed():
    """재리뷰 1 (HIGH): plate/conti UUID 미해결(row 부재·조회 예외 None)
    = 등록·성공 record 영속 전 ValueError — 불완전 lineage 성공 고착
    금지."""
    from app.modules.pipeline.still_recipe import bgfirst_require_input_ids

    assert bgfirst_require_input_ids(
        conti_asset_id="c", plate_asset_id="p", plate_path="x.png",
    ) == ["c", "p"]
    with pytest.raises(ValueError, match="플레이트"):
        bgfirst_require_input_ids(
            conti_asset_id="c", plate_asset_id=None, plate_path="x.png")
    with pytest.raises(ValueError, match="콘티"):
        bgfirst_require_input_ids(
            conti_asset_id=None, plate_asset_id="p", plate_path="x.png")


def test_structural_skip_excludes_conti_step_declared_nontargets():
    """E2E10 실측 결함 1호: 콘티 스텝이 구조적 비대상으로 선언한 샷
    (skipped_reason=no_plate/prev/bgonly)은 BGFIRST2 정책 대상이 아니다 —
    fail-closed 가 아니라 기존(legacy) 경로 유지.

    근거: shot_conti_light 모듈 계약 "콘티 부재가 스틸을 막지 않는다 —
    플레이트 없는 샷은 skipped_reason='no_plate'". E2E10 에서 no_plate
    10샷 fail-closed + prev 앵커 부재 연쇄 4샷(skipped=prev 인데
    prev_sel=None → prev_used=False 오판정) 실측.
    """
    from app.modules.pipeline.still_recipe import bgfirst_structural_skip

    assert bgfirst_structural_skip({"skipped_reason": "no_plate"}) is True
    assert bgfirst_structural_skip({"skipped_reason": "prev"}) is True
    assert bgfirst_structural_skip({"skipped_reason": "bgonly"}) is True


def test_structural_skip_keeps_genuine_defects_fail_closed():
    """진짜 결손(entry 부재·생성 error·skip 사유 없음)은 여전히 정책 대상
    — bgfirst_conti_defect fail-closed 경로 유지 (Codex BLOCKING 계약)."""
    from app.modules.pipeline.still_recipe import bgfirst_structural_skip

    assert bgfirst_structural_skip(None) is False
    assert bgfirst_structural_skip({}) is False
    assert bgfirst_structural_skip(
        {"skipped_reason": None, "error": "boom"}) is False
    # 미지 사유는 보수적으로 정책 대상 유지 (fail-closed 우선)
    assert bgfirst_structural_skip({"skipped_reason": "unknown"}) is False


def test_structural_skip_error_wins_over_skip_reason():
    """Codex NARROW: 손상·stale CP 에서 error+skipped_reason 공존 시
    error 우선 → 정책 대상 유지(fail-closed) — 구조적 skip 에 가려 legacy
    무음 하강 금지. non-dict persisted entry 도 보수적으로 False."""
    from app.modules.pipeline.still_recipe import bgfirst_structural_skip

    assert bgfirst_structural_skip(
        {"skipped_reason": "no_plate", "error": "boom"}) is False
    assert bgfirst_structural_skip("no_plate") is False  # type: ignore[arg-type]
    assert bgfirst_structural_skip(["no_plate"]) is False  # type: ignore[arg-type]


def test_contract_version_pinned_to_current_chain_prompt_migration():
    """계약 버전을 **지금 판에** 못박는다 — 「올리는 걸 잊지 않았다」 핀.

    이 값은 상수 주석대로 eligibility·체인 조립·2택1 구조가 바뀔 때마다
    올라간다. 지금까지:

      v2 (2026-07-20) 구조적 skip 3종 도입 = eligibility 계약 변경
      v3 (2026-08-27) **ordinary bgfirst 체인 프롬프트 계약** — 재조립
         범위를 `lane_chain or prev_sel` 에서 `bgfirst_used` 로 넓히고
         그 재조립이 빠뜨리던 `handled_by` 를 복원했다. 둘 다 나가는
         프롬프트가 달라진다.

    ★이 핀은 **버전을 올렸는가**만 증명한다. 동작이 옳은지는 서비스
     끝점 시험이 증명한다 — 여기서 값을 갱신하는 것은 migration pin 의
     본래 역할이지 자기 변경을 정답으로 못박는 것이 아니다.
    """
    from app.modules.pipeline.still_recipe import BGFIRST_CONTRACT_VERSION

    assert BGFIRST_CONTRACT_VERSION == 3


# ── BGFIRST full (E2E10 fix③④) ───────────────────────────────────────


def test_pack_nine_registered():
    from app.modules.pipeline.still_recipe import (
        BGFIRST_FULL_PROMPT_VERSION,
        resolve_prompt_version,
    )

    # v10 (E2E11 fix⑥): head 개정 팩 — selector 와 디렉토리 접두 일치 계약
    assert resolve_prompt_version(BGFIRST_FULL_PROMPT_VERSION).startswith(
        BGFIRST_FULL_PROMPT_VERSION + ".")


def test_register_bg_asset_groupbg_type_and_role(tmp_path):
    from app.modules.pipeline.still_recipe import register_bgfirst_bg_asset

    bg = tmp_path / "groupbg_seaside.png"
    _store, created, find, new, annotate = _fake_registry()
    asset = register_bgfirst_bg_asset(
        bg_path=bg, prompt="P", input_ids=["conti-a"],
        rel_fn=lambda p: "rel/" + Path(p).name,
        find_asset_by_rel=find, new_asset=new, annotate_fn=annotate,
        asset_type="bgfirst_group_bg", role="bgfirst_group_bg",
        chain="bgfirst_groupbg",
    )
    assert created == [asset]
    assert asset.asset_type == "bgfirst_group_bg"
    assert asset.is_intermediate is True
    ann = asset.annotations[-1]
    assert ann["role"] == "bgfirst_group_bg"
    assert ann["input_ids"] == ["conti-a"]


def test_full_contract_version_present():
    from app.modules.pipeline.still_recipe import (
        BGFIRST_FULL_CONTRACT_VERSION,
    )

    # v3 (E2E11 ③): groupbg 장소 근거 절(LOCATION DETAIL+SCENE EVIDENCE)
    # 조립 = groupbg 산출 실질 변경 bump (sidecar 재사용 자동 무효화)
    assert BGFIRST_FULL_CONTRACT_VERSION == "bgfirst_full_v3"


def test_bgfirst_winner_lineage_matrix():
    """Codex HIGH-3 — final 직접 첨부 lineage 는 승자 실제 refs 와 동기."""
    from app.modules.pipeline.still_recipe import bgfirst_winner_lineage

    # 체인 승 — 권위/seed 는 Step1 중간 bg 의 input edge 소유 (직접 X)
    for kind in ("plate", "groupbg", "seed_bg"):
        assert bgfirst_winner_lineage(
            chain_won=True, authority_kind=kind,
            structure_seed_attached=True,
        ) == ["conti", "bgfirst_bg"]
    # 무콘티 승 — B 후보 실제 refs=[권위(+seed), 엔티티]
    assert bgfirst_winner_lineage(
        chain_won=False, authority_kind="plate",
        structure_seed_attached=True,
    ) == ["plate", "structure_seed"]
    assert bgfirst_winner_lineage(
        chain_won=False, authority_kind="seed_bg",
        structure_seed_attached=False,
    ) == ["seed_bg"]
    assert bgfirst_winner_lineage(
        chain_won=False, authority_kind="groupbg",
        structure_seed_attached=False,
    ) == ["groupbg"]
    import pytest

    with pytest.raises(ValueError):
        bgfirst_winner_lineage(
            chain_won=False, authority_kind="???",
            structure_seed_attached=False,
        )


# ── 마네킹 유출 3층 가드 (2026-07-26): 소비자 무조건 검사 ────────────


def test_service_rejects_mannequin_cp_when_chain_flag_off():
    """소비자 검사는 lane_chain 분기 **밖**에서 무조건 돌아야 한다."""
    from app.core.errors import AppError
    from app.services.still_recipe_service import (
        _require_mannequin_chain,
    )
    from app.core.steps.shot_conti_light_step import (
        LANE_SKETCH_PACK_VERSION,
    )
    from app.modules.pipeline.outdoor_marker_map import (
        resolve_sketch_pack_version,
    )
    from app.core.steps.shot_conti_light_step import (
        LANE_SKETCH_PACK_VERSION,
    )

    entries = {"SAMPLE_FIXTURE_TAG": {
        "lane": "map_marker", "status": "ok",
        "lane_sketch_pack": resolve_sketch_pack_version(LANE_SKETCH_PACK_VERSION)}}
    # 체인 준비 안 됨 → fail-closed
    with pytest.raises(AppError) as ei:
        _require_mannequin_chain(entries, chain_ready=False)
    assert "mannequin" in str(ei.value.code)
    # 체인 준비됨 → 통과
    _require_mannequin_chain(entries, chain_ready=True)
    # 구 v13 콘티는 마네킹 검사 대상이 아니다 (팩 스탬프 없는 구 CP 도)
    _require_mannequin_chain(
        {"SAMPLE_FIXTURE_TAG": {
            "lane": "map_marker", "status": "ok",
            "lane_sketch_pack": resolve_sketch_pack_version("13")}},
        chain_ready=False)
    _require_mannequin_chain(
        {"SAMPLE_FIXTURE_TAG": {"lane": "map_marker", "status": "ok"}},
        chain_ready=False)
    # 비 lane 프로젝트(빈 CP)·비 dict entry = no-op
    _require_mannequin_chain({}, chain_ready=False)
    _require_mannequin_chain({"SAMPLE_FIXTURE_TAG": None},
                             chain_ready=False)


def test_service_requires_exact_sketch_pack_when_chain_ready():
    """반대 방향 구멍 (2026-07-27 리뷰 I-1) — 체인 ON 인데 구 팩 콘티.

    이 wave 이전에 lane 콘티를 구운 프로젝트의 entry 에는
    `lane_sketch_pack` 키가 **아예 없다**(스탬프가 이 wave 산물).
    운영자가 shot_conti_light 를 다시 굽지 않고 still_recipe/이미지
    스텝만 재개하면 — 파일 상단 주석이 경고하는 바로 그 부분 실행 —
    구 v13 인물 그림에 Step1 이 "마네킹을 회색 그대로 두라"를, Step2 가
    "모든 마네킹을 실제 인물로 바꾸라"를 건다. 전부 조용히, 유료 렌더
    비용을 태우면서. 일반 콘티 팩(conti_pack)을 보는
    bgfirst_conti_pack_mismatch 는 다른 키라 잡지 못한다.
    """
    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_marker_map import (
        resolve_sketch_pack_version,
    )
    from app.core.steps.shot_conti_light_step import (
        LANE_SKETCH_PACK_VERSION,
    )
    from app.services.still_recipe_service import (
        _require_mannequin_chain,
    )

    def _entry(**kw):
        return {"SAMPLE_FIXTURE_TAG": {
            "lane": "map_marker", "status": "ok", **kw}}

    # ① 스탬프 키 부재(구 CP) = fail-closed
    with pytest.raises(AppError) as ei:
        _require_mannequin_chain(_entry(), chain_ready=True)
    assert ei.value.code == "still_recipe.lane_sketch_pack_mismatch"

    # ② 구 팩 값 = fail-closed
    with pytest.raises(AppError) as ei2:
        _require_mannequin_chain(
            _entry(lane_sketch_pack=resolve_sketch_pack_version("13")),
            chain_ready=True)
    assert ei2.value.code == "still_recipe.lane_sketch_pack_mismatch"
    # selector 접두만 같은 값(다른 발행본)도 통과시키지 않는다 — exact
    _cur = resolve_sketch_pack_version(LANE_SKETCH_PACK_VERSION)
    with pytest.raises(AppError):
        _require_mannequin_chain(
            _entry(lane_sketch_pack=_cur.split(".", 1)[0]),
            chain_ready=True)

    # ③ 현행 팩 = 통과
    _require_mannequin_chain(
        _entry(lane_sketch_pack=_cur), chain_ready=True)

    # ④ 스케치를 굽지 않는 entry(structure_plate 정책·실패 격리)는
    #    팩 스탬프 대상이 아니므로 검사 대상도 아니다
    for _st in ("ab_select_pending", "ab_select_bypass", "failed"):
        _require_mannequin_chain(
            {"SAMPLE_FIXTURE_TAG": {"lane": "structure_plate",
                                    "status": _st}},
            chain_ready=True)
    # 비 lane 프로젝트(빈 CP)·비 dict entry = no-op
    _require_mannequin_chain({}, chain_ready=True)
    _require_mannequin_chain({"SAMPLE_FIXTURE_TAG": None}, chain_ready=True)


def test_run_still_recipe_raises_on_stale_sketch_pack_with_chain_on(
        tmp_path, monkeypatch):
    """런타임 증명 — 세 플래그 ON(= 캔ary 가 도는 바로 그 상태)에서 구
    팩 lane CP 를 소비하면 이미지 생성 전에 raise 한다."""
    from app.core.config import settings
    from app.core.errors import AppError
    from app.modules.pipeline.shot_conti_light import (
        resolve_prompt_version as conti_pack_version,
    )
    from app.services.still_recipe_service import (
        run_still_recipe_generation,
    )

    for on in ("still_bgfirst_enabled", "still_bgfirst_full_enabled",
               "still_lane_prev_bgfirst_enabled"):
        monkeypatch.setattr(settings, on, True, raising=False)
    # 2026-08-20: 아래 둘을 안 끄면 하네스가 기계의 .env 를 물어(둘 다 켜짐)
    # 걷기가 **실제 유료 LLM 호출**을 내보낸다 — 시험을 돌릴 때마다 돈이
    # 나간다. 이 파일의 시험들은 간판·시대를 겨누지 않는다.
    # 감시: .venv/bin/python -m pytest ... -p tests.netprobe
    for off in ("signage_author_enabled", "era_research_enabled"):
        monkeypatch.setattr(settings, off, False, raising=False)
    monkeypatch.setattr(settings, "projects_dir", str(tmp_path))

    _write_cp(tmp_path, "shot_ref_classify", {"shots": {}, "scenes": {}})
    _write_cp(tmp_path, "shot_continuity", {"shots": {}})
    _write_cp(tmp_path, "shot_conti_light", {
        "contis": {},
        # 앞선 conti_pack 검사는 통과해야 한다 — 이 구멍은 그 검사가
        # 보지 않는 별개 키(lane_sketch_pack)의 문제다
        "conti_pack": conti_pack_version("5"),
        "lane_conti": {"SAMPLE_FIXTURE_TAG": {
            "lane": "map_marker", "status": "ok",
            "image_path": str(tmp_path / "SAMPLE_FIXTURE_sketch.png"),
        }},
    })

    with pytest.raises(AppError) as ei:
        run_still_recipe_generation(
            db=None, project_id="SAMPLE_FIXTURE_PROJECT",
            episode_id="SAMPLE_FIXTURE_EPISODE",
            stills=[], stills_orm=[], entity_lookup={}, ref_image_map={},
            reference_svc=None, scene_ref_image_map={},
            scene_ref_asset_id_map={}, staging_map={}, scene_cp=None,
            persistence_svc=None, progress=None, project_config=None,
            scene_dir=tmp_path, already_done_stills=set(),
        )
    assert ei.value.code == "still_recipe.lane_sketch_pack_mismatch"


def test_service_mannequin_check_runs_before_lane_chain_branch():
    """구멍의 본체 — 검사 호출이 `lane_chain` 계산보다 **앞**이어야 한다.

    분기 안에서 검사하면 플래그를 내린 순간 lane_chain=False 라 검사
    자체가 실행되지 않는다(= v14 CP 조용한 재사용). 소스 순서를 잠근다.
    """
    import inspect

    from app.services import still_recipe_service as svc

    src = inspect.getsource(svc.run_still_recipe_generation)
    # 문 시작(줄머리)으로 앵커 — 다른 이름의 부분일치 통과 방지
    call_at = src.index("\n    _require_mannequin_chain(")
    branch_at = src.index("\n            lane_chain = ")
    assert call_at < branch_at


def _write_cp(root: Path, step_id: str, data: dict) -> None:
    cp = (root / "SAMPLE_FIXTURE_PROJECT" / "checkpoints" / "episodes"
          / "SAMPLE_FIXTURE_EPISODE" / step_id)
    cp.mkdir(parents=True, exist_ok=True)
    (cp / "manifest.json").write_text(
        json.dumps({"status": "completed", "data": data}),
        encoding="utf-8")


def test_run_still_recipe_raises_on_mannequin_cp_with_chain_off(
        tmp_path, monkeypatch):
    """런타임 증명 — 세 플래그 OFF(= lane_chain 이 False 로 계산되는 바로
    그 상태)에서 v14 CP 를 소비하면 분기에 닿기 전에 raise 한다."""
    from app.core.config import settings
    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_marker_map import (
        resolve_sketch_pack_version,
    )
    from app.core.steps.shot_conti_light_step import (
        LANE_SKETCH_PACK_VERSION,
    )
    from app.services.still_recipe_service import (
        run_still_recipe_generation,
    )

    for off in ("still_bgfirst_enabled", "still_bgfirst_full_enabled",
                "still_lane_prev_bgfirst_enabled"):
        monkeypatch.setattr(settings, off, False, raising=False)
    monkeypatch.setattr(settings, "projects_dir", str(tmp_path))

    _write_cp(tmp_path, "shot_ref_classify", {"shots": {}, "scenes": {}})
    _write_cp(tmp_path, "shot_continuity", {"shots": {}})
    _write_cp(tmp_path, "shot_conti_light", {
        "contis": {},
        "lane_conti": {"SAMPLE_FIXTURE_TAG": {
            "lane": "map_marker", "status": "ok",
            "image_path": str(tmp_path / "SAMPLE_FIXTURE_sketch.png"),
            "lane_sketch_pack": resolve_sketch_pack_version(LANE_SKETCH_PACK_VERSION),
            "mannequin_chain_contract": "1"}},
    })

    with pytest.raises(AppError) as ei:
        run_still_recipe_generation(
            db=None, project_id="SAMPLE_FIXTURE_PROJECT",
            episode_id="SAMPLE_FIXTURE_EPISODE",
            stills=[], stills_orm=[], entity_lookup={}, ref_image_map={},
            reference_svc=None, scene_ref_image_map={},
            scene_ref_asset_id_map={}, staging_map={}, scene_cp=None,
            persistence_svc=None, progress=None, project_config=None,
            scene_dir=tmp_path, already_done_stills=set(),
        )
    assert ei.value.code == "still_recipe.lane_mannequin_chain_off"


def test_run_still_recipe_raises_on_mannequin_cp_with_lane_prev_off(
        tmp_path, monkeypatch):
    """런타임 증명 ② — bgfirst ON + full ON + lane_prev OFF.

    v14 CP 가 가장 그럴듯하게 남는 상태다(체인 ON 으로 구운 뒤 플래그
    하나만 내림): 앞선 conti_pack(v5) 검사를 **통과**하기 때문에 마네킹
    가드가 없으면 그대로 legacy 조립으로 흘러간다.
    """
    from app.core.config import settings
    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_marker_map import (
        resolve_sketch_pack_version,
    )
    from app.core.steps.shot_conti_light_step import (
        LANE_SKETCH_PACK_VERSION,
    )
    from app.modules.pipeline.shot_conti_light import (
        resolve_prompt_version as conti_pack_version,
    )
    from app.services.still_recipe_service import (
        run_still_recipe_generation,
    )

    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)
    monkeypatch.setattr(settings, "projects_dir", str(tmp_path))

    _write_cp(tmp_path, "shot_ref_classify", {"shots": {}, "scenes": {}})
    _write_cp(tmp_path, "shot_continuity", {"shots": {}})
    _write_cp(tmp_path, "shot_conti_light", {
        "contis": {},
        # full ON 기대 팩 — 여기서 걸리지 않고 마네킹 가드까지 가야 한다
        "conti_pack": conti_pack_version("5"),
        "lane_conti": {"SAMPLE_FIXTURE_TAG": {
            "lane": "map_marker", "status": "ok",
            "image_path": str(tmp_path / "SAMPLE_FIXTURE_sketch.png"),
            "lane_sketch_pack": resolve_sketch_pack_version(LANE_SKETCH_PACK_VERSION),
            "mannequin_chain_contract": "1"}},
    })

    with pytest.raises(AppError) as ei:
        run_still_recipe_generation(
            db=None, project_id="SAMPLE_FIXTURE_PROJECT",
            episode_id="SAMPLE_FIXTURE_EPISODE",
            stills=[], stills_orm=[], entity_lookup={}, ref_image_map={},
            reference_svc=None, scene_ref_image_map={},
            scene_ref_asset_id_map={}, staging_map={}, scene_cp=None,
            persistence_svc=None, progress=None, project_config=None,
            scene_dir=tmp_path, already_done_stills=set(),
        )
    assert ei.value.code == "still_recipe.lane_mannequin_chain_off"


# ── lane_conti_only 권위 (2026-07-26 확정 흐름) ───────────────────────


def test_lane_conti_only_input_ids_exempts_plate_only_in_that_mode():
    """외부 사진 0 체인 — 플레이트 UUID 면제는 **이 모드에서만**.

    콘티 UUID 는 여전히 필수이고(불완전 lineage 를 성공 record 로 영속
    금지), 다른 권위 종류는 기존 fail-closed 그대로다.
    """
    from app.modules.pipeline.still_recipe import (
        LANE_CONTI_ONLY,
        bgfirst_require_input_ids,
    )

    ids = bgfirst_require_input_ids(
        conti_asset_id="conti-uuid", plate_asset_id=None,
        plate_path=None, authority_kind=LANE_CONTI_ONLY)
    assert ids == ["conti-uuid"]

    # 콘티 UUID 는 여전히 필수
    with pytest.raises(ValueError):
        bgfirst_require_input_ids(
            conti_asset_id=None, plate_asset_id=None, plate_path=None,
            authority_kind=LANE_CONTI_ONLY)

    # 다른 kind 에서는 면제 없음 (기존 fail-closed 유지)
    with pytest.raises(ValueError):
        bgfirst_require_input_ids(
            conti_asset_id="conti-uuid", plate_asset_id=None,
            plate_path=None, authority_kind="plate")


def test_lane_conti_only_rejects_supplied_plate_input():
    """참조 0 계약 위반(플레이트 입력 동반)은 조용히 통과시키지 않는다 —
    권위가 두 갈래로 갈리면 콘티와 배경이 다른 장소가 된다."""
    from app.modules.pipeline.still_recipe import (
        LANE_CONTI_ONLY,
        bgfirst_require_input_ids,
    )

    with pytest.raises(ValueError, match="참조 0"):
        bgfirst_require_input_ids(
            conti_asset_id="conti-uuid", plate_asset_id="plate-uuid",
            plate_path=None, authority_kind=LANE_CONTI_ONLY)
    with pytest.raises(ValueError, match="참조 0"):
        bgfirst_require_input_ids(
            conti_asset_id="conti-uuid", plate_asset_id=None,
            plate_path="SAMPLE_FIXTURE_plate.png",
            authority_kind=LANE_CONTI_ONLY)


def test_lane_conti_only_final_lineage_has_no_conti_edge():
    from app.modules.pipeline.still_recipe import (
        LANE_CONTI_ONLY,
        bgfirst_winner_lineage,
    )

    # 확정 흐름: Step2 참조에 콘티가 없다 → final direct edge 도 없다
    assert bgfirst_winner_lineage(
        chain_won=True, authority_kind=LANE_CONTI_ONLY,
        structure_seed_attached=False,
        conti_attached=False) == ["bgfirst_bg"]
    # 콘티를 실제로 첨부하는 기존 체인은 그대로
    assert bgfirst_winner_lineage(
        chain_won=True, authority_kind="plate",
        structure_seed_attached=False) == ["conti", "bgfirst_bg"]
    # lane_conti_only + 무콘티 승은 도달 불가 조합 → fail-closed
    with pytest.raises(ValueError):
        bgfirst_winner_lineage(
            chain_won=False, authority_kind=LANE_CONTI_ONLY,
            structure_seed_attached=False, conti_attached=False)


def test_bgfirst_refs_omits_sketch_slot_when_conti_is_none(tmp_path):
    """lane 체인 Step2 는 콘티 슬롯을 생략한다 — 배경본이 이미 배치·
    장소를 담고 있어 중복이고 선 그림 참조는 스케치 선 잔류 위험이다.
    비-lane 호출(콘티 실재)의 참조 순서·라벨은 불변."""
    bg = tmp_path / "SAMPLE_FIXTURE_bg.png"
    conti = tmp_path / "SAMPLE_FIXTURE_conti.png"
    char = [("SAMPLE_FIXTURE_A", tmp_path / "SAMPLE_FIXTURE_a.png")]

    with_conti = build_bgfirst_refs(
        bg=bg, conti=conti, char_refs=char, prop_refs=[])
    without = build_bgfirst_refs(
        bg=bg, conti=None, char_refs=char, prop_refs=[])

    assert len(with_conti) - len(without) == 1
    assert [p for _lb, p in without] == [bg, char[0][1]]
    assert [lb for lb, _p in with_conti][0] == [
        lb for lb, _p in without][0]


def _service_src() -> str:
    import inspect

    from app.services import still_recipe_service as svc

    return inspect.getsource(svc.run_still_recipe_generation)


def test_lane_chain_authority_is_entry_assert_not_branch_condition():
    """진입 assert 의 본체 — lane 판정이 plate/seed_bg 분기보다 **앞**이고,
    플레이트가 공급되면 조용히 plate 권위로 떨어지는 대신 죽는다.

    `plate is None` 을 분기 **조건**으로 두면, plate_map 이 lane 샷에
    플레이트를 물린 순간(서비스는 체인 ON 이면 그 조회를 되살린다) 조건이
    빗나가 권위가 "plate" 로 떨어지고 Step1 참조가 [콘티, 외부 플레이트]
    가 된다 — 이 흐름이 없애려던 구도 불일치 그 자체다. 이 상태를 실물
    샷으로 재현하려면 LLM·이미지 생성 전 구간이 필요해, 서비스 본문의
    분기 구조를 AST 로 잠근다(문자열 매칭보다 강한 구조 계약).
    """
    import ast

    tree = ast.parse(_service_src())
    # 권위 해석 블록 = `if bgfirst_full_on:` 중 lane 판정을 품은 것
    # (같은 조건의 다른 블록이 여럿이라 조건만으로는 특정되지 않는다)
    heads = [
        st
        for n in ast.walk(tree)
        if isinstance(n, ast.If) and ast.unparse(n.test) == "bgfirst_full_on"
        for st in n.body
        if isinstance(st, ast.If) and "lane_chain" in ast.unparse(st.test)
    ]
    assert len(heads) == 1, "lane 권위 판정 지점은 하나여야 한다"
    head = heads[0]
    # ① 조건이 정확히 `lane_chain` — `plate is None and lane_chain` 금지
    assert ast.unparse(head.test) == "lane_chain"

    # ② 사슬의 나머지 어디에도 lane_chain 이 없다 = lane 샷이 plate/
    #    seed_bg/prev/groupbg 분기로 흘러갈 경로 자체가 없다
    node = head
    while (len(node.orelse) == 1
           and isinstance(node.orelse[0], ast.If)):
        node = node.orelse[0]
        assert "lane_chain" not in ast.unparse(node.test)

    # ③ 분기 첫 문장이 공급 거부 가드 — 플레이트/seed_bg 가 오면 raise
    guard = head.body[0]
    assert isinstance(guard, ast.If)
    assert ast.unparse(guard.test) == (
        "plate is not None or seed_bg_path is not None")
    assert isinstance(guard.body[0], ast.Raise)

    # ④ 본체까지 잠근다 — ①②③ 은 머리와 가드 **모양**만 본다. 순서도
    #    가드도 그대로 둔 채 본체에서 `_authority_kind = "plate"` 로
    #    되돌리면 전부 초록인 채 Step1 참조가 [콘티, 외부 플레이트] 로
    #    돌아간다. 이 분기의 산출(LANE_CONTI_ONLY·권위 파일 None)이 곧
    #    "외부 사진 0" 계약이므로 값 자체를 계약으로 고정한다.
    body_assigns = {
        ast.unparse(st.targets[0]): ast.unparse(st.value)
        for st in head.body
        if isinstance(st, ast.Assign) and len(st.targets) == 1
    }
    assert body_assigns.get("_authority_kind") == "LANE_CONTI_ONLY"
    assert body_assigns.get("_authority_path") == "None"
    assert body_assigns.get("_authority_aid") == "None"


def test_chain_only_decided_before_b_candidate_assembly():
    """B 조립 앞 판정이 lane 샷의 생사 — 순서를 소스로 잠근다.

    아래로 되돌리면 LANE_CONTI_ONLY 는 plate·seed_bg 가 모두 None 이라
    build_ab_branch_refs 의 "A/B 는 LOCATION 권위 필수" ValueError 로 lane
    샷이 전량 죽는데, 순수 함수 테스트만으로는 아무것도 붉어지지 않는다.
    """
    src = _service_src()
    assert (src.index("\n                    _chain_only = lane_chain")
            < src.index("_, refs_b = build_ab_branch_refs("))


def test_chain_only_branch_critiques_selected_prompt_only():
    """2026-07-27 리뷰 I-2 — 체인 단독 멀티롤의 critique 프롬프트.

    이 kwarg 가 없으면 multiroll_select 가 critique 프롬프트를
    f"{_chain_prompt}\\n\\n{prompt}" 로 합성한다. `prompt` 는
    lane_ref_mode="sketch" 조립이라 "첨부된 STORYBOARD SKETCH 가 배치를
    고정한다 / 장소 사진은 첨부되지 않았다" 고 말하는데, 이 wave 에서
    스케치는 **의도적으로 미첨부**이고 _chain_prompt 는 배경본을 장소
    권위로 선언한다(location_lock_chain_bg) — 서로 배타인 LOCATION lock
    2개, 그중 하나는 없는 참조를 가리킨다. 그 상태로 매긴 VLM 지적은
    unfixable 마킹이 없어 build_fix_prompt 로 흘러 정상 스틸을 고친다.
    A/B 분기(~:2649)는 이미 넘기고 있으니 비대칭 자체가 결함이다.
    """
    import ast

    tree = ast.parse(_service_src())
    ifs = [
        n for n in ast.walk(tree)
        if isinstance(n, ast.If) and ast.unparse(n.test) == "_chain_only"
    ]
    assert len(ifs) == 1, "체인 단독 분기는 하나여야 한다"
    # orelse(A/B 분기)는 제외 — body 안의 호출만 본다
    calls = [
        c
        for st in ifs[0].body
        for c in ast.walk(st)
        if isinstance(c, ast.Call) and ast.unparse(c.func) == "_run_branch"
    ]
    assert len(calls) == 1
    kw = {k.arg: ast.unparse(k.value) for k in calls[0].keywords}
    assert kw.get("critique_selected_prompt_only") == "True"
    # 전제 — roll_prompts 가 함께 공급된다(없으면 multiroll_select 가
    # ValueError 로 거부한다)
    assert "roll_prompts" in kw


def test_service_threads_lane_lineage_and_ref_drop():
    """lane 체인의 Step2 콘티 제거·direct edge 정정은 기본값을 되돌려도
    테스트가 초록이라 되돌리기 쉽다 — 배선 자체를 소스로 잠근다.

    문 시작(줄머리+들여쓰기)으로 앵커한다 — 맨 substring 이면 같은 문구를
    인용한 주석 한 줄만으로도 통과해 배선이 사라진 걸 못 잡는다.
    """
    src = _service_src()
    assert "\n                        conti_attached=not lane_chain," in src
    assert (
        "\n                        bg=bg_path, "
        "conti=None if lane_chain else conti," in src
    )


# ── 사전 플레이트 재유입 차단 (2026-07-26 계약) ───────────────────────


SAMPLE_FIXTURE_LANE_SPEC = {
    "layout_narration_en": "The site is a paved open yard reached from a "
                           "single lane, enclosed by a low wall.",
    "zone_labels_en": ["Yard"],
    "items": [
        {"code": "A1", "kind": "wall", "name_en": "low perimeter wall",
         "placement_en": "Runs along the far edge of the yard.",
         "inferred": False, "temporal_scope": "persistent_site"},
    ],
    "excluded_transient_elements": [],
}
SAMPLE_FIXTURE_WORLD_RULES = {
    "region": "SAMPLE_FIXTURE_REGION",
    "era": "SAMPLE_FIXTURE_ERA",
    "rules": [],
}


def _run_lane_shot(
    tmp_path, monkeypatch, *,
    person_visible: bool,
    place_spec: Optional[dict] = SAMPLE_FIXTURE_LANE_SPEC,
    world_rules: Optional[dict] = SAMPLE_FIXTURE_WORLD_RULES,
    place_group: str = "SAMPLE_FIXTURE_PLACE_GROUP",
):
    """플레이트가 **배정된** lane 샷 1개를 실제 레시피 루프에 태운다.

    background_render CP 가 그 샷을 배경 그룹에 물려 놓은 상태 —
    실측(9 에피소드)에서 bgfirst 대상 lane 샷 57개 중 14개가 이 모양이다.
    이미지/LLM 호출은 전부 sentinel 로 막고, 관찰 대상은 ①플레이트 해석
    결과(build_still_refs 의 plate 인자) ②Step1 참조 목록 ③Step1 프롬프트
    ④샷이 어떤 사유로 멈췄는가 뿐이다. 시나리오 의존 0.

    place_spec/world_rules 를 None 으로 주면 그 CP 를 아예 쓰지 않는다 —
    T7 fail-closed(사실 결손 시 배경 생성 금지) 검증용.
    """
    from contextlib import ExitStack
    from unittest.mock import MagicMock, patch

    from app.core.config import settings
    from app.modules.pipeline import still_recipe as sr_mod
    from app.modules.pipeline.outdoor_marker_map import (
        resolve_sketch_pack_version,
    )
    from app.core.steps.shot_conti_light_step import (
        LANE_SKETCH_PACK_VERSION,
    )
    from app.modules.pipeline.shot_conti_light import (
        resolve_prompt_version as conti_pack_version,
    )
    from app.services.still_recipe_service import (
        run_still_recipe_generation,
    )

    for on in ("still_bgfirst_enabled", "still_bgfirst_full_enabled",
               "still_lane_prev_bgfirst_enabled"):
        monkeypatch.setattr(settings, on, True, raising=False)
    for off in ("still_variants_enabled", "still_plate_select_enabled",
                "still_conti_ab_enabled", "multiroll_fix_rejudge_enabled",
                "multiroll_gpt_composition_enabled",
                "still_recipe_camera_frame_enabled",
                "still_recipe_lighting_enabled",
                "still_recipe_conduct_enabled",
                "background_share_plan_enabled"):
        monkeypatch.setattr(settings, off, False, raising=False)
    monkeypatch.setattr(settings, "projects_dir", str(tmp_path))
    monkeypatch.setattr(settings, "openai_api_key", "SAMPLE_FIXTURE_KEY")

    sketch = tmp_path / "SAMPLE_FIXTURE_sketch.png"
    sketch.write_bytes(b"SAMPLE_FIXTURE_sketch")
    plate = tmp_path / "SAMPLE_FIXTURE_plate.png"
    plate.write_bytes(b"SAMPLE_FIXTURE_plate")

    _write_cp(tmp_path, "shot_ref_classify", {
        "shots": {"S1sh1": {"person_visible": person_visible,
                            "place_en": "SAMPLE FIXTURE PLACE"}},
        "scenes": {"1": {"place_en": "SAMPLE FIXTURE PLACE",
                         "time_of_day_en": "day"}},
        "world_anchor_en": "",
    })
    _write_cp(tmp_path, "shot_continuity", {"pose_canon": []})
    _write_cp(tmp_path, "shot_conti_light", {
        "contis": {},
        "conti_pack": conti_pack_version("5"),
        "lane_conti": {"S1sh1": {
            "lane": "map_marker", "status": "ok",
            "image_path": str(sketch),
            "asset_id": "SAMPLE_FIXTURE_SKETCH_UUID",
            # T7: 장소 사실 조회 키 — lane CP 실측 필드
            "group_id": place_group,
            "lane_sketch_pack": resolve_sketch_pack_version(LANE_SKETCH_PACK_VERSION),
            "mannequin_chain_contract": "1"}},
    })
    # 이 샷에 사전 배경 플레이트가 배정돼 있다 = 재유입의 실물 조건
    _write_cp(tmp_path, "background_render", {"groups": {
        "SAMPLE_FIXTURE_BG": {"status": "ok", "png_path": str(plate),
                              "shot_ids": ["S1_Shot1"]}}})
    # T7: 참조 0 배경의 장소·world 권위(텍스트). 성공 entry 는 status 키가
    # 없는 shape — spec dict + error 부재가 성공 조건이다.
    if place_spec is not None:
        _write_cp(tmp_path, "outdoor_place_spec", {
            "groups": {place_group: {
                "spec": place_spec, "attempts": 1,
                "outdoor_loc_ids": ["SAMPLE_FIXTURE_LOC"],
                "scene_indices": [1]}}})
    if world_rules is not None:
        _write_cp(tmp_path, "visual_world_rules", world_rules)

    seen: dict = {"refs_plate": [], "step1_refs": [], "step1_prompt": [],
                  "prompt_kw": [], "prompt_out": []}
    real_build_still_refs = sr_mod.build_still_refs
    real_build_still_prompt = sr_mod.build_still_prompt

    def _spy_refs(**kw):
        seen["refs_plate"].append(kw.get("plate"))
        return real_build_still_refs(**kw)

    def _spy_prompt(**kw):
        # 스틸 프롬프트의 LOCATION 권위 모드 관찰 — 참조(위 _spy_refs)와
        # 프롬프트가 같은 말을 하는지 대조하는 것이 I-3 의 본체다.
        seen["prompt_kw"].append(dict(kw))
        out = real_build_still_prompt(**kw)
        seen["prompt_out"].append(out)
        return out

    def _step1_sentinel(*_a, **kw):
        seen["step1_refs"].append(list(kw.get("ref_paths") or []))
        seen["step1_prompt"].append(kw.get("prompt") or "")
        raise RuntimeError("SAMPLE_FIXTURE step1 blocked")

    still = {
        "id": "SAMPLE_FIXTURE_STILL", "still_index": 0,
        "scene_index": 1, "shot_index": 1,
        "screenplay_scene_heading": "S#1. SAMPLE", "beat_title": "",
        "still_frame_prompt": "SAMPLE still prompt",
        "camera_json": "{}", "lighting_json": "{}",
        "visible_entities_json": "[]", "dependent_scene_id": None,
    }
    db = MagicMock()
    _q = MagicMock()
    for _m in ("filter", "filter_by", "join", "order_by", "options"):
        getattr(_q, _m).return_value = _q
    _q.all.return_value = []
    _q.first.return_value = None
    db.query.return_value = _q
    scene_cp = MagicMock()

    with ExitStack() as stack:
        for pch in (
            patch("app.modules.pipeline.still_recipe.build_still_refs",
                  _spy_refs),
            patch("app.modules.pipeline.still_recipe.build_still_prompt",
                  _spy_prompt),
            patch("app.modules.pipeline.multiroll_gemini.make_nb2_gen_fn",
                  return_value=MagicMock()),
            patch("app.modules.pipeline.multiroll_gemini."
                  "make_gemini_judge_fn", return_value=MagicMock()),
            patch("app.modules.pipeline.multiroll_gemini."
                  "make_gemini_critique_fn", return_value=MagicMock()),
            patch("app.modules.llm.gpt_image_primitive.call_gpt_image_bytes",
                  _step1_sentinel),
            patch("app.modules.pipeline.multiroll_select."
                  "run_multiroll_select",
                  side_effect=RuntimeError("SAMPLE_FIXTURE gen blocked")),
        ):
            stack.enter_context(pch)
        run_still_recipe_generation(
            db=db, project_id="SAMPLE_FIXTURE_PROJECT",
            episode_id="SAMPLE_FIXTURE_EPISODE",
            stills=[still], stills_orm=[], entity_lookup={},
            ref_image_map={}, reference_svc=MagicMock(),
            scene_ref_image_map={}, scene_ref_asset_id_map={},
            staging_map={}, scene_cp=scene_cp,
            persistence_svc=MagicMock(), progress=MagicMock(),
            project_config=None, scene_dir=tmp_path / "scene",
            already_done_stills=set(),
        )
    seen["failed"] = [
        str(c.args[1]) for c in scene_cp.mark_failed.call_args_list]
    seen["plate"] = plate
    seen["sketch"] = sketch
    return seen


def test_lane_shot_with_assigned_plate_resolves_lane_conti_only(
        tmp_path, monkeypatch):
    """실측 결함의 회귀 잠금 — 플레이트가 배정된 lane 샷이 **죽지 않고**
    LANE_CONTI_ONLY 로 간다.

    직전 판은 체인 ON 이면 plate_map 조회를 되살렸고, 그 값이 곧바로 같은
    함수의 진입 assert 에 걸려 샷이 전량 mark_failed 됐다(실측 14/57).
    플레이트 해석 자체를 끊는 것이 고침이고, assert 는 이중 방어로 남는다.
    """
    seen = _run_lane_shot(tmp_path, monkeypatch, person_visible=True)

    # ① 플레이트를 애초에 해석하지 않는다
    assert seen["refs_plate"] == [None]
    # ② 진입 assert 에 걸려 죽지 않는다 — 멈춤 사유는 Step1 sentinel 뿐
    assert seen["failed"] == ["SAMPLE_FIXTURE step1 blocked"]
    assert not any("사전 배경 플레이트 금지" in m for m in seen["failed"])
    # ③ LANE_CONTI_ONLY 의 산출 = Step1 참조 [콘티] 1장 (외부 사진 0)
    assert seen["step1_refs"] == [[seen["sketch"]]]


def test_bg_only_lane_shot_keeps_its_plate_reference(tmp_path, monkeypatch):
    """knock-on 잠금 — bg_only lane 샷의 플레이트는 끊지 않는다.

    bg_only 는 bgfirst_eligible_full 이 제외해 LANE_CONTI_ONLY 에 아예
    닿지 않는다(assert 대상 아님). 그런데 체인 ON 에서는 스케치가 conti
    슬롯으로 옮겨가고 build_still_refs 의 bg_only 조기 return 이 conti
    슬롯 앞에서 끝나므로, 플레이트가 이 샷의 **유일한** 참조다 — 함께
    끊으면 참조 0(text-only)로 떨어진다.
    """
    seen = _run_lane_shot(tmp_path, monkeypatch, person_visible=False)

    assert seen["refs_plate"] == [seen["plate"]]
    # 체인 비대상이라 Step1 자체를 타지 않는다
    assert seen["step1_refs"] == []
    assert seen["failed"] == ["SAMPLE_FIXTURE gen blocked"]


def test_bg_only_lane_shot_prompt_matches_its_attached_plate(
        tmp_path, monkeypatch):
    """2026-07-27 리뷰 I-3 — 되살린 플레이트와 프롬프트의 LOCATION 권위가
    같은 말을 해야 한다.

    바로 위 테스트가 지키는 플레이트 재유입은 build_still_refs 에서
    "LOCATION PHOTOGRAPH(건축·재질·고정물·조명이 공간 진실)" 라벨로
    붙는다. 그런데 lane_ref_mode 가 lane_used 만 보고 무조건 "sketch"
    면 프롬프트는 "첨부된 STORYBOARD SKETCH 가 배치를 고정하고 장소
    사진은 첨부되지 않았다" 고 단언한다 — 방금 붙인 유일한 참조를
    부정하는 문장이라 모델이 그걸 무시한다. wave 이전엔 참조가 실제로
    0 이라 자기정합이었고, 이 모순은 wave 가 만든 것이다.
    """
    seen = _run_lane_shot(tmp_path, monkeypatch, person_visible=False)

    # 참조: 플레이트 1장 (위 테스트와 동일 전제)
    assert seen["refs_plate"] == [seen["plate"]]
    # 프롬프트: 이 샷은 sketch 권위를 주장하지 않는다
    assert [kw.get("lane_ref_mode") for kw in seen["prompt_kw"]] == [""]
    body = " ".join(seen["prompt_out"][0].split())
    assert "STORYBOARD SKETCH" not in body
    assert "the attached LOCATION PHOTOGRAPH shows the exact spot." in body


def test_person_visible_lane_shot_keeps_sketch_location_lock(
        tmp_path, monkeypatch):
    """I-3 의 반대편 — 참조 0 인 본 경로는 sketch 권위 그대로.

    조건을 넓게 잡아 lane 전체에서 sketch lock 을 걷어내면 참조가 진짜로
    0 인 샷이 존재하지 않는 LOCATION PHOTOGRAPH 를 가리키게 된다(v3 에서
    한 번 제거한 모순의 재발). 두 방향을 함께 잠근다.
    """
    seen = _run_lane_shot(tmp_path, monkeypatch, person_visible=True)

    # base=sketch 권위, 체인 저작 base=권위 모드 없음(배경본이 첫 참조)
    assert [kw.get("lane_ref_mode") for kw in seen["prompt_kw"]] == [
        "sketch", ""]
    assert [kw.get("chain_bg_mode") for kw in seen["prompt_kw"]] == [
        None, True]


# ── T7: 장소·world 텍스트 권위 (2026-07-26 확정 흐름) ────────────────
#
# 참조 이미지 0 으로 사라진 장소 외형 권위를 텍스트로 대체한다. 사용자
# 확정: "할루시네이션 안 생기게 너무 디테일하게 넣으면 안 돼" — 절제
# 계약이 이 블록의 본질이라 테스트가 '넣는 것'과 '빼는 것'을 함께 핀한다.

SAMPLE_FIXTURE_SPEC = {
    "layout_narration_en": "The site is a paved waiting area beside a "
                           "vehicle roadway, with a shoreline behind it.",
    "zone_labels_en": ["Waiting Area"],
    "items": [
        {"code": "B1", "kind": "shelter",
         "name_en": "roofed waiting shelter",
         "placement_en": "Stands at the middle of the waiting area.",
         "inferred": False, "temporal_scope": "persistent_site"},
        {"code": "B2", "kind": "sign",
         "name_en": "fixed identification sign",
         "placement_en": "Beside the roadway edge.",
         # 감사 필드 전수 커버 — evidence/inferred 는 실제 저작 산출에
         # 붙는데 fixture 에 없으면 재유입해도 테스트가 초록이었다
         "evidence": {"scene_index": 3, "quote_ko": "SAMPLE_FIXTURE_QUOTE"},
         "inferred": True, "temporal_scope": "persistent_site"},
    ],
    "excluded_transient_elements": [
        {"name_en": "temporary police control line",
         "reason_en": "installed for the investigation"},
    ],
}


def test_place_facts_block_is_restrained():
    from app.modules.pipeline.still_recipe import build_place_facts_block

    block = build_place_facts_block(SAMPLE_FIXTURE_SPEC)
    # 행 전수 고정 — 부분 문자열 부재 나열은 새 필드가 늘 때마다 빠뜨린다.
    # 이 한 줄이 배치 서술/코드/evidence/inferred/temporal_scope/zone/
    # 제외 목록 전부의 미주입을 동시에 잠근다(narration + kind/name 만).
    assert block.splitlines() == [
        "The site is a paved waiting area beside a vehicle roadway, "
        "with a shoreline behind it.",
        "- shelter: roofed waiting shelter",
        "- sign: fixed identification sign",
    ]


def test_place_facts_block_degrades_on_non_dict():
    """비정형 입력은 빈 문자열 — 판정은 호출부(fail-closed)의 몫."""
    from app.modules.pipeline.still_recipe import build_place_facts_block

    assert build_place_facts_block(None) == ""
    assert build_place_facts_block([]) == ""
    assert build_place_facts_block({}) == ""


def test_place_facts_block_dedupes_and_skips_nameless():
    from app.modules.pipeline.still_recipe import build_place_facts_block

    block = build_place_facts_block({
        "layout_narration_en": "SAMPLE_FIXTURE narration.",
        "items": [
            {"kind": "bench", "name_en": "long seating bench"},
            {"kind": "bench", "name_en": "long seating bench"},
            {"kind": "post", "name_en": ""},
            {"name_en": "unlabelled kind item"},
            "not-a-dict",
        ],
    })
    assert block.count("long seating bench") == 1
    assert "- unlabelled kind item" in block  # kind 부재=이름만
    assert block.splitlines() == [
        "SAMPLE_FIXTURE narration.",
        "- bench: long seating bench",
        "- unlabelled kind item",
    ]


def test_lane_pack_registered():
    from app.modules.pipeline.still_recipe import (
        BGFIRST_LANE_PROMPT_VERSION,
        resolve_prompt_version,
    )

    # selector 와 디렉토리 접두 일치 계약 (v7/v11 선례와 동형)
    assert resolve_prompt_version(BGFIRST_LANE_PROMPT_VERSION).startswith(
        BGFIRST_LANE_PROMPT_VERSION + ".")


def test_bg_fill_prompt_requires_place_and_world_facts():
    from app.modules.pipeline.still_recipe import (
        BGFIRST_LANE_PROMPT_VERSION,
        build_bgfirst_bg_prompt,
    )

    p = build_bgfirst_bg_prompt(
        shot_desc="SAMPLE_FIXTURE_SHOT",
        place_text="SAMPLE_FIXTURE_PLACE",
        time_of_day_en="night",
        place_facts_block="- shelter: roofed waiting shelter",
        world_facts_block="- Region (real-world reference): SAMPLE",
        lane_fill=True,
        prompt_version=BGFIRST_LANE_PROMPT_VERSION)
    assert "roofed waiting shelter" in p
    assert "Region (real-world reference)" in p
    # 마네킹 보존 계약이 실려야 한다
    assert "mannequin" in p.lower()
    # 발명 유도 문구는 없어야 한다
    assert "such a place really has" not in p

    # lane_fill 인데 사실 블록이 비면 fail-closed
    with pytest.raises(ValueError):
        build_bgfirst_bg_prompt(
            shot_desc="S", place_text="P", time_of_day_en="night",
            place_facts_block="", world_facts_block="- Region: X",
            lane_fill=True, prompt_version=BGFIRST_LANE_PROMPT_VERSION)
    with pytest.raises(ValueError):
        build_bgfirst_bg_prompt(
            shot_desc="S", place_text="P", time_of_day_en="night",
            place_facts_block="- shelter: x", world_facts_block="",
            lane_fill=True, prompt_version=BGFIRST_LANE_PROMPT_VERSION)


def test_bg_fill_prompt_order_and_stems():
    """bg_fill 조립 순서 — head → SHOT/LOCATION/TIME → CAM/LIGHT →
    장소 사실 → world 사실 → tail."""
    from app.modules.pipeline.still_recipe import (
        BGFIRST_LANE_PROMPT_VERSION,
        build_bgfirst_bg_prompt,
    )

    p = build_bgfirst_bg_prompt(
        shot_desc="SAMPLE_FIXTURE_SHOT",
        place_text="SAMPLE_FIXTURE_PLACE",
        time_of_day_en="night",
        camera_frame_en="CAM-CLAUSE",
        lighting_mood_en="LIGHT-CLAUSE",
        place_facts_block="- shelter: roofed waiting shelter",
        world_facts_block="- Era: SAMPLE_FIXTURE_ERA",
        lane_fill=True,
        prompt_version=BGFIRST_LANE_PROMPT_VERSION)
    # 스템 자체가 여러 단락이라 "\n\n" split 은 절 경계가 아니다 —
    # 등장 위치 순서로 조립 계약을 핀한다.
    marks = [
        "Turn the attached storyboard sketch into",
        "SHOT TEXT this background must serve (Korean): SAMPLE_FIXTURE_SHOT",
        "LOCATION (lock): SAMPLE_FIXTURE_PLACE",
        "TIME OF DAY (lock): night.",
        "CAM-CLAUSE",
        "LIGHT-CLAUSE",
        "THINGS AT THIS PLACE:\n- shelter: roofed waiting shelter",
        "WORLD FACTS (creator-confirmed — always true):\n"
        "- Era: SAMPLE_FIXTURE_ERA",
        "THINGS THAT LIVE AT THIS PLACE",
    ]
    assert p.startswith(marks[0])
    positions = [p.index(m) for m in marks]
    assert positions == sorted(positions)
    # 순서만으로는 절 구분자가 "\n" 으로 무너져도 통과한다 — 사실 블록이
    # 앞 절에 붙어버리면 tail 의 "the list above" 지시가 흐려지므로
    # 빈 줄 경계를 함께 핀한다.
    assert "\n\nTHINGS AT THIS PLACE:" in p
    assert "\n\nWORLD FACTS (creator-confirmed — always true):" in p
    # 줄바꿈 위치(하드랩)는 팩 재래핑으로 움직이는 서식이라 계약이 아니다
    # — 공백 정규화 후 tail 문장으로 핀한다("tail 이 마지막"이라는 순서
    # 계약은 위 positions assert 가 이미 담당).
    # ★tail 문장을 **팩에서 읽어** 견준다 (2026-09-20). 문안을 여기 적어
    #  두면 표기 정책을 고칠 때마다 이 순서 시험이 같이 깨진다 — 이 시험이
    #  잠그는 것은 **tail 이 맨 끝**이라는 것이지 그 문장이 아니다.
    from app.modules.pipeline.still_recipe import (
        TEXT_POLICY_PROMPT_VERSION, resolve_prompt_version)
    from app.modules.prompt_loader import load_prompt
    _tail = " ".join(load_prompt(
        "still_recipe", "bg_fill_tail",
        version=resolve_prompt_version(TEXT_POLICY_PROMPT_VERSION)).split())
    assert " ".join(p.split()).endswith(_tail)
    # lane 경로는 외부 사진 스템을 쓰지 않는다 (참조 0 계약)
    assert "LOCATION PHOTOGRAPH" not in p
    assert "RE-PROJECT" not in p


def test_non_lane_bg_prompt_is_byte_identical():
    """기존 경로는 새 인자 default 로 조립 결과가 변하지 않는다."""
    from app.modules.pipeline.still_recipe import (
        BGFIRST_FULL_PROMPT_VERSION,
        build_bgfirst_bg_prompt,
    )

    for _sel in (BGFIRST_PROMPT_VERSION, BGFIRST_FULL_PROMPT_VERSION):
        kw = dict(shot_desc="S", place_text="P", time_of_day_en="night",
                  camera_frame_en="CAM", lighting_mood_en="LIGHT",
                  prompt_version=_sel)
        assert build_bgfirst_bg_prompt(**kw) == build_bgfirst_bg_prompt(
            **kw, place_facts_block="", world_facts_block="",
            lane_fill=False)
        # 비 lane 은 사실 블록이 실려도 조립이 변하지 않는다(무시)
        assert build_bgfirst_bg_prompt(**kw) == build_bgfirst_bg_prompt(
            **kw, place_facts_block="- x: y",
            world_facts_block="- Era: Z", lane_fill=False)


def test_lane_contract_version_present():
    from app.modules.pipeline.still_recipe import (
        BGFIRST_LANE_CONTRACT_VERSION,
    )

    assert BGFIRST_LANE_CONTRACT_VERSION == "1"


def test_lane_step1_prompt_carries_place_and_world_facts(
        tmp_path, monkeypatch):
    """배선 증명 — 참조 0 인 lane 샷의 Step1 프롬프트에 장소 사실
    (narration+kind/name)과 world 사실(지역·시대)이 실제로 실린다.

    T7 이전 서비스는 outdoor_place_spec CP 를 **로드조차** 하지 않았고
    world 는 classify.world_anchor_en 한 줄뿐이라, 모듈만 고쳐서는 효과가
    0 이었다.
    """
    seen = _run_lane_shot(tmp_path, monkeypatch, person_visible=True)

    assert len(seen["step1_prompt"]) == 1
    p = seen["step1_prompt"][0]
    # bg_fill 스템 (참조 0 i2i 채색 — 재투영 스템 아님)
    assert p.startswith("Turn the attached storyboard sketch into")
    assert "mannequin" in p.lower()
    assert "LOCATION PHOTOGRAPH" not in p
    # 장소 사실 — narration + kind/name
    assert "paved open yard" in p
    assert "- wall: low perimeter wall" in p
    # 배치 서술은 텍스트로 주지 않는다(콘티 선이 유일 배치 권위)
    assert "Runs along the far edge" not in p
    assert "A1" not in p
    # world 사실
    assert "- Region (real-world reference): SAMPLE_FIXTURE_REGION" in p
    assert "- Era: SAMPLE_FIXTURE_ERA" in p


def test_lane_step1_fails_closed_without_place_spec(tmp_path, monkeypatch):
    """장소 스펙 CP 결손 = 무국적 배경 — 조용히 만들지 않고 샷을 죽인다."""
    seen = _run_lane_shot(
        tmp_path, monkeypatch, person_visible=True, place_spec=None)

    assert seen["step1_prompt"] == []
    assert len(seen["failed"]) == 1
    assert "place spec 그룹" in seen["failed"][0]
    assert "fail-closed" in seen["failed"][0]


def test_lane_step1_fails_closed_on_failed_place_spec_group(
        tmp_path, monkeypatch):
    """실패 entry(error 보유)도 성공으로 읽지 않는다 — 성공 shape 은
    status 키가 없어 `status == "ok"` 식 판정이 아예 성립하지 않는다."""
    from app.services import still_recipe_service as svc

    real_load_cp = svc._load_cp

    def _fake_load_cp(projects_dir, project_id, episode_id, step_id):
        if step_id == "outdoor_place_spec":
            return {"data": {"groups": {"SAMPLE_FIXTURE_PLACE_GROUP": {
                "error": "SAMPLE_FIXTURE spec authoring failed",
                "outdoor_loc_ids": [], "scene_indices": [1]}}}}
        return real_load_cp(projects_dir, project_id, episode_id, step_id)

    monkeypatch.setattr(svc, "_load_cp", _fake_load_cp)
    seen = _run_lane_shot(tmp_path, monkeypatch, person_visible=True)

    assert seen["step1_prompt"] == []
    assert "SAMPLE_FIXTURE spec authoring failed" in seen["failed"][0]


def test_lane_step1_fails_closed_on_skipped_place_spec_group(
        tmp_path, monkeypatch):
    """저작 스킵 entry(근거 씬 0)는 세 번째 shape — spec 키가 없어 어차피
    죽지만, 운영자에게 "dict 아님"이 아니라 "스킵"으로 보여야 원인(씬
    매핑 결손)에 닿는다."""
    from app.services import still_recipe_service as svc

    real_load_cp = svc._load_cp

    def _fake_load_cp(projects_dir, project_id, episode_id, step_id):
        if step_id == "outdoor_place_spec":
            return {"data": {"groups": {"SAMPLE_FIXTURE_PLACE_GROUP": {
                "skipped": "no scenes mapped to outdoor locs",
                "outdoor_loc_ids": ["SAMPLE_FIXTURE_LOC"]}}}}
        return real_load_cp(projects_dir, project_id, episode_id, step_id)

    monkeypatch.setattr(svc, "_load_cp", _fake_load_cp)
    seen = _run_lane_shot(tmp_path, monkeypatch, person_visible=True)

    assert seen["step1_prompt"] == []
    assert "저작 스킵" in seen["failed"][0]
    assert "근거 씬이 매핑되지 않아" in seen["failed"][0]
    assert "dict 아님" not in seen["failed"][0]


def test_lane_step1_fails_closed_without_world_rules(tmp_path, monkeypatch):
    """world CP 부재 → build_world_facts_block 이 "" 를 돌려주고(설계상
    비-lane 소비자 byte-identical), lane 은 그 빈 블록에서 명시적으로
    죽는다 — 의존 선언만으로는 무국적 배경을 못 막는다."""
    seen = _run_lane_shot(
        tmp_path, monkeypatch, person_visible=True, world_rules=None)

    assert seen["step1_prompt"] == []
    assert "world_facts_block 이 비어 있음" in seen["failed"][0]


def test_lane_facts_are_not_wired_into_non_lane_paths():
    """비 lane 조립은 새 인자를 넘기지 않는다 — 소스로 잠근다."""
    src = _service_src()
    assert "\n                        lane_fill=_lane_fill," in src
    assert (
        "\n                        place_facts_block=(\n"
        "                            _lane_place_facts("
        "lane_entry.get(\"group_id\") or \"\")\n"
        "                            if _lane_fill else \"\"\n"
        "                        )," in src
    )
    assert "_lane_fill = _authority_kind == LANE_CONTI_ONLY" in src


# ── T8: 엔티티 단계 마네킹 교체 계약 (2026-07-26 확정 흐름) ───────────
#
# Step1 이 마네킹을 **보존한 채** 배경만 실사화하므로, Step2 가 마네킹을
# 실제 인물로 바꾸지 않으면 회색 마네킹이 최종 스틸까지 살아남는다.


def test_mannequin_stage_head_replaces_figures():
    from app.modules.pipeline.still_recipe import (
        BGFIRST_LANE_PROMPT_VERSION,
        build_bgfirst_final_prompt,
    )

    p = build_bgfirst_final_prompt(
        "SAMPLE_FIXTURE_BASE_STILL_PROMPT",
        prompt_version=BGFIRST_LANE_PROMPT_VERSION, mannequin=True)
    # 스템은 하드랩이라 토큰이 줄바꿈을 가로지른다 — 공백 정규화 후 검사
    low = " ".join(p.split()).lower()
    assert "mannequin" in low
    # 리뷰 F6: 방향 계약을 "never mirrored" 하나로만 핀하면 위치·크기·
    # 자세·정면 방향 문구를 전부 지워도 초록이 된다 — 실질 토큰을 건다.
    assert "never mirrored" in low
    for token in (
        "facing the same way",   # 방향
        "position",              # 위치
        "size",                  # 화면 크기
        "at the same scale",     # 스케일
        "in the same pose",      # 자세
    ):
        assert token in low, token
    # 리뷰 F3: "must stand" 는 immobile_physics(정본 부위 중력 순응)·
    # 스케치의 앉음/쓰러짐 자세와 정면 충돌 — 문구 자체를 금지한다.
    assert "must stand" not in low
    # 리뷰 F2: 참조가 복수인데 교체 대상이 단수면 "한 사람 복제"로 읽힌다
    assert "the real people" in low
    assert "never repeat one person across two mannequins" in low
    # 리뷰 F4: pose-locked 인물은 char_refs 에서 빠지고 lane 체인엔 prev
    # 스틸도 없다 — 참조 0 인 figure 에도 교체 계약이 서야 한다
    assert "if no character reference image is attached" in low
    # 리뷰 F5: Step1 이 회색화에 미달하면 "grey" 단일 키로는 안 걸린다
    assert "grey or outlined mannequin figure" in low
    # F5 파생: 잔류 0 을 '지워서' 만족시키는 우회 차단
    assert "erasing a figure" in low
    # base 스틸 프롬프트 전문이 유지돼야 한다(자세·조명 계약 유실 금지)
    assert "SAMPLE_FIXTURE_BASE_STILL_PROMPT" in p


def test_mannequin_stage_head_points_only_at_attached_refs():
    """lane Step2 참조는 [배경본, 엔티티] — LAYOUT SKETCH 슬롯이 없다.

    v7 stage_head 를 그대로 쓰면 ①"SECOND attached image (LAYOUT SKETCH)"
    가 **없는** 참조를 가리켜 모델이 인물 배치를 즉흥으로 지어내고
    ②"배경을 EXACTLY 유지"가 곧 "회색 마네킹을 유지"로 읽힌다. 스템을
    앞에 덧붙이는 것으로는 두 문장이 남아 있어 못 고친다.
    """
    from app.modules.pipeline.still_recipe import (
        BGFIRST_LANE_PROMPT_VERSION,
        build_bgfirst_final_prompt,
    )

    p = build_bgfirst_final_prompt(
        "SAMPLE_FIXTURE_BASE_STILL_PROMPT",
        prompt_version=BGFIRST_LANE_PROMPT_VERSION, mannequin=True)
    stem = p[: -len("\n\nSAMPLE_FIXTURE_BASE_STILL_PROMPT")]
    assert "LAYOUT SKETCH" not in stem
    assert "SECOND attached image" not in stem
    # 배경 자체는 여전히 고정 — 유지 대상이 '첫 이미지의 배경'으로
    # 좁혀졌는지(=마네킹은 유지 대상이 아님) 확인
    low = " ".join(stem.split()).lower()
    assert "background" in low
    assert "character reference" in low
    # 잔류 0 계약 — 리뷰 F6: str.split 은 구분자가 없으면 원문 전체를
    # 돌려주므로 "KEEP EXACTLY" 를 지워도 통과했다(1문단에 이미
    # "mannequin" 이 있다). 헤더 실재를 먼저 건 뒤 잘라낸다.
    assert "KEEP EXACTLY" in stem
    tail = low.split("keep exactly", 1)[1]
    assert "mannequin" in tail
    assert "may remain anywhere in the output" in tail


def test_final_prompt_default_stays_byte_identical():
    """비-lane 경로 불변 — 새 인자 기본값이 오늘의 산출을 바꾸지 않는다."""
    from app.modules.prompt_loader import load_prompt

    base = "SAMPLE_FIXTURE_BASE_STILL_PROMPT"
    default = build_bgfirst_final_prompt(base)
    assert default == build_bgfirst_final_prompt(base, mannequin=False)
    assert default == (
        load_prompt(
            "still_recipe", "stage_head",
            version=resolve_prompt_version(BGFIRST_PROMPT_VERSION),
        ).strip()
        + "\n\n"
        + base
    )


def test_service_threads_mannequin_stem_for_lane():
    """lane 만 마네킹 스템·lane 팩을 받는다 — 배선을 소스로 잠근다.

    순수 함수 테스트는 기본값을 되돌려도 전부 초록이라(서비스가 인자를
    안 넘기면 v7 stage_head 가 그대로 나간다) 호출부를 함께 핀한다.
    """
    src = _service_src()
    assert (
        "\n                    _chain_prompt = build_bgfirst_final_prompt(\n"
        "                        prompt_chain or prompt,\n"
        "                        prompt_version=(\n"
        "                            _BGF_LANE_PACK_SEL if lane_chain\n"
        "                            else _BGFIRST_PACK\n"
        "                        ),\n"
        "                        mannequin=lane_chain,\n" in src
    )
    # 2026-08-07: 같은 호출에 기하 권위도 실린다 — 인물 삽입이 배경 기하를
    # 다시 그리던 자리라 배선이 빠지면 그 결함이 그대로 남는다.
    assert (
        "                        geom_authority=geom_authority,\n"
        "                    )" in src
    )


# ── F1: 체인 Step2 LOCATION authority (2026-07-27 리뷰) ──────────────
#
# 체인 저작 base 는 prev_used=False·lane_ref_mode="" 라 기본 fallback
# ("the attached LOCATION PHOTOGRAPH shows the exact spot.")이 그대로
# 나갔는데, Step2 참조는 [배경본, (콘티), 엔티티] 뿐이라 그 사진이
# 어디에도 없다 — v3 에서 lane 샷에 대해 한 번 제거된 모순의 재발.


def _chain_base_kwargs():
    return dict(
        shot_desc="SAMPLE_FIXTURE_SHOT_DESC",
        place_text="SAMPLE_FIXTURE_PLACE.",
        time_of_day_en="day",
        bg_only=False,
        prev_used=False,
        char_names=["SAMPLE_FIXTURE_CHAR (traits)"],
        prompt_version="1",
    )


def test_chain_bg_mode_replaces_location_photograph_tail():
    from app.modules.pipeline.still_recipe import build_still_prompt

    out = build_still_prompt(**_chain_base_kwargs(), chain_bg_mode=True)
    # 없는 사진을 가리키던 문구가 사라져야 한다
    assert "LOCATION PHOTOGRAPH" not in out
    # 첫 참조=배경본이 곧 이 장소라는 선언 + 사진 부재 명시
    assert "FIRST attached image (SHOT BACKGROUND) is this exact place" in out
    assert "No location photograph is attached" in out
    # LOCATION 절 안에 들어간다(별도 절 신설 아님 — tail 교체)
    assert out.index("LOCATION (lock)") < out.index("SHOT BACKGROUND")


def test_chain_bg_mode_off_is_byte_identical():
    """비-체인 조립 불변 — 새 인자 기본값이 오늘의 산출을 안 바꾼다."""
    from app.modules.pipeline.still_recipe import build_still_prompt

    kw = _chain_base_kwargs()
    base = build_still_prompt(**kw)
    assert base == build_still_prompt(**kw, chain_bg_mode=False)
    assert "the attached LOCATION PHOTOGRAPH shows the exact spot." in base
    # lane/prev/seed_bg 등 다른 권위 경로도 그대로
    lane = build_still_prompt(
        **{**kw, "prompt_version": "3"}, lane_ref_mode="sketch")
    assert lane == build_still_prompt(
        **{**kw, "prompt_version": "3"},
        lane_ref_mode="sketch", chain_bg_mode=False)
    prev = build_still_prompt(**{**kw, "prev_used": True})
    assert "attached PREVIOUS SHOT STILL" in prev


def test_chain_bg_mode_is_exclusive_with_other_authorities():
    from app.modules.pipeline.still_recipe import build_still_prompt

    kw = _chain_base_kwargs()
    with pytest.raises(ValueError, match="상호 배타"):
        build_still_prompt(
            **{**kw, "prev_used": True}, chain_bg_mode=True)
    with pytest.raises(ValueError, match="상호 배타"):
        build_still_prompt(
            **{**kw, "prompt_version": "3"},
            lane_ref_mode="sketch", chain_bg_mode=True)
    with pytest.raises(ValueError, match="상호 배타"):
        build_still_prompt(
            **{**kw, "prompt_version": "5"},
            seed_bg_mode=True, chain_bg_mode=True)


def test_chain_bg_stem_loads_from_explicit_version():
    """단일 스템 selector — 샷 팩("1")이 아니라 전용 버전에서 로드."""
    from app.modules.prompt_loader import load_prompt

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

    stem = load_prompt(
        "still_recipe", "location_lock_chain_bg",
        version=resolve_prompt_version(CHAIN_BG_LOCATION_PROMPT_VERSION),
    ).strip()
    out = build_still_prompt(**_chain_base_kwargs(), chain_bg_mode=True)
    assert stem in out
    assert resolve_prompt_version(
        CHAIN_BG_LOCATION_PROMPT_VERSION).startswith("12.")


def test_service_threads_chain_bg_mode():
    """체인 저작만 chain_bg_mode 를 받는다 — 배선을 소스로 잠근다."""
    src = _service_src()
    assert "\n                    chain_bg_mode=True,\n" in src
    # 비-체인 조립(위쪽 build_still_prompt)엔 인자가 없다 — 유일 호출
    assert src.count("chain_bg_mode=True") == 1
    assert "chain_bg_mode=False" not in src
