from __future__ import annotations

import json
from pathlib import Path

import pytest

from recoil.pipeline._lib.render_schema import CameraTestedEpisode, GlobalBible
from recoil.pipeline.orchestrator import ingest_pipeline as ip


def _camera_tested() -> CameraTestedEpisode:
    return 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() -> GlobalBible:
    return GlobalBible.model_validate(
        {
            "project": "demo",
            "total_episodes": 1,
            "characters": {
                "JANE": {
                    "char_id": "JANE",
                    "display_name": "Jane",
                    "visual_description": "mid 30s, focused, tired eyes",
                    "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 _episode_plan_json(*, include_motion_line: bool = True) -> str:
    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",
    }
    if include_motion_line:
        prompt_skeleton["motion_line"] = (
            "Jane leans closer to the console as the glow pulses across her face."
        )

    return json.dumps(
        {
            "episode_id": "EP001",
            "project": "demo",
            "total_shots": 1,
            "shots": [
                {
                    "shot_id": "EP001_SH01",
                    "scene_index": 1,
                    "source_text": "JANE studies the empty bridge.",
                    "routing_data": {
                        "target_editorial_duration_s": 4,
                        "has_dialogue": False,
                        "camera_complexity": "static",
                        "num_characters": 1,
                        "is_env_only": False,
                    },
                    "prompt_data": {
                        "shot_type": "CU",
                        "camera_movement": "static",
                        "lighting": {
                            "sources": [
                                {
                                    "motivator": "console glow",
                                    "direction": "below",
                                    "quality": "soft",
                                    "color_temp": "cool",
                                }
                            ]
                        },
                        "prompt_skeleton": {
                            **prompt_skeleton,
                        },
                    },
                    "spatial_data": {
                        "camera_side": "A",
                        "screen_direction": "center",
                    },
                    "asset_data": {
                        "location_id": "bridge",
                        "time_of_day": "interior",
                        "visual_mode": "reality",
                        "characters": [
                            {
                                "char_id": "JANE",
                                "wardrobe_phase_id": "jane_ep1",
                                "emotion_keyword": "worried",
                            }
                        ],
                        "props": [],
                    },
                    "audio_data": {
                        "dialogue": [],
                        "ambient_sfx": "low console hum",
                        "foley_action": "",
                    },
                }
            ],
        }
    )


def _creative_output_json(*, include_motion_line: bool = True) -> str:
    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",
    }
    if include_motion_line:
        prompt_skeleton["motion_line"] = (
            "Jane leans closer to the console as the glow pulses across her face."
        )

    return json.dumps(
        {
            "episode_id": "EP001",
            "total_shots": 1,
            "shots": [
                {
                    "shot_index": 1,
                    "prompt_skeleton": prompt_skeleton,
                    "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",
                }
            ],
        }
    )


@pytest.fixture()
def pipeline(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ip.IngestPipeline:
    # REC-164: storyboard now stamps the derivation manifest after the plan
    # write, which resolves ProjectPaths.for_project(project). Point the projects
    # root at a tmp dir so the conftest shim auto-creates the project and the
    # stamp stays hermetic (the manifest write failing FAILs the stage by design).
    projects_root = tmp_path / "projects"
    projects_root.mkdir()
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(projects_root))

    pipe = object.__new__(ip.IngestPipeline)
    pipe.project = "demo"
    pipe.project_root = tmp_path
    pipe.dry_run = False
    pipe.extraction_model = "opus-4.6"
    pipe.episodes_dir = tmp_path / "plans"
    pipe.episodes_dir.mkdir()
    pipe.plans_dir = pipe.episodes_dir
    pipe.camera_tested_dir = tmp_path / "camera_tested"
    pipe.bible_path = tmp_path / "global_bible.json"

    monkeypatch.setattr(pipe, "_load_camera_tested", lambda _episode_num: _camera_tested())
    monkeypatch.setattr(pipe, "_load_episode_script", lambda _episode_num: "JANE studies the empty bridge.")
    monkeypatch.setattr(pipe, "_load_project_config", lambda: {})
    monkeypatch.setattr(pipe, "_save_json", lambda _path, _data: None)
    monkeypatch.setattr(pipe, "_send_to_review_queue", lambda *_args, **_kwargs: None)

    return pipe


def test_storyboard_prompt_contains_motion_line_contract() -> None:
    prompt = ip.STORYBOARD_SYSTEM_PROMPT

    assert "Output ONE CreativeEpisodeOutput containing {total_shots} CreativeShot" in prompt
    assert "do NOT emit ids, per-shot character/location/prop references" in prompt
    assert "Also emit axis_plans" in prompt  # REC-180 scene axis authoring
    assert "motion_line: ONE or TWO full sentences of TEMPORAL action" in prompt
    assert "what visibly changes between the first and last frame" in prompt
    assert (
        'Good: "motion blur on point of contact, dust kicked into lens". '
        'Bad: "He is scared".'
    ) in prompt
    assert "light_motivator: what PHYSICALLY emits the dominant light" in prompt
    assert "Output valid JSON only: a CreativeEpisodeOutput object" in prompt


def test_episode_plan_schema_preserves_optional_motion_line() -> None:
    schema = ip.EpisodePlan.model_json_schema()
    prompt_props = schema["$defs"]["PromptSkeleton"]["properties"]

    assert "motion_line" in prompt_props
    assert "motion_line" not in schema["$defs"]["PromptSkeleton"].get("required", [])
    with_motion = ip.EpisodePlan.model_validate_json(_episode_plan_json())
    without_motion = ip.EpisodePlan.model_validate_json(_episode_plan_json(include_motion_line=False))
    assert (
        with_motion.shots[0].prompt_data.prompt_skeleton.motion_line
        == "Jane leans closer to the console as the glow pulses across her face."
    )
    assert without_motion.shots[0].prompt_data.prompt_skeleton.motion_line is None


def test_claude_storyboard_uses_opus_oauth_with_schema(
    pipeline: ip.IngestPipeline,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    calls: list[dict] = []

    def fake_call_opus(model_id, system_prompt, user_prompt, *, json_schema=None, timeout=600, **_kwargs):
        calls.append(
            {
                "model_id": model_id,
                "system_prompt": system_prompt,
                "user_prompt": user_prompt,
                "json_schema": json_schema,
                "timeout": timeout,
            }
        )
        return _creative_output_json()

    def fail_gemini(*_args, **_kwargs):
        raise AssertionError("_call_gemini should not be used for claude storyboard")

    monkeypatch.setattr(ip, "STORYBOARD_MODEL", "claude-opus-4-8")
    monkeypatch.setattr(ip, "call_opus_oauth", fake_call_opus)
    monkeypatch.setattr(ip.IngestPipeline, "_call_gemini", fail_gemini)

    result = pipeline.run_storyboard_pass(1, bible=_bible())

    assert result is not None
    assert result.episode_id == "EP001"
    assert result.shots[0].shot_id == "EP001_SH01"
    assert calls[0]["model_id"] == "claude-opus-4-8"
    assert calls[0]["json_schema"] == ip.CreativeEpisodeOutput.model_json_schema()
    assert calls[0]["timeout"] == 2400
    assert "Respond with ONLY a single raw JSON object: the exact CreativeEpisodeOutput object" in calls[0]["user_prompt"]


def test_storyboard_pass_default_uses_opus_oauth_with_minimal_schema(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    projects_root = tmp_path / "projects"
    projects_root.mkdir()
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(projects_root))

    pipeline = ip.IngestPipeline(
        project="_phase4_storyboard",
        project_root=projects_root / "_phase4_storyboard",
    )

    calls: list[dict] = []

    def fake_call_opus(model_id, system_prompt, user_prompt, *, json_schema=None, timeout=600, **_kwargs):
        calls.append(
            {
                "model_id": model_id,
                "system_prompt": system_prompt,
                "user_prompt": user_prompt,
                "json_schema": json_schema,
                "timeout": timeout,
            }
        )
        return _creative_output_json()

    monkeypatch.setattr(pipeline, "_load_camera_tested", lambda _episode_num: _camera_tested())
    monkeypatch.setattr(pipeline, "_load_bible", lambda: _bible())
    monkeypatch.setattr(pipeline, "_load_episode_script", lambda _episode_num: "JANE studies the empty bridge.")
    monkeypatch.setattr(pipeline, "_load_project_config", lambda: {})
    monkeypatch.setattr(pipeline, "_save_json", lambda _path, _data: None)
    monkeypatch.setattr(pipeline, "_send_to_review_queue", lambda *_args, **_kwargs: None)
    monkeypatch.setattr(ip, "call_opus_oauth", fake_call_opus)
    monkeypatch.setattr(
        ip.IngestPipeline,
        "_call_gemini",
        lambda *_args, **_kwargs: pytest.fail("_call_gemini should not be used"),
    )

    result = pipeline.run_storyboard_pass(1)

    assert ip.STORYBOARD_MODEL == "claude-opus-4-8"
    assert pipeline.extraction_model == ip.DEFAULT_EXTRACTION_MODEL
    assert result is not None
    assert result.episode_id == "EP001"
    assert calls[0]["model_id"] == "claude-opus-4-8"
    assert calls[0]["json_schema"] == ip.CreativeEpisodeOutput.model_json_schema()
    assert calls[0]["timeout"] == 2400


def test_storyboard_missing_motion_line_retries(
    pipeline: ip.IngestPipeline,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    responses = iter([
        _creative_output_json(include_motion_line=False),
        _creative_output_json(include_motion_line=True),
    ])
    calls: list[str] = []

    def fake_call_opus(*_args, **_kwargs):
        calls.append("call")
        return next(responses)

    monkeypatch.setattr(ip, "STORYBOARD_MODEL", "claude-opus-4-8")
    monkeypatch.setattr(ip, "call_opus_oauth", fake_call_opus)
    monkeypatch.setattr(
        ip.IngestPipeline,
        "_call_gemini",
        lambda *_args, **_kwargs: pytest.fail("_call_gemini should not be used"),
    )

    result = pipeline.run_storyboard_pass(1, bible=_bible())

    assert result is not None
    assert len(calls) == 2
    assert result.shots[0].prompt_data.prompt_skeleton.motion_line


def test_storyboard_legacy_saved_plan_without_motion_line_merges(
    pipeline: ip.IngestPipeline,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    old_plan = json.loads(_episode_plan_json(include_motion_line=False))
    old_plan["shots"][0]["source_text_hash"] = "78d1ce4e076366f4b2a2ca237475b5f1"
    old_plan["shots"][0]["selected_take_id"] = "legacy-take"
    out_path = pipeline.episodes_dir / "ep_001_plan.json"
    out_path.write_text(json.dumps(old_plan), encoding="utf-8")
    saved: dict[str, object] = {}

    def fake_save(path, data):
        saved["path"] = path
        saved["data"] = data

    monkeypatch.setattr(pipeline, "_save_json", fake_save)
    monkeypatch.setattr(ip, "STORYBOARD_MODEL", "claude-opus-4-8")
    monkeypatch.setattr(ip, "call_opus_oauth", lambda *_args, **_kwargs: _creative_output_json())
    monkeypatch.setattr(
        ip.IngestPipeline,
        "_call_gemini",
        lambda *_args, **_kwargs: pytest.fail("_call_gemini should not be used"),
    )

    result = pipeline.run_storyboard_pass(1, bible=_bible())

    assert result is not None
    saved_plan = saved["data"]
    assert saved_plan["shots"][0]["selected_take_id"] == "legacy-take"


def test_claude_storyboard_parses_fenced_json_response(
    pipeline: ip.IngestPipeline,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(ip, "STORYBOARD_MODEL", "claude-opus-4-8")
    monkeypatch.setattr(
        ip,
        "call_opus_oauth",
        lambda *_args, **_kwargs: f"```json\n{_creative_output_json()}\n```",
    )
    monkeypatch.setattr(
        ip.IngestPipeline,
        "_call_gemini",
        lambda *_args, **_kwargs: pytest.fail("_call_gemini should not be used"),
    )

    result = pipeline.run_storyboard_pass(1, bible=_bible())

    assert result is not None
    assert result.total_shots == 1


def test_claude_storyboard_invalid_json_retries_then_fails(
    pipeline: ip.IngestPipeline,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    responses = iter(["not json", '{"episode_id": "EP001"', "still not json"])
    calls: list[str] = []

    def fake_call_opus(*_args, **_kwargs):
        calls.append("call")
        return next(responses)

    monkeypatch.setattr(ip, "STORYBOARD_MODEL", "claude-opus-4-8")
    monkeypatch.setattr(ip, "call_opus_oauth", fake_call_opus)
    monkeypatch.setattr(
        ip.IngestPipeline,
        "_call_gemini",
        lambda *_args, **_kwargs: pytest.fail("_call_gemini should not be used"),
    )

    with pytest.raises(Exception):
        pipeline.run_storyboard_pass(1, bible=_bible())

    assert len(calls) == ip.MAX_RETRIES + 1


def test_claude_storyboard_dry_run_calls_neither_model(
    pipeline: ip.IngestPipeline,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pipeline.dry_run = True

    monkeypatch.setattr(ip, "STORYBOARD_MODEL", "claude-opus-4-8")
    monkeypatch.setattr(ip, "call_opus_oauth", lambda *_args, **_kwargs: pytest.fail("call_opus_oauth called"))
    monkeypatch.setattr(
        ip.IngestPipeline,
        "_call_gemini",
        lambda *_args, **_kwargs: pytest.fail("_call_gemini called"),
    )

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