"""outdoor_place_spec — 야외 촬영 부지 마커 스펙 저작 (W22 직행 체인 ①).

building_group(실외 멤버 보유)당 1스펙을 LLM 이 evidence-bound 로 저작한다:

    {layout_narration_en, zone_labels_en[],
     items[{code, kind, name_en, placement_en, inferred,
            evidence{scene_index, quote_ko} | null}]}

- code: 대문자1+숫자1 (MARKER_CODE_RE), 그룹 내 비중복 — 결정론 검증.
- name_en: ID-free 서술명 — 하류(nb2 직행 프롬프트)에서 코드 대신 소비되므로
  이름 자체로 유일 구별 필요 (중복 검증).
- inferred=false 항목은 evidence(scene_index+quote_ko) 필수 — 발명 경계.

하류: outdoor_place_canon(항공 실사 마스터+탑다운 맵 생성),
outdoor_shot_grounding(샷별 존/앵커/카메라 접지).
설계: docs/w22-outdoor-canon-direct-compose-design-20260709/design.md
"""

import logging
import re
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple

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

logger = logging.getLogger(__name__)

_MODULE = "outdoor_place_spec"

# selector → 팩 디렉토리. typo 시 ValueError (silent latest 금지 — floor_plan_prompt 관례).
PROMPT_VERSION_MAP = {
    "1": "1.202607100041",
    # 2 (2026-07-11): 시간 불변 계약 신설 — 마커=영속 물리 요소만(상태물 금지).
    # 3회차 실측: 수사 상태물(폴리스라인 등)이 마커→캐논 소착→타 시점 샷 오염.
    "2": "2.202607110020",
    # 3 (2026-07-25 사용자 지적 "맵이 틀렸다 — 한국식 버스정류장은 도로
    # 바로 옆이고 표지도 있다"): 허용 추정을 **기능적 성립**까지 확장.
    # v2 는 "물리적 성립"만 허용해, 차량이 서는 시설인데 차도가, 승하차
    # 시설인데 표지가 spec 에서 빠졌다(실측 items=정류장+바다 뿐).
    # 하류 photo/map 은 spec 서술을 그대로 그리므로 결손이 그대로
    # 전파돼 도로에서 떨어진 정류장이 생성됐다. 지역 관행 배치 원칙도
    # 명시(원문 침묵 시 그 지역의 보통 배치가 기본값). 재질·치수·색 등
    # 세계 사실 발명 금지는 그대로.
    "3": "3.202607251954",
}

MARKER_CODE_RE = re.compile(r"^[A-Z][0-9]$")
# ID-free 서술 필드(name_en/placement_en)에 마커 코드가 새는 것을 구조적으로
# 차단 — 하류 nb2 직행 프롬프트는 코드 노출 금지 계약 (시스템 구조 ID 방어,
# 시나리오 휴리스틱 아님).
MARKER_CODE_LEAK_RE = re.compile(r"\b[A-Z][0-9]\b")
MAX_ATTEMPTS = 3


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


def validate_place_spec(
    spec: Dict[str, Any],
    allowed_scene_indices: Optional[Set[int]] = None,
) -> List[str]:
    """스펙 결정론 검증 — 위반 메시지 리스트 반환 (빈 리스트 = 통과).

    스키마(jsonschema)가 형식을 잡고, 여기서는 스키마로 표현하기 어려운
    계약을 검사한다: 코드/서술명 비중복, inferred=false ↔ evidence 필수,
    evidence.scene_index ∈ allowed_scene_indices(제공 시 — 프롬프트에 공급된
    씬 밖 인용 차단), 서술 필드 마커 코드 누출 금지(ID-free).
    """
    violations: List[str] = []

    narration = spec.get("layout_narration_en")
    if not isinstance(narration, str) or not narration.strip():
        violations.append("layout_narration_en 이 비어 있음")

    zones = spec.get("zone_labels_en")
    if (
        not isinstance(zones, list)
        or not zones
        or any(not isinstance(z, str) or not z.strip() for z in zones)
    ):
        violations.append("zone_labels_en 이 비어 있거나 빈 라벨 포함")
    elif len({z.strip() for z in zones}) != len(zones):
        violations.append("zone_labels_en 에 중복 라벨")

    items = spec.get("items")
    if not isinstance(items, list) or not items:
        violations.append("items 가 비어 있음")
        return violations

    # 시간 불변 계약 (v2, 2026-07-11 — Codex 합의): admitted item 은 전부
    # temporal_scope="persistent_site" 감사 표식 필수, 제외 목록 항목은
    # 코드 부여 금지 (마커 계층 진입 차단). 문자열/kind 의미 판정은 하지
    # 않는다 — 판단은 LLM 저작, 여기는 구조 계약만 잠근다.
    for it in items:
        if isinstance(it, dict) and it.get("temporal_scope") != "persistent_site":
            violations.append(
                f"items[{it.get('code', '?')}].temporal_scope 가 "
                f"'persistent_site' 아님 — 시간 불변 계약 위반"
            )
    excluded = spec.get("excluded_transient_elements")
    if isinstance(excluded, list):
        for ex in excluded:
            if isinstance(ex, dict) and ex.get("code"):
                violations.append(
                    "excluded_transient_elements 항목에 code 부여 금지"
                )

    seen_codes: set = set()
    seen_names: set = set()
    for i, it in enumerate(items):
        if not isinstance(it, dict):
            violations.append(f"items[{i}] 이 객체가 아님")
            continue
        code = it.get("code")
        if not isinstance(code, str) or not MARKER_CODE_RE.match(code):
            violations.append(f"items[{i}].code {code!r} 형식 위반 (대문자1+숫자1)")
        elif code in seen_codes:
            violations.append(f"items[{i}].code {code!r} 중복")
        else:
            seen_codes.add(code)

        name = (it.get("name_en") or "").strip()
        if not name:
            violations.append(f"items[{i}].name_en 비어 있음")
        elif name.lower() in seen_names:
            violations.append(
                f"items[{i}].name_en {name!r} 중복 — 서술명만으로 유일 구별 필요"
            )
        else:
            seen_names.add(name.lower())
        if name and MARKER_CODE_LEAK_RE.search(name):
            violations.append(
                f"items[{i}].name_en {name!r} 에 마커 코드 포함 — "
                "ID-free 서술명이어야 함"
            )

        placement = (it.get("placement_en") or "").strip()
        if not placement:
            violations.append(f"items[{i}].placement_en 비어 있음")
        elif MARKER_CODE_LEAK_RE.search(placement):
            violations.append(
                f"items[{i}].placement_en 에 마커 코드 포함 — "
                "다른 항목은 코드가 아니라 서술명으로 지칭할 것"
            )

        inferred = it.get("inferred")
        if not isinstance(inferred, bool):
            violations.append(f"items[{i}].inferred 가 bool 아님")
        elif inferred is False:
            ev = it.get("evidence")
            if (
                not isinstance(ev, dict)
                or not isinstance(ev.get("scene_index"), int)
                or not (ev.get("quote_ko") or "").strip()
            ):
                violations.append(
                    f"items[{i}]: inferred=false 인데 evidence"
                    "(scene_index+quote_ko) 없음"
                )
            elif (
                allowed_scene_indices is not None
                and ev.get("scene_index") not in allowed_scene_indices
            ):
                violations.append(
                    f"items[{i}].evidence.scene_index={ev.get('scene_index')} 는 "
                    f"제공된 씬({sorted(allowed_scene_indices)}) 밖 — "
                    "제공된 씬 원문에서만 인용할 것"
                )

    return violations


def build_locations_block(
    members: Sequence[Dict[str, Any]],
    entity_locations: Sequence[Dict[str, Any]],
) -> str:
    """실외 멤버 loc 상세 블록 — entity_merge locations 조인."""
    loc_by_id = {l.get("short_id"): l for l in entity_locations or []}
    lines: List[str] = []
    for m in members or []:
        lid = m.get("loc_id", "")
        loc = loc_by_id.get(lid) or {}
        label = m.get("label") or loc.get("name", "")
        desc = loc.get("description", "")
        traits = ", ".join(loc.get("visual_traits") or [])
        line = f"- [{lid}] {label}: {desc}"
        if traits:
            line += f" (시각 특징: {traits})"
        lines.append(line)
    return "\n".join(lines) or "(장소 상세 없음)"


def build_scenes_block(scene_texts: Sequence[Tuple[int, str]]) -> str:
    """씬 원문 블록 — 원문 전문 그대로, 절대 자르지 않는다 (CLAUDE.md 절대 규칙)."""
    parts: List[str] = []
    for si, text in scene_texts or []:
        parts.append(f"### 씬 {si}\n{text}")
    return "\n\n".join(parts) or "(씬 없음)"


def build_shots_block(shots: Sequence[Dict[str, Any]]) -> str:
    """선택 샷 스테이징 요지 블록 — 화면 중요 요소 단서 참고용."""
    lines: List[str] = []
    for sh in shots or []:
        si = sh.get("scene_index")
        shi = sh.get("shot_index")
        desc = sh.get("description", "")
        cam = sh.get("camera_direction", "")
        line = f"- S{si}_Shot{shi}: {desc}"
        if cam:
            line += f" / camera: {cam}"
        lines.append(line)
    return "\n".join(lines) or "(샷 연출 데이터 없음)"


_TEMPLATE_KEYS = (
    "group_label",
    "locations_block",
    "world_rules_block",
    "scenes_block",
    "shots_block",
)


def build_user_prompt(template: str, **blocks: str) -> str:
    """user_template 변수 치환 — .format 대신 replace (본문 중괄호 안전)."""
    out = template
    for key in _TEMPLATE_KEYS:
        out = out.replace("{" + key + "}", blocks.get(key, ""))
    return out


def _format_retry_hint(violations: List[str]) -> str:
    lines = [
        "",
        "",
        "[재시도 — 직전 응답이 아래 계약을 위반했습니다. 전부 고쳐서",
        " 전체 스펙을 다시 출력하세요:]",
    ]
    lines.extend(f"  - {v}" for v in violations)
    return "\n".join(lines)


def run_outdoor_place_spec(
    *,
    group: Dict[str, Any],
    outdoor_members: Sequence[Dict[str, Any]],
    entity_locations: Sequence[Dict[str, Any]],
    scene_texts: Sequence[Tuple[int, str]],
    shots: Sequence[Dict[str, Any]],
    rules_text: str = "",
    creator_corrections_block: str = "",
    prompt_version: str = "1",
    call_structured_fn: Optional[Callable[..., Dict[str, Any]]] = None,
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
    max_attempts: int = MAX_ATTEMPTS,
) -> Dict[str, Any]:
    """그룹 1개의 마커 스펙 저작 — 검증 위반 시 위반 목록을 힌트로 재시도.

    반환 {"spec": <검증 통과 스펙>, "attempts": n}.
    max_attempts 소진 시 AppError(step.contract_violation.outdoor_place_spec).
    """
    if call_structured_fn is None:
        from app.modules.llm.llm_client import call_structured

        call_structured_fn = call_structured

    resolved = resolve_prompt_version(prompt_version)
    system = load_prompt(_MODULE, "system", version=resolved)
    # 제작자 정정 채널 — 빈 문자열이면 기존과 byte-identical
    system += creator_corrections_block or ""
    template = load_prompt(_MODULE, "user_template", version=resolved)
    schema = load_schema(_MODULE, "schema", version=resolved)

    gid = group.get("group_id", "")
    group_label = gid
    anchor = group.get("anchor_loc", "")
    if anchor:
        group_label += f" (anchor: {anchor})"

    user = build_user_prompt(
        template,
        group_label=group_label,
        locations_block=build_locations_block(outdoor_members, entity_locations),
        world_rules_block=rules_text or "(세계 물리 규칙 없음)",
        scenes_block=build_scenes_block(scene_texts),
        shots_block=build_shots_block(shots),
    )

    # evidence 인용은 프롬프트에 공급된 씬에서만 허용 (evidence-bound)
    allowed_scene_indices = {si for si, _ in scene_texts or []}

    attempts = 0
    current_user = user
    last_violations: List[str] = []
    while attempts < max_attempts:
        attempts += 1
        result = call_structured_fn(
            "outdoor_place_spec",
            system,
            current_user,
            schema,
            project_config=project_config,
            schema_name="outdoor_place_spec",
            opik_metadata=opik_metadata,
        )
        violations = validate_place_spec(result or {}, allowed_scene_indices)
        if not violations:
            return {"spec": result, "attempts": attempts}
        last_violations = violations
        logger.warning(
            "outdoor_place_spec group=%s attempt=%d violations=%d: %s",
            gid, attempts, len(violations), "; ".join(violations[:5]),
        )
        current_user = user + _format_retry_hint(violations)

    raise AppError(
        code="step.contract_violation.outdoor_place_spec",
        message=(
            f"outdoor_place_spec group={gid!r} 스펙 계약 위반 "
            f"(attempts={max_attempts}): " + "; ".join(last_violations[:8])
        ),
        status_code=422,
    )
