//! On-disk OAuth token model, cache, and lock guard.
//!
//! The cache keeps one JSON document per MCP server URL under
//! `~/.mcptest/auth/<sha256(server_url)>.json`. A sibling `<key>.lock`
//! file provides an advisory file lock so two concurrent runs do not
//! race to refresh the same token. The cache file is written atomically
//! (tmp + rename) and chmod'd to 0600 on Unix so other users on a
//! shared host cannot read tokens at rest.

use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};

use fs2::FileExt;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;

/// Errors raised by the auth cache and refresh layer.
///
/// Refresh errors carry a `retryable` hint so the caller can decide
/// whether to surface the failure to the user immediately or retry on a
/// later request.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum AuthError {
    /// Underlying filesystem error while reading or writing the cache.
    #[error("io error on {}: {source}", path.display())]
    Io {
        /// Path that triggered the error.
        path: PathBuf,
        /// Underlying I/O error.
        #[source]
        source: std::io::Error,
    },

    /// The cache file exists but does not deserialize into a [`Token`].
    /// This usually means the file was hand-edited or corrupted; the
    /// caller should delete it and re-authenticate.
    #[error("malformed token at {}: {reason}", path.display())]
    MalformedToken {
        /// Path to the malformed file.
        path: PathBuf,
        /// Human-readable reason (parser error message).
        reason: String,
    },

    /// Acquiring or releasing the advisory file lock failed.
    #[error("file lock failed on {}: {source}", path.display())]
    LockFailed {
        /// Path to the `.lock` file.
        path: PathBuf,
        /// Underlying I/O error.
        #[source]
        source: std::io::Error,
    },

    /// The HTTP refresh call (run by the [`crate::auth::Refresher`]
    /// implementation) failed. `retryable` is true for transient
    /// failures (network, 5xx) and false for fatal ones (revoked
    /// refresh_token, 400, 401).
    #[error("refresh failed (retryable={retryable}): {reason}")]
    RefreshFailed {
        /// Operator-facing reason.
        reason: String,
        /// True if the caller should try again on the next request.
        retryable: bool,
    },

    /// The cached token has no `refresh_token`, so we cannot refresh it.
    /// The user must re-authenticate.
    #[error("no refresh_token in cached token; re-authenticate")]
    NoRefreshToken,

    /// The config rejected validation. Used by mTLS to require both cert
    /// and key when either is set, and by SigV4 to require region+service.
    #[error("invalid auth config: {reason}")]
    InvalidConfig {
        /// Human-readable reason.
        reason: String,
    },

    /// A private key file is more permissive than 0600 on Unix.
    /// Surfaced as a warning by `mtls::check_key_file_permissions`.
    #[error("key file {} has permissive permissions {mode:o} (recommend 0600)", path.display())]
    PermissiveKeyFile {
        /// Path to the key file.
        path: PathBuf,
        /// Current Unix mode bits.
        mode: u32,
    },

    /// A referenced environment variable was not set. Used when a key or
    /// credential is sourced from an env var that the process does not
    /// have. The variable name is safe to surface; the value is not.
    #[error("environment variable `{name}` is not set")]
    MissingEnvVar {
        /// Name of the missing variable.
        name: String,
    },

    /// Building a TLS client identity or signing a request failed. The
    /// `scheme` names the auth scheme (`mtls`, `aws_sigv4`,
    /// `web_bot_auth`) and `reason` is a non-secret explanation. Private
    /// keys and credentials are never included in `reason`.
    #[error("{scheme} crypto error: {reason}")]
    Crypto {
        /// Auth scheme that failed.
        scheme: &'static str,
        /// Non-secret human-readable reason.
        reason: String,
    },
}

/// A cached OAuth 2.1 access token plus refresh metadata.
///
/// We persist enough to make refresh decisions offline: the expiry
/// (absolute UNIX seconds, not the relative `expires_in` the IdP
/// returned), the optional refresh_token, the scope string, and an
/// ISO-8601 `last_refreshed` timestamp for diagnostics.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Token {
    /// Bearer token presented to the MCP server as `Authorization`.
    pub access_token: String,
    /// Refresh token, when the IdP issued one. Missing means refresh
    /// is impossible and the user must log in again.
    pub refresh_token: Option<String>,
    /// Seconds since UNIX epoch when `access_token` expires.
    pub expires_at_unix: u64,
    /// OAuth `token_type`, almost always `"Bearer"`.
    pub token_type: String,
    /// Granted scope string, when the IdP returned one.
    pub scope: Option<String>,
    /// ISO-8601 timestamp at which this entry was written to disk.
    pub last_refreshed: String,
    /// Token endpoint this token was minted at. Persisted so the connector can
    /// refresh an expired token without re-discovering it. Optional for
    /// backward compatibility with tokens written before this field existed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub token_endpoint: Option<String>,
    /// OAuth client id used to mint this token, needed to refresh it. Optional
    /// for backward compatibility.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub client_id: Option<String>,
}

impl Token {
    /// True when `now_unix` is at or past the expiry deadline.
    pub fn is_expired_at(&self, now_unix: u64) -> bool {
        now_unix >= self.expires_at_unix
    }

    /// True when the token expires within `seconds` from `now_unix`.
    /// Used to refresh proactively a bit before expiry so an in-flight
    /// request does not race the deadline.
    pub fn expires_within(&self, now_unix: u64, seconds: u64) -> bool {
        self.expires_at_unix.saturating_sub(now_unix) <= seconds
    }
}

/// A directory-rooted token cache. One file per server URL.
///
/// Default root is `~/.mcptest/auth/`. Tests override via
/// [`TokenCache::with_root`] to point at a `tempfile::TempDir`.
pub struct TokenCache {
    dir: PathBuf,
}

impl TokenCache {
    /// Build a cache rooted at the user's home directory under
    /// `.mcptest/auth/`. Returns an error only if the home directory
    /// cannot be resolved (very rare; happens on some sandboxes).
    pub fn user_default() -> Result<Self, AuthError> {
        let home = etcetera::home_dir().ok().ok_or_else(|| AuthError::Io {
            path: PathBuf::from("$HOME"),
            source: std::io::Error::new(
                std::io::ErrorKind::NotFound,
                "could not resolve home directory",
            ),
        })?;
        Ok(Self {
            dir: home.join(".mcptest").join("auth"),
        })
    }

    /// Build a cache rooted at an explicit directory. Used by tests
    /// and by users who want the cache somewhere other than `$HOME`.
    pub fn with_root(path: impl Into<PathBuf>) -> Self {
        Self { dir: path.into() }
    }

    /// Cache root directory. Exposed so sibling caches (the DCR client
    /// metadata cache in [`crate::auth::dcr::ClientCache`]) can land
    /// their files next to the OAuth token files for the same server.
    pub fn root(&self) -> &Path {
        &self.dir
    }

    /// SHA-256 hex of the server URL, used as the per-server filename
    /// stem. Hashing avoids filesystem-unfriendly characters in URLs.
    pub fn key_for(&self, server_url: &str) -> String {
        let mut hasher = Sha256::new();
        hasher.update(server_url.as_bytes());
        let digest = hasher.finalize();
        let mut out = String::with_capacity(digest.len() * 2);
        for byte in digest {
            out.push_str(&format!("{byte:02x}"));
        }
        out
    }

    /// Path to the JSON cache file for `server_url`.
    fn json_path(&self, server_url: &str) -> PathBuf {
        self.dir.join(format!("{}.json", self.key_for(server_url)))
    }

    /// Path to the lock file for `server_url`. The lock is taken on a
    /// sibling `.lock` file so we never lock the cache file itself.
    fn lock_path(&self, server_url: &str) -> PathBuf {
        self.dir.join(format!("{}.lock", self.key_for(server_url)))
    }

    /// Read the cached token for `server_url`. Returns `Ok(None)` when
    /// no file exists yet. Surfaces [`AuthError::MalformedToken`] when
    /// the file exists but does not parse.
    pub fn read(&self, server_url: &str) -> Result<Option<Token>, AuthError> {
        let path = self.json_path(server_url);
        let mut file = match File::open(&path) {
            Ok(f) => f,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(e) => return Err(AuthError::Io { path, source: e }),
        };
        let mut buf = String::new();
        file.read_to_string(&mut buf).map_err(|e| AuthError::Io {
            path: path.clone(),
            source: e,
        })?;
        let token: Token = serde_json::from_str(&buf).map_err(|e| AuthError::MalformedToken {
            path: path.clone(),
            reason: e.to_string(),
        })?;
        Ok(Some(token))
    }

    /// Write the token atomically. Creates the cache directory if
    /// missing, writes to `<key>.tmp`, then renames over `<key>.json`.
    /// Chmod 0600 on Unix so other users cannot read it.
    pub fn write(&self, server_url: &str, token: &Token) -> Result<(), AuthError> {
        ensure_dir(&self.dir)?;
        let final_path = self.json_path(server_url);
        let tmp_path = self.dir.join(format!("{}.tmp", self.key_for(server_url)));

        let bytes = serde_json::to_vec_pretty(token).map_err(|e| AuthError::MalformedToken {
            path: final_path.clone(),
            reason: e.to_string(),
        })?;

        write_tmp(&tmp_path, &bytes)?;
        set_owner_only(&tmp_path)?;
        std::fs::rename(&tmp_path, &final_path).map_err(|e| AuthError::Io {
            path: final_path,
            source: e,
        })?;
        Ok(())
    }

    /// Acquire an exclusive advisory file lock on the server's lock
    /// file. Blocks until the lock is granted. Drop the returned guard
    /// to release.
    pub fn lock(&self, server_url: &str) -> Result<TokenLockGuard, AuthError> {
        ensure_dir(&self.dir)?;
        let path = self.lock_path(server_url);
        let file = OpenOptions::new()
            .create(true)
            .read(true)
            .write(true)
            .truncate(false)
            .open(&path)
            .map_err(|e| AuthError::LockFailed {
                path: path.clone(),
                source: e,
            })?;
        FileExt::lock_exclusive(&file).map_err(|e| AuthError::LockFailed {
            path: path.clone(),
            source: e,
        })?;
        Ok(TokenLockGuard { file, path })
    }
}

/// RAII guard that releases the cache file lock on drop.
///
/// We avoid panicking from `Drop` so a failure to unlock just leaves
/// the lock in place until the OS reclaims the file handle.
pub struct TokenLockGuard {
    file: File,
    path: PathBuf,
}

impl TokenLockGuard {
    /// Path to the lock file this guard holds. Useful for diagnostics.
    pub fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for TokenLockGuard {
    fn drop(&mut self) {
        // Best-effort unlock. If this fails the OS will release the lock
        // when the underlying file handle closes.
        let _ = FileExt::unlock(&self.file);
    }
}

fn ensure_dir(dir: &Path) -> Result<(), AuthError> {
    std::fs::create_dir_all(dir).map_err(|e| AuthError::Io {
        path: dir.to_path_buf(),
        source: e,
    })
}

fn write_tmp(tmp_path: &Path, bytes: &[u8]) -> Result<(), AuthError> {
    let mut tmp = OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(true)
        .open(tmp_path)
        .map_err(|e| AuthError::Io {
            path: tmp_path.to_path_buf(),
            source: e,
        })?;
    tmp.write_all(bytes).map_err(|e| AuthError::Io {
        path: tmp_path.to_path_buf(),
        source: e,
    })?;
    tmp.sync_all().map_err(|e| AuthError::Io {
        path: tmp_path.to_path_buf(),
        source: e,
    })?;
    Ok(())
}

#[cfg(unix)]
fn set_owner_only(path: &Path) -> Result<(), AuthError> {
    use std::os::unix::fs::PermissionsExt;
    let perms = std::fs::Permissions::from_mode(0o600);
    std::fs::set_permissions(path, perms).map_err(|e| AuthError::Io {
        path: path.to_path_buf(),
        source: e,
    })
}

#[cfg(not(unix))]
fn set_owner_only(_path: &Path) -> Result<(), AuthError> {
    // Windows ACLs are out of scope for this layer. The cache directory
    // sits under the user's profile already.
    Ok(())
}

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

    fn sample_token() -> Token {
        Token {
            access_token: "at-123".into(),
            refresh_token: Some("rt-abc".into()),
            expires_at_unix: 1_700_000_000,
            token_type: "Bearer".into(),
            scope: Some("mcp:read".into()),
            last_refreshed: "2026-05-15T00:00:00Z".into(),
            token_endpoint: None,
            client_id: None,
        }
    }

    #[test]
    fn token_round_trips_through_serde() {
        let tok = sample_token();
        let json = serde_json::to_string(&tok).unwrap();
        let back: Token = serde_json::from_str(&json).unwrap();
        assert_eq!(tok, back);
    }

    #[test]
    fn is_expired_at_returns_true_past_deadline() {
        let tok = sample_token();
        assert!(!tok.is_expired_at(tok.expires_at_unix - 1));
        assert!(tok.is_expired_at(tok.expires_at_unix));
        assert!(tok.is_expired_at(tok.expires_at_unix + 60));
    }

    #[test]
    fn expires_within_detects_proactive_window() {
        let tok = sample_token();
        let now = tok.expires_at_unix - 30;
        assert!(tok.expires_within(now, 60));
        assert!(!tok.expires_within(now, 10));
    }

    #[test]
    fn read_returns_none_when_missing() {
        let dir = TempDir::new().unwrap();
        let cache = TokenCache::with_root(dir.path());
        assert!(cache.read("https://example.invalid/").unwrap().is_none());
    }

    #[test]
    fn write_creates_parent_dir_and_round_trips() {
        let dir = TempDir::new().unwrap();
        let cache = TokenCache::with_root(dir.path().join("nested").join("auth"));
        let tok = sample_token();
        cache.write("https://srv.invalid/", &tok).unwrap();
        let got = cache.read("https://srv.invalid/").unwrap().unwrap();
        assert_eq!(got, tok);
    }

    #[cfg(unix)]
    #[test]
    fn write_sets_mode_0600_on_unix() {
        use std::os::unix::fs::PermissionsExt;
        let dir = TempDir::new().unwrap();
        let cache = TokenCache::with_root(dir.path());
        let tok = sample_token();
        cache.write("https://srv.invalid/", &tok).unwrap();
        let mode = std::fs::metadata(
            dir.path()
                .join(format!("{}.json", cache.key_for("https://srv.invalid/"))),
        )
        .unwrap()
        .permissions()
        .mode()
            & 0o777;
        assert_eq!(mode, 0o600);
    }

    #[test]
    fn read_of_malformed_file_returns_malformed_token() {
        let dir = TempDir::new().unwrap();
        let cache = TokenCache::with_root(dir.path());
        std::fs::create_dir_all(dir.path()).unwrap();
        let path = dir
            .path()
            .join(format!("{}.json", cache.key_for("https://bad.invalid/")));
        std::fs::write(&path, b"{not valid json").unwrap();
        match cache.read("https://bad.invalid/") {
            Err(AuthError::MalformedToken { path: p, .. }) => assert_eq!(p, path),
            other => panic!("expected MalformedToken, got {other:?}"),
        }
    }

    #[test]
    fn key_for_is_deterministic_and_distinct_per_url() {
        let cache = TokenCache::with_root("/tmp/whatever");
        let a = cache.key_for("https://a.invalid/");
        let b = cache.key_for("https://b.invalid/");
        let a2 = cache.key_for("https://a.invalid/");
        assert_eq!(a, a2);
        assert_ne!(a, b);
        assert_eq!(a.len(), 64);
    }
}
