use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

use axum::{
    Json, Router,
    extract::{Request, State},
    http::StatusCode,
    middleware::Next,
    response::{IntoResponse, Response},
    routing::get,
};
use axum_extra::{
    TypedHeader,
    headers::{Authorization, authorization::Bearer},
};
use http::Method;
use networked_key_resolver::NetworkedKeyResolver;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use schemars::JsonSchema;
use serde::Deserialize;
use tower_http::cors::{Any, CorsLayer};
use tracing::warn;
use url::Url;

mod networked_key_resolver;
mod protected_resource;
mod valid_token;
mod www_authenticate;

use protected_resource::ProtectedResource;
use valid_token::TokenValidator;
pub(crate) use valid_token::ValidToken;
use www_authenticate::{BearerError, WwwAuthenticate};

/// Scope enforcement mode for authenticated requests.
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ScopeMode {
    /// Skip scope enforcement entirely.
    Disabled,
    /// Token must have ALL configured scopes (default).
    #[default]
    RequireAll,
    /// Token must have at least ONE configured scope.
    RequireAny,
}

/// Errors that can occur when building a TLS-configured HTTP client
#[derive(Debug, thiserror::Error)]
pub enum TlsConfigError {
    #[error("Failed to read CA certificate from {path}: {source}")]
    CertificateRead {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("Failed to parse CA certificate from {path}: invalid PEM format")]
    CertificateParse { path: PathBuf },
    #[error("Failed to build HTTP client: {0}")]
    ClientBuild(#[from] reqwest::Error),
    #[error("Auth server URL at index {index} ({url}) has no host")]
    ServerUrlMissingHost { index: usize, url: String },
    #[error("Resource URL has non-HTTP(S) scheme '{scheme}': {url}")]
    ResourceUrlInvalidScheme { url: String, scheme: String },
}

impl TlsConfig {
    /// Build a reqwest client configured with the TLS settings and default headers
    pub fn build_client(
        &self,
        default_headers: HeaderMap,
    ) -> Result<reqwest::Client, TlsConfigError> {
        let mut builder = reqwest::Client::builder().default_headers(default_headers);

        // Add custom CA certificate if provided
        if let Some(ca_cert_path) = &self.ca_cert {
            let cert_bytes =
                std::fs::read(ca_cert_path).map_err(|e| TlsConfigError::CertificateRead {
                    path: ca_cert_path.clone(),
                    source: e,
                })?;
            let cert = reqwest::Certificate::from_pem(&cert_bytes).map_err(|_| {
                TlsConfigError::CertificateParse {
                    path: ca_cert_path.clone(),
                }
            })?;
            builder = builder.add_root_certificate(cert);
            tracing::debug!("Added custom CA certificate from {:?}", ca_cert_path);
        }

        // Accept invalid certs if configured (development only)
        if self.danger_accept_invalid_certs {
            tracing::warn!(
                "TLS certificate validation is disabled. This is insecure and should only be used for development."
            );
            builder = builder.danger_accept_invalid_certs(true);
        }

        Ok(builder.build()?)
    }
}

/// Deserialize a `HeaderMap` from a map of string keys and values.
fn deserialize_header_map<'de, D>(deserializer: D) -> Result<HeaderMap, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let map = HashMap::<String, String>::deserialize(deserializer)?;
    let mut headers = HeaderMap::with_capacity(map.len());
    for (key, value) in map {
        let name = HeaderName::from_bytes(key.as_bytes())
            .map_err(|e| serde::de::Error::custom(e.to_string()))?;
        let value =
            HeaderValue::from_str(&value).map_err(|e| serde::de::Error::custom(e.to_string()))?;
        headers.insert(name, value);
    }
    Ok(headers)
}

fn deserialize_auth_servers<'de, D>(d: D) -> Result<Vec<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::de::Error;
    let servers: Vec<String> = Vec::deserialize(d)?;
    for s in &servers {
        Url::parse(s)
            .map_err(|e| D::Error::custom(format!("invalid auth server URL {s:?}: {e}")))?;
    }
    Ok(servers)
}

/// Auth configuration options
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Config {
    /// List of upstream OAuth servers to delegate auth.
    /// Not `Vec<Url>`: `Url::parse` appends `/` to bare-authority inputs,
    /// breaking issuer string-matching in clients.
    #[serde(deserialize_with = "deserialize_auth_servers")]
    #[schemars(with = "Vec<Url>")]
    pub servers: Vec<String>,

    /// List of accepted audiences for the OAuth tokens
    #[serde(default)]
    pub audiences: Vec<String>,

    /// List of accepted token issuers (the JWT `iss` claim).
    ///
    /// When non-empty, a token's `iss` claim must exactly match one of these
    /// values or the token is rejected. When empty (default), issuer validation
    /// is skipped. Not `Vec<Url>`: like `servers`, `Url::parse` appends `/` to
    /// bare-authority inputs, which would break exact `iss` string matching.
    #[serde(default)]
    pub issuers: Vec<String>,

    /// Allow any audience (skip validation) - use with caution
    #[serde(default)]
    pub allow_any_audience: bool,

    /// The resource to protect.
    ///
    /// Note: This is usually the publicly accessible URL of this running MCP server
    pub resource: Url,

    /// Link to documentation related to the protected resource
    pub resource_documentation: Option<Url>,

    /// Supported OAuth scopes by this resource server
    pub scopes: Vec<String>,

    /// Scope enforcement mode: disabled, require_all (default), or require_any.
    #[serde(default)]
    pub scope_mode: ScopeMode,

    /// Whether to disable the auth token passthrough to upstream API
    #[serde(default)]
    pub disable_auth_token_passthrough: bool,

    /// Allow unauthenticated access to MCP discovery methods (e.g. `tools/list`).
    ///
    /// When enabled, requests without a bearer token that contain a discovery
    /// JSON-RPC method call will be allowed through without authentication.
    /// All other requests still require valid authentication.
    #[serde(default)]
    pub allow_anonymous_mcp_discovery: bool,

    /// TLS configuration for connecting to OAuth servers
    #[serde(default)]
    pub tls: TlsConfig,

    /// Timeout for OIDC discovery requests.
    ///
    /// Accepts human-readable durations (e.g., "5s", "10s", "30s").
    /// Defaults to 5 seconds when not specified.
    #[serde(deserialize_with = "humantime_serde::deserialize", default)]
    #[serde(serialize_with = "humantime_serde::serialize")]
    #[schemars(with = "Option<String>")]
    pub discovery_timeout: Option<Duration>,

    /// Headers to include in OIDC discovery and JWKS requests.
    ///
    /// Use this to set headers like `User-Agent` that may be required
    /// by upstream OAuth servers or web application firewalls.
    #[serde(default, deserialize_with = "deserialize_header_map")]
    #[schemars(with = "HashMap<String, String>")]
    pub discovery_headers: HeaderMap,
}

/// TLS configuration for OAuth server connections
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TlsConfig {
    /// Path to additional CA certificates to trust (PEM format).
    /// Use this when your OAuth server uses a self-signed certificate
    /// or a certificate signed by a private CA.
    pub ca_cert: Option<PathBuf>,

    /// Whether to accept invalid TLS certificates.
    ///
    /// **WARNING**: This is insecure and should only be used for development/testing.
    /// When enabled, the server will accept any certificate, including self-signed
    /// and expired certificates, without validation.
    #[serde(default)]
    pub danger_accept_invalid_certs: bool,
}

/// Constructs the protected resource metadata URL per RFC 9728 Section 3.
///
/// The well-known URI is formed by inserting `/.well-known/oauth-protected-resource`
/// between the host and path components of the resource identifier.
/// Query strings and fragments are stripped per RFC 9728.
fn build_resource_metadata_url(resource: &Url) -> Url {
    let mut url = resource.clone();
    url.set_query(None);
    url.set_fragment(None);

    if url.host_str().is_none() {
        warn!("resource URL has no host, falling back to root-level metadata path");
        url.set_path("/.well-known/oauth-protected-resource");
        return url;
    }

    let path = url
        .path()
        .trim_matches('/')
        .split('/')
        .filter(|s| !s.is_empty())
        .collect::<Vec<_>>()
        .join("/");

    if path.is_empty() {
        url.set_path("/.well-known/oauth-protected-resource");
    } else {
        url.set_path(&format!("/.well-known/oauth-protected-resource/{path}"));
    }

    url
}

/// Internal state for the auth middleware, containing both config and pre-built HTTP client
#[derive(Clone)]
struct AuthState {
    config: Arc<Config>,
    client: reqwest::Client,
    resource_metadata_url: Url,
    /// Upstream OAuth server URLs, parsed once at startup so the per-request
    /// path neither re-parses nor allocates them.
    auth_servers: Arc<[Url]>,
    /// Per-operation required scopes, keyed by operation name.
    required_scopes: Arc<HashMap<String, Vec<String>>>,
}

impl Config {
    /// Enable auth middleware on the router.
    ///
    /// Builds the HTTP client at startup to validate TLS configuration eagerly.
    pub fn enable_middleware(
        &self,
        router: Router,
        required_scopes: HashMap<String, Vec<String>>,
    ) -> Result<Router, TlsConfigError> {
        // Parse and validate server URLs once at startup (fail fast on config
        // errors). The parsed list is reused for every request via `AuthState`.
        #[allow(clippy::expect_used)] // parseability validated at deserialize
        let auth_servers = self
            .servers
            .iter()
            .enumerate()
            .map(|(i, server)| {
                let parsed = Url::parse(server).expect("validated by deserialize_auth_servers");
                if parsed.host_str().is_none() {
                    return Err(TlsConfigError::ServerUrlMissingHost {
                        index: i,
                        url: server.clone(),
                    });
                }
                Ok(parsed)
            })
            .collect::<Result<Vec<Url>, _>>()?;

        let scheme = self.resource.scheme();
        if scheme != "http" && scheme != "https" {
            return Err(TlsConfigError::ResourceUrlInvalidScheme {
                url: self.resource.to_string(),
                scheme: scheme.to_string(),
            });
        }
        if self.allow_any_audience {
            warn!(
                "allow_any_audience is enabled - audience validation is disabled. This reduces security."
            );
        }

        if self.scope_mode == ScopeMode::Disabled && !self.scopes.is_empty() {
            warn!(
                "scope_mode is 'disabled' but scopes are configured - scope enforcement will be skipped"
            );
        }

        /// Simple handler to encode our config into the desired OAuth 2.1 protected
        /// resource format
        async fn protected_resource(
            State(auth_state): State<AuthState>,
        ) -> Json<ProtectedResource> {
            Json(ProtectedResource::from(auth_state.config.as_ref().clone()))
        }

        // Build HTTP client with TLS configuration and discovery headers
        let client = self.tls.build_client(self.discovery_headers.clone())?;
        let resource_metadata_url = build_resource_metadata_url(&self.resource);
        let metadata_route_path = resource_metadata_url.path().to_string();
        let auth_state = AuthState {
            config: Arc::new(self.clone()),
            client,
            resource_metadata_url,
            auth_servers: Arc::from(auth_servers),
            required_scopes: Arc::new(required_scopes),
        };

        // Set up auth routes. NOTE: CORs needs to allow for get requests to the
        // metadata information paths.
        let cors = CorsLayer::new()
            .allow_methods([Method::GET])
            .allow_origin(Any);
        let auth_router = Router::new()
            .route(&metadata_route_path, get(protected_resource))
            .with_state(auth_state.clone())
            .layer(cors);

        // Merge with MCP server routes
        Ok(Router::new().merge(auth_router).merge(router.layer(
            axum::middleware::from_fn_with_state(auth_state, oauth_validate),
        )))
    }
}

/// Default timeout for OIDC/OAuth discovery and JWKS requests when
/// `transport.auth.discovery_timeout` is not configured.
const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5);

/// MCP discovery methods that are allowed without authentication when
/// `allow_anonymous_mcp_discovery` is enabled.
const ANONYMOUS_DISCOVERY_METHODS: &[&str] = &["initialize", "tools/list", "resources/list"];

/// Maximum body size to buffer when peeking at the JSON-RPC method.
/// A typical `tools/list` request is under 100 bytes; 16 KiB gives generous
/// headroom while preventing abuse from unauthenticated clients.
const ANONYMOUS_PEEK_BODY_LIMIT: usize = 16 * 1024;

/// Struct for deserializing the JSON-RPC `method` and optional `params.name`
/// from a request body. Used by both anonymous discovery and per-operation scope checks.
#[derive(Deserialize)]
struct JsonRpcBodyPeek {
    method: String,
    params: Option<JsonRpcParams>,
}

#[derive(Deserialize)]
struct JsonRpcParams {
    name: Option<String>,
}

async fn extract_body(request: &mut Request) -> Result<JsonRpcBodyPeek, StatusCode> {
    let body = std::mem::take(request.body_mut());

    let bytes = axum::body::to_bytes(body, ANONYMOUS_PEEK_BODY_LIMIT)
        .await
        .inspect_err(
            |e| tracing::error!(error = %e, "Failed to read request body in oauth middleware"),
        )
        .map_err(|_| StatusCode::PAYLOAD_TOO_LARGE)?;

    let peek = serde_json::from_slice::<JsonRpcBodyPeek>(&bytes)
        .inspect_err(
            |e| tracing::error!(error = %e, "Failed to parse request body in oauth middleware"),
        )
        .map_err(|_| StatusCode::BAD_REQUEST)?;

    *request.body_mut() = axum::body::Body::from(bytes);

    Ok(peek)
}

/// Checks if a `tools/call` request is missing required scopes for the named operation.
///
/// Returns `Some(required_scopes)` if the token is missing scopes, `None` if the request
/// is allowed to proceed (wrong method, unknown operation, or scopes satisfied).
fn missing_scopes_for_operation<'a>(
    peek: &JsonRpcBodyPeek,
    required_scopes: &'a HashMap<String, Vec<String>>,
    token_scopes: &[String],
) -> Option<&'a [String]> {
    if peek.method != "tools/call" {
        return None;
    }
    let op_name = peek.params.as_ref()?.name.as_deref()?;
    let required = required_scopes.get(op_name)?;
    if required.iter().all(|s| token_scopes.contains(s)) {
        return None;
    }
    Some(required)
}

/// Validate that requests made have a corresponding bearer JWT token
#[tracing::instrument(skip_all, fields(status_code, reason))]
async fn oauth_validate(
    State(auth_state): State<AuthState>,
    token: Option<TypedHeader<Authorization<Bearer>>>,
    mut request: Request,
    next: Next,
) -> Result<Response, (StatusCode, TypedHeader<WwwAuthenticate>)> {
    let auth_config = &auth_state.config;
    let resource_metadata_url = &auth_state.resource_metadata_url;

    // Unauthorized error for missing or invalid tokens
    let unauthorized_error = || {
        let scope = if auth_config.scopes.is_empty() {
            None
        } else {
            Some(auth_config.scopes.join(" "))
        };

        (
            StatusCode::UNAUTHORIZED,
            TypedHeader(WwwAuthenticate::Bearer {
                resource_metadata: resource_metadata_url.clone(),
                scope,
                error: None,
                scope_mode: Some(auth_config.scope_mode),
            }),
        )
    };

    // Forbidden error for valid tokens with insufficient scopes (RFC 6750 Section 3.1)
    let forbidden_error = |required_scopes: &[String]| {
        (
            StatusCode::FORBIDDEN,
            TypedHeader(WwwAuthenticate::Bearer {
                resource_metadata: resource_metadata_url.clone(),
                scope: Some(required_scopes.join(" ")),
                error: Some(BearerError::InsufficientScope),
                scope_mode: Some(auth_config.scope_mode),
            }),
        )
    };

    // Extract the body once if we need to inspect the JSON-RPC method for either
    // anonymous discovery or per-operation scope checks.
    let body_peek = if request.method() == http::Method::POST
        && ((auth_config.allow_anonymous_mcp_discovery && token.is_none())
            || !auth_state.required_scopes.is_empty())
    {
        match extract_body(&mut request).await {
            Ok(peek) => Some(peek),
            Err(status) => {
                tracing::Span::current().record("status_code", status.as_u16());
                return Ok(status.into_response());
            }
        }
    } else {
        None
    };

    // Anonymous discovery bypass: when enabled, unauthenticated POST requests
    // whose body contains a discovery method (e.g. tools/list) skip auth entirely.
    if auth_config.allow_anonymous_mcp_discovery
        && token.is_none()
        && body_peek
            .as_ref()
            .is_some_and(|peek| ANONYMOUS_DISCOVERY_METHODS.contains(&peek.method.as_str()))
    {
        let response = next.run(request).await;
        tracing::Span::current().record("status_code", response.status().as_u16());
        return Ok(response);
    }

    let discovery_timeout = auth_config
        .discovery_timeout
        .unwrap_or(DEFAULT_DISCOVERY_TIMEOUT);

    let validator = TokenValidator {
        audiences: &auth_config.audiences,
        issuers: &auth_config.issuers,
        allow_any_audience: auth_config.allow_any_audience,
        servers: &auth_state.auth_servers,
        keys: NetworkedKeyResolver::new(&auth_state.client, discovery_timeout),
    };
    let token = token.ok_or_else(|| {
        tracing::Span::current().record("reason", "missing_token");
        tracing::Span::current().record("status_code", StatusCode::UNAUTHORIZED.as_u16());
        unauthorized_error()
    })?;

    let valid_token = validator.validate(token.0).await.ok_or_else(|| {
        tracing::Span::current().record("reason", "invalid_token");
        tracing::Span::current().record("status_code", StatusCode::UNAUTHORIZED.as_u16());
        unauthorized_error()
    })?;

    // Scope validation: only applies when scopes are configured
    if !auth_config.scopes.is_empty() {
        let sufficient = match auth_config.scope_mode {
            ScopeMode::Disabled => true,
            ScopeMode::RequireAll => auth_config
                .scopes
                .iter()
                .all(|req| valid_token.scopes.contains(req)),
            ScopeMode::RequireAny => auth_config
                .scopes
                .iter()
                .any(|req| valid_token.scopes.contains(req)),
        };

        if !sufficient {
            // Compute missing scopes for diagnostic logging
            let missing: Vec<_> = auth_config
                .scopes
                .iter()
                .filter(|req| !valid_token.scopes.contains(*req))
                .collect();

            tracing::warn!(
                required = ?auth_config.scopes,
                present = ?valid_token.scopes,
                missing = ?missing,
                mode = ?auth_config.scope_mode,
                "Token has insufficient scopes"
            );
            tracing::Span::current().record("reason", "insufficient_scope");
            tracing::Span::current().record("status_code", StatusCode::FORBIDDEN.as_u16());
            // NOTE: WWW-Authenticate lists all configured scopes per RFC 6750.
            // In require_any mode, only one is needed, but the header format
            // doesn't distinguish. This matches existing behavior.
            return Err(forbidden_error(&auth_config.scopes));
        }
    }

    // Per-operation scope check using the already-extracted body peek.
    if let Some(required) = body_peek.as_ref().and_then(|peek| {
        missing_scopes_for_operation(peek, &auth_state.required_scopes, &valid_token.scopes)
    }) {
        tracing::warn!(
            required = ?required,
            present = ?valid_token.scopes,
            "Token has insufficient scopes for operation"
        );
        tracing::Span::current().record("reason", "insufficient_scope");
        tracing::Span::current().record("status_code", StatusCode::FORBIDDEN.as_u16());
        return Err(forbidden_error(required));
    }

    // Insert new context to ensure that handlers only use our enforced token verification
    // for propagation
    request.extensions_mut().insert(valid_token);

    let response = next.run(request).await;
    tracing::Span::current().record("status_code", response.status().as_u16());
    Ok(response)
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::middleware::from_fn_with_state;
    use axum::routing::get;
    use axum::{
        Router,
        body::Body,
        http::{Request, StatusCode},
    };
    use http::header::{AUTHORIZATION, WWW_AUTHENTICATE};
    use tower::ServiceExt; // for .oneshot()
    use url::Url;

    fn test_config() -> Config {
        Config {
            servers: vec!["http://localhost:1234".to_string()],
            audiences: vec!["test-audience".to_string()],
            issuers: vec![],
            allow_any_audience: false,
            resource: Url::parse("http://localhost:4000").unwrap(),
            resource_documentation: None,
            scopes: vec!["read".to_string()],
            scope_mode: ScopeMode::default(),
            disable_auth_token_passthrough: false,
            allow_anonymous_mcp_discovery: false,
            tls: TlsConfig::default(),
            discovery_timeout: None,
            discovery_headers: HeaderMap::new(),
        }
    }

    fn test_auth_state(config: Config) -> AuthState {
        let resource_metadata_url = build_resource_metadata_url(&config.resource);
        let auth_servers = config
            .servers
            .iter()
            .map(|s| Url::parse(s).expect("valid test server URL"))
            .collect::<Vec<_>>();
        AuthState {
            config: Arc::new(config),
            client: reqwest::Client::new(),
            resource_metadata_url,
            auth_servers: Arc::from(auth_servers),
            required_scopes: Arc::new(HashMap::new()),
        }
    }

    fn test_router(config: Config) -> Router {
        Router::new()
            .route("/test", get(|| async { "ok" }))
            .layer(from_fn_with_state(test_auth_state(config), oauth_validate))
    }

    mod oauth_validate {
        use super::*;

        #[tokio::test]
        async fn missing_token_returns_unauthorized() {
            let config = test_config();
            let app = test_router(config.clone());
            let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
            let headers = res.headers();
            let www_auth = headers.get(WWW_AUTHENTICATE).unwrap().to_str().unwrap();
            assert!(www_auth.contains("Bearer"));
            assert!(www_auth.contains("resource_metadata"));
        }

        #[tokio::test]
        async fn invalid_token_returns_unauthorized() {
            let config = test_config();
            let app = test_router(config.clone());
            let req = Request::builder()
                .uri("/test")
                .header(AUTHORIZATION, "Bearer invalidtoken")
                .body(Body::empty())
                .unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
            let headers = res.headers();
            let www_auth = headers.get(WWW_AUTHENTICATE).unwrap().to_str().unwrap();
            assert!(www_auth.contains("Bearer"));
            assert!(www_auth.contains("resource_metadata"));
        }

        #[tokio::test]
        async fn missing_token_with_multiple_scopes() {
            let mut config = test_config();
            config.scopes = vec!["read".to_string(), "write".to_string()];
            let app = test_router(config);
            let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
            let headers = res.headers();
            let www_auth = headers.get(WWW_AUTHENTICATE).unwrap().to_str().unwrap();
            assert!(www_auth.contains(r#"scope="read write""#));
        }

        #[tokio::test]
        async fn missing_token_without_scopes_omits_scope_parameter() {
            let mut config = test_config();
            config.scopes = vec![];
            let app = test_router(config);
            let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
            let headers = res.headers();
            let www_auth = headers.get(WWW_AUTHENTICATE).unwrap().to_str().unwrap();
            assert!(www_auth.contains("Bearer"));
            assert!(www_auth.contains("resource_metadata"));
            assert!(!www_auth.contains("scope="));
        }
    }

    mod scope_validation {
        use super::*;
        use rstest::rstest;

        fn is_sufficient(mode: ScopeMode, required: &[String], present: &[String]) -> bool {
            if required.is_empty() {
                return true;
            }
            match mode {
                ScopeMode::Disabled => true,
                ScopeMode::RequireAll => required.iter().all(|req| present.contains(req)),
                ScopeMode::RequireAny => required.iter().any(|req| present.contains(req)),
            }
        }

        fn s(vals: &[&str]) -> Vec<String> {
            vals.iter().map(|v| v.to_string()).collect()
        }

        #[rstest]
        #[case::all_present(ScopeMode::RequireAll, &["read", "write"], &["read", "write"], true)]
        #[case::missing_one(ScopeMode::RequireAll, &["read", "write"], &["read"], false)]
        #[case::none_present(ScopeMode::RequireAll, &["read"], &[], false)]
        #[case::superset(ScopeMode::RequireAll, &["read"], &["read", "write", "admin"], true)]
        #[case::reversed_order(ScopeMode::RequireAll, &["write", "read"], &["read", "write"], true)]
        #[case::any_one_match(ScopeMode::RequireAny, &["read", "write"], &["read"], true)]
        #[case::any_zero_matches(ScopeMode::RequireAny, &["read", "write"], &["admin"], false)]
        #[case::any_none_present(ScopeMode::RequireAny, &["read"], &[], false)]
        #[case::disabled_ignores_scopes(ScopeMode::Disabled, &["read", "write"], &[], true)]
        fn scope_check(
            #[case] mode: ScopeMode,
            #[case] required: &[&str],
            #[case] present: &[&str],
            #[case] expected: bool,
        ) {
            assert_eq!(is_sufficient(mode, &s(required), &s(present)), expected);
        }

        #[rstest]
        #[case::require_all(ScopeMode::RequireAll)]
        #[case::require_any(ScopeMode::RequireAny)]
        #[case::disabled(ScopeMode::Disabled)]
        fn empty_required_scopes_is_sufficient(#[case] mode: ScopeMode) {
            assert!(is_sufficient(mode, &[], &s(&["anything"])));
        }

        #[test]
        fn forbidden_error_contains_insufficient_scope() {
            let header = WwwAuthenticate::Bearer {
                resource_metadata: Url::parse(
                    "https://test.com/.well-known/oauth-protected-resource",
                )
                .unwrap(),
                scope: Some("read write".to_string()),
                error: Some(BearerError::InsufficientScope),
                scope_mode: None,
            };

            let mut values = Vec::new();
            headers::Header::encode(&header, &mut values);
            let encoded = values.first().unwrap().to_str().unwrap();

            assert!(encoded.contains(r#"error="insufficient_scope""#));
        }

        #[test]
        fn forbidden_error_includes_required_scopes() {
            let header = WwwAuthenticate::Bearer {
                resource_metadata: Url::parse(
                    "https://test.com/.well-known/oauth-protected-resource",
                )
                .unwrap(),
                scope: Some("read write".to_string()),
                error: Some(BearerError::InsufficientScope),
                scope_mode: None,
            };

            let mut values = Vec::new();
            headers::Header::encode(&header, &mut values);
            let encoded = values.first().unwrap().to_str().unwrap();

            assert!(encoded.contains(r#"scope="read write""#));
        }

        #[test]
        fn scope_mode_yaml_deserialization() {
            let yaml = r#"
                servers:
                  - http://localhost:1234
                audiences:
                  - test-audience
                resource: http://localhost:4000
                scopes:
                  - read
                scope_mode: require_any
            "#;

            let config: Config = serde_yaml::from_str(yaml).unwrap();
            assert_eq!(config.scope_mode, ScopeMode::RequireAny);
        }

        #[test]
        fn scope_mode_defaults_to_require_all() {
            let yaml = r#"
                servers:
                  - http://localhost:1234
                audiences:
                  - test-audience
                resource: http://localhost:4000
                scopes:
                  - read
            "#;

            let config: Config = serde_yaml::from_str(yaml).unwrap();
            assert_eq!(config.scope_mode, ScopeMode::RequireAll);
        }
    }

    mod tls_config {
        use super::*;
        use std::io::Write;
        use tempfile::NamedTempFile;

        #[test]
        fn rejects_server_url_without_host() {
            let mut config = test_config();
            // file:// URLs have no host
            config.servers = vec!["file:///some/path".to_string()];

            let router = Router::new();
            let err = config
                .enable_middleware(router, HashMap::new())
                .unwrap_err();

            assert!(matches!(
                err,
                TlsConfigError::ServerUrlMissingHost { index: 0, .. }
            ));
        }

        #[test]
        fn default_config_builds_client() {
            let config = TlsConfig::default();
            let client = config.build_client(HeaderMap::new());
            assert!(client.is_ok());
        }

        #[test]
        fn danger_accept_invalid_certs_builds_client() {
            let config = TlsConfig {
                ca_cert: None,
                danger_accept_invalid_certs: true,
            };
            let client = config.build_client(HeaderMap::new());
            assert!(client.is_ok());
        }

        #[test]
        fn valid_ca_cert_is_loaded() {
            // Create a temporary file with a valid PEM certificate
            // This is the ISRG Root X1 certificate (Let's Encrypt root CA)
            let mut temp_file = NamedTempFile::new().unwrap();
            let test_cert = r#"-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----"#;
            temp_file.write_all(test_cert.as_bytes()).unwrap();

            let config = TlsConfig {
                ca_cert: Some(temp_file.path().to_path_buf()),
                danger_accept_invalid_certs: false,
            };
            let client = config.build_client(HeaderMap::new());
            assert!(client.is_ok());
        }

        #[test]
        fn missing_ca_cert_file_returns_error() {
            let config = TlsConfig {
                ca_cert: Some("/nonexistent/path/to/cert.pem".into()),
                danger_accept_invalid_certs: false,
            };
            let result = config.build_client(HeaderMap::new());
            assert!(result.is_err());
            assert!(matches!(
                result.unwrap_err(),
                TlsConfigError::CertificateRead { .. }
            ));
        }

        #[test]
        fn invalid_pem_returns_error() {
            // Create a temporary file with invalid PEM content
            let mut temp_file = NamedTempFile::new().unwrap();
            temp_file.write_all(b"not a valid certificate").unwrap();

            let config = TlsConfig {
                ca_cert: Some(temp_file.path().to_path_buf()),
                danger_accept_invalid_certs: false,
            };
            let result = config.build_client(HeaderMap::new());
            assert!(result.is_err());
            assert!(matches!(
                result.unwrap_err(),
                TlsConfigError::CertificateParse { .. }
            ));
        }

        #[test]
        fn yaml_deserialization_with_discovery_timeout() {
            let y = r#"
              servers:
                - http://localhost:1234
              audiences:
                - test-audience
              resource: http://localhost:4000
              scopes:
                - read
              discovery_timeout: 10s
            "#;

            let config: Config = serde_yaml::from_str(y).unwrap();
            assert_eq!(config.discovery_timeout, Some(Duration::from_secs(10)));
        }

        #[test]
        fn yaml_deserialization_without_discovery_timeout_defaults_to_none() {
            let y = r#"
              servers:
                - http://localhost:1234
              audiences:
                - test-audience
              resource: http://localhost:4000
              scopes:
                - read
            "#;

            let config: Config = serde_yaml::from_str(y).unwrap();
            assert_eq!(config.discovery_timeout, None);
        }

        #[test]
        fn yaml_deserialization_with_discovery_headers() {
            let y = r#"
              servers:
                - http://localhost:1234
              audiences:
                - test-audience
              resource: http://localhost:4000
              scopes:
                - read
              discovery_headers:
                User-Agent: apollo-mcp-server
                X-Custom-Header: custom-value
            "#;

            let config: Config = serde_yaml::from_str(y).unwrap();
            assert_eq!(
                config
                    .discovery_headers
                    .get("user-agent")
                    .map(|v| v.to_str().unwrap()),
                Some("apollo-mcp-server")
            );
            assert_eq!(
                config
                    .discovery_headers
                    .get("x-custom-header")
                    .map(|v| v.to_str().unwrap()),
                Some("custom-value")
            );
        }

        #[test]
        fn yaml_deserialization_without_discovery_headers_defaults_to_empty() {
            let y = r#"
              servers:
                - http://localhost:1234
              audiences:
                - test-audience
              resource: http://localhost:4000
              scopes:
                - read
            "#;

            let config: Config = serde_yaml::from_str(y).unwrap();
            assert!(config.discovery_headers.is_empty());
        }

        #[tokio::test]
        async fn build_client_with_discovery_headers() {
            let mut mock_server = mockito::Server::new_async().await;
            let mock = mock_server
                .mock("GET", "/test")
                .match_header("user-agent", "apollo-mcp-server")
                .create_async()
                .await;

            let config = TlsConfig::default();
            let mut headers = HeaderMap::new();
            headers.insert("user-agent", HeaderValue::from_static("apollo-mcp-server"));
            let client = config.build_client(headers).unwrap();

            client
                .get(format!("{}/test", mock_server.url()))
                .send()
                .await
                .unwrap();

            mock.assert_async().await;
        }
    }

    mod build_resource_metadata_url {
        use super::*;
        use rstest::rstest;

        #[rstest]
        #[case::no_path(
            "https://mcp.example.com",
            "https://mcp.example.com/.well-known/oauth-protected-resource"
        )]
        #[case::single_path_segment(
            "https://mcp.example.com/mcp",
            "https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
        )]
        #[case::multi_path_segments(
            "https://api.example.com/first-service/mcp",
            "https://api.example.com/.well-known/oauth-protected-resource/first-service/mcp"
        )]
        #[case::trailing_slash_normalized(
            "https://mcp.example.com/mcp/",
            "https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
        )]
        #[case::non_standard_port(
            "https://localhost:8443/mcp",
            "https://localhost:8443/.well-known/oauth-protected-resource/mcp"
        )]
        #[case::no_path_with_port(
            "https://localhost:4000",
            "https://localhost:4000/.well-known/oauth-protected-resource"
        )]
        #[case::deep_path(
            "https://api.example.com/v1/services/mcp",
            "https://api.example.com/.well-known/oauth-protected-resource/v1/services/mcp"
        )]
        #[case::query_string_stripped(
            "https://mcp.example.com/mcp?version=2",
            "https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
        )]
        #[case::fragment_stripped(
            "https://mcp.example.com/mcp#section",
            "https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
        )]
        #[case::root_trailing_slash(
            "https://mcp.example.com/",
            "https://mcp.example.com/.well-known/oauth-protected-resource"
        )]
        fn constructs_correct_url(#[case] resource: &str, #[case] expected: &str) {
            let resource_url = Url::parse(resource).unwrap();
            let result = build_resource_metadata_url(&resource_url);
            assert_eq!(result.as_str(), expected);
        }
    }

    mod servers_field {
        use super::*;

        #[test]
        fn rejects_unparseable_server_url_at_load() {
            let yaml = r#"
                servers:
                  - "not a url"
                audiences:
                  - test-audience
                resource: http://localhost:4000
                scopes:
                  - read
            "#;
            let err = serde_yaml::from_str::<Config>(yaml).unwrap_err();
            assert!(
                err.to_string().contains("invalid auth server URL"),
                "got: {err}"
            );
        }
    }

    mod issuers_field {
        use super::*;

        #[test]
        fn yaml_deserialization_with_issuers() {
            let yaml = r#"
                servers:
                  - http://localhost:1234
                audiences:
                  - test-audience
                issuers:
                  - https://auth.example.com
                  - https://auth.other.com
                resource: http://localhost:4000
                scopes:
                  - read
            "#;

            let config: Config = serde_yaml::from_str(yaml).unwrap();
            assert_eq!(
                config.issuers,
                vec![
                    "https://auth.example.com".to_string(),
                    "https://auth.other.com".to_string()
                ]
            );
        }

        #[test]
        fn yaml_deserialization_without_issuers_defaults_to_empty() {
            let yaml = r#"
                servers:
                  - http://localhost:1234
                audiences:
                  - test-audience
                resource: http://localhost:4000
                scopes:
                  - read
            "#;

            let config: Config = serde_yaml::from_str(yaml).unwrap();
            assert!(config.issuers.is_empty());
        }
    }

    mod resource_url_validation {
        use super::*;

        #[test]
        fn rejects_resource_url_with_file_scheme() {
            let mut config = test_config();
            config.resource = Url::parse("file:///some/path").unwrap();

            let err = config
                .enable_middleware(Router::new(), HashMap::new())
                .unwrap_err();

            assert!(matches!(
                err,
                TlsConfigError::ResourceUrlInvalidScheme { .. }
            ));
        }

        #[test]
        fn rejects_resource_url_with_non_http_scheme() {
            let mut config = test_config();
            config.resource = Url::parse("ftp://example.com/mcp").unwrap();

            let err = config
                .enable_middleware(Router::new(), HashMap::new())
                .unwrap_err();

            assert!(matches!(
                err,
                TlsConfigError::ResourceUrlInvalidScheme { .. }
            ));
        }

        #[test]
        fn accepts_http_resource_url() {
            let mut config = test_config();
            config.resource = Url::parse("http://localhost:4000/mcp").unwrap();

            let result = config.enable_middleware(Router::new(), HashMap::new());

            assert!(result.is_ok());
        }

        #[test]
        fn accepts_https_resource_url() {
            let mut config = test_config();
            config.resource = Url::parse("https://mcp.example.com/mcp").unwrap();

            let result = config.enable_middleware(Router::new(), HashMap::new());

            assert!(result.is_ok());
        }
    }

    mod enable_middleware_integration {
        use super::*;

        #[tokio::test]
        async fn unauthorized_response_contains_path_scoped_resource_metadata_url() {
            let mut config = test_config();
            config.resource = Url::parse("https://mcp.example.com/my-service/mcp").unwrap();

            let base_router = Router::new().route("/my-service/mcp", get(|| async { "ok" }));
            let app = config
                .enable_middleware(base_router, HashMap::new())
                .unwrap();

            let req = Request::builder()
                .uri("/my-service/mcp")
                .body(Body::empty())
                .unwrap();
            let res = app.oneshot(req).await.unwrap();

            assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
            let www_auth = res
                .headers()
                .get(WWW_AUTHENTICATE)
                .unwrap()
                .to_str()
                .unwrap();
            assert!(www_auth.contains(
                r#"resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/my-service/mcp""#
            ));
        }

        #[tokio::test]
        async fn get_resource_metadata_path_returns_ok() {
            let mut config = test_config();
            config.resource = Url::parse("https://mcp.example.com/my-service/mcp").unwrap();

            let base_router = Router::new().route("/my-service/mcp", get(|| async { "ok" }));
            let app = config
                .enable_middleware(base_router, HashMap::new())
                .unwrap();

            let req = Request::builder()
                .uri("/.well-known/oauth-protected-resource/my-service/mcp")
                .body(Body::empty())
                .unwrap();
            let res = app.oneshot(req).await.unwrap();

            assert_eq!(res.status(), StatusCode::OK);
        }

        #[tokio::test]
        async fn root_resource_metadata_path_returns_ok() {
            let config = test_config();

            let base_router = Router::new().route("/mcp", get(|| async { "ok" }));
            let app = config
                .enable_middleware(base_router, HashMap::new())
                .unwrap();

            let req = Request::builder()
                .uri("/.well-known/oauth-protected-resource")
                .body(Body::empty())
                .unwrap();
            let res = app.oneshot(req).await.unwrap();

            assert_eq!(res.status(), StatusCode::OK);
        }
    }

    mod anonymous_mcp_discovery {
        use super::*;
        use axum::routing::post;

        fn discovery_router(allow: bool) -> Router {
            let mut config = test_config();
            config.allow_anonymous_mcp_discovery = allow;
            Router::new()
                .route("/mcp", post(|| async { "ok" }))
                .layer(from_fn_with_state(test_auth_state(config), oauth_validate))
        }

        fn tools_list_body() -> Body {
            Body::from(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#)
        }

        fn tools_call_body() -> Body {
            Body::from(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test"}}"#)
        }

        fn initialize_body() -> Body {
            Body::from(r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#)
        }

        fn resources_list_body() -> Body {
            Body::from(r#"{"jsonrpc":"2.0","id":1,"method":"resources/list"}"#)
        }

        #[tokio::test]
        async fn initialize_without_token_allowed_when_enabled() {
            let app = discovery_router(true);
            let req = Request::builder()
                .method("POST")
                .uri("/mcp")
                .body(initialize_body())
                .unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::OK);
        }

        #[tokio::test]
        async fn resources_list_without_token_allowed_when_enabled() {
            let app = discovery_router(true);
            let req = Request::builder()
                .method("POST")
                .uri("/mcp")
                .body(resources_list_body())
                .unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::OK);
        }

        #[tokio::test]
        async fn tools_list_without_token_allowed_when_enabled() {
            let app = discovery_router(true);
            let req = Request::builder()
                .method("POST")
                .uri("/mcp")
                .body(tools_list_body())
                .unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::OK);
        }

        #[tokio::test]
        async fn tools_list_without_token_rejected_when_disabled() {
            let app = discovery_router(false);
            let req = Request::builder()
                .method("POST")
                .uri("/mcp")
                .body(tools_list_body())
                .unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
        }

        #[tokio::test]
        async fn tools_call_without_token_rejected_when_enabled() {
            let app = discovery_router(true);
            let req = Request::builder()
                .method("POST")
                .uri("/mcp")
                .body(tools_call_body())
                .unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
        }

        #[tokio::test]
        async fn get_without_token_rejected_when_enabled() {
            // Anonymous discovery bypass only applies to POST requests;
            // GET requests are rejected by the auth middleware before routing.
            let app = discovery_router(true);
            let req = Request::builder()
                .method("GET")
                .uri("/mcp")
                .body(Body::empty())
                .unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
        }

        #[tokio::test]
        async fn malformed_json_without_token_rejected_when_enabled() {
            let app = discovery_router(true);
            let req = Request::builder()
                .method("POST")
                .uri("/mcp")
                .body(Body::from("not json"))
                .unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::BAD_REQUEST);
        }

        #[tokio::test]
        async fn empty_body_without_token_rejected_when_enabled() {
            let app = discovery_router(true);
            let req = Request::builder()
                .method("POST")
                .uri("/mcp")
                .body(Body::empty())
                .unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::BAD_REQUEST);
        }

        #[tokio::test]
        async fn oversized_body_without_token_returns_payload_too_large() {
            let app = discovery_router(true);
            let body = Body::from("x".repeat(super::ANONYMOUS_PEEK_BODY_LIMIT + 1));
            let req = Request::builder()
                .method("POST")
                .uri("/mcp")
                .body(body)
                .unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE);
        }

        #[tokio::test]
        async fn tools_list_with_invalid_token_still_validates_when_enabled() {
            let app = discovery_router(true);
            let req = Request::builder()
                .method("POST")
                .uri("/mcp")
                .header(AUTHORIZATION, "Bearer invalidtoken")
                .body(tools_list_body())
                .unwrap();
            let res = app.oneshot(req).await.unwrap();
            assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
        }

        #[test]
        fn yaml_with_allow_anonymous_mcp_discovery() {
            let yaml = r#"
                servers:
                  - http://localhost:1234
                audiences:
                  - test-audience
                resource: http://localhost:4000
                scopes:
                  - read
                allow_anonymous_mcp_discovery: true
            "#;
            let config: Config = serde_yaml::from_str(yaml).unwrap();
            assert!(config.allow_anonymous_mcp_discovery);
        }

        #[test]
        fn yaml_defaults_allow_anonymous_mcp_discovery_to_false() {
            let yaml = r#"
                servers:
                  - http://localhost:1234
                audiences:
                  - test-audience
                resource: http://localhost:4000
                scopes:
                  - read
            "#;
            let config: Config = serde_yaml::from_str(yaml).unwrap();
            assert!(!config.allow_anonymous_mcp_discovery);
        }
    }

    mod per_operation_scope_enforcement {
        use super::*;

        fn required() -> HashMap<String, Vec<String>> {
            HashMap::from([(
                "RestrictedOp".to_string(),
                vec!["sensitive:read".to_string()],
            )])
        }

        fn tools_call_peek(op: &str) -> JsonRpcBodyPeek {
            JsonRpcBodyPeek {
                method: "tools/call".to_string(),
                params: Some(JsonRpcParams {
                    name: Some(op.to_string()),
                }),
            }
        }

        #[test]
        fn returns_required_scopes_when_token_missing_scope() {
            let peek = tools_call_peek("RestrictedOp");
            let scopes = vec!["other:scope".to_string()];
            let required = required();
            let result = missing_scopes_for_operation(&peek, &required, &scopes);
            assert_eq!(result, Some(["sensitive:read".to_string()].as_slice()));
        }

        #[test]
        fn returns_none_when_token_has_required_scope() {
            let peek = tools_call_peek("RestrictedOp");
            let scopes = vec!["sensitive:read".to_string(), "other:scope".to_string()];
            let required = required();
            let result = missing_scopes_for_operation(&peek, &required, &scopes);
            assert!(result.is_none());
        }

        #[test]
        fn returns_none_for_unrestricted_operation() {
            let peek = tools_call_peek("PublicOp");
            let required = required();
            let result = missing_scopes_for_operation(&peek, &required, &[]);
            assert!(result.is_none());
        }

        #[test]
        fn returns_none_for_non_tools_call_method() {
            let peek = JsonRpcBodyPeek {
                method: "tools/list".to_string(),
                params: None,
            };
            let required = required();
            let result = missing_scopes_for_operation(&peek, &required, &[]);
            assert!(result.is_none());
        }

        #[test]
        fn returns_none_when_required_scopes_map_is_empty() {
            let peek = tools_call_peek("RestrictedOp");
            let empty = HashMap::new();
            let result = missing_scopes_for_operation(&peek, &empty, &[]);
            assert!(result.is_none());
        }
    }
}
