"""Default-deny at the framework layer.

A route is protected because this middleware protects everything by default, not
because someone remembered to guard it. Adding a new /api route makes it
inaccessible until it is either given a dependency or deliberately listed below.

NEVER add a broad prefix exemption inside /api/ (an "/api/public/*" or a
"skip auth for /api/*"). That inverted default is how API routes get left open to
the internet — a documented, repeated failure on this server.
"""

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse

from app import bw_view_as

SESSION_USER_KEY = "user"

# The only unauthenticated API surface. Each entry is exact-match, and each one
# is here because sign-in itself depends on it:
#   /api/me    — the SPA asks "am I signed in?" before it can render anything.
#   /api/meta  — version + vocabularies; rendered on the signed-out screen too.
PUBLIC_API_PATHS = frozenset({"/api/me", "/api/meta"})

# The sign-in machinery (browser redirects, not data) plus the anonymous
# forgot-password endpoint — which must stay constant-response and throttled in
# its handler precisely BECAUSE it is public (D32).
PUBLIC_PATHS = frozenset(
    {"/healthz", "/auth/probe", "/auth/login", "/auth/callback", "/auth/forgot", "/logout"}
)

# Hashed build assets and the SPA shell. Static files with no data in them: the
# shell has to reach an anonymous visitor or nobody could see the sign-in screen.
PUBLIC_PREFIXES = ("/assets/",)


def _is_public(path: str) -> bool:
    if path in PUBLIC_PATHS or path in PUBLIC_API_PATHS:
        return True
    if path.startswith(PUBLIC_PREFIXES):
        return True
    if path.startswith("/api/") or path.startswith("/auth/"):
        return False  # default-deny: anything new under these needs a session
    return True  # the SPA shell — a static document carrying no data


# Mutations still allowed while impersonating READ-ONLY: ending the view-as and
# signing out. Everything else that changes state is refused, structurally, so
# no individual route has to remember the check.
_READONLY_EXEMPT = frozenset({"/api/view-as/stop", "/logout"})
_MUTATING = frozenset({"POST", "PUT", "PATCH", "DELETE"})


class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        if _is_public(request.url.path):
            return await call_next(request)
        if not request.session.get(SESSION_USER_KEY):
            return JSONResponse(
                status_code=401,
                content={
                    "error_code": "NOT_AUTHENTICATED",
                    "summary": "Sign in to continue.",
                    "details": "This request needs a signed-in Scout session.",
                },
            )
        # View As, read-only mode: looking is fine, changing anything is not.
        if (
            request.method in _MUTATING
            and request.url.path not in _READONLY_EXEMPT
            and request.session.get("bw_acting_as")
            and not bw_view_as.can_write(request.session)
        ):
            return JSONResponse(
                status_code=403,
                content={
                    "error_code": "VIEW_AS_READ_ONLY",
                    "summary": "You are viewing as someone else, read-only.",
                    "details": "Return to yourself to make changes, or start "
                    "View As in act mode.",
                },
            )
        return await call_next(request)
