# api/routes/reroll.py
"""Reroll endpoint — REC-111 (consult decision #17, the merged contract).

``POST /reroll`` rerolls the single r2v_multi beat of one persisted continuity/
oner batch scene. SINGLE-BEAT contract: continuity batch scenes persist exactly
one r2v_multi beat. ``dry_run`` returns a budget estimate with ZERO state writes.

NO environment mutation anywhere here (env set+restore is race-prone under
concurrent requests). The strategy override + note ride explicit ``run_scene``
kwargs, never ``RECOIL_AUTHOR_STRATEGY``.
"""

import asyncio

from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse

from recoil.pipeline.core.persistence import (
    SceneVersionConflictError,
    active_scene_body_path,
    load_scene_active_with_version,
)
from recoil.pipeline.orchestrator.batch_selector import (
    parse_batch_selector,
    verify_scene_grouping_metadata,
)
from recoil.pipeline.orchestrator.episode_runner import (
    BoardGateError,
    BudgetExhaustedError,
    EpisodeRunner,
    RerollPreflightError,
    _preflight_board_gate,
)

router = APIRouter(tags=["reroll"])

_DEFAULT_BUDGET_USD = 25.0


def _runner_for_reroll(project: str, episode: int) -> EpisodeRunner:
    """Build the EpisodeRunner seam for a reroll. Monkeypatched in tests."""
    from recoil.execution.execution_store import ExecutionStore
    from recoil.execution.step_runner import StepRunner
    from recoil.execution.step_types import ProjectPaths

    paths = ProjectPaths.for_episode(project, episode)
    store = ExecutionStore(project, migrate=False)
    step_runner = StepRunner(store=store, paths=paths, episode=episode)
    return EpisodeRunner(
        project=project,
        plan={},
        episode=f"ep_{episode:03d}",
        budget_usd=_DEFAULT_BUDGET_USD,
        step_runner=step_runner,
        strategy_engine=None,
    )


@router.post("/reroll")
def reroll(body: dict | None = Body(default=None)):
    """Reroll one continuity/oner batch's single r2v_multi beat (REC-111)."""
    body = body or {}
    if not isinstance(body, dict):
        return JSONResponse({"error": "request body must be an object"}, status_code=422)
    allowed_fields = {"project", "episode", "batch_id", "strategy", "note", "dry_run"}
    extra_fields = sorted(set(body) - allowed_fields)
    if extra_fields:
        return JSONResponse(
            {"error": "unsupported_fields", "fields": extra_fields},
            status_code=422,
        )

    project = body.get("project")
    episode = body.get("episode")
    batch_id = body.get("batch_id")
    strategy = body.get("strategy")
    note = body.get("note")
    dry_run = (body.get("dry_run", False))

    # project + episode are REQUIRED — never inferred from defaults or batch_id.
    if not project or not isinstance(project, str):
        return JSONResponse({"error": "project is required"}, status_code=422)
    if not isinstance(episode, int) or isinstance(episode, bool):
        return JSONResponse({"error": "episode is required (int)"}, status_code=422)
    if not batch_id or not isinstance(batch_id, str):
        return JSONResponse({"error": "batch_id is required"}, status_code=422)

    selector = parse_batch_selector(batch_id)
    if selector is None:
        return JSONResponse({"error": "invalid_batch_selector"}, status_code=422)
    if selector.episode != episode:
        return JSONResponse(
            {
                "error": "invalid_batch_selector",
                "message": (
                    f"batch_id episode EP{selector.episode:03d} does not match "
                    f"episode {episode}"
                ),
            },
            status_code=422,
        )
    if strategy is not None and not isinstance(strategy, str):
        return JSONResponse({"error": "strategy must be string or null"}, status_code=422)
    if strategy is not None:
        from recoil.pipeline._lib.author_strategies import AUTHOR_STRATEGIES

        known = {
            name for (_m, _mod, name) in AUTHOR_STRATEGIES if _mod == "r2v_multi"
        }
        if strategy not in known:
            return JSONResponse(
                {"error": "unknown_author_strategy",
                 "message": f"strategy {strategy!r} not in {sorted(known)}"},
                status_code=422,
            )
    if not isinstance(dry_run, bool):
        return JSONResponse({"error": "dry_run must be boolean"}, status_code=422)
    if note is not None and not isinstance(note, str):
        return JSONResponse({"error": "note must be string or null"}, status_code=422)

    episode_token = f"ep_{episode:03d}"
    # REC-231 Phase 4: read the ACTIVE version body via the pointer, capturing the
    # version atomically so a downstream status write targets the version it loaded.
    try:
        scene, expected_version = load_scene_active_with_version(
            project, episode_token, selector.scene_id
        )
    except FileNotFoundError:
        return JSONResponse(
            {
                "error": "batch_scene_missing",
                "path": str(
                    active_scene_body_path(project, episode_token, selector.scene_id)
                ),
            },
            status_code=404,
        )
    path = active_scene_body_path(project, episode_token, selector.scene_id)

    if len(scene.beats) != 1:
        return JSONResponse({"error": "batch_not_single_beat"}, status_code=422)
    beat = scene.beats[0]
    if beat.beat_metadata.get("modality") != "r2v_multi":
        return JSONResponse(
            {"error": "batch_not_single_beat",
             "message": "the batch beat is not an r2v_multi target"},
            status_code=422,
        )

    try:
        verify_scene_grouping_metadata(scene, selector, beat)
    except RerollPreflightError as exc:
        return JSONResponse(
            {"error": exc.error_code, "message": str(exc)}, status_code=422
        )

    runner = _runner_for_reroll(project, episode)
    budget_estimate_usd = runner._estimate_take_cost(beat)

    if dry_run:
        # ZERO state writes: no prepare_beat_for_reroll (it mutates/persists),
        # no dispatch. Estimate only.
        return JSONResponse(
            {"dispatched": [], "budget_estimate_usd": budget_estimate_usd}
        )

    # Board gate pre-scan BEFORE the first mutating call: prepare_beat_for_reroll
    # clears stale primaries + persists the scene, and a gate-blocked reroll must
    # leave scene state untouched (Phase 6: zero state writes before the scan).
    try:
        _preflight_board_gate(project=project, episode=episode, beats=[beat])
    except BoardGateError as exc:
        return JSONResponse(
            {"error": "board_gate_blocked", "message": str(exc), "beat_id": exc.beat_id},
            status_code=409,
        )
    except SceneVersionConflictError as exc:
        return JSONResponse(
            {
                "success": False,
                "error": "scene_version_conflict",
                "message": str(exc),
                "batch_id": exc.batch_id,
                "expected_version": exc.expected_version,
                "current_version": exc.actual_version,
            },
            status_code=409,
        )

    prep = runner.prepare_beat_for_reroll(scene, beat, expected_version=expected_version)
    take_number = prep["next_take_index"] + 1
    try:
        asyncio.run(
            runner.run_scene(
                scene,
                force_new_take=True,
                reroll_beat_id=prep["beat_id"],
                strategy_override=strategy,
                reroll_note=note,
                allow_cleared_stale=True,
                expected_version=expected_version,
            )
        )
    except RerollPreflightError as exc:
        return JSONResponse(
            {"error": exc.error_code, "message": str(exc)}, status_code=422
        )
    except BudgetExhaustedError as exc:
        return JSONResponse(
            {"error": "budget_exhausted", "message": str(exc)}, status_code=402
        )
    except BoardGateError as exc:
        return JSONResponse(
            {"error": "board_gate_blocked", "message": str(exc), "beat_id": exc.beat_id},
            status_code=409,
        )
    except SceneVersionConflictError as exc:
        return JSONResponse(
            {
                "success": False,
                "error": "scene_version_conflict",
                "message": str(exc),
                "batch_id": exc.batch_id,
                "expected_version": exc.expected_version,
                "current_version": exc.actual_version,
            },
            status_code=409,
        )

    new_takes = [t for t in beat.takes if t.take_index >= prep["next_take_index"]]
    newest = new_takes[-1] if new_takes else None
    if newest is not None:
        take_number = newest.take_index + 1
    if newest is None or newest.status != "succeeded":
        return JSONResponse(
            {
                "error": "dispatch_failed",
                "message": "reroll dispatched but the new take did not succeed",
                "beat_id": prep["beat_id"],
                "take_number": take_number,
                "take_status": getattr(newest, "status", "missing"),
            },
            status_code=502,
        )
    return JSONResponse(
        {
            "dispatched": [
                {
                    "beat_id": prep["beat_id"],
                    "take_number": take_number,
                    "batch_file": path.name,
                }
            ],
            "budget_estimate_usd": budget_estimate_usd,
        }
    )
