"""내보내기 서비스 — 웹북 생성 및 PDF 렌더링 파이프라인."""

import json
import logging
import re
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set

from sqlalchemy.orm import Session as OrmSession

from app.core.config import settings
from app.core.file_paths import resolve_image_path

logger = logging.getLogger(__name__)
from app.core.errors import AppError
from app.i18n.loader import t
from app.logging.activity_logger import ActivityLogger
from app.models.project import (
    Episode,
    EntityCanon,
    EntityEpisodeLink,
    ImageAsset,
    SceneStill,
    WebbookPackage,
    WorldGuide,
)

import html as html_module

from app.modules.pdf_renderer import PDFRenderer
from app.modules.pdf_validator import PDFValidator
from app.modules.progress_tracker import ProgressTracker
from app.modules.provenance import ProvenanceRecorder
from app.modules.webbook_generator import WebbookGenerator


def _now() -> str:
    return datetime.now(timezone.utc).isoformat()


def _new_id() -> str:
    return str(uuid.uuid4())


# C/L/P 단일 short_id 또는 C##O## 복합 ID 만 허용 (filter 와 일치).
# 옛 패턴은 O\d{2,3} 도 통과시켰으나 _visible_short_ids_from_json 의
# startswith(("C","L","P")) filter 가 폐기 → silent drop 발생. regex 를
# filter 에 맞춰 좁힘 (Codex B2).
_SHORT_ID_RE = re.compile(r"^(?:[CLP]\d{2,3}|C\d{2,3}O\d{2,3})$")


def _safe_json_load(value: Any, default: Any) -> Any:
    """Load JSON-like DB fields while tolerating already-decoded current shapes."""
    if value is None or value == "":
        return default
    if isinstance(value, (list, dict)):
        return value
    if not isinstance(value, str):
        return default
    try:
        return json.loads(value)
    except (TypeError, json.JSONDecodeError):
        return default


def _visible_short_ids_from_json(value: Any) -> Set[str]:
    """Extract C/L/P short ids from current and legacy visible_entities_json shapes."""
    raw = _safe_json_load(value, [])
    if isinstance(raw, dict):
        raw = (
            raw.get("visible_entities")
            or raw.get("entities")
            or raw.get("ids")
            or []
        )
    if not isinstance(raw, list):
        return set()

    out: Set[str] = set()
    for item in raw:
        sid = ""
        if isinstance(item, str):
            sid = item.strip()
        elif isinstance(item, dict):
            for key in ("short_id", "id", "entity_id"):
                candidate = item.get(key)
                if isinstance(candidate, str) and _SHORT_ID_RE.fullmatch(candidate.strip()):
                    sid = candidate.strip()
                    break
        if not sid or not _SHORT_ID_RE.fullmatch(sid):
            continue
        if "O" in sid and sid.startswith("C"):
            sid = sid.split("O", 1)[0]
        if sid.startswith(("C", "L", "P")):
            out.add(sid)
    return out


def _t2i_text_from_variations_json(value: Any, fallback: str = "") -> str:
    """Extract prompt text from current rich variation arrays and older prompt shapes."""
    raw = _safe_json_load(value, [])
    if isinstance(raw, dict):
        raw = raw.get("t2i_variations") or raw.get("variations") or [raw]
    texts: List[str] = []
    if isinstance(raw, list):
        for item in raw:
            if isinstance(item, str):
                text = item
            elif isinstance(item, dict):
                text = item.get("t2i_prompt") or item.get("prompt") or ""
            else:
                continue
            if isinstance(text, str) and text.strip():
                texts.append(text.strip())
    return " ".join(texts) or fallback or ""


def _existing_asset_path(file_path: Any) -> Optional[Path]:
    """Resolve ImageAsset.file_path for both absolute ORM reads and legacy relative rows."""
    if not file_path:
        return None
    p = resolve_image_path(str(file_path))
    if p and p.exists():
        return p
    raw = Path(str(file_path))
    return raw if raw.exists() else None


class ExportService:
    """웹북 생성 및 PDF 렌더링 서비스."""

    def __init__(
        self,
        db: OrmSession,
        project_id: str,
        actor_id: str,
    ) -> None:
        self._db = db
        self._logger = ActivityLogger(db)
        self._project_id = project_id
        self._actor_id = actor_id

    def _get_episode(self, episode_id: str) -> Episode:
        ep = (
            self._db.query(Episode)
            .filter(Episode.id == episode_id, Episode.project_id == self._project_id)
            .first()
        )
        if not ep:
            raise AppError(
                code="episode.not_found",
                message=t("episode.not_found"),
                status_code=404,
            )
        return ep

    def _get_project_dir(self) -> Path:
        return Path(settings.projects_dir) / self._project_id

    def _get_exports_dir(self) -> Path:
        d = self._get_project_dir() / "assets" / "exports"
        d.mkdir(parents=True, exist_ok=True)
        return d

    def generate_webbook(
        self,
        episode_id: str,
        web_episode_count: int = 4,
        sections_per_episode: int = 10,
        ip: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Generate webbook package for an episode."""
        episode = self._get_episode(episode_id)

        if not episode.fulltext:
            raise AppError(
                code="analysis.no_text",
                message=t("analysis.no_text"),
                status_code=400,
            )

        from app.core.openai_keys import has_openai_key
        if not has_openai_key():
            raise AppError(
                code="analysis.openai_key_missing",
                message=t("analysis.openai_key_missing"),
                status_code=400,
            )

        language = episode.language or "ko"

        # Get entities
        links = (
            self._db.query(EntityEpisodeLink)
            .filter(
                EntityEpisodeLink.project_id == self._project_id,
                EntityEpisodeLink.episode_id == episode_id,
            )
            .all()
        )
        canon_ids = [link.canon_id for link in links]
        entities_orm = (
            self._db.query(EntityCanon)
            .filter(EntityCanon.id.in_(canon_ids))
            .all()
            if canon_ids
            else []
        )
        entities = [
            {
                "id": e.id,
                "name": e.name,
                "entity_type": e.entity_type,
                "description": e.description or "",
                "stable_traits": e.stable_traits or "{}",
            }
            for e in entities_orm
        ]

        # Get scene stills — stale 제외 + NULL status 보존 (Codex iter 2 I1).
        # SQL 의 `status != 'stale'` 는 NULL 행 제외 — _build_episode_html 의
        # SceneStill query 와 일관되게 NULL 도 포함.
        from sqlalchemy import or_
        stills_orm = (
            self._db.query(SceneStill)
            .filter(
                SceneStill.project_id == self._project_id,
                SceneStill.episode_id == episode_id,
                or_(SceneStill.status.is_(None), SceneStill.status != "stale"),
            )
            .order_by(SceneStill.still_index)
            .all()
        )
        stills = [
            {
                "id": s.id,
                "still_index": s.still_index,
                "screenplay_scene_heading": s.screenplay_scene_heading or "",
                "beat_title": s.beat_title or "",
                "still_frame_prompt": s.still_frame_prompt or "",
            }
            for s in stills_orm
        ]

        # Get world guide
        wg_record = (
            self._db.query(WorldGuide)
            .filter(WorldGuide.project_id == self._project_id, WorldGuide.episode_id == episode_id)
            .order_by(WorldGuide.created_at.desc())
            .first()
        )
        world_guide = (
            json.loads(wg_record.guide_json) if wg_record else {}
        )

        # Generate webbook package
        provenance = ProvenanceRecorder(self._db, self._project_id)
        progress = ProgressTracker(self._db, episode_id, "webbook", self._project_id)
        progress.update("웹북 텍스트 생성 중", 0, 1)

        # 프로젝트별 LLM 설정 로드
        from app.models.project import ProjectSettings
        _ps = self._db.query(ProjectSettings).filter(ProjectSettings.project_id == self._project_id).first()
        _llm_config = None
        if _ps and _ps.llm_config_json:
            try:
                _llm_config = json.loads(_ps.llm_config_json)
            except Exception as exc:
                logger.warning("llm_config_json parse failed for project %s: %s — 기본 설정으로 진행", self._project_id, exc)
        generator = WebbookGenerator(project_llm_config=_llm_config)
        try:
            with provenance.start_operation(
                "webbook_generation", "webbook_generator", episode_id=episode_id,
            ) as op:
                op.set_input({
                    "fulltext_chars": len(episode.fulltext),
                    "entities": len(entities),
                    "stills": len(stills),
                    "web_episode_count": web_episode_count,
                    "sections_per_episode": sections_per_episode,
                })
                package = generator.generate(
                    fulltext=episode.fulltext,
                    entities=entities,
                    stills=stills,
                    world_guide=world_guide,
                    language=language,
                    web_episode_count=web_episode_count,
                    sections_per_episode=sections_per_episode,
                )
                ep_count = len(package.get("episodes", []))
                op.set_output({"episodes_generated": ep_count})

            # Save to DB
            wb_record = WebbookPackage(
                id=_new_id(),
                project_id=self._project_id,
                episode_id=episode_id,
                package_json=json.dumps(package, ensure_ascii=False),
                prompt_version="v5",
                created_at=_now(),
            )
            self._db.add(wb_record)
            self._db.commit()

            progress.complete()
        except Exception as exc:
            progress.fail(str(exc))
            raise

        self._logger.log(
            actor_id=self._actor_id,
            action="export.generate_webbook",
            resource_type="episode",
            resource_id=episode_id,
            project_id=self._project_id,
            detail={
                "web_episode_count": web_episode_count,
                "sections_per_episode": sections_per_episode,
            },
            ip_address=ip,
        )

        return package

    def render_pdfs(
        self,
        episode_id: str,
        ip: Optional[str] = None,
    ) -> List[str]:
        """Render PDFs from existing webbook package + images."""
        self._get_episode(episode_id)

        # Get latest webbook package
        wb_record = (
            self._db.query(WebbookPackage)
            .filter(
                WebbookPackage.project_id == self._project_id,
                WebbookPackage.episode_id == episode_id,
            )
            .order_by(WebbookPackage.created_at.desc())
            .first()
        )
        if not wb_record:
            raise AppError(
                code="export.no_webbook",
                message=t("export.no_webbook"),
                status_code=400,
            )

        package = json.loads(wb_record.package_json)

        # Build image map: still_id -> file_path
        # For each still, prefer is_primary=1, then most recent original variant
        images_orm = (
            self._db.query(ImageAsset)
            .filter(
                ImageAsset.project_id == self._project_id,
                ImageAsset.episode_id == episode_id,
                ImageAsset.asset_type == "scene",
            )
            .all()
        )

        # Group images by still_id
        still_images: Dict[str, list] = {}
        for img in images_orm:
            if img.still_id and img.status in ("generated", "approved"):
                still_images.setdefault(img.still_id, []).append(img)

        image_map: Dict[str, str] = {}
        for still_id, imgs in still_images.items():
            # 1. Find primary image
            primary = next((i for i in imgs if i.is_primary), None)
            if primary:
                image_map[still_id] = primary.file_path
                continue
            # 2. Fallback: most recent original variant
            originals = [i for i in imgs if (i.variant_type or "original") == "original"]
            if originals:
                originals.sort(key=lambda i: i.created_at or "", reverse=True)
                image_map[still_id] = originals[0].file_path
                continue
            # 3. Last fallback: most recent image of any variant
            imgs.sort(key=lambda i: i.created_at or "", reverse=True)
            image_map[still_id] = imgs[0].file_path

        # Render PDFs
        exports_dir = self._get_exports_dir()
        renderer = PDFRenderer()
        provenance = ProvenanceRecorder(self._db, self._project_id)
        progress = ProgressTracker(self._db, episode_id, "pdf_render", self._project_id)
        progress.update("PDF 렌더링 중", 0, 1)

        try:
            with provenance.start_operation(
                "pdf_rendering", "pdf_renderer", episode_id=episode_id,
            ) as op:
                op.set_input({"episodes_in_package": len(package.get("episodes", [])), "images_available": len(image_map)})
                paths = renderer.render_all(
                    package=package,
                    images=image_map,
                    output_dir=exports_dir,
                )
                op.set_output({"pdf_count": len(paths)})

            progress.complete()
        except Exception as exc:
            progress.fail(str(exc))
            raise

        result = [str(p) for p in paths]

        self._logger.log(
            actor_id=self._actor_id,
            action="export.render_pdf",
            resource_type="episode",
            resource_id=episode_id,
            project_id=self._project_id,
            detail={"pdf_count": len(result)},
            ip_address=ip,
        )

        return result

    def validate_pdf(
        self,
        filename: str,
        ip: Optional[str] = None,
    ) -> Dict[str, Any]:
        from app.core.openai_keys import has_openai_key
        if not has_openai_key():
            raise AppError(
                code="analysis.openai_key_missing",
                message=t("analysis.openai_key_missing"),
                status_code=400,
            )

        exports_dir = self._get_exports_dir()
        safe_filename = Path(filename).name  # path traversal 방지
        pdf_path = exports_dir / safe_filename

        # resolve() 후 exports_dir 하위인지 검증
        if not pdf_path.resolve().is_relative_to(exports_dir.resolve()):
            raise AppError(code="export.invalid_path", message="Invalid filename", status_code=400)
        if not pdf_path.exists() or not pdf_path.is_file():
            raise AppError(
                code="export.file_not_found",
                message=t("export.file_not_found"),
                status_code=404,
            )

        validator = PDFValidator()
        result = validator.validate(pdf_path)

        self._logger.log(
            actor_id=self._actor_id,
            action="export.validate_pdf",
            resource_type="export",
            resource_id=filename,
            project_id=self._project_id,
            detail={
                "overall_quality": result.get("overall_quality", 0),
                "issues_count": len(result.get("issues", [])),
            },
            ip_address=ip,
        )

        return result

    def generate_html_zip(
        self,
        episode_id: str,
        ip: Optional[str] = None,
        original: bool = False,
    ) -> str:
        """씬별 원본 텍스트 + 이미지를 HTML ZIP으로 생성.

        에피소드별 HTML 파일 분리, 프로젝트명 기반 고정 파일명, 항상 덮어쓰기.

        original=False(기본): 모든 이미지를 JPEG 로 변환(용량 절감).
        original=True: 변환 없이 원본 파일(주로 PNG) 그대로 export — 무손실/풀해상도.
        JPG/PNG ZIP 은 파일명 태그로 구분해 둘 다 보존된다.
        """
        import shutil
        import tempfile
        from sqlalchemy import func, or_
        from sqlalchemy.orm import undefer
        from app.models.catalog import ProjectRegistry

        from app.services.project_service import ensure_english_name
        project_name = ensure_english_name(self._db, self._project_id)

        # 대상 에피소드 로드
        episode = self._db.query(Episode).options(
            undefer(Episode.fulltext)
        ).filter(
            Episode.id == episode_id,
            Episode.project_id == self._project_id,
        ).first()
        if not episode or not episode.fulltext:
            raise AppError(code="export.no_text", message="시나리오 텍스트가 없습니다.", status_code=400)

        # 전체 에피소드 목록 (여러 에피소드 지원)
        all_episodes = (
            self._db.query(Episode)
            .options(undefer(Episode.fulltext))
            .filter(Episode.project_id == self._project_id)
            .order_by(Episode.episode_number)
            .all()
        )

        with tempfile.TemporaryDirectory() as tmp_dir:
            tmp = Path(tmp_dir)
            img_dir = tmp / "images"
            img_dir.mkdir()

            # 분석된 scene_still 이 하나도 없는 episode 는 제외 — 기획안 PDF 같은
            # 미분석 업로드본이 "씬 데이터 없음" 빈 HTML 로 ZIP 에 끼어드는 사고 차단.
            still_counts = dict(
                self._db.query(SceneStill.episode_id, func.count(SceneStill.id))
                .filter(
                    SceneStill.project_id == self._project_id,
                    SceneStill.episode_id.in_([ep.id for ep in all_episodes]),
                    or_(SceneStill.status.is_(None), SceneStill.status != "stale"),
                )
                .group_by(SceneStill.episode_id)
                .all()
            )

            written = 0
            for ep in all_episodes:
                if not ep.fulltext:
                    continue
                if still_counts.get(ep.id, 0) == 0:
                    logger.info(
                        "export.html_zip skip episode without analyzed scene_still: ep_id=%s ep_num=%s title=%r",
                        ep.id, ep.episode_number, ep.title,
                    )
                    continue
                ep_html = self._build_episode_html(ep, img_dir, original=original)
                ep_filename = f"{project_name} ep{ep.episode_number}.html"
                (tmp / ep_filename).write_text(ep_html, encoding="utf-8")
                written += 1

            if written == 0:
                raise AppError(
                    code="export.no_analyzed_episodes",
                    message="ZIP 으로 내보낼 분석 완료 episode 가 없습니다.",
                    status_code=400,
                )

            # ZIP 생성 — 고정 파일명, 항상 덮어쓰기
            exports_dir = self._get_exports_dir()
            # top-level `re` 사용 (Codex iter 2 I4: 중복 import 제거).
            safe_name = re.sub(r'[^a-zA-Z0-9 ]', '', project_name).strip().replace(' ', '_') or "Project"
            kind = "PNG" if original else "JPG"
            ts = datetime.now().strftime("%Y%m%d_%H%M")
            # 같은 종류(JPG/PNG)의 기존 ZIP 만 삭제 — 다른 종류는 보존(둘 다 다운로드 가능).
            # legacy(태그 없는) ZIP 은 JPG 로 간주해 JPG export 시 정리.
            for old_zip in exports_dir.glob("*.zip"):
                if ("_PNG_" in old_zip.name) == original:
                    old_zip.unlink()
            zip_path = exports_dir / f"{safe_name}_{kind}_{ts}.zip"
            shutil.make_archive(str(zip_path.with_suffix("")), "zip", tmp)

        self._logger.log(
            actor_id=self._actor_id,
            action="export.generate_html_zip",
            resource_type="episode",
            resource_id=episode_id,
            project_id=self._project_id,
            detail={"episodes": len(all_episodes), "zip": zip_path.name},
            ip_address=ip,
        )

        return str(zip_path)

    def _build_episode_html(self, episode: Episode, img_dir: Path, original: bool = False) -> str:
        """단일 에피소드 HTML 생성 — shot 단위 이미지를 씬 텍스트에 인라인 삽입.

        original=True 면 이미지를 JPEG 변환 없이 원본 파일 그대로 복사하고
        <img src> 도 원본 확장자(.png 등)로 참조한다.
        """
        import logging
        import re as _re
        import shutil
        from collections import defaultdict
        from concurrent.futures import ThreadPoolExecutor, as_completed
        from sqlalchemy import or_

        _log = logging.getLogger(__name__)
        fulltext = episode.fulltext
        ep_num = episode.episode_number
        title = episode.title or f"EP{ep_num}"

        stills = (
            self._db.query(SceneStill)
            .filter(
                SceneStill.project_id == self._project_id,
                SceneStill.episode_id == episode.id,
                or_(SceneStill.status.is_(None), SceneStill.status != "stale"),
            )
            .order_by(SceneStill.still_index)
            .all()
        )
        if not stills:
            return _build_html_template(title, "<p>씬 데이터 없음</p>")

        # 세그먼트 로드: current scene_save 우선, legacy scene_segmentation fallback.
        cp_root = (
            Path(settings.projects_dir)
            / self._project_id
            / "checkpoints"
            / "episodes"
            / episode.id
        )
        segments = []
        for step_name in ("scene_save", "scene_segmentation"):
            cp_path = cp_root / step_name / "manifest.json"
            if not cp_path.exists():
                continue
            try:
                cp_data = json.loads(cp_path.read_text(encoding="utf-8"))
            except (OSError, json.JSONDecodeError) as exc:
                # silent fallback 차단 (feedback_no_silent_fallback): 다음
                # step 으로 진행하되 운영자가 경로 읽기 실패를 인지하도록 warn.
                _log.warning(
                    "checkpoint manifest read failed: path=%s, exc=%s — fallback 진행",
                    cp_path, exc,
                )
                continue
            raw_segments = cp_data.get("data", {}).get("segments", [])
            if isinstance(raw_segments, list) and raw_segments:
                segments = raw_segments
                break
        seg_map = {
            s["scene_index"]: s
            for s in segments
            if isinstance(s, dict) and s.get("scene_index") is not None
        }

        # 씬 이미지 로드
        images_orm = (
            self._db.query(ImageAsset)
            .filter(
                ImageAsset.project_id == self._project_id,
                ImageAsset.episode_id == episode.id,
                ImageAsset.asset_type == "scene",
            )
            .order_by(ImageAsset.created_at)
            .all()
        )
        # ImageAsset.status 화이트리스트 — generated/approved 만 export 대상.
        # NULL status (legacy row) 는 포함, regenerating/failed 는 명시 제외 +
        # 운영자 경로 가시화를 위해 debug 로그 (feedback_no_silent_fallback).
        _ALLOWED_IMG_STATUS = {"generated", "approved"}
        still_images: Dict[str, List[ImageAsset]] = {}
        for img in images_orm:
            if img.status and img.status not in _ALLOWED_IMG_STATUS:
                _log.debug(
                    "skip image asset: id=%s, still_id=%s, status=%s — export 제외",
                    img.id, img.still_id, img.status,
                )
                continue
            if img.still_id:
                still_images.setdefault(img.still_id, []).append(img)

        # ── stills를 scene_index로 그룹핑 (v4: 씬당 여러 shot) ──
        scene_stills: Dict[int, List[SceneStill]] = defaultdict(list)
        for st in stills:
            si = st.scene_index if st.scene_index is not None else st.still_index
            scene_stills[si].append(st)
        # shot_index 순 정렬
        for si in scene_stills:
            scene_stills[si].sort(key=lambda s: s.shot_index if s.shot_index is not None else 0)

        def _scene_text_from_stills(si: int, si_stills: List[SceneStill]) -> str:
            seg = seg_map.get(si)
            if seg:
                text = seg.get("text") or seg.get("content") or seg.get("scene_text") or ""
                if isinstance(text, str) and text:
                    return text
            ranges = [
                (s.segment_start_char, s.segment_end_char)
                for s in si_stills
                if s.segment_start_char is not None and s.segment_end_char is not None
            ]
            if ranges:
                start = max(0, min(a for a, _ in ranges))
                end = max(b for _, b in ranges)
                if end > start:
                    return fulltext[start:end]
            return ""

        # ── 씬 텍스트를 단락으로 분할 + LLM으로 이미지 배치 위치 결정 ──
        def _split_paragraphs(text: str) -> List[str]:
            """씬 텍스트를 빈 줄 기준으로 단락 분할."""
            blocks = _re.split(r'\n\s*\n', text.strip())
            return [b.strip() for b in blocks if b.strip()]

        def _determine_placements(scene_index: int, paragraphs: List[str], shots: List[dict]) -> List[int]:
            """LLM으로 각 shot 이미지의 삽입 위치 결정. 반환: shot별 after_paragraph (1-indexed)."""
            if not shots or not paragraphs:
                return [len(paragraphs)] * len(shots)
            if len(paragraphs) == 1:
                return [1] * len(shots)

            from app.modules.llm.llm_client import call_structured

            numbered = "\n".join(f"[{i+1}] {p}" for i, p in enumerate(paragraphs))
            shot_list = "\n".join(
                f"- shot_{sh['shot_index']}: {sh['description']}"
                for sh in shots
            )
            user_prompt = (
                f"씬 {scene_index}의 텍스트가 {len(paragraphs)}개 단락으로 나뉘어 있습니다.\n"
                f"각 샷 이미지를 어느 단락 뒤에 삽입하면 내용과 가장 어울리는지 결정하세요.\n\n"
                f"## 단락\n{numbered}\n\n"
                f"## 삽입할 샷\n{shot_list}\n\n"
                f"## 규칙\n"
                f"- 각 샷의 시각적 순간과 가장 관련 있는 단락 바로 뒤에 배치\n"
                f"- after_paragraph는 1~{len(paragraphs)} 범위 (해당 번호 단락 뒤에 삽입)\n"
                f"- 샷 순서 유지: 앞 샷의 위치 ≤ 뒤 샷의 위치\n"
                f"- 같은 위치에 여러 샷 가능"
            )
            schema = {
                "type": "object",
                "properties": {
                    "placements": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "shot_index": {"type": "integer"},
                                "after_paragraph": {"type": "integer"},
                            },
                            "required": ["shot_index", "after_paragraph"],
                            "additionalProperties": False,
                        },
                    },
                },
                "required": ["placements"],
                "additionalProperties": False,
            }
            try:
                result = call_structured(
                    step="image_placement",
                    system_prompt="시나리오 텍스트에 스틸 이미지를 배치하는 전문가. JSON으로만 답하세요.",
                    user_prompt=user_prompt,
                    response_schema=schema,
                    temperature=0.1,
                )
                placement_map = {p["shot_index"]: p["after_paragraph"] for p in result.get("placements", [])}
                # 순서 보정 + 범위 클램프
                positions = []
                prev_pos = 1
                for sh in shots:
                    pos = placement_map.get(sh["shot_index"], prev_pos)
                    pos = max(prev_pos, min(pos, len(paragraphs)))
                    positions.append(pos)
                    prev_pos = pos
                return positions
            except Exception as exc:
                _log.warning("Image placement LLM failed for scene %d: %s", scene_index, exc)
                # fallback: 균등 분배
                n = len(paragraphs)
                return [min(n, max(1, (i + 1) * n // (len(shots) + 1))) for i in range(len(shots))]

        # 병렬 LLM 호출 — 씬별 배치 위치 결정
        placement_results: Dict[int, List[int]] = {}
        futures = {}
        with ThreadPoolExecutor(max_workers=8) as pool:
            for si, si_stills in scene_stills.items():
                scene_text = _scene_text_from_stills(si, si_stills)
                paragraphs = _split_paragraphs(scene_text)
                shots_info = [
                    {"shot_index": s.shot_index or 0, "description": s.still_frame_prompt or s.beat_title or ""}
                    for s in si_stills
                ]
                if len(si_stills) > 0 and len(paragraphs) > 1:
                    fut = pool.submit(_determine_placements, si, paragraphs, shots_info)
                    futures[fut] = si
                else:
                    # 단락 1개 이하 → 마지막에 배치
                    placement_results[si] = [len(paragraphs) or 1] * len(si_stills)

            for fut in as_completed(futures):
                si = futures[fut]
                try:
                    placement_results[si] = fut.result()
                except Exception:
                    placement_results[si] = [1] * len(scene_stills[si])

        # ── 참조 이미지 관련 로드 ──
        ref_images = (
            self._db.query(ImageAsset)
            .filter(
                ImageAsset.project_id == self._project_id,
                ImageAsset.asset_type == "reference",
                ImageAsset.is_primary == 1,
            )
            .all()
        )
        entity_ids = {r.entity_id for r in ref_images if r.entity_id}
        entities_map = {}
        if entity_ids:
            for ec in self._db.query(EntityCanon).filter(EntityCanon.id.in_(entity_ids)).all():
                entities_map[ec.id] = ec
        ref_by_entity: Dict[str, ImageAsset] = {}
        for r in ref_images:
            if r.entity_id and r.entity_id not in ref_by_entity:
                ref_by_entity[r.entity_id] = r

        composite_images = (
            self._db.query(ImageAsset)
            .filter(
                ImageAsset.project_id == self._project_id,
                ImageAsset.asset_type == "reference",
                ImageAsset.prompt_used.like("%composite:%"),
            )
            .all()
        )
        composite_map: Dict[str, ImageAsset] = {}
        for ci in composite_images:
            m = _re.search(r'composite:([a-f0-9-]+):([a-f0-9-]+)', ci.prompt_used or "")
            if m:
                composite_map[f"{m.group(1)}:{m.group(2)}"] = ci

        # 전체 엔티티 short_id → EntityCanon 매핑 (참조 이미지 유무 관계없이)
        all_entities = self._db.query(EntityCanon).filter(
            EntityCanon.project_id == self._project_id
        ).all()
        sid_to_entity: Dict[str, EntityCanon] = {e.short_id: e for e in all_entities if e.short_id}

        ref_copied: set = set()

        def _copy_ref(asset: ImageAsset, label: str) -> str:
            src = _existing_asset_path(asset.file_path)
            if not src:
                return ""
            ext = src.suffix if original else ".jpg"
            fname = f"ref_{asset.id[:8]}{ext}"
            dst = img_dir / fname
            if fname not in ref_copied:
                if original:
                    shutil.copy2(src, dst)
                else:
                    _copy_as_jpeg(src, dst, quality=75)
                ref_copied.add(fname)
            return (
                f'<div class="ref-item">'
                f'<img src="images/{fname}" onclick="openModal(this)" />'
                f'<div class="ref-label">{_html_escape(label)}</div>'
                f'<div class="meta" style="display:none"></div>'
                f'</div>'
            )

        # #7 fix (2026-07-02): 샷별 참조 섹션의 SOT = 생성 시 실제 첨부 기록
        # (scene primary asset pipeline_metadata_json.actual_attached_refs —
        # {role,label,asset_id}). 기존 VE+T2I C##O## 재구성 휴리스틱은 metadata
        # 가 없는 legacy still 의 fallback 으로만 유지. 이 기록이 없던 시절의
        # export 는 composite/배경/가이드/상태변형이 전부 빠지고 base 얼굴로
        # 붕괴("얼굴만 나옴")했다. asset_id 해석만 — role/라벨 텍스트 파싱 0.
        def _parse_attached_refs(
            assets: List[ImageAsset],
        ) -> Optional[List[Dict[str, Any]]]:
            """primary asset 의 actual_attached_refs — 키 부재(legacy)=None,
            기록 있음=list([] 포함: 진짜 무참조 T2I 샷)."""
            meta_asset = next(
                (a for a in assets if a.is_primary),
                assets[0] if assets else None,
            )
            if meta_asset is None:
                return None
            try:
                pm = json.loads(meta_asset.pipeline_metadata_json or "{}")
            except (TypeError, json.JSONDecodeError):
                return None
            refs = pm.get("actual_attached_refs")
            if not isinstance(refs, list):
                return None
            return [r for r in refs if isinstance(r, dict)]

        _attached_by_still: Dict[str, Optional[List[Dict[str, Any]]]] = {
            st_id: _parse_attached_refs(assets)
            for st_id, assets in still_images.items()
        }
        _attached_ids = {
            r.get("asset_id")
            for refs in _attached_by_still.values()
            if refs
            for r in refs
            if r.get("asset_id")
        }
        # 에피소드 단위 일괄 preload (N+1 회피) — 참조 asset 은 asset_type 무관
        # (reference/scene/intermediate 전부 가능).
        _attached_assets: Dict[str, ImageAsset] = {}
        if _attached_ids:
            for a in (
                self._db.query(ImageAsset)
                .filter(ImageAsset.id.in_(_attached_ids))
                .all()
            ):
                _attached_assets[a.id] = a

        # role 문자열은 actual_attached_refs 실측 기준 (reference_* 계열 +
        # pipeline role 계열 공존). 미등록 role 은 원문 그대로 노출.
        _ROLE_CAPTION = {
            "reference_composite": "합성(인물+아웃룩)",
            "reference_face": "얼굴",
            "reference_prop": "소품",
            "reference_outfit": "아웃룩",
            "background_render": "배경",
            "composition_guide": "구도 가이드",
            "registered_pose_guide": "포즈 가이드",
            "indoor_pose_guide": "포즈 가이드(실내)",
            "character_state_variant": "상태 변형",
            "scene_prev_frame": "이전 샷 프레임",
            "immobilized_prev_frame": "이전 샷 프레임(고정 인물)",
        }

        def _build_shot_refs(st: SceneStill) -> str:
            """샷별 참조 이미지 HTML — SOT=actual_attached_refs(실제 첨부),
            metadata 부재 legacy still 만 VE + T2I C##O## 휴리스틱."""
            attached = _attached_by_still.get(st.id)
            if attached is not None:
                sot_tags: List[str] = []
                seen_ids: set = set()
                for ref in attached:
                    aid = ref.get("asset_id")
                    if not aid or aid in seen_ids:
                        continue
                    seen_ids.add(aid)
                    asset = _attached_assets.get(aid)
                    if not asset:
                        continue
                    role = str(ref.get("role") or "")
                    tag = _copy_ref(asset, _ROLE_CAPTION.get(role, role or "참조"))
                    if tag:
                        sot_tags.append(tag)
                if sot_tags:
                    return (
                        f'<div class="shot-refs">'
                        f'<div class="ref-grid">{"".join(sot_tags)}</div>'
                        f'</div>'
                    )
                # 기록 존재 + 렌더 0(무참조 T2I / 파일 유실) → 휴리스틱 재구성
                # 금지 (틀린 참조를 보여주는 것보다 빈 것이 정직).
                return ""
            ve_sids = _visible_short_ids_from_json(st.visible_entities_json)
            # T2I에서 C##O## 콤보 추출
            t2i_text = _t2i_text_from_variations_json(
                st.t2i_variations_json,
                st.t2i_prompt_cinematic or "",
            )
            co_combos = set(_re.findall(r'C\d{2,3}O\d{2,3}', t2i_text))

            ref_tags = []
            seen = set()
            for sid in sorted(ve_sids):
                ec = sid_to_entity.get(sid)
                if not ec:
                    continue
                if ec.entity_type == "character":
                    # T2I에서 이 캐릭터의 C##O## 콤보 찾기 → composite 우선
                    char_combos = [co for co in co_combos if co.split("O")[0] == sid]
                    if char_combos:
                        for co in char_combos:
                            o_sid = "O" + co.split("O")[1]
                            o_ec = sid_to_entity.get(o_sid)
                            if o_ec:
                                ckey = f"{ec.id}:{o_ec.id}"
                                if ckey in composite_map and ckey not in seen:
                                    seen.add(ckey)
                                    tag = _copy_ref(composite_map[ckey], co)
                                    if tag:
                                        ref_tags.append(tag)
                                    continue
                            # composite 없으면 얼굴
                            if ec.id not in seen and ec.id in ref_by_entity:
                                seen.add(ec.id)
                                ref_tags.append(_copy_ref(ref_by_entity[ec.id], f"{sid} {ec.name}"))
                    else:
                        if ec.id not in seen and ec.id in ref_by_entity:
                            seen.add(ec.id)
                            ref_tags.append(_copy_ref(ref_by_entity[ec.id], f"{sid} {ec.name}"))
                elif ec.entity_type in ("location", "prop"):
                    if ec.id not in seen and ec.id in ref_by_entity:
                        seen.add(ec.id)
                        tag = _copy_ref(ref_by_entity[ec.id], f"{sid} {ec.name}")
                        if tag:
                            ref_tags.append(tag)
            ref_tags = [t for t in ref_tags if t]
            if not ref_tags:
                return ""
            return (
                f'<div class="shot-refs">'
                f'<div class="ref-grid">{"".join(ref_tags)}</div>'
                f'</div>'
            )

        # ── 씬별 HTML 생성 (이미지 인라인 삽입) ──
        scenes_html = []
        sorted_scenes = sorted(scene_stills.keys())
        for si in sorted_scenes:
            si_stills = scene_stills[si]
            heading = si_stills[0].screenplay_scene_heading or f"씬 {si}"
            scene_text = _scene_text_from_stills(si, si_stills)
            paragraphs = _split_paragraphs(scene_text)
            positions = placement_results.get(si, [])

            # 각 shot의 이미지 복사 + HTML 태그 준비
            shot_img_html: Dict[int, str] = {}  # shot 순서 idx → img html
            for shot_i, st in enumerate(si_stills):
                imgs = still_images.get(st.id, [])
                if not imgs:
                    continue
                img_tags = []
                for idx, img in enumerate(imgs):
                    src_path = _existing_asset_path(img.file_path)
                    if not src_path:
                        continue
                    shot_idx = st.shot_index if st.shot_index is not None else 0
                    ext = src_path.suffix if original else ".jpg"
                    fname = f"ep{ep_num}_s{si:03d}_shot{shot_idx:02d}_{idx+1:02d}{ext}"
                    dst = img_dir / fname
                    if not dst.exists():
                        if original:
                            shutil.copy2(src_path, dst)
                        else:
                            _copy_as_jpeg(src_path, dst)

                    meta_lines = []
                    if img.variant_type:
                        meta_lines.append(f"Type: {img.variant_type}")
                    if st.beat_title:
                        meta_lines.append(f"Beat: {st.beat_title}")
                    meta_html = "<br>".join(f"<span>{_html_escape(m)}</span>" for m in meta_lines)

                    img_tags.append(
                        f'<div class="img-wrap">'
                        f'<img src="images/{fname}" onclick="openModal(this)" />'
                        f'<div class="meta">{meta_html}</div>'
                        f'</div>'
                    )
                if img_tags:
                    caption = _html_escape(st.still_frame_prompt or st.beat_title or "")[:120]
                    n_imgs = len(img_tags)
                    ref_html = _build_shot_refs(st)
                    shot_img_html[shot_i] = (
                        f'<div class="inline-shot cols-{min(n_imgs, 2)}">'
                        f'{"".join(img_tags)}'
                        f'{"<div class=\"shot-caption\">" + caption + "</div>" if caption else ""}'
                        f'{ref_html}'
                        f'</div>'
                    )

            # 단락 + 이미지 인터리빙
            # positions[i] = i번째 shot을 paragraphs[positions[i]-1] 뒤에 삽입
            insert_map: Dict[int, List[str]] = defaultdict(list)  # para_idx(1-based) → [img_html, ...]
            for shot_i, pos in enumerate(positions):
                if shot_i in shot_img_html:
                    insert_map[pos].append(shot_img_html[shot_i])

            body_parts = []
            if paragraphs:
                for p_idx, para in enumerate(paragraphs, 1):
                    body_parts.append(f'<div class="para">{_html_escape(para)}</div>')
                    if p_idx in insert_map:
                        body_parts.extend(insert_map[p_idx])
            else:
                body_parts.append(f'<div class="para">{_html_escape(scene_text)}</div>')
                for html_block in insert_map.get(1, []):
                    body_parts.append(html_block)

            scenes_html.append(
                f'<section class="scene open">'
                f'<h2 onclick="this.parentElement.classList.toggle(\'open\')" style="cursor:pointer">'
                f'씬 {si}: {_html_escape(heading)} '
                f'<span class="shot-count">({len(si_stills)}샷)</span>'
                f' <span class="toggle-arrow">&#9654;</span></h2>'
                f'<div class="scene-body">{"".join(body_parts)}</div>'
                f'</section>'
            )

        return _build_html_template(title, "\n".join(scenes_html))

    def list_exports(self) -> List[Dict[str, Any]]:
        """List all exported files."""
        exports_dir = self._get_exports_dir()
        result: List[Dict[str, Any]] = []

        if not exports_dir.exists():
            return result

        for f in sorted(exports_dir.iterdir()):
            if f.is_file() and f.suffix in (".pdf", ".zip"):
                stat = f.stat()
                result.append(
                    {
                        "filename": f.name,
                        "episode_id": None,
                        "file_path": str(f),
                        "size_bytes": stat.st_size,
                        "created_at": datetime.fromtimestamp(
                            stat.st_mtime, tz=timezone.utc
                        ).isoformat(),
                    }
                )

        return result


def _copy_as_jpeg(src: Path, dst: Path, quality: int = 80) -> None:
    """PNG를 JPEG로 변환하여 복사. 실패 시 HTML이 참조하는 dst 경로에 원본 복사.

    fallback (Codex iter 2 B1): dst 의 확장자(.jpg)와 raw bytes(PNG) 가 mismatch
    되지만 HTML <img src="...jpg"> 참조 깨짐을 우선 방지. 운영자가 변환 실패를
    인지하도록 logger.warning 으로 가시화 (silent 회피, feedback_no_silent_fallback).
    """
    try:
        from PIL import Image
        with Image.open(src) as im:
            rgb = im.convert("RGB") if im.mode != "RGB" else im
            rgb.save(dst, "JPEG", quality=quality, optimize=True)
    except Exception as exc:
        import shutil
        logger.warning(
            "_copy_as_jpeg PIL conversion failed: src=%s, dst=%s, exc=%s — "
            "raw 복사 fallback (MIME mismatch 가능, 운영자 확인 권장).",
            src, dst, exc,
        )
        shutil.copy2(src, dst)


def _html_escape(text: str) -> str:
    return html_module.escape(text).replace("\n", "<br>")


def _build_html_template(title: str, body: str) -> str:
    return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{html_module.escape(title)}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif; background: #111; color: #e0e0e0; padding: 20px; }}
h1 {{ text-align: center; padding: 30px 0; font-size: 28px; color: #fff; border-bottom: 1px solid #333; margin-bottom: 30px; }}
.scene {{ margin-bottom: 40px; border: 1px solid #333; border-radius: 8px; overflow: hidden; }}
.scene h2 {{ background: #1a1a2e; padding: 14px 20px; font-size: 18px; color: #a0c4ff; user-select: none; }}
.scene h2 .toggle-arrow {{ font-size: 12px; margin-left: 8px; transition: transform 0.2s; display: inline-block; }}
.scene h2 .shot-count {{ font-size: 13px; color: #666; font-weight: normal; }}
.scene.open h2 .toggle-arrow {{ transform: rotate(90deg); }}
.scene-body {{ display: none; padding: 16px 20px; background: #1a1a1a; }}
.scene.open .scene-body {{ display: block; }}
.para {{ font-size: 14px; line-height: 1.8; white-space: pre-wrap; margin-bottom: 12px; color: #e0e0e0; }}
.inline-shot {{ display: grid; gap: 4px; margin: 16px 0; border-radius: 6px; overflow: hidden; }}
.inline-shot.cols-1 {{ grid-template-columns: 1fr; }}
.inline-shot.cols-2 {{ grid-template-columns: 1fr 1fr; }}
.shot-caption {{ grid-column: 1 / -1; padding: 6px 10px; font-size: 12px; color: #999; background: #0d0d0d; font-style: italic; }}
.shot-refs {{ grid-column: 1 / -1; padding: 6px 8px; background: #0a0a0a; border-top: 1px solid #222; }}
.img-wrap {{ position: relative; overflow: hidden; }}
.img-wrap img {{ width: 100%; display: block; cursor: pointer; transition: opacity 0.2s; }}
.img-wrap img:hover {{ opacity: 0.85; }}
.img-wrap .meta {{ display: none; }}
.ref-section {{ padding: 12px 16px; background: #151515; border-top: 1px solid #333; }}
.ref-title {{ font-size: 12px; font-weight: 600; color: #666; text-transform: uppercase; margin-bottom: 8px; }}
.ref-grid {{ display: flex; flex-wrap: wrap; gap: 6px; }}
.ref-item {{ width: 100px; flex-shrink: 0; }}
.ref-item img {{ width: 100px; height: 100px; object-fit: cover; border-radius: 4px; cursor: pointer; }}
.ref-item img:hover {{ opacity: 0.8; }}
.ref-label {{ font-size: 10px; color: #888; text-align: center; margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
.modal-overlay {{ display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.95); z-index: 1000; justify-content: center; align-items: center; flex-direction: column; cursor: pointer; }}
.modal-overlay.active {{ display: flex; }}
.modal-overlay img {{ max-width: 95vw; max-height: 85vh; object-fit: contain; border-radius: 4px; }}
.modal-meta {{ margin-top: 12px; padding: 16px 24px; background: #1a1a1a; border-radius: 6px; max-width: 90vw; max-height: 20vh; overflow-y: auto; font-size: 12px; color: #aaa; line-height: 1.7; word-break: break-all; }}
.modal-close {{ position: fixed; top: 20px; right: 30px; font-size: 36px; color: #fff; cursor: pointer; z-index: 1001; }}
</style>
</head>
<body>
<h1>{html_module.escape(title)}</h1>
{body}
<div class="modal-overlay" id="modal" onclick="closeModal()">
<span class="modal-close">&times;</span>
<img id="modal-img" src="" />
<div class="modal-meta" id="modal-meta"></div>
</div>
<script>
function openModal(el) {{
  const modal = document.getElementById('modal');
  const modalImg = document.getElementById('modal-img');
  const modalMeta = document.getElementById('modal-meta');
  modalImg.src = el.src;
  const metaEl = el.parentElement.querySelector('.meta');
  modalMeta.innerHTML = metaEl ? metaEl.innerHTML : '';
  modal.classList.add('active');
  document.body.style.overflow = 'hidden';
}}
function closeModal() {{
  document.getElementById('modal').classList.remove('active');
  document.body.style.overflow = '';
}}
document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeModal(); }});
</script>
</body>
</html>"""
