"""Unit tests for the krea2-flora Phase 3 project_config binding + validation.

Fast + offline. Self-contained tmp_path fixtures — these tests do NOT read or
mutate any real project's project_config.json. The XOR rule under test:

    A project may set EITHER `look` OR a project-level `cinema_mode`, never
    both (a Look already carries `extends_cinema_mode`, so both is
    contradictory). Absent → byte-identical to today.

Two surfaces are exercised:
  1. `validate_project_binding(cfg)` directly (the single validation point).
  2. `load_project_config(project)` end-to-end (the canonical loader in
     recoil_bridge.py), pointed at a tmp projects_root so no real project is
     touched.

The look-only resolution check also asserts that the effective cinema mode
equals `look.extends_cinema_mode` via the Phase-1 `resolve_look` resolver,
using the committed example look `noir_neon` (extends_cinema_mode=noir_tension).
"""

from __future__ import annotations

import json

import pytest

from recoil.pipeline._lib import recoil_bridge
from recoil.pipeline._lib.look_loader import (
    ProjectBindingError,
    reload_registries,
    resolve_look,
    validate_project_binding,
)


# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #


def _write_project_config(tmp_path, monkeypatch, body: dict, project: str = "_look_fixture"):
    """Materialize a tmp projects_root/<project>/project_config.json and point
    recoil_bridge.projects_root() at it. Returns the project name.

    Patches projects_root in recoil_bridge so load_project_config() reads our
    tmp config and NEVER any real project under the real projects_root.
    """
    root = tmp_path / "projects_root"
    proj_dir = root / project
    proj_dir.mkdir(parents=True, exist_ok=True)
    (proj_dir / "project_config.json").write_text(json.dumps(body), encoding="utf-8")
    monkeypatch.setattr(recoil_bridge, "projects_root", lambda: root)
    return project


# --------------------------------------------------------------------------- #
# validate_project_binding — direct unit tests of the XOR rule
# --------------------------------------------------------------------------- #


def test_both_look_and_cinema_mode_raises():
    """BOTH look + cinema_mode set → ProjectBindingError (contradictory)."""
    with pytest.raises(ProjectBindingError) as exc:
        validate_project_binding({"look": "noir_neon", "cinema_mode": "noir_tension"})
    msg = str(exc.value)
    assert "look" in msg and "cinema_mode" in msg


def test_only_look_is_valid():
    """only look set → valid (cinema mode comes from look.extends_cinema_mode)."""
    validate_project_binding({"look": "noir_neon"})  # must not raise


def test_only_cinema_mode_is_valid():
    """only cinema_mode set (no look) → unchanged, valid."""
    validate_project_binding({"cinema_mode": "seventies_new_wave_arriflex_cookes4"})


def test_neither_is_valid():
    """NEITHER set → valid (byte-identical to today)."""
    validate_project_binding({})
    validate_project_binding({"aspect_ratio": "9_16"})


def test_empty_string_cinema_mode_with_look_is_valid():
    """An empty-string cinema_mode is treated as 'not set' (matches the
    loader's skip-empty-string merge policy) — so look + "" is NOT a conflict."""
    validate_project_binding({"look": "noir_neon", "cinema_mode": ""})


def test_none_config_is_noop():
    """None / non-dict config → tolerant no-op (no raise)."""
    validate_project_binding(None)
    validate_project_binding("not a dict")  # type: ignore[arg-type]


# --------------------------------------------------------------------------- #
# load_project_config — end-to-end through the canonical loader
# --------------------------------------------------------------------------- #


def test_loader_raises_on_both(tmp_path, monkeypatch):
    """load_project_config raises ProjectBindingError when a config sets BOTH."""
    proj = _write_project_config(
        tmp_path, monkeypatch,
        {"look": "noir_neon", "cinema_mode": "noir_tension"},
    )
    with pytest.raises(ProjectBindingError):
        recoil_bridge.load_project_config(proj)


def test_loader_only_look_loads_and_resolves_cinema_mode(tmp_path, monkeypatch):
    """A config with only `look` loads without error, and the effective cinema
    mode resolves from the look's extends_cinema_mode via resolve_look."""
    reload_registries()  # use committed registry (conftest self-heals refs)
    proj = _write_project_config(tmp_path, monkeypatch, {"look": "noir_neon"})

    cfg = recoil_bridge.load_project_config(proj)  # must not raise
    assert cfg["look"] == "noir_neon"
    assert "cinema_mode" not in cfg  # never set when only look is bound

    # resolve_look returns the look; effective cinema mode == extends_cinema_mode.
    look = resolve_look({"project_config": cfg})
    assert look is not None
    assert look["look_id"] == "noir_neon"
    assert look["extends_cinema_mode"] == "noir_tension"


def test_loader_only_cinema_mode_unchanged(tmp_path, monkeypatch):
    """only cinema_mode (no look) → loads valid, resolve_look returns None."""
    proj = _write_project_config(
        tmp_path, monkeypatch,
        {"cinema_mode": "seventies_new_wave_arriflex_cookes4"},
    )
    cfg = recoil_bridge.load_project_config(proj)  # must not raise
    assert cfg["cinema_mode"] == "seventies_new_wave_arriflex_cookes4"
    assert resolve_look({"project_config": cfg}) is None


def test_loader_neither_is_unchanged(tmp_path, monkeypatch):
    """NEITHER look nor cinema_mode → loads valid, resolve_look returns None
    (byte-identical to today's behavior for current projects)."""
    proj = _write_project_config(tmp_path, monkeypatch, {"aspect_ratio": "9_16"})
    cfg = recoil_bridge.load_project_config(proj)  # must not raise
    assert "look" not in cfg
    assert resolve_look({"project_config": cfg}) is None


# --------------------------------------------------------------------------- #
# The committed live fixture (deferred generate.py run consumes this)
# --------------------------------------------------------------------------- #


def test_committed_look_fixture_is_valid():
    """The dedicated _look_fixture config snippet binds noir_neon + kara_voss
    and validates (look set, cinema_mode intentionally omitted)."""
    from pathlib import Path

    fixture = (
        Path(__file__).parent / "fixtures" / "_look_fixture_project_config.json"
    )
    body = json.loads(fixture.read_text(encoding="utf-8"))
    assert body["look"] == "noir_neon"
    assert "cinema_mode" not in body  # XOR-compliant
    assert body["identities"] == {"KARA_VOSS": "kara_voss"}
    validate_project_binding(body)  # must not raise

    reload_registries()
    look = resolve_look({"project_config": body})
    assert look is not None and look["extends_cinema_mode"] == "noir_tension"


# --------------------------------------------------------------------------- #
# Render-path enforcement — StepRunner._resolve_look_bundle
#
# The StepRunner's _paths is the EPISODE-SCOPED step_types.ProjectPaths, which
# has `project_root` but NOT the `project_config_path` property. These tests
# guard two regressions: (1) the look must still resolve via the project_root
# fallback (else the whole feature is silently dead on the real render path);
# (2) a contradictory binding must fail LOUD on the render path, not be
# swallowed by execute_keyframe's best-effort guard.
# --------------------------------------------------------------------------- #


def _make_step_runner(tmp_path, config):
    from unittest.mock import MagicMock

    from recoil.execution.step_runner import StepRunner
    from recoil.execution.step_types import ProjectPaths

    (tmp_path / "project_config.json").write_text(json.dumps(config), encoding="utf-8")
    paths = ProjectPaths(
        project="t",
        project_root=tmp_path,
        frames_dir=tmp_path / "frames",
        video_dir=tmp_path / "video",
        plans_dir=tmp_path / "plans",
        previs_dir=tmp_path / "previs",
    )
    return StepRunner(store=MagicMock(), paths=paths)


def test_render_path_resolves_look_via_project_root(tmp_path):
    """_resolve_look_bundle must find project_config.json via project_root even
    though the episode-scoped ProjectPaths exposes no project_config_path."""
    reload_registries()
    runner = _make_step_runner(tmp_path, {"look": "noir_neon"})
    bundle = runner._resolve_look_bundle("seedream-v4.5", {"characters": []})
    assert bundle is not None, "look silently failed to resolve on render path"
    assert bundle.provenance.get("look_id") == "noir_neon"


def test_render_path_no_binding_returns_none(tmp_path):
    reload_registries()
    runner = _make_step_runner(tmp_path, {"cinema_mode": "noir_tension"})
    assert runner._resolve_look_bundle("seedream-v4.5", {"characters": []}) is None


def test_render_path_contradictory_binding_raises(tmp_path):
    """Both look + cinema_mode → ProjectBindingError on the render path (loud)."""
    reload_registries()
    runner = _make_step_runner(
        tmp_path, {"look": "noir_neon", "cinema_mode": "noir_tension"}
    )
    with pytest.raises(ProjectBindingError):
        runner._resolve_look_bundle("seedream-v4.5", {"characters": []})
