//! OAuth 2.1 authorization-code + PKCE flow primitives.
//!
//! Why this lives in `mcptest-core`: the runner and the `mcptest login`
//! command both need to build PKCE parameters and exchange codes for
//! tokens. Keeping the building blocks here (parameter generation, URL
//! construction, token exchange) means the binary stays a thin shell
//! over a transport-aware library that can be reused from FFI hosts.
//!
//! Strictly OAuth 2.1: no implicit flow, no resource owner password
//! credentials grant, PKCE `S256` is mandatory, and `redirect_uri` must
//! be passed on every authorization and token exchange request so the
//! IdP can enforce a match against the registered value.

use std::time::Duration;

use rand::rngs::OsRng;
use rand::RngCore;
use reqwest::blocking::Client;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use thiserror::Error;

use super::token::Token;

/// Length the PKCE spec recommends in practice. The RFC permits 43 to
/// 128 characters; 64 is the sweet spot most servers tolerate.
const VERIFIER_LEN: usize = 64;

/// Unreserved-character alphabet per RFC 7636 §4.1
/// (A-Z / a-z / 0-9 / `-` / `.` / `_` / `~`).
const VERIFIER_ALPHABET: &[u8] =
    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";

/// Per-request timeout for the token endpoint exchange.
///
/// Token exchanges are a single short POST; 15 seconds is generous and
/// matches the refresh layer. Shared with the device-code grant
/// (WOR-1286) so every token-endpoint POST in the auth module carries
/// the same budget.
pub(super) const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(15);

/// Errors raised by the OAuth 2.1 + PKCE primitives in this module.
///
/// Mapped from the RFC 6749 §5.2 error code set on token exchange so
/// callers can pattern-match on well-known failure modes (`invalid_grant`
/// is the canonical "the auth code is dead" signal that means re-login).
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum OAuthError {
    /// The PKCE verifier did not satisfy RFC 7636 §4.1 (length 43..=128,
    /// alphabet A-Z / a-z / 0-9 / `-` / `.` / `_` / `~`).
    #[error("invalid PKCE code_verifier: {reason}")]
    InvalidVerifier {
        /// Human-readable reason.
        reason: String,
    },

    /// `authorization_endpoint` or `token_endpoint` did not parse as a URL.
    #[error("invalid endpoint URL: {reason}")]
    InvalidEndpoint {
        /// Human-readable reason.
        reason: String,
    },

    /// The HTTPS POST to the token endpoint failed before a response was
    /// available. `retryable` is true for network failures and 5xx
    /// responses, false for client errors.
    #[error("token endpoint network failure (retryable={retryable}): {reason}")]
    Network {
        /// Operator-facing reason.
        reason: String,
        /// True when the caller should retry.
        retryable: bool,
    },

    /// The IdP returned an OAuth error response per RFC 6749 §5.2.
    /// `code` is the machine-readable token (`invalid_grant`,
    /// `invalid_client`, `server_error`, etc.); `description` is the
    /// optional `error_description` field.
    #[error("token endpoint returned `{code}`: {description}")]
    OAuth {
        /// RFC 6749 §5.2 error code.
        code: String,
        /// Optional `error_description` from the response body.
        description: String,
    },

    /// The IdP returned a 2xx response whose body did not parse as a
    /// well-formed token response. Treated as fatal; the caller should
    /// surface to the user instead of retrying.
    #[error("malformed token response: {reason}")]
    Malformed {
        /// Parser message.
        reason: String,
    },
}

/// PKCE verifier + S256 challenge pair.
///
/// `verifier` is the high-entropy secret the client retains and presents
/// in the token exchange; `challenge` is the base64url-no-pad SHA-256 of
/// the verifier that the client puts in the authorization URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PkceParams {
    /// Raw verifier (the secret).
    pub code_verifier: String,
    /// `base64url(sha256(verifier))` (no padding).
    pub code_challenge: String,
    /// Always `"S256"`; OAuth 2.1 forbids the plaintext method.
    pub code_challenge_method: &'static str,
}

impl PkceParams {
    /// Generate a fresh verifier + challenge using the OS CSPRNG.
    pub fn generate() -> Self {
        let verifier = random_verifier(VERIFIER_LEN);
        let challenge = derive_challenge(&verifier);
        Self {
            code_verifier: verifier,
            code_challenge: challenge,
            code_challenge_method: "S256",
        }
    }

    /// Build a [`PkceParams`] from a pre-generated verifier. Returns
    /// [`OAuthError::InvalidVerifier`] when the verifier does not meet
    /// RFC 7636 §4.1's constraints. Tests use this to pin a known
    /// verifier through the rest of the flow.
    pub fn from_verifier(verifier: impl Into<String>) -> Result<Self, OAuthError> {
        let verifier = verifier.into();
        validate_verifier(&verifier)?;
        let challenge = derive_challenge(&verifier);
        Ok(Self {
            code_verifier: verifier,
            code_challenge: challenge,
            code_challenge_method: "S256",
        })
    }
}

/// Builder for an OAuth 2.1 authorization request URL.
///
/// Every field is required by the spec for a PKCE-protected
/// authorization-code flow; the struct exists so callers cannot forget
/// one. `state` is the CSRF token the caller must compare on the
/// callback.
#[derive(Debug, Clone)]
pub struct AuthorizationUrl {
    /// Authorization endpoint URL the user is redirected to.
    pub authorization_endpoint: String,
    /// OAuth `client_id` (from static config or DCR).
    pub client_id: String,
    /// Redirect URI the IdP must call back into. Required on both the
    /// authorization request and the token exchange so the IdP can
    /// enforce a match.
    pub redirect_uri: String,
    /// Space-separated scope string. Empty means no scope.
    pub scope: String,
    /// Random CSRF token; the callback must echo this value.
    pub state: String,
    /// PKCE challenge to bind the eventual code exchange.
    pub code_challenge: String,
    /// Always `"S256"` for OAuth 2.1; exposed as a field so the helper
    /// stays cohesive with [`PkceParams`].
    pub code_challenge_method: &'static str,
}

impl AuthorizationUrl {
    /// Render the authorization URL. Returns
    /// [`OAuthError::InvalidEndpoint`] when the endpoint does not parse.
    pub fn build(&self) -> Result<String, OAuthError> {
        let mut url = url::Url::parse(&self.authorization_endpoint).map_err(|e| {
            OAuthError::InvalidEndpoint {
                reason: format!("{}: {e}", self.authorization_endpoint),
            }
        })?;
        {
            let mut q = url.query_pairs_mut();
            q.append_pair("response_type", "code");
            q.append_pair("client_id", &self.client_id);
            q.append_pair("redirect_uri", &self.redirect_uri);
            if !self.scope.is_empty() {
                q.append_pair("scope", &self.scope);
            }
            q.append_pair("state", &self.state);
            q.append_pair("code_challenge", &self.code_challenge);
            q.append_pair("code_challenge_method", self.code_challenge_method);
        }
        Ok(url.into())
    }
}

/// Inputs to [`exchange_code`]. Grouped in a struct so the public
/// function signature stays under the call-site overload thresholds.
#[derive(Debug, Clone)]
pub struct TokenExchangeRequest {
    /// Token endpoint discovered from `.well-known/oauth-authorization-server`.
    pub token_endpoint: String,
    /// `client_id` from static config or DCR.
    pub client_id: String,
    /// Optional `client_secret`. PKCE public clients omit it.
    pub client_secret: Option<String>,
    /// Auth code returned on the redirect_uri callback.
    pub code: String,
    /// Redirect URI presented in the authorization request. Must match
    /// what the IdP has on file for the client.
    pub redirect_uri: String,
    /// PKCE verifier that proves possession of the original challenge.
    pub code_verifier: String,
}

/// Generate a random `state` value for CSRF protection on the callback.
///
/// Uses the same base64url-friendly alphabet as the PKCE verifier and
/// the same CSPRNG. Length is fixed at 32 characters which is plenty of
/// entropy for a single-use CSRF token.
pub fn random_state() -> String {
    random_verifier(32)
}

/// Exchange `code` for an access token using a blocking `reqwest`
/// client. `now_unix` is the wall clock seed for `expires_at_unix` on
/// the returned [`Token`].
///
/// This call is blocking on purpose: the surrounding `mcptest login`
/// flow already owns the runtime, the request is a single short POST,
/// and keeping it blocking matches the existing refresher.
pub fn exchange_code(req: &TokenExchangeRequest, now_unix: u64) -> Result<Token, OAuthError> {
    let client = Client::builder()
        .timeout(EXCHANGE_TIMEOUT)
        .build()
        .map_err(|e| OAuthError::Network {
            reason: format!("reqwest client construction failed: {e}"),
            retryable: false,
        })?;
    exchange_with_client(&client, req, now_unix)
}

/// Variant of [`exchange_code`] that takes a pre-built client. Used by
/// tests to point at a `wiremock` server without paying the cost of
/// rebuilding the TLS stack on every test.
pub fn exchange_with_client(
    client: &Client,
    req: &TokenExchangeRequest,
    now_unix: u64,
) -> Result<Token, OAuthError> {
    let mut form: Vec<(&str, String)> = vec![
        ("grant_type", "authorization_code".into()),
        ("code", req.code.clone()),
        ("redirect_uri", req.redirect_uri.clone()),
        ("client_id", req.client_id.clone()),
        ("code_verifier", req.code_verifier.clone()),
    ];
    if let Some(secret) = &req.client_secret {
        form.push(("client_secret", secret.clone()));
    }

    let resp = client
        .post(&req.token_endpoint)
        .form(&form)
        .send()
        .map_err(|e| OAuthError::Network {
            reason: format!("network error: {e}"),
            retryable: e.is_timeout() || e.is_connect(),
        })?;

    let status = resp.status();
    let body = resp.text().unwrap_or_default();
    if status.is_success() {
        let parsed: TokenResponse =
            serde_json::from_str(&body).map_err(|e| OAuthError::Malformed {
                reason: e.to_string(),
            })?;
        return Ok(token_from_response(parsed, now_unix));
    }
    Err(oauth_error_from_response(status, &body))
}

/// Inputs for an OAuth 2.0 client-credentials token request (RFC 6749 §4.4).
///
/// Machine-to-machine auth: no user, no browser, no authorization code. The
/// client presents its id and secret directly to the token endpoint. The
/// secret is resolved from an env var by the caller and never stored inline.
#[derive(Debug, Clone)]
pub struct ClientCredentialsRequest {
    /// Token endpoint URL.
    pub token_endpoint: String,
    /// OAuth client id.
    pub client_id: String,
    /// Client secret (resolved from an env var by the caller).
    pub client_secret: String,
    /// Optional space-delimited scope string.
    pub scope: Option<String>,
}

/// Exchange client credentials for an access token using a blocking client.
/// `now_unix` seeds `expires_at_unix` on the returned [`Token`].
pub fn exchange_client_credentials(
    req: &ClientCredentialsRequest,
    now_unix: u64,
) -> Result<Token, OAuthError> {
    let client = Client::builder()
        .timeout(EXCHANGE_TIMEOUT)
        .build()
        .map_err(|e| OAuthError::Network {
            reason: format!("reqwest client construction failed: {e}"),
            retryable: false,
        })?;
    client_credentials_with_client(&client, req, now_unix)
}

/// Variant of [`exchange_client_credentials`] taking a pre-built client, so
/// tests can point at a `wiremock` server.
pub fn client_credentials_with_client(
    client: &Client,
    req: &ClientCredentialsRequest,
    now_unix: u64,
) -> Result<Token, OAuthError> {
    let mut form: Vec<(&str, String)> = vec![
        ("grant_type", "client_credentials".into()),
        ("client_id", req.client_id.clone()),
        ("client_secret", req.client_secret.clone()),
    ];
    if let Some(scope) = &req.scope {
        if !scope.is_empty() {
            form.push(("scope", scope.clone()));
        }
    }

    let resp = client
        .post(&req.token_endpoint)
        .form(&form)
        .send()
        .map_err(|e| OAuthError::Network {
            reason: format!("network error: {e}"),
            retryable: e.is_timeout() || e.is_connect(),
        })?;

    let status = resp.status();
    let body = resp.text().unwrap_or_default();
    if status.is_success() {
        let parsed: TokenResponse =
            serde_json::from_str(&body).map_err(|e| OAuthError::Malformed {
                reason: e.to_string(),
            })?;
        return Ok(token_from_response(parsed, now_unix));
    }
    Err(oauth_error_from_response(status, &body))
}

/// Map a non-success token-endpoint response to a structured [`OAuthError`].
///
/// 4xx and 5xx both deserve the structured-error path. RFC 6749 section 5.2
/// says the body is JSON with at least `error`; some IdPs return an empty body
/// on 5xx, in which case we synthesize `server_error`. `pub(super)` so the
/// device-code grant (WOR-1286) maps its polling errors identically.
pub(super) fn oauth_error_from_response(status: reqwest::StatusCode, body: &str) -> OAuthError {
    if let Ok(err) = serde_json::from_str::<ErrorResponse>(body) {
        return OAuthError::OAuth {
            code: err.error,
            description: err.error_description.unwrap_or_default(),
        };
    }
    OAuthError::OAuth {
        code: if status.is_server_error() {
            "server_error".into()
        } else {
            "invalid_request".into()
        },
        description: format!("HTTP {}: {}", status.as_u16(), truncate(body, 200)),
    }
}

/// Wire shape of a successful token response (RFC 6749 section 5.1).
/// `pub(super)` so the device-code grant (WOR-1286) parses the same shape
/// instead of duplicating the serde defaults.
#[derive(Deserialize)]
pub(super) struct TokenResponse {
    access_token: String,
    #[serde(default)]
    refresh_token: Option<String>,
    #[serde(default = "default_expires_in")]
    expires_in: u64,
    #[serde(default = "default_token_type")]
    token_type: String,
    #[serde(default)]
    scope: Option<String>,
}

#[derive(Deserialize)]
struct ErrorResponse {
    error: String,
    #[serde(default)]
    error_description: Option<String>,
}

fn default_expires_in() -> u64 {
    3_600
}

fn default_token_type() -> String {
    "Bearer".into()
}

/// Turn a parsed token response into a cacheable [`Token`]. `pub(super)`
/// so every grant in the auth module builds tokens through one path.
pub(super) fn token_from_response(body: TokenResponse, now_unix: u64) -> Token {
    Token {
        access_token: body.access_token,
        refresh_token: body.refresh_token,
        expires_at_unix: now_unix.saturating_add(body.expires_in),
        token_type: body.token_type,
        scope: body.scope,
        last_refreshed: format_unix_rfc3339(now_unix),
        // The caller stamps the endpoint/client id when it wants the token to be
        // refreshable later; the bare exchange does not know them.
        token_endpoint: None,
        client_id: None,
    }
}

fn random_verifier(len: usize) -> String {
    let mut out = String::with_capacity(len);
    let alphabet_len = VERIFIER_ALPHABET.len() as u32;
    let mut rng = OsRng;
    for _ in 0..len {
        // Reject-sampling on a uniform u32 keeps the distribution flat
        // across the 66-character alphabet without leaning on the `rand`
        // distribution traits.
        loop {
            let r = rng.next_u32();
            let upper = u32::MAX - (u32::MAX % alphabet_len);
            if r < upper {
                let idx = (r % alphabet_len) as usize;
                out.push(VERIFIER_ALPHABET[idx] as char);
                break;
            }
        }
    }
    out
}

fn derive_challenge(verifier: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(verifier.as_bytes());
    base64url_no_pad(&hasher.finalize())
}

fn validate_verifier(verifier: &str) -> Result<(), OAuthError> {
    if !(43..=128).contains(&verifier.len()) {
        return Err(OAuthError::InvalidVerifier {
            reason: format!(
                "length {} is outside the RFC 7636 range 43..=128",
                verifier.len()
            ),
        });
    }
    for (i, b) in verifier.as_bytes().iter().enumerate() {
        if !VERIFIER_ALPHABET.contains(b) {
            return Err(OAuthError::InvalidVerifier {
                reason: format!("byte {i} (0x{b:02x}) is outside the unreserved alphabet"),
            });
        }
    }
    Ok(())
}

fn base64url_no_pad(bytes: &[u8]) -> String {
    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
    let chunks = bytes.chunks(3);
    for chunk in chunks {
        let mut buf = [0u8; 3];
        for (i, b) in chunk.iter().enumerate() {
            buf[i] = *b;
        }
        let triple = (u32::from(buf[0]) << 16) | (u32::from(buf[1]) << 8) | u32::from(buf[2]);
        let n = chunk.len();
        out.push(ALPHABET[((triple >> 18) & 0x3f) as usize] as char);
        out.push(ALPHABET[((triple >> 12) & 0x3f) as usize] as char);
        if n >= 2 {
            out.push(ALPHABET[((triple >> 6) & 0x3f) as usize] as char);
        }
        if n >= 3 {
            out.push(ALPHABET[(triple & 0x3f) as usize] as char);
        }
    }
    out
}

fn truncate(s: &str, max: usize) -> String {
    s.chars().take(max).collect()
}

fn format_unix_rfc3339(unix: u64) -> String {
    // Same compact RFC 3339 formatter used by the refresher so values
    // round-trip through the auth layer's parser.
    let days_since_epoch = unix / 86_400;
    let sod = unix % 86_400;
    let hour = (sod / 3_600) as u32;
    let minute = ((sod % 3_600) / 60) as u32;
    let second = (sod % 60) as u32;
    let (y, mo, d) = super::civil_date::epoch_days_to_ymd(days_since_epoch as i64);
    format!("{y:04}-{mo:02}-{d:02}T{hour:02}:{minute:02}:{second:02}Z")
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use wiremock::matchers::{body_string_contains, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    fn rt() -> tokio::runtime::Runtime {
        tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap()
    }

    #[test]
    fn generate_produces_verifier_in_alphabet_and_length() {
        let p = PkceParams::generate();
        assert_eq!(p.code_verifier.len(), VERIFIER_LEN);
        for b in p.code_verifier.as_bytes() {
            assert!(VERIFIER_ALPHABET.contains(b));
        }
        assert_eq!(p.code_challenge_method, "S256");
    }

    #[test]
    fn from_verifier_rejects_short_input() {
        let err = PkceParams::from_verifier("too-short").unwrap_err();
        match err {
            OAuthError::InvalidVerifier { reason } => assert!(reason.contains("length")),
            other => panic!("expected InvalidVerifier, got {other:?}"),
        }
    }

    #[test]
    fn from_verifier_rejects_disallowed_character() {
        let mut v: String = std::iter::repeat_n('a', 64).collect();
        v.pop();
        v.push('!');
        let err = PkceParams::from_verifier(v).unwrap_err();
        match err {
            OAuthError::InvalidVerifier { reason } => assert!(reason.contains("alphabet")),
            other => panic!("expected InvalidVerifier, got {other:?}"),
        }
    }

    #[test]
    fn challenge_matches_rfc_7636_appendix_b_vector() {
        // RFC 7636 Appendix B fixed vector.
        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
        let params = PkceParams::from_verifier(verifier).unwrap();
        assert_eq!(
            params.code_challenge,
            "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
        );
    }

    #[test]
    fn authorization_url_includes_pkce_scope_and_state() {
        let url = AuthorizationUrl {
            authorization_endpoint: "https://idp.invalid/auth".into(),
            client_id: "abc".into(),
            redirect_uri: "http://127.0.0.1:7777/callback".into(),
            scope: "mcp:read mcp:write".into(),
            state: "state-value".into(),
            code_challenge: "challenge-value".into(),
            code_challenge_method: "S256",
        }
        .build()
        .unwrap();

        assert!(url.starts_with("https://idp.invalid/auth?"));
        assert!(url.contains("response_type=code"));
        assert!(url.contains("client_id=abc"));
        assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A7777%2Fcallback"));
        assert!(url.contains("scope=mcp%3Aread+mcp%3Awrite"));
        assert!(url.contains("state=state-value"));
        assert!(url.contains("code_challenge=challenge-value"));
        assert!(url.contains("code_challenge_method=S256"));
    }

    #[test]
    fn authorization_url_omits_empty_scope() {
        let url = AuthorizationUrl {
            authorization_endpoint: "https://idp.invalid/auth".into(),
            client_id: "abc".into(),
            redirect_uri: "http://127.0.0.1:7777/callback".into(),
            scope: String::new(),
            state: "s".into(),
            code_challenge: "c".into(),
            code_challenge_method: "S256",
        }
        .build()
        .unwrap();
        assert!(!url.contains("scope="));
    }

    #[test]
    fn exchange_code_happy_path_returns_token() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/token"))
                .and(body_string_contains("grant_type=authorization_code"))
                .and(body_string_contains("code=auth-code-abc"))
                .and(body_string_contains("code_verifier="))
                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                    "access_token": "AT.123",
                    "refresh_token": "RT.456",
                    "expires_in": 600,
                    "token_type": "Bearer",
                    "scope": "mcp:read"
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let req = TokenExchangeRequest {
            token_endpoint: format!("{uri}/token"),
            client_id: "client".into(),
            client_secret: None,
            code: "auth-code-abc".into(),
            redirect_uri: "http://127.0.0.1:9000/callback".into(),
            code_verifier: PkceParams::generate().code_verifier,
        };
        let now = 1_700_000_000u64;
        let token = exchange_code(&req, now).unwrap();
        assert_eq!(token.access_token, "AT.123");
        assert_eq!(token.refresh_token.as_deref(), Some("RT.456"));
        assert_eq!(token.expires_at_unix, now + 600);
        assert_eq!(token.scope.as_deref(), Some("mcp:read"));
    }

    #[test]
    fn client_credentials_happy_path_returns_token() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/token"))
                .and(body_string_contains("grant_type=client_credentials"))
                .and(body_string_contains("client_id=svc"))
                .and(body_string_contains("scope=mcp.read"))
                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                    "access_token": "CC.789",
                    "expires_in": 120,
                    "token_type": "Bearer"
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let req = ClientCredentialsRequest {
            token_endpoint: format!("{uri}/token"),
            client_id: "svc".into(),
            client_secret: "shh".into(),
            scope: Some("mcp.read".into()),
        };
        let now = 1_700_000_000u64;
        let token = exchange_client_credentials(&req, now).unwrap();
        assert_eq!(token.access_token, "CC.789");
        assert_eq!(token.expires_at_unix, now + 120);
    }

    #[test]
    fn client_credentials_maps_invalid_client() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/token"))
                .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
                    "error": "invalid_client",
                    "error_description": "bad secret"
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let req = ClientCredentialsRequest {
            token_endpoint: format!("{uri}/token"),
            client_id: "svc".into(),
            client_secret: "wrong".into(),
            scope: None,
        };
        match exchange_client_credentials(&req, 1_700_000_000) {
            Err(OAuthError::OAuth { code, .. }) => assert_eq!(code, "invalid_client"),
            other => panic!("expected invalid_client, got {other:?}"),
        }
    }

    #[test]
    fn exchange_code_maps_invalid_grant() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/token"))
                .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
                    "error": "invalid_grant",
                    "error_description": "authorization code expired"
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let req = TokenExchangeRequest {
            token_endpoint: format!("{uri}/token"),
            client_id: "client".into(),
            client_secret: None,
            code: "stale".into(),
            redirect_uri: "http://127.0.0.1:9000/callback".into(),
            code_verifier: PkceParams::generate().code_verifier,
        };
        match exchange_code(&req, 1_700_000_000) {
            Err(OAuthError::OAuth { code, description }) => {
                assert_eq!(code, "invalid_grant");
                assert!(description.contains("expired"));
            }
            other => panic!("expected OAuth invalid_grant, got {other:?}"),
        }
    }

    #[test]
    fn exchange_code_maps_invalid_client() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/token"))
                .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
                    "error": "invalid_client"
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let req = TokenExchangeRequest {
            token_endpoint: format!("{uri}/token"),
            client_id: "client".into(),
            client_secret: None,
            code: "x".into(),
            redirect_uri: "http://127.0.0.1:9000/callback".into(),
            code_verifier: PkceParams::generate().code_verifier,
        };
        match exchange_code(&req, 0) {
            Err(OAuthError::OAuth { code, .. }) => assert_eq!(code, "invalid_client"),
            other => panic!("expected OAuth invalid_client, got {other:?}"),
        }
    }

    #[test]
    fn exchange_code_maps_server_error_with_empty_body() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/token"))
                .respond_with(ResponseTemplate::new(500))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let req = TokenExchangeRequest {
            token_endpoint: format!("{uri}/token"),
            client_id: "client".into(),
            client_secret: None,
            code: "x".into(),
            redirect_uri: "http://127.0.0.1:9000/callback".into(),
            code_verifier: PkceParams::generate().code_verifier,
        };
        match exchange_code(&req, 0) {
            Err(OAuthError::OAuth { code, .. }) => assert_eq!(code, "server_error"),
            other => panic!("expected OAuth server_error, got {other:?}"),
        }
    }

    #[test]
    fn random_state_returns_alphabet_characters() {
        let s = random_state();
        assert_eq!(s.len(), 32);
        for b in s.as_bytes() {
            assert!(VERIFIER_ALPHABET.contains(b));
        }
    }

    #[test]
    fn base64url_no_pad_matches_known_vector() {
        // sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
        // base64url no pad of that digest:
        let h = Sha256::digest([]);
        assert_eq!(
            base64url_no_pad(&h),
            "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"
        );
    }
}
