//! Auth profile storage and OAuth helpers.
//!
//! Auth profiles persist provider credentials for runtime use. Some providers
//! are OAuth-backed and store refreshable token sets plus provider config;
//! others store direct provider tokens. Profiles are persisted to disk as JSON.

use base64::Engine as _;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
#[cfg(test)]
use std::cell::Cell;
use std::collections::HashMap;
use std::fmt;
use std::fmt::Write as _;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
#[cfg(test)]
use std::time::Instant;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use crate::config::secrets::{is_encrypted, SecretStore};

/// Maximum number of auth profiles that can be stored.
const MAX_PROFILES: usize = 20;
const LAST_USED_WRITE_INTERVAL_MS: u64 = 5 * 60 * 1000;

/// On-disk cap for the auth profile store JSON. With `MAX_PROFILES
/// = 20` and each profile carrying an encrypted refresh token plus
/// metadata, a realistic store stays under 256 KiB; 16 MiB is a
/// generous bound that still refuses the planted-multi-GB attack
/// vector at startup.
const AUTH_PROFILE_STORE_MAX_BYTES: u64 = 16 * 1024 * 1024;

/// Sanitize the configured OAuth redirect base URL before it is used to build
/// callback URLs or exposed through control surfaces.
pub(crate) fn sanitize_redirect_base_url(raw: &str) -> Result<String, &'static str> {
    let candidate = raw.trim();
    if candidate.is_empty() || candidate.chars().any(char::is_whitespace) {
        return Err("redirectBaseUrl must be a non-empty URL without whitespace");
    }

    let parsed =
        url::Url::parse(candidate).map_err(|_| "redirectBaseUrl must be an absolute URL")?;
    match parsed.scheme() {
        "http" | "https" => {}
        _ => return Err("redirectBaseUrl must use http or https"),
    }
    if parsed.host_str().is_none() {
        return Err("redirectBaseUrl must include a host");
    }
    if !parsed.username().is_empty() || parsed.password().is_some() {
        return Err("redirectBaseUrl must not include userinfo");
    }
    if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() {
        return Err("redirectBaseUrl must be an origin without path, query, or fragment");
    }

    Ok(parsed[..url::Position::BeforePath].to_string())
}

/// Sanitize a provider-specific OAuth redirect URI before it is passed to the
/// OAuth client. Unlike `redirectBaseUrl`, this may include a callback path.
pub(crate) fn sanitize_redirect_uri(raw: &str) -> Result<String, &'static str> {
    let candidate = raw.trim();
    if candidate.is_empty() || candidate.chars().any(char::is_whitespace) {
        return Err("redirectUri must be a non-empty URL without whitespace");
    }

    let parsed = url::Url::parse(candidate).map_err(|_| "redirectUri must be an absolute URL")?;
    match parsed.scheme() {
        "http" | "https" => {}
        _ => return Err("redirectUri must use http or https"),
    }
    if parsed.host_str().is_none() {
        return Err("redirectUri must include a host");
    }
    if !parsed.username().is_empty() || parsed.password().is_some() {
        return Err("redirectUri must not include userinfo");
    }
    if parsed.fragment().is_some() {
        return Err("redirectUri must not include a fragment");
    }

    Ok(parsed.to_string())
}

/// Shared HTTP client for all OAuth2 requests, with a 30-second
/// timeout. SECURITY (B138): build failure panics rather than
/// falling back to an unbounded `reqwest::Client::new()` — the
/// fallback strips the timeout, and a hostile/MITM token endpoint
/// would then hold the tokio task indefinitely. Per the outbound-
/// HTTP discipline in `.claude/rules/rust-patterns.md`, every
/// production client constructor must set a `.timeout(...)` and
/// the fallback must not silently downgrade.
static OAUTH_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
    reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .build()
        .expect("OAuth HTTP client build failed; check tls/network configuration at startup")
});

#[cfg(test)]
std::thread_local! {
    static FORCE_SECRET_ENCRYPTION_FAILURE: Cell<bool> = const { Cell::new(false) };
}

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

/// Errors that can occur during auth profile operations.
#[derive(Debug)]
pub enum AuthProfileError {
    ProviderNotConfigured(String),
    TokenExchangeFailed(String),
    TokenRefreshFailed(String),
    UserInfoFailed(String),
    SecretStorageError(String),
    CredentialTypeMismatch(String),
    ProfileNotFound,
    MaxProfilesExceeded,
    IoError(String),
    SerializationError(String),
    InvalidState,
    PkceError(String),
    DuplicateProfileId(String),
}

impl fmt::Display for AuthProfileError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::ProviderNotConfigured(p) => write!(f, "Provider not configured: {}", p),
            Self::TokenExchangeFailed(msg) => write!(f, "Token exchange failed: {}", msg),
            Self::TokenRefreshFailed(msg) => write!(f, "Token refresh failed: {}", msg),
            Self::UserInfoFailed(msg) => write!(f, "User info fetch failed: {}", msg),
            Self::SecretStorageError(msg) => write!(f, "Secret storage error: {}", msg),
            Self::CredentialTypeMismatch(msg) => write!(f, "Credential type mismatch: {}", msg),
            Self::ProfileNotFound => write!(f, "Profile not found"),
            Self::MaxProfilesExceeded => {
                write!(f, "Maximum number of profiles ({}) exceeded", MAX_PROFILES)
            }
            Self::IoError(msg) => write!(f, "I/O error: {}", msg),
            Self::SerializationError(msg) => write!(f, "Serialization error: {}", msg),
            Self::InvalidState => write!(f, "Invalid state parameter"),
            Self::PkceError(msg) => write!(f, "PKCE error: {}", msg),
            Self::DuplicateProfileId(id) => write!(f, "Duplicate profile ID: {}", id),
        }
    }
}

impl std::error::Error for AuthProfileError {}

// ---------------------------------------------------------------------------
// Store format versioning
// ---------------------------------------------------------------------------

/// Current store format version.
///
/// Version 2: `{ "version": 2, "profiles": [...] }` envelope. Supports
///   `OAuthProvider::Anthropic` and `AuthProfileCredentialKind::Token`.
const CURRENT_STORE_VERSION: u32 = 2;

#[derive(Serialize, Deserialize)]
struct AuthProfileStoreEnvelope {
    version: u32,
    profiles: Vec<Value>,
}

/// A profile that could not be deserialized but is preserved for round-trip
/// safety. The raw JSON value is kept so it can be written back without loss.
#[derive(Debug, Clone)]
struct SkippedProfile {
    raw: Value,
}

/// Result of loading the profile store: successfully parsed profiles plus
/// any that were skipped and preserved for round-trip.
struct LoadedProfiles {
    profiles: Vec<AuthProfile>,
    skipped: Vec<SkippedProfile>,
}

struct LastUsedPersistence {
    state: Mutex<LastUsedPersistenceState>,
    condvar: Condvar,
}

struct LastUsedPersistenceState {
    dirty_generation: u64,
    flushed_generation: u64,
    shutdown: bool,
    auto_flush_enabled: bool,
}

struct ProfileStoreShared {
    profiles: RwLock<Vec<AuthProfile>>,
    /// Profiles that could not be deserialized but are preserved for
    /// round-trip safety (for example profiles from a newer binary version).
    skipped_profiles: RwLock<Vec<SkippedProfile>>,
    state_path: PathBuf,
    secret_store: Option<Arc<SecretStore>>,
    /// Password bytes kept for `decrypt_rekey` (values on disk may have been
    /// encrypted with a different salt than the current `SecretStore`).
    encryption_password: Option<Arc<zeroize::Zeroizing<Vec<u8>>>>,
    /// Serializes all disk writes and load/save replacement so deferred
    /// `last_used_ms` flushes cannot race newer synchronous profile saves.
    persist_lock: Mutex<()>,
    last_used_persistence: LastUsedPersistence,
}

impl ProfileStoreShared {
    fn new(
        state_path: PathBuf,
        secret_store: Option<Arc<SecretStore>>,
        encryption_password: Option<Arc<zeroize::Zeroizing<Vec<u8>>>>,
    ) -> Self {
        Self {
            profiles: RwLock::new(Vec::new()),
            skipped_profiles: RwLock::new(Vec::new()),
            state_path,
            secret_store,
            encryption_password,
            persist_lock: Mutex::new(()),
            last_used_persistence: LastUsedPersistence::new(),
        }
    }
}

impl LastUsedPersistence {
    fn new() -> Self {
        Self {
            state: Mutex::new(LastUsedPersistenceState::new()),
            condvar: Condvar::new(),
        }
    }
}

impl LastUsedPersistenceState {
    fn new() -> Self {
        Self {
            dirty_generation: 0,
            flushed_generation: 0,
            shutdown: false,
            auto_flush_enabled: true,
        }
    }

    fn reset_generations(&mut self) {
        self.dirty_generation = 0;
        self.flushed_generation = 0;
    }
}

/// Parse a store file in the current envelope format.
fn parse_store_file(content: &str) -> Result<LoadedProfiles, AuthProfileError> {
    let raw_values = match serde_json::from_str::<Value>(content) {
        Ok(Value::Object(map)) => {
            let envelope: AuthProfileStoreEnvelope = serde_json::from_value(Value::Object(map))
                .map_err(|e| AuthProfileError::SerializationError(e.to_string()))?;
            if envelope.version > CURRENT_STORE_VERSION {
                return Err(AuthProfileError::SerializationError(format!(
                    "auth profile store version {} is newer than supported version {}; upgrade Carapace or restore from a backup",
                    envelope.version, CURRENT_STORE_VERSION
                )));
            }
            if envelope.version != CURRENT_STORE_VERSION {
                return Err(AuthProfileError::SerializationError(format!(
                    "auth profile store version {} is not supported; expected version {}",
                    envelope.version, CURRENT_STORE_VERSION
                )));
            }
            envelope.profiles
        }
        Ok(Value::Array(_)) => {
            return Err(AuthProfileError::SerializationError(
                "auth profile store uses the unsupported legacy bare-array format; delete the file and re-enroll auth profiles".to_string(),
            ));
        }
        Ok(_) => {
            return Err(AuthProfileError::SerializationError(
                "expected auth profile store envelope object at top level".to_string(),
            ));
        }
        Err(e) => return Err(AuthProfileError::SerializationError(e.to_string())),
    };

    let mut profiles = Vec::new();
    let mut skipped = Vec::new();
    for raw in raw_values {
        match serde_json::from_value::<AuthProfile>(raw.clone()) {
            Ok(profile) => profiles.push(profile),
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    "skipping unrecognized auth profile during load (preserved for round-trip)"
                );
                skipped.push(SkippedProfile { raw });
            }
        }
    }

    Ok(LoadedProfiles { profiles, skipped })
}

/// Serialize profiles into the versioned envelope format.
fn serialize_store(
    profiles: &[AuthProfile],
    skipped: &[SkippedProfile],
) -> Result<String, AuthProfileError> {
    let mut raw_values: Vec<Value> = profiles
        .iter()
        .map(serde_json::to_value)
        .collect::<Result<Vec<_>, _>>()
        .map_err(|e| AuthProfileError::SerializationError(e.to_string()))?;
    for skipped_profile in skipped {
        raw_values.push(skipped_profile.raw.clone());
    }

    let envelope = AuthProfileStoreEnvelope {
        version: CURRENT_STORE_VERSION,
        profiles: raw_values,
    };

    serde_json::to_string_pretty(&envelope)
        .map_err(|e| AuthProfileError::SerializationError(e.to_string()))
}

// ---------------------------------------------------------------------------
// Data structures
// ---------------------------------------------------------------------------

/// Supported auth-profile providers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OAuthProvider {
    Anthropic,
    Google,
    GitHub,
    Discord,
    OpenAI,
}

impl fmt::Display for OAuthProvider {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Anthropic => write!(f, "anthropic"),
            Self::Google => write!(f, "google"),
            Self::GitHub => write!(f, "github"),
            Self::Discord => write!(f, "discord"),
            Self::OpenAI => write!(f, "openai"),
        }
    }
}

/// Stored credential kind for an auth profile.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AuthProfileCredentialKind {
    #[default]
    OAuth,
    Token,
}

impl fmt::Display for AuthProfileCredentialKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::OAuth => write!(f, "oauth"),
            Self::Token => write!(f, "token"),
        }
    }
}

/// OAuth2 token set for a profile.
#[derive(Clone, Serialize, Deserialize)]
pub struct OAuthTokens {
    pub access_token: String,
    pub refresh_token: Option<String>,
    pub token_type: String,
    pub expires_at_ms: Option<u64>,
    pub scope: Option<String>,
}

/// Provider-specific OAuth configuration retained for runtime refresh.
///
/// This stores the provider metadata needed to refresh an OAuth-backed runtime
/// session without relying on the application config as a secret transport.
#[derive(Clone, Serialize, Deserialize)]
pub struct StoredOAuthProviderConfig {
    pub client_id: String,
    pub client_secret: String,
    #[serde(default)]
    pub redirect_uri: String,
    pub auth_url: String,
    pub token_url: String,
    pub userinfo_url: String,
    pub scopes: Vec<String>,
}

impl StoredOAuthProviderConfig {
    pub fn to_provider_config(&self, redirect_uri: String) -> OAuthProviderConfig {
        OAuthProviderConfig {
            client_id: self.client_id.clone(),
            client_secret: self.client_secret.clone(),
            redirect_uri,
            auth_url: self.auth_url.clone(),
            token_url: self.token_url.clone(),
            userinfo_url: self.userinfo_url.clone(),
            scopes: self.scopes.clone(),
        }
    }
}

impl From<&OAuthProviderConfig> for StoredOAuthProviderConfig {
    fn from(value: &OAuthProviderConfig) -> Self {
        Self {
            client_id: value.client_id.clone(),
            client_secret: value.client_secret.clone(),
            redirect_uri: value.redirect_uri.clone(),
            auth_url: value.auth_url.clone(),
            token_url: value.token_url.clone(),
            userinfo_url: value.userinfo_url.clone(),
            scopes: value.scopes.clone(),
        }
    }
}

/// A stored auth profile.
#[derive(Clone, Serialize, Deserialize)]
pub struct AuthProfile {
    pub id: String,
    pub name: String,
    pub provider: OAuthProvider,
    pub user_id: Option<String>,
    pub email: Option<String>,
    pub display_name: Option<String>,
    pub avatar_url: Option<String>,
    pub created_at_ms: u64,
    pub last_used_ms: Option<u64>,
    #[serde(default)]
    pub credential_kind: AuthProfileCredentialKind,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tokens: Option<OAuthTokens>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub token: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub oauth_provider_config: Option<StoredOAuthProviderConfig>,
}

/// Profile summary (no tokens exposed).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthProfileSummary {
    pub id: String,
    pub name: String,
    pub provider: OAuthProvider,
    pub credential_kind: AuthProfileCredentialKind,
    pub email: Option<String>,
    pub display_name: Option<String>,
    pub avatar_url: Option<String>,
    pub created_at_ms: u64,
    pub last_used_ms: Option<u64>,
    pub token_valid: bool,
}

impl AuthProfile {
    /// Convert to a summary that does not contain any token values.
    pub fn to_summary(&self) -> AuthProfileSummary {
        let now_ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0);

        let token_valid = self.token_valid_at(now_ms);

        AuthProfileSummary {
            id: self.id.clone(),
            name: self.name.clone(),
            provider: self.provider,
            credential_kind: self.credential_kind,
            email: self.email.clone(),
            display_name: self.display_name.clone(),
            avatar_url: self.avatar_url.clone(),
            created_at_ms: self.created_at_ms,
            last_used_ms: self.last_used_ms,
            token_valid,
        }
    }

    pub fn token_valid(&self) -> bool {
        let now_ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0);
        self.token_valid_at(now_ms)
    }

    fn token_valid_at(&self, now_ms: u64) -> bool {
        match self.credential_kind {
            AuthProfileCredentialKind::OAuth => self
                .tokens
                .as_ref()
                .filter(|tokens| !tokens.access_token.trim().is_empty())
                .map(|tokens| match tokens.expires_at_ms {
                    Some(expires) => now_ms < expires,
                    None => true,
                })
                .unwrap_or(false),
            AuthProfileCredentialKind::Token => self
                .token
                .as_ref()
                .map(|value| {
                    let trimmed = value.trim();
                    !trimmed.is_empty() && !is_encrypted(trimmed)
                })
                .unwrap_or(false),
        }
    }

    pub fn oauth_tokens(&self) -> Option<&OAuthTokens> {
        match self.credential_kind {
            AuthProfileCredentialKind::OAuth => self.tokens.as_ref(),
            AuthProfileCredentialKind::Token => None,
        }
    }

    pub fn oauth_tokens_mut(&mut self) -> Option<&mut OAuthTokens> {
        match self.credential_kind {
            AuthProfileCredentialKind::OAuth => self.tokens.as_mut(),
            AuthProfileCredentialKind::Token => None,
        }
    }

    pub fn provider_token(&self) -> Option<&str> {
        match self.credential_kind {
            AuthProfileCredentialKind::OAuth => None,
            AuthProfileCredentialKind::Token => self
                .token
                .as_deref()
                .map(str::trim)
                .filter(|value| !value.is_empty() && !is_encrypted(value)),
        }
    }
}

/// User information returned from a provider's userinfo endpoint.
#[derive(Debug, Clone)]
pub struct UserInfo {
    pub user_id: String,
    pub email: Option<String>,
    pub display_name: Option<String>,
    pub avatar_url: Option<String>,
}

/// Provider-specific OAuth2 configuration.
#[derive(Clone)]
pub struct OAuthProviderConfig {
    pub client_id: String,
    pub client_secret: String,
    pub redirect_uri: String,
    pub auth_url: String,
    pub token_url: String,
    pub userinfo_url: String,
    pub scopes: Vec<String>,
}

impl fmt::Debug for OAuthProviderConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("OAuthProviderConfig")
            .field("client_id", &"<redacted>")
            .field("client_secret", &"<redacted>")
            .field("redirect_uri", &self.redirect_uri)
            .field("auth_url", &self.auth_url)
            .field("token_url", &self.token_url)
            .field("userinfo_url", &self.userinfo_url)
            .field("scopes", &self.scopes)
            .finish()
    }
}

// ---------------------------------------------------------------------------
// Provider configurations
// ---------------------------------------------------------------------------

impl OAuthProvider {
    /// Build a provider config with default endpoints for this provider.
    ///
    /// Returns `None` for providers that do not use OAuth (e.g., Anthropic
    /// uses direct token-backed auth profiles instead of OAuth flows).
    pub fn default_config(
        &self,
        client_id: &str,
        client_secret: &str,
        redirect_uri: &str,
    ) -> Option<OAuthProviderConfig> {
        match self {
            OAuthProvider::Anthropic => None,
            OAuthProvider::Google => Some(OAuthProviderConfig {
                client_id: client_id.to_string(),
                client_secret: client_secret.to_string(),
                redirect_uri: redirect_uri.to_string(),
                auth_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
                token_url: "https://oauth2.googleapis.com/token".to_string(),
                userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo".to_string(),
                scopes: vec![
                    "openid".to_string(),
                    "email".to_string(),
                    "profile".to_string(),
                ],
            }),
            OAuthProvider::GitHub => Some(OAuthProviderConfig {
                client_id: client_id.to_string(),
                client_secret: client_secret.to_string(),
                redirect_uri: redirect_uri.to_string(),
                auth_url: "https://github.com/login/oauth/authorize".to_string(),
                token_url: "https://github.com/login/oauth/access_token".to_string(),
                userinfo_url: "https://api.github.com/user".to_string(),
                scopes: vec!["read:user".to_string(), "user:email".to_string()],
            }),
            OAuthProvider::Discord => Some(OAuthProviderConfig {
                client_id: client_id.to_string(),
                client_secret: client_secret.to_string(),
                redirect_uri: redirect_uri.to_string(),
                auth_url: "https://discord.com/api/oauth2/authorize".to_string(),
                token_url: "https://discord.com/api/oauth2/token".to_string(),
                userinfo_url: "https://discord.com/api/users/@me".to_string(),
                scopes: vec!["identify".to_string(), "email".to_string()],
            }),
            OAuthProvider::OpenAI => Some(OAuthProviderConfig {
                client_id: client_id.to_string(),
                client_secret: client_secret.to_string(),
                redirect_uri: redirect_uri.to_string(),
                auth_url: "https://auth.openai.com/oauth/authorize".to_string(),
                token_url: "https://auth.openai.com/oauth/token".to_string(),
                userinfo_url: "https://api.openai.com/v1/me".to_string(),
                scopes: vec![
                    "openid".to_string(),
                    "profile".to_string(),
                    "email".to_string(),
                    "offline_access".to_string(),
                ],
            }),
        }
    }
}

// ---------------------------------------------------------------------------
// PKCE helpers
// ---------------------------------------------------------------------------

/// Generate a cryptographically random PKCE code verifier (43-128 chars, URL-safe).
fn generate_code_verifier() -> Result<String, AuthProfileError> {
    // 32 random bytes -> 43 base64url chars (without padding)
    let mut buf = [0u8; 32];
    getrandom::fill(&mut buf)
        .map_err(|e| AuthProfileError::PkceError(format!("RNG failed: {}", e)))?;
    Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf))
}

/// Compute the PKCE code challenge = base64url(SHA256(code_verifier)).
fn compute_code_challenge(verifier: &str) -> String {
    let digest = Sha256::digest(verifier.as_bytes());
    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
}

// ---------------------------------------------------------------------------
// OAuth2 flow functions
// ---------------------------------------------------------------------------

/// Generate the OAuth2 authorization URL with PKCE and state parameter.
///
/// Returns `(authorization_url, code_verifier)`.
pub fn generate_auth_url(
    provider_config: &OAuthProviderConfig,
    state: &str,
) -> Result<(String, String), AuthProfileError> {
    let code_verifier = generate_code_verifier()?;
    let code_challenge = compute_code_challenge(&code_verifier);

    let scope = provider_config.scopes.join(" ");

    let params = [
        ("response_type", "code"),
        ("client_id", &provider_config.client_id),
        ("redirect_uri", &provider_config.redirect_uri),
        ("scope", &scope),
        ("state", state),
        ("code_challenge", &code_challenge),
        ("code_challenge_method", "S256"),
    ];

    let query = params
        .iter()
        .map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v)))
        .collect::<Vec<_>>()
        .join("&");

    let url = format!("{}?{}", provider_config.auth_url, query);
    Ok((url, code_verifier))
}

/// Minimal percent-encoding for URL query parameters.
fn url_encode(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                result.push(b as char);
            }
            _ => {
                result.push('%');
                result.push_str(&format!("{:02X}", b));
            }
        }
    }
    result
}

/// Exchange an authorization code for tokens.
pub async fn exchange_code(
    provider_config: &OAuthProviderConfig,
    code: &str,
    code_verifier: &str,
) -> Result<OAuthTokens, AuthProfileError> {
    let params = [
        ("grant_type", "authorization_code"),
        ("code", code),
        ("redirect_uri", &provider_config.redirect_uri),
        ("client_id", &provider_config.client_id),
        ("client_secret", &provider_config.client_secret),
        ("code_verifier", code_verifier),
    ];

    let resp = OAUTH_CLIENT
        .post(&provider_config.token_url)
        .header("Accept", "application/json")
        .form(&params)
        .send()
        .await
        .map_err(|e| AuthProfileError::TokenExchangeFailed(e.without_url().to_string()))?;

    if !resp.status().is_success() {
        let status = resp.status();
        let body = crate::net_util::read_response_body_text_capped(
            resp,
            crate::net_util::MAX_RESPONSE_BODY_BYTES,
        )
        .await
        .unwrap_or_else(|_| "<unreadable>".to_string());
        return Err(AuthProfileError::TokenExchangeFailed(
            format_oauth_token_error(status, &body),
        ));
    }

    let body_text = crate::net_util::read_response_body_text_capped(
        resp,
        crate::net_util::MAX_RESPONSE_BODY_BYTES,
    )
    .await
    .map_err(|e| AuthProfileError::TokenExchangeFailed(e.to_string()))?;
    let body: Value = serde_json::from_str(&body_text)
        .map_err(|e| AuthProfileError::TokenExchangeFailed(e.to_string()))?;

    parse_token_response(&body)
}

/// Refresh an expired access token.
pub async fn refresh_token(
    provider_config: &OAuthProviderConfig,
    refresh_tok: &str,
) -> Result<OAuthTokens, AuthProfileError> {
    let params = [
        ("grant_type", "refresh_token"),
        ("refresh_token", refresh_tok),
        ("client_id", &provider_config.client_id),
        ("client_secret", &provider_config.client_secret),
    ];

    let resp = OAUTH_CLIENT
        .post(&provider_config.token_url)
        .header("Accept", "application/json")
        .form(&params)
        .send()
        .await
        .map_err(|e| AuthProfileError::TokenRefreshFailed(e.without_url().to_string()))?;

    if !resp.status().is_success() {
        let status = resp.status();
        let body = crate::net_util::read_response_body_text_capped(
            resp,
            crate::net_util::MAX_RESPONSE_BODY_BYTES,
        )
        .await
        .unwrap_or_else(|_| "<unreadable>".to_string());
        return Err(AuthProfileError::TokenRefreshFailed(
            format_oauth_token_error(status, &body),
        ));
    }

    let body_text = crate::net_util::read_response_body_text_capped(
        resp,
        crate::net_util::MAX_RESPONSE_BODY_BYTES,
    )
    .await
    .map_err(|e| AuthProfileError::TokenRefreshFailed(e.to_string()))?;
    let body: Value = serde_json::from_str(&body_text)
        .map_err(|e| AuthProfileError::TokenRefreshFailed(e.to_string()))?;

    let mut tokens = parse_token_response(&body)?;

    // Preserve the original refresh token if the response did not include one
    if tokens.refresh_token.is_none() {
        tokens.refresh_token = Some(refresh_tok.to_string());
    }

    Ok(tokens)
}

fn format_oauth_token_error(status: reqwest::StatusCode, body: &str) -> String {
    let trimmed = body.trim();
    if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
        let error = parsed.get("error").and_then(Value::as_str);
        let description = parsed.get("error_description").and_then(Value::as_str);
        let error_uri = parsed.get("error_uri").and_then(Value::as_str);
        let summary = match (error, description, error_uri) {
            (Some(error), Some(description), Some(error_uri)) => {
                format!("{error}: {description} ({error_uri})")
            }
            (Some(error), Some(description), None) => format!("{error}: {description}"),
            (Some(error), None, Some(error_uri)) => format!("{error} ({error_uri})"),
            (Some(error), None, None) => error.to_string(),
            (None, Some(description), _) => description.to_string(),
            _ => String::new(),
        };
        if !summary.is_empty() {
            return format!("HTTP {status}: {summary}");
        }
    }

    if trimmed.is_empty() {
        format!("HTTP {status}")
    } else {
        format!("HTTP {status}: {trimmed}")
    }
}

/// Parse an OAuth2 token JSON response into `OAuthTokens`.
fn parse_token_response(body: &Value) -> Result<OAuthTokens, AuthProfileError> {
    let access_token = body
        .get("access_token")
        .and_then(|v| v.as_str())
        .ok_or_else(|| {
            AuthProfileError::TokenExchangeFailed("Missing access_token in response".to_string())
        })?
        .to_string();

    let refresh_token = body
        .get("refresh_token")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let token_type = body
        .get("token_type")
        .and_then(|v| v.as_str())
        .unwrap_or("Bearer")
        .to_string();

    let scope = body
        .get("scope")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let expires_at_ms = body
        .get("expires_in")
        .and_then(|v| v.as_u64())
        .map(compute_expires_at_ms);

    Ok(OAuthTokens {
        access_token,
        refresh_token,
        token_type,
        expires_at_ms,
        scope,
    })
}

/// Compute `expires_at_ms` from an `expires_in` value in seconds.
///
/// This is a public helper so callers can manually compute expiry timestamps.
///
/// SECURITY (round-9 deserialization MEDIUM): IdP responses are
/// untrusted (MITM-able TLS, IdP compromise, downstream cache
/// poisoning). A response of `"expires_in": 18446744073709551 `
/// (≈ `u64::MAX / 1000`) would wrap `now_ms + secs * 1000` past
/// `u64::MAX` and land `expires_at_ms` near zero — but also values
/// in a particular range wrap to a value *greater* than `now_ms`,
/// which `token_valid_at` would then treat as "valid forever",
/// defeating the rotation contract. Clamp to a year via
/// `MAX_REASONABLE_EXPIRES_SECS` and use `saturating_mul` /
/// `checked_add` so neither the multiplication nor the addition
/// can silently wrap.
pub fn compute_expires_at_ms(expires_in_secs: u64) -> u64 {
    const MAX_REASONABLE_EXPIRES_SECS: u64 = 365 * 24 * 60 * 60; // 1 year
    let secs = expires_in_secs.min(MAX_REASONABLE_EXPIRES_SECS);
    let now_ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);
    now_ms
        .checked_add(secs.saturating_mul(1000))
        .unwrap_or(now_ms)
}

/// Fetch user info from the provider's userinfo endpoint.
pub async fn fetch_user_info(
    provider: OAuthProvider,
    provider_config: &OAuthProviderConfig,
    access_token: &str,
) -> Result<UserInfo, AuthProfileError> {
    if provider == OAuthProvider::OpenAI {
        return user_info_from_openai_token(access_token);
    }

    let mut req = OAUTH_CLIENT
        .get(&provider_config.userinfo_url)
        .bearer_auth(access_token);

    // GitHub requires an Accept header for JSON
    if provider == OAuthProvider::GitHub {
        req = req.header("Accept", "application/vnd.github+json");
        req = req.header("User-Agent", "carapace-gateway");
    }

    let resp = req
        .send()
        .await
        .map_err(|e| AuthProfileError::UserInfoFailed(e.without_url().to_string()))?;

    if !resp.status().is_success() {
        let status = resp.status();
        let body = crate::net_util::read_response_body_text_capped(
            resp,
            crate::net_util::MAX_RESPONSE_BODY_BYTES,
        )
        .await
        .unwrap_or_else(|_| "<unreadable>".to_string());
        return Err(AuthProfileError::UserInfoFailed(format!(
            "HTTP {}: {}",
            status, body
        )));
    }

    let body_text = crate::net_util::read_response_body_text_capped(
        resp,
        crate::net_util::MAX_RESPONSE_BODY_BYTES,
    )
    .await
    .map_err(|e| AuthProfileError::UserInfoFailed(e.to_string()))?;
    let body: Value = serde_json::from_str(&body_text)
        .map_err(|e| AuthProfileError::UserInfoFailed(e.to_string()))?;

    match provider {
        OAuthProvider::Anthropic => Err(AuthProfileError::UserInfoFailed(
            "Anthropic setup-token profiles do not support OAuth user-info fetch".to_string(),
        )),
        OAuthProvider::Google => Ok(UserInfo {
            user_id: body
                .get("sub")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            email: body
                .get("email")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string()),
            display_name: body
                .get("name")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string()),
            avatar_url: body
                .get("picture")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string()),
        }),
        OAuthProvider::GitHub => Ok(UserInfo {
            user_id: body
                .get("id")
                .and_then(|v| v.as_u64())
                .map(|id| id.to_string())
                .unwrap_or_default(),
            email: body
                .get("email")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string()),
            display_name: body
                .get("name")
                .and_then(|v| v.as_str())
                .or_else(|| body.get("login").and_then(|v| v.as_str()))
                .map(|s| s.to_string()),
            avatar_url: body
                .get("avatar_url")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string()),
        }),
        OAuthProvider::Discord => Ok(UserInfo {
            user_id: body
                .get("id")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            email: body
                .get("email")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string()),
            display_name: body
                .get("global_name")
                .and_then(|v| v.as_str())
                .or_else(|| body.get("username").and_then(|v| v.as_str()))
                .map(|s| s.to_string()),
            avatar_url: body.get("avatar").and_then(|v| v.as_str()).map(|hash| {
                let user_id = body.get("id").and_then(|v| v.as_str()).unwrap_or("0");
                format!(
                    "https://cdn.discordapp.com/avatars/{}/{}.png",
                    user_id, hash
                )
            }),
        }),
        OAuthProvider::OpenAI => unreachable!("OpenAI user info is decoded from token claims"),
    }
}

fn user_info_from_openai_token(access_token: &str) -> Result<UserInfo, AuthProfileError> {
    let payload = decode_jwt_payload(access_token).ok_or_else(|| {
        AuthProfileError::UserInfoFailed("failed to decode OpenAI access token claims".to_string())
    })?;
    let auth_claim = payload
        .get("https://api.openai.com/auth")
        .and_then(Value::as_object);
    let profile_claim = payload
        .get("https://api.openai.com/profile")
        .and_then(Value::as_object);

    // OpenAI subscription tokens do not have a single stable public claim
    // contract for user identity. We check the claim names observed in current
    // Codex/OpenAI tokens and fall back to `sub`; if this starts failing, treat
    // it as integration drift and revisit the provider contract before adding
    // more heuristics here.
    let user_id = auth_claim
        .and_then(|claim| {
            claim
                .get("user_id")
                .and_then(Value::as_str)
                .or_else(|| claim.get("chatgpt_user_id").and_then(Value::as_str))
                .or_else(|| claim.get("chatgpt_account_user_id").and_then(Value::as_str))
        })
        .or_else(|| payload.get("sub").and_then(Value::as_str))
        .unwrap_or_default()
        .to_string();
    if user_id.trim().is_empty() {
        return Err(AuthProfileError::UserInfoFailed(
            "OpenAI access token did not contain a usable user id".to_string(),
        ));
    }

    let email = profile_claim
        .and_then(|claim| claim.get("email").and_then(Value::as_str))
        .or_else(|| payload.get("email").and_then(Value::as_str))
        .map(|s| s.to_string());
    let display_name = email.clone().or_else(|| {
        auth_claim
            .and_then(|claim| claim.get("chatgpt_plan_type").and_then(Value::as_str))
            .map(|plan| format!("OpenAI {plan}"))
    });

    Ok(UserInfo {
        user_id,
        email,
        display_name,
        avatar_url: None,
    })
}

fn decode_jwt_payload(token: &str) -> Option<Value> {
    // This is only used to extract profile metadata from an access token
    // returned directly by the provider's token endpoint over TLS. It is not a
    // general-purpose JWT verifier and intentionally does not validate
    // signatures or claims for authorization decisions.
    let payload = token.split('.').nth(1)?;
    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(payload.as_bytes())
        .ok()?;
    serde_json::from_slice(&bytes).ok()
}

// ---------------------------------------------------------------------------
// ProfileStore
// ---------------------------------------------------------------------------

/// Persistent store for auth profiles.
///
/// Profiles are stored in `{state_dir}/auth_profiles.json` using a versioned
/// envelope format: `{ "version": 2, "profiles": [...] }`.
/// Uses `parking_lot::RwLock` for synchronous interior mutability.
///
/// When a `SecretStore` is provided, sensitive token fields (`access_token`,
/// `refresh_token`) are encrypted at rest using AES-256-GCM.
pub struct ProfileStore {
    shared: Arc<ProfileStoreShared>,
    last_used_worker: Mutex<Option<std::thread::JoinHandle<()>>>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct PlaintextAuthProfileField {
    profile_id: String,
    field_name: &'static str,
}

pub(crate) fn profile_store_state_path(state_dir: &std::path::Path) -> PathBuf {
    state_dir.join("auth_profiles.json")
}

impl ProfileStore {
    /// Open the profile store for the given state directory.
    ///
    /// If `CARAPACE_CONFIG_PASSWORD` is set, token fields are encrypted at
    /// rest using the same password-derived keying material used for config
    /// secret sealing. Otherwise the store falls back to plaintext storage.
    pub fn from_env(state_dir: PathBuf) -> Result<Self, AuthProfileError> {
        match crate::config::read_process_env_zeroizing("CARAPACE_CONFIG_PASSWORD") {
            Some(password) if !password.trim().is_empty() => {
                Self::with_encryption(state_dir, password.as_bytes())
            }
            _ => Ok(Self::new(state_dir)),
        }
    }

    /// Create a new profile store that persists to `{state_dir}/auth_profiles.json`.
    ///
    /// Token fields are stored as plaintext.  Use [`with_encryption`](Self::with_encryption)
    /// to enable at-rest encryption.
    pub fn new(state_dir: PathBuf) -> Self {
        let state_path = profile_store_state_path(&state_dir);
        let shared = Arc::new(ProfileStoreShared::new(state_path, None, None));
        Self {
            last_used_worker: Mutex::new(Self::start_last_used_worker(&shared)),
            shared,
        }
    }

    /// Create a new profile store with at-rest encryption for token fields.
    ///
    /// When a `SecretStore` is provided, `access_token` and `refresh_token`
    /// values are encrypted before being written to disk and decrypted when
    /// loaded.
    ///
    /// The `password` is retained so that values encrypted with a different
    /// salt (e.g. from a previous run) can still be decrypted via key
    /// re-derivation.
    pub fn with_encryption(state_dir: PathBuf, password: &[u8]) -> Result<Self, AuthProfileError> {
        let state_path = profile_store_state_path(&state_dir);
        let secret_store = SecretStore::new(password)
            .map_err(|e| AuthProfileError::IoError(format!("encryption init failed: {e}")))?;
        let shared = Arc::new(ProfileStoreShared::new(
            state_path,
            Some(Arc::new(secret_store)),
            Some(Arc::new(zeroize::Zeroizing::new(password.to_vec()))),
        ));
        Ok(Self {
            last_used_worker: Mutex::new(Self::start_last_used_worker(&shared)),
            shared,
        })
    }

    fn lock_persist(&self) -> MutexGuard<'_, ()> {
        self.shared
            .persist_lock
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
    }

    fn lock_last_used_state(&self) -> MutexGuard<'_, LastUsedPersistenceState> {
        self.shared
            .last_used_persistence
            .state
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
    }

    fn start_last_used_worker(
        shared: &Arc<ProfileStoreShared>,
    ) -> Option<std::thread::JoinHandle<()>> {
        let worker_shared = Arc::clone(shared);
        match std::thread::Builder::new()
            .name("auth-profile-last-used".to_string())
            .spawn(move || Self::last_used_worker_loop(worker_shared))
        {
            Ok(handle) => Some(handle),
            Err(err) => {
                tracing::warn!(
                    error = %err,
                    "failed to start auth profile last_used persistence worker; pending timestamps will flush on synchronous saves or shutdown"
                );
                None
            }
        }
    }

    fn last_used_worker_loop(shared: Arc<ProfileStoreShared>) {
        while let Some(shutdown_flush) = Self::next_last_used_work(&shared) {
            if let Err(err) = Self::flush_pending_last_used_once_shared(&shared) {
                if shutdown_flush {
                    tracing::warn!(
                        error = %err,
                        "failed to flush auth profile last_used metadata during shutdown"
                    );
                    return;
                }
                tracing::warn!(
                    error = %err,
                    "failed to persist auth profile last_used metadata"
                );
                std::thread::sleep(Duration::from_millis(250));
            }
            if shutdown_flush {
                return;
            }
        }
    }

    fn next_last_used_work(shared: &ProfileStoreShared) -> Option<bool> {
        let mut state = shared
            .last_used_persistence
            .state
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        loop {
            if state.shutdown {
                return (state.dirty_generation > state.flushed_generation).then_some(true);
            }
            if state.auto_flush_enabled && state.dirty_generation > state.flushed_generation {
                return Some(false);
            }
            state = shared
                .last_used_persistence
                .condvar
                .wait(state)
                .unwrap_or_else(|poisoned| poisoned.into_inner());
        }
    }

    fn capture_dirty_generation(&self) -> u64 {
        self.lock_last_used_state().dirty_generation
    }

    fn mark_last_used_persisted(&self, generation: u64) {
        let mut state = self.lock_last_used_state();
        state.flushed_generation = state.flushed_generation.max(generation);
        self.shared.last_used_persistence.condvar.notify_all();
    }

    fn note_last_used_dirty(&self) {
        let mut state = self.lock_last_used_state();
        state.dirty_generation = state.dirty_generation.saturating_add(1);
    }

    fn notify_last_used_dirty(&self) {
        self.shared.last_used_persistence.condvar.notify_one();
    }

    fn flush_pending_last_used_once_shared(
        shared: &ProfileStoreShared,
    ) -> Result<bool, AuthProfileError> {
        {
            let state = shared
                .last_used_persistence
                .state
                .lock()
                .unwrap_or_else(|poisoned| poisoned.into_inner());
            if state.dirty_generation <= state.flushed_generation {
                return Ok(false);
            }
        }

        let _persist_guard = shared
            .persist_lock
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        {
            let state = shared
                .last_used_persistence
                .state
                .lock()
                .unwrap_or_else(|poisoned| poisoned.into_inner());
            if state.dirty_generation <= state.flushed_generation {
                return Ok(false);
            }
        }
        let (profiles_to_save, target_generation) = {
            let profiles = shared.profiles.read();
            let state = shared
                .last_used_persistence
                .state
                .lock()
                .unwrap_or_else(|poisoned| poisoned.into_inner());
            // Capture both the saved snapshot and dirty generation under the
            // same profile lock so a concurrent `update_last_used()` cannot
            // advance the generation past what was actually written.
            if state.dirty_generation <= state.flushed_generation {
                return Ok(false);
            }
            (profiles.to_vec(), state.dirty_generation)
        };
        Self::save_profiles_shared(shared, &profiles_to_save)?;
        let mut state = shared
            .last_used_persistence
            .state
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        state.flushed_generation = state.flushed_generation.max(target_generation);
        shared.last_used_persistence.condvar.notify_all();
        Ok(true)
    }

    fn flush_pending_last_used_once(&self) -> Result<bool, AuthProfileError> {
        Self::flush_pending_last_used_once_shared(self.shared.as_ref())
    }

    #[cfg(test)]
    fn pause_last_used_auto_flush_for_tests(&self) {
        let mut state = self.lock_last_used_state();
        state.auto_flush_enabled = false;
        self.shared.last_used_persistence.condvar.notify_all();
    }

    #[cfg(test)]
    fn flush_pending_last_used_for_tests(&self) -> Result<bool, AuthProfileError> {
        self.flush_pending_last_used_once()
    }

    #[cfg(test)]
    fn wait_for_last_used_flush_for_tests(&self) -> Result<(), &'static str> {
        let timeout = Duration::from_secs(5);
        let deadline = Instant::now() + timeout;
        let mut state = self.lock_last_used_state();
        while state.dirty_generation > state.flushed_generation {
            let remaining = deadline.saturating_duration_since(Instant::now());
            if remaining.is_zero() {
                return Err("timed out waiting for background last_used flush");
            }
            let (next_state, wait_result) = self
                .shared
                .last_used_persistence
                .condvar
                .wait_timeout(state, remaining)
                .unwrap_or_else(|poisoned| poisoned.into_inner());
            state = next_state;
            if wait_result.timed_out() && state.dirty_generation > state.flushed_generation {
                return Err("timed out waiting for background last_used flush");
            }
        }
        Ok(())
    }

    fn shutdown_last_used_worker(&self) {
        {
            let mut state = self.lock_last_used_state();
            state.shutdown = true;
            self.shared.last_used_persistence.condvar.notify_all();
        }

        let handle = self
            .last_used_worker
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .take();
        if let Some(handle) = handle {
            if handle.join().is_err() {
                tracing::warn!(
                    "auth profile last_used persistence worker panicked during shutdown"
                );
                if let Err(err) = self.flush_pending_last_used_once() {
                    tracing::warn!(
                        error = %err,
                        "failed to flush auth profile last_used metadata after worker panic"
                    );
                }
            }
        } else if let Err(err) = self.flush_pending_last_used_once() {
            tracing::warn!(
                error = %err,
                "failed to flush auth profile last_used metadata during shutdown"
            );
        }
    }

    /// Load profiles from disk. Replaces any in-memory data.
    ///
    /// If a `SecretStore` is configured, encrypted token fields using
    /// supported `enc:v2:` envelopes are decrypted transparently.
    pub fn load(&self) -> Result<(), AuthProfileError> {
        let _persist_guard = self.lock_persist();
        // SECURITY: route via `read_to_vec_no_hang_no_follow_capped`
        // so a same-uid attacker who plants a FIFO or symlink at
        // the auth-profile store path cannot hang daemon startup
        // (FIFO open(2) blocks indefinitely under the bare
        // `fs::read_to_string` path) and cannot OOM the daemon by
        // dropping a multi-GB file there. The path stores OAuth
        // refresh tokens — high enough stakes that the helper's
        // post-open `is_file()` check is meaningful additional
        // defense even on directories that are already 0o700.
        let bytes = match crate::paths::read_to_vec_no_hang_no_follow_capped(
            &self.shared.state_path,
            AUTH_PROFILE_STORE_MAX_BYTES,
        ) {
            Ok(Some(bytes)) => bytes,
            Ok(None) => return Ok(()),
            Err(e) => return Err(AuthProfileError::IoError(e.to_string())),
        };
        let content = std::str::from_utf8(&bytes).map_err(|e| {
            AuthProfileError::IoError(format!(
                "auth profile store at {} is not valid UTF-8: {e}",
                self.shared.state_path.display()
            ))
        })?;
        let loaded = parse_store_file(content)?;
        let mut profiles = loaded.profiles;

        // Decrypt token fields if we have a SecretStore
        if let Some(ref store) = self.shared.secret_store {
            let password: &[u8] = self
                .shared
                .encryption_password
                .as_deref()
                .map(|v| v.as_slice())
                .unwrap_or(&[]);
            let plaintext_fields = Self::plaintext_encrypted_profile_fields(&profiles);
            if !plaintext_fields.is_empty() {
                return Err(Self::plaintext_encrypted_profiles_error(&plaintext_fields));
            }
            for profile in profiles.iter_mut() {
                Self::decrypt_profile_credential(profile, store, password)?;
            }
        }

        let mut profiles_guard = self.shared.profiles.write();
        let mut skipped_guard = self.shared.skipped_profiles.write();
        let mut state = self.lock_last_used_state();
        *profiles_guard = profiles;
        *skipped_guard = loaded.skipped;
        state.reset_generations();
        self.shared.last_used_persistence.condvar.notify_all();
        Ok(())
    }

    /// Save profiles to disk atomically (write temp file, fsync, rename).
    ///
    /// If a `SecretStore` is configured, `access_token` and `refresh_token`
    /// fields are encrypted before serialization.  In-memory profiles remain
    /// in plaintext so that callers never see encrypted values.
    pub fn save(&self) -> Result<(), AuthProfileError> {
        let _persist_guard = self.lock_persist();
        let (profiles_to_save, persisted_generation) = {
            let guard = self.shared.profiles.read();
            let persisted_generation = self.capture_dirty_generation();
            (guard.to_vec(), persisted_generation)
        };
        self.save_profiles(&profiles_to_save)?;
        self.mark_last_used_persisted(persisted_generation);
        Ok(())
    }

    /// Internal save that operates on an already-borrowed slice of profiles.
    ///
    /// This avoids the TOCTOU race where a caller drops its write lock before
    /// `save()` re-acquires a read lock -- another thread could interleave and
    /// mutate the data between the two lock acquisitions.  Callers that already
    /// hold a write guard should use this method directly.
    fn save_profiles(&self, profiles: &[AuthProfile]) -> Result<(), AuthProfileError> {
        Self::save_profiles_shared(self.shared.as_ref(), profiles)
    }

    fn save_profiles_shared(
        shared: &ProfileStoreShared,
        profiles: &[AuthProfile],
    ) -> Result<(), AuthProfileError> {
        // Clone so we can encrypt without mutating in-memory state
        let mut to_save = profiles.to_vec();

        // Encrypt token fields if we have a SecretStore
        if let Some(ref store) = shared.secret_store {
            for profile in to_save.iter_mut() {
                Self::encrypt_profile_credential(profile, store.as_ref())?;
            }
        }

        let content = {
            let skipped = shared.skipped_profiles.read();
            serialize_store(&to_save, &skipped)?
        };

        // Ensure parent directory exists
        if let Some(parent) = shared.state_path.parent() {
            fs::create_dir_all(parent).map_err(|e| AuthProfileError::IoError(e.to_string()))?;
        }

        // Atomic write with crash safety:
        // 1. Write to .tmp + sync_all so the file content is durable.
        // 2. Rename .tmp → state_path (atomic on POSIX).
        // 3. Fsync the parent directory so the rename is durable.
        // 4. Clean up the .tmp file on any failure path to avoid
        //    leaking encrypted credentials under a less-protected
        //    name.
        //
        // Steps 3 and 4 mirror the discipline already established in
        // `sessions/store.rs` and `sessions/crypto.rs`. Without (3),
        // a power loss between rename and the kernel directory-entry
        // commit could lose the rename — auth tokens silently revert
        // to the prior on-disk state. Without (4), an interrupted
        // write leaves the previous serialized profiles in a
        // `.tmp` shadow file indefinitely.
        // SECURITY: unique tmp path + create_atomic_tmp_owner_only
        // (O_NOFOLLOW + O_EXCL + mode 0o600). auth-profiles holds
        // OAuth refresh tokens / access tokens / encrypted secrets;
        // a same-uid symlink pre-plant under a predictable tmp path
        // could redirect the daemon's truncate+write to an arbitrary
        // operator-writable file, then `rename(tmp, dst)` would move
        // the symlink onto the live path.
        let temp_path = crate::paths::atomic_tmp_path(&shared.state_path, "json");

        let result = (|| -> Result<(), AuthProfileError> {
            let mut file = crate::paths::create_atomic_tmp_owner_only(&temp_path)
                .map_err(|e| AuthProfileError::IoError(e.to_string()))?;
            file.write_all(content.as_bytes())
                .map_err(|e| AuthProfileError::IoError(e.to_string()))?;
            file.sync_all()
                .map_err(|e| AuthProfileError::IoError(e.to_string()))?;
            fs::rename(&temp_path, &shared.state_path)
                .map_err(|e| AuthProfileError::IoError(e.to_string()))?;
            crate::paths::sync_parent_dir_blocking(&shared.state_path)
                .map_err(|e| AuthProfileError::IoError(e.to_string()))?;
            Ok(())
        })();

        if result.is_err() {
            // Best-effort cleanup of the leaked .tmp file. Ignore the
            // remove error itself — the original failure is the one
            // we want to surface.
            let _ = fs::remove_file(&temp_path);
        }
        result
    }

    // -- private helpers for token encryption/decryption --

    fn encrypt_secret_value(
        field_name: &str,
        plaintext: &str,
        store: &SecretStore,
    ) -> Result<String, AuthProfileError> {
        #[cfg(test)]
        if FORCE_SECRET_ENCRYPTION_FAILURE.with(|flag| flag.get()) {
            return Err(AuthProfileError::SecretStorageError(format!(
                "failed to encrypt {field_name}: simulated test failure"
            )));
        }

        store.encrypt(plaintext).map_err(|e| {
            AuthProfileError::SecretStorageError(format!("failed to encrypt {field_name}: {e}"))
        })
    }

    /// Encrypt sensitive token fields in-place. Already-encrypted `enc:v2:`
    /// values are skipped to avoid double-encryption.
    fn encrypt_tokens(
        tokens: &mut OAuthTokens,
        store: &SecretStore,
    ) -> Result<(), AuthProfileError> {
        if !is_encrypted(&tokens.access_token) {
            tokens.access_token =
                Self::encrypt_secret_value("access_token", &tokens.access_token, store)?;
        }
        if let Some(ref rt) = tokens.refresh_token {
            if !is_encrypted(rt) {
                tokens.refresh_token =
                    Some(Self::encrypt_secret_value("refresh_token", rt, store)?);
            }
        }
        Ok(())
    }

    fn encrypt_profile_credential(
        profile: &mut AuthProfile,
        store: &SecretStore,
    ) -> Result<(), AuthProfileError> {
        match profile.credential_kind {
            AuthProfileCredentialKind::OAuth => {
                if let Some(tokens) = profile.tokens.as_mut() {
                    Self::encrypt_tokens(tokens, store)?;
                }
                Self::encrypt_provider_config_secret(&mut profile.oauth_provider_config, store)?;
            }
            AuthProfileCredentialKind::Token => {
                if let Some(token) = profile.token.as_mut() {
                    if !is_encrypted(token) {
                        *token = Self::encrypt_secret_value("token credential", token, store)?;
                    }
                }
            }
        }
        Ok(())
    }

    fn encrypt_provider_config_secret(
        provider_config: &mut Option<StoredOAuthProviderConfig>,
        store: &SecretStore,
    ) -> Result<(), AuthProfileError> {
        let Some(provider_config) = provider_config.as_mut() else {
            return Ok(());
        };
        if !is_encrypted(&provider_config.client_secret) {
            provider_config.client_secret = Self::encrypt_secret_value(
                "oauth provider client_secret",
                &provider_config.client_secret,
                store,
            )?;
        }
        Ok(())
    }

    /// Uses `decrypt_rekey` so that values encrypted with a different salt
    /// (e.g. from a previous `SecretStore` instance) are still decryptable
    /// as long as the same password is used.
    fn decrypt_tokens(
        profile_id: &str,
        tokens: &mut OAuthTokens,
        store: &SecretStore,
        password: &[u8],
    ) -> Result<(), AuthProfileError> {
        if is_encrypted(&tokens.access_token) {
            match store.decrypt_rekey(&tokens.access_token, password) {
                Ok(plaintext) => tokens.access_token = plaintext,
                Err(e) => {
                    tracing::warn!("Failed to decrypt access_token for profile: {}; clearing token to prevent opaque downstream errors", e);
                    tokens.access_token = String::new();
                }
            }
        } else if !tokens.access_token.is_empty() {
            return Err(Self::plaintext_encrypted_profile_error(
                profile_id,
                "access_token",
            ));
        }
        if let Some(ref rt) = tokens.refresh_token {
            if is_encrypted(rt) {
                match store.decrypt_rekey(rt, password) {
                    Ok(plaintext) => tokens.refresh_token = Some(plaintext),
                    Err(e) => {
                        tracing::warn!("Failed to decrypt refresh_token for profile: {}; clearing token to prevent opaque downstream errors", e);
                        tokens.refresh_token = Some(String::new());
                    }
                }
            } else if !rt.is_empty() {
                return Err(Self::plaintext_encrypted_profile_error(
                    profile_id,
                    "refresh_token",
                ));
            }
        }
        Ok(())
    }

    fn decrypt_profile_credential(
        profile: &mut AuthProfile,
        store: &SecretStore,
        password: &[u8],
    ) -> Result<(), AuthProfileError> {
        let profile_id = profile.id.clone();
        match profile.credential_kind {
            AuthProfileCredentialKind::OAuth => {
                if let Some(tokens) = profile.tokens.as_mut() {
                    Self::decrypt_tokens(&profile_id, tokens, store, password)?;
                }
                Self::decrypt_provider_config_secret(
                    &profile_id,
                    &mut profile.oauth_provider_config,
                    store,
                    password,
                )?;
            }
            AuthProfileCredentialKind::Token => {
                if let Some(token) = profile.token.as_mut() {
                    if is_encrypted(token) {
                        match store.decrypt_rekey(token, password) {
                            Ok(plaintext) => *token = plaintext,
                            Err(e) => {
                                tracing::warn!(
                                    "Failed to decrypt token credential for profile: {}; preserving encrypted value so later saves do not destroy it",
                                    e
                                );
                            }
                        }
                    } else if !token.is_empty() {
                        return Err(Self::plaintext_encrypted_profile_error(
                            &profile_id,
                            "token",
                        ));
                    }
                }
            }
        }
        Ok(())
    }

    fn plaintext_encrypted_profile_fields(
        profiles: &[AuthProfile],
    ) -> Vec<PlaintextAuthProfileField> {
        let mut fields = Vec::new();
        for profile in profiles {
            match profile.credential_kind {
                AuthProfileCredentialKind::OAuth => {
                    if let Some(tokens) = profile.tokens.as_ref() {
                        if !tokens.access_token.is_empty() && !is_encrypted(&tokens.access_token) {
                            fields.push(PlaintextAuthProfileField {
                                profile_id: profile.id.clone(),
                                field_name: "access_token",
                            });
                        }
                        if let Some(refresh_token) = tokens.refresh_token.as_ref() {
                            if !refresh_token.is_empty() && !is_encrypted(refresh_token) {
                                fields.push(PlaintextAuthProfileField {
                                    profile_id: profile.id.clone(),
                                    field_name: "refresh_token",
                                });
                            }
                        }
                    }
                    if let Some(provider_config) = profile.oauth_provider_config.as_ref() {
                        if !provider_config.client_secret.is_empty()
                            && !is_encrypted(&provider_config.client_secret)
                        {
                            fields.push(PlaintextAuthProfileField {
                                profile_id: profile.id.clone(),
                                field_name: "oauth provider client_secret",
                            });
                        }
                    }
                }
                AuthProfileCredentialKind::Token => {
                    if let Some(token) = profile.token.as_ref() {
                        if !token.is_empty() && !is_encrypted(token) {
                            fields.push(PlaintextAuthProfileField {
                                profile_id: profile.id.clone(),
                                field_name: "token",
                            });
                        }
                    }
                }
            }
        }
        fields
    }

    fn decrypt_provider_config_secret(
        profile_id: &str,
        provider_config: &mut Option<StoredOAuthProviderConfig>,
        store: &SecretStore,
        password: &[u8],
    ) -> Result<(), AuthProfileError> {
        let Some(provider_config) = provider_config.as_mut() else {
            return Ok(());
        };
        if is_encrypted(&provider_config.client_secret) {
            match store.decrypt_rekey(&provider_config.client_secret, password) {
                Ok(plaintext) => provider_config.client_secret = plaintext,
                Err(e) => {
                    tracing::warn!(
                        "Failed to decrypt oauth provider client_secret for profile: {}; clearing secret to prevent opaque downstream errors",
                        e
                    );
                    provider_config.client_secret = String::new();
                }
            }
        } else if !provider_config.client_secret.is_empty() {
            return Err(Self::plaintext_encrypted_profile_error(
                profile_id,
                "oauth provider client_secret",
            ));
        }
        Ok(())
    }

    fn plaintext_encrypted_profile_error(profile_id: &str, field_name: &str) -> AuthProfileError {
        AuthProfileError::SerializationError(format!(
            "auth profile '{profile_id}' field {field_name} is stored in plaintext while auth-profile encryption is enabled; do not run the gateway normally without CARAPACE_CONFIG_PASSWORD. Use a maintenance shell with CARAPACE_CONFIG_PASSWORD unset only to delete or re-create the affected auth profile, then set CARAPACE_CONFIG_PASSWORD again and enroll it under encryption"
        ))
    }

    fn plaintext_encrypted_profiles_error(
        fields: &[PlaintextAuthProfileField],
    ) -> AuthProfileError {
        let mut entries = String::new();
        for (index, field) in fields.iter().enumerate() {
            if index > 0 {
                entries.push_str(", ");
            }
            let _ = write!(entries, "'{}' field {}", field.profile_id, field.field_name);
        }
        AuthProfileError::SerializationError(format!(
            "auth profile credentials are stored in plaintext while auth-profile encryption is enabled: {entries}; do not run the gateway normally without CARAPACE_CONFIG_PASSWORD. Use a maintenance shell with CARAPACE_CONFIG_PASSWORD unset only to delete or re-create the affected auth profiles, then set CARAPACE_CONFIG_PASSWORD again and enroll them under encryption"
        ))
    }

    /// Add a profile. Fails if MAX_PROFILES would be exceeded.
    pub fn add(&self, profile: AuthProfile) -> Result<(), AuthProfileError> {
        let _persist_guard = self.lock_persist();
        let mut guard = self.shared.profiles.write();
        if guard.len() >= MAX_PROFILES {
            return Err(AuthProfileError::MaxProfilesExceeded);
        }
        if guard.iter().any(|p| p.id == profile.id) {
            return Err(AuthProfileError::DuplicateProfileId(profile.id));
        }
        guard.push(profile);
        let persisted_generation = self.capture_dirty_generation();
        self.save_profiles(&guard)?;
        self.mark_last_used_persisted(persisted_generation);
        Ok(())
    }

    /// Remove a profile by ID. Returns `true` if a profile was removed.
    pub fn remove(&self, id: &str) -> Result<bool, AuthProfileError> {
        let _persist_guard = self.lock_persist();
        let mut guard = self.shared.profiles.write();
        let before = guard.len();
        guard.retain(|p| p.id != id);
        let removed = guard.len() < before;
        if removed {
            let persisted_generation = self.capture_dirty_generation();
            self.save_profiles(&guard)?;
            self.mark_last_used_persisted(persisted_generation);
        }
        Ok(removed)
    }

    /// Get a profile by ID (cloned).
    pub fn get(&self, id: &str) -> Option<AuthProfile> {
        let guard = self.shared.profiles.read();
        guard.iter().find(|p| p.id == id).cloned()
    }

    /// Find a profile by provider + stable user identity.
    pub fn find_matching(
        &self,
        provider: OAuthProvider,
        user_id: Option<&str>,
        email: Option<&str>,
    ) -> Option<AuthProfile> {
        let guard = self.shared.profiles.read();
        guard
            .iter()
            .find(|profile| {
                if profile.provider != provider {
                    return false;
                }

                if let (Some(existing), Some(target)) = (profile.user_id.as_deref(), user_id) {
                    if !existing.trim().is_empty() && existing == target {
                        return true;
                    }
                }

                if let (Some(existing), Some(target)) = (profile.email.as_deref(), email) {
                    if !existing.trim().is_empty() && existing.eq_ignore_ascii_case(target) {
                        return true;
                    }
                }

                false
            })
            .cloned()
    }

    /// List all profiles as summaries (no tokens exposed).
    pub fn list(&self) -> Vec<AuthProfileSummary> {
        let guard = self.shared.profiles.read();
        guard.iter().map(|p| p.to_summary()).collect()
    }

    /// Update the tokens for a profile.
    pub fn update_tokens(&self, id: &str, tokens: OAuthTokens) -> Result<(), AuthProfileError> {
        let _persist_guard = self.lock_persist();
        let mut guard = self.shared.profiles.write();
        let profile = guard
            .iter_mut()
            .find(|p| p.id == id)
            .ok_or(AuthProfileError::ProfileNotFound)?;
        if profile.credential_kind != AuthProfileCredentialKind::OAuth {
            return Err(AuthProfileError::CredentialTypeMismatch(format!(
                "profile \"{id}\" is not OAuth-backed"
            )));
        }
        profile.tokens = Some(tokens);
        let persisted_generation = self.capture_dirty_generation();
        self.save_profiles(&guard)?;
        self.mark_last_used_persisted(persisted_generation);
        Ok(())
    }

    /// Update the last-used timestamp for a profile.
    pub fn update_last_used(&self, id: &str) {
        let now_ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0);

        let mut guard = self.shared.profiles.write();
        let mut should_schedule_flush = false;
        if let Some(profile) = guard.iter_mut().find(|p| p.id == id) {
            let is_stale = profile
                .last_used_ms
                .map(|last_used| now_ms.saturating_sub(last_used) >= LAST_USED_WRITE_INTERVAL_MS)
                .unwrap_or(true);
            if is_stale {
                profile.last_used_ms = Some(now_ms);
                should_schedule_flush = true;
            }
        }
        if should_schedule_flush {
            self.note_last_used_dirty();
        }
        drop(guard);
        if should_schedule_flush {
            self.notify_last_used_dirty();
        }
    }

    /// Insert or replace a full profile by ID.
    pub fn upsert(&self, profile: AuthProfile) -> Result<(), AuthProfileError> {
        let _persist_guard = self.lock_persist();
        let mut guard = self.shared.profiles.write();
        if let Some(existing) = guard.iter_mut().find(|p| p.id == profile.id) {
            *existing = profile;
        } else {
            if guard.len() >= MAX_PROFILES {
                return Err(AuthProfileError::MaxProfilesExceeded);
            }
            guard.push(profile);
        }
        let persisted_generation = self.capture_dirty_generation();
        self.save_profiles(&guard)?;
        self.mark_last_used_persisted(persisted_generation);
        Ok(())
    }
}

impl Drop for ProfileStore {
    fn drop(&mut self) {
        self.shutdown_last_used_worker();
    }
}

pub fn profile_store_encryption_enabled_from_env() -> bool {
    crate::config::read_process_env_zeroizing("CARAPACE_CONFIG_PASSWORD")
        .map(|value| !value.trim().is_empty())
        .unwrap_or(false)
}

pub fn resolve_anthropic_profile_token(
    profile_store: &ProfileStore,
    profile_id: &str,
) -> Result<String, String> {
    let profile = profile_store.get(profile_id).ok_or_else(|| {
        format!("configured Anthropic auth profile \"{profile_id}\" was not found")
    })?;
    if profile.provider != OAuthProvider::Anthropic {
        return Err(format!(
            "configured Anthropic auth profile \"{profile_id}\" belongs to {}, not anthropic",
            profile.provider
        ));
    }
    if profile.credential_kind != AuthProfileCredentialKind::Token {
        return Err(format!(
            "configured Anthropic auth profile \"{profile_id}\" is not token-backed"
        ));
    }
    if profile
        .token
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .is_some_and(is_encrypted)
    {
        return Err(format!(
            "configured Anthropic auth profile \"{profile_id}\" could not decrypt the stored token; check CARAPACE_CONFIG_PASSWORD"
        ));
    }
    profile.provider_token().map(str::to_string).ok_or_else(|| {
        format!("configured Anthropic auth profile \"{profile_id}\" has no usable token")
    })
}

// ---------------------------------------------------------------------------
// Config integration
// ---------------------------------------------------------------------------

/// Auth profiles configuration parsed from the application config.
#[derive(Debug, Clone, Default)]
pub struct AuthProfilesConfig {
    pub enabled: bool,
    pub providers: HashMap<OAuthProvider, OAuthProviderConfig>,
    pub redirect_base_url: Option<String>,
}

/// Build auth profiles configuration from the root config `Value`.
///
/// Expected config path: `auth.profiles`.
pub fn build_auth_profiles_config(cfg: &Value) -> AuthProfilesConfig {
    let section = match cfg.pointer("/auth/profiles") {
        Some(v) => v,
        None => return AuthProfilesConfig::default(),
    };

    let enabled = section
        .get("enabled")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    let redirect_base_url = section
        .get("redirectBaseUrl")
        .and_then(|v| v.as_str())
        .and_then(|s| sanitize_redirect_base_url(s).ok());

    let default_redirect = redirect_base_url
        .as_deref()
        .map(|base| format!("{}/auth/callback", base))
        .unwrap_or_else(|| "http://localhost:3000/auth/callback".to_string());

    let mut providers = HashMap::new();

    if let Some(providers_obj) = section.get("providers").and_then(|v| v.as_object()) {
        let provider_entries: &[(&str, OAuthProvider)] = &[
            ("google", OAuthProvider::Google),
            ("github", OAuthProvider::GitHub),
            ("discord", OAuthProvider::Discord),
            ("openai", OAuthProvider::OpenAI),
        ];

        for &(key, provider) in provider_entries {
            if let Some(pcfg) = providers_obj.get(key) {
                let client_id = pcfg
                    .get("clientId")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                let client_secret = pcfg
                    .get("clientSecret")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                let redirect_uri = pcfg
                    .get("redirectUri")
                    .and_then(|v| v.as_str())
                    .and_then(|s| sanitize_redirect_uri(s).ok())
                    .unwrap_or_else(|| default_redirect.clone());

                if !client_id.is_empty() && !client_secret.is_empty() {
                    if let Some(config) =
                        provider.default_config(&client_id, &client_secret, &redirect_uri)
                    {
                        providers.insert(provider, config);
                    }
                }
            }
        }
    }

    AuthProfilesConfig {
        enabled,
        providers,
        redirect_base_url,
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    struct SecretEncryptionFailureGuard;

    impl Drop for SecretEncryptionFailureGuard {
        fn drop(&mut self) {
            FORCE_SECRET_ENCRYPTION_FAILURE.with(|flag| flag.set(false));
        }
    }

    fn force_secret_encryption_failure_for_tests() -> SecretEncryptionFailureGuard {
        FORCE_SECRET_ENCRYPTION_FAILURE.with(|flag| flag.set(true));
        SecretEncryptionFailureGuard
    }

    // -----------------------------------------------------------------------
    // Helper builders
    // -----------------------------------------------------------------------

    fn sample_tokens() -> OAuthTokens {
        OAuthTokens {
            access_token: "access-123".to_string(),
            refresh_token: Some("refresh-456".to_string()),
            token_type: "Bearer".to_string(),
            expires_at_ms: Some(9_999_999_999_999),
            scope: Some("openid email".to_string()),
        }
    }

    fn sample_profile(id: &str) -> AuthProfile {
        let now_ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0);

        AuthProfile {
            id: id.to_string(),
            name: format!("Test Profile {}", id),
            provider: OAuthProvider::Google,
            user_id: Some("user-1".to_string()),
            email: Some("test@example.com".to_string()),
            display_name: Some("Test User".to_string()),
            avatar_url: Some("https://example.com/avatar.png".to_string()),
            created_at_ms: now_ms,
            last_used_ms: None,
            credential_kind: AuthProfileCredentialKind::OAuth,
            tokens: Some(sample_tokens()),
            token: None,
            oauth_provider_config: None,
        }
    }

    fn sample_token_profile(id: &str, provider: OAuthProvider, token: &str) -> AuthProfile {
        let now_ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0);

        AuthProfile {
            id: id.to_string(),
            name: format!("Token Profile {}", id),
            provider,
            user_id: None,
            email: None,
            display_name: None,
            avatar_url: None,
            created_at_ms: now_ms,
            last_used_ms: None,
            credential_kind: AuthProfileCredentialKind::Token,
            tokens: None,
            token: Some(token.to_string()),
            oauth_provider_config: None,
        }
    }

    fn oauth_tokens(profile: &AuthProfile) -> &OAuthTokens {
        profile.oauth_tokens().expect("oauth-backed sample profile")
    }

    fn oauth_tokens_mut(profile: &mut AuthProfile) -> &mut OAuthTokens {
        profile
            .oauth_tokens_mut()
            .expect("oauth-backed sample profile")
    }

    fn google_config() -> OAuthProviderConfig {
        OAuthProvider::Google
            .default_config("cid", "csecret", "https://example.com/cb")
            .unwrap()
    }

    fn github_config() -> OAuthProviderConfig {
        OAuthProvider::GitHub
            .default_config("cid", "csecret", "https://example.com/cb")
            .unwrap()
    }

    fn discord_config() -> OAuthProviderConfig {
        OAuthProvider::Discord
            .default_config("cid", "csecret", "https://example.com/cb")
            .unwrap()
    }

    fn openai_config() -> OAuthProviderConfig {
        OAuthProvider::OpenAI
            .default_config("cid", "csecret", "https://example.com/cb")
            .unwrap()
    }

    fn random_password() -> Vec<u8> {
        let mut bytes = [0u8; 32];
        getrandom::fill(&mut bytes).expect("random test password bytes");
        bytes.to_vec()
    }

    // -----------------------------------------------------------------------
    // Provider config tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_provider_default_configs() {
        let g = google_config();
        let gh = github_config();
        let d = discord_config();
        let o = openai_config();

        assert!(g.auth_url.contains("accounts.google.com"));
        assert!(gh.auth_url.contains("github.com"));
        assert!(d.auth_url.contains("discord.com"));
        assert!(o.auth_url.contains("auth.openai.com"));

        assert!(g.client_id == "cid");
        assert!(gh.client_secret == "csecret");
        assert_eq!(d.redirect_uri, "https://example.com/cb");
        assert_eq!(o.redirect_uri, "https://example.com/cb");
    }

    #[test]
    fn test_anthropic_default_config_returns_none() {
        assert!(
            OAuthProvider::Anthropic
                .default_config("cid", "csecret", "https://example.com/cb")
                .is_none(),
            "Anthropic does not use OAuth; default_config should return None"
        );
    }

    #[test]
    fn test_google_config_endpoints() {
        let cfg = google_config();
        assert_eq!(cfg.auth_url, "https://accounts.google.com/o/oauth2/v2/auth");
        assert_eq!(cfg.token_url, "https://oauth2.googleapis.com/token");
        assert_eq!(
            cfg.userinfo_url,
            "https://openidconnect.googleapis.com/v1/userinfo"
        );
        assert_eq!(cfg.scopes, vec!["openid", "email", "profile"]);
    }

    #[test]
    fn test_github_config_endpoints() {
        let cfg = github_config();
        assert_eq!(cfg.auth_url, "https://github.com/login/oauth/authorize");
        assert_eq!(cfg.token_url, "https://github.com/login/oauth/access_token");
        assert_eq!(cfg.userinfo_url, "https://api.github.com/user");
        assert_eq!(cfg.scopes, vec!["read:user", "user:email"]);
    }

    #[test]
    fn test_discord_config_endpoints() {
        let cfg = discord_config();
        assert_eq!(cfg.auth_url, "https://discord.com/api/oauth2/authorize");
        assert_eq!(cfg.token_url, "https://discord.com/api/oauth2/token");
        assert_eq!(cfg.userinfo_url, "https://discord.com/api/users/@me");
        assert_eq!(cfg.scopes, vec!["identify", "email"]);
    }

    #[test]
    fn test_openai_config_endpoints() {
        let cfg = openai_config();
        assert_eq!(cfg.auth_url, "https://auth.openai.com/oauth/authorize");
        assert_eq!(cfg.token_url, "https://auth.openai.com/oauth/token");
        assert_eq!(cfg.userinfo_url, "https://api.openai.com/v1/me");
        assert_eq!(
            cfg.scopes,
            vec!["openid", "profile", "email", "offline_access"]
        );
    }

    // -----------------------------------------------------------------------
    // Auth URL / PKCE tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_generate_auth_url_contains_required_params() {
        let cfg = google_config();
        let (url, _verifier) = generate_auth_url(&cfg, "test-state").unwrap();

        assert!(url.contains("response_type=code"), "missing response_type");
        assert!(url.contains("client_id=cid"), "missing client_id");
        assert!(url.contains("redirect_uri="), "missing redirect_uri");
        assert!(url.contains("scope="), "missing scope");
        assert!(url.contains("state=test-state"), "missing state");
        assert!(url.contains("code_challenge="), "missing code_challenge");
        assert!(
            url.contains("code_challenge_method=S256"),
            "missing code_challenge_method"
        );
    }

    #[test]
    fn test_generate_auth_url_pkce_challenge() {
        let cfg = google_config();
        let (url, verifier) = generate_auth_url(&cfg, "s").unwrap();

        // Manually compute expected challenge
        let expected_challenge = compute_code_challenge(&verifier);

        // The URL should contain the expected challenge
        assert!(
            url.contains(&expected_challenge),
            "URL does not contain correct PKCE code_challenge. Expected: {}",
            expected_challenge
        );
    }

    #[test]
    fn test_generate_auth_url_state_param() {
        let cfg = google_config();
        let (url, _) = generate_auth_url(&cfg, "my-unique-state-42").unwrap();
        assert!(url.contains("state=my-unique-state-42"));
    }

    #[test]
    fn test_pkce_verifier_length() {
        let verifier = generate_code_verifier().unwrap();
        // 32 bytes -> 43 base64url chars without padding
        assert!(
            verifier.len() >= 43 && verifier.len() <= 128,
            "Verifier length {} is out of valid PKCE range 43-128",
            verifier.len()
        );
    }

    #[test]
    fn test_pkce_challenge_is_base64url() {
        let verifier = generate_code_verifier().unwrap();
        let challenge = compute_code_challenge(&verifier);

        // base64url uses A-Z a-z 0-9 - _ and no padding
        for ch in challenge.chars() {
            assert!(
                ch.is_ascii_alphanumeric() || ch == '-' || ch == '_',
                "Challenge contains non-base64url character: '{}'",
                ch
            );
        }
        assert!(
            !challenge.contains('='),
            "Challenge should not have padding"
        );
        assert!(!challenge.contains('+'), "Challenge should not contain '+'");
        assert!(!challenge.contains('/'), "Challenge should not contain '/'");
    }

    // -----------------------------------------------------------------------
    // Serialization round-trip tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_oauth_tokens_serialization() {
        let tokens = sample_tokens();
        let json = serde_json::to_string(&tokens).unwrap();
        let deserialized: OAuthTokens = serde_json::from_str(&json).unwrap();

        assert!(deserialized.access_token == tokens.access_token);
        assert!(deserialized.refresh_token == tokens.refresh_token);
        assert_eq!(deserialized.token_type, tokens.token_type);
        assert_eq!(deserialized.expires_at_ms, tokens.expires_at_ms);
        assert_eq!(deserialized.scope, tokens.scope);
    }

    #[test]
    fn test_auth_profile_serialization() {
        let profile = sample_profile("abc-123");
        let json = serde_json::to_string(&profile).unwrap();
        let deserialized: AuthProfile = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.id, profile.id);
        assert_eq!(deserialized.name, profile.name);
        assert_eq!(deserialized.provider, profile.provider);
        assert_eq!(deserialized.user_id, profile.user_id);
        assert_eq!(deserialized.email, profile.email);
        assert_eq!(deserialized.display_name, profile.display_name);
        assert_eq!(deserialized.avatar_url, profile.avatar_url);
        assert_eq!(deserialized.created_at_ms, profile.created_at_ms);
        assert_eq!(deserialized.last_used_ms, profile.last_used_ms);
        assert_eq!(
            oauth_tokens(&deserialized).access_token,
            oauth_tokens(&profile).access_token
        );
    }

    #[test]
    fn test_parse_store_ignores_unknown_future_profile_fields() {
        let content = serde_json::json!({
            "version": 2,
            "profiles": [{
                "id": "future-fields",
                "name": "Future Fields",
                "provider": "anthropic",
                "created_at_ms": 1,
                "credential_kind": "o-auth",
                "future_profile_field": true,
                "tokens": {
                    "access_token": "access",
                    "refresh_token": "refresh",
                    "token_type": "Bearer",
                    "expires_at_ms": 2,
                    "scope": "profile",
                    "future_token_field": "kept by newer binaries"
                },
                "oauth_provider_config": {
                    "client_id": "client",
                    "client_secret": "secret",
                    "redirect_uri": "http://localhost/callback",
                    "auth_url": "https://example.test/auth",
                    "token_url": "https://example.test/token",
                    "userinfo_url": "https://example.test/user",
                    "scopes": ["profile"],
                    "future_provider_field": "kept by newer binaries"
                }
            }]
        })
        .to_string();

        let loaded = parse_store_file(&content).unwrap();

        assert_eq!(loaded.profiles.len(), 1);
        assert!(loaded.skipped.is_empty());
        assert_eq!(loaded.profiles[0].id, "future-fields");
        assert_eq!(oauth_tokens(&loaded.profiles[0]).access_token, "access");
    }

    #[test]
    fn test_parse_store_preserves_unrecognized_current_version_profiles() {
        let content = serde_json::json!({
            "version": 2,
            "profiles": [
                {
                    "id": "known",
                    "name": "Known",
                    "provider": "anthropic",
                    "created_at_ms": 1,
                    "credential_kind": "token",
                    "token": "sk-ant-test"
                },
                {
                    "id": "future-provider",
                    "name": "Future Provider",
                    "provider": "future-provider",
                    "created_at_ms": 2,
                    "credential_kind": "token",
                    "token": "future-token"
                }
            ]
        })
        .to_string();

        let loaded = parse_store_file(&content).unwrap();
        assert_eq!(loaded.profiles.len(), 1);
        assert_eq!(loaded.skipped.len(), 1);

        let serialized = serialize_store(&loaded.profiles, &loaded.skipped).unwrap();
        let serialized: Value = serde_json::from_str(&serialized).unwrap();
        let profiles = serialized["profiles"].as_array().unwrap();
        assert!(profiles.iter().any(|profile| profile["id"] == "known"));
        assert!(profiles
            .iter()
            .any(|profile| profile["id"] == "future-provider"));
    }

    #[test]
    fn test_profile_store_preserves_skipped_profiles_on_save() {
        let dir = tempdir().unwrap();
        let state_path = profile_store_state_path(dir.path());
        std::fs::write(
            &state_path,
            serde_json::json!({
                "version": 2,
                "profiles": [
                    {
                        "id": "known",
                        "name": "Known",
                        "provider": "anthropic",
                        "created_at_ms": 1,
                        "credential_kind": "token",
                        "token": "sk-ant-test"
                    },
                    {
                        "id": "future-provider",
                        "name": "Future Provider",
                        "provider": "future-provider",
                        "created_at_ms": 2,
                        "credential_kind": "token",
                        "token": "future-token"
                    }
                ]
            })
            .to_string(),
        )
        .unwrap();

        let store = ProfileStore::new(dir.path().to_path_buf());
        store.load().unwrap();
        store.save().unwrap();
        store.shutdown_last_used_worker();

        let saved: Value = serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap())
            .expect("saved store should be valid JSON");
        let profiles = saved["profiles"].as_array().unwrap();
        assert!(profiles
            .iter()
            .any(|profile| profile["id"] == "future-provider"));
    }

    // -----------------------------------------------------------------------
    // Profile summary tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_profile_summary_no_tokens() {
        let profile = sample_profile("id-1");
        let summary = profile.to_summary();

        // Summary must not contain access_token or refresh_token
        let json = serde_json::to_string(&summary).unwrap();
        assert!(!json.contains("access-123"), "Summary leaks access_token");
        assert!(!json.contains("refresh-456"), "Summary leaks refresh_token");

        assert_eq!(summary.id, "id-1");
        assert_eq!(summary.provider, OAuthProvider::Google);
    }

    #[test]
    fn test_profile_summary_token_valid() {
        let far_future_ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_millis() as u64
            + 3_600_000; // +1 hour

        let mut profile = sample_profile("valid");
        oauth_tokens_mut(&mut profile).expires_at_ms = Some(far_future_ms);
        let summary = profile.to_summary();
        assert!(
            summary.token_valid,
            "Token expiring in the future should be valid"
        );
    }

    #[test]
    fn test_profile_summary_token_expired() {
        let mut profile = sample_profile("expired");
        oauth_tokens_mut(&mut profile).expires_at_ms = Some(1000); // far in the past
        let summary = profile.to_summary();
        assert!(
            !summary.token_valid,
            "Token that expired long ago should be invalid"
        );
    }

    // -----------------------------------------------------------------------
    // ProfileStore CRUD tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_profile_store_add_remove() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());

        let profile = sample_profile("p1");
        store.add(profile).unwrap();

        assert!(store.get("p1").is_some());
        assert_eq!(store.list().len(), 1);

        let removed = store.remove("p1").unwrap();
        assert!(removed);
        assert!(store.get("p1").is_none());
        assert_eq!(store.list().len(), 0);
    }

    #[test]
    fn test_profile_store_max_profiles() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());

        for i in 0..MAX_PROFILES {
            store.add(sample_profile(&format!("p-{}", i))).unwrap();
        }

        // Next add should fail
        let result = store.add(sample_profile("overflow"));
        assert!(
            matches!(result, Err(AuthProfileError::MaxProfilesExceeded)),
            "Expected MaxProfilesExceeded error"
        );
    }

    #[test]
    fn test_profile_store_persistence() {
        let dir = tempdir().unwrap();

        // Create store and add profile
        {
            let store = ProfileStore::new(dir.path().to_path_buf());
            store.add(sample_profile("persist-1")).unwrap();
            store.add(sample_profile("persist-2")).unwrap();
        }

        // Create a new store and load
        {
            let store = ProfileStore::new(dir.path().to_path_buf());
            store.load().unwrap();

            let profiles = store.list();
            assert_eq!(profiles.len(), 2);
            assert!(store.get("persist-1").is_some());
            assert!(store.get("persist-2").is_some());
        }
    }

    #[test]
    fn test_profile_store_duplicate_id() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());

        store.add(sample_profile("dup")).unwrap();
        // Adding another profile with the same ID should be rejected.
        let result = store.add(sample_profile("dup"));
        assert!(
            matches!(result, Err(AuthProfileError::DuplicateProfileId(ref id)) if id == "dup"),
            "Expected DuplicateProfileId error"
        );

        // Only the first profile exists
        assert_eq!(store.list().len(), 1);
    }

    #[test]
    fn test_profile_store_list_returns_summaries() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());

        store.add(sample_profile("ls-1")).unwrap();
        store.add(sample_profile("ls-2")).unwrap();

        let summaries = store.list();
        assert_eq!(summaries.len(), 2);

        // Verify summaries do not leak tokens
        for summary in &summaries {
            let json = serde_json::to_string(summary).unwrap();
            assert!(!json.contains("access-123"));
            assert!(!json.contains("refresh-456"));
        }
    }

    #[test]
    fn test_profile_store_update_tokens() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());

        store.add(sample_profile("upd")).unwrap();

        let new_tokens = OAuthTokens {
            access_token: "new-access".to_string(),
            refresh_token: Some("new-refresh".to_string()),
            token_type: "Bearer".to_string(),
            expires_at_ms: Some(123_456_789),
            scope: None,
        };

        store.update_tokens("upd", new_tokens).unwrap();

        let profile = store.get("upd").unwrap();
        assert!(oauth_tokens(&profile).access_token == "new-access");
        assert_eq!(
            oauth_tokens(&profile).refresh_token,
            Some("new-refresh".to_string())
        );
    }

    #[test]
    fn test_profile_store_update_last_used() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());

        store.add(sample_profile("lu")).unwrap();

        // Initially None
        assert!(store.get("lu").unwrap().last_used_ms.is_none());

        store.update_last_used("lu");

        let profile = store.get("lu").unwrap();
        assert!(
            profile.last_used_ms.is_some(),
            "last_used_ms should be set after update"
        );
    }

    #[test]
    fn test_profile_store_update_last_used_defers_disk_write_until_flush() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());
        store.pause_last_used_auto_flush_for_tests();
        store.add(sample_profile("lu-flush")).unwrap();

        let state_path = dir.path().join("auth_profiles.json");
        let raw_before = std::fs::read_to_string(&state_path).unwrap();
        let saved_before: serde_json::Value = serde_json::from_str(&raw_before).unwrap();
        assert!(saved_before["profiles"][0]["last_used_ms"].is_null());

        store.update_last_used("lu-flush");

        let raw_after_update = std::fs::read_to_string(&state_path).unwrap();
        assert_eq!(raw_after_update, raw_before);

        store.flush_pending_last_used_for_tests().unwrap();

        let raw_after_flush = std::fs::read_to_string(&state_path).unwrap();
        assert_ne!(raw_after_flush, raw_before);
        let saved: serde_json::Value = serde_json::from_str(&raw_after_flush).unwrap();
        assert!(saved["profiles"][0]["last_used_ms"].as_u64().is_some());
    }

    #[test]
    fn test_profile_store_auto_flush_persists_last_used_in_background() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());
        store.add(sample_profile("lu-auto")).unwrap();

        let state_path = dir.path().join("auth_profiles.json");
        let raw_before = std::fs::read_to_string(&state_path).unwrap();

        store.update_last_used("lu-auto");
        assert!(
            store.wait_for_last_used_flush_for_tests().is_ok(),
            "background worker should flush last_used metadata within the test deadline"
        );

        let raw_after = std::fs::read_to_string(&state_path).unwrap();
        assert_ne!(raw_after, raw_before);
        let saved: serde_json::Value = serde_json::from_str(&raw_after).unwrap();
        assert!(saved["profiles"][0]["last_used_ms"].as_u64().is_some());
    }

    #[test]
    fn test_profile_store_update_last_used_skips_recent_timestamp() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());

        let mut profile = sample_profile("lu-recent");
        profile.last_used_ms = Some(
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .map(|d| d.as_millis() as u64)
                .unwrap_or(0),
        );
        let expected = profile.last_used_ms;
        store.add(profile).unwrap();

        store.update_last_used("lu-recent");

        let profile = store.get("lu-recent").unwrap();
        assert_eq!(profile.last_used_ms, expected);
    }

    #[test]
    fn test_profile_store_sync_save_absorbs_pending_last_used_flush() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());
        store.pause_last_used_auto_flush_for_tests();
        store.add(sample_profile("lu-sync")).unwrap();

        let state_path = dir.path().join("auth_profiles.json");
        let raw_before = std::fs::read_to_string(&state_path).unwrap();

        store.update_last_used("lu-sync");
        assert_eq!(std::fs::read_to_string(&state_path).unwrap(), raw_before);

        store
            .update_tokens(
                "lu-sync",
                OAuthTokens {
                    access_token: "new-access".to_string(),
                    refresh_token: Some("new-refresh".to_string()),
                    token_type: "Bearer".to_string(),
                    expires_at_ms: Some(123_456_789),
                    scope: None,
                },
            )
            .unwrap();

        let raw_after_save = std::fs::read_to_string(&state_path).unwrap();
        assert_ne!(raw_after_save, raw_before);
        let saved: serde_json::Value = serde_json::from_str(&raw_after_save).unwrap();
        assert_eq!(saved["profiles"][0]["tokens"]["access_token"], "new-access");
        assert!(saved["profiles"][0]["last_used_ms"].as_u64().is_some());

        let flushed = store.flush_pending_last_used_for_tests().unwrap();
        assert!(
            !flushed,
            "synchronous save should absorb pending last_used dirtiness"
        );
        assert_eq!(
            std::fs::read_to_string(&state_path).unwrap(),
            raw_after_save
        );
    }

    #[test]
    fn test_profile_store_drop_flushes_pending_last_used() {
        let dir = tempdir().unwrap();
        {
            let store = ProfileStore::new(dir.path().to_path_buf());
            store.pause_last_used_auto_flush_for_tests();
            store.add(sample_profile("lu-drop")).unwrap();
            store.update_last_used("lu-drop");
        }

        let reopened = ProfileStore::new(dir.path().to_path_buf());
        reopened.load().unwrap();
        assert!(
            reopened
                .get("lu-drop")
                .expect("profile after drop flush")
                .last_used_ms
                .is_some(),
            "drop should flush pending last_used metadata"
        );
    }

    #[test]
    fn test_profile_store_shutdown_flushes_pending_last_used_after_worker_panic() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());

        let original_worker = store
            .last_used_worker
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .take()
            .expect("background worker handle");
        {
            let mut state = store.lock_last_used_state();
            state.shutdown = true;
            store.shared.last_used_persistence.condvar.notify_all();
        }
        original_worker
            .join()
            .expect("original background worker should shut down cleanly");
        {
            let mut state = store.lock_last_used_state();
            state.shutdown = false;
            state.auto_flush_enabled = false;
            state.reset_generations();
        }

        store.add(sample_profile("lu-panic")).unwrap();
        store.update_last_used("lu-panic");
        *store
            .last_used_worker
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(std::thread::spawn(|| {
            panic!("forced panic for shutdown fallback test");
        }));

        store.shutdown_last_used_worker();

        let reopened = ProfileStore::new(dir.path().to_path_buf());
        reopened.load().unwrap();
        assert!(
            reopened
                .get("lu-panic")
                .expect("profile after panic fallback flush")
                .last_used_ms
                .is_some(),
            "shutdown should flush pending last_used metadata even if the worker panics"
        );
    }

    #[test]
    fn test_profile_store_get_nonexistent() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());
        assert!(store.get("does-not-exist").is_none());
    }

    #[test]
    fn test_profile_store_remove_nonexistent() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());
        let removed = store.remove("nope").unwrap();
        assert!(!removed);
    }

    #[test]
    fn test_profile_store_empty_initial() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());
        assert_eq!(store.list().len(), 0);
        assert!(store.get("any").is_none());
    }

    // -----------------------------------------------------------------------
    // Config integration tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_build_config_defaults() {
        let cfg = serde_json::json!({});
        let result = build_auth_profiles_config(&cfg);
        assert!(!result.enabled);
        assert!(result.providers.is_empty());
        assert!(result.redirect_base_url.is_none());
    }

    #[test]
    fn test_build_config_enabled() {
        let cfg = serde_json::json!({
            "auth": {
                "profiles": {
                    "enabled": true
                }
            }
        });
        let result = build_auth_profiles_config(&cfg);
        assert!(result.enabled);
    }

    #[test]
    fn test_build_config_with_providers() {
        let cfg = serde_json::json!({
            "auth": {
                "profiles": {
                    "enabled": true,
                    "redirectBaseUrl": "https://gw.example.com",
                    "providers": {
                        "google": {
                            "clientId": "google-cid",
                            "clientSecret": "google-cs"
                        },
                        "github": {
                            "clientId": "gh-cid",
                            "clientSecret": "gh-cs"
                        },
                        "discord": {
                            "clientId": "dc-cid",
                            "clientSecret": "dc-cs"
                        },
                        "openai": {
                            "clientId": "oa-cid",
                            "clientSecret": "oa-cs"
                        }
                    }
                }
            }
        });
        let result = build_auth_profiles_config(&cfg);
        assert!(result.enabled);
        assert_eq!(
            result.redirect_base_url.as_deref(),
            Some("https://gw.example.com")
        );
        assert_eq!(result.providers.len(), 4);

        let google = result.providers.get(&OAuthProvider::Google).unwrap();
        assert!(google.client_id == "google-cid");
        assert!(google.client_secret == "google-cs");
        assert_eq!(google.redirect_uri, "https://gw.example.com/auth/callback");

        let github = result.providers.get(&OAuthProvider::GitHub).unwrap();
        assert!(github.client_id == "gh-cid");

        let discord = result.providers.get(&OAuthProvider::Discord).unwrap();
        assert!(discord.client_id == "dc-cid");

        let openai = result.providers.get(&OAuthProvider::OpenAI).unwrap();
        assert!(openai.client_id == "oa-cid");
        assert!(openai.client_secret == "oa-cs");
        assert_eq!(openai.redirect_uri, "https://gw.example.com/auth/callback");
    }

    #[test]
    fn test_build_config_rejects_redirect_base_url_userinfo() {
        let cfg = serde_json::json!({
            "auth": {
                "profiles": {
                    "redirectBaseUrl": "https://user:pass@gw.example.com",
                    "providers": {
                        "openai": {
                            "clientId": "oa-cid",
                            "clientSecret": "oa-cs"
                        }
                    }
                }
            }
        });
        let result = build_auth_profiles_config(&cfg);
        assert!(result.redirect_base_url.is_none());
        let openai = result.providers.get(&OAuthProvider::OpenAI).unwrap();
        assert_eq!(openai.redirect_uri, "http://localhost:3000/auth/callback");
    }

    #[test]
    fn test_build_config_rejects_provider_redirect_uri_userinfo() {
        let cfg = serde_json::json!({
            "auth": {
                "profiles": {
                    "redirectBaseUrl": "https://gw.example.com",
                    "providers": {
                        "openai": {
                            "clientId": "oa-cid",
                            "clientSecret": "oa-cs",
                            "redirectUri": "https://user:pass@evil.example.com/auth/callback"
                        }
                    }
                }
            }
        });
        let result = build_auth_profiles_config(&cfg);
        let openai = result.providers.get(&OAuthProvider::OpenAI).unwrap();
        assert_eq!(openai.redirect_uri, "https://gw.example.com/auth/callback");
    }

    #[test]
    fn test_sanitize_redirect_base_url_origin_only() {
        assert_eq!(
            sanitize_redirect_base_url("https://gw.example.com").unwrap(),
            "https://gw.example.com"
        );
        assert!(sanitize_redirect_base_url("https://gw.example.com/oauth").is_err());
        assert!(sanitize_redirect_base_url("https://user@gw.example.com").is_err());
    }

    #[test]
    fn test_sanitize_redirect_uri_rejects_userinfo_and_fragment() {
        assert_eq!(
            sanitize_redirect_uri("https://gw.example.com/auth/callback").unwrap(),
            "https://gw.example.com/auth/callback"
        );
        assert!(sanitize_redirect_uri("https://user@gw.example.com/auth/callback").is_err());
        assert!(sanitize_redirect_uri("https://gw.example.com/auth/callback#token").is_err());
    }

    #[test]
    fn test_build_config_missing_section() {
        let cfg = serde_json::json!({
            "auth": {
                "mode": "token"
            }
        });
        let result = build_auth_profiles_config(&cfg);
        assert!(!result.enabled);
        assert!(result.providers.is_empty());
    }

    #[tokio::test]
    async fn test_fetch_user_info_openai_uses_token_claims_without_http_request() {
        let mut provider_config = OAuthProvider::OpenAI
            .default_config(
                "client-id",
                "client-secret",
                "http://127.0.0.1:3000/auth/callback",
            )
            .unwrap();
        let payload = serde_json::json!({
            "sub": "user-123",
            "https://api.openai.com/profile": {
                "email": "user@example.com"
            }
        });
        let encoded_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
            .encode(serde_json::to_vec(&payload).expect("serialize payload"));
        let access_token = format!("header.{encoded_payload}.sig");
        provider_config.userinfo_url = "http://127.0.0.1:1/should-not-be-called".to_string();

        let user_info = fetch_user_info(OAuthProvider::OpenAI, &provider_config, &access_token)
            .await
            .expect("should decode token claims without calling userinfo endpoint");

        assert_eq!(user_info.user_id, "user-123");
        assert_eq!(user_info.email.as_deref(), Some("user@example.com"));
    }

    #[test]
    fn test_format_oauth_token_error_uses_structured_fields() {
        let message = format_oauth_token_error(
            reqwest::StatusCode::BAD_REQUEST,
            r#"{"error":"invalid_grant","error_description":"authorization code already used"}"#,
        );
        assert_eq!(
            message,
            "HTTP 400 Bad Request: invalid_grant: authorization code already used"
        );
    }

    #[test]
    fn test_format_oauth_token_error_falls_back_to_raw_body() {
        let message =
            format_oauth_token_error(reqwest::StatusCode::BAD_REQUEST, "plain-text failure");
        assert_eq!(message, "HTTP 400 Bad Request: plain-text failure");
    }

    // -----------------------------------------------------------------------
    // Display / error tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_provider_display() {
        assert_eq!(format!("{}", OAuthProvider::Google), "google");
        assert_eq!(format!("{}", OAuthProvider::GitHub), "github");
        assert_eq!(format!("{}", OAuthProvider::Discord), "discord");
        assert_eq!(format!("{}", OAuthProvider::OpenAI), "openai");
    }

    #[test]
    fn test_error_display() {
        let err = AuthProfileError::ProviderNotConfigured("google".to_string());
        assert!(err.to_string().contains("Provider not configured"));
        assert!(err.to_string().contains("google"));

        let err = AuthProfileError::TokenExchangeFailed("timeout".to_string());
        assert!(err.to_string().contains("Token exchange failed"));

        let err = AuthProfileError::TokenRefreshFailed("expired".to_string());
        assert!(err.to_string().contains("Token refresh failed"));

        let err = AuthProfileError::UserInfoFailed("403".to_string());
        assert!(err.to_string().contains("User info fetch failed"));

        let err = AuthProfileError::SecretStorageError("encrypt failed".to_string());
        assert!(err.to_string().contains("Secret storage error"));

        let err = AuthProfileError::CredentialTypeMismatch("expected oauth".to_string());
        assert!(err.to_string().contains("Credential type mismatch"));

        let err = AuthProfileError::ProfileNotFound;
        assert!(err.to_string().contains("Profile not found"));

        let err = AuthProfileError::MaxProfilesExceeded;
        assert!(err.to_string().contains("Maximum number of profiles"));
        assert!(err.to_string().contains("20"));

        let err = AuthProfileError::IoError("disk full".to_string());
        assert!(err.to_string().contains("I/O error"));

        let err = AuthProfileError::SerializationError("bad json".to_string());
        assert!(err.to_string().contains("Serialization error"));

        let err = AuthProfileError::InvalidState;
        assert!(err.to_string().contains("Invalid state"));

        let err = AuthProfileError::PkceError("rng fail".to_string());
        assert!(err.to_string().contains("PKCE error"));
    }

    // -----------------------------------------------------------------------
    // PKCE / token computation tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_oauth_tokens_expires_at_computation() {
        let before = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_millis() as u64;

        let expires_at = compute_expires_at_ms(3600);

        let after = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_millis() as u64;

        // expires_at should be now + 3600 seconds
        assert!(expires_at >= before + 3_600_000);
        assert!(expires_at <= after + 3_600_000);
    }

    /// Round-9 deserialization-MEDIUM regression: a hostile / MITM-
    /// reachable / compromised IdP could return a huge `expires_in`
    /// to wrap `now_ms + secs * 1000` past `u64::MAX` and land
    /// `expires_at_ms` at a value `token_valid_at` would treat as
    /// "valid forever", silently defeating the refresh contract. The
    /// clamp at `MAX_REASONABLE_EXPIRES_SECS = 1 year` plus the
    /// `saturating_mul`/`checked_add` chain prevents the wrap; the
    /// returned `expires_at_ms` must land at most 1 year past `now_ms`.
    #[test]
    fn test_compute_expires_at_ms_clamps_huge_input_to_one_year() {
        let before = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_millis() as u64;

        // Worst-case `expires_in` an IdP could return.
        let expires_at = compute_expires_at_ms(u64::MAX);

        let after = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_millis() as u64;

        // Must not have wrapped to near-zero or near-u64::MAX.
        let one_year_ms: u64 = 365 * 24 * 60 * 60 * 1000;
        assert!(
            expires_at >= before + one_year_ms,
            "expires_at must be at least 1 year after now (before={before}, expires_at={expires_at})"
        );
        assert!(
            expires_at <= after + one_year_ms,
            "expires_at must be at MOST 1 year after now — huge expires_in must NOT mint a \
             token valid forever (after={after}, expires_at={expires_at})"
        );
    }

    #[test]
    fn test_compute_expires_at_ms_clamps_value_in_overflow_window_to_one_year() {
        // A more subtle attack: pick `secs` whose `secs * 1000` does
        // NOT overflow but `now_ms + secs * 1000` does — wrapping to
        // a value greater than `now_ms`. The clamp at 1 year must
        // catch this regardless of the wrap arithmetic.
        let before = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_millis() as u64;
        let attacker_secs = 1_u64 << 50;
        let expires_at = compute_expires_at_ms(attacker_secs);
        let one_year_ms: u64 = 365 * 24 * 60 * 60 * 1000;
        assert!(
            expires_at <= before + one_year_ms + 10_000,
            "the 1-year clamp must apply to overflow-window inputs too \
             (expires_at={expires_at}, before+1y={})",
            before + one_year_ms
        );
    }

    #[test]
    fn test_auth_profile_created_at_set() {
        let before_ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_millis() as u64;

        let profile = sample_profile("ts-test");

        let after_ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_millis() as u64;

        assert!(
            profile.created_at_ms >= before_ms && profile.created_at_ms <= after_ms,
            "created_at_ms ({}) should be between {} and {}",
            profile.created_at_ms,
            before_ms,
            after_ms,
        );
    }

    // -----------------------------------------------------------------------
    // OAuthProvider serde tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_oauth_provider_serde_lowercase() {
        let json = serde_json::to_string(&OAuthProvider::Google).unwrap();
        assert_eq!(json, "\"google\"");

        let json = serde_json::to_string(&OAuthProvider::GitHub).unwrap();
        assert_eq!(json, "\"github\"");

        let json = serde_json::to_string(&OAuthProvider::Discord).unwrap();
        assert_eq!(json, "\"discord\"");

        let json = serde_json::to_string(&OAuthProvider::OpenAI).unwrap();
        assert_eq!(json, "\"openai\"");

        // Deserialize
        let provider: OAuthProvider = serde_json::from_str("\"google\"").unwrap();
        assert_eq!(provider, OAuthProvider::Google);

        let provider: OAuthProvider = serde_json::from_str("\"github\"").unwrap();
        assert_eq!(provider, OAuthProvider::GitHub);

        let provider: OAuthProvider = serde_json::from_str("\"discord\"").unwrap();
        assert_eq!(provider, OAuthProvider::Discord);

        let provider: OAuthProvider = serde_json::from_str("\"openai\"").unwrap();
        assert_eq!(provider, OAuthProvider::OpenAI);
    }

    // -----------------------------------------------------------------------
    // URL encoding test
    // -----------------------------------------------------------------------

    #[test]
    fn test_url_encode_special_chars() {
        assert_eq!(url_encode("hello world"), "hello%20world");
        assert_eq!(url_encode("a+b"), "a%2Bb");
        assert_eq!(url_encode("key=val&x=y"), "key%3Dval%26x%3Dy");
        assert_eq!(url_encode("safe-chars_here.ok~"), "safe-chars_here.ok~");
    }

    // -----------------------------------------------------------------------
    // At-rest encryption tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_encrypted_profile_store_roundtrip() {
        let dir = tempdir().unwrap();
        let password = random_password();

        // Create an encrypted store, add a profile, and save
        {
            let store = ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();

            store.add(sample_profile("enc-1")).unwrap();
        }

        // Read the raw file and verify tokens are encrypted on disk
        let raw = std::fs::read_to_string(dir.path().join("auth_profiles.json")).unwrap();
        assert!(
            !raw.contains("access-123"),
            "access_token must not appear in plaintext on disk"
        );
        assert!(
            !raw.contains("refresh-456"),
            "refresh_token must not appear in plaintext on disk"
        );
        assert!(
            raw.contains("enc:v2:"),
            "encrypted tokens should be present on disk"
        );

        // Load the profiles back with a new store derived from the same password
        let store2 = ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();
        store2.load().unwrap();

        let profile = store2.get("enc-1").unwrap();
        assert_eq!(
            oauth_tokens(&profile).access_token,
            "access-123",
            "access_token should be decrypted correctly"
        );
        assert_eq!(
            oauth_tokens(&profile).refresh_token,
            Some("refresh-456".to_string()),
            "refresh_token should be decrypted correctly"
        );
    }

    #[test]
    fn test_no_secret_store_saves_plaintext() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());

        store.add(sample_profile("pt-1")).unwrap();

        let raw = std::fs::read_to_string(dir.path().join("auth_profiles.json")).unwrap();
        assert!(
            raw.contains("access-123"),
            "without SecretStore, tokens should remain plaintext"
        );
        assert!(
            raw.contains("refresh-456"),
            "without SecretStore, refresh_token should remain plaintext"
        );
        assert!(
            !raw.contains("enc:v"),
            "without SecretStore, no encryption prefix should appear"
        );
    }

    #[test]
    fn test_encrypted_store_rejects_plaintext_profile_with_guidance() {
        let dir = tempdir().unwrap();
        let plaintext_store = ProfileStore::new(dir.path().to_path_buf());
        plaintext_store.add(sample_profile("plaintext")).unwrap();

        let password = random_password();
        let encrypted_store =
            ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();
        let err = encrypted_store
            .load()
            .expect_err("plaintext auth profile should fail under encryption");
        let message = err.to_string();

        assert!(message.contains("'plaintext'"), "got: {message}");
        assert!(message.contains("stored in plaintext"), "got: {message}");
        assert!(
            message.contains("do not run the gateway normally without CARAPACE_CONFIG_PASSWORD"),
            "got: {message}"
        );
        assert!(message.contains("maintenance shell"), "got: {message}");
        assert!(
            message.contains("delete or re-create the affected auth profile"),
            "got: {message}"
        );
    }

    #[test]
    fn test_encrypted_store_plaintext_profile_load_error_leaves_store_empty() {
        let dir = tempdir().unwrap();
        let password = random_password();

        {
            let store = ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();
            store.add(sample_profile("encrypted")).unwrap();
        }

        let state_path = dir.path().join("auth_profiles.json");
        let mut envelope: Value =
            serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
        envelope["profiles"]
            .as_array_mut()
            .expect("profiles array")
            .push(serde_json::to_value(sample_profile("plaintext")).unwrap());
        std::fs::write(
            &state_path,
            serde_json::to_string_pretty(&envelope).unwrap(),
        )
        .unwrap();

        let store = ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();
        let err = store
            .load()
            .expect_err("plaintext profile should fail under encryption");

        assert!(err.to_string().contains("'plaintext'"), "got: {err}");
        assert!(
            err.to_string().contains("stored in plaintext"),
            "got: {err}"
        );
        assert!(
            store.shared.profiles.read().is_empty(),
            "failed load must not publish a partially-decrypted profile set"
        );
    }

    #[test]
    fn test_encrypted_store_reports_all_plaintext_profiles() {
        let dir = tempdir().unwrap();
        let plaintext_store = ProfileStore::new(dir.path().to_path_buf());
        plaintext_store.add(sample_profile("plaintext-a")).unwrap();
        plaintext_store.add(sample_profile("plaintext-b")).unwrap();

        let password = random_password();
        let encrypted_store =
            ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();
        let err = encrypted_store
            .load()
            .expect_err("plaintext auth profiles should fail under encryption");
        let message = err.to_string();

        assert!(message.contains("'plaintext-a'"), "got: {message}");
        assert!(message.contains("'plaintext-b'"), "got: {message}");
        assert!(message.contains("access_token"), "got: {message}");
        assert!(message.contains("refresh_token"), "got: {message}");
        assert!(message.contains("affected auth profiles"), "got: {message}");
        assert!(
            encrypted_store.shared.profiles.read().is_empty(),
            "failed load must not publish a partially-decrypted profile set"
        );
    }

    #[test]
    fn test_encrypted_store_no_double_encryption() {
        let dir = tempdir().unwrap();
        let password = random_password();

        // Save a profile (tokens get encrypted on disk)
        {
            let store = ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();
            store.add(sample_profile("de-1")).unwrap();
        }

        // Load and save again -- should not double-encrypt
        let store2 = ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();
        store2.load().unwrap();

        // In-memory tokens should be plaintext
        let profile = store2.get("de-1").unwrap();
        assert!(oauth_tokens(&profile).access_token == "access-123");

        // Force a re-save
        store2.update_last_used("de-1");
        store2.flush_pending_last_used_for_tests().unwrap();

        // Load yet again to verify
        let store3 = ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();
        store3.load().unwrap();

        let profile2 = store3.get("de-1").unwrap();
        assert_eq!(
            oauth_tokens(&profile2).access_token,
            "access-123",
            "tokens should survive multiple save/load cycles"
        );
        assert_eq!(
            oauth_tokens(&profile2).refresh_token,
            Some("refresh-456".to_string()),
        );
    }

    #[test]
    fn test_encrypted_store_no_refresh_token() {
        let dir = tempdir().unwrap();
        let password = random_password();

        // Create a profile without a refresh token
        let mut profile = sample_profile("nrt-1");
        oauth_tokens_mut(&mut profile).refresh_token = None;

        let store = ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();
        store.add(profile).unwrap();

        // Reload and verify
        let store2 = ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();
        store2.load().unwrap();

        let loaded = store2.get("nrt-1").unwrap();
        assert!(oauth_tokens(&loaded).access_token == "access-123");
        assert!(oauth_tokens(&loaded).refresh_token.is_none());
    }

    #[test]
    fn test_encrypted_token_profile_wrong_password_preserves_ciphertext() {
        let dir = tempdir().unwrap();
        let password = random_password();
        let wrong_password = loop {
            let candidate = random_password();
            if candidate != password {
                break candidate;
            }
        };

        {
            let store = ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();
            store
                .add(sample_token_profile(
                    "anthropic:default",
                    OAuthProvider::Anthropic,
                    "token-value-123",
                ))
                .unwrap();
        }

        let state_path = dir.path().join("auth_profiles.json");
        let raw_before = std::fs::read_to_string(&state_path).unwrap();
        let saved_before: serde_json::Value = serde_json::from_str(&raw_before).unwrap();
        let ciphertext_before = saved_before["profiles"][0]["token"]
            .as_str()
            .unwrap()
            .to_string();
        assert!(ciphertext_before.starts_with("enc:v2:"));

        let store =
            ProfileStore::with_encryption(dir.path().to_path_buf(), &wrong_password).unwrap();
        store.load().unwrap();

        let loaded = store.get("anthropic:default").unwrap();
        assert!(!loaded.to_summary().token_valid);

        let err = resolve_anthropic_profile_token(&store, "anthropic:default").unwrap_err();
        assert!(err.contains("could not decrypt the stored token"));
        assert!(err.contains("CARAPACE_CONFIG_PASSWORD"));

        store.update_last_used("anthropic:default");
        store.flush_pending_last_used_for_tests().unwrap();

        let raw_after = std::fs::read_to_string(&state_path).unwrap();
        let saved_after: serde_json::Value = serde_json::from_str(&raw_after).unwrap();
        let ciphertext_after = saved_after["profiles"][0]["token"].as_str().unwrap();
        assert_eq!(ciphertext_after, ciphertext_before);
    }

    #[test]
    fn test_encrypted_store_save_fails_closed_when_secret_encryption_fails() {
        let dir = tempdir().unwrap();
        let password = random_password();
        let store = ProfileStore::with_encryption(dir.path().to_path_buf(), &password).unwrap();

        store
            .add(sample_token_profile(
                "anthropic:default",
                OAuthProvider::Anthropic,
                "token-before",
            ))
            .unwrap();

        let state_path = dir.path().join("auth_profiles.json");
        let raw_before = std::fs::read_to_string(&state_path).unwrap();
        assert!(!raw_before.contains("token-before"));

        let _guard = force_secret_encryption_failure_for_tests();
        let err = store
            .upsert(sample_token_profile(
                "anthropic:default",
                OAuthProvider::Anthropic,
                "token-after",
            ))
            .unwrap_err();
        assert!(
            matches!(err, AuthProfileError::SecretStorageError(ref msg) if msg.contains("token credential")),
            "unexpected error: {err}"
        );

        let raw_after = std::fs::read_to_string(&state_path).unwrap();
        assert_eq!(raw_after, raw_before);
        assert!(!raw_after.contains("token-after"));
    }

    // -------------------------------------------------------------------
    // Store format versioning tests
    // -------------------------------------------------------------------

    #[test]
    fn save_writes_versioned_envelope() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());
        store.load().unwrap();

        {
            let _persist_guard = store.lock_persist();
            let mut guard = store.shared.profiles.write();
            guard.push(sample_profile("envelope-test"));
            store.save_profiles(&guard).unwrap();
        }

        let raw = std::fs::read_to_string(dir.path().join("auth_profiles.json")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
        assert!(
            parsed.is_object(),
            "saved file should be an envelope object"
        );
        assert_eq!(parsed["version"], 2);
        assert!(parsed["profiles"].is_array());
        assert_eq!(parsed["profiles"].as_array().unwrap().len(), 1);
    }

    #[test]
    fn load_v2_envelope() {
        let dir = tempdir().unwrap();
        let state_path = dir.path().join("auth_profiles.json");

        let profile_val = serde_json::to_value(sample_profile("v2-test")).unwrap();
        let envelope = serde_json::json!({
            "version": 2,
            "profiles": [profile_val]
        });
        std::fs::write(
            &state_path,
            serde_json::to_string_pretty(&envelope).unwrap(),
        )
        .unwrap();

        let store = ProfileStore::new(dir.path().to_path_buf());
        store.load().unwrap();
        assert_eq!(store.shared.profiles.read().len(), 1);
        assert_eq!(store.shared.profiles.read()[0].id, "v2-test");
    }

    #[test]
    fn load_bare_array_store_rejected() {
        let dir = tempdir().unwrap();
        let state_path = dir.path().join("auth_profiles.json");

        let profile_val = serde_json::to_value(sample_profile("v1-array")).unwrap();
        std::fs::write(
            &state_path,
            serde_json::to_string_pretty(&serde_json::json!([profile_val])).unwrap(),
        )
        .unwrap();

        let store = ProfileStore::new(dir.path().to_path_buf());
        let err = store.load().unwrap_err();
        assert!(
            err.to_string()
                .contains("unsupported legacy bare-array format"),
            "got: {err}"
        );
        assert!(
            err.to_string().contains("delete the file and re-enroll"),
            "got: {err}"
        );
        assert!(store.shared.profiles.read().is_empty());
    }

    #[test]
    fn unknown_profile_provider_is_skipped_and_preserved_on_load() {
        let dir = tempdir().unwrap();
        let state_path = dir.path().join("auth_profiles.json");

        let good_profile = serde_json::to_value(sample_profile("good")).unwrap();
        let bad_profile = serde_json::json!({
            "id": "bad",
            "name": "Bad Profile",
            "provider": "unknown_future_provider",
            "created_at_ms": 0,
            "credential_kind": "o-auth",
            "tokens": { "access_token": "x", "token_type": "Bearer" }
        });

        let envelope = serde_json::json!({
            "version": 2,
            "profiles": [good_profile, bad_profile]
        });
        std::fs::write(
            &state_path,
            serde_json::to_string_pretty(&envelope).unwrap(),
        )
        .unwrap();

        let store = ProfileStore::new(dir.path().to_path_buf());
        store.load().unwrap();
        assert_eq!(store.shared.profiles.read().len(), 1);
        assert_eq!(store.shared.profiles.read()[0].id, "good");
        assert_eq!(store.shared.skipped_profiles.read().len(), 1);
        store.save().unwrap();

        let saved: Value = serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap())
            .expect("saved store should be valid JSON");
        assert!(saved["profiles"]
            .as_array()
            .unwrap()
            .iter()
            .any(|profile| profile["provider"] == "unknown_future_provider"));
        store.shutdown_last_used_worker();
    }

    #[test]
    fn unknown_credential_kind_is_skipped_and_preserved_on_load() {
        let dir = tempdir().unwrap();
        let state_path = dir.path().join("auth_profiles.json");

        let bad_profile = serde_json::json!({
            "id": "future-kind",
            "name": "Future Kind",
            "provider": "google",
            "created_at_ms": 0,
            "credential_kind": "passkey"
        });

        let envelope = serde_json::json!({
            "version": 2,
            "profiles": [bad_profile]
        });
        std::fs::write(
            &state_path,
            serde_json::to_string_pretty(&envelope).unwrap(),
        )
        .unwrap();

        let store = ProfileStore::new(dir.path().to_path_buf());
        store.load().unwrap();
        assert!(store.shared.profiles.read().is_empty());
        assert_eq!(store.shared.skipped_profiles.read().len(), 1);
        store.save().unwrap();

        let saved: Value = serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap())
            .expect("saved store should be valid JSON");
        assert!(saved["profiles"]
            .as_array()
            .unwrap()
            .iter()
            .any(|profile| profile["credential_kind"] == "passkey"));
        store.shutdown_last_used_worker();
    }

    #[test]
    fn unknown_profile_field_is_ignored_for_current_version_load() {
        let dir = tempdir().unwrap();
        let state_path = dir.path().join("auth_profiles.json");

        let mut profile = serde_json::to_value(sample_profile("strict")).unwrap();
        profile["unknownField"] = serde_json::json!(true);
        let envelope = serde_json::json!({
            "version": 2,
            "profiles": [profile]
        });
        std::fs::write(
            &state_path,
            serde_json::to_string_pretty(&envelope).unwrap(),
        )
        .unwrap();

        let store = ProfileStore::new(dir.path().to_path_buf());
        store.load().unwrap();
        assert_eq!(store.shared.profiles.read().len(), 1);
        assert!(store.shared.skipped_profiles.read().is_empty());
        store.shutdown_last_used_worker();
    }

    #[test]
    fn token_backed_profile_roundtrips_through_v2() {
        let dir = tempdir().unwrap();
        let store = ProfileStore::new(dir.path().to_path_buf());

        {
            let _persist_guard = store.lock_persist();
            let mut guard = store.shared.profiles.write();
            guard.push(sample_token_profile(
                "anthropic:default",
                OAuthProvider::Anthropic,
                "sk-ant-oat01-test-token",
            ));
            store.save_profiles(&guard).unwrap();
        }

        // Reload and verify.
        store.load().unwrap();
        let profiles = store.shared.profiles.read();
        assert_eq!(profiles.len(), 1);
        assert_eq!(profiles[0].id, "anthropic:default");
        assert_eq!(
            profiles[0].credential_kind,
            AuthProfileCredentialKind::Token
        );
        assert_eq!(
            profiles[0].token.as_deref(),
            Some("sk-ant-oat01-test-token")
        );
        assert!(profiles[0].tokens.is_none());
    }

    #[test]
    fn future_version_rejected() {
        let dir = tempdir().unwrap();
        let state_path = dir.path().join("auth_profiles.json");

        let envelope = serde_json::json!({
            "version": 99,
            "profiles": []
        });
        std::fs::write(
            &state_path,
            serde_json::to_string_pretty(&envelope).unwrap(),
        )
        .unwrap();

        let store = ProfileStore::new(dir.path().to_path_buf());
        let err = store.load().unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("99"),
            "error should mention the version: {msg}"
        );
        assert!(msg.contains("newer than supported version 2"), "got: {msg}");
        assert!(msg.contains("upgrade Carapace"), "got: {msg}");
    }
}
