"""Tests for derivation-manifest stamping at the stage write sites (REC-164 Phase 3).

Each test drives the REAL stage writer against a tmp project root
(`RECOIL_PROJECTS_ROOT`), with the model calls monkeypatched, then asserts the
manifest is stamped correctly after the artifact write:

  - camera_tested   → per-episode stamp (content_sha + script_sha source)
  - plan            → per-episode stamp (structural_sha + source linkage)
  - bible           → PROJECT-LEVEL record (breakdown, then enrich re-stamp)
  - coverage_passes → producer stamp (plan_structural_sha source — the D3 guard reads this)

Fixtures mirror `test_storyboard_opus.py` so the real `_assemble_plan_from_creative`
path runs unchanged.
"""
from __future__ import annotations

import hashlib
import importlib
import json
from pathlib import Path

import pytest

from recoil.pipeline._lib import derivation_manifest
from recoil.pipeline._lib.derivation_sha import content_sha, plan_structural_sha
from recoil.pipeline.orchestrator import ingest_pipeline as ip


# ── Minimal valid model fixtures (mirroring test_storyboard_opus.py) ───────

def _camera_tested():
    return ip.CameraTestedEpisode.model_validate(
        {
            "episode_id": "EP001",
            "project": "demo",
            "total_shots": 1,
            "shots": [
                {
                    "shot_index": 1,
                    "scene_index": 1,
                    "source_text": "JANE studies the empty bridge.",
                    "has_dialogue": False,
                    "characters_mentioned": ["JANE"],
                    "location_hint": "INT. BRIDGE",
                }
            ],
        }
    )


def _bible_dict(*, with_placeholder: bool = False) -> dict:
    visual_description = "[OPUS_ENRICHMENT]" if with_placeholder else "mid 30s, focused, tired eyes"
    return {
        "project": "demo",
        "total_episodes": 1,
        "characters": {
            "JANE": {
                "char_id": "JANE",
                "display_name": "Jane",
                "visual_description": visual_description,
                "episodes": [1],
                "phases": [
                    {
                        "phase_id": "jane_ep1",
                        "start_ep": 1,
                        "end_ep": 1,
                        "wardrobe_description": "navy work jacket and black boots",
                    }
                ],
            }
        },
        "locations": {
            "bridge": {
                "location_id": "bridge",
                "description": "compact command bridge with dim consoles",
            }
        },
        "props": {},
    }


def _bible():
    return ip.GlobalBible.model_validate(_bible_dict())


def _creative_output_json() -> str:
    return json.dumps(
        {
            "episode_id": "EP001",
            "total_shots": 1,
            "shots": [
                {
                    "shot_index": 1,
                    "prompt_skeleton": {
                        "subject_line": "Jane leans over the command console",
                        "environment_line": "dim compact bridge, cool console glow",
                        "action_line": "still frame, quiet tension",
                        "emotion_line": "controlled worry",
                        "motion_line": "Jane leans closer to the console as the glow pulses across her face.",
                    },
                    "shot_type": "CU",
                    "camera_movement": "static",
                    "kinetic_action": "console glow rolls across the lens",
                    "target_editorial_duration_s": 4,
                    "narrative_requires_match_cut": False,
                    "light_motivator": "console glow",
                }
            ],
        }
    )


def _coverage_plan_payload(project: str) -> dict:
    def _shot(scene: int, ordinal: int) -> dict:
        return {
            "shot_id": f"EP001_SH{ordinal:02d}",
            "scene_index": scene,
            "pipeline": "video",
            "video_model": "seeddance-2.0",
            "asset_data": {"characters": [], "location_id": f"LOC_{scene}"},
            "prompt_data": {"shot_type": "MS"},
            "routing_data": {
                "target_editorial_duration_s": 2.0,
                "is_env_only": False,
                "has_dialogue": False,
            },
            "aspect_ratio": "9:16",
        }

    return {
        "episode_id": "ep_001",
        "project": project,
        "shots": [
            _shot(scene, (scene - 1) * 3 + idx)
            for scene in range(1, 5)
            for idx in range(1, 4)
        ],
    }


def _pipeline(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, slug: str, **kwargs) -> ip.IngestPipeline:
    """A real IngestPipeline rooted at a tmp projects root (slug starts with '_'
    so `ProjectPaths.for_project` constructs paths without a pre-existing dir)."""
    projects_root = tmp_path / "projects"
    projects_root.mkdir(exist_ok=True)
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(projects_root))
    return ip.IngestPipeline(project=slug, project_root=projects_root / slug, **kwargs)


# ── camera_tested (per-episode stamp) ──────────────────────────────────────

def test_camera_test_write_stamps_manifest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    pipeline = _pipeline(tmp_path, monkeypatch, "_manifest_ct", min_shots=1, max_shots=200)
    script = "JANE studies the empty bridge."

    monkeypatch.setattr(pipeline, "_load_episode_script", lambda _ep: script)
    monkeypatch.setattr(pipeline, "_resolve_model", lambda _m: "claude-test")
    monkeypatch.setattr(pipeline, "_send_to_review_queue", lambda *a, **k: None)
    monkeypatch.setattr(ip, "call_opus_oauth", lambda *a, **k: _camera_tested().model_dump_json())

    result = pipeline.run_camera_test(episode_num=1)
    assert result is not None

    ct = derivation_manifest.load("_manifest_ct", 1)["stages"]["camera_tested"]
    assert ct["kind"] == "derived"
    assert ct["structural_sha"] is None
    assert ct["content_sha"] == content_sha(result.model_dump())
    assert ct["source"]["script_sha"] == hashlib.md5(script.encode("utf-8")).hexdigest()
    assert ct["builder"] == "stage0.camera_test"


# ── plan (per-episode stamp + source linkage) ──────────────────────────────

def test_plan_write_stamps_manifest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    pipeline = _pipeline(tmp_path, monkeypatch, "_manifest_plan")
    project = "_manifest_plan"

    # Prior stages (camera_tested + project bible) already stamped, with known shas.
    ct_sha = "sha256:CT_KNOWN"
    bible_sha = "sha256:BIBLE_KNOWN"
    derivation_manifest.stamp_stage(
        project, 1, "camera_tested",
        kind="derived", content_sha=ct_sha, structural_sha=None,
        source={"script_sha": "deadbeef"}, builder="stage0.camera_test",
    )
    derivation_manifest.stamp_bible(
        project, content_sha=bible_sha, builder="stage1.breakdown",
        built_at="2026-06-14T00:00:00+00:00",
    )

    monkeypatch.setattr(pipeline, "_load_camera_tested", lambda _ep: _camera_tested())
    monkeypatch.setattr(pipeline, "_load_episode_script", lambda _ep: "JANE studies the empty bridge.")
    monkeypatch.setattr(pipeline, "_load_project_config", lambda: {})
    monkeypatch.setattr(pipeline, "_send_to_review_queue", lambda *a, **k: None)
    monkeypatch.setattr(ip, "STORYBOARD_MODEL", "claude-opus-4-8")
    monkeypatch.setattr(ip, "call_opus_oauth", lambda *a, **k: _creative_output_json())
    monkeypatch.setattr(ip.IngestPipeline, "_call_gemini", lambda *a, **k: pytest.fail("gemini must not be called"))

    result = pipeline.run_storyboard_pass(1, bible=_bible())
    assert result is not None

    # The manifest must match the plan dict that was actually written to disk.
    written = json.loads((pipeline.episodes_dir / "ep_001_plan.json").read_text(encoding="utf-8"))

    plan = derivation_manifest.load(project, 1)["stages"]["plan"]
    assert plan["kind"] == "derived"
    assert plan["structural_sha"] == plan_structural_sha(written)
    assert plan["content_sha"] == content_sha(written)
    assert plan["source"]["camera_tested_content_sha"] == ct_sha
    assert plan["source"]["bible_content_sha"] == bible_sha
    assert plan["builder"] == "stage2.plan"
    assert plan["shot_ids"] == ["EP001_SH01"]


# ── bible (PROJECT-LEVEL record; breakdown then enrich re-stamp) ────────────

def test_bible_uses_project_level_record(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    pipeline = _pipeline(tmp_path, monkeypatch, "_manifest_bible")
    project = "_manifest_bible"

    # ── Breakdown → bible WITH an [OPUS_ENRICHMENT] placeholder (so enrich runs). ──
    monkeypatch.setattr(pipeline, "_load_camera_tested", lambda _ep: _camera_tested())
    monkeypatch.setattr(pipeline, "_resolve_model", lambda _m: "claude-test")
    monkeypatch.setattr(pipeline, "_send_to_review_queue", lambda *a, **k: None)
    monkeypatch.setattr(
        ip, "call_opus_oauth",
        lambda *a, **k: json.dumps(_bible_dict(with_placeholder=True)),
    )

    bible_result = pipeline.run_breakdown_pass(episode_nums=[1])
    assert bible_result is not None

    bible_record = derivation_manifest.load_bible(project)
    assert bible_record.get("content_sha")  # populated after breakdown
    assert bible_record["builder"] == "stage1.breakdown"
    breakdown_sha = bible_record["content_sha"]

    # The bible is PROJECT-LEVEL — no per-episode `stages.bible` entry exists.
    assert "bible" not in derivation_manifest.load(project, 1)["stages"]

    # ── Enrich → re-stamps the SAME project-level record (builder flips). ──
    def _fake_call_opus(_system_prompt, _user_prompt):
        return {
            "text": "FIELD: characters.JANE.visual_description\nVALUE: lean build, cropped dark hair, hazel eyes",
            "cost": 0.0,
            "input_tokens": 0,
            "output_tokens": 0,
        }

    monkeypatch.setattr(pipeline, "_call_opus", _fake_call_opus)

    enrich_result = pipeline.run_opus_enrichment()
    assert enrich_result["filled"] == 1

    re_stamped = derivation_manifest.load_bible(project)
    assert re_stamped["builder"] == "stage1_5.enrich"        # builder flipped
    assert re_stamped["content_sha"] != breakdown_sha         # content changed (placeholder filled)

    # Still project-level — enrich must not create a per-episode bible stage.
    assert "bible" not in derivation_manifest.load(project, 1)["stages"]


# ── coverage_passes (producer stamp — the D3 guard reads this) ──────────────

def test_coverage_passes_producer_stamps(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    from recoil.pipeline.tools import build_coverage_passes as bcp
    from recoil.core.paths import ProjectPaths as CoreProjectPaths

    projects_root = tmp_path / "projects"
    projects_root.mkdir()
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(projects_root))
    project = "_manifest_cov"
    episode = 1

    # Write a known full plan dict to the canonical plan path.
    plans_dir = CoreProjectPaths.for_project(project).plans_dir
    plans_dir.mkdir(parents=True, exist_ok=True)
    plan_dict = _coverage_plan_payload(project)
    (plans_dir / f"ep_{episode:03d}_plan.json").write_text(json.dumps(plan_dict), encoding="utf-8")

    # Keep the test focused on the stamp — no validation blockers.
    coverage_validator = importlib.import_module("orchestrator.coverage_validator")
    monkeypatch.setattr(coverage_validator, "validate_all_passes", lambda passes: [])

    monkeypatch.setattr(
        bcp.sys, "argv",
        ["build_coverage_passes.py", "--project", project, "--episode", str(episode), "--lock"],
    )
    bcp.main()

    cov = derivation_manifest.load(project, episode)["stages"]["coverage_passes"]
    assert cov["kind"] == "derived"
    assert cov["structural_sha"] is None
    assert cov["source"]["plan_structural_sha"] == plan_structural_sha(plan_dict)
    assert cov["builder"] == "build_coverage_passes --lock"
    assert "covers_shots" in cov
