"""T2I 프롬프트 최종 조립 서비스 — Phase 3b.1.

기존 `image_service._build_final_scene_prompt` 211줄을 4단계 함수로 분리.
단계:
1. `resolve_ref_roles` — labeled_refs → ref_roles + ref_instructions
2. `replace_entity_ids` — t2i_prompt 내 C##/C##O##/P##/L## ID를 참조 이미지 번호 또는 entity 텍스트로 치환
3. `translate_if_korean` — 한국어 잔재 있으면 LLM 번역
4. `build_scene_text` — 최종 조립
공개 진입점: `build_final_scene_prompt` (기존 함수 계약과 동일한 시그니처).
"""
from __future__ import annotations

import logging
import re
import time as _time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)


# ──────────────────────────────────────────────────────────────
# Area #11 v1 W1 — LabeledRefPayload container + factory.
# spec: docs/superpowers/specs/2026-05-18-area-11-classify-label-substring-replacement-design.md §3.1
# plan: docs/superpowers/plans/2026-05-18-area-11-classify-label-substring-replacement-implementation.md Task 1.1
# ──────────────────────────────────────────────────────────────

REF_ROLE_VALUES = (
    "outfit_ref_explicit",
    "previous_shot_same_frame_zoomed",
    "previous_shot_same_room",
    "previous_shot_continuity",
    "background_chain_ref",
    "character_ref",
    "character_state_ref",   # state variant 별 enum (Codex iter 1 Important fix)
    "outfit_ref_inline",
    "prop_ref",
    "background_general",
    "fallback",
    # W21B-W8 composition guide (2026-06-13) — 야외 departing 샷 구도 스케치.
    # background/character/prop 계약 불참 (required_refs 충족 판단 제외).
    "composition_guide",
    # P8 I-1 (2026-06-28) — 실내 immobilized 피사체 자세 등록 가이드(bg-aware 시각
    # anchor). pose/contact/support surface 전용 SOT. composition_guide 와 같이
    # background/character/prop 계약 불참 (required_refs 충족 판단 제외).
    "immobilized_pose_guide",
    # Wave5 (2026-06-30) — 실내 일반 샷 shared-model pose/placement 가이드(bg plate
    # underlay 등록). registered/outdoor guide 와 render branch 분리. pose/placement
    # 전용 SOT — identity/clothing 은 final char refs 담당. background/character/prop
    # 계약 불참 (required_refs 충족 판단 제외).
    "indoor_pose_guide",
    # W22 (2026-07-10) — 야외 직행 합성 장소 캐논 2종 (s33 참조 관할 계약).
    # photo=룩 SOT(실사 마스터), map=배치 전용(작도 금지). attached_meta kind 는
    # "background"(value `outdoor_canon:<place>`) 로 붙어 required/declared
    # background 를 충족한다 (ref_contract_validator waiver 참조).
    "outdoor_canon_photo_ref",
    "outdoor_canon_map_ref",
)


class RefRoleError(Exception):
    """LabeledRefPayload invariant 위반 — fail-fast (No Silent Fallback gate)."""


@dataclass(frozen=True)
class LabeledRefPayload:
    """Area #11 v1 — producer-side structured payload for reference image dispatch.

    Invariants (fail-fast via make_labeled_ref_payload factory):
    - len(labeled_refs) == len(ref_roles) == len(ref_role_metadata) == len(attached_meta)
    - 각 ref_roles entry ∈ REF_ROLE_VALUES (else RefRoleError)
    - 각 ref_role_metadata entry 는 dict
    - 모든 4 collection 은 list (factory top-level isinstance gate)

    W1 = container + factory + REF_ROLE_VALUES + RefRoleError 신설.
    Production 진입은 W2 atomic switch.
    """

    labeled_refs: List[Tuple[str, Any]]
    ref_roles: List[str]
    ref_role_metadata: List[Dict[str, Any]]
    attached_meta: List[Tuple[str, str]]


def make_labeled_ref_payload(
    labeled_refs: List[Tuple[str, Any]],
    ref_roles: List[str],
    ref_role_metadata: List[Dict[str, Any]],
    attached_meta: List[Tuple[str, str]],
) -> LabeledRefPayload:
    """Factory — fail-fast on invariant violation (No Silent Fallback gate).

    9 fail-fast paths:
    - 4 non-list isinstance: labeled_refs / ref_roles / ref_role_metadata / attached_meta
    - 3 length mismatch: ref_roles / ref_role_metadata / attached_meta vs labeled_refs
    - 1 invalid enum value: ref_roles entry not in REF_ROLE_VALUES
    - 1 non-dict metadata entry: ref_role_metadata entry not dict
    """
    # Top-level isinstance gate (Codex iter 2 Minor 3 fix — non-list 우회 차단)
    if not isinstance(labeled_refs, list):
        raise RefRoleError(f"labeled_refs is {type(labeled_refs).__name__}, not list")
    if not isinstance(ref_roles, list):
        raise RefRoleError(f"ref_roles is {type(ref_roles).__name__}, not list")
    if not isinstance(ref_role_metadata, list):
        raise RefRoleError(f"ref_role_metadata is {type(ref_role_metadata).__name__}, not list")
    if not isinstance(attached_meta, list):
        raise RefRoleError(f"attached_meta is {type(attached_meta).__name__}, not list")
    # Length parity
    n = len(labeled_refs)
    if len(ref_roles) != n:
        raise RefRoleError(f"ref_roles length {len(ref_roles)} != labeled_refs length {n}")
    if len(ref_role_metadata) != n:
        raise RefRoleError(
            f"ref_role_metadata length {len(ref_role_metadata)} != labeled_refs length {n}"
        )
    if len(attached_meta) != n:
        raise RefRoleError(f"attached_meta length {len(attached_meta)} != labeled_refs length {n}")
    # Enum value gate
    for i, role in enumerate(ref_roles):
        if role not in REF_ROLE_VALUES:
            raise RefRoleError(
                f"ref_roles[{i}]={role!r} not in REF_ROLE_VALUES "
                f"(allowed: {REF_ROLE_VALUES})"
            )
    # Metadata type gate
    for i, m in enumerate(ref_role_metadata):
        if not isinstance(m, dict):
            raise RefRoleError(f"ref_role_metadata[{i}] is {type(m).__name__}, not dict")
    return LabeledRefPayload(
        labeled_refs=list(labeled_refs),
        ref_roles=list(ref_roles),
        ref_role_metadata=list(ref_role_metadata),
        attached_meta=list(attached_meta),
    )


# ──────────────────────────────────────────────────────────────
# 1. Reference roles / instructions
# ──────────────────────────────────────────────────────────────

@dataclass
class RefResolution:
    """`resolve_ref_roles` 반환 컨테이너."""

    ref_roles: List[str]
    ref_instructions: List[str]

    @property
    def roles_text(self) -> str:
        return "\n".join(self.ref_roles) if self.ref_roles else "No reference images."

    @property
    def instructions_text(self) -> str:
        return "\n".join(self.ref_instructions)


# Area #11 v1 W4 (2026-05-18+): RC-D (G4.6 Phase 1) substring-classifier cluster
# 전체 폐기 — production dispatch path 에서 substring matching 0 (Codex W4 권고).
# producer (scene_reference_service.py) 가 ref_roles enum sidecar 를 emit하고
# consumer (resolve_ref_roles 아래) 가 enum 만 dispatch. 폐기된 식별자:
#   - `_IMAGE_N_PAREN_HEADER_RE` / `_IMAGE_N_BARE_PREFIX_RE` regex
#   - `_HEADER_CHARACTER_TAGS` / `_BARE_LABEL_CHARACTER_PREFIXES` frozenset
#   - `_is_explicit_character_ref_label` helper
#   - `_classify_label` 10-branch dispatcher (production caller 0, test caller 0)


def _back_to_camera_instruction(i: int) -> str:
    """B-run S15 실측 fix (2026-06-12) — back_to_camera staged 인물의 ref 지시.

    producer 는 scene_reference_service.apply_back_to_camera_constraints
    (staging.character_angles structured enum 조인) — 정면 여권사진 ref +
    "match identity" 지시가 본문의 back-to-camera 작문을 누르는 정면 bias 대응.
    """
    return (
        f"- image {i}: this character faces AWAY from the camera in this shot — "
        f"the face must NOT be visible; use image {i} only for hair silhouette, "
        f"body build, and clothing/outfit continuity; show the back of the head "
        f"and body, do not turn the character toward the camera"
        # F1b (2026-06-12 S15 2차 피드백): 스틸 한 장에는 카메라워크가 없어
        # 멀어지는 피사체가 foreground 에 크게 나오면 '멀어짐'이 안 읽힘 —
        # 떠나는 인물은 머무는 인물보다 깊이 안쪽/작게 (조건 판단은 본문을
        # 읽는 이미지 모델에 위임 — 코드 감지 0, generic).
        f"; in a single still image motion direction reads through depth — if "
        f"the prompt describes this character as moving away from the camera "
        f"or from another figure, they must be visibly receding: placed deeper "
        f"in the scene, and smaller in the frame than the stationary figure "
        f"they are moving away from when both are visible — never large in "
        f"the immediate foreground"
    )


def _continuity_anchor_ref(i: int) -> Tuple[str, List[str]]:
    """W21B-W8 재배선 v2 — 같은 장소·순간 인접 샷의 '완성 프레임' 을 연속성 anchor
    로 사용(마네킹 스케치 대신). 장소·조명·인물 외형 + broad staging/figure
    placement 의 연속성은 이 프레임에서, 이 샷의 카메라/프레이밍/POV/crop/액션은
    본문 SOT (충돌 시 본문 우선 — 예: 같은 그룹 샷이 '엿보는 POV' 등 고유 프레이밍을
    가질 때 그 프레이밍 보존). 인접 샷끼리 '같은 연속된 순간' 으로 읽히게 한다."""
    role_text = (
        f"Reference image {i}: SAME-PLACE COMPLETED FRAME from an adjacent shot "
        f"at the same moment — visual continuity reference"
    )
    instructions = [
        f"- keep this shot visually continuous with image {i}: the same location, "
        f"environment, architecture, materials, lighting, weather and time of "
        f"day, and the same people's appearance, build and clothing",
        f"- match image {i}'s broad staging — where each figure stands across the "
        f"space and their relative scale — so the adjacent shots read as one "
        f"continuous moment; this is the one exception to the 'do not copy "
        f"compositions' rule below, limited to continuity of place and figure "
        f"placement",
        f"- follow the prompt text for THIS shot's specific camera position, "
        f"framing, point of view, crop and action — where the prompt text and "
        f"image {i} differ on framing or viewpoint, the prompt text wins",
    ]
    return role_text, instructions


def _immobilized_prev_frame_ref(
    i: int, pose_guide_present: bool,
) -> Tuple[str, List[str]]:
    """A5 (2026-07-02) — immobilized 그룹 env 멤버의 '완성 프레임' 연속성 ref.

    부동(dead/unconscious/severely_injured) 인물은 샷 사이에 움직일 수 없으므로
    이 프레임이 그 인물의 포즈/접촉점/지지면 + 방 상태·소품의 연속성 SOT.
    단 등록 pose 가이드가 함께 붙으면 자세/접촉/지지면의 1차 SOT 는 가이드
    (P8 I-1 역할 분리) — 이 프레임은 same-configuration 확인 + 환경/소품 연속성.
    프레이밍/카메라/crop 은 본문 프롬프트 SOT. 시나리오 토큰 0.
    """
    role_text = (
        f"Reference image {i}: COMPLETED EARLIER FRAME from this same scene — "
        f"state continuity reference for an immobilized figure"
    )
    if pose_guide_present:
        _pose_line = (
            f"- any figure in an immobilized state (dead, unconscious, severely "
            f"injured) has NOT moved since image {i}. Take its body pose, contact "
            f"points and the surface it rests on from the attached pose guide "
            f"(the source of truth for this figure's pose and contact); image {i} "
            f"shows the same configuration for environment and continuity"
        )
    else:
        _pose_line = (
            f"- any figure in an immobilized state (dead, unconscious, severely "
            f"injured) has NOT moved since image {i}: reproduce its exact body "
            f"pose, contact points and the surface supporting it from image {i} — "
            f"if the prompt text implies a different body position for this "
            f"immobilized figure, image {i} wins"
        )
    instructions = [
        f"- keep the room state from image {i}: the same architecture, furniture, "
        f"materials, lighting, and the same props in the same places",
        _pose_line,
        f"- follow the prompt text for THIS shot's camera position, framing, "
        f"point of view and crop — do NOT copy image {i}'s composition",
        f"- do NOT copy any standing/moving people from image {i}",
    ]
    return role_text, instructions


def resolve_ref_roles(payload: LabeledRefPayload) -> RefResolution:
    """Area #11 v1 W2 — `payload.ref_roles` enum dispatch only.

    `_classify_label` substring branch 폐기 (production dispatch path). consumer
    는 producer 가 emit 한 11-enum sidecar 만 read (No Silent Fallback gate).

    spec §3.3 / plan Task 2.5.
    """
    ref_roles_text: List[str] = []
    ref_instructions: List[str] = []
    # P8 I-1: 이 샷에 immobilized pose 등록 가이드가 함께 붙었는지 (다른 ref). 붙은
    # 경우 zoomed-frame(Inc3-A) lock 을 그 가이드와 정렬해 3중 신호(가이드/직전 프레임/
    # 본문)가 싸우지 않게 한다 (가이드=자세 SOT, image=환경/연속성).
    pose_guide_present = "immobilized_pose_guide" in payload.ref_roles
    # W22 직행 place-continuity (Codex NARROW_1, 2026-07-10): 같은 payload 에
    # 직행 prev 연속성 ref 가 있으면 캐논 실사 지시를 SOLE→PRIMARY 로 전환 —
    # "canon 만 유일 SOT" 와 "prev 의 고정 디테일 연속성" 이 동시에 오는 계약
    # 모순 차단. marker 없는 기존 직행은 SOLE 그대로 (byte-identical).
    direct_place_prev_present = any(
        isinstance(md, dict) and md.get("outdoor_direct_place_continuity")
        for md in payload.ref_role_metadata
    )
    for i, (label, _) in enumerate(payload.labeled_refs, 1):
        role = payload.ref_roles[i - 1]
        metadata = payload.ref_role_metadata[i - 1]

        if role == "outfit_ref_explicit":
            ref_roles_text.append(f"Reference image {i}: standalone outfit/costume reference")
            ref_instructions.append(f"- dress the character in the outfit shown in image {i}")
        elif role == "outfit_ref_inline":
            ref_roles_text.append(f"Reference image {i}: {label}")
            ref_instructions.append(
                f"- use image {i} as character appearance reference — "
                f"match the person's identity and outfit where visible in the scene"
            )
            if metadata.get("view_angle_constraint") == "back_to_camera":
                ref_instructions.append(_back_to_camera_instruction(i))
        elif role == "previous_shot_same_frame_zoomed":
            # W3 (2026-06-11 fresh full E2E S29 실측, Codex 합의): 'reuse the
            # exact frame'/'do NOT change the pose' 절대 지시가 본문 프롬프트를
            # 눌러 ref 프레임 통째 복제를 유발. 환경/구도 연속성 기준으로 완화 —
            # 피사체·포즈는 본문 프롬프트가 SOT (진짜 zoom 이면 본문이 같은 순간을
            # 묘사하므로 결과 동일).
            ref_roles_text.append(
                f"Reference image {i}: PREVIOUS SHOT (SAME MOMENT, zoomed-in "
                f"reframing) — continuity base"
            )
            # P8 Inc3 (2026-06-23, S12 sh8↔sh13 시신 자세 드리프트 VLM 실측): zoom
            # 프레임에 immobilized 피사체(dead/unconscious/severely_injured)가 있으면
            # W3 완화의 "render poses as described in the prompt text" 가 본문의
            # 위치 묘사(바닥/표면 등)를 따르게 해 시신이 image{i} 대비 움직인다.
            # → immobilized 일 때 pose-from-text 절을 standing/moving 피사체로 한정
            # 하고(immobilized 제외) image{i} 를 immobilized 피사체의 pose SOT 로
            # 고정한다. mobile 피사체는 W3 완화 유지 (S29 sh11 프레임복제 방지 보존,
            # subject_state enum gate). 시나리오 하드코딩 0.
            _has_immobilized = bool(metadata.get("immobilized_subjects"))
            if _has_immobilized:
                _pose_line = (
                    f"- keep the environment, lighting, and spatial layout from "
                    f"image {i}; render the poses of any standing or moving subject "
                    f"as described in the prompt text (for a true zoom they match "
                    f"image {i})"
                )
            else:
                _pose_line = (
                    f"- keep the environment, lighting, and spatial layout from "
                    f"image {i}; render the subjects and their poses as described "
                    f"in the prompt text (for a true zoom they match image {i})"
                )
            ref_instructions.extend([
                f"- use image {i} as the continuity base for a zoomed-in reframing — "
                f"same moment, same environment, the camera moved closer",
                _pose_line,
                f"- do NOT duplicate body parts",
                f"- render the focused region (hand, wrist, face area, object, etc.) "
                f"enlarged, consistent with the frame context of image {i}",
            ])
            # feedback6-C (2026-06-11) + P8 Inc3 (2026-06-23): immobilized 피사체
            # (dead/unconscious/severely_injured — subject_state enum SOT) 는 자세·
            # 위치·안식 표면을 image{i} 에 픽셀 고정. 본문 텍스트가 다른 신체 위치를
            # 묘사해도 image{i} 가 이긴다 (시신은 두 샷 사이 움직일 수 없음). mobile
            # 피사체는 위 W3 완화 유지.
            # P8 I-1 (2026-06-28): 같은 샷에 등록 pose 가이드가 함께 붙은 경우, 자세/
            # 접촉/지지면의 1차 SOT 는 그 가이드다 (역할 분리). 충돌을 막기 위해 이
            # zoomed-frame lock 을 "가이드=자세 SOT, image{i}=환경/연속성"으로 정렬한다.
            # 가이드가 없으면 기존 Inc3-A 동작("image{i} wins") 유지.
            if _has_immobilized and pose_guide_present:
                ref_instructions.append(
                    f"- the immobilized figure (dead, unconscious, severely "
                    f"injured) has NOT moved between these two shots. Take its body "
                    f"pose, the visible limb (hand, wrist, arm), the surface it "
                    f"rests on, and its hand-object contact from the attached pose "
                    f"guide (the source of truth for this figure's pose and "
                    f"contact); image {i} shows the same configuration for "
                    f"environment and continuity. The crop and framing may change "
                    f"(this is a closer insert), but reproduce that one pose and "
                    f"contact exactly — do not let the prompt wording move the "
                    f"immobilized figure"
                )
            elif _has_immobilized:
                ref_instructions.append(
                    f"- from image {i}: any figure in an immobilized state "
                    f"(dead, unconscious, severely injured) has NOT moved between "
                    f"these two shots. Lock to image {i}: its body and the visible "
                    f"limb (hand, wrist, arm), their exact position and "
                    f"orientation, the surface the figure/limb rests on, and how "
                    f"the hand contacts any held object — reproduce them precisely. "
                    f"The crop and framing may change (this is a closer insert), "
                    f"but if the prompt text implies a different body position, "
                    f"resting/support surface, or hand-object contact for this "
                    f"immobilized figure, image {i} wins"
                )
            # Area #11 v1 W2: pre-parsed keep_elements / ignore / remove_hints (substring branch 0)
            for elem in metadata.get("keep_elements", []) or []:
                _label = elem.get("label", "") if isinstance(elem, dict) else str(elem)
                if _label:
                    ref_instructions.append(f"- from image {i}: keep {_label}")
            _ignore = metadata.get("ignore", "")
            if _ignore:
                ref_instructions.append(f"- from image {i}: ignore {_ignore}")
        elif role == "previous_shot_same_room":
            if metadata.get("immobilized_prev_frame_anchor"):
                # A5 (2026-07-02): immobilized 그룹 env 멤버 완성 프레임 — 부동
                # 인물의 상태(포즈/접촉/지지면/소품/방) 연속성 SOT. 프레이밍은 본문
                # SOT. 등록 pose 가이드 동반 시 가이드=자세 1차 SOT 로 정렬(P8 I-1
                # zoomed-frame lock 과 동일 역할 분리, 충돌 방지).
                _rt, _ins = _immobilized_prev_frame_ref(i, pose_guide_present)
                ref_roles_text.append(_rt)
                ref_instructions.extend(_ins)
            elif metadata.get("composition_continuity_anchor"):
                # W21B-W8 재배선 v2: 같은 그룹 인접 샷 완성 프레임을 연속성 anchor 로
                # 승격 (마네킹 스케치 미부착). 장소·인물 배치 연속성 + 본문 SOT framing.
                _rt, _ins = _continuity_anchor_ref(i)
                ref_roles_text.append(_rt)
                ref_instructions.extend(_ins)
            elif metadata.get("composition_relaxed"):
                # W21B-W8 composition guide: guide 부착 샷 한정 — "use as-is"
                # 구도 잠금 해제, 장소 identity 만 유지 (스케치+본문이 framing SOT).
                ref_roles_text.append(
                    f"Reference image {i}: BACKGROUND from a previous shot "
                    f"(SAME PLACE) — place identity reference"
                )
                ref_instructions.extend([
                    f"- keep this location's materials, architecture, furniture "
                    f"identity, lighting and weather from image {i}",
                    f"- do NOT copy the camera position or composition of image "
                    f"{i} — the composition sketch and the prompt text define "
                    f"this shot's framing",
                    f"- do NOT copy any standing/moving people from image {i}",
                ])
            else:
                ref_roles_text.append(
                    f"Reference image {i}: BACKGROUND from a previous shot (SAME ROOM) — use as-is"
                )
                ref_instructions.extend([
                    f"- use the background, furniture layout, walls, and lighting from image {i} as-is",
                    f"- do NOT copy any standing/moving people from image {i}",
                ])
            for elem in metadata.get("keep_elements", []) or []:
                _label = elem.get("label", "") if isinstance(elem, dict) else str(elem)
                if _label:
                    ref_instructions.append(f"- from image {i}: keep {_label}")
            _ignore = metadata.get("ignore", "")
            if _ignore:
                ref_instructions.append(f"- from image {i}: ignore {_ignore}")
        elif role == "previous_shot_continuity":
            if metadata.get("immobilized_prev_frame_anchor"):
                # A5 (2026-07-02): same_room 분기와 동일 — 부동 인물 상태 연속성 SOT.
                _rt, _ins = _immobilized_prev_frame_ref(i, pose_guide_present)
                ref_roles_text.append(_rt)
                ref_instructions.extend(_ins)
            elif metadata.get("composition_continuity_anchor"):
                # W21B-W8 재배선 v2: 연속성 anchor 승격 (same_room 분기와 동일 의미).
                _rt, _ins = _continuity_anchor_ref(i)
                ref_roles_text.append(_rt)
                ref_instructions.extend(_ins)
            elif metadata.get("outdoor_direct_place_continuity"):
                # W22 직행 place-continuity (2026-07-10, Codex BLOCKING_1):
                # 캐논 실사/맵이 장소 identity·topology 의 1차 SOT 이고, prev 는
                # 겹치는 영역의 '보이는 고정 디테일' 연속성 담당. 기본 분기의
                # lighting/mood 지시는 TIME_LOCK(본문 시간·조명 SOT)과 충돌하므로
                # 여기선 조명 복사를 명시적으로 금지한다.
                ref_roles_text.append(
                    f"Reference image {i}: PREVIOUS STILL at the same property — "
                    f"continuity source for stable visible details only"
                )
                ref_instructions.extend([
                    f"- where this shot overlaps image {i}'s view, keep the same "
                    f"stable visible details (openings, fixed fittings, lettering, "
                    f"local object placement) consistent with it",
                    f"- the attached LOCATION PHOTOGRAPH/SITE PLAN stay the primary "
                    f"source for the property's overall identity and layout",
                    f"- do NOT copy lighting or time of day from image {i} — the "
                    f"shot text is the sole time/lighting source",
                    f"- do NOT copy characters, people, poses, or composition from image {i}",
                ])
            else:
                ref_roles_text.append(
                    f"Reference image {i}: BACKGROUND/ENVIRONMENT from a previous shot at the same location"
                )
                ref_instructions.extend([
                    f"- use ONLY the lighting, color palette, and environment mood from image {i}",
                    f"- do NOT copy characters, people, or their appearances from image {i}",
                    f"- do NOT copy the composition or character poses from image {i}",
                ])
        elif role == "background_chain_ref":
            # W1 (2026-06-11 fresh full E2E 육안 피드백, Codex 합의): 'use as-is'
            # + 'match ... exactly' 가 카메라 위치·구도까지 강제 — 실내/문앞 샷에
            # exterior establishing 이 주입되면 모델이 카메라를 밖에 두고 문틈/유리
            # 평면을 발명 (S10 실측). 역할을 'environment identity reference' 로
            # 좁히고 카메라/프레이밍/액션은 본문이 SOT 임을 명시.
            ref_roles_text.append(
                f"Reference image {i}: pre-rendered BACKGROUND environment reference — "
                f"layout/material/lighting identity"
            )
            ref_instructions.extend([
                f"- use image {i} for this location's wall/floor/ceiling materials, "
                f"furniture identity, and lighting mood",
                f"- follow the camera position, framing, and action requested in the "
                f"prompt text — do NOT copy the camera position or composition of "
                f"image {i} unless the prompt explicitly asks for it",
                f"- do NOT copy any people from image {i}",
                # B-run S11 sh5 실측 fix (2026-06-12): plate 에 현관문이 명확한데
                # 모델이 새 문을 발명 — 개구부는 위치/type/재질만 plate 정합,
                # 열림/닫힘 등 상태는 본문이 SOT (Codex FEEDBACK3 문구 합의).
                f"- architectural openings (doors, windows, thresholds) must match "
                f"image {i} — same position, type, and material; do NOT invent "
                f"additional doors or windows unless the prompt text explicitly "
                f"asks for them",
                # F2b (2026-06-12 S11 2차 피드백): 카메라가 개구부 안쪽인 샷에서
                # 그 개구부/건물 외관이 배경에 다시 그려지는 자기 복제 — 판단은
                # 본문을 읽는 이미지 모델에 위임 (generic). canary 실측 2회:
                # 'duplicate 금지'만으로 완화하면 자기 외벽(facade)이 배경에 재발
                # (2차) — 외벽 금지를 명시한 강한 문구가 작동(1차). frame-edge
                # 허용 절로 '카메라측 요소 전부 금지' 부작용은 완화 (Codex 우려).
                f"- if the prompt text places the camera inside or at one of "
                f"these openings, camera-side walls and door/window frames may "
                f"appear only as local foreground edges — do NOT show the same "
                f"opening again in the scene, and do NOT show the exterior "
                f"facade of the structure that contains the camera out in "
                f"front of it",
            ])
        elif role == "character_ref":
            ref_roles_text.append(f"Reference image {i}: {label}")
            ref_instructions.append(
                f"- use image {i} as character appearance reference — "
                f"match the person's identity where visible in the scene"
            )
            if metadata.get("view_angle_constraint") == "back_to_camera":
                ref_instructions.append(_back_to_camera_instruction(i))
        elif role == "character_state_ref":
            # Area #11 v1 — state-variant reference (e.g. unconscious / dead).
            _state = metadata.get("state", "")
            ref_roles_text.append(f"Reference image {i}: {label}")
            if _state:
                ref_instructions.append(
                    f"- use image {i} as state-variant reference ({_state}) — "
                    f"match the character's body language for this specific state"
                )
            else:
                ref_instructions.append(
                    f"- use image {i} as state-variant reference — "
                    f"match the character's body language for this specific state"
                )
        elif role == "prop_ref":
            ref_roles_text.append(f"Reference image {i}: {label}")
            ref_instructions.append(f"- include the object shown in image {i}")
        elif role == "background_general":
            ref_roles_text.append(f"Reference image {i}: background/environment reference.")
            ref_instructions.append(f"- use the lighting, architecture, and environment mood from image {i}")
        elif role == "outdoor_canon_photo_ref":
            # W22 직행 — 실사 마스터 = 장소 룩 SOT (s33 REF_NOTE 관할을 role
            # 지시문으로 이식). 카메라/구도는 본문(FREE_CAMERA)이 SOT.
            ref_roles_text.append(
                f"Reference image {i}: LOCATION PHOTOGRAPH — the real filming "
                f"property this shot happens at"
            )
            _canon_authority = (
                # NARROW_1: prev 연속성 ref 동반 시 PRIMARY (겹치는 영역의
                # 고정 디테일은 prev 가 보완), 단독이면 기존 SOLE 유지.
                f"- image {i} is the PRIMARY source of how this place looks "
                f"(building, materials, aging, colours, surroundings); the "
                f"previous-still reference refines stable visible details in "
                f"overlapping areas"
                if direct_place_prev_present else
                f"- image {i} is the SOLE source of how this place looks: "
                f"building, materials, aging, colours, surroundings"
            )
            ref_instructions.extend([
                _canon_authority,
                f"- do NOT copy image {i}'s camera angle, composition, time of "
                f"day or lighting — the prompt text defines this shot's moment",
                f"- do NOT copy any people from image {i}",
            ])
        elif role == "outdoor_canon_map_ref":
            # W22 직행 — 탑다운 맵 = 배치 전용 (그림 자체 작도 금지).
            ref_roles_text.append(
                f"Reference image {i}: SITE-PLAN DRAWING of the same property "
                f"— layout source only"
            )
            ref_instructions.extend([
                f"- use image {i} ONLY to understand where things are on the "
                f"property (positions, adjacency, routes)",
                f"- NEVER draw image {i} itself or its colours, circles, "
                f"markers or labels into the output",
            ])
        elif role == "composition_guide":
            # W21B-W8 composition guide (재배선) — 인물을 featureless 마네킹
            # +Loomis 머리로 그린 POSE+COMPOSITION 스케치. 이 스케치만 구도/
            # 포즈/머리방향 SOT (아래 일반 'do not copy compositions' 우선).
            ref_roles_text.append(
                f"Reference image {i}: POSE + COMPOSITION STORYBOARD SKETCH of "
                f"THIS exact shot — figures drawn as featureless artist's "
                f"mannequins (framing + pose guide only)"
            )
            ref_instructions.extend([
                f"- match this frame's composition to image {i}: the same figure "
                f"screen positions, relative sizes and depth, and the same "
                f"receding path / vanishing perspective — this sketch is the one "
                f"exception to the 'do not copy compositions' rule below",
                # 재배선: 마네킹은 머리방향을 명시적으로 인코딩하므로(실루엣과
                # 달리) facing 까지 신뢰 가능 — 0d9a0d69 gaze fix 의 '자세 추론
                # 금지'를 supersede. 마네킹이 구도/포즈/머리방향 SOT, 정체성·외형은
                # 본문·캐릭터 ref SOT. 둘은 같은 geometry 에서 도출돼 일치한다.
                f"- each human figure in image {i} is a featureless posed "
                f"mannequin with a constructed head: reproduce its body pose, "
                f"stance and head-facing direction exactly, then render it as a "
                f"real clothed person — identity, face, hair, clothing and skin "
                f"come from the prompt text and the character reference image(s), "
                f"never from the mannequin",
                f"- image {i} is only a rough construction sketch: do NOT draw "
                f"any mannequin, joints, spheres or construction lines in the "
                f"final image, and do NOT copy its flat tones, line style or any "
                f"environment surface detail — environment identity comes from "
                f"the other reference images and the prompt text",
                # W-D (2026-07-03) 스타일 격리 강문 — S24sh2 실측: 스케치 line-art
                # 가 최종 배경(바위/수풀/해안선)에 그대로 누출(만화풍). 스케치는
                # framing/placement/pose 만, 렌더 스타일은 절대 복사 금지.
                f"- the final image must be a full-colour PHOTOREALISTIC render "
                f"under the scene's real lighting: never reproduce image {i}'s "
                f"monochrome line-art look, pencil/marker strokes, sketch "
                f"shading, unfinished background or paper texture in ANY part "
                f"of the frame",
            ])
        elif role == "immobilized_pose_guide":
            # P8 I-1 (2026-06-28): 실내 immobilized 피사체 bg-aware 등록 pose 가이드.
            # 이 가이드가 그 피사체의 자세/접촉/지지면 1차 SOT (역할 분리). 구도/
            # 프레이밍은 본문 SOT(insert 면 visible portion 만), 정체성/외형/상태는
            # 캐릭터(state) ref+본문, 환경은 bg plate — 마네킹 아님. 아래 일반 'do not
            # copy poses' 규칙의 예외(이 피사체 한정). I-3 real-human 단언 포함.
            _vf = str(metadata.get("visible_focus") or "")
            # P8 Fix2 (Codex BLOCKING2 — 상태 일반화): IMMOBILIZED_STATES 는
            # dead/unconscious/severely_injured. "lifeless" 단정은 dead 일 때만 —
            # unconscious/severely_injured(살아있음)는 상태를 character_state ref+
            # 본문에 맡긴다(generic immobilized 경로에 death 의미 하드코딩 방지).
            _state = str(metadata.get("subject_state") or "").strip().lower()
            _life = " (now lifeless)" if _state == "dead" else ""
            ref_roles_text.append(
                f"Reference image {i}: POSE / SUPPORT GUIDE for the immobilized "
                f"figure — a line-art mannequin registered over a faint room "
                f"(pose / contact / support-surface reference only, NOT identity, "
                f"appearance or environment)"
            )
            ref_instructions.extend([
                f"- image {i} fixes the immobilized figure's exact body posture, "
                f"limb positions, the support surface it rests on, and how its hand "
                f"contacts any held or adjacent object: reproduce that one pose and "
                f"contact exactly so the body stays supported on that surface and "
                f"never floats. This guide is the source of truth for this figure's "
                f"pose, resting/support surface and hand-object contact — it is the "
                f"one exception to the 'do not copy poses' rule below, and where the "
                f"prompt wording implies a different body position or contact for "
                f"this immobilized figure, image {i} wins",
                # I-3 (real-human 단언 + pose-only): 가이드는 마네킹 line-art 이므로
                # 최종은 실인간으로 렌더해야 한다. 정체성/외형은 character (state) ref
                # +본문, 환경은 bg plate. mannequin/doll/statue/sketch look 복사 금지.
                f"- render this figure as ONE real, photorealistic human person"
                f"{_life} with real skin, hair, fabric clothing and a natural "
                f"human face and body: its identity, face, hair, clothing and state "
                f"come from the prompt text and the character reference image(s), and "
                f"its environment from the background reference — NEVER from this "
                f"guide. It must NOT look like a wooden mannequin, an articulated "
                f"dummy, a doll, a statue, a figurine or a 3D render — no wooden or "
                f"plastic surface, no visible ball joints, no smooth featureless or "
                f"missing face",
                f"- this shot's camera, framing and crop come from the prompt text: "
                f"if it is a tight insert, show only the visible portion relevant to "
                f"this shot"
                + (f" ({_vf})" if _vf else "")
                + f" and do NOT force the whole body into the frame",
                f"- image {i} is only a rough pose sketch: do NOT draw any mannequin, "
                f"joints, spheres or construction lines in the final image, and do "
                f"NOT copy any environment, surface texture or background from it",
            ])
        elif role == "indoor_pose_guide":
            # W-D (2026-07-03) 렌더 분기 신설 — 기존엔 이 role 이 fallback 분기로
            # 떨어져 라벨 1줄만 렌더되고, 아래 일반 'do not copy poses' 규칙이
            # 가이드를 무효화(전수 육안: indoor 가이드가 최종에서 무시되던 NEUTRAL
            # 의 근본원인). registered 마네킹 가이드 = pose/placement SOT 명시 +
            # 스타일 격리 강문(composition_guide 와 동일 계약).
            ref_roles_text.append(
                f"Reference image {i}: POSE + PLACEMENT GUIDE for this shot — "
                f"line-art mannequins registered over a faint underlay of this "
                f"room (pose/placement reference only, NOT identity, appearance, "
                f"style or environment)"
            )
            ref_instructions.extend([
                f"- image {i} fixes each figure's screen position, depth, body "
                f"posture and facing direction: reproduce that placement and "
                f"pose for the matching figures — it is an exception to the "
                f"'do not copy poses' rule below",
                f"- render those figures as real clothed people: identity, "
                f"face, hair and clothing come from the prompt text and the "
                f"character reference image(s), never from the mannequins",
                f"- image {i} is only a rough registration sketch: do NOT draw "
                f"any mannequin, joints or construction lines in the final "
                f"image, and never reproduce its monochrome line-art look, "
                f"pencil strokes, faint washed-out underlay or paper texture "
                f"in ANY part of the frame — the final image is a full-colour "
                f"PHOTOREALISTIC render under the scene's real lighting, and "
                f"its environment comes from the background reference and the "
                f"prompt text, not from this guide",
            ])
        else:  # "fallback"
            ref_roles_text.append(f"Reference image {i}: {label}")
            ref_instructions.append(f"- reference image {i}: {label}")

    ref_instructions.extend([
        "- do not copy poses or compositions from reference images",
        "- do not alter character identities where their face is visible in the scene",
        # 의상 SOT 계약(2026-07-02): 같은 인물의 의상이 샷마다 텍스트 수식으로
        # 흔들리던 결함 — 아웃룩 합성/상태변형 ref 가 의상의 유일 SOT 이고, 텍스트
        # 속 의상 표현과 충돌하면 ref 이미지가 이긴다(reference hierarchy).
        "- each character's clothing/outfit must match that character's appearance "
        "reference image EXACTLY; if any clothing wording in the text conflicts with "
        "the reference image, follow the reference image and ignore that wording",
        "- only render what the scene description asks for — "
        "if only a hand or wrist is described, do NOT add the character's face",
    ])
    return RefResolution(ref_roles=ref_roles_text, ref_instructions=ref_instructions)


# ──────────────────────────────────────────────────────────────
# 2. Entity ID substitution
# ──────────────────────────────────────────────────────────────

def replace_entity_ids(
    t2i_prompt: str,
    labeled_refs: List[Tuple[str, Any]],
    entity_text_map: Optional[Dict[str, str]] = None,
) -> str:
    """t2i_prompt 내 short_id(C##/C##O##/P##/L##)를 참조 이미지 번호 또는 엔티티 텍스트로 치환.

    - labeled_refs에 해당 ID가 있으면 "the {type} shown in image N"
    - 없으면 entity_text_map 경유 한글/영어 설명으로 치환
    - 대괄호 `[L##: desc]`은 치환 전 내용만 남겨 global replace 간섭 방지

    표현 주의 (Area #5 v1, 2026-05-18): numbered image wording
    (e.g. "the character shown in image N") is preserved for reference mapping.
    phantom validation is handled by structured `reference_phrase_kinds` sidecar
    (scene_detail v26 producer + ref_contract_validator step 6), not prompt-prose
    regex. legacy "from Reference image N" 표현은 의미 동등 표현 "shown in
    image N" 으로 치환.
    """
    _etm = entity_text_map or {}

    def _get_ref_num(sid: str) -> Optional[int]:
        for idx, (label, _) in enumerate(labeled_refs, 1):
            if sid in label:
                return idx
        return None

    id_to_ref: Dict[str, str] = {}

    # short_id 패턴: C01O02 → character (reference 우선, 없으면 entity text)
    for match in re.finditer(r"C\d{2,3}O\d{2,3}", t2i_prompt):
        sid = match.group(0)
        if sid not in id_to_ref:
            ref_n = _get_ref_num(sid)
            if ref_n:
                id_to_ref[sid] = f"the character shown in image {ref_n}"
            else:
                # ref image 없음 → 인물+아웃룩 합성 텍스트 설명으로 대체
                if sid in _etm:
                    id_to_ref[sid] = _etm[sid]
                else:
                    char_sid = sid.split("O")[0]
                    if char_sid in _etm:
                        id_to_ref[sid] = _etm[char_sid]

    # 단독 C## (composite 없는 캐릭터)
    for match in re.finditer(r"(?<![CO\d])C\d{2,3}(?!O\d)", t2i_prompt):
        sid = match.group(0)
        if sid not in id_to_ref:
            ref_n = _get_ref_num(sid)
            if ref_n:
                id_to_ref[sid] = f"the character shown in image {ref_n}"
            elif sid in _etm:
                id_to_ref[sid] = _etm[sid]

    for match in re.finditer(r"(?<![A-Z])P\d{2,3}", t2i_prompt):
        sid = match.group(0)
        if sid not in id_to_ref:
            ref_n = _get_ref_num(sid)
            if ref_n:
                id_to_ref[sid] = f"the object shown in image {ref_n}"
            elif sid in _etm:
                id_to_ref[sid] = _etm[sid]

    # 단독 L## → 텍스트 설명 치환 ([L##: desc] 밖에서 사용된 경우)
    for match in re.finditer(r"(?<!\[)(L\d{2,3})(?!\d)(?![^\[]*\])", t2i_prompt):
        sid = match.group(1)
        if sid not in id_to_ref and sid in _etm:
            id_to_ref[sid] = _etm[sid]

    # 브라켓 제거를 ID 치환 전에 실행 — [L01: desc]의 L01이 global replace로 깨지는 것 방지
    cleaned = t2i_prompt
    cleaned = re.sub(r"\[L\d{2,3}:\s*([^\]]+)\]", r"\1", cleaned)

    # 치환 적용 (긴 ID부터) — 대괄호 안 L##은 이미 제거됨, 단독만 남음
    for sid, replacement in sorted(id_to_ref.items(), key=lambda x: -len(x[0])):
        cleaned = cleaned.replace(sid, replacement)

    # "in O##" 제거 — C01O01에 이미 의상 정보 포함, 단독 O##은 중복
    cleaned = re.sub(r",?\s*in O\d{2,3}\b", "", cleaned)

    return cleaned


# ──────────────────────────────────────────────────────────────
# 3. Korean → English translation
# ──────────────────────────────────────────────────────────────

def translate_if_korean(
    cleaned: str,
    ref_roles_text: str,
    ref_instructions_text: str,
    style_context: str,
    *,
    scene_index: int = 0,
    project_config: Optional[Dict] = None,
    tracer: Any = None,
    labeled_refs_count: int = 0,
) -> str:
    """한국어가 남아있으면 LLM으로 영어 번역. 실패 시 원본 유지.

    프롬프트 템플릿은 `prompts/_base/scene_image/{version}/translate_prompt.md`에서 읽음.
    번역 후에도 한국어 잔재가 있으면 error 로그 (안전 필터 위험).
    """
    if not re.search(r"[가-힣]", cleaned):
        return cleaned

    from app.modules.llm.llm_client import call_text
    from app.modules.prompt_loader import load_prompt

    # prompt_loader 통합 (problems.md #14): 직접 디렉토리 read + version 정렬
    # 제거 → load_prompt 경유 (DB 우선 + numeric-aware + #6 drift detection +
    # #13 schema validation 정책 일괄 적용).
    #
    # Exception 광범위 catch (review B3/I1): load_prompt 가
    #   - FileNotFoundError: 모듈/stem 미존재
    #   - RuntimeError: PROMPT_VERSION_PACK_STRICT=true 환경에서 stem latest pack
    #     누락 시 (#6 strict mode)
    #   - KeyError: format kwargs 와 template placeholder 불일치
    # 모두 raise 가능. translation 은 best-effort 이므로 실패 시 원본 반환이
    # 안전 — production T2I 파이프라인 crash 방지.
    try:
        translation_prompt = load_prompt(
            "scene_image",
            "translate_prompt",
            ref_roles_text=ref_roles_text,
            ref_instructions=ref_instructions_text,
            style_context=style_context,
            t2i_prompt=cleaned,
        )
    except (FileNotFoundError, RuntimeError, KeyError) as exc:
        logger.error(
            "translate_prompt unavailable for scene_image module — "
            "skipping translation: %s", exc,
        )
        return cleaned
    # v2 template은 설계 변경으로 `{ref_instructions}` placeholder를 제거하고
    # scene body만 반환한다 — format() extra kwargs는 KeyError 없이 무시되므로
    # v1(이 placeholder 사용) / v2 모두 호환 (Claude Review Critical #1).
    translated = False
    try:
        _t0 = _time.time()
        result = call_text(
            step="prompt_translation",
            system_prompt=(
                "T2I 프롬프트 번역 전문가. 한국어를 영어로 변환하세요. "
                "'the character/object shown in image N' 표현은 정확히 보존하세요. "
                "원문에 없던 reference attribution 표현을 새로 추가하지 마세요 "
                "(producer 가 declare 한 reference_phrase_kinds sidecar 와 mismatch 차단)."
            ),
            user_prompt=translation_prompt,
            project_config=project_config,
            temperature=0.1,
        )
        _elapsed = int((_time.time() - _t0) * 1000)
        if isinstance(result, str) and result.strip():
            cleaned = result.strip()
            translated = True
            if tracer:
                tracer.log(
                    stage="prompt_translation",
                    model="gemini-3.5-flash",
                    input_data={
                        "translation_prompt": translation_prompt[:500] + "...",
                        "ref_count": labeled_refs_count,
                    },
                    output_data=cleaned,
                    metadata={"scene_index": scene_index},
                    elapsed_ms=_elapsed,
                )
    except Exception as exc:
        logger.warning("Prompt translation failed: %s", exc)

    if not translated and re.search(r"[가-힣]", cleaned):
        logger.error(
            "Prompt translation failed and Korean text remains in prompt "
            "(scene=%s). Gemini may trigger safety filter. Raw: %s",
            scene_index,
            cleaned[:300],
        )
    return cleaned


# ──────────────────────────────────────────────────────────────
# 4. Final assembly
# ──────────────────────────────────────────────────────────────

def build_scene_text(
    ref_roles_text: str,
    cleaned: str,
    ref_instructions_text: str,
) -> str:
    """최종 조립: ref_roles + 씬 본문 + 지시 + CRITICAL 주의.

    단일 프레임 계약 (2026-07-02): 하나의 t2i = 한 순간·한 프레임 (CLAUDE.md
    단일 스틸컷 원칙)의 렌더측 강제 — 매체 형식 계약이라 시나리오 무관.
    """
    return (
        "Photorealistic cinematic still.\n\n"
        f"{ref_roles_text}\n\n"
        f"{cleaned.replace('Photorealistic cinematic still.', '').strip()}\n\n"
        f"Generate one image:\n{ref_instructions_text}\n"
        "- CRITICAL: Only render what the scene description explicitly describes. "
        "If a character's face is visible in the scene, match it to the reference. "
        "If only a body part is shown, do NOT add the face.\n"
        "- CRITICAL: Output exactly ONE single continuous photograph of ONE moment "
        "— never a panel grid, collage, contact sheet, split-screen, storyboard "
        "sheet, or multiple sub-frames inside one image.\n"
        # W-H (2026-07-03): mid-motion 부양 방지 — 물리 접지/무게 계약 (generic
        # 매체 물리 계약, 시나리오 무관). 쓰러짐/비틀거림 등 동작 중간 순간을
        # 모델이 '경직된 몸이 공중에 기울어 떠 있는' 형태로 렌더하던 결함.
        "- CRITICAL: Every person must be physically grounded and weight-bearing — "
        "in real contact with a supporting surface, with body weight visibly carried "
        "by that contact. For a figure caught mid-action (falling, stumbling, "
        "collapsing, jumping off something), render a physically plausible instant: "
        "joints bent, weight clearly transferring, at least one believable point of "
        "contact or support — never a rigid body hovering, tilted or suspended in "
        "mid-air without support."
    )


# ──────────────────────────────────────────────────────────────
# Public entry point (backward compatible)
# ──────────────────────────────────────────────────────────────

def build_final_scene_prompt(
    t2i_prompt: str,
    payload: LabeledRefPayload,
    style_context: str,
    tracer: Any = None,
    scene_index: int = 0,
    project_config: Optional[Dict] = None,
    entity_text_map: Optional[Dict[str, str]] = None,
) -> str:
    """T2I 프롬프트를 최종 영어 씬 프롬프트로 변환.

    Area #11 v1 W2 (2026-05-18+): signature `labeled_refs: list` →
    `payload: LabeledRefPayload`. producer-side structured sidecar 받아
    consumer enum dispatch only (substring branch 0). spec §3.3.

    4단계 파이프라인: ref 역할 (enum dispatch) → ID 치환 → 한글 번역 → 최종 조립.
    """
    ref_resolution = resolve_ref_roles(payload)
    cleaned = replace_entity_ids(t2i_prompt, payload.labeled_refs, entity_text_map)
    cleaned = translate_if_korean(
        cleaned,
        ref_roles_text=ref_resolution.roles_text,
        ref_instructions_text=ref_resolution.instructions_text,
        style_context=style_context,
        scene_index=scene_index,
        project_config=project_config,
        tracer=tracer,
        labeled_refs_count=len(payload.labeled_refs),
    )
    final = build_scene_text(
        ref_roles_text=ref_resolution.roles_text,
        cleaned=cleaned,
        ref_instructions_text=ref_resolution.instructions_text,
    )
    if tracer:
        tracer.log_ref_matching(scene_index, t2i_prompt, payload.labeled_refs, final)
    return final
