"""Easel — one Python process serving the API and the pre-built SPA.

Node exists only in the image build; nothing here needs it at runtime. The whole
account/level/View-As surface comes from the BW-Auth kit: `bw_admin_api.build_router`
mounts the admin API + the /api/me capability contract, and the app supplies only
identity, policy, and its own product routes.
"""

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

from fastapi import FastAPI, Response
from fastapi.responses import FileResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware

from app import (
    accounts, bw_auth, bw_client, bw_config, bw_view_as, identity, view_as_policy,
)
from app.bw_admin_api import build_router
from app import bw_accounts
from app.config import DEV_SESSION_SECRET, get_settings
from app.middleware import AuthMiddleware
from app.routers import (
    board,
    app_routes, auth, meta, mockups, notifications, onboarding, outbound, pins,
    projects, walkthrough,
)
from app.version import VERSION

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger(bw_config.APP_NAME)

STATIC_DIR = Path(__file__).resolve().parent / "static"
INDEX_HTML = STATIC_DIR / "index.html"


@asynccontextmanager
async def lifespan(_app: FastAPI):
    settings = get_settings()
    settings.data_dir.mkdir(parents=True, exist_ok=True)
    if settings.is_production and settings.session_secret == DEV_SESSION_SECRET:
        raise RuntimeError(
            "APP_SESSION_SECRET is still the development default. Set a real value "
            "in .app.env before running in production.")
    accounts.init_accounts_kit()   # wire storage + seed levels before any permission question
    if not settings.has_bw_client:
        log.warning("BW Auth is not configured — sign-in is unavailable until it is.")
    elif bw_config.HUB_REPORTING:
        try:
            bw_accounts.sync_reports(bw_auth)   # backfill central visibility, best-effort
        except Exception as exc:  # noqa: BLE001
            log.warning("initial sync_reports failed: %s", exc)
    # The digest loop (services/digest.py) and the retry of held mail. Only
    # where mail can actually go; tests and local runs call the route instead.
    ticker = (asyncio.create_task(_mail_loop(settings.digest_poll_seconds))
              if settings.is_production else None)
    yield
    if ticker is not None:
        ticker.cancel()
        with suppress(asyncio.CancelledError):
            await ticker


def _mail_tick() -> None:
    from app.db import get_session_factory
    from app.services import digest, outbound

    with get_session_factory()() as db:
        retried = outbound.retry_held(db)
        queued = digest.run_once(db)
        db.commit()
    if retried or queued:
        log.info("mail tick: %d retried, %d digests", retried, queued)


async def _mail_loop(every: int) -> None:
    while True:
        await asyncio.sleep(max(30, every))
        try:
            await asyncio.to_thread(_mail_tick)
        except Exception as exc:  # noqa: BLE001 - the loop outlives any one failure
            log.warning("mail tick failed: %s", exc)


app = FastAPI(title=bw_config.APP_TITLE, version=VERSION, lifespan=lifespan,
              docs_url=None, redoc_url=None)

# Middleware order matters: the LAST added runs FIRST. SessionMiddleware must run
# before AuthMiddleware, which reads request.session.
app.add_middleware(AuthMiddleware)
app.add_middleware(
    SessionMiddleware,
    secret_key=get_settings().session_secret,
    session_cookie=get_settings().session_cookie,
    same_site="lax",   # blocks cross-site POSTs from carrying the session
    https_only=True,   # the app is only ever reached over TLS through Caddy
    max_age=30 * 24 * 3600,
)

# The standard BW-Auth admin API + the /api/me capability contract, all from the
# kit. The app injects only identity, policy hooks, and (optionally) View-As.
_view_as_on = bw_config.VIEW_AS
app.include_router(build_router(
    bwa=bw_accounts,
    optional_user=identity.optional_user,
    real_user=identity.real_user,
    bw_auth=bw_auth,
    bw_view_as=(bw_view_as if _view_as_on else None),
    instance_obj=(accounts.instance_obj if bw_config.HAS_INSTANCES else None),
    session_of=((lambda req: req.session) if _view_as_on else None),
    view_as_policy=(view_as_policy.can_view_as if _view_as_on else None),
    rank_of=view_as_policy.rank_of,
    target_valid=view_as_policy.target_valid,
    start_hook=(view_as_policy.start if _view_as_on else None),
    stop_hook=(view_as_policy.stop if _view_as_on else None),
    account_url=bw_client.account_url(),
    prefix="/api/bw",
    audit=accounts.audit,
))

app.include_router(meta.router)
app.include_router(auth.router)
app.include_router(app_routes.router)
app.include_router(projects.router)
app.include_router(projects.screen_router)
app.include_router(projects.option_router)
app.include_router(projects.variant_router)
app.include_router(mockups.router)
app.include_router(pins.router)
app.include_router(walkthrough.router)
app.include_router(notifications.router)
app.include_router(onboarding.router)
app.include_router(board.router)
app.include_router(outbound.router)


@app.get("/healthz", include_in_schema=False)
def healthz() -> PlainTextResponse:
    return PlainTextResponse("ok")


# Vite content-hashes every asset filename, so a given /assets/ URL can never
# change content — cache it hard. The SHELL is the opposite (below).
class _ImmutableAssets(StaticFiles):
    def file_response(self, *args, **kwargs):  # type: ignore[override]
        resp = super().file_response(*args, **kwargs)
        resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
        return resp


if (STATIC_DIR / "assets").is_dir():
    app.mount("/assets", _ImmutableAssets(directory=STATIC_DIR / "assets"),
              name="assets")


# response_model=None: the return is a union of Response subclasses. HEAD alongside
# GET so link checkers and health probes don't see 405.
@app.api_route("/{path:path}", methods=["GET", "HEAD"], include_in_schema=False,
               response_model=None)
def spa(path: str) -> Response:
    """Serve the SPA shell for any route the API did not claim, so a deep link or a
    refresh lands on the right screen. The shell carries no data — it renders the
    sign-in screen until /api/bw/me says otherwise."""
    static_file = (STATIC_DIR / path).resolve() if path else None
    if static_file and static_file.is_file() and STATIC_DIR in static_file.parents:
        return FileResponse(static_file)
    if INDEX_HTML.is_file():
        # `no-cache` = revalidate every time, NOT "never store": the ETag makes
        # the check a 304 costing nothing. Without it the shell carried only an
        # ETag, so browsers applied HEURISTIC caching and kept serving an old
        # shell — which references the OLD hashed bundle, so a deployed fix
        # stayed invisible on a normal reload while /api/meta reported the new
        # version. That combination is the "green deploy, stale code" trap.
        return FileResponse(INDEX_HTML, headers={"Cache-Control": "no-cache"})
    return PlainTextResponse(
        "Frontend is not built. Run `npm ci && npm run build` in main/frontend.",
        status_code=503)


if __name__ == "__main__":  # pragma: no cover - local convenience only
    import uvicorn

    uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", "3000")))
