"""Default-deny authentication — "Sign in with BW" (Pattern B).

Everything requires an app session except the EXPLICIT allowlist: the
health/version probes, the three /auth/* flow routes, and the SPA's
static assets/sounds. 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). A route is protected because the middleware
protects everything, not because someone remembered to guard it; there
is deliberately NO broad `/api/*` exemption.

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-User or any other header NEVER grants
identity (mirrors with's T-AZ-045): nothing here reads identity from
request headers. (The only header read at all is X-View-As, and only
under the dev stub, and only to pick a RENDERED perspective — never an
identity.)

Dev bypass: when APP_ENV != "production" and AUTH_DEV_USER is set,
every request resolves to that username with no OAuth, so the UI can be
driven/screenshotted overnight. create_app() refuses to start when
AUTH_DEV_USER is set in production (see services/auth/config.py) — the
env check here is defense in depth.
"""
from __future__ import annotations

import urllib.parse

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

from ..db import SessionLocal
from ..models import Player
from ..services.auth import config as auth_config
from ..services.auth import session as auth_session

# The allowlist is explicit. Exact paths, plus the two static prefixes the
# built SPA serves (hashed Vite bundles + the game's sound files).
ALLOWLIST_EXACT = frozenset(
    {
        "/health",
        "/api/version",
        "/auth/login",
        "/auth/callback",
        "/auth/logout",
    }
)
PUBLIC_PREFIXES = ("/assets/", "/sounds/")


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


def resolve_identity(request: Request) -> dict | None:
    """The ONE place a request becomes a user. Session cookie only —
    never a header. Returns {"username","email"} or None."""
    stub = auth_config.dev_user()
    if stub and not auth_config.is_production():
        return {"username": stub, "email": ""}

    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", "")}


class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        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
            request.state.username = identity["username"]
            # Dev-only perspective switch (overnight checks). NEVER an
            # identity: real view-as state rides the signed vb_view_as
            # cookie and is re-authorized per request in services/view_as.
            request.state.view_as = (
                request.headers.get("X-View-As")
                if auth_config.dev_stub_active()
                else None
            )
            guard = _view_as_write_guard(request)
            if guard is not None:
                return guard
            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
        # (same-origin by construction; /auth/login re-validates it too).
        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)


_MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
# The impersonator must always be able to operate view-as itself (stop!).
_VIEW_AS_EXEMPT_PREFIXES = ("/view-as/", "/auth/")


def _view_as_write_guard(request: Request):
    """VIEW-AS.md: read-only is the DEFAULT impersonation mode — while rian
    renders the app as Erin, an accidental click must not mutate anything.
    Enforced here so a route is protected because the middleware protects
    everything (default-deny instinct), not because a router remembered.
    Writes act as the REAL user everywhere (services.viewer), so this guard
    is about accident-prevention, not privilege."""
    if request.method not in _MUTATING_METHODS:
        return None
    path = request.url.path
    if any(path.startswith(p) for p in _VIEW_AS_EXEMPT_PREFIXES):
        return None
    from ..services import view_as as view_as_service
    from ..services.auth import bw_view_as

    session, _ = view_as_service.resolve(request)
    if bw_view_as.is_impersonating(session) and not bw_view_as.can_write(session):
        return JSONResponse(
            status_code=403,
            content={
                "error_code": "VIEW_AS_READ_ONLY",
                "summary": "You are viewing as someone else (read-only).",
                "detail": "Stop View As (or restart it in act mode) to make changes.",
            },
        )
    return None


def current_player(request: Request) -> Player | None:
    """Resolve the acting player from request.state.username (set by the
    middleware). Returns None if no matching Player row exists yet (e.g.
    a BW user who signed in but hasn't been added to a match). Routers
    decide whether that's a 403."""
    username = getattr(request.state, "username", None)
    if not username:
        return None
    db = SessionLocal()
    try:
        return db.query(Player).filter(Player.username == username).one_or_none()
    finally:
        db.close()
