"""Phase 19 — real mutation routes that emit EventBus events.

Replaces the Phase 17 stub POST handlers in ``stub_routes.py``. Wire shape
is unchanged — every route still returns ``{"ok": true}`` — but each call
also:

  • Emits an EventBus event (visible on the SSE stream).
  • Updates ``stub_routes._acted`` so ``/api/queue`` filtering remains
    correct (proposals removed from pending after approve/reject/defer).
  • For take mutations: writes to projects/{slug}/state/visual/shots/*.json
    via the beats adapter when the take id can be resolved on disk.
    Unresolvable take ids (e.g. fixture ids the UI carries) silently fall
    through — the stub bookkeeping still runs and the UI sees ok=true.

The 8 ProposalKind cases are dispatched through ``proposal_dispatch.py``
which decides whether engine execution fires (none today — all kinds emit
``deferred_execution: true`` in their event payload).
"""
from __future__ import annotations

import logging
from typing import Any, Optional

from fastapi import APIRouter, HTTPException, Query, status
from pydantic import BaseModel

from recoil.api.adapters import beats as beats_adapter
from recoil.api.adapters import memory as memory_adapter
from recoil.api.adapters._ids import validate_hierarchy_id
from recoil.api.proposal_dispatch import (
    emit_decision,
    emit_memory_action,
    emit_take_action,
)
from recoil.api.fallback_bridge import emit_fallback


class ToggleBody(BaseModel):
    """Optional body for the idempotent toggle endpoints (Debug R1 fix).

    If ``value`` is provided, the route SETS the flag to that value
    (idempotent — safe to retry). If absent, the route preserves the
    legacy toggle-on-each-call behavior so existing UI callers keep
    working.
    """

    value: Optional[bool] = None

# Reuse the Phase-17 in-memory acted-on set so /api/queue keeps filtering
# correctly. We deliberately do NOT migrate this to a new module — the
# state machine is small, scoped to GET /api/queue, and dies on restart.
from recoil.api import stub_routes

logger = logging.getLogger(__name__)

router = APIRouter()


def _ok() -> dict[str, bool]:
    return {"ok": True}


def _proposal_kind(proposal_id: str) -> str | None:
    """Look up the ProposalKind for an id.

    Resolution order:
      1. ``stub_routes._pending_items()`` — the eight canonical fixtures.
         Filters out already-acted ids, so this only resolves on the
         FIRST action against a given id.
      2. ``stub_routes._proposal_kind_in_acted()`` — the per-process
         ``_acted`` history. Stores kind alongside id when the proposal
         is moved to acted, so a repeat action (approve-then-reject
         testing) still surfaces a labelled event payload.

    Returns ``None`` for ids that have never been pending (test/unknown
    ids); the EventBus surfaces a generic "Proposal" label in that case.

    Bug-fix (Debug R7 BUG-6): the (1)→(2) fallback is the fix. Without
    it the second action on the same id always degraded to ``kind=None``
    because step 1 was the only resolver.
    """
    try:
        items = stub_routes._pending_items()
    except Exception:  # noqa: BLE001
        items = []
    for it in items:
        if it.get("id") == proposal_id:
            return it.get("kind")
    # Fallback — recover the kind we stored when the proposal was moved
    # to _acted. Returns None for ids that were never in pending.
    return stub_routes._proposal_kind_in_acted(proposal_id)


# ── Proposals ─────────────────────────────────────────────────────────────


def _decide_proposal(proposal_id: str, decision: str, bucket: str) -> dict[str, bool]:
    try:
        validate_hierarchy_id("proposal_id", proposal_id)
    except ValueError as exc:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
        ) from exc
    kind = _proposal_kind(proposal_id)
    was_pending = stub_routes._is_known_proposal_id(proposal_id)
    stub_routes._acted_add(bucket, proposal_id, kind)
    emit_decision(
        proposal_id=proposal_id,
        decision=decision,
        was_pending=was_pending,
        kind=kind,
        scope="engine/proposals",
    )
    return _ok()


@router.post("/proposals/{proposal_id}/approve")
async def approve_proposal(proposal_id: str) -> dict[str, bool]:
    return _decide_proposal(proposal_id, "approved", "approved_proposals")


@router.post("/proposals/{proposal_id}/reject")
async def reject_proposal(proposal_id: str) -> dict[str, bool]:
    return _decide_proposal(proposal_id, "rejected", "rejected_proposals")


@router.post("/proposals/{proposal_id}/defer")
async def defer_proposal(proposal_id: str) -> dict[str, bool]:
    return _decide_proposal(proposal_id, "deferred", "deferred_proposals")


# ── Takes ─────────────────────────────────────────────────────────────────


def _try_disk_mutation(
    action_name: str,
    fn,
    *,
    take_id: str,
) -> dict[str, Any] | None:
    """Run a beats-adapter mutation; swallow KeyError (id not on disk).

    Returns the adapter's result dict on success, None when the take id
    didn't resolve to a shot file. Other exceptions bubble — callers can
    let FastAPI surface them as 500.

    Phase 2 (console-v2-fix): on KeyError we now emit the
    ``take_id_not_on_disk`` sanctioned fallback (Law 4 / Tenet 6 prong-1)
    instead of a silent ``logger.debug``. Body still preserves
    ``{"ok": True}`` for the fixture demo (per JT decision A); the SSE
    event severity flips to ``"fallback"`` so operators can see the gap.
    """
    try:
        return fn()
    except KeyError:
        emit_fallback(
            "take_id_not_on_disk",
            scope=f"api/mutations/{action_name}",
            payload={"take_id": take_id, "action": action_name},
        )
        return None


@router.post("/takes/{take_id}/mark-primary")
async def mark_primary(
    take_id: str,
    project_id: Optional[str] = Query(default=None, alias="projectId"),
) -> dict[str, bool]:
    try:
        extra = _try_disk_mutation(
            "mark_primary",
            lambda: beats_adapter.set_primary(take_id, project_id=project_id),
            take_id=take_id,
        )
    except ValueError as exc:  # Debug R1 — path-traversal guard.
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
        ) from exc
    stub_routes._acted_add("primary_takes", take_id)
    emit_take_action(
        take_id=take_id,
        action="marked_primary",
        scope="engine/takes",
        extra=extra,
        severity="fallback" if extra is None else "success",
    )
    return _ok()


@router.post("/takes/{take_id}/mark-circled")
async def mark_circled(
    take_id: str,
    project_id: Optional[str] = Query(default=None, alias="projectId"),
    body: ToggleBody = ToggleBody(),
) -> dict[str, bool]:
    """Mark a take 'circled'.

    Debug R1 fix — idempotent when caller provides ``{"value": bool}`` in
    the request body. Without a body the legacy toggle-on-each-call
    semantics are preserved so existing UI callers keep working.
    """
    try:
        if body.value is None:
            extra = _try_disk_mutation(
                "mark_circled",
                lambda: beats_adapter.toggle_circled(take_id, project_id=project_id),
                take_id=take_id,
            )
        else:
            extra = _try_disk_mutation(
                "mark_circled",
                lambda: beats_adapter.set_circled(take_id, body.value, project_id=project_id),
                take_id=take_id,
            )
    except ValueError as exc:  # Debug R1 — path-traversal guard.
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
        ) from exc

    if body.value is None:
        # Toggle: flip in-memory bookkeeping.
        if stub_routes._acted_contains("circled_takes", take_id):
            stub_routes._acted_remove("circled_takes", take_id)
        else:
            stub_routes._acted_add("circled_takes", take_id)
    else:
        # Idempotent set: reflect the requested value.
        if body.value:
            stub_routes._acted_add("circled_takes", take_id)
        else:
            stub_routes._acted_remove("circled_takes", take_id)
    emit_take_action(
        take_id=take_id,
        action="circled_toggled",
        scope="engine/takes",
        extra=extra,
        severity="fallback" if extra is None else "info",
    )
    return _ok()


@router.post("/takes/{take_id}/reject")
async def reject_take(
    take_id: str,
    project_id: Optional[str] = Query(default=None, alias="projectId"),
) -> dict[str, bool]:
    try:
        extra = _try_disk_mutation(
            "reject_take",
            lambda: beats_adapter.reject_take(take_id, project_id=project_id),
            take_id=take_id,
        )
    except ValueError as exc:  # Debug R1 — path-traversal guard.
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
        ) from exc
    stub_routes._acted_add("rejected_takes", take_id)
    emit_take_action(
        take_id=take_id,
        action="rejected",
        scope="engine/takes",
        extra=extra,
        severity="fallback" if extra is None else "info",
    )
    return _ok()


# ── Memory ────────────────────────────────────────────────────────────────


@router.post("/memory/{entry_id}/toggle")
async def toggle_memory(
    entry_id: str, body: ToggleBody = ToggleBody()
) -> dict[str, bool]:
    """Toggle (or set) a memory entry's on/off bit.

    Debug R1 fix — idempotent when caller provides ``{"value": bool}`` in
    the request body. Without a body the legacy toggle-on-each-call
    semantics are preserved.

    Debug R2 fix — entry_id is validated against HIERARCHY_ID_RE so an
    attacker can't grow ``_acted`` / ``_OVERLAY`` unbounded with random
    junk ids. Both downstream containers are also bounded internally.
    """
    try:
        validate_hierarchy_id("entry_id", entry_id)
    except ValueError as exc:  # Debug R2 — id-format guard.
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
        ) from exc

    if body.value is None:
        extra = memory_adapter.toggle_entry(entry_id)
    else:
        extra = memory_adapter.set_entry(entry_id, body.value)

    if body.value is None:
        if stub_routes._acted_contains("toggled_memory", entry_id):
            stub_routes._acted_remove("toggled_memory", entry_id)
        else:
            stub_routes._acted_add("toggled_memory", entry_id)
    else:
        if body.value:
            stub_routes._acted_add("toggled_memory", entry_id)
        else:
            stub_routes._acted_remove("toggled_memory", entry_id)
    emit_memory_action(
        entry_id=entry_id,
        on=bool(extra.get("on")),
        scope="engine/memory",
        extra=extra,
    )
    return _ok()


__all__ = ["router"]
