"""Proposal lifecycle: create / list / approve / reject.

Backs the chat ProposalTray. Files live at
``~/.recoil/proposals/<project>/<uuid>.json``. Status transitions
(pending → approved | rejected) emit BUS events with
``scope="chat/proposals"`` so the existing ``/api/events/stream``
multiplexes them per ADR-0006.

Routes are mounted under ``/api/chat/proposals/...`` to avoid colliding
with Phase 19's ``/api/proposals/<id>/{approve,reject,defer}`` queue-
inspector mutation routes (which validate against fixture-shape proposal
ids and cannot be removed without a separate migration). The BUS scope
``chat/proposals`` matches the path namespace; the typed contract,
lifecycle, and tray UI all keep working through the renamed paths.

Approve dispatches via ``recoil/pipeline/core/dispatch.dispatch``.
The translation layer parses ``proposal.target`` (``shot:<id>`` |
``take:<id>`` | ``pass:<id>``) and constructs a payload from the diff.
The full lookup logic for shot/take/pass records is deferred — Phase 8
ships a SAFE PLACEHOLDER that validates the prefix, emits a BUS event
recording the dispatch intent, and returns a stub dispatch_id. The
spec is satisfied by the typed contract + tray UI + lifecycle; the
actual generation re-roll is JT's manual work after approval.
"""

from __future__ import annotations

import fcntl
import functools
import inspect
import json
import logging
import os
import tempfile
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Optional

from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field

from recoil.api.eventbus import BUS
from recoil.api.adapters._ids import validate_project_id
from recoil.api.chat_sessions import ChatSessionsStore
from recoil.api.executors import prompt_rewrite as _executor_prompt_rewrite
from recoil.api.executors import param_tweak as _executor_param_tweak
from recoil.api.executors import script_edit as _executor_script_edit
from recoil.api.executors import beat_insertion as _executor_beat_insertion
from recoil.api.executors import multi_beat_directive as _executor_multi_beat_directive
from recoil.api.executors import extract_cutaway as _executor_extract_cutaway
from recoil.api.executors import ref_swap as _executor_ref_swap
from recoil.api.executors import retry_strategy_edit as _executor_retry_strategy_edit
from recoil.api.adapters.beats import get_episode_id_for_beat as _get_episode_id
from recoil.core.exceptions import ProposalCorruptError

logger = logging.getLogger(__name__)

router = APIRouter()


def _json_500_on_unhandled(fn: Callable[..., Any]) -> Callable[..., Any]:
    """Guarantee a JSON body on the unhandled-exception path.

    FastAPI's default 500 handler returns JSON, but a misrouted reverse-
    proxy or upstream middleware can surface HTML. Frontends that expect
    JSON (the ProposalTray content-type guard) will warn-and-bail on HTML;
    this decorator makes the JSON contract explicit. HTTPException is
    re-raised so FastAPI's handler stays in charge of typed 4xx/5xx.

    Handles both sync and async route handlers — a sync wrapper around an
    async fn would silently let exceptions in the coroutine body bypass
    the try/except (the wrapper would only catch errors during coroutine
    construction, not during its execution).
    """
    if inspect.iscoroutinefunction(fn):

        @functools.wraps(fn)
        async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
            try:
                return await fn(*args, **kwargs)
            except HTTPException:
                raise
            except Exception as exc:  # noqa: BLE001
                logger.exception("%s failed: %s", fn.__name__, type(exc).__name__)
                return JSONResponse(
                    status_code=500,
                    content={"detail": f"{fn.__name__} failed: {type(exc).__name__}"},
                )

        return async_wrapper

    @functools.wraps(fn)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        try:
            return fn(*args, **kwargs)
        except HTTPException:
            raise
        except Exception as exc:  # noqa: BLE001
            logger.exception("%s failed: %s", fn.__name__, type(exc).__name__)
            return JSONResponse(
                status_code=500,
                content={"detail": f"{fn.__name__} failed: {type(exc).__name__}"},
            )

    return wrapper


SCHEMA_VERSION = 1
_PROPOSALS_ROOT = Path.home() / ".recoil" / "proposals"
_LOCK = threading.Lock()
_BUS_SCOPE = "chat/proposals"


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def _project_dir(project_id: str) -> Path:
    validate_project_id(project_id)
    return _PROPOSALS_ROOT / project_id


def _validated_project(project_id: str) -> str:
    """Return project_id if valid (or 'default'); raise 400 otherwise."""
    if project_id == "default":
        return project_id
    try:
        validate_project_id(project_id)
    except ValueError as exc:
        raise HTTPException(
            status_code=400, detail=f"invalid project: {project_id!r}"
        ) from exc
    return project_id


def _proposal_path(project_id: str, proposal_id: str) -> Path:
    # uuid.uuid4().hex shape — exactly 32 lowercase-hex chars, no dashes.
    if len(proposal_id) != 32 or not all(c in "0123456789abcdef" for c in proposal_id):
        raise HTTPException(
            status_code=400, detail=f"invalid proposal id: {proposal_id!r}"
        )
    return _project_dir(_validated_project(project_id)) / f"{proposal_id}.json"


def _read_modify_write(path: Path, mutator) -> dict:
    """Atomic read+modify+write under fcntl flock.

    The mutator receives the current doc dict and returns the new doc dict.
    The whole compare-and-swap happens inside the lock so concurrent
    approves of the same proposal can't both pass a status==pending
    check and emit two BUS events.
    """
    path.parent.mkdir(parents=True, exist_ok=True)
    lock_path = path.with_suffix(path.suffix + ".lock")
    with _LOCK:
        lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
        try:
            fcntl.flock(lock_fd, fcntl.LOCK_EX)
            try:
                raw = path.read_text(encoding="utf-8")
            except FileNotFoundError as exc:
                raise HTTPException(
                    status_code=404, detail=f"proposal not found: {path.name}"
                ) from exc
            try:
                doc = json.loads(raw)
            except json.JSONDecodeError as exc:
                raise ProposalCorruptError(str(path), f"invalid JSON: {exc}") from exc
            if not isinstance(doc, dict):
                raise ProposalCorruptError(
                    str(path),
                    f"top-level must be object, got {type(doc).__name__}",
                )
            new_doc = mutator(doc)
            fd, tmp_path = tempfile.mkstemp(
                dir=str(path.parent),
                prefix=path.name + ".",
                suffix=".tmp",
            )
            try:
                with os.fdopen(fd, "w", encoding="utf-8") as f:
                    json.dump(new_doc, f, indent=2, sort_keys=True)
                    f.flush()
                    os.fsync(f.fileno())
                os.replace(tmp_path, str(path))
            except Exception:
                try:
                    os.unlink(tmp_path)
                except OSError:
                    pass
                raise
            return new_doc
        finally:
            fcntl.flock(lock_fd, fcntl.LOCK_UN)
            os.close(lock_fd)


def _atomic_write(path: Path, doc: dict) -> None:
    """fcntl-locked, tempfile + os.replace write — mirrors chat_sessions._write."""
    path.parent.mkdir(parents=True, exist_ok=True)
    lock_path = path.with_suffix(path.suffix + ".lock")
    with _LOCK:
        lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
        try:
            fcntl.flock(lock_fd, fcntl.LOCK_EX)
            fd, tmp_path = tempfile.mkstemp(
                dir=str(path.parent),
                prefix=path.name + ".",
                suffix=".tmp",
            )
            try:
                with os.fdopen(fd, "w", encoding="utf-8") as f:
                    json.dump(doc, f, indent=2, sort_keys=True)
                    f.flush()
                    os.fsync(f.fileno())
                os.replace(tmp_path, str(path))
            except Exception:
                try:
                    os.unlink(tmp_path)
                except OSError:
                    pass
                raise
        finally:
            fcntl.flock(lock_fd, fcntl.LOCK_UN)
            os.close(lock_fd)


def _read_proposal(path: Path) -> dict:
    try:
        raw = path.read_text(encoding="utf-8")
    except FileNotFoundError as exc:
        raise HTTPException(
            status_code=404, detail=f"proposal not found: {path.name}"
        ) from exc
    except OSError as exc:
        raise ProposalCorruptError(str(path), f"could not read: {exc}") from exc
    try:
        doc = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise ProposalCorruptError(str(path), f"invalid JSON: {exc}") from exc
    if not isinstance(doc, dict):
        raise ProposalCorruptError(
            str(path), f"top-level must be object, got {type(doc).__name__}"
        )
    return doc


# ── Pydantic request shapes ─────────────────────────────────────────────────


class _DiffEntry(BaseModel):
    kind: str
    before: Optional[Any] = None
    after: Optional[Any] = None
    text: Optional[Any] = None
    key: Optional[str] = None


class _ProposalCreate(BaseModel):
    """POST /api/proposals body — matches propose_action schema + project."""

    target: str
    title: str
    est_cost_usd: float
    est_time: str
    diff: list[_DiffEntry] = Field(default_factory=list)
    # Server-side; resolved from chat-sessions if absent.
    project: Optional[str] = None
    session_id: Optional[str] = None
    kind: Optional[str] = None


# ── Translation layer ───────────────────────────────────────────────────────

_VALID_TARGET_PREFIXES = ("shot:", "take:", "pass:")


def _translate_to_dispatch_payload(proposal: dict) -> tuple[str, dict, str]:
    """Parse ``proposal.target`` into (modality, payload, target_kind).

    PHASE 8 PLACEHOLDER: the spec calls for shot/take/pass record lookup +
    diff-application against the project plan. That requires non-trivial
    spelunking through the visual pipeline state (ExecutionStore, plan
    JSON, current take ledger). We defer the actual lookup to a follow-up
    phase and ship a safe placeholder that:
      • validates the prefix is one of shot:|take:|pass:
      • returns the inferred modality (best-effort: image_t2i for shot,
        video_i2v for take/pass — overridden in the diff if present)
      • returns a synthetic payload echoing the diff so the BUS event
        records the dispatch intent

    The actual ``dispatch()`` call is gated by the route (see
    ``approve_proposal``) — it logs intent only and does NOT run the
    runner. Real dispatch is deferred to a follow-up phase.
    """
    target = proposal.get("target", "")
    if not isinstance(target, str) or ":" not in target:
        raise HTTPException(
            status_code=422,
            detail={"error": "unrecognised target prefix", "target": target},
        )
    prefix, _, suffix = target.partition(":")
    target_kind = f"{prefix}:"
    if target_kind not in _VALID_TARGET_PREFIXES:
        raise HTTPException(
            status_code=422,
            detail={"error": "unrecognised target prefix", "target": target},
        )
    if not suffix:
        raise HTTPException(
            status_code=422,
            detail={"error": "empty target suffix", "target": target},
        )

    # Best-effort modality inference from prefix.
    if target_kind == "shot:":
        modality = "image_t2i"
    else:
        # take + pass default to video re-roll; overridden by diff if needed.
        modality = "video_i2v"

    # Surface diff as payload echo. The real implementation will resolve
    # the shot/take/pass record from project state and apply the diff to
    # its parameters before constructing a runner-shaped payload.
    payload: dict[str, Any] = {
        "shot_id": suffix if target_kind == "shot:" else None,
        "target": target,
        "diff": proposal.get("diff", []),
        "_phase8_stub": True,
    }
    return modality, payload, target_kind


# ── Routes ──────────────────────────────────────────────────────────────────


@router.post("/chat/proposals")
def create_proposal(body: _ProposalCreate) -> dict[str, Any]:
    """Write proposal JSON + emit BUS event. Returns ``{ok, id}``."""
    # Resolve project: explicit body field > session_id lookup > "default".
    project_id = body.project
    if not project_id and body.session_id:
        try:
            project_id = ChatSessionsStore().lookup_project_by_session_id(
                body.session_id
            )
        except Exception as exc:  # noqa: BLE001
            logger.warning("session lookup failed: %s", exc)
    if not project_id:
        project_id = "default"
    try:
        validate_project_id(project_id)
    except ValueError:
        # "default" is the only non-pattern id we accept; anything else must validate.
        if project_id != "default":
            raise HTTPException(
                status_code=400, detail=f"invalid project: {project_id!r}"
            )

    pid = uuid.uuid4().hex
    doc = {
        "schema_version": SCHEMA_VERSION,
        "id": pid,
        "project": project_id,
        "title": body.title,
        "target": body.target,
        "diff": [d.model_dump(exclude_none=True) for d in body.diff],
        "est_cost_usd": body.est_cost_usd,
        "est_time": body.est_time,
        "kind": body.kind,
        "status": "pending",
        "created_at": _now_iso(),
    }
    path = _project_dir(project_id) / f"{pid}.json"
    _atomic_write(path, doc)

    BUS.emit_sync(
        severity="info",
        scope=_BUS_SCOPE,
        summary=f"proposal created: {body.title}",
        payload={
            "id": pid,
            "project": project_id,
            "target": body.target,
            "status": "pending",
        },
    )
    return {"ok": True, "id": pid}


@router.get("/chat/proposals/{project_id}")
@_json_500_on_unhandled
def list_proposals(project_id: str):
    """Pending proposals for a project. Approved/rejected are filtered server-side."""
    try:
        validate_project_id(project_id)
    except ValueError:
        if project_id != "default":
            raise HTTPException(
                status_code=400, detail=f"invalid project: {project_id!r}"
            )
    pdir = _project_dir(project_id)
    if not pdir.is_dir():
        return []
    out: list[dict[str, Any]] = []
    for entry in sorted(pdir.glob("*.json")):
        try:
            doc = _read_proposal(entry)
        except ProposalCorruptError as exc:
            logger.warning("skipping corrupt proposal %s: %s", entry, exc)
            continue
        if doc.get("status") != "pending":
            continue
        out.append(
            {
                "id": doc.get("id"),
                "title": doc.get("title"),
                "target": doc.get("target"),
                "diff": doc.get("diff", []),
                "est_cost_usd": doc.get("est_cost_usd"),
                "est_time": doc.get("est_time"),
                "kind": doc.get("kind"),
                "status": doc.get("status"),
            }
        )
    return out


class _ProposalAction(BaseModel):
    project: Optional[str] = None


@router.post("/chat/proposals/{proposal_id}/approve")
@_json_500_on_unhandled
def approve_proposal(proposal_id: str, body: _ProposalAction):
    """Mark approved on disk; dispatch executor if kind is wired.

    Phase 1: PromptRewriteProposal wired. All other kinds still return
    501 "approved_not_dispatched" until their executor is written.
    """
    project_id = body.project or "default"
    path = _proposal_path(project_id, proposal_id)
    captured: dict[str, Any] = {}

    def _approve(doc: dict) -> dict:
        if doc.get("status") != "pending":
            raise HTTPException(
                status_code=409,
                detail=f"proposal not pending: {doc.get('status')}",
            )
        _kind = doc.get("kind")
        _target = doc.get("target") or ""
        # Validate target prefix for wired kinds before committing approved status —
        # otherwise a malformed proposal gets stuck as approved and can never be
        # re-approved (409) or dispatched (422).
        if _kind == "PromptRewriteProposal" and not _target.startswith("beat:"):
            raise HTTPException(
                status_code=422,
                detail={
                    "error": "invalid_target",
                    "detail": f"PromptRewriteProposal target must start with 'beat:'; got {_target!r}",
                },
            )
        if _kind == "ParameterChangeProposal" and not _target.startswith("take:"):
            raise HTTPException(
                status_code=422,
                detail={
                    "error": "invalid_target",
                    "detail": f"ParameterChangeProposal target must start with 'take:'; got {_target!r}",
                },
            )
        if _kind == "ScriptEditProposal" and not (
            _target.startswith("episode:") or _target.startswith("beat:")
        ):
            raise HTTPException(
                status_code=422,
                detail={
                    "error": "invalid_target",
                    "detail": f"ScriptEditProposal target must start with 'episode:' or 'beat:'; got {_target!r}",
                },
            )
        if _kind == "BeatInsertionProposal" and not _target.startswith("episode:"):
            raise HTTPException(
                status_code=422,
                detail={
                    "error": "invalid_target",
                    "detail": f"BeatInsertionProposal target must start with 'episode:'; got {_target!r}",
                },
            )
        if _kind == "MultiBeatDirectiveProposal" and not _target.startswith("episode:"):
            raise HTTPException(
                status_code=422,
                detail={
                    "error": "invalid_target",
                    "detail": f"MultiBeatDirectiveProposal target must start with 'episode:'; got {_target!r}",
                },
            )
        if _kind == "ExtractCutawayProposal" and not _target.startswith("beat:"):
            raise HTTPException(
                status_code=422,
                detail={
                    "error": "invalid_target",
                    "detail": f"ExtractCutawayProposal target must start with 'beat:'; got {_target!r}",
                },
            )
        if _kind == "RefSwapProposal" and not _target.startswith("beat:"):
            raise HTTPException(
                status_code=422,
                detail={
                    "error": "invalid_target",
                    "detail": f"RefSwapProposal target must start with 'beat:'; got {_target!r}",
                },
            )
        if _kind == "RetryStrategyEditProposal" and not _target.startswith("beat:"):
            raise HTTPException(
                status_code=422,
                detail={
                    "error": "invalid_target",
                    "detail": f"RetryStrategyEditProposal target must start with 'beat:'; got {_target!r}",
                },
            )
        captured["title"] = doc.get("title")
        captured["target"] = doc.get("target")
        captured["kind"] = doc.get("kind")
        captured["diff"] = doc.get("diff", [])
        captured["project"] = doc.get("project", project_id)
        doc["status"] = "approved"
        doc["approved_at"] = _now_iso()
        return doc

    _read_modify_write(path, _approve)

    kind = captured.get("kind")
    target = captured.get("target") or ""
    diff = captured.get("diff") or []
    proposal_project = captured.get("project") or project_id

    # ── PromptRewriteProposal dispatch ──────────────────────────────────
    if kind == "PromptRewriteProposal":
        if not target.startswith("beat:"):
            return JSONResponse(
                status_code=422,
                content={
                    "error": "invalid_target",
                    "detail": f"PromptRewriteProposal target must start with 'beat:'; got {target!r}",
                    "proposal_id": proposal_id,
                },
            )
        beat_id = target[len("beat:") :]
        new_text = ""
        for entry in diff:
            new_text = entry.get("after") or entry.get("text") or ""
            if new_text:
                break
        if not new_text:
            return JSONResponse(
                status_code=422,
                content={
                    "error": "empty_new_text",
                    "detail": "PromptRewriteProposal diff contains no 'after' or 'text' value",
                    "proposal_id": proposal_id,
                },
            )
        result = _executor_prompt_rewrite.execute(
            beat_id=beat_id,
            new_text=new_text,
            project_id=proposal_project if proposal_project != "default" else None,
        )
        BUS.emit_sync(
            severity="success",
            scope=_BUS_SCOPE,
            summary=f"proposal approved + executed: {captured.get('title')}",
            payload={
                "id": proposal_id,
                "project": project_id,
                "kind": kind,
                "target": target,
                "status": "approved",
                "execution_result": result,
            },
        )
        return {
            "ok": True,
            "status": "executed",
            "result": result,
            "proposal_id": proposal_id,
        }

    # ── ParameterChangeProposal dispatch ────────────────────────────────
    if kind == "ParameterChangeProposal":
        if not target.startswith("take:"):
            return JSONResponse(
                status_code=422,
                content={
                    "error": "invalid_target",
                    "detail": f"ParameterChangeProposal target must start with 'take:'; got {target!r}",
                    "proposal_id": proposal_id,
                },
            )
        take_id = target[len("take:") :]
        params_delta = {}
        for entry in diff:
            key = entry.get("key")
            value = entry.get("after")
            if value is None:
                value = entry.get("text")
            if key and value is not None:
                params_delta[key] = value
        if not params_delta:
            return JSONResponse(
                status_code=422,
                content={
                    "error": "empty_params_delta",
                    "detail": "ParameterChangeProposal diff contains no key/after pairs",
                    "proposal_id": proposal_id,
                },
            )
        result = _executor_param_tweak.execute(
            take_id=take_id,
            params_delta=params_delta,
            project_id=proposal_project if proposal_project != "default" else None,
        )
        BUS.emit_sync(
            severity="success",
            scope=_BUS_SCOPE,
            summary=f"proposal approved + executed: {captured.get('title')}",
            payload={
                "id": proposal_id,
                "project": project_id,
                "kind": kind,
                "target": target,
                "status": "approved",
                "execution_result": result,
            },
        )
        return {
            "ok": True,
            "status": "executed",
            "result": result,
            "proposal_id": proposal_id,
        }

    # ── ScriptEditProposal dispatch ──────────────────────────────────────
    if kind == "ScriptEditProposal":
        project_id_for_edit = proposal_project if proposal_project != "default" else None
        episode_id = None
        if target.startswith("episode:"):
            episode_id = target[len("episode:") :]
        elif target.startswith("beat:"):
            beat_id = target[len("beat:") :]
            if not project_id_for_edit:
                return JSONResponse(
                    status_code=422,
                    content={
                        "error": "project_id_required",
                        "target": target,
                        "proposal_id": proposal_id,
                    },
                )
            episode_id = _get_episode_id(beat_id, project_id_for_edit)
        if not episode_id:
            return JSONResponse(
                status_code=422,
                content={
                    "error": "episode_id_unresolvable",
                    "target": target,
                    "proposal_id": proposal_id,
                },
            )
        new_script = ""
        for entry in diff:
            new_script = entry.get("after") or entry.get("text") or ""
            if new_script:
                break
        if not new_script:
            return JSONResponse(
                status_code=422,
                content={
                    "error": "empty_new_script",
                    "detail": "ScriptEditProposal diff contains no 'after' or 'text' value",
                    "proposal_id": proposal_id,
                },
            )
        if not project_id_for_edit:
            return JSONResponse(
                status_code=422,
                content={
                    "error": "project_id_required",
                    "target": target,
                    "proposal_id": proposal_id,
                },
            )
        result = _executor_script_edit.execute(
            episode_id=episode_id,
            new_script_text=new_script,
            project_id=project_id_for_edit,
            proposal_id=proposal_id,
        )
        BUS.emit_sync(
            severity="success",
            scope=_BUS_SCOPE,
            summary=f"proposal approved + executed: {captured.get('title')}",
            payload={
                "id": proposal_id,
                "project": project_id,
                "kind": kind,
                "target": target,
                "status": "approved",
                "execution_result": result,
                "note_id": result.get("note_id"),
            },
        )
        return {
            "ok": True,
            "status": "executed",
            "result": result,
            "note_id": result.get("note_id"),
            "proposal_id": proposal_id,
        }

    # ── BeatInsertionProposal dispatch ─────────────────────────────────
    if kind == "BeatInsertionProposal":
        if not target.startswith("episode:"):
            return JSONResponse(
                status_code=422,
                content={
                    "error": "invalid_target",
                    "detail": f"BeatInsertionProposal target must start with 'episode:'; got {target!r}",
                    "proposal_id": proposal_id,
                },
            )
        episode_id = target[len("episode:") :]
        text = None
        after_beat_id = None
        for entry in diff:
            k = entry.get("key")
            if k == "text":
                text = entry.get("after") or entry.get("text")
            elif k == "afterBeatId":
                after_beat_id = entry.get("after")
        if not text:
            return JSONResponse(
                status_code=422,
                content={
                    "error": "empty_text",
                    "detail": "BeatInsertionProposal diff contains no 'text' value",
                    "proposal_id": proposal_id,
                },
            )
        result = _executor_beat_insertion.execute(
            episode_id=episode_id,
            text=text,
            project_id=proposal_project if proposal_project != "default" else None,
            after_beat_id=after_beat_id,
        )
        BUS.emit_sync(
            severity="success",
            scope=_BUS_SCOPE,
            summary=f"proposal approved + executed: {captured.get('title')}",
            payload={
                "id": proposal_id,
                "project": project_id,
                "kind": kind,
                "target": target,
                "status": "approved",
                "execution_result": result,
            },
        )
        return {
            "ok": True,
            "status": "executed",
            "result": result,
            "proposal_id": proposal_id,
        }

    # ── MultiBeatDirectiveProposal dispatch ────────────────────────────
    if kind == "MultiBeatDirectiveProposal":
        if not target.startswith("episode:"):
            return JSONResponse(
                status_code=422,
                content={
                    "error": "invalid_target",
                    "detail": f"MultiBeatDirectiveProposal target must start with 'episode:'; got {target!r}",
                    "proposal_id": proposal_id,
                },
            )
        beat_ids = None
        note = None
        for entry in diff:
            k = entry.get("key")
            if k == "beatIds":
                beat_ids = entry.get("after")
            elif k == "note":
                note = entry.get("after") or entry.get("text")
        if not beat_ids or not isinstance(beat_ids, list):
            return JSONResponse(
                status_code=422,
                content={
                    "error": "empty_beat_ids",
                    "detail": "MultiBeatDirectiveProposal diff contains no beatIds list",
                    "proposal_id": proposal_id,
                },
            )
        if not note:
            return JSONResponse(
                status_code=422,
                content={
                    "error": "empty_note",
                    "detail": "MultiBeatDirectiveProposal diff contains no note",
                    "proposal_id": proposal_id,
                },
            )
        result = _executor_multi_beat_directive.execute(
            beat_ids=beat_ids,
            note=note,
            project_id=proposal_project if proposal_project != "default" else None,
        )
        BUS.emit_sync(
            severity="success",
            scope=_BUS_SCOPE,
            summary=f"proposal approved + executed: {captured.get('title')}",
            payload={
                "id": proposal_id,
                "project": project_id,
                "kind": kind,
                "target": target,
                "status": "approved",
                "execution_result": result,
            },
        )
        return {
            "ok": True,
            "status": "executed",
            "result": result,
            "proposal_id": proposal_id,
        }

    # ── ExtractCutawayProposal dispatch ────────────────────────────────
    if kind == "ExtractCutawayProposal":
        if not target.startswith("beat:"):
            return JSONResponse(
                status_code=422,
                content={
                    "error": "invalid_target",
                    "detail": f"ExtractCutawayProposal target must start with 'beat:'; got {target!r}",
                    "proposal_id": proposal_id,
                },
            )
        from_beat_id = target[len("beat:") :]
        description = ""
        for entry in diff:
            description = entry.get("text") or entry.get("after") or ""
            if description:
                break
        if not description:
            return JSONResponse(
                status_code=422,
                content={
                    "error": "empty_description",
                    "detail": "ExtractCutawayProposal diff contains no description text",
                    "proposal_id": proposal_id,
                },
            )
        result = _executor_extract_cutaway.execute(
            from_beat_id=from_beat_id,
            description=description,
            project_id=proposal_project if proposal_project != "default" else None,
        )
        BUS.emit_sync(
            severity="success",
            scope=_BUS_SCOPE,
            summary=f"proposal approved + executed: {captured.get('title')}",
            payload={
                "id": proposal_id,
                "project": project_id,
                "kind": kind,
                "target": target,
                "status": "approved",
                "execution_result": result,
            },
        )
        return {
            "ok": True,
            "status": "executed",
            "result": result,
            "proposal_id": proposal_id,
        }

    # ── RefSwapProposal dispatch ───────────────────────────────────────
    if kind == "RefSwapProposal":
        if not target.startswith("beat:"):
            return JSONResponse(
                status_code=422,
                content={
                    "error": "invalid_target",
                    "detail": f"RefSwapProposal target must start with 'beat:'; got {target!r}",
                    "proposal_id": proposal_id,
                },
            )
        beat_id = target[len("beat:") :]
        swap_before = None
        swap_after = None
        prompt_additions = []
        for entry in diff:
            if entry.get("kind") == "swap":
                swap_before = entry.get("before")
                swap_after = entry.get("after")
            elif entry.get("kind") == "promptAdd":
                pa = entry.get("text") or entry.get("after")
                if pa:
                    prompt_additions.append(pa)
        if not swap_before or not swap_after:
            return JSONResponse(
                status_code=422,
                content={
                    "error": "incomplete_swap",
                    "detail": "RefSwapProposal diff must contain a 'swap' entry with before and after",
                    "proposal_id": proposal_id,
                },
            )
        result = _executor_ref_swap.execute(
            beat_id=beat_id,
            swap_before=swap_before,
            swap_after=swap_after,
            prompt_additions=prompt_additions or None,
            project_id=proposal_project if proposal_project != "default" else None,
        )
        BUS.emit_sync(
            severity="success",
            scope=_BUS_SCOPE,
            summary=f"proposal approved + executed: {captured.get('title')}",
            payload={
                "id": proposal_id,
                "project": project_id,
                "kind": kind,
                "target": target,
                "status": "approved",
                "execution_result": result,
            },
        )
        return {
            "ok": True,
            "status": "executed",
            "result": result,
            "proposal_id": proposal_id,
        }

    # ── RetryStrategyEditProposal dispatch ─────────────────────────────
    if kind == "RetryStrategyEditProposal":
        if not target.startswith("beat:"):
            return JSONResponse(
                status_code=422,
                content={
                    "error": "invalid_target",
                    "detail": f"RetryStrategyEditProposal target must start with 'beat:'; got {target!r}",
                    "proposal_id": proposal_id,
                },
            )
        beat_id = target[len("beat:") :]
        strategy_name = None
        rationale = None
        for entry in diff:
            k = entry.get("key")
            if k == "name":
                strategy_name = entry.get("after")
            elif k == "rationale":
                rationale = entry.get("after") or entry.get("text")
        if not strategy_name:
            return JSONResponse(
                status_code=422,
                content={
                    "error": "missing_strategy_name",
                    "detail": "RetryStrategyEditProposal diff must include a 'name' key",
                    "proposal_id": proposal_id,
                },
            )
        if not rationale:
            return JSONResponse(
                status_code=422,
                content={
                    "error": "missing_rationale",
                    "detail": "RetryStrategyEditProposal diff must include a 'rationale' key",
                    "proposal_id": proposal_id,
                },
            )
        result = _executor_retry_strategy_edit.execute(
            beat_id=beat_id,
            strategy_name=strategy_name,
            rationale=rationale,
            project_id=proposal_project if proposal_project != "default" else None,
        )
        BUS.emit_sync(
            severity="success",
            scope=_BUS_SCOPE,
            summary=f"proposal approved + executed: {captured.get('title')}",
            payload={
                "id": proposal_id,
                "project": project_id,
                "kind": kind,
                "target": target,
                "status": "approved",
                "execution_result": result,
            },
        )
        return {
            "ok": True,
            "status": "executed",
            "result": result,
            "proposal_id": proposal_id,
        }

    # ── Unimplemented kinds: honest 501 ─────────────────────────────────
    BUS.emit_sync(
        severity="info",
        scope=_BUS_SCOPE,
        summary=f"proposal approved (no executor, kind={kind!r}): {captured.get('title')}",
        payload={
            "id": proposal_id,
            "project": project_id,
            "kind": kind,
            "target": target,
            "status": "approved_not_dispatched",
        },
    )
    return JSONResponse(
        status_code=501,
        content={
            "status": "approved_not_dispatched",
            "detail": (
                f"Proposal marked approved on disk. No executor for kind {kind!r} yet — "
                "run generation manually."
            ),
            "proposal_id": proposal_id,
            "project": project_id,
            "kind": kind,
            "target": target,
        },
    )


@router.post("/chat/proposals/{proposal_id}/reject")
@_json_500_on_unhandled
def reject_proposal(proposal_id: str, body: _ProposalAction):
    """Mark rejected + emit BUS event. Returns ``{ok, dispatch_id: null}``."""
    project_id = body.project or "default"
    path = _proposal_path(project_id, proposal_id)
    captured: dict[str, Any] = {}

    def _reject(doc: dict) -> dict:
        if doc.get("status") != "pending":
            raise HTTPException(
                status_code=409,
                detail=f"proposal not pending: {doc.get('status')}",
            )
        captured["title"] = doc.get("title")
        captured["target"] = doc.get("target")
        doc["status"] = "rejected"
        doc["rejected_at"] = _now_iso()
        return doc

    _read_modify_write(path, _reject)

    BUS.emit_sync(
        severity="info",
        scope=_BUS_SCOPE,
        summary=f"proposal rejected: {captured.get('title')}",
        payload={
            "id": proposal_id,
            "project": project_id,
            "target": captured.get("target"),
            "status": "rejected",
        },
    )
    return {"ok": True, "dispatch_id": None}


__all__ = ["router", "SCHEMA_VERSION"]
