//! Per-launch authentication token for `cqs serve`.
//!
//! Without auth, anyone able to reach the bind address gets full read access
//! to the project (source, notes, graph, embeddings) — the host-header
//! allowlist and SQL caps don't help on a multi-user box or after a container
//! escape.
//!
//! A per-launch token is generated once at startup and checked on every
//! request via three accepted channels:
//!
//! 1. **`Authorization: Bearer <token>` header** — for API clients and
//!    `fetch()` callers that can set headers.
//! 2. **`?token=<token>` query param** — for initial browser navigation
//!    and bookmarks. On match the middleware sets a `cqs_token_<port>`
//!    cookie (`HttpOnly; SameSite=Strict; Path=/`) and 302-redirects
//!    to the clean URL, so reload + bookmark both work without leaving
//!    the token in the address bar.
//! 3. **`cqs_token_<port>` cookie** — used after the redirect handoff
//!    above, for every subsequent request. Cookie name is per-port so
//!    two `cqs serve` instances on the same host don't collide in the
//!    browser jar.
//!
//! The compare is constant-time via [`subtle::ConstantTimeEq`] so a
//! timing oracle can't recover the token byte-by-byte. The token is 32
//! random bytes URL-safe base64-encoded (no padding) — 256 bits of
//! entropy in a paste-friendly 43-character string.
//!
//! `--no-auth` opts out for scripted automation; the caller is responsible for
//! emitting the loud-warning banner.

use std::sync::Arc;

use axum::{
    body::Body,
    extract::{Request, State},
    http::{header, HeaderValue, Response, StatusCode, Uri},
    middleware::Next,
    response::{IntoResponse, Redirect},
};
use base64::Engine;
use subtle::ConstantTimeEq;

/// Cookie-name prefix. The full cookie name is
/// [`cookie_name_for_port`], which appends the bind port so two
/// instances of `cqs serve` running on the same host with different
/// ports don't stomp on each other's cookies in the browser jar.
///
/// Browser cookies on `localhost`/`127.0.0.1` are scoped by host but
/// **not** by port (RFC 6265 §5.4). Without the port suffix, project A
/// on `:8080` and project B on `:8081` both write `cqs_token=…` for
/// the same `(host, path)` pair — the second redirect overwrites the
/// first, silently logging the user out of the original tab and
/// (worse) sending B's token to A on every subsequent request.
const COOKIE_NAME_PREFIX: &str = "cqs_token";

/// Compute the per-launch cookie name from the bind port. Two
/// instances on the same host but different ports get distinct
/// cookies, so the browser cookie jar can hold both without
/// collision.
pub(crate) fn cookie_name_for_port(port: u16) -> String {
    format!("{COOKIE_NAME_PREFIX}_{port}")
}

/// Per-launch auth token. Generated by [`AuthToken::random`] at server
/// startup; cloned cheaply for the middleware closure (it's just an
/// `Arc<String>` under the hood).
///
/// **Alphabet invariant.** The wrapped string is always non-empty and
/// matches `[A-Za-z0-9_-]+` (URL-safe base64 alphabet, no padding) —
/// enforced at construction by every public constructor. This means
/// the byte sequence is always valid in an HTTP header value and a
/// cookie value, so the `HeaderValue::from_str(...).expect(...)` at
/// the cookie-set callsite is a structural guarantee rather than a
/// docstring promise.
///
/// Public so `bin/cqs` can construct one in `cmd_serve` and hand it to
/// [`super::run_server`]; the token string itself is private and only
/// surfaced via [`AuthToken::as_str`] for the listening-banner / open-
/// browser URL.
#[derive(Clone, Debug)]
pub struct AuthToken(Arc<String>);

/// Error returned by [`AuthToken::try_from_string`] when the input
/// violates the URL-safe base64 alphabet contract.
#[derive(Debug, thiserror::Error)]
#[error("token must be non-empty and contain only URL-safe base64 characters [A-Za-z0-9_-]")]
pub struct InvalidTokenAlphabet;

impl AuthToken {
    /// Generate a fresh token: 32 random bytes URL-safe base64-encoded
    /// (no padding). 43 ASCII characters, 256 bits of entropy. Uses
    /// `rand::random::<[u8; 32]>()` — same source the rest of the
    /// codebase uses for non-crypto random IDs (`llm/prompts.rs`,
    /// `convert/naming.rs`). Backed by `getrandom` on Linux, so the
    /// bytes are cryptographically suitable for a session token.
    ///
    /// The encoded output is guaranteed to satisfy the alphabet
    /// invariant — the base64 URL-safe-no-pad encoder only emits
    /// `[A-Za-z0-9_-]`. The `debug_assert!` here pins that property
    /// against future encoder swaps.
    pub fn random() -> Self {
        let buf: [u8; 32] = rand::random();
        let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf);
        debug_assert!(
            is_valid_token_alphabet(&token),
            "URL_SAFE_NO_PAD must emit only alphabet bytes"
        );
        Self(Arc::new(token))
    }

    /// Construct from a known string. Validates the alphabet at
    /// construction so the type-level invariant on [`AuthToken`] holds
    /// for every constructed value — there is no path that produces
    /// an `AuthToken` with bytes outside `[A-Za-z0-9_-]`.
    ///
    /// Used by tests pinning a deterministic token, and by production callers
    /// supplying a stable token (e.g. from env for scripted automation).
    /// Rejects CR/LF, semicolons, commas, and every other byte that would let
    /// an attacker inject cookie or header syntax through a configured token
    /// value.
    pub fn try_from_string(s: impl Into<String>) -> Result<Self, InvalidTokenAlphabet> {
        let s = s.into();
        if !is_valid_token_alphabet(&s) {
            return Err(InvalidTokenAlphabet);
        }
        Ok(Self(Arc::new(s)))
    }

    /// The encoded token as a string. Returned by reference — never
    /// pass through `tracing::info!`/`debug!` (the startup banner is
    /// the one allowed surfacing).
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Validate the URL-safe base64 alphabet plus non-empty: every byte
/// must be `[A-Za-z0-9_-]`. Pulled out so [`AuthToken::random`] and
/// [`AuthToken::try_from_string`] share the same predicate.
///
/// The length need not be exactly 43 (the natural `random()` output) because
/// deterministic tokens (env-var stable IDs) may be shorter — the invariant
/// the type guarantees is "valid in an HTTP header / cookie / URL query without
/// escaping", not "exactly 256 bits of entropy".
fn is_valid_token_alphabet(s: &str) -> bool {
    if s.is_empty() {
        return false;
    }
    s.bytes()
        .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}

/// Authentication mode for `cqs serve`.
///
/// The enum makes "auth disabled" *physically* impossible without importing
/// [`NoAuthAcknowledgement`] — an internal caller passing the wrong default
/// can't ship a fully-open server with no compile-time signal.
///
/// Construct via [`AuthMode::required`] or [`AuthMode::disabled`].
#[derive(Clone, Debug)]
pub enum AuthMode {
    /// Auth required on every request. `cookie_port` is the bind
    /// port; appended to the cookie name so two `cqs serve`
    /// instances on the same host don't share a cookie jar entry.
    Required { token: AuthToken, cookie_port: u16 },
    /// Auth disabled. The contained [`NoAuthAcknowledgement`] is a
    /// proof-of-intent token — the only way to construct it is via
    /// the explicit `--no-auth` CLI flag (or, in tests, the
    /// test-only constructor). Disabling auth without the proof type
    /// is a compile error.
    Disabled(NoAuthAcknowledgement),
}

impl AuthMode {
    /// Build an auth-required mode bound to the given port.
    pub fn required(token: AuthToken, cookie_port: u16) -> Self {
        Self::Required { token, cookie_port }
    }

    /// Build an auth-disabled mode. Requires the caller to supply a
    /// [`NoAuthAcknowledgement`] proof token, which can only be
    /// constructed inside the `--no-auth` CLI parsing path or
    /// via the test constructor.
    pub fn disabled(ack: NoAuthAcknowledgement) -> Self {
        Self::Disabled(ack)
    }

    /// `Some(&token)` when auth is required, `None` when disabled.
    /// Used by the listening-banner code, which needs the token for
    /// the paste-ready URL.
    pub fn token(&self) -> Option<&AuthToken> {
        match self {
            Self::Required { token, .. } => Some(token),
            Self::Disabled(_) => None,
        }
    }
}

/// Proof token: `AuthMode::Disabled` requires you to construct one
/// of these. The struct is `pub` so callers can use the type, but
/// the inner zero-sized field is private — the only paths that can
/// produce a value are the explicit constructors below. An internal
/// caller cannot disable auth without intentionally importing one of
/// these methods.
#[derive(Clone, Debug)]
pub struct NoAuthAcknowledgement(());

impl NoAuthAcknowledgement {
    /// Construct from the `--no-auth` CLI flag. The function name is
    /// the proof: any internal caller reading a code review who
    /// sees this name knows the user explicitly opted into a
    /// no-auth server.
    pub fn from_cli_no_auth_flag() -> Self {
        Self(())
    }

    /// Construct for tests only. Gated to `#[cfg(test)]` so the
    /// production binary cannot reach this constructor at all. The
    /// test routers in `serve::tests` use this to build the handler
    /// tree without the auth layer.
    #[cfg(test)]
    pub fn for_test() -> Self {
        Self(())
    }
}

/// Constant-time string compare. `subtle::ConstantTimeEq` returns a
/// `Choice` (0 or 1); we collapse to bool. Timing-safe — same number
/// of operations regardless of where the strings differ.
fn ct_eq(a: &str, b: &str) -> bool {
    // Different lengths → not equal, but still iterate to a fixed cost
    // per bound length so we don't leak length via early-return timing.
    // `ct_eq` over `&[u8]` already does this.
    a.as_bytes().ct_eq(b.as_bytes()).into()
}

/// Case-fold + percent-decode a query-pair key for comparison.
/// `?Token=…` and `?%74oken=…` are recognised as `token=` so the redirect
/// strips them (URL-bar leakage scrub).
fn pair_key_is_token(pair: &str) -> bool {
    let Some(eq_idx) = pair.find('=') else {
        return pair.eq_ignore_ascii_case("token");
    };
    let raw_key = &pair[..eq_idx];
    // Percent-decode the key. The token alphabet itself is URL-safe
    // base64 (no percent-encoding needed), but operators sometimes
    // hand-encode the key; we want `%74oken=` to match too.
    let decoded = percent_encoding::percent_decode_str(raw_key).decode_utf8_lossy();
    decoded.eq_ignore_ascii_case("token")
}

/// Strip the `token` parameter from the URI's query string for the
/// post-auth redirect. Other query params are preserved in their
/// original order. Recognises `Token=`, `%74oken=`, and any case-/
/// percent-folded variant.
fn strip_token_param(uri: &Uri) -> String {
    let path = uri.path();
    let Some(query) = uri.query() else {
        return path.to_string();
    };
    let kept: Vec<&str> = query
        .split('&')
        .filter(|pair| !pair.is_empty() && !pair_key_is_token(pair))
        .collect();
    if kept.is_empty() {
        path.to_string()
    } else {
        format!("{path}?{}", kept.join("&"))
    }
}

/// Low-cardinality classification for a 401 rejection. Surfaced on the
/// `enforce_auth` rejection warn so operators can distinguish "client sent
/// nothing" from "client sent a stale or wrong-channel credential" without
/// logging the actual material.
///
/// Cardinality is fixed at four — the variants cover every channel
/// `check_request` inspects, so the journal's reason-cardinality stays
/// bounded regardless of traffic shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UnauthorizedReason {
    /// No `Authorization`, no cookie, no `?token=…` query param. The
    /// request sent zero auth channels — most often a fresh tab hitting
    /// the bind address with no token.
    MissingAll,
    /// `Authorization: Bearer <…>` header present but the value didn't
    /// match (after the strict `"Bearer "` prefix strip).
    BearerMismatch,
    /// `Cookie: cqs_token_<port>=<…>` present but the value didn't
    /// match. Typically a stale cookie from a previous launch — the
    /// per-launch token rotates on every `cqs serve` restart.
    CookieMismatch,
    /// `?token=<…>` query param present but the value didn't match.
    QueryParamMismatch,
}

/// Per-channel decision for one `(request, expected_token)` pair.
///
/// The channel is responsible only for "did *this* channel pass". Whether the
/// overall request authenticates and which `UnauthorizedReason` to surface
/// is decided by [`check_request`] from the combination of channel outcomes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChannelOutcome {
    /// Channel observed a credential and it matched — request authenticated
    /// via this channel.
    Authenticated,
    /// Channel observed a credential but it did NOT match (e.g. stale
    /// Bearer header, expired cookie, wrong `?token=…`). Counts as an
    /// attempt for `UnauthorizedReason` classification.
    Mismatch,
    /// Channel did not observe a credential on this request. Skip silently.
    NotPresent,
}

/// One auth channel — Bearer header, cookie, query param, or any future
/// addition (mTLS client cert, API key, JWT, SSO).
///
/// Adding a new channel is:
/// 1. New `impl AuthChannel for MyChannel` block.
/// 2. New row in [`UnauthorizedReason`] (so 401 telemetry stays specific).
/// 3. One line in [`check_request`]'s channel array, in the desired
///    priority position.
///
/// `check_request` walks the channels in priority order on every request.
/// Priority is (header > cookie > query), documented at the channel array's
/// construction site, not implicit in the trait.
trait AuthChannel {
    /// Stable short name. Used only for tracing; an observability follow-up
    /// could log the matched channel here.
    #[allow(
        dead_code,
        reason = "reserved for the audit-logging follow-up flagged in #1218"
    )]
    fn name(&self) -> &'static str;

    /// Inspect the request for this channel's credential and compare
    /// constant-time against `expected`. Returns
    /// [`ChannelOutcome::Authenticated`] / `Mismatch` / `NotPresent`.
    fn check(&self, req: &Request, expected: &AuthToken) -> ChannelOutcome;

    /// `UnauthorizedReason` to surface on the 401 telemetry warn when
    /// *this* channel was the most-specific channel that attempted
    /// (i.e. `Mismatch`). The aggregator picks the highest-priority
    /// channel that returned `Mismatch`, then asks it for this reason.
    fn unauthorized_reason(&self) -> UnauthorizedReason;

    /// Whether the request shape this channel sees requires post-auth URL
    /// strip + redirect (the leakage scrub for `?token=…` even when another
    /// channel matched). Default is `false` for channels that don't carry
    /// leakable state in the URL; `QueryParamChannel` overrides to `true` when
    /// the URL has any `?token=` shape (case-folded, percent-decoded). The
    /// aggregator ORs across channels, so the redirect fires whenever *any*
    /// channel sees a leaky URL — even when the cookie/header is what
    /// authenticated.
    fn needs_url_strip(&self, _req: &Request) -> bool {
        false
    }
}

/// `Authorization: Bearer <token>` header channel. Highest priority — API
/// clients and anything that can set headers should use this.
struct BearerHeaderChannel;

impl AuthChannel for BearerHeaderChannel {
    fn name(&self) -> &'static str {
        "bearer"
    }

    fn check(&self, req: &Request, expected: &AuthToken) -> ChannelOutcome {
        let bearer = req
            .headers()
            .get(header::AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            .filter(|v| v.starts_with("Bearer "));
        match bearer {
            Some(value) => {
                let stripped = value.strip_prefix("Bearer ").unwrap_or("");
                if ct_eq(stripped, expected.as_str()) {
                    ChannelOutcome::Authenticated
                } else {
                    ChannelOutcome::Mismatch
                }
            }
            None => ChannelOutcome::NotPresent,
        }
    }

    fn unauthorized_reason(&self) -> UnauthorizedReason {
        UnauthorizedReason::BearerMismatch
    }
}

/// `cqs_token_<port>` cookie channel. Lifetime ties to the per-server
/// `AuthMiddlewareState` that owns the pre-built lookup needle string.
///
/// Cookie name is per-port so two `cqs serve` instances on the same host don't
/// collide in the browser jar. RFC 6265 syntax: name=value pairs separated by
/// `; `; quoted values are not used by this server.
struct CookieChannel<'a> {
    needle: &'a str,
}

impl AuthChannel for CookieChannel<'_> {
    fn name(&self) -> &'static str {
        "cookie"
    }

    fn check(&self, req: &Request, expected: &AuthToken) -> ChannelOutcome {
        let cookie_header = req
            .headers()
            .get(header::COOKIE)
            .and_then(|v| v.to_str().ok());
        let Some(cookie_header) = cookie_header else {
            return ChannelOutcome::NotPresent;
        };
        let mut attempted = false;
        for pair in cookie_header.split(';').map(str::trim) {
            if let Some(value) = pair.strip_prefix(self.needle) {
                attempted = true;
                if ct_eq(value, expected.as_str()) {
                    return ChannelOutcome::Authenticated;
                }
            }
        }
        if attempted {
            ChannelOutcome::Mismatch
        } else {
            ChannelOutcome::NotPresent
        }
    }

    fn unauthorized_reason(&self) -> UnauthorizedReason {
        UnauthorizedReason::CookieMismatch
    }
}

/// `?token=<token>` query-param channel. Lowest priority of the three;
/// always pairs with [`needs_url_strip`](AuthChannel::needs_url_strip)
/// to scrub the URL bar after the handoff. Case-folded `Token=` and
/// percent-decoded `%74oken=` both count as `?token=` for the strip test.
struct QueryParamChannel;

impl AuthChannel for QueryParamChannel {
    fn name(&self) -> &'static str {
        "query"
    }

    fn check(&self, req: &Request, expected: &AuthToken) -> ChannelOutcome {
        let Some(query) = req.uri().query() else {
            return ChannelOutcome::NotPresent;
        };
        let mut attempted = false;
        for pair in query.split('&').filter(|p| pair_key_is_token(p)) {
            attempted = true;
            let value = pair.split_once('=').map(|(_, v)| v).unwrap_or("");
            if ct_eq(value, expected.as_str()) {
                return ChannelOutcome::Authenticated;
            }
        }
        if attempted {
            ChannelOutcome::Mismatch
        } else {
            ChannelOutcome::NotPresent
        }
    }

    fn unauthorized_reason(&self) -> UnauthorizedReason {
        UnauthorizedReason::QueryParamMismatch
    }

    fn needs_url_strip(&self, req: &Request) -> bool {
        // Any case-folded `?token=…` form needs the post-auth scrub, even when
        // authentication came from another channel. The check is `Token=` /
        // `%74oken=` aware (via `pair_key_is_token`) because the strip uses the
        // same predicate; a mismatch between detection and stripping would
        // re-introduce the URL-bar leakage.
        req.uri()
            .query()
            .is_some_and(|q| q.split('&').any(pair_key_is_token))
    }
}

/// Walk the auth-channel registry in priority order and combine outcomes.
///
/// Priority: header > cookie > query. The order is encoded at the
/// channels-array construction below; rearranging is the supported way to
/// change priority. Each channel is a trait impl (`BearerHeaderChannel`,
/// `CookieChannel`, `QueryParamChannel`) so adding a fourth (mTLS, API key,
/// JWT, SSO) is a new impl plus one line here.
///
/// Even when a higher-priority channel matches, a `?token=…` in the URL must
/// trigger the redirect so the URL bar is scrubbed — `needs_url_strip` is OR'd
/// across channels for that reason.
///
/// `cookie_lookup_needle` is the pre-built `cqs_token_<port>=` string from
/// [`AuthMiddlewareState`]. Passed by reference so the happy path doesn't
/// `format!()` per request.
fn check_request(req: &Request, expected: &AuthToken, cookie_lookup_needle: &str) -> AuthOutcome {
    // Priority order: header > cookie > query. Asserted by
    // `channel_priority_is_bearer_cookie_query` below.
    let bearer = BearerHeaderChannel;
    let cookie = CookieChannel {
        needle: cookie_lookup_needle,
    };
    let query = QueryParamChannel;
    let channels: [&dyn AuthChannel; 3] = [&bearer, &cookie, &query];

    let mut authenticated = false;
    // Pick the highest-priority channel that *attempted* (returned
    // `Mismatch`). Walking in priority order means the first mismatch we hit is
    // the most-specific reason.
    let mut first_mismatch: Option<UnauthorizedReason> = None;
    let mut needs_url_strip = false;
    for ch in &channels {
        match ch.check(req, expected) {
            ChannelOutcome::Authenticated => {
                authenticated = true;
            }
            ChannelOutcome::Mismatch => {
                if first_mismatch.is_none() {
                    first_mismatch = Some(ch.unauthorized_reason());
                }
            }
            ChannelOutcome::NotPresent => {}
        }
        if ch.needs_url_strip(req) {
            needs_url_strip = true;
        }
    }

    if authenticated {
        if needs_url_strip {
            AuthOutcome::OkViaQueryParam
        } else {
            AuthOutcome::Ok
        }
    } else {
        AuthOutcome::Unauthorized(first_mismatch.unwrap_or(UnauthorizedReason::MissingAll))
    }
}

#[derive(Debug)]
enum AuthOutcome {
    /// Token matched via header or cookie — pass the request through
    /// unchanged.
    Ok,
    /// Token matched via `?token=<…>` query param — set the cookie and
    /// 302-redirect to the same URL with the token stripped.
    OkViaQueryParam,
    /// No token matched. Reject with 401. Carries a reason classification so
    /// the rejection warn can distinguish "no auth at all" from "stale
    /// credential" / "wrong channel" without logging the material itself.
    Unauthorized(UnauthorizedReason),
}

/// State plumbed into the auth middleware. Holds the per-launch
/// [`AuthToken`] and the pre-built cookie strings. Cloned cheaply on
/// every request — `AuthToken` is `Arc<String>` and the cookie strings
/// are `Arc<str>`.
///
/// The cookie name (`cqs_token_<port>`) and the lookup needle
/// (`cqs_token_<port>=`) are pre-built at construction time, so the auth happy
/// path on every request is zero-allocation — both forms are borrowed out of
/// the state. `cookie_port` is not stored: it's only needed at construction.
#[derive(Clone, Debug)]
pub(crate) struct AuthMiddlewareState {
    pub(crate) token: AuthToken,
    /// Full cookie name, computed once: `cqs_token_<port>`.
    pub(crate) cookie_name: Arc<str>,
    /// Lookup needle, computed once: `cqs_token_<port>=`. Used by
    /// [`check_request`] when scanning the `Cookie:` header for the
    /// per-launch entry. Pre-formatted so the per-request hot path
    /// doesn't allocate.
    pub(crate) cookie_lookup_needle: Arc<str>,
}

impl AuthMiddlewareState {
    /// Construct from a launched token + bind port. Builds the cookie
    /// name and lookup needle once, so the per-request middleware path
    /// is allocation-free.
    pub(crate) fn new(token: AuthToken, cookie_port: u16) -> Self {
        let cookie_name = cookie_name_for_port(cookie_port);
        let cookie_lookup_needle = format!("{cookie_name}=");
        Self {
            token,
            cookie_name: Arc::from(cookie_name.as_str()),
            cookie_lookup_needle: Arc::from(cookie_lookup_needle.as_str()),
        }
    }
}

/// axum middleware: enforce per-launch token on every request.
///
/// Wired via [`axum::middleware::from_fn_with_state`] with a cloned
/// [`AuthMiddlewareState`] as state. Sits inside the host-header
/// allowlist (so rejected hosts skip the auth check entirely — saves
/// a constant-time compare on a request we'd reject anyway) and
/// outside the compression/trace layers (so 401 responses still get
/// gzip/trace).
pub(crate) async fn enforce_auth(
    State(state): State<AuthMiddlewareState>,
    req: Request,
    next: Next,
) -> Response<Body> {
    match check_request(&req, &state.token, &state.cookie_lookup_needle) {
        AuthOutcome::Ok => next.run(req).await,
        AuthOutcome::OkViaQueryParam => {
            let clean_uri = strip_token_param(req.uri());
            let mut resp = Redirect::to(&clean_uri).into_response();
            // SameSite=Strict so a third-party page can't replay the
            // cookie via embedded `<img src>` etc. HttpOnly so JS on
            // the page can't read it (defence in depth — XSS in a
            // server we control + bind to localhost is unlikely, but
            // free to set). No `Secure` — we're on plain HTTP for
            // localhost; setting Secure here would forbid the cookie
            // from sticking, defeating the handoff. Cookie name is
            // per-port so concurrent instances don't collide.
            let cookie = format!(
                "{}={}; Path=/; HttpOnly; SameSite=Strict",
                state.cookie_name,
                state.token.as_str()
            );
            // The token alphabet is enforced at construction time
            // (`AuthToken::try_from_string` / `random()`), and the cookie name
            // is `cqs_token_<port>` (digits + ASCII letters + underscore). Both
            // fall inside the HTTP-header-byte set, so `HeaderValue::from_str`
            // succeeds by structural guarantee. The `.expect` is runtime
            // evidence that the type contract holds — a panic here means the
            // type system was bypassed.
            let value = HeaderValue::from_str(&cookie).expect(
                "AuthToken alphabet + cookie-name byte set are HTTP-header-safe by construction",
            );
            resp.headers_mut().insert(header::SET_COOKIE, value);
            resp
        }
        AuthOutcome::Unauthorized(reason) => {
            // Body intentionally minimal: no debug data, no token-length leak.
            // Tracing emits method + path (NOT query string — that may carry
            // `?token=` candidates) plus the low-cardinality `reason` so
            // operators get a journal trail for 401s without logging token
            // material.
            tracing::warn!(
                method = %req.method(),
                path = %req.uri().path(),
                reason = ?reason,
                "serve: rejected unauthenticated request",
            );
            (StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::assert_matches;

    #[test]
    fn random_token_is_url_safe_base64_43_chars() {
        let t = AuthToken::random();
        let s = t.as_str();
        // 32 bytes → 43 chars in URL-safe base64 with no padding.
        assert_eq!(s.len(), 43);
        for c in s.chars() {
            assert!(
                c.is_ascii_alphanumeric() || c == '-' || c == '_',
                "non-URL-safe char {c} in token"
            );
        }
    }

    #[test]
    fn random_tokens_differ() {
        let a = AuthToken::random();
        let b = AuthToken::random();
        assert_ne!(a.as_str(), b.as_str(), "two random tokens collided");
    }

    /// `try_from_string` accepts the URL-safe base64 alphabet.
    #[test]
    fn try_from_string_accepts_alphabet() {
        for input in [
            "secretvalue",
            "test-token-fixed",
            "TokenWith-And_Underscores",
            "0123456789",
            "a", // single-char minimum
        ] {
            assert!(
                AuthToken::try_from_string(input).is_ok(),
                "valid alphabet rejected: {input:?}"
            );
        }
    }

    /// `try_from_string` rejects every byte outside `[A-Za-z0-9_-]`, including
    /// the dangerous ones (CR/LF crash the worker on `HeaderValue::from_str`;
    /// `;`/`,` split cookie syntax).
    #[test]
    fn try_from_string_rejects_invalid_alphabet() {
        for input in [
            "",                    // empty
            "with space",          // space
            "tab\there",           // tab
            "newline\n",           // LF — would break HeaderValue
            "carriage\r",          // CR — same
            "semi;colon",          // splits cookie syntax
            "comma,here",          // splits cookie/header lists
            "equals=in-token",     // splits cookie syntax
            "quote\"here",         // shouldn't be in a token
            "a/b",                 // forward slash — base64 STD, not URL-safe
            "a+b",                 // plus — base64 STD, not URL-safe
            "non-ascii-\u{00e9}",  // Unicode
            "control\u{0000}byte", // NUL
            "high\u{0080}byte",    // non-ASCII upper bit
        ] {
            assert!(
                AuthToken::try_from_string(input).is_err(),
                "invalid input accepted: {input:?}"
            );
        }
    }

    /// `cookie_name_for_port` includes the port — two instances on
    /// different ports get different cookie names.
    #[test]
    fn cookie_name_includes_port() {
        let a = cookie_name_for_port(8080);
        let b = cookie_name_for_port(8081);
        assert_eq!(a, "cqs_token_8080");
        assert_eq!(b, "cqs_token_8081");
        assert_ne!(a, b, "cookie names must differ across ports");
    }

    /// `AuthMode::Disabled` requires a `NoAuthAcknowledgement`, which can only
    /// be constructed via the named factory functions. Pins the API shape — an
    /// internal caller cannot `AuthMode::Disabled(NoAuthAcknowledgement(()))`
    /// without a privacy-violation compile error.
    #[test]
    fn no_auth_requires_explicit_acknowledgement() {
        let ack = NoAuthAcknowledgement::for_test();
        let mode = AuthMode::disabled(ack);
        match mode {
            AuthMode::Disabled(_) => {} // expected
            AuthMode::Required { .. } => panic!("disabled mode misconstructed"),
        }
        // The CLI flag constructor returns the same opaque type. We
        // can't observe much (it's a ZST) but we can confirm both
        // constructors yield a value the enum accepts.
        let cli_ack = NoAuthAcknowledgement::from_cli_no_auth_flag();
        let _mode2 = AuthMode::disabled(cli_ack);
    }

    /// `AuthMode::token()` returns `Some` for `Required`, `None` for
    /// `Disabled` — used by the listening banner code to decide whether to
    /// embed the token in the paste-ready URL.
    #[test]
    fn auth_mode_token_accessor() {
        let token = AuthToken::random();
        let token_str = token.as_str().to_string();
        let required = AuthMode::required(token, 8080);
        assert_eq!(
            required.token().map(|t| t.as_str().to_string()),
            Some(token_str)
        );

        let disabled = AuthMode::disabled(NoAuthAcknowledgement::for_test());
        assert!(disabled.token().is_none());
    }

    #[test]
    fn ct_eq_matches_string_eq() {
        assert!(ct_eq("abc", "abc"));
        assert!(!ct_eq("abc", "abd"));
        assert!(!ct_eq("abc", "ab"));
        assert!(!ct_eq("ab", "abc"));
        assert!(ct_eq("", ""));
        assert!(!ct_eq("a", ""));
    }

    #[test]
    fn strip_token_param_removes_only_token() {
        let uri: Uri = "/api/graph?token=abc&depth=3".parse().unwrap();
        assert_eq!(strip_token_param(&uri), "/api/graph?depth=3");
    }

    #[test]
    fn strip_token_param_leaves_no_query_when_token_was_only_param() {
        let uri: Uri = "/api/graph?token=abc".parse().unwrap();
        assert_eq!(strip_token_param(&uri), "/api/graph");
    }

    #[test]
    fn strip_token_param_returns_path_when_no_query() {
        let uri: Uri = "/api/graph".parse().unwrap();
        assert_eq!(strip_token_param(&uri), "/api/graph");
    }

    #[test]
    fn strip_token_param_preserves_other_params() {
        let uri: Uri = "/api/graph?depth=3&token=abc&limit=5".parse().unwrap();
        assert_eq!(strip_token_param(&uri), "/api/graph?depth=3&limit=5");
    }

    // ===== adversarial tests for strip_token_param + check_request =====
    //
    // These pin the current (intentionally minimal) behavior so a regression —
    // either tightening or loosening — surfaces loudly. Covered cases:
    //   (1) Case-sensitivity: `?Token=…` is case-folded and stripped.
    //   (2) Percent-encoded key (`?%74oken=…`): percent-decoded and stripped.
    //   (3) Double `&&` between params: empty pair ignored cleanly.
    //   (4) Multiple `token=` params: every one is stripped.

    #[test]
    #[allow(non_snake_case)]
    fn p2_30_strip_token_param_capital_T_token_IS_stripped() {
        // Capital `Token=` is case-folded and stripped.
        let uri: Uri = "/api/graph?Token=abc&depth=3".parse().unwrap();
        let stripped = strip_token_param(&uri);
        assert_eq!(stripped, "/api/graph?depth=3");
    }

    #[test]
    #[allow(non_snake_case)]
    fn p2_30_strip_token_param_percent_encoded_key_IS_stripped() {
        // `%74oken=` is percent-decoded and stripped.
        let uri: Uri = "/api/graph?%74oken=abc&depth=3".parse().unwrap();
        let stripped = strip_token_param(&uri);
        assert_eq!(stripped, "/api/graph?depth=3");
    }

    #[test]
    fn p2_30_strip_token_param_handles_double_ampersand() {
        // `?token=abc&&depth=3` — the empty pair between the `&&` is
        // kept by the current `split('&')` filter (empty string fails
        // `starts_with` AND fails the `*pair != "token"` test). Pin the
        // resulting URL shape.
        let uri: Uri = "/api/graph?token=abc&&depth=3".parse().unwrap();
        let stripped = strip_token_param(&uri);
        // The empty pair is kept because filter doesn't drop it; the
        // resulting URL is harmless but ugly. Pin it as a regression
        // signal — a future cleanup that *also* drops the empty pair
        // is fine, just want the test to flag the change.
        assert!(
            stripped == "/api/graph?&depth=3" || stripped == "/api/graph?depth=3",
            "double-ampersand handling drifted: got {stripped}"
        );
    }

    #[test]
    fn p2_30_strip_token_param_strips_multiple_token_params() {
        // `?token=a&token=b` — both must be stripped. Pin behavior so a
        // regression that strips only the first doesn't leave a token in the URL.
        let uri: Uri = "/api/graph?token=a&depth=3&token=b".parse().unwrap();
        let stripped = strip_token_param(&uri);
        assert!(
            !stripped.contains("token="),
            "multiple `token=` params must all be stripped; got {stripped}"
        );
    }

    #[test]
    fn p2_30_strip_token_param_empty_value() {
        // `?token=` — pin current behavior: empty-value pair is stripped
        // because `value=""` matches `pair.strip_prefix("token=")`.
        let uri: Uri = "/api/graph?token=&depth=3".parse().unwrap();
        let stripped = strip_token_param(&uri);
        assert_eq!(stripped, "/api/graph?depth=3");
    }

    #[test]
    #[allow(non_snake_case)]
    fn p2_30_check_request_capital_T_token_IS_recognised() {
        // Capital `Token=` is case-folded; the request authenticates via the
        // query channel and triggers the redirect.
        let token = AuthToken::try_from_string("secretvalue").expect("test token alphabet");
        let req = Request::builder()
            .uri("/api/graph?Token=secretvalue")
            .body(axum::body::Body::empty())
            .unwrap();
        // Tests pass the pre-built lookup needle directly.
        let needle = format!("{}=", cookie_name_for_port(8080));
        match check_request(&req, &token, &needle) {
            AuthOutcome::OkViaQueryParam => {}
            AuthOutcome::Ok | AuthOutcome::Unauthorized(_) => {
                panic!(
                    "post-fix expectation: case-folded `Token=` matches and the redirect path fires"
                );
            }
        }
    }

    #[test]
    fn check_request_cookie_with_redundant_query_token_redirects() {
        // Even when the cookie matches, a `?token=` query param must trigger
        // the redirect so the URL bar is scrubbed.
        let token = AuthToken::random();
        let cookie_name = "cqs_token_8080";
        let req = Request::builder()
            .uri("/api/graph?token=anything")
            .header(header::COOKIE, format!("{cookie_name}={}", token.as_str()))
            .body(axum::body::Body::empty())
            .unwrap();
        // `check_request` takes the pre-built lookup needle (`<cookie_name>=`)
        // instead of `cookie_name` itself.
        let needle = format!("{cookie_name}=");
        let outcome = check_request(&req, &token, &needle);
        assert_matches!(outcome, AuthOutcome::OkViaQueryParam);
    }

    // ===== adversarial pins for `try_from_string` length, cookie/query
    // interaction, duplicate-cookie handling, and Bearer grammar strictness.
    // Each test pins current behavior — the `*_today` suffix marks tests that
    // should invert if the corresponding grammar is later relaxed.

    /// `try_from_string` accepts a 10K-char alphabet input — no
    /// `MAX_TOKEN_LEN` cap exists. Pin so a future cap addition trips the test
    /// (then invert to expect `Err`).
    #[test]
    fn try_from_string_accepts_long_alphabet_input_today() {
        let long = "a".repeat(10_240);
        assert!(
            AuthToken::try_from_string(long).is_ok(),
            "no MAX_TOKEN_LEN cap exists today; long alphabet inputs accepted"
        );
    }

    /// When a `?token=…` query param is present alongside a valid cookie, the
    /// redirect path wins (cookie does NOT win — the redirect's job is to scrub
    /// the URL bar).
    #[test]
    fn auth_query_present_cookie_right_redirects_to_scrub_url_bar() {
        // Right cookie value, garbage `?token=…` value: still redirects.
        // The redirect is the only path that strips the URL-bar token.
        let token = AuthToken::try_from_string("rightvalue").expect("test alphabet");
        let cookie_name = "cqs_token_8080";
        let needle = format!("{cookie_name}=");
        let req = Request::builder()
            .uri("/api/graph?token=wrong")
            .header(header::COOKIE, format!("{cookie_name}={}", token.as_str()))
            .body(axum::body::Body::empty())
            .unwrap();
        match check_request(&req, &token, &needle) {
            AuthOutcome::OkViaQueryParam => { /* expected: redirect path */ }
            AuthOutcome::Ok => panic!(
                "expected OkViaQueryParam (any `?token=` triggers redirect, AC-V1.30.1-5), \
                 got Ok"
            ),
            AuthOutcome::Unauthorized(reason) => {
                panic!("expected OkViaQueryParam, got Unauthorized({reason:?})")
            }
        }
    }

    /// Wrong cookie value + right query token → redirect (the cookie mismatch
    /// is irrelevant — the query channel authenticates and the redirect
    /// overwrites the cookie).
    #[test]
    fn auth_query_right_cookie_wrong_redirects_and_overwrites_cookie() {
        let token = AuthToken::try_from_string("rightvalue").expect("test alphabet");
        let cookie_name = "cqs_token_8080";
        let needle = format!("{cookie_name}=");
        let req = Request::builder()
            .uri(format!("/api/graph?token={}", token.as_str()))
            .header(header::COOKIE, format!("{cookie_name}=wrongvalue"))
            .body(axum::body::Body::empty())
            .unwrap();
        let outcome = check_request(&req, &token, &needle);
        assert!(
            matches!(outcome, AuthOutcome::OkViaQueryParam),
            "right-query overrides wrong-cookie + redirects to scrub the URL bar"
        );
    }

    /// Two cookies with the same name in one `Cookie:` header. RFC 6265 says
    /// order is arbitrary; the code uses `.any(...)`, so ANY matching pair
    /// authenticates regardless of position.
    #[test]
    fn auth_two_cookies_with_same_name_any_match_authenticates() {
        let token = AuthToken::try_from_string("rightvalue").expect("test alphabet");
        let cookie_name = "cqs_token_8080";
        let needle = format!("{cookie_name}=");
        // Wrong-then-right ordering: `.any(...)` returns true on the
        // second pair → request authenticates.
        let req = Request::builder()
            .uri("/api/graph")
            .header(
                header::COOKIE,
                format!("{cookie_name}=wrong; {cookie_name}={}", token.as_str()),
            )
            .body(axum::body::Body::empty())
            .unwrap();
        match check_request(&req, &token, &needle) {
            AuthOutcome::Ok => { /* any-match wins */ }
            AuthOutcome::OkViaQueryParam => panic!(
                "expected Ok — any matching duplicate cookie authenticates, got OkViaQueryParam"
            ),
            AuthOutcome::Unauthorized(reason) => {
                panic!("expected Ok, got Unauthorized({reason:?})")
            }
        }
        // Symmetric: right-then-wrong also authenticates (first pair wins).
        let req = Request::builder()
            .uri("/api/graph")
            .header(
                header::COOKIE,
                format!("{cookie_name}={}; {cookie_name}=wrong", token.as_str()),
            )
            .body(axum::body::Body::empty())
            .unwrap();
        assert!(
            matches!(check_request(&req, &token, &needle), AuthOutcome::Ok),
            "right-then-wrong duplicate cookies still authenticate via the right one"
        );
    }

    /// Lowercase `bearer` scheme returns 401. RFC 6750 §2.1 says the scheme is
    /// case-insensitive, but the code is strict (`strip_prefix("Bearer ")`).
    /// Pin current 401 behavior; invert if the grammar is relaxed.
    #[test]
    fn bearer_lowercase_scheme_returns_401_today() {
        let token = AuthToken::try_from_string("rightvalue").expect("test alphabet");
        let cookie_name = "cqs_token_8080";
        let needle = format!("{cookie_name}=");
        let req = Request::builder()
            .uri("/api/graph")
            .header(header::AUTHORIZATION, format!("bearer {}", token.as_str()))
            .body(axum::body::Body::empty())
            .unwrap();
        match check_request(&req, &token, &needle) {
            AuthOutcome::Unauthorized(reason) => {
                // Lowercase scheme means `starts_with("Bearer ")` is false, so
                // no channel attempts — falls through to MissingAll.
                assert_eq!(
                    reason,
                    UnauthorizedReason::MissingAll,
                    "lowercase 'bearer ' fails the strict prefix check entirely; \
                     pinning MissingAll today (when grammar is relaxed, this should \
                     become BearerMismatch on a typo, or Bearer-success)",
                );
            }
            AuthOutcome::Ok => panic!("expected Unauthorized today, got Ok"),
            AuthOutcome::OkViaQueryParam => {
                panic!("expected Unauthorized today, got OkViaQueryParam")
            }
        }
    }

    /// `Authorization: Bearer  <token>` (double space) returns 401. Strict
    /// separator check; the second space is included as the leading byte of the
    /// value, breaking ct_eq. Pin so a grammar relaxation has a clear inversion
    /// target.
    #[test]
    fn bearer_double_space_returns_401_today() {
        let token = AuthToken::try_from_string("rightvalue").expect("test alphabet");
        let cookie_name = "cqs_token_8080";
        let needle = format!("{cookie_name}=");
        let req = Request::builder()
            .uri("/api/graph")
            .header(header::AUTHORIZATION, format!("Bearer  {}", token.as_str()))
            .body(axum::body::Body::empty())
            .unwrap();
        match check_request(&req, &token, &needle) {
            AuthOutcome::Unauthorized(reason) => {
                // `starts_with("Bearer ")` matches (one space prefix succeeds),
                // so `bearer_attempted` IS true. The value starts with " "
                // which mismatches → BearerMismatch.
                assert_eq!(
                    reason,
                    UnauthorizedReason::BearerMismatch,
                    "double-space 'Bearer  …' is bearer_attempted but value-mismatch",
                );
            }
            AuthOutcome::Ok => panic!("expected Unauthorized today, got Ok"),
            AuthOutcome::OkViaQueryParam => {
                panic!("expected Unauthorized today, got OkViaQueryParam")
            }
        }
    }

    /// `Authorization: Bearer<token>` (no separator) returns 401.
    /// `starts_with("Bearer ")` requires the trailing space; without it the
    /// prefix check fails and no channel attempts → MissingAll.
    #[test]
    fn bearer_no_separator_returns_401_today() {
        let token = AuthToken::try_from_string("rightvalue").expect("test alphabet");
        let cookie_name = "cqs_token_8080";
        let needle = format!("{cookie_name}=");
        let req = Request::builder()
            .uri("/api/graph")
            .header(header::AUTHORIZATION, format!("Bearer{}", token.as_str()))
            .body(axum::body::Body::empty())
            .unwrap();
        match check_request(&req, &token, &needle) {
            AuthOutcome::Unauthorized(reason) => {
                assert_eq!(
                    reason,
                    UnauthorizedReason::MissingAll,
                    "no-separator 'Bearer<token>' fails starts_with(\"Bearer \"); \
                     bearer_attempted is false → MissingAll today",
                );
            }
            AuthOutcome::Ok => panic!("expected Unauthorized today, got Ok"),
            AuthOutcome::OkViaQueryParam => {
                panic!("expected Unauthorized today, got OkViaQueryParam")
            }
        }
    }

    /// Pin the channel priority order as `Bearer > Cookie > QueryParam`. With
    /// ALL three channels presenting mismatching credentials simultaneously,
    /// the 401's `UnauthorizedReason` must be `BearerMismatch` — the
    /// highest-priority channel that attempted. A refactor that reorders the
    /// channels array in `check_request` flips this test, so the
    /// telemetry-cardinality contract on [`UnauthorizedReason`] can't drift
    /// silently.
    #[test]
    fn channel_priority_is_bearer_cookie_query() {
        let token = AuthToken::try_from_string("rightvalue").expect("test alphabet");
        let cookie_name = "cqs_token_8080";
        let needle = format!("{cookie_name}=");
        let req = Request::builder()
            .uri("/api/graph?token=wrongquery")
            .header(header::AUTHORIZATION, "Bearer wrongbearer")
            .header(header::COOKIE, format!("{cookie_name}=wrongcookie"))
            .body(axum::body::Body::empty())
            .unwrap();
        match check_request(&req, &token, &needle) {
            AuthOutcome::Unauthorized(reason) => {
                assert_eq!(
                    reason,
                    UnauthorizedReason::BearerMismatch,
                    "Bearer must win priority over Cookie and QueryParam when all three mismatch"
                );
            }
            AuthOutcome::Ok => panic!("expected Unauthorized(BearerMismatch), got Ok"),
            AuthOutcome::OkViaQueryParam => {
                panic!("expected Unauthorized(BearerMismatch), got OkViaQueryParam")
            }
        }

        // No bearer → cookie wins next-priority.
        let req2 = Request::builder()
            .uri("/api/graph?token=wrongquery")
            .header(header::COOKIE, format!("{cookie_name}=wrongcookie"))
            .body(axum::body::Body::empty())
            .unwrap();
        match check_request(&req2, &token, &needle) {
            AuthOutcome::Unauthorized(reason) => {
                assert_eq!(
                    reason,
                    UnauthorizedReason::CookieMismatch,
                    "Cookie wins when Bearer is absent"
                );
            }
            AuthOutcome::Ok => panic!("expected Unauthorized(CookieMismatch), got Ok"),
            AuthOutcome::OkViaQueryParam => {
                panic!("expected Unauthorized(CookieMismatch), got OkViaQueryParam")
            }
        }

        // Only query → query wins.
        let req3 = Request::builder()
            .uri("/api/graph?token=wrongquery")
            .body(axum::body::Body::empty())
            .unwrap();
        match check_request(&req3, &token, &needle) {
            AuthOutcome::Unauthorized(reason) => {
                assert_eq!(
                    reason,
                    UnauthorizedReason::QueryParamMismatch,
                    "QueryParam wins when nothing else attempts"
                );
            }
            AuthOutcome::Ok => panic!("expected Unauthorized(QueryParamMismatch), got Ok"),
            AuthOutcome::OkViaQueryParam => {
                panic!("expected Unauthorized(QueryParamMismatch), got OkViaQueryParam")
            }
        }
    }
}
