//! OAuth refresh-on-expiry and refresh-on-401 logic.
//!
//! The `Refresher` trait is the seam where transport lives. This crate
//! stays HTTP-free so the runner ([] or successor) can plug in
//! its own async client without forcing every consumer of `mcptest-core`
//! to pull `reqwest` and an async runtime.

use super::token::{AuthError, Token, TokenCache};

/// Abstraction over the OAuth refresh-token endpoint.
///
/// Implementors do the HTTP call. They should retry transient failures
/// (network errors, 5xx) up to 3 times with exponential backoff (1s,
/// 2s, 4s) before surfacing `AuthError::RefreshFailed { retryable: true }`.
/// A 4xx response should map to `RefreshFailed { retryable: false }`.
pub trait Refresher: Send + Sync {
    /// Exchange `refresh_token` for a new [`Token`].
    fn refresh(&self, refresh_token: &str) -> Result<Token, AuthError>;
}

/// Type alias for a boxed [`Refresher`] trait object. The runner crate
/// stores one of these per server config.
pub type BoxedRefresher = Box<dyn Refresher>;

/// Decide whether a 401 response warrants a refresh attempt.
///
/// Returns true only when:
/// - the response status is 401,
/// - the cached token has a `refresh_token`, and
/// - the cached token was written more than 60 seconds ago.
///
/// The 60 second floor guards against tight loops on a broken server
/// that returns 401 even with a fresh access token.
pub fn should_refresh_on_response(response_status: u16, token: &Token, now_unix: u64) -> bool {
    if response_status != 401 {
        return false;
    }
    if token.refresh_token.is_none() {
        return false;
    }
    age_seconds(token, now_unix).is_some_and(|age| age >= 60)
}

/// Best-effort age in seconds from the token's `last_refreshed`
/// timestamp (RFC 3339). Returns `None` if the timestamp does not
/// parse, which makes `should_refresh_on_response` conservative (no
/// refresh) on garbage input.
fn age_seconds(token: &Token, now_unix: u64) -> Option<u64> {
    let parsed = parse_iso8601_unix(&token.last_refreshed)?;
    Some(now_unix.saturating_sub(parsed))
}

/// Minimal RFC 3339 parser for the `last_refreshed` field.
///
/// We only need to handle the subset our writers produce
/// (`YYYY-MM-DDTHH:MM:SSZ`). A real time crate would be overkill for
/// one field. Returns `None` on any deviation.
fn parse_iso8601_unix(s: &str) -> Option<u64> {
    // Format: 2026-05-15T00:00:00Z
    if s.len() < 20 || !s.ends_with('Z') {
        return None;
    }
    let year: i64 = s.get(0..4)?.parse().ok()?;
    let month: u32 = s.get(5..7)?.parse().ok()?;
    let day: u32 = s.get(8..10)?.parse().ok()?;
    let hour: u32 = s.get(11..13)?.parse().ok()?;
    let minute: u32 = s.get(14..16)?.parse().ok()?;
    let second: u32 = s.get(17..19)?.parse().ok()?;
    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
        return None;
    }
    if hour >= 24 || minute >= 60 || second >= 60 {
        return None;
    }
    let days_since_epoch = super::civil_date::ymd_to_epoch_days(year, month, day)?;
    let secs =
        days_since_epoch as u64 * 86_400 + hour as u64 * 3_600 + minute as u64 * 60 + second as u64;
    Some(secs)
}

/// Return a usable access token, refreshing on disk if needed.
///
/// Behavior:
/// - If no cached token, surfaces `AuthError::NoRefreshToken` (the user
///   has not logged in yet and there is nothing to refresh).
/// - If the cached token has more than `proactive_refresh_window_seconds`
///   of lifetime, returns it unchanged.
/// - Otherwise acquires the file lock, re-reads the token (another
///   process may have already refreshed), and if it still needs
///   refresh, calls `refresher.refresh(...)`. Writes the new token
///   atomically and returns it.
pub fn maybe_refresh(
    cache: &TokenCache,
    server_url: &str,
    refresher: &dyn Refresher,
    now_unix: u64,
    proactive_refresh_window_seconds: u64,
) -> Result<Token, AuthError> {
    let current = cache.read(server_url)?.ok_or(AuthError::NoRefreshToken)?;
    if !current.expires_within(now_unix, proactive_refresh_window_seconds) {
        return Ok(current);
    }

    // Need to refresh. Serialize across processes with the lock, then
    // re-read in case another process beat us to it.
    let _guard = cache.lock(server_url)?;
    let after_lock = cache.read(server_url)?.ok_or(AuthError::NoRefreshToken)?;
    if !after_lock.expires_within(now_unix, proactive_refresh_window_seconds) {
        return Ok(after_lock);
    }

    let refresh_token = after_lock
        .refresh_token
        .as_deref()
        .ok_or(AuthError::NoRefreshToken)?;
    let new_token = refresher.refresh(refresh_token)?;
    cache.write(server_url, &new_token)?;
    Ok(new_token)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::auth::token::Token;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::{Arc, Mutex};
    use std::thread;
    use std::time::Duration;
    use tempfile::TempDir;

    const URL: &str = "https://srv.invalid/mcp";
    const NOW: u64 = 1_700_000_000;

    fn fresh_token() -> Token {
        Token {
            access_token: "at-old".into(),
            refresh_token: Some("rt-old".into()),
            expires_at_unix: NOW + 600,
            token_type: "Bearer".into(),
            scope: None,
            last_refreshed: "2026-05-14T00:00:00Z".into(),
            token_endpoint: None,
            client_id: None,
        }
    }

    fn near_expiry_token() -> Token {
        Token {
            access_token: "at-old".into(),
            refresh_token: Some("rt-old".into()),
            expires_at_unix: NOW + 10,
            token_type: "Bearer".into(),
            scope: None,
            last_refreshed: "2026-05-14T00:00:00Z".into(),
            token_endpoint: None,
            client_id: None,
        }
    }

    fn refreshed_token() -> Token {
        Token {
            access_token: "at-NEW".into(),
            refresh_token: Some("rt-NEW".into()),
            expires_at_unix: NOW + 3_600,
            token_type: "Bearer".into(),
            scope: None,
            last_refreshed: "2026-05-15T00:00:00Z".into(),
            token_endpoint: None,
            client_id: None,
        }
    }

    struct CountingRefresher {
        calls: Arc<AtomicUsize>,
        out: Token,
        delay: Duration,
    }

    impl Refresher for CountingRefresher {
        fn refresh(&self, _refresh_token: &str) -> Result<Token, AuthError> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            if !self.delay.is_zero() {
                thread::sleep(self.delay);
            }
            Ok(self.out.clone())
        }
    }

    struct ErroringRefresher;

    impl Refresher for ErroringRefresher {
        fn refresh(&self, _refresh_token: &str) -> Result<Token, AuthError> {
            Err(AuthError::RefreshFailed {
                reason: "503 from idp".into(),
                retryable: true,
            })
        }
    }

    #[test]
    fn maybe_refresh_returns_cached_when_not_expiring() {
        let dir = TempDir::new().unwrap();
        let cache = TokenCache::with_root(dir.path());
        cache.write(URL, &fresh_token()).unwrap();
        let calls = Arc::new(AtomicUsize::new(0));
        let r = CountingRefresher {
            calls: calls.clone(),
            out: refreshed_token(),
            delay: Duration::ZERO,
        };
        let got = maybe_refresh(&cache, URL, &r, NOW, 60).unwrap();
        assert_eq!(got, fresh_token());
        assert_eq!(calls.load(Ordering::SeqCst), 0);
    }

    #[test]
    fn maybe_refresh_calls_refresher_within_proactive_window() {
        let dir = TempDir::new().unwrap();
        let cache = TokenCache::with_root(dir.path());
        cache.write(URL, &near_expiry_token()).unwrap();
        let calls = Arc::new(AtomicUsize::new(0));
        let r = CountingRefresher {
            calls: calls.clone(),
            out: refreshed_token(),
            delay: Duration::ZERO,
        };
        let got = maybe_refresh(&cache, URL, &r, NOW, 60).unwrap();
        assert_eq!(got, refreshed_token());
        assert_eq!(calls.load(Ordering::SeqCst), 1);
    }

    #[test]
    fn maybe_refresh_persists_new_token_to_disk() {
        let dir = TempDir::new().unwrap();
        let cache = TokenCache::with_root(dir.path());
        cache.write(URL, &near_expiry_token()).unwrap();
        let calls = Arc::new(AtomicUsize::new(0));
        let r = CountingRefresher {
            calls,
            out: refreshed_token(),
            delay: Duration::ZERO,
        };
        maybe_refresh(&cache, URL, &r, NOW, 60).unwrap();
        let on_disk = cache.read(URL).unwrap().unwrap();
        assert_eq!(on_disk, refreshed_token());
    }

    #[test]
    fn maybe_refresh_returns_no_refresh_token_when_missing() {
        let dir = TempDir::new().unwrap();
        let cache = TokenCache::with_root(dir.path());
        let mut t = near_expiry_token();
        t.refresh_token = None;
        cache.write(URL, &t).unwrap();
        let calls = Arc::new(AtomicUsize::new(0));
        let r = CountingRefresher {
            calls,
            out: refreshed_token(),
            delay: Duration::ZERO,
        };
        match maybe_refresh(&cache, URL, &r, NOW, 60) {
            Err(AuthError::NoRefreshToken) => {}
            other => panic!("expected NoRefreshToken, got {other:?}"),
        }
    }

    #[test]
    fn maybe_refresh_surfaces_refresher_error() {
        let dir = TempDir::new().unwrap();
        let cache = TokenCache::with_root(dir.path());
        cache.write(URL, &near_expiry_token()).unwrap();
        match maybe_refresh(&cache, URL, &ErroringRefresher, NOW, 60) {
            Err(AuthError::RefreshFailed {
                retryable: true, ..
            }) => {}
            other => panic!("expected retryable RefreshFailed, got {other:?}"),
        }
    }

    #[test]
    fn concurrent_maybe_refresh_dedupes_via_file_lock() {
        let dir = TempDir::new().unwrap();
        let cache = Arc::new(TokenCache::with_root(dir.path().to_path_buf()));
        cache.write(URL, &near_expiry_token()).unwrap();

        let calls = Arc::new(AtomicUsize::new(0));
        let observed = Arc::new(Mutex::new(Vec::<Token>::new()));

        let mut handles = Vec::new();
        for _ in 0..4 {
            let cache = cache.clone();
            let calls = calls.clone();
            let observed = observed.clone();
            handles.push(thread::spawn(move || {
                // Each thread builds its own Refresher so the closure
                // does not need to be Sync; they all increment the
                // shared atomic counter.
                let r = CountingRefresher {
                    calls,
                    out: refreshed_token(),
                    delay: Duration::from_millis(50),
                };
                let tok = maybe_refresh(&cache, URL, &r, NOW, 60).unwrap();
                observed.lock().unwrap().push(tok);
            }));
        }
        for h in handles {
            h.join().unwrap();
        }

        assert_eq!(
            calls.load(Ordering::SeqCst),
            1,
            "refresher must be called exactly once"
        );
        let observed = observed.lock().unwrap();
        assert_eq!(observed.len(), 4);
        for tok in observed.iter() {
            assert_eq!(tok, &refreshed_token());
        }
    }

    #[test]
    fn should_refresh_returns_true_for_401_with_refresh_token() {
        let mut tok = fresh_token();
        // Anchor last_refreshed well before NOW so age is comfortably
        // over the 60s floor.
        tok.last_refreshed = "2023-11-13T00:00:00Z".into();
        assert!(should_refresh_on_response(401, &tok, NOW));
    }

    #[test]
    fn should_refresh_returns_false_for_200() {
        let tok = fresh_token();
        assert!(!should_refresh_on_response(200, &tok, NOW));
    }

    #[test]
    fn should_refresh_returns_false_when_no_refresh_token() {
        let mut tok = fresh_token();
        tok.refresh_token = None;
        assert!(!should_refresh_on_response(401, &tok, NOW));
    }

    #[test]
    fn should_refresh_returns_false_when_token_just_refreshed() {
        let mut tok = fresh_token();
        // last_refreshed less than 60s before now means we just refreshed.
        // Compute now_unix that corresponds to the parsed timestamp + 30s.
        let parsed = parse_iso8601_unix("2026-05-15T00:00:00Z").unwrap();
        tok.last_refreshed = "2026-05-15T00:00:00Z".into();
        assert!(!should_refresh_on_response(401, &tok, parsed + 30));
        // Boundary: exactly 60s old is allowed to refresh.
        assert!(should_refresh_on_response(401, &tok, parsed + 60));
    }

    #[test]
    fn parse_iso8601_handles_known_anchor() {
        // 1970-01-01T00:00:00Z is 0.
        assert_eq!(parse_iso8601_unix("1970-01-01T00:00:00Z"), Some(0));
        // 2024-01-01T00:00:00Z is 1704067200.
        assert_eq!(
            parse_iso8601_unix("2024-01-01T00:00:00Z"),
            Some(1_704_067_200)
        );
        // Reject obviously broken input rather than guessing.
        assert_eq!(parse_iso8601_unix("not a date"), None);
        assert_eq!(parse_iso8601_unix("2024-13-01T00:00:00Z"), None);
    }
}
