"""W21B-W8 (2026-06-12; 2026-06-13 구도 가이드 재배선) — outdoor_site_layout
LLM/이미지 IO boundary.

site-layout spike 의 production 포팅 — 단 spike 의 layout schema(특정 지형
enum)는 scenario-specific 이라 폐기하고 generic landmarks/figures/cameras
schema 로 대체한다.

  - ``emit_site_layout`` — LLM1: 같은 outdoor location 의 멤버 샷들 + 씬 원문
    전체에서 sparse top-down 좌표(0–100) emit. 좌표 검증은 plan 의
    shape/범위 validator (의미 게이트 없음).
  - ``rewrite_position_phrases`` — LLM2: deterministic 공간 요약을 근거로
    위치/깊이/상대크기/카메라거리 구절만 최소 보정 (Codex ⓑ 계약 — entity ID
    불변, edited_spans + no_edit_reason 출력, 카메라워크 언어 금지).
  - ``generate_composition_brief`` — 구도 가이드 ①: scene action + SCENE
    GEOGRAPHY → 요소포함 구도 브리프 텍스트 (gemini text).
  - ``generate_composition_sketch`` — 구도 가이드 ②: 브리프 → 마네킹+Loomis
    스케치 PNG (gpt-image-2 T2I). 최종 i2i still(③)은 이 모듈 밖(coordinator)
    에서 production 이미지 모델(nb2)로 — 변경하지 않는다.

규칙 (CLAUDE.md / 절대 규칙):
  - 입력 씬 텍스트는 절대 자르지 않는다 — 원문 전체 전달.
  - 프롬프트에 작품 고유명사/특정 시나리오 토큰 0 (요소는 데이터/LLM 으로만).
  - 출력 품질은 deterministic 테스트 비대상 — canary + 육안 gate.

LLM1/LLM2 모델 라우팅은 ``call_structured(step="outdoor_site_layout")`` —
manifest default_model(gpt=gpt-5.5) + project_config override 를 따른다. 구도
브리프/스케치 모델은 step 이 config(outdoor_composition_brief_model,
outdoor_composition_guide_model)에서 주입한다.
"""
from __future__ import annotations

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

from app.services.image_capture.sink import capture_artifact

logger = logging.getLogger(__name__)

PROVIDER_VERSION: str = "outdoor_site_layout_v1"
PROMPT_VERSION: str = "1.202606170100"
# Phase II shared-model 카메라 가이드 producer 버전 (config_hash 에 ON 일 때만 접힌다).
# v2(2026-06-29 재배선) — PIL birdseye edit → 라벨 마커 항공뷰(T2I base + I2I 블로킹 +
# I2I 스케치) 파이프라인. base 캐시 key / manifest base_prompt_version 구성요소이기도 함.
# v4 (2026-07-03 W-A): SITE_LAYOUT faces_toward 방향성 필드 + brief 개방면 방향구 +
# 스케치 SYSTEM 강문 3종(방향/스케일앵커/발명억제) — 캐시/manifest invalidation.
SHARED_MODEL_GUIDE_VERSION: str = "shared_model_aerial_v4.202607031200"
# 3-stage 필수화(2026-07-02): seed 확대(figure0/primary_location fallback)로 한
# location 그룹이 4-5샷(씬 3-4개 전문)까지 커진다 — layout 출력(landmarks+샷별
# figures/cameras/spatial_summaries)이 4000 을 초과하면 length cutoff 가
# "LLM returned empty response" 로 나타나 그룹 전체가 드롭된다(E2E L03 3-tier
# 전멸 2/3 재현). 씬 텍스트 절단은 금지 규칙 — 출력 예산을 그룹 규모에 맞춘다.
LAYOUT_MAX_TOKENS: int = 16000
REWRITE_MAX_TOKENS: int = 6000

_STEP = "outdoor_site_layout"

# ───────────────────────── schemas (strict) ─────────────────────────

_POINT_SCHEMA = {
    "type": "array",
    "items": {"type": "number"},
    "minItems": 2,
    "maxItems": 2,
    "description": "[x, y] normalized 0-100",
}

SITE_LAYOUT_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "landmarks": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "id": {"type": "string"},
                    "label": {
                        "type": "string",
                        "description": "short English label taken from the scene "
                                       "text (e.g. the road, the shoreline, a shelter)",
                    },
                    "kind": {"type": "string", "enum": ["point", "line", "area"]},
                    "points": {
                        "type": "array",
                        "items": _POINT_SCHEMA,
                        "description": "1 point for kind=point, 2+ for line, 3+ for area",
                    },
                    "faces_toward": {
                        "anyOf": [_POINT_SCHEMA, {"type": "null"}],
                        "description": "if this structure has ONE open/front side whose "
                                       "orientation the scene text states or its function "
                                       "makes unambiguous (an open-fronted structure faces "
                                       "what it serves; a doorway opens onto its approach): "
                                       "a point that open/front side faces toward. "
                                       "null when uncertain — never guess.",
                    },
                },
                "required": ["id", "label", "kind", "points", "faces_toward"],
                "additionalProperties": False,
            },
        },
        "figures": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "figure_id": {"type": "string"},
                    "label": {
                        "type": "string",
                        "description": "the figure's name exactly as written in the staging",
                    },
                    "entity_token": {
                        "type": ["string", "null"],
                        "description": "the figure's entity ID token (like C01) if one "
                                       "appears in the provided staging, else null. Never invent one.",
                    },
                    "positions": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "shot_index": {"type": "integer"},
                                "pos": _POINT_SCHEMA,
                                "moving_toward": {
                                    "anyOf": [_POINT_SCHEMA, {"type": "null"}],
                                    "description": "if the staging says this figure is "
                                                   "moving at this moment: a point they move "
                                                   "toward. null if stationary.",
                                },
                            },
                            "required": ["shot_index", "pos", "moving_toward"],
                            "additionalProperties": False,
                        },
                    },
                },
                "required": ["figure_id", "label", "entity_token", "positions"],
                "additionalProperties": False,
            },
        },
        "cameras": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "shot_index": {"type": "integer"},
                    "pos": _POINT_SCHEMA,
                    "look_at": _POINT_SCHEMA,
                },
                "required": ["shot_index", "pos", "look_at"],
                "additionalProperties": False,
            },
        },
    },
    "required": ["landmarks", "figures", "cameras"],
    "additionalProperties": False,
}

REWRITE_RESULT_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "revised_prompts": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "variation_index": {"type": "integer"},
                    "revised_prompt": {"type": "string"},
                    "edited_spans": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "original": {"type": "string"},
                                "revised": {"type": "string"},
                            },
                            "required": ["original", "revised"],
                            "additionalProperties": False,
                        },
                        "description": "each placement phrase you changed: the original "
                                       "span and what it became. Empty if unchanged.",
                    },
                    "no_edit_reason": {
                        "type": ["string", "null"],
                        "description": "if you changed nothing, why the original already "
                                       "matches the spatial summary. null when edits were made.",
                    },
                },
                "required": [
                    "variation_index", "revised_prompt", "edited_spans", "no_edit_reason",
                ],
                "additionalProperties": False,
            },
        },
    },
    "required": ["revised_prompts"],
    "additionalProperties": False,
}

# ───────────────────────── prompts (generic — no scenario tokens) ─────────────────────────

SITE_LAYOUT_SYSTEM = (
    "You are a film location planner. Several shots take place in the SAME outdoor "
    "location. From the scene text(s) (source of truth) and each shot's staging, emit a "
    "SPARSE top-down site layout as JSON with normalized coordinates 0-100 (pick any "
    "consistent orientation and keep it for everything).\n"
    "Rules:\n"
    "- landmarks: ONLY the large spatial anchors that the scene text itself establishes "
    "(paths, water, structures, terrain edges...) — no props, no decoration, no invention.\n"
    "- faces_toward: for a structure whose open or front side is stated in the text or "
    "unambiguous from its function (an open-fronted structure faces what it serves; a "
    "doorway opens onto its approach path), give a point that side faces toward. When "
    "the orientation is not clearly established, use null — NEVER guess.\n"
    "- figures: every person the staging places in these shots, one entry per person, "
    "with a position per shot. If the staging says a figure is moving at that moment, "
    "give a moving_toward point consistent with the text; otherwise null.\n"
    "- cameras: one entry per shot, position + look_at consistent with that shot's "
    "staging (camera_direction / frame_spatial_contract). The camera MUST reproduce "
    "the staged screen zones: a figure whose staging puts it on the left of the frame "
    "(screen_zone *_left) must fall on the LEFT of the frame as seen from your camera "
    "position and look_at, and likewise for the right.\n"
    "- The same physical place must keep the same coordinates across shots — figures "
    "and cameras move, the place does not.\n"
    "- A figure who is DEPARTING (their moving_toward leads away from the other "
    "figures) must already be plotted FARTHER from each camera than the stationary "
    "figures are — the stills must read them as receding into the scene, never as a "
    "large foreground subject, even if the staging text frames the camera near them.\n"
    "- Make distances meaningful: a figure described as far away must be plotted far "
    "from the camera; a figure right next to something must be plotted next to it."
)

# Codex ⓑ 계약 + W-C1 S29 규칙 승계 (원본에 없는 인물 추가 금지) + 카메라워크
# 언어 금지 (still 구도 표현만).
# 1차 canary 피드백 2건 반영 (2026-06-13, 사용자 육안 — spike v2 성공 구절 대조):
#   ⓐ near 인물에 "nearest foreground" 류 확대 구절을 삽입하면 i2i 가 env ref 와
#      합성하며 거대 반투명 고스트로 렌더 → FARTHER/이동 인물 구절만 보정,
#      nearest 인물 구절은 무수정 (상대 크기는 먼 쪽 축소만으로 성립).
#   ⓑ "away from the camera" 카메라 상대 동사는 env ref 기하와 결합해 임의 방향
#      (바다 쪽 등)으로 해석됨 → 위치 진술("already partway along its stated
#      path, deeper, smaller")로 표현, 경로 표현은 원문 landmark 구절 유지.
REWRITE_POSITION_SYSTEM = (
    "You revise T2I still-image prompts for ONE shot so that figure placement matches a "
    "SPATIAL SUMMARY computed from the production's site-layout coordinates. The summary "
    "is the source of truth for figure depth, relative size and camera distance.\n"
    "Rewrite ONLY the placement phrases of figures the summary marks as FARTHER than "
    "the nearest figure or as moving. Rewrite those minimally so each such figure reads "
    "as ALREADY at its summary position: already partway along the path the geography "
    "states, deeper in the frame, visibly smaller than the nearer figure, with the "
    "movement direction written out in the geography's terms (which frame side their "
    "path leads to, what it follows, what it moves away from, and what it does NOT "
    "head into). You may also insert ONE short scene-geography sentence taken from the "
    "summary (how the main path runs across the frame and where the key landmarks "
    "sit), placed before the figures are described. Everything else — the nearest "
    "figure's existing phrases, camera position and angle, framing, lens feel, "
    "lighting, mood, environment description, character appearance, gaze and actions "
    "— stays AS-IS.\n"
    "Hard rules:\n"
    "- The figure the summary marks as NEAREST keeps its size, depth, prominence, "
    "gaze and action wording exactly as the original — do not shrink it, push it "
    "deeper, or add foreground/size wording to it (the ONLY change ever allowed on the "
    "nearest figure is the left/right lateral correction described below, which never "
    "alters its size or depth). Never describe any figure as small/tiny/deep "
    "unless the summary marks that figure as farther or moving, and never add wording "
    "like 'in the foreground', 'nearest to the camera' or 'larger' to any figure — "
    "relative size is expressed only by making the farther figure smaller and deeper.\n"
    "- Express distance as a position already reached, never as motion relative to the "
    "camera: do not write 'toward the camera' or 'away from the camera'. Keep the "
    "movement's destination/path exactly as the original prompt states it (the same "
    "road, doorway, etc.).\n"
    "- This is a single STILL frame: never use camera-movement language (tracking, "
    "dolly, panning, zooming, following...).\n"
    "- LATERAL placement: each figure must occupy the frame side (left / center / "
    "right) that the summary gives for THAT figure. If the original wording places a "
    "figure on a different side — including an orientation or facing phrase (which way "
    "the figure is turned, e.g. a three-quarter angle) that could be misread as a "
    "screen position — correct that figure's stated screen side to match the summary, "
    "while preserving its facing/orientation, gaze and every other aspect of its "
    "wording. An orientation/angle phrase describes how a figure is turned, NOT which "
    "side of the frame it stands on; the frame side comes only from the summary. This "
    "left/right correction also applies to the nearest figure (it changes only the "
    "side of the frame, never its size, depth or prominence).\n"
    "- Do NOT add new characters, events or locations. Do NOT add any person who is not "
    "already present in the original prompt — not by entity ID, not by name, not by "
    "plain-text description.\n"
    "- Do NOT introduce or remove entity ID tokens (like C01, P02, L03): the exact set "
    "of ID tokens in each revised prompt must equal the original's.\n"
    "- If an original prompt already matches the summary, return it unchanged with a "
    "no_edit_reason.\n"
    "- Revise EVERY prompt given, keeping its variation_index, and list each changed "
    "span in edited_spans."
)


# ───────────────────────── user prompt assembly ─────────────────────────


def build_site_layout_user_prompt(
    scene_texts: List[Tuple[int, str]],
    member_shots: List[Dict[str, Any]],
    constraint_feedback: Optional[str] = None,
) -> str:
    """scene_texts: [(scene_index, 원문 전체)] — 자르지 않는다.
    member_shots: [{scene_index, shot_index, description, staging}].
    constraint_feedback: retry 시 이전 시도의 staged-side 충돌 목록 (deterministic
    검증 결과 — caller 가 조립)."""
    parts: List[str] = []
    for si, text in scene_texts:
        parts.append(f"SCENE {si} TEXT (full):\n{text}\n")
    for shot in member_shots:
        parts.append(
            f"SHOT scene {shot.get('scene_index')} / shot_index {shot.get('shot_index')}"
        )
        if shot.get("description"):
            parts.append("description: " + str(shot["description"]))
        if shot.get("staging") is not None:
            parts.append("staging: " + json.dumps(shot["staging"], ensure_ascii=False))
        parts.append("")
    if constraint_feedback:
        parts.append(
            "PREVIOUS ATTEMPT VIOLATED STAGED SCREEN ZONES — fix the camera "
            "positions/orientations (or figure positions, staying consistent with the "
            "scene text) so each staged side is reproduced:\n" + constraint_feedback
        )
    return "\n".join(parts)


def build_rewrite_user_prompt(
    variations: List[Tuple[int, str]],
    spatial_summary: str,
) -> str:
    parts = ["ORIGINAL T2I PROMPTS:"]
    for idx, prompt in variations:
        parts.append(f"[variation_index {idx}]\n{prompt}")
    parts.append("\n" + spatial_summary)
    return "\n".join(parts)


# ──────────── composition guide (3-모델: 브리프→마네킹 스케치) ────────────
#
# spike(~/tmp/geom_osl_compath, 육안 채택본 = 마네킹+Loomis) 의 production 포팅:
#   ① 요소포함 구도 브리프 (gemini-3.1-pro, text) — geometry 에서 인물 배향과
#      환경요소 화면배치를 도출 (요소명은 데이터에서 LLM 이 고름 — 하드코딩 0).
#   ② 마네킹+Loomis 스케치 (gpt-image-2, T2I) — 관절 마네킹으로 포즈, Loomis
#      머리로 머리방향을 명시 (실루엣과 달리 facing 을 신뢰 가능하게 전달).
#   ③ 최종 i2i still 은 step/coordinator 가 nb2 로 — 이 모듈 밖.

# {body} = 해당 샷의 scene action(primary t2i 본문), {geo} = SCENE GEOGRAPHY 전문.
# 둘 다 자르지 않는다. 인물은 역할어로만, 요소는 geography 에서 LLM 이 선택.
COMPOSITION_BRIEF_INSTRUCTION = (
    "You are a storyboard supervisor composing ONE film frame. From the scene action "
    "and the spatial geography below, write a SHORT shot-composition brief for a rough "
    "storyboard sketch. Cover TWO things:\n"
    "(1) PEOPLE — for each person: screen position (left/center/right), depth "
    "(foreground / midground / background), relative size, and how their head and body "
    "are oriented relative to the camera. DERIVE each orientation only from the "
    "geometry of whom they look at or move toward and where that target sits "
    "(a person attending to a target deeper in the frame turns away from the lens; a "
    "person attending to something toward the camera faces the lens).\n"
    "(2) ENVIRONMENT — list the salient built/natural FEATURES that are visible in "
    "this shot (the kind named in the geography), each with its screen placement and "
    "depth, so the sketch can actually include them (do not omit a structure the "
    "camera would see).\n"
    "Refer to people only by role (e.g. the one staying, the one leaving). No colors, "
    "no mood, no proper names — layout, elements and facing only. At most 5 sentences.\n\n"
    "SCENE ACTION:\n{body}\n\n"
    "SPATIAL GEOGRAPHY (source of truth for positions, depth, movement and which "
    "features exist):\n{geo}"
)

# 인물 = 관절 마네킹(포즈 명확) + Loomis 머리(머리방향 명시), 환경 = 라인아트.
_SKETCH_TECH = (
    "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) so the body POSE, stance and limb positions are completely "
    "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 "
    "wrapping it — so the head's facing direction is explicit. Keep the surrounding "
    "ENVIRONMENT as clean thin line-art (not mannequin)."
)

# {brief} = ① 의 출력. 와이어프레임/단순 OK, 단 브리프의 요소 전부 포함.
COMPOSITION_SKETCH_PROMPT = (
    "A rough black-and-white STORYBOARD SKETCH of a single cinematic film frame "
    "(eye-level). Composition and ELEMENTS only — include EVERY feature and person "
    "described below at its stated screen position, depth and relative size; do not "
    "omit any structure. No color, no lettering, no labels. " + _SKETCH_TECH + "\n"
    "The ground/path recedes into the distance with clear perspective.\n\n"
    "FRAME TO SKETCH:\n{brief}"
)


def generate_composition_brief(
    scene_action: str,
    spatial_summary: str,
    *,
    model: str,
    log_context: Optional[Dict[str, Any]] = None,
) -> str:
    """① scene action + SCENE GEOGRAPHY → 요소포함 구도 브리프 텍스트 (LLM 1콜)."""
    from app.modules.llm.gemini_text_client import GeminiTextClient

    client = GeminiTextClient(model=model)
    if log_context:
        client.set_context(**log_context)
    out = client.send(
        COMPOSITION_BRIEF_INSTRUCTION.format(body=scene_action, geo=spatial_summary),
        temperature=0.2,
    )
    return out if isinstance(out, str) else str(out)


def generate_composition_sketch(
    brief: str,
    *,
    openai_client: Any,
    model: str,
    size: str = "1536x1024",
) -> bytes:
    """② 구도 브리프 → 마네킹+Loomis 스케치 PNG (gpt-image T2I 1콜)."""
    from app.modules.pipeline.location_floor_plan import generate_floor_plan_image

    return generate_floor_plan_image(
        prompt=COMPOSITION_SKETCH_PROMPT.format(brief=brief),
        openai_client=openai_client,
        model=model,
        size=size,
    )


# ──────────── figure pose brief (P1 2026-07-01, shared-model v2 Stage C) ────────────
#
# shared-model v2 스케치(generate_shared_model_camera_guide)는 compute_camera_brief(좌표
# 만, "featureless markers")로만 인물을 서술 → 씬 액션(포즈/동작)을 드롭 → generic 서있는
# 마네킹으로 최종을 오염(무-가이드보다 나쁨, S15 HARM). 복구 = scene action(무절단)에서
# 인물별 자세/동작/배향을 structured(evidence-backed) 추출해 Stage C posed-mannequin
# 스케치의 pose SOT 로 운반. legacy generate_composition_brief(orientation/framing only,
# OFF 경로)는 미변경·byte-identical. 이 브리프는 v2 Stage C 에서만 소비.
POSE_BRIEF_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "figures": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "slot": {
                        "type": "string",
                        "description": "generic role label for this person in the shot, "
                                       "e.g. 'the seated one', 'the one leaving'. NO proper "
                                       "names.",
                    },
                    "body_posture": {
                        "type": "string",
                        "enum": [
                            "standing", "sitting", "crouching", "kneeling", "lying",
                            "leaning", "squatting", "walking", "running", "bending",
                            "unknown",
                        ],
                    },
                    "limb_action": {
                        "type": "string",
                        "description": "what the arms/hands/body are doing (reaching, "
                                       "holding, pushing, pointing, covering the face, arms "
                                       "crossed, bracing, hands at sides). Generic action "
                                       "words only, NO specific object names. 'none' if "
                                       "nothing stated.",
                    },
                    "head_body_orientation": {
                        "type": "string",
                        "description": "which way the head and torso face relative to the "
                                       "camera, derived from whom/what they attend to or "
                                       "move toward.",
                    },
                    "contact_or_support": {
                        "type": "string",
                        "description": "what the body rests on or touches, as a GENERIC "
                                       "class: ground, seat, wall, railing, none.",
                    },
                    "interaction_target_role": {
                        "type": "string",
                        "description": "generic role of what they interact with: 'another "
                                       "figure', 'a nearby object', 'a surface', 'a held "
                                       "object', or 'none'. NO specific prop or person "
                                       "names.",
                    },
                    "evidence_quote": {
                        "type": "string",
                        "description": "short verbatim words from the scene action that "
                                       "justify this pose.",
                    },
                    "confidence": {"type": "string", "enum": ["low", "medium", "high"]},
                },
                "required": [
                    "slot", "body_posture", "limb_action", "head_body_orientation",
                    "contact_or_support", "interaction_target_role", "evidence_quote",
                    "confidence",
                ],
                "additionalProperties": False,
            },
        },
    },
    "required": ["figures"],
    "additionalProperties": False,
}

POSE_BRIEF_SYSTEM = (
    "You extract ONLY the BODY POSE and ACTION of each person in ONE film frame, so an "
    "artist can draw them as posed mannequins. Work solely from the scene action and the "
    "spatial geography given. For EACH person actually present in this shot, report their "
    "body_posture (standing / sitting / crouching / kneeling / lying / leaning / squatting "
    "/ walking / running / bending — pick what the action implies; use 'unknown' ONLY if "
    "the text gives no posture cue at all), limb_action (what the arms, hands and body are "
    "doing), head_body_orientation (which way the head and torso face relative to the "
    "camera, derived from whom or what they attend to or move toward — a person attending "
    "to a target deeper in the frame turns away from the lens; toward the camera faces the "
    "lens), contact_or_support (what the body rests on or touches, as a GENERIC class) and "
    "interaction_target_role (the GENERIC role of what they interact with).\n"
    "Refer to people ONLY by a generic role slot. Extract POSE and ACTION only — NEVER "
    "output clothing, colours, facial features, mood or emotion words, proper names, "
    "specific object or place names, camera or lens talk, or environment description. Put "
    "the short verbatim words that justify each pose in evidence_quote and set confidence "
    "honestly. If the scene action does not describe a person's posture, set "
    "body_posture='unknown' and confidence='low'. Report only people actually in THIS "
    "shot; if none, return an empty figures list."
)

# {body} = 해당 샷 primary t2i 본문(scene action, 무절단), {geo} = spatial geography(무절단).
POSE_BRIEF_INSTRUCTION = (
    "SCENE ACTION (verbatim — do not skip anyone or anything):\n{body}\n\n"
    "SPATIAL GEOGRAPHY (who is where / who moves toward what — use ONLY to derive each "
    "figure's orientation):\n{geo}"
)


def generate_pose_brief(
    scene_action: str,
    spatial_summary: str,
    *,
    model: str,
    log_context: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """P1(2026-07-01) — scene action(무절단) + geography → 인물별 자세/동작/배향 structured
    brief (gemini text, evidence-backed). shared-model v2 Stage C 가 포즈를 드롭해 generic
    서있는 마네킹으로 최종을 오염하던 회귀 복구: camera_brief(좌표/프레이밍)와 병행해 pose
    SOT 를 운반한다. legacy generate_composition_brief(orientation/framing only, OFF 경로)와
    는 별개·미변경. 반환 = POSE_BRIEF_SCHEMA dict({figures:[...]}); 렌더/fail-closed 판정은
    step(render_pose_brief_text)."""
    from app.modules.llm.gemini_text_client import GeminiTextClient

    client = GeminiTextClient(model=model)
    if log_context:
        client.set_context(**log_context)
    out = client.send(
        POSE_BRIEF_INSTRUCTION.format(body=scene_action, geo=spatial_summary),
        response_schema=POSE_BRIEF_SCHEMA,
        schema_name="figure_pose_brief",
        system_instruction=POSE_BRIEF_SYSTEM,
        temperature=0.2,
    )
    return out if isinstance(out, dict) else {"figures": []}


# ──────────── shared-model 카메라 가이드 (Phase II) ────────────
#
# 현 producer(generate_composition_sketch)는 brief→독립 스케치라 gpt-image-2 가
# 환경 구조물(셸터 등)을 발명해 nb2 still 의 장소 정체성을 덮어쓴다(§4e-B R1).
# shared-model producer 는 **하나의 site-layout master(좌표)에서 카메라뷰 가이드를
# 파생**한다. ★Codex Q2: master 는 좌표/폴리곤일 뿐 실제 디자인 SOT 가 아니므로,
# 가이드는 layout/geometry-only — 재질/양식/지붕/장식을 정의하지 않고 layout·depth·
# figure 배치·구조물 외곽 envelope 만 고정한다. 디자인 상속은 chaining + bg ref 채널
# (이 모듈 밖). 시나리오 토큰 0.
#
# v2(2026-06-29 재배선): PIL birdseye→edit 경로(literal 좌표 렌더의 품질 노출)를
# 라벨 마커 항공뷰 파이프라인으로 교체 — A) generate_location_aerial_base(T2I, 환경=
# 원형 숫자, group 공통) → B) inject_shot_blocking(I2I, 엔티티=원형 글자 + 카메라,
# 조건부) → C) generate_shared_model_camera_guide(I2I, 블로킹 or base + 좌표 산술
# camera brief → eye-level 라인아트). 프롬프트 빌더는 plan.py(generic). 아래 PIL
# 경로(_render_clean_birdseye_png) + SHARED_MODEL_CAMVIEW_SYSTEM 은 **LEGACY**(v2
# 미사용, 삭제 금지 — 회귀/다른 소비 참조).


def _sha16(text: str) -> str:
    import hashlib
    return hashlib.sha256((text or "").encode("utf-8")).hexdigest()[:16]


def _png_sha16(data: bytes) -> str:
    import hashlib
    return hashlib.sha256(data or b"").hexdigest()[:16]


def _t2i_image(
    prompt: str, *, openai_client: Any, model: str, size: str,
    capture_role: str = "floor_plan_image",
    capture_extra_metadata: Optional[Dict[str, Any]] = None,
) -> bytes:
    """ref 없는 T2I (Stage A base). capture_role/extra_metadata 는 Phase C scope 배선용
    (default 면 byte-identical)."""
    from app.modules.pipeline.location_floor_plan import generate_floor_plan_image

    return generate_floor_plan_image(
        prompt=prompt, openai_client=openai_client, model=model, size=size,
        capture_role=capture_role, capture_extra_metadata=capture_extra_metadata)


def _edit_image_with_ref(
    prompt: str, ref_png: bytes, *, openai_client: Any, model: str, size: str,
    capture_role: str = "floor_plan_image",
    capture_extra_metadata: Optional[Dict[str, Any]] = None,
) -> bytes:
    """ref 1장 I2I edit (Stage B/C) — ref_png 를 임시파일로 쓰고 generate_floor_plan_image."""
    import tempfile
    from pathlib import Path

    from app.modules.pipeline.location_floor_plan import generate_floor_plan_image

    tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
    try:
        tmp.write(ref_png)
        tmp.flush()
        tmp.close()
        return generate_floor_plan_image(
            prompt=prompt, openai_client=openai_client,
            ref_paths=[Path(tmp.name)], model=model, size=size,
            capture_role=capture_role, capture_extra_metadata=capture_extra_metadata)
    finally:
        try:
            Path(tmp.name).unlink()
        except OSError:
            pass


def generate_location_aerial_base(
    layout: Dict[str, Any], *, openai_client: Any, model: str, size: str = "1024x1024",
    capture_extra_metadata: Optional[Dict[str, Any]] = None,
    building_fp_png: Optional[bytes] = None,
) -> Tuple[bytes, Dict[str, Any]]:
    """Stage A — location 공통 빈 항공뷰 base PNG (T2I, 환경=원형 숫자, 엔티티/카메라
    제외). step 이 group 단위로 1회 생성/캐싱해 모든 샷에 공유한다(set 일관성).
    실패는 raise (호출자가 no-guide + diagnostic).

    Phase C: ``capture_extra_metadata``(group_id/location_id)가 주어지면 producer_stage/
    parent_stage 를 덧붙여 capture(scope 미배선이면 no-op, default None=byte-identical).

    W-G: ``building_fp_png``(같은 building 그룹 indoor floor plan PNG)가 주어지면
    T2I→I2I(fp ref) 승격 + 정합 지시(BUILDING_FP_AERIAL_GUIDANCE) — 건물 footprint/
    개구부가 fp 와 정합된 base 를 만들고 blocking/sketch 체인이 그대로 상속한다.
    None(default) = 기존 T2I 경로 byte-identical."""
    from app.modules.pipeline.outdoor_site_layout_plan import (
        BUILDING_FP_AERIAL_GUIDANCE,
        build_aerial_base_prompt,
    )

    prompt = build_aerial_base_prompt(layout)
    cap_meta = {**(capture_extra_metadata or {}),
                "producer_stage": "aerial_base", "parent_stage": None}
    if building_fp_png:
        prompt = prompt + BUILDING_FP_AERIAL_GUIDANCE
        png = _edit_image_with_ref(
            prompt, building_fp_png, openai_client=openai_client, model=model,
            size=size, capture_role="outdoor_aerial_base",
            capture_extra_metadata=cap_meta)
    else:
        png = _t2i_image(
            prompt, openai_client=openai_client, model=model, size=size,
            capture_role="outdoor_aerial_base", capture_extra_metadata=cap_meta)
    meta = {
        "base_prompt": prompt,
        "base_prompt_hash": _sha16(prompt),
        "base_png_hash": _png_sha16(png),
        "base_prompt_version": SHARED_MODEL_GUIDE_VERSION,
        "building_fp_used": bool(building_fp_png),
    }
    return png, meta


def inject_shot_blocking(
    base_png: bytes, layout: Dict[str, Any], shot_index: int, *,
    openai_client: Any, model: str, size: str = "1024x1024",
    capture_extra_metadata: Optional[Dict[str, Any]] = None,
) -> Tuple[bytes, Dict[str, Any]]:
    """Stage B — 공통 base 위 I2I 블로킹: 이 샷의 엔티티=원형 글자 + 카메라 1개 주입,
    base set 보존. 실패는 raise. 조건부 호출(단순 구도는 step 이 생략하고 base→C 직행).

    Phase C: parent_stage=aerial_base (base 위 I2I). default None=byte-identical."""
    from app.modules.pipeline.outdoor_site_layout_plan import build_shot_blocking_prompt

    camera = next(
        (c for c in layout.get("cameras") or [] if c.get("shot_index") == shot_index),
        None,
    )
    if camera is None:
        raise RuntimeError(
            f"inject_shot_blocking: no camera for shot_index={shot_index}")
    prompt = build_shot_blocking_prompt(layout, camera, shot_index)
    cap_meta = {**(capture_extra_metadata or {}),
                "producer_stage": "shot_blocking", "parent_stage": "aerial_base",
                "shot_index": shot_index}
    png = _edit_image_with_ref(
        prompt, base_png, openai_client=openai_client, model=model, size=size,
        capture_role="outdoor_shot_blocking", capture_extra_metadata=cap_meta)
    return png, {
        "blocking_prompt": prompt,
        "blocking_prompt_hash": _sha16(prompt),
        "blocking_png_hash": _png_sha16(png),
    }


# LEGACY (v2 미사용, 삭제 금지) — PIL birdseye edit 경로의 카메라뷰 프롬프트.
SHARED_MODEL_CAMVIEW_SYSTEM = (
    "The attached image is a flat TOP-DOWN SET MAP (a bird's-eye plan) of one single "
    "outdoor location, drawn only as plain coloured shapes, a small green camera wedge "
    "and red figure dots seen from straight above. It has NO words on it. Use it ONLY "
    "as a spatial reference for WHERE the structures, ground areas and figures are and "
    "how they are laid out relative to the camera. Do NOT keep the top-down look and do "
    "NOT copy the flat map shapes.\n\n"
    "Produce ONE rough black-and-white STORYBOARD control sketch: the EYE-LEVEL "
    "PERSPECTIVE camera view from the marked camera, drawn as clean thin pencil/ink "
    "line-art outlines (an animation layout sheet — NOT a photograph, NOT 3D, no "
    "shading, no textures, white paper). Draw any people as featureless wooden artist "
    "mannequins (ball joints, no face, no hair, no clothing) that only mark position, "
    "depth and rough scale.\n\n"
    "This sketch fixes LAYOUT and GEOMETRY ONLY: copy the spatial layout, the depth "
    "ordering, the figures' placement and the rough outer ENVELOPE/footprint of the "
    "major structures and ground areas. Do NOT decide or draw any material, surface "
    "texture, roof or structural style, ornament or architectural design — leave every "
    "structure as a plain blank outline. ABSOLUTELY NO text, letters, numbers, labels, "
    "arrows or signatures anywhere in the image."
)


def build_shared_model_guide_prompt(camera_brief: str) -> str:
    """LEGACY (v2 미사용, 삭제 금지) — PIL clean birdseye edit 용 카메라뷰 프롬프트.
    v2 는 plan.build_blocking_sketch_prompt (라벨 마커 블로킹 ref) 를 쓴다."""
    return (
        SHARED_MODEL_CAMVIEW_SYSTEM + "\n\n"
        "Camera-view brief (computed from the map coordinates — use it to place the "
        "structures and figures by depth and screen position):\n\n"
        + (camera_brief or "") + "\n\n"
        "Reminder: plain blank structure outlines only — no material, style or design; "
        "figures are featureless mannequin markers; no text or labels anywhere in the image."
    )


_BIRDSEYE_EDGE_FALLBACK = (110, 110, 110)


def _render_clean_birdseye_png(spec: Dict[str, Any], *, scale: int = 7) -> bytes:
    """LEGACY (v2 미사용, 삭제 금지) — birdseye spec → top-down set-map PNG (PIL, 결정론).
    literal 좌표 렌더라 좌표 품질을 그대로 노출(셸터가 도로 위)해 v2 에서 T2I 라벨
    마커 항공뷰(generate_location_aerial_base)로 교체됨. 회귀/다른 소비 참조용 보존.

    모델 입력 전용 — **글자/라벨/숫자 0** (색 도형 + 카메라 wedge + figure dot 만).
    spec 이 색/도형/좌표만 담으므로 이 렌더는 텍스트를 그리지 않는다(by construction)."""
    from io import BytesIO

    from PIL import Image, ImageDraw

    rng = spec.get("coord_range") or [0.0, 100.0]
    lo, hi = float(rng[0]), float(rng[1])
    pad = 20
    side = int((hi - lo) * scale + pad * 2)
    im = Image.new("RGB", (side, side), (250, 250, 248))
    dr = ImageDraw.Draw(im, "RGBA")

    def _xy(p: Any) -> Tuple[float, float]:
        return (pad + (float(p[0]) - lo) * scale, pad + (float(p[1]) - lo) * scale)

    for lm in spec.get("landmarks") or []:
        pts = [_xy(p) for p in lm.get("points") or []]
        edge = tuple(lm.get("edge") or _BIRDSEYE_EDGE_FALLBACK)
        shape = lm.get("shape")
        if shape == "line" and len(pts) >= 2:
            dr.line(pts, fill=edge, width=4)
        elif shape == "polygon" and len(pts) >= 3:
            dr.polygon(pts, fill=tuple(lm.get("fill") or (200, 200, 200, 90)),
                       outline=edge)
        elif pts:
            x, y = pts[0]
            dr.ellipse([x - 5, y - 5, x + 5, y + 5], outline=edge, width=2)

    cam_color = tuple(spec.get("camera_color") or (40, 120, 40))
    for cam in spec.get("cameras") or []:
        px, py = _xy(cam["pos"])
        lx, ly = _xy(cam["look_at"])
        ang = math.atan2(ly - py, lx - px)
        length = 26 * scale
        half = math.radians(20)
        a = (px + length * math.cos(ang - half), py + length * math.sin(ang - half))
        b = (px + length * math.cos(ang + half), py + length * math.sin(ang + half))
        dr.polygon([(px, py), a, b],
                   fill=(cam_color[0], cam_color[1], cam_color[2], 55), outline=cam_color)
        dr.line([(px, py), (lx, ly)], fill=cam_color, width=2)
        dr.ellipse([px - 7, py - 7, px + 7, py + 7], fill=cam_color)

    fig_color = tuple(spec.get("figure_color") or (200, 60, 60))
    for fg in spec.get("figures") or []:
        fx, fy = _xy(fg["pos"])
        dr.ellipse([fx - 7, fy - 7, fx + 7, fy + 7], fill=fig_color)
        mt = fg.get("moving_toward")
        if mt:
            mx, my = _xy(mt)
            dr.line([(fx, fy), (mx, my)], fill=fig_color, width=2)

    buf = BytesIO()
    im.save(buf, format="PNG")
    png = buf.getvalue()
    # Phase B: outdoor birdseye set-map capture(비모델 PIL, LEGACY/미호출 — 보강용,
    # scope 미배선이면 no-op).
    capture_artifact(
        png, role="outdoor_birdseye_setmap", pipeline_metadata={"legacy": True}
    )
    return png


def generate_shared_model_camera_guide(
    layout: Dict[str, Any],
    shot_index: int,
    *,
    base_png: bytes,
    use_blocking: bool,
    openai_client: Any,
    model: str,
    size: str = "1536x1024",
    capture_extra_metadata: Optional[Dict[str, Any]] = None,
    pose_brief: Optional[str] = None,
) -> Tuple[bytes, Dict[str, Any]]:
    """Stage C — group 공통 base(Stage A 결과, step 이 캐싱해 전달) 에서 이 anchor 샷의
    카메라뷰 구도 가이드 PNG + meta 를 파생한다 (v2 라벨 마커 항공뷰 파이프라인).

    use_blocking=True  → Stage B(inject_shot_blocking)로 base 위에 엔티티 글자 + 카메라를
                         주입한 블로킹을 ref 로 C (다수 figure/깊이 분리 등 복잡 구도).
    use_blocking=False → base 를 직접 ref 로 C (단순 구도 — figure/카메라뷰는 좌표 산술
                         brief 가 운반; 검증상 brief-only 도 단순 구도엔 충분).
    어느 경로든 최종 스케치는 eye-level 라인아트 + 좌표 산술 brief, 디자인 발명 0,
    diagram 마커(숫자/글자/카메라 아이콘) 누출 금지(프롬프트 가드 + canary 평가항목).
    실패는 raise — 호출자(step)가 no-guide + diagnostic (old sketch fallback 금지).

    반환: (png, {camera_brief, camera_brief_hash, blocking_stage, blocking_prompt_hash,
    blocking_png_hash, sketch_prompt_hash, camera_view_sketch_hash, helper_version})."""
    from app.modules.pipeline.outdoor_site_layout_plan import (
        build_blocking_sketch_prompt,
        compute_camera_brief,
    )

    camera = next(
        (c for c in layout.get("cameras") or [] if c.get("shot_index") == shot_index),
        None,
    )
    if camera is None:
        raise RuntimeError(
            f"shared_model_camera_guide: no camera for shot_index={shot_index}")
    brief = compute_camera_brief(camera, layout)
    if not brief:
        raise RuntimeError(
            "shared_model_camera_guide: camera coords missing for "
            f"shot_index={shot_index}")
    if not base_png:
        raise RuntimeError(
            "shared_model_camera_guide: base_png missing (Stage A 미제공)")

    meta: Dict[str, Any] = {
        "camera_brief": brief,
        "camera_brief_hash": _sha16(brief),
        # P1: Stage C pose SOT — pose_brief 있으면 posed mannequin 경로(자세 복원),
        # 없으면 legacy featureless degrade(step 이 fail-closed 로 대부분 미부착).
        "pose_brief_present": bool(pose_brief and str(pose_brief).strip()),
        "pose_brief_hash": _sha16(pose_brief) if pose_brief else None,
        "helper_version": SHARED_MODEL_GUIDE_VERSION,
    }
    if use_blocking:
        blocking_png, blk_meta = inject_shot_blocking(
            base_png, layout, shot_index, openai_client=openai_client, model=model,
            capture_extra_metadata=capture_extra_metadata)
        ref_png = blocking_png
        meta["blocking_stage"] = "blocking"
        meta["blocking_prompt_hash"] = blk_meta["blocking_prompt_hash"]
        meta["blocking_png_hash"] = blk_meta["blocking_png_hash"]
    else:
        ref_png = base_png
        meta["blocking_stage"] = "skipped_simple"
        meta["blocking_prompt_hash"] = None
        meta["blocking_png_hash"] = None

    sketch_prompt = build_blocking_sketch_prompt(brief, pose_brief)
    # Phase C: Stage C sketch — parent 는 use_blocking 에 따라 shot_blocking|aerial_base.
    sketch_cap_meta = {**(capture_extra_metadata or {}),
                       "producer_stage": "camera_sketch",
                       "parent_stage": "shot_blocking" if use_blocking else "aerial_base",
                       "shot_index": shot_index}
    png = _edit_image_with_ref(
        sketch_prompt, ref_png, openai_client=openai_client, model=model, size=size,
        capture_role="outdoor_camera_sketch", capture_extra_metadata=sketch_cap_meta)
    meta["sketch_prompt"] = sketch_prompt
    meta["sketch_prompt_hash"] = _sha16(sketch_prompt)
    meta["camera_view_sketch_hash"] = _png_sha16(png)
    return png, meta


# ──────────── composition guide 적용성 의미 게이트 (샷레벨 분류기) ────────────
#
# 기하 후보(composition_guide_shot_keys)는 '2인+ & receding mover' 만 본다 —
# 그것만으론 창 너머/문턱/실내 구조물 프레이밍·근접 대치 샷을 못 거른다(좌표는
# 멀어짐을 만족해도 구도는 구조물에 갇힘). 이 LLM 게이트가 generic 공간 class 로
# '열린 외부 departure' 만 통과시킨다 — 시나리오 명사/장소명 규칙 0, evidence-backed.

COMPOSITION_GUIDE_JUDGE_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "is_open_exterior_departure": {"type": "boolean"},
        "confidence": {"type": "string", "enum": ["low", "medium", "high"]},
        "reasoning": {"type": "string"},
        "evidence_quote": {
            "type": "string",
            "description": "a short verbatim quote from the shot text that decides "
                           "the verdict",
        },
    },
    "required": [
        "is_open_exterior_departure", "confidence", "reasoning", "evidence_quote",
    ],
    "additionalProperties": False,
}

COMPOSITION_GUIDE_JUDGE_SYSTEM = (
    "You gate an optional departing-figure composition guide for a single film "
    "shot. The guide should be drawn ONLY for an OPEN-EXTERIOR shot whose frame "
    "has real depth receding away from the camera and where a figure moves off "
    "into that unobstructed distance. It must NOT be drawn when the shot is framed "
    "or bounded by a structural opening or surface (seen through a window or glass "
    "pane, square to a doorway or threshold), set inside or looking into an "
    "enclosed interior, or staged as a confined close-quarters confrontation where "
    "the figures are clustered at one depth. Decide ONLY from this shot's own "
    "camera description, action and geography below — do not assume anything not "
    "stated. Set is_open_exterior_departure true only when the text clearly shows "
    "the open-exterior receding case, and quote the deciding words in "
    "evidence_quote. When the framing is unclear or the text is ambiguous, answer "
    "false with low confidence. Judge by these generic spatial classes only — "
    "never by what the place or the objects are called."
)


def build_composition_guide_judge_prompt(
    scene_action: Optional[str],
    camera_direction: Optional[str],
    spatial_summary: str,
) -> str:
    parts: List[str] = []
    if camera_direction:
        parts.append("CAMERA DESCRIPTION (staging):\n" + str(camera_direction))
    if scene_action:
        parts.append("SHOT ACTION:\n" + str(scene_action))
    if spatial_summary:
        parts.append(str(spatial_summary))
    return "\n\n".join(parts)


def judge_composition_guide_applicable(
    scene_action: Optional[str],
    camera_direction: Optional[str],
    spatial_summary: str,
    *,
    model: str,
    log_context: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """샷레벨 적용성 판정 (gemini text, structured). 반환 = JUDGE_SCHEMA dict."""
    from app.modules.llm.gemini_text_client import GeminiTextClient

    client = GeminiTextClient(model=model)
    if log_context:
        client.set_context(**log_context)
    out = client.send(
        build_composition_guide_judge_prompt(
            scene_action, camera_direction, spatial_summary),
        response_schema=COMPOSITION_GUIDE_JUDGE_SCHEMA,
        schema_name="composition_guide_applicability",
        system_instruction=COMPOSITION_GUIDE_JUDGE_SYSTEM,
        temperature=0.1,
    )
    return out if isinstance(out, dict) else {}


# ──────────── shared-model 가이드 route judge (Phase II v2, 2026-06-29) ────────────
#
# 위 departure judge(G1/G2)는 OFF(legacy) 경로 전용 — 목적이 '열린 외부 departure'로
# 너무 좁다(Codex §6-d). v2 shared-model 경로는 **별도 schema/version 의 route judge**
# 를 쓴다(scaffolding 만 재사용). judge 는 **route 만** 결정한다 — 가이드가 필요한가/
# scope 는 group 인가/confidence·evidence — 셸터 모양·재질·양식 등 시각 디자인은 절대
# 결정하지 않는다. default-deny: confidence low 거나 evidence 비면 deny. 시나리오 명사/
# 장소명 규칙 0 — generic 공간 class(multi-angle/공유 구조물/figure 배치 연속성 등)로만.

SHARED_MODEL_GUIDE_JUDGE_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "needs_shared_model_guide": {"type": "boolean"},
        "decision_type": {
            "type": "string",
            "enum": [
                "cross_shot_continuity", "single_shot_complexity", "both", "no_guide",
            ],
        },
        "confidence": {"type": "string", "enum": ["low", "medium", "high"]},
        "evidence": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "shot_key": {"type": "string"},
                    "source_field": {"type": "string"},
                    "quote": {
                        "type": "string",
                        "description": "verbatim words from that shot's signals/staging "
                                       "that support the route decision",
                    },
                },
                "required": ["shot_key", "source_field", "quote"],
                "additionalProperties": False,
            },
        },
        "reasons": {
            "type": "array",
            "items": {
                "type": "string",
                "enum": [
                    "multi_angle_same_set", "shared_structure_continuity",
                    "figure_placement_continuity", "depth_layering",
                    "occlusion_or_framing_risk", "camera_or_framing_risk",
                    "insufficient_evidence",
                ],
            },
        },
        "guide_scope": {"type": "string", "enum": ["group", "single", "none"]},
        "optical_risk_shot_keys": {
            "type": "array",
            "items": {"type": "string"},
            "description": (
                "shot_key list whose staging/directives frame the subject through a "
                "reflective or transparent surface (reflection, mirror, through-glass) "
                "— those shots must not receive a mannequin sketch; [] when none"),
        },
        "risk_notes": {"type": "string"},
    },
    "required": [
        "needs_shared_model_guide", "decision_type", "confidence", "evidence",
        "reasons", "guide_scope", "optical_risk_shot_keys", "risk_notes",
    ],
    "additionalProperties": False,
}

SHARED_MODEL_GUIDE_JUDGE_SYSTEM = (
    "You ROUTE an optional shared-model composition guide for a GROUP of film shots the "
    "production has placed in the SAME outdoor location. The guide is one shared "
    "top-down set map turned into per-shot eye-level layout sketches; its ONLY purpose "
    "is to keep the SAME set (structures, ground, water, paths) and the figures' "
    "placement CONSISTENT across the group's shots. You decide ROUTING ONLY — whether "
    "such a guide is warranted and at what scope. You do NOT decide any material, "
    "surface, roof, architectural style, ornament or visual design, and you do NOT "
    "describe how anything should look.\n"
    "Set needs_shared_model_guide=true with decision_type=cross_shot_continuity when the "
    "group shows the same location across two or more shots — especially from different "
    "camera angles or positions — so an unguided render would risk drawing a different "
    "version of the same structures or moving the figures between shots. Set "
    "needs_shared_model_guide=true with decision_type=single_shot_complexity when a "
    "SINGLE shot's composition is complex enough on its own that an unguided render would "
    "likely get the framing or figure placement wrong — for example several figures to "
    "arrange, figures spread across distinct depth layers, or a figure moving through the "
    "space — so a one-shot layout sketch would meaningfully help. Even with a SINGLE "
    "figure on a SINGLE depth plane, the camera or framing directive ITSELF can justify a "
    "single_shot_complexity guide when that directive creates spatial ambiguity or a high "
    "placement risk — for instance an extreme viewpoint (steep overhead or very low "
    "angle), a strong wall/floor/ceiling relationship the body must sit correctly within, "
    "foreshortening, or occlusion — because an unguided render often mis-places the body "
    "or collapses the intended depth. When you route on that basis, cite the directive in "
    "evidence (source_field=camera_direction or the relevant raw director field) and "
    "include camera_or_framing_risk in reasons. Use both when both hold, and no_guide when "
    "the layout signals are weak and a single render would be fine unaided. Set "
    "guide_scope=group for a cross-shot guide, single for a single-shot guide, none "
    "otherwise.\n"
    "A shot with NO figures can still warrant a single_shot_complexity guide when its "
    "camera or framing directive alone creates spatial ambiguity — an approach path, "
    "threshold or structure whose position and scale must read correctly (tight "
    "architectural framing, an entrance seen head-on, an extreme viewpoint); route on "
    "the directive evidence exactly as above.\n"
    "Separately, ALWAYS fill optical_risk_shot_keys: list the shot_key of every shot "
    "whose staging or directives present the subject through a REFLECTIVE or "
    "TRANSPARENT surface — a reflection in glass, a mirror image, a subject visible "
    "only as a reflection, or similar through-surface optics. A mannequin layout "
    "sketch routinely confuses the subject's true position with its reflected or "
    "transmitted image, so those shots are excluded from sketches even when the group "
    "is otherwise admitted; cite the deciding words in evidence. Use an empty array "
    "when none apply.\n"
    "Judge ONLY from the structured group signals and each shot's staging/geography "
    "below; never decide by what the place or objects are CALLED — use the generic "
    "spatial classes in reasons only. Put the deciding words in evidence with their "
    "shot_key and source_field. If evidence is empty or the case is unclear, answer "
    "needs_shared_model_guide=false with confidence=low."
)


def build_shared_model_guide_judge_prompt(group_payload: Dict[str, Any]) -> str:
    """group route judge 입력 — 구조화 group meta + per-shot signals/staging/geography
    (시나리오 명사는 데이터 label 로만 흘러들 뿐, 템플릿은 generic)."""
    return json.dumps(group_payload, ensure_ascii=False, indent=1)


def judge_shared_model_guide(
    group_payload: Dict[str, Any],
    *,
    model: str,
    log_context: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """group-level shared-model 가이드 route 판정 (gemini text, structured). route only —
    반환 = SHARED_MODEL_GUIDE_JUDGE_SCHEMA dict (가드/attach 판정은 step)."""
    from app.modules.llm.gemini_text_client import GeminiTextClient

    client = GeminiTextClient(model=model)
    if log_context:
        client.set_context(**log_context)
    out = client.send(
        build_shared_model_guide_judge_prompt(group_payload),
        response_schema=SHARED_MODEL_GUIDE_JUDGE_SCHEMA,
        schema_name="shared_model_guide_route",
        system_instruction=SHARED_MODEL_GUIDE_JUDGE_SYSTEM,
        temperature=0.1,
    )
    return out if isinstance(out, dict) else {}


# ───────────────────────── LLM calls ─────────────────────────


def emit_site_layout(
    scene_texts: List[Tuple[int, str]],
    member_shots: List[Dict[str, Any]],
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
    *,
    constraint_feedback: Optional[str] = None,
) -> Dict[str, Any]:
    from app.modules.llm.llm_client import call_structured

    return call_structured(
        step=_STEP,
        system_prompt=SITE_LAYOUT_SYSTEM,
        user_prompt=build_site_layout_user_prompt(
            scene_texts, member_shots, constraint_feedback),
        response_schema=SITE_LAYOUT_SCHEMA,
        project_config=project_config,
        schema_name="site_layout",
        opik_metadata=opik_metadata,
        max_tokens=LAYOUT_MAX_TOKENS,
    )


def rewrite_position_phrases(
    variations: List[Tuple[int, str]],
    spatial_summary: str,
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> Dict[int, Dict[str, Any]]:
    """{variation_index: {revised_prompt, edited_spans, no_edit_reason}}."""
    from app.modules.llm.llm_client import call_structured

    result = call_structured(
        step=_STEP,
        system_prompt=REWRITE_POSITION_SYSTEM,
        user_prompt=build_rewrite_user_prompt(variations, spatial_summary),
        response_schema=REWRITE_RESULT_SCHEMA,
        project_config=project_config,
        schema_name="revised_position_prompts",
        opik_metadata=opik_metadata,
        max_tokens=REWRITE_MAX_TOKENS,
    )
    out: Dict[int, Dict[str, Any]] = {}
    for r in result.get("revised_prompts", []):
        if isinstance(r, dict) and isinstance(r.get("revised_prompt"), str):
            out[int(r["variation_index"])] = {
                "revised_prompt": r["revised_prompt"],
                "edited_spans": r.get("edited_spans") or [],
                "no_edit_reason": r.get("no_edit_reason"),
            }
    return out
