"""Recoil API — Console v2 backend.

Phase 1: workspace_state routes.
Phase 16: engine entity routes.
Phase 17: stub routes (queue/chat/slash/commands-ref + ok-true mutation
          stubs) so the desktop+mobile http-adapter swap has a backing
          API surface until Phase 19 lands the real mutation routes.
Phase 19: mutation routes + EventBus SSE. POST handlers moved out of
          stub_routes; SSE stream + EventBus bound on startup.
Phase 20: chat + slash dispatch (FRAMEWORK + STUBS). POST /api/chat
          returns a canned SSE-style stream; POST /api/slash/dispatch
          validates against a whitelist and emits a BUS event. NO
          Anthropic SDK, NO subprocess; CP-N+ wires real handlers.

Single FastAPI app, single uvicorn process, single port (8431).
"""

import asyncio
import os
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from recoil.pipeline._lib import sanctioned_fallbacks  # noqa: F401  # canonical registry init (Build A Phase 5)
from recoil.api import fallback_bridge  # noqa: F401  # BUS-emitting bridge (Build A Phase 5)
from recoil.api.clicks_routes import router as clicks_router
from recoil.api.engine_routes import router as engine_router
from recoil.api.eventbus import BUS
from recoil.api.generation_routes import router as generation_router
from recoil.api.internal_bus_routes import router as internal_bus_router
from recoil.api.mutation_routes import router as mutation_router
from recoil.api.proposals_routes import router as proposals_router
from recoil.api.sse_routes import router as sse_router
from recoil.api.stub_routes import router as stub_router
from recoil.api.system_status import router as system_status_router
from recoil.api.media_routes import router as media_router
from recoil.api.ttyd_routes import router as ttyd_router
from recoil.api.recent_routes import router as recent_router
from recoil.api.selection_routes import router as selection_router
from recoil.api.workspace_state import router as workspace_router
from recoil.api.workspace_state_report import router as workspace_report_router
from recoil.api.write_proxy import install_write_proxy

# ── Build A Convergence: structural boot guards ──────────────────────────────
# Enforce that no sanctioned fallback is past its retire_by date, and (Phase 3+)
# that every ProposalKind has a non-None exec field. Both fire at module import
# (before FastAPI accepts requests). LaunchAgent log surfaces the RuntimeError;
# the surface goes blank until fixed. No env-var override.
from datetime import date as _date
from recoil.pipeline._lib.sanctioned_fallbacks import (
    list_sanctioned_fallbacks as _list_sanctioned_fallbacks,
)


def _enforce_fallback_retirement() -> None:
    today = _date.today().isoformat()
    expired: list[str] = []
    for record in _list_sanctioned_fallbacks():
        retire_by = getattr(record, "retire_by", None)
        if retire_by is not None and retire_by < today:
            expired.append(f"{record.name} (retire_by={retire_by})")
    if expired:
        raise RuntimeError(
            "Fallback retirement gate failed. Past retire_by:\n  "
            + "\n  ".join(expired)
            + "\nEither extend retire_by with justification, or fix the "
            "underlying data and delete the entry."
        )


def _enforce_proposal_executor() -> None:
    """A ProposalKind with exec=None in _KIND_INFO fails API boot.

    Phase 2 (this phase) registers the guard but does NOT invoke it at boot
    yet — _KIND_INFO still has 8 dead kinds. Phase 3 deletes the 8 dead kinds
    and flips the invocation on. Until Phase 3, this function exists but is
    not called.
    """
    from recoil.api.proposal_dispatch import _KIND_INFO
    dead: list[str] = [k for k, info in _KIND_INFO.items() if info.get("exec") is None]
    if dead:
        raise RuntimeError(
            f"ProposalKind(s) with exec=None at boot: {dead}. "
            "Wire an executor or remove the kind."
        )


# Fire the retirement guard now. The proposal-executor guard is deferred to
# Phase 3 (after the 8 dead kinds are deleted).
_enforce_fallback_retirement()
_enforce_proposal_executor()  # Build A Phase 3: empty _KIND_INFO passes; future regression fails boot.
# ─────────────────────────────────────────────────────────────────────────────


@asynccontextmanager
async def _lifespan(_: FastAPI):
    """Bind the FastAPI uvicorn loop to the EventBus.

    ``BUS.emit_sync`` (called from non-async code paths like the
    dispatch.py hook) needs a running loop to schedule emits onto. This
    handler runs once per uvicorn process at startup.
    """
    BUS.bind_loop(asyncio.get_running_loop())
    yield


app = FastAPI(
    title="Recoil API",
    version="2.0.0",
    description="Console v2 backend: workspace state + engine entities + EventBus.",
    lifespan=_lifespan,
)

# Tailscale-mesh assumed; explicit allowlist for dev ergonomics.
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:5173",
        "http://127.0.0.1:5173",
        "http://100.105.59.118:5173",
        "http://100.114.133.97:5173",
        "http://localhost:5174",
        "http://127.0.0.1:5174",
        "http://100.105.59.118:5174",
        "http://100.114.133.97:5174",
        "http://localhost:5175",
        "http://127.0.0.1:5175",
        "http://100.105.59.118:5175",
        "http://100.114.133.97:5175",
    ],
    allow_credentials=False,
    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allow_headers=["*"],
)

# Write-proxy: when RECOIL_WRITE_UPSTREAM is set, mutations forward to that
# URL instead of running locally. See write_proxy.py for the rationale.
# Studio's LaunchAgent doesn't set the env var; MacBook's .env.local does.
install_write_proxy(app)

app.include_router(workspace_router, prefix="/api")
# P5 (console-v2-fix): POST /api/workspace-state/{wid}/report — corrupt-blob
# capture for the SessionRestoreModal Report path (Law 4 prong-3 fix).
app.include_router(workspace_report_router, prefix="/api")
app.include_router(engine_router, prefix="/api")
# stub_routes only exposes GETs after Phase 19 — see stub_routes.py.
app.include_router(stub_router, prefix="/api")
# Phase 19: mutation POSTs (proposals/takes/memory) — emit EventBus events.
app.include_router(mutation_router, prefix="/api")
# Phase 8 (embedded-claude-terminal): proposals lifecycle (create/list/approve/reject).
# Multiplexes onto /api/events/stream via scope="chat/proposals" per ADR-0006.
app.include_router(proposals_router, prefix="/api")
# Phase 19: SSE stream — /api/events/stream.
app.include_router(sse_router, prefix="/api")
# P4 (console-v2-fix): GET /api/system-status — single source of truth for
# chrome status surfaces (Titlebar, StatusBar, StatusPopover, BottomBay).
app.include_router(system_status_router, prefix="/api")
# POST /api/internal/bus — loopback bridge so out-of-process callers (the
# workspace MCP server) can emit to BUS without importing it.
app.include_router(internal_bus_router, prefix="/api")
# /api/ttyd/{start,stop,status,context-window} — per-project ttyd lifecycle.
# Module import registers atexit + signal handlers for process-group cleanup.
app.include_router(ttyd_router, prefix="/api")
# Phase 9 (embedded-claude-terminal): /api/clicks — recent click history
# (POST append + GET newest-first, ring-buffered per project, persisted
# to ~/.recoil/click-history.jsonl).
app.include_router(clicks_router, prefix="/api")


app.include_router(media_router, prefix="/api")
app.include_router(selection_router, prefix="/api")
app.include_router(recent_router, prefix="/api")
# Phase 5 (console-v2-convergence A): GenerationProposal executor.
# POST /api/proposals/generate spawns dispatch_cli.py via asyncio subprocess
# and streams stdout/stderr to scope="engine/generation" on the EventBus.
app.include_router(generation_router, prefix="/api")


@app.get("/api/health")
def health() -> dict[str, bool]:
    return {"ok": True}


@app.get("/api/config")
def config() -> dict[str, object]:
    """Runtime config for the frontend — ttydHost for iframe URL construction.

    Null when unset so the frontend can distinguish "use page hostname" from
    an explicit host (TTYD_HOST=100.105.59.118 for MacBook → Studio dev pair).
    """
    ttyd_host = os.environ.get("TTYD_HOST", "").strip() or None
    return {
        "schemaVersion": 1,
        "ttydHost": ttyd_host,
    }
