"""레인1 마커 맵 — geometry 스키마·검증·결정론 합성 (설계 v2 Stage B).

LLM 은 normalized(0..1) geometry JSON 만 저작한다(합의 결정 2). 코드는
스키마 잠금·fail-closed 검증·PIL 결정론 합성만 담당 — base map PNG 는
immutable SOT(복사본에만 그림), 이미지 안에 고유명·장소명 0(슬롯 코드
E1../CAM 만).
"""
from __future__ import annotations

import io
import logging
import math
import re
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

MAX_SLOTS = 6
_ALLOWED_SLOTS = frozenset(f"E{i}" for i in range(1, MAX_SLOTS + 1))
_MIN_CAMERA_DIST = 0.02
# v3 (2026-07-25 사용자 지적①): facing/시야 쐐기 degenerate 가드.
# ★화각 값 자체가 아니다 — 화각은 샷별 LLM 저작(view_left/right)이고,
# 여기 상수는 "방향 벡터가 성립하지 않는 입력"만 거르는 무결성 경계
# (구 PIL 콘의 고정 56° 상수 승계 금지 교훈, GEOMETRY_TEXT_VERSION v2).
_MIN_FACING_DIST = 0.02
_MIN_VIEW_SPAN_RAD = math.radians(4.0)
_MAX_VIEW_SPAN_RAD = math.radians(176.0)

# 배치 리뷰 HIGH-5: subject_en 은 자연어 명사구 계약 — 스키마 자체의
# 구조 토큰(슬롯 코드 E<n>, CAM)이 섞이면 ID-free 직렬화(v4)에 그대로
# 누출된다. 결정론 **구조 토큰** reject (의미 판단 아님 — evidence
# literal 검증과 같은 계약 가드 계열, 대문자 단어 경계 한정).
_SUBJECT_STRUCT_TOKEN_RE = re.compile(r"\b(?:E\d+|CAM)\b")

_MODULE = "outdoor_marker_geometry"

PROMPT_VERSION_MAP = {
    "1": "1.202607150100",
    # v2 (2026-07-24 Codex AGREE_WITH_CONDITIONS ①③): per-marker
    # landmark anchor 저작 — entity anchor_en + camera origin/
    # look_target anchor 분리 required(교정 축 분리), 맵 가시 피처
    # 한정·타 마커/off-map 앵커 금지·코드 없는 서술. 캔ary 3회 실측:
    # i2i 는 수치 분율을 정밀 추종 못함 — 배치 권위=anchor.
    "2": "2.202607241115",
    # v3 (2026-07-25 사용자 지적① — "카메라가 어떤 방향으로 엔티티를
    # 찍고 있는지 안 나타나고 엔티티도 제대로 표시하지 않는다"):
    # 시야 쐐기(camera view_left/view_right + anchor)와 피사체 facing
    # (facing + facing_anchor_en) 저작 추가. v2 스키마는 origin→
    # look_target 선 하나뿐이라 화각·향한 방향이 데이터에 없었다.
    "3": "3.202607251321",
    # v4 (2026-07-26): SHOT TEXT 가 말한 카메라 대면 관계를 선언 필드로
    # 저작시키는 계약(camera_facing_relation/_evidence) 추가. v3 의
    # anchor·시야 쐐기·facing stem 은 그대로 승계 — 삽입은 (1) facing
    # 문단 뒤 한 곳뿐이다.
    "4": "4.202607270248",
}

# anchor 저작 계약이 실리는 geometry 팩 (스키마·validator 게이트)
_ANCHOR_GEOMETRY_PACKS = {"2", "3", "4"}

# v3+: 시야 쐐기·facing 저작 계약이 실리는 geometry 팩
_VIEW_GEOMETRY_PACKS = {"3", "4"}

# v4 (2026-07-26): SHOT TEXT 가 말한 카메라 대면 관계를 선언 필드로 올려
# 좌표와 결정론 대조한다. 프롬프트 문구·rationale 만으로는 틀린 facing 을
# validator 가 잡을 수 없다 — 실측(S15sh5): SHOT TEXT 가 '뒷모습'인데
# 저작 facing 의 카메라 축 성분이 dep=-0.211(부호가 오히려 카메라 쪽)
# 이었고 _SCREEN_EPS 미달로 심도 문구가 침묵해 은폐됐다.
CAMERA_FACING_RELATIONS = (
    "toward_camera", "away_from_camera", "profile", "unspecified")
_FACING_RELATION_GEOMETRY_PACKS = {"4"}

# geometry 스키마·validator 계약 버전 (Codex ④): anchor required 화 등
# 구조 변화 시 bump — config hash·sidecar 지문 스탬프 대상.
# 3 (2026-07-25): view wedge·facing required 화.
# ★v4(대면 관계) 는 의도적으로 bump 하지 않았다 — 누락이 아니다. 해석된
# 팩명이 이미 lane 지문에 geometry_pack 으로 실려 v4 를 쓰는 샷은 그것만으로
# 무효화된다. 반면 이 상수는 팩과 무관하게 **모든** lane 샷에 스탬프되므로
# 올리면 구 팩에 머무는 프로젝트까지 얻는 것 없이 전량 재저작한다.
MARKER_GEOMETRY_CONTRACT_VERSION = 3

# 렌더 스타일 상수 — 결정론의 일부 (변경 시 canary 재검증)
_STYLE = {
    "entity_fill": (220, 50, 50, 230),
    "entity_outline": (255, 255, 255, 255),
    "camera_color": (40, 90, 220, 255),
    "cone_fill": (40, 90, 220, 60),
    "entity_radius_frac": 0.018,
    "line_width_frac": 0.004,
    "cone_half_angle_deg": 28.0,
}


def resolve_prompt_version(version: str) -> str:
    if version not in PROMPT_VERSION_MAP:
        raise ValueError(f"outdoor_marker_geometry 프롬프트 버전 없음: {version}")
    return PROMPT_VERSION_MAP[version]


def build_marker_geometry_schema(
    max_slots: int = MAX_SLOTS, *, include_anchors: bool = False,
    include_view: bool = False, include_facing_relation: bool = False,
) -> Dict[str, Any]:
    """normalized geometry 스키마 — slot enum·0..1 bounds 잠금.

    include_anchors (v2+, Codex ①): per-marker landmark anchor 를
    required 로 강제. False=기존 byte-identical.
    include_view (v3+, 2026-07-25 사용자 지적①): 시야 쐐기(camera
    view_left/view_right)와 피사체 facing 을 required 로 강제 —
    화각·향한 방향이 저작되지 않으면 마커 맵이 그릴 데이터가 없다.
    include_facing_relation (v4+): 카메라 대면 관계 선언 필드 2개를
    required 로 강제. False=기존 byte-identical.
    """
    point = {
        "type": "object",
        "properties": {
            "x": {"type": "number", "minimum": 0, "maximum": 1},
            "y": {"type": "number", "minimum": 0, "maximum": 1},
        },
        "required": ["x", "y"],
        "additionalProperties": False,
    }
    placement_props = {
        "slot": {"enum": [f"E{i}" for i in range(1, max_slots + 1)]},
        "subject_en": {"type": "string", "minLength": 3},
        "x": {"type": "number", "minimum": 0, "maximum": 1},
        "y": {"type": "number", "minimum": 0, "maximum": 1},
    }
    placement_req = ["slot", "subject_en", "x", "y"]
    cam_props: Dict[str, Any] = {"origin": point, "look_target": point}
    cam_req = ["origin", "look_target"]
    if include_anchors:
        # Codex ①: origin/look_target anchor 분리 — 어느 쪽이 틀렸는지
        # validator/retry/check 가 개별 교정 가능해야 한다
        placement_props["anchor_en"] = {"type": "string", "minLength": 8}
        placement_req = placement_req + ["anchor_en"]
        cam_props["origin_anchor_en"] = {"type": "string", "minLength": 8}
        cam_props["look_target_anchor_en"] = {
            "type": "string", "minLength": 8}
        cam_req = cam_req + ["origin_anchor_en", "look_target_anchor_en"]
    if include_view:
        # v3: 프레임 좌/우 가장자리가 맵에서 지나는 지점 — origin 과
        # 함께 시야 쐐기를 이룬다. 폭은 샷 텍스트 framing 에서 LLM 이
        # 저작(고정 상수 금지). facing 은 피사체가 향한 쪽.
        placement_props["facing"] = point
        placement_props["facing_anchor_en"] = {
            "type": "string", "minLength": 8}
        placement_req = placement_req + ["facing", "facing_anchor_en"]
        cam_props["view_left"] = point
        cam_props["view_right"] = point
        cam_props["view_left_anchor_en"] = {"type": "string", "minLength": 8}
        cam_props["view_right_anchor_en"] = {"type": "string", "minLength": 8}
        cam_req = cam_req + [
            "view_left", "view_right",
            "view_left_anchor_en", "view_right_anchor_en",
        ]
    if include_facing_relation:
        # 관계는 enum, 근거는 SHOT TEXT 원문 인용 — unspecified 는 빈
        # 문자열이어야 한다(validator 가 양방향 검증).
        placement_props["camera_facing_relation"] = {
            "enum": list(CAMERA_FACING_RELATIONS)}
        placement_props["camera_facing_evidence"] = {"type": "string"}
        placement_req = placement_req + [
            "camera_facing_relation", "camera_facing_evidence"]
    placement = {
        "type": "object",
        "properties": placement_props,
        "required": placement_req,
        "additionalProperties": False,
    }
    return {
        "type": "object",
        "properties": {
            "entity_placements": {
                "type": "array", "items": placement,
                "minItems": 1, "maxItems": max_slots,
            },
            "camera": {
                "type": "object",
                "properties": cam_props,
                "required": cam_req,
                "additionalProperties": False,
            },
            "rationale_ko": {"type": "string", "minLength": 5},
        },
        "required": ["entity_placements", "camera", "rationale_ko"],
        "additionalProperties": False,
    }


def _is_number(val: Any) -> bool:
    # bool 은 int 하위 타입 — 좌표로 위장 통과 차단 (Codex Stage B N3)
    return isinstance(val, (int, float)) and not isinstance(val, bool)


def _check_point(label: str, pt: Any, violations: List[str]) -> None:
    if not isinstance(pt, dict):
        violations.append(f"{label} 가 객체 아님")
        return
    for k in ("x", "y"):
        val = pt.get(k)
        if not _is_number(val) or not (0 <= val <= 1):
            violations.append(f"{label}.{k}={val!r} 숫자 범위(0..1) 밖")


# anchor 문장의 구조 코드 차단 (Codex ③): E<n>/CAM 만이 아니라
# geometry LLM 이 보는 legend 코드([A-Z][0-9]+ 일반형)까지 —
# downstream ID-free 유지. 의미 판정은 regex 로 하지 않는다(존재하는
# 코드 토큰 차단만).
_ANCHOR_CODE_TOKEN_RE = re.compile(r"\b[A-Z][0-9]+\b|\bCAM\b")


def _check_anchor(
    label: str, raw: Any, violations: List[str], *, required: bool,
) -> None:
    if raw is None:
        if required:
            violations.append(f"{label} 결손 (anchor 필수)")
        return
    if not isinstance(raw, str):
        violations.append(
            f"{label} 이 문자열 아님: {type(raw).__name__}")
        return
    text = raw.strip()
    if not text:
        violations.append(f"{label} 비어 있음")
        return
    if any(ord(c) < 32 for c in text):
        violations.append(f"{label} 에 제어문자 포함")
    if _ANCHOR_CODE_TOKEN_RE.search(text):
        violations.append(
            f"{label} 에 구조/legend 코드 토큰 포함 — 피처를 서술형으로 "
            f"명명하라: {text!r}")


def _norm_angle(rad: float) -> float:
    """(-pi, pi] 정규화 — 쐐기 포함 판정용 순수 기하."""
    out = math.fmod(rad, 2 * math.pi)
    if out <= -math.pi:
        out += 2 * math.pi
    elif out > math.pi:
        out -= 2 * math.pi
    return out


def _heading(src: Dict[str, Any], dst: Dict[str, Any]) -> float:
    return math.atan2(dst["y"] - src["y"], dst["x"] - src["x"])


def _check_view_wedge(
    cam: Dict[str, Any], violations: List[str],
    placements: Any = None,
) -> None:
    """시야 쐐기 무결성 (v3) — 순수 기하. 좌표 결측/형식은 상위
    _check_point 이 이미 보고하므로 여기선 조용히 skip.

    placements 전달 시 **이 샷이 배치한 피사체가 전부 쐐기 안**인지도
    검사한다 (캔ary 실측: 카메라가 피사체 반대쪽을 보게 저작돼 마커
    맵이 빈 도로를 향한 쐐기를 그렸다 — 시야를 데이터화하기 전에는
    선 하나뿐이라 드러나지 않던 배치 오류).
    """
    try:
        origin = cam["origin"]
        left, right = cam["view_left"], cam["view_right"]
        target = cam["look_target"]
        for label, pt in (("view_left", left), ("view_right", right)):
            dist = math.hypot(pt["x"] - origin["x"], pt["y"] - origin["y"])
            if dist < _MIN_CAMERA_DIST:
                violations.append(
                    f"camera.{label} 이 origin 과 겹침 — 시야 경계 미성립 "
                    f"(dist={dist:.4f} < {_MIN_CAMERA_DIST})")
        a_left = _heading(origin, left)
        a_right = _heading(origin, right)
        span = _norm_angle(a_right - a_left)
        if abs(span) < _MIN_VIEW_SPAN_RAD:
            violations.append(
                f"시야 쐐기가 열리지 않음 (좌우 경계 사이 각 "
                f"{math.degrees(abs(span)):.1f}°) — 두 경계는 프레임의 "
                "좌/우 끝이라 서로 달라야 한다")
        elif abs(span) > _MAX_VIEW_SPAN_RAD:
            violations.append(
                f"시야 쐐기가 과대 ({math.degrees(abs(span)):.1f}°) — "
                "카메라가 사실상 전방향을 본다는 뜻이라 프레임이 성립 안 함")
        else:
            def _inside(pt: Dict[str, Any]) -> bool:
                rel = _norm_angle(_heading(origin, pt) - a_left)
                return (
                    (0 <= rel <= span) if span >= 0
                    else (span <= rel <= 0)
                )

            if not _inside(target):
                violations.append(
                    "look_target 이 시야 쐐기(view_left..view_right) 밖 — "
                    "카메라가 보는 지점은 프레임 안에 있어야 한다")
            for pl in placements or []:
                if not isinstance(pl, dict):
                    continue
                try:
                    if _inside(pl):
                        continue
                except (KeyError, TypeError):
                    continue
                violations.append(
                    f"{pl.get('slot') or '피사체'} 가 시야 쐐기 밖 — 이 "
                    "샷이 담는 피사체는 프레임 안에 있어야 한다. 카메라를 "
                    "피사체 맞은편으로 옮기거나(origin), 시선/시야 경계를 "
                    "피사체 쪽으로 다시 잡아라"
                )
    except (KeyError, TypeError):
        pass


def validate_marker_geometry(
    geometry: Any, *, require_anchors: bool = False,
    require_view: bool = False, require_facing_relation: bool = False,
    shot_text: str = "",
) -> List[str]:
    """결정론 무결성 검증 — 위반 리스트 반환 (fail-closed 재시도용).

    스키마(call_structured strict)가 정상 경로를 막더라도, render 등
    public 소비자가 이 validator 를 직접 신뢰하므로 자체 완결이어야
    한다 (Codex Stage B N3): root/placement 타입, MAX_SLOTS 상한,
    slot enum, bool 좌표 배제까지 여기서 검증.

    require_anchors (v2+, Codex ①③): per-marker anchor(entity
    anchor_en, camera origin/look_target anchor)를 필수로 검증.
    False 여도 anchor 필드가 존재하면 형식(문자열·제어문자·구조 코드)
    은 검증한다 — 오염된 선택 필드 통과 차단.

    require_view (v3+): 시야 쐐기(camera view_left/view_right)와
    피사체 facing 을 필수로 검증. False 여도 존재하면 형식·기하
    (쐐기 개폐·look_target 포함·facing 자기겹침)는 검증한다.

    require_facing_relation (v4+): per-placement 카메라 대면 관계 선언과
    좌표의 정합 + 근거 인용 provenance 를 필수로 검증. False 여도 어느
    placement 든 camera_facing_relation 이 존재하면 enum·evidence 형식·
    unspecified 규칙·좌표 정합은 검증한다 — 오염된 선택 필드 통과 차단
    (위 두 게이트와 동일 관례).

    shot_text 는 그 샷의 SHOT TEXT 원문(인용 대조용). 인용 대조는
    require_facing_relation=True 이거나 shot_text 가 실제로 공급된
    경우에만 수행한다 — 둘 다 아니면(기본 플래그 소비자) 대조 자체가
    성립하지 않으므로 건너뛴다. require_facing_relation=True 인데
    shot_text 미공급이면 모든 명시 relation 이 거부된다(fail-closed).
    """
    if not isinstance(geometry, dict):
        return ["geometry 가 객체 아님"]
    violations: List[str] = []
    placements = geometry.get("entity_placements")
    if not isinstance(placements, list):
        violations.append("entity_placements 가 배열 아님")
        placements = []
    if not placements:
        violations.append("entity_placements 비어 있음")
    if len(placements) > MAX_SLOTS:
        violations.append(
            f"entity_placements {len(placements)}개 — 최대 {MAX_SLOTS}")
    seen: List[str] = []
    for i, pl in enumerate(placements):
        if not isinstance(pl, dict):
            violations.append(f"[{i}] placement 가 객체 아님")
            continue
        slot = pl.get("slot") or ""
        if slot not in _ALLOWED_SLOTS:
            violations.append(
                f"[{i}] slot {slot!r} 허용 밖 (E1..E{MAX_SLOTS})")
        elif slot in seen:
            violations.append(f"slot {slot} 중복")
        seen.append(slot)
        raw_subject = pl.get("subject_en")
        # 재리뷰 NARROW-4: validator 자체 완결 — non-string 은 예외가
        # 아니라 위반으로 반환 (fail-closed 재시도 경로)
        if raw_subject is not None and not isinstance(raw_subject, str):
            violations.append(
                f"{slot or i} subject_en 이 문자열 아님: "
                f"{type(raw_subject).__name__}"
            )
            raw_subject = ""
        subject = (raw_subject or "").strip()
        if not subject:
            violations.append(f"{slot or i} subject_en 비어 있음")
        elif _SUBJECT_STRUCT_TOKEN_RE.search(subject):
            violations.append(
                f"{slot or i} subject_en 에 구조 토큰(E<n>/CAM) 포함 — "
                f"자연어 명사구만 허용: {subject!r}"
            )
        _check_point(f"{slot or i}", pl, violations)
        _check_anchor(
            f"{slot or i} anchor_en", pl.get("anchor_en"), violations,
            required=require_anchors)
        # v3: 향한 방향 — 선택 필드여도 존재하면 형식·거리 검증
        _check_anchor(
            f"{slot or i} facing_anchor_en", pl.get("facing_anchor_en"),
            violations, required=require_view)
        facing = pl.get("facing")
        if facing is None:
            if require_view:
                violations.append(f"{slot or i} facing 결손 (v3 필수)")
        else:
            _check_point(f"{slot or i}.facing", facing, violations)
            try:
                fdist = math.hypot(
                    facing["x"] - pl["x"], facing["y"] - pl["y"])
                if fdist < _MIN_FACING_DIST:
                    violations.append(
                        f"{slot or i} facing 이 자기 위치와 겹침 — 향한 "
                        f"방향 미성립 (dist={fdist:.4f})")
            except (KeyError, TypeError):
                pass
    expected = [f"E{i}" for i in range(1, len(seen) + 1)]
    if seen and sorted(set(seen) & _ALLOWED_SLOTS) != [
            s for s in expected if s in _ALLOWED_SLOTS]:
        violations.append(
            f"슬롯은 E1 부터 빠짐없이 순차여야 함 (현재 {sorted(set(seen))})")
    cam = geometry.get("camera")
    if not isinstance(cam, dict):
        violations.append("camera 가 객체 아님")
        cam = {}
    _check_point("camera.origin", cam.get("origin"), violations)
    _check_point("camera.look_target", cam.get("look_target"), violations)
    _check_anchor(
        "camera.origin_anchor_en", cam.get("origin_anchor_en"),
        violations, required=require_anchors)
    _check_anchor(
        "camera.look_target_anchor_en", cam.get("look_target_anchor_en"),
        violations, required=require_anchors)
    # v3: 시야 쐐기 — 필수 팩이거나 부분 저작 시 형식·기하 검증
    # (부분 결손이 조용히 통과해 마커 맵이 쐐기 없이 그려지는 것 차단)
    _check_anchor(
        "camera.view_left_anchor_en", cam.get("view_left_anchor_en"),
        violations, required=require_view)
    _check_anchor(
        "camera.view_right_anchor_en", cam.get("view_right_anchor_en"),
        violations, required=require_view)
    if (
        require_view
        or cam.get("view_left") is not None
        or cam.get("view_right") is not None
    ):
        _check_point("camera.view_left", cam.get("view_left"), violations)
        _check_point("camera.view_right", cam.get("view_right"), violations)
        _check_view_wedge(cam, violations, placements)
    try:
        dist = math.hypot(
            cam["look_target"]["x"] - cam["origin"]["x"],
            cam["look_target"]["y"] - cam["origin"]["y"],
        )
        if dist < _MIN_CAMERA_DIST:
            violations.append(
                f"camera 방향 벡터 미성립 — origin≈look_target "
                f"(dist={dist:.4f} < {_MIN_CAMERA_DIST})")
    except (KeyError, TypeError):
        pass  # 좌표 결측은 위 range 검사가 이미 보고
    # v4: 카메라 대면 관계 — 카메라 dict 와 검증 끝난 placements 를 모두
    # 확보한 뒤여야 하므로 반환 직전에 per-placement 로 돈다.
    # 리뷰 지적 Important-2: anchor/view 게이트와 같은 관례로 —
    # require 가 False 여도 relation 이 **존재하면** 검증한다(오염된 선택
    # 필드 통과 차단). 그렇지 않으면 v4 팩 출시 후 기본 플래그 소비자
    # (build_geometry_text_lines·render_marker_map 등)가 좌표와 어긋난
    # relation 을 그대로 통과시킨다 — 이 기능이 잡으려던 바로 그 실패다.
    if require_facing_relation or any(
        isinstance(pl, dict) and pl.get("camera_facing_relation") is not None
        for pl in placements
    ):
        # 인용 대조만 조건부: 기본 플래그 소비자는 SHOT TEXT 를 갖지
        # 않아 대조가 성립하지 않는다(전량 거부 방지). 저작 경로
        # (require=True)는 shot_text 미공급 시에도 fail-closed 유지.
        _provenance = require_facing_relation or bool(
            (shot_text or "").strip())
        # Minor-4: cam 은 위에서 이미 정규화(비-dict → {} + 위반 기록)됐다.
        # 재조회하면 같은 사실이 재시도 힌트에 두 번 실리므로 그 로컬을
        # 재사용하고, 카메라가 없으면 여기서는 조용히 건너뛴다.
        if cam:
            for idx, pl in enumerate(placements):
                if not isinstance(pl, dict):
                    continue
                label = f"placement[{idx}]({pl.get('slot') or '?'})"
                violations.extend(_check_facing_relation(
                    cam, pl, label, shot_text,
                    check_provenance=_provenance))
    return violations


def render_marker_map(base_png: bytes, geometry: Dict[str, Any]) -> bytes:
    """(DEPRECATED — 프로덕션 호출 금지) base map 위 마커 PIL 합성.

    2026-07-16 사용자 확정: 코드(PIL 등)로 이미지에 그리는 행위 절대 금지
    — 마커·콘·텍스트 오버레이 전부. 시각 요소는 항상 이미지 모델 몫.
    geometry 는 build_geometry_text_lines(ID-free 텍스트 직렬화)로만
    소비한다. 함수는 모듈 삭제 금지 원칙에 따라 보존만.
    """
    violations = validate_marker_geometry(geometry)
    if violations:
        raise ValueError(f"invalid geometry: {'; '.join(violations)}")

    from PIL import Image, ImageDraw, ImageFont

    base = Image.open(io.BytesIO(base_png)).convert("RGBA")
    overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(overlay)
    w, h = base.size
    unit = min(w, h)
    r = max(6, int(unit * _STYLE["entity_radius_frac"]))
    lw = max(2, int(unit * _STYLE["line_width_frac"]))
    font = ImageFont.load_default()

    cam = geometry["camera"]
    ox, oy = cam["origin"]["x"] * w, cam["origin"]["y"] * h
    tx, ty = cam["look_target"]["x"] * w, cam["look_target"]["y"] * h
    ang = math.atan2(ty - oy, tx - ox)
    half = math.radians(_STYLE["cone_half_angle_deg"])
    # FOV ray 는 look_target 거리와 무관 — 이미지 경계 밖까지 뻗어야
    # target 및 그 뒤 같은 시선축 피사체가 콘 안에 든다 (Codex Stage B B1:
    # target-distance 삼각형은 반각 28° 기하상 target 자체도 빗변 밖).
    # PIL polygon 은 캔버스에서 자연 clip 되므로 대각선 2배면 충분.
    ray_len = math.hypot(w, h) * 2.0
    p1 = (ox + ray_len * math.cos(ang - half),
          oy + ray_len * math.sin(ang - half))
    p2 = (ox + ray_len * math.cos(ang + half),
          oy + ray_len * math.sin(ang + half))
    draw.polygon([(ox, oy), p1, p2], fill=_STYLE["cone_fill"])
    # 방향선은 look_target 까지 — 시선점 표시는 유지
    draw.line([(ox, oy), (tx, ty)], fill=_STYLE["camera_color"], width=lw)
    draw.ellipse(
        [ox - r, oy - r, ox + r, oy + r],
        fill=_STYLE["camera_color"], outline=_STYLE["entity_outline"],
        width=max(1, lw // 2),
    )
    draw.text((ox + r + 2, oy - r), "CAM",
              fill=_STYLE["camera_color"], font=font)

    for pl in geometry["entity_placements"]:
        ex, ey = pl["x"] * w, pl["y"] * h
        draw.ellipse(
            [ex - r, ey - r, ex + r, ey + r],
            fill=_STYLE["entity_fill"], outline=_STYLE["entity_outline"],
            width=max(1, lw // 2),
        )
        draw.text((ex + r + 2, ey - r), pl["slot"],
                  fill=_STYLE["entity_fill"], font=font)

    out = Image.alpha_composite(base, overlay)
    buf = io.BytesIO()
    out.save(buf, format="PNG")
    return buf.getvalue()


def run_marker_geometry_shot(
    *,
    spec: Dict[str, Any],
    shot: Dict[str, Any],
    scene_text: str,
    map_png: bytes,
    segment_label_en: str,
    prompt_version: str = "1",
    call_structured_fn=None,
    project_config: Dict[str, Any] | None = None,
    opik_metadata: Dict[str, Any] | None = None,
    max_attempts: int = 3,
) -> Dict[str, Any]:
    """샷 1개 geometry 저작 — 무결성 위반 시 위반 힌트 재시도.

    반환 {"geometry": <검증 통과 결과>, "attempts": n}.
    소진 시 AppError(step.contract_violation.outdoor_marker_geometry).

    v4+ 대면 관계: camera_facing_evidence 인용 대조 원문 = 프롬프트에
    실린 SHOT BLOCK **그대로**(build_shot_block — description + 등장
    인물 + 연출 카메라/배경 메모). LLM 이 본 텍스트와 대조 원문이
    갈라지면(예: description 만 대조) 카메라 메모 줄에서 인용한 근거가
    '원문에 없음'으로 거부돼 재시도가 소진된다 — fail-closed 계약이라
    미공급도 같은 결과다.
    """
    from app.core.errors import AppError

    if call_structured_fn is None:
        from app.modules.llm.llm_client import call_structured

        call_structured_fn = call_structured

    from app.modules.pipeline.multiroll_gemini import png_part
    from app.modules.pipeline.outdoor_shot_grounding import (
        build_legend_block,
        build_shot_block,
    )
    from app.modules.prompt_loader import load_prompt

    resolved = resolve_prompt_version(prompt_version)
    system = load_prompt(_MODULE, "system", version=resolved)
    template = load_prompt(_MODULE, "user_template", version=resolved)
    _anchored = prompt_version in _ANCHOR_GEOMETRY_PACKS
    _viewed = prompt_version in _VIEW_GEOMETRY_PACKS
    _related = prompt_version in _FACING_RELATION_GEOMETRY_PACKS
    schema = build_marker_geometry_schema(
        include_anchors=_anchored, include_view=_viewed,
        include_facing_relation=_related)
    # 인용 대조 원문은 프롬프트에 실리는 것과 **같은 객체**여야 한다 —
    # 별도 인자로 받거나 여기서 다시 조립하면 두 원문이 갈라진다.
    shot_block = build_shot_block(shot)

    filled = template
    for key, val in {
        "legend_block": build_legend_block(spec),
        "zones_block": "\n".join(
            f"- {z}" for z in spec.get("zone_labels_en", []) or []
        ),
        "segment_label": segment_label_en,
        "shot_block": shot_block,
        # 씬 원문 전문 — 절대 자르지 않는다 (CLAUDE.md 절대 규칙)
        "scene_text_block": scene_text,
    }.items():
        filled = filled.replace("{" + key + "}", val)
    base_parts = [
        {"type": "text",
         "text": "TOP-DOWN SITE PLAN of the filming property:"},
        png_part(map_png),
        {"type": "text", "text": filled},
    ]

    attempts = 0
    parts = base_parts
    last: List[str] = []
    while attempts < max_attempts:
        attempts += 1
        result = call_structured_fn(
            _MODULE, system, parts, schema,
            project_config=project_config,
            schema_name=_MODULE,
            opik_metadata=opik_metadata,
        )
        violations = validate_marker_geometry(
            result or {}, require_anchors=_anchored,
            require_view=_viewed, require_facing_relation=_related,
            shot_text=shot_block)
        if not violations:
            return {"geometry": result, "attempts": attempts}
        last = violations
        hint = "\n".join(
            ["", "", "[재시도 — 직전 응답이 아래 계약을 위반했습니다. 전부",
             " 고쳐서 전체 결과를 다시 출력하세요:]"]
            + [f"  - {v}" for v in violations]
        )
        parts = base_parts + [{"type": "text", "text": hint}]

    raise AppError(
        code="step.contract_violation.outdoor_marker_geometry",
        message=f"marker geometry 계약 위반 (attempts={max_attempts}): "
                + "; ".join(last[:8]),
        status_code=422,
    )


def lane_conti_fingerprint_payload(
    *,
    spec: Dict[str, Any],
    vshot: Dict[str, Any],
    scene_text: str,
    lane: str,
    segment_label_en: str,
    place_text: str,
    pose_clauses: List[str],
    carried_en: str,
) -> str:
    """lane 콘티 사이드카 지문의 결정 입력 직렬화 (Codex Stage D HIGH-5).

    geometry(build_shot_block=vshot 전체)와 스케치(place/pose/carried/
    camera)가 소비하는 **모든** 결정 입력을 포함 — 부분 필드 추림 금지.
    """
    import json as _json

    return _json.dumps(
        {
            "spec": spec,
            "shot": vshot,
            "scene_text": scene_text,
            "lane": lane,
            "segment_label_en": segment_label_en,
            "place_text": place_text,
            "pose_clauses": list(pose_clauses),
            "carried_en": carried_en,
        },
        sort_keys=True, ensure_ascii=False,
    )


SKETCH_PACK_MODULE = "marker_map_sketch"

SKETCH_PACK_VERSION_MAP = {
    "1": "1.202607150110",
    # v2 (Codex Stage B B2): GROUND-LEVEL 강제 제거 — 맵=XY/heading SOT,
    # camera_direction=수직 각도/framing SOT 관할 분리 + leakage judge 가
    # dimensional overhead/high-angle 을 오탐하지 않도록 구분.
    "2": "2.202607150300",
    # v3 (Stage D): 레인2(structure_plate) seed 룩 조항 정식화 —
    # seed_look_attached=True 시 2번째 참조(구조물 확정 실사)=형태·비율·
    # 개구부·실루엣 SOT, 맵=배치·카메라 SOT 관할 분리(v4 canary
    # SKETCH_SEED_CLAUSE 실증). 나머지 stem 은 v2 동일.
    "3": "3.202607152340",
    # v4 (2026-07-16 R4·R5, 코드로 이미지 그리기 절대 금지): 마커 굽기
    # 전면 제거 — 참조=클린 SITE PLAN, geometry 는 ID-free 텍스트
    # 직렬화(STAGING GEOMETRY)로만 전달. head/no_marker/leakage_judge
    # 전면 갱신(마커 전제 프라이밍 제거).
    "4": "4.202607162230",
    # v6 (Codex AGREE_WITH_CONDITIONS ②): anchor 기반 배치 권위 재편 —
    # annotate/check=per-marker landmark anchor 가 배치 SOT, 좌표=
    # coarse estimate, sketch_head=검증 통과 마커 맵이 raster 배치
    # SOT(수치는 override 불가 보조). v5 는 캔ary 3회 소비라 불변.
    # (맵 아래에 추가)
    # v5 (2026-07-24 사용자 강질책 — 마커 맵 단계 복원, 단 i2i 로):
    # v4 가 '코드 드로잉 금지'를 '단계 제거'로 오해석한 것을 교정 —
    # 시각 요소=이미지 모델 몫이므로 마커도 이미지 모델이 그린다.
    # 신규 marker_annotate(클린 canon 맵→i2i 로 CAM+시선+슬롯 마커
    # 작화, 그 외 무변경 계약)+marker_check_judge(존재·근사 위치·플랜
    # 보존 VLM 검증)+sketch_head=마커 맵 참조 전제(마커=staging aid,
    # STAGING GEOMETRY 텍스트와 동일 사실 병기). no_marker/no_text/
    # leakage(스케치에 마커 렌더 금지)는 v4 승계 — 마커는 맵에만 있고
    # 스케치 프레임에는 절대 나타나지 않는다.
    "5": "5.202607241055",
    "6": "6.202607241120",
    # v7 (Codex 재리뷰 ①②): v6 이 _GEOMETRY_TEXT_PACKS 미편입으로
    # 스케치 조립이 legacy MAP SLOT MEANINGS 분기로 하강하던 BLOCKING
    # 교정 + staging_geometry_head 를 마커 맵 권위와 정합(anchor=권위
    # 재서술, 분율=coarse audit — 어느 쪽도 마커 맵 override 불가).
    # v6 는 캔ary 2샷 소비라 불변.
    "7": "7.202607241150",
    # v8 (2026-07-25 사용자 확인 — lane 자연 연기 사각 봉합): v7 사본 +
    # naturalism_clause(shot_conti_light v5 원문 byte 사본 — 계약
    # 드리프트 방지). lane 스케치에만 없던 차렷·렌즈 응시 default 금지
    # 계약 이식(default-only — 명시 연출·pose 계약이 항상 우선).
    # v7=캔ary 소비라 불변.
    "8": "8.202607250817",
    # v9 (2026-07-25 사용자 지적①): marker_annotate 가 '원+직선 1개'
    # 만 그리게 해 화각·향한 방향이 맵에 나타나지 않던 결함 교정 —
    # CAM 에 시야 쐐기(좌/우 프레임 경계+틴트), 피사체에 facing 화살표
    # +짧은 설명 라벨. marker_check_judge 는 쐐기·화살표 검증 항목
    # 추가, sketch_head 는 마커 해석(쐐기=프레임 포함 범위, 화살표=
    # 몸 방향)을 정합. 나머지 스템은 v8 승계(byte 사본).
    # v8=캔ary 소비라 불변.
    "9": "9.202607251321",
    # v10 (2026-07-25 v9 캔ary 실측): 쐐기·화살표·라벨 작화 자체는
    # 성공했으나 i2i 가 **쐐기를 캔버스 안에 담으려고 CAM 을 지정 위치
    # (정류장 아래 포장)에서 도로 경계로 끌어내리고 E2 도 옆으로 밀어
    # marker_check 2회 소진. v10=마커 이동 금지 조항(쐐기/화살표가 캔버스
    # 밖으로 나가면 경계에서 자른다)+"피사체는 전부 쐐기 안" 명시.
    # 나머지 스템은 v9 승계(byte 사본). v9=캔ary 소비라 불변.
    "10": "10.202607251348",
    # v11 (2026-07-25 v10 캔ary 실측): 마커 맵 자체는 스케치 참조로
    # 충분한 품질(쐐기가 look target 쪽으로 열려 두 피사체를 포함,
    # 화살표·라벨 정확)이었는데 judge 가 "CAM 이 연석 위가 아닌 도로
    # 위", "쐐기 경계가 설명 지점을 벗어남" 같은 **측량 수준**을 요구해
    # 3회 소진했다. v11=판정 기준을 목적(작화가 배치를 옳게 읽는가)에
    # 맞춤 — 인접 구역 오차·쐐기 폭·화살표 길이는 통과, 마커 부재/다른
    # 구역/슬롯 뒤바뀜/방향 반대/피사체 쐐기 밖/플랜 훼손만 거부.
    # annotate 등 나머지 스템은 v10 승계. v10=캔ary 소비라 불변.
    "11": "11.202607251359",
    # v12 (2026-07-25 사용자 지적 — 콘티에서 외치는 인물이 달아나는
    # 인물 반대편을 봄): staging_geometry_head 를 개정해 "맵 분율은
    # top-down 장부이지 프레임 좌/우가 아니다 + 각 라인 끝의 화면 기준
    # 서술이 작화 기준"임을 명시. 나머지 스템은 v11 승계.
    "12": "12.202607251846",
    # v13 (2026-07-25 실측): 마커 맵이 쐐기 경계선·시선·화살표·라벨을
    # 모두 갖췄는데 **틴트 하나** 때문에 3회 소진(스케치는 마커 맵의
    # 배치를 읽지 틴트를 읽지 않는다 — 스타일 항목이 배치 검증을
    # 막았다). v13=판정에서 틴트/채움/색/선굵기 등 스타일 축을 명시
    # 제외하고 배치·방향·보존만 본다. annotate 등 나머지 스템은 v12
    # 승계(작화 지시로는 틴트를 계속 요구 — 있으면 가독성이 낫다).
    "13": "13.202607260218",
    # v14 (2026-07-26 사용자 확정 — 3변형 격리 실측): 같은 geometry·같은
    # 마커 맵에 STYLE 절만 바꾼 3변형에서 마네킹+사진O=정확 / 마네킹+
    # 사진X=정확 / 세밀인물+사진X=반대 — 방향 반전 원인은 데이터가 아니라
    # 렌더 스타일이다(얼굴·머리가 있으면 모델이 방향을 얼버무린다).
    # v14 = ① light_frame → mannequin_frame(인물=목각 인형, 방향 단서는
    # 가슴·골반·발뿐) ② plate_look 삭제(콘티 참조는 마커 맵 1장뿐 —
    # 배경은 뒤 단계에서 i2i 로) ③ 관할 명문화(수평 배치=staging 우선,
    # CAMERA DIRECTION=수직 각도/프레이밍) + staging head 열거에
    # side-on(profile) 형태 추가(geometry v4 선언 관계와 정합).
    # annotate/check 등 나머지 스템은 v13 승계 — 단 naturalism 만은
    # v14 전용 사본이다(무안면 마네킹에 'gaze/stare' 를 요구하면 STYLE
    # 과 정면 충돌 → 몸 방향 서술로 교체, 계약 자체는 동일).
    "14": "14.202607270344",
    # v15 (2026-08-25 사용자 지적 "마네킹이 3d 로 바뀐다고 그냥
    # 스케치여야하는데"): lane 콘티만 회색 음영 렌더 + 3D 목각
    # 마네킹으로 나왔다(젖은 노면 반사까지). 하류가 그 화풍을
    # 물려받아 배경 판의 마네킹이 3D 그대로였다. 원인 둘 —
    # ① 실내 팩(light_frame)에 있는 "Absolutely NO shading, NO
    # tone, NO texture …" 가 mannequin_frame 에는 없었다
    # ② "like a wooden posing figure" 가 3D 입체를 불렀다.
    # 수정 = 실내 팩의 실증된 STYLE 문장을 가져오고 목각 인형
    # 비유만 뺀다. **방향 문구는 한 줄도 안 건드렸다** — v14 가
    # 막던 실패(인물 방향 반전)를 되살리지 않기 위해서다.
    # A/B 실측(ABBA 3회차, 같은 마커 맵·나머지 6,700자 동일):
    # PNG 평균 A 3,119KB → B 2,017KB, 3회차 모두 B 가 단순.
    # 육안 판정: 흰 종이·가는 선·음영 없음, 사거리 구도와
    # 마네킹 방향 유지. 사용자 채택.
    "15": "15.202608251258",
    # v16 (2026-08-25 사용자 지시 "방향도 같이 수정하자 그게 먼저야"):
    # 마커 맵이 세 번 다 **같은 쪽으로** 틀렸다(S2sh1 — 부채꼴이 피사체
    # 반대편으로 열림). 원인은 모델이 아니라 문안이었다 —
    # ① annotate 가 "landmark-relative description 이 placement
    #    authority, 분율은 coarse estimate, 헷갈리면 랜드마크가 이긴다"
    #    라고 못박고 있었다.
    # ② 그 랜드마크 문구가 "south-east corner" 처럼 방위를 부르는데
    #    **이 도면에는 나침반도 「위가 북쪽」도 없다**. 모델은 방위를
    #    그림에 앉힐 기준이 없어 스스로 짐작했고, 매번 같은 짐작을 했다.
    # ③ 검사기 문안도 같은 말을 해서, 좌표가 중앙에 둔 피사체를
    #    "남동에 있어야 하는데 아니다" 로 반려했다(그리는 쪽과 재는
    #    쪽이 같이 틀렸으니 재시도가 소용없었다).
    # v16 = annotate·check_judge 두 스템에서 **좌표를 권위로** 뒤집고,
    # 랜드마크는 참고로 남기고(빼면 도면에서 자리를 짚을 재료가 없다),
    # 나침반 낱말은 이 그림에서 확인할 수 없는 말이라고 못박는다.
    # 나머지 스템은 v15 승계 — 화풍·마네킹 문구는 한 줄도 안 건드렸다.
    "16": "16.202608252310",
}

# seed_look stem 은 v3 만 — selector 별 조립 계약 (Stage C 교훈:
# versioned builder 는 selector 별 stem 구성까지 실행 계약). v4 는
# 레인1 전용(레인2=A/B 재설계로 스케치 소멸)이라 seed_look 없음.
_SEED_LOOK_PACKS = {"3"}

# v4+: geometry 를 텍스트 절로 전달하는 팩 (코드 마커 굽기 없음).
# v5 도 포함 — 마커 맵(i2i)과 STAGING GEOMETRY 텍스트가 같은 사실을
# 병기한다. v6/v7(Codex 재리뷰 BLOCKING-1): anchor 팩도 geometry-text
# 분기 — 미편입 시 legacy MAP SLOT MEANINGS 분기로 하강한다.
_GEOMETRY_TEXT_PACKS = {
    "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15",
    "16"}

# v8+: lane 스케치에 자연 연기 계약(naturalism_clause)이 실리는 팩 —
# v13 까지는 일반 콘티(shot_conti_light _CONDUCT_PACKS)와 byte 동일
# 문구. v14 는 팩 사본만 갈라진다 — 마네킹은 얼굴이 없어 눈/시선
# 어휘가 무의미하므로 몸(가슴·머리 타원) 방향 서술로 바꿨다.
# default-only(명시 연출·pose 우선) 계약은 전 팩 공통.
_NATURALISM_SKETCH_PACKS = {"8", "9", "10", "11", "12", "13", "14", "15",
                            "16"}

# v9+: 시야 쐐기·facing 화살표 작화/검증 계약이 실리는 팩 — geometry
# v3(_VIEW_GEOMETRY_PACKS)와 짝. 팩 짝이 어긋나면 annotate 조립이
# 없는 필드를 참조하므로 소비자에서 fail-closed.
_VIEW_SKETCH_PACKS = {"9", "10", "11", "12", "13", "14", "15", "16"}

# v9+: lane 스케치의 2번째 참조=장소 실사(플레이트) 조항이 실리는 팩.
# 2026-07-25 사용자 스펙 케이스1 단계 C·D — lane 샷만 배경 권위 없이
# 그려져 같은 장소 일반 샷과 배경이 어긋나던 실측 결함(S15sh1 vs
# S15sh5) 교정.
# v14 는 사진 참조 계약이 없다 — 확정 흐름(2026-07-26 사용자)에서 콘티
# 참조는 마커 맵 1장뿐이다. 집합에 넣지 않는 것이 곧 가드다
# (build_marker_sketch_prompt 의 plate_attached 검사).
_PLATE_LOOK_PACKS = {"9", "10", "11", "12", "13"}

# 마네킹 스타일 팩 — 인물을 목각 인형으로만 그린다. 이 산출은 배경/엔티티
# 단계의 마네킹 교체 계약과 **짝으로만** 유효하다. 짝이 없는 경로로 흘러
# 가면 마네킹이 최종 스틸까지 유출된다.
_MANNEQUIN_SKETCH_PACKS = {"14", "15", "16"}

# v5+: 스케치 참조=i2i 마커 맵 — build_marker_annotate_prompt /
# marker_check_judge 스템이 존재하는 팩 (2026-07-24 사용자 지시:
# '맵 위에 카메라·엔티티 표시' 단계는 필수, 구현은 i2i).
_ANNOTATE_PACKS = {"5", "6", "7", "8", "9", "10", "11", "12", "13", "14",
                   "15", "16"}

# anchor 배치 권위 계약이 실리는 스케치 팩 (v6+): annotate/check 가
# geometry anchor 를 required 로 소비.
_ANCHOR_SKETCH_PACKS = {"6", "7", "8", "9", "10", "11", "12", "13", "14",
                        "15", "16"}

# v16+: **좌표가 배치 권위**이고 anchor 는 참고인 팩. v6~v15 는 반대
# (anchor 가 권위, 좌표는 coarse estimate)로 발행됐고 그 문안은 팩 스템에
# 박혀 있다 — 코드 표기만 바꾸면 스템과 본문이 서로 다른 말을 하게 되므로
# **버전으로 가른다**. 옛 팩으로 만든 프롬프트는 바이트 그대로 유지된다.
_COORD_AUTHORITY_PACKS = {"16"}


def _coord_first(prompt_version: str) -> bool:
    """이 팩에서 좌표가 권위인가 — 표기 갈래를 정하는 유일한 자리."""
    return prompt_version in _COORD_AUTHORITY_PACKS


def marker_check_user_header(prompt_version: str) -> str:
    """마커 검사 user 턴 머리말 — 무엇이 권위인지 **한 번** 말한다.

    ★스텝이 이 문장을 손으로 들고 있었고, 팩을 v16 으로 올려도 머리말은
     "fractions are coarse estimates" 라고 옛말을 하고 있었다. 팩 버전과
     함께 움직이도록 여기로 옮긴다.
    """
    if _coord_first(prompt_version):
        return ("Expected staging — the coordinates are the authority "
                "(x runs left→right 0..1, y runs top→bottom 0..1 of the "
                "canvas); a named landmark is only a pointer to the same "
                "spot:")
    return ("Expected staging (each marker described relative to "
            "landmarks visible on the plan; fractions are coarse "
            "estimates, x left→right 0..1, y top→bottom 0..1):")

# 마커 작화·검증 계약 버전 (Codex HIGH-3): check 스키마 shape·2이미지
# (원본+마커 맵) 비교 payload·재시도 정책이 실질 판정 입력 — 바뀌면
# bump (config hash·sidecar 지문 스탬프 대상).
# 2 (2026-07-24 Codex ②): 배치 권위=anchor/검증 통과 마커 맵, 좌표=
# coarse estimate 강등.
# 3 (2026-07-25 지적①): 작화·검증 대상에 시야 쐐기·facing 화살표 추가.
MARKER_ANNOTATE_CONTRACT_VERSION = 3

# 마커 작화 i2i 캔버스 계약 (Codex BLOCKING-1): canon 맵 생성 계약
# (outdoor_place_canon_step, 1:1)과 동일 크기 — 16:9 스케치 캔버스로
# edit 하면 '동일 맵에 마커만 추가'와 normalized 좌표계가 깨진다.
MARKER_MAP_SIZE = "1024x1024"

# R5: geometry→텍스트 직렬화 계약 버전 — 표기 형식이 바뀌면 bump
# (사이드카 지문·config hash 스탬프 대상).
# v2 (배치 리뷰 HIGH-6): 고정 수평 화각 56° 수치 제거 — 구 PIL 콘의
# 임의 상수를 텍스트 SOT 로 승계하는 것은 하드코딩 금지 위반이자 샷별
# lens/framing(camera_direction SOT)과 충돌. 화각·프레이밍은
# camera_direction·샷 텍스트에 위임.
# v3 (2026-07-24 Codex ②): anchor 병기 — 좌표는 coarse estimate 로
# 강등(마커 맵=raster 배치 SOT), anchor 존재 시 서술을 선행 표기.
# v4 (2026-07-25 지적①): 시야 쐐기(좌/우 프레임 경계)·피사체 facing
# 직렬화 — geometry v3 데이터가 있을 때만 표기(없으면 v3 byte-동일).
# v5 (2026-07-25 사용자 지적): 맵 좌표 facing 이 화면 좌/우로 변환되지
# 않아 작화가 방향을 뒤집었다 — 카메라 기준 투영(프레임 좌/우·깊이·
# 시선 대상)을 같은 라인에 병기.
# v6 (2026-07-26): 심도 문구가 앞/뒤를 명시하고(중의어 "back toward the
# camera" 제거), 선언 relation 이 있으면 eps 추론 대신 그 값을 문장화.
# 시선 대상 병기도 동격절이 아니라 주어를 명시한 독립절이다(리뷰 지적
# Important-1).
# ★bump 필수 — 심도 문구는 relation 필드가 없는 구 geometry 에서도
# 바뀌므로 전 팩의 실질 입력이 달라진다. completed CP 는 sidecar 지문을
# 타지 않아 이 상수의 hash 스탬프가 유일한 무효화 경로다
# (test_config_hash_stamps_geometry_text_version).
# ★단 v6 **안에서의** 교정(위 독립절)은 재bump 하지 않는다 — v6 은 이
# 브랜치의 직전 커밋에서 처음 도입돼 아직 어떤 CP 에도 스탬프된 적이
# 없고, 구 팩(≤5) CP 는 6 으로의 상승만으로 이미 전량 무효화된다.
GEOMETRY_TEXT_VERSION = 6


def _place_text(pt: Dict[str, Any], anchor: str,
                *, coord_first: bool = False) -> str:
    """쐐기 경계·facing 이 가리키는 한 점.

    ★2026-08-25 방향 수리 — 이 한 줄이 **두 프롬프트에 같이 쓰인다.**
     두 프롬프트에서 권위가 서로 다르므로 표기도 갈라야 한다:

       마커 맵 (`coord_first=True`)  좌표가 권위. 도면 위에 동그라미를
         찍는 일이고, 그 도면에는 나침반이 없다. "past the west edge
         (coarse estimate x=0.20, y=0.30)" 라고 쓰면 그림에서 짚을 수
         없는 낱말이 짚을 수 있는 값을 이긴다 — 부채꼴이 세 번 다
         반대편으로 열린 자리가 바로 여기다(S2sh1).
       콘티 스케치 (기본)  **마커 맵 그림이 권위**다. 좌표는 감사용
         병기이고, staging head 가 "neither ever overrides the marked
         plan" 이라고 못박아 두었다. 여기까지 좌표를 권위로 바꾸면 그
         문장과 정면으로 부딪히고, 위에서 본 좌표를 화면 좌우로 읽는
         옛 반전 결함(v11)을 되살린다. 그래서 **기본값은 그대로 둔다.**
    """
    if coord_first:
        return _at_xy(pt, anchor)
    coarse = f"x={pt['x']:.2f}, y={pt['y']:.2f}"
    return (
        f"{anchor} (coarse estimate {coarse})" if anchor
        else f"({coarse})"
    )


def _view_wedge_text(cam: Dict[str, Any], *,
                     coord_first: bool = False) -> str:
    """시야 쐐기 서술 — geometry v3 데이터 부재 시 빈 문자열(구 팩 불변)."""
    vl, vr = cam.get("view_left"), cam.get("view_right")
    if not isinstance(vl, dict) or not isinstance(vr, dict):
        return ""
    try:
        left = _place_text(
            vl, str(cam.get("view_left_anchor_en") or "").strip(),
            coord_first=coord_first)
        right = _place_text(
            vr, str(cam.get("view_right_anchor_en") or "").strip(),
            coord_first=coord_first)
    except (KeyError, TypeError):
        return ""
    return (
        f"the frame's LEFT edge cuts through {left} and its RIGHT edge "
        f"through {right}"
    )


def _facing_text(pl: Dict[str, Any], *, coord_first: bool = False) -> str:
    """피사체가 향한 쪽 서술 — v3 데이터 부재 시 빈 문자열."""
    f = pl.get("facing")
    if not isinstance(f, dict):
        return ""
    try:
        return "facing " + _place_text(
            f, str(pl.get("facing_anchor_en") or "").strip(),
            coord_first=coord_first)
    except (KeyError, TypeError):
        return ""


# ── 카메라(화면) 기준 변환 — v5 (2026-07-25 사용자 지적: 콘티에서
# 외치는 인물이 달아나는 인물 반대쪽을 봄) ─────────────────────────
# 원인: geometry 는 **맵 좌표계**로 facing 을 말하는데("facing x=0.64,
# y=0.46") 이미지 모델이 그것을 카메라 뷰의 좌/우로 변환하지 못한다.
# 코드가 순수 기하로 화면 축에 투영해 좌/우·깊이를 병기한다(좌표 변환
# 이지 의미 판단이 아니다). 실측 대조: v8(facing 없음)=정상 작화,
# v11(맵 좌표 facing 병기)=반전 — 정보 추가가 오히려 방향을 꼬았다.
# ★v4(2026-07-26)부터 이 값은 산문 임계값만이 아니다 —
# _check_facing_relation 의 away/toward/profile accept·reject 경계까지
# 정의하는 validator 계약이다. 바꾸면 세 구간이 함께 움직여 기존 통과
# geometry 가 거부(또는 그 반대)로 뒤집히므로 _check_facing_relation 과
# 그 구간 테스트를 반드시 재검증할 것.
_SCREEN_EPS = 0.25

# 선언 relation → 화면 서술 (렌더링 전용 매핑, 의미 판단 아님)
_FACING_RELATION_PHRASES = {
    "away_from_camera": (
        "away from the camera, into the depth of the shot, so the camera "
        "sees this subject from behind"
    ),
    "toward_camera": (
        "toward the camera, so the camera sees this subject from the front"
    ),
    "profile": (
        "side-on to the camera, neither toward it nor away from it"
    ),
}
# 리뷰 지적 Minor-2: 매핑 키 = enum − {unspecified} 를 상수 바로 옆에서
# 고정한다. 어긋나면 (a) 매핑에 없는 관계를 조회해 KeyError 로 직렬화가
# 통째로 죽거나 (b) enum 에만 추가된 관계가 조용히 eps 추론으로 강등된다
# — 둘 다 import 시점에 잡는다 (core/perception_mode.py invariant 관례).
assert set(_FACING_RELATION_PHRASES) == (
    set(CAMERA_FACING_RELATIONS) - {"unspecified"})


def _screen_basis(cam: Dict[str, Any]):
    """(forward, right) 단위벡터 — 맵은 y 가 아래로 증가하므로 카메라가
    북(위)을 보면 forward=(0,-1), right=(+1,0)=동쪽=화면 오른쪽."""
    try:
        ox, oy = cam["origin"]["x"], cam["origin"]["y"]
        dx = cam["look_target"]["x"] - ox
        dy = cam["look_target"]["y"] - oy
    except (KeyError, TypeError):
        return None
    norm = math.hypot(dx, dy)
    if norm < 1e-9:
        return None
    fwd = (dx / norm, dy / norm)
    return fwd, (-fwd[1], fwd[0])


def _screen_side_text(cam: Dict[str, Any], pl: Dict[str, Any]) -> str:
    """피사체가 프레임의 어느 쪽에 서는지 (순수 투영)."""
    basis = _screen_basis(cam)
    if basis is None:
        return ""
    fwd, right = basis
    try:
        rel = (pl["x"] - cam["origin"]["x"], pl["y"] - cam["origin"]["y"])
    except (KeyError, TypeError):
        return ""
    depth = rel[0] * fwd[0] + rel[1] * fwd[1]
    if depth <= 0:
        return ""  # 카메라 뒤 — 쐐기 검사가 이미 보고
    lateral = (rel[0] * right[0] + rel[1] * right[1]) / depth
    if lateral > 0.18:
        return "in the RIGHT part of the frame"
    if lateral < -0.18:
        return "in the LEFT part of the frame"
    return "near the CENTER of the frame"


def facing_camera_depth(
    cam: Dict[str, Any], pl: Dict[str, Any],
) -> Optional[float]:
    """피사체 facing 의 카메라 축 성분 (-1..+1).

    양수 = 카메라에서 멀어지는 쪽(카메라는 등을 본다),
    음수 = 카메라 쪽으로 도는 쪽(카메라는 앞을 본다),
    0 근처 = 카메라 축에 직각(측면).

    좌표가 없거나 facing 이 자기 위치와 같으면 None — 순수 벡터 연산이며
    의미 판단은 하지 않는다.
    """
    basis = _screen_basis(cam)
    if basis is None or not isinstance(pl.get("facing"), dict):
        return None
    fwd, _right = basis
    try:
        vec = (pl["facing"]["x"] - pl["x"], pl["facing"]["y"] - pl["y"])
    except (KeyError, TypeError):
        return None
    norm = math.hypot(*vec)
    if norm < 1e-9:
        return None
    return (vec[0] / norm) * fwd[0] + (vec[1] / norm) * fwd[1]


def _check_facing_relation(
    cam: Dict[str, Any], pl: Dict[str, Any], label: str, shot_text: str,
    *, check_provenance: bool = True,
) -> List[str]:
    """선언된 대면 관계 ↔ 좌표 정합 + 근거 provenance (v4).

    구간은 하나의 named epsilon(_SCREEN_EPS)으로 **완전 비중첩**이다:
      away_from_camera : dep > +eps
      toward_camera    : dep < -eps
      profile          : abs(dep) <= eps
    겹치게 정의하면(예: away 를 dep>0 으로) eps=0.25·dep=0.1 이 away 와
    profile 양쪽을 통과해 보증이 무너진다.

    evidence 는 필드 존재로 통과시키지 않는다 — 명시 relation 이면 SHOT
    TEXT 원문에 실재하는 인용이어야 하고, unspecified 면 비어 있어야
    한다. substring 으로 의미를 판정하는 것이 아니라 LLM 이 제시한 인용의
    진위만 확인하는 결정론 게이트다(outdoor_lane_plan._check_evidence 와
    동형).

    check_provenance=False (리뷰 지적 Important-2): 인용 대조 **만**
    건너뛴다 — enum·evidence 타입·unspecified 규칙·좌표 정합은 항상
    검증한다. 저작 경로가 아닌 소비자(build_geometry_text_lines 등)는
    SHOT TEXT 를 갖고 있지 않아 대조 자체가 성립하지 않는데, 그 이유로
    좌표 정합 검사까지 통째로 꺼 두면 오염 relation 이 그대로 통과한다.
    """
    from app.modules.pipeline.outdoor_lane_plan import ws_normalize

    rel = pl.get("camera_facing_relation")
    if rel not in CAMERA_FACING_RELATIONS:
        return [f"{label} camera_facing_relation 이 계약 값 아님: {rel!r}"]
    ev_raw = pl.get("camera_facing_evidence")
    if not isinstance(ev_raw, str):
        return [f"{label} camera_facing_evidence 가 문자열 아님"]
    ev = ws_normalize(ev_raw)
    out: List[str] = []
    if rel == "unspecified":
        if ev:
            out.append(
                f"{label} camera_facing_relation=unspecified 인데 "
                "camera_facing_evidence 가 비어 있지 않음 — 근거만 있고 "
                "선언이 없는 상태 금지"
            )
        return out
    if check_provenance:
        if not ev:
            out.append(
                f"{label} camera_facing_relation={rel} 인데 "
                "camera_facing_evidence(SHOT TEXT 원문 인용) 누락"
            )
        elif ev not in ws_normalize(shot_text or ""):
            out.append(
                f"{label} camera_facing_evidence 인용이 SHOT TEXT 원문에 없음 "
                f"— 원문 그대로 인용할 것: {ev!r}"
            )
    dep = facing_camera_depth(cam, pl)
    if dep is None:
        out.append(
            f"{label} facing 으로 카메라 축 성분을 계산할 수 없음 "
            "(facing/좌표 결손 또는 자기 위치와 동일)"
        )
        return out
    ok = {
        "away_from_camera": dep > _SCREEN_EPS,
        "toward_camera": dep < -_SCREEN_EPS,
        "profile": abs(dep) <= _SCREEN_EPS,
    }[rel]
    if not ok:
        out.append(
            f"{label} camera_facing_relation={rel} 인데 facing 좌표의 "
            f"카메라 축 성분이 {dep:.3f} — 관계를 만족하지 않는다 "
            f"(away>{_SCREEN_EPS}, toward<-{_SCREEN_EPS}, "
            f"profile |dep|<={_SCREEN_EPS}). SHOT TEXT 가 말한 대면 "
            "관계대로 facing 점을 옮길 것"
        )
    return out


def _facing_screen_text(cam: Dict[str, Any], pl: Dict[str, Any]) -> str:
    """피사체가 향한 쪽을 **화면 기준**으로.

    좌/우는 언제나 순수 투영이고, 카메라 대면(앞/뒤/측면)만 선언
    relation 이 있으면 그 값에서 렌더한다 — 여기 도달한 선언값은
    _check_facing_relation 이 좌표와 정합을 확인한 뒤이므로 eps 추론보다
    권위다. 코드가 의미를 새로 판정하는 것이 아니라 검증 통과한 선언을
    문장으로 옮길 뿐이다.
    """
    basis = _screen_basis(cam)
    if basis is None or not isinstance(pl.get("facing"), dict):
        return ""
    fwd, right = basis
    try:
        vec = (pl["facing"]["x"] - pl["x"], pl["facing"]["y"] - pl["y"])
    except (KeyError, TypeError):
        return ""
    norm = math.hypot(*vec)
    if norm < 1e-9:
        return ""
    vec = (vec[0] / norm, vec[1] / norm)
    lat = vec[0] * right[0] + vec[1] * right[1]
    dep = vec[0] * fwd[0] + vec[1] * fwd[1]
    horiz = (
        "toward the RIGHT of the frame" if lat > _SCREEN_EPS
        else "toward the LEFT of the frame" if lat < -_SCREEN_EPS
        else ""
    )
    # v14/v4 (2026-07-26): 앞/뒤를 명시한다. 구 문구 "back toward the
    # camera" 는 부사("다시 카메라 쪽으로")와 신체부위("등을 카메라로")로
    # 갈려 읽혔다 — 마네킹은 얼굴이 없어 몸통 방향이 유일한 신호라
    # 앞/뒤 오독이 곧 반전이다.
    rel = pl.get("camera_facing_relation")
    # 멤버십 판정 대상 = 매핑 자체 (리뷰 지적 Minor-2). 키 목록을 따로
    # 나열하면 두 곳이 갈라져 조회가 KeyError 를 내거나 새 관계가 조용히
    # eps 로 강등된다 — 위 module-level assert 와 짝.
    if rel in _FACING_RELATION_PHRASES:
        # 선언값은 validator 가 좌표와 정합을 확인한 뒤에만 도달한다
        # (Task 1) — eps 추론보다 이것이 권위다. unspecified·필드 부재·
        # 계약 밖 문자열은 매핑에 키가 없어 아래 eps 경로로 흐른다
        # (조회로 예외를 내지 않는다 — enum 거부는 validator 의 몫).
        depth = _FACING_RELATION_PHRASES[rel]
    elif dep > _SCREEN_EPS:
        depth = (
            "away from the camera, into the depth of the shot, so the "
            "camera sees this subject from behind"
        )
    elif dep < -_SCREEN_EPS:
        depth = (
            "toward the camera, so the camera sees this subject from "
            "the front"
        )
    else:
        depth = ""
    if horiz and depth:
        return f"{horiz} and {depth}"
    return horiz or depth or "across the frame"


def _facing_subject_en(
    placements: Any, pl: Dict[str, Any], tol: float = 0.06,
) -> str:
    """facing 좌표가 다른 피사체의 위치와 사실상 같은 점이면 그 피사체를
    보는 것 — 좌표 근접 판정(의미 판단 아님)."""
    face = pl.get("facing")
    if not isinstance(face, dict):
        return ""
    for other in placements or []:
        if other is pl or not isinstance(other, dict):
            continue
        try:
            if math.hypot(
                other["x"] - face["x"], other["y"] - face["y"],
            ) <= tol:
                return str(other.get("subject_en") or "").strip()
        except (KeyError, TypeError):
            continue
    return ""


def build_geometry_text_lines(geometry: Dict[str, Any]) -> List[str]:
    """geometry JSON → ID-free planning prose 라인 (R5 — 순수 직렬화).

    슬롯 코드(E1..)/CAM/marker/wedge 토큰을 이미지 모델에 노출하지
    않는다 — 금지 대상을 프라이밍하지 않기 위함(Codex HIGH-5, validator
    가 subject_en 의 구조 토큰을 reject 해 봉인). 좌표 표기는
    normalized (x=?, y=?) 소수 2자리. 코드는 의미 판단 없이 데이터
    직렬화만 수행한다.
    """
    violations = validate_marker_geometry(geometry)
    if violations:
        raise ValueError(f"invalid geometry: {'; '.join(violations)}")
    cam = geometry["camera"]
    ox, oy = cam["origin"]["x"], cam["origin"]["y"]
    tx, ty = cam["look_target"]["x"], cam["look_target"]["y"]
    o_anchor = str(cam.get("origin_anchor_en") or "").strip()
    t_anchor = str(cam.get("look_target_anchor_en") or "").strip()
    # v4: 쐐기가 저작됐으면 추상 문구 대신 실제 좌/우 경계를 서술
    # (미저작 팩은 아래 tail 이 기존 문장과 byte-identical)
    wedge = _view_wedge_text(cam)
    fov_tail = (
        f"; {wedge} — everything between those two edges is in frame, "
        "anything outside them is out of frame"
        if wedge else
        "; subjects outside the camera's horizontal field of view are "
        "out of frame"
    )
    if o_anchor and t_anchor:
        cam_line = (
            f"- the camera stands {o_anchor} (coarse estimate x={ox:.2f}, "
            f"y={oy:.2f}); its sight line ends at LOOK TARGET: {t_anchor} "
            f"(coarse estimate x={tx:.2f}, y={ty:.2f}){fov_tail}"
        )
    else:
        cam_line = (
            f"- the camera stands at (x={ox:.2f}, y={oy:.2f}) and looks "
            f"toward (x={tx:.2f}, y={ty:.2f}){fov_tail}"
        )
    lines = [cam_line]
    placements = geometry["entity_placements"]
    for pl in placements:
        anchor = str(pl.get("anchor_en") or "").strip()
        facing = _facing_text(pl)
        tail = f", {facing}" if facing else ""
        # v5: 맵 좌표만으로는 이미지 모델이 화면 좌/우를 뒤집는다(실측
        # — 외치는 인물이 달아나는 인물 반대편을 봄). 같은 사실을
        # **카메라 기준**으로 한 번 더 명시한다. 순수 투영 결과이며,
        # 맵/마커가 여전히 권위다.
        if facing:
            # ★화면 기준 병기는 v3(facing) 데이터가 있을 때만 — 구 팩
            # (v1/v2)은 facing 이 없으므로 이 절 자체가 붙지 않아
            # 출력이 byte-identical 로 남는다 (Codex HIGH-5 교정: 이전
            # 판은 origin/look_target 만으로도 붙어 구 팩을 오염시켰다).
            screen_side = _screen_side_text(cam, pl)
            if screen_side:
                tail += f" — from this camera the subject is {screen_side}"
            screen_face = _facing_screen_text(cam, pl)
            if screen_face:
                looks_at = _facing_subject_en(placements, pl)
                # v6 리뷰 지적 Important-1: 시선 대상을 동격절(" — that
                # is, at ...")로 달면 바로 앞 "the camera sees this
                # subject from behind" 가 선행사로 읽혀 **카메라가** 겨눈
                # 대상으로 오독된다(선언 relation 경로에서는 profile 도
                # 심도 문구를 내므로 이 3중 결합이 더 자주 생긴다).
                # 주어를 명시한 독립절로 자기완결시켜, 심도 중의성을 지운
                # 자리에 새 중의성이 들어서지 않게 한다. 마네킹은 얼굴이
                # 없어 몸 방향이 유일한 신호라 'gaze/eyes' 대신 몸이
                # 돌아선 방향(turned toward)으로 서술한다.
                tail += (
                    f" and faces {screen_face}"
                    + (f"; this subject is turned toward the {looks_at}"
                       if looks_at else "")
                )
        if anchor:
            lines.append(
                f"- {pl['subject_en']} stands {anchor} "
                f"(coarse estimate x={pl['x']:.2f}, y={pl['y']:.2f}){tail}"
            )
        else:
            lines.append(
                f"- {pl['subject_en']} stands at "
                f"(x={pl['x']:.2f}, y={pl['y']:.2f}){tail}"
            )
    return lines


def build_marker_annotate_prompt(
    *, geometry: Dict[str, Any], prompt_version: str,
) -> str:
    """i2i 마커 작화 프롬프트 (v5+) — 참조=클린 canon 맵 1장.

    2026-07-24 사용자 지시: '맵 위에 카메라 위치+엔티티 표시' 단계는
    필수이며 구현은 이미지 모델 i2i — 코드(PIL) 드로잉 금지의 해법은
    단계 제거가 아니다. 이 프롬프트만 슬롯 라벨(CAM/E1..)을 노출한다:
    작화 대상이 마커 그 자체이기 때문. 스케치 경로의 ID-free 직렬화
    (build_geometry_text_lines)와 의도적으로 분리.
    """
    from app.modules.prompt_loader import load_prompt

    violations = validate_marker_geometry(geometry)
    if violations:
        raise ValueError(f"invalid geometry: {'; '.join(violations)}")
    if prompt_version not in _ANNOTATE_PACKS:
        raise ValueError(
            f"marker_map_sketch v{prompt_version} 팩에는 marker_annotate "
            "계약이 없음 — i2i 마커 맵은 v5+ 전용")
    resolved = resolve_sketch_pack_version(prompt_version)
    anchored = prompt_version in _ANCHOR_SKETCH_PACKS
    # v9: 쐐기·facing 작화 계약 — geometry v3 데이터를 요구한다
    viewed = prompt_version in _VIEW_SKETCH_PACKS
    if anchored:
        # v6 (Codex ②): 배치 권위=anchor — 필수 검증 후 마커별
        # anchor 문장+coarse 분율 병기. rationale 병기는 폐기(감사
        # 전용 강등 — per-marker anchor 와 경쟁 금지).
        anchor_violations = validate_marker_geometry(
            geometry, require_anchors=True, require_view=viewed)
        if anchor_violations:
            raise ValueError(
                "anchored annotate 요구 계약 위반: "
                + "; ".join(anchor_violations))
    cam = geometry["camera"]
    # v16+ 만 좌표가 권위다 — 옛 팩은 스템이 "landmark 가 권위" 라고
    # 못박고 있으므로 본문 표기도 그때 그대로 둔다(발행본 불변).
    coord_first = _coord_first(prompt_version)
    if anchored:
        cam_line = (
            "- CAM (camera): circle "
            + _place_text(cam["origin"],
                          str(cam.get("origin_anchor_en") or ""),
                          coord_first=coord_first)
            + "; its sight line ends at LOOK TARGET: "
            + _place_text(cam["look_target"],
                          str(cam.get("look_target_anchor_en") or ""),
                          coord_first=coord_first)
        )
        if viewed:
            # 쐐기 경계 2점 — 작화 지시(marker_annotate v9)의 대상
            cam_line += "; " + _view_wedge_text(cam, coord_first=coord_first)
        lines = [cam_line]
        for pl in geometry["entity_placements"]:
            # v9: 라벨에 슬롯 코드+짧은 설명 (사용자 지적① — 마커만
            # 보고는 어떤 엔티티인지 알 수 없었다)
            head = (
                f"- {pl['slot']} ({pl['subject_en']}): circle "
                if viewed else f"- {pl['slot']}: circle "
            )
            line = head + _place_text(
                {"x": pl["x"], "y": pl["y"]},
                str(pl.get("anchor_en") or ""), coord_first=coord_first)
            if viewed:
                line += ", " + _facing_text(pl, coord_first=coord_first)
            lines.append(line)
    else:
        lines = [
            (
                "- CAM (camera): circle at "
                f"(x={cam['origin']['x']:.2f}, y={cam['origin']['y']:.2f}), "
                "sight line toward "
                f"(x={cam['look_target']['x']:.2f}, "
                f"y={cam['look_target']['y']:.2f})"
            ),
        ]
        for pl in geometry["entity_placements"]:
            lines.append(
                f"- {pl['slot']}: circle at "
                f"(x={pl['x']:.2f}, y={pl['y']:.2f})"
            )
    parts = [
        load_prompt(SKETCH_PACK_MODULE, "marker_annotate",
                    version=resolved).strip(),
        "MARKERS TO DRAW:\n" + "\n".join(lines),
    ]
    if not anchored:
        # v5 경로 보존: rationale 배치 의도 병기 (발행본 계약 불변)
        intent = str(geometry.get("rationale_ko") or "").strip()
        if intent:
            parts.append(
                "PLACEMENT INTENT (authoritative cross-check, Korean — "
                "the coordinates above and this intent describe the same "
                "staging; if a marker seems to land elsewhere, re-measure "
                "the fractions): " + intent)
    return "\n\n".join(parts)


def load_marker_check_sys(prompt_version: str) -> str:
    """마커 맵 VLM 검증 system (v5+) — 판정 호출은 caller."""
    from app.modules.prompt_loader import load_prompt

    if prompt_version not in _ANNOTATE_PACKS:
        raise ValueError(
            f"marker_map_sketch v{prompt_version} 팩에는 marker_check "
            "계약이 없음 — i2i 마커 맵은 v5+ 전용")
    resolved = resolve_sketch_pack_version(prompt_version)
    return load_prompt(
        SKETCH_PACK_MODULE, "marker_check_judge", version=resolved).strip()


def build_marker_check_user_lines(
    geometry: Dict[str, Any], *, require_anchors: bool = False,
    require_view: bool = False, coord_first: bool = False,
) -> List[str]:
    """마커 검증 user 턴의 기대 배치 직렬화 (annotate 와 동일 표기).

    require_anchors (Codex 재리뷰 HIGH-3): anchor 팩 소비자는 True 로
    호출 — partial/missing anchor 가 수치 경로로 조용히 하강하지 않고
    ValueError (public builder 재검증 계약의 독립 성립).
    require_view (v3+): 쐐기·facing 팩 소비자는 True — 같은 이유로
    부분 결손이 구 표기로 하강하지 않는다.

    ★2026-08-25 방향 수리 — 종전에는 **검사기 문안만** anchor 산문을 앞에
     세우고 좌표를 "coarse estimate" 로 뒤에 달았다. 그리는 쪽은 좌표가
     권위인데 재는 쪽은 산문이 권위여서, 기하가 중앙에 둔 피사체를 검사기가
     "남동에 있어야 하는데 아니다" 로 반려하는 일이 실제로 났다. 지도에는
     나침반이 없으므로 「남동」은 그림 위에서 확인할 수 없는 말이다.
     그래서 그리는 쪽이 쓰는 표기(`_at_xy`)를 **그대로** 쓴다 — 좌표가
     권위, 화면 구역이 그 좌표를 눈으로 짚는 말, anchor 는 참고.
     ★anchor 를 빼지는 않는다. 나침반이 없는 도면에서 자리를 짚어 주는
      유일한 재료다(빼면 좌표만 남아 그림 위에서 못 찾는다).
    """
    violations = validate_marker_geometry(
        geometry, require_anchors=require_anchors,
        require_view=require_view)
    if violations:
        raise ValueError(f"invalid geometry: {'; '.join(violations)}")
    cam = geometry["camera"]
    o_anchor = str(cam.get("origin_anchor_en") or "").strip()
    t_anchor = str(cam.get("look_target_anchor_en") or "").strip()
    wedge = _view_wedge_text(cam, coord_first=coord_first)
    wedge_tail = f"; its view wedge is such that {wedge}" if wedge else ""
    if o_anchor and t_anchor:
        lines = [
            (
                "- CAM expected "
                + _place_text(cam["origin"], o_anchor,
                              coord_first=coord_first)
                + "; its sight line ends at LOOK TARGET: "
                + _place_text(cam["look_target"], t_anchor,
                              coord_first=coord_first)
                + wedge_tail
            ),
        ]
    else:
        lines = [
            (
                "- CAM expected at "
                f"(x={cam['origin']['x']:.2f}, y={cam['origin']['y']:.2f}), "
                "sight line toward "
                f"(x={cam['look_target']['x']:.2f}, "
                f"y={cam['look_target']['y']:.2f}){wedge_tail}"
            ),
        ]
    for pl in geometry["entity_placements"]:
        anchor = str(pl.get("anchor_en") or "").strip()
        facing = _facing_text(pl, coord_first=coord_first)
        tail = f", {facing}" if facing else ""
        if anchor:
            lines.append(
                f"- {pl['slot']} expected "
                + _place_text({"x": pl["x"], "y": pl["y"]}, anchor,
                              coord_first=coord_first)
                + tail
            )
        else:
            lines.append(
                f"- {pl['slot']} expected at "
                f"(x={pl['x']:.2f}, y={pl['y']:.2f}){tail}"
            )
    return lines


def _screen_zone_en(x: float, y: float) -> str:
    """좌표를 **그림에서 어디인가**로 옮긴다 — 방위가 아니라 화면 위치.

    ★2026-08-25 실측: 이 도면에는 방위 표시가 없다(나침반도, "위가 북쪽"도
     없다). 그런데 문안은 "southeast" 를 계속 불렀다 — 모델은 그 말을 그림에
     앉힐 기준이 없어 **스스로 짐작**했고, 여섯 번 다 같은 짐작을 했다.
     좌표는 처음부터 정확했으므로 그것을 그림 좌표 그대로 말한다.
    ★수치를 말로 바꾸는 것이지 글자의 뜻을 재는 것이 아니다.
    """
    ver = "upper" if y < 0.34 else ("lower" if y > 0.66 else "middle")
    hor = "left" if x < 0.34 else ("right" if x > 0.66 else "center")
    if ver == "middle" and hor == "center":
        return "the center of the plan"
    if ver == "middle":
        return f"the {hor} edge area of the plan"
    if hor == "center":
        return f"the {ver} middle of the plan"
    return f"the {ver}-{hor} area of the plan"


def _at_xy(point: Dict[str, Any], anchor_en: str = "") -> str:
    """마커 한 자리 — **좌표가 권위**이고 랜드마크 서술은 참고다.

    종전에는 랜드마크 서술(방위 낱말 포함)이 앞에 오고 좌표가 "coarse
    estimate" 라는 이름으로 괄호에 들어갔다. 「대략적인 추정」이라 불린 값이
    실제로는 유일하게 정확한 값이었고, 앞의 방위 낱말이 그것을 이겼다.
    순서를 뒤집고 이름을 바로잡는다.

    ★축 관례와 「좌표가 권위」는 여기서 되풀이하지 않는다 — 팩 머리말
     (marker_annotate·marker_check_judge v16)과 고르기 문안이 **한 번씩**
     말한다. 마커 한 자리마다 붙이면 한 프롬프트에 예닐곱 번 실린다.
    """
    x = float(point["x"])
    y = float(point["y"])
    out = f"at x={x:.2f}, y={y:.2f} — {_screen_zone_en(x, y)}"
    if anchor_en.strip():
        # 랜드마크는 남긴다 — 걷어내면 「무엇 옆인가」를 그릴 재료가 사라진다.
        # 다만 좌표와 어긋나 보이면 좌표가 이긴다고 머리말이 못박았다.
        out += f" (landmark, for reference: {anchor_en.strip()})"
    return out


# 어긋날 수 있는 자리 = **데이터 계약**. 무엇이 어긋났는지는 VLM 이 말하고,
# 코드는 이 목록의 개수만 센다(2026-08-25 사용자 지시 "3번 해서 안 되면 그
# 중에 가장 좋은 것으로 선택").
# ★종전 스키마는 `markers_ok` 한 칸뿐이라 **순위를 매길 재료가 없었다** —
#  통과 못 하면 전부 똑같이 실패였고, 그래서 「가장 좋은 것」을 고를 수 없어
#  계속 다시 그리는 수밖에 없었다.
MARKER_VIOLATION_AXES = (
    "camera_position",      # CAM 동그라미가 지정한 자리에 없다
    "camera_wedge",         # 시야각 부채꼴의 방향·범위가 다르다
    "entity_position",      # 엔티티 마커가 지정한 자리에 없다
    "entity_facing",        # 엔티티가 바라보는 쪽 표시가 없거나 다르다
    "plan_preserved",       # 바탕 도면이 지워지거나 바뀌었다
    "extra_content",        # 요구하지 않은 글자·기호가 덧붙었다
)


def build_marker_check_schema() -> Dict[str, Any]:
    """마커 맵 검증 스키마 — 존재·근사 위치·플랜 보존 + **어긋난 자리 목록**.

    `violations` 는 「가장 좋은 것」을 고르는 유일한 재료다. 비어 있으면
    통과이고, 여러 장 중에서는 **적게 어긋난 것**이 낫다.
    """
    return {
        "type": "object",
        "additionalProperties": False,
        "properties": {
            "markers_ok": {"type": "boolean"},
            "details_ko": {"type": "string"},
            "violations": {
                "type": "array",
                "items": {
                    "type": "object",
                    "additionalProperties": False,
                    "properties": {
                        "axis": {"type": "string",
                                 "enum": list(MARKER_VIOLATION_AXES)},
                        "note_ko": {"type": "string"},
                    },
                    "required": ["axis", "note_ko"],
                },
            },
        },
        "required": ["markers_ok", "details_ko", "violations"],
    }


def pick_best_marker_map(
    *,
    base_png: bytes,
    candidates: List[Dict[str, Any]],
    geometry: Dict[str, Any],
    prompt_version: str,
    opik_metadata: Optional[Dict[str, Any]] = None,
    project_config: Optional[Dict[str, Any]] = None,
) -> int:
    """세 장 중 **어느 것이 기하를 가장 잘 지켰나** — VLM 이 고른다.

    2026-08-25 사용자 지시: "nb2 가 3번 실패하면 그냥 그 셋 중에 하나 하라고,
    VLM 으로". 통과가 없다고 멈추지 않고 **가장 나은 판으로 계속 간다**.

    ★「몇 점인가」를 묻지 않는다. 절대 점수는 못 믿는다는 실측이 있고
     (Qwen 판정 실험: 관찰·셈은 좋고 matches_brief 는 못 믿는다), 여기서
     필요한 것도 순위 하나뿐이다 — **여럿 중 어느 것**만 묻는다.
    ★판정 계약은 검사기가 쓰던 것을 그대로 쓴다. 「무엇이 옳은가」를 두 벌로
     두면 고르는 기준과 재는 기준이 갈린다.
    ★후보가 하나뿐이면 부르지 않는다 — 물어볼 것이 없는 데 돈을 쓰지 않는다.

    반환: 고른 후보의 인덱스. 판정이 못 돌면 0(첫 판)을 돌려준다 —
    판정기가 죽었다고 이미 산 그림을 다 버리면 안 된다.
    """
    if len(candidates) <= 1:
        return 0
    from app.modules.llm.llm_client import call_structured
    from app.modules.pipeline.multiroll_gemini import png_part

    lines = [marker_check_user_header(prompt_version)]
    lines += build_marker_check_user_lines(
        geometry,
        require_anchors=(prompt_version in _ANCHOR_SKETCH_PACKS),
        require_view=(prompt_version in _VIEW_SKETCH_PACKS),
        coord_first=_coord_first(prompt_version))
    lines.append("")
    lines.append(
        "None of the candidates below passed the check. Choose the ONE that "
        "departs least from the staging above — judge only what you can see, "
        "and do not rate them on a scale. If two are equally close, choose "
        "the earlier one.")
    parts: List[Dict[str, Any]] = [
        {"type": "text", "text": "\n".join(lines)},
        {"type": "text", "text": "ORIGINAL CLEAN SITE PLAN:"},
        png_part(base_png),
    ]
    for i, cand in enumerate(candidates):
        # 판정기가 남긴 반려 사유를 함께 보여 준다 — 무엇을 이미 한 번
        # 걸렀는지 알면 같은 자리를 다시 안 본다. ★사유는 **참고**다,
        # 그림이 사유와 다르게 보이면 그림이 이긴다(판정기도 틀린다).
        why = marker_reject_line(cand.get("check") or {})
        head = f"CANDIDATE {i + 1}:"
        if why:
            head += f" (the checker rejected this one for — {why})"
        parts.append({"type": "text", "text": head})
        parts.append(png_part(cand["png"]))
    schema = {
        "type": "object",
        "additionalProperties": False,
        "properties": {
            "choice": {"type": "integer", "minimum": 1,
                       "maximum": len(candidates)},
            "why_ko": {"type": "string"},
        },
        "required": ["choice", "why_ko"],
    }
    try:
        out = call_structured(
            "lane_marker_map_pick", load_marker_check_sys(prompt_version),
            parts, schema, project_config=project_config,
            schema_name="lane_marker_map_pick",
            opik_metadata=opik_metadata)
    except Exception as exc:  # noqa: BLE001 — 판정 실패는 그림을 못 버린다
        logger.warning("marker map 고르기 실패 — 첫 판을 쓴다 (%r)", exc)
        return 0
    idx = int(out.get("choice") or 1) - 1
    if not 0 <= idx < len(candidates):
        return 0
    candidates[idx]["pick_why_ko"] = str(out.get("why_ko") or "")
    return idx


def marker_reject_line(check: Dict[str, Any]) -> str:
    """이 판이 무엇 때문에 반려됐나 — 고를 때 함께 보여 줄 한 줄.

    ★셈으로 순위를 매기지 않는다(사용자 지시: 고르는 것은 VLM). 여기서
     만드는 것은 **판정기가 남긴 사유**뿐이고, 어느 것이 나은지는 그림을
     보는 쪽이 정한다.
    """
    if not isinstance(check, dict):
        return ""
    axes = [str(v.get("axis") or "") for v in check.get("violations") or []
            if isinstance(v, dict)]
    axes = [a for a in axes if a]
    detail = str(check.get("details_ko") or "").strip()
    if axes and detail:
        return f"{', '.join(axes)} — {detail}"
    return ", ".join(axes) or detail


def resolve_sketch_pack_version(version: str) -> str:
    if version not in SKETCH_PACK_VERSION_MAP:
        raise ValueError(f"marker_map_sketch 팩 버전 없음: {version}")
    return SKETCH_PACK_VERSION_MAP[version]


def build_marker_sketch_prompt(
    *,
    shot_desc: str,
    place_text: str,
    geometry: Dict[str, Any],
    pose_clauses: List[str],
    carried_en: str,
    camera_direction_en: str = "",
    seed_look_attached: bool = False,
    plate_attached: bool = False,
    prompt_version: str = "2",
) -> str:
    """스케치 프롬프트 조립. 소비 전 geometry 무결성 재검증 (fail-closed
    — persisted/reloaded geometry 도 신뢰하지 않음).

    v4+ (_GEOMETRY_TEXT_PACKS): 참조=클린 SITE PLAN — head → STYLE
    → STAGING GEOMETRY(ID-free 텍스트 직렬화) → CAMERA DIRECTION →
    LOCATION → SHOT TEXT → pose → carried → no_marker → no_text.
    v1~v3 (마커 굽힌 맵 전제, deprecated 경로): head → STYLE →
    slot 매핑 → [SEED LOOK] → 이하 동일.

    STYLE 스템은 팩별로 갈린다 — v14+(_MANNEQUIN_SKETCH_PACKS)=
    mannequin_frame(인물=목각 인형), 그 외=light_frame.

    seed_look_attached=True (레인2, v3 팩 전용): 2번째 참조=구조물 확정
    실사(형태 SOT) 조항을 조립. seed_look stem 이 없는 팩(v1/v2/v4)에서
    True 요청 = ValueError (조항 없이 참조만 첨부되는 silent 계약 위반
    차단)."""
    from app.modules.prompt_loader import load_prompt

    violations = validate_marker_geometry(
        geometry,
        require_anchors=(prompt_version in _ANCHOR_SKETCH_PACKS),
        require_view=(prompt_version in _VIEW_SKETCH_PACKS))
    if violations:
        raise ValueError(f"invalid geometry: {'; '.join(violations)}")

    if seed_look_attached and prompt_version not in _SEED_LOOK_PACKS:
        raise ValueError(
            f"marker_map_sketch v{prompt_version} 팩에는 seed_look 계약이 "
            "없음 — seed_look_attached=True 는 v3 전용"
        )
    if plate_attached and prompt_version not in _PLATE_LOOK_PACKS:
        raise ValueError(
            f"marker_map_sketch v{prompt_version} 팩에는 plate_look 계약이 "
            "없음 — 조항 없이 배경 참조만 첨부되는 silent 계약 위반 차단"
        )

    resolved = resolve_sketch_pack_version(prompt_version)
    # v14+: 인물 렌더 스타일 스템 — 마네킹 팩은 light_frame 대신
    # mannequin_frame (3변형 격리 실측: 방향 반전 원인=렌더 스타일).
    # 구 팩은 스템 이름이 그대로라 byte-identical.
    _style_stem = (
        "mannequin_frame"
        if prompt_version in _MANNEQUIN_SKETCH_PACKS
        else "light_frame"
    )
    if prompt_version in _GEOMETRY_TEXT_PACKS:
        parts = [
            load_prompt(SKETCH_PACK_MODULE, "sketch_head",
                        version=resolved).strip(),
            load_prompt(SKETCH_PACK_MODULE, _style_stem,
                        version=resolved).strip(),
            (
                load_prompt(SKETCH_PACK_MODULE, "staging_geometry_head",
                            version=resolved).strip()
                + "\n" + "\n".join(build_geometry_text_lines(geometry))
            ),
        ]
        if plate_attached:
            # v9: 2번째 참조=장소 실사 — 맵/마커=배치·카메라 SOT,
            # 사진=장소 외형 SOT (관할 분리, seed_look 관례와 동형)
            parts.append(
                load_prompt(SKETCH_PACK_MODULE, "plate_look",
                            version=resolved).strip()
            )
        if camera_direction_en:
            parts.append(
                "CAMERA DIRECTION (vertical angle / elevation / framing "
                "SOT): " + camera_direction_en
            )
        parts.append(f"THE LOCATION: {place_text}")
        parts.append(f"SHOT TEXT (authoritative, Korean): {shot_desc}")
        if pose_clauses:
            parts.append("\n".join(pose_clauses))
        if carried_en:
            parts.append("CARRIED STATE (persist exactly): " + carried_en)
        if prompt_version in _NATURALISM_SKETCH_PACKS:
            # v8: 자세·시선 default 계약 — 일반 콘티와 동일 문구
            # (명시 연출·pose 계약이 항상 우선인 default-only)
            parts.append(
                load_prompt(SKETCH_PACK_MODULE, "naturalism_clause",
                            version=resolved).strip()
            )
        parts.append(
            load_prompt(SKETCH_PACK_MODULE, "no_marker",
                        version=resolved).strip()
        )
        parts.append(
            load_prompt(SKETCH_PACK_MODULE, "no_text",
                        version=resolved).strip()
        )
        return "\n\n".join(parts)

    slot_lines = [
        f"{pl['slot']} = {pl['subject_en']}"
        for pl in geometry.get("entity_placements") or []
    ]
    parts = [
        load_prompt(SKETCH_PACK_MODULE, "sketch_head",
                    version=resolved).strip(),
        load_prompt(SKETCH_PACK_MODULE, _style_stem,
                    version=resolved).strip(),
        "MAP SLOT MEANINGS:\n" + "\n".join(slot_lines),
    ]
    if seed_look_attached:
        parts.append(
            load_prompt(SKETCH_PACK_MODULE, "seed_look",
                        version=resolved).strip()
        )
    if camera_direction_en:
        parts.append(
            "CAMERA DIRECTION (vertical angle / elevation / framing SOT): "
            + camera_direction_en
        )
    parts.append(f"THE LOCATION: {place_text}")
    parts.append(f"SHOT TEXT (authoritative, Korean): {shot_desc}")
    if pose_clauses:
        parts.append("\n".join(pose_clauses))
    if carried_en:
        parts.append("CARRIED STATE (persist exactly): " + carried_en)
    parts.append(
        load_prompt(SKETCH_PACK_MODULE, "no_marker", version=resolved).strip()
    )
    parts.append(
        load_prompt(SKETCH_PACK_MODULE, "no_text", version=resolved).strip()
    )
    return "\n\n".join(parts)


def build_leakage_schema() -> Dict[str, Any]:
    """스케치 마커 leakage VLM gate 스키마 (판정 호출은 caller)."""
    return {
        "type": "object",
        "properties": {
            "has_marker_leakage": {"type": "boolean"},
            "details_ko": {"type": "string"},
        },
        "required": ["has_marker_leakage", "details_ko"],
        "additionalProperties": False,
    }
