"""shot_conti_light — 샷별 경량 콘티 (2026-07-13 레시피 이식).

s41 확정: bgonly·prev 샷을 제외한 인물 샷마다, 해당 샷의 배경 플레이트
1장을 **느슨한 공간 참고 전용**(BG_REF_CLAUSE — 카메라·조명·디테일 복사
금지, 샷 텍스트가 요구하는 카메라 선택)으로 참조해 LIGHT_FRAME(얇은 균일
윤곽선만) 단일 프레임 16:9 콘티를 gpt-image-2 로 1롤 생성한다(콘티는
multiroll 파이프 미적용 — s41 실증). 정본: scratchpad/forest_exp/
s41_conti_light.py stage_conti + s38 _pose_clauses.

콘티 부재가 스틸을 막지 않는다: 플레이트 없는 샷은 skipped_reason="no_plate"
로 격리(콘티의 정의가 '플레이트 느슨 참조'이므로 텍스트 전용 콘티는 만들지
않는다 — s34 장소 이탈 실측의 교훈), 스틸 단계는 콘티 없이 진행.

실험의 `_soften`(고어 어휘 치환 하드코딩)은 이식하지 않는다 — moderation
대응은 production PromptSanitizer 경로(스텝 어댑터)가 담당.
"""
from __future__ import annotations

import json
import logging
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set, Tuple

# 정본(scene_checkpoint_loaders._SHOT_ID_RE)과 동일 — "S4_Shot1" → (4, 1)
_SHOT_ID_RE = re.compile(r"^S(\d+)_Shot(\d+)$")

from app.modules.prompt_loader import load_prompt
from app.modules.pipeline.multiroll_select import compute_input_fingerprint
from app.modules.pipeline.shot_continuity_author import carried_clause_for
from app.modules.pipeline.shot_ref_classify import parse_tag, tag_of

logger = logging.getLogger(__name__)

_MODULE = "shot_conti_light"

PROMPT_VERSION_MAP = {
    "1": "1.202607132300",
    # v2 (2026-07-20 BGFIRST2 이식 ②): 원근 가이드 콘티 — v1 계약 그대로
    # + 샷별 서브공간 place + CAMERA/FRAME 절 + PERSPECTIVE & SCALE GUIDES
    # (수평선·소실선·실크기 비례·시선/동선 화살표 — 전부 이미지 모델이
    # 그림, 코드 드로잉 0) + GPT ref 서두 설명 + 캐릭터 비례 참조.
    # 정본=exp_conti_persp.py/exp_chainA_multi.py (0adc4df1… scratchpad).
    "2": "2.202607201545",
    # v3 (2026-07-21 E2E10 fix④): v2 전체 사본 + ref_header_char 스템 —
    # no_plate 샷 콘티 저작(플레이트 무참조, 캐릭터 비례 참조만)용 헤더.
    # v2 무변경(덮어쓰기 금지). 선택은 스텝 flag(still_bgfirst_full).
    "3": "3.202607211530",
    # v4 (2026-07-22 E2E11 fix②⑤): v3 전체 사본 + prop_orientation(소품
    # 기능면=사용자 눈 방향 — S21sh3 콘티가 폰/사진 뒷면 응시로 저작된
    # 실측, 스틸 팩 계약을 콘티 저작에도 이식) + naturalism_clause(차렷
    # 직립·무표정 렌즈 응시 금지, 예외=샷 텍스트가 명시하는 제식·운동·
    # 진료·직시). v3 무변경.
    "4": "4.202607221110",
    # v5 (2026-07-22 E2E13 fix①⑦): v4 전체 사본 + keyshot_clause(키샷
    # 레이아웃 가치 — 화면 가득 단독 얼굴·증명사진식 정면 레이아웃 금지,
    # SHOT TEXT 명시 예외, ECU 에도 의미 요소 유지. S21sh6 '눈만 나온
    # 콘티' 실측 근본 대응). v4 무변경.
    "5": "5.202607222353",
}

# 원근 가이드 계약이 실리는 팩 — 선택은 스텝 flag(still_bgfirst_enabled)
_PERSP_PACKS = frozenset({"2", "3", "4", "5"})
# no_plate 샷 콘티 저작(ref_header_char 스템)이 가능한 팩 — E2E10 fix④
_NO_PLATE_PACKS = frozenset({"3", "4", "5"})
# 소품 방향+자연 연기 절이 실리는 팩 — E2E11 fix②⑤
_CONDUCT_PACKS = frozenset({"4", "5"})
# 키샷 레이아웃 가치 절이 실리는 팩 — E2E13 fix①⑦
_KEYSHOT_PACKS = frozenset({"5"})

# 콘티 저작에 첨부하는 캐릭터 비례 참조 상한 (T2I 단일 스틸컷 원칙:
# 인물 2~3명 최대 — 콘티는 비례 참조 목적이라 보수적으로 2)
CONTI_CHAR_REF_MAX = 2

# 콘티 엔진 — 선화 매체는 gpt 전담 (실험 판정 지식: nb2 콘티는 레이아웃·캡션
# 확률 위반, i2=콘티/스케치/도면)
CONTI_IMAGE_MODEL = "gpt-image-2.5-sunburst"
CONTI_SIZE = "1536x864"

# gen_fn(tag, prompt, labeled_refs, out_path) -> out_path
# labeled_refs = [(라벨, Path)] — v1 은 [("plate", 플레이트)] 단일(기존
# 호출과 동일 GPT 입력), v2 는 캐릭터 비례 참조가 뒤에 붙는다.
ContiGenFn = Callable[[str, str, List[Tuple[str, Path]], Path], Path]


def resolve_prompt_version(selector: str) -> str:
    try:
        return PROMPT_VERSION_MAP[selector]
    except KeyError:
        raise ValueError(
            f"unknown shot_conti_light prompt version selector "
            f"{selector!r} (known: {sorted(PROMPT_VERSION_MAP)})"
        )


def conti_targets(
    classify_shots: Dict[str, Dict[str, Any]],
    geom_tags: Optional[Set[str]] = None,
) -> List[str]:
    """콘티 대상 = 인물 샷(person_visible) 중 prev 없는 샷 — 스토리 순서.

    2026-08-07: 좁고 복잡한 실내로 판별된 샷(`confined_structure`, 분류 팩
    v4)은 prev 가 있어도 콘티를 만든다. prev 사진 한 장은 "같은 장소"만
    말해 줄 뿐 이 프레임의 기하 — 어느 좌석에 누가 앉고 카메라가 어디
    있는지 — 를 말해 주지 않는다. 자동차 캐빈 육안 결함 4건이 전부 이
    경로였다. 만든 콘티는 `build_still_refs(geom_authority=True)` 가 prev 와
    함께 싣는다.

    ★판별을 `classify_shots` 에서 **직접 읽는다.** 호출측이 따로 넘기는
    형태로 두면 배선을 빠뜨렸을 때 조용히 기존 집합으로 돌아가고, 그러면
    새 팩 문안이 유료 재생성에 실리지 않은 채 비용만 나간다(Codex 리뷰가
    실제로 그 상태를 잡았다). `geom_tags` 는 그 위에 더 얹고 싶을 때만
    쓰는 보조 인자다.
    """
    geom = geom_tags or frozenset()
    tags = [
        t
        for t, it in classify_shots.items()
        if it.get("person_visible")
        and (not it.get("prev")
             or t in geom
             or bool(it.get("confined_structure")))
    ]
    return sorted(tags, key=parse_tag)


def resolve_shot_plate_map(
    projects_dir: str, project_id: str, episode_id: str
) -> Dict[str, Path]:
    """shot key("si_shi") → 배경 플레이트 PNG 경로.

    background_render(Phase 7) 우선, background_chain_render(Phase 5) fallback —
    load_background_chain_bg_map 과 동일 shape(data.groups[bg_id].{status,
    png_path, shot_ids[]})를 경로 기반으로 읽는다(레시피는 bytes 아닌 경로
    참조가 필요). background_chain_enabled 게이트는 여기 적용하지 않는다 —
    레시피 자체가 still_recipe_mode 로 게이트되고, 플레이트 부재는 하류에서
    no_plate 로 자연 격리된다.
    """
    out: Dict[str, Path] = {}
    for step in ("background_render", "background_chain_render"):
        cp = (
            Path(projects_dir) / project_id / "checkpoints" / "episodes"
            / episode_id / step / "manifest.json"
        )
        if not cp.exists():
            continue
        try:
            data = json.loads(cp.read_text(encoding="utf-8")).get("data", {})
        except Exception as exc:  # noqa: BLE001
            logger.warning("%s: %s 로드 실패: %s", _MODULE, step, exc)
            continue
        for bg_id, g in (data.get("groups") or {}).items():
            if not isinstance(g, dict) or g.get("status") != "ok":
                continue
            png = g.get("png_path") or ""
            if not png:
                continue
            p = Path(png)
            if not p.exists():
                logger.warning(
                    "%s: %s png 부재 — skip (%s)", _MODULE, bg_id, png
                )
                continue
            for sid in g.get("shot_ids") or []:
                # 정본 shape: "S{si}_Shot{shi}" (_ingest_phase7_groups_shape
                # 의 _SHOT_ID_RE 와 동일 파싱). 숫자형 "si_shi" 도 허용.
                m = _SHOT_ID_RE.match(str(sid))
                if m:
                    key = f"{int(m.group(1))}_{int(m.group(2))}"
                else:
                    key = str(sid)
                if key not in out:  # 선순위(background_render) 보존
                    out[key] = p
    return out


def build_conti_prompt(
    *,
    shot_desc: str,
    place_text: str,
    has_plate: bool,
    pose_clauses: List[str],
    carried_en: str,
    movement_en: str,
    figures_en: str,
    camera_frame_en: str = "",
    prompt_version: str = "1",
) -> str:
    """s41 stage_conti 조립 순서 이식 (head → LIGHT_FRAME → BG_REF →
    LOCATION → SHOT TEXT → MOVEMENT/FIGURES → pose → carried → no_text).

    camera_frame_en (v2, BGFIRST2): still_recipe.build_camera_frame_clause
    렌더 완문 — staging 구도·스케일 계약을 콘티 저작에 주입. 정본 순서
    (exp_conti_persp): v1 전체 조립 뒤에 CAMERA/FRAME 절 → PERSPECTIVE &
    SCALE GUIDES. v1 팩에는 원근 계약 스템이 없어 상호 배타(ValueError).
    """
    if camera_frame_en and prompt_version not in _PERSP_PACKS:
        raise ValueError(
            f"shot_conti_light v{prompt_version} 팩에는 원근 가이드 계약이 "
            "없음 — camera_frame_en 은 v2+ 전용"
        )
    resolved = resolve_prompt_version(prompt_version)
    parts = [
        load_prompt(_MODULE, "conti_head", version=resolved).strip(),
        load_prompt(_MODULE, "light_frame", version=resolved).strip(),
    ]
    if has_plate:
        parts.append(
            load_prompt(_MODULE, "bg_ref_clause", version=resolved).strip()
        )
    parts.append(f"THE LOCATION: {place_text}")
    parts.append(f"SHOT TEXT (authoritative, Korean): {shot_desc}")
    if movement_en:
        parts.append("MOVEMENT (follow exactly): " + movement_en)
    if figures_en:
        parts.append(
            "FIGURES — apparent size & depth (follow exactly): " + figures_en
        )
    if pose_clauses:
        parts.append("\n".join(pose_clauses))
    if carried_en:
        parts.append("CARRIED STATE (persist exactly): " + carried_en)
    if prompt_version in _CONDUCT_PACKS:
        # fix②⑤ (E2E11): 소품 방향+자연 연기 — 자세·시선의 정본은 콘티
        # 이므로 저작 단계에서 계약(스틸만 있으면 콘티 오류가 전파됨)
        parts.append(
            load_prompt(_MODULE, "prop_orientation", version=resolved)
            .strip()
        )
        parts.append(
            load_prompt(_MODULE, "naturalism_clause", version=resolved)
            .strip()
        )
    if prompt_version in _KEYSHOT_PACKS:
        # fix①⑦ (E2E13): 키샷 레이아웃 가치 — 콘티가 자세·구도의 정본
        # 이므로 증명사진식 레이아웃 차단 계약을 저작 단계에 주입
        parts.append(
            load_prompt(_MODULE, "keyshot_clause", version=resolved)
            .strip()
        )
    parts.append(load_prompt(_MODULE, "no_text", version=resolved).strip())
    if prompt_version in _PERSP_PACKS:
        if camera_frame_en:
            parts.append(camera_frame_en)
        parts.append(
            load_prompt(
                _MODULE, "perspective_guides", version=resolved
            ).strip()
        )
    return "\n\n".join(parts)


def build_conti_ref_header(
    *, has_char_refs: bool, prompt_version: str, has_plate: bool = True
) -> str:
    """v2+ GPT ref 서두 설명 — 참조 구성(플레이트/캐릭터 유무)에 따라 분기
    (조건=참조 유무, 의미 판단 아님). v1 = ""(기존 프롬프트 불변).

    has_plate=False (v3, E2E10 fix④): no_plate 샷 — 캐릭터 참조만 있으면
    ref_header_char(플레이트 부재 명시), 참조 0 이면 ""(서두 불요).
    """
    if prompt_version not in _PERSP_PACKS:
        return ""
    resolved = resolve_prompt_version(prompt_version)
    if not has_plate:
        if not has_char_refs:
            return ""
        if prompt_version not in _NO_PLATE_PACKS:
            raise ValueError(
                f"shot_conti_light v{prompt_version} 팩에는 no_plate ref "
                "헤더가 없음 — has_plate=False 는 v3+ 전용"
            )
        return load_prompt(
            _MODULE, "ref_header_char", version=resolved).strip()
    name = "ref_header_plate_char" if has_char_refs else "ref_header_plate"
    return load_prompt(_MODULE, name, version=resolved).strip()


def build_pose_clauses(
    pose_canon: List[Dict[str, Any]], tag: str, prompt_version: str = "1"
) -> List[str]:
    """s38 _pose_clauses — 이 샷에 걸리는 자세 정본을 계약 헤더와 함께."""
    resolved = resolve_prompt_version(prompt_version)
    head = load_prompt(_MODULE, "pose_clause_head", version=resolved).strip()
    out = []
    for it in pose_canon or []:
        if tag in (it.get("shots") or []):
            out.append(f"{head} {it.get('pose_en') or ''}")
    return out


def load_sidecar_records(path: Path) -> Dict[str, Any]:
    if path.exists():
        try:
            return json.loads(path.read_text(encoding="utf-8"))
        except Exception:  # noqa: BLE001
            logger.warning("%s: sidecar 파싱 실패 — 재생성", path)
    return {}


def save_sidecar_records(path: Path, data: Dict[str, Any]) -> None:
    tmp = path.with_suffix(".tmp")
    tmp.write_text(
        json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8"
    )
    tmp.replace(path)


def archive_stale_output(p: Path) -> None:
    """지문 mismatch/force 의 기존 산출을 타임스탬프 이름으로 보존 이동.

    3차 리뷰 MINOR: 같은 초 재실행이 이전 stale 을 덮지 않도록
    microseconds + 충돌 시 순번 suffix.
    """
    if not p.exists():
        return
    ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f")
    target = p.with_name(f"{p.stem}.stale_{ts}{p.suffix}")
    n = 1
    while target.exists():
        target = p.with_name(f"{p.stem}.stale_{ts}_{n}{p.suffix}")
        n += 1
    p.rename(target)


def register_intermediate_assets(
    *,
    contis: Dict[str, Any],
    map_plates: Dict[str, Any],
    rel_fn: Callable[[str], str],
    find_asset_by_rel: Callable[[str], Any],
    new_asset: Callable[..., Any],
    annotate_fn: Callable[..., None],
    conti_model: str,
    plate_model: str,
    lane_contis: Optional[Dict[str, Any]] = None,
) -> None:
    """콘티/맵 플레이트/lane 스케치 intermediate asset 등록 (순수 로직).

    Codex 3차 리뷰 B1 계약:
      - map_plate 를 **먼저** 등록 — 맵 기반 conti 의 plate 입력이 fresh
        run 에서도 UUID 로 이어진다(canon→map_plate→conti 2번째 edge).
      - 같은 경로의 기존 row 도 현재 입력 기준으로 lineage/prompt 갱신 —
        재생성(지문/force) 후 stale plate edge 잔존 차단.
      - 기대 입력 파일이 실재하는데 UUID 미해결 = unresolved 진단 기록.

    DB 구체는 callable 주입: find_asset_by_rel(rel)->asset|None,
    new_asset(rel=, tag=, asset_type=, model=, prompt=)->asset(add 포함),
    annotate_fn(asset, role=, input_ids=, meta=). 각 entry 에 asset_id 병기.
    """
    registered_by_rel: Dict[str, Any] = {}

    def _resolve_input(p: Optional[str]):
        if not p:
            return None, False
        rel = rel_fn(str(p))
        if rel in registered_by_rel:
            return registered_by_rel[rel].id, True
        a = find_asset_by_rel(rel)
        return (a.id if a is not None else None), Path(p).exists()

    def _register(tag: str, entry: Dict[str, Any], *, path_key: str,
                  asset_type: str, role: str, model: str,
                  input_paths: List[Optional[str]],
                  prompt_key: str = "prompt") -> None:
        path = (entry or {}).get(path_key)
        if not path or not Path(path).exists():
            return
        rel = rel_fn(str(path))
        input_ids: List[str] = []
        unresolved: List[str] = []
        for p in input_paths:
            aid, file_exists = _resolve_input(p)
            if aid:
                if aid not in input_ids:
                    input_ids.append(aid)
            elif file_exists:
                unresolved.append(str(p))
        # 4차 리뷰 H1: exact current-state 계약 — input_ids 는 빈 리스트
        # 포함 그대로 전달(annotate 는 None 일 때만 미갱신 → 옛 edge 잔존),
        # unresolved_input_paths 는 항상 list(정상 resolve 시 [] 로 merge
        # 덮어쓰기 — 과거 진단 잔존 차단).
        meta: Dict[str, Any] = {
            "shot_tag": tag,
            "unresolved_input_paths": unresolved,
        }
        if unresolved:
            logger.warning(
                "%s: %s(%s) 입력 asset 미해결 — %s",
                _MODULE, tag, role, unresolved,
            )
        existing = find_asset_by_rel(rel)
        if existing is not None:
            annotate_fn(existing, role=role, input_ids=input_ids, meta=meta)
            # 4차 MINOR: 재생성이면 provenance 도 현재 값 — 모델은 항상,
            # prompt 는 현재 값이 있을 때 정확 대입(reuse 경로의 prompt=None
            # 은 '이번 run 에 프롬프트 재조립 없음' — 기존 값 유지 계약).
            existing.generation_model = model
            if entry.get(prompt_key) is not None:
                existing.prompt_used = entry[prompt_key][:65535]
            entry["asset_id"] = existing.id
            registered_by_rel[rel] = existing
            return
        asset = new_asset(
            rel=rel, tag=tag, asset_type=asset_type, model=model,
            prompt=(entry.get(prompt_key) or "")[:65535] or None,
        )
        annotate_fn(asset, role=role, input_ids=input_ids, meta=meta)
        entry["asset_id"] = asset.id
        registered_by_rel[rel] = asset

    for tag, entry in (map_plates or {}).items():
        if isinstance(entry, dict):
            _register(
                tag, entry, path_key="plate_path",
                asset_type="map_plate", role="map_conti_plate",
                model=plate_model,
                input_paths=[
                    entry.get("source_master_png"),
                    entry.get("source_map_png"),
                ],
            )
    for tag, entry in (contis or {}).items():
        if isinstance(entry, dict):
            # Codex 리뷰 3 (BGFIRST2 v2): ref_paths(라벨드 실제 생성 입력 —
            # 플레이트+캐릭터 비례 참조)가 있으면 전부 lineage 입력으로
            # resolve. v1 entry(키 부재)=기존 플레이트 단일 byte-identical.
            _ref_paths = entry.get("ref_paths")
            _inputs = (
                [p for _lab, p in _ref_paths]
                if isinstance(_ref_paths, list) and _ref_paths
                else [entry.get("plate_path")]
            )
            _register(
                tag, entry, path_key="image_path",
                asset_type="conti_light", role="conti_light",
                model=conti_model,
                input_paths=_inputs,
            )
    # lane 분기(레인1 스케치): R4 (2026-07-16) — PIL control asset 소멸,
    # 스케치 input=클린 canon 맵 UUID 직결. role/asset_type 도 marker 가
    # 아닌 중립 이름(lane_storyboard_sketch) — 구 lane_marker_* row 는
    # 보존만. structure_plate 정책 entry(image_path 없음)는 자연 skip.
    for tag, entry in (lane_contis or {}).items():
        if not isinstance(entry, dict):
            continue
        # v5 (2026-07-24): i2i 마커 맵(이미지 모델이 맵 위에 CAM·엔티티
        # 마커 작화) = 중간 산출 등록 — input=클린 canon 맵. 스케치
        # lineage 는 마커 맵 경유(구 entry=marker_map_path 부재 시 기존
        # base_map 직결 byte-identical).
        if entry.get("marker_map_path"):
            _register(
                tag, entry, path_key="marker_map_path",
                asset_type="lane_marker_map", role="lane_marker_map",
                model=conti_model,
                input_paths=[entry.get("base_map_path")],
                prompt_key="marker_prompt",
            )
        # 2026-07-25 (케이스1 스펙 C·D): 스케치는 마커 맵(배치·카메라)과
        # canon master(장소 외형)를 **둘 다** 참조해 생성된다 — 실참조를
        # lineage 에 그대로 남긴다(장소 LOOK 권위 edge 소실 방지).
        # master 미소비 팩(구 entry)은 키 부재 → 기존 단일 input 동일.
        _register(
            tag, entry, path_key="image_path",
            asset_type="lane_storyboard_sketch",
            role="lane_storyboard_sketch",
            model=conti_model,
            input_paths=[
                p for p in (
                    entry.get("marker_map_path")
                    or entry.get("base_map_path"),
                    entry.get("master_png_path"),
                ) if p
            ],
        )


def resolve_structure_bg_source(
    *,
    shot_key: str,
    plate_map: Dict[str, Path],
    assign_by_key: Dict[str, str],
    seed_path: Optional[str],
    seed_status: str,
) -> Tuple[str, str, str]:
    """structure_plate 샷의 배경 권위 typed 해석 (Codex seed-bg 조건 2).

    우선순위 고정: **valid selected plate > 구조적 부재 plate + valid
    seed > failure**. '배정 기록이 있는데 파일/그룹 결손' 은 seed 로
    조용히 하강하지 않는다(손상 감지 계약 침식 금지 — fail-closed).
    반환 = (bg_source "plate"|"seed"|"failed", bg_path, reason).
    """
    p = plate_map.get(shot_key)
    if p is not None:
        if Path(p).is_file():
            return "plate", str(p), ""
        # B2 재리뷰: 생산 기록(plate_map key)이 있는데 파일만 없음 =
        # 손상 — assign 유무와 무관하게 seed 하강 금지
        return (
            "failed", "",
            f"플레이트 파일 결손({p}) — seed 하강 금지 "
            "(손상 감지 fail-closed)",
        )
    assigned = assign_by_key.get(shot_key) or ""
    if assigned:
        # assign_by_key 는 status 불문 배정 기록
        # (load_bg_assignment_any_status) — producer 실패/거절/부분손상도
        # '구조적 무생산'과 구별해 fail-closed (Codex B2)
        return (
            "failed", "",
            f"배정 플레이트 결손(assigned={assigned}) — seed 하강 금지"
            " (손상 감지 fail-closed)",
        )
    if seed_status == "ok" and seed_path and Path(seed_path).is_file():
        return "seed", str(seed_path), ""
    return (
        "failed", "",
        "플레이트 구조적 부재 + seed 결손 — LOCATION 권위 소스 없음",
    )


def finalize_structure_ab_entries(
    *,
    lane_contis: Dict[str, Any],
    contis: Dict[str, Any],
    classify_shots: Dict[str, Dict[str, Any]],
) -> Dict[str, int]:
    """복잡 구조물(structure_plate) A/B 정책 entry finalize (Codex R2 +
    seed-bg 조건 2026-07-17).

    2026-07-16 사용자 확정: 복잡 구조물 샷=맵·마커·마커 스케치 제거,
    A(콘티+배경+엔티티) vs B(배경+엔티티) VLM 선택. 배경 권위는 entry 의
    typed 해석(resolve_structure_bg_source — bg_source/bg_path/bg_reason)
    을 소비한다:

      - bg_source="plate": 기존 계약 — ready 조건에 STRUCTURE LOOK 용
        seed 실재 포함 (plate=주변 SOT / seed=구조물 SOT tie-break).
      - bg_source="seed" (플레이트 producer 구조적 부재 그룹): seed 가
        LOCATION 단일 권위 — 콘티가 정확히 그 seed 를 참조했는지
        (conti.plate_path == bg_path) 검증. STRUCTURE LOOK 별도 부착
        없음 (Codex 조건 1: 동일 seed 이중 첨부 금지).
      - bg_source="failed": eligible/bg_only = fail-closed (조용한 하강
        금지), prev bypass 는 bg 불요라 무관.

    분류: bg_only/prev = ab_select_bypass(+reason), eligible 완비 =
    ab_select_ready, 결손 = failed. 반환 = lane 집계 **보정 delta**
    {"applicable", "completed", "failed"} — unique shot 계약(배치 리뷰
    BLOCKING-1): 일반 콘티 생성 실패(error)는 delta 0, completed 가산
    샷의 정책 실패 = completed -1/failed +1, 일반 콘티 비대상 결손 =
    applicable +1/failed +1.
    """
    applicable = completed = failed = 0

    def _fail_delta(conti_entry: Dict[str, Any]) -> None:
        nonlocal applicable, completed, failed
        counted_completed = bool(
            conti_entry.get("image_path") and not conti_entry.get("error")
        )
        counted_failed = bool(conti_entry.get("error"))
        if counted_failed:
            return  # 일반 콘티 failed 에 이미 가산 — 이중 가산 금지
        if counted_completed:
            completed -= 1
            failed += 1
        else:
            applicable += 1
            failed += 1

    for tag in sorted(lane_contis, key=parse_tag):
        entry = lane_contis[tag]
        if (
            not isinstance(entry, dict)
            or entry.get("status") != "ab_select_pending"
        ):
            continue
        cls = classify_shots.get(tag) or {}
        bg_source = entry.get("bg_source") or "failed"
        # N4 재리뷰: enum default-deny — 허용 집합 밖 값은 fail-closed
        # (unknown 값을 plate 로 관용하면 typed 감사 계약이 무의미)
        if bg_source not in ("plate", "seed", "failed"):
            entry.update(
                status="failed",
                error=f"bg_source 허용 밖 값: {bg_source!r} — fail-closed",
            )
            applicable += 1
            failed += 1
            continue
        bg_path = entry.get("bg_path") or ""
        conti_entry = contis.get(tag) or {}
        if not cls.get("person_visible", True):
            # bg_only 는 배경이 유일 참조 — bg 결손도 fail-closed
            if bg_source == "failed":
                entry.update(
                    status="failed",
                    error=entry.get("bg_reason") or "배경 권위 소스 없음",
                )
                applicable += 1
                failed += 1
                continue
            entry.update(status="ab_select_bypass", bypass_reason="bg_only")
            continue
        if cls.get("prev"):
            # prev-only — 배경 권위=prev 스틸 (bg_source 무관, 조건 4)
            entry.update(status="ab_select_bypass", bypass_reason="prev")
            continue
        # ── A/B eligible ─────────────────────────────────────────
        if bg_source == "failed":
            entry.update(
                status="failed",
                error=entry.get("bg_reason") or "배경 권위 소스 없음",
            )
            _fail_delta(conti_entry)
            continue
        conti_ok = bool(
            conti_entry.get("image_path")
            and not conti_entry.get("error")
            and Path(conti_entry["image_path"]).is_file()
        )
        reasons: List[str] = []
        if bg_source == "seed":
            if not bg_path or not Path(bg_path).is_file():
                reasons.append(f"seed-bg 결손: {bg_path!r}")
            elif conti_ok and str(conti_entry.get("plate_path")) != bg_path:
                reasons.append(
                    "콘티 배경 참조가 seed-bg 와 불일치 "
                    f"(conti={conti_entry.get('plate_path')!r})"
                )
        else:  # plate — 기존 계약 (파일 실재 + STRUCTURE LOOK seed 필수)
            if not (
                (conti_entry.get("plate_path") or "")
                and Path(conti_entry["plate_path"]).is_file()
            ):
                reasons.append("플레이트 파일 결손")
            seed_ok = bool(
                entry.get("seed_status") == "ok"
                and (entry.get("seed_path") or "")
                and Path(entry["seed_path"]).is_file()
            )
            if not seed_ok:
                reasons.append(
                    "structure seed 결손"
                    f"(status={entry.get('seed_status')!r})"
                )
        if not conti_ok:
            reasons.append(
                "일반 콘티 결손: "
                + str(
                    conti_entry.get("error")
                    or conti_entry.get("skipped_reason")
                    or "파일 부재"
                )
            )
        if reasons:
            entry.update(status="failed", error="; ".join(reasons))
            _fail_delta(conti_entry)
            continue
        if bg_source == "plate":
            # H3 재리뷰: plate authority 가 lane 기록 이후 플레이트를
            # 교체할 수 있음 — typed 감사 계약의 참값은 콘티가 실제
            # 참조한 최종 effective plate. bg_path 를 동기화 기록.
            entry["bg_path"] = str(conti_entry.get("plate_path") or "")
        entry.update(
            status="ab_select_ready",
            conti_path=conti_entry.get("image_path"),
            plate_path=conti_entry.get("plate_path"),
        )
    return {"applicable": applicable, "completed": completed,
            "failed": failed}


def run_shot_conti_light(
    *,
    shots: List[Dict[str, Any]],
    classify_shots: Dict[str, Dict[str, Any]],
    continuity: Dict[str, Any],
    location_by_scene: Dict[int, str],
    plate_map: Dict[str, Path],
    out_dir: Path,
    gen_fn: ContiGenFn,
    prompt_version: str = "1",
    extra_fingerprint: Optional[Dict[str, Any]] = None,
    force: bool = False,
    classify_scenes: Optional[Dict[str, Any]] = None,
    staging_by_key: Optional[Dict[str, Any]] = None,
    char_refs_by_tag: Optional[Dict[str, List[Tuple[str, Path]]]] = None,
    no_plate_conti: bool = False,
    visible_char_sids_by_tag: Optional[Dict[str, set]] = None,
) -> Dict[str, Any]:
    """전 샷 콘티 산출(대상 외=skipped_reason 기록).

    no_plate_conti (v3, E2E10 fix④): True 면 플레이트 부재 대상 샷도
    콘티 저작 — 참조=캐릭터 비례만(has_plate=False 조립: bg_ref 절·
    플레이트 헤더 제외), entry 에 no_plate=True/plate_path=None 병기.
    이후 스틸 단계가 이 콘티로 장소 단위 배경(groupbg)을 생성한다.
    False(default)=기존 skipped_reason="no_plate" byte-identical.

    재개(Codex 2차 리뷰 B3): 파일 존재만으로 skip 하지 않는다 — sidecar
    (conti_records.json)의 입력 지문(프롬프트+플레이트 내용+모델·팩)이
    일치할 때만 재사용. mismatch/force/무기록 = 기존 산출 stale 아카이브
    후 재생성 (맵 플레이트 전환·프롬프트 변경이 실제 이미지에 반영).

    v2 (BGFIRST2, prompt_version in _PERSP_PACKS):
      - THE LOCATION = 샷별 서브공간(classify v3 shots[tag].place_en →
        scenes[si].place_en → location_by_scene fallback — fix2 계약과 동일
        우선순위).
      - CAMERA/FRAME 절 = staging_by_key("si_shi"→shot_staging entry)를
        still_recipe.build_camera_frame_clause 로 결정론 렌더(부재="" —
        보강 절이라 fail-safe).
      - 참조 = [플레이트] + 캐릭터 비례 참조(char_refs_by_tag, 상한
        CONTI_CHAR_REF_MAX) + GPT ref 서두 설명. 지문에 참조 내용 자동
        기여(labeled_refs).
    v1 = 세 param 전부 무시 — 기존 프롬프트·지문 byte-identical.

    Returns {"contis": {tag: {image_path, plate_path, prompt,
                               skipped_reason, error}},
             "applicable_count", "completed_count", "failed_count"}
    """
    if no_plate_conti and prompt_version not in _NO_PLATE_PACKS:
        raise ValueError(
            f"shot_conti_light v{prompt_version} 팩에는 no_plate 콘티 "
            "계약이 없음 — no_plate_conti 는 v3+ 전용 (fail-closed)"
        )
    persp = prompt_version in _PERSP_PACKS
    ordered = sorted(
        shots, key=lambda s: (int(s["scene_index"]), int(s["shot_index"]))
    )
    targets = set(conti_targets(classify_shots))
    pose_canon = continuity.get("pose_canon") or []
    carried_map = continuity.get("carried") or {}
    pose_fix = continuity.get("pose_fix") or {}
    out_dir.mkdir(parents=True, exist_ok=True)
    records_path = out_dir / "conti_records.json"
    sidecar = load_sidecar_records(records_path)

    contis: Dict[str, Any] = {}
    applicable = completed = failed = 0
    for s in ordered:
        si, shi = int(s["scene_index"]), int(s["shot_index"])
        tag = tag_of(si, shi)
        cls = classify_shots.get(tag) or {}
        if tag not in targets:
            reason = "bgonly" if not cls.get("person_visible") else "prev"
            contis[tag] = {
                "image_path": None, "plate_path": None, "prompt": None,
                "skipped_reason": reason, "error": None,
            }
            continue
        plate = plate_map.get(f"{si}_{shi}")
        if plate is None and not no_plate_conti:
            logger.warning("%s: %s 플레이트 없음 — 콘티 생략", _MODULE, tag)
            contis[tag] = {
                "image_path": None, "plate_path": None, "prompt": None,
                "skipped_reason": "no_plate", "error": None,
            }
            continue

        applicable += 1
        fix = pose_fix.get(tag) or {}
        # 감사 1-B (2026-08-27) — 그 샷에 배정된 인물의 상태만 붙인다.
        # ★참조 이미지 유무와 **묶지 않는다**: `char_refs_by_tag` 는 참조가
        #  없는 인물을 빠뜨리지만, 참조가 없어도 그 사람은 화면에 있다.
        carried = carried_clause_for(
            carried_map, tag, fix,
            visible_short_ids=(visible_char_sids_by_tag or {}).get(tag)
            or set(),
            bg_only=not cls.get("person_visible", True))
        # v2: 샷별 서브공간 place + staging 구도 계약 (fix2/fix1 우선순위)
        place_text = location_by_scene.get(si, "")
        camera_frame_en = ""
        if persp:
            place_text = (
                cls.get("place_en")
                or (classify_scenes or {}).get(str(si), {}).get("place_en")
                or place_text
            )
            staging = (staging_by_key or {}).get(f"{si}_{shi}")
            if staging is not None:
                from app.modules.pipeline.still_recipe import (
                    build_camera_frame_clause,
                )

                camera_frame_en = build_camera_frame_clause(staging)
        char_refs: List[Tuple[str, Path]] = []
        if persp:
            char_refs = list(
                (char_refs_by_tag or {}).get(tag) or []
            )[:CONTI_CHAR_REF_MAX]
        prompt = build_conti_prompt(
            shot_desc=s.get("description") or "",
            place_text=place_text,
            has_plate=plate is not None,
            pose_clauses=build_pose_clauses(pose_canon, tag, prompt_version),
            carried_en=carried,
            movement_en=fix.get("movement_en") or "",
            figures_en=fix.get("figures_en") or "",
            camera_frame_en=camera_frame_en,
            prompt_version=prompt_version,
        )
        header = build_conti_ref_header(
            has_char_refs=bool(char_refs), prompt_version=prompt_version,
            has_plate=plate is not None,
        )
        if header:
            prompt = header + "\n\n" + prompt
        labeled_refs: List[Tuple[str, Path]] = (
            [("plate", plate)] if plate is not None else []
        ) + [
            (f"char:{name}", p) for name, p in char_refs
        ]
        out_path = out_dir / f"conti_{tag}.png"
        try:
            fingerprint = compute_input_fingerprint(
                prompt=prompt,
                labeled_refs=labeled_refs,
                roll_count=1,
                critique_enabled=False,
                extra=extra_fingerprint,
            )
            prev_rec = sidecar.get(tag) or {}
            reuse = (
                not force
                and out_path.exists()
                and prev_rec.get("input_fingerprint") == fingerprint
            )
            if not reuse:
                if out_path.exists():
                    logger.warning(
                        "%s: %s 지문 불일치/force — stale 아카이브 후 재생성",
                        _MODULE, tag,
                    )
                    archive_stale_output(out_path)
                gen_fn(tag, prompt, labeled_refs, out_path)
                sidecar[tag] = {"input_fingerprint": fingerprint}
                save_sidecar_records(records_path, sidecar)
            contis[tag] = {
                "image_path": str(out_path),
                "plate_path": str(plate) if plate is not None else None,
                "prompt": prompt,
                "skipped_reason": None,
                "error": None,
            }
            if plate is None:
                # fix④: 플레이트 부재 저작 — 스틸 단계 groupbg 대상 마킹
                contis[tag]["no_plate"] = True
            if persp:
                # Codex 리뷰 3: v2 는 캐릭터 비례 참조도 실제 생성 입력 —
                # intermediate asset lineage 가 전 입력을 resolve 할 수
                # 있게 라벨드 경로 보존 (v1 CP shape byte-identical 유지,
                # reuse 경로에서도 매 run 재계산되어 항상 실린다)
                contis[tag]["ref_paths"] = [
                    [lab, str(p)] for lab, p in labeled_refs
                ]
            completed += 1
        except Exception as exc:  # noqa: BLE001 — 샷 단위 실패 격리
            logger.exception("%s: %s 콘티 생성 실패", _MODULE, tag)
            contis[tag] = {
                "image_path": None,
                "plate_path": str(plate) if plate is not None else None,
                "prompt": prompt,
                "skipped_reason": None,
                "error": str(exc),
            }
            failed += 1

    return {
        "contis": contis,
        "applicable_count": applicable,
        "completed_count": completed,
        "failed_count": failed,
    }
