"""W21B-W8 (2026-06-12) — outdoor_site_layout deterministic core.

B-run 피드백 "가는 방향/멀어짐 구분 못함" 의 production fix. ablation 실측
(spike_gallery 21~24절): 텍스트 위치 구절이 지배 변수, top-down 이미지는 ref
채널로 무력 → 좌표는 **텍스트 생성의 입력** 으로만 쓴다 (FP→VLM→T2I 패턴).

이 모듈은 순수 deterministic 영역만 담는다:

  - seed 탐지 (LLM 0): selected shot × outdoor(background_classify
    ``is_indoor == false`` structured) × staging ``character_angles`` 2인+.
    제외는 전부 structured 조인 — zoom continuity 멤버
    (shot_dependency_t2i ``ref_usage == "zoom_in_detail"`` pair),
    1인 이동(frame_spatial_contract ``gesture_action == "moves_toward"`` enum).
    이동 동사 regex / 단어 의미 판별 절대 금지.
  - layout 좌표 validator: **shape/범위만** (의미 게이트 없음 — 의미는 visual
    canary. Codex W21B_W8 ⓔ).
  - 카메라-인물 거리/방향 deterministic 공간 요약 (LLM2 입력).
  - revised prompt 토큰 audit (W-C1 ``revised_prompt_new_tokens`` 재사용 +
    기존 토큰 누락 검사 — entity ID 불변 계약).
  - prompt override 명시적 priority merge: custom > zoom > site > original
    (custom 은 별도 분기 선행 승리, original 은 override 부재).

LLM IO 는 ``outdoor_site_layout_provider`` 가 담당. 작문 품질은 deterministic
테스트 비대상 — canary + 육안 gate.

설계: docs/w21b-w8-outdoor-site-layout-production-brief-20260612/ (Codex
W21B_W8_SITE_LAYOUT_DESIGN_REVIEW=APPROVED_WITH_NARROW_SCOPE).
"""
from __future__ import annotations

import logging
import math
from typing import Any, Dict, List, Optional, Set, Tuple

from app.modules.pipeline.visual_continuity_anchor_plan import (
    extract_entity_tokens,
    revised_prompt_new_tokens,
)

logger = logging.getLogger(__name__)

SCHEMA_VERSION: int = 1
DEFAULT_GROUP_CAP: int = 8

REF_USAGE_ZOOM = "zoom_in_detail"
GESTURE_MOVES_TOWARD = "moves_toward"
TARGET_KIND_CHARACTER = "character"

COORD_MIN: float = 0.0
COORD_MAX: float = 100.0

PROMPT_SOURCE_ZOOM = "zoom_continuity_anchor"
PROMPT_SOURCE_SITE = "outdoor_site_layout"

ShotKey = Tuple[int, int]  # (scene_index, shot_index)


def shot_hint_key(scene_index: int, shot_index: int) -> str:
    return f"{scene_index}:{shot_index}"


# ─────────────────────────── seed 탐지 ───────────────────────────


def outdoor_loc_ids(background_classify_groups: List[Dict[str, Any]]) -> Set[str]:
    """background_classify building_groups → ``is_indoor == false`` loc_id 집합.

    structured field 조인만 — label/summary 텍스트는 보지 않는다."""
    out: Set[str] = set()
    for g in background_classify_groups or []:
        for m in g.get("members") or []:
            loc_id = m.get("loc_id")
            if isinstance(loc_id, str) and loc_id and m.get("is_indoor") is False:
                out.add(loc_id)
    return out


def primary_location_uuid_by_scene(
    primary_location_by_scene: Dict[int, str],
    outdoor_loc_sids: Set[str],
    entity_lookup: Dict[str, Dict[str, Any]],
) -> Dict[int, str]:
    """B (2026-07-02) — scene_index → outdoor primary location 의 entity UUID.

    scene_director.primary_location(L## short_id)이 outdoor 집합(background_
    classify is_indoor==false)에 속하고 entity_lookup 역매핑(entity_type==
    'location' AND short_id 일치)이 존재할 때만 매핑. structured field 조인만 —
    이름/라벨 텍스트는 보지 않는다. 역매핑 충돌(같은 short_id 2+)은 제외(결정론)."""
    sid_to_uuid: Dict[str, Optional[str]] = {}
    for uid, ent in (entity_lookup or {}).items():
        if not isinstance(ent, dict) or ent.get("entity_type") != "location":
            continue
        sid = ent.get("short_id")
        if not sid:
            continue
        # 같은 short_id 2+ → 모호 — 매핑 제외 (None 마킹)
        sid_to_uuid[sid] = None if sid in sid_to_uuid else uid
    out: Dict[int, str] = {}
    for si, pl in (primary_location_by_scene or {}).items():
        if not isinstance(si, int) or pl not in (outdoor_loc_sids or set()):
            continue
        uid = sid_to_uuid.get(pl)
        if uid:
            out[si] = uid
    return out


def resolve_outdoor_history_fallback_uuid(
    scene_index: Optional[int],
    *,
    primary_loc_uuid_by_scene: Dict[int, str],
    location_scene_history: Dict[str, Any],
) -> Optional[str]:
    """B — VE 에 location 이 없어 history lookup 을 놓친 outdoor 샷의 primary
    location UUID fallback. history 에 실재하는 entry 가 있을 때만 반환."""
    if not isinstance(scene_index, int):
        return None
    uid = (primary_loc_uuid_by_scene or {}).get(scene_index)
    if uid and uid in (location_scene_history or {}):
        return uid
    return None


def build_outdoor_prev_frame_diag(
    *,
    scene_index: Optional[int],
    shot_index: Optional[int],
    outdoor_loc_sids: Set[str],
    primary_location_by_scene: Dict[int, str],
    ve_location_sids: Set[str],
    chain_bg_attached: bool,
    ref_usage: str,
    has_dep: bool,
    has_same_scene_prior: bool,
    resolved: bool,
    bytes_source_kind: str,
    source_still_id: Optional[str],
    close_framing: bool = False,
) -> Optional[Dict[str, Any]]:
    """B — outdoor prev-frame 의무첨부 진단 (pure). 대상 아니면 None.

    대상 = outdoor location(primary 또는 frame-visible 이 outdoor 집합에 속함)
    AND bg plate(chain_bg) 미첨부 AND zoom 아님 AND close framing 아님(close 는
    close×ref_usage matrix 계약이 prev-shot bg 합성을 의도적으로 금지 — FINDING 9
    W3, 의무 판정하면 구조적 false alarm) AND 연속성/의존 신호(dep_detail entry
    또는 same-scene 선행 selected shot) 존재 — Codex 합의(narrow first,
    cross-scene history 는 의무 판정에 안 넣음). resolved=False 면 reason=
    previous_frame_required_missing (hard fail 아님 — 소비자가 WARNING + still
    pipeline_metadata 로 영속)."""
    if not (isinstance(scene_index, int) and isinstance(shot_index, int)):
        return None
    outdoor = bool(outdoor_loc_sids) and (
        (primary_location_by_scene or {}).get(scene_index) in outdoor_loc_sids
        or bool((ve_location_sids or set()) & outdoor_loc_sids)
    )
    if not outdoor:
        return None
    if chain_bg_attached:
        return None  # plate 실재 = prev 프레임 의무 대상 아님
    if ref_usage == "zoom_in_detail":
        return None  # zoom 은 dep_scene-only 별도 정책(W20F10 O)
    if close_framing:
        return None  # close 는 prev-shot bg 합성 금지 계약 — 의무 대상 아님
    if not (has_dep or has_same_scene_prior):
        return None  # 연속성/의존 신호 없음 — 의무 아님
    return {
        "lane": "outdoor",
        "previous_frame_required": True,
        "resolved": bool(resolved),
        "source_selection": bytes_source_kind if resolved else None,
        "source_still_id": source_still_id,
        "reason_if_missing": None if resolved else "previous_frame_required_missing",
        "required_signals": {
            "dep": bool(has_dep), "same_scene_prior": bool(has_same_scene_prior),
        },
    }


def zoom_member_shots(dependencies: List[Dict[str, Any]]) -> Set[ShotKey]:
    """shot_dependency_t2i dependencies 에서 zoom_in_detail pair 의 양쪽
    (zoom + source) shot key 집합 — site seed 제외 신호 (이중 재작문 방지;
    merge 우선순위가 2차 방어)."""
    out: Set[ShotKey] = set()
    for dep in dependencies or []:
        si, shi = dep.get("scene_index"), dep.get("shot_index")
        if not isinstance(si, int) or not isinstance(shi, int):
            continue
        for ref in dep.get("location_refs") or []:
            if ref.get("ref_usage") != REF_USAGE_ZOOM:
                continue
            src_si, src_shi = ref.get("scene_index"), ref.get("shot_index")
            out.add((si, shi))
            if isinstance(src_si, int) and isinstance(src_shi, int):
                out.add((src_si, src_shi))
    return out


def _staging_is_single_figure_moving(staging: Dict[str, Any]) -> bool:
    """1인 + 이동 — structured enum 조인만 (frame_spatial_contract constraint:
    target_kind == character AND gesture_action == moves_toward)."""
    angles = staging.get("character_angles") or []
    if len(angles) != 1:
        return False
    constraints = (staging.get("frame_spatial_contract") or {}).get("constraints") or []
    return any(
        c.get("target_kind") == TARGET_KIND_CHARACTER
        and c.get("gesture_action") == GESTURE_MOVES_TOWARD
        for c in constraints
    )


def detect_site_seeds(
    selected_map: Dict[int, Set[int]],
    *,
    ve_by_shot: Dict[ShotKey, List[str]],
    staging_by_shot: Dict[ShotKey, Dict[str, Any]],
    outdoor_locs: Set[str],
    zoom_members: Set[ShotKey],
    primary_location_by_scene: Optional[Dict[int, str]] = None,
    single_shot_lane_enabled: bool = False,
) -> Tuple[Dict[str, List[ShotKey]], List[Dict[str, Any]], Dict[ShotKey, Dict[str, Any]]]:
    """seed: outdoor selected shot → {loc_id: [shot_key…]}.

    반환: (groups, skipped, seed_meta).

    **legacy(single_shot_lane_enabled=False, default)**: v1 그대로 —
    outdoor × character_angles 2인+ frame-visible location 만. byte-identical.

    **broad single-shot lane(single_shot_lane_enabled=True, 4a fix 2026-07-01,
    Codex 합의)**: 사용자 요구 "한샷이라도 구도/복잡이면 실내외 무조건 마네킹" —
    복잡샷이 seed 에서 조용히 탈락하지 않게 넓게 연다(판정은 judge/QC fail-closed).
      - figure_count>=1 이면 단일 figure 샷도 seed(2인+ 요구 제거).
      - location source: frame_visible(VE 에 L##) → 없으면 primary_location fallback
        (scene_director.primary_location, outdoor 일 때만). location_source 기록.
      - ★코드는 free-text(camera_direction 등) 의미판정 안 함 — 구조필드 존재/count/
        enum 만. seed_meta 에 why_candidate/구조 signal 기록(judge/과발동 튜닝 SOT).
      - figure 0 = no_figure skip(마네킹 대상 아님).

    skipped 진단(silent drop 금지): zoom_continuity_member / single_figure_moving
    (legacy 만) / no_figure(broad) / multiple_outdoor_locations.
    """
    groups: Dict[str, List[ShotKey]] = {}
    skipped: List[Dict[str, Any]] = []
    seed_meta: Dict[ShotKey, Dict[str, Any]] = {}
    _pl = primary_location_by_scene or {}

    for si in sorted(selected_map):
        for shi in sorted(selected_map[si]):
            key = (si, shi)
            locs = sorted(
                tok for tok in (ve_by_shot.get(key) or []) if tok in outdoor_locs
            )
            location_source = "frame_visible"
            if not locs and single_shot_lane_enabled:
                # B (location-in-VE 완화): frame-visible VE 에 outdoor location 이
                # 없어도 scene primary_location 이 outdoor 면 seed 후보로 올린다
                # (S15 sh5: scene_director present 엔 L11, VE 엔 C02 만). judge payload
                # 에 location_source 를 넣어 non-frame-visible 은 더 엄격히 볼 수 있게.
                _plc = _pl.get(si)
                if _plc and _plc in outdoor_locs:
                    locs = [_plc]
                    location_source = "primary_location"
            if not locs:
                continue
            staging = staging_by_shot.get(key) or {}
            label = f"S{si}sh{shi}"
            if key in zoom_members:
                skipped.append({"shot": label, "reason": "zoom_continuity_member"})
                continue
            angles = staging.get("character_angles") or []
            figure_count = len(angles)
            if figure_count < 2:
                if not single_shot_lane_enabled:
                    if _staging_is_single_figure_moving(staging):
                        skipped.append({"shot": label, "reason": "single_figure_moving"})
                    continue
                # broad single-shot lane(3-stage 필수화 2026-07-02): figure 0(구조/
                # 공간 establishing 샷)도 seed 에 올린다 — 사용자 정책 "야외는 aerial
                # 기반으로 시작". 과거 no_figure skip 은 인물 없는 outdoor 접근부/
                # establishing 샷을 aerial 공간 추론 없이 렌더하게 만들어 불가능한
                # 카메라 프레이밍(문앞 벽 발명 류)을 못 막았다. attach 필요성 판정은
                # 여전히 judge(evidence-backed)/fail-closed 가 결정한다.
            if len(locs) > 1:
                skipped.append({
                    "shot": label,
                    "reason": "multiple_outdoor_locations",
                    "locations": locs,
                    "chosen": locs[0],
                })
            chosen = locs[0]
            groups.setdefault(chosen, []).append(key)
            _fsc = staging.get("frame_spatial_contract")
            _fsc_present = bool(
                isinstance(_fsc, dict) and (_fsc.get("constraints") or []))
            seed_meta[key] = {
                "why_candidate": (
                    "single_shot_outdoor_no_figure" if figure_count == 0
                    else ("single_shot_outdoor_figure_present" if figure_count < 2
                          else "cross_shot_or_multi_figure_present")),
                "location_source": location_source,
                "location_id": chosen,
                "figure_count": figure_count,
                "framing_scale": staging.get("framing_scale"),
                "character_angle_count": figure_count,
                "frame_spatial_contract_present": _fsc_present,
                "available_structural_signal_names": [
                    _n for _n, _present in (
                        ("framing_scale", staging.get("framing_scale") is not None),
                        ("character_angles", figure_count > 0),
                        ("frame_spatial_contract", _fsc_present),
                        ("gaze_direction", any(
                            a.get("gaze_direction_kind") for a in angles)),
                    ) if _present
                ],
            }

    return groups, skipped, seed_meta


# ─────────────────────────── layout validator (shape/범위만) ───────────────────────────


def _is_point(value: Any) -> bool:
    return (
        isinstance(value, (list, tuple))
        and len(value) == 2
        and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in value)
        and all(COORD_MIN <= float(v) <= COORD_MAX for v in value)
    )


def validate_site_layout(layout: Any) -> List[str]:
    """shape/좌표 범위만 검증 — 의미 게이트 없음 (Codex ⓔ).

    Returns: violation 문자열 목록 (빈 목록 = 통과)."""
    violations: List[str] = []
    if not isinstance(layout, dict):
        return ["layout must be an object"]

    landmarks = layout.get("landmarks")
    if not isinstance(landmarks, list):
        violations.append("landmarks must be a list")
    else:
        for i, lm in enumerate(landmarks):
            points = (lm or {}).get("points")
            if not isinstance(points, list) or not points or not all(
                _is_point(p) for p in points
            ):
                violations.append(f"landmarks[{i}]: points must be [x,y] pairs in 0-100")
            # W-A(2026-07-03) 구조물 방향성: 존재 시에만 shape 검증(point or null) —
            # faces_toward 부재는 구 layout 하위호환(방향구 생략)이라 위반 아님.
            ft = (lm or {}).get("faces_toward")
            if ft is not None and not _is_point(ft):
                violations.append(
                    f"landmarks[{i}]: faces_toward must be [x,y] in 0-100 or null")

    figures = layout.get("figures")
    if not isinstance(figures, list) or not figures:
        violations.append("figures must be a non-empty list")
    else:
        for i, fig in enumerate(figures):
            for j, pos_entry in enumerate((fig or {}).get("positions") or []):
                if not isinstance(pos_entry.get("shot_index"), int):
                    violations.append(f"figures[{i}].positions[{j}]: shot_index must be int")
                if not _is_point(pos_entry.get("pos")):
                    violations.append(f"figures[{i}].positions[{j}]: pos must be [x,y] in 0-100")
                mt = pos_entry.get("moving_toward")
                if mt is not None and not _is_point(mt):
                    violations.append(
                        f"figures[{i}].positions[{j}]: moving_toward must be [x,y] in 0-100 or null"
                    )

    cameras = layout.get("cameras")
    if not isinstance(cameras, list) or not cameras:
        violations.append("cameras must be a non-empty list")
    else:
        for i, cam in enumerate(cameras):
            if not isinstance((cam or {}).get("shot_index"), int):
                violations.append(f"cameras[{i}]: shot_index must be int")
            for field in ("pos", "look_at"):
                if not _is_point((cam or {}).get(field)):
                    violations.append(f"cameras[{i}]: {field} must be [x,y] in 0-100")

    # 조인 무결성 (의미 아님): 멤버 shot 의 카메라 부재는 위반이 아니라 그 shot
    # 의 요약/재작문 skip 신호 — caller 가 missing 집합으로 진단 기록.
    return violations


def camera_missing_shots(
    layout: Dict[str, Any], member_shot_indices: Set[int],
) -> Set[int]:
    present = {
        cam.get("shot_index")
        for cam in layout.get("cameras") or []
        if isinstance(cam.get("shot_index"), int)
    }
    return set(member_shot_indices) - present


# ─────────────────────────── deterministic 공간 요약 ───────────────────────────


def _dist(a: Any, b: Any) -> float:
    return math.hypot(float(b[0]) - float(a[0]), float(b[1]) - float(a[1]))


def _frame_side(cam_pos: Any, look_at: Any, point: Any) -> str:
    """카메라 시선축 기준 프레임 좌/우/중앙 — 2D 외적 부호 (순수 기하).

    화면 좌표 관례: 시선축 기준 반시계(외적 양수)=왼쪽."""
    axis = (float(look_at[0]) - float(cam_pos[0]), float(look_at[1]) - float(cam_pos[1]))
    rel = (float(point[0]) - float(cam_pos[0]), float(point[1]) - float(cam_pos[1]))
    axis_n = math.hypot(*axis) or 1.0
    rel_n = math.hypot(*rel) or 1.0
    cross = (axis[0] * rel[1] - axis[1] * rel[0]) / (axis_n * rel_n)
    if cross > 0.20:
        return "left"
    if cross < -0.20:
        return "right"
    return "center"


def _angle_between(v1: Tuple[float, float], v2: Tuple[float, float]) -> float:
    n1 = math.hypot(*v1) or 1.0
    n2 = math.hypot(*v2) or 1.0
    dot = (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)
    return math.degrees(math.acos(max(-1.0, min(1.0, dot))))


def _point_label(cam_pos: Any, look_at: Any, point: Any, nearest_d: float) -> str:
    side = _frame_side(cam_pos, look_at, point)
    d = _dist(cam_pos, point)
    depth = "near" if d <= nearest_d * 1.2 else ("mid-distance" if d <= nearest_d * 2.5 else "far")
    side_s = "center" if side == "center" else f"{side} side"
    return f"{depth} {side_s} of the frame"


def _definite(label: Any) -> str:
    """영어 정관사 조립 — label 이 이미 관사로 시작하면 중복 부착하지 않는다
    ("the the sea" 방지). 출력 문장 템플릿 조립이며 의미 판단이 아니다."""
    text = str(label or "landmark").strip()
    head = text.split(None, 1)[0].lower() if text else ""
    return text if head in ("the", "a", "an") else f"the {text}"


_ELONGATED_MIN_EXTENT = 25.0   # 0-100 정규 좌표 기준 — 경로성 landmark 판정
_PATH_MIN_ASPECT = 3.0         # 종횡비 extent/width — 길고 얇아야 path (blob 배제)
_FOLLOW_MAX_ANGLE = 30.0       # 이동 벡터 ↔ landmark 축 평행 한계
_FOLLOW_MAX_OFFSET = 10.0      # 인물이 landmark 축 위에 있다고 보는 거리


def _extent_segment(points: List[Any]) -> Tuple[Any, Any, float]:
    """점 집합의 최장 쌍 = landmark 의 주축 segment (순수 기하)."""
    best = (points[0], points[-1], 0.0)
    for i in range(len(points)):
        for j in range(i + 1, len(points)):
            d = _dist(points[i], points[j])
            if d > best[2]:
                best = (points[i], points[j], d)
    return best


def _max_perp_width(points: List[Any], a: Any, b: Any) -> float:
    """점들이 주축 a→b 무한직선에서 벗어난 최대 수직거리 = landmark 의 '폭'
    (순수 기하). extent/width = 종횡비 → 경로성 판정에 쓴다."""
    ax, ay, bx, by = float(a[0]), float(a[1]), float(b[0]), float(b[1])
    dx, dy = bx - ax, by - ay
    denom = math.hypot(dx, dy) or 1.0
    w = 0.0
    for p in points:
        # |외적| / 길이 = 점에서 주축 직선까지의 수직거리
        d = abs((float(p[0]) - ax) * dy - (float(p[1]) - ay) * dx) / denom
        w = max(w, d)
    return w


def _point_segment_dist(p: Any, a: Any, b: Any) -> float:
    ax, ay = float(a[0]), float(a[1])
    vx, vy = float(b[0]) - ax, float(b[1]) - ay
    seg_len2 = vx * vx + vy * vy
    if seg_len2 == 0:
        return _dist(p, a)
    t = max(0.0, min(1.0, ((float(p[0]) - ax) * vx + (float(p[1]) - ay) * vy) / seg_len2))
    return _dist(p, (ax + t * vx, ay + t * vy))


def _polygon_area(points: List[Any]) -> float:
    if len(points) < 3:
        return 0.0
    s = 0.0
    for i in range(len(points)):
        x1, y1 = points[i]
        x2, y2 = points[(i + 1) % len(points)]
        s += float(x1) * float(y2) - float(x2) * float(y1)
    return abs(s) / 2.0


def _centroid(points: List[Any]) -> Tuple[float, float]:
    n = len(points) or 1
    return (sum(float(p[0]) for p in points) / n,
            sum(float(p[1]) for p in points) / n)


def _cam_axes(camera: Dict[str, Any]) -> Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]:
    """카메라 시선축 forward 단위벡터 + 화면-우 단위벡터 (순수 기하).

    right = (f[1], -f[0]) — 화면-우 양수 컨벤션 (좌표 산술, spike sh6 검증). 이
    프레임으로 점을 투영하면 depth(near→far)+lateral(좌(-)→우(+))이 나온다."""
    px, py = float(camera["pos"][0]), float(camera["pos"][1])
    lx, ly = float(camera["look_at"][0]), float(camera["look_at"][1])
    fx, fy = lx - px, ly - py
    n = math.hypot(fx, fy) or 1.0
    f = (fx / n, fy / n)
    right = (f[1], -f[0])
    return (px, py), f, right


def _project(camera: Dict[str, Any], point: Any) -> Tuple[float, float]:
    """점을 카메라 프레임에 투영 → (depth, lateral)."""
    (px, py), f, right = _cam_axes(camera)
    dx, dy = float(point[0]) - px, float(point[1]) - py
    return dx * f[0] + dy * f[1], dx * right[0] + dy * right[1]


def build_shot_spatial_summary(
    layout: Dict[str, Any], shot_index: int,
) -> Optional[str]:
    """layout 좌표 → 해당 shot 의 **프레임 기준 지형+인물 요약** (순수 계산,
    LLM 0). 카메라 또는 인물 위치가 없으면 None.

    R2 사용자 피드백: "길의 방향(왼쪽→오른쪽인지)·정류장 위치 같은 지형을
    설명하면서 방향을 알려줘야 모델이 제대로 안다" — landmark 가 프레임에서
    어떻게 놓이고, 이동 인물의 경로가 무엇을 따라 어느 쪽으로 가며 무엇으로는
    가지 않는지를 좌표 산술로만 서술한다 (의미 추론 0).
    좌/우는 layout 카메라 기준 — LLM1 이 staged screen zone 재현을 강제받고
    step 이 `layout_side_conflicts` 로 검증한다 (충돌 시 retry+진단)."""
    camera = next(
        (c for c in layout.get("cameras") or [] if c.get("shot_index") == shot_index),
        None,
    )
    if camera is None or not _is_point(camera.get("pos")) or not _is_point(camera.get("look_at")):
        return None

    cam_pos, look = camera["pos"], camera["look_at"]

    # ── 인물 수집 ──
    entries: List[Dict[str, Any]] = []
    for fig in layout.get("figures") or []:
        pos_entry = next(
            (p for p in fig.get("positions") or [] if p.get("shot_index") == shot_index),
            None,
        )
        if pos_entry is None or not _is_point(pos_entry.get("pos")):
            continue
        entries.append({
            "label": fig.get("label") or fig.get("figure_id") or "figure",
            "entity_token": fig.get("entity_token"),
            "pos": pos_entry["pos"],
            "distance": _dist(cam_pos, pos_entry["pos"]),
            "moving_toward": pos_entry.get("moving_toward")
            if pos_entry.get("moving_toward") is not None
            and _is_point(pos_entry.get("moving_toward")) else None,
        })
    if not entries:
        return None
    entries.sort(key=lambda e: e["distance"])
    # Codex HIGH-3 (E2E11 재리뷰): 실제 nearest 거리와 라벨 정규화용
    # 폴백을 분리 — nearest=0(인물이 카메라 좌표에 저작)이면 그 인물과의
    # 비율 자체가 정의 불가라 상대크기 문장 전체를 생략한다(임의 1m 기준
    # 비율은 거짓 SOT). 폴백은 _point_label 거리 정규화에만 쓴다.
    true_nearest_d = entries[0]["distance"]
    nearest_d = true_nearest_d or 1.0

    lines = [
        f"SHOT {shot_index} SCENE GEOGRAPHY (computed from the site-layout "
        "coordinates, frame-relative from this shot's camera — source of truth "
        "for where things are, which way paths run, figure depth and relative "
        "size):",
    ]

    # ── landmark 지형: 프레임에서 어떻게 놓이는가 ──
    landmarks = [
        lm for lm in layout.get("landmarks") or []
        if (lm.get("points") and all(_is_point(p) for p in lm["points"]))
    ]
    # 경로성(긴) landmark = kind=="line" 이거나 (충분히 긺 AND 길고 얇음:
    # 종횡비 extent/width ≥ _PATH_MIN_ASPECT). 긴 선형을 area kind 로 emit 해도
    # 진행 방향을 서술하되, extent 만으로 승격하면 넓은 면적/정사각형 군집의
    # blob 이 path 로 둔갑해 "far X → far X" 로 퇴화한다 → aspect 가 진짜
    # 판별자 (전부 generic 기하, 요소 종류 무관).
    elongated: List[Dict[str, Any]] = []
    for lm in landmarks:
        label = lm.get("label") or lm.get("id") or "landmark"
        pts = lm["points"]
        a, b, extent = _extent_segment(pts) if len(pts) >= 2 else (None, None, 0.0)
        aspect = (
            extent / max(_max_perp_width(pts, a, b), 1.0) if a is not None else 0.0
        )
        if a is not None and (
            lm.get("kind") == "line"
            or (extent >= _ELONGATED_MIN_EXTENT and aspect >= _PATH_MIN_ASPECT)
        ):
            # 가까운 끝 → 먼 끝 순으로 서술 (진행 방향이 읽히도록)
            if _dist(cam_pos, a) > _dist(cam_pos, b):
                a, b = b, a
            lines.append(
                f"- {label} (path): runs from the {_point_label(cam_pos, look, a, nearest_d)} "
                f"to the {_point_label(cam_pos, look, b, nearest_d)}."
            )
            elongated.append({"label": label, "a": a, "b": b})
        else:
            cx = sum(float(p[0]) for p in pts) / len(pts)
            cy = sum(float(p[1]) for p in pts) / len(pts)
            lines.append(
                f"- {label}: at the {_point_label(cam_pos, look, (cx, cy), nearest_d)}."
            )

    # ── 인물: 위치 + 상대크기 + 이동의 지형 기준 서술 ──
    area_landmarks = [lm for lm in landmarks if lm.get("kind") == "area"]
    lines.append("FIGURES:")
    for rank, e in enumerate(entries):
        name = e["label"] + (f" ({e['entity_token']})" if e.get("entity_token") else "")
        parts = [
            f"- {name}: at the {_point_label(cam_pos, look, e['pos'], nearest_d)}"
            + (" — NEAREST figure to the camera" if rank == 0 else ""),
        ]
        if rank > 0 and e["distance"] > 0 and true_nearest_d > 0:
            # E2E11 실측 크래시 fix + Codex HIGH-3: degenerate 거리(자기
            # 거리 0 또는 실제 nearest 0)에선 비율이 정의 불가 — 상대크기
            # 문장을 생략(보강 정보 fail-safe — 위치 문장은 유지). 실제
            # nearest 0 을 1m 로 치환한 비율은 거짓 SOT 라 금지.
            ratio = e["distance"] / true_nearest_d
            parts.append(
                f"about {ratio:.1f}x as far from the camera as the nearest figure, "
                f"so they must appear roughly {1.0 / ratio:.2f}x the nearest figure's "
                "height (smaller, deeper in the frame)"
            )
        mt = e["moving_toward"]
        if mt is not None:
            move_vec = (float(mt[0]) - float(e["pos"][0]), float(mt[1]) - float(e["pos"][1]))
            move_parts = [
                "in motion: already partway along their path, heading toward the "
                f"{_point_label(cam_pos, look, mt, nearest_d)}"
            ]
            # 따라가는 경로 = 이동 벡터가 landmark 주축과 평행(≤30°) AND 인물이
            # 그 주축 위에 있음(점-선분 거리 — 평행하기만 한 먼 landmark 배제,
            # 예: 도로와 평행한 해안선).
            followed_label = None
            for lm in elongated:
                axis_vec = (float(lm["b"][0]) - float(lm["a"][0]),
                            float(lm["b"][1]) - float(lm["a"][1]))
                ang = _angle_between(move_vec, axis_vec)
                if (
                    min(ang, 180.0 - ang) <= _FOLLOW_MAX_ANGLE
                    and _point_segment_dist(e["pos"], lm["a"], lm["b"]) <= _FOLLOW_MAX_OFFSET
                ):
                    followed_label = lm["label"]
                    move_parts.append(f"their path follows {_definite(followed_label)}")
                    break
            # 멀어지는 기준: 다른 (정지) 인물
            for other in entries:
                if other is e or other["moving_toward"] is not None:
                    continue
                if _dist(mt, other["pos"]) > _dist(e["pos"], other["pos"]) * 1.1:
                    move_parts.append(f"moving away from {other['label']}")
            # 가지 않는 곳: 이동 방향과 60° 이상 벗어난 area landmark 중
            # 가장 큰 1개만 (노이즈 방지 — 방향을 잡아먹는 큰 면적이 중요).
            not_into_best = None
            for lm in area_landmarks:
                label = lm.get("label") or lm.get("id")
                if label == followed_label:
                    continue
                pts = lm["points"]
                cx = sum(float(p[0]) for p in pts) / len(pts)
                cy = sum(float(p[1]) for p in pts) / len(pts)
                to_area = (cx - float(e["pos"][0]), cy - float(e["pos"][1]))
                if _angle_between(move_vec, to_area) >= 60.0:
                    area_size = _polygon_area(pts)
                    if not_into_best is None or area_size > not_into_best[0]:
                        not_into_best = (area_size, label)
            if not_into_best:
                move_parts.append(f"NOT heading into {_definite(not_into_best[1])}")
            parts.append("; ".join(move_parts))
        lines.append("; ".join(parts) + ".")
    if len(entries) > 1:
        far, near = entries[-1], entries[0]
        lines.append(
            f"DEPTH CONTRACT: {far['label']} is farther than {near['label']} and "
            "must read visibly smaller and deeper in the frame."
        )
    return "\n".join(lines)


# ── composition guide 적용 샷 선별 (좌표 산술만 — v1 departing class) ──


def composition_guide_shot_keys(
    layout: Dict[str, Any],
    member_keys: List[ShotKey],
    summary_keys: Set[str],
) -> List[ShotKey]:
    """구도 스케치 가이드 **기하 후보** 샷 — 2인+ departing class (좌표 산술만).
    이 함수는 1차(기하) 게이트일 뿐 — caller 가 샷레벨 의미 게이트(열린 외부
    departure 인가)로 한 번 더 거른다.

    조건 (전부 좌표 산술, 의미 추론 0):
      ①해당 샷의 spatial summary 가 존재 (caller 가 side-conflict/skip 샷을
        이미 제외한 ``summary_keys`` 를 전달)
      ②layout 에 그 샷 위치가 있는 figure 2인 이상
      ③**실제로 멀어지는(receding)** figure 가 존재하고 그 figure 가 NEAREST 가
        아님 — moving_toward 가 figure 의 현 위치보다 카메라에서 **더 멀어야**
        한다 (카메라/전경 쪽으로 다가오는 lunge 는 departure 가 아니다 — 공격·
        근접 대치 샷이 좌표상 mover 로 잡히던 오선별 제거).
    """
    selected: List[ShotKey] = []
    for si, shi in member_keys:
        if shot_hint_key(si, shi) not in summary_keys:
            continue
        camera = next(
            (c for c in layout.get("cameras") or [] if c.get("shot_index") == shi),
            None,
        )
        if camera is None or not _is_point(camera.get("pos")):
            continue
        cam_pos = camera["pos"]
        entries: List[Tuple[float, bool]] = []  # (distance, is_receding_mover)
        for fig in layout.get("figures") or []:
            pos_entry = next(
                (p for p in fig.get("positions") or [] if p.get("shot_index") == shi),
                None,
            )
            if pos_entry is None or not _is_point(pos_entry.get("pos")):
                continue
            d_pos = _dist(cam_pos, pos_entry["pos"])
            mt = pos_entry.get("moving_toward")
            # receding = moving AND the destination is farther from the camera
            # than the current position (departs into the frame, not toward it).
            receding = (
                mt is not None and _is_point(mt) and _dist(cam_pos, mt) > d_pos
            )
            entries.append((d_pos, receding))
        if len(entries) < 2:
            continue
        nearest_d = min(d for d, _ in entries)
        if any(receding and d > nearest_d for d, receding in entries):
            selected.append((si, shi))
    return selected


# ── composition continuity chain (option C, 2026-06-15) — 순수 그룹/모드 산출 ──


def composition_continuity_chain(
    admitted_keys: List[ShotKey],
) -> List[Dict[str, Any]]:
    """admitted guide 샷들을 **same scene_index** 그룹으로 묶어 shot_index 순으로
    정렬한 뒤 각 샷의 continuity mode/anchor_source/admitted_order 를 산출한다
    (순수 — LLM/좌표 0).

    option C (사용자 정정 + Codex 합의): 같은 장소·같은 scene 의 admitted 샷들을
    하나의 연속성 그룹으로 보고
      - 첫 admitted 샷 = ``mode='sketch'`` — 구도 스케치(마네킹 브리프)로 구도를
        deliberately 확립하고, 그 결과 프레임이 나머지의 연속성 anchor 가 된다
        (이 샷은 직전에 참조할 같은-그룹 완성 프레임이 없다).
      - 이후 admitted 샷 = ``mode='continuity_anchor'`` + ``anchor_source`` = 같은
        그룹의 직전 admitted 샷 키 — 생성 시점에 그 샷의 현재-run 완성 프레임을
        연속성 anchor 로 쓴다 (사전 스케치 미생성).
    **다른 scene 으로는 체이닝하지 않는다** — 같은 location 이 에피소드 후반에 다시
    등장해도 무관한 시퀀스를 연결하지 않기 위함 (Codex 합의: same scene + same
    location + admitted 로 좁게).

    Returns (입력 순서 무관, scene/shot 정렬 순):
        [{"key": (si, shi), "mode": "sketch"|"continuity_anchor",
          "anchor_source": (psi, pshi)|None, "scene_index": si,
          "admitted_order": int}] — caller 가 location_id 와 합쳐 group_id 부여.
    """
    by_scene: Dict[int, List[ShotKey]] = {}
    for si, shi in admitted_keys:
        by_scene.setdefault(si, []).append((si, shi))
    out: List[Dict[str, Any]] = []
    for si in sorted(by_scene):
        members = sorted(by_scene[si], key=lambda k: k[1])
        prev: Optional[ShotKey] = None
        for order, key in enumerate(members):
            out.append({
                "key": key,
                "mode": "sketch" if order == 0 else "continuity_anchor",
                "anchor_source": None if order == 0 else prev,
                "scene_index": si,
                "admitted_order": order,
            })
            prev = key
    return out


# ── shared-model 카메라 가이드 (Phase II II-1) — 좌표 산술 brief + birdseye spec ──
#
# master(site layout 좌표)에서 **카메라뷰 layout 가이드**를 파생한다. master 는
# 좌표/폴리곤일 뿐 실제 디자인 SOT 가 아니므로 (Codex Q2), 여기서 산출하는 brief 와
# birdseye spec 은 **depth/배치/외곽 envelope** 만 명세하고 재질/양식은 정의하지 않는다
# (그 layout-only 제약은 provider 프롬프트가 강제). 전부 순수 좌표 산술, LLM/의미판정 0,
# 이름은 데이터 label 그대로(템플릿 시나리오 토큰 0).


def _point_in_polygon(pt: Any, poly: List[Any]) -> bool:
    """ray-casting 내부 판정 (순수 좌표 산술, 3점 미만 폴리곤은 항상 False).

    3-stage 필수화(2026-07-02) enclosure 관계용 — 경계 위 점의 미세 판정은 중요치
    않다(불확실하면 관계 서술을 생략하는 쪽이 안전, Codex 합의)."""
    if not (_is_point(pt) and len(poly) >= 3):
        return False
    x, y = float(pt[0]), float(pt[1])
    inside = False
    n = len(poly)
    for i in range(n):
        x1, y1 = float(poly[i][0]), float(poly[i][1])
        x2, y2 = float(poly[(i + 1) % n][0]), float(poly[(i + 1) % n][1])
        if (y1 > y) != (y2 > y):
            x_cross = x1 + (y - y1) * (x2 - x1) / ((y2 - y1) or 1e-9)
            if x_cross > x:
                inside = not inside
    return inside


def _enclosure_relation_phrase(
    camera: Dict[str, Any], fig_pos: Any, layout: Dict[str, Any],
) -> str:
    """figure↔area-landmark 위상 관계 구절 (순수 폴리곤 산술만, 자유문장 파싱 0).

    figure centroid 가 area landmark polygon 내부이고 카메라는 그 밖이면 스케치가
    인물을 개방 지면으로 끌어내는 오류(카메라-피사체 사이 경계면 관계 손실)를 막는
    generic 관계를 서술한다. 라벨은 데이터 label 그대로(템플릿 시나리오 토큰 0).
    둘 다 내부면 'inside'만, 불확실(비폴리곤/판정 불가)이면 빈 문자열 = 서술 생략."""
    cam_pos = camera.get("pos")
    for lm in layout.get("landmarks") or []:
        if (lm.get("kind") or "area") != "area":
            continue
        pts = [p for p in (lm.get("points") or []) if _is_point(p)]
        if len(pts) < 3 or not _point_in_polygon(fig_pos, pts):
            continue
        label = lm.get("label") or lm.get("id") or "a mapped area"
        # Codex NARROW minor(2026-07-02): area 가 항상 방/문/창 enclosure 는 아님
        # (광장/도로 area 가능) — 'enclosure/opening' 단정 대신 within-area 유지
        # 지시 + 경계가 보이면 사이에 그리라는 조건부 문구로 완화.
        if _is_point(cam_pos) and not _point_in_polygon(cam_pos, pts):
            return (f", INSIDE the mapped area '{label}' while the camera is "
                    f"OUTSIDE that area — keep the figure within that area; if "
                    f"that area's boundary or opening is visible, show it between "
                    f"the camera and the figure")
        return f", inside the mapped area '{label}'"
    return ""


def compute_camera_brief(
    camera: Dict[str, Any], layout: Dict[str, Any], *, half_fov_deg: float = 20.0,
) -> Optional[str]:
    """layout + 한 카메라 → 그 카메라뷰의 layout/depth/figure 배치 brief (좌표 산술만).

    structure 는 near→far depth ordering + 화면 좌/우, figure 는 상대 스케일만 서술한다.
    카메라 좌표 부재 시 None. (shared-model 가이드 image edit 프롬프트 입력.)"""
    if not (_is_point(camera.get("pos")) and _is_point(camera.get("look_at"))):
        return None
    shi = camera.get("shot_index")

    def _axis_dir(points: List[Any]) -> str:
        """장축 화면방향(좌표 산술): 최장 쌍을 카메라에 투영 → horizontal/receding/diagonal."""
        if len(points) < 2:
            return ""
        a, b, _ = _extent_segment(points)
        d0, l0 = _project(camera, a)
        d1, l1 = _project(camera, b)
        dd, dl = abs(d1 - d0), abs(l1 - l0)
        if dl >= 2 * dd:
            return " (runs roughly horizontally across the frame, left to right)"
        if dd >= 2 * dl:
            return " (recedes into the distance, a strong near-to-far perspective line)"
        return " (runs diagonally across the frame)"

    def _faces_phrase(pts: List[Any], ft: Any) -> str:
        """faces_toward open_vec(=ft−centroid)을 카메라 프레임에 투영 → 개방/전면 방향구.

        W-A(2026-07-03): 구조물 개방면 방향이 brief 어디에도 없어 스케치가 개방면을
        임의 방향(인접 도로를 등지는 등)으로 그리던 결함 — 지배 성분(깊이 vs 좌우)으로
        4방향 산술 서술. 순수 좌표 산술, 라벨/의미판정 0. faces_toward 부재/무효/영벡터
        → 빈 문자열(구 layout 하위호환 = 방향구 생략)."""
        if not _is_point(ft):
            return ""
        d_c, l_c = _project(camera, _centroid(pts))
        d_f, l_f = _project(camera, ft)
        dd, dl = d_f - d_c, l_f - l_c
        if abs(dd) < 1e-6 and abs(dl) < 1e-6:
            return ""
        if abs(dd) >= abs(dl):
            side = ("toward the camera" if dd < 0
                    else "away from the camera, into the depth of the frame")
        else:
            side = ("toward the left of frame" if dl < 0
                    else "toward the right of frame")
        return f"; its open/front side faces {side}"

    items: List[Tuple[float, float, str, str, str, str]] = []
    for lm in layout.get("landmarks") or []:
        pts = [p for p in (lm.get("points") or []) if _is_point(p)]
        if not pts:
            continue
        depth, lateral = _project(camera, _centroid(pts))
        if depth <= 0.5:
            continue  # 카메라 뒤
        label = lm.get("label") or lm.get("id") or "a structure"
        kind = lm.get("kind") or "area"
        axis = _axis_dir(pts) if kind == "area" else ""
        faces = _faces_phrase(pts, lm.get("faces_toward"))
        items.append((depth, lateral, label, kind, axis, faces))
    items.sort(key=lambda t: t[0])
    if not items:
        return (f"The eye-level camera view for shot {shi}. "
                "(no landmarks in front of the camera)")

    dmin, dmax = items[0][0], items[-1][0]
    span = (dmax - dmin) or 1.0

    def _band(d: float) -> str:
        r = (d - dmin) / span
        return "foreground" if r < 0.34 else ("midground" if r < 0.67 else "background")

    def _screen(lateral: float, depth: float) -> str:
        ang = math.degrees(math.atan2(lateral, max(depth, 1e-3)))
        if ang < -half_fov_deg * 0.4:
            return "toward the left of frame"
        if ang > half_fov_deg * 0.4:
            return "toward the right of frame"
        return "near centre of frame"

    lines: List[str] = []
    for depth, lateral, label, kind, axis, faces in items:
        # 큰 면적은 centroid 한 점의 좌/우 라벨이 가짜정밀도 → 가로로 펼쳐지면
        # 'spanning across' (좌우 오라벨 방지).
        if kind == "area" and "horizontally" in axis:
            lines.append(
                f"- {label}: in the {_band(depth)}, spanning across the frame{faces}.")
        else:
            lines.append(
                f"- {label}: in the {_band(depth)}, {_screen(lateral, depth)}{axis}{faces}.")

    fig_depths: List[Tuple[float, float, Any, Any]] = []
    for fig in layout.get("figures") or []:
        for p in fig.get("positions") or []:
            if p.get("shot_index") != shi or not _is_point(p.get("pos")):
                continue
            d, lat = _project(camera, p["pos"])
            if d <= 0.5:
                continue
            mt = p.get("moving_toward")
            fig_depths.append((d, lat, mt if _is_point(mt) else None, p["pos"]))
    fig_lines: List[str] = []
    if fig_depths:
        nearest = min(d for d, _, _, _ in fig_depths)
        for d, lat, mt, fpos in sorted(fig_depths, key=lambda t: t[0]):
            scale = nearest / (d or 1.0)
            mv = " (in motion, moving deeper into the scene)" if mt is not None else ""
            # 3-stage 필수화(2026-07-02): enclosure 위상 관계(폴리곤 산술) — 카메라
            # 밖/인물 안 관계가 brief 에 없으면 스케치가 인물을 개방 지면으로 옮긴다.
            enc = _enclosure_relation_phrase(camera, fpos, layout)
            fig_lines.append(
                f"- a figure: {_band(d)}, {_screen(lat, d)}, about "
                f"{scale:.2f}x the nearest figure's size{mv}{enc}.")

    out = [
        f"The eye-level camera view for shot {shi}.",
        "Keep the horizon a single LEVEL, roughly horizontal line across the frame — "
        "do not tilt or slant it; this is an eye-level shot.",
        "Visible structures, ordered near to far:",
    ]
    out += lines
    if fig_lines:
        out.append("Figures (featureless markers only):")
        out += fig_lines
    return "\n".join(out)


# top-down set-map area 채움색 팔레트(RGBA) — landmark 종류/이름이 아니라 등장
# 순서(index)로 generic 배정한다. 의미는 camera brief 텍스트가 운반하고, 색은 서로
# 다른 큰 면을 시각적으로 구분할 뿐 (시나리오 토큰 0). provider 가 글자 0 으로 렌더한다.
_BIRDSEYE_AREA_PALETTE: List[Tuple[int, int, int, int]] = [
    (150, 190, 220, 110), (210, 180, 120, 150), (170, 200, 150, 130),
    (200, 170, 200, 120), (205, 205, 150, 130), (180, 180, 180, 120),
]
_BIRDSEYE_EDGE: Tuple[int, int, int] = (110, 110, 110)
_BIRDSEYE_FIGURE: Tuple[int, int, int] = (200, 60, 60)
_BIRDSEYE_CAMERA: Tuple[int, int, int] = (40, 120, 40)


def build_clean_birdseye_spec(
    layout: Dict[str, Any], *, shot_index: Optional[int] = None,
) -> Dict[str, Any]:
    """layout 좌표 → 결정론 top-down set-map 그리기 spec (순수 데이터, PIL/LLM 0).

    ★재배선 v2(2026-06-29) 에서 **미사용** — PIL birdseye 경로(literal 좌표 렌더 →
    셸터가 도로 위 같은 좌표 품질 노출)를 라벨 마커 항공뷰(T2I base + I2I 블로킹)로
    교체했다. 삭제하지 않고 보존(다른 소비/회귀 참조 가능). 새 경로는
    ``build_aerial_base_prompt`` / ``build_shot_blocking_prompt`` 를 쓴다.

    색은 area 등장 index 로 generic 배정 (시나리오 토큰 0). ``shot_index`` 지정 시
    그 샷의 카메라+figure 위치만 (단일 카메라뷰 가이드의 명확성). landmark 는 고정
    장소이므로 항상 전부. **글자/라벨 필드 없음** — provider 렌더가 텍스트를 그리지
    않게(모델 입력) spec 자체에 인간 가독 라벨을 담지 않는다. serialize-가능 → 해시."""
    landmarks: List[Dict[str, Any]] = []
    area_idx = 0
    for lm in layout.get("landmarks") or []:
        pts = [[float(p[0]), float(p[1])]
               for p in (lm.get("points") or []) if _is_point(p)]
        if not pts:
            continue
        kind = lm.get("kind")
        if kind == "line" and len(pts) >= 2:
            landmarks.append(
                {"shape": "line", "points": pts, "edge": list(_BIRDSEYE_EDGE)})
        elif len(pts) >= 3:
            fill = _BIRDSEYE_AREA_PALETTE[area_idx % len(_BIRDSEYE_AREA_PALETTE)]
            landmarks.append({"shape": "polygon", "points": pts,
                              "fill": list(fill), "edge": list(_BIRDSEYE_EDGE)})
            area_idx += 1
        else:
            landmarks.append(
                {"shape": "point", "points": pts[:1], "edge": list(_BIRDSEYE_EDGE)})

    cameras: List[Dict[str, Any]] = []
    for c in layout.get("cameras") or []:
        if shot_index is not None and c.get("shot_index") != shot_index:
            continue
        if _is_point(c.get("pos")) and _is_point(c.get("look_at")):
            cameras.append({
                "shot_index": c.get("shot_index"),
                "pos": [float(c["pos"][0]), float(c["pos"][1])],
                "look_at": [float(c["look_at"][0]), float(c["look_at"][1])],
            })

    figures: List[Dict[str, Any]] = []
    for fg in layout.get("figures") or []:
        for p in fg.get("positions") or []:
            if shot_index is not None and p.get("shot_index") != shot_index:
                continue
            if not _is_point(p.get("pos")):
                continue
            mt = p.get("moving_toward")
            figures.append({
                "shot_index": p.get("shot_index"),
                "pos": [float(p["pos"][0]), float(p["pos"][1])],
                "moving_toward": ([float(mt[0]), float(mt[1])]
                                  if _is_point(mt) else None),
            })

    return {
        "coord_range": [COORD_MIN, COORD_MAX],
        "landmarks": landmarks,
        "cameras": cameras,
        "figures": figures,
        "figure_color": list(_BIRDSEYE_FIGURE),
        "camera_color": list(_BIRDSEYE_CAMERA),
    }


# ── shared-model v2 (Phase II 재배선, 2026-06-29) — 라벨 마커 항공뷰 파이프라인 ──
#
# 검증 완료된 새 설계(scratchpad phase2_newdesign2 / phase2_sketch, 8899 갤러리 육안):
#   A) location 공통 빈 항공뷰 base — 환경 요소를 원형 숫자 (1)(2)(3) 로 (T2I, group 공통)
#   B) 샷별 블로킹 — 그 base 위에 엔티티를 원형 글자 (A)(B)(C) + 카메라 1개 (I2I)
#   C) 카메라뷰 스케치 — 블로킹(or base) + compute_camera_brief → eye-level 라인아트 (I2I)
# 아래 빌더는 전부 **generic 템플릿** — 환경 label 만 데이터에서 흘러들고(원형 숫자 옆
# 표기), 인물은 익명 글자 마커(이름/토큰 누출 0). 좌표 산술 + 데이터 label 만, LLM/
# 의미판정 0. 원 숫자/글자 = 위치 식별 시각 마커(프로젝트 FP→VLM 패턴), 글자-의미판정 X.

_GRID_LO: float = 34.0
_GRID_HI: float = 66.0
_FIGURE_LETTERS: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
_CAMERA_AIM_EPS: float = 12.0   # _camera_aim 방향 판정 임계 (scratchpad 검증값)
# 두 카메라가 좌표상 사실상 동일/중복인지 가르는 **기술 tolerance** — 의미 임계가
# 아니다. True 의 의미는 단지 "non-identical camera geometry"(완전히 같은 좌표가 아님)
# 일 뿐, "다른 연출/앵글"이라고 단정하지 않는다 (그 판단은 judge route, Codex 정렬).
_CAMERA_DEDUP_EPS: float = 1.5


def _grid_cell(x: float, y: float) -> str:
    """0-100 좌표 → 9-cell grid 라벨 (top/middle/bottom × left/centre/right) — 순수
    3분할 산술 (셀 경계 generic, 의미판정 0)."""
    col = "left" if x < _GRID_LO else ("right" if x > _GRID_HI else "centre")
    row = "top" if y < _GRID_LO else ("bottom" if y > _GRID_HI else "middle")
    if row == "middle" and col == "centre":
        return "centre"
    return f"{row}-{col}".replace("middle-", "").replace("-centre", "")


def _shape_word(kind: Optional[str]) -> str:
    return {"line": "a long line/edge", "area": "a filled zone/area",
            "point": "a small fixed marker"}.get(kind or "", "a zone")


def _landmark_anchor(lm: Dict[str, Any]) -> Optional[Tuple[float, float]]:
    """landmark 대표점 (순수 기하): line=양끝 중점, point=그 점, area=centroid."""
    pts = [p for p in (lm.get("points") or []) if _is_point(p)]
    if not pts:
        return None
    if lm.get("kind") == "line" and len(pts) >= 2:
        return ((float(pts[0][0]) + float(pts[-1][0])) / 2.0,
                (float(pts[0][1]) + float(pts[-1][1])) / 2.0)
    if lm.get("kind") == "point":
        return (float(pts[0][0]), float(pts[0][1]))
    return _centroid(pts)


def _nearest_landmark(pos: Any, landmarks: List[Dict[str, Any]]) -> Tuple[int, str]:
    """pos 에 가장 가까운 환경 landmark → (1-based 번호, label). 번호는 base 의 원형
    숫자와 정렬한다 (enumerate 순서 동일). 없으면 (0, "the set")."""
    best_i, best_label, best_d = 0, "the set", float("inf")
    for i, lm in enumerate(landmarks, start=1):
        a = _landmark_anchor(lm)
        if a is None:
            continue
        d = _dist(pos, a)
        if d < best_d:
            best_i, best_label, best_d = (
                i, str(lm.get("label") or lm.get("id") or "an element"), d)
    return best_i, best_label


def _figures_at_shot(
    layout: Dict[str, Any], shot_index: int,
) -> List[Tuple[List[float], Optional[List[float]]]]:
    """이 샷에 위치가 있는 figure 들 → [(pos, moving_toward|None)] (순수 조인)."""
    out: List[Tuple[List[float], Optional[List[float]]]] = []
    for fg in layout.get("figures") or []:
        for p in fg.get("positions") or []:
            if p.get("shot_index") != shot_index or not _is_point(p.get("pos")):
                continue
            mt = p.get("moving_toward")
            out.append((p["pos"], mt if _is_point(mt) else None))
            break
    return out


def _camera_aim_phrase(camera: Dict[str, Any], layout: Dict[str, Any]) -> str:
    """카메라 pos→look_at 방향을 화면어 + 가장 가까운 환경 원 번호로 서술 (좌표 산술)."""
    pos, look = camera["pos"], camera["look_at"]
    dx, dy = float(look[0]) - float(pos[0]), float(look[1]) - float(pos[1])
    v = ("upward (toward the top)" if dy < -_CAMERA_AIM_EPS else
         ("downward (toward the bottom)" if dy > _CAMERA_AIM_EPS else ""))
    h = ("to the right" if dx > _CAMERA_AIM_EPS else
         ("to the left" if dx < -_CAMERA_AIM_EPS else ""))
    aim = " and ".join(p for p in (v, h) if p) or "across the plan"
    ni, nl = _nearest_landmark(look, layout.get("landmarks") or [])
    return f"{aim}, toward circle {ni} ({nl}) and the lettered figures"


# W-G (2026-07-03) — Stage A aerial base 를 same-building indoor floor plan ref 로
# I2I 승격할 때 프롬프트 끝에 덧붙이는 정합 지시. generic — 건물 규모/개구부 정합만
# 요구하고 반례/시나리오 토큰 0. attached fp 는 '편집 캔버스'가 아니라 구조 참조임을
# 명시(fp 위에 덧그리는 오해 방지).
BUILDING_FP_AERIAL_GUIDANCE: str = (
    "\nThe ATTACHED image is a TOP-DOWN INTERIOR FLOOR PLAN of the building that "
    "stands on this site — a STRUCTURAL REFERENCE ONLY, not the drawing to edit. "
    "Draw the aerial site plan described above as a fresh image. Where that "
    "building appears on the site, keep its footprint size, shape, proportions "
    "and its door/window openings consistent with the attached floor plan. Do "
    "NOT copy the floor plan's text, numbers, colours, furniture symbols or "
    "line style into the aerial site plan."
)


def build_aerial_base_prompt(layout: Dict[str, Any]) -> str:
    """Stage A — location 공통 빈 항공뷰 base 프롬프트 (환경=원형 숫자, 엔티티/카메라
    제외). generic — landmark label 만 데이터서 흘러들고 시나리오 토큰 0. group 공통."""
    lines = [
        "Draw a CLEAN top-down COLOUR aerial SITE PLAN of ONE outdoor location — a flat "
        "schematic blueprint-style map seen straight from directly above (flat colour "
        "fills + clean outlines), NOT a photograph and NOT a realistic 3D render: no "
        "photo texture, no harsh shadows, no perspective.",
        "Lay the elements out physically sensibly: any structure built beside a long "
        "path/road runs PARALLEL alongside it, never on top of or across it; any "
        "water/coast and road stay separated along the shoreline.",
        "This is a SHARED EMPTY SET map reused by every camera shot. Draw each fixed "
        "ENVIRONMENT element below as a flat schematic shape AND mark it with a small "
        "CIRCLE containing its NUMBER at the element's location:",
    ]
    for i, lm in enumerate(layout.get("landmarks") or [], start=1):
        label = lm.get("label") or lm.get("id") or "an element"
        lines.append(f"- circle {i}: {label} ({_shape_word(lm.get('kind'))}).")
    lines.append(
        "Draw NO named characters, NO people, NO props and NO camera. The ONLY text in "
        "the image is the single DIGIT inside each numbered circle — no other words, "
        "letters or labels.")
    return "\n".join(lines)


def build_shot_blocking_prompt(
    layout: Dict[str, Any], camera: Dict[str, Any], shot_index: int,
) -> str:
    """Stage B — 공통 base 위 I2I 블로킹 프롬프트: 엔티티=원형 글자, 카메라 1개. base
    set 보존 강제. generic — 위치는 grid + 환경 원 상대, 인물은 익명 글자(이름 누출 0)."""
    landmarks = layout.get("landmarks") or []
    ent_lines: List[str] = []
    for idx, (pos, mt) in enumerate(_figures_at_shot(layout, shot_index)):
        if idx >= len(_FIGURE_LETTERS):
            break
        letter = _FIGURE_LETTERS[idx]
        ni, nl = _nearest_landmark(pos, landmarks)
        clause = (f"circle {letter}: a figure at the {_grid_cell(*pos)} of the plan, "
                  f"beside circle {ni} ({nl})")
        if mt:
            clause += f", moving toward the {_grid_cell(*mt)}"
        ent_lines.append("   - " + clause + ".")
    ent_block = "\n".join(ent_lines) or "   - (none)"
    cpos = camera["pos"]
    return (
        "The attached image is the SHARED empty site-plan base for this location: the "
        "numbered circles mark the fixed environment. KEEP the whole base EXACTLY as it "
        "is — same layout, shapes, numbered circles, colours, proportions and "
        "orientation; do NOT redraw, move, recolour or restyle anything.\n"
        "ADD on top, for THIS ONE shot only:\n"
        "1) ENTITIES — for each, draw a small CIRCLE containing a CAPITAL LETTER at its "
        "position (these lettered circles are NEW; the numbered circles stay unchanged):\n"
        f"{ent_block}\n"
        "2) THE CAMERA — exactly ONE camera: a clearly recognizable CAMERA icon at the "
        f"{_grid_cell(*cpos)} of the plan, with a BOLD direction ARROW and a translucent "
        f"triangular view-CONE opening {_camera_aim_phrase(camera, layout)}, so the "
        "shot's viewpoint and aim are unmistakable.\n"
        "Add NO text other than the existing numbers and these new capital letters.")


# Stage C 카메라뷰 스케치 시스템 — top-down 블로킹(or base) ref 를 읽어 eye-level 구도
# 스케치로 변환. "if 글자원/카메라 있으면" 절로 블로킹/ base-only 둘 다 graceful 처리
# (단순 구도는 B 생략 → base 만 ref, figure/카메라뷰는 brief 가 운반). generic.
_BLOCKING_SKETCH_SYSTEM: str = (
    "The attached image is a TOP-DOWN diagram of one outdoor location seen from directly "
    "above: numbered circles mark fixed environment elements. If the diagram also "
    "contains lettered circles, those mark the figures present in this shot, and a "
    "camera icon with a view-cone shows the camera position and the direction it looks. "
    "Read it ONLY to understand which elements are in shot and their relative "
    "placement.\n"
    "Now draw a ROUGH eye-level STORYBOARD COMPOSITION SKETCH of what THAT camera sees "
    "— a clean MONOCHROME pencil/marker line-art storyboard frame, NOT a finished "
    "render and NOT a top-down map. Use a normal horizontal EYE-LEVEL viewpoint, never "
    "a bird's-eye.\n"
    "Lay out the frame exactly by this depth / left-right brief:\n{brief}\n"
    "If the brief states a figure is INSIDE a mapped area while the camera is "
    "OUTSIDE that area, keep the figure within that area as seen from the camera; "
    "if the area's boundary or opening is visible, draw it between the camera and "
    "the figure — do NOT relocate the figure outside its area.\n"
    "If the brief states which way a structure's open or front side faces, draw that "
    "structure opening exactly that way relative to the camera — never mirror or flip "
    "its orientation.\n"
    "Keep real-world human scale throughout: a doorway reads as adult height, a bench "
    "seat as knee height, a road as wider than a car. If figures are present, size every "
    "structure against them; with no figure, still keep adult-human proportions — never "
    "shrink a structure into a miniature or toy-sized box.\n"
    "Draw ONLY the environment elements the diagram and the brief list — do NOT add "
    "any structure or object that is not in them, and never draw a second copy of a "
    "listed structure.\n"
    "Draw each figure as a simple featureless mannequin/placeholder at its mapped screen "
    "position and depth. Draw environment elements as simple line shapes only. This "
    "sketch defines ONLY framing, placement, depth and viewpoint — do NOT invent "
    "materials, colours, style, architecture, faces or surface finish.\n"
    "The diagram's NUMBERED circles, LETTERED circles, the camera icon, view-cone and "
    "arrow are spatial annotations only — do NOT copy ANY of them into the sketch. The "
    "output must contain NO numbers, letters, circles, arrows, labels or text anywhere."
)


# Stage C(포즈 복원 P1 2026-07-01) — camera_brief(set/framing SOT) + pose_brief(body/action
# SOT)를 함께 받아 posed mannequin 스케치로 변환. v2 가 씬 액션(포즈)을 드롭해 generic
# 서있는 마네킹으로 오염되던 회귀(무-가이드보다 나쁨) 복구. pose_brief 없으면
# build_blocking_sketch_prompt 가 _BLOCKING_SKETCH_SYSTEM(기존)로 degrade(byte-identical).
_POSED_SKETCH_SYSTEM: str = (
    "The attached image is a TOP-DOWN diagram of one outdoor location seen from directly "
    "above: numbered circles mark fixed environment elements. If the diagram also "
    "contains lettered circles, those mark the figures present in this shot, and a "
    "camera icon with a view-cone shows the camera position and the direction it looks. "
    "Read it ONLY to understand which elements are in shot and their relative "
    "placement.\n"
    "Now draw a ROUGH STORYBOARD COMPOSITION SKETCH of what THAT camera sees — a clean "
    "MONOCHROME pencil/marker line-art storyboard frame, NOT a finished render and NOT "
    "a top-down map. Draw it from the exact CAMERA ANGLE and viewpoint the FRAMING brief "
    "below describes (eye-level, a high or steep overhead angle, a low angle, etc.); do "
    "NOT default to a level eye-level view when the brief calls for a different angle.\n"
    "FRAMING / SET GEOMETRY (source of truth for the camera angle, viewpoint, depth, "
    "left-right placement and which elements are in frame):\n{brief}\n"
    "If the framing brief states a figure is INSIDE a mapped area while the camera "
    "is OUTSIDE that area, keep the figure within that area as seen from the camera; "
    "if the area's boundary or opening is visible, draw it between the camera and "
    "the figure — do NOT relocate the figure outside its area.\n"
    "If the brief states which way a structure's open or front side faces, draw that "
    "structure opening exactly that way relative to the camera — never mirror or flip "
    "its orientation.\n"
    "Keep real-world human scale throughout: a doorway reads as adult height, a bench "
    "seat as knee height, a road as wider than a car. Size every structure against the "
    "mannequin figures — never shrink a structure into a miniature or toy-sized box.\n"
    "Draw ONLY the environment elements the diagram and the brief list — do NOT add "
    "any structure or object that is not in them, and never draw a second copy of a "
    "listed structure.\n"
    "BODY POSE AND ACTION of each figure (source of truth for posture, gesture and which "
    "way each body faces — draw the bodies EXACTLY like this, do NOT default to a plain "
    "standing pose):\n{pose}\n"
    "Draw every PERSON as a posed artist's wooden MANNEQUIN — a smooth featureless "
    "articulated mannequin (clear ball joints at shoulders, elbows, hips and knees; no "
    "clothing; no face) posed EXACTLY in the posture and action stated above, so the "
    "body POSE, stance and limb positions are unambiguous. Construct each mannequin's "
    "head with the LOOMIS METHOD (a sphere with the side plane sliced flat, a vertical "
    "centerline and a horizontal brow line) so the head's facing direction is explicit. "
    "Place each figure at its mapped screen position and depth. Draw environment "
    "elements as simple line shapes only.\n"
    "When the sources conflict: the FRAMING brief governs camera angle, depth and "
    "left-right placement; the POSE lines govern each body's posture, action and "
    "orientation; the top-down diagram governs only relative set placement. This sketch "
    "fixes ONLY framing, placement, depth, viewpoint and body pose — it does NOT define "
    "identity, clothing, faces, materials, colours, architecture or surface finish, "
    "which come from the final character and background references, not this sketch; do "
    "NOT invent any of them.\n"
    "The diagram's NUMBERED circles, LETTERED circles, the camera icon, view-cone and "
    "arrow are spatial annotations only — do NOT copy ANY of them into the sketch. The "
    "output must contain NO numbers, letters, circles, arrows, labels or text anywhere."
)


def build_blocking_sketch_prompt(
    camera_brief: str, pose_brief: Optional[str] = None,
) -> str:
    """Stage C — 블로킹(or base) ref + 좌표 산술 camera brief (+선택 pose brief) → 카메라뷰
    스케치 프롬프트. pose_brief(자세/동작 SOT) 있으면 posed mannequin 프롬프트, 없으면
    (pose LLM 실패 degrade / 다른 소비) 기존 featureless 프롬프트(byte-identical)."""
    if pose_brief and pose_brief.strip():
        return _POSED_SKETCH_SYSTEM.format(
            brief=camera_brief or "(no brief)", pose=pose_brief.strip())
    return _BLOCKING_SKETCH_SYSTEM.format(brief=camera_brief or "(no brief)")


def render_pose_brief_text(pose_result: Optional[Dict[str, Any]]) -> Optional[str]:
    """structured pose brief(figures[])를 Stage C 스케치용 텍스트 블록으로 렌더(순수).

    자세 확정(body_posture != unknown) + confidence medium/high 인 figure 만 포함한다.
    하나도 없으면 None → step 이 fail-closed(가이드 미부착) 해서 generic standing 마네킹
    재발(무-가이드보다 나쁜 오염)을 막는다(Codex 가드). 슬롯/동작은 generic role·동작만
    (이름/의상/색/소품 고유명사는 스키마/프롬프트 단계에서 차단)."""
    figs = (pose_result or {}).get("figures") or []
    lines: List[str] = []
    for f in figs:
        if not isinstance(f, dict):
            continue
        posture = str(f.get("body_posture") or "").strip()
        conf = str(f.get("confidence") or "").strip().lower()
        if not posture or posture.lower() == "unknown" or conf == "low":
            continue
        slot = str(f.get("slot") or "").strip() or "a figure"
        limb = str(f.get("limb_action") or "").strip()
        orient = str(f.get("head_body_orientation") or "").strip()
        support = str(f.get("contact_or_support") or "").strip()
        target = str(f.get("interaction_target_role") or "").strip()
        parts = [f"{slot} is {posture}"]
        if limb and limb.lower() != "none":
            parts.append(limb)
        if support and support.lower() not in ("none", ""):
            parts.append(f"resting on/against {support}")
        if target and target.lower() != "none":
            parts.append(f"engaging {target}")
        if orient:
            # orientation 값이 'facing ...' 로 시작하면 중복 'facing facing' 방지.
            o = orient[len("facing "):].strip() if orient.lower().startswith(
                "facing ") else orient
            if o:
                parts.append(f"facing {o}")
        lines.append("- " + "; ".join(parts) + ".")
    if not lines:
        return None
    return "\n".join(lines)


# ── cross-shot continuity 후보 + per-shot 구조 signal (순수 좌표/구조, 의미판정 0) ──
#
# shared-model 가이드의 가치 = 같은 location 을 여러 샷이 (다른 앵글로) 보일 때 set
# 일관성(§4e-B harm: 샷마다 셸터 발명). 후보 = 같은 scene·location 에 valid 카메라
# member 2+ & 카메라 구조적 차이 1쌍+. 단일샷 복잡도는 코드가 확정하지 않고 signal/
# diagnostic 으로만 — production attach 판정은 LLM judge route + candidate AND (step).


def _camera_for_shot(
    layout: Dict[str, Any], shot_index: int,
) -> Optional[Dict[str, Any]]:
    return next(
        (c for c in layout.get("cameras") or []
         if c.get("shot_index") == shot_index
         and _is_point(c.get("pos")) and _is_point(c.get("look_at"))),
        None,
    )


def _cameras_non_identical(cameras: List[Dict[str, Any]]) -> bool:
    """카메라 목록에 좌표가 사실상 동일하지 않은(non-identical geometry) 쌍이 하나라도
    있으면 True. _CAMERA_DEDUP_EPS 는 좌표 중복 제거용 기술 tolerance — '다른 앵글/
    연출'이라는 의미 단정이 아니다 (그 판단은 judge route). 순수 기하, 의미판정 0."""
    for i in range(len(cameras)):
        for j in range(i + 1, len(cameras)):
            a, b = cameras[i], cameras[j]
            if (_dist(a["pos"], b["pos"]) > _CAMERA_DEDUP_EPS
                    or _dist(a["look_at"], b["look_at"]) > _CAMERA_DEDUP_EPS):
                return True
    return False


def shot_complexity_signals(
    layout: Dict[str, Any], shot_index: int,
) -> Dict[str, Any]:
    """per-shot 구조 signal (순수 좌표) — judge 입력 + Stage B 트리거. 이름/토큰 누출 0.

    entity_count_bucket(none/single/multiple), depth_planes(figure 가 점유하는 카메라
    depth band 수 0-3), has_motion."""
    figs = _figures_at_shot(layout, shot_index)
    n = len(figs)
    bucket = "none" if n == 0 else ("single" if n == 1 else "multiple")
    has_motion = any(mt is not None for _, mt in figs)
    planes = 0
    camera = _camera_for_shot(layout, shot_index)
    if camera is not None and figs:
        depths = [_project(camera, pos)[0] for pos, _ in figs]
        depths = [d for d in depths if d > 0.5]
        if depths:
            dmin, dmax = min(depths), max(depths)
            span = (dmax - dmin) or 1.0
            bands = {0 if (d - dmin) / span < 0.34
                     else (1 if (d - dmin) / span < 0.67 else 2) for d in depths}
            planes = len(bands)
    return {
        "entity_count_bucket": bucket,
        "entity_count": n,
        "depth_planes": planes,
        "has_motion": has_motion,
    }


def shared_model_candidate_groups(
    layout: Dict[str, Any],
    member_keys: List[ShotKey],
    summary_keys: Set[str],
) -> List[Dict[str, Any]]:
    """cross-shot continuity **결정론 후보** (좌표/구조만, 의미판정 0).

    member_keys(이 location 그룹의 selected 멤버)를 scene_index 로 묶어, 각 scene
    서브그룹이 (a) summary 존재 + valid 카메라 member 2+ (b) 카메라가 non-identical
    geometry 1쌍+ (좌표 중복 제외) (c) layout 에 공통 landmark 1+ 면 후보. 단일샷
    복잡도/의미는 판단하지 않는다 (judge route + signal). anchor = shot_index 최소 멤버.

    Returns: [{"scene_index", "member_keys":[(si,shi)..], "anchor_key":(si,shi),
    "cameras_non_identical":bool, "landmark_count":int, "per_shot":{hint_key: signals}}].
    """
    landmark_count = sum(
        1 for lm in layout.get("landmarks") or []
        if [p for p in (lm.get("points") or []) if _is_point(p)]
    )
    by_scene: Dict[int, List[ShotKey]] = {}
    for si, shi in member_keys:
        if shot_hint_key(si, shi) in summary_keys and _camera_for_shot(layout, shi):
            by_scene.setdefault(si, []).append((si, shi))

    out: List[Dict[str, Any]] = []
    for si in sorted(by_scene):
        members = sorted(by_scene[si], key=lambda k: k[1])
        if len(members) < 2:
            continue
        cams = [c for c in (_camera_for_shot(layout, shi) for _, shi in members)
                if c is not None]
        if not _cameras_non_identical(cams):
            continue
        if landmark_count < 1:
            continue
        out.append({
            "scene_index": si,
            "member_keys": members,
            "anchor_key": members[0],
            "cameras_non_identical": True,
            "landmark_count": landmark_count,
            "per_shot": {
                shot_hint_key(m_si, m_shi): shot_complexity_signals(layout, m_shi)
                for m_si, m_shi in members
            },
        })
    return out


def single_shot_complexity_candidates(
    layout: Dict[str, Any],
    member_keys: List[ShotKey],
    summary_keys: Set[str],
    exclude_keys: Optional[Set[ShotKey]] = None,
    broad: bool = False,
) -> List[Dict[str, Any]]:
    """단일 샷 복잡도 **후보**(좌표/구조 pre-filter — 의미판정은 LLM judge).

    사용자 결정(2026-07-01): 실외도 실내처럼 단일 복잡샷에 aerial→blocking→sketch 가이드를
    발동(복잡도/구도 복잡은 LLM 이 판단). cross-shot 그룹에 이미 든 샷(exclude_keys)은
    제외해 중복 발동을 막는다. pre-filter = valid 카메라 + summary 존재 + figure>=1 +
    구조 복잡 신호(multiple figures OR depth_planes>=2 OR has_motion). 최종 attach 여부는
    judge route(single_shot_complexity) + _evaluate 가 결정한다.

    ★broad=True (4a fix 2026-07-01, Codex 합의): is_complex 구조 게이트를 **제거**하고
    figure>=1 이면 모두 candidate 로 올린다(사용자 "한샷이라도 구도/복잡이면 무조건 마네킹"
    — 좁은 구조 prefilter 는 frame_spatial_contract 없는 복잡 구도샷[S15/S19]을 구조적으로
    누락). 복잡/구도 판정은 downstream shared-model judge 가 camera_direction/frame 구조
    필드로 결정(코드 free-text 파싱 0). is_complex 는 signal 로 계속 계산해 per_shot 에
    남기되 candidate 여부 게이트로는 쓰지 않는다. broad=False = 기존 게이트(byte-identical).

    cross-shot 후보와 동일 shape 로 반환(step 루프 공용) + is_single_shot=True.
    """
    exclude = exclude_keys or set()
    landmark_count = sum(
        1 for lm in layout.get("landmarks") or []
        if [p for p in (lm.get("points") or []) if _is_point(p)]
    )
    out: List[Dict[str, Any]] = []
    for si, shi in sorted(member_keys):
        if (si, shi) in exclude:
            continue
        if shot_hint_key(si, shi) not in summary_keys:
            continue
        if not _camera_for_shot(layout, shi):
            continue
        sig = shot_complexity_signals(layout, shi)
        # 3-stage 필수화(2026-07-02): broad lane 은 figure 0(구조/공간 샷)도 후보 —
        # seed(detect_site_seeds)와 대칭. 인물 없는 establishing 샷도 aerial 공간
        # 추론이 필요하며(불가능 카메라 프레이밍 방지), 필요성 판정은 judge 가 한다.
        if not broad and int(sig.get("entity_count") or 0) < 1:
            continue
        is_complex = (
            sig.get("entity_count_bucket") == "multiple"
            or int(sig.get("depth_planes") or 0) >= 2
            or bool(sig.get("has_motion"))
        )
        if not broad and not is_complex:
            continue
        out.append({
            "scene_index": si,
            "member_keys": [(si, shi)],
            "anchor_key": (si, shi),
            "cameras_non_identical": False,
            "landmark_count": landmark_count,
            "is_single_shot": True,
            "per_shot": {shot_hint_key(si, shi): sig},
        })
    return out


def shot_blocking_recommended(
    signals: Dict[str, Any], judge_reasons: Optional[List[str]] = None,
) -> bool:
    """Stage B(블로킹) 권고 (순수) — 구조 신호(다수 figure or depth_planes≥2) OR judge
    reasons 에 figure 배치 연속성 플래그. False = 단순 구도 → base→C 직행(B 생략)."""
    if signals.get("entity_count_bucket") == "multiple":
        return True
    if int(signals.get("depth_planes") or 0) >= 2:
        return True
    if judge_reasons and "figure_placement_continuity" in judge_reasons:
        return True
    return False


# ── layout 좌/우 ↔ staging screen_zone 정합 검증 (deterministic 조인) ──

# frame_spatial_contract.screen_zone enum 값의 좌/우 성분 (enum 구조 필드 해석 —
# 자유 텍스트 의미 추론이 아니라 고정 enum 의 부분 라벨이다).
_ZONE_SIDE = {
    "upper_left": "left", "middle_left": "left", "lower_left": "left",
    "upper_right": "right", "middle_right": "right", "lower_right": "right",
}


def staged_sides_for_shot(staging: Optional[Dict[str, Any]]) -> Dict[str, str]:
    """staging frame_spatial_contract → {entity_token(C##): "left"|"right"}.

    target_kind=character + target_id 있는 constraint 만 (deterministic 조인)."""
    out: Dict[str, str] = {}
    constraints = ((staging or {}).get("frame_spatial_contract") or {}).get("constraints") or []
    for c in constraints:
        side = _ZONE_SIDE.get(c.get("screen_zone") or "")
        target = c.get("target_id")
        if side and c.get("target_kind") == TARGET_KIND_CHARACTER and target:
            out[str(target)] = side
    return out


def layout_side_conflicts(
    layout: Dict[str, Any],
    shot_index: int,
    staged_sides: Dict[str, str],
) -> List[Dict[str, Any]]:
    """layout 카메라 기준 인물 좌/우가 staged screen_zone 과 다른 항목.

    조인 키 = figure.entity_token ↔ constraint.target_id (C##). 충돌은 caller
    가 LLM1 retry 신호 + diagnostic 으로 쓴다 (lateral SOT=staging)."""
    if not staged_sides:
        return []
    camera = next(
        (c for c in layout.get("cameras") or [] if c.get("shot_index") == shot_index),
        None,
    )
    if camera is None or not _is_point(camera.get("pos")) or not _is_point(camera.get("look_at")):
        return []
    conflicts: List[Dict[str, Any]] = []
    for fig in layout.get("figures") or []:
        token = fig.get("entity_token")
        expected = staged_sides.get(str(token)) if token else None
        if not expected:
            continue
        pos_entry = next(
            (p for p in fig.get("positions") or [] if p.get("shot_index") == shot_index),
            None,
        )
        if pos_entry is None or not _is_point(pos_entry.get("pos")):
            continue
        actual = _frame_side(camera["pos"], camera["look_at"], pos_entry["pos"])
        if actual != "center" and actual != expected:
            conflicts.append({
                "shot_index": shot_index,
                "entity_token": token,
                "staged_side": expected,
                "layout_side": actual,
            })
    return conflicts


# ─────────────────────────── revised prompt 토큰 audit ───────────────────────────


def revised_prompt_token_violations(original: str, revised: str) -> Dict[str, List[str]]:
    """entity ID 불변 계약 — 신규 토큰 도입(W-C1 audit 재사용) + 기존 토큰 누락
    둘 다 위반. 비어있지 않으면 caller 는 revised 를 폐기하고 diagnostic 기록
    (비차단)."""
    out: Dict[str, List[str]] = {}
    new_tokens = revised_prompt_new_tokens(original, revised)
    if new_tokens:
        out["new_tokens"] = sorted(new_tokens)
    missing = extract_entity_tokens(original) - extract_entity_tokens(revised)
    if missing:
        out["missing_tokens"] = sorted(missing)
    return out


# ─────────────────────────── prompt override merge ───────────────────────────


def merge_prompt_overrides(
    zoom_overrides: Dict[ShotKey, Dict[str, Any]],
    site_overrides: Dict[ShotKey, Dict[str, Any]],
) -> Dict[ShotKey, Dict[str, Any]]:
    """image-phase prompt override 의 **명시적 priority merge** —
    custom > zoom > site > original (Codex ⓒ).

    custom_prompt 는 소비자의 별도 분기가 이 merge 이전에 승리하고, original 은
    override 부재(빈 맵)다 — 이 함수는 zoom/site 두 단계만 (shot, variation_index)
    단위로 합친다: 같은 index 는 zoom 이 승리, site 는 빈 index 만 채운다.

    entry shape 는 zoom ``source_overrides`` 와 호환 (revised{int: str} /
    provenance{str: {...}} / group_id) + ``prompt_source`` /
    ``prompt_source_by_index`` stamp — override 적용 로그가 출처를 기록한다.
    둘 다 빈 맵이면 빈 맵 (소비자 no-op, default 경로 byte-identical).
    """
    merged: Dict[ShotKey, Dict[str, Any]] = {}
    for key in set(zoom_overrides) | set(site_overrides):
        revised: Dict[int, str] = {}
        provenance: Dict[str, Dict[str, Any]] = {}
        source_by_index: Dict[int, str] = {}
        for source_label, entry in (
            (PROMPT_SOURCE_SITE, site_overrides.get(key)),
            (PROMPT_SOURCE_ZOOM, zoom_overrides.get(key)),  # 나중 적용 = 우선
        ):
            if not entry:
                continue
            for vi, prompt in (entry.get("revised") or {}).items():
                vi = int(vi)
                revised[vi] = prompt
                source_by_index[vi] = source_label
                prov = (entry.get("provenance") or {}).get(str(vi))
                if prov is not None:
                    provenance[str(vi)] = prov
        if not revised:
            continue
        zoom_entry = zoom_overrides.get(key) or {}
        site_entry = site_overrides.get(key) or {}
        first_idx = min(revised)
        merged[key] = {
            "revised": revised,
            "provenance": provenance,
            "group_id": zoom_entry.get("group_id") or site_entry.get("group_id"),
            "prompt_source": source_by_index[first_idx],
            "prompt_source_by_index": source_by_index,
        }
    return merged


# ─────────────────────────── manifest ───────────────────────────


def build_manifest(
    groups: List[Dict[str, Any]],
    prompt_overrides: Dict[str, Dict[str, Any]],
    diagnostics: Dict[str, Any],
) -> Dict[str, Any]:
    return {
        "schema_version": SCHEMA_VERSION,
        "groups": groups,
        "prompt_overrides": prompt_overrides,
        "diagnostics": diagnostics,
    }


def validate_site_manifest(
    manifest: Dict[str, Any],
    selected_map: Dict[int, Set[int]],
) -> List[str]:
    """조인 무결성만 — 의미 게이트 없음. override key 는 멤버 shot 이어야 한다."""
    violations: List[str] = []
    member_keys: Set[str] = set()
    for g in manifest.get("groups") or []:
        if not g.get("location_id"):
            violations.append("group without location_id")
        for si, shi in (tuple(m) for m in g.get("member_shots") or []):
            if not isinstance(si, int) or not isinstance(shi, int):
                violations.append(f"{g.get('location_id')}: invalid member shot {(si, shi)!r}")
                continue
            if shi not in selected_map.get(si, set()):
                violations.append(
                    f"{g.get('location_id')}: member S{si}sh{shi} not a selected shot"
                )
            member_keys.add(shot_hint_key(si, shi))
    for key in manifest.get("prompt_overrides") or {}:
        if key not in member_keys:
            violations.append(f"prompt_overrides[{key}] is not a group member shot")
    return violations
