from __future__ import annotations

import json
from pathlib import Path

import pytest

from recoil.pipeline._lib.render_schema import (
    CameraTestedEpisode,
    CreativeEpisodeOutput,
    EpisodePlan,
    GlobalBible,
)
from recoil.pipeline.orchestrator import ingest_pipeline as ip
from recoil.pipeline.orchestrator.ingest_pipeline import LocationUnresolvedError


def _pipeline(project: str = "demo") -> ip.IngestPipeline:
    pipe = object.__new__(ip.IngestPipeline)
    pipe.project = project
    pipe.dry_run = False
    pipe.extraction_model = "opus-4.6"
    return pipe


def _camera_tested() -> CameraTestedEpisode:
    return CameraTestedEpisode.model_validate(
        {
            "episode_id": "EP001",
            "project": "demo",
            "total_shots": 2,
            "shots": [
                {
                    "shot_index": 1,
                    "scene_index": 1,
                    "source_text": "JANE studies the empty bridge.",
                    "has_dialogue": True,
                    "characters_mentioned": ["JANE"],
                    "location_hint": "bridge",
                },
                {
                    "shot_index": 2,
                    "scene_index": 2,
                    "source_text": "The bridge sits silent.",
                    "has_dialogue": False,
                    "characters_mentioned": [],
                    "location_hint": "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",
                    "aliases": ["INT. COMMAND BRIDGE"],
                    "description": "compact command bridge with dim consoles",
                    "lighting_profile": {
                        "primary_source": "console glow",
                        "direction": "below",
                        "quality": "soft",
                        "color_temp": "cool",
                    },
                },
                "corridor": {
                    "location_id": "corridor",
                    "aliases": ["INT. CORRIDOR"],
                    "description": "narrow service corridor with ribbed wall panels",
                }
            },
            "props": {},
        }
    )


def _creative() -> CreativeEpisodeOutput:
    return CreativeEpisodeOutput.model_validate(
        {
            "episode_id": "EP001",
            "total_shots": 2,
            "shots": [
                {
                    "shot_index": 1,
                    "prompt_skeleton": {
                        "subject_line": "Jane alone at the command console",
                        "environment_line": "empty bridge, dark glass, dim consoles",
                        "action_line": "fingers hover above controls, reflected light",
                        "motion_line": "Jane studies the console as the bridge lights pulse around her.",
                        "emotion_line": "focused worry, restrained urgency",
                    },
                    "shot_type": "CU",
                    "camera_movement": "static",
                    "kinetic_action": "console reflections shimmer across the lens",
                    "target_editorial_duration_s": 4,
                    "narrative_requires_match_cut": False,
                    "light_motivator": "strip light",
                },
                {
                    "shot_index": 2,
                    "prompt_skeleton": {
                        "subject_line": "empty command bridge consoles",
                        "environment_line": "silent bridge, dark chairs, inactive stations",
                        "action_line": "dust drifts through console glow",
                        "motion_line": "The bridge remains still while console light slowly fades across the floor.",
                        "emotion_line": "lonely quiet, suspended tension",
                    },
                    "shot_type": "WS",
                    "camera_movement": "pan",
                    "kinetic_action": "slow parallax across empty stations",
                    "target_editorial_duration_s": 5,
                    "narrative_requires_match_cut": True,
                    "light_motivator": "",
                },
            ],
        }
    )


def _creative_for(ct: CameraTestedEpisode) -> CreativeEpisodeOutput:
    creative_data = _creative().model_dump(mode="json")
    templates = creative_data["shots"]
    shots = []
    for i, ct_shot in enumerate(ct.shots):
        shot = json.loads(json.dumps(templates[i % len(templates)]))
        shot["shot_index"] = ct_shot.shot_index
        shots.append(shot)
    creative_data["total_shots"] = len(shots)
    creative_data["shots"] = shots
    return CreativeEpisodeOutput.model_validate(creative_data)


def _creative_output_json() -> str:
    return _creative().model_dump_json()


def test_assemble_returns_valid_episode_plan() -> None:
    plan = _pipeline()._assemble_plan_from_creative(_creative(), _camera_tested(), _bible(), 1)

    assert isinstance(plan, EpisodePlan)
    assert plan.total_shots == 2
    assert len(plan.shots) == 2
    EpisodePlan.model_validate(plan.model_dump(mode="json"))


def test_mechanical_fields_copied_verbatim() -> None:
    ct = _camera_tested()
    plan = _pipeline()._assemble_plan_from_creative(_creative(), ct, _bible(), 1)

    for i, (shot, cts) in enumerate(zip(plan.shots, ct.shots), 1):
        assert shot.source_text == cts.source_text
        assert shot.scene_index == cts.scene_index
        assert shot.shot_id == f"EP001_SH{i:02d}"


def test_creative_fields_preserved() -> None:
    creative = _creative()
    plan = _pipeline()._assemble_plan_from_creative(creative, _camera_tested(), _bible(), 1)

    assert (
        plan.shots[0].prompt_data.prompt_skeleton.subject_line
        == creative.shots[0].prompt_skeleton.subject_line
    )
    assert (
        plan.shots[0].prompt_data.prompt_skeleton.environment_line
        == creative.shots[0].prompt_skeleton.environment_line
    )
    assert (
        plan.shots[0].prompt_data.prompt_skeleton.action_line
        == creative.shots[0].prompt_skeleton.action_line
    )
    assert (
        plan.shots[0].prompt_data.prompt_skeleton.motion_line
        == creative.shots[0].prompt_skeleton.motion_line
    )
    assert (
        plan.shots[0].prompt_data.prompt_skeleton.emotion_line
        == creative.shots[0].prompt_skeleton.emotion_line
    )
    assert plan.shots[0].prompt_data.shot_type == creative.shots[0].shot_type


def test_derived_routing() -> None:
    plan = _pipeline()._assemble_plan_from_creative(_creative(), _camera_tested(), _bible(), 1)

    assert plan.shots[0].routing_data.num_characters == 1
    assert plan.shots[0].routing_data.is_env_only is False
    assert plan.shots[0].routing_data.has_dialogue is True
    assert plan.shots[1].routing_data.num_characters == 0
    assert plan.shots[1].routing_data.is_env_only is True
    assert plan.shots[1].routing_data.has_dialogue is False


def test_character_phase_resolved() -> None:
    plan = _pipeline()._assemble_plan_from_creative(_creative(), _camera_tested(), _bible(), 1)

    character = plan.shots[0].asset_data.characters[0]
    assert character.char_id == "JANE"
    assert character.wardrobe_phase_id == "jane_ep1"


def test_lighting_from_bible_profile() -> None:
    bible = _bible()
    plan = _pipeline()._assemble_plan_from_creative(_creative(), _camera_tested(), bible, 1)

    shot_1_source = plan.shots[0].prompt_data.lighting.sources[0]
    shot_2_source = plan.shots[1].prompt_data.lighting.sources[0]
    profile = bible.locations["bridge"].lighting_profile

    assert shot_1_source.motivator == "strip light"
    assert shot_2_source.motivator == "console glow"
    assert shot_2_source.color_temp == profile.color_temp
    assert shot_2_source.direction == profile.direction
    assert shot_2_source.quality == profile.quality


def test_minimal_schema_round_trip_oauth() -> None:
    schema = CreativeEpisodeOutput.model_json_schema()
    schema_json = json.dumps(schema)

    assert "routing_data" not in schema_json
    assert "prompt_skeleton" in schema_json
    assert CreativeEpisodeOutput.model_validate_json(_creative_output_json())


def test_creative_validator_injects_missing_wrapper() -> None:
    payload = {
        "shots": [_creative().shots[0].model_dump(mode="json")],
    }

    result = _pipeline()._validate_creative_response(json.dumps(payload), 1)

    assert result.episode_id == "EP001"
    assert result.total_shots == 1


def test_creative_validator_missing_shots_raises() -> None:
    payload = {
        "episode_id": "EP001",
        "total_shots": 1,
    }

    with pytest.raises(Exception):
        _pipeline()._validate_creative_response(json.dumps(payload), 1)


def test_camera_test_validator_injects_missing_wrapper() -> None:
    pipe = _pipeline()
    payload = {
        "shots": [_camera_tested().shots[0].model_dump(mode="json")],
    }

    result = pipe._validate_camera_test_response(json.dumps(payload), 1)

    assert result.episode_id == "EP001"
    assert result.project == pipe.project
    assert result.total_shots == 1


def test_camera_test_validator_missing_shots_raises() -> None:
    payload = {
        "episode_id": "EP001",
        "project": "demo",
        "total_shots": 1,
    }

    with pytest.raises(Exception):
        _pipeline()._validate_camera_test_response(json.dumps(payload), 1)


def test_storyboard_pass_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="_phase5_storyboard",
        project_root=projects_root / "_phase5_storyboard",
    )
    captured: dict[str, object] = {}

    def fake_call_opus(model, _system_prompt, _user_prompt, *, json_schema=None, **kwargs):
        captured["model"] = model
        captured["kwargs"] = {**kwargs, "json_schema": json_schema}
        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: "episode script")
    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)

    result = pipeline.run_storyboard_pass(1)

    assert captured["model"] == "claude-opus-4-8"
    assert captured["kwargs"]["json_schema"] == CreativeEpisodeOutput.model_json_schema()
    assert isinstance(result, EpisodePlan)
    EpisodePlan.model_validate(result.model_dump(mode="json"))


def test_shot_index_mismatch_raises() -> None:
    creative_data = _creative().model_dump(mode="json")
    creative_data["shots"][0]["shot_index"] = 99
    creative = CreativeEpisodeOutput.model_validate(creative_data)

    with pytest.raises(ValueError, match="creative shot_index 99 has no camera-tested match"):
        _pipeline()._assemble_plan_from_creative(creative, _camera_tested(), _bible(), 1)


def test_character_canonicalized_case_variant() -> None:
    ct_data = _camera_tested().model_dump(mode="json")
    ct_data["shots"][0]["characters_mentioned"] = ["Jane"]
    ct = CameraTestedEpisode.model_validate(ct_data)

    plan = _pipeline()._assemble_plan_from_creative(_creative(), ct, _bible(), 1)

    assert plan.shots[0].asset_data.characters[0].char_id == "JANE"
    assert plan.shots[0].routing_data.num_characters == 1
    assert plan.shots[0].routing_data.is_env_only is False


def test_nonempty_characters_zero_match_raises() -> None:
    ct_data = _camera_tested().model_dump(mode="json")
    ct_data["shots"][0]["characters_mentioned"] = ["NOBODY"]
    ct = CameraTestedEpisode.model_validate(ct_data)

    with pytest.raises(ValueError, match="resolved to ZERO bible characters"):
        _pipeline()._assemble_plan_from_creative(_creative(), ct, _bible(), 1)

    ct_data["shots"][0]["characters_mentioned"] = []
    ct = CameraTestedEpisode.model_validate(ct_data)
    plan = _pipeline()._assemble_plan_from_creative(_creative(), ct, _bible(), 1)
    assert plan.shots[0].routing_data.is_env_only is True


def test_location_resolution_strict() -> None:
    bible = _bible()
    pipe = _pipeline()

    # alias / direct match still resolves
    assert pipe._resolve_location_id("int. command bridge", bible) == "bridge"
    # canonicalization: INT./EXT. prefix + TOD suffix are stripped before match
    assert pipe._resolve_location_id("INT. COMMAND BRIDGE - CONTINUOUS", bible) == "bridge"
    assert pipe._resolve_location_id("EXT. COMMAND BRIDGE - NIGHT", bible) == "bridge"

    # unmatched heading now RAISES (no silent default to the first location)
    with pytest.raises(LocationUnresolvedError):
        pipe._resolve_location_id("unmatched location", bible)

    # empty bible: the distinct pre-existing guard still fires
    empty_bible = GlobalBible.model_validate(
        {
            "project": "demo",
            "total_episodes": 1,
            "characters": {},
            "locations": {},
            "props": {},
        }
    )
    with pytest.raises(ValueError, match="bible has no locations"):
        pipe._resolve_location_id("anything", empty_bible)


def test_location_carry_forward() -> None:
    # shot 1 = heading A, shots 2-3 = null (inherit A), shot 4 = heading B, shot 5 = null (inherit B)
    ct_data = _camera_tested().model_dump(mode="json")
    while len(ct_data["shots"]) < 5:
        shot = json.loads(json.dumps(ct_data["shots"][-1]))
        shot["shot_index"] = len(ct_data["shots"]) + 1
        shot["scene_index"] = 2
        shot["source_text"] = f"Shot {shot['shot_index']} source text."
        shot["has_dialogue"] = False
        shot["characters_mentioned"] = []
        ct_data["shots"].append(shot)
    ct_data["total_shots"] = 5
    for shot, hint in zip(
        ct_data["shots"],
        ["INT. COMMAND BRIDGE", None, None, "INT. CORRIDOR", None],
        strict=True,
    ):
        shot["location_hint"] = hint
    ct = CameraTestedEpisode.model_validate(ct_data)

    plan = _pipeline()._assemble_plan_from_creative(_creative_for(ct), ct, _bible(), 1)

    # Key by SHOT (shot_id / shot_index) — scene_index is NOT a stable per-shot
    # ordering key (multiple shots share a scene_index). shot_id is EP###_SH##.
    loc_by_shot = {s.shot_id: s.asset_data.location_id for s in plan.shots}
    assert loc_by_shot["EP001_SH01"] == "bridge"
    assert loc_by_shot["EP001_SH02"] == "bridge"  # inherited (null hint)
    assert loc_by_shot["EP001_SH03"] == "bridge"  # inherited (null hint)
    assert loc_by_shot["EP001_SH04"] == "corridor"  # new heading
    assert loc_by_shot["EP001_SH05"] == "corridor"  # inherited (null hint)


def test_headingless_first_shot_raises() -> None:
    ct_data = _camera_tested().model_dump(mode="json")
    ct_data["shots"][0]["location_hint"] = None  # episode opens with no heading
    ct = CameraTestedEpisode.model_validate(ct_data)

    with pytest.raises(LocationUnresolvedError):
        _pipeline()._assemble_plan_from_creative(_creative(), ct, _bible(), 1)


def test_unresolved_heading_carries_shot_index() -> None:
    # shot 1 resolves (bridge); shot 2 carries a non-null heading matching no
    # bible location -> raises with the offending shot index attached (P2 fix).
    ct_data = _camera_tested().model_dump(mode="json")
    while len(ct_data["shots"]) < 2:
        shot = json.loads(json.dumps(ct_data["shots"][-1]))
        shot["shot_index"] = len(ct_data["shots"]) + 1
        shot["scene_index"] = 2
        shot["source_text"] = f"Shot {shot['shot_index']} source text."
        shot["has_dialogue"] = False
        shot["characters_mentioned"] = []
        ct_data["shots"].append(shot)
    ct_data["shots"] = ct_data["shots"][:2]
    ct_data["total_shots"] = 2
    for shot, hint in zip(
        ct_data["shots"],
        ["INT. COMMAND BRIDGE", "INT. NOWHERE"],
        strict=True,
    ):
        shot["location_hint"] = hint
    ct = CameraTestedEpisode.model_validate(ct_data)

    with pytest.raises(LocationUnresolvedError) as excinfo:
        _pipeline()._assemble_plan_from_creative(_creative_for(ct), ct, _bible(), 1)

    assert excinfo.value.shot_index == 2
    assert excinfo.value.hint == "INT. NOWHERE"
