"""background_prompt — Phase 7 Step 5 / Phase 8 v2 / D6 v6.

배경별 detailed t2i prompt 생성. 도면 ref + 이전 배경 ref 활용 가이드 포함.
plot-critical 시각 요소를 LLM이 scene segments에서 자연히 picking하도록.

Phase 8 v2:
- t2i_prompt 본문 = ``scene_segments`` 원문 언어 (한국 시나리오면 한국어).
  → 식별자(bg_id, sub_location, state_label, shot_id)에만 ASCII 검증 유지,
  t2i_prompt 본문에는 ASCII 검증 제거.
- ``floor_plan_prompt`` 가 산출한 ``numbered_elements`` + ``camera_recommendation``
  (현재 bg_id 매칭 단일 entry) 을 user_template 에 inject한다. 두 값이 None
  이면 "(none — derive ...)" fallback 가이드 문자열로 대체된다.

D6 v6: BG_ID_RE (`^L\\d{2,3}B\\d{2,3}$`) 정합 + 2-layer 방어.
- L1 static schema: ``bg_id`` pattern → BG_ID_RE. 옛 lowercase pattern reject.
- L2 runtime enum injection: ``run_background_prompt`` 가 schema deep-copy +
  ``bg_id.enum=[expected_bg_id]`` 주입 → LLM 출력을 정확한 ID 로 강제.

추가: ``build_bg_user_prompt`` 가 D6 raw intent 의 ``state_label_raw`` /
``sub_location_label`` 우선 사용 (legacy ``state_label`` / ``sub_location``
fallback 유지). prior 5.x 까지는 D6 master_plan 의 raw intent 출력에서
이 두 필드가 빈 문자열로 inject 되는 silent gap 결함이 있었다.
"""
from __future__ import annotations

import copy
import logging
import re
import time
from typing import Any, Callable, Dict, FrozenSet, List, Optional, Set

from app.modules.prompt_loader import load_prompt, load_schema

logger = logging.getLogger(__name__)
_MODULE = "background_prompt"
_FALLBACK_BLOCK = "(none — derive from scene segments and shot descriptions)"

# W19B-2 / W21B-w1/W21B-wave-2: selector ↔ on-disk pack directory mapping.
# default "6" 는 기존 v6 pack 을 그대로 가리킨다 → resolved hash byte-compatible.
# opt-in "7" 은 floor_plan_overlay_payload checkpoint 를 요구하는 layer-aware
# pack. opt-in "8" (W21B-wave-1) 은 v7 의 overlay payload SOT 를 그대로 받지만
# BG plate purity 강화로 transient overlay markers / scene_segments verbatim
# 본문 어떤 것도 prompt 에 surface 시키지 않는다. cinematic tone (anti-
# stylization checklist 재정의) + 인물/팔/손/얼굴/시신/현재 shot action /
# photo-handling 차단. opt-in "9" (W21B-wave-2) 은 v8 contract 를 유지하면서
# surface_role prompt slot + display/screen content guard 를 추가한다. opt-in
# "10" (W21B-wave-2.1) 은 v9 의 screen guard 를 강화해 screen/frame 내부를
# black/off/blank/glare/static/unreadable block 으로 제한한다.
# unknown selector 는 silent latest-pack 으로 빠지지 않도록 ValueError.
# opt-in "11" (W21B-wave-4 A2) 은 v10 contract 를 그대로 유지하면서 Base markers
# 블록에 marker legibility(legibility=low → 도면 glyph 대신 label 로 정체 판단)
# guidance 1줄만 추가한다. schema/output shape v10 동일.
# opt-in "12" (W21B-wave-4 C4) 은 v10 contract 를 그대로 유지하면서
# ``{projection_card_plate_block}`` placeholder 한 개를 추가한다 — projection-card
# guard (resolve_projection_plate_injection) 가 inject=True 로 판정한 anchor plate
# prose 만 surface 시키고, 그 외(모든 fallback_reason / selector!="12")엔 clean
# sentinel 로 치환해 v10 inference path 와 byte-faithful 하게 둔다. schema/output
# shape v10 동일. bg_plate_visible_description 외 어떤 필드도 노출하지 않는다.
PROMPT_VERSION_MAP: Dict[str, str] = {
    "6": "6.202605091200",
    "7": "7.202605262330",
    "8": "8.202605282046",
    "9": "9.202605290248",
    "10": "10.202605290451",
    "11": "11.202605300939",
    "12": "12.202605310513",
    # 14 (2026-07-14): v6 + FACADE & LIVED-IN — 실외 배경의 실물 주거 전형
    # (지역 외장·마감 구체 명시, 생활 설비 층층이, 이웃 실물 동네, 요새 금지).
    # 웹 조사 근거(한국=녹색 우레탄 방수·적벽돌 등)+L03 재저작 3롤 실증.
    "14": "14.202607141240",
}

# E2E11 fix① (Codex BLOCKING-1 재설계): 휴먼 스케일 앵커는 **선택형 팩이
# 아니라 활성 selector(v7 shot-aware/v14 등) 무관 부착 스템** — selector
# Literal/overlay gate/preflight 계약을 건드리지 않는다. 단일 스템 팩
# 디렉토리(camera_frame v6 선례)만 명시 버전으로 로드.
HUMAN_SCALE_RULE_PACK = "15.202607221105"


def load_human_scale_rule() -> str:
    """건축 규모 휴먼 스케일 앵커 규칙 — 활성 system 프롬프트에 부착.

    실측(E2E11): 옥탑방(창 1개)이 건물 덩어리로 과대 렌더 — 규모 계약이
    저작 프롬프트에 구조적으로 없었다. 문/창/층고/난간 등 사람 크기 고정
    요소 기준의 범용 앵커(시나리오 특정 0).
    """
    return load_prompt(
        _MODULE, "human_scale_rule", version=HUMAN_SCALE_RULE_PACK
    ).strip()


# W21B-wave-4 C4: v12 projection plate block sentinel — DEFENSIVE only. The
# production step (BackgroundPromptStep) routes every fallback bg to the
# effective v10 pack/path (no projection block at all), so it never emits this
# sentinel. It is reached only if build_bg_user_prompt is called directly with
# prompt_version="12" and no plate prose; the sentinel then tells the LLM
# "no verified anchor plate → derive from the inputs above" rather than leaving
# a raw placeholder. Not a production fallback surface.
_PROJECTION_PLATE_NONE_SENTINEL = (
    "(none — no verified anchor projection plate for this background; "
    "derive the plate from the inputs above)"
)


def resolve_prompt_version(selector: str) -> str:
    """selector ("6"|"7"|"8"|"9"|"10"|"11"|"12") → on-disk pack directory name."""
    try:
        return PROMPT_VERSION_MAP[selector]
    except KeyError as exc:
        raise ValueError(
            f"unknown background_prompt selector {selector!r}; "
            f"allowed: {sorted(PROMPT_VERSION_MAP)}"
        ) from exc


class BackgroundPromptError(Exception):
    """retry 한도까지 실패."""


class BackgroundPromptOverlayError(Exception):
    """v7 path 의 overlay payload 가 누락/형상-오류 — fail-closed."""


def _format_numbered_elements_block(
    items: Optional[List[Dict[str, Any]]],
) -> str:
    """floor_plan_prompt 결과의 ``numbered_elements`` 를 자연어 블록으로 직렬화.

    한 줄 1 entry: ``"<number>. <label> [<category>] — <position_hint>"``.
    LLM이 t2i_prompt 작성 시 번호 ref 를 자연어로 변환하는 dictionary 역할.
    None / empty list 이면 fallback 가이드 문자열 반환.
    """
    if not items:
        return _FALLBACK_BLOCK
    lines: List[str] = []
    for it in items:
        num = it.get("number", "?")
        label = it.get("label", "(missing label)")
        category = it.get("category", "?")
        pos = it.get("position_hint", "(missing position)")
        lines.append(f"{num}. {label} [{category}] — {pos}")
    return "\n".join(lines)


def _format_layer_markers_block(markers: List[Dict[str, Any]]) -> str:
    """W19B-2 v7 helper: ``base_markers_to_reference`` 또는
    ``transient_markers_to_describe`` 의 marker entry 리스트를 자연어 블록으로
    직렬화. 한 줄 1 entry: ``"<number>. <label> [<category>] — <position_hint>"``.

    label/prose 의미 추출 X — entry 의 number / label / category / position_hint
    필드만 그대로 사용. clean BG 의 sentinel 은 caller (``_format_transient_block``)
    에서 처리한다.

    W21B-wave-4 A2 (Claude+Codex lock): marker entry 가 ``render_role`` /
    ``top_down_legibility`` 를 carry 하면 ``[<category>, <render_role>,
    legibility=<clear|low>]`` 로 확장한다. 두 필드가 없거나 ``not_applicable``
    이면 옛 라인과 **byte-identical** (backward-compat). ``not_applicable`` 은
    prompt 에 짧게 표기하지 않고 아예 생략한다 (값 자체는 payload enum 보존).
    enum 값을 그대로 출력만 — 어떤 lexical 분류도 하지 않는다.
    """
    if not markers:
        return ""
    lines: List[str] = []
    for it in markers:
        num = it.get("number", "?")
        label = it.get("label", "(missing label)")
        category = it.get("category", "?")
        pos = it.get("position_hint", "(missing position)")
        extras: List[str] = []
        role = it.get("render_role")
        if role and role != "not_applicable":
            extras.append(str(role))
        leg = it.get("top_down_legibility")
        if leg and leg != "not_applicable":
            extras.append(f"legibility={leg}")
        bracket = ", ".join([category, *extras]) if extras else category
        lines.append(f"{num}. {label} [{bracket}] — {pos}")
    return "\n".join(lines)


_CLEAN_BG_SENTINEL = (
    "(none — clean / undisturbed state; do not introduce any transient cue, "
    "debris, mark, or displaced object in the t2i_prompt body, and do not "
    "enumerate marker numbers or labels in any 'missing' or 'absent' "
    "sentence)"
)


def _format_transient_block(
    transient: List[Dict[str, Any]],
    *,
    clean_background_expected: bool,
) -> str:
    """v7 transient markers 직렬화. empty 인 경우 clean sentinel 반환.

    ``clean_background_expected`` 는 overlay payload 의 동일 필드. 직접 길이
    체크와 별개로 sentinel 표기 조건을 명시적으로 carry 한다.
    """
    if transient:
        return _format_layer_markers_block(transient)
    if clean_background_expected:
        return _CLEAN_BG_SENTINEL
    # 누락된 경우에도 sentinel 동일 — caller 단계에서 fail-closed 가 먼저 동작해야.
    return _CLEAN_BG_SENTINEL


def _extract_transient_marker_numbers(
    overlay_payload: Optional[Dict[str, Any]],
) -> FrozenSet[int]:
    """Structured set of NON-base (transient / ignored-state-overlay) marker
    numbers from a floor_plan_overlay_payload (W21B-wave-4 C4 follow-up).

    These numbers are the BG-plate purity exclusion set: a transient overlay
    marker (event residue, displaced object) or an ignored state-overlay
    marker must never be cited in the background-plate prompt. Base markers
    (``base_markers_to_reference``) are intentionally NOT included — they are
    the layout reference and the LLM legitimately translates them (Rule 7).

    Returns an empty set for any structural gap (None / malformed / no
    transient markers) → no sanitization (e.g. the v6 no-overlay path).
    """
    nums: Set[int] = set()
    if not isinstance(overlay_payload, dict):
        return frozenset()
    for key in ("transient_markers_to_describe", "ignored_state_overlay_markers"):
        for m in overlay_payload.get(key) or []:
            if isinstance(m, dict):
                n = m.get("number")
                if isinstance(n, int) and not isinstance(n, bool):
                    nums.add(n)
    return frozenset(nums)


def _text_cites_transient_marker(
    text: str, transient_numbers: FrozenSet[int]
) -> bool:
    """True if ``text`` references a transient marker NUMBER token.

    Self-defined marker-number token check (``number N`` / ``#N`` /
    ``marker N``) over the STRUCTURED transient-number set — identical in
    spirit to the C2 ``find_plate_prose_leaks`` emitted-marker-number check.
    This matches the system's own enumerated reference syntax, NOT natural
    language meaning; Korean / free prose semantics are never parsed.
    """
    if not text or not transient_numbers:
        return False
    for n in transient_numbers:
        if re.search(
            rf"number\s*{n}\b|#\s*{n}\b|\bmarker\s*{n}\b", text, re.IGNORECASE
        ):
            return True
    return False


def _format_camera_recommendation_block(
    cam: Optional[Dict[str, Any]],
    transient_numbers: FrozenSet[int] = frozenset(),
) -> str:
    """현재 bg_id에 매칭되는 단일 camera_recommendation entry를 자연어 블록으로 직렬화.

    None 이면 fallback 가이드 문자열. ``framing_notes`` 가 있을 때만 그 줄 추가.

    W21B-wave-4 C4 follow-up (Codex gate Required): ``transient_numbers`` 가
    주어지면(overlay-path packs v7-v12), camera_position / framing_notes 중
    transient marker 번호를 인용하는 필드는 BG-plate purity 위반이므로
    fail-closed 로 drop 한다(base marker 번호 인용은 정당하므로 유지). 빈 set
    (v6 no-overlay)이면 기존 verbatim 동작과 byte-identical.
    """
    if not cam:
        return _FALLBACK_BLOCK
    parts = []
    pos = cam.get("camera_position", "(missing)")
    if not _text_cites_transient_marker(str(pos), transient_numbers):
        parts.append(f"camera_position: {pos}")
    parts.append(f"camera_height: {cam.get('camera_height', '(missing)')}")
    parts.append(f"lens_hint: {cam.get('lens_hint', '(missing)')}")
    fn = cam.get("framing_notes", "")
    if fn and not _text_cites_transient_marker(str(fn), transient_numbers):
        parts.append(f"framing_notes: {fn}")
    return "\n".join(parts)


def _validate_overlay_payload_shape(
    bg_id: str, overlay_payload: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
    """v7 path 의 overlay payload 형상 검증 — fail-closed.

    Required keys (W19B-1 출력 shape):
        bg_id, fp_id, use_numbered_elements (list[int]),
        ignore_numbered_elements (list[int]), base_markers_to_reference (list[dict]),
        transient_markers_to_describe (list[dict]), ignored_state_overlay_markers
        (list[dict]), target_unit_marker_numbers (list[int]),
        dominant_target_unit_marker_number (int | None),
        clean_background_expected (bool), diagnostics (list[str]).

    Raises ``BackgroundPromptOverlayError`` 로 step 에서 흡수.
    """
    if overlay_payload is None:
        raise BackgroundPromptOverlayError(
            f"bg_id={bg_id!r} v7 requires floor_plan_overlay_payload entry "
            "but received None"
        )
    if not isinstance(overlay_payload, dict):
        raise BackgroundPromptOverlayError(
            f"bg_id={bg_id!r} overlay payload not a dict "
            f"(got {type(overlay_payload).__name__})"
        )
    if overlay_payload.get("bg_id") != bg_id:
        raise BackgroundPromptOverlayError(
            f"bg_id={bg_id!r} overlay payload bg_id mismatch: "
            f"{overlay_payload.get('bg_id')!r}"
        )
    for field, expected_type in (
        ("base_markers_to_reference", list),
        ("transient_markers_to_describe", list),
        ("ignored_state_overlay_markers", list),
        ("clean_background_expected", bool),
    ):
        if not isinstance(overlay_payload.get(field), expected_type):
            raise BackgroundPromptOverlayError(
                f"bg_id={bg_id!r} overlay payload {field!r} missing or wrong "
                f"type (expected {expected_type.__name__}, "
                f"got {type(overlay_payload.get(field)).__name__})"
            )
    return overlay_payload


def build_bg_user_prompt(
    bg_spec: Dict[str, Any],
    floor_plan_path: Optional[str],
    prior_bg_paths: List[str],
    scene_segments: List[Dict[str, Any]],
    visual_world_rules: str,
    numbered_elements: Optional[List[Dict[str, Any]]] = None,
    camera_recommendation: Optional[Dict[str, Any]] = None,
    source_language: str = "",
    prompt_version: str = "6",
    overlay_payload: Optional[Dict[str, Any]] = None,
    projection_plate_prose: Optional[str] = None,
) -> str:
    """배경 user prompt — scene text 무절단 inject.

    .replace() 사용 (.format() brace 충돌 회피, T4/T7/T9 동일 패턴).

    Phase 8 v2:
    - ``numbered_elements`` : floor_plan_prompt 결과 list 전체 (도면의 모든 번호).
    - ``camera_recommendation`` : 현재 bg_id 에 해당하는 단일 entry (없으면 None).

    Phase 8.1:
    - ``source_language`` : ISO 639-1 코드 (예: "ko", "en"). 빈 문자열이면 LLM이
      scene_segments 에서 자체 추론 (system prompt fallback). v3 prompt 가
      이 값을 본문 언어로 강제한다.

    W19B-2 / W21B-w1 / W21B-wave-2 (selector "6"|"7"|"8"|"9"|"10"):
    - ``prompt_version="6"`` (default): 기존 v6 pack + flat numbered_elements_block
      유지. ``overlay_payload`` 는 무시 (v6 user_template 에 해당 placeholder 없음).
    - ``prompt_version="7"`` (opt-in): v7 pack 로드. ``overlay_payload`` 필수
      (None / shape 오류 시 ``BackgroundPromptOverlayError`` raise).
      ``base_markers_to_reference`` → ``{base_markers_block}``,
      ``transient_markers_to_describe`` (또는 clean sentinel) →
      ``{transient_markers_block}``. ignored_state_overlay_markers 는 prompt 에
      번호/라벨 X (overlay leak 방지).
    - ``prompt_version="8"`` (opt-in, W21B-wave-1): v7 와 동일한 overlay payload
      SOT 를 그대로 받지만 BG plate purity 강화로 prompt 본문에 transient
      overlay markers / scene_segments verbatim 본문 어떤 것도 surface 시키지
      않는다. user_template 에 ``{transient_markers_block}``,
      ``{scene_segments_block}`` placeholder 모두 부재 — base markers / camera
      recommendation / applies_to_shots / visual_world_rules / source language
      만 본문에 들어간다. source_language fallback 도 visual_world_rules +
      주변 context 기반으로 분리 (scene_segments 언급 제거). system rules 가
      cinematic photoreal plate 톤 + 인물/팔/손/얼굴/시신/혈흔/photo-handling
      action 차단을 명시한다.
    - ``prompt_version="9"`` (opt-in, W21B-wave-2): v8 의 BG-purity contract 를
      유지하면서 background_master_plan 의 ``surface_role`` 을 prompt 에
      노출하고 TV/monitor/photo-frame 내부 content 를 non-semantic surface 로
      제한한다. ``interior_room`` / floor-plan-backed BG 는 overlay payload 를
      계속 필수로 요구한다. fp-less exterior / transition / site plate 는 overlay
      cp 의 대상이 아니므로 base marker block 없이 prompt 를 구성한다.
    - ``prompt_version="10"`` (opt-in, W21B-wave-2.1): v9 의 surface_role /
      fp-less plate path 를 유지하면서 screen / display / frame 내부 content 를
      더 강하게 blank/off/glare/static/unreadable-only 로 제한한다.
    - ``prompt_version="12"`` (opt-in, W21B-wave-4 C4): v10 contract 를 그대로
      받고 ``{projection_card_plate_block}`` 하나만 추가한다. ``projection_plate_prose``
      (projection-card guard 가 inject=True 로 판정한 anchor plate prose) 가
      주어지면 그것만 block 에 surface 시키고, None 이면 clean sentinel 로
      치환한다. base markers / overlay / fp-less path 는 v10 과 동일. plate prose
      외 어떤 card 필드(scene_visible_description / visible_items / marker_registry
      / card_id / enum)도 노출하지 않는다.
    """
    resolved = resolve_prompt_version(prompt_version)
    template = load_prompt(_MODULE, "user_template", version=resolved)

    fp_block = floor_plan_path if floor_plan_path else "(none)"
    prior_block = "\n".join(f"- {p}" for p in prior_bg_paths) or "(none)"
    shots_block = (
        "\n".join(f"- {s}" for s in (bg_spec.get("applies_to_shots") or []))
        or "(none)"
    )

    seg_lines: List[str] = []
    for seg in scene_segments:
        si = seg.get("scene_index")
        heading = (seg.get("heading") or "").strip()
        text = (seg.get("text") or "").strip()
        seg_lines.append(
            f"### Scene {si} — {heading}" if heading else f"### Scene {si}"
        )
        seg_lines.append(text)
        seg_lines.append("")
    seg_block = "\n".join(seg_lines).rstrip() or "(none)"

    # W21B-wave-4 C4 follow-up: sanitize the camera block against the
    # overlay's structured transient marker-number set so a transient cue
    # (e.g. "depict number 18 as a transient restocking cue") never reaches
    # the BG-plate prompt surface. Empty set (v6 no-overlay) → unchanged.
    camera_block = _format_camera_recommendation_block(
        camera_recommendation,
        transient_numbers=_extract_transient_marker_numbers(overlay_payload),
    )

    # D6 v6: raw intent 필드 우선, legacy fallback 유지.
    sub_location = (
        bg_spec.get("sub_location_label") or bg_spec.get("sub_location") or ""
    )
    state_label_raw = (
        bg_spec.get("state_label_raw") or bg_spec.get("state_label") or ""
    )
    bg_id = bg_spec.get("bg_id", "")

    # W21B-w1: v6/v7 의 source_language fallback 은 scene_segments 본문을
    # SOT 로 가리키는 표현. v8 은 scene_segments 자체를 user_template 에서
    # 빼버렸으므로 같은 fallback 을 쓰면 모순이 된다 (LLM 이 존재하지 않는
    # section 을 참조). v8 fallback 은 visual_world_rules + 주변 context 만
    # 가리키도록 분리한다.
    if prompt_version in ("8", "9", "10", "11", "12"):
        source_language_fallback = (
            source_language
            or "(unknown — derive from visual_world_rules and source context)"
        )
    else:
        source_language_fallback = (
            source_language or "(unknown — derive from scene_segments)"
        )

    rendered = (
        template
        .replace("{bg_id}", bg_id)
        .replace("{loc_id}", bg_spec.get("loc_id", ""))
        .replace("{sub_location}", sub_location)
        # v6 template 은 ``{state_label_raw}`` 사용. v5 backward-compat 으로
        # ``{state_label}`` placeholder 도 동일 값으로 치환 — pack drift 방어.
        .replace("{state_label_raw}", state_label_raw)
        .replace("{state_label}", state_label_raw)
        .replace("{surface_role}", bg_spec.get("surface_role", "") or "(unspecified)")
        .replace("{source_language}", source_language_fallback)
        .replace("{floor_plan_path_block}", fp_block)
        .replace("{prior_bg_paths_block}", prior_block)
        .replace("{camera_recommendation_block}", camera_block)
        .replace("{applies_to_shots_block}", shots_block)
        .replace("{scene_segments_block}", seg_block)
        .replace("{visual_world_rules}", visual_world_rules or "(none)")
    )

    if prompt_version == "6":
        numbered_block = _format_numbered_elements_block(numbered_elements)
        rendered = rendered.replace("{numbered_elements_block}", numbered_block)
    elif prompt_version == "7":
        # v7 — overlay payload 가 SOT. base + transient 둘 다 prompt 본문에
        # 직접 노출 (전자는 layout reference, 후자는 surface state prose 번역).
        payload = _validate_overlay_payload_shape(bg_id, overlay_payload)
        base_block = (
            _format_layer_markers_block(payload["base_markers_to_reference"])
            or "(none)"
        )
        transient_block = _format_transient_block(
            payload["transient_markers_to_describe"],
            clean_background_expected=bool(payload["clean_background_expected"]),
        )
        rendered = (
            rendered
            .replace("{base_markers_block}", base_block)
            .replace("{transient_markers_block}", transient_block)
        )
    elif prompt_version in ("8", "9", "10", "11", "12"):
        # v8/v9/v10 — overlay payload validation 은 v7 와 동일하게 유지
        # 하지만 BG-plate purity 강화를 위해 transient markers 는 prompt
        # 본문에 노출하지 않는다. user_template 에 ``{transient_markers_block}``
        # placeholder 가 존재하지 않으므로 transient block 직렬화 자체를 생략한다.
        # 결과적으로 base markers + camera + applies_to_shots + world rules 만
        # prompt 본문에 들어가고, transient overlay 의 label / position_hint /
        # prose 어떤 형태로도 leak 되지 않는다 — W20E7 의 hand/arm/body/
        # fresh-blood label 누설 회로를 prompt level 에서 끊는다. v9 는
        # 여기에 surface_role + display/screen guard 문구만 더한다. W21B-wave-2
        # 에서 새로 생기는 fp-less exterior / transition / site plate 는
        # floor_plan_overlay_payload 산출 대상이 아니므로 v9 에 한해 overlay
        # 없이도 base marker block 을 비워 진행한다. v10 은 screen/display
        # content guard 만 더 강하게 만든다. interior / floor-plan-backed BG 의
        # overlay 누락은 여전히 fail-closed 한다.
        surface_role = bg_spec.get("surface_role", "") or "interior_room"
        fp_less_surface_plate = (
            prompt_version in {"9", "10", "11", "12"}
            and overlay_payload is None
            and not floor_plan_path
            and surface_role in {"exterior_plate", "transition_zone", "site_surface"}
        )
        if fp_less_surface_plate:
            base_block = (
                "(none — fp-less surface plate; derive persistent physical "
                "structure from surface_role, sub_location, state_label_raw, "
                "applies_to_shots, and visual_world_rules)"
            )
        else:
            payload = _validate_overlay_payload_shape(bg_id, overlay_payload)
            base_block = (
                _format_layer_markers_block(payload["base_markers_to_reference"])
                or "(none)"
            )
        rendered = rendered.replace("{base_markers_block}", base_block)

    if prompt_version == "12":
        # W21B-wave-4 C4: projection-card guard (resolve_projection_plate_injection)
        # 가 inject=True 로 판정해 ``projection_plate_prose`` 를 넘긴 경우에만 anchor
        # plate prose 를 block 에 surface. 모든 fallback_reason (None) 엔 clean
        # sentinel 로 치환해 v10 inference 와 동치로 둔다. plate prose 외 어떤 card
        # 필드도 여기서 노출되지 않는다 (guard 가 plate prose 단일 문자열만 반환).
        plate = (projection_plate_prose or "").strip()
        projection_block = plate or _PROJECTION_PLATE_NONE_SENTINEL
        rendered = rendered.replace(
            "{projection_card_plate_block}", projection_block
        )

    return rendered


def validate_bg_prompt_output(
    output: Dict[str, Any],
    expected_bg_id: str,
    applies_to_shots: Set[str],
) -> None:
    """Phase 8 v2 + G3.2 invariants:
    1. ``bg_id`` matches expected (식별자 ASCII는 schema pattern에서 강제).
    2. ``t2i_prompt`` >= 50 chars (본문 ASCII guard 제거 — source-language 허용).
    3. ``shot_guides`` covers every applies_to_shots entry.
    4. (G3.2) ``objects_owned_by_background`` 는 list, normalize 후 1+ entries.
       in-place normalize (strip/dedupe/sort/maxLen=80) — 단순 검증 아닌 저장
       직전 정규화 책임.
    """
    from app.core.steps._owned_helpers import normalize_owned_list

    if output.get("bg_id") != expected_bg_id:
        raise ValueError(
            f"bg_id mismatch: got {output.get('bg_id')!r}, expected {expected_bg_id!r}"
        )
    t2i = output.get("t2i_prompt") or ""
    if len(t2i) < 50:
        raise ValueError(
            f"background_prompt {expected_bg_id} t2i too short ({len(t2i)})"
        )
    guides = output.get("shot_guides") or []
    guide_shot_ids = {g.get("shot_id") for g in guides if isinstance(g, dict)}
    missing = applies_to_shots - guide_shot_ids
    if missing:
        raise ValueError(
            f"background_prompt {expected_bg_id} missing shot_guides for: {sorted(missing)}"
        )
    # G3.2: objects_owned_by_background 검증 + normalize
    raw_owned = output.get("objects_owned_by_background")
    if not isinstance(raw_owned, list):
        raise ValueError(
            f"background_prompt {expected_bg_id} objects_owned_by_background "
            f"must be list (got {type(raw_owned).__name__})"
        )
    # round 6 BLOCKING 3: raw list 에 non-ASCII entry 가 하나라도 있으면 raise.
    # silent drop 금지 — 한국어 owned 가 silent 로 축소되면 contract 가 약해짐.
    # validate 단계에서 fail-fast 한 후, 모든 entry 가 ASCII 라야 normalize 진입.
    for idx, item in enumerate(raw_owned):
        if not isinstance(item, str):
            continue  # normalize 가 처리 (non-string drop)
        try:
            item.encode("ascii")
        except UnicodeEncodeError:
            raise ValueError(
                f"background_prompt {expected_bg_id} objects_owned_by_background "
                f"entry [{idx}] {item!r} contains non-ASCII (Hangul/CJK/Kana). "
                "owned MUST be English canonical common nouns (round 4 Q2=B / "
                "round 6 BLOCKING 3)."
            )
    normalized = normalize_owned_list(raw_owned)
    if not normalized:
        raise ValueError(
            f"background_prompt {expected_bg_id} objects_owned_by_background "
            "empty after normalize (must have 1+ entries)"
        )
    output["objects_owned_by_background"] = normalized  # in-place 저장 직전 정규화


def _inject_bg_id_enum(
    base_schema: Dict[str, Any], expected_bg_id: str
) -> Dict[str, Any]:
    """schema deep-copy + ``bg_id.enum=[expected_bg_id]`` 주입.

    D6 v6 L2 방어. base_schema 는 매 호출마다 새 dict 지만 DB cache /
    공유 dict 가능성 + retry 회로 안 mutation 누수 방어를 위해 deep-copy.
    """
    schema = copy.deepcopy(base_schema)
    bg_props = schema.get("properties", {}).get("bg_id")
    if isinstance(bg_props, dict):
        schema["properties"]["bg_id"] = {
            "type": "string",
            "pattern": bg_props.get("pattern", r"^L\d{2,3}B\d{2,3}$"),
            "enum": [expected_bg_id],
        }
    return schema


def run_background_prompt(
    *,
    user_prompt: str,
    expected_bg_id: str,
    applies_to_shots: List[str],
    call_structured_fn: Callable[..., Dict[str, Any]],
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
    max_retries: int = 3,
    backoff_base_sec: float = 2.0,
    sleep_fn: Callable[[float], None] = time.sleep,
    prompt_version: str = "6",
    scale_anchor: bool = False,
) -> Dict[str, Any]:
    """W19B-2: selector default ``"6"`` 는 기존 v6 pack 을 정확히 pin (latest-pack
    auto-resolve silent shift 방지). ``"7"`` opt-in 시 v7 pack 의 system/schema
    로드. schema 출력 shape 은 v6/v7 동일 → 기존 validator 그대로 사용.

    scale_anchor (E2E11 fix①): True 면 활성 system 에 휴먼 스케일 앵커
    규칙을 부착 — selector 계약(v7 shot-aware 등) 무변경. False(default)=
    기존 프롬프트 byte-identical.
    """
    resolved = resolve_prompt_version(prompt_version)
    system = load_prompt(_MODULE, "system", version=resolved)
    if scale_anchor:
        system = system + "\n" + load_human_scale_rule()
    base_schema = load_schema(_MODULE, "schema", version=resolved)
    # D6 v6: schema 에 expected bg_id 단일 enum 주입 (LLM 강제 + mutation 방어).
    schema = _inject_bg_id_enum(base_schema, expected_bg_id)
    shot_set = set(applies_to_shots)
    last_err: Optional[Exception] = None
    for attempt in range(max_retries):
        try:
            result = call_structured_fn(
                step="background_prompt",
                system_prompt=system,
                user_prompt=user_prompt,
                response_schema=schema,
                project_config=project_config,
                schema_name="background_prompt",
                opik_metadata=opik_metadata,
            )
            validate_bg_prompt_output(result, expected_bg_id, shot_set)
            return result
        except Exception as exc:  # T20 I3: LLM client errors (litellm/openai/httpx) 포함
            last_err = exc
            logger.warning(
                "background_prompt %s attempt %d/%d failed: %s",
                expected_bg_id, attempt + 1, max_retries, exc,
            )
            if attempt + 1 < max_retries:
                sleep_fn(backoff_base_sec * (attempt + 1))
    raise BackgroundPromptError(
        f"background_prompt {expected_bg_id} exhausted {max_retries} retries: {last_err}"
    )
