"""Default-deny auth gate (04-architecture "Sign in with BW").

Everything requires an app session except the EXPLICIT allowlist:
/healthz, the three /auth/* flow routes, and the SPA's hashed static
assets. Unauthenticated /api/* gets 401 JSON (the structured error
shape); unauthenticated page routes 302 into /auth/login — which, for
a visitor holding a live BW SSO session, round-trips auth.bowden.works
silently (session re-establishment without a login form).

Identity comes ONLY from the signed app-session cookie (established in
/auth/callback from a verified id_token) — or, in non-production envs,
the AUTH_DEV_USER stub. X-Auth-* or any other header NEVER grants
identity (T-AZ-045): nothing here reads request headers at all.
"""

import logging
import urllib.parse

from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse

from app.services.auth import config as auth_config
from app.services.auth import session as auth_session

log = logging.getLogger("with.auth")

# T-AZ-041: the allowlist is explicit. Exact paths only, plus the ONE
# static-assets prefix Vite emits hashed bundles under.
ALLOWLIST_EXACT = frozenset({
    "/healthz",
    "/auth/login",
    "/auth/callback",
    "/auth/logout",
})
ASSETS_PREFIX = "/assets/"

# Satellite registry API (ADR #014). These routes are server-to-server
# (a satellite app -> `with`, NOT a browser): they carry NO app session and
# enforce their OWN service-token auth at the router layer
# (`Authorization: Bearer <token>`, timing-safe). They MUST NEVER read the
# session cookie — so the default-deny SESSION gate lets them through here
# and the router-level token check becomes the ONLY gate. The
# "no unauthenticated surface" invariant still holds (it's the token, not
# the cookie); the security route-auth sweep asserts every registry route is
# token-gated. Kept OUT of ALLOWLIST_EXACT (which is the browser/session
# public set) precisely because this is a different auth mechanism.
REGISTRY_PREFIX = "/api/registry/"


def is_public_path(path: str) -> bool:
    return (
        path in ALLOWLIST_EXACT
        or path.startswith(ASSETS_PREFIX)
        or path.startswith(REGISTRY_PREFIX)
    )


def resolve_identity(request: Request) -> dict | None:
    """The ONE place a request becomes a user. Session cookie only —
    never a header. Returns {username, email, is_super_admin} or None."""
    stub = auth_config.dev_user()
    if stub and not auth_config.is_production():
        # Local-test stub (judgment #13). create_app() refuses to start
        # when this is set in production; the env check here is defense
        # in depth. Zero capabilities, like any fresh identity.
        return {"username": stub, "email": "", "is_super_admin": False}

    token = request.cookies.get(auth_session.SESSION_COOKIE, "")
    claims = auth_session.verify_session(token)
    if claims is None:
        return None
    return {
        "username": claims["username"],
        "email": claims.get("email", ""),
        "is_super_admin": bool(claims.get("is_super_admin", False)),
    }


async def auth_gate(request: Request, call_next):
    """HTTP middleware. Registered in create_app()."""
    path = request.url.path
    if is_public_path(path):
        return await call_next(request)

    identity = resolve_identity(request)
    if identity is not None:
        request.state.identity = identity
        return await call_next(request)

    if path.startswith("/api/"):
        return JSONResponse(
            status_code=401,
            content={
                "error_code": "NOT_AUTHENTICATED",
                "summary": "Sign in required",
                "detail": "No valid app session. Start the sign-in flow at /auth/login.",
            },
        )

    # Page route: bounce into the flow, preserving the destination.
    target = path + (f"?{request.url.query}" if request.url.query else "")
    login = "/auth/login?next=" + urllib.parse.quote(target, safe="")
    return RedirectResponse(url=login, status_code=302)
