"""outdoor_map_conti — 야외 복잡 구도 맵 기반 메인 → 경량 콘티 변환 (2026-07-13).

사용자 확정(production_still_recipe_port_plan 단계 3b): 구도가 복잡한 야외
샷은 '위에서 본 사이트 맵(항공 배치도)+카메라 마커+엔티티 마커' grounding 이
메인 — grounding 으로 카메라·배치를 접지한 **맵 기반 배경 플레이트**(nb2,
참조=[룩 마스터+사이트 맵])를 만들고, 그 플레이트를 shot_conti_light 의 참조
플레이트로 넣어 일반 샷과 동일한 LIGHT_FRAME 콘티→스틸 파이프에 병합한다.

정본: scratchpad/forest_exp/s30_shot_apply.py stage_bg(_bg_prompt/LOOK_NOTE/
MAP_NOTE)+s27 NO_ANNOTATION. s30 의 WORLD_FACTS(작품 하드코딩)는 이식 금지 —
씬 원문 세계 사실 보강은 W22 grounding 의 moment_context_en 이 담당.
grounding 자체는 기존 production outdoor_shot_grounding 모듈(s30 ground 계약의
W22 이식본)을 재사용한다 — 이 모듈은 판별·프롬프트 조립·오케스트레이션만.

"복잡 구도" 판별도 LLM(complex_judge — 신규 범용 계약, 하드코딩 금지):
여러 요소의 상대 위치/거리/방향 관계가 결정적이고 글만으로 오해하기 쉬운
샷만 true. 단순 구도(단일 요소 중심·배치 자명)=false — 분기 불요.
"""
from __future__ import annotations

import logging
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional

from app.modules.prompt_loader import load_prompt, load_schema
from app.modules.pipeline.shot_ref_classify import parse_tag

logger = logging.getLogger(__name__)

_MODULE = "outdoor_map_conti"

PROMPT_VERSION_MAP = {
    "1": "1.202607132300",
}

# nb2 첨부 라벨 — s30 stage_bg 검증본 verbatim (긴 관할 설명은 프롬프트의
# look_note/map_note 절이 담당)
LOOK_REF_LABEL = (
    "LOOK REFERENCE — real photograph of the same property;"
    " sole source of how everything looks."
)
MAP_REF_LABEL = (
    "SITE PLAN — layout source only; never draw this"
    " drawing or its markers."
)

# ground_fn(tag, spec, shot_desc, scene_text, map_png_path) -> ground dict
GroundFn = Callable[[str, Dict[str, Any], str, str, Path], Dict[str, Any]]
# bg_gen_fn(tag, prompt, labeled_refs[(label, Path)], out_path) -> out_path
BgGenFn = Callable[[str, str, List[tuple], Path], Path]


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


def judge_complex_shots(
    *,
    shot_desc_by_tag: Dict[str, str],
    call_structured_fn: Optional[Callable[..., Dict[str, Any]]] = None,
    prompt_version: str = "1",
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Dict[str, Any]]:
    """야외 후보 샷의 복잡 구도 LLM 판별. 누락 샷 = false(안전 기본)."""
    if not shot_desc_by_tag:
        return {}
    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, "complex_judge_system", version=resolved)
    schema = load_schema(
        _MODULE, "complex_judge_schema", None, version=resolved
    )
    # 판정 모델 명시 — unknown-step silent fallback 차단 (분석 계열=gpt)
    project_config = {
        **(project_config or {}),
        f"{_MODULE}_judge": {"model": "gpt"},
    }
    tags = sorted(shot_desc_by_tag, key=parse_tag)
    shots_txt = "\n".join(f"{t}: {shot_desc_by_tag[t]}" for t in tags)
    res = call_structured_fn(
        f"{_MODULE}_judge",
        system,
        f"야외 샷 목록:\n{shots_txt}",
        schema,
        project_config=project_config,
        schema_name=f"{_MODULE}_judge",
        opik_metadata=opik_metadata,
    )
    got = {it.get("shot"): it for it in res.get("items") or []}
    out: Dict[str, Dict[str, Any]] = {}
    for t in tags:
        it = got.get(t)
        if it is None:
            logger.warning(
                "%s: 판별 누락 샷 %s — complex=false 기본", _MODULE, t
            )
            out[t] = {"complex": False, "reason_ko": "판별 누락 — 기본 false"}
        else:
            out[t] = {
                "complex": bool(it.get("complex")),
                "reason_ko": it.get("reason_ko") or "",
            }
    return out


def build_map_bg_prompt(
    *,
    ground: Dict[str, Any],
    spec: Dict[str, Any],
    time_of_day_en: str,
    prompt_version: str = "1",
) -> str:
    """맵 기반 배경 플레이트 프롬프트 — s30 _bg_prompt 이식 (concept 콜 제거
    = W22 축약, WORLD_FACTS 제거, moment_context_en·time_of_day lock 추가)."""
    resolved = resolve_prompt_version(prompt_version)
    c2i = {
        it.get("code"): it
        for it in spec.get("items") or []
        if it.get("code")
    }
    anchors = "\n".join(
        f"- {c2i[c].get('name_en', '')}: {c2i[c].get('placement_en', '')}"
        for c in ground.get("anchor_markers") or []
        if c in c2i
    )
    parts = [
        load_prompt(_MODULE, "bg_head", version=resolved).strip(),
        "CAMERA: " + (ground.get("camera_position_en") or "")
        + " Looking: " + (ground.get("look_direction_en") or ""),
        'MAP POINT: this plate is taken in the "'
        + (ground.get("map_zone") or "")
        + '" area of the property on the attached site plan, anchored to'
        " these mapped elements:\n" + anchors,
        "IN FRAME (near to far): " + (ground.get("in_frame_en") or ""),
    ]
    moment = ground.get("moment_context_en") or ""
    if moment:
        parts.append("MOMENT CONTEXT: " + moment)
    time_light = "TIME & LIGHT: " + (time_of_day_en or "") + (
        " Do not add weather, atmosphere or colour moods beyond this."
    )
    parts.append(time_light)
    parts.append(
        load_prompt(_MODULE, "invention_boundary", version=resolved).strip()
    )
    parts.append(load_prompt(_MODULE, "look_note", version=resolved).strip())
    parts.append(load_prompt(_MODULE, "map_note", version=resolved).strip())
    parts.append(
        load_prompt(_MODULE, "no_annotation", version=resolved).strip()
    )
    return "\n\n".join(parts)


def run_outdoor_map_conti(
    *,
    conti_target_tags: List[str],
    shot_desc_by_tag: Dict[str, str],
    group_by_tag: Dict[str, str],
    group_assets: Dict[str, Dict[str, Any]],
    complex_by_tag: Dict[str, Dict[str, Any]],
    time_of_day_by_scene: Dict[str, Dict[str, Any]],
    scene_texts: Dict[int, str],
    ground_fn: GroundFn,
    bg_gen_fn: BgGenFn,
    out_dir: Path,
    prompt_version: str = "1",
    extra_fingerprint: Optional[Dict[str, Any]] = None,
    force: bool = False,
) -> Dict[str, Any]:
    """콘티 대상 샷 중 야외 복잡 구도 샷의 맵 기반 플레이트 산출.

    group_assets[gid] = {"spec": dict, "master_png": Path, "map_png": Path}
    반환 {"map_plates": {tag: {plate_path, ground, complex_reason_ko,
                                prompt, skipped_reason, error}}}

    재개(Codex 2차 리뷰 B3): sidecar(map_records.json)의 입력 지문(스펙+샷
    텍스트+씬 전문+마스터/맵 내용+시간대+모델·팩)이 일치할 때만 재사용 —
    ground(LLM) 재호출 없이 판정 가능하도록 프롬프트가 아닌 결정 입력을
    지문화. mismatch/force/무기록 = stale 아카이브 후 ground+생성 재수행.
    샷 단위 실패 격리.
    """
    import json as _json

    from app.modules.pipeline.multiroll_select import (
        compute_input_fingerprint,
    )
    from app.modules.pipeline.shot_conti_light import (
        archive_stale_output,
        load_sidecar_records,
        save_sidecar_records,
    )

    out_dir.mkdir(parents=True, exist_ok=True)
    records_path = out_dir / "map_records.json"
    sidecar = load_sidecar_records(records_path)
    plates: Dict[str, Any] = {}
    for tag in conti_target_tags:
        gid = group_by_tag.get(tag)
        if gid is None:
            plates[tag] = {"skipped_reason": "not_outdoor"}
            continue
        cx = complex_by_tag.get(tag) or {"complex": False, "reason_ko": ""}
        if not cx["complex"]:
            plates[tag] = {
                "skipped_reason": "not_complex",
                "complex_reason_ko": cx.get("reason_ko") or "",
            }
            continue
        assets = group_assets.get(gid)
        if (
            not assets
            or not assets.get("spec")
            or not assets.get("master_png")
            or not assets.get("map_png")
        ):
            logger.warning(
                "%s: %s 캐논 자산 부재(group=%s) — 일반 콘티 fallback",
                _MODULE, tag, gid,
            )
            plates[tag] = {"skipped_reason": "no_canon_assets"}
            continue

        si, _shi = parse_tag(tag)
        out_path = out_dir / f"map_plate_{tag}.png"
        try:
            tod = (
                time_of_day_by_scene.get(str(si), {}).get("time_of_day_en")
                or ""
            )
            # 결정 입력 지문 — ground(LLM) 없이 재사용 판정 가능
            fingerprint = compute_input_fingerprint(
                prompt=_json.dumps(
                    {
                        "spec": assets["spec"],
                        "shot_desc": shot_desc_by_tag.get(tag) or "",
                        "scene_text": scene_texts.get(si, ""),
                        "tod": tod,
                    },
                    sort_keys=True, ensure_ascii=False,
                ),
                labeled_refs=[
                    ("master", Path(assets["master_png"])),
                    ("map", Path(assets["map_png"])),
                ],
                roll_count=1,
                critique_enabled=False,
                extra=extra_fingerprint,
            )
            prev_rec = sidecar.get(tag) or {}
            if (
                not force
                and out_path.exists()
                and prev_rec.get("input_fingerprint") == fingerprint
            ):
                plates[tag] = {
                    "plate_path": str(out_path),
                    "ground": prev_rec.get("ground"),
                    "complex_reason_ko": cx.get("reason_ko") or "",
                    "prompt": prev_rec.get("prompt"),
                    "skipped_reason": None,
                    "error": None,
                    # 3차 리뷰 B1: reuse 경로에서도 canon 입력 복원 —
                    # DB 등록 rollback 후 재시도가 lineage 를 복구 가능
                    "source_master_png": str(assets["master_png"]),
                    "source_map_png": str(assets["map_png"]),
                }
                continue
            if out_path.exists():
                logger.warning(
                    "%s: %s 지문 불일치/force — stale 아카이브 후 재생성",
                    _MODULE, tag,
                )
                archive_stale_output(out_path)
            ground = ground_fn(
                tag,
                assets["spec"],
                shot_desc_by_tag.get(tag) or "",
                scene_texts.get(si, ""),
                Path(assets["map_png"]),
            )
            prompt = build_map_bg_prompt(
                ground=ground,
                spec=assets["spec"],
                time_of_day_en=tod,
                prompt_version=prompt_version,
            )
            bg_gen_fn(
                tag,
                prompt,
                [
                    (LOOK_REF_LABEL, Path(assets["master_png"])),
                    (MAP_REF_LABEL, Path(assets["map_png"])),
                ],
                out_path,
            )
            plates[tag] = {
                "plate_path": str(out_path),
                "ground": ground,
                "complex_reason_ko": cx.get("reason_ko") or "",
                "prompt": prompt,
                "skipped_reason": None,
                "error": None,
                # intermediate 등록의 input lineage 소스 (2차 리뷰 M6)
                "source_master_png": str(assets["master_png"]),
                "source_map_png": str(assets["map_png"]),
            }
            sidecar[tag] = {
                "input_fingerprint": fingerprint,
                "ground": ground,
                "prompt": prompt,
            }
            save_sidecar_records(records_path, sidecar)
        except Exception as exc:  # noqa: BLE001 — 샷 단위 실패 격리
            logger.exception("%s: %s 맵 플레이트 실패", _MODULE, tag)
            plates[tag] = {
                "plate_path": None,
                "ground": None,
                "complex_reason_ko": cx.get("reason_ko") or "",
                "prompt": None,
                "skipped_reason": None,
                "error": str(exc),
            }
    return {"map_plates": plates}
