"""BW Auth (Pattern B, PUBLIC mode) — the app-side session gate.

Users sign in with a BW account ("Sign in with BW"). id-auth
(auth.bowden.works) is the identity broker; the OAuth+PKCE handshake lives in
routers/auth.py. Once a user completes it, THIS app mints its own signed session
cookie (app_session.py) and carries them from there — BW's id_token is a one-shot
bootstrap, verified and discarded in the callback.

AuthMiddleware is DEFAULT-DENY for DATA. The trust boundary:

  * ``/api/*`` requires a valid app session. No session -> 401. This is the
    load-bearing gate. **Never widen it with a prefix exemption** — "skip auth
    for /api/*" is the exact inversion that has left routes open to the internet
    on this server before (coding.md).
  * A small EXACT-path allowlist inside /api is public because PUBLIC mode
    requires an anonymous visitor to be able to load the app's own welcome page:
    ``/api/health`` (liveness, no data) and ``/api/me`` (reports session state,
    returns authenticated=false when anonymous).
  * Everything outside /api — the built SPA shell, its client-side routes, and
    the auth flow (/login, /auth/probe, /auth/callback, /logout) — is public.
    The shell is static; all real data sits behind the /api gate above.

**Authenticated is not authorized.** This middleware resolves the session and
nothing more. Admission (the invite check) is a separate decision made by the
`current_user` dependency in deps.py, because a not-invited user must still be
able to call /api/me to be told they are not invited.
"""

from __future__ import annotations

from dataclasses import dataclass

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import RedirectResponse, Response

from .app_session import SESSION_COOKIE, verify_session
from .config import get_settings
from .errors import AUTH_NOT_CONFIGURED, AUTH_SESSION_MISSING, error_response

# Exact /api paths reachable WITHOUT a session. Exact matches only — a prefix
# here would be a hole. Asserted in tests/test_auth.py.
PUBLIC_API_EXACT: frozenset[str] = frozenset({"/api/health", "/api/me"})

# The namespaces that require a session (default-deny). Anything not under one
# of these is the static SPA shell, a client route, or the public BW auth flow.
#
#   /api/     — all app data.
#   /spotify/ — connecting a third-party account is a signed-in action. The
#               callback is reached by a top-level GET redirect from Spotify,
#               which under SameSite=Lax still carries the session cookie, so
#               gating it costs nothing and closes the obvious hole (an
#               unauthenticated callback would have no user to attach to).
GATED_PREFIXES: tuple[str, ...] = ("/api/", "/spotify/")


@dataclass(frozen=True)
class AuthenticatedUser:
    """A verified app session. Says who signed in — NOT what they may do."""

    username: str


def _is_public(path: str) -> bool:
    if path in PUBLIC_API_EXACT:
        return True
    return not path.startswith(GATED_PREFIXES)


def get_authenticated_user(request: Request) -> AuthenticatedUser | None:
    """The signed-in user for this request, or None when anonymous."""
    return getattr(request.state, "auth_user", None)


class AuthMiddleware(BaseHTTPMiddleware):
    """Resolve the app session onto request.state.auth_user, then default-deny
    the DATA namespace."""

    async def dispatch(self, request: Request, call_next) -> Response:
        path = request.url.path
        settings = get_settings()

        # Resolve on EVERY request so public pages and /api/me know who is
        # signed in. One HMAC — cheap, and harmless when anonymous.
        token = request.cookies.get(SESSION_COOKIE, "")
        username = (
            verify_session(token, settings.app_session_secret) if token else None
        )
        if username:
            request.state.auth_user = AuthenticatedUser(username=username)

        if _is_public(path):
            return await call_next(request)

        # Gated. A misconfigured deployment fails CLOSED, never open.
        if not settings.auth_configured:
            return error_response(
                AUTH_NOT_CONFIGURED,
                "authentication is not configured on this deployment",
                503,
            )
        if username is None:
            # A gated /api path is called by JS, which wants the structured
            # 401. A gated BROWSER path (/spotify/connect) is a navigation —
            # answering it with raw JSON strands the user on a page of error
            # text, so send them to the app, which will show the welcome page.
            if not path.startswith("/api/"):
                return RedirectResponse("/", status_code=302)
            return error_response(
                AUTH_SESSION_MISSING, "authentication required", 401
            )
        return await call_next(request)
