"""Tests for recoil.api.adapters.beats — episode_id canonical-field contract.

Build A Phase 4 retired the stage-4 filename-prefix fallback in
``_derive_episode_id``.  A non-REF shot with no resolvable episode_id is now a
structurally-absent canonical field: ``list_beats`` / ``_synthesize_episodes``
raise ``MissingCanonicalFieldError`` (surfaced as HTTP 422 at the route
boundary) instead of silently dropping the shot.

These tests pin:
  * the still-valid derivation ladder (stages 1-3 + REF_ → None), and
  * the new raise behavior for absent-and-not-derivable episode_id.

(Was a cadence-guard test for the retired ``episode_id_derived_from_filename_prefix``
fallback emit — that emit and its ``_FALLBACK_FILENAME_PREFIX_SEEN`` guard no
longer exist; B6 rewrote this file to assert the 422 contract instead.)
"""
from __future__ import annotations

import json
from pathlib import Path

import pytest

import recoil.api.adapters.beats as beats_mod
from recoil.api.adapters.beats import _derive_episode_id
from recoil.api.schemas.engine import MissingCanonicalFieldError


@pytest.fixture(autouse=True)
def _clear_project_cache():
    """core.project.get_project is @lru_cache'd by slug (NOT by projects
    root). Tests below build synthetic projects under tmp roots; clearing the
    cache before+after prevents a synthetic Project from leaking into other
    tests (e.g. the wire-shape e2e that reads the real projects/ root)."""
    from recoil.core.project import get_project as _gp

    _gp.cache_clear()
    yield
    _gp.cache_clear()


# ── Derivation ladder: stages 1-3 still resolve (no raise) ───────────────────


def test_stage1_direct_episode_id_field(tmp_path: Path) -> None:
    """Stage 1 — explicit episode_id field short-circuits everything."""
    shot = {"episode_id": "EP001"}
    path = tmp_path / "EP001_SH01.json"
    assert _derive_episode_id(shot, path, project_id="tartarus") == "EP001"


def test_stage2_derives_from_output_path(tmp_path: Path) -> None:
    """Stage 2 — /ep_NNN/ in output_path (driver-beware convention)."""
    shot = {"output_path": "output/video/ep_007/sh02_take1.mp4"}
    path = tmp_path / "SEEDANCE_I2V_1778032783.json"
    assert _derive_episode_id(shot, path, project_id="driver-beware") == "ep_007"


def test_stage3_derives_from_sh_filename(tmp_path: Path) -> None:
    """Stage 3 — `_SH` delimiter in the filename (microdrama convention)."""
    shot: dict = {}
    path = tmp_path / "EP012_SH03.json"
    assert _derive_episode_id(shot, path, project_id="tartarus") == "EP012"


def test_ref_file_returns_none_not_raise(tmp_path: Path) -> None:
    """REF_-prefixed files are reference assets — a None episode_id is
    EXPECTED, not a missing canonical field."""
    shot: dict = {}
    path = tmp_path / "REF_jade_character.json"
    assert _derive_episode_id(shot, path, project_id="tartarus") is None


# ── Retired stage 4: unresolvable episode_id now returns None ────────────────


def test_unresolvable_episode_id_returns_none(tmp_path: Path) -> None:
    """A non-REF shot with no episode_id, no output_path, and no `_SH`
    delimiter is no longer laundered through a filename-prefix fallback —
    the helper returns None and the caller decides (raise at synthesis/beats
    boundary)."""
    shot: dict = {}
    path = tmp_path / "REGEN_orphan.json"
    assert _derive_episode_id(shot, path, project_id="driver-beware") is None


# ── New contract: caller raises MissingCanonicalFieldError ───────────────────


def _write_shot(root: Path, project_id: str, filename: str, shot: dict) -> Path:
    (root / ".recoil-data-root").write_text("recoil-data-root\n")
    shots = root / project_id / "_pipeline" / "state" / "visual" / "shots"
    shots.mkdir(parents=True, exist_ok=True)
    p = shots / filename
    p.write_text(json.dumps(shot), encoding="utf-8")
    return p


def test_synthesize_episodes_raises_on_absent_episode_id(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """_synthesize_episodes raises MissingCanonicalFieldError when a non-REF
    shot has no resolvable episode_id (was: silently skipped)."""
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))
    _write_shot(tmp_path, "orphanproj", "REGEN_orphan.json",
                {"shot_id": "REGEN_orphan", "takes": []})

    with pytest.raises(MissingCanonicalFieldError) as exc_info:
        beats_mod._synthesize_episodes("orphanproj")
    assert exc_info.value.field == "episode_id"
    assert exc_info.value.project_id == "orphanproj"
    assert exc_info.value.fix_cli  # remediation command populated


def test_list_beats_raises_on_absent_episode_id(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """list_beats raises MissingCanonicalFieldError when a non-REF shot under
    the requested episode has no resolvable episode_id (was: silently dropped
    from the beat list)."""
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))
    _write_shot(tmp_path, "orphanproj", "REGEN_orphan.json",
                {"shot_id": "REGEN_orphan", "takes": []})

    with pytest.raises(MissingCanonicalFieldError) as exc_info:
        beats_mod.list_beats(
            "orphanproj", "EP001", "EP001__synthetic_scene_1"
        )
    assert exc_info.value.field == "episode_id"
    assert exc_info.value.project_id == "orphanproj"


def test_synthesize_episodes_ref_only_no_raise(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A project with only REF_ assets synthesizes an empty episode list with
    NO raise — REF_ files legitimately have no episode_id."""
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))
    _write_shot(tmp_path, "refonly", "REF_char.json",
                {"shot_id": "REF_char", "takes": []})

    assert beats_mod._synthesize_episodes("refonly") == []
