"""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.

On top of the four classes sits one more placement, the environment (`SITE_ROLE`, Stream
AW6.2): every `PERMISSION` key and `SPA_PERMISSION` path is either a `LIVE_FEATURE` or a
development surface (`DEVELOPMENT_ROUTES`, `DEVELOPMENT_SPA`), and on live a development
surface answers 404 before any sign-in redirect, to a signed-in owner too. It is not a fifth
class and not the public gate: the class still decides who may reach a live feature, and
`SITE_ACCESS` still decides what an anonymous visitor may read. The other three classes carry
no environment: the sign-in machinery, the storefront and the member routes are the same on
both hosts. `tests/test_route_inventory.py` fails when a permission-gated route is in neither
set, so a new owner route cannot ship unplaced.

`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, version and role 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",
    "MOUNT /uploads/images": "the pictures at three levels: brand-supplied or openly licensed derivatives, content-hashed (services/imagery.py); 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 /api/categories", "GET /api/categories/{slug}", "GET /api/families/{slug}",
    "GET /products/{slug}", "GET /airports/{slug}", "GET /airports/{slug}/{category_slug}", "GET /brands/{slug}",
    "GET /alcohol", "GET /alcohol/{category_slug}", "GET /beauty", "GET /beauty/{category_slug}",
    "GET /articles", "GET /articles/{slug}", "GET /data",
    "GET /how-we-choose", "GET /api/featured/method",
    "GET /privacy", "GET /terms", "GET /api/legal/{name}",
    "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",
    "/how-we-choose", "/privacy", "/terms", "/alcohol", "/beauty",
})
SPA_PUBLIC_WHEN_OPEN_PREFIXES = ("/products/", "/airports/", "/brands/", "/articles/", "/feature/", "/alcohol/", "/beauty/")
#: 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/images/wanted": 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/live": 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,
    # The live page's control plane (Stream AW4): every write audits the actor; a GET never writes.
    "POST /api/collectors/{slug}/start": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/{slug}/pause": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/{slug}/resume": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/{slug}/stop": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/{slug}/pace": accounts.PERM_SOURCES_MANAGE,
    "POST /api/collectors/{slug}/mode": accounts.PERM_SOURCES_MANAGE,
    # What one address is (docs/SEO.md): the owner and the admin level; never in a served body.
    "GET /api/pages/status": accounts.PERM_PAGES_STATUS,
    "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/hints/{brand_slug}": "owner",
    "GET /api/review/notes": "owner",
    "GET /api/review/sheets/{brand_slug}/line": "owner",
    "GET /api/review/batches/{batch_uid}": "owner",
    "GET /api/review/decisions": "owner",
    "GET /api/review/rows/{uid}": "owner",
    "GET /api/review/listings": "owner",
    "GET /api/review/collection-rules": "owner",
    "GET /api/review/guidelines": "owner",
    "GET /api/review/brands": "owner",
    "GET /api/review/brands/{brand_slug}/card": "owner",
    "POST /api/review/brands/{brand_slug}/card": "owner",
    "GET /api/review/word-lists": "owner",
    "GET /api/review/process": "owner",
    "GET /api/review/folding": "owner",
    "GET /api/review/folding/{level}": "owner",
    "POST /api/review/sheets/{brand_slug}/approve": "owner",
    "POST /api/review/brands/{brand_slug}/split": "owner",
    "POST /api/review/batches/{batch_uid}/undo": "owner",
    "POST /api/review/decisions/{decision_id}/undo": "owner",
    # The precedent register (Stream K12): read by a pass, overturned by a person; owner only.
    "GET /api/review/precedents": "owner",
    "POST /api/review/precedents/{slug}/overturn": "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",
}

# --- the environment: a live feature, or a surface built for the build ------------------------
#
# Every PERMISSION key and SPA_PERMISSION path is named in exactly one of the three sets below
# (tests/test_route_inventory.py). On live (`SITE_ROLE=live`) a development name answers 404;
# on staging every name behaves as its class says. Nothing else carries an environment: the
# sign-in machinery, the storefront reads and the member routes are the same on both hosts.

#: The build conversation, the plan and the running list, the demo settings and the picture
#: review, and the explainer pages: built to run the build, and absent from the live site.
DEVELOPMENT_SPA = frozenset({
    "/discuss", "/quote", "/structure", "/todo", "/settings", "/images", "/plan", "/issues",
})
#: The same surfaces' routes and their own reads and writes. The side panel's threads,
#: comments, asks and attachments are NOT here: a comment about a real page belongs where
#: the page is, so they stay live (docs/ACCOUNTS.md, the route policy).
DEVELOPMENT_ROUTES = frozenset({
    # The client's material at /discuss: the decision cards, the feature comments, the priorities.
    "GET /api/discussion", "POST /api/discussion",
    "POST /api/discussion/{item_id}", "POST /api/discussion/{item_id}/resolved",
    "POST /api/discussion/{item_id}/comments",
    "GET /api/discussion/feature-comments", "POST /api/discussion/feature-comments",
    "POST /api/discussion/feature-comments/{comment_id}",
    "GET /api/discussion/feature-priorities", "POST /api/discussion/feature-priorities",
    # The launch proposal and the client's to-do list.
    "GET /api/quote/selections", "POST /api/quote/selections",
    "GET /api/quote/requests", "POST /api/quote/requests",
    "GET /api/todos", "GET /api/todos/uploads/{upload_id}",
    "POST /api/todos/{todo_id}/complete", "POST /api/todos/{todo_id}/reopen",
    "POST /api/todos/{todo_id}/uploads",
    # The build plan and the running list.
    "GET /api/plan", "GET /api/items",
    "POST /api/items/{item_id}/decide", "POST /api/items/{item_id}/done",
    "POST /api/items/{item_id}/dismiss", "POST /api/items/{item_id}/archive",
    "POST /api/items/{item_id}/reopen",
    # The picture review at /images.
    "GET /api/images/recommended", "GET /api/images/products",
    "GET /api/images/brands", "GET /api/images/wanted",
    # The pages themselves: the three moved addresses, the built docs, the explainer pages.
    "GET /todo", "GET /structure", "GET /quote",
    "MOUNT /docs-static", "GET /{page}.html",
})
#: Everything else a permission gates: the catalogue's own surfaces, which exist wherever the
#: catalogue is, and the side panel the people reviewing it talk in.
LIVE_FEATURES = frozenset({
    # The SPA pages.
    "/sources", "/collectors", "/review", "/admin",
    # The side panel (T11 to T18): threads, comments, asks, people, topics, attachments.
    "GET /api/discussion/threads", "GET /api/discussion/people", "POST /api/discussion/topics",
    "POST /api/discussion/comments", "POST /api/discussion/comments/{comment_id}/edit",
    "POST /api/discussion/comments/{comment_id}/delete",
    "POST /api/discussion/comments/{comment_id}/move",
    "POST /api/discussion/comments/{comment_id}/ack",
    "POST /api/discussion/comments/{comment_id}/flag",
    "POST /api/discussion/threads/{thread_id}/resolve",
    "POST /api/discussion/threads/{thread_id}/read",
    "POST /api/discussion/threads/{thread_id}/unread",
    "POST /api/discussion/threads/{thread_id}/followup",
    "POST /api/discussion/threads/{thread_id}/followup/clear",
    "POST /api/discussion/threads/{thread_id}/asks",
    "POST /api/discussion/threads/{thread_id}/archive",
    "POST /api/discussion/threads/{thread_id}/stage",
    "POST /api/discussion/asks/{ask_id}/done", "POST /api/discussion/asks/{ask_id}/edit",
    "POST /api/discussion/asks/{ask_id}/delete",
    "POST /api/attachments/{subject_type}/{subject_id}",
    "GET /api/attachments/{subject_type}/{subject_id}/{name}",
    # The sources and the kill switch.
    "GET /api/sources", "POST /api/sources/{slug}/enabled",
    # The collectors: the reads, the merge desk, the decided layer, the control plane.
    "GET /api/collectors", "GET /api/collectors/catalogue", "GET /api/collectors/products",
    "GET /api/collectors/runs", "GET /api/collectors/live", "GET /api/collectors/problems",
    "GET /api/collectors/listings", "GET /api/collectors/merge",
    "GET /api/collectors/desk/brands", "GET /api/collectors/desk/lines",
    "POST /api/collectors/merge/{suggestion_id}/confirm",
    "POST /api/collectors/merge/{suggestion_id}/keep-separate",
    "POST /api/collectors/merge/suggest", "POST /api/collectors/merge/batch",
    "POST /api/collectors/merge/propose",
    "POST /api/collectors/products/{variant_id}/override",
    "POST /api/collectors/listings/{listing_id}/pin",
    "POST /api/collectors/listings/{listing_id}/unpin",
    "POST /api/collectors/listings/{listing_id}/ignore",
    "POST /api/collectors/listings/{listing_id}/unignore",
    "POST /api/collectors/{slug}/start", "POST /api/collectors/{slug}/pause",
    "POST /api/collectors/{slug}/resume", "POST /api/collectors/{slug}/stop",
    "POST /api/collectors/{slug}/pace", "POST /api/collectors/{slug}/mode",
    # What one address is, and who is being viewed as whom.
    "GET /api/pages/status", "GET /api/ops/impersonations",
    # The review area: the sheets, the cards, the precedents, the index candidates.
    "GET /api/review/sheets", "GET /api/review/sheets/{brand_slug}",
    "GET /api/review/sheets/{brand_slug}/line", "GET /api/review/hints/{brand_slug}",
    "GET /api/review/notes", "GET /api/review/batches/{batch_uid}", "GET /api/review/decisions",
    "GET /api/review/rows/{uid}", "GET /api/review/listings",
    "GET /api/review/collection-rules", "GET /api/review/guidelines", "GET /api/review/brands",
    "GET /api/review/brands/{brand_slug}/card", "POST /api/review/brands/{brand_slug}/card",
    "GET /api/review/word-lists", "GET /api/review/process",
    "GET /api/review/folding", "GET /api/review/folding/{level}",
    "POST /api/review/sheets/{brand_slug}/approve", "POST /api/review/brands/{brand_slug}/split",
    "POST /api/review/batches/{batch_uid}/undo", "POST /api/review/decisions/{decision_id}/undo",
    "GET /api/review/precedents", "POST /api/review/precedents/{slug}/overturn",
    "GET /api/review/index/candidates", "POST /api/review/index/candidates/decide",
})

#: 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 /alcohol", "GET /alcohol/{category_slug}", "GET /beauty", "GET /beauty/{category_slug}",
    "GET /articles", "GET /articles/{slug}",
    "GET /data", "GET /how-we-choose", "GET /privacy", "GET /terms",
    "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


def is_development(key: str | None, page: str | None) -> bool:
    """A surface built for the build, by route key or by the SPA path the catch-all serves."""
    if key in DEVELOPMENT_ROUTES:
        return True
    if key == CATCH_ALL and page is not None:
        return (page.rstrip("/") or "/") in DEVELOPMENT_SPA
    return False


# --- 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, live: bool = False) -> 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. `live` is the environment (`settings.is_live`), which the
    middleware always passes; it defaults to staging for the same reason the setting does,
    so an omission shows the development surfaces rather than hiding a live feature."""
    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 live and is_development(key, page):
        # On live a development surface does not exist, for the owner either: a real 404 before
        # any sign-in redirect or permission answer, so nothing discloses that the address is
        # there. The shell for a page (verify by status, never by what the shell renders), JSON
        # for a read.
        return Verdict(404, "NOT_FOUND",
                       summary="No such page." if wants_html else "No such route.")
    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),
            live=settings.is_live,
        )
        # 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
