"""scene_steps v3 -- SceneSegmentationStep, SceneSplitStep, SceneSaveStep 단위 테스트."""

import json
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest


# -- Fixtures --

@pytest.fixture()
def tmp_projects_dir(tmp_path):
    """임시 projects 디렉토리를 생성하고 settings.projects_dir을 패치."""
    return str(tmp_path / "projects")


@pytest.fixture()
def fake_db():
    """SQLAlchemy 세션을 모킹."""
    db = MagicMock()
    db.execute.return_value.fetchone.return_value = None
    return db


def _make_checkpoint(tmp_projects_dir, project_id, episode_id, step_id, data):
    """체크포인트 manifest.json 생성 헬퍼."""
    cp_dir = (
        Path(tmp_projects_dir)
        / project_id
        / "checkpoints"
        / "episodes"
        / episode_id
        / step_id
    )
    cp_dir.mkdir(parents=True, exist_ok=True)
    manifest = {"status": "completed", "data": data}
    (cp_dir / "manifest.json").write_text(
        json.dumps(manifest, ensure_ascii=False), encoding="utf-8"
    )


SAMPLE_SEGMENTS_SMALL = [
    {"scene_index": 1, "heading": "씬1", "start_char": 0, "end_char": 100, "length": 100},
    {"scene_index": 2, "heading": "씬2", "start_char": 100, "end_char": 200, "length": 100},
]

SAMPLE_SEGMENTS_WITH_LARGE = [
    {"scene_index": 1, "heading": "씬1", "start_char": 0, "end_char": 100, "length": 100},
    {"scene_index": 2, "heading": "큰씬", "start_char": 100, "end_char": 1000, "length": 900},
]


# -- SceneSegmentationStep Tests --
# W3-2: 구현이 `scene_extractor_v2._segment_scenes` 직접 호출에서 `call_structured`로
# LLM이 regex pattern을 반환하는 방식으로 변경됐다 (scene_steps.py:65+).
# 이전 `@patch("_segment_scenes")` 기반 테스트는 삭제하고 아래 3개로 재작성.
# Codex W3-2 H1: happy-path 계약(call_structured → pattern → segments) 검증 필요.


def _long_body(label: str, chars: int = 700) -> str:
    """씬 본문 자리표시 (avg segment length threshold 통과용)."""
    return (f"{label} 본문. " * (chars // 12))[:chars] + "\n"


SAMPLE_FULLTEXT = (
    "\n1. 사무실 - 낮\n" + _long_body("S1", 700) +
    "\n2. 골목 - 밤\n" + _long_body("S2", 700) +
    "\n3. 지하실 - 심야\n" + _long_body("S3", 700)
)

SAMPLE_CLEANED = (
    "\n1. 정리된 씬A - 낮\n" + _long_body("A", 700) +
    "\n2. 정리된 씬B - 밤\n" + _long_body("B", 700)
)


def _make_scene_segmentation_step(fake_db, cleaned_text=None):
    """SceneSegmentationStep 인스턴스 + 필수 속성만 세팅."""
    with patch("app.core.steps.scene_steps.StepRunner.__init__", return_value=None):
        from app.core.steps.scene_steps import SceneSegmentationStep

        step = SceneSegmentationStep.__new__(SceneSegmentationStep)
        step.step_id = "scene_segmentation"
        step.project_id = "proj1"
        step.episode_id = "ep1"
        step.db = fake_db
        step.project_config = {}
        # build_opik_metadata는 opik_context를 읽으므로 수동 세팅
        step.opik_context = {}
        # _load_cleaned_text → text_cleanup 체크포인트가 있으면 사용, 없으면 fulltext
        if cleaned_text is not None:
            step._load_cleaned_text = lambda: cleaned_text
        else:
            step._load_cleaned_text = lambda: SAMPLE_FULLTEXT
        # ProjectSettings 없음 → threshold=600 기본값
        fake_db.query.return_value.filter.return_value.first.return_value = None
        return step


class TestSceneSegmentationStep:

    @staticmethod
    def _reply(*cands):
        """저작 모델의 응답 모양 — 후보를 여럿 낸다.

        각 후보는 정규식 하나이거나 `(정규식, 번호그룹)` 짝이다.
        """
        out = []
        for i, c in enumerate(cands, start=1):
            pattern, group = c if isinstance(c, tuple) else (c, 1)
            out.append({"name": f"후보{i}", "pattern": pattern,
                        "number_group": group, "rationale": "1. 사무실"})
        return {"observed_format": "줄머리 번호", "candidates": out}

    def test_segments_built_from_authored_rule(self, fake_db):
        """저작된 규칙으로 코드가 경계를 찍는다."""
        step = _make_scene_segmentation_step(fake_db)

        with patch("app.modules.llm.llm_client.call_structured") as mock_llm:
            # ★후보를 둘 준다. 하나뿐이면 견줄 상대가 없어 "차이 없음"이
            #  확인된 것이 아니라 아무도 안 본 것이 된다.
            mock_llm.return_value = self._reply(
                r"^(\d+)\. [^\n]+$", (r"^\d+\. .+$", None))
            result = step._execute()

        assert result["data"]["total_scenes"] == 3
        assert len(result["data"]["segments"]) == 3
        first = result["data"]["segments"][0]
        assert first["scene_index"] == 1
        assert "1. 사무실" in first["heading"]
        # 하류가 읽는 자리 — 이 이름들이 바뀌면 씬 저장부터 다 밀린다
        assert set(first) >= {"scene_index", "heading", "start_char",
                              "end_char", "length"}

    def test_uses_cleaned_text_when_available(self, fake_db):
        """text_cleanup 체크포인트가 있으면 그 정리본을 통째로 보낸다."""
        step = _make_scene_segmentation_step(fake_db, cleaned_text=SAMPLE_CLEANED)

        with patch("app.modules.llm.llm_client.call_structured") as mock_llm:
            # ★후보를 둘 준다. 하나뿐이면 견줄 상대가 없어 "차이 없음"이
            #  확인된 것이 아니라 아무도 안 본 것이 된다.
            mock_llm.return_value = self._reply(
                r"^(\d+)\. [^\n]+$", (r"^\d+\. .+$", None))
            step._execute()

        call_kwargs = mock_llm.call_args.kwargs
        user_prompt = call_kwargs.get("user_prompt", "") + str(call_kwargs)
        assert "정리된 씬A" in user_prompt

    def test_retries_when_no_candidate_passes(self, fake_db):
        """계약을 통과한 후보가 없으면 이유를 주고 다시 쓰게 한다."""
        step = _make_scene_segmentation_step(fake_db)

        with patch("app.modules.llm.llm_client.call_structured") as mock_llm:
            mock_llm.side_effect = [
                self._reply(r"^(\d+)\. 없는헤딩$"),      # 아무것도 안 걸린다 → C1
                self._reply(r"^(\d+)\. [^\n]+$",         # 정상 — 견줄 상대까지
                            (r"^\d+\. .+$", None)),
            ]
            result = step._execute()

        assert result["data"]["total_scenes"] == 3
        assert mock_llm.call_count >= 2

    def test_lone_candidate_is_not_confirmed_silently(self, fake_db):
        """통과 후보가 하나뿐이면 "차이 없음"은 확인이 아니라 **아무도 안 본 것**이다.

        ★실측: gemini-flash 저작에서 후보 셋 중 둘이 탈락해 하나만 남았고,
        견줄 상대가 없어 씬 하나를 놓친 규칙이 그대로 확정됐다. gpt 22개는
        전부 통과 후보가 둘 이상이라 이 요구가 정상 대본의 비용을 안 늘린다.
        """
        step = _make_scene_segmentation_step(fake_db)

        with patch("app.modules.llm.llm_client.call_structured") as mock_llm:
            mock_llm.return_value = self._reply(r"^(\d+)\. [^\n]+$")   # 하나뿐
            result = step._execute()

        # 다시 요구했고(상한까지), 끝내 못 구해 "확인 못 했다"를 남긴 채 쓴다 —
        # 여기서 실패시키면 후보가 하나뿐인 대본은 아예 안 돈다.
        assert mock_llm.call_count == step.MAX_RETRY
        assert result["data"]["total_scenes"] == 3

    def test_unresolved_difference_is_not_success(self, fake_db):
        """상한을 다 쓰고도 후보끼리 경계가 갈리면 성공으로 두지 않는다.

        ★코드가 이미 아는 차이를 남긴 채 확정하면 대본 전체가 밀린 채 하류로
        내려간다. 개수만 보면 정상으로 보이는 것이 오늘 찾은 결함 넷의 공통점이다.
        """
        from app.core.errors import AppError

        step = _make_scene_segmentation_step(fake_db)
        # 둘 다 계약은 통과하는데 잡는 자리가 다르다 — 넓은 쪽이 본문까지 잡는다.
        # 매 회차 같은 답을 주므로 차이가 끝내 안 풀린다.
        with patch("app.modules.llm.llm_client.call_structured") as mock_llm:
            mock_llm.return_value = self._reply(
                r"^(\d+)\. [^\n]+$",                    # 헤딩 3개
                (r"^(?:\d+\. .*|S\d .*)$", None),       # 헤딩 3 + 본문 3 = 6개
            )
            with pytest.raises(AppError) as got:
                step._execute()

        assert "segmentation_failed" in got.value.code
        assert mock_llm.call_count == step.MAX_RETRY


# -- SceneSplitStep Tests --

class TestSceneSplitStep:

    def _make_step(self, fake_db, checkpoints=None):
        """SceneSplitStep 인스턴스를 생성하는 헬퍼."""
        with patch("app.core.steps.scene_steps.StepRunner.__init__", return_value=None):
            from app.core.steps.scene_steps import SceneSplitStep

            step = SceneSplitStep.__new__(SceneSplitStep)
            step.step_id = "scene_split"
            step.project_id = "proj1"
            step.episode_id = "ep1"
            step.db = fake_db
            step.project_config = {}
            step.manifest = {"applicability": "if_large_scenes"}

            # _load_prev_checkpoint를 dict 매핑으로 모킹
            if checkpoints:
                step._load_prev_checkpoint = lambda sid: checkpoints.get(sid)
            else:
                step._load_prev_checkpoint = lambda sid: None

            return step

    def test_check_applicability_false_when_no_large_scenes(self, fake_db):
        """모든 씬이 threshold 이하면 False."""
        checkpoints = {
            "scene_segmentation": {
                "data": {"segments": SAMPLE_SEGMENTS_SMALL}
            }
        }
        step = self._make_step(fake_db, checkpoints)
        # ProjectSettings 없음 -> threshold=600
        fake_db.query.return_value.filter.return_value.first.return_value = None

        assert step.check_applicability() is False

    def test_check_applicability_true_when_large_scenes(self, fake_db):
        """threshold 초과 씬이 있으면 True."""
        checkpoints = {
            "scene_segmentation": {
                "data": {"segments": SAMPLE_SEGMENTS_WITH_LARGE}
            }
        }
        step = self._make_step(fake_db, checkpoints)
        fake_db.query.return_value.filter.return_value.first.return_value = None

        assert step.check_applicability() is True

    def test_check_applicability_false_when_no_checkpoint(self, fake_db):
        """scene_segmentation 체크포인트가 없으면 False."""
        step = self._make_step(fake_db, checkpoints={})
        assert step.check_applicability() is False

    @patch("app.modules.pipeline.scene_extractor_v2._split_large_scene_by_llm")
    def test_execute_splits_large_scenes(self, mock_split, fake_db):
        """큰 씬이 분할되어 final_segments에 포함."""
        sub_a = {"scene_index": 0, "heading": "큰씬 (파트A)", "start_char": 100, "end_char": 550, "length": 450}
        sub_b = {"scene_index": 0, "heading": "큰씬 (파트B)", "start_char": 550, "end_char": 1000, "length": 450}
        mock_split.return_value = [sub_a, sub_b]

        checkpoints = {
            "scene_segmentation": {
                "data": {"segments": SAMPLE_SEGMENTS_WITH_LARGE}
            }
        }
        step = self._make_step(fake_db, checkpoints)
        step._load_cleaned_text = lambda: "x" * 1000

        fake_db.query.return_value.filter.return_value.first.return_value = None

        result = step._execute()

        mock_split.assert_called_once()
        # 1 small + 2 split = 3 total
        assert result["data"]["total_scenes"] == 3
        assert result["data"]["splits"] == 1
        # Re-indexed
        assert result["data"]["segments"][0]["scene_index"] == 1
        assert result["data"]["segments"][1]["scene_index"] == 2
        assert result["data"]["segments"][2]["scene_index"] == 3

    @patch("app.modules.pipeline.scene_extractor_v2._split_large_scene_by_llm")
    def test_execute_fallback_on_split_error(self, mock_split, fake_db):
        """분할 실패 시 원본 씬을 유지."""
        mock_split.side_effect = RuntimeError("LLM error")

        checkpoints = {
            "scene_segmentation": {
                "data": {"segments": SAMPLE_SEGMENTS_WITH_LARGE}
            }
        }
        step = self._make_step(fake_db, checkpoints)
        step._load_cleaned_text = lambda: "x" * 1000

        fake_db.query.return_value.filter.return_value.first.return_value = None

        result = step._execute()

        # 분할 실패 -> 원본 2개 유지
        assert result["data"]["total_scenes"] == 2
        assert result["data"]["splits"] == 0


# -- SceneSaveStep Tests --

class TestSceneSaveStep:

    def _make_step(self, fake_db, checkpoints=None):
        """SceneSaveStep 인스턴스를 생성하는 헬퍼."""
        with patch("app.core.steps.scene_steps.StepRunner.__init__", return_value=None):
            from app.core.steps.scene_steps import SceneSaveStep

            step = SceneSaveStep.__new__(SceneSaveStep)
            step.step_id = "scene_save"
            step.project_id = "proj1"
            step.episode_id = "ep1"
            step.db = fake_db
            step.project_config = {}

            if checkpoints:
                step._load_prev_checkpoint = lambda sid: checkpoints.get(sid)
            else:
                step._load_prev_checkpoint = lambda sid: None

            return step

    def test_scene_segmentation_wins_over_scene_split(self, fake_db):
        """v4 계약: scene_split과 scene_segmentation 모두 populate돼도 scene_segmentation 사용.

        Codex W3-2 M4: 이전 test_picks_scene_split_over_segmentation을 반대 계약으로 재작성.
        scene_split은 on_demand 레거시로 유지되지만 SceneSaveStep의 기본 입력은 segmentation.
        """
        split_segments = [
            {"scene_index": 1, "heading": "분할씬A", "start_char": 0, "end_char": 100, "length": 100},
            {"scene_index": 2, "heading": "분할씬B", "start_char": 100, "end_char": 200, "length": 100},
            {"scene_index": 3, "heading": "분할씬C", "start_char": 200, "end_char": 300, "length": 100},
        ]
        checkpoints = {
            "scene_split": {"data": {"segments": split_segments}},
            "scene_segmentation": {"data": {"segments": SAMPLE_SEGMENTS_SMALL}},
        }
        step = self._make_step(fake_db, checkpoints)
        # _load_cleaned_text는 segment.text 주입용 fulltext 제공
        step._load_cleaned_text = lambda: "x" * 1000

        result = step._execute()

        # scene_segmentation(2 segments) 사용 — scene_split(3) 무시
        assert result["data"]["total_scenes"] == 2
        assert result["data"]["segments"][0]["heading"] == SAMPLE_SEGMENTS_SMALL[0]["heading"]

    def test_falls_back_to_segmentation(self, fake_db):
        """scene_split이 없으면 scene_segmentation을 사용."""
        checkpoints = {
            "scene_segmentation": {"data": {"segments": SAMPLE_SEGMENTS_SMALL}},
        }
        step = self._make_step(fake_db, checkpoints)

        result = step._execute()

        assert result["data"]["segments"] == SAMPLE_SEGMENTS_SMALL
        assert result["data"]["total_scenes"] == 2

    def test_falls_back_when_split_has_empty_segments(self, fake_db):
        """scene_split의 segments가 빈 배열이면 scene_segmentation으로 폴백."""
        checkpoints = {
            "scene_split": {"data": {"segments": []}},
            "scene_segmentation": {"data": {"segments": SAMPLE_SEGMENTS_SMALL}},
        }
        step = self._make_step(fake_db, checkpoints)

        result = step._execute()

        assert result["data"]["segments"] == SAMPLE_SEGMENTS_SMALL
        assert result["data"]["total_scenes"] == 2

    def test_raises_when_no_input(self, fake_db):
        """체크포인트가 전혀 없으면 AppError."""
        step = self._make_step(fake_db, checkpoints={})

        from app.core.errors import AppError

        with pytest.raises(AppError) as exc_info:
            step._execute()
        assert exc_info.value.code == "step.no_input"

    def test_counts_match_segment_count(self, fake_db):
        """completed_count와 applicable_count가 segments 수와 일치."""
        checkpoints = {
            "scene_segmentation": {"data": {"segments": SAMPLE_SEGMENTS_SMALL}},
        }
        step = self._make_step(fake_db, checkpoints)

        result = step._execute()

        assert result["completed_count"] == 2
        assert result["applicable_count"] == 2
        assert result["failed_count"] == 0
