//! `reqwest`-backed [`Refresher`] used by the runner to swap an OAuth
//! refresh token for a fresh access token.
//!
//! Why this lives in `mcptest-core` and not the runner crate: the
//! refresh contract is documented on [`Refresher`] and the existing
//! token cache types ship from this crate. Putting the HTTP impl here
//! keeps everything OAuth-shaped in one place; the runner imports a
//! single type instead of plumbing a `dyn Refresher` through an extra
//! crate boundary.
//!
//! The impl uses the blocking `reqwest::blocking::Client` because the
//! [`Refresher`] trait is sync. Refresh is a single short HTTPS POST,
//! so the cost of jumping out of the async runtime once per refresh is
//! negligible next to the round trip.

use std::time::Duration;

use reqwest::blocking::Client;
use serde::Deserialize;

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

/// Operator-facing knobs for [`ReqwestRefresher`]. Defaults match the
/// values  documented.
#[derive(Debug, Clone)]
pub struct ReqwestRefresherConfig {
    /// Token endpoint URL. Required.
    pub token_endpoint: String,
    /// OAuth `client_id`. Required by every RFC 6749 token request.
    pub client_id: String,
    /// OAuth `client_secret`. Optional (PKCE flows omit it).
    pub client_secret: Option<String>,
    /// Total budget for the refresh call, including retries. Defaults
    /// to 15 seconds.
    pub timeout: Duration,
    /// Maximum retry count for transient (network/5xx) failures.
    /// Defaults to 3.
    pub max_retries: u32,
    /// Sleep schedule between retries. The Nth retry waits
    /// `backoff_steps_ms[min(N, len-1)]` milliseconds. Defaults to
    /// `[1000, 2000, 4000]`.
    pub backoff_steps_ms: Vec<u64>,
}

impl Default for ReqwestRefresherConfig {
    fn default() -> Self {
        Self {
            token_endpoint: String::new(),
            client_id: String::new(),
            client_secret: None,
            timeout: Duration::from_secs(15),
            max_retries: 3,
            backoff_steps_ms: vec![1_000, 2_000, 4_000],
        }
    }
}

/// `reqwest::blocking`-backed refresher.
///
/// Construct one per server URL the runner cares about. The struct is
/// `Send + Sync`, so the runner can stuff it inside an `Arc` and share
/// it across worker threads.
pub struct ReqwestRefresher {
    cfg: ReqwestRefresherConfig,
    client: Client,
}

impl ReqwestRefresher {
    /// Build a refresher from `cfg`. Returns an error if the
    /// underlying `reqwest::Client` cannot be constructed (mostly
    /// TLS-backend issues on uncommon platforms).
    pub fn new(cfg: ReqwestRefresherConfig) -> Result<Self, AuthError> {
        let client = Client::builder()
            .timeout(cfg.timeout)
            .build()
            .map_err(|e| AuthError::RefreshFailed {
                reason: format!("reqwest client construction failed: {e}"),
                retryable: false,
            })?;
        Ok(Self { cfg, client })
    }

    /// Used by tests to inject a pre-built client (for example one
    /// pointed at a wiremock instance).
    pub fn with_client(cfg: ReqwestRefresherConfig, client: Client) -> Self {
        Self { cfg, client }
    }
}

impl Refresher for ReqwestRefresher {
    fn refresh(&self, refresh_token: &str) -> Result<Token, AuthError> {
        let mut last_err: Option<AuthError> = None;
        let max_attempts = self.cfg.max_retries.saturating_add(1);
        for attempt in 0..max_attempts {
            match self.try_once(refresh_token) {
                Ok(token) => return Ok(token),
                Err(AuthError::RefreshFailed {
                    retryable: true,
                    reason,
                }) => {
                    last_err = Some(AuthError::RefreshFailed {
                        retryable: true,
                        reason,
                    });
                    if attempt + 1 < max_attempts {
                        let idx = (attempt as usize)
                            .min(self.cfg.backoff_steps_ms.len().saturating_sub(1));
                        let wait = self.cfg.backoff_steps_ms.get(idx).copied().unwrap_or(1_000);
                        std::thread::sleep(Duration::from_millis(wait));
                        continue;
                    }
                }
                Err(other) => return Err(other),
            }
        }
        Err(last_err.unwrap_or(AuthError::RefreshFailed {
            reason: "exhausted retries with no error captured".into(),
            retryable: false,
        }))
    }
}

#[derive(Deserialize)]
struct TokenResponse {
    access_token: String,
    #[serde(default)]
    refresh_token: Option<String>,
    #[serde(default = "default_expires_in")]
    expires_in: u64,
    #[serde(default = "default_token_type")]
    token_type: String,
    #[serde(default)]
    scope: Option<String>,
}

fn default_expires_in() -> u64 {
    3_600
}

fn default_token_type() -> String {
    "Bearer".into()
}

impl ReqwestRefresher {
    fn try_once(&self, refresh_token: &str) -> Result<Token, AuthError> {
        let mut form = vec![
            ("grant_type", "refresh_token".to_string()),
            ("refresh_token", refresh_token.to_string()),
            ("client_id", self.cfg.client_id.clone()),
        ];
        if let Some(secret) = &self.cfg.client_secret {
            form.push(("client_secret", secret.clone()));
        }
        let resp = self
            .client
            .post(&self.cfg.token_endpoint)
            .form(&form)
            .send()
            .map_err(|e| AuthError::RefreshFailed {
                reason: format!("network error: {e}"),
                retryable: e.is_timeout() || e.is_connect(),
            })?;

        let status = resp.status();
        if status.is_success() {
            let body: TokenResponse = resp.json().map_err(|e| AuthError::RefreshFailed {
                reason: format!("malformed token response body: {e}"),
                retryable: false,
            })?;
            return Ok(token_from_response(body));
        }
        let retryable = status.is_server_error();
        // WOR-1507: surface a body-read failure (the IdP's error JSON is the
        // actionable part) instead of blanking it to an empty reason.
        let body = resp
            .text()
            .unwrap_or_else(|e| format!("<error body unreadable: {e}>"));
        let excerpt: String = body.chars().take(200).collect();
        Err(AuthError::RefreshFailed {
            reason: format!("{status}: {excerpt}"),
            retryable,
        })
    }
}

fn token_from_response(body: TokenResponse) -> Token {
    let now_unix = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    Token {
        access_token: body.access_token,
        refresh_token: body.refresh_token,
        expires_at_unix: now_unix.saturating_add(body.expires_in),
        token_type: body.token_type,
        scope: body.scope,
        last_refreshed: format_unix_rfc3339(now_unix),
        // A refresh response does not echo these; the caller re-stamps them on
        // the refreshed token so the next run can refresh again.
        token_endpoint: None,
        client_id: None,
    }
}

fn format_unix_rfc3339(unix: u64) -> String {
    // Same compact RFC3339 formatter used by `runner/step.rs` so the
    // strings round-trip through the auth layer's parser.
    let days_since_epoch = unix / 86_400;
    let sod = unix % 86_400;
    let hour = (sod / 3_600) as u32;
    let minute = ((sod % 3_600) / 60) as u32;
    let second = (sod % 60) as u32;
    let (y, mo, d) = epoch_days_to_ymd(days_since_epoch as i64);
    format!("{y:04}-{mo:02}-{d:02}T{hour:02}:{minute:02}:{second:02}Z")
}

fn epoch_days_to_ymd(days: i64) -> (i32, u32, u32) {
    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
    let doe = (z - era * 146_097) as u32;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe as i32 + (era * 400) as i32;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let year = if m <= 2 { y + 1 } else { y };
    (year, m, d)
}

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

    #[test]
    fn token_from_response_uses_expires_in_relative_to_now() {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let body = TokenResponse {
            access_token: "at".into(),
            refresh_token: Some("rt".into()),
            expires_in: 3_600,
            token_type: "Bearer".into(),
            scope: Some("read".into()),
        };
        let token = token_from_response(body);
        assert!(token.expires_at_unix >= now + 3_590);
        assert!(token.expires_at_unix <= now + 3_610);
        assert_eq!(token.scope.as_deref(), Some("read"));
    }

    #[test]
    fn format_unix_rfc3339_matches_known_anchor() {
        assert_eq!(format_unix_rfc3339(0), "1970-01-01T00:00:00Z");
        assert_eq!(format_unix_rfc3339(1_704_067_200), "2024-01-01T00:00:00Z");
    }
}
