"""Tests for REC-19: exhausted phantom primary still skipped after REC-14 fix.

Beat.is_exhausted flipped True after _demote_take cleared primary_take_id,
so run_scene filtered the beat out even though the artifact was merely missing.

Fix (option C): phantom_recovery_count on Beat grants bounded extra slots —
up to _MAX_PHANTOM_RECOVERIES (2) phantom re-dispatches, then genuinely failed.
"""
import pytest
from unittest.mock import MagicMock, patch

from recoil.pipeline.core.take import Beat, Take
from recoil.pipeline.core.workflow import Workflow, WorkflowStep
from recoil.pipeline.orchestrator.episode_runner import EpisodeRunner
from recoil.pipeline.orchestrator.production_types import RetryCostPolicy


# ── Autouse fixture ────────────────────────────────────────────────────


@pytest.fixture(autouse=True)
def _isolate(monkeypatch):
    monkeypatch.setattr(
        "recoil.pipeline.orchestrator.episode_runner.init_scenes_from_plan",
        lambda *a, **kw: None,
    )
    monkeypatch.setattr(
        "recoil.pipeline.orchestrator.episode_runner.ops_log.write",
        lambda *a, **kw: None,
    )


# ── Helpers ────────────────────────────────────────────────────────────


def _make_runner():
    return EpisodeRunner(
        project="test_proj",
        plan={"sequences": {"seq_01": {"shots": [{"shot_id": "SH01"}]}}},
        max_takes=3,
        budget_usd=50.0,
        step_runner=MagicMock(),
        retry_cost_policy=RetryCostPolicy(max_retry_spend_usd=6.0),
    )


def _make_beat(max_takes: int = 1) -> Beat:
    return Beat(beat_id="SH01", max_takes=max_takes)


def _make_succeeded_take(beat: Beat) -> Take:
    """Append a succeeded take to the beat and return it."""
    wf = Workflow(
        workflow_id=f"{beat.beat_id}_take_{len(beat.takes)}",
        steps=[WorkflowStep(step_id="video", modality="video_i2v",
                            payload={"shot_id": beat.beat_id})],
        global_provenance={},
    )
    take = beat.new_take(workflow=wf)
    take.status = "succeeded"
    return take


# ── Test 1: exhausted phantom → not exhausted after grant ─────────────


def test_exhausted_phantom_becomes_eligible_after_demotion():
    """Exhausted beat (max_takes=1, 1 take) with a phantom succeeded take:
    after phantom demotion via _demote_take, the beat should NOT be exhausted
    so run_scene will pick it up for re-dispatch (REC-19)."""
    runner = _make_runner()
    beat = _make_beat(max_takes=1)
    take = _make_succeeded_take(beat)
    beat.primary_take_id = take.take_id

    # Before demotion: beat has primary, is not exhausted
    assert not beat.is_exhausted
    assert beat.primary_take is not None

    # Simulate phantom demotion
    runner._demote_take(beat, take, phantom=True)

    assert beat.primary_take is None
    assert beat.phantom_recovery_count == 1
    # is_exhausted must be False — effective_max = 1 + 1 = 2, len = 1 < 2
    assert not beat.is_exhausted


# ── Test 2: second phantom → still eligible ────────────────────────────


def test_second_phantom_still_eligible():
    """A beat that receives two consecutive phantom demotions (both artifacts
    missing) should still be eligible after the second — effective_max = 1+2=3,
    len=2 < 3."""
    runner = _make_runner()
    beat = _make_beat(max_takes=1)

    take0 = _make_succeeded_take(beat)
    beat.primary_take_id = take0.take_id
    runner._demote_take(beat, take0, phantom=True)
    assert beat.phantom_recovery_count == 1
    assert not beat.is_exhausted

    take1 = _make_succeeded_take(beat)
    beat.primary_take_id = take1.take_id
    runner._demote_take(beat, take1, phantom=True)
    assert beat.phantom_recovery_count == 2
    # effective_max = 1 + 2 = 3, len = 2 < 3 → still eligible
    assert not beat.is_exhausted


# ── Test 3: cap — third phantom does not grant another slot ───────────


def test_phantom_recovery_capped_at_max():
    """After _MAX_PHANTOM_RECOVERIES phantom demotions, a third phantom does
    not increment the count or extend the effective limit — the beat is
    genuinely exhausted."""
    runner = _make_runner()
    beat = _make_beat(max_takes=1)

    # Grant two recoveries (cap)
    for _ in range(Beat._MAX_PHANTOM_RECOVERIES):
        t = _make_succeeded_take(beat)
        beat.primary_take_id = t.take_id
        runner._demote_take(beat, t, phantom=True)
    assert beat.phantom_recovery_count == 2

    # Third phantom: count must not increase
    t = _make_succeeded_take(beat)
    beat.primary_take_id = t.take_id
    runner._demote_take(beat, t, phantom=True)
    assert beat.phantom_recovery_count == 2  # capped
    # effective_max = 1 + 2 = 3, len = 3 → exhausted
    assert beat.is_exhausted


# ── Test 4: fingerprint-drift demotion does not increment count ────────


def test_fingerprint_drift_demotion_does_not_increment_phantom_count():
    """_demote_take called WITHOUT phantom=True (REC-12 fingerprint drift path)
    must not touch phantom_recovery_count."""
    runner = _make_runner()
    beat = _make_beat(max_takes=1)
    take = _make_succeeded_take(beat)
    beat.primary_take_id = take.take_id

    runner._demote_take(beat, take)  # phantom=False (default)

    assert beat.phantom_recovery_count == 0


# ── Test 5: serialization round-trip ─────────────────────────────────


def test_phantom_recovery_count_serializes():
    """phantom_recovery_count survives to_dict / from_dict (needed for resume
    so an interrupted overnight run doesn't lose its recovery state)."""
    beat = _make_beat(max_takes=2)
    beat.phantom_recovery_count = 1
    d = beat.to_dict()
    assert d["phantom_recovery_count"] == 1

    restored = Beat.from_dict(d)
    assert restored.phantom_recovery_count == 1


def test_phantom_recovery_count_defaults_to_zero_on_old_scene_json():
    """Existing scene JSON files omit phantom_recovery_count — from_dict must
    default to 0 so old scenes load cleanly without error."""
    beat = _make_beat()
    d = beat.to_dict()
    del d["phantom_recovery_count"]  # simulate pre-REC-19 scene file

    restored = Beat.from_dict(d)
    assert restored.phantom_recovery_count == 0
