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

garden2 signs users in with a BW account ("Sign in with BW"). id-auth is the
identity broker (auth.bowden.works); the OAuth+PKCE dance 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:

  * DATA namespaces — ``/api/*`` and ``/notes/*`` — require a valid app
    session (or, for the export allowlist, an agent Bearer token). No session
    → 401. This is the load-bearing gate; never widen it.
  * Everything else — the built SPA shell (index.html + assets + sw.js…), the
    SPA's client routes (served as index.html), and the public auth flow
    (/login, /auth/callback, /logout) plus /api/me and /api/health — is PUBLIC,
    because PUBLIC mode means an anonymous visitor must be able to load the
    app's own welcome page. The shell is static; all real data stays behind
    the /api and /notes gate above.

The silent auto-signin PROBE for anonymous SPA loads is in main.py's SPA
handler (it needs to run on the document request); this middleware only
resolves/authorizes the session.
"""
from __future__ import annotations

from dataclasses import dataclass

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import 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 (everything else under /api is
# gated). Reviewed in tests/test_auth.py.
#   /api/health                     — liveness (no data)
#   /api/me                         — reports session state (null when anon);
#                                     the SPA calls it to decide welcome vs app
#   /api/notifications/telegram/webhook — its own X-Telegram secret-token auth
PUBLIC_API_EXACT: frozenset[str] = frozenset(
    {"/api/health", "/api/me", "/api/notifications/telegram/webhook"}
)

# The DATA namespaces that require a session (default-deny). Anything NOT under
# these prefixes is the static SPA shell / client routes / auth flow → public.
GATED_PREFIXES: tuple[str, ...] = ("/api/", "/notes/")


@dataclass(frozen=True)
class AuthenticatedUser:
    username: str


def _is_public(path: str) -> bool:
    """Public = not a gated DATA path (with a small exact-match /api allowlist).
    The SPA shell, its client routes, and the auth flow are all public."""
    if path in PUBLIC_API_EXACT:
        return True
    return not path.startswith(GATED_PREFIXES)


def get_authenticated_user(request: Request) -> AuthenticatedUser | None:
    """Returns the signed-in user, or None when unauthenticated."""
    return getattr(request.state, "auth_user", None)


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

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

        # Resolve the session on EVERY request so public pages + /api/me know
        # who's signed in. Cheap (one HMAC); harmless on anonymous requests.
        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)

        # Public: the SPA shell, client routes, auth flow, /api/me, health.
        if _is_public(path):
            return await call_next(request)

        # Agent Bearer path (export allowlist) — CLI/agents that can't hold a
        # browser session. Re-validated at the route dependency (defense in
        # depth). auth_user stays unset for these.
        from .services.agent_tokens import is_agent_token_request

        if is_agent_token_request(path, request.headers.get("Authorization", "")):
            return await call_next(request)

        # Gated DATA path. Misconfiguration is a hard close, never an open door.
        if not settings.app_session_secret:
            return error_response(
                AUTH_NOT_CONFIGURED, "auth is not configured on this deployment", 503
            )
        if username is None:
            return error_response(AUTH_SESSION_MISSING, "authentication required", 401)
        return await call_next(request)
