"""outdoor_direct_compose — nb2 직행 합성 프롬프트 결정론 조립 (W22 ④, s33 SOT).

LLM 콜 0 — s33_direct_compose `_prompt` 9절 계약을 코드 조립으로 이식:

  ① DIRECT_HEAD       ② SPOT(fixed, ID-free place_desc)
  ③ SHOT TEXT (한국어 authoritative: heading/shot/people/context)
  ④ FREE_CAMERA (구도 지시 의도적 0)   ⑤ CHARACTERS / no-people
  ⑥ TIME_LOCK  ⑥-b REAL_SCALE(v2+)
  ⑦ WORLD FACTS (선택 — caller 데이터 주입 시만. ★2026-07-10 정정 원문은
     주입하지 않음: T2I 언급=묘사라 무관 장소 전역 오염 실측 — 정정은
     outdoor_place_spec 저작 경유 마커 서술로만 관통)
  ⑧ REF_NOTE (참조 관할 3종 분리)      ⑨ NO_ANNOTATION

참조 라벨 (관할 분리, s33 계약):
  - LOCATION_PHOTO_LABEL  = 실사 마스터 (룩 SOT)
  - SITE_PLAN_LABEL       = 탑다운 맵 (배치 전용·작도 금지)
  - character_ref_label() = passport (정체)

직행은 nb2 전용 (i2 는 참조 광원 우세로 TIME_LOCK 파괴 — s33/s31 판정).
설계: docs/w22-outdoor-canon-direct-compose-design-20260709/design.md
"""

import logging
import re
from typing import Any, Dict, List, Optional, Sequence

from app.core.errors import AppError
from app.modules.prompt_loader import load_prompt

logger = logging.getLogger(__name__)

_MODULE = "outdoor_direct_compose"

PROMPT_VERSION_MAP = {
    "1": "1.202607100135",
    # 2 (2026-07-10): 2회차 E2E 육안 결함 대응 — characters_present 에 단일
    # 인물 출현(중복 인물 금지) 강화 + real_scale 블록 신설(소품 실물 스케일).
    "2": "2.202607101600",
    # 3 (2026-07-11): grounded_camera 블록 신설 — grounding 의 카메라 판단
    # (camera_position/look_direction/in_frame)을 soft guidance 로 주입.
    # 3회차 실측: free_camera 재량이 71샷 규모에서 원거리 편향·시선 불일치.
    "3": "3.202607110030",
}

LOCATION_PHOTO_LABEL = (
    "LOCATION PHOTOGRAPH — the same property; sole source of how "
    "everything looks."
)
SITE_PLAN_LABEL = (
    "SITE PLAN — layout source only; never draw this drawing or its markers."
)

_BLOCK_STEMS = (
    "direct_head", "free_camera", "characters_present", "characters_none",
    "time_lock", "ref_note", "no_annotation",
)
# 버전별 추가 블록 — 도입 버전 미만 팩엔 없으므로 optional, 도입 버전 이상은
# fail-closed (Codex NARROW: silent disable 차단).
_OPTIONAL_BLOCK_STEMS = ("real_scale", "grounded_camera")
_OPTIONAL_REQUIRED_FROM = {"real_scale": 2, "grounded_camera": 3}


def resolve_prompt_version(selector: str) -> str:
    try:
        return PROMPT_VERSION_MAP[selector]
    except KeyError:
        raise ValueError(
            f"unknown outdoor_direct_compose prompt version selector "
            f"{selector!r} (known: {sorted(PROMPT_VERSION_MAP)})"
        )


def load_blocks(prompt_version: str = "3") -> Dict[str, str]:
    resolved = resolve_prompt_version(prompt_version)
    blocks = {
        stem: load_prompt(_MODULE, stem, version=resolved).strip()
        for stem in _BLOCK_STEMS
    }
    ver_num = int(prompt_version)
    for stem in _OPTIONAL_BLOCK_STEMS:
        required = ver_num >= _OPTIONAL_REQUIRED_FROM[stem]
        if not required:
            # 도입 이전 팩 — 하위 호환으로만 빈 문자열 허용.
            try:
                blocks[stem] = load_prompt(
                    _MODULE, stem, version=resolved).strip()
            except Exception:  # noqa: BLE001
                blocks[stem] = ""
        else:
            # 도입 버전 이상은 fail-closed (Codex NARROW): 손상/누락이
            # 조용히 빈 문자열로 빠지면 해당 fix 가 silent disable 된다.
            blocks[stem] = load_prompt(_MODULE, stem, version=resolved).strip()
            if not blocks[stem]:
                raise ValueError(
                    f"outdoor_direct_compose pack {resolved}: required block "
                    f"'{stem}' is empty"
                )
    return blocks


def character_ref_label(name: str) -> str:
    return f"CHARACTER REFERENCE — {name}: the exact person in the shot."


def build_place_desc(ground: Dict[str, Any], spec: Dict[str, Any]) -> str:
    """grounding 존+앵커를 ID-free 서술로 치환 (s31._place_desc 계약).

    앵커 코드는 스펙 name_en 으로만 노출 — 코드는 절대 프롬프트에 안 나간다.
    """
    code_to_name = {
        it.get("code", ""): it.get("name_en", "")
        for it in spec.get("items", []) or []
    }
    names = [
        code_to_name[c] for c in ground.get("anchor_markers", []) or []
        if code_to_name.get(c)
    ]
    joined = " and ".join(names) if names else "the mapped area"
    zone = ground.get("map_zone", "")
    return f'the "{zone}" area of the property, by {joined}'


def assert_prompt_id_free(
    prompt: str, codes: Sequence[str], where: str = "direct_compose"
) -> None:
    """조립 결과에 마커 코드가 새지 않았는지 최종 봉인 (단어 경계)."""
    leaked = [
        c for c in codes
        if c and re.search(rf"\b{re.escape(c)}\b", prompt)
    ]
    if leaked:
        raise AppError(
            code="step.outdoor_direct_compose.id_free_violation",
            message=f"{where} 프롬프트에 마커 코드 누출: {leaked}",
            status_code=422,
        )


def build_direct_prompt(
    blocks: Dict[str, str],
    *,
    place_desc: str,
    scene_heading: str,
    shot_description: str,
    character_names: Sequence[str],
    moment_context_en: str = "",
    world_facts_block: str = "",
    style_context: str = "",
    spec_codes: Optional[Sequence[str]] = None,
    include_ref_note: bool = True,
    camera_guidance: Optional[Dict[str, str]] = None,
) -> str:
    """s33 `_prompt` 9절 결정론 조립. LLM 콜 없음.

    - world_facts_block: 제작자 정정 등 세계 사실 (데이터 주입 — 하드코딩 금지)
    - style_context: 프로젝트 시대/지역 한 줄 (있으면 최상단, 전역 무드 아님)
    - spec_codes 제공 시 조립 결과 ID-free 봉인
    - include_ref_note=False: production 접점용 — 참조 관할 서술은
      resolve_ref_roles 의 role 지시문 + labeled_refs 라벨이 담당하므로
      ⑧절을 빼서 REF_NOTE 중복을 피한다 (s33 대비 유일한 의도적 편차).
    """
    parts: List[str] = []
    if style_context.strip():
        parts.append(style_context.strip())

    parts.append(blocks["direct_head"])                                # ①
    parts.append(f"SPOT (fixed): this shot happens at {place_desc}.")  # ②

    shot_lines = [
        "SHOT TEXT (authoritative, Korean):",
        f"scene_heading: {scene_heading}",
        f"shot: {shot_description}",
    ]
    if character_names:
        shot_lines.append(
            "people in shot: " + ", ".join(character_names)
        )
    if moment_context_en.strip():
        shot_lines.append(f"context: {moment_context_en.strip()}")
    parts.append("\n".join(shot_lines))                                # ③

    _cam = camera_guidance or {}
    if (blocks.get("grounded_camera")
            and _cam.get("camera_position_en")
            and _cam.get("look_direction_en")):
        # ④ (v3+) grounding 카메라 soft guidance — 데이터 주입, 하드코딩 금지.
        parts.append(
            blocks["grounded_camera"]
            .replace("{camera_position}", _cam["camera_position_en"].strip())
            .replace("{look_direction}", _cam["look_direction_en"].strip())
            .replace("{in_frame}", (_cam.get("in_frame_en") or
                                    "what the shot text describes").strip())
        )
    else:
        parts.append(blocks["free_camera"])                            # ④ fallback

    if character_names:                                                # ⑤
        parts.append(
            blocks["characters_present"].replace(
                "{names}", ", ".join(character_names)
            )
        )
    else:
        parts.append(blocks["characters_none"])

    parts.append(blocks["time_lock"])                                  # ⑥
    if blocks.get("real_scale"):                                       # ⑥-b (v2+)
        parts.append(blocks["real_scale"])

    if world_facts_block.strip():                                      # ⑦
        parts.append(
            "WORLD FACTS (creator-confirmed / evidence-bound — always true):\n"
            + world_facts_block.strip()
        )

    if include_ref_note:                                               # ⑧
        parts.append(blocks["ref_note"])
    parts.append(blocks["no_annotation"])                              # ⑨

    prompt = "\n\n".join(parts)
    if spec_codes:
        assert_prompt_id_free(prompt, spec_codes)
    return prompt
