"""Tests for cinema_loader.py — CINEMA_MODES.yaml load, validation, and
render_cinema_tokens model-aware rendering.

Covers:
  7a. YAML structural integrity
  7b. resolve_mode composition + override merge
  7c. render_cinema_tokens full vs compressed
  7d. Soft-fail behavior on unknown mode
  7e. Validation crash on invalid mode reference
"""
import tempfile
import pathlib

import pytest

from recoil.pipeline._lib.cinema_loader import (
    CinemaConfigError,
    load_cinema_modes,
    reload_cinema_modes,
    render_camera_line,
    render_cinema_tokens,
    render_constraint_block,
    resolve_mode,
)


# ============================================================
# 7a. YAML structural integrity
# ============================================================

def test_yaml_loads_with_six_catalogs():
    d = load_cinema_modes()
    expected = {"camera_bodies", "lens_systems", "filtration",
                "film_stocks", "texture_grain", "color_grades"}
    assert expected <= set(d["catalogs"].keys())


def test_default_mode_uses_cooke():
    """JT's house default: narrative_cinematic uses cooke_s4_spherical."""
    d = load_cinema_modes()
    assert d["modes"]["narrative_cinematic"]["lens"] == "cooke_s4_spherical"
    assert "Cooke S4/i" in d["catalogs"]["lens_systems"]["cooke_s4_spherical"]["prompt_tokens"]


def test_six_starter_modes_present():
    d = load_cinema_modes()
    for m in ("narrative_cinematic", "kinetic_action", "surveillance_doc",
              "golden_hour", "noir_tension", "cartoon_tartarus"):
        assert m in d["modes"], f"mode {m} missing"


# ============================================================
# 7b. resolve_mode composition + override merge
# ============================================================

def test_resolve_mode_returns_six_token_fields():
    r = resolve_mode("narrative_cinematic")
    for k in ("body_tokens", "lens_tokens", "filtration_tokens",
              "stock_tokens", "grain_tokens", "grade_tokens"):
        assert k in r and r[k], f"{k} missing or empty"
    assert "Cooke S4/i" in r["lens_tokens"]


def test_shot_override_replaces_lens():
    r = resolve_mode("narrative_cinematic",
                     shot_overrides={"lens": "panavision_ultra_vintage"})
    assert "Panavision Ultra Vintage" in r["lens_tokens"]
    assert "Cooke S4/i" not in r["lens_tokens"]
    # Other fields stay at mode default
    assert "ARRI Alexa 35" in r["body_tokens"]


def test_invalid_override_value_falls_back_to_mode_default(caplog):
    """Invalid catalog id in override → log WARNING, use mode default."""
    import logging
    with caplog.at_level(logging.WARNING):
        r = resolve_mode("narrative_cinematic",
                         shot_overrides={"lens": "nonexistent_lens_id"})
    assert "Cooke S4/i" in r["lens_tokens"]
    assert any("nonexistent_lens_id" in msg for msg in caplog.messages)


# ============================================================
# 7c. render_cinema_tokens full vs compressed
# ============================================================

def test_seeddance_full_render():
    s = render_cinema_tokens("narrative_cinematic", "seeddance-2.0")
    assert "ARRI Alexa 35" in s
    assert "Cooke S4/i" in s
    assert ". " in s  # period join for full rendering


def test_kling_compressed_render():
    s = render_cinema_tokens("narrative_cinematic", "kling-v3")
    # Compressed: head clause only, not the full paragraph
    assert "Cooke S4/i spherical prime lenses" in s
    assert "classic Cooke Look" not in s  # compressed removed trailing clauses
    # Camera body opted out (null in token_map)
    assert "ARRI Alexa" not in s
    # Aperture / shutter opted out
    assert "T2.8" not in s
    assert "180-degree shutter" not in s


def test_veo_compressed_render():
    s = render_cinema_tokens("narrative_cinematic", "veo-3.1")
    # Only stock, grain, grade, filtration survive
    assert "Vision3 500T" in s
    assert "Cooke" not in s  # lens opted out


# ============================================================
# 7d. Soft-fail behavior on unknown mode
# ============================================================

def test_unknown_mode_returns_empty_string(caplog):
    import logging
    with caplog.at_level(logging.WARNING):
        s = render_cinema_tokens("definitely_does_not_exist", "seeddance-2.0")
    assert s == ""
    assert any("definitely_does_not_exist" in msg for msg in caplog.messages)


def test_none_mode_returns_empty():
    assert render_cinema_tokens(None, "seeddance-2.0") == ""
    assert render_cinema_tokens("", "seeddance-2.0") == ""


def test_unknown_model_defaults_to_full_render():
    """Model not wired with cinema_token_map → fall back to full rendering."""
    s = render_cinema_tokens("narrative_cinematic", "some-unwired-model")
    assert "Cooke S4/i" in s
    assert "ARRI Alexa 35" in s  # all fields default to "full"


# ============================================================
# 7e. Validation crash on invalid mode reference (load time)
# ============================================================

def test_load_crashes_on_invalid_catalog_reference(tmp_path, monkeypatch):
    """Modes referencing non-existent catalog ids must crash at load time."""
    bad = tmp_path / "CINEMA_MODES.yaml"
    bad.write_text("""
schema_version: 1
catalogs:
  camera_bodies:
    real_body:
      prompt_tokens: "real"
  lens_systems:
    real_lens:
      prompt_tokens: "real"
  filtration:
    real_f:
      prompt_tokens: "real"
  film_stocks:
    real_stock:
      prompt_tokens: "real"
  texture_grain:
    real_grain:
      prompt_tokens: "real"
  color_grades:
    real_grade:
      prompt_tokens: "real"
modes:
  broken_mode:
    body: real_body
    lens: this_lens_does_not_exist
    filtration: real_f
    stock: real_stock
    grain: real_grain
    grade: real_grade
""")
    # Point loader at the bad file
    from recoil.pipeline._lib import cinema_loader
    monkeypatch.setattr(cinema_loader, "_CINEMA_PATH", bad)
    cinema_loader._cinema = None
    cinema_loader._cinema_mtime = None
    with pytest.raises(CinemaConfigError, match="this_lens_does_not_exist"):
        cinema_loader.load_cinema_modes()
    # Restore real path for subsequent tests
    monkeypatch.undo()
    cinema_loader._cinema = None
    cinema_loader._cinema_mtime = None


# ============================================================
# 8a. render_camera_line — lens_type resolution
# ============================================================

def _fake_shot(shot_type=None, camera_movement=None, cinematography=None):
    """Factory for minimal shot objects used by render_camera_line tests."""
    class FakeShot:
        def __init__(self):
            pd = {}
            if shot_type is not None:
                pd["shot_type"] = shot_type
            if camera_movement is not None:
                pd["camera_movement"] = camera_movement
            self.raw = {"prompt_data": pd}
            self.cinematography = cinematography
    return FakeShot()


def test_camera_line_with_mode_lens_default():
    """Camera line resolves lens_type from PROMPT_BIBLE global_defaults."""
    result = render_camera_line(
        _fake_shot("CU", "dolly_in"), mode=None, model_id="seeddance-2.0",
    )
    assert "Camera:" in result
    assert "tele lens" in result
    assert "dolly-in" in result


def test_camera_line_with_mode_override():
    """Per-mode lens_per_shot_size_override takes precedence over global defaults."""
    mode = {"lens_per_shot_size_override": {"CU": "anamorphic wide"}}
    result = render_camera_line(
        _fake_shot("CU", "static"), mode=mode, model_id="seeddance-2.0",
    )
    assert "anamorphic wide" in result
    assert "tele lens" not in result


def test_camera_line_with_shot_override():
    """Per-shot lens_type_override takes highest precedence."""
    mode = {"lens_per_shot_size_override": {"MS": "should_not_appear"}}
    result = render_camera_line(
        _fake_shot("MS", "tracking", cinematography={"lens_type_override": "fisheye"}),
        mode=mode, model_id="seeddance-2.0",
    )
    assert "fisheye" in result
    assert "should_not_appear" not in result


def test_camera_line_empty_on_missing_shot_type():
    """Returns empty string when shot_type is missing."""
    result = render_camera_line(
        _fake_shot(), mode=None, model_id="seeddance-2.0",
    )
    assert result == ""


def test_camera_line_static_camera_omits_movement():
    """Static camera does not append movement token."""
    result = render_camera_line(
        _fake_shot("WS", "static"), mode=None, model_id="seeddance-2.0",
    )
    assert "Camera:" in result
    assert "wide lens" in result
    assert "static" not in result


# ============================================================
# 8b. render_constraint_block — emit policy
# ============================================================

def test_constraint_positive_suffix_for_no_neg_prompt_model():
    """Models without negative_prompt get positive-suffix bans."""
    pos, neg = render_constraint_block(
        ["no_modern_color_grade", "no_text_overlays"],
        "seeddance-2.0",
    )
    assert pos.startswith("Constraints:")
    assert "no modern color grade" in pos
    assert "no text overlays" in pos
    assert neg == []


def test_constraint_negative_list_for_neg_prompt_model():
    """Models with negative_prompt get phrases in neg list."""
    pos, neg = render_constraint_block(
        ["no_modern_color_grade", "no_text_overlays"],
        "kling-v3",
    )
    assert pos == ""
    assert len(neg) == 2
    assert "modern color grade, teal and orange, HDR look" in neg
    assert "text overlays, watermarks, UI elements, subtitles" in neg


def test_constraint_empty_input():
    """Empty constraint list returns empty output."""
    pos, neg = render_constraint_block([], "seeddance-2.0")
    assert pos == ""
    assert neg == []


def test_constraint_unknown_slug_skipped(caplog):
    """Unknown slugs are skipped with a WARNING."""
    import logging
    with caplog.at_level(logging.WARNING):
        pos, neg = render_constraint_block(
            ["no_modern_color_grade", "TOTALLY_FAKE_SLUG"],
            "seeddance-2.0",
        )
    assert "TOTALLY_FAKE_SLUG" not in pos
    assert "modern color grade" in pos
    assert any("TOTALLY_FAKE_SLUG" in msg for msg in caplog.messages)


def test_constraint_all_six_slugs_resolve():
    """All 6 Phase 2a slugs resolve to non-empty phrases."""
    slugs = [
        "no_modern_color_grade", "no_digital_sharpness",
        "no_text_overlays", "no_smooth_video_look",
        "no_camera_shake", "no_extra_characters",
    ]
    pos, neg = render_constraint_block(slugs, "seeddance-2.0")
    assert pos.startswith("Constraints:")
    clauses = pos.removeprefix("Constraints: ").removesuffix(".").split("; ")
    assert len(clauses) >= 6
    assert all(c.startswith("no ") for c in clauses)


# ============================================================
# 8c. render_cinema_tokens — new 70s mode integration
# ============================================================

def test_seventies_mode_full_render_seeddance():
    """70s mode renders with full tokens on seeddance."""
    s = render_cinema_tokens("seventies_new_wave_arriflex_cookes4", "seeddance-2.0")
    assert "Arriflex 35BL" in s
    assert "Cooke S4/i" in s
    assert "Kodak 5247" in s
    assert "T2.0" in s
    assert "180-degree shutter" in s
    assert "Kodachrome" in s
    assert "heavy 35mm pushed film grain" in s
    assert ". " in s


def test_seventies_mode_compressed_render_kling():
    """70s mode renders compressed on kling (body/aperture/shutter nulled)."""
    s = render_cinema_tokens("seventies_new_wave_arriflex_cookes4", "kling-v3")
    assert "Arriflex" not in s
    assert "T2.0" not in s
    assert "Cooke S4/i spherical prime lenses" in s
    assert "classic Cooke Look" not in s
