"""Shot 연관 분석 (LLM) — 같은 장소의 앞쪽 샷 중 배경 참조를 고른다.

**shot description + typed 프레이밍**(`shot_staging` 의
`framing_scale`·`camera_direction`)을 LLM 에 전달해 연관성을 판단한다.
이미지 생성 시 참조 이미지 선택에 쓰이고, shot_dependency(1차) 결과를
덮어쓴다.

★2026-08-27 (v1 중복 저작 정리): 종전에는 프레이밍 재료를
 `scene_detail.t2i_variations[0].t2i_prompt`(호출당 832~1,160자 산문)로
 받았다. 같은 정보가 `shot_staging` 에 typed 로 있고 이 스텝은 그 CP 를
 이미 읽고 있었다(LLM **뒤** close 정책에만 썼다). 이제 그것을 LLM
 입력으로 쓴다 — `depends_on` 에 `shot_staging` 을 명시했다.
"""
import json
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from app.core.errors import AppError
from app.core.framing_scale import FRAMING_CLOSE, get_framing_scale_or_raise
from app.core.keep_elements import validate_keep_elements_entry
from app.core.step_runner import StepRunner
from app.modules.llm.llm_client import call_structured
from app.modules.prompt_loader import load_prompt, load_schema

logger = logging.getLogger(__name__)


def _process_llm_result(
    result: Dict[str, Any],
    *,
    shots: List[Dict[str, Any]],
    loc_id: str,
) -> Dict[tuple, Dict[str, Any]]:
    """LLM result 의 dependencies → llm_results dict 변환 + post-validation.

    각 location_refs[].keep_elements 의 entry 를 validate_keep_elements_entry
    로 검증. forward reference 검증도 본 helper 안에서 수행.

    Returns: {(si, shi): {"location_refs": [validated_ref, ...]}}.

    Raises: AppError (keep_elements shape 위반).
    """
    llm_results: Dict[tuple, Dict[str, Any]] = {}
    for dep in result.get("dependencies", []):
        key = (dep.get("scene_index"), dep.get("shot_index"))
        loc_refs = dep.get("location_refs", [])
        validated_refs = []
        for ref_idx_in_list, ref in enumerate(loc_refs[:1]):
            label_source = f"{loc_id} S{key[0]}_Shot{key[1]} loc_ref[{ref_idx_in_list}]"
            # Area D-next-min — keep_elements required field.
            # `.get("keep_elements", [])` silent fallback 금지. schema v7
            # 이 required 로 정의하므로 누락 시 AppError fail-fast.
            if "keep_elements" not in ref:
                raise AppError(
                    code="step.shot_dependency_t2i.keep_elements_shape_invalid",
                    message=(
                        f"{label_source}: 'keep_elements' key missing in "
                        f"location_ref — schema v7 required field. value={ref!r}"
                    ),
                    status_code=400,
                )
            # entry 별 shape validation (L1 producer SOT).
            for entry in ref["keep_elements"]:
                validate_keep_elements_entry(
                    entry, label_source=label_source,
                    error_code="step.shot_dependency_t2i.keep_elements_shape_invalid",
                )
            # forward reference 검증 (기존 로직).
            ref_key = (ref.get("scene_index"), ref.get("shot_index"))
            ref_idx = next(
                (i for i, s in enumerate(shots)
                 if s["scene_index"] == ref_key[0] and s["shot_index"] == ref_key[1]),
                -1,
            )
            cur_idx = next(
                (i for i, s in enumerate(shots)
                 if s["scene_index"] == key[0] and s["shot_index"] == key[1]),
                -1,
            )
            if 0 <= ref_idx < cur_idx:
                validated_refs.append(ref)
            else:
                logger.warning(
                    "shot_dependency_t2i: forward ref S%d_Shot%d → S%d_Shot%d, skipping",
                    key[0], key[1], ref_key[0], ref_key[1],
                )
        llm_results[key] = {"location_refs": validated_refs}
    return llm_results


def _handle_llm_call_for_loc(
    *,
    system: str,
    user_prompt: str,
    schema: Dict[str, Any],
    project_config: Any,
    opik_metadata: Dict[str, Any],
    shots: List[Dict[str, Any]],
    loc_id: str,
) -> Tuple[Dict[tuple, Dict[str, Any]], int]:
    """단일 location 의 LLM call + post-validation + provider failure fallback.

    Returns: (llm_results, failed_count_increment).

    Behavior:
    - call_structured 성공 시 _process_llm_result 로 dict 반환.
    - shape AppError 는 silent absorb 차단 — caller 까지 re-raise.
    - provider/transport/JSON parse failure (non-AppError Exception) 만
      catch + warning + fallback empty refs + failed_count++.
    """
    try:
        result = call_structured(
            step="shot_dependency_t2i",
            system_prompt=system,
            user_prompt=user_prompt,
            response_schema=schema,
            project_config=project_config,
            opik_metadata=opik_metadata,
        )
        llm_results = _process_llm_result(result, shots=shots, loc_id=loc_id)
        logger.info("shot_dependency_t2i: %s (%d shots) — LLM OK", loc_id, len(shots))
        return llm_results, 0
    except AppError:
        # Area D-next — shape validation 위반 은 silent absorb 차단,
        # broad except 밖으로 re-raise.
        raise
    except Exception as e:
        logger.warning("shot_dependency_t2i: %s LLM failed: %s — fallback to empty", loc_id, e)
        empty = {(s["scene_index"], s["shot_index"]): {"location_refs": []} for s in shots}
        return empty, 1


_VALID_REF_USAGE = ("zoom_in_detail", "exact_background", "atmosphere_reference")


def _framing_line_or_raise(
    si: int, shi: int,
    framing_scale_map: Dict[Tuple[int, int], str],
    camera_map: Dict[Tuple[int, int], str],
) -> str:
    """LLM 입력에 실을 프레이밍 한 줄 — **없으면 호출 전에 선다.**

    ★이 판은 t2i 산문을 빼고 프레이밍을 핵심 대체 재료로 삼는다. 그것이
     비어 있는데 그대로 유료 호출을 하면 1순위 기준(카메라 이동/확대
     연속)을 **재료 없이 판단**하게 된다 — 조용히 나빠지고 돈은 나간다.
     종전 enum 검증(`_build_framing_map`)은 **호출 뒤** close 정책에만
     있었다 (2026-08-27 Codex BLOCK-4).
    """
    scale = framing_scale_map.get((si, shi), "")
    cam = camera_map.get((si, shi), "")
    if not scale:
        raise AppError(
            code="step.contract_violation.shot_dependency_t2i",
            message=(f"S{si}_Shot{shi}: framing_scale 부재 — shot_staging "
                     f"가 이 샷의 프레이밍을 안 준다(유료 호출 전 중단)"),
        )
    if not cam:
        raise AppError(
            code="step.contract_violation.shot_dependency_t2i",
            message=(f"S{si}_Shot{shi}: camera_direction 이 비었다 — "
                     f"프레이밍 재료 없이 참조를 고를 수 없다"),
        )
    return f"{scale} / {cam}"


def _build_framing_map(
    staging_cp: Dict[str, Any],
) -> Dict[Tuple[int, int], str]:
    """shot_staging checkpoint → {(scene_index, shot_index): framing_scale}.

    framing_scale 는 shot_staging enum SOT — get_framing_scale_or_raise 로
    누락/invalid 를 fail-fast (No Silent Fallback).
    """
    framing_map: Dict[Tuple[int, int], str] = {}
    for shot in staging_cp.get("data", {}).get("shots", []):
        si = shot.get("scene_index")
        shi = shot.get("shot_index")
        if si is None or shi is None:
            continue
        framing_map[(si, shi)] = get_framing_scale_or_raise(
            shot, where=f"shot_dependency_t2i._build_framing_map S{si}_Shot{shi}"
        )
    return framing_map


def _apply_close_framing_ref_usage_policy(
    dependencies: List[Dict[str, Any]],
    framing_map: Dict[Tuple[int, int], str],
) -> None:
    """close framing × ref_usage cross-step 정합 — deterministic post-process.

    FINDING 9 W3 sub-cause A. shot_dependency_t2i 의 LLM 은 shot_staging.
    framing_scale 을 모른 채 ref_usage 를 고른다. consumer close×ref_usage
    matrix invariant 상 close shot 의 location_ref 는 `zoom_in_detail` 만
    허용되므로, shot_staging.framing_scale SOT 기준으로 각 dependency 의
    location_ref 를 deterministic 하게 정합한다 (in-place mutate).

    close + location_ref present:
      - exact_background     → ref_usage='zoom_in_detail' (같은 방 배경 연속)
      - atmosphere_reference → location_ref drop (다른 방 — zoom_in_detail 의미모순)
      - zoom_in_detail       → keep
      - 그 외(빈값/enum 밖)  → AppError fail-fast (silent normalize 금지)
    close + location_refs=[] / non-close → 변경 없음.

    Raises:
      AppError: dependency shot 의 framing_scale 누락, 또는 close shot 의
        location_ref ref_usage 가 invalid.
    """
    for dep in dependencies:
        si = dep.get("scene_index")
        shi = dep.get("shot_index")
        framing = framing_map.get((si, shi))
        if framing is None:
            raise AppError(
                code="step.shot_dependency_t2i.framing_scale_missing",
                message=(
                    f"S{si}_Shot{shi}: framing_scale 부재 — shot_staging "
                    f"checkpoint 에 해당 shot 없음 (FINDING 9 W3)."
                ),
                status_code=400,
            )
        if framing != FRAMING_CLOSE:
            continue
        loc_refs = dep.get("location_refs", [])
        if not loc_refs:
            continue
        ref = loc_refs[0]
        ref_usage = ref.get("ref_usage")
        if ref_usage == "exact_background":
            ref["ref_usage"] = "zoom_in_detail"
        elif ref_usage == "atmosphere_reference":
            dep["location_refs"] = []
        elif ref_usage == "zoom_in_detail":
            pass
        else:
            raise AppError(
                code="step.shot_dependency_t2i.close_ref_usage_invalid",
                message=(
                    f"S{si}_Shot{shi}: close framing location_ref 의 "
                    f"ref_usage={ref_usage!r} invalid — expected one of "
                    f"{_VALID_REF_USAGE} (FINDING 9 W3, silent normalize 금지)."
                ),
                status_code=400,
            )


class ShotDependencyT2iStep(StepRunner):
    """T2I 기반 shot 연관 재계산 — LLM이 스토리 연관성으로 판단."""

    def _load_prev_checkpoint(self, step_id: str):
        from app.core.config import settings
        cp = (
            Path(settings.projects_dir) / self.project_id
            / "checkpoints" / "episodes" / self.episode_id
            / step_id / "manifest.json"
        )
        if cp.exists():
            return json.loads(cp.read_text(encoding="utf-8"))
        return None

    def _execute(self, mode="resume") -> Dict[str, Any]:
        shot_cp = self._load_prev_checkpoint("shot_validator")
        if not shot_cp or not shot_cp.get("data", {}).get("scenes"):
            raise AppError(code="step.no_shots", message="shot_extract 결과 없음", status_code=400)

        from app.core.steps.shot_validator_step import assert_no_failed_scenes
        assert_no_failed_scenes(shot_cp, self.project_config, consumer_step="shot_dependency_t2i")

        sel_cp = self._load_prev_checkpoint("shot_selection")
        selected_map: Dict[int, set] = {}
        if sel_cp and sel_cp.get("data", {}).get("scenes"):
            for s in sel_cp["data"]["scenes"]:
                selected_map[s["scene_index"]] = set(s.get("selected_shot_indices", []))

        # scene_director — 씬별 primary_location
        director_cp = self._load_prev_checkpoint("scene_director")
        scene_location: Dict[int, str] = {}
        location_names: Dict[str, str] = {}
        if director_cp and director_cp.get("data", {}).get("scenes"):
            for ds in director_cp["data"]["scenes"]:
                loc = ds.get("primary_location", "")
                scene_location[ds["scene_index"]] = loc

        # entity_merge에서 location short_id → name 매핑 + 전체 엔티티 이름 매핑
        merge_cp = self._load_prev_checkpoint("entity_merge")
        entity_names: Dict[str, str] = {}  # short_id → name
        if merge_cp:
            for loc in merge_cp.get("data", {}).get("locations", []):
                sid = loc.get("short_id", "")
                if sid:
                    location_names[sid] = loc.get("name", sid)
            for et in ("characters", "locations", "props"):
                for ent in merge_cp.get("data", {}).get(et, []):
                    sid = ent.get("short_id", "")
                    if sid:
                        entity_names[sid] = ent.get("name", sid)

        # scene_director에서 씬별 엔티티 목록
        scene_entities: Dict[int, List[str]] = {}
        if director_cp and director_cp.get("data", {}).get("scenes"):
            for ds in director_cp["data"]["scenes"]:
                scene_entities[ds["scene_index"]] = ds.get("present_entity_ids", [])

        # ── 프레이밍 재료 (2026-08-27, v1 산문 소비 끊기 1단계) ────────
        #
        # 이 스텝의 1순위 판단 기준은 **카메라 이동/확대 연속**이다 —
        # 「같은 순간의 같은 공간·같은 피사체를 다른 프레이밍으로」
        # (팩 system 문안). `description` 은 순간·인물·소품만 말하고
        # **프레이밍을 안 말하므로** 그 기준에 쓸 재료가 없다.
        #
        # v1: `shot_staging` 의 typed 두 칸으로 받는다.
        #     `camera_direction` 은 최종 이미지 프롬프트 CAMERA 절에
        #     들어가는 **바로 그 문장**이고 `framing_scale` 은 enum SOT 다.
        # legacy(off): **종전 그대로** `scene_detail.t2i_prompt`.
        #     그 경로는 t2i_variations 를 이미지에 실제로 쓴다
        #     (`scene_image_service.py:397-404` 의 v1 분기 반대편) —
        #     v1 6샷 실측으로 legacy 입력까지 바꿀 근거가 없다.
        from app.core.config import settings as _st

        _lean = getattr(_st, "still_recipe_mode", "off") == "v1"
        staging_cp_in = self._load_prev_checkpoint("shot_staging")
        # ★검증된 map 을 **한 번만** 만들어 LLM 입력과 close 후처리가
        #  같은 것을 쓴다. 종전에는 close 쪽만 enum 을 검증했고 그것이
        #  **유료 호출 뒤**였다 — 이 판은 프레이밍을 핵심 대체 재료로
        #  삼으므로 **호출 전에** fail-closed 해야 한다(Codex BLOCK-4).
        framing_scale_map = _build_framing_map(staging_cp_in or {})
        camera_map: Dict[tuple, str] = {}
        for _sh in (staging_cp_in or {}).get("data", {}).get("shots", []):
            _si, _shi = _sh.get("scene_index"), _sh.get("shot_index")
            if _si is not None and _shi is not None:
                camera_map[(_si, _shi)] = str(
                    _sh.get("camera_direction") or "").strip()

        t2i_map: Dict[tuple, str] = {}
        if not _lean:
            detail_cp = self._load_prev_checkpoint("scene_detail")
            if detail_cp:
                for s_ in detail_cp.get("data", detail_cp).get(
                        "scenes", detail_cp.get("scenes", [])):
                    si_, shot_idx_ = s_.get("scene_index"), s_.get("_shot_index")
                    if si_ is None or shot_idx_ is None:
                        continue
                    for var in s_.get("t2i_variations", []):
                        if var.get("t2i_prompt"):
                            t2i_map[(si_, shot_idx_)] = var["t2i_prompt"]
                            break

        # shot_extract descriptions
        shot_desc_map: Dict[tuple, str] = {}
        for sc in shot_cp["data"]["scenes"]:
            si = sc["scene_index"]
            for sh in sc.get("shots", []):
                shot_desc_map[(si, sh.get("shot_index", 0))] = sh.get("description", "")

        # selected shots 수집 + location별 그루핑
        all_selected: List[Dict] = []
        location_groups: Dict[str, List[Dict]] = {}  # loc_id → [shot_info, ...]

        for sc in shot_cp["data"]["scenes"]:
            si = sc["scene_index"]
            sel_indices = selected_map.get(si)
            for sh in sc.get("shots", []):
                shot_idx = sh.get("shot_index", 0)
                if sel_indices is None or shot_idx in sel_indices:
                    loc = scene_location.get(si, "")
                    info = {
                        "scene_index": si,
                        "shot_index": shot_idx,
                        "location": loc,
                        "description": shot_desc_map.get((si, shot_idx), ""),
                        "framing": _framing_line_or_raise(
                            si, shot_idx, framing_scale_map, camera_map,
                        ) if _lean else "",
                        "t2i": t2i_map.get((si, shot_idx), ""),
                    }
                    all_selected.append(info)
                    if loc:
                        location_groups.setdefault(loc, []).append(info)

        if not all_selected:
            return {"completed_count": 0, "applicable_count": 0, "failed_count": 0,
                    "data": {"dependencies": []}}

        # LLM 시스템 프롬프트 로드
        system = load_prompt("shot_dependency_t2i", "system")
        schema = load_schema("shot_dependency_t2i", "schema")

        # 위치별 LLM 호출
        llm_results: Dict[tuple, Dict] = {}  # (si, shi) → {location_refs: [...]}
        failed = 0

        for loc_id, shots in location_groups.items():
            if len(shots) < 2:
                # 1샷만 있으면 참조 불가
                for s in shots:
                    llm_results[(s["scene_index"], s["shot_index"])] = {"location_refs": []}
                continue

            loc_name = location_names.get(loc_id, loc_id)
            shot_lines = []
            for s in shots:
                si = s["scene_index"]
                ent_sids = scene_entities.get(si, [])
                ent_names_list = [entity_names.get(sid, sid) for sid in ent_sids if sid]
                ent_line = f"\n  Entities: {', '.join(ent_names_list)}" if ent_names_list else ""
                shot_lines.append(
                    f"[S{si:02d}_Shot{s['shot_index']}]\n"
                    f"  Description: {s['description']}\n"
                    + (f"  Framing: {s['framing']}" if _lean
                       else f"  T2I: {s['t2i']}")
                    + ent_line
                )

            user_prompt = (
                f"장소: {loc_name} ({loc_id})\n\n"
                f"아래는 이 장소에서 촬영되는 샷들입니다 (시간순).\n"
                f"각 샷에 대해 배경 참조로 가장 적합한 앞쪽 샷을 선택하세요.\n\n"
                + "\n\n".join(shot_lines)
            )

            results_for_loc, failed_inc = _handle_llm_call_for_loc(
                system=system,
                user_prompt=user_prompt,
                schema=schema,
                project_config=self.project_config,
                opik_metadata=self.build_opik_metadata(),
                shots=shots,
                loc_id=loc_id,
            )
            llm_results.update(results_for_loc)
            failed += failed_inc

        # 전체 결과 조립
        dependencies = []
        for s in all_selected:
            key = (s["scene_index"], s["shot_index"])
            dep = llm_results.get(key, {"location_refs": []})
            dependencies.append({
                "scene_index": s["scene_index"],
                "shot_index": s["shot_index"],
                "location_refs": dep.get("location_refs", []),
                "character_refs": [],
            })

        # FINDING 9 W3 — close framing × ref_usage cross-step 정합.
        # shot_staging.framing_scale SOT 기준 deterministic post-process.
        staging_cp = self._load_prev_checkpoint("shot_staging")
        if not staging_cp:
            raise AppError(
                code="step.shot_dependency_t2i.no_shot_staging",
                message=(
                    "shot_staging checkpoint 없음 — framing_scale 정합 불가 "
                    "(FINDING 9 W3)"
                ),
                status_code=400,
            )
        _apply_close_framing_ref_usage_policy(
            dependencies, _build_framing_map(staging_cp)
        )

        linked = sum(1 for d in dependencies if d["location_refs"])
        logger.info("shot_dependency_t2i: %d/%d linked (LLM-based)", linked, len(all_selected))

        # 결과는 자체 체크포인트(shot_dependency_t2i)에 저장됨 (StepRunner.save_checkpoint)
        # image_service는 shot_dependency_t2i 체크포인트를 우선 읽고, 없으면 shot_dependency fallback

        return {
            "completed_count": len(dependencies),
            "applicable_count": len(all_selected),
            "failed_count": failed,
            "config_hash": self._config_hash(),
            "data": {"dependencies": dependencies},
        }

    def _config_hash(self) -> str:
        """실제로 로드된 프롬프트 팩 판을 담은 config_hash.

        ★이 step 은 판 인자 없이 **최신** 팩을 로드하는데(`_execute` 의
        `load_prompt`/`load_schema`) 반환에 `config_hash` 가 없어
        `StepRunner` 가 `compute_config_hash(project_config)` 로 떨어졌다.
        그래서 팩을 새 판으로 올려도 **옛 판 완료 CP 가 유효한 CP 로 SKIP**
        된다 — v8 완료 CP 가 v9 배포 뒤에도 그대로 재사용됐고, CP 만 보고는
        어느 판으로 만든 산출인지 가릴 수도 없었다. `step_runner` 주석이
        말하는 *"PROMPT_VERSION 등 step-local 시그널"* 이 이 자리다.

        `version_registry` 의 v9 표기는 provenance 기록용이라 이 판정에
        참여하지 않는다. 그래서 로더가 고른 판을 여기서 직접 읽는다.
        """
        import hashlib
        import json as _json

        from app.modules.prompt_loader import _resolve_stem_in_pack

        _, sys_ver, _ = _resolve_stem_in_pack(
            "shot_dependency_t2i", "system", ext=".md")
        _, sch_ver, _ = _resolve_stem_in_pack(
            "shot_dependency_t2i", "schema", ext=".json")
        payload = {
            "prompt_pack_system": sys_ver or "",
            # stem 별 독립 탐색이라 둘이 다른 판일 수 있다(로더 계약) —
            # 각각 싣는다.
            "prompt_pack_schema": sch_ver or "",
            "project_config": self.project_config,
        }
        # ★2026-08-27 (Codex BLOCK-3): **입력 계약이 지문에 들어간다.**
        #  v1 은 LLM 에 t2i 산문 대신 typed 프레이밍을 보낸다. 그것을 안
        #  접으면 옛 계약으로 만든 완주 CP 가 **새 계약인 것처럼** current
        #  로 읽혀 조용히 skip 된다.
        #
        # ★legacy 지문은 **그대로 둔다** — 그 경로는 입력이 안 바뀌었다.
        #  OFF 에서 칸을 아예 안 만들어 byte-identical 을 지킨다.
        from app.core.config import settings as _st

        if getattr(_st, "still_recipe_mode", "off") == "v1":
            payload["llm_input_contract"] = "v1_typed_framing"
        return hashlib.sha256(
            _json.dumps(payload, sort_keys=True, ensure_ascii=False,
                        default=lambda o: type(o).__name__).encode("utf-8")
        ).hexdigest()[:16]
