"""The route policy: every route in exactly one class, the pure `decide()`, the middleware.

Sources of truth: this module, `tests/test_access.py` (the policy walked case by case),
`tests/test_route_inventory.py` (every route, mount, page and SPA path classified),
`tests/fixtures/open_routes.json` (the exact PUBLIC_WHEN_OPEN set: opening a route is a
reviewed diff), `tests/fixtures/next_cases.json` (shared with the SPA's `safeNext`),
`docs/ACCOUNTS.md`. Design: `.logs/planning/accounts-2026-09.md` §4.7.

Four classes. PUBLIC_ALWAYS: what an anonymous visitor needs to reach the sign-in and what
carries no data. PUBLIC_WHEN_OPEN: every storefront read, public only when
`SITE_ACCESS=public`, otherwise any signed-in account. MEMBER: any signed-in account (the
kit enforces its own permissions behind that floor). PERMISSION: one permission string,
or `owner`. A route in no class is refused before its handler runs: the fail-safe that
makes a new route closed, not open, until someone classifies it.

`wants_html` is computed from the route key, never from the `Accept` header, so a client
cannot pick a friendlier answer by asking for one. Members-only mode answers every
anonymous HTML request with one 302 to `/login`, so route existence is not disclosed and
no server-rendered body (prices) is ever built for an anonymous caller: the refusal
happens before routing. `safe_next()` is the only place a redirect target is accepted.
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import quote, unquote, urlsplit

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, RedirectResponse, Response
from fastapi.routing import APIRoute
from starlette.routing import Match, Mount

from app.config import settings
from app.services import accounts, audit_log, identity, sessions, view_as

log = logging.getLogger(__name__)

SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
CATCH_ALL = "GET /{full_path:path}"
LOGIN_PATH = "/login"
ACCOUNT_PATH = "/account"

# --- the classes ------------------------------------------------------------------------------

#: Reachable by anyone in every mode; a write here carries the reason it is open.
PUBLIC_ALWAYS: dict[str, str] = {
    "GET /api/health": "ok and version only",
    "GET /api/bw/me": "the SPA's am-I-signed-in probe; an anonymous caller gets the anonymous payload",
    "POST /api/auth/login": "the sign-in; throttled per address, silent per-account lock",
    "POST /api/auth/logout": "clearing one's own session",
    "POST /api/auth/forgot": "asking for a reset link; the answer never changes",
    "POST /api/auth/token-state": "whether a welcome or reset link still works; writes nothing",
    "POST /api/auth/welcome": "redeeming an invite link; the token rides in the body",
    "POST /api/auth/reset": "redeeming a reset link; the token rides in the body",
    "GET /robots.txt": "a 302 or 401 there reads as 'no restrictions'; members mode answers Disallow: /",
    "MOUNT /assets": "the hashed build assets; no data",
    "MOUNT /fonts": "no data",
    "MOUNT /medals": "the medal artwork; no data",
    "MOUNT /flags": "country flags for the airport cards; no data",
    "MOUNT /media": "editorial images (article heroes) that ship with the site; no data",
}
#: The SPA paths an anonymous visitor may load (the token is read from location.hash).
SPA_PUBLIC_ALWAYS = frozenset({"/login", "/forgot", "/welcome", "/reset"})

#: Every storefront read; open to anyone only when SITE_ACCESS=public, else any signed-in account.
PUBLIC_WHEN_OPEN = frozenset({
    "GET /api/products", "GET /api/products/featured-savings", "GET /api/products/{slug}",
    "GET /api/products/{variant_id}/similar", "GET /api/airports", "GET /api/airports/{iata}",
    "GET /api/airports/{iata}/hours", "GET /api/airports/{iata}/categories/{category_slug}",
    "GET /api/brands", "GET /api/brands/{slug}", "GET /api/dataset", "GET /api/stats",
    "GET /api/search/suggest",
    "GET /api/articles", "GET /api/articles/airport/{iata}", "GET /api/articles/category/{category}",
    "GET /api/articles/{slug}",
    "GET /api/trip", "GET /api/trip/featured", "GET /api/trip/suggested", "GET /api/trip/stops",
    "GET /api/sources/coverage", "GET /api/sources/uncovered", "GET /api/sources/expandable",
    "POST /api/subscribers",
    "GET /products/{slug}", "GET /airports/{slug}", "GET /airports/{slug}/{category_slug}", "GET /brands/{slug}",
    "GET /articles/{slug}", "GET /data",
    "GET /sitemap.xml", "GET /feed.xml", "GET /llms.txt",
    CATCH_ALL,
})
PUBLIC_WRITES_WHEN_OPEN: dict[str, str] = {
    "POST /api/subscribers": "the newsletter form on the storefront; throttled, honeypotted",
}
#: The storefront SPA paths, and the prefixes under them; anything else the catch-all
#: serves is the not-found shell, which sits in the same class.
SPA_PUBLIC_WHEN_OPEN = frozenset({
    "/", "/products", "/savings", "/trip", "/exclusives", "/awards", "/airports", "/articles", "/data",
})
SPA_PUBLIC_WHEN_OPEN_PREFIXES = ("/products/", "/airports/", "/brands/", "/articles/", "/feature/")
#: Machine files answer a plain 404 while members-only: a crawler must not learn a shape.
MACHINE_FILES = frozenset({"GET /sitemap.xml", "GET /feed.xml", "GET /llms.txt"})
#: FastAPI's docs are off in production; these answer a plain 404 in every mode.
DOCS_PATHS = frozenset({"/docs", "/redoc", "/openapi.json", "/docs/oauth2-redirect"})

#: Any signed-in, active account. The kit's routes are listed one by one: a re-vendor that
#: adds a route fails the inventory until it is classified.
MEMBER = frozenset({
    "POST /api/auth/change-password", "POST /api/auth/heartbeat", "GET /api/whoami",
    "GET /api/notifications", "POST /api/notifications/read",
    "GET /api/notifications/preferences", "POST /api/notifications/preferences",
    "GET /api/bw/levels", "POST /api/bw/levels", "POST /api/bw/levels/adopt",
    "PATCH /api/bw/levels/{name}", "DELETE /api/bw/levels/{name}",
    "GET /api/bw/accounts", "GET /api/bw/users/check", "POST /api/bw/accounts/invite",
    "POST /api/bw/accounts/{username}/level", "POST /api/bw/accounts/{username}/all-instances",
    "DELETE /api/bw/accounts/{username}", "POST /api/bw/accounts/{username}/send-reset",
    "POST /api/bw/accounts/{username}/new-password",
    "GET /api/bw/instances", "POST /api/bw/instances/{iid}/members",
    "POST /api/bw/instances/{iid}/members/{username}/level",
    "DELETE /api/bw/instances/{iid}/members/{username}", "GET /api/bw/instances/{iid}/members",
    "POST /api/bw/instances/{iid}/invite", "GET /api/bw/access-matrix", "GET /api/bw/users/search",
    "GET /api/bw/my-access", "POST /api/bw/my/send-reset", "POST /api/bw/sync",
    "GET /api/bw/view-as/targets", "POST /api/bw/view-as/start", "POST /api/bw/view-as/stop",
})
SPA_MEMBER = frozenset({ACCOUNT_PATH})

#: One permission string, or `owner`.
PERMISSION: dict[str, str] = {
    "GET /api/discussion/feature-comments": accounts.PERM_CLIENT_VIEW,
    "POST /api/discussion/feature-comments": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/discussion/feature-comments/{comment_id}": accounts.PERM_CLIENT_PARTICIPATE,
    "GET /api/discussion/feature-priorities": accounts.PERM_CLIENT_VIEW,
    "POST /api/discussion/feature-priorities": accounts.PERM_CLIENT_PARTICIPATE,
    "GET /api/discussion": accounts.PERM_CLIENT_VIEW,
    "POST /api/discussion": accounts.PERM_DISCUSSION_CURATE,
    "POST /api/discussion/{item_id}": accounts.PERM_DISCUSSION_CURATE,
    "POST /api/discussion/{item_id}/resolved": accounts.PERM_DISCUSSION_CURATE,
    "POST /api/discussion/{item_id}/comments": accounts.PERM_CLIENT_PARTICIPATE,
    "GET /api/discussion/threads": accounts.PERM_CLIENT_VIEW,
    "GET /api/discussion/people": accounts.PERM_CLIENT_VIEW,
    "POST /api/discussion/topics": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/discussion/comments": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/discussion/comments/{comment_id}/edit": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/discussion/comments/{comment_id}/delete": accounts.PERM_CLIENT_PARTICIPATE,
    # Re-filing a comment under another subject (T18): a curator's.
    "POST /api/discussion/comments/{comment_id}/move": accounts.PERM_DISCUSSION_CURATE,
    "POST /api/discussion/threads/{thread_id}/resolve": accounts.PERM_CLIENT_PARTICIPATE,
    # The panel's read state (T12): any viewer moves their own stamp, so client.view, not participate.
    "POST /api/discussion/threads/{thread_id}/read": accounts.PERM_CLIENT_VIEW,
    "POST /api/discussion/threads/{thread_id}/unread": accounts.PERM_CLIENT_VIEW,
    # The needs-follow-up mark (T13): a curator's.
    "POST /api/discussion/threads/{thread_id}/followup": accounts.PERM_DISCUSSION_CURATE,
    "POST /api/discussion/threads/{thread_id}/followup/clear": accounts.PERM_DISCUSSION_CURATE,
    # The workflow: an ask (anyone who may write), its done (the person asked or a curator, in
    # the handler), and "Got it" on a comment.
    "POST /api/discussion/threads/{thread_id}/asks": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/discussion/asks/{ask_id}/done": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/discussion/asks/{ask_id}/edit": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/discussion/asks/{ask_id}/delete": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/discussion/comments/{comment_id}/ack": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/discussion/threads/{thread_id}/archive": accounts.PERM_DISCUSSION_CURATE,
    "POST /api/discussion/threads/{thread_id}/stage": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/discussion/comments/{comment_id}/flag": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/attachments/{subject_type}/{subject_id}": accounts.PERM_CLIENT_PARTICIPATE,
    "GET /api/attachments/{subject_type}/{subject_id}/{name}": accounts.PERM_CLIENT_VIEW,
    "GET /api/quote/selections": accounts.PERM_CLIENT_VIEW,
    "POST /api/quote/selections": accounts.PERM_CLIENT_PARTICIPATE,
    "GET /api/quote/requests": accounts.PERM_CLIENT_VIEW,
    "POST /api/quote/requests": accounts.PERM_CLIENT_PARTICIPATE,
    "GET /api/todos": accounts.PERM_CLIENT_VIEW,
    "GET /api/todos/uploads/{upload_id}": accounts.PERM_CLIENT_VIEW,
    "POST /api/todos/{todo_id}/complete": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/todos/{todo_id}/reopen": accounts.PERM_CLIENT_PARTICIPATE,
    "POST /api/todos/{todo_id}/uploads": accounts.PERM_CLIENT_PARTICIPATE,
    "GET /api/plan": accounts.PERM_PLAN_VIEW,
    "GET /api/items": accounts.PERM_PLAN_VIEW,
    "POST /api/items/{item_id}/decide": accounts.PERM_ITEMS_ACT,
    "POST /api/items/{item_id}/done": accounts.PERM_ITEMS_ACT,
    "POST /api/items/{item_id}/dismiss": accounts.PERM_ITEMS_ACT,
    "POST /api/items/{item_id}/archive": accounts.PERM_ITEMS_ACT,
    "POST /api/items/{item_id}/reopen": accounts.PERM_ITEMS_ACT,
    "GET /api/sources": accounts.PERM_SOURCES_MANAGE,
    "GET /api/images/recommended": accounts.PERM_CLIENT_VIEW,
    "GET /api/images/products": accounts.PERM_CLIENT_VIEW,
    "GET /api/images/brands": accounts.PERM_CLIENT_VIEW,
    "GET /api/collectors": accounts.PERM_SOURCES_MANAGE,
    "GET /api/collectors/catalogue": accounts.PERM_SOURCES_MANAGE,
    "GET /api/collectors/products": accounts.PERM_SOURCES_MANAGE,
    "GET /api/collectors/runs": accounts.PERM_SOURCES_MANAGE,
    "GET /api/collectors/problems": accounts.PERM_SOURCES_MANAGE,
    "GET /api/collectors/listings": accounts.PERM_SOURCES_MANAGE,
    "GET /api/collectors/merge": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/merge/{suggestion_id}/confirm": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/merge/{suggestion_id}/keep-separate": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/merge/suggest": accounts.PERM_SOURCES_MANAGE,
    # The merge desk (Stream L): the two tables, the batch, the proposals.
    "GET /api/collectors/desk/brands": accounts.PERM_SOURCES_MANAGE,
    "GET /api/collectors/desk/lines": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/merge/batch": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/merge/propose": accounts.PERM_SOURCES_MANAGE,
    # The decided layer (Stream L): a product decision, a listing pin or ignore.
    "POST /api/collectors/products/{variant_id}/override": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/listings/{listing_id}/pin": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/listings/{listing_id}/unpin": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/listings/{listing_id}/ignore": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/listings/{listing_id}/unignore": accounts.PERM_SOURCES_MANAGE,
    "POST /api/sources/{slug}/enabled": accounts.PERM_SOURCES_MANAGE,
    "GET /api/ops/impersonations": "owner",
    # The review (Stream K4): the proposals sheet, its approval and the two undos; owner only.
    "GET /api/review/sheets": "owner",
    "GET /api/review/sheets/{brand_slug}": "owner",
    "GET /api/review/batches/{batch_uid}": "owner",
    "GET /api/review/decisions": "owner",
    "POST /api/review/sheets/{brand_slug}/approve": "owner",
    "POST /api/review/batches/{batch_uid}/undo": "owner",
    "POST /api/review/decisions/{decision_id}/undo": "owner",
    # Index candidates (Stream K6): which generated pages search engines are shown; owner only.
    "GET /api/review/index/candidates": "owner",
    "POST /api/review/index/candidates/decide": "owner",
    # The client pages folded into /discuss (T8): the old addresses 301 with the fragment kept.
    "GET /todo": accounts.PERM_CLIENT_VIEW,
    "GET /structure": accounts.PERM_CLIENT_VIEW,
    "GET /quote": accounts.PERM_CLIENT_VIEW,
    "MOUNT /docs-static": accounts.PERM_CLIENT_VIEW,
    "GET /{page}.html": accounts.PERM_CLIENT_VIEW,
}
SPA_PERMISSION: dict[str, str] = {
    "/discuss": accounts.PERM_CLIENT_VIEW, "/quote": accounts.PERM_CLIENT_VIEW,
    "/structure": accounts.PERM_CLIENT_VIEW, "/todo": accounts.PERM_CLIENT_VIEW,
    "/settings": accounts.PERM_CLIENT_VIEW, "/images": accounts.PERM_CLIENT_VIEW,
    "/plan": accounts.PERM_PLAN_VIEW, "/issues": accounts.PERM_PLAN_VIEW,
    "/sources": accounts.PERM_SOURCES_MANAGE,
    "/collectors": accounts.PERM_SOURCES_MANAGE,
    "/review": "owner",
    "/admin": "accounts.view",
}

#: Writes still admitted while impersonating read-only: ending the View As, signing out.
READ_ONLY_EXEMPT = frozenset({"POST /api/bw/view-as/stop", "POST /api/auth/logout"})
#: Routes a must-change-password session may still reach (plus PUBLIC_ALWAYS and the shell).
MUST_CHANGE_EXEMPT = frozenset({
    "POST /api/auth/change-password", "POST /api/auth/logout", "GET /api/bw/me", "GET /api/whoami",
})
#: Keys whose answer is a page (the redirect form of a refusal); everything else is JSON.
HTML_KEYS = frozenset({
    "GET /products/{slug}", "GET /airports/{slug}", "GET /airports/{slug}/{category_slug}", "GET /brands/{slug}",
    "GET /articles/{slug}",
    "GET /data", "MOUNT /docs-static", "GET /{page}.html", CATCH_ALL,
    "GET /todo", "GET /structure", "GET /quote",
})
_KEY_FILE = re.compile(r"^GET /[A-Za-z0-9]{8,128}\.txt$")  # the IndexNow key file route

#: The regular files in the shell's root, taken at mount time and matched by exact path
#: (logo, favicon, manifest); a path with a `..` segment or an encoded slash is never one.
ROOT_FILES: frozenset[str] = frozenset()


def register_root_files(static_dir: Path) -> None:
    global ROOT_FILES
    ROOT_FILES = frozenset(f"/{p.name}" for p in static_dir.iterdir() if p.is_file())


# --- classification -------------------------------------------------------------------------

def spa_class(path: str) -> tuple[str, str | None]:
    """The class of a path the catch-all serves: a root file, an SPA page, or the not-found
    shell (which sits with the storefront so an unknown path answers like a known one)."""
    if path in ROOT_FILES:
        return "public_always", None
    norm = path.rstrip("/") or "/"
    if norm in SPA_PUBLIC_ALWAYS:
        return "public_always", None
    if norm in SPA_MEMBER:
        return "member", None
    if norm in SPA_PERMISSION:
        return "permission", SPA_PERMISSION[norm]
    return "public_when_open", None


def classify(key: str | None, page: str | None = None) -> tuple[str | None, str | None]:
    """`(class, permission)`; class None means unclassified. `page` is the request path when
    the key is the catch-all."""
    if key is None:
        return None, None
    if key == CATCH_ALL:
        return spa_class(page or "/")
    if key in PUBLIC_ALWAYS:
        return "public_always", None
    if key in PUBLIC_WHEN_OPEN or _KEY_FILE.match(key):
        return "public_when_open", None
    if key in MEMBER:
        return "member", None
    if key in PERMISSION:
        return "permission", PERMISSION[key]
    if not settings.is_production and key in ("GET /docs", "GET /redoc", "GET /openapi.json",
                                              "GET /docs/oauth2-redirect"):
        return "public_always", None
    return None, None


def wants_html(key: str | None) -> bool:
    return key in HTML_KEYS


# --- the redirect target ----------------------------------------------------------------------

_CONTROL = re.compile(r"[\x00-\x1f\x7f]")


def safe_next(value: str | None) -> str:
    """The only place a redirect target is accepted, on the server and (as `safeNext` in the
    SPA) on the client; both read `tests/fixtures/next_cases.json`. A path, nothing more:
    empty scheme and netloc, one leading slash, no backslash, no control character, no
    encoded backslash or slash after decoding, at most 200 characters; anything else is `/`."""
    if not value or len(value) > 200:
        return "/"
    if not value.startswith("/") or value.startswith("//") or "\\" in value or _CONTROL.search(value):
        return "/"
    parts = urlsplit(value)
    if parts.scheme or parts.netloc:
        return "/"
    decoded = unquote(value)
    if "\\" in decoded or "%2f" in value.lower() or "%5c" in value.lower() or _CONTROL.search(decoded):
        return "/"
    if decoded.startswith("//"):
        return "/"
    return value


def login_redirect(path: str) -> str:
    target = safe_next(path)
    return LOGIN_PATH if target == "/" else f"{LOGIN_PATH}?next={quote(target, safe='/')}"


# --- the decision -------------------------------------------------------------------------------

@dataclass(frozen=True)
class Verdict:
    status: int
    code: str
    location: str | None = None
    summary: str = ""


def decide(*, method: str, key: str | None, page: str | None, site_open: bool, signed_in: bool,
           active: bool, must_change: bool, holds, read_only: bool, origin_ok: bool,
           wants_html: bool) -> Verdict | None:
    """The whole policy as a pure function; None means let the request through.
    `holds(permission)` answers the permission question for the EFFECTIVE user and takes
    the literal `owner` too."""
    cls, permission = classify(key, page)
    if cls is None:
        return Verdict(403, "ROUTE_NOT_CLASSIFIED",
                       summary="This route is in no class of services/access.py.")
    non_safe = method not in SAFE_METHODS
    if non_safe and not origin_ok:
        return Verdict(403, "ORIGIN_MISMATCH", summary="The request came from another site.")
    if non_safe and read_only and key not in READ_ONLY_EXEMPT:
        return Verdict(403, "VIEW_AS_READ_ONLY",
                       summary="You are viewing as someone else, read-only. Return to yourself to make changes.")
    if key == CATCH_ALL and page is not None:
        norm = page.rstrip("/") or "/"
        if norm in DOCS_PATHS:
            return Verdict(404, "NOT_FOUND", summary="No such page.")
        last = norm.rsplit("/", 1)[-1]
        if cls != "public_always" and "." in last:
            # A missing file answers as a file in every mode, never with a login page.
            return Verdict(404, "NOT_FOUND", summary="No such file.")
    if cls == "public_always":
        return None
    if key in MACHINE_FILES and not site_open:
        return Verdict(404, "NOT_FOUND", summary="No such file.")
    if cls == "public_when_open" and site_open:
        return None
    if not signed_in:
        if wants_html:
            return Verdict(302, "NOT_SIGNED_IN", location=login_redirect(page or "/"))
        return Verdict(401, "NOT_SIGNED_IN", summary="Sign in to see this.")
    if not active:
        if wants_html:
            return Verdict(302, "ACCOUNT_DISABLED", location=LOGIN_PATH)
        return Verdict(401, "ACCOUNT_DISABLED", summary="This account is disabled.")
    if must_change and key not in MUST_CHANGE_EXEMPT and key != CATCH_ALL:
        if wants_html:
            return Verdict(302, "PASSWORD_CHANGE_REQUIRED", location=ACCOUNT_PATH)
        return Verdict(403, "PASSWORD_CHANGE_REQUIRED", summary="Choose a new password first.")
    if cls in ("public_when_open", "member"):
        return None
    if not holds(permission):
        if wants_html and permission == accounts.PERM_PLAN_VIEW:
            return Verdict(404, "NOT_FOUND", summary="No such page.")
        return Verdict(403, "FORBIDDEN", summary="You do not have access to this.")
    return None


# --- the request side -----------------------------------------------------------------------

def origin_ok(request: Request, public_base_url: str) -> bool:
    """Every non-safe request carries an Origin or Referer whose host is the request host or
    the configured public origin. Required, not optional: the cookie is SameSite=Lax and this
    is the CSRF check for every write, the kit's routes included."""
    sent = request.headers.get("origin") or request.headers.get("referer")
    if not sent:
        return False
    host = urlsplit(sent).netloc.lower()
    if not host:
        return False
    allowed = {(request.headers.get("host") or "").lower()}
    if public_base_url:
        allowed.add(urlsplit(public_base_url).netloc.lower())
    allowed.discard("")
    return host in allowed


def _walk(routes):
    for route in routes:
        if isinstance(route, APIRoute | Mount):
            yield route
        elif hasattr(route, "routes"):
            yield from _walk(route.routes)
        elif hasattr(route, "original_router"):
            yield from _walk(route.original_router.routes)


def route_key(app: FastAPI, scope: dict) -> str | None:
    """The 'METHOD /template' (or 'MOUNT /path') the request would dispatch to, matched the
    way the router matches: exact template, first full match, HEAD folded into GET."""
    method = scope["method"].upper()
    lookup = "GET" if method == "HEAD" else method
    for route in _walk(app.routes):
        if isinstance(route, Mount):
            match, _ = route.matches(scope)
            if match is Match.FULL:
                return f"MOUNT {route.path}"
            continue
        if lookup not in route.methods:
            continue
        match, _ = route.matches(scope)
        if match is Match.FULL:
            return f"{lookup} {route.path}"
    return None


def _render(verdict: Verdict, request: Request) -> Response:
    headers = {"Cache-Control": "no-store"}
    if verdict.status == 302:
        return RedirectResponse(verdict.location or LOGIN_PATH, status_code=302, headers=headers)
    if verdict.status == 404 and verdict.summary == "No such page.":
        not_found = getattr(request.app.state, "not_found", None)
        if not_found is not None:
            return not_found(request)
    return JSONResponse({"detail": {"error_code": verdict.code, "summary": verdict.summary}},
                        status_code=verdict.status, headers=headers)


def install(app: FastAPI) -> None:
    """Register the middleware; it reads settings at request time so a test can change them.
    Runs inside the session loader (which is added after it) and before routing."""

    @app.middleware("http")
    async def access_policy(request: Request, call_next):
        method = request.method.upper()
        path = request.url.path
        key = route_key(app, request.scope)
        page = path if wants_html(key) else None  # the path, for the redirect's next and the catch-all's class
        row = sessions.of_request(request)
        signed_in = row is not None
        active = bool(row and row.active)
        effective = identity.optional_user(request) if active else None
        verdict = decide(
            method=method, key=key, page=page, site_open=settings.site_open,
            signed_in=signed_in, active=active,
            must_change=bool(row and row.must_change_password),
            holds=accounts.holder(effective), read_only=view_as.read_only(request),
            origin_ok=origin_ok(request, settings.public_base_url), wants_html=wants_html(key),
        )
        # Read before the route runs: the request that STARTS an act-mode View As is not itself
        # an act-mode write.
        acting = bool(row is not None and row.acting_as and row.acting_mode == "act")
        if verdict is not None:
            log.info("refused %s %s: %s", method, path, verdict.code)
            response = _render(verdict, request)
        else:
            response = await call_next(request)
            if method not in SAFE_METHODS and acting:
                # The one record of who really acted: the sink fills both ids from the session.
                audit_log.record("act_as.write", detail={"key": key, "status": response.status_code})
        if not settings.site_open:
            response.headers["X-Robots-Tag"] = "noindex, nofollow"
        return response
