"""씬 Phase StepRunner -- scene_segmentation, scene_split, scene_save."""

import json
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional

from app.core.step_runner import StepRunner

logger = logging.getLogger(__name__)


class _SceneStepMixin:
    """씬 단계 공통 -- fulltext 로드, 이전 단계 결과 로드."""

    def _load_cleaned_text(self) -> str:
        """text_cleanup 결과 사용, 없으면 원본 fulltext."""
        cp = self._load_prev_checkpoint("text_cleanup")
        if cp and cp.get("data", {}).get("cleaned_text"):
            return cp["data"]["cleaned_text"]
        return self._load_fulltext()

    def _load_fulltext(self) -> str:
        from app.models.project import Episode
        from sqlalchemy.orm import undefer

        ep = (
            self.db.query(Episode)
            .options(undefer(Episode.fulltext))
            .filter(Episode.id == self.episode_id)
            .first()
        )
        if not ep or not ep.fulltext:
            from app.core.errors import AppError

            raise AppError(
                code="step.no_fulltext",
                message="시나리오 텍스트가 없습니다.",
                status_code=400,
            )
        return ep.fulltext

    def _load_prev_checkpoint(self, step_id: str) -> Optional[Dict]:
        from app.core.config import settings

        cp = (
            Path(settings.projects_dir)
            / self.project_id
            / "checkpoints"
            / "episodes"
            / self.episode_id
            / step_id
            / "manifest.json"
        )
        if cp.exists():
            return json.loads(cp.read_text(encoding="utf-8"))
        return None


class SceneSegmentationStep(_SceneStepMixin, StepRunner):
    """Step 1: 씬 세그먼테이션 — LLM 은 그 대본용 규칙을 쓰고, 코드가 경계를 찍는다.

    설계 = docs/superpowers/specs/2026-08-08-scene-segmentation-rule-authoring-design.md

    ## 무엇이 달라졌나 (2026-08-08)

    전에도 LLM 이 정규식을 만들었지만 **후보를 하나만 받고 검증이 평균 길이
    하나**였다. 그래서 "개수는 맞는데 경계가 틀린" 규칙이 그대로 통과했다 —
    22개 대본 실측에서 그런 결함을 넷 찾았고, 넷 다 숫자만 봐서는 안 드러났다.

    지금은 후보를 2~3개 받아 기계 계약(C1~C7)으로 재고, 통과한 후보끼리 **경계가
    갈린 자리를 양쪽 다** 되돌려주고 다시 쓰게 한다. 한쪽만 보면 정답을 통째로
    포함하는 넓은 규칙이 조용히 이긴다(재현함).

    ★상한을 다 쓰고도 차이가 남으면 성공으로 두지 않는다. 코드가 이미 아는
    차이를 남긴 채 확정하면 대본 전체가 밀린 채 하류로 내려간다.
    """

    MAX_RETRY = 3

    def _execute(self, mode="resume") -> Dict[str, Any]:
        from app.modules.pipeline.segment_rule import apply_rule
        from app.modules.pipeline.segment_rule_author import author_and_choose

        fulltext = self._load_cleaned_text()

        chosen, verdict, rounds = author_and_choose(
            fulltext,                       # ★자르지 않는다
            max_rounds=self.MAX_RETRY,
            step="scene_segmentation",
            project_config=self.project_config,
            project_id=self.project_id,
            episode_id=self.episode_id,
        )

        for rd in rounds:
            if rd.get("error"):
                logger.warning("scene_segmentation %d회차 실패: %s",
                               rd["round"], rd["error"])
                continue
            logger.info(
                "scene_segmentation %d회차: 후보 %d개 · 통과 %d개%s",
                rd["round"], len(rd.get("candidates") or []),
                sum(1 for c in rd.get("candidates") or [] if c.get("ok")),
                (f" · 재저작 이유={rd['retry_reason']}" if rd.get("retry_reason") else ""),
            )

        if not chosen:
            from app.core.errors import AppError

            last = rounds[-1] if rounds else {}
            if last.get("unresolved"):
                why = (f"후보끼리 경계가 갈린 채 남았다 "
                       f"(놓침 {last['unresolved']['missed']} · "
                       f"저 혼자 잡음 {last['unresolved']['extra']})")
            elif last.get("error"):
                why = last["error"]
            else:
                why = "계약을 통과한 규칙이 없다"
            raise AppError(
                code="step.segmentation_failed",
                message=f"씬 세그먼테이션 실패 ({self.MAX_RETRY}회 시도): {why}",
                status_code=500,
            )

        logger.info("scene_segmentation 확정: %s · %d개 · 헤딩 내용 %.2f",
                    chosen.pattern[:60], verdict.match_count,
                    verdict.heading_content)

        # 하류가 읽는 모양은 그대로 둔다 — scene_index/heading/start_char/
        # end_char/length. 이 계약이 바뀌면 씬 저장부터 이미지까지 다 밀린다.
        segments = []
        for i, seg in enumerate(apply_rule(fulltext, chosen)):
            if len(seg.heading) > 200:
                logger.warning("Scene %d heading 비정상적으로 김 (%d chars): %.60s...",
                               i + 1, len(seg.heading), seg.heading)
            segments.append({
                "scene_index": i + 1,
                "heading": seg.heading,
                "start_char": seg.start_char,
                "end_char": seg.end_char,
                "length": seg.end_char - seg.start_char,
            })

        return {
            "completed_count": 1,
            "applicable_count": 1,
            "failed_count": 0,
            "data": {"segments": segments, "total_scenes": len(segments),
                     "rule": {"name": chosen.name, "pattern": chosen.pattern,
                              "number_group": chosen.number_group,
                              "rationale": chosen.rationale}},
        }


class SceneSplitStep(_SceneStepMixin, StepRunner):
    """Step 5: 큰 씬 분할 (조건부)."""

    def check_applicability(self) -> bool:
        """threshold 초과 씬이 있을 때만 적용."""
        seg_cp = self._load_prev_checkpoint("scene_segmentation")
        if not seg_cp or not seg_cp.get("data", {}).get("segments"):
            return False
        from app.models.project import ProjectSettings

        ps = (
            self.db.query(ProjectSettings)
            .filter(ProjectSettings.project_id == self.project_id)
            .first()
        )
        threshold = ps.scene_split_threshold if ps else 600
        segments = seg_cp["data"]["segments"]
        return any(s.get("length", 0) > threshold for s in segments)

    def _execute(self, mode="resume") -> Dict[str, Any]:
        seg_cp = self._load_prev_checkpoint("scene_segmentation")
        segments = seg_cp["data"]["segments"]
        fulltext = self._load_cleaned_text()

        from app.models.project import ProjectSettings

        ps = (
            self.db.query(ProjectSettings)
            .filter(ProjectSettings.project_id == self.project_id)
            .first()
        )
        threshold = ps.scene_split_threshold if ps else 600

        from app.modules.pipeline.scene_extractor_v2 import (
            _split_large_scene_by_llm,
        )
        from concurrent.futures import ThreadPoolExecutor, as_completed

        large_segs = [(i, seg) for i, seg in enumerate(segments) if seg.get("length", 0) > threshold]
        small_segs = [(i, seg) for i, seg in enumerate(segments) if seg.get("length", 0) <= threshold]

        split_results: Dict[int, List[Dict]] = {}  # index → split results

        def _split_one(idx, seg):
            scene_text = seg.get("text", fulltext[seg["start_char"]:seg["end_char"]])
            return idx, _split_large_scene_by_llm(scene_text, seg, threshold)

        split_count = 0
        from app.core.config import settings
        max_workers = min(len(large_segs) or 1, settings.max_concurrent_entity_detail)
        with ThreadPoolExecutor(max_workers=max_workers) as pool:
            futures = {pool.submit(_split_one, i, seg): i for i, seg in large_segs}
            for f in as_completed(futures):
                idx = futures[f]
                try:
                    _, sub = f.result()
                    split_results[idx] = sub
                    split_count += len(sub) - 1
                except Exception as exc:
                    logger.warning("Split failed for scene %s: %s", segments[idx].get("scene_index"), exc)
                    split_results[idx] = [segments[idx]]

        # 원래 순서대로 재조합
        final_segments: List[Dict[str, Any]] = []
        for i, seg in enumerate(segments):
            if i in split_results:
                final_segments.extend(split_results[i])
            else:
                final_segments.append(seg)

        # Re-index
        for i, seg in enumerate(final_segments):
            seg["scene_index"] = i + 1

        return {
            "completed_count": split_count + 1,
            "applicable_count": len(segments),
            "failed_count": 0,
            "data": {
                "segments": final_segments,
                "total_scenes": len(final_segments),
                "splits": split_count,
            },
        }


class SceneSaveStep(_SceneStepMixin, StepRunner):
    """Step 6: 씬 저장 (DB + checkpoint). LLM 호출 없음.

    scene_segmentation 결과(오프셋)를 받아 fulltext를 슬라이스하여
    각 segment에 text 필드를 추가한다.
    이후 downstream 스텝은 seg["text"]만 사용하고 fulltext 로딩 불필요.
    """

    def _execute(self, mode="resume") -> Dict[str, Any]:
        # v4: scene_segmentation 직접 사용 (scene_split은 on_demand 레거시)
        seg_cp = self._load_prev_checkpoint("scene_segmentation")

        if seg_cp and seg_cp.get("data", {}).get("segments"):
            segments = seg_cp["data"]["segments"]
        else:
            from app.core.errors import AppError

            raise AppError(
                code="step.no_input",
                message="씬 세그먼테이션 결과 없음",
                status_code=400,
            )

        # 씬 텍스트를 segment에 직접 저장 — downstream에서 fulltext 슬라이싱 제거
        fulltext = self._load_cleaned_text()
        for seg in segments:
            start = seg.get("start_char", 0)
            end = seg.get("end_char", len(fulltext))
            seg["text"] = fulltext[start:end]

        # Save to checkpoint (DB sync happens in _sync_checkpoints_to_db)
        return {
            "completed_count": len(segments),
            "applicable_count": len(segments),
            "failed_count": 0,
            "data": {"segments": segments, "total_scenes": len(segments)},
        }
