"""Shot별 VE 판정 — 변형 캐릭터 전환 시점 확정 + frame-visible SOT.

2026-05-13 frame-visible SOT unification:
    visible_entity_ids 의미를 "scene 에 존재" 가 아닌 "카메라 프레임 안에 물리적
    으로 보이는" 으로 통일. character / location / prop 모두 동일 기준 — LLM SOT.

    이전 _resolve_scene_no_variant deterministic path 는 L/P 를 ``scene_ve`` 에서
    무조건 keep 했음 (ECU/CU/macro framing 에서 frame 밖 prop/location 도 포함 →
    Area B render_contracts ↔ LLM frame 정확 묘사 충돌). 본 모듈은 항상 LLM path
    (``_resolve_scene_llm``) 호출 — open-world frame visibility 판단은 LLM 만.
"""
import logging
from typing import Dict, List, Optional, Set

from app.modules.llm.llm_client import call_structured
from app.modules.prompt_loader import load_prompt, load_schema
from app.modules.name_matcher import base_name
from app.modules.pipeline.shot_visibility import detect_gaze_pattern_exclusions

logger = logging.getLogger(__name__)
_MODULE = "shot_director"


def _build_entity_name_map(entities: Dict[str, List[Dict]]) -> Dict[str, str]:
    """short_id → name 매핑."""
    m = {}
    for etype in ["characters", "locations", "props"]:
        for e in entities.get(etype, []):
            sid = e.get("short_id", "")
            if sid:
                m[sid] = e.get("name", "")
    return m


def _build_variant_map(relations: List[Dict]) -> Dict[str, str]:
    """base_short_id → variant_short_id 매핑 (visual_similarity=true만)."""
    m = {}
    for rel in relations:
        if rel.get("visual_similarity"):
            m[rel["base_short_id"]] = rel["variant_short_id"]
    return m


def _build_entity_desc_map(entities: Dict[str, List[Dict]]) -> Dict[str, str]:
    """short_id → description 매핑."""
    m = {}
    for etype in ["characters", "locations", "props"]:
        for e in entities.get(etype, []):
            sid = e.get("short_id", "")
            if sid:
                m[sid] = e.get("description", "")
    return m


def _resolve_scene_llm(
    scene_index: int,
    scene_ve: List[str],
    shots: List[Dict],
    scene_text: str,
    variant_map: Dict[str, str],
    entity_name_map: Dict[str, str],
    entity_desc_map: Dict[str, str],
    prev_scenes_context: str = "",
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> List[Dict]:
    """LLM 으로 shot별 VE 확정 — frame-visible SOT.

    2026-05-13 frame-visible SOT unification 이전엔 변형 쌍이 있는 씬에만
    호출됐고, 비-variant 씬은 deterministic ``_resolve_scene_no_variant`` 가
    L/P 를 무조건 keep 했다. 그 path 는 ECU/CU framing 에서 frame 밖 L/P 도
    visible 에 포함시켜 Area B render_contracts ↔ LLM frame 정확 묘사 충돌을
    surface 시켰다. 본 함수는 그 deterministic path 를 흡수 — variant 유무와
    무관하게 항상 호출되며, 비-variant 씬에선 ``variant_block = "(없음)"`` 이
    되어 prompt 가 variant 규칙을 무시한다.

    visible_entity_ids 의미: 카메라 프레임 안에 물리적으로 보이는 entity
    (character + location + prop 동일 기준). open-world semantic 판단은
    LLM SOT 만 — code heuristic 금지.
    """
    # VE 블록
    ve_lines = []
    for sid in scene_ve:
        name = entity_name_map.get(sid, "")
        ve_lines.append(f"- {sid} {name}")
    ve_block = "\n".join(ve_lines)

    # 변형 쌍 블록 (이 씬에 해당하는 것만, description 포함)
    ve_set = set(scene_ve)
    variant_lines = []
    for base_sid, var_sid in variant_map.items():
        if base_sid in ve_set and var_sid in ve_set:
            base_name_str = entity_name_map.get(base_sid, "")
            var_name_str = entity_name_map.get(var_sid, "")
            base_desc = entity_desc_map.get(base_sid, "")
            var_desc = entity_desc_map.get(var_sid, "")
            variant_lines.append(
                f"[원본] {base_sid} {base_name_str}: {base_desc}\n"
                f"[변형] {var_sid} {var_name_str}: {var_desc}"
            )
    variant_block = "\n\n".join(variant_lines) if variant_lines else "(없음)"

    # Shot 리스트 블록
    shot_lines = []
    for sh in shots:
        shot_lines.append(
            f"Shot {sh['shot_index']}: {sh['description']}"
        )
    shots_block = "\n".join(shot_lines)

    system = load_prompt(_MODULE, "system")
    analyze_template = load_prompt(_MODULE, "analyze")

    import copy
    schema = copy.deepcopy(load_schema(_MODULE, "analyze_schema"))

    # 스키마에 enum 제약 주입
    all_ids = list(scene_ve)
    schema["properties"]["shots"]["items"]["properties"]["visible_entity_ids"]["items"] = {
        "type": "string",
        "enum": all_ids,
    }

    user_prompt = analyze_template.format(
        ve_block=ve_block,
        variant_block=variant_block,
        prev_context=prev_scenes_context if prev_scenes_context else "(이전 씬 정보 없음 — 이 씬이 첫 번째이거나 이전 씬에 변형이 없음)",
        shots_block=shots_block,
        scene_text=scene_text,
    )

    result = call_structured(
        step="shot_director",
        system_prompt=system,
        user_prompt=user_prompt,
        response_schema=schema,
        project_config=project_config,
        schema_name="shot_director",
        opik_metadata=opik_metadata,
    )

    llm_shots = result.get("shots", [])

    # LLM 결과 검증: 누락된 shot 이 있으면 fail-fast.
    #
    # 2026-05-13 frame-visible SOT unification fix-up (Codex review Important):
    # 이전 fallback 은 누락 shot 에 ``visible_entity_ids = list(scene_ve)`` 를
    # synthesize 했는데, 이는 본 patch 가 영구 폐기한 scene-present L/P 의미
    # 그대로다 — Area B render_contracts 가 단일 shot 누락 만으로도 PRO-13
    # 결함 재발할 수 있다. CLAUDE.md feedback_llm_based_judgment.md Gate 4
    # (No Silent Fallback): structured field 부재/빈 응답 시 fail-fast 의무.
    # LLM contract 위반 (모든 selected shot emit 의무) → 운영자 명시 재실행 의무.
    expected_indices = {sh["shot_index"] for sh in shots}
    returned_indices = {s["shot_index"] for s in llm_shots}
    missing = expected_indices - returned_indices
    if missing:
        from app.core.errors import AppError
        raise AppError(
            code="shot_director.llm_missed_shots",
            message=(
                f"shot_director S{scene_index}: LLM 가 응답에서 shot index "
                f"{sorted(missing)} 누락. selected shots = {sorted(expected_indices)}, "
                f"returned shots = {sorted(returned_indices)}. scene-present L/P "
                f"fallback 으로 합성하면 frame-visible SOT 위반 (Area B contract "
                f"재발). LLM contract — 모든 selected shot emit 의무. 운영자 "
                f"명시 재실행 의무."
            ),
            status_code=502,
        )

    # Defense-in-depth gaze exclusion — LLM prompt 가 이미 처리했더라도 한 번 더
    # deterministic 검사. 결과는 audit field 로 함께 emit.
    name_to_char_id: Dict[str, str] = {}
    for sid, nm in entity_name_map.items():
        if not sid.startswith("C") or not nm or len(nm) < 2:
            continue
        name_to_char_id[nm] = sid
        bnm = base_name(nm)
        if bnm and len(bnm) >= 2 and bnm not in name_to_char_id:
            name_to_char_id[bnm] = sid
    desc_by_idx = {sh["shot_index"]: sh.get("description", "") for sh in shots}
    for ls in llm_shots:
        desc = desc_by_idx.get(ls.get("shot_index"), "")
        excluded_map = detect_gaze_pattern_exclusions(desc, name_to_char_id)
        excluded_ids = set(excluded_map.keys())

        # Area #3 W2: mutation 권한 박탈 — visible_entity_ids 재구성 제거.
        # LLM emit 결과를 SOT 로 유지. lexicon candidates 와 mismatch 시
        # logger.warning only (no mutation, no raise).
        if excluded_ids:
            ve_set = set(ls.get("visible_entity_ids", []))
            mismatch = ve_set & excluded_ids
            if mismatch:
                logger.warning(
                    "shot_director S%d_Shot%s: LLM emitted visible_entity_ids "
                    "%s intersect lexicon diagnostic candidates %s "
                    "(Area #3 diagnostic only, no mutation)",
                    scene_index, ls.get("shot_index"),
                    sorted(mismatch),
                    [f"{sid}({excluded_map[sid]})" for sid in sorted(mismatch)],
                )

        # Area #3: diagnostic only. This field no longer mutates visible_entity_ids.
        # Historical name kept for checkpoint/debug compatibility.
        ls["excluded_offscreen_entity_ids"] = sorted(excluded_ids)

    return llm_shots


def direct_shots(
    segments: List[Dict],
    scene_director_data: Dict = None,
    shot_extract_data: Dict = None,
    shot_selection_data: Optional[Dict] = None,
    entity_relation_data: Optional[Dict] = None,
    entities: Dict[str, List[Dict]] = None,
    fulltext: str = "",
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> Dict:
    """전체 씬에 대해 shot별 VE를 결정."""

    entity_name_map = _build_entity_name_map(entities)
    entity_desc_map = _build_entity_desc_map(entities)

    # 변형 맵
    relations = (entity_relation_data or {}).get("relations", [])
    variant_map = _build_variant_map(relations)

    # scene_director VE
    scene_ve_map: Dict[int, List[str]] = {}
    for ds in scene_director_data.get("scenes", []):
        scene_ve_map[ds["scene_index"]] = ds.get("present_entity_ids", [])

    # shot_extract
    scene_shots_map: Dict[int, List[Dict]] = {}
    for sc in shot_extract_data.get("scenes", []):
        scene_shots_map[sc["scene_index"]] = sc.get("shots", [])

    # shot_selection (selected shots만)
    sel_map: Dict[int, Set[int]] = {}
    if shot_selection_data:
        for s in shot_selection_data.get("scenes", []):
            sel_map[s["scene_index"]] = set(s.get("selected_shot_indices", []))

    # segment index → text
    seg_map: Dict[int, str] = {}
    for seg in segments:
        si = seg["scene_index"]
        seg_map[si] = seg.get("text", "")

    all_scenes = []
    llm_called = 0
    skipped = 0
    total_shots = 0

    # 이전 씬 결과를 누적 (최근 2개 씬 컨텍스트 빌드용)
    # [(scene_index, shots_with_ve)] — 처리된 순서대로 저장
    processed_scenes: List[tuple] = []

    def _build_prev_context(current_si: int) -> str:
        """이전 2개 씬의 shot 내용 + 결정된 VE를 컨텍스트 문자열로 빌드."""
        # current_si보다 앞선 씬 중 최근 2개
        prev = [
            (psi, shots) for psi, shots in processed_scenes
            if psi < current_si
        ]
        prev = prev[-2:]  # 최근 2개만
        if not prev:
            return ""
        lines = []
        for psi, shots in prev:
            lines.append(f"[씬 {psi}]")
            for sh in shots:
                ve_ids = sh.get("visible_entity_ids", [])
                ve_names = [f"{sid}({entity_name_map.get(sid, '')})" for sid in ve_ids if sid.startswith("C")]
                lines.append(
                    f"  Shot {sh['shot_index']}: {sh.get('description', '')} → VE: {', '.join(ve_names)}"
                )
            lines.append("")
        return "\n".join(lines)

    for si in sorted(scene_ve_map.keys()):
        scene_ve = scene_ve_map.get(si, [])
        all_shots = scene_shots_map.get(si, [])
        sel_indices = sel_map.get(si)
        selected_shots = [
            sh for sh in all_shots
            if sel_indices is None or sh["shot_index"] in sel_indices
        ]

        if not selected_shots:
            continue

        # 2026-05-13 frame-visible SOT unification: variant 유무와 무관하게 항상
        # LLM path. deterministic _resolve_scene_no_variant 영구 폐기 — L/P
        # frame-visibility 는 open-world semantic 이라 LLM SOT 만 판정 가능.
        # has_variants 는 audit field 로 유지 (consumer 없지만 cp 호환).
        ve_set = set(scene_ve)
        has_variants = any(
            b in ve_set and v in ve_set for b, v in variant_map.items()
        )
        scene_text = seg_map.get(si, "")
        prev_context = _build_prev_context(si)
        shot_results = _resolve_scene_llm(
            scene_index=si,
            scene_ve=scene_ve,
            shots=selected_shots,
            scene_text=scene_text,
            variant_map=variant_map,
            entity_name_map=entity_name_map,
            entity_desc_map=entity_desc_map,
            prev_scenes_context=prev_context,
            project_config=project_config,
            opik_metadata=opik_metadata,
        )
        llm_called += 1

        # shot description을 결과에 포함 (다음 씬 컨텍스트용)
        shot_desc_map = {sh["shot_index"]: sh.get("description", "") for sh in selected_shots}
        for sr in shot_results:
            if "description" not in sr:
                sr["description"] = shot_desc_map.get(sr["shot_index"], "")

        total_shots += len(shot_results)
        all_scenes.append({
            "scene_index": si,
            "shots": shot_results,
            "has_variants": has_variants,
        })
        processed_scenes.append((si, shot_results))

    logger.info(
        "shot_director: %d scenes (%d LLM, %d skipped), %d total shots",
        len(all_scenes), llm_called, skipped, total_shots,
    )

    return {
        "scenes": all_scenes,
        "total_shots": total_shots,
        "llm_called_scenes": llm_called,
        "skipped_scenes": skipped,
    }
