"""E2E integration test for Pipeline v3 — 20 analysis steps with mocked LLM calls.

Tests:
1. Step ordering (all 20 analysis steps in correct order)
2. Dependency graph (unmet deps raise gate.blocked)
3. STEP_CLASSES completeness (all 24 manifest step_ids covered)
4. Checkpoint flow (3-step mini-pipeline with checkpoint I/O)
5. Resume mode (completed steps are skipped)
6. Force mode (downstream invalidation)
7. V/A/H fields in manifest (scene_director outputs)
8. Sync integration (_sync_checkpoints_to_db writes V/A/H to scene_still)
"""

import json
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from sqlalchemy import text as sql_text


# ── Helpers ──


def _make_checkpoint(base_dir, project_id, episode_id, step_id, data, status="completed"):
    """Create a checkpoint manifest.json file."""
    cp_dir = (
        Path(base_dir) / project_id / "checkpoints" / "episodes"
        / episode_id / step_id
    )
    cp_dir.mkdir(parents=True, exist_ok=True)
    manifest = {"status": status, "data": data}
    (cp_dir / "manifest.json").write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
    )


def _create_step_run_table(engine):
    """Create step_run table (not a SQLAlchemy model, raw SQL only).

    NOTE: 컬럼 set는 alembic 003 (Task 1) 이후 production schema와 일치해야 한다.
    recovery_count + last_recovery_reason은 StepRunner._reset_recovery_counter /
    _record_recovery 가 직접 raw SQL UPDATE로 만지므로 누락 시 e2e가 깨진다.
    """
    with engine.connect() as conn:
        conn.execute(sql_text("""
            CREATE TABLE IF NOT EXISTS step_run (
                id TEXT PRIMARY KEY,
                project_id TEXT NOT NULL,
                episode_id TEXT NOT NULL,
                step_id TEXT NOT NULL,
                status TEXT NOT NULL DEFAULT 'pending',
                run_id TEXT,
                resolved_model TEXT,
                applicable_count INTEGER DEFAULT 0,
                completed_count INTEGER DEFAULT 0,
                failed_count INTEGER DEFAULT 0,
                error_message TEXT,
                result_summary TEXT,
                started_at TEXT,
                completed_at TEXT,
                created_at TEXT,
                updated_at TEXT,
                recovery_count INTEGER NOT NULL DEFAULT 0,
                last_recovery_reason TEXT,
                UNIQUE(project_id, episode_id, step_id)
            )
        """))
        conn.commit()


def _setup_db_parents(db, pid, eid, uid):
    """Insert parent rows needed for FK constraints (user, project, episode).

    PG-only (Block C 후속): INSERT OR IGNORE (sqlite) → ON CONFLICT DO NOTHING.
    """
    db.execute(sql_text(
        "INSERT INTO user_account (id, username, display_name, password_hash, role, is_active, created_at, updated_at) "
        "VALUES (:id, :un, :dn, :ph, :role, 1, :ca, :ua) "
        "ON CONFLICT (id) DO NOTHING"
    ), {"id": uid, "un": f"tester-{uid[-8:]}", "dn": "Tester", "ph": "hash",
        "role": "admin", "ca": "2026-01-01", "ua": "2026-01-01"})
    db.execute(sql_text(
        "INSERT INTO project_registry (id, name, created_by, created_at, updated_at) "
        "VALUES (:id, :name, :cb, :ca, :ua) "
        "ON CONFLICT (id) DO NOTHING"
    ), {"id": pid, "name": f"test-project-{pid[-8:]}", "cb": uid,
        "ca": "2026-01-01", "ua": "2026-01-01"})
    db.execute(sql_text(
        "INSERT INTO episode (id, project_id, episode_number, title, source_filename, source_path, status, created_at, updated_at) "
        "VALUES (:id, :pid, :en, :title, :sf, :sp, :status, :ca, :ua) "
        "ON CONFLICT (id) DO NOTHING"
    ), {
        "id": eid, "pid": pid, "en": 1, "title": "ep1",
        "sf": "test.txt", "sp": "/tmp/test.txt", "status": "pending",
        "ca": "2026-01-01", "ua": "2026-01-01",
    })
    db.commit()


def _mark_step_completed(db, pid, eid, step_id):
    """Insert a completed step_run row."""
    now = datetime.now(timezone.utc).isoformat()
    db.execute(sql_text("""
        INSERT INTO step_run (id, project_id, episode_id, step_id, status,
            completed_count, applicable_count, created_at, updated_at)
        VALUES (:id, :pid, :eid, :sid, 'completed', 1, 1, :now, :now)
        ON CONFLICT (project_id, episode_id, step_id) DO UPDATE SET
            status = 'completed', updated_at = :now
    """), {"id": str(uuid.uuid4()), "pid": pid, "eid": eid, "sid": step_id, "now": now})
    db.commit()


# ── Fixtures ──


@pytest.fixture()
def e2e_env(tmp_path, pg_engine, pg_session):
    """Full PG test environment + tmp projects dir.

    Block C 후속 (사용자 결정 — 모든 test PG 통일): sqlite in-memory ad-hoc
    fixture 제거. conftest_pg 의 pg_engine / pg_session 으로 통일. pid/eid 는
    매 test 마다 unique uuid suffix 로 격리 (cleanup 부담 최소).
    """
    proj_dir = str(tmp_path / "projects")
    suffix = uuid.uuid4().hex[:8]
    pid = f"proj-e2e-{suffix}"
    eid = f"ep-e2e-{suffix}"
    uid = f"test-user-{suffix}"

    _create_step_run_table(pg_engine)  # idempotent (IF NOT EXISTS)
    _setup_db_parents(pg_session, pid, eid, uid)

    yield {
        "engine": pg_engine,
        "db": pg_session,
        "proj_dir": proj_dir,
        "pid": pid,
        "eid": eid,
    }

    # cleanup — 본 test 가 만든 row 만 삭제 (다른 test 격리, FK 순서 준수).
    try:
        pg_session.rollback()  # 진행 중 transaction 정리
    except Exception:
        pass
    for sql in (
        "DELETE FROM step_run WHERE project_id=:pid",
        "DELETE FROM episode WHERE project_id=:pid",
        "DELETE FROM project_registry WHERE id=:pid",
        "DELETE FROM user_account WHERE id=:uid",
    ):
        try:
            params = {"pid": pid} if ":pid" in sql else {"uid": uid}
            pg_session.execute(sql_text(sql), params)
        except Exception:
            pg_session.rollback()
    pg_session.commit()


# ── 1. Test Step Ordering ──
# W3-2: v3 20-step 하드코딩(EXPECTED_ANALYSIS_ORDER)은 manifest가 v4/shot-more로 확장되어
# 의미를 잃었다. 계약 수준 검증(order 단조성, analysis→image 경계)만 남긴다.
# 총량/순서 baseline은 `docs/architecture/_step_manifest.generated.md` 참조.


class TestStepOrdering:
    """Manifest 전체 ordering 계약을 검증."""

    def test_analysis_orders_are_monotonic(self):
        """analysis category 모든 step의 order가 중복 없이 증가한다."""
        from app.core.step_manifest import STEP_MANIFEST

        analysis_orders = sorted(
            info["order"]
            for info in STEP_MANIFEST.values()
            if info["category"] == "analysis"
        )
        assert len(analysis_orders) == len(set(analysis_orders)), \
            f"duplicate order in analysis category: {analysis_orders}"

    def test_image_steps_after_analysis(self):
        """scene-level image step은 모든 scene-level analysis step 뒤에 온다.

        Phase 7 background pipeline (2026-04-29)은 의도적으로 image와 analysis가
        interleave된다 — floor_plan_render(21.55, image)가 background_prompt(21.6,
        analysis)의 입력으로 쓰이고, background_render(24.72, image)가 그 결과를
        쓴다. 이 mini-DAG는 background pipeline 내부에서만 유효하고, scene-level
        파이프라인 전체로 보면 background pipeline 결과 뒤에 scene_image_pipeline
        등이 와야 한다는 글로벌 invariant은 여전히 유지된다.
        """
        from app.core.step_manifest import STEP_MANIFEST, get_ordered_steps

        # Phase 7/5 background pipeline 자체 mini-DAG는 제외 (intentional interleave).
        # 새 background step이 추가될 때 자동으로 따라가도록 manifest에서 유도:
        #   - active: applicability == "if_background_mode" (Phase 7)
        #   - deprecated background pipeline: lifecycle="deprecated" + 이름에 background/floor_plan
        background_pipeline = {
            sid for sid, s in STEP_MANIFEST.items()
            if s.get("applicability") == "if_background_mode"
            or (s.get("lifecycle") == "deprecated"
                and ("background" in sid or "floor_plan" in sid))
        }
        ordered = [s for s in get_ordered_steps() if s["step_id"] not in background_pipeline]
        last_analysis_order = max(
            s["order"] for s in ordered if s["category"] == "analysis"
        )
        first_image_order = min(
            s["order"] for s in ordered if s["category"] == "image"
        )
        assert first_image_order > last_analysis_order


# ── 2. Test Dependency Graph ──


class TestDependencyGraph:
    """Verify unmet dependencies raise AppError with code gate.blocked."""

    def test_scene_segmentation_blocked_without_text_cleanup(self, e2e_env):
        """scene_segmentation depends on text_cleanup; blocked if not completed."""
        from app.core.errors import AppError

        db = e2e_env["db"]
        pid, eid = e2e_env["pid"], e2e_env["eid"]

        with patch("app.core.config.settings") as mock_s:
            mock_s.projects_dir = e2e_env["proj_dir"]

            from app.core.steps.scene_steps import SceneSegmentationStep

            runner = SceneSegmentationStep(
                step_id="scene_segmentation",
                project_id=pid, episode_id=eid, db=db,
            )
            with pytest.raises(AppError) as exc_info:
                runner.check_gate()
            assert exc_info.value.code == "gate.blocked"

    def test_scene_segmentation_passes_with_text_cleanup_completed(self, e2e_env):
        """scene_segmentation passes gate when text_cleanup is completed."""
        db = e2e_env["db"]
        pid, eid = e2e_env["pid"], e2e_env["eid"]

        _mark_step_completed(db, pid, eid, "text_cleanup")

        with patch("app.core.config.settings") as mock_s:
            mock_s.projects_dir = e2e_env["proj_dir"]

            from app.core.steps.scene_steps import SceneSegmentationStep

            runner = SceneSegmentationStep(
                step_id="scene_segmentation",
                project_id=pid, episode_id=eid, db=db,
            )
            # Should not raise
            runner.check_gate()

    def test_scene_detail_blocked_without_all_deps(self, e2e_env):
        """scene_detail: depends_on 중 하나라도 미완료면 gate.blocked.

        W3-2: v3 시절 `len(deps) == 4` 하드코딩은 삭제. 현재 manifest에 선언된
        deps가 N개이든, 마지막 하나를 미완료로 두면 gate가 막혀야 한다.
        """
        from app.core.errors import AppError
        from app.core.step_manifest import get_depends_on

        db = e2e_env["db"]
        pid, eid = e2e_env["pid"], e2e_env["eid"]

        deps = get_depends_on("scene_detail")
        assert len(deps) >= 1, f"scene_detail should have at least 1 dep, got {deps}"

        # 마지막 하나 빼고 모두 완료
        for dep in deps[:-1]:
            _mark_step_completed(db, pid, eid, dep)

        with patch("app.core.config.settings") as mock_s:
            mock_s.projects_dir = e2e_env["proj_dir"]

            from app.core.steps.detail_steps import SceneDetailStep

            runner = SceneDetailStep(
                step_id="scene_detail",
                project_id=pid, episode_id=eid, db=db,
            )
            with pytest.raises(AppError) as exc_info:
                runner.check_gate()
            assert exc_info.value.code == "gate.blocked"

    def test_text_cleanup_has_no_gate(self, e2e_env):
        """text_cleanup has no dependencies, gate always passes."""
        db = e2e_env["db"]
        pid, eid = e2e_env["pid"], e2e_env["eid"]

        with patch("app.core.config.settings") as mock_s:
            mock_s.projects_dir = e2e_env["proj_dir"]

            from app.core.steps.text_steps import TextCleanupStep

            runner = TextCleanupStep(
                step_id="text_cleanup",
                project_id=pid, episode_id=eid, db=db,
            )
            # Should not raise
            runner.check_gate()


# ── 3. Test STEP_CLASSES Completeness ──


class TestStepClassesCompleteness:
    """Verify all 24 manifest step_ids have a registered class."""

    def test_all_manifest_ids_have_class_or_legacy(self):
        """STEP_MANIFEST의 active/on_demand step 전부에 class가 등록돼 있다.

        W3-2: lifecycle=removed인 step(예: project_summary)은 의도적으로 class
        미구현(UI legacy 표시 목적). 이 필터를 반영.
        """
        from app.core.step_manifest import STEP_MANIFEST
        from app.core.steps import STEP_CLASSES

        # Legacy fallback (same as _get_step_runner logic)
        try:
            from app.core.steps.analysis_steps import ANALYSIS_STEP_CLASSES
        except ImportError:
            ANALYSIS_STEP_CLASSES = {}
        try:
            from app.core.steps.image_steps import IMAGE_STEP_CLASSES
        except ImportError:
            IMAGE_STEP_CLASSES = {}

        all_classes = {**ANALYSIS_STEP_CLASSES, **IMAGE_STEP_CLASSES, **STEP_CLASSES}

        missing = []
        for step_id, info in STEP_MANIFEST.items():
            if info.get("lifecycle") == "removed":
                continue
            if step_id not in all_classes:
                missing.append(step_id)

        assert missing == [], f"Manifest step_ids without class: {missing}"

    def test_step_classes_count_at_least_19(self):
        """STEP_CLASSES has at least 19 entries (18 analysis + outlook_dedup)."""
        from app.core.steps import STEP_CLASSES

        assert len(STEP_CLASSES) >= 19, f"Expected >= 19, got {len(STEP_CLASSES)}"

    def test_all_classes_inherit_step_runner(self):
        """Every value in STEP_CLASSES is a StepRunner subclass."""
        from app.core.steps import STEP_CLASSES
        from app.core.step_runner import StepRunner

        for key, cls in STEP_CLASSES.items():
            assert issubclass(cls, StepRunner), f"{key} -> {cls} is not StepRunner subclass"


# ── 4. Test Checkpoint Flow ──


class TestCheckpointFlow:
    """Mock a 3-step flow: text_cleanup -> scene_segmentation -> scene_save.

    Verify checkpoints are created after each step, and scene_save can load
    scene_segmentation checkpoint.
    """

    def test_checkpoint_creation_after_step(self, e2e_env):
        """Running a step creates a checkpoint manifest.json."""
        db = e2e_env["db"]
        pid, eid = e2e_env["pid"], e2e_env["eid"]
        proj_dir = e2e_env["proj_dir"]

        with patch("app.core.config.settings") as mock_s:
            mock_s.projects_dir = proj_dir

            from app.core.steps.text_steps import TextCleanupStep

            runner = TextCleanupStep(
                step_id="text_cleanup",
                project_id=pid, episode_id=eid, db=db,
            )

            # Mock _execute to return a result without actual LLM call
            def fake_execute(mode="resume"):
                return {
                    "completed_count": 1,
                    "applicable_count": 1,
                    "failed_count": 0,
                    "data": {"cleaned_text": "INT. OFFICE - DAY\nHello world."},
                }

            runner._execute = fake_execute

            # Mock _resolve_model to avoid LLM router init
            with patch.object(runner, "_resolve_model", return_value="test-model"):
                with patch("app.modules.llm.llm_client.set_opik_context"):
                    result = runner.run(mode="resume")

            assert result["status"] == "completed"

            # Verify checkpoint file exists
            cp_path = (
                Path(proj_dir) / pid / "checkpoints" / "episodes"
                / eid / "text_cleanup" / "manifest.json"
            )
            assert cp_path.exists(), "text_cleanup checkpoint not created"

            cp_data = json.loads(cp_path.read_text(encoding="utf-8"))
            assert cp_data["status"] == "completed"
            assert cp_data["data"]["cleaned_text"] == "INT. OFFICE - DAY\nHello world."

    def test_three_step_checkpoint_chain(self, e2e_env):
        """Run text_cleanup -> scene_segmentation -> scene_save with checkpoints."""
        db = e2e_env["db"]
        pid, eid = e2e_env["pid"], e2e_env["eid"]
        proj_dir = e2e_env["proj_dir"]

        with patch("app.core.config.settings") as mock_s:
            mock_s.projects_dir = proj_dir

            # Step 1: text_cleanup
            from app.core.steps.text_steps import TextCleanupStep

            step1 = TextCleanupStep(
                step_id="text_cleanup",
                project_id=pid, episode_id=eid, db=db,
            )
            step1._execute = lambda mode="resume": {
                "completed_count": 1, "applicable_count": 1, "failed_count": 0,
                "data": {"cleaned_text": "cleaned text content"},
            }
            with patch.object(step1, "_resolve_model", return_value="test"):
                with patch("app.modules.llm.llm_client.set_opik_context"):
                    step1.run(mode="resume")

            # Step 2: scene_segmentation (depends on text_cleanup)
            from app.core.steps.scene_steps import SceneSegmentationStep

            step2 = SceneSegmentationStep(
                step_id="scene_segmentation",
                project_id=pid, episode_id=eid, db=db,
            )
            segments = [
                {"scene_index": 1, "heading": "S1", "start_char": 0, "end_char": 50, "length": 50},
                {"scene_index": 2, "heading": "S2", "start_char": 50, "end_char": 100, "length": 50},
            ]
            step2._execute = lambda mode="resume": {
                "completed_count": 1, "applicable_count": 1, "failed_count": 0,
                "data": {"segments": segments, "total_scenes": 2},
            }
            with patch.object(step2, "_resolve_model", return_value="test"):
                with patch("app.modules.llm.llm_client.set_opik_context"):
                    step2.run(mode="resume")

            # Step 3: scene_save (depends on scene_split which depends on scene_segmentation)
            # For scene_save, we need scene_split to be completed or not_applicable
            _mark_step_completed(db, pid, eid, "scene_split")

            from app.core.steps.scene_steps import SceneSaveStep

            step3 = SceneSaveStep(
                step_id="scene_save",
                project_id=pid, episode_id=eid, db=db,
            )

            with patch.object(step3, "_resolve_model", return_value="test"):
                with patch("app.modules.llm.llm_client.set_opik_context"):
                    result3 = step3.run(mode="resume")

            assert result3["status"] == "completed"

            # scene_save should have loaded scene_segmentation checkpoint
            cp_path = (
                Path(proj_dir) / pid / "checkpoints" / "episodes"
                / eid / "scene_save" / "manifest.json"
            )
            assert cp_path.exists()
            cp_data = json.loads(cp_path.read_text(encoding="utf-8"))
            assert cp_data["data"]["total_scenes"] == 2
            assert len(cp_data["data"]["segments"]) == 2

    # W3-2: test_scene_save_loads_segmentation_checkpoint 삭제 —
    # v3에서는 scene_save가 scene_segmentation을 소비했지만 v4에서는 scene_split 단계가
    # 제거되고 scene_save가 직접 segmentation 결과를 저장한다. 관련 계약은
    # tests/services/test_checkpoint_sync_services.py 및 test_scene_steps_v3.py에서 검증.


# ── 5. Test Resume Mode ──


class TestResumeMode:
    """Verify completed steps are skipped in resume mode."""

    def test_completed_step_is_skipped(self, e2e_env):
        """If a step is already completed AND checkpoint is intact, resume returns skipped.

        D1 (Task 10) 이후 완전한 "completed" 상태는 step_run.status='completed'와
        checkpoint manifest 양쪽이 모두 갖춰져야 한다. 한쪽이 누락되면 force로
        격상되어 자동 재실행한다 (PID 0bb48ebf 사고 가드).
        """
        db = e2e_env["db"]
        pid, eid = e2e_env["pid"], e2e_env["eid"]
        proj_dir = e2e_env["proj_dir"]

        # D1 contract: status=completed + cp 양쪽 정합 = skipped.
        # cp 누락 시 force-rerun으로 격상되어 _execute 호출됨.
        _mark_step_completed(db, pid, eid, "text_cleanup")
        _make_checkpoint(proj_dir, pid, eid, "text_cleanup", {"cleaned_text": "stub"})

        with patch("app.core.config.settings") as mock_s:
            mock_s.projects_dir = proj_dir

            from app.core.steps.text_steps import TextCleanupStep

            runner = TextCleanupStep(
                step_id="text_cleanup",
                project_id=pid, episode_id=eid, db=db,
            )
            # _execute should NOT be called
            runner._execute = lambda mode="resume": pytest.fail("_execute should not be called in resume when already completed")

            with patch.object(runner, "_resolve_model", return_value="test"):
                with patch("app.modules.llm.llm_client.set_opik_context"):
                    result = runner.run(mode="resume")

            assert result["status"] == "skipped"
            assert result["reason"] == "already completed"

    def test_pending_step_runs_in_resume(self, e2e_env):
        """If a step is pending, resume mode runs it."""
        db = e2e_env["db"]
        pid, eid = e2e_env["pid"], e2e_env["eid"]
        proj_dir = e2e_env["proj_dir"]

        with patch("app.core.config.settings") as mock_s:
            mock_s.projects_dir = proj_dir

            from app.core.steps.text_steps import TextCleanupStep

            runner = TextCleanupStep(
                step_id="text_cleanup",
                project_id=pid, episode_id=eid, db=db,
            )
            executed = {"called": False}

            def fake_execute(mode="resume"):
                executed["called"] = True
                return {
                    "completed_count": 1, "applicable_count": 1, "failed_count": 0,
                    "data": {"cleaned_text": "test"},
                }

            runner._execute = fake_execute

            with patch.object(runner, "_resolve_model", return_value="test"):
                with patch("app.modules.llm.llm_client.set_opik_context"):
                    result = runner.run(mode="resume")

            assert executed["called"], "_execute was not called for pending step"
            assert result["status"] == "completed"


# ── 6. Test Force Mode ──


class TestForceMode:
    """Verify force mode invalidates downstream steps."""

    def test_force_invalidates_downstream(self, e2e_env):
        """Force mode marks downstream steps as stale and deletes checkpoints."""
        db = e2e_env["db"]
        pid, eid = e2e_env["pid"], e2e_env["eid"]
        proj_dir = e2e_env["proj_dir"]

        # Complete text_cleanup and scene_segmentation
        _mark_step_completed(db, pid, eid, "text_cleanup")
        _mark_step_completed(db, pid, eid, "scene_segmentation")

        # Create scene_segmentation checkpoint
        _make_checkpoint(proj_dir, pid, eid, "scene_segmentation", {
            "segments": [{"scene_index": 1}], "total_scenes": 1,
        })

        with patch("app.core.config.settings") as mock_s:
            mock_s.projects_dir = proj_dir

            from app.core.steps.text_steps import TextCleanupStep

            runner = TextCleanupStep(
                step_id="text_cleanup",
                project_id=pid, episode_id=eid, db=db,
            )
            runner._execute = lambda mode="resume": {
                "completed_count": 1, "applicable_count": 1, "failed_count": 0,
                "data": {"cleaned_text": "forced re-run"},
            }

            with patch.object(runner, "_resolve_model", return_value="test"):
                with patch("app.modules.llm.llm_client.set_opik_context"):
                    result = runner.run(mode="force")

            assert result["status"] == "completed"

            # scene_segmentation should be invalidated (stale)
            row = db.execute(sql_text(
                "SELECT status FROM step_run "
                "WHERE project_id = :pid AND episode_id = :eid AND step_id = 'scene_segmentation'"
            ), {"pid": pid, "eid": eid}).fetchone()
            assert row is not None
            assert row[0] == "stale", f"Expected stale, got {row[0]}"

            # scene_segmentation checkpoint should be deleted
            seg_cp = (
                Path(proj_dir) / pid / "checkpoints" / "episodes"
                / eid / "scene_segmentation" / "manifest.json"
            )
            assert not seg_cp.exists(), "scene_segmentation checkpoint should be deleted after force"

    def test_force_reruns_completed_step(self, e2e_env):
        """Force mode re-executes even if step is already completed."""
        db = e2e_env["db"]
        pid, eid = e2e_env["pid"], e2e_env["eid"]
        proj_dir = e2e_env["proj_dir"]

        _mark_step_completed(db, pid, eid, "text_cleanup")

        with patch("app.core.config.settings") as mock_s:
            mock_s.projects_dir = proj_dir

            from app.core.steps.text_steps import TextCleanupStep

            runner = TextCleanupStep(
                step_id="text_cleanup",
                project_id=pid, episode_id=eid, db=db,
            )
            executed = {"called": False}

            def fake_execute(mode="resume"):
                executed["called"] = True
                return {
                    "completed_count": 1, "applicable_count": 1, "failed_count": 0,
                    "data": {"cleaned_text": "forced"},
                }

            runner._execute = fake_execute

            with patch.object(runner, "_resolve_model", return_value="test"):
                with patch("app.modules.llm.llm_client.set_opik_context"):
                    runner.run(mode="force")

            assert executed["called"], "_execute should run in force mode even when completed"


# ── 7. Test V/A/H in Manifest ──


class TestVAHManifest:
    """Verify scene_director outputs audio_entity_ids and hallucination_entity_ids."""

    # W3-2: test_scene_director_manifest_label 삭제 —
    # v3 시절 label에 "V/A/H"를 포함하도록 강제했으나 현재 label은
    # "씬 감독 (물리적 존재)"로 변경됨. label 텍스트는 계약이 아니라 UI 표기.

    def test_scene_still_model_has_vah_columns(self):
        """SceneStill model has audio_entity_ids and hallucination_entity_ids columns."""
        from app.models.project import SceneStill

        columns = {c.name for c in SceneStill.__table__.columns}
        assert "audio_entity_ids" in columns
        assert "hallucination_entity_ids" in columns

    def test_scene_director_checkpoint_has_vah_fields(self, tmp_path):
        """scene_director checkpoint data contains audio/hallucination entity IDs."""
        proj_dir = str(tmp_path / "projects")
        pid, eid = "proj-vah", "ep-vah"

        director_data = {
            "scenes": [
                {
                    "scene_index": 1,
                    "visual_entity_ids": ["C01", "L01"],
                    "audio_entity_ids": ["C03"],
                    "hallucination_entity_ids": [],
                },
                {
                    "scene_index": 2,
                    "visual_entity_ids": ["C01"],
                    "audio_entity_ids": [],
                    "hallucination_entity_ids": ["C02"],
                },
            ]
        }
        _make_checkpoint(proj_dir, pid, eid, "scene_director", director_data)

        cp_path = (
            Path(proj_dir) / pid / "checkpoints" / "episodes"
            / eid / "scene_director" / "manifest.json"
        )
        cp = json.loads(cp_path.read_text(encoding="utf-8"))
        scenes = cp["data"]["scenes"]

        assert scenes[0]["audio_entity_ids"] == ["C03"]
        assert scenes[0]["hallucination_entity_ids"] == []
        assert scenes[1]["audio_entity_ids"] == []
        assert scenes[1]["hallucination_entity_ids"] == ["C02"]


# ── 8. Test Sync Integration ──


class TestSyncIntegration:
    """Verify _sync_checkpoints_to_db loads scene_director checkpoint and writes V/A/H."""

    @pytest.mark.skip(reason="W3-3 Codex High 수용 — v3 _sync_checkpoints_to_db 경로 drift. scene_director V/A/H → scene_still.audio_entity_ids/hallucination_entity_ids projection 계약은 production (scene_still_sync_service.py:166) 유효하지만, shot-based 재작성 narrow test는 entity_canon 등 다수 테이블 의존으로 W5 cluster D와 함께 일괄 복원 예정. docs/review-codex-1/11-fix-plan.md §8.2.")
    def test_sync_writes_vah_to_scene_still(self, e2e_env):
        """_sync_checkpoints_to_db writes audio/hallucination from scene_director to scene_still."""
        db = e2e_env["db"]
        pid, eid = e2e_env["pid"], e2e_env["eid"]
        proj_dir = e2e_env["proj_dir"]

        # Create scene_detail checkpoint (needed to create scene_still rows)
        _make_checkpoint(proj_dir, pid, eid, "scene_detail", {
            "scenes": [
                {"scene_index": 1, "heading": "S1", "representative_moment": "rm1", "visible_entities": [], "t2i_prompt": "p1"},
                {"scene_index": 2, "heading": "S2", "representative_moment": "rm2", "visible_entities": [], "t2i_prompt": "p2"},
            ]
        })

        # Mark scene_detail step as completed in step_run
        _mark_step_completed(db, pid, eid, "scene_detail")

        # Create scene_director checkpoint with V/A/H data
        _make_checkpoint(proj_dir, pid, eid, "scene_director", {
            "scenes": [
                {"scene_index": 1, "audio_entity_ids": ["C02", "C03"], "hallucination_entity_ids": []},
                {"scene_index": 2, "audio_entity_ids": [], "hallucination_entity_ids": ["C01"]},
            ]
        })

        with patch("app.core.config.settings") as mock_s:
            mock_s.projects_dir = proj_dir

            from app.api.v1.steps import _sync_checkpoints_to_db
            _sync_checkpoints_to_db(pid, eid, db)

            # Verify scene_still rows were created with V/A/H
            row1 = db.execute(sql_text(
                "SELECT audio_entity_ids, hallucination_entity_ids FROM scene_still "
                "WHERE project_id = :pid AND episode_id = :eid AND still_index = 1"
            ), {"pid": pid, "eid": eid}).fetchone()
            assert row1 is not None
            assert json.loads(row1[0]) == ["C02", "C03"]
            assert json.loads(row1[1]) == []

            row2 = db.execute(sql_text(
                "SELECT audio_entity_ids, hallucination_entity_ids FROM scene_still "
                "WHERE project_id = :pid AND episode_id = :eid AND still_index = 2"
            ), {"pid": pid, "eid": eid}).fetchone()
            assert row2 is not None
            assert json.loads(row2[0]) == []
            assert json.loads(row2[1]) == ["C01"]

    def test_sync_updates_episode_status(self, e2e_env):
        """_sync_checkpoints_to_db sets episode status to 'analyzed'.

        problems.md #12 (commit fe442ec) strict projection 도입 후: active analysis
        step 전부 completed/not_applicable 일 때만 advance. fixture 가 step_run 을
        만들지 않으므로 본 test 가 contract 충족하려면 mark 필요. not_applicable
        분류는 production 로직이 자동 처리하므로 일괄 mark 잉여 OK.
        """
        from app.core.step_manifest import STEP_MANIFEST

        db = e2e_env["db"]
        pid, eid = e2e_env["pid"], e2e_env["eid"]
        proj_dir = e2e_env["proj_dir"]

        for sid, info in STEP_MANIFEST.items():
            if info.get("category") == "analysis" and info.get("lifecycle", "active") == "active":
                _mark_step_completed(db, pid, eid, sid)

        with patch("app.core.config.settings") as mock_s:
            mock_s.projects_dir = proj_dir

            from app.api.v1.steps import _sync_checkpoints_to_db
            _sync_checkpoints_to_db(pid, eid, db)

            row = db.execute(sql_text(
                "SELECT status FROM episode WHERE id = :eid"
            ), {"eid": eid}).fetchone()
            assert row is not None
            assert row[0] == "analyzed"
