"""씬 추출 v2/v3 — 씬 세그먼테이션(정규식 or Gemini Lite) + Gemini 긴 씬 분할 + 멀티턴 상세 분석.

v3 추가: 아웃룩 감지, N개 T2I 변형, [[인물]+[아웃룩]] 마커.

1단계: 씬 세그먼테이션 — INT./EXT. 헤딩(시나리오) 또는 Gemini Lite AI(소설/일반 텍스트)
2단계: threshold 이상 긴 씬 → Gemini에 논리적 2분할 요청
3단계: Turn 0 — 컨텍스트 전달 (스타일 + 요소 + 요약)
4단계: Turn 1~N — 각 씬 텍스트를 직접 제공하여 상세 분석 (아웃룩 + N개 T2I)
"""

import hashlib
import json
import logging
import os
import re
import time
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional

from app.core.config import settings
from app.modules.prompt_loader import load_prompt, load_schema as _loader_schema

DEFAULT_SPLIT_THRESHOLD = 600  # 기본 분할 임계값

logger = logging.getLogger(__name__)

_MODULE = "scene_extractor_v2"


def _load_system_prompt() -> str:
    return load_prompt(_MODULE, "system")


def _load_turn_prompt(turn_name: str, **kwargs) -> str:
    return load_prompt(_MODULE, turn_name, **kwargs)


def _load_schema(schema_name: str) -> Optional[Dict[str, Any]]:
    """JSON 스키마 로드. 없으면 None."""
    stem = schema_name.replace(".json", "") if schema_name.endswith(".json") else schema_name
    try:
        return _loader_schema(_MODULE, stem)
    except FileNotFoundError:
        return None


# ── JSON 스키마 ──

SPLIT_SUGGESTION_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "split_suggestions": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "original_scene_index": {"type": "integer"},
                    "split": {"type": "boolean", "description": "항상 true"},
                    "split_after_line": {
                        "type": "string",
                        "description": "이 줄 직후에서 분할. 원문에서 정확히 복사한 한 줄.",
                    },
                    "part1_summary": {"type": "string"},
                    "part2_summary": {"type": "string"},
                },
                "required": [
                    "original_scene_index", "split", "split_after_line",
                    "part1_summary", "part2_summary",
                ],
                "additionalProperties": False,
            },
        },
    },
    "required": ["split_suggestions"],
    "additionalProperties": False,
}


def _get_scene_detail_schema() -> Dict[str, Any]:
    """씬 상세 분석 스키마 — 외부 JSON 우선, 없으면 내장 fallback."""
    schema = _load_schema("scene_detail_schema.json")
    if schema:
        return schema
    # fallback (v2 호환)
    return {
        "type": "object",
        "properties": {
            "scene_index": {"type": "integer"},
            "heading": {"type": "string"},
            "beat_title": {"type": "string"},
            "scene_type": {
                "type": "string",
                "enum": ["normal", "montage", "flashback", "dream", "voiceover", "transition"],
            },
            "representative_moment": {"type": "string"},
            "t2i_prompt": {"type": "string"},
            "visible_entities": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "entity_name": {"type": "string"},
                        "entity_type": {"type": "string"},
                    },
                    "required": ["entity_name", "entity_type"],
                },
            },
            "dependent_scene_index": {"type": "integer"},
            "dependency_reason": {"type": "string"},
        },
        "required": [
            "scene_index", "heading", "beat_title", "scene_type",
            "representative_moment", "t2i_prompt", "visible_entities",
            "dependent_scene_index", "dependency_reason",
        ],
    }


# ── 1단계: 씬 세그먼테이션 (시나리오 or 일반 텍스트) ──

def _segment_scenes(fulltext: str, split_threshold: int = DEFAULT_SPLIT_THRESHOLD, project_config: Optional[Dict] = None) -> List[Dict[str, Any]]:
    """씬 세그먼트 분리 — 2단계 Gemini Lite.

    1단계: Gemini Lite로 1차 씬 분할
    2단계: threshold 초과 씬을 Gemini Lite로 재분할
    """
    # 1단계: Gemini Lite 1차 분할
    segments = _segment_by_llm(fulltext, max_chars=split_threshold, project_config=project_config)

    if not segments:
        # fallback: 정규식 시도 (LLM 실패 시)
        logger.info("LLM segmentation returned empty — trying regex fallback")
        segments = _segment_by_heading(fulltext)
    if not segments:
        # 최종 fallback: 전체를 1개 씬으로
        segments = _single_scene_fallback(fulltext)

    # 2단계: 큰 씬 재분할
    final_segments = []
    for seg in segments:
        if seg["length"] > split_threshold:
            scene_text = seg.get("text") or fulltext[seg.get("start_char", 0):seg.get("end_char", len(fulltext))]
            sub_segments = _split_large_scene_by_llm(scene_text, seg, split_threshold, project_config=project_config)
            final_segments.extend(sub_segments)
        else:
            final_segments.append(seg)

    # scene_index 재정렬
    for i, s in enumerate(final_segments):
        s["scene_index"] = i + 1

    logger.info("Final segmentation: %d scenes (threshold=%d)", len(final_segments), split_threshold)
    return final_segments


def _split_large_scene_by_llm(scene_text: str, parent_seg: dict, max_chars: int, project_config: Optional[Dict] = None) -> List[Dict[str, Any]]:
    """큰 씬을 Gemini Flash로 재분할 (자연스러운 분할점이 없으면 1개 유지)."""
    from app.modules.llm.llm_client import call_structured

    expected = max(2, len(scene_text) // max_chars)

    prompt = f"""다음 씬 텍스트({len(scene_text)}자)를 자연스러운 지점에서 나눠주세요.

규칙:
- 각 부분은 약 {max_chars}자 이내가 목표
- 장면 전환, 대화 끊김, 시점 변화 등 자연스러운 분할점이 있으면 나누세요
- 자연스러운 분할점이 없으면 무리하게 나누지 말고 1개로 유지해도 됩니다
- 약 {expected}개로 나누는 것이 목표이지만 강제가 아닙니다

씬 제목: {parent_seg['heading']}
텍스트:
{scene_text}

JSON 반환. 각 항목: scene_index(1부터), heading(부제목), start_line_text(시작 첫 줄, 원문 20자 이내)"""

    schema = {
        "type": "object",
        "properties": {
            "scenes": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "scene_index": {"type": "integer"},
                        "heading": {"type": "string"},
                        "start_line_text": {"type": "string"},
                    },
                    "required": ["scene_index", "heading", "start_line_text"],
                },
            },
        },
        "required": ["scenes"],
    }

    try:
        result = call_structured(
            step="scene_split",
            system_prompt=f"텍스트를 약 {max_chars}자 간격으로 분할.",
            user_prompt=prompt,
            response_schema=schema,
            project_config=project_config,
            schema_name="scene_split",
        )
        scenes = result.get("scenes", [])
        if len(scenes) <= 1:
            return [parent_seg]

        # 위치 매핑 (parent_seg.start_char 기준)
        base = parent_seg["start_char"]
        sub_segments = []
        for i, sc in enumerate(scenes):
            st = sc.get("start_line_text", "").strip()
            search_from = sub_segments[-1]["start_char"] + 1 - base if sub_segments else 0
            pos = scene_text.find(st, max(0, search_from)) if st else -1
            if pos < 0 and sub_segments:
                pos = sub_segments[-1]["end_char"] - base
            elif pos < 0:
                pos = 0
            sub_segments.append({
                "scene_index": 0,
                "heading": f"{parent_seg['heading']} ({sc['heading']})",
                "start_char": base + pos,
                "end_char": parent_seg["end_char"],
                "length": 0,
            })

        for i in range(len(sub_segments) - 1):
            sub_segments[i]["end_char"] = sub_segments[i + 1]["start_char"]
            sub_segments[i]["length"] = sub_segments[i]["end_char"] - sub_segments[i]["start_char"]
        if sub_segments:
            sub_segments[-1]["length"] = sub_segments[-1]["end_char"] - sub_segments[-1]["start_char"]

        sub_segments = [s for s in sub_segments if s["length"] > 0]
        logger.info("Split scene '%s' (%d chars) → %d sub-scenes", parent_seg["heading"][:20], parent_seg["length"], len(sub_segments))
        return sub_segments if sub_segments else [parent_seg]

    except Exception as exc:
        logger.warning("Scene split failed for '%s': %s", parent_seg["heading"][:20], exc)
        return [parent_seg]


def _segment_by_heading(fulltext: str) -> List[Dict[str, Any]]:
    """INT./EXT. 헤딩 기반 시나리오 세그먼테이션 (기존 방식)."""
    pattern = re.compile(r"^\s*((?:INT|EXT|INT/EXT|EXT/INT)\..+)$", re.MULTILINE)
    matches = list(pattern.finditer(fulltext))
    if not matches:
        return []  # 빈 리스트 반환 — caller가 LLM fallback 결정

    segments = []
    for i, m in enumerate(matches):
        start = m.start()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(fulltext)
        segments.append({
            "scene_index": i + 1,
            "heading": m.group(1).strip(),
            "start_char": start,
            "end_char": end,
            "length": end - start,
        })

    logger.info("Regex segmentation: %d scenes found", len(segments))
    return segments


def _segment_by_llm(fulltext: str, max_chars: int = DEFAULT_SPLIT_THRESHOLD, project_config: Optional[Dict] = None) -> List[Dict[str, Any]]:
    """Gemini Lite AI 기반 씬/챕터 분할 — 소설, 일반 텍스트 대응."""
    from app.modules.llm.llm_client import call_structured

    logger.info("LLM segmentation: sending %d chars (max_chars=%d)", len(fulltext), max_chars)
    if len(fulltext) > 500_000:
        logger.warning("fulltext exceeds 500k chars — context limit may be hit")

    prompt = f"""다음 텍스트를 장면(씬) 단위로 분할하세요.

규칙:
- 시간, 장소, 또는 상황이 바뀌는 지점에서 분할
- 각 씬은 최대 {max_chars}자를 넘지 않도록 분할 ({max_chars}자 초과 시 논리적으로 나눠야 함)
- 최소 100자 이상이어야 함
- 시나리오라면 INT./EXT. 헤딩으로, 소설이라면 장면 전환 지점으로 분할
- 각 씬에 간결한 제목(heading)을 한국어로 붙이세요

텍스트:
{fulltext}

JSON 배열로 반환하세요. 각 항목:
- scene_index: 1부터 시작하는 번호
- heading: 씬 제목 (한국어, 간결하게)
- start_line_text: 이 씬이 시작하는 첫 줄의 텍스트 (원문에서 정확히 복사, 20자 이내)"""

    schema = {
        "type": "object",
        "properties": {
            "scenes": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "scene_index": {"type": "integer"},
                        "heading": {"type": "string"},
                        "start_line_text": {"type": "string"},
                    },
                    "required": ["scene_index", "heading", "start_line_text"],
                },
            },
        },
        "required": ["scenes"],
    }

    try:
        result = call_structured(
            step="scene_segmentation",
            system_prompt="텍스트 분할 전문가. 장면/씬 단위로 텍스트를 정확하게 분할한다.",
            user_prompt=prompt,
            response_schema=schema,
            project_config=project_config,
            schema_name="scene_segmentation",
        )

        scenes = result.get("scenes", [])
        if not scenes:
            logger.warning("Gemini Lite returned no scenes — returning empty for regex fallback")
            return []  # 빈 리스트 → caller가 정규식 fallback 시도

        # start_line_text로 실제 char position 매핑 (이전 씬 이후부터 검색)
        segments = []
        for i, scene in enumerate(scenes):
            start_text = scene.get("start_line_text", "").strip()
            # 이전 씬 위치 이후부터 검색 (중복 텍스트 방지)
            search_from = segments[-1]["start_char"] + 1 if segments else 0
            pos = fulltext.find(start_text, search_from) if start_text else -1
            if pos < 0 and i > 0:
                pos = segments[-1]["end_char"] if segments else 0
            elif pos < 0:
                pos = 0

            start_char = pos
            # end_char는 다음 씬의 start_char로 결정 (마지막은 텍스트 끝)
            segments.append({
                "scene_index": scene["scene_index"],
                "heading": scene["heading"],
                "start_char": start_char,
                "end_char": len(fulltext),  # 임시 — 아래에서 수정
                "length": 0,  # 임시
            })

        # end_char 계산
        for i in range(len(segments) - 1):
            segments[i]["end_char"] = segments[i + 1]["start_char"]
            segments[i]["length"] = segments[i]["end_char"] - segments[i]["start_char"]
        if segments:
            segments[-1]["length"] = segments[-1]["end_char"] - segments[-1]["start_char"]

        # 빈 세그먼트 제거
        segments = [s for s in segments if s["length"] > 0]

        # scene_index 재정렬
        for i, s in enumerate(segments):
            s["scene_index"] = i + 1

        logger.info("Gemini Lite segmentation: %d scenes", len(segments))
        return segments

    except Exception as exc:
        logger.warning("Gemini Lite segmentation failed: %s — returning empty for regex fallback", exc)
        return []  # 빈 리스트 → caller가 정규식 fallback 시도


def _single_scene_fallback(fulltext: str) -> List[Dict[str, Any]]:
    """전체 텍스트를 하나의 씬으로 처리 (최종 fallback)."""
    return [{
        "scene_index": 1,
        "heading": "(전체)",
        "start_char": 0,
        "end_char": len(fulltext),
        "length": len(fulltext),
    }]


# ── 2단계: 긴 씬 분할 ──

def _split_long_scenes(
    fulltext: str,
    segments: List[Dict[str, Any]],
    split_client=None,
    threshold: int = DEFAULT_SPLIT_THRESHOLD,
    project_config: Optional[Dict] = None,
) -> List[Dict[str, Any]]:
    """threshold 이상 긴 씬을 Gemini로 2분할."""
    from app.modules.llm.llm_client import call_structured

    long_scenes = [s for s in segments if s["length"] >= threshold]

    if not long_scenes:
        logger.info("No long scenes to split (threshold=%d)", threshold)
        return segments

    logger.info("Splitting %d long scenes (>=%d chars)", len(long_scenes), threshold)

    long_scene_texts = []
    for s in long_scenes:
        scene_text = s.get("text") or fulltext[s.get("start_char", 0):s.get("end_char", len(fulltext))]
        long_scene_texts.append(
            f"--- 씬 {s['scene_index']} ({s.get('heading', '')}, {s.get('length', 0)}자) ---\n{scene_text}"
        )
    long_scenes_block = "\n\n".join(long_scene_texts)

    split_prompt = _load_turn_prompt(
        "turn1_split_long",
        threshold=threshold,
        long_scenes_block=long_scenes_block,
    )

    split_result = None
    for retry in range(3):
        try:
            split_result = call_structured(
                step="scene_split",
                system_prompt="시나리오 분석 전문가. 긴 씬의 논리적 분할 지점을 정확히 찾는다.",
                user_prompt=split_prompt,
                response_schema=SPLIT_SUGGESTION_SCHEMA,
                project_config=project_config,
                schema_name="scene_split",
            )
            break
        except Exception as exc:
            logger.warning("Split request failed (attempt %d): %s", retry + 1, exc)
            if retry < 2:
                time.sleep(5 * (retry + 1))

    if not split_result:
        logger.warning("Split request all failed — keeping original segments")
        return segments

    split_map: Dict[int, str] = {}
    for suggestion in split_result.get("split_suggestions", []):
        if suggestion.get("split"):
            idx = suggestion["original_scene_index"]
            split_text = suggestion.get("split_after_line", "")
            if split_text:
                split_map[idx] = split_text

    final_scenes: List[Dict[str, Any]] = []
    new_idx = 1

    for seg in segments:
        scene_text = seg.get("text") or fulltext[seg.get("start_char", 0):seg.get("end_char", len(fulltext))]

        if seg["scene_index"] in split_map:
            split_text = split_map[seg["scene_index"]]
            if split_text in scene_text:
                split_pos = scene_text.index(split_text) + len(split_text)
                while split_pos < len(scene_text) and scene_text[split_pos] in "\n\r":
                    split_pos += 1

                part1_len = split_pos
                part2_len = len(scene_text) - split_pos

                if part1_len >= 50 and part2_len >= 50:
                    final_scenes.append({
                        "scene_index": new_idx,
                        "heading": seg["heading"],
                        "start_char": seg["start_char"],
                        "end_char": seg["start_char"] + split_pos,
                        "length": part1_len,
                    })
                    new_idx += 1
                    final_scenes.append({
                        "scene_index": new_idx,
                        "heading": seg["heading"] + " (계속)",
                        "start_char": seg["start_char"] + split_pos,
                        "end_char": seg["end_char"],
                        "length": part2_len,
                    })
                    new_idx += 1
                    logger.info("Scene %d split at %d/%d chars", seg["scene_index"], part1_len, seg["length"])
                    continue
                else:
                    logger.warning("Scene %d: split too uneven (%d/%d), keeping original",
                                   seg["scene_index"], part1_len, part2_len)
            else:
                logger.warning("Scene %d: split text not found in original, keeping original",
                               seg["scene_index"])

        final_scenes.append({
            "scene_index": new_idx,
            "heading": seg["heading"],
            "start_char": seg["start_char"],
            "end_char": seg["end_char"],
            "length": seg["length"],
        })
        new_idx += 1

    logger.info("After split: %d scenes (was %d)", len(final_scenes), len(segments))
    return final_scenes


# ── 체크포인트 유틸 ──

def _checkpoint_path(checkpoint_dir: str, fulltext: str) -> Path:
    """체크포인트 파일 경로 생성 (fulltext MD5 해시 기반)."""
    episode_hash = hashlib.md5(fulltext.encode("utf-8")).hexdigest()[:8]
    return Path(checkpoint_dir) / f"scene_extraction_{episode_hash}.json"


def _save_checkpoint(
    cp_path: Path,
    completed_scenes: List[Dict[str, Any]],
    scene_list_brief: List[Dict[str, Any]],
    all_outlooks: List[Dict[str, Any]],
    outlook_names_so_far: List[str],
) -> None:
    """체크포인트 저장."""
    data = {
        "completed_scenes": completed_scenes,
        "scene_list_brief": scene_list_brief,
        "all_outlooks": all_outlooks,
        "outlook_names_so_far": outlook_names_so_far,
    }
    os.makedirs(cp_path.parent, exist_ok=True)
    cp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")


def _load_checkpoint(cp_path: Path) -> Optional[Dict[str, Any]]:
    """체크포인트 로드. 없거나 파싱 실패 시 None."""
    if not cp_path.exists():
        return None
    try:
        data = json.loads(cp_path.read_text(encoding="utf-8"))
        if "completed_scenes" in data and "outlook_names_so_far" in data:
            return data
    except Exception as exc:
        logger.warning("Checkpoint load failed, starting fresh: %s", exc)
    return None


def _delete_checkpoint(cp_path: Path) -> None:
    """체크포인트 삭제 (정상 완료 시)."""
    try:
        if cp_path.exists():
            cp_path.unlink()
            logger.info("Checkpoint deleted: %s", cp_path)
    except Exception as exc:
        logger.warning("Checkpoint delete failed: %s", exc)


# ── 메인 함수 ──

def extract_scenes_multiturn(
    fulltext: str,
    entities: Dict[str, Any],
    style: Dict[str, Any] = None,
    on_scene_progress: Optional[Callable] = None,
    split_threshold: int = DEFAULT_SPLIT_THRESHOLD,
    existing_outlooks: Optional[List[Dict[str, Any]]] = None,
    checkpoint_dir: Optional[str] = None,
    outlook_assignments: Optional[Dict[int, List[Dict[str, str]]]] = None,
    scene_dependencies: Optional[Dict[int, Dict[str, Any]]] = None,
    pre_segments: Optional[List[Dict[str, Any]]] = None,
    scene_llm: str = "gemini",  # "gemini" or "gpt"
    project_llm_config: Optional[Dict[str, Any]] = None,
    visual_world_rules: Optional[List[str]] = None,
    scene_shot_map: Optional[Dict[int, Dict]] = None,
    scene_present_entities_map: Optional[Dict[int, Dict]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """정규식 세그먼테이션 + 씬 상세 분석 (아웃룩 사전 확정 시 병렬).

    Args:
        fulltext: 시나리오 전문
        entities: entity_extractor_v2의 결과 (characters, locations, props)
        style: Turn 0에서 생성된 스타일 결과
        on_scene_progress: 콜백 (scene_index, total)
        split_threshold: 이 글자수 이상 씬을 분할 (기본 600)
        existing_outlooks: (v3 호환) 이전 아웃룩 목록
        checkpoint_dir: 체크포인트 저장 디렉토리
        outlook_assignments: (v4) 씬별 캐릭터-아웃룩 매핑
            {scene_index: [{character_name, outlook_name}]}
        pre_segments: 이미 분할된 segments (있으면 재분할 건너뜀)

    Returns:
        {"scene_list": [...], "scenes": [...], "total_scenes": int}
    """
    variation_count = settings.scene_variation_count

    if pre_segments:
        segments = pre_segments
    else:
        # Gemini Lite 2단계 분할 (1차 분할 + 큰 씬 재분할)
        segments = _segment_scenes(fulltext, split_threshold=split_threshold, project_config=project_llm_config)
    total_scenes = len(segments)

    # ── 요소/스타일 텍스트 준비 ──
    entity_summary_lines = []
    for c in entities.get("characters", []):
        entity_summary_lines.append(f"인물: {c['name']} — {c.get('description', '')}")
    for loc in entities.get("locations", []):
        entity_summary_lines.append(f"배경: {loc['name']} — {loc.get('description', '')}")
    for p in entities.get("props", []):
        entity_summary_lines.append(f"물체: {p['name']} — {p.get('description', '')}")
    entity_summary = "\n".join(entity_summary_lines)

    style_summary = ""
    if style:
        style_summary = ", ".join(f"{k}: {v}" for k, v in style.items() if v and k != "summary")

    # ── 체크포인트 ──
    cp_path: Optional[Path] = None
    checkpoint_data: Optional[Dict[str, Any]] = None
    completed_indices: set = set()

    if checkpoint_dir:
        cp_path = _checkpoint_path(checkpoint_dir, fulltext)
        checkpoint_data = _load_checkpoint(cp_path)

    if checkpoint_data:
        scenes_detail_resumed = checkpoint_data["completed_scenes"]
        scene_list_brief_resumed = checkpoint_data.get("scene_list_brief", [])
        completed_indices = {s["scene_index"] for s in scenes_detail_resumed}
        logger.info("Resuming: %d/%d scenes completed", len(completed_indices), total_scenes)
    else:
        scenes_detail_resumed = []
        scene_list_brief_resumed = []

    # 스키마 로드 — visible_entities는 코드에서 자동 구축하므로 enum 불필요
    scene_detail_schema = _get_scene_detail_schema()

    system_prompt = _load_system_prompt()
    if visual_world_rules:
        rules_text = "\n".join(f"- {r}" for r in visual_world_rules)
        system_prompt += f"\n\n[시각적 세계관 규칙 — 인물 물리적 존재 판단 시 반드시 참고]\n{rules_text}"

    # ── v4: 병렬 처리 (큐 구조) — 아웃룩 없어도 병렬 ──
    if outlook_assignments is None:
        outlook_assignments = {}
    # 아웃룩 미배정 씬은 빈 리스트로 채움
    for seg in segments:
        if seg["scene_index"] not in outlook_assignments:
            outlook_assignments[seg["scene_index"]] = []
    if True:  # 항상 병렬 모드
        logger.info("Scene detail: PARALLEL mode (%d scenes, outlooks pre-assigned)", total_scenes)
        from concurrent.futures import ThreadPoolExecutor, as_completed
        from queue import Queue

        # 작업 큐 구성
        task_queue: Queue = Queue()
        for seg in segments:
            si = seg["scene_index"]
            if si in completed_indices:
                continue
            task_queue.put(seg)

        scenes_detail: List[Dict[str, Any]] = list(scenes_detail_resumed)
        scene_list_brief: List[Dict[str, Any]] = list(scene_list_brief_resumed)
        completed_count = len(completed_indices)

        # 연관 씬 텍스트 미리 준비
        _deps = scene_dependencies or {}
        _seg_map = {s["scene_index"]: s for s in segments}

        def _get_ref_text(ref_idx: int) -> str:
            """연관 씬 참조 — 헤딩 + 씬 텍스트 요약(앞 300자)."""
            if ref_idx < 0 or ref_idx not in _seg_map:
                return "없음"
            seg_ref = _seg_map[ref_idx]
            ref_text = seg_ref.get("text") or fulltext[seg_ref.get("start_char", 0):seg_ref.get("end_char", len(fulltext))]
            excerpt = ref_text
            return f"씬 {ref_idx}: {seg_ref['heading']}\n{excerpt}"

        def _process_scene(seg: Dict[str, Any]) -> Dict[str, Any]:
            """독립 Gemini 세션으로 씬 1개 분석 (스레드 안전)."""
            si = seg["scene_index"]
            scene_text = seg.get("text") or fulltext[seg.get("start_char", 0):seg.get("end_char", len(fulltext))]
            heading = seg.get("heading", "")

            # 이 씬의 캐릭터-아웃룩 매핑 — short_id 사용
            scene_chars = outlook_assignments.get(si, [])
            # short_id 매핑 구성
            _char_short = {c.get("name", ""): c.get("short_id", "") for c in entities.get("characters", [])}
            # outlook short_id — entities에 outlook이 있으면 사용 (없으면 빈 dict)
            _ol_short = {}
            for ol in entities.get("outlooks", []):
                if ol.get("name") and ol.get("short_id"):
                    _ol_short[ol["name"]] = ol["short_id"]
            mapping_lines = []
            for c in scene_chars:
                cname = c.get('character_name') or c.get('character_id', '')
                oname = c['outlook_name']
                csid = _char_short.get(cname, "")
                osid = _ol_short.get(oname, "")
                if csid and osid:
                    mapping_lines.append(f"- {csid}{osid} ({cname}+{oname})")
                elif csid:
                    mapping_lines.append(f"- [[{cname}]+[{oname}]]")  # outlook short_id 없으면 기존 형태
                else:
                    mapping_lines.append(f"- [[{cname}]+[{oname}]]")
            mapping_text = "\n".join(mapping_lines) if mapping_lines else "없음"

            # 연관 씬 정보
            dep = _deps.get(si, {})
            prev_ref_text = _get_ref_text(dep.get("prev_ref", -1))
            next_ref_text = _get_ref_text(dep.get("next_ref", -1))

            # 해당 씬 요소만 필터 — 전체 요소 전달 금지
            scene_char_names = {c.get("character_name") or c.get("character_id", "") for c in scene_chars}
            scene_entity_ids = scene_present_entities_map.get(si) if scene_present_entities_map else None
            scene_entity_block = _build_scene_entity_block(entities, scene_char_names, scene_text,
                                                           scene_present_entity_ids=scene_entity_ids)

            # 촬영 기법 지시 (scene_cinematography 결과)
            cinematography_block = ""
            if scene_shot_map and si in scene_shot_map:
                shots = scene_shot_map[si]
                s1 = shots.get("shot_1", {})
                s2 = shots.get("shot_2", {})
                cinematography_block = (
                    f"\n## 촬영 기법 지시 (촬영 감독 결정)\n"
                    f"변형 1: {s1.get('name', '')} — {s1.get('description', '')} (초점: {s1.get('focus', '')})\n"
                    f"변형 2: {s2.get('name', '')} — {s2.get('description', '')} (초점: {s2.get('focus', '')})"
                )

            turn_msg = _load_turn_prompt(
                "turn_scene_detail",
                scene_index=si,
                heading=heading,
                scene_text=scene_text,
                entity_names_block=scene_entity_block,
                scene_outlook_mapping=mapping_text,
                prev_ref_text=prev_ref_text,
                next_ref_text=next_ref_text,
                variation_count=variation_count,
            )
            if cinematography_block:
                # 기존 카메라 자유선택 지시를 촬영 감독 지시로 대체
                import re as _re_cine
                # "카메라 구도 선택지:" 이하 색감 선택지까지 제거
                turn_msg = _re_cine.sub(
                    r'카메라 구도 선택지:.*?주의:',
                    '주의:',
                    turn_msg,
                    flags=_re_cine.DOTALL,
                )
                turn_msg += cinematography_block

            detail = None
            for retry in range(3):
                try:
                    from app.modules.llm.llm_client import call_structured
                    detail = call_structured(
                        step="scene_detail",
                        system_prompt=system_prompt,
                        user_prompt=turn_msg,
                        response_schema=scene_detail_schema,
                        project_config=project_llm_config,
                        schema_name="scene_detail",
                        opik_metadata=dict(opik_metadata) if opik_metadata else None,
                    )
                    break
                except Exception as exc:
                    logger.warning("Scene %d failed (attempt %d): %s", si, retry + 1, exc)
                    if retry < 2:
                        time.sleep(5 * (retry + 1))

            if detail:
                detail["scene_index"] = si

                # visible_entities는 확정 데이터에서 자동 구축 (LLM 반환값 무시)
                # 인물+아웃룩: scene_assignments (outlook_extraction에서 확정)
                # 배경+소품: scene_director (scene_present_entities_map에서 확정)
                built_ve = []
                seen_ve = set()

                # 1) 인물 — scene_assignments에서 (확정)
                for c in scene_chars:
                    cname = c.get('character_name') or c.get('character_id', '')
                    oname = c.get('outlook_name', '')
                    _char_sid = _char_short.get(cname, '')
                    _out_sid = _ol_short.get(oname, '')
                    ve_entry = {"entity_type": "character"}
                    if _char_sid:
                        ve_entry["short_id"] = _char_sid
                        if _out_sid:
                            ve_entry["outlook_short_id"] = _out_sid
                    ve_entry["entity_name"] = cname
                    if _char_sid not in seen_ve and cname not in seen_ve:
                        built_ve.append(ve_entry)
                        seen_ve.add(_char_sid or cname)

                # 2) 배경+소품 — scene_director에서 (확정)
                if scene_entity_ids:
                    for loc_id in scene_entity_ids.get("locations", []):
                        loc = next((l for l in entities.get("locations", []) if l.get("id") == loc_id), None)
                        if loc:
                            sid = loc.get("short_id", "")
                            if sid not in seen_ve:
                                built_ve.append({"short_id": sid, "entity_type": "location", "entity_name": loc["name"]})
                                seen_ve.add(sid)
                    for prop_id in scene_entity_ids.get("props", []):
                        prop = next((p for p in entities.get("props", []) if p.get("id") == prop_id), None)
                        if prop:
                            sid = prop.get("short_id", "")
                            if sid not in seen_ve:
                                built_ve.append({"short_id": sid, "entity_type": "prop", "entity_name": prop["name"]})
                                seen_ve.add(sid)

                detail["visible_entities"] = built_ve

                # ── 아웃룩 배정 인물이 T2I에 누락됐으면 강제 추가 ──
                if scene_chars:
                    variations = detail.get("t2i_variations", [])
                    for var in variations:
                        current_t2i = var.get("t2i_prompt", "")
                        added = []
                        for sc in scene_chars:
                            char_name = sc["character_name"]
                            outlook_name = sc["outlook_name"]
                            marker = f"[[{char_name}]+[{outlook_name}]]"
                            if marker not in current_t2i and f"[[{char_name}]+[" not in current_t2i:
                                added.append(marker)
                        if added:
                            # 누락된 인물 모두 한 번에 추가
                            suffix = ", ".join(added) + " visible in the background."
                            var["t2i_prompt"] = current_t2i.rstrip() + " " + suffix
                            logger.info("Scene %d: force-added %d missing characters to T2I: %s", si, len(added), added)

                # v4→v2 호환
                variations = detail.get("t2i_variations", [])
                if variations and not detail.get("t2i_prompt"):
                    detail["t2i_prompt"] = variations[0].get("t2i_prompt", "")
            else:
                detail = {
                    "scene_index": si, "heading": heading,
                    "beat_title": "", "representative_moment": "",
                    "t2i_prompt": "", "t2i_variations": [],
                    "visible_entities": [],
                    "dependent_scene_index": -1, "dependency_reason": "",
                }
            return detail

        # 병렬 실행 (큐에서 가져와서 처리)
        max_workers = min(settings.max_concurrent_entity_detail, task_queue.qsize() or 1)
        logger.info("Launching %d parallel scene workers (%d tasks)", max_workers, task_queue.qsize())

        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = {}
            # 큐에서 작업을 꺼내서 submit (2초 stagger)
            tasks_submitted = 0
            while not task_queue.empty():
                seg = task_queue.get()
                if tasks_submitted > 0:
                    time.sleep(2)
                futures[executor.submit(_process_scene, seg)] = seg
                tasks_submitted += 1

            for future in as_completed(futures):
                seg = futures[future]
                si = seg["scene_index"]
                try:
                    detail = future.result()
                    scenes_detail.append(detail)
                    scene_list_brief.append({
                        "scene_index": si, "heading": seg["heading"],
                        "summary": "", "start_char": seg["start_char"], "end_char": seg["end_char"],
                    })
                    completed_count += 1
                    if on_scene_progress:
                        on_scene_progress(completed_count, total_scenes)
                    # 체크포인트
                    if cp_path:
                        _save_checkpoint(cp_path, scenes_detail, scene_list_brief, [], [])
                except Exception as exc:
                    logger.error("Scene %d worker failed: %s", si, exc)

        # 정렬 (scene_index 순)
        scenes_detail.sort(key=lambda s: s.get("scene_index", 0))
        scene_list_brief.sort(key=lambda s: s.get("scene_index", 0))

        # 교차 검증은 SceneVerifyStep에서 독립 실행 (중복 방지)

        if cp_path:
            _delete_checkpoint(cp_path)

        return {
            "scene_list": scene_list_brief,
            "scenes": scenes_detail,
            "total_scenes": total_scenes,
        }

    # v3 순차 경로는 v4 병렬 모드로 완전 대체됨 — 데드코드 제거됨


def _build_scene_entity_block(entities: Dict[str, Any], scene_char_names: set, scene_text: str,
                               scene_present_entity_ids: Optional[Dict] = None) -> str:
    """해당 씬에 등장하는 요소만 필터하여 텍스트 블록 생성.

    - 인물: scene_assignments에서 결정된 캐릭터만
    - 배경/소품: scene_director ID 기반 (없으면 전체)
    """
    lines = []

    # 인물 — scene_assignments 기준 (short_id 표시)
    scene_chars = [c for c in entities.get("characters", []) if c["name"] in scene_char_names]
    lines.append("이 씬의 인물 (아래 목록만 사용. 추가 금지!):")
    if scene_chars:
        for c in scene_chars:
            sid = c.get("short_id", "")
            if sid:
                lines.append(f"  - {sid} ({c['name']})")
            else:
                lines.append(f"  - [[{c['name']}]+[아웃룩이름]]")
    else:
        lines.append("  (인물 없음)")

    # 배경 — scene_director ID 기반
    if scene_present_entity_ids:
        loc_ids = set(scene_present_entity_ids.get("locations", []))
        prop_ids = set(scene_present_entity_ids.get("props", []))
        scene_locs = [loc for loc in entities.get("locations", []) if loc.get("id") in loc_ids]
        scene_props = [p for p in entities.get("props", []) if p.get("id") in prop_ids]
    else:
        logger.warning("_build_scene_entity_block: no scene_director IDs, falling back to all locations/props")
        scene_locs = entities.get("locations", [])
        scene_props = entities.get("props", [])

    lines.append("이 씬의 배경 (아래 목록만 사용. 추가 금지!):")
    if scene_locs:
        for loc in scene_locs:
            sid = loc.get("short_id", "")
            desc = loc.get("description", "")
            if sid:
                lines.append(f"  - {sid} ({loc['name']}: {desc})")
            else:
                lines.append(f"  - [{loc['name']}: {desc}]")
    else:
        lines.append("  (배경 없음 — 묘사로 대체)")

    lines.append("이 씬의 물체 (아래 목록만 사용. 추가 금지!):")
    if scene_props:
        for p in scene_props:
            sid = p.get("short_id", "")
            if sid:
                lines.append(f"  - {sid} ({p['name']})")
            else:
                lines.append(f"  - [[{p['name']}]]")
    else:
        lines.append("  (물체 없음)")

    lines.append("")
    lines.append("중요: 위 목록에 없는 인물, 배경, 물체를 T2I 프롬프트와 visible_entities에 절대 추가하지 마세요.")
    lines.append("visible_entities에도 위 목록의 short_id만 포함하세요.")

    return "\n".join(lines)


