"""FastAPI wiring: /api routers, /healthz, SPA static serving, and the
nightly-snapshot background task. Single process — Node exists only in
the Docker build stage (react.md)."""

import asyncio
import logging
from contextlib import asynccontextmanager, suppress
from pathlib import Path

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import JSONResponse

from app.config import settings
from app.routers import (
    auth,
    books,
    cc,
    clients,
    comments,
    entities,
    entries,
    expenses,
    hub,
    imports_,
    invoices,
    invoices_plus,
    meta,
    projects,
    projects_plus,
    registry,
    saved_views,
    settlement,
    summary,
    team,
    tenant,
    vendors,
    view_as,
)
from app.services.auth import config as auth_config
from app.services.auth.middleware import auth_gate
from app.services.reporting.money import MixedCurrencyError
from app.version import VERSION
from scripts.nightly_snapshot import snapshot_on_boot, snapshot_scheduler
from scripts.seed_views import seed_views_on_boot

logging.basicConfig(
    level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
log = logging.getLogger("with.app")

ROOT = Path(__file__).resolve().parents[1]


class SPAStaticFiles(StaticFiles):
    """Serve the Vite build; extension-less 404s fall back to index.html
    so client-side routes deep-link correctly."""

    async def get_response(self, path, scope):
        try:
            return await super().get_response(path, scope)
        except StarletteHTTPException as exc:
            if exc.status_code == 404 and "." not in path.rsplit("/", 1)[-1]:
                return await super().get_response("index.html", scope)
            raise


@asynccontextmanager
async def lifespan(app: FastAPI):
    tasks: list[asyncio.Task] = []
    if settings.snapshot_enabled:
        tasks.append(asyncio.create_task(snapshot_scheduler(), name="nightly-snapshot"))
        # M2: run the pipeline NOW if the sidecar has no legacy snapshot
        # yet (or RUN_SNAPSHOT_ON_START=1) — background, non-blocking.
        tasks.append(asyncio.create_task(snapshot_on_boot(), name="boot-snapshot"))
    if settings.with_db_url:
        # M8: idempotently seed rian's recurring saved views (best-effort,
        # never blocks boot; no-op once seeded or if the user is absent).
        tasks.append(asyncio.create_task(seed_views_on_boot(), name="seed-views"))
    yield
    for task in tasks:
        task.cancel()
        with suppress(asyncio.CancelledError):
            await task


def create_app() -> FastAPI:
    # Judgment #13 / T-AZ-043: the AUTH_DEV_USER stub refuses production.
    auth_config.enforce_dev_stub_policy()
    if auth_config.dev_user():
        log.warning(
            "AUTH_DEV_USER=%r — dev identity stub ACTIVE (APP_ENV=%s). "
            "Every request resolves to this identity. Local testing only.",
            auth_config.dev_user(),
            auth_config.app_env(),
        )

    app = FastAPI(
        title=settings.app_name,
        version=VERSION,
        lifespan=lifespan,
        openapi_url="/api/openapi.json",  # the generated TS client reads this
        docs_url="/api/docs",
        redoc_url=None,
    )

    # Default-deny auth gate. Registered FIRST so version_header (added
    # after, therefore outermost in Starlette's stack) stamps even the
    # gate's own 401/302 responses.
    app.middleware("http")(auth_gate)

    @app.middleware("http")
    async def version_header(request, call_next):
        # Every response carries the running version — the SPA compares it
        # against its own build for the skew banner (04-architecture).
        response = await call_next(request)
        response.headers["X-With-Version"] = VERSION
        return response

    app.include_router(auth.router)
    app.include_router(meta.router)
    app.include_router(entries.router)
    app.include_router(summary.router)
    app.include_router(projects.router)
    app.include_router(projects_plus.router)
    app.include_router(view_as.router)
    app.include_router(tenant.router)
    app.include_router(imports_.router)
    app.include_router(entities.router)
    app.include_router(invoices.router)
    app.include_router(invoices_plus.router)
    app.include_router(team.router)
    app.include_router(comments.router)
    app.include_router(cc.router)
    app.include_router(saved_views.router)
    app.include_router(registry.router)
    app.include_router(settlement.router)
    app.include_router(books.router)
    app.include_router(clients.router)
    app.include_router(vendors.router)
    app.include_router(expenses.router)
    app.include_router(hub.router)

    # Structured errors everywhere (04-architecture: every route speaks
    # {error_code, summary, detail}). ApiError already carries the
    # triple in .detail; plain HTTPExceptions get wrapped.
    @app.exception_handler(StarletteHTTPException)
    async def structured_http_error(request, exc: StarletteHTTPException):
        if isinstance(exc.detail, dict) and "error_code" in exc.detail:
            content = exc.detail
        else:
            content = {
                "error_code": "HTTP_ERROR",
                "summary": str(exc.detail),
                "detail": "",
            }
        return JSONResponse(status_code=exc.status_code, content=content)

    @app.exception_handler(MixedCurrencyError)
    async def mixed_currency_error(request, exc: MixedCurrencyError):
        # Loud by design (02 §3): a mixed-currency window is a data bug,
        # never something to silently sum.
        log.error("mixed-currency aggregate: %s", exc)
        return JSONResponse(
            status_code=500,
            content={
                "error_code": "MIXED_CURRENCY",
                "summary": "Mixed currencies inside one aggregate",
                "detail": str(exc),
            },
        )

    dist = ROOT / "frontend" / "dist"
    if dist.is_dir():
        app.mount("/", SPAStaticFiles(directory=dist, html=True), name="spa")
    else:
        log.warning("frontend/dist not found — serving API only")

    return app


app = create_app()
