"""씬 추출 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.llm.gemini_text_client import GeminiTextClient

PROMPT_DIR = (
    Path(__file__).resolve().parent.parent.parent.parent.parent
    / "prompts" / "_base" / "scene_extractor_v2"
)

DEFAULT_SPLIT_THRESHOLD = 600  # 기본 분할 임계값

logger = logging.getLogger(__name__)


# ── 프롬프트 로드 ──

def _get_latest_version_dir() -> Path:
    """최신 프롬프트 버전 디렉토리."""
    versions = sorted([d.name for d in PROMPT_DIR.iterdir() if d.is_dir()], reverse=True)
    if not versions:
        raise FileNotFoundError(f"No prompt versions in {PROMPT_DIR}")
    return PROMPT_DIR / versions[0]


def _load_system_prompt() -> str:
    return (_get_latest_version_dir() / "system.md").read_text(encoding="utf-8").strip()


def _load_turn_prompt(turn_name: str, **kwargs) -> str:
    text = (_get_latest_version_dir() / f"{turn_name}.md").read_text(encoding="utf-8").strip()
    return text.format(**kwargs) if kwargs else text


def _load_schema(schema_name: str) -> Dict[str, Any]:
    """최신 버전에서 JSON 스키마 로드. 없으면 None."""
    path = _get_latest_version_dir() / schema_name
    if path.exists():
        return json.loads(path.read_text(encoding="utf-8"))
    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",
                ],
            },
        },
    },
    "required": ["split_suggestions"],
}


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) -> 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)

    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 = fulltext[seg["start_char"]:seg["end_char"]]
            sub_segments = _split_large_scene_by_llm(scene_text, seg, split_threshold)
            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) -> List[Dict[str, Any]]:
    """큰 씬을 Gemini Flash로 재분할 (자연스러운 분할점이 없으면 1개 유지)."""
    expected = max(2, len(scene_text) // max_chars)
    client = GeminiTextClient(model=settings.gemini_flash_model)

    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 = client.send_structured(
            user_message=prompt,
            response_schema=schema,
            system_instruction=f"텍스트를 약 {max_chars}자 간격으로 분할.",
        )
        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'][:40]} ({sc['heading'][:30]})",
                "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) -> List[Dict[str, Any]]:
    """Gemini Lite AI 기반 씬/챕터 분할 — 소설, 일반 텍스트 대응."""
    logger.info("Gemini Lite segmentation: sending %d chars to %s (max_chars=%d)", len(fulltext), settings.gemini_lite_model, max_chars)
    if len(fulltext) > 500_000:
        logger.warning("fulltext exceeds 500k chars — Gemini Lite context limit may be hit")
    client = GeminiTextClient(model=settings.gemini_lite_model)

    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 = client.send_structured(
            user_message=prompt,
            response_schema=schema,
            system_instruction="텍스트 분할 전문가. 장면/씬 단위로 텍스트를 정확하게 분할한다.",
        )

        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: GeminiTextClient,
    threshold: int = DEFAULT_SPLIT_THRESHOLD,
) -> List[Dict[str, Any]]:
    """threshold 이상 긴 씬을 Gemini로 2분할."""
    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 = fulltext[s["start_char"]:s["end_char"]]
        long_scene_texts.append(
            f"--- 씬 {s['scene_index']} ({s['heading']}, {s['length']}자) ---\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 = split_client.send_structured(
                user_message=split_prompt,
                response_schema=SPLIT_SUGGESTION_SCHEMA,
                system_instruction="시나리오 분석 전문가. 긴 씬의 논리적 분할 지점을 정확히 찾는다.",
            )
            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 = fulltext[seg["start_char"]:seg["end_char"]]

        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,
) -> 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)
    total_scenes = len(segments)

    # ── 요소/스타일 텍스트 준비 ──
    entity_names_block = _build_entity_names_block(entities)

    entity_summary_lines = []
    for c in entities.get("characters", []):
        entity_summary_lines.append(f"인물: {c['name']} — {c.get('description', '')[:150]}")
    for loc in entities.get("locations", []):
        entity_summary_lines.append(f"배경: {loc['name']} — {loc.get('description', '')[:150]}")
    for p in entities.get("props", []):
        entity_summary_lines.append(f"물체: {p['name']} — {p.get('description', '')[:150]}")
    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 = []

    # 스키마 로드
    scene_detail_schema = _get_scene_detail_schema()
    system_prompt = _load_system_prompt()

    # ── 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:
            """연관 씬 참조 — 헤딩만 전달 (텍스트 전달 시 LLM이 내용을 현재 씬에 혼입)."""
            if ref_idx < 0 or ref_idx not in _seg_map:
                return "없음"
            seg_ref = _seg_map[ref_idx]
            return f"씬 {ref_idx}: {seg_ref['heading']}"

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

            # 이 씬의 캐릭터-아웃룩 매핑
            scene_chars = outlook_assignments.get(si, [])
            mapping_text = "\n".join(
                f"- [[{c['character_name']}]+[{c['outlook_name']}]]"
                for c in scene_chars
            ) if scene_chars 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))

            turn_msg = _load_turn_prompt(
                "turn_scene_detail",
                scene_index=si,
                heading=heading,
                scene_text=scene_text,
                entity_names_block=entity_names_block,
                scene_outlook_mapping=mapping_text,
                prev_ref_text=prev_ref_text,
                next_ref_text=next_ref_text,
                variation_count=variation_count,
            )

            detail = None
            for retry in range(3):
                try:
                    from app.modules.llm.llm_router import call_structured
                    detail = call_structured(
                        step="scene_detail",
                        system_prompt=system_prompt,
                        user_prompt=turn_msg,
                        response_schema=scene_detail_schema,
                        project_llm_config=project_llm_config,
                        schema_name="scene_detail",
                    )
                    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
                # Validate: V.O. 전용 캐릭터 필터 — 아웃룩 매핑에 없는 캐릭터는 경고만
                if scene_chars:
                    valid_char_names = {c['character_name'] for c in scene_chars}
                    for ve in detail.get("visible_entities", []):
                        if ve.get("entity_type") == "character" and ve.get("entity_name") not in valid_char_names:
                            logger.warning("Scene %d: %s is in visible_entities but not in outlook assignments (may be V.O. only)",
                                          si, ve.get("entity_name"))
                # 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))

        if cp_path:
            _delete_checkpoint(cp_path)

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

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


def _build_entity_names_block(entities: Dict[str, Any]) -> str:
    """요소 목록을 텍스트 블록으로 정리 (v3: [[인물]+[아웃룩]] 형식 안내)."""
    lines = []

    lines.append("인물 (프롬프트에서 반드시 [[이름]+[아웃룩이름]] 형식. 아웃룩 불명이면 [[이름]+[미지정]]):")
    for c in entities.get("characters", []):
        lines.append(f"  - {c['name']} (예: [[{c['name']}]+[아웃룩이름]] 또는 [[{c['name']}]+[미지정]])")

    lines.append("배경 (프롬프트에서 [이름: 시각적 설명] 사용 — 상세 설명 필수):")
    for loc in entities.get("locations", []):
        desc = loc.get("description", "")[:60]
        lines.append(f"  - [{loc['name']}: {desc}]")

    lines.append("물체 (프롬프트에서 [[이름]] 사용):")
    for p in entities.get("props", []):
        lines.append(f"  - [[{p['name']}]]")

    return "\n".join(lines)
