"""shot_ref_classify — bgonly·prev v3·time_of_day LLM 분류 (2026-07-13 레시피 이식).

s40/s41 확정 레시피의 샷 분류 3종을 한 스텝 데이터로 산출한다:
  1) bgonly(person_visible): 샷 텍스트만으로 '살아있는 인물의 실물 몸' 등장
     판정. 신체 일부·거울 반사=인물 / 그림자·사진 속 이미지·발자국 흔적=배경
     전용. 정본: scratchpad/forest_exp/s39_threeroll_vlm.py stage_bgonly.
  2) prev v3: ★유일 기준=배경(장소) 동일성 — 이동·LOCATION 상이·앞 샷
     클로즈업(더 앞 샷 거슬러 지정 허용)·시간대 상이=null. prev 시
     usage_en(TAKE/EXCLUDE) 동시 저작. 정본: s38_lightconti_full.py stage_prev2.
  3) time_of_day: 씬별 촬영 시간대 잠금 구절 저작 — 실험의 `_time_of` 헤딩
     substring 매칭을 LLM 저작으로 대체(글자/substring 의미 판단 금지 규칙).

코드 강제 규칙(LLM 결과 후처리):
  - bgonly 샷은 prev 판정이 있어도 무효(플레이트 강제, usage_en 소거).
  - prev 는 반드시 목록 내 + 스토리 순서상 앞 샷 — 위반은 null 로 격리하고
    prev_violation 에 기록(실험은 abort, production 은 샷 격리).
  - 세 판정 모두 전 샷/전 씬 누락 시 fail-fast(AppError).

LLM 입력 계약: 씬 원문 전문(절대 자르지 않음) + 샷 목록(각 샷에
`[LOCATION: <장소 서술>]` 병기). 모델은 manifest default(gpt, 분석 계열) —
실험 정본과 동일 배정.
"""
from __future__ import annotations

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

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

logger = logging.getLogger(__name__)

_MODULE = "shot_ref_classify"

PROMPT_VERSION_MAP = {
    "1": "1.202607132300",
    # 2 (2026-07-14, E2E 육안 피드백): world_anchor_en 저작(스틸 헤드 앵커 —
    # 실험의 국가·연대 하드코딩을 원문 근거 LLM 저작으로 대체, 사진 속
    # 서양인 드리프트 실측 대응) + 씬별 place_en(LOCATION lock 밀도 보강).
    "2": "2.202607140310",
    # 3 (2026-07-19, E2E9 S19sh4 실측 — 실내외 정합 fix2):
    #   a) time_of_day_en 장소 중립 계약 — 실내/실외·조명 기구·실내 광원
    #      언급 금지("early morning, interior lighting unspecified"가 야외
    #      샷을 오염시키던 실측 대응). 실내 광원 사실은 (b)로 이관.
    #   b) 샷별 place_en 신설(shot_place 번들) — 씬 장소 전체 서술 대신
    #      샷의 정확한 서브공간(외벽 앞/출입구/안쪽 통로 등)을 특정 +
    #      environment(interior/exterior/ambiguous) 판정. 씬별 place_en 은
    #      fallback·플레이트 선택용으로 유지.
    "3": "3.202607192150",
    # 4 (2026-08-07, 사용자 지적 — 손에 든 물건은 손까지 그려져야 한다):
    #   bgonly 번들에 `handled_by` 신설 — 그 순간 물건을 다루고 있는 사람이
    #   누구인지 샷 텍스트 근거로 적게 한다. person_visible 과 독립이다.
    #   실측: "누군가의 핸드폰 화면에 지도앱이 뜬 찰나"가 person_visible=
    #   false 로 분류됐고(화면 '속' 이미지만 보인다는 제외 항목에 걸렸다),
    #   그 결과 완성본에서 휴대폰이 손 없이 공중에 떠 있었다. 화면 속에
    #   무엇이 보이는가와 그 화면을 누가 들고 있는가는 다른 물음인데 판정이
    #   하나뿐이라 둘이 붙어 버렸다. 이 값이 있으면 스틸 조립이 bg_only
    #   샷에도 그 인물의 참조와 손 계약을 싣는다.
    "4": "4.202608071400",
}

_TAG_RE = re.compile(r"^S(\d+)sh(\d+)$")


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


def tag_of(scene_index: int, shot_index: int) -> str:
    """샷 태그 — 실험/레시피 전반의 공통 키 형식 `S{si}sh{shi}`."""
    return f"S{scene_index}sh{shot_index}"


def parse_tag(tag: str) -> Tuple[int, int]:
    m = _TAG_RE.match(tag)
    if not m:
        raise ValueError(f"invalid shot tag: {tag!r}")
    return int(m.group(1)), int(m.group(2))


def derive_location_by_scene(
    db: Any,
    project_id: str,
    scene_primary: Dict[int, str],
    scene_headings: Dict[int, str],
) -> Dict[int, str]:
    """scene_index → 장소 서술 — scene_director primary_location 을 EntityCanon
    이름으로 풀고, 없으면 씬 헤딩으로 degrade (전부 데이터 파생, 하드코딩 0).

    shot_ref_classify / shot_conti_light 등 레시피 스텝 공용.
    """
    from app.models.project import EntityCanon

    loc_ids = {v for v in scene_primary.values() if v}
    name_by_id: Dict[str, str] = {}
    if loc_ids:
        rows = (
            db.query(EntityCanon)
            .filter(EntityCanon.project_id == project_id)
            .all()
        )
        for e in rows:
            name_by_id[e.id] = e.name
            if e.short_id:
                name_by_id[e.short_id] = e.name
    out: Dict[int, str] = {}
    all_scenes = set(scene_primary) | set(scene_headings)
    for si in all_scenes:
        pid = scene_primary.get(si) or ""
        name = name_by_id.get(pid) or pid
        heading = scene_headings.get(si) or ""
        out[si] = name if name else heading
    return out


def _index_items_by_shot(
    items: List[Dict[str, Any]], tags: List[str], what: str
) -> Dict[str, Dict[str, Any]]:
    got = {it.get("shot"): it for it in items or []}
    missing = [t for t in tags if t not in got]
    if missing:
        raise AppError(
            code=f"step.contract_violation.{_MODULE}",
            message=f"{what} 판정 누락 샷: {missing}",
            status_code=422,
        )
    return got


def _call_items_with_retry(
    *,
    call_structured_fn: Callable[..., Dict[str, Any]],
    step_tag: str,
    system: str,
    user: str,
    schema: Dict[str, Any],
    tags: List[str],
    what: str,
    project_config: Optional[Dict[str, Any]],
    opik_metadata: Optional[Dict[str, Any]],
    max_attempts: int = 2,
) -> Dict[str, Dict[str, Any]]:
    """번들 판정 콜 + 누락 태그 힌트 재시도 (E2E 실측: 대형 입력에서 tier
    fallback 응답이 태그 누락/표기 이탈할 수 있음 — 1회 교정 재시도)."""
    current = user
    last_err: Optional[Exception] = None
    for attempt in range(1, max_attempts + 1):
        res = call_structured_fn(
            step_tag,
            system,
            current,
            schema,
            project_config=project_config,
            schema_name=step_tag,
            opik_metadata=opik_metadata,
        )
        try:
            return _index_items_by_shot(res.get("items"), tags, what)
        except AppError as exc:
            last_err = exc
            got = {it.get("shot") for it in res.get("items") or []}
            missing = [t for t in tags if t not in got]
            logger.warning(
                "%s %s attempt=%d 누락 %d샷 — 재시도", _MODULE, what,
                attempt, len(missing),
            )
            current = (
                user
                + "\n\n[재시도 지시] 직전 응답에서 다음 샷들이 누락되었거나"
                " 태그 표기가 달랐다. 목록의 모든 샷을, 입력에 적힌 태그"
                " 문자열 그대로(shot 필드) 빠짐없이 포함해 다시 판정하라: "
                + ", ".join(missing)
            )
    raise last_err  # type: ignore[misc]


def run_shot_ref_classify(
    *,
    shots: List[Dict[str, Any]],
    scene_texts: Dict[int, str],
    scene_headings: Dict[int, str],
    location_by_scene: Dict[int, 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,
) -> Dict[str, Any]:
    """분류 3종 실행 → 체크포인트 payload.

    Args:
        shots: [{scene_index, shot_index, description}] — 선택(selected) 샷만.
        scene_texts: scene_index → 씬 원문 전문 (자르지 않음).
        scene_headings: scene_index → 씬 헤딩.
        location_by_scene: scene_index → 장소 서술(데이터 파생 — 하드코딩 금지).

    Returns:
        {"shots": {tag: {person_visible, bgonly_reason_ko, prev, prev_reason_ko,
                          usage_en, prev_violation}},
         "scenes": {str(scene_index): {time_of_day_en, basis_ko}}}
    """
    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)
    ordered = sorted(
        shots, key=lambda s: (int(s["scene_index"]), int(s["shot_index"]))
    )
    tags = [tag_of(int(s["scene_index"]), int(s["shot_index"])) for s in ordered]
    if not tags:
        return {"shots": {}, "scenes": {}}
    desc_by_tag = {
        t: (s.get("description") or "") for t, s in zip(tags, ordered)
    }
    scene_ids = sorted({int(s["scene_index"]) for s in ordered})

    # 판정 모델 명시 — 실험 정본(forest_lib.llm default)=gpt(분석 계열).
    # step_tag 가 manifest 에 없어 gemini-pro 로 silent fallback 되고 tier
    # fallback 시 응답 형식이 흔들리던 E2E 실측 결함 대응.
    pc = {
        **(project_config or {}),
        f"{_MODULE}_bgonly": {"model": "gpt"},
        f"{_MODULE}_prev": {"model": "gpt"},
        f"{_MODULE}_time_of_day": {"model": "gpt"},
        f"{_MODULE}_world_anchor": {"model": "gpt"},
        f"{_MODULE}_shot_place": {"model": "gpt"},
    }

    # ── 1) bgonly ────────────────────────────────────────────────────
    bg_sys = load_prompt(_MODULE, "bgonly_system", version=resolved)
    bg_schema = load_schema(_MODULE, "bgonly_schema", None, version=resolved)
    shots_txt = "\n".join(f"{t}: {desc_by_tag[t]}" for t in tags)
    bg_got = _call_items_with_retry(
        call_structured_fn=call_structured_fn,
        step_tag=f"{_MODULE}_bgonly",
        system=bg_sys,
        user=f"샷 목록:\n{shots_txt}",
        schema=bg_schema,
        tags=tags,
        what="bgonly",
        project_config=pc,
        opik_metadata=opik_metadata,
    )

    # ── 2) prev v3 ───────────────────────────────────────────────────
    prev_sys = load_prompt(_MODULE, "prev_system", version=resolved)
    prev_schema = load_schema(_MODULE, "prev_schema", None, version=resolved)
    scenes_txt = "\n\n".join(
        f"[씬 {si}] {scene_headings.get(si, '')}\n{scene_texts.get(si, '')}"
        for si in scene_ids
    )
    shots_loc_txt = "\n".join(
        f"{t} [LOCATION: {location_by_scene.get(parse_tag(t)[0], '')}]"
        f"\n  {desc_by_tag[t]}"
        for t in tags
    )
    prev_got = _call_items_with_retry(
        call_structured_fn=call_structured_fn,
        step_tag=f"{_MODULE}_prev",
        system=prev_sys,
        user=f"씬 원문 전문:\n{scenes_txt}\n\n샷 목록(시간순):\n{shots_loc_txt}",
        schema=prev_schema,
        tags=tags,
        what="prev",
        project_config=pc,
        opik_metadata=opik_metadata,
    )

    # ── 3) time_of_day ───────────────────────────────────────────────
    tod_sys = load_prompt(_MODULE, "time_of_day_system", version=resolved)
    tod_schema = load_schema(
        _MODULE, "time_of_day_schema", None, version=resolved
    )
    tod_res = call_structured_fn(
        f"{_MODULE}_time_of_day",
        tod_sys,
        f"씬 목록:\n\n{scenes_txt}",
        tod_schema,
        project_config=pc,
        schema_name=f"{_MODULE}_time_of_day",
        opik_metadata=opik_metadata,
    )
    tod_got = {
        int(it["scene_index"]): it for it in tod_res.get("items") or []
    }
    tod_missing = [si for si in scene_ids if si not in tod_got]
    if tod_missing:
        raise AppError(
            code=f"step.contract_violation.{_MODULE}",
            message=f"time_of_day 판정 누락 씬: {tod_missing}",
            status_code=422,
        )

    # ── 3b) 샷별 place (v3 팩에만 존재 — 팩 1/2 하위호환) ────────────
    # fix2 (2026-07-19): 씬 place_en 은 씬 장소 전체 서술이라 한 씬 안의
    # 실내/실외 서브공간 차이를 못 잡는다(마트 외벽 샷이 실내 통로로
    # 그려진 실측) — 샷별 서브공간 특정 LOCATION lock 저작.
    place_got: Dict[str, Dict[str, Any]] = {}
    try:
        sp_sys = load_prompt(_MODULE, "shot_place_system", version=resolved)
        sp_schema = load_schema(
            _MODULE, "shot_place_schema", None, version=resolved
        )
    except FileNotFoundError:
        sp_sys = None
    if sp_sys is not None:
        place_got = _call_items_with_retry(
            call_structured_fn=call_structured_fn,
            step_tag=f"{_MODULE}_shot_place",
            system=sp_sys,
            user=(
                f"씬 원문 전문:\n{scenes_txt}\n\n샷 목록(시간순):\n"
                f"{shots_loc_txt}"
            ),
            schema=sp_schema,
            tags=tags,
            what="shot_place",
            project_config=pc,
            opik_metadata=opik_metadata,
        )

    # ── 코드 강제 규칙 병합 ──────────────────────────────────────────
    tag_order = {t: i for i, t in enumerate(tags)}
    out_shots: Dict[str, Dict[str, Any]] = {}
    for t in tags:
        person_visible = bool(bg_got[t]["person_visible"])
        prev_item = prev_got[t]
        prev: Optional[str] = prev_item.get("prev") or None
        usage_en = prev_item.get("usage_en") or ""
        violation: Optional[str] = None
        if prev is not None:
            if prev not in tag_order:
                violation = f"prev={prev!r} 목록 밖 — null 격리"
            elif tag_order[prev] >= tag_order[t]:
                violation = f"prev={prev!r} 앞 샷 아님 — null 격리"
            if violation:
                logger.warning("shot_ref_classify %s: %s", t, violation)
                prev, usage_en = None, ""
        if not person_visible and prev is not None:
            # 배경 전용 샷은 prev 무효 — 플레이트 강제 (s39 규칙)
            prev, usage_en = None, ""
        out_shots[t] = {
            "person_visible": person_visible,
            # 팩 v4 — 구 팩은 이 키가 없으므로 빈 문자열(=다루는 사람 없음).
            "handled_by": (bg_got[t].get("handled_by") or "").strip(),
            "bgonly_reason_ko": bg_got[t].get("reason_ko") or "",
            "prev": prev,
            "prev_reason_ko": prev_item.get("reason_ko") or "",
            "usage_en": usage_en if prev is not None else "",
            "prev_violation": violation,
        }
        if place_got:
            # v3: 샷별 서브공간 LOCATION lock — 소비측은 부재 시 씬
            # place_en 으로 fail-safe (팩 1/2 CP 하위호환)
            sp = place_got[t]
            out_shots[t]["place_en"] = sp.get("place_en") or ""
            out_shots[t]["environment"] = sp.get("environment") or ""
            # v4: 좁고 복잡한 구조물 내부 — 스틸이 기하 권위 계약을 켤지
            # 정하는 값. 구 팩 CP 에는 없으므로 False(기존 동작).
            out_shots[t]["confined_structure"] = bool(
                sp.get("confined_structure"))
            out_shots[t]["place_basis_ko"] = sp.get("basis_ko") or ""

    out_scenes = {
        str(si): {
            "time_of_day_en": tod_got[si].get("time_of_day_en") or "",
            # v2: 씬별 장소 서술 — 스틸 LOCATION lock 밀도 보강
            "place_en": tod_got[si].get("place_en") or "",
            "basis_ko": tod_got[si].get("basis_ko") or "",
        }
        for si in scene_ids
    }

    # ── 4) world anchor (v2 팩에만 존재 — 팩 1 하위호환) ─────────────
    world_anchor_en = ""
    try:
        wa_sys = load_prompt(_MODULE, "world_anchor_system", version=resolved)
        wa_schema = load_schema(
            _MODULE, "world_anchor_schema", None, version=resolved
        )
    except FileNotFoundError:
        wa_sys = None
    if wa_sys is not None:
        wa_res = call_structured_fn(
            f"{_MODULE}_world_anchor",
            wa_sys,
            f"씬 원문 전문:\n\n{scenes_txt}",
            wa_schema,
            project_config=pc,
            schema_name=f"{_MODULE}_world_anchor",
            opik_metadata=opik_metadata,
        )
        world_anchor_en = (wa_res or {}).get("world_anchor_en") or ""

    return {
        "shots": out_shots,
        "scenes": out_scenes,
        "world_anchor_en": world_anchor_en,
    }
