//! OAuth credential store and auth manager for upstream MCP server authentication.
//!
//! Provides [`CompositeCredentialStore`] (OS keyring + file fallback) and
//! [`CompositeStateStore`] (file-based PKCE state) that implement the rmcp
//! `CredentialStore` and `StateStore` traits respectively.

use std::path::PathBuf;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use arc_swap::ArcSwap;
use async_trait::async_trait;
use dashmap::DashMap;
use rmcp::transport::auth::{
    AuthError, CredentialStore, OAuthClientConfig, StateStore, StoredAuthorizationState,
    StoredCredentials,
};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};

use crate::config;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// Default token lifetime when `expires_in` is missing or invalid.
pub const DEFAULT_TOKEN_LIFETIME_SECS: u64 = 3600;

/// Refresh window — refresh this many seconds before expiry.
pub const TOKEN_REFRESH_WINDOW_SECS: u64 = 300;

/// Minimum clamped `expires_in` value.
const MIN_EXPIRES_IN: u64 = 60;

/// Maximum clamped `expires_in` value.
const MAX_EXPIRES_IN: u64 = 86400;

/// Short-lived threshold: if `expires_in < 600`, use the 50% rule.
const SHORT_LIVED_THRESHOLD: u64 = 600;

// ---------------------------------------------------------------------------
// Global store registry
// ---------------------------------------------------------------------------

static STORES: LazyLock<DashMap<String, Arc<CompositeCredentialStore>>> =
    LazyLock::new(DashMap::new);

/// Get or create a per-server [`CompositeCredentialStore`].
///
/// Callers may share the returned `Arc` freely; stores are lazily
/// created and live for the process lifetime.
pub fn get_or_create_store(server_name: &str) -> Arc<CompositeCredentialStore> {
    STORES
        .entry(server_name.to_string())
        .or_insert_with(|| Arc::new(CompositeCredentialStore::new(server_name.to_string())))
        .clone()
}

// ---------------------------------------------------------------------------
// Token helpers
// ---------------------------------------------------------------------------

/// Return the `~/.config/plug/tokens/` directory path.
pub fn tokens_dir() -> PathBuf {
    config::config_dir().join("tokens")
}

/// Normalise an `expires_in` value: apply defaults and clamping.
fn effective_expires_in(expires_in: Option<u64>) -> u64 {
    match expires_in {
        Some(0) | None => DEFAULT_TOKEN_LIFETIME_SECS,
        Some(v) => v.clamp(MIN_EXPIRES_IN, MAX_EXPIRES_IN),
    }
}

/// Determine whether a token should be refreshed now.
///
/// Rules:
/// - If `expires_in` is `None`, `0`: use [`DEFAULT_TOKEN_LIFETIME_SECS`].
/// - Clamp to `[60, 86400]`.
/// - If `expires_in < 600`: refresh at 50% of lifetime.
/// - Otherwise: refresh at `expires_in - TOKEN_REFRESH_WINDOW_SECS`.
pub fn token_needs_refresh(received_at: u64, expires_in: Option<u64>) -> bool {
    let effective = effective_expires_in(expires_in);
    let refresh_at = if effective < SHORT_LIVED_THRESHOLD {
        effective / 2
    } else {
        effective.saturating_sub(TOKEN_REFRESH_WINDOW_SECS)
    };

    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);

    let elapsed = now.saturating_sub(received_at);
    elapsed >= refresh_at
}

/// Compute the [`Duration`] until the refresh window opens.
///
/// Returns [`Duration::ZERO`] if the window is already open.
pub fn time_until_refresh_window(received_at: u64, expires_in: Option<u64>) -> Duration {
    let effective = effective_expires_in(expires_in);
    let refresh_at = if effective < SHORT_LIVED_THRESHOLD {
        effective / 2
    } else {
        effective.saturating_sub(TOKEN_REFRESH_WINDOW_SECS)
    };

    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);

    let elapsed = now.saturating_sub(received_at);
    if elapsed >= refresh_at {
        Duration::ZERO
    } else {
        Duration::from_secs(refresh_at - elapsed)
    }
}

/// Get the current access token for a server from the in-memory cache.
///
/// Returns `None` if no token is cached for the server.
pub fn current_access_token(server_name: &str) -> Option<String> {
    let store = STORES.get(server_name)?;
    let guard = store.cache.load();
    let cached = guard.as_ref().as_ref()?;
    Some(cached.access_token.clone())
}

pub fn credential_debug_snapshot(server_name: &str) -> CredentialDebugSnapshot {
    get_or_create_store(server_name).credential_debug_snapshot()
}

/// Return the current access token for a server, hydrating the in-memory cache
/// from persisted credentials on cache miss.
///
/// This lets a fresh daemon/runtime recover after a successful `plug auth login`
/// without requiring an injected-token IPC path to populate the cache first.
///
/// When a mirrored token file contains newer credentials than the current
/// in-memory cache, prefer the persisted credentials and refresh the cache.
pub async fn current_or_stored_access_token(server_name: &str) -> Option<String> {
    let store = get_or_create_store(server_name);
    let creds = store.current_or_freshest_persisted_credentials().await?;

    use oauth2::TokenResponse;
    creds
        .token_response
        .as_ref()
        .map(|tr| tr.access_token().secret().to_string())
}

// ---------------------------------------------------------------------------
// CachedCredentials
// ---------------------------------------------------------------------------

#[derive(Clone)]
struct CachedCredentials {
    credentials: StoredCredentials,
    access_token: String,
    /// Epoch seconds when the token was received. Used by refresh checks.
    #[allow(dead_code)]
    token_received_at: u64,
    /// Token lifetime in seconds. Used by refresh checks.
    #[allow(dead_code)]
    expires_in: Option<u64>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum CredentialSource {
    Cache,
    File,
    Keyring,
}

#[derive(Clone, Debug)]
pub struct CredentialSnapshot {
    pub credentials: Option<StoredCredentials>,
    pub source: Option<&'static str>,
    pub token_expires_in_secs: Option<u64>,
    pub warnings: Vec<String>,
}

#[derive(Clone, Debug)]
pub struct CredentialDebugSnapshot {
    pub source: Option<&'static str>,
    pub token_received_at: Option<u64>,
    pub token_expires_in_secs: Option<u64>,
    pub client_id: Option<String>,
    pub has_refresh_token: bool,
    pub warnings: Vec<String>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DynamicOauthClientRegistration {
    pub client_id: String,
    pub client_secret: Option<String>,
    pub redirect_uri: String,
}

impl DynamicOauthClientRegistration {
    pub fn to_oauth_client_config(&self, scopes: Vec<String>) -> OAuthClientConfig {
        oauth_client_config(
            self.client_id.clone(),
            self.client_secret.clone(),
            scopes,
            self.redirect_uri.clone(),
        )
    }
}

fn oauth_client_config(
    client_id: String,
    client_secret: Option<String>,
    scopes: Vec<String>,
    redirect_uri: String,
) -> OAuthClientConfig {
    let config = OAuthClientConfig::new(client_id, redirect_uri).with_scopes(scopes);
    if let Some(secret) = client_secret {
        config.with_client_secret(secret)
    } else {
        config
    }
}

impl std::fmt::Debug for CachedCredentials {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("CachedCredentials")
            .field("credentials", &"[REDACTED]")
            .field("access_token", &"[REDACTED]")
            .field("token_received_at", &self.token_received_at)
            .field("expires_in", &self.expires_in)
            .finish()
    }
}

// ---------------------------------------------------------------------------
// CompositeCredentialStore
// ---------------------------------------------------------------------------

/// Credential store that mirrors credentials to a JSON file at
/// `~/.config/plug/tokens/{server_name}.json` and can also use the OS keyring.
///
/// Reads prefer the local 0600 token file once credentials have been mirrored
/// there, which avoids repeated macOS keychain prompts across short-lived CLI
/// processes while preserving the keyring-backed write path.
#[derive(Clone)]
pub struct CompositeCredentialStore {
    server_name: String,
    cache: Arc<ArcSwap<Option<CachedCredentials>>>,
}

impl CompositeCredentialStore {
    /// Create a new store for `server_name`.
    pub fn new(server_name: String) -> Self {
        Self {
            server_name,
            cache: Arc::new(ArcSwap::from_pointee(None)),
        }
    }

    // -- keyring helpers --------------------------------------------------

    fn keyring_entry(&self) -> Option<keyring::Entry> {
        let user = format!("oauth:{}", self.server_name);
        match keyring::Entry::new("plug", &user) {
            Ok(entry) => Some(entry),
            Err(e) => {
                debug!(server = %self.server_name, error = %e, "keyring entry creation failed");
                None
            }
        }
    }

    fn keyring_load(&self) -> Option<StoredCredentials> {
        let entry = self.keyring_entry()?;
        match entry.get_password() {
            Ok(json) => match serde_json::from_str::<StoredCredentials>(&json) {
                Ok(creds) => Some(creds),
                Err(e) => {
                    warn!(server = %self.server_name, error = %e, "keyring: invalid JSON, ignoring");
                    None
                }
            },
            Err(keyring::Error::NoEntry) => None,
            Err(e) => {
                debug!(server = %self.server_name, error = %e, "keyring: load failed");
                None
            }
        }
    }

    fn keyring_save(&self, creds: &StoredCredentials) -> bool {
        let entry = match self.keyring_entry() {
            Some(e) => e,
            None => return false,
        };
        let json = match serde_json::to_string(creds) {
            Ok(j) => j,
            Err(e) => {
                warn!(server = %self.server_name, error = %e, "keyring: serialization failed");
                return false;
            }
        };
        match entry.set_password(&json) {
            Ok(()) => {
                debug!(server = %self.server_name, "keyring: credentials saved");
                true
            }
            Err(e) => {
                debug!(server = %self.server_name, error = %e, "keyring: save failed");
                false
            }
        }
    }

    fn keyring_clear(&self) {
        if let Some(entry) = self.keyring_entry() {
            match entry.delete_credential() {
                Ok(()) => debug!(server = %self.server_name, "keyring: credential deleted"),
                Err(keyring::Error::NoEntry) => {}
                Err(e) => {
                    debug!(server = %self.server_name, error = %e, "keyring: delete failed");
                }
            }
        }
    }

    // -- file helpers -----------------------------------------------------

    fn token_file_path(&self) -> Result<PathBuf, AuthError> {
        let safe = config::sanitize_server_name_for_path(&self.server_name).map_err(|e| {
            AuthError::InternalError(format!("invalid server name for file path: {e}"))
        })?;
        Ok(tokens_dir().join(format!("{safe}.json")))
    }

    fn registration_file_path(&self) -> Result<PathBuf, AuthError> {
        let safe = config::sanitize_server_name_for_path(&self.server_name).map_err(|e| {
            AuthError::InternalError(format!("invalid server name for registration path: {e}"))
        })?;
        Ok(tokens_dir().join(format!("{safe}.registration.json")))
    }

    fn load_json_file<T: DeserializeOwned>(
        &self,
        path: Result<PathBuf, AuthError>,
        file_kind: &'static str,
    ) -> Option<T> {
        let path = match path {
            Ok(path) => path,
            Err(_) => return None,
        };
        let data = match std::fs::read_to_string(&path) {
            Ok(data) => data,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
            Err(error) => {
                warn!(
                    server = %self.server_name,
                    file_kind,
                    error = %error,
                    "credential store read failed"
                );
                return None;
            }
        };
        match serde_json::from_str::<T>(&data) {
            Ok(value) => Some(value),
            Err(error) => {
                warn!(
                    server = %self.server_name,
                    file_kind,
                    error = %error,
                    "credential store JSON invalid; ignoring"
                );
                None
            }
        }
    }

    fn save_json_file<T: Serialize>(
        &self,
        path: PathBuf,
        value: &T,
        file_kind: &'static str,
    ) -> Result<(), AuthError> {
        use fs2::FileExt;
        use std::io::Write;

        let dir = path.parent().ok_or_else(|| {
            AuthError::InternalError(format!("{file_kind} path has no parent directory"))
        })?;
        std::fs::create_dir_all(dir)
            .map_err(|e| AuthError::InternalError(format!("failed to create tokens dir: {e}")))?;

        let json = serde_json::to_string_pretty(value)
            .map_err(|e| AuthError::InternalError(format!("serialization failed: {e}")))?;
        let tmp_path = path.with_extension("json.tmp");

        #[cfg(unix)]
        let file = {
            use std::os::unix::fs::OpenOptionsExt;
            std::fs::OpenOptions::new()
                .write(true)
                .create(true)
                .truncate(true)
                .mode(0o600)
                .open(&tmp_path)
        };
        #[cfg(not(unix))]
        let file = std::fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .open(&tmp_path);

        let mut file = file.map_err(|e| {
            AuthError::InternalError(format!("failed to open temp {file_kind}: {e}"))
        })?;

        file.lock_exclusive().map_err(|e| {
            AuthError::InternalError(format!("failed to lock temp {file_kind}: {e}"))
        })?;

        file.write_all(json.as_bytes()).map_err(|e| {
            let _ = FileExt::unlock(&file);
            AuthError::InternalError(format!("failed to write temp {file_kind}: {e}"))
        })?;

        FileExt::unlock(&file).map_err(|e| {
            AuthError::InternalError(format!("failed to unlock temp {file_kind}: {e}"))
        })?;

        std::fs::rename(&tmp_path, &path).map_err(|e| {
            let _ = std::fs::remove_file(&tmp_path);
            AuthError::InternalError(format!("failed to rename temp {file_kind}: {e}"))
        })?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
        }

        debug!(server = %self.server_name, file_kind, "credential store file saved");
        Ok(())
    }

    fn clear_json_file(&self, path: Result<PathBuf, AuthError>, file_kind: &'static str) {
        if let Ok(path) = path {
            match std::fs::remove_file(&path) {
                Ok(()) => {
                    debug!(server = %self.server_name, file_kind, "credential store file deleted")
                }
                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
                Err(error) => {
                    debug!(
                        server = %self.server_name,
                        file_kind,
                        error = %error,
                        "credential store file delete failed"
                    );
                }
            }
        }
    }

    fn file_load(&self) -> Option<StoredCredentials> {
        self.load_json_file(self.token_file_path(), "token file")
    }

    fn file_save(&self, creds: &StoredCredentials) -> Result<(), AuthError> {
        let path = self.token_file_path()?;
        self.save_json_file(path, creds, "token file")
    }

    fn file_clear(&self) {
        self.clear_json_file(self.token_file_path(), "token file");
    }

    fn registration_load(&self) -> Option<DynamicOauthClientRegistration> {
        self.load_json_file(self.registration_file_path(), "registration file")
    }

    fn registration_save(
        &self,
        registration: &DynamicOauthClientRegistration,
    ) -> Result<(), AuthError> {
        let path = self.registration_file_path()?;
        self.save_json_file(path, registration, "registration file")
    }

    fn registration_clear(&self) {
        self.clear_json_file(self.registration_file_path(), "registration file");
    }

    // -- cache helpers ----------------------------------------------------

    fn update_cache(&self, creds: &StoredCredentials) {
        let cached = creds.token_response.as_ref().map(|tr| {
            // Access the access token via the oauth2 TokenResponse trait.
            // OAuthTokenResponse = StandardTokenResponse which has .access_token() -> &AccessToken
            // and .expires_in() -> Option<Duration>.
            use oauth2::TokenResponse;
            let access_token = tr.access_token().secret().to_string();
            let expires_in = tr.expires_in().map(|d| d.as_secs());
            let token_received_at = creds.token_received_at.unwrap_or_else(|| {
                SystemTime::now()
                    .duration_since(UNIX_EPOCH)
                    .map(|d| d.as_secs())
                    .unwrap_or(0)
            });
            CachedCredentials {
                credentials: creds.clone(),
                access_token,
                token_received_at,
                expires_in,
            }
        });
        self.cache.store(Arc::new(cached));
    }

    fn clear_cache(&self) {
        self.cache.store(Arc::new(None));
    }

    fn token_identity(creds: &StoredCredentials) -> Option<String> {
        use oauth2::TokenResponse;

        creds
            .token_response
            .as_ref()
            .map(|tr| tr.access_token().secret().to_string())
    }

    fn choose_fresher_credentials<'a>(
        lhs: Option<(&'a StoredCredentials, CredentialSource)>,
        rhs: Option<(&'a StoredCredentials, CredentialSource)>,
    ) -> Option<(&'a StoredCredentials, CredentialSource)> {
        match (lhs, rhs) {
            (Some(left), Some(right)) => {
                let left_received = left.0.token_received_at.unwrap_or(0);
                let right_received = right.0.token_received_at.unwrap_or(0);

                if right_received > left_received {
                    Some(right)
                } else if right_received < left_received {
                    Some(left)
                } else if Self::token_identity(left.0) != Self::token_identity(right.0)
                    && right.1 >= left.1
                {
                    Some(right)
                } else {
                    Some(left)
                }
            }
            (Some(left), None) => Some(left),
            (None, Some(right)) => Some(right),
            (None, None) => None,
        }
    }

    /// Return the cached token timing info for refresh-check decisions.
    ///
    /// Returns `(token_received_at, expires_in)` or `None` if no token is cached.
    pub fn cached_expiry(&self) -> Option<(u64, Option<u64>)> {
        let guard = self.cache.load();
        let cached = guard.as_ref().as_ref()?;
        Some((cached.token_received_at, cached.expires_in))
    }

    fn remaining_token_lifetime_secs(creds: &StoredCredentials) -> Option<u64> {
        use oauth2::TokenResponse;

        let token_response = creds.token_response.as_ref()?;
        let effective = token_response
            .expires_in()
            .map(|d| d.as_secs())
            .unwrap_or(DEFAULT_TOKEN_LIFETIME_SECS);
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let received_at = creds.token_received_at.unwrap_or(now);

        Some(effective.saturating_sub(now.saturating_sub(received_at)))
    }

    /// Return the full cached credential bundle, if one has been hydrated in-process.
    pub fn cached_credentials(&self) -> Option<StoredCredentials> {
        let guard = self.cache.load();
        let cached = guard.as_ref().as_ref()?;
        Some(cached.credentials.clone())
    }

    /// Prime the in-memory cache with credentials obtained from another trusted path.
    pub fn hydrate_credentials(&self, creds: &StoredCredentials) {
        self.update_cache(creds);
    }

    pub fn dynamic_client_registration(&self) -> Option<DynamicOauthClientRegistration> {
        self.registration_load()
    }

    pub fn remember_dynamic_client_registration(
        &self,
        config: &OAuthClientConfig,
    ) -> Result<(), AuthError> {
        let registration = DynamicOauthClientRegistration {
            client_id: config.client_id.clone(),
            client_secret: config.client_secret.clone(),
            redirect_uri: config.redirect_uri.clone(),
        };
        self.registration_save(&registration)
    }

    pub async fn clear_credentials_preserve_registration(&self) -> Result<(), AuthError> {
        self.keyring_clear();
        self.file_clear();
        self.clear_cache();
        Ok(())
    }

    /// Return human-readable warnings about backing-store drift between the
    /// mirrored token file and the keyring entry for this server.
    fn compute_backing_store_warnings(
        file: Option<&StoredCredentials>,
        keyring: Option<&StoredCredentials>,
    ) -> Vec<String> {
        let mut warnings = Vec::new();

        match (file, keyring) {
            (Some(_), None) => {
                warnings.push("token file mirror exists but keyring entry is missing".to_string())
            }
            (None, Some(_)) => {
                warnings.push("keyring entry exists but token file mirror is missing".to_string())
            }
            (Some(file), Some(keyring)) => {
                let file_received = file.token_received_at.unwrap_or(0);
                let keyring_received = keyring.token_received_at.unwrap_or(0);
                let file_token = Self::token_identity(file);
                let keyring_token = Self::token_identity(keyring);

                if file_received != keyring_received || file_token != keyring_token {
                    warnings.push(
                        "keyring entry and token file mirror disagree; using freshest persisted credentials"
                            .to_string(),
                    );
                }
            }
            (None, None) => {}
        }

        warnings
    }

    fn persisted_credentials_match(lhs: &StoredCredentials, rhs: &StoredCredentials) -> bool {
        lhs.token_received_at == rhs.token_received_at
            && Self::token_identity(lhs) == Self::token_identity(rhs)
    }

    pub fn backing_store_warnings(&self) -> Vec<String> {
        let file = self.file_load();
        let keyring = self.keyring_load();
        Self::compute_backing_store_warnings(file.as_ref(), keyring.as_ref())
    }

    fn fallback_auth_snapshot_inner(&self) -> CredentialSnapshot {
        let cached = self.cached_credentials();
        let persisted_file = self.file_load();

        let freshest = Self::choose_fresher_credentials(
            cached
                .as_ref()
                .map(|creds| (creds, CredentialSource::Cache)),
            persisted_file
                .as_ref()
                .map(|creds| (creds, CredentialSource::File)),
        );

        match freshest {
            Some((creds, CredentialSource::Cache)) => CredentialSnapshot {
                credentials: Some(creds.clone()),
                source: Some("cache"),
                token_expires_in_secs: Self::remaining_token_lifetime_secs(creds),
                warnings: Vec::new(),
            },
            Some((creds, CredentialSource::File)) => {
                self.update_cache(creds);
                CredentialSnapshot {
                    credentials: Some(creds.clone()),
                    source: Some("file"),
                    token_expires_in_secs: Self::remaining_token_lifetime_secs(creds),
                    warnings: Vec::new(),
                }
            }
            Some((_, CredentialSource::Keyring)) => unreachable!("keyring is not considered here"),
            None => {
                let persisted_keyring = self.keyring_load();
                let warnings = Self::compute_backing_store_warnings(
                    persisted_file.as_ref(),
                    persisted_keyring.as_ref(),
                );

                match persisted_keyring.as_ref() {
                    Some(creds) => {
                        self.update_cache(creds);
                        CredentialSnapshot {
                            credentials: Some(creds.clone()),
                            source: Some("keyring"),
                            token_expires_in_secs: Self::remaining_token_lifetime_secs(creds),
                            warnings,
                        }
                    }
                    None => CredentialSnapshot {
                        credentials: None,
                        source: None,
                        token_expires_in_secs: None,
                        warnings,
                    },
                }
            }
        }
    }

    fn credential_snapshot_inner(&self) -> CredentialSnapshot {
        let cached = self.cached_credentials();
        let persisted_file = self.file_load();
        let persisted_keyring = self.keyring_load();

        let freshest = Self::choose_fresher_credentials(
            cached
                .as_ref()
                .map(|creds| (creds, CredentialSource::Cache)),
            Self::choose_fresher_credentials(
                persisted_file
                    .as_ref()
                    .map(|creds| (creds, CredentialSource::File)),
                persisted_keyring
                    .as_ref()
                    .map(|creds| (creds, CredentialSource::Keyring)),
            ),
        );

        let warnings = Self::compute_backing_store_warnings(
            persisted_file.as_ref(),
            persisted_keyring.as_ref(),
        );

        match freshest {
            Some((creds, CredentialSource::Cache)) => CredentialSnapshot {
                credentials: Some(creds.clone()),
                source: Some("cache"),
                token_expires_in_secs: Self::remaining_token_lifetime_secs(creds),
                warnings,
            },
            Some((creds, source)) => {
                if matches!(source, CredentialSource::Keyring)
                    && persisted_file
                        .as_ref()
                        .map(|file| !Self::persisted_credentials_match(file, creds))
                        .unwrap_or(true)
                {
                    if let Err(error) = self.file_save(creds) {
                        warn!(
                            server = %self.server_name,
                            error = %error,
                            "token file mirror repair failed after keyring-backed load"
                        );
                    }
                }
                self.update_cache(creds);
                CredentialSnapshot {
                    credentials: Some(creds.clone()),
                    source: Some(match source {
                        CredentialSource::Cache => "cache",
                        CredentialSource::File => "file",
                        CredentialSource::Keyring => "keyring",
                    }),
                    token_expires_in_secs: Self::remaining_token_lifetime_secs(creds),
                    warnings,
                }
            }
            None => CredentialSnapshot {
                credentials: None,
                source: None,
                token_expires_in_secs: None,
                warnings,
            },
        }
    }

    pub fn credential_snapshot(&self) -> CredentialSnapshot {
        self.credential_snapshot_inner()
    }

    pub fn fallback_auth_snapshot(&self) -> CredentialSnapshot {
        self.fallback_auth_snapshot_inner()
    }

    pub fn credential_debug_snapshot(&self) -> CredentialDebugSnapshot {
        let snapshot = self.credential_snapshot_inner();
        let (token_received_at, client_id, has_refresh_token) = snapshot
            .credentials
            .as_ref()
            .map(|creds| {
                use oauth2::TokenResponse;

                (
                    creds.token_received_at,
                    Some(creds.client_id.clone()),
                    creds
                        .token_response
                        .as_ref()
                        .is_some_and(|tr| tr.refresh_token().is_some()),
                )
            })
            .unwrap_or((None, None, false));

        CredentialDebugSnapshot {
            source: snapshot.source,
            token_received_at,
            token_expires_in_secs: snapshot.token_expires_in_secs,
            client_id,
            has_refresh_token,
            warnings: snapshot.warnings,
        }
    }

    /// Return the best currently-known credentials for this server.
    ///
    /// When cached credentials exist, prefer a newer mirrored token-file entry
    /// over the in-memory copy. This lets a long-lived daemon recover from
    /// fresher persisted credentials written by another process without
    /// requiring a full restart to clear cache state.
    async fn current_or_freshest_persisted_credentials(&self) -> Option<StoredCredentials> {
        self.credential_snapshot_inner().credentials
    }
}

#[async_trait]
impl CredentialStore for CompositeCredentialStore {
    async fn load(&self) -> Result<Option<StoredCredentials>, AuthError> {
        Ok(self.credential_snapshot_inner().credentials)
    }

    async fn save(&self, credentials: StoredCredentials) -> Result<(), AuthError> {
        // Try keyring first.
        let keyring_ok = self.keyring_save(&credentials);

        // Always try file as well (independent of keyring result).
        let file_result = self.file_save(&credentials);

        // Update in-memory cache regardless of backing-store outcome.
        self.update_cache(&credentials);

        // If both backends failed, propagate the file error.
        if !keyring_ok {
            file_result?;
        } else if let Err(error) = file_result {
            warn!(
                server = %self.server_name,
                error = %error,
                "token file mirror save failed after keyring save; continuing with keyring-backed credentials"
            );
        }

        Ok(())
    }

    async fn clear(&self) -> Result<(), AuthError> {
        self.keyring_clear();
        self.file_clear();
        self.registration_clear();
        self.clear_cache();
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// CompositeStateStore
// ---------------------------------------------------------------------------

/// File-based PKCE state store. Each state entry is stored at
/// `~/.config/plug/tokens/{server_name}_state_{csrf}.json`.
pub struct CompositeStateStore {
    server_name: String,
}

impl CompositeStateStore {
    /// Create a new state store for `server_name`.
    pub fn new(server_name: String) -> Self {
        Self { server_name }
    }

    fn state_file_path(&self, csrf_token: &str) -> Result<PathBuf, AuthError> {
        let safe_server =
            config::sanitize_server_name_for_path(&self.server_name).map_err(|e| {
                AuthError::InternalError(format!("invalid server name for state path: {e}"))
            })?;
        // Sanitise the CSRF token for use in filenames — replace non-alphanumeric
        // chars to avoid path injection.
        let safe_csrf: String = csrf_token
            .chars()
            .map(|c| {
                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                    c
                } else {
                    '_'
                }
            })
            .collect();
        Ok(tokens_dir().join(format!("{safe_server}_state_{safe_csrf}.json")))
    }
}

#[async_trait]
impl StateStore for CompositeStateStore {
    async fn save(
        &self,
        csrf_token: &str,
        state: StoredAuthorizationState,
    ) -> Result<(), AuthError> {
        let path = self.state_file_path(csrf_token)?;
        let dir = path.parent().ok_or_else(|| {
            AuthError::InternalError("state file path has no parent directory".into())
        })?;
        std::fs::create_dir_all(dir)
            .map_err(|e| AuthError::InternalError(format!("failed to create tokens dir: {e}")))?;

        let json = serde_json::to_string_pretty(&state)
            .map_err(|e| AuthError::InternalError(format!("state serialization failed: {e}")))?;

        #[cfg(unix)]
        {
            use std::io::Write;
            use std::os::unix::fs::OpenOptionsExt;
            let mut file = std::fs::OpenOptions::new()
                .write(true)
                .create(true)
                .truncate(true)
                .mode(0o600)
                .open(&path)
                .map_err(|e| AuthError::InternalError(format!("failed to open state file: {e}")))?;
            file.write_all(json.as_bytes()).map_err(|e| {
                AuthError::InternalError(format!("failed to write state file: {e}"))
            })?;
        }

        #[cfg(not(unix))]
        std::fs::write(&path, &json)
            .map_err(|e| AuthError::InternalError(format!("failed to write state file: {e}")))?;

        debug!(server = %self.server_name, "state file saved for CSRF flow");
        Ok(())
    }

    async fn load(&self, csrf_token: &str) -> Result<Option<StoredAuthorizationState>, AuthError> {
        let path = self.state_file_path(csrf_token)?;
        match std::fs::read_to_string(&path) {
            Ok(data) => {
                let state: StoredAuthorizationState = serde_json::from_str(&data).map_err(|e| {
                    AuthError::InternalError(format!("state deserialization failed: {e}"))
                })?;
                Ok(Some(state))
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(AuthError::InternalError(format!(
                "failed to read state file: {e}"
            ))),
        }
    }

    async fn delete(&self, csrf_token: &str) -> Result<(), AuthError> {
        let path = self.state_file_path(csrf_token)?;
        match std::fs::remove_file(&path) {
            Ok(()) => {
                debug!(server = %self.server_name, "state file deleted");
                Ok(())
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(AuthError::InternalError(format!(
                "failed to delete state file: {e}"
            ))),
        }
    }
}

// ---------------------------------------------------------------------------
// Token refresh
// ---------------------------------------------------------------------------

/// Outcome of an OAuth token refresh attempt.
#[derive(Debug)]
pub enum RefreshResult {
    /// Token was successfully refreshed and the global cache updated.
    Refreshed,
    /// Cannot refresh: credentials were injected (no real OAuth client).
    InjectedToken,
    /// Cannot refresh: stored credentials have no refresh_token.
    NoRefreshToken,
    /// Cannot refresh: no stored credentials at all.
    NoCredentials,
    /// Refresh rejected by the authorization server (token revoked, etc.).
    AuthError(String),
    /// Refresh failed due to a transient error (network, metadata, etc.).
    TransientError(String),
}

fn has_token(haystack: &str, needle: &str) -> bool {
    let bytes = haystack.as_bytes();
    let needle_bytes = needle.as_bytes();

    if needle_bytes.is_empty() || bytes.len() < needle_bytes.len() {
        return false;
    }

    bytes
        .windows(needle_bytes.len())
        .enumerate()
        .any(|(idx, window)| {
            if window != needle_bytes {
                return false;
            }

            let start_ok = idx == 0 || !bytes[idx - 1].is_ascii_alphanumeric();
            let end_idx = idx + needle_bytes.len();
            let end_ok = end_idx == bytes.len() || !bytes[end_idx].is_ascii_alphanumeric();

            start_ok && end_ok
        })
}

pub(crate) fn is_auth_error(message: &str) -> bool {
    let normalized = message.to_lowercase();

    normalized.contains("authorization required")
        || normalized.contains("authrequired")
        || has_token(&normalized, "invalid_grant")
        || has_token(&normalized, "invalid_token")
        || normalized.contains("resource_metadata=")
        || has_token(&normalized, "unauthorized")
        || has_token(&normalized, "401")
}

pub(crate) fn is_auth_error_chain(error: &anyhow::Error) -> bool {
    error.chain().any(|cause| is_auth_error(&cause.to_string()))
}

pub fn injected_client_identity(
    is_oauth: bool,
    configured_client_id: Option<&str>,
    existing_client_id: Option<&str>,
    has_refresh_token: bool,
) -> (String, bool) {
    if !is_oauth || !has_refresh_token {
        return ("injected".to_string(), false);
    }

    if let Some(client_id) = configured_client_id {
        return (client_id.to_string(), true);
    }

    if let Some(client_id) = existing_client_id.filter(|client_id| *client_id != "injected") {
        return (client_id.to_string(), true);
    }

    ("injected".to_string(), false)
}

/// Attempt to refresh the OAuth access token for `server_name`.
///
/// Builds an [`rmcp::transport::auth::AuthorizationManager`], discovers
/// server metadata, configures the OAuth client, and calls `refresh_token()`
/// to exchange the stored refresh token for a fresh access token.
///
/// On success the backing stores (keyring/file) and the global in-memory
/// cache are updated before returning.
///
/// Returns [`RefreshResult::InjectedToken`] when `client_id == "injected"`.
pub async fn refresh_access_token(
    server_name: &str,
    server_url: &str,
    oauth_client_id: Option<&str>,
) -> RefreshResult {
    use rmcp::transport::auth::AuthorizationManager;

    // Ensure the TLS provider is available (reqwest needs it even for HTTP).
    crate::tls::ensure_rustls_provider_installed();

    // 1. Load stored credentials from the global store.
    let store = get_or_create_store(server_name);
    let creds = match CredentialStore::load(&*store).await {
        Ok(Some(c)) => c,
        Ok(None) => return RefreshResult::NoCredentials,
        Err(e) => return RefreshResult::TransientError(format!("failed to load credentials: {e}")),
    };
    let load_snapshot = store.credential_debug_snapshot();
    debug!(
        server = %server_name,
        credential_source = ?load_snapshot.source,
        token_received_at = ?load_snapshot.token_received_at,
        token_expires_in_secs = ?load_snapshot.token_expires_in_secs,
        client_id = ?load_snapshot.client_id,
        has_refresh_token = load_snapshot.has_refresh_token,
        warnings = ?load_snapshot.warnings,
        "loaded OAuth credentials for refresh"
    );

    // 2. Injected tokens have no real OAuth client — cannot refresh.
    if creds.client_id == "injected" {
        return RefreshResult::InjectedToken;
    }

    // 3. A refresh_token must be present.
    let has_refresh = creds.token_response.as_ref().is_some_and(|tr| {
        use oauth2::TokenResponse;
        tr.refresh_token().is_some()
    });
    if !has_refresh {
        return RefreshResult::NoRefreshToken;
    }

    // 4. Build an AuthorizationManager for the server URL.
    let mut auth_manager = match AuthorizationManager::new(server_url).await {
        Ok(m) => m,
        Err(e) => {
            return RefreshResult::TransientError(format!("failed to init auth manager: {e}"));
        }
    };

    // Fresh store instance sharing the same backing files/keyring — we
    // cannot reuse the global Arc because `set_credential_store` takes
    // ownership.
    let refresh_store = CompositeCredentialStore::new(server_name.to_string());
    auth_manager.set_credential_store(refresh_store.clone());

    // 5. Discover OAuth server metadata.
    let metadata = match auth_manager.discover_metadata().await {
        Ok(m) => m,
        Err(e) => return RefreshResult::TransientError(format!("metadata discovery failed: {e}")),
    };
    auth_manager.set_metadata(metadata);

    // 6. Configure the OAuth client.
    let client_id = oauth_client_id
        .map(|s| s.to_string())
        .unwrap_or_else(|| creds.client_id.clone());

    let dynamic_registration = store
        .dynamic_client_registration()
        .filter(|registration| registration.client_id == client_id);
    let config = oauth_client_config(
        client_id,
        dynamic_registration
            .as_ref()
            .and_then(|registration| registration.client_secret.clone()),
        creds.granted_scopes.clone(),
        dynamic_registration
            .map(|registration| registration.redirect_uri)
            .unwrap_or_else(|| "http://localhost:0/callback".to_string()),
    );
    if let Err(e) = auth_manager.configure_client(config) {
        return RefreshResult::TransientError(format!("failed to configure client: {e}"));
    }

    // 7. Exchange refresh_token for a new access_token.
    match auth_manager.refresh_token().await {
        Ok(_) => {
            // rmcp's refresh_token() saves via the refresh store and updates its
            // in-memory cache. Copy that fresh credential bundle into the shared
            // store cache instead of re-reading persisted storage and re-hitting
            // the keychain.
            let refreshed = match CredentialStore::load(&refresh_store).await {
                Ok(Some(creds)) => creds,
                Ok(None) => {
                    return RefreshResult::TransientError(
                        "token refreshed but refreshed credentials were unavailable".to_string(),
                    );
                }
                Err(e) => {
                    return RefreshResult::TransientError(format!(
                        "token refreshed but refreshed credentials could not be loaded: {e}"
                    ));
                }
            };
            store.hydrate_credentials(&refreshed);
            let refreshed_snapshot = refresh_store.credential_debug_snapshot();
            debug!(
                server = %server_name,
                credential_source = ?refreshed_snapshot.source,
                token_received_at = ?refreshed_snapshot.token_received_at,
                token_expires_in_secs = ?refreshed_snapshot.token_expires_in_secs,
                client_id = ?refreshed_snapshot.client_id,
                has_refresh_token = refreshed_snapshot.has_refresh_token,
                warnings = ?refreshed_snapshot.warnings,
                "OAuth token refreshed successfully"
            );
            RefreshResult::Refreshed
        }
        Err(e) => {
            let err = anyhow::anyhow!("{e:#}");
            if is_auth_error_chain(&err) {
                RefreshResult::AuthError(format!("{e}"))
            } else {
                RefreshResult::TransientError(format!("{e}"))
            }
        }
    }
}

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

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

    /// Token just received — should not need refresh.
    #[test]
    fn test_token_needs_refresh_not_due() {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        assert!(!token_needs_refresh(now, Some(3600)));
    }

    /// Token near expiry — should need refresh.
    #[test]
    fn test_token_needs_refresh_due() {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        // Received 3500 seconds ago with expires_in = 3600.
        // Refresh window is 3600 - 300 = 3300 seconds. 3500 >= 3300 → true.
        assert!(token_needs_refresh(now - 3500, Some(3600)));
    }

    /// When `expires_in` is `None`, uses the default lifetime.
    #[test]
    fn test_token_needs_refresh_none_expires_in() {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        // Default lifetime is 3600, refresh at 3300.
        // Received 100s ago → not due.
        assert!(!token_needs_refresh(now - 100, None));
        // Received 3400s ago → due (3400 >= 3300).
        assert!(token_needs_refresh(now - 3400, None));
    }

    /// Short-lived token (expires_in < 600) uses the 50% rule.
    #[test]
    fn test_token_needs_refresh_short_lived() {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        // expires_in = 120, clamped to 120 (>= 60). Short-lived: refresh at 60s.
        assert!(!token_needs_refresh(now - 30, Some(120))); // 30 < 60
        assert!(token_needs_refresh(now - 70, Some(120))); // 70 >= 60
    }

    /// Basic computation of time until refresh window.
    #[test]
    fn test_time_until_refresh_window() {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        // expires_in = 3600, refresh at 3300. Received now → 3300s until refresh.
        let dur = time_until_refresh_window(now, Some(3600));
        // Allow 2s tolerance for test execution time.
        assert!(dur.as_secs() >= 3298 && dur.as_secs() <= 3300);

        // Already past refresh window → zero.
        let dur = time_until_refresh_window(now - 3500, Some(3600));
        assert_eq!(dur, Duration::ZERO);
    }

    #[test]
    fn test_is_auth_error_matches_known_auth_failures() {
        assert!(is_auth_error("OAuth authorization required"));
        assert!(is_auth_error("OAuth token refresh failed: invalid_grant"));
        assert!(is_auth_error("server returned 401 unauthorized"));
        assert!(is_auth_error(
            "Bearer resource_metadata=\"https://example.com/.well-known/oauth-protected-resource/mcp\""
        ));
        assert!(is_auth_error(
            "AuthRequired(AuthRequiredError { www_authenticate_header: \"Bearer\" })"
        ));
    }

    #[test]
    fn test_is_auth_error_rejects_metadata_discovery_false_positive() {
        assert!(!is_auth_error(
            "metadata discovery failed: failed to fetch authorization server metadata"
        ));
    }

    #[test]
    fn test_is_auth_error_rejects_port_number_false_positive() {
        assert!(!is_auth_error(
            "connection refused while calling http://127.0.0.1:4018/callback"
        ));
    }

    #[test]
    fn test_is_auth_error_chain_detects_masked_auth_root_cause() {
        let err = anyhow::anyhow!(
            "worker quit with fatal: Transport channel closed, when AuthRequired(AuthRequiredError {{ www_authenticate_header: \"Bearer resource_metadata=\\\"https://example.com/.well-known/oauth-protected-resource/mcp\\\"\" }})"
        )
        .context("failed to connect to HTTP upstream");

        assert!(is_auth_error_chain(&err));
        assert!(!is_auth_error(&err.to_string()));
    }

    #[test]
    fn injected_client_identity_prefers_configured_client_id() {
        let (client_id, refreshable) = injected_client_identity(
            true,
            Some("configured-client"),
            Some("persisted-client"),
            true,
        );
        assert_eq!(client_id, "configured-client");
        assert!(refreshable);
    }

    #[test]
    fn injected_client_identity_reuses_existing_persisted_client_id() {
        let (client_id, refreshable) =
            injected_client_identity(true, None, Some("persisted-client"), true);
        assert_eq!(client_id, "persisted-client");
        assert!(refreshable);
    }

    #[test]
    fn injected_client_identity_falls_back_without_refresh_token() {
        let (client_id, refreshable) = injected_client_identity(
            true,
            Some("configured-client"),
            Some("persisted-client"),
            false,
        );
        assert_eq!(client_id, "injected");
        assert!(!refreshable);
    }

    /// Write credentials to file, read back, and verify.
    #[tokio::test]
    async fn test_file_store_round_trip() {
        use rmcp::transport::auth::StoredCredentials;

        let dir = std::env::temp_dir().join(format!("plug_oauth_test_{}", std::process::id()));
        // Override tokens_dir by using the store's internal file path method
        // indirectly — we test via a store with a simple server name and
        // verify the file-level round trip.

        let server_name = format!("test-server-{}", std::process::id());
        let store = CompositeCredentialStore::new(server_name.clone());

        let creds = StoredCredentials::new(
            "test-client-id".to_string(),
            None,
            vec!["read".to_string(), "write".to_string()],
            Some(1234567890),
        );

        // Save to file (keyring may or may not work in CI).
        let save_result = store.file_save(&creds);
        assert!(save_result.is_ok(), "file_save failed: {save_result:?}");

        // Load back from file.
        let loaded = store.file_load();
        assert!(loaded.is_some(), "file_load returned None");
        let loaded = loaded.unwrap();
        assert_eq!(loaded.client_id, "test-client-id");
        assert_eq!(loaded.granted_scopes, vec!["read", "write"]);
        assert_eq!(loaded.token_received_at, Some(1234567890));

        // Verify file permissions on Unix.
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let path = store.token_file_path().unwrap();
            let meta = std::fs::metadata(&path).unwrap();
            let mode = meta.permissions().mode() & 0o777;
            assert_eq!(mode, 0o600, "token file should have 0600 permissions");
        }

        // Clean up.
        store.file_clear();
        assert!(store.file_load().is_none());

        // Clean up directory.
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn dynamic_registration_round_trip() {
        let server_name = format!("test-registration-{}", std::process::id());
        let store = CompositeCredentialStore::new(server_name);

        let registration = DynamicOauthClientRegistration {
            client_id: "dynamic-client-123".to_string(),
            client_secret: Some("secret-xyz".to_string()),
            redirect_uri: "http://localhost:43189/callback".to_string(),
        };

        store.registration_save(&registration).unwrap();

        let loaded = store
            .dynamic_client_registration()
            .expect("expected dynamic registration");
        assert_eq!(loaded, registration);

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let path = store.registration_file_path().unwrap();
            let meta = std::fs::metadata(&path).unwrap();
            let mode = meta.permissions().mode() & 0o777;
            assert_eq!(
                mode, 0o600,
                "registration file should have 0600 permissions"
            );
        }

        store.registration_clear();
        assert!(store.dynamic_client_registration().is_none());
    }

    #[tokio::test]
    async fn clear_credentials_preserve_registration_keeps_dynamic_client() {
        let name = format!("oauth-preserve-registration-{}", std::process::id());
        let store = get_or_create_store(&name);

        let registration = oauth_client_config(
            "dynamic-client-123".to_string(),
            Some("secret-xyz".to_string()),
            vec!["read".to_string()],
            "http://localhost:43189/callback".to_string(),
        );
        store
            .remember_dynamic_client_registration(&registration)
            .expect("persist registration");
        store
            .save(make_test_token(
                "access-token",
                Some("refresh-token"),
                Some(3600),
            ))
            .await
            .expect("save credentials");

        store
            .clear_credentials_preserve_registration()
            .await
            .expect("clear credentials");

        assert!(store.load().await.unwrap().is_none());
        assert!(store.dynamic_client_registration().is_some());

        store.clear().await.unwrap();
    }

    #[tokio::test]
    async fn clear_removes_dynamic_registration() {
        let name = format!("oauth-clear-registration-{}", std::process::id());
        let store = get_or_create_store(&name);

        let registration = oauth_client_config(
            "dynamic-client-123".to_string(),
            Some("secret-xyz".to_string()),
            vec!["read".to_string()],
            "http://localhost:43189/callback".to_string(),
        );
        store
            .remember_dynamic_client_registration(&registration)
            .expect("persist registration");

        store.clear().await.unwrap();

        assert!(store.dynamic_client_registration().is_none());
    }

    /// Verify that token file paths use sanitized server names.
    #[test]
    fn test_sanitize_integration() {
        // Valid name produces a path.
        let store = CompositeCredentialStore::new("my-server".to_string());
        let path = store.token_file_path().unwrap();
        assert!(path.ends_with("my-server.json"));
        assert!(path.starts_with(tokens_dir()));

        // Invalid name (path separator) returns an error.
        let store = CompositeCredentialStore::new("../evil".to_string());
        assert!(store.token_file_path().is_err());
    }

    // -- refresh_access_token tests ----------------------------------------

    /// Helper: build a synthetic token response for test fixtures.
    fn make_test_token(
        access: &str,
        refresh: Option<&str>,
        expires_in: Option<u64>,
    ) -> rmcp::transport::auth::StoredCredentials {
        use oauth2::{AccessToken, RefreshToken, basic::BasicTokenType};
        use rmcp::transport::auth::VendorExtraTokenFields;

        let mut token =
            oauth2::StandardTokenResponse::<VendorExtraTokenFields, BasicTokenType>::new(
                AccessToken::new(access.to_string()),
                BasicTokenType::Bearer,
                VendorExtraTokenFields::default(),
            );
        if let Some(rt) = refresh {
            token.set_refresh_token(Some(RefreshToken::new(rt.to_string())));
        }
        if let Some(secs) = expires_in {
            token.set_expires_in(Some(&std::time::Duration::from_secs(secs)));
        }
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        StoredCredentials::new("test-client".to_string(), Some(token), vec![], Some(now))
    }

    /// No stored credentials → NoCredentials.
    #[tokio::test]
    async fn test_refresh_no_credentials() {
        let name = format!("refresh-nocreds-{}", std::process::id());
        let result = refresh_access_token(&name, "http://localhost:9999", None).await;
        assert!(matches!(result, RefreshResult::NoCredentials));
    }

    /// Injected token (client_id == "injected") → InjectedToken.
    #[tokio::test]
    async fn test_refresh_injected_token() {
        let name = format!("refresh-injected-{}", std::process::id());
        let store = get_or_create_store(&name);

        let mut creds = make_test_token("test-access", Some("test-refresh"), Some(3600));
        creds.client_id = "injected".to_string();
        store.save(creds).await.unwrap();

        let result = refresh_access_token(&name, "http://localhost:9999", None).await;
        assert!(matches!(result, RefreshResult::InjectedToken));

        // Clean up
        store.clear().await.unwrap();
    }

    /// No refresh_token in stored credentials → NoRefreshToken.
    #[tokio::test]
    async fn test_refresh_no_refresh_token() {
        let name = format!("refresh-norefresh-{}", std::process::id());
        let store = get_or_create_store(&name);

        // Token without a refresh_token
        let creds = make_test_token("test-access", None, Some(3600));
        store.save(creds).await.unwrap();

        let result = refresh_access_token(&name, "http://localhost:9999", None).await;
        assert!(matches!(result, RefreshResult::NoRefreshToken));

        // Clean up
        store.clear().await.unwrap();
    }

    /// Valid credentials but unreachable server → TransientError (metadata
    /// discovery fails).
    #[tokio::test]
    async fn test_refresh_transient_error_unreachable_server() {
        crate::tls::ensure_rustls_provider_installed();
        let name = format!("refresh-transient-{}", std::process::id());
        let store = get_or_create_store(&name);

        let creds = make_test_token("test-access", Some("test-refresh"), Some(3600));
        store.save(creds).await.unwrap();

        // Point at a non-existent server — metadata discovery will fail.
        let result = refresh_access_token(&name, "http://127.0.0.1:1", None).await;
        assert!(
            matches!(result, RefreshResult::TransientError(_)),
            "expected TransientError, got {result:?}"
        );

        // Clean up
        store.clear().await.unwrap();
    }

    /// Persisted credentials should rehydrate the cache on first access after a
    /// cold start / `AuthRequired` state.
    #[tokio::test]
    async fn test_current_or_stored_access_token_hydrates_cache() {
        let name = format!("oauth-hydrate-{}", std::process::id());
        let store = get_or_create_store(&name);

        let creds = make_test_token("persisted-access", Some("persisted-refresh"), Some(3600));
        store.save(creds).await.unwrap();
        store.clear_cache();

        assert!(current_access_token(&name).is_none());

        let token = current_or_stored_access_token(&name).await;
        assert_eq!(token.as_deref(), Some("persisted-access"));
        assert_eq!(
            current_access_token(&name).as_deref(),
            Some("persisted-access")
        );

        store.clear().await.unwrap();
    }

    /// A long-lived daemon can hold stale cached credentials while another
    /// process persists fresher credentials to disk. Reconnect-time token lookup
    /// should prefer the newer persisted token and refresh the shared cache.
    #[tokio::test]
    async fn test_current_or_stored_access_token_prefers_newer_persisted_credentials() {
        let name = format!("oauth-refresh-cache-{}", std::process::id());
        let store = get_or_create_store(&name);

        let mut stale = make_test_token("stale-access", Some("stale-refresh"), Some(3600));
        stale.token_received_at = Some(100);
        store.save(stale).await.unwrap();

        let external_store = CompositeCredentialStore::new(name.clone());
        let mut fresh = make_test_token("fresh-access", Some("fresh-refresh"), Some(3600));
        fresh.token_received_at = Some(200);
        external_store.save(fresh).await.unwrap();

        assert_eq!(
            current_access_token(&name).as_deref(),
            Some("stale-access"),
            "expected shared cache to remain stale before lookup"
        );

        let token = current_or_stored_access_token(&name).await;
        assert_eq!(token.as_deref(), Some("fresh-access"));
        assert_eq!(current_access_token(&name).as_deref(), Some("fresh-access"));

        store.clear().await.unwrap();
    }

    #[tokio::test]
    async fn test_load_prefers_in_memory_cache_before_backing_stores() {
        let name = format!("oauth-cache-first-{}", std::process::id());
        let store = get_or_create_store(&name);

        let creds = make_test_token("cached-access", Some("cached-refresh"), Some(3600));
        store.save(creds.clone()).await.unwrap();

        // Remove persisted backends; load should still succeed from cache.
        store.keyring_clear();
        store.file_clear();

        let loaded = store.load().await.unwrap();
        assert!(
            loaded.is_some(),
            "expected cached credentials to satisfy load"
        );
        let loaded = loaded.unwrap();
        use oauth2::TokenResponse;
        let token = loaded
            .token_response
            .as_ref()
            .map(|tr| tr.access_token().secret().to_string());
        assert_eq!(token.as_deref(), Some("cached-access"));

        store.clear_cache();
    }

    #[tokio::test]
    async fn test_load_prefers_file_mirror_before_keyring_probe() {
        let name = format!("oauth-file-first-{}", std::process::id());
        let store = get_or_create_store(&name);
        store.clear().await.unwrap();

        let mut stale_keyring =
            make_test_token("keyring-access", Some("keyring-refresh"), Some(3600));
        stale_keyring.token_received_at = Some(100);
        assert!(store.keyring_save(&stale_keyring));

        let mut fresh_file = make_test_token("file-access", Some("file-refresh"), Some(3600));
        fresh_file.token_received_at = Some(200);
        store.file_save(&fresh_file).unwrap();
        store.clear_cache();

        let loaded = store
            .load()
            .await
            .unwrap()
            .expect("expected credentials from file mirror");
        use oauth2::TokenResponse;
        let token = loaded
            .token_response
            .as_ref()
            .map(|tr| tr.access_token().secret().to_string());
        assert_eq!(token.as_deref(), Some("file-access"));
        assert_eq!(current_access_token(&name).as_deref(), Some("file-access"));

        store.clear().await.unwrap();
    }

    #[test]
    fn choose_fresher_credentials_prefers_newer_timestamp() {
        let mut older = make_test_token("older-access", Some("older-refresh"), Some(3600));
        older.token_received_at = Some(100);
        let mut newer = make_test_token("newer-access", Some("newer-refresh"), Some(3600));
        newer.token_received_at = Some(200);

        let chosen = CompositeCredentialStore::choose_fresher_credentials(
            Some((&older, CredentialSource::File)),
            Some((&newer, CredentialSource::Keyring)),
        )
        .expect("choose fresher credentials");

        assert_eq!(chosen.0.token_received_at, Some(200));
        assert_eq!(chosen.1, CredentialSource::Keyring);
    }

    #[test]
    fn choose_fresher_credentials_prefers_keyring_on_tied_timestamp_and_different_tokens() {
        let mut file = make_test_token("file-access", Some("file-refresh"), Some(3600));
        file.token_received_at = Some(100);
        let mut keyring = make_test_token("keyring-access", Some("keyring-refresh"), Some(3600));
        keyring.token_received_at = Some(100);

        let chosen = CompositeCredentialStore::choose_fresher_credentials(
            Some((&file, CredentialSource::File)),
            Some((&keyring, CredentialSource::Keyring)),
        )
        .expect("choose fresher credentials");

        assert_eq!(
            CompositeCredentialStore::token_identity(chosen.0).as_deref(),
            Some("keyring-access")
        );
        assert_eq!(chosen.1, CredentialSource::Keyring);
    }

    #[tokio::test]
    async fn backing_store_warnings_report_missing_keyring_entry_when_file_exists() {
        let name = format!("oauth-warn-file-only-{}", std::process::id());
        let store = get_or_create_store(&name);
        store.clear().await.unwrap();

        let creds = make_test_token("file-only-access", Some("file-only-refresh"), Some(3600));
        store.file_save(&creds).unwrap();
        store.keyring_clear();

        let warnings = store.backing_store_warnings();
        assert_eq!(
            warnings,
            vec!["token file mirror exists but keyring entry is missing".to_string()]
        );

        store.clear().await.unwrap();
    }

    #[tokio::test]
    async fn load_repairs_missing_file_mirror_from_keyring() {
        let name = format!("oauth-repair-file-mirror-{}", std::process::id());
        let store = get_or_create_store(&name);
        store.clear().await.unwrap();

        let creds = make_test_token(
            "keyring-only-access",
            Some("keyring-only-refresh"),
            Some(3600),
        );
        store.save(creds.clone()).await.unwrap();

        // Simulate the operator state we hit locally: keyring entry exists, but
        // the non-interactive token-file mirror is gone before a cold read.
        store.file_clear();
        store.clear_cache();

        assert!(
            store.file_load().is_none(),
            "expected file mirror to be missing before load"
        );

        let loaded = store.load().await.unwrap();
        assert!(loaded.is_some(), "expected keyring-backed load to succeed");

        let mirrored = store
            .file_load()
            .expect("expected load to repair the missing token-file mirror");
        use oauth2::TokenResponse;
        let token = mirrored
            .token_response
            .as_ref()
            .map(|tr| tr.access_token().secret().to_string());
        assert_eq!(token.as_deref(), Some("keyring-only-access"));

        store.clear().await.unwrap();
    }

    #[tokio::test]
    async fn fallback_auth_snapshot_uses_keyring_when_file_mirror_is_missing() {
        let name = format!("oauth-fallback-keyring-only-{}", std::process::id());
        let store = get_or_create_store(&name);
        store.clear().await.unwrap();

        let creds = make_test_token(
            "keyring-only-access",
            Some("keyring-only-refresh"),
            Some(3600),
        );
        store.save(creds).await.unwrap();
        store.file_clear();
        store.clear_cache();

        let snapshot = store.fallback_auth_snapshot();
        let loaded = snapshot
            .credentials
            .as_ref()
            .expect("expected keyring-backed fallback auth snapshot");
        use oauth2::TokenResponse;
        let token = loaded
            .token_response
            .as_ref()
            .map(|tr| tr.access_token().secret().to_string());
        assert_eq!(token.as_deref(), Some("keyring-only-access"));
        assert_eq!(snapshot.source, Some("keyring"));
        assert_eq!(
            snapshot.warnings,
            vec!["keyring entry exists but token file mirror is missing".to_string()]
        );
        assert!(
            store.file_load().is_none(),
            "fallback auth snapshot should not repair the token-file mirror"
        );

        store.clear().await.unwrap();
    }
}
