use crate::email::EmailProvider;
use crate::error::AuthError;
use chrono::Duration;
use std::collections::HashMap;
use std::sync::Arc;

/// Well-known core route paths.
///
/// These constants are the single source of truth for route paths used by both
/// the core request dispatcher (`handle_core_request`) and framework-specific
/// routers (e.g. Axum) so that path strings are never duplicated.
pub mod core_paths {
    pub const OK: &str = "/ok";
    pub const ERROR: &str = "/error";
    pub const HEALTH: &str = "/health";
    pub const OPENAPI_SPEC: &str = "/reference/openapi.json";
    pub const UPDATE_USER: &str = "/update-user";
    pub const DELETE_USER: &str = "/delete-user";
    pub const CHANGE_EMAIL: &str = "/change-email";
    pub const DELETE_USER_CALLBACK: &str = "/delete-user/callback";
}

/// Main configuration for AthenaAuth
#[derive(Clone)]
pub struct AuthConfig {
    /// Secret key for signing tokens and sessions
    pub secret: String,

    /// Application name, used for cookie prefixes, email templates, etc.
    ///
    /// Defaults to `"Better Auth"`.
    pub app_name: String,

    /// Base URL for the authentication service (e.g. `"http://localhost:3000"`).
    pub base_url: String,

    /// Base path where the auth routes are mounted.
    ///
    /// All routes handled by AthenaAuth will be prefixed with this path.
    /// For example, with the default `"/api/auth"`, the sign-in route becomes
    /// `"/api/auth/sign-in/email"`.
    ///
    /// Defaults to `"/api/auth"`.
    pub base_path: String,

    /// Public-facing base path used when generating auth URLs for emails and redirects.
    ///
    /// This can differ from [`Self::base_path`] when the server is mounted at `/`
    /// internally but exposed through a proxy path such as `"/api/auth"`.
    ///
    /// Defaults to the internal [`Self::base_path`] when unset.
    pub public_base_path: Option<String>,

    /// Origins that are trusted for CSRF and other cross-origin checks.
    ///
    /// Supports glob patterns (e.g. `"https://*.example.com"`).
    /// These are shared across all middleware that needs origin validation
    /// (CSRF, CORS, etc.).
    pub trusted_origins: Vec<String>,

    /// Paths that should be disabled (skipped) by the router.
    ///
    /// Any request whose path matches an entry in this list will receive
    /// a 404 response, even if a handler is registered for it.
    pub disabled_paths: Vec<String>,
    /// Session configuration
    pub session: SessionConfig,

    /// JWT configuration
    pub jwt: JwtConfig,

    /// Password configuration
    pub password: PasswordConfig,

    /// Account configuration (linking, token encryption, etc.)
    pub account: AccountConfig,

    /// Email provider for sending emails (verification, password reset, etc.)
    pub email_provider: Option<Arc<dyn EmailProvider>>,

    /// Avatar storage settings used to normalize and optionally upload user
    /// and organization images.
    pub avatar_storage: AvatarStorageConfig,

    /// Advanced configuration options
    pub advanced: AdvancedConfig,
}

/// Configuration for optional S3-compatible avatar storage.
#[derive(Debug, Clone)]
pub struct AvatarStorageConfig {
    /// Whether avatar uploads are enabled.
    pub enabled: bool,

    /// Bucket name used for avatar objects.
    pub bucket: String,

    /// S3-compatible endpoint URL.
    pub endpoint: String,

    /// Access key used for static credentials.
    pub access_key: String,

    /// Secret key used for static credentials.
    pub secret_key: String,

    /// Whether to emit path-style or virtual-hosted-style public URLs.
    pub url_style: AvatarStorageUrlStyle,

    /// Optional normalized prefix prepended to generated object keys.
    pub prefix_path: Option<String>,

    /// Template used to generate the object key.
    pub file_key_template: String,
}

/// Public URL style for stored avatar objects.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AvatarStorageUrlStyle {
    Path,
    VirtualHost,
}

/// Account-level configuration: linking, token encryption, sign-in behavior.
#[derive(Debug, Clone)]
pub struct AccountConfig {
    /// Update OAuth tokens on every sign-in (default: true)
    pub update_account_on_sign_in: bool,
    /// Account linking settings
    pub account_linking: AccountLinkingConfig,
    /// Encrypt OAuth tokens at rest (default: false)
    pub encrypt_oauth_tokens: bool,
}

/// Settings that control how OAuth accounts are linked to existing users.
#[derive(Debug, Clone)]
pub struct AccountLinkingConfig {
    /// Enable account linking (default: true)
    pub enabled: bool,
    /// Trusted providers that can auto-link (default: empty = all trusted)
    pub trusted_providers: Vec<String>,
    /// Allow linking accounts with different emails (default: false) - SECURITY WARNING
    pub allow_different_emails: bool,
    /// Allow unlinking all accounts (default: false)
    pub allow_unlinking_all: bool,
    /// Update user info when a new account is linked (default: false)
    pub update_user_info_on_link: bool,
}

/// Session-specific configuration
#[derive(Debug, Clone)]
pub struct SessionConfig {
    /// Session expiration duration
    pub expires_in: Duration,

    /// How often to refresh the session expiry (as a Duration).
    ///
    /// When set, session expiry is only updated if the session is older than
    /// this duration since the last update. When `None`, every request
    /// refreshes the session (equivalent to the old `update_age: true`).
    pub update_age: Option<Duration>,

    /// If `true`, sessions are never automatically refreshed on access.
    pub disable_session_refresh: bool,

    /// Session freshness window. A session younger than this is considered
    /// "fresh" (useful for step-up auth or sensitive operations).
    pub fresh_age: Option<Duration>,

    /// Cookie name for session token
    pub cookie_name: String,

    /// Cookie settings
    pub cookie_domain: Option<String>,
    pub cookie_secure: bool,
    pub cookie_http_only: bool,
    pub cookie_same_site: SameSite,

    /// Optional cookie-based session cache to avoid DB lookups.
    ///
    /// When enabled, session data is cached in a signed/encrypted cookie.
    /// `SessionManager` checks the cookie cache before hitting the database.
    pub cookie_cache: Option<CookieCacheConfig>,
}

/// JWT configuration
#[derive(Debug, Clone)]
pub struct JwtConfig {
    /// JWT expiration duration
    pub expires_in: Duration,

    /// JWT algorithm
    pub algorithm: String,

    /// Issuer claim
    pub issuer: Option<String>,

    /// Audience claim
    pub audience: Option<String>,
}

/// Password hashing configuration
#[derive(Debug, Clone)]
pub struct PasswordConfig {
    /// Minimum password length
    pub min_length: usize,

    /// Require uppercase letters
    pub require_uppercase: bool,

    /// Require lowercase letters
    pub require_lowercase: bool,

    /// Require numbers
    pub require_numbers: bool,

    /// Require special characters
    pub require_special: bool,

    /// Argon2 configuration
    pub argon2_config: Argon2Config,
}

/// Argon2 hashing configuration
#[derive(Debug, Clone)]
pub struct Argon2Config {
    pub memory_cost: u32,
    pub time_cost: u32,
    pub parallelism: u32,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SameSite {
    Strict,
    Lax,
    None,
}

/// Configuration for cookie-based session caching.
///
/// When enabled, session data is stored in a signed or encrypted cookie so that
/// subsequent requests can skip the database lookup.
#[derive(Debug, Clone)]
pub struct CookieCacheConfig {
    /// Whether the cookie cache is active.
    pub enabled: bool,

    /// Maximum age of the cached cookie before a fresh DB lookup is required.
    ///
    /// Default: 5 minutes.
    pub max_age: Duration,

    /// Strategy used to protect the cached cookie value.
    pub strategy: CookieCacheStrategy,
}

/// Strategy for signing / encrypting the cookie cache.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CookieCacheStrategy {
    /// Base64url-encoded payload + HMAC-SHA256 signature.
    Compact,
    /// Standard JWT with HMAC signing.
    Jwt,
    /// JWE with AES-256-GCM encryption.
    Jwe,
}

impl Default for CookieCacheConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            max_age: Duration::minutes(5),
            strategy: CookieCacheStrategy::Compact,
        }
    }
}

impl Default for AccountConfig {
    fn default() -> Self {
        Self {
            update_account_on_sign_in: true,
            account_linking: AccountLinkingConfig::default(),
            encrypt_oauth_tokens: false,
        }
    }
}

impl Default for AccountLinkingConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            trusted_providers: Vec::new(),
            allow_different_emails: false,
            allow_unlinking_all: false,
            update_user_info_on_link: false,
        }
    }
}

impl std::fmt::Display for SameSite {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SameSite::Strict => f.write_str("Strict"),
            SameSite::Lax => f.write_str("Lax"),
            SameSite::None => f.write_str("None"),
        }
    }
}

// ── Advanced configuration ──────────────────────────────────────────────

/// Advanced configuration options (mirrors TS `advanced` block).
#[derive(Debug, Clone, Default)]
pub struct AdvancedConfig {
    /// IP address extraction configuration.
    pub ip_address: IpAddressConfig,

    /// If `true`, the CSRF-check middleware is disabled.
    pub disable_csrf_check: bool,

    /// If `true`, the Origin header check is skipped.
    pub disable_origin_check: bool,

    /// Cross-subdomain cookie sharing configuration.
    pub cross_sub_domain_cookies: Option<CrossSubDomainConfig>,

    /// Per-cookie-name overrides (name, attributes, prefix).
    ///
    /// Keys are the *logical* cookie names (e.g. `"session_token"`,
    /// `"csrf_token"`). Values specify the attributes to override.
    pub cookies: HashMap<String, CookieOverride>,

    /// Default cookie attributes applied to *every* cookie the library sets
    /// (individual overrides in `cookies` take precedence).
    pub default_cookie_attributes: CookieAttributes,

    /// Optional prefix prepended to every cookie name (e.g. `"myapp"` →
    /// `"myapp.session_token"`).
    pub cookie_prefix: Option<String>,

    /// Database-related advanced options.
    pub database: AdvancedDatabaseConfig,

    /// List of header names the framework trusts for extracting the
    /// client's real IP when behind a proxy (e.g. `X-Forwarded-For`).
    pub trusted_proxy_headers: Vec<String>,

    /// Session intelligence features (GeoIP enrichment, anomaly flags, ...).
    pub session_intelligence: SessionIntelligenceConfig,
}

/// IP-address extraction configuration.
#[derive(Debug, Clone)]
pub struct IpAddressConfig {
    /// Ordered list of headers to check for the client IP.
    /// Defaults to `["x-forwarded-for", "x-real-ip"]`.
    pub headers: Vec<String>,

    /// If `true`, IP tracking is entirely disabled (no IP stored in sessions).
    pub disable_ip_tracking: bool,
}

/// Configuration for optional session intelligence behavior.
#[derive(Debug, Clone)]
pub struct SessionIntelligenceConfig {
    /// Enables session intelligence processing.
    pub enabled: bool,

    /// GeoIP provider settings and cache strategy.
    pub geoip: GeoIpConfig,

    /// If true, compare each new session country with the previous known
    /// country for the same user and persist a change flag.
    pub detect_country_change: bool,
}

/// GeoIP provider configuration.
#[derive(Debug, Clone)]
pub struct GeoIpConfig {
    /// Enables network lookups for GeoIP data.
    pub enabled: bool,

    /// Which provider adapter to use for lookup/parsing.
    pub provider: GeoIpProvider,

    /// Provider base URL. For `IpWhoIs`, this should point at the API origin.
    pub base_url: String,

    /// Optional API key (provider-dependent).
    pub api_key: Option<String>,

    /// Optional API key header name (provider-dependent).
    pub api_key_header: Option<String>,

    /// HTTP timeout in milliseconds.
    pub timeout_ms: u64,

    /// Cache TTL for IP profiles.
    pub cache_ttl: Duration,
}

/// Supported GeoIP lookup providers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GeoIpProvider {
    /// https://ipwho.is/{ip}
    IpWhoIs,
    /// https://ipapi.co/{ip}/json/
    IpApiCo,
}

/// Configuration for sharing cookies across sub-domains.
#[derive(Debug, Clone)]
pub struct CrossSubDomainConfig {
    /// The parent domain (e.g. `".example.com"`).
    pub domain: String,
}

/// Overridable cookie attributes.
#[derive(Debug, Clone, Default)]
pub struct CookieAttributes {
    /// Override `Secure` flag.
    pub secure: Option<bool>,
    /// Override `HttpOnly` flag.
    pub http_only: Option<bool>,
    /// Override `SameSite` policy.
    pub same_site: Option<SameSite>,
    /// Override `Path`.
    pub path: Option<String>,
    /// Override `Max-Age` (seconds).
    pub max_age: Option<i64>,
    /// Override cookie `Domain`.
    pub domain: Option<String>,
}

/// Per-cookie override entry.
#[derive(Debug, Clone, Default)]
pub struct CookieOverride {
    /// Custom name to use instead of the logical name.
    pub name: Option<String>,
    /// Attribute overrides for this cookie.
    pub attributes: CookieAttributes,
}

/// Database-related advanced options.
#[derive(Debug, Clone)]
pub struct AdvancedDatabaseConfig {
    /// Default `LIMIT` for "find many" queries.
    pub default_find_many_limit: usize,

    /// If `true`, auto-generated IDs will be numeric (auto-increment style)
    /// rather than UUIDs.
    pub use_number_id: bool,
}
impl Default for AuthConfig {
    fn default() -> Self {
        Self {
            secret: String::new(),
            app_name: "Athena Auth".to_string(),
            base_url: "http://localhost:3000".to_string(),
            base_path: "/api/auth".to_string(),
            public_base_path: None,
            trusted_origins: Vec::new(),
            disabled_paths: Vec::new(),
            session: SessionConfig::default(),
            jwt: JwtConfig::default(),
            password: PasswordConfig::default(),
            account: AccountConfig::default(),
            email_provider: None,
            avatar_storage: AvatarStorageConfig::default(),
            advanced: AdvancedConfig::default(),
        }
    }
}

impl Default for AvatarStorageConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            bucket: String::new(),
            endpoint: String::new(),
            access_key: String::new(),
            secret_key: String::new(),
            url_style: AvatarStorageUrlStyle::Path,
            prefix_path: None,
            file_key_template: "{kind}/{entity_id}/{sha256}.{extension}".to_string(),
        }
    }
}

impl Default for SessionConfig {
    fn default() -> Self {
        Self {
            expires_in: Duration::hours(24 * 7),   // 7 days
            update_age: Some(Duration::hours(24)), // refresh once per day
            disable_session_refresh: false,
            fresh_age: None,
            cookie_name: "athena-auth.session-token".to_string(),
            cookie_domain: None,
            cookie_secure: true,
            cookie_http_only: true,
            cookie_same_site: SameSite::Lax,
            cookie_cache: None,
        }
    }
}

impl Default for IpAddressConfig {
    fn default() -> Self {
        Self {
            headers: vec!["x-forwarded-for".to_string(), "x-real-ip".to_string()],
            disable_ip_tracking: false,
        }
    }
}

impl Default for SessionIntelligenceConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            geoip: GeoIpConfig::default(),
            detect_country_change: true,
        }
    }
}

impl Default for GeoIpConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            provider: GeoIpProvider::IpWhoIs,
            base_url: "https://ipwho.is".to_string(),
            api_key: None,
            api_key_header: None,
            timeout_ms: 2500,
            cache_ttl: Duration::hours(24),
        }
    }
}

impl Default for AdvancedDatabaseConfig {
    fn default() -> Self {
        Self {
            default_find_many_limit: 100,
            use_number_id: false,
        }
    }
}

impl std::fmt::Display for GeoIpProvider {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            GeoIpProvider::IpWhoIs => f.write_str("ipwhois"),
            GeoIpProvider::IpApiCo => f.write_str("ipapi"),
        }
    }
}

impl std::fmt::Display for AvatarStorageUrlStyle {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AvatarStorageUrlStyle::Path => f.write_str("path"),
            AvatarStorageUrlStyle::VirtualHost => f.write_str("virtual_host"),
        }
    }
}

impl Default for JwtConfig {
    fn default() -> Self {
        Self {
            expires_in: Duration::hours(24), // 1 day
            algorithm: "HS256".to_string(),
            issuer: None,
            audience: None,
        }
    }
}

impl Default for PasswordConfig {
    fn default() -> Self {
        Self {
            min_length: 8,
            require_uppercase: false,
            require_lowercase: false,
            require_numbers: false,
            require_special: false,
            argon2_config: Argon2Config::default(),
        }
    }
}

impl Default for Argon2Config {
    fn default() -> Self {
        Self {
            memory_cost: 1024, // 1MB
            time_cost: 2,      // 2 iterations
            parallelism: 1,    // 1 thread
        }
    }
}

impl AuthConfig {
    pub fn new(secret: impl Into<String>) -> Self {
        Self {
            secret: secret.into(),
            ..Default::default()
        }
    }

    /// Set the application name.
    pub fn app_name(mut self, name: impl Into<String>) -> Self {
        self.app_name = name.into();
        self
    }

    /// Set the base URL (e.g. `"https://myapp.com"`).
    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        let url: String = url.into();
        self.base_url = normalize_configured_base_url(&url).unwrap_or(url);
        self
    }

    pub fn account(mut self, account: AccountConfig) -> Self {
        self.account = account;
        self
    }

    pub fn avatar_storage(mut self, avatar_storage: AvatarStorageConfig) -> Self {
        self.avatar_storage = avatar_storage;
        self
    }

    /// Set the base path where auth routes are mounted.
    pub fn base_path(mut self, path: impl Into<String>) -> Self {
        self.base_path = path.into();
        self
    }

    /// Set the public-facing base path used for generated auth URLs.
    pub fn public_base_path(mut self, path: impl Into<String>) -> Self {
        self.public_base_path = Some(path.into());
        self
    }

    /// Resolve the external auth mount path used for generated URLs.
    pub fn effective_public_base_path(&self) -> &str {
        self.public_base_path
            .as_deref()
            .unwrap_or(self.base_path.as_str())
    }

    /// Add a trusted origin pattern (exact or glob).
    ///
    /// Examples:
    /// - `"https://*.example.com"`
    /// - `"http://localhost:*"`
    /// - `"localhost:*"` (scheme inferred as `http` for localhost-style hosts)
    pub fn trusted_origin(mut self, origin: impl Into<String>) -> Self {
        self.trusted_origins.push(origin.into());
        self
    }

    /// Set all trusted origins at once.
    pub fn trusted_origins(mut self, origins: Vec<String>) -> Self {
        self.trusted_origins = origins;
        self
    }

    /// Add a path to the disabled paths list.
    pub fn disabled_path(mut self, path: impl Into<String>) -> Self {
        self.disabled_paths.push(path.into());
        self
    }

    /// Set all disabled paths at once.
    pub fn disabled_paths(mut self, paths: Vec<String>) -> Self {
        self.disabled_paths = paths;
        self
    }

    /// Set the session expiration duration.
    pub fn session_expires_in(mut self, duration: Duration) -> Self {
        self.session.expires_in = duration;
        self
    }

    /// Replace the full password policy config.
    pub fn password(mut self, password: PasswordConfig) -> Self {
        self.password = password;
        self
    }

    /// Replace the Argon2 cost profile used by the built-in password hasher.
    pub fn password_argon2_config(mut self, argon2_config: Argon2Config) -> Self {
        self.password.argon2_config = argon2_config;
        self
    }

    /// Set the Argon2 memory cost in KiB.
    pub fn password_argon2_memory_cost(mut self, memory_cost: u32) -> Self {
        self.password.argon2_config.memory_cost = memory_cost;
        self
    }

    /// Set the Argon2 time cost (number of iterations).
    pub fn password_argon2_time_cost(mut self, time_cost: u32) -> Self {
        self.password.argon2_config.time_cost = time_cost;
        self
    }

    /// Set the Argon2 parallelism (lane count encoded in the PHC hash).
    pub fn password_argon2_parallelism(mut self, parallelism: u32) -> Self {
        self.password.argon2_config.parallelism = parallelism;
        self
    }

    pub fn session_update_age(mut self, duration: Duration) -> Self {
        self.session.update_age = Some(duration);
        self
    }

    pub fn disable_session_refresh(mut self, disabled: bool) -> Self {
        self.session.disable_session_refresh = disabled;
        self
    }

    pub fn session_fresh_age(mut self, duration: Duration) -> Self {
        self.session.fresh_age = Some(duration);
        self
    }

    /// Set the cookie cache configuration for sessions.
    pub fn session_cookie_cache(mut self, config: CookieCacheConfig) -> Self {
        self.session.cookie_cache = Some(config);
        self
    }

    /// Share the session cookie with sibling subdomains by setting a parent
    /// `Domain` attribute such as `.example.com`.
    pub fn session_cookie_domain(mut self, domain: impl Into<String>) -> Self {
        self.session.cookie_domain = normalize_session_cookie_domain(domain.into());
        self
    }

    /// Set the JWT expiration duration.
    pub fn jwt_expires_in(mut self, duration: Duration) -> Self {
        self.jwt.expires_in = duration;
        self
    }

    /// Set the minimum password length.
    pub fn password_min_length(mut self, length: usize) -> Self {
        self.password.min_length = length;
        self
    }

    pub fn advanced(mut self, advanced: AdvancedConfig) -> Self {
        self.advanced = advanced;
        self
    }

    pub fn cookie_prefix(mut self, prefix: impl Into<String>) -> Self {
        self.advanced.cookie_prefix = Some(prefix.into());
        self
    }

    pub fn disable_csrf_check(mut self, disabled: bool) -> Self {
        self.advanced.disable_csrf_check = disabled;
        self
    }

    pub fn cross_sub_domain_cookies(mut self, domain: impl Into<String>) -> Self {
        let normalized = normalize_session_cookie_domain(domain.into());
        self.session.cookie_domain = normalized.clone();
        self.advanced.cross_sub_domain_cookies =
            normalized.map(|domain| CrossSubDomainConfig { domain });
        self
    }

    /// Resolve the configured `Domain` attribute for session cookies.
    pub fn effective_session_cookie_domain(&self) -> Option<String> {
        self.session
            .cookie_domain
            .as_deref()
            .and_then(trimmed_session_cookie_domain)
            .map(str::to_string)
            .or_else(|| {
                self.advanced
                    .cross_sub_domain_cookies
                    .as_ref()
                    .and_then(|config| trimmed_session_cookie_domain(config.domain.as_str()))
                    .map(str::to_string)
            })
            .or_else(|| derive_shared_parent_cookie_domain(&self.base_url, &self.trusted_origins))
    }

    /// Check whether a given origin is trusted.
    ///
    /// An origin is trusted if it matches:
    /// 1. The origin extracted from [`base_url`](Self::base_url), or
    /// 2. Any pattern in [`trusted_origins`](Self::trusted_origins) after
    ///    normalization.
    ///
    /// Glob patterns are supported — `*` matches any characters except `/`,
    /// `**` matches any characters including `/`.
    ///
    /// Trusted-origin patterns may omit scheme. In that case, configured
    /// origins use the same default-scheme rules as base URLs:
    /// - `http` is assumed for localhost-style hosts (`localhost`, `127.0.0.1`, `::1`)
    /// - `http` is assumed when the value explicitly includes `sslmode=disable`
    /// - `https` is assumed otherwise.
    pub fn is_origin_trusted(&self, origin: &str) -> bool {
        let Some(origin) = extract_origin(origin) else {
            return false;
        };

        // Check base_url origin
        if let Some(base_origin) = normalize_configured_origin_pattern(&self.base_url)
            && origin == base_origin
        {
            return true;
        }

        // Check trusted_origins patterns
        self.trusted_origins.iter().any(|pattern| {
            normalize_configured_origin_pattern(pattern)
                .is_some_and(|normalized| glob_match::glob_match(&normalized, &origin))
        })
    }

    /// Check whether a given path is disabled.
    pub fn is_path_disabled(&self, path: &str) -> bool {
        self.disabled_paths.iter().any(|disabled| disabled == path)
    }

    /// Absolute URL for a route mounted under the public auth base path.
    ///
    /// `path` is the path after the auth mount (e.g. `"verify-email"` or `"change-email/verify"`),
    /// with or without a leading slash. Does not include a query string.
    ///
    /// Example: `base_url = "https://app.com"`, `public_base_path = "/api/auth"`,
    /// `path = "verify-email"` → `"https://app.com/api/auth/verify-email"`.
    pub fn public_auth_url(&self, path: &str) -> String {
        let base: &str = self.base_url.trim_end_matches('/');
        let path: &str = path.trim_start_matches('/');
        let mount: &str = self
            .effective_public_base_path()
            .trim()
            .trim_end_matches('/');
        let mount: String = if mount.is_empty() || mount == "/" {
            String::new()
        } else if mount.starts_with('/') {
            mount.to_string()
        } else {
            format!("/{mount}")
        };

        if mount.is_empty() {
            format!("{base}/{path}")
        } else {
            format!("{base}{mount}/{path}")
        }
    }

    pub fn validate(&self) -> Result<(), AuthError> {
        if self.secret.is_empty() {
            return Err(AuthError::config("Secret key cannot be empty"));
        }

        if self.secret.len() < 32 {
            return Err(AuthError::config(
                "Secret key must be at least 32 characters",
            ));
        }

        if let Some(cookie_domain) = self.effective_session_cookie_domain() {
            validate_session_cookie_domain(cookie_domain.as_str())?;
            if self.session.cookie_name.starts_with("__Host-") {
                return Err(AuthError::config(
                    "Session cookies using the __Host- prefix cannot also set a shared cookie domain",
                ));
            }
        }

        if self.avatar_storage.enabled {
            if self.avatar_storage.bucket.trim().is_empty() {
                return Err(AuthError::config(
                    "Avatar storage bucket cannot be empty when avatar storage is enabled",
                ));
            }
            if self.avatar_storage.endpoint.trim().is_empty() {
                return Err(AuthError::config(
                    "Avatar storage endpoint cannot be empty when avatar storage is enabled",
                ));
            }
            if self.avatar_storage.access_key.trim().is_empty() {
                return Err(AuthError::config(
                    "Avatar storage access_key cannot be empty when avatar storage is enabled",
                ));
            }
            if self.avatar_storage.secret_key.trim().is_empty() {
                return Err(AuthError::config(
                    "Avatar storage secret_key cannot be empty when avatar storage is enabled",
                ));
            }
            if self.avatar_storage.file_key_template.trim().is_empty() {
                return Err(AuthError::config(
                    "Avatar storage file_key_template cannot be empty when avatar storage is enabled",
                ));
            }
        }

        Ok(())
    }
}

fn normalize_session_cookie_domain(domain: String) -> Option<String> {
    let trimmed = domain.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

fn trimmed_session_cookie_domain(domain: &str) -> Option<&str> {
    let trimmed = domain.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed)
    }
}

fn validate_session_cookie_domain(domain: &str) -> Result<(), AuthError> {
    let trimmed = domain.trim();
    let normalized = trimmed.trim_start_matches('.');

    if normalized.is_empty() {
        return Err(AuthError::config(
            "Session cookie domain cannot be blank when configured",
        ));
    }

    if trimmed.contains('/') || trimmed.contains(';') || trimmed.contains(',') {
        return Err(AuthError::config(
            "Session cookie domain must be a bare host or parent domain like .example.com",
        ));
    }

    if trimmed.chars().any(char::is_whitespace) {
        return Err(AuthError::config(
            "Session cookie domain cannot contain whitespace",
        ));
    }

    if normalized.eq_ignore_ascii_case("localhost")
        || normalized.parse::<std::net::IpAddr>().is_ok()
    {
        return Err(AuthError::config(
            "Session cookie domain must be a parent DNS domain, not localhost or an IP address",
        ));
    }

    if !normalized.contains('.') {
        return Err(AuthError::config(
            "Session cookie domain must include a parent domain like .example.com",
        ));
    }

    Ok(())
}

fn derive_shared_parent_cookie_domain(
    base_url: &str,
    trusted_origins: &[String],
) -> Option<String> {
    let base_host = configured_host_without_port(base_url)?;
    let parent_domain = derived_parent_domain_candidate(base_host.as_str())?;
    let mut saw_sibling_subdomain = false;

    for origin in trusted_origins {
        let host = configured_host_without_port(origin)?;
        if host == base_host {
            continue;
        }
        if is_subdomain_of(host.as_str(), parent_domain.as_str()) {
            saw_sibling_subdomain = true;
            continue;
        }
        return None;
    }

    saw_sibling_subdomain.then(|| format!(".{parent_domain}"))
}

fn configured_host_without_port(value: &str) -> Option<String> {
    let normalized = normalize_configured_url(value)?;
    if normalized.contains('*') {
        return None;
    }
    let origin = extract_origin(normalized.as_str())?;
    let authority = origin.split_once("://")?.1;
    let host = extract_host_from_authority(authority)
        .trim()
        .trim_end_matches('.')
        .to_ascii_lowercase();
    if host.is_empty()
        || is_localhost_host(host.as_str())
        || host.parse::<std::net::IpAddr>().is_ok()
    {
        return None;
    }
    Some(host)
}

fn derived_parent_domain_candidate(host: &str) -> Option<String> {
    let (_, parent_domain) = host.split_once('.')?;
    if !parent_domain.contains('.') {
        return None;
    }
    Some(parent_domain.to_string())
}

fn is_subdomain_of(host: &str, parent_domain: &str) -> bool {
    host.len() > parent_domain.len()
        && host.ends_with(parent_domain)
        && host
            .as_bytes()
            .get(host.len().saturating_sub(parent_domain.len() + 1))
            == Some(&b'.')
}

/// Extract the origin (scheme + host + port) from a URL string.
///
/// For example, `"https://example.com/path"` → `"https://example.com"`.
///
/// This is used by [`AuthConfig::is_origin_trusted`] and the CSRF middleware
/// so that origin comparison is centralised in one place.
pub fn extract_origin(url: &str) -> Option<String> {
    let scheme_end: usize = url.find("://")?;
    let rest: &str = &url[scheme_end + 3..];
    let host_end: usize = rest.find(['/', '?', '#']).unwrap_or(rest.len());
    let origin: String = format!("{}{}", &url[..scheme_end + 3], &rest[..host_end]);
    Some(origin)
}

/// Normalize a configured URL by adding a default scheme when one is omitted.
///
/// The default is `https` for public hosts. Localhost-style hosts and explicit
/// cleartext hints such as `sslmode=disable` default to `http`.
pub fn normalize_configured_url(url: &str) -> Option<String> {
    let raw: &str = url.trim();
    if raw.is_empty() {
        return None;
    }

    if raw.contains("://") {
        return Some(raw.to_string());
    }

    let authority_end: usize = raw.find(['/', '?', '#']).unwrap_or(raw.len());
    let authority: &str = raw[..authority_end].trim();
    if authority.is_empty() {
        return None;
    }

    let scheme: &str = default_scheme_for_configured_url(authority, raw);
    Some(format!("{scheme}://{raw}"))
}

fn normalize_configured_base_url(url: &str) -> Option<String> {
    let normalized: String = normalize_configured_url(url)?;
    if normalized.find(['?', '#']).is_some() {
        extract_origin(&normalized)
    } else {
        Some(normalized)
    }
}

/// Normalize a configured origin pattern to `scheme://authority`.
///
/// This accepts exact origins and glob patterns, including scheme-less values
/// such as `*.example.com` or `*example.com/*`.
pub fn normalize_configured_origin_pattern(pattern: &str) -> Option<String> {
    normalize_configured_url(pattern).and_then(|normalized| extract_origin(&normalized))
}

fn default_scheme_for_configured_url(authority: &str, raw: &str) -> &'static str {
    let host: &str = extract_host_from_authority(authority);
    if is_localhost_host(host) || has_cleartext_scheme_hint(raw) {
        "http"
    } else {
        "https"
    }
}

fn extract_host_from_authority(authority: &str) -> &str {
    let without_user: &str = authority.rsplit('@').next().unwrap_or(authority);
    if without_user.starts_with('[')
        && let Some(end) = without_user.find(']')
    {
        return &without_user[1..end];
    }

    without_user
        .split_once(':')
        .map(|(host, _)| host)
        .unwrap_or(without_user)
}

fn is_localhost_host(host: &str) -> bool {
    matches!(
        host.to_ascii_lowercase().as_str(),
        "localhost" | "127.0.0.1" | "::1" | "0.0.0.0"
    )
}

fn has_cleartext_scheme_hint(raw: &str) -> bool {
    raw.split(['?', '&', '#'])
        .any(|part| part.eq_ignore_ascii_case("sslmode=disable"))
}

#[cfg(test)]
mod public_auth_url_tests {
    use super::{AuthConfig, extract_origin, normalize_configured_origin_pattern};

    #[test]
    fn joins_base_url_and_base_path() {
        let c: AuthConfig = AuthConfig::new("x".repeat(32))
            .base_url("https://api.example.com")
            .base_path("/api/auth");
        assert_eq!(
            c.public_auth_url("verify-email"),
            "https://api.example.com/api/auth/verify-email"
        );
        assert_eq!(
            c.public_auth_url("/change-email/verify"),
            "https://api.example.com/api/auth/change-email/verify"
        );
    }

    #[test]
    fn trims_slashes_on_base_url_and_handles_empty_mount() {
        let c: AuthConfig = AuthConfig::new("x".repeat(32))
            .base_url("https://api.example.com/")
            .base_path("/");
        assert_eq!(
            c.public_auth_url("verify-email"),
            "https://api.example.com/verify-email"
        );
    }

    #[test]
    fn base_path_without_leading_slash() {
        let c: AuthConfig = AuthConfig::new("x".repeat(32))
            .base_url("http://localhost:3000")
            .base_path("api/auth");
        assert_eq!(
            c.public_auth_url("verify-email"),
            "http://localhost:3000/api/auth/verify-email"
        );
    }

    #[test]
    fn public_base_path_overrides_internal_base_path_for_generated_urls() {
        let c: AuthConfig = AuthConfig::new("x".repeat(32))
            .base_url("https://app.example.com")
            .base_path("/")
            .public_base_path("/api/auth");
        assert_eq!(
            c.public_auth_url("verify-email"),
            "https://app.example.com/api/auth/verify-email"
        );
    }

    #[test]
    fn public_auth_url_oauth_callback_with_api_prefix() {
        let c: AuthConfig = AuthConfig::new("x".repeat(32))
            .base_url("https://xbp.app")
            .base_path("/")
            .public_base_path("/api");
        assert_eq!(
            c.public_auth_url("callback/github"),
            "https://xbp.app/api/callback/github"
        );
    }

    #[test]
    fn trusted_origin_defaults_https_for_non_local_authorities() {
        assert_eq!(
            normalize_configured_origin_pattern("*.athena-cluster.com"),
            Some("https://*.athena-cluster.com".to_string())
        );
        assert_eq!(
            normalize_configured_origin_pattern("*.v3.athena-cluster.com"),
            Some("https://*.v3.athena-cluster.com".to_string())
        );
        assert_eq!(
            normalize_configured_origin_pattern("*suits-formations.v2.athena-auth.com/*"),
            Some("https://*suits-formations.v2.athena-auth.com".to_string())
        );
    }

    #[test]
    fn trusted_origin_defaults_http_for_local_authorities() {
        assert_eq!(
            normalize_configured_origin_pattern("localhost:*"),
            Some("http://localhost:*".to_string())
        );
        assert_eq!(
            normalize_configured_origin_pattern("127.0.0.1:*"),
            Some("http://127.0.0.1:*".to_string())
        );
        assert_eq!(
            normalize_configured_origin_pattern(
                "suits-formations.v2.athena-auth.com?sslmode=disable"
            ),
            Some("http://suits-formations.v2.athena-auth.com".to_string())
        );
    }

    #[test]
    fn extract_origin_handles_query_and_fragment() {
        assert_eq!(
            extract_origin("https://example.com/path?q=1#frag"),
            Some("https://example.com".to_string())
        );
    }

    #[test]
    fn trusted_origin_glob_patterns_support_wildcard_ports_and_subdomains() {
        let config: AuthConfig = AuthConfig::new("x".repeat(32)).trusted_origins(vec![
            "https://*.athena-cluster.com".to_string(),
            "http://localhost:*".to_string(),
            "https://*.v3.athena-cluster.com".to_string(),
            "https://athena-auth-*-suits-finance.vercel.app".to_string(),
            "*suits-formations.v2.athena-auth.com/*".to_string(),
        ]);
        let localhost_40xx_only: AuthConfig = AuthConfig::new("x".repeat(32))
            .trusted_origins(vec!["http://localhost:40**".to_string()]);

        assert!(config.is_origin_trusted("https://auth.athena-cluster.com"));
        assert!(config.is_origin_trusted("https://foo.v3.athena-cluster.com"));
        assert!(config.is_origin_trusted("http://localhost:3001"));
        assert!(config.is_origin_trusted("https://athena-auth-git-main-suits-finance.vercel.app"));
        assert!(config.is_origin_trusted("https://athena-auth-git-sexy-suits-finance.vercel.app"));
        assert!(config.is_origin_trusted("https://athena-auth-git-evil-suits-finance.vercel.app"));
        assert!(config.is_origin_trusted("https://suits-formations.v2.athena-auth.com"));
        assert!(config.is_origin_trusted("https://preview-suits-formations.v2.athena-auth.com"));
        assert!(config.is_origin_trusted("http://localhost:4000"));
        assert!(config.is_origin_trusted("http://localhost:5000"));
        assert!(localhost_40xx_only.is_origin_trusted("http://localhost:40123"));
        assert!(!localhost_40xx_only.is_origin_trusted("http://localhost:5000"));
        assert!(!config.is_origin_trusted("http://suits-formations.v2.athena-auth.com"));
        assert!(!config.is_origin_trusted("http://evil.com"));
    }

    #[test]
    fn scheme_less_base_url_defaults_to_https_for_public_hosts() {
        let config: AuthConfig = AuthConfig::new("x".repeat(32))
            .base_url("suits-formations.v2.athena-auth.com")
            .base_path("/api/auth");

        assert_eq!(
            config.public_auth_url("verify-email"),
            "https://suits-formations.v2.athena-auth.com/api/auth/verify-email"
        );
        assert!(config.is_origin_trusted("https://suits-formations.v2.athena-auth.com"));
        assert!(!config.is_origin_trusted("http://suits-formations.v2.athena-auth.com"));
    }

    #[test]
    fn scheme_less_base_url_with_sslmode_disable_defaults_to_http() {
        let config: AuthConfig = AuthConfig::new("x".repeat(32))
            .base_url("suits-formations.v2.athena-auth.com?sslmode=disable")
            .base_path("/api/auth");

        assert_eq!(
            config.public_auth_url("verify-email"),
            "http://suits-formations.v2.athena-auth.com/api/auth/verify-email"
        );
        assert!(config.is_origin_trusted("http://suits-formations.v2.athena-auth.com"));
        assert!(!config.is_origin_trusted("https://suits-formations.v2.athena-auth.com"));
    }

    #[test]
    fn session_cookie_domain_is_exposed_for_shared_parent_domain_cookies() {
        let config = AuthConfig::new("x".repeat(32)).session_cookie_domain(".athena-auth.com");

        assert_eq!(
            config.effective_session_cookie_domain(),
            Some(".athena-auth.com".to_string())
        );
    }

    #[test]
    fn cross_sub_domain_cookie_builder_also_sets_session_cookie_domain() {
        let config = AuthConfig::new("x".repeat(32)).cross_sub_domain_cookies(".example.com");

        assert_eq!(
            config.effective_session_cookie_domain(),
            Some(".example.com".to_string())
        );
    }

    #[test]
    fn sibling_subdomains_auto_derive_shared_cookie_parent_domain() {
        let config = AuthConfig::new("x".repeat(32))
            .base_url("https://auth.example.co.uk")
            .trusted_origins(vec![
                "https://app.example.co.uk".to_string(),
                "https://admin.example.co.uk".to_string(),
            ]);

        assert_eq!(
            config.effective_session_cookie_domain(),
            Some(".example.co.uk".to_string())
        );
    }

    #[test]
    fn wildcard_or_unrelated_trusted_origins_do_not_auto_derive_cookie_parent_domain() {
        let wildcard = AuthConfig::new("x".repeat(32))
            .base_url("https://auth.example.com")
            .trusted_origins(vec!["https://*.example.com".to_string()]);
        assert_eq!(wildcard.effective_session_cookie_domain(), None);

        let unrelated = AuthConfig::new("x".repeat(32))
            .base_url("https://auth.example.com")
            .trusted_origins(vec!["https://app.other-example.com".to_string()]);
        assert_eq!(unrelated.effective_session_cookie_domain(), None);
    }

    #[test]
    fn session_cookie_domain_validation_rejects_localhost_and_host_prefix_mix() {
        let localhost = AuthConfig::new("x".repeat(32)).session_cookie_domain("localhost");
        assert!(
            localhost.validate().is_err(),
            "localhost should not be accepted as a shared parent cookie domain"
        );

        let mut host_prefix =
            AuthConfig::new("x".repeat(32)).session_cookie_domain(".athena-auth.com");
        host_prefix.session.cookie_name = "__Host-athena-auth.session-token".to_string();
        assert!(
            host_prefix.validate().is_err(),
            "__Host- cookies must reject shared parent-domain cookie configuration"
        );
    }
}
