//! Thread-safe OAuth 2.0 token cache with automatic refresh.
//!
//! `TokenManager` holds a single `reqwest::Client` for token requests and
//! caches the current access token behind an async `Mutex`. Tokens are
//! proactively refreshed 60 seconds before they expire, preventing any
//! request from using a stale bearer token.

use std::sync::Arc;
use std::time::{Duration, Instant};

use tokio::sync::Mutex;

use crate::error::AgentIdPError;
use crate::models::TokenResponse;

/// Internal token cache — holds the raw token string and its calculated expiry.
#[derive(Debug, Default)]
pub(crate) struct TokenCache {
    /// Cached bearer token, or `None` if no token has been fetched yet.
    pub access_token: Option<String>,
    /// Monotonic instant at which the cached token expires (less the 60 s buffer).
    pub expires_at: Option<Instant>,
}

impl TokenCache {
    /// Returns `true` when the cached token is present and has not yet reached
    /// its expiry instant (which already includes the 60 s refresh buffer).
    fn is_valid(&self) -> bool {
        match (&self.access_token, self.expires_at) {
            (Some(_), Some(exp)) => Instant::now() < exp,
            _ => false,
        }
    }
}

/// Thread-safe OAuth 2.0 client-credentials token manager.
///
/// Obtains bearer tokens from the AgentIdP server and caches them until they
/// are within 60 seconds of expiry, at which point the next call to
/// [`get_token`](TokenManager::get_token) transparently fetches a fresh one.
///
/// The inner `Arc<Mutex<TokenCache>>` makes `TokenManager` cheap to clone and
/// safe to share across `tokio` tasks.
///
/// # Example
///
/// ```rust,no_run
/// use sentryagent_idp::TokenManager;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
///     let tm = TokenManager::new("https://api.sentryagent.ai", "client_id", "client_secret");
///     let token = tm.get_token().await?;
///     println!("Bearer {}", token);
///     Ok(())
/// }
/// ```
pub struct TokenManager {
    /// Base URL of the AgentIdP API (no trailing slash).
    pub(crate) api_url: String,
    /// OAuth 2.0 client identifier.
    pub(crate) client_id: String,
    /// OAuth 2.0 client secret.
    pub(crate) client_secret: String,
    /// Shared async token cache.
    pub(crate) cache: Arc<Mutex<TokenCache>>,
    /// Reusable HTTP client for token endpoint requests.
    http: reqwest::Client,
}

impl TokenManager {
    /// Creates a new `TokenManager`. No network calls are made at construction time.
    ///
    /// # Arguments
    ///
    /// * `api_url` — Base URL of the AgentIdP API (e.g. `"https://api.sentryagent.ai"`).
    /// * `client_id` — OAuth 2.0 client identifier.
    /// * `client_secret` — OAuth 2.0 client secret.
    pub fn new(api_url: &str, client_id: &str, client_secret: &str) -> Self {
        Self {
            api_url: api_url.trim_end_matches('/').to_owned(),
            client_id: client_id.to_owned(),
            client_secret: client_secret.to_owned(),
            cache: Arc::new(Mutex::new(TokenCache::default())),
            http: reqwest::Client::new(),
        }
    }

    /// Returns a valid bearer access token.
    ///
    /// If a cached token exists and will not expire within the next 60 seconds,
    /// it is returned immediately without any network call. Otherwise a new
    /// token is fetched from `POST /oauth2/token` and the cache is updated.
    ///
    /// This method is safe to call concurrently from multiple `tokio` tasks —
    /// the `Mutex` ensures only one token fetch occurs at a time.
    pub async fn get_token(&self) -> Result<String, AgentIdPError> {
        let mut cache = self.cache.lock().await;

        if cache.is_valid() {
            // Safety: is_valid() guarantees access_token is Some.
            return Ok(cache.access_token.clone().expect("token present when valid"));
        }

        // Fetch a fresh token.
        let token_resp = self.fetch_token().await?;

        // Expire the cache 60 s before the server-reported expiry so we never
        // hand out a token that is about to become invalid.
        let ttl = token_resp
            .expires_in
            .saturating_sub(60);
        cache.access_token = Some(token_resp.access_token.clone());
        cache.expires_at = Some(Instant::now() + Duration::from_secs(ttl));

        Ok(token_resp.access_token)
    }

    /// Performs the OAuth 2.0 client-credentials grant against the token endpoint.
    async fn fetch_token(&self) -> Result<TokenResponse, AgentIdPError> {
        let token_url = format!("{}/oauth2/token", self.api_url);

        let params = [
            ("grant_type", "client_credentials"),
            ("client_id", self.client_id.as_str()),
            ("client_secret", self.client_secret.as_str()),
        ];

        let resp = self
            .http
            .post(&token_url)
            .form(&params)
            .send()
            .await?;

        if !resp.status().is_success() {
            let status = resp.status().as_u16();
            let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
            let message = body
                .get("message")
                .and_then(|v| v.as_str())
                .unwrap_or("token request failed")
                .to_owned();
            return Err(AgentIdPError::AuthError(format!(
                "token endpoint returned {}: {}",
                status, message
            )));
        }

        let token_resp: TokenResponse = resp.json().await?;
        Ok(token_resp)
    }
}

// ─── Unit tests ───────────────────────────────────────────────────────────────

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

    fn token_body(expires_in: u64) -> String {
        format!(
            r#"{{"access_token":"test-token","token_type":"Bearer","expires_in":{},"scope":"agents:read"}}"#,
            expires_in
        )
    }

    /// `get_token()` should return the cached token on a second call without
    /// hitting the mock server again.
    #[tokio::test]
    async fn test_returns_cached_token() {
        let mut server = Server::new_async().await;
        let mock = server
            .mock("POST", "/oauth2/token")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(token_body(3600))
            .expect(1) // Must be called exactly once.
            .create_async()
            .await;

        let tm = TokenManager::new(&server.url(), "id", "secret");

        let t1 = tm.get_token().await.expect("first call succeeds");
        let t2 = tm.get_token().await.expect("second call succeeds");

        assert_eq!(t1, "test-token");
        assert_eq!(t2, "test-token");
        mock.assert_async().await;
    }

    /// When the cached token's `expires_at` is in the past, `get_token()` must
    /// fetch a new token (i.e. hit the mock server a second time).
    #[tokio::test]
    async fn test_refreshes_expired_token() {
        let mut server = Server::new_async().await;
        let mock = server
            .mock("POST", "/oauth2/token")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(token_body(3600))
            .expect(2) // Must be called twice.
            .create_async()
            .await;

        let tm = TokenManager::new(&server.url(), "id", "secret");

        // First call — populates cache.
        let _ = tm.get_token().await.expect("first call succeeds");

        // Manually expire the cache.
        {
            let mut cache = tm.cache.lock().await;
            cache.expires_at = Some(Instant::now() - Duration::from_secs(1));
        }

        // Second call — cache expired, must fetch again.
        let t2 = tm.get_token().await.expect("second call succeeds");
        assert_eq!(t2, "test-token");
        mock.assert_async().await;
    }

    /// Ten concurrent `get_token()` calls must all succeed and the token
    /// endpoint must be called exactly once (all but the first see the cache).
    #[tokio::test]
    async fn test_concurrent_no_race() {
        let mut server = Server::new_async().await;
        let mock = server
            .mock("POST", "/oauth2/token")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(token_body(3600))
            .expect(1)
            .create_async()
            .await;

        let tm = Arc::new(TokenManager::new(&server.url(), "id", "secret"));

        let handles: Vec<_> = (0..10)
            .map(|_| {
                let tm_clone = Arc::clone(&tm);
                tokio::spawn(async move { tm_clone.get_token().await })
            })
            .collect();

        for handle in handles {
            let result = handle.await.expect("task did not panic");
            assert_eq!(result.expect("get_token succeeded"), "test-token");
        }

        mock.assert_async().await;
    }
}
