"""W21B space_set_bg — frame/addon/life 3층 공간 세트 BG 파이프라인 (pure core).

~/tmp/geum_fp_frame_addon_exp.py 에서 14회 실험-육안-수정 반복으로 검증된 방식의
production 포팅 (2026-06-10 사용자 합의):

  · ANALYZE 3층: frame(경계·개구부·access path·construction_character)
                 / addon(씬 증거·기능 필수 핵심 가구 — FP 도면에 그려짐)
                 / life_baseline(인물 상황→환경 추론 고정물 — FP 미표기, BG 생성시만)
  · 실내: frame 2D(t2i) → addon i2i = ★최종 2D FP★ → VLM 좌표
          → 공간당 ★단일 번호 마킹★ 사본(PIL) → 그 사본을 base 로 BG i2i
          (★3D FP 단계 없음 — 실험 비교에서 손실 변환으로 판정, 제거 확정)
  · 옥외(open_air): FP 미경유 — 공간당 ★단 한 장★ T2I 기준 BG
                    (이후 모든 샷이 i2i 참조 변형하기 쉬운 일반 구도)
  · threshold = connector (plate 생략) / same_space = canonical 사진 참조
  · place_desc = whole/indoor/outdoor 분리 + 세계관 시각 정체성(문화권·시대 양식)
                 + visible_adjacent_enclosed_masses(반대 그룹 매스 외관) 반영
  · frame_check = VLM 진단(게이트 아님 — 사람 육안 반복이 완성 수단)

이 모듈은 pure core — 프롬프트 접근자 + 순수 함수만. 프롬프트 본문 15건은
2026-08-15 에 ``prompts/_base/space_set_bg/`` 팩으로 옮겼고(내용 무변경), 이 모듈은
그것을 지연 로드해 종전과 같은 상수 이름으로 내놓는다. LLM/VLM/이미지/DB I/O 는
provider(space_set_bg_provider) / step(space_set_bg_step) 몫.
★generic 절대 — 장소 종류·품목·시나리오 하드코딩 0 (SF 등 어떤 장소도 동일 작동).
★CLAUDE.md: LLM 입력(씬/시나리오 텍스트)은 절대 자르지 않는다.
"""
from __future__ import annotations

import json
import re
from collections import OrderedDict
from functools import lru_cache
from typing import Any, Dict, List, Optional, Tuple

from app.services.image_capture.sink import capture_artifact

SCHEMA_VERSION = 3  # 3: Phase 3 plate_action (reuse_base|derive_from_base|no_plate) + derive 파생 plate

# ════════════════════════════ ⓪ 프롬프트 팩 (2026-08-15) ════════════════════════════
# 코드에 박혀 있던 프롬프트 15건(18,563자)을 prompts/_base/space_set_bg/ 로 옮겼다
# (내용은 그대로, 자리만 옮김). 이 스텝은 기본으로 꺼져 있으므로(settings.
# space_set_bg_enabled=False) 읽기를 ★지연★ 시킨다 — 모듈 __getattr__ 이 이름을
# 처음 만질 때만 파일을 읽고, 그 뒤로는 캐시가 ★같은 문자열 객체★ 를 돌려준다
# (스텝·시험의 `is` 비교 계약 보존). 꺼져 있는 동안에는 파일 I/O 도 일어나지 않는다.
#
# 프롬프트 본문에 JSON 중괄호가 들어 있어 load_prompt 에 format 인자를 주지 않는다
# — {num}·{view} 같은 자리는 종전대로 호출부의 .format 이 채운다.
PROMPT_PACK_MODULE = "space_set_bg"

_PACK_STEMS: Dict[str, str] = {
    "ANALYZE_FA_SYS": "analyze_fa_sys",
    "PLACE_GROUP_SYS": "place_group_sys",
    "FRAME_FP_SYS": "frame_fp_sys",
    "ADDON_I2I": "addon_i2i",
    "FRAME_CHECK_SYS": "frame_check_sys",
    "SPACE_POS_SYS": "space_pos_sys",
    "VIEW_BRIEF_SYS": "view_brief_sys",
    "MARKED_FP_VIEW": "marked_fp_view",
    "SAMESPACE_VIEW": "samespace_view",
    "INDOOR_BG_T2I_SYS": "indoor_bg_t2i_sys",
    "OUTDOOR_BG_T2I_SYS": "outdoor_bg_t2i_sys",
    "SHOT_ASSIGN_SYS": "shot_assign_sys",
    "DERIVE_PLATE_VIEW": "derive_plate_view",
}

# 상수 이름이 없는 조각 — build_analyze_user / build_view_briefs_user 가 직접 읽는다.
_FRAGMENT_STEMS = ("analyze_user_head", "view_briefs_user_tail")

# 이 스텝이 실제로 소비하는 stem 전체 (상수 13 + 조각 2 = 15).
PACK_STEM_NAMES = tuple(sorted(set(_PACK_STEMS.values()) | set(_FRAGMENT_STEMS)))


@lru_cache(maxsize=None)
def _pack(stem: str) -> str:
    """팩에서 프롬프트 stem 을 읽고 프로세스 수명 동안 같은 객체로 캐시."""
    from app.modules.prompt_loader import load_prompt
    return load_prompt(PROMPT_PACK_MODULE, stem)


def __getattr__(name: str) -> str:
    """상수처럼 보이는 프롬프트 이름을 팩에서 지연 로드 (PEP 562)."""
    stem = _PACK_STEMS.get(name)
    if stem is None:
        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
    return _pack(stem)


def pack_identity() -> Dict[str, Dict[str, str]]:
    """소비하는 stem 각각의 (판 이름, 내용 해시) — 팩 신원.

    프롬프트가 코드에 있을 때는 코드 해시가 곧 프롬프트 신원이었다. 팩으로
    옮긴 뒤로는 ★파일이 실행 입력★ 이므로, 같은 코드로도 다른 프롬프트가 나갈
    수 있다 — 그래서 스텝의 config_hash·결과 기록이 이 신원을 접어야 한다.

    로더는 stem 마다 최신판을 ★따로★ 고른다(한 모듈 안에서 판이 섞일 수 있다).
    그래서 판 이름을 모듈 하나로 뭉뚱그리지 않고 stem 단위로 담는다. 내용 해시를
    같이 두는 이유는 판 이름이 그대로여도 파일이 바뀔 수 있기 때문이다.

    ★캐시하지 않는다★ — 캐시하면 파일이 바뀌어도 지문이 안 움직인다.
    stem 이 하나라도 없으면 예외(fail-closed): 없는 팩을 빈 신원으로 접으면
    "팩이 사라져도 지문이 그대로"인 구멍이 다시 생긴다.
    """
    from app.modules.prompt_loader import (
        get_effective_source,
        pack_stem_content_hash,
    )

    identity: Dict[str, Dict[str, str]] = {}
    for stem in PACK_STEM_NAMES:
        cand = (get_effective_source(PROMPT_PACK_MODULE, stem)
                .get("candidates") or {}).get("file")
        if not cand or not cand.get("version"):
            raise FileNotFoundError(
                f"{PROMPT_PACK_MODULE} 팩 stem 을 찾지 못했다: {stem}")
        version = cand["version"]
        identity[stem] = {
            "version": version,
            "sha256_16": pack_stem_content_hash(
                PROMPT_PACK_MODULE, version, stem),
        }
    return identity


# ════════════════════════════ ① ANALYZE — frame / addon / life_baseline 3층 ════════════════════════════
# 팩 stem: analyze_fa_sys → 상수 ANALYZE_FA_SYS (지연 로드).


def build_analyze_user(shot_blocks_text: str, fulltext: str, scene_nums: List[int]) -> str:
    """analyze user 프롬프트 — ★씬/전문 입력 절대 무삭제 (CLAUDE.md)."""
    return (
        # 팩 stem: analyze_user_head — 끝의 개행 1개는 로더가 strip 하므로 여기서 잇는다.
        _pack("analyze_user_head") + "\n" + shot_blocks_text +
        "\n\n[원본 시나리오 전문 — 표제(슬러그라인) 아래 '/' 하위구분 = 별개 공간 강력 증거. 위 샷이 다루는 장면(scene "
        + ", ".join(str(n) for n in scene_nums) + ")만 분석하고 다른 장소의 장면은 무시하라. "
        "★예외: 이 장소로 들어오는 access path 의 증거만은 전문 어디서든 사용하라(그 경유 장소들을 spaces 로 추가하진 말 것).★]\n" + fulltext
    )


def shot_blocks(shots: List[Dict[str, Any]]) -> str:
    """scene 별로 묶은 LLM 입력 텍스트 (heading + 요약 + 그 scene 의 모든 shot 묘사, 무삭제)."""
    by: "OrderedDict[Any, Dict[str, Any]]" = OrderedDict()
    for s in shots:
        sc = s.get("scene")
        if sc not in by:
            by[sc] = {"headings": [], "summary": s.get("summary"), "shots": []}
        h = s.get("heading")
        if h and h not in by[sc]["headings"]:
            by[sc]["headings"].append(h)
        if s.get("shot_desc"):
            by[sc]["shots"].append(s["shot_desc"])
    blocks = []
    for sc, d in by.items():
        heads = " / ".join(d["headings"])
        lines = "\n".join(f"  - {x}" for x in d["shots"])
        blocks.append(f"[scene {sc}] {heads}\n장면요약: {d.get('summary') or ''}\nshot 묘사들:\n{lines}")
    return "\n\n".join(blocks)


def validate_fa(analysis: Dict[str, Any]) -> Dict[str, Any]:
    """deterministic — frame/addon 계약 + access path 검사 (의미추론 X, 카운트·존재만). 게이트 아님(진단)."""
    rooms = analysis.get("spaces") or []
    indep = [r for r in rooms if not r.get("same_space_as")]
    fails: List[str] = []
    warns: List[str] = []
    sk = analysis.get("structure_kinds") or []
    sep = [s for s in sk if str(s.get("kind", "")).lower() == "separate_space"]
    if len(sep) > len(indep):
        fails.append(f"STRUCTURE SHRUNK: separate_space 증거 {len(sep)} > 독립 spaces {len(indep)}")
    enc_by = {r.get("name"): str(r.get("enclosure") or "enclosed").lower() for r in rooms}
    for r in rooms:
        ref = r.get("same_space_as")
        if ref and ref in enc_by:
            e1 = str(r.get("enclosure") or "enclosed").lower()
            e2 = enc_by[ref]
            if (e1 == "open_air") != (e2 == "open_air"):
                fails.append(f"CROSS-ENCLOSURE same_space: '{r.get('name')}'({e1}) ↔ '{ref}'({e2})")
    for r in indep:
        if str(r.get("enclosure") or "").lower() == "threshold":
            continue
        frame = r.get("frame") or {}
        aps = frame.get("access_paths") or []
        ops = frame.get("openings") or []
        if not aps and not ops:
            fails.append(f"NO ACCESS: 공간 '{r.get('name')}' 에 access_path/opening 둘 다 없음 (물리적으로 막힘)")
        elif not aps:
            warns.append(f"access_path 명시 없음(opening 으로만 추정): '{r.get('name')}'")
    for av in analysis.get("access_validation") or []:
        if av.get("has_access") is False:
            warns.append(f"access_validation has_access=false: {av.get('space')} ({av.get('note')})")
    for u in analysis.get("uncertain_structure_items") or []:
        warns.append(f"uncertain: {u.get('item')} ({u.get('reason')})")
    analysis["_validation"] = {
        "fails": fails, "warnings": warns,
        "independent_spaces": len(indep), "separate_space_evidence": len(sep),
    }
    return analysis


def enclosure_groups(analysis: Dict[str, Any]) -> "OrderedDict[str, List[Dict[str, Any]]]":
    """spaces 를 enclosure 로 그룹핑. indoor=enclosed+threshold(connector 라 실내 FP 에 붙임), outdoor=open_air.
    ★generic: enclosure 미지정이면 enclosed 취급. indoor 가 항상 먼저(있으면)."""
    indoor: List[Dict[str, Any]] = []
    outdoor: List[Dict[str, Any]] = []
    for s in analysis.get("spaces") or []:
        enc = str(s.get("enclosure") or "enclosed").lower()
        (outdoor if enc == "open_air" else indoor).append(s)
    groups: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
    if indoor:
        groups["indoor"] = indoor
    if outdoor:
        groups["outdoor"] = outdoor
    return groups


# ════════════════════════════ ② place_desc — whole / indoor / outdoor 분리 ════════════════════════════
# 팩 stem: place_group_sys → 상수 PLACE_GROUP_SYS (지연 로드).


def build_place_group_user(
    analysis: Dict[str, Any],
    guide: Dict[str, Any],
    gkey: str,
    gspaces: List[Dict[str, Any]],
) -> str:
    payload = [
        {
            "name": s.get("name"), "enclosure": s.get("enclosure"),
            "construction": s.get("construction_character"),
            "addon": [a.get("implementation") for a in (s.get("addon") or []) if a.get("implementation")],
        }
        for s in gspaces
    ]
    # ★반대 그룹 enclosed 매스 = 이 그룹 장면에서 외벽이 보이는 인접 구조물 → 그 외관(construction)을 desc 에 전달
    gnames = {s.get("name") for s in gspaces}
    masses = [
        {"name": s.get("name"), "construction": s.get("construction_character")}
        for s in (analysis.get("spaces") or [])
        if s.get("name") not in gnames
        and str(s.get("enclosure") or "").lower() in ("enclosed", "threshold")
        and s.get("construction_character")
    ]
    mass_part = (
        "\n\nvisible_adjacent_enclosed_masses(이 그룹에서 외벽이 보이는 인접 구조물 — 외관만 반영, 내부 금지):\n"
        + json.dumps(masses, ensure_ascii=False, indent=2)
    ) if masses else ""
    return (
        "[세계관]\n" + json.dumps(guide, ensure_ascii=False)
        + f"\n\n[대상 그룹 = {gkey}]\n공간들:\n" + json.dumps(payload, ensure_ascii=False, indent=2)
        + "\n\nphysical_realism:\n" + json.dumps(analysis.get("physical_realism") or [], ensure_ascii=False)
        + mass_part
        + f"\n\n이 {gkey} 그룹 공간만 일반 영어로 묘사하라(다른 환경 맥락 제외). 2~3문장."
    )


# ════════════════════════════ ③ frame 2D / addon i2i (최종 2D FP) ════════════════════════════
# 팩 stem: frame_fp_sys → 상수 FRAME_FP_SYS (지연 로드).

# 변형1(검증 승자) = frame 2D 위에 addon 심볼만 i2i 추가 (frame 절대 수정 금지)
# 팩 stem: addon_i2i → 상수 ADDON_I2I (지연 로드, {addons} 는 호출부 format).


def build_fp_user(
    analysis: Dict[str, Any],
    spaces: List[Dict[str, Any]],
    group_kind: str,
    frame_only: bool,
) -> str:
    fr_spaces = []
    for s in spaces:
        fr = s.get("frame") or {}
        item: Dict[str, Any] = {
            "name": s.get("name"), "enclosure": s.get("enclosure"),
            "boundaries": fr.get("boundaries") or [],
            "openings": fr.get("openings") or [],
            "access_paths": fr.get("access_paths") or [],
        }
        if not frame_only:
            item["addon"] = [a.get("implementation") for a in (s.get("addon") or []) if a.get("implementation")]
        fr_spaces.append(item)
    payload: Dict[str, Any] = {
        "space_type": analysis.get("space_type"), "genre_or_setting": analysis.get("genre_or_setting"),
        "physical_realism": analysis.get("physical_realism") or [],
        "render_group": group_kind, "spaces": fr_spaces,
    }
    # ★반대 그룹 enclosed 공간 = 이 평면에서 '닫힌 건물 매스 외벽 윤곽(footprint)' 으로 존재 (통째 생략 방지)
    gnames = {s.get("name") for s in spaces}
    opp = [
        s.get("name") for s in (analysis.get("spaces") or [])
        if s.get("name") not in gnames
        and str(s.get("enclosure") or "").lower() in ("enclosed", "threshold")
    ]
    if opp:
        payload["adjacent_enclosed_masses_outside_this_group"] = opp
    head = ("[frame ONLY — 가구 절대 금지, 구조 셸만]\n" if frame_only else "[frame 우선 + addon 종속 — 한 번에]\n")
    tail = (
        "\n\n위로 ★흑백 2D 구조 평면도★ T2I 프롬프트(영어)를 작성하라. spaces 의 모든 공간·경계·개구부·"
        "★access_path(옥외/상부 진입경로 포함, 막히지 않게)★ 를 정확히 반영하라. "
        + ("가구·설비·물체는 절대 그리지 마라(frame only)."
           if frame_only else "frame 을 먼저 정확히 그리고 그 안에 각 공간 addon 만 심볼로(frame 변형 금지).")
        + " 지정 그룹만(반대 그룹 매스는 빈 외벽 윤곽). 인물명·분위기·재질·치수 금지."
    )
    return head + json.dumps(payload, ensure_ascii=False, indent=2) + tail


# ════════════════════════════ ④ frame 보존 검증 (VLM 진단 — 게이트 아님) ════════════════════════════
# 팩 stem: frame_check_sys → 상수 FRAME_CHECK_SYS (지연 로드).


# ════════════════════════════ ⑤ VLM 공간 좌표 + 단일 번호 마킹 (이미지 처리) ════════════════════════════
# 팩 stem: space_pos_sys → 상수 SPACE_POS_SYS (지연 로드).


def build_space_positions_user(names: List[str]) -> str:
    return "이 평면도에서 다음 공간들 각각의 방 중심 정규화 좌표를 찾아 schema 로 출력하라: " + json.dumps(names, ensure_ascii=False)


def mark_fp(fp_png_path: str, out_png_path: str, cx: float, cy: float, num: int) -> None:
    """최종 2D FP 사본에 ★빨간 원 + 번호 1개만★ 마킹 (코드 이미지 처리, LLM 미사용).
    ★BG 한 장당 마킹 하나 — 모든 번호를 한 도면에 같이 찍지 않는다 (사용자 hard rule)."""
    from PIL import Image, ImageDraw, ImageFont  # 지연 import (pure core 가벼움 유지)
    im = Image.open(fp_png_path).convert("RGB")
    w, h = im.size
    r = int(min(w, h) * 0.05)
    x, y = int(cx * w), int(cy * h)
    d = ImageDraw.Draw(im)
    d.ellipse([x - r, y - r, x + r, y + r], outline=(255, 0, 0), width=max(3, r // 7))
    fsize = max(12, int(r * 1.1))   # 극소 size 는 일부 폰트 render 가 division by zero
    try:
        font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", fsize)
    except Exception:
        font = ImageFont.load_default()
    try:
        d.text((x, y), str(num), fill=(255, 0, 0), font=font, anchor="mm")
    except Exception:
        d.text((max(0, x - fsize // 3), max(0, y - fsize // 2)), str(num), fill=(255, 0, 0))
    im.save(out_png_path)
    # Phase B: 마킹된 FP capture(비모델 PIL 아티팩트, scope 미배선이면 no-op). 방금
    # 저장한 파일을 그대로 읽어 byte-identical 보존(im.save 인자 불변).
    with open(out_png_path, "rb") as _mf:
        capture_artifact(
            _mf.read(), role="space_set_marked_fp", disposition="diagnostic",
            pipeline_metadata={"num": num},
        )


# ════════════════════════════ ⑥ view brief (wide establishing 기준 배경) ════════════════════════════
# 팩 stem: view_brief_sys → 상수 VIEW_BRIEF_SYS (지연 로드).


def adapt_for_view(analysis: Dict[str, Any]) -> Dict[str, Any]:
    """frame/addon analysis → view brief 입력 형태(fixed_elements + defining_features + top-level openings)로 평면화.
    construction_character 는 defining_features 로(재질 정체성이 view 에 흘러가게)."""
    import copy
    a = copy.deepcopy(analysis)
    flat_openings: List[Dict[str, Any]] = []
    for s in a.get("spaces", []):
        s["fixed_elements"] = [x.get("implementation") for x in (s.get("addon") or []) if x.get("implementation")]
        s["defining_features"] = [s.get("construction_character")] if s.get("construction_character") else []
        for o in ((s.get("frame") or {}).get("openings") or []):
            flat_openings.append(o)
    a["openings"] = flat_openings
    return a


def build_view_briefs_user(analysis: Dict[str, Any]) -> str:
    return (
        "공간 분석(JSON):\n" + json.dumps(adapt_for_view(analysis), ensure_ascii=False, indent=2)
        # 팩 stem: view_briefs_user_tail — 앞의 빈 줄은 로더가 strip 하므로 여기서 잇는다.
        + "\n\n" + _pack("view_briefs_user_tail")
    )


# ════════════════════════════ ⑦ BG 생성 프롬프트 (실내 마킹 FP i2i / 옥외 단일 T2I / same_space 참조) ════════════════════════════
# 팩 stem: marked_fp_view → 상수 MARKED_FP_VIEW
# (지연 로드, {num}/{place}/{view}/{extra} 는 호출부 format).

# 같은 물리 공간의 다른 시점 — canonical 실사진 참조로 '동일 공간' 일관 유지
# 팩 stem: samespace_view → 상수 SAMESPACE_VIEW (지연 로드, {view} 는 호출부 format).

# 단일 marked_indoor 그룹용 — FP 미경유 직접 T2I (2026-06-10 사용자 피드백: 마킹할 다른 방이
# 없는 단일 공간에 FP 는 무용 + 직역 위험[도트 그리드→벽지, 심볼→평면 장식]만 추가).
# 구조는 frame 분석 텍스트로 전달, life_baseline 도 여기서 텍스트 주입.
# 팩 stem: indoor_bg_t2i_sys → 상수 INDOOR_BG_T2I_SYS (지연 로드).


def build_indoor_bg_user(
    analysis: Dict[str, Any],
    space: Dict[str, Any],
    place: str,
    view_hint: str,
) -> str:
    fr = space.get("frame") or {}
    payload = {
        "space": {
            "name": space.get("name"), "enclosure": space.get("enclosure"),
            "construction_character": space.get("construction_character"),
            "boundaries": fr.get("boundaries") or [], "openings": fr.get("openings") or [],
            "access_paths": fr.get("access_paths") or [],
            "addon": [a.get("implementation") for a in (space.get("addon") or []) if a.get("implementation")],
            "life_baseline(이동하지 않는 배경 필수 물품 — 그 환경의 현실 수준으로 자연 배치)": [
                x.get("item") for x in (space.get("life_baseline") or []) if x.get("item")
            ],
        },
        "physical_realism": analysis.get("physical_realism") or [],
        "place": place, "suggested_view": view_hint,
    }
    return (
        json.dumps(payload, ensure_ascii=False, indent=2)
        + "\n\n위 실내 공간의 ★단 한 장 기준 배경 실사진★ T2I 프롬프트(영어)를 작성하라. "
        "이후 샷들이 i2i 로 변형해 쓰는 유일한 원본이므로 입구를 등진 wide establishing 일반 구도로, "
        "구조·고정물·구축 방식(재질 이질감)·life_baseline 을 정확히, place 의 시각 정체성을 충실히. 사건·표식·인물·글자 금지."
    )


# 팩 stem: outdoor_bg_t2i_sys → 상수 OUTDOOR_BG_T2I_SYS (지연 로드).


def build_outdoor_bg_user(
    analysis: Dict[str, Any],
    space: Dict[str, Any],
    place: str,
    view_hint: str,
) -> str:
    fr = space.get("frame") or {}
    payload = {
        "space": {
            "name": space.get("name"), "enclosure": space.get("enclosure"),
            "construction_character": space.get("construction_character"),
            "boundaries": fr.get("boundaries") or [], "openings": fr.get("openings") or [],
            "access_paths": fr.get("access_paths") or [],
            "addon": [a.get("implementation") for a in (space.get("addon") or []) if a.get("implementation")],
            "life_baseline(이동하지 않는 배경 필수 물품 — 그 환경의 현실 수준으로 자연 배치)": [
                x.get("item") for x in (space.get("life_baseline") or []) if x.get("item")
            ],
        },
        "physical_realism": analysis.get("physical_realism") or [],
        "place": place, "suggested_view": view_hint,
    }
    return (
        json.dumps(payload, ensure_ascii=False, indent=2)
        + "\n\n위 옥외 공간의 ★단 한 장 기준 배경 실사진★ T2I 프롬프트(영어)를 작성하라. "
        "이후 샷들이 i2i 로 변형해 쓰는 유일한 원본이므로 변형이 쉬운 일반적 wide establishing 구도로, "
        "구조·고정물·구축 방식(재질 이질감)을 정확히. 사건·표식·인물·글자 금지."
    )


def build_marked_view_prompt(num: int, place: str, view: str, life_items: List[str]) -> str:
    """실내 마킹 FP → BG i2i 프롬프트. life_baseline(FP 미표기 고정물)은 여기서만 주입."""
    extra = (
        "★Additionally, make the room realistically lived-in by including these non-movable items that belong in this place "
        "(NOT drawn in the plan): " + "; ".join(life_items) + " — true to the place's real condition and level, placed plausibly, "
        "WITHOUT altering any wall, door, window or the drawn furniture positions.★ "
    ) if life_items else ""
    # 모듈 __getattr__ 은 ★모듈 밖에서의 속성 접근★ 만 받는다 — 모듈 안의 전역
    # 이름 조회는 거치지 않으므로 여기서는 _pack 을 직접 부른다.
    return _pack("marked_fp_view").format(
        num=num, place=place, view=view, extra=extra)


# ════════════════════════════ ⑦b Phase 2: shot→space 배정 (LLM 1회 + 순수 조인) ════════════════════════════
# 판단은 상류(이 step) 1회 — 하류 scene_image 는 shot_plate_map 을 코드 조인만 한다 (Codex B 합의).
# 팩 stem: shot_assign_sys → 상수 SHOT_ASSIGN_SYS (지연 로드).

PLATE_ACTIONS = ("reuse_base", "derive_from_base", "no_plate")


def build_shot_assign_user(analysis: Dict[str, Any], shot_rows: List[Dict[str, Any]]) -> str:
    """shot→space 배정 user 프롬프트 — ★씬/샷 입력 절대 무삭제 (CLAUDE.md).
    shot_rows = step._scenes_to_shot_rows 출력 (scene/shot 인덱스 포함)."""
    spaces = [
        {
            "name": s.get("name"), "enclosure": s.get("enclosure"),
            "same_space_as": s.get("same_space_as"),
            "addon": [a.get("implementation") for a in (s.get("addon") or []) if a.get("implementation")],
        }
        for s in (analysis.get("spaces") or [])
    ]
    lines: List[str] = []
    seen_scene: set = set()
    for r in shot_rows:
        sc = r.get("scene")
        if sc not in seen_scene:
            seen_scene.add(sc)
            lines.append(f"\n[scene {sc}] {r.get('heading') or ''}\n장면요약: {r.get('summary') or ''}")
        lines.append(f"  - (scene {sc}, shot {r.get('shot')}) {r.get('shot_desc') or ''}")
    return (
        "[이 장소의 공간 목록 — space 값은 이 name 들 중에서만]\n"
        + json.dumps(spaces, ensure_ascii=False, indent=2)
        + "\n\n[샷 목록 — 각 샷의 (scene, shot) 인덱스를 출력에 그대로 사용]"
        + "\n".join(lines)
        + "\n\n각 샷이 촬영되는 주 공간 하나를 증거로 배정해 schema 로 출력하라. 불확실하면 null."
    )


def build_shot_plate_map(
    assignments: List[Dict[str, Any]],
    plates: Dict[str, Dict[str, Any]],
    *,
    connector_names: Optional[set] = None,
) -> Tuple[Dict[str, Dict[str, Any]], List[Dict[str, Any]]]:
    """배정 결과 × plate 산출의 ★순수 deterministic 조인★ (LLM/이미지 호출 없음).

    반환: (shot_plate_map, diagnostics)
      shot_plate_map["{scene}_{shot}"] = {space, plate_key, plate_png, plate_kind,
                                          shot_id, basis, plate_action[, secondary_spaces]
                                          [, derive_instruction, canonical_plate_key,
                                             canonical_plate_png]}
      diagnostics = 배정 불가 사유 기록 (게이트 아님 — 해당 shot 은 기존 경로 유지)
      connector_names = plate 를 의도적으로 생략한 threshold 공간 — 배정되면
      LLM 발명(unknown_space)과 구분해 connector_no_plate 로 기록 (운영 디버깅).

    Phase 3 plate_action (얇은 gate — 조인은 비치명 유지):
      · reuse_base(기본/미지정/enum 밖 값) = canonical plate 그대로.
      · derive_from_base = derive 메타만 기록하고 plate_png 는 ★canonical 유지★ —
        step 이 derive i2i 성공 시에만 derived png 로 교체 (실패 = canonical fallback).
      · no_plate = spm 제외 + 진단 (해당 shot 은 기존 경로 유지).
    """
    spm: Dict[str, Dict[str, Any]] = {}
    diags: List[Dict[str, Any]] = []
    connectors = connector_names or set()
    for a in assignments or []:
        sc, sh = a.get("scene"), a.get("shot")
        space = a.get("space")
        try:
            sci, shi = int(sc), int(sh)
        except (TypeError, ValueError):
            diags.append({"reason": "invalid_index", "scene": sc, "shot": sh, "space": space})
            continue
        key = f"{sci}_{shi}"
        if not space:
            diags.append({"reason": "unassigned", "scene": sc, "shot": sh,
                          "basis": a.get("basis")})
            continue
        action = a.get("plate_action") or "reuse_base"
        if action not in PLATE_ACTIONS:
            diags.append({"reason": "unknown_plate_action", "scene": sc, "shot": sh,
                          "space": space, "plate_action": action})
            action = "reuse_base"
        if action == "no_plate":
            diags.append({"reason": "plate_action_no_plate", "scene": sc, "shot": sh,
                          "space": space, "basis": a.get("basis")})
            continue
        derive_instruction = ""
        if action == "derive_from_base":
            derive_instruction = (a.get("derive_instruction") or "").strip()
            if not derive_instruction:
                diags.append({"reason": "derive_missing_instruction", "scene": sc,
                              "shot": sh, "space": space})
                action = "reuse_base"
        plate = plates.get(space)
        if plate is None:
            reason = "connector_no_plate" if space in connectors else "unknown_space"
            diags.append({"reason": reason, "scene": sc, "shot": sh, "space": space})
            continue
        if plate.get("status") != "ok":
            diags.append({"reason": "plate_not_ok", "scene": sc, "shot": sh, "space": space})
            continue
        if key in spm:
            diags.append({"reason": "duplicate_shot", "scene": sc, "shot": sh, "space": space})
            continue
        entry: Dict[str, Any] = {
            "space": space,
            "plate_key": plate.get("key"),
            "plate_png": plate.get("png"),
            "plate_kind": plate.get("kind"),
            "shot_id": f"S{sci}_Shot{shi}",
            "basis": a.get("basis") or "",
            "plate_action": action,
        }
        if action == "derive_from_base":
            entry["derive_instruction"] = derive_instruction
            entry["canonical_plate_key"] = plate.get("key")
            entry["canonical_plate_png"] = plate.get("png")
        if a.get("secondary_spaces"):
            # multi-ref 금지 — diagnostic 보존만 (Codex caveat)
            entry["secondary_spaces"] = a.get("secondary_spaces")
        spm[key] = entry
    return spm, diags


# derive 파생 plate i2i — 배경 전용 (검증 패턴: 정체성 유지 + 카메라만 이동.
# 인물/사건/스토리 소품을 원천 배제해 moderation 차단(폭력 직역) 경로도 차단).
# 팩 stem: derive_plate_view → 상수 DERIVE_PLATE_VIEW
# (지연 로드, {instruction} 은 호출부 format).


def build_derive_plate_prompt(instruction: str) -> str:
    """derive_from_base i2i 프롬프트 — canonical plate 를 identity 참조로 카메라만 이동."""
    # 모듈 안이라 __getattr__ 을 못 거친다 (build_marked_view_prompt 와 같은 이유).
    return _pack("derive_plate_view").format(
        instruction=(instruction or "").strip())


# ════════════════════════════ ⑧ view 순서/종류 계획 (순수 — step 이 그대로 실행) ════════════════════════════
def slug(name: str) -> str:
    return re.sub(r"[^a-z0-9]+", "_", (name or "").lower()).strip("_")


def plan_space_views(
    analysis: Dict[str, Any],
    briefs: Dict[str, str],
    *,
    threshold_plate: bool = False,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
    """각 공간의 BG 생성 계획을 결정 (순수 로직 — 이미지/LLM 호출 없음).

    반환: (plans, connectors)
      plans[i] = {key, num, room, view, kind, ref_room, group_suffix}
        kind ∈ {"marked_indoor", "outdoor_t2i", "samespace_ref"}
        · canonical(독립) 공간 먼저, same_space 종속은 그 뒤 (참조 가능 순서)
      connectors = threshold(=connector) 공간 — plate 생략 (threshold_plate=False 시)
    """
    same = {r.get("name"): (r.get("same_space_as") or None) for r in analysis.get("spaces", [])}
    enc = {r.get("name"): str(r.get("enclosure") or "enclosed").lower() for r in analysis.get("spaces", [])}
    groups = enclosure_groups(analysis)
    multi = len(groups) > 1
    space_suffix: Dict[str, str] = {}
    for gk, gs in groups.items():
        sfx = f"_{gk}" if multi else ""
        for s in gs:
            space_suffix[s.get("name") or ""] = sfx
    connectors = [
        rn for rn in briefs
        if (not threshold_plate) and enc.get(rn) == "threshold" and not same.get(rn)
    ]
    indep = [rn for rn in briefs if not same.get(rn) and rn not in connectors]
    deps = [rn for rn in briefs if same.get(rn) and rn not in connectors]
    ordered = indep + deps
    keymap: Dict[str, str] = {}
    for i, rn in enumerate(ordered):
        k = slug(rn) or f"space{i + 1}"
        while k in keymap.values():
            k = f"{k}_{i + 1}"
        keymap[rn] = k
    plans: List[Dict[str, Any]] = []
    for i, rn in enumerate(ordered):
        ref = same.get(rn)
        is_out = enc.get(rn) == "open_air"
        if ref and ref in keymap:
            kind = "samespace_ref"
        elif is_out:
            kind = "outdoor_t2i"
        else:
            kind = "marked_indoor"
        plans.append({
            "key": keymap[rn], "num": i + 1, "room": rn, "view": briefs.get(rn, ""),
            "kind": kind, "ref_room": ref, "ref_key": keymap.get(ref) if ref else None,
            "group_suffix": space_suffix.get(rn, ""), "enclosure": enc.get(rn),
        })
    # ★단일 marked_indoor 그룹 = FP 미경유 직접 T2I (2026-06-10 사용자 피드백).
    # 마킹의 존재 이유 = '여러 방 중 어느 방' 지정 — 그룹에 marked 공간이 1개뿐이면
    # FP 가 주는 배치 가치가 0 이고 직역 위험(도트 그리드→벽지, 심볼→평면 장식)만 남는다.
    marked_by_sfx: Dict[str, int] = {}
    for p in plans:
        if p["kind"] == "marked_indoor":
            marked_by_sfx[p["group_suffix"]] = marked_by_sfx.get(p["group_suffix"], 0) + 1
    for p in plans:
        if p["kind"] == "marked_indoor" and marked_by_sfx.get(p["group_suffix"], 0) == 1:
            p["kind"] = "indoor_t2i"
    conn_meta = [
        {"room": rn, "enclosure": enc.get(rn), "role": "entry connector (no plate)", "view": briefs.get(rn)}
        for rn in connectors
    ]
    return plans, conn_meta
