//! OAuth 2.0 device authorization grant (RFC 8628) primitives (WOR-1286).
//!
//! Why this lives in `mcptest-core`: `mcptest login --device` mints a
//! token on machines with no local browser (SSH boxes, containers) by
//! printing a verification URL plus user code and polling the token
//! endpoint until a human approves on another device. The request/poll
//! pair sits here, next to the PKCE primitives, so the binary stays a
//! thin shell and the two grants share one error type, one token shape,
//! and one timeout budget.
//!
//! The poll loop never sleeps on its own. It asks the caller-supplied
//! sleeper to wait, which lets tests record the requested durations
//! instead of paying for real wall-clock time.

use std::time::Duration;

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

use super::oauth_pkce::{
    oauth_error_from_response, token_from_response, OAuthError, TokenResponse, EXCHANGE_TIMEOUT,
};
use super::token::Token;

/// RFC 8628 section 3.4 grant type for the device-code token request.
const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";

/// Seconds added to the polling interval on each `slow_down` response,
/// per RFC 8628 section 3.5.
const SLOW_DOWN_INCREMENT: u64 = 5;

/// Inputs to [`request_device_authorization`]. Grouped in a struct, like
/// [`super::oauth_pkce::TokenExchangeRequest`], so callers cannot
/// transpose the endpoint and the client id.
#[derive(Debug, Clone)]
pub struct DeviceAuthorizationRequest {
    /// `device_authorization_endpoint` from the IdP's RFC 8414 metadata.
    pub device_authorization_endpoint: String,
    /// OAuth `client_id` (from static config or DCR).
    pub client_id: String,
    /// Space-separated scope string. Empty means the parameter is
    /// omitted entirely, matching the PKCE flow's convention.
    pub scope: String,
}

/// Successful device authorization response (RFC 8628 section 3.2).
///
/// Carried whole through the poll loop because the loop needs both the
/// `device_code` (resent on every poll) and the `interval`/`expires_in`
/// pacing fields the IdP chose.
#[derive(Debug, Clone, Deserialize)]
pub struct DeviceAuthorization {
    /// Opaque code the client presents on every token poll.
    pub device_code: String,
    /// Short code the human types at `verification_uri`.
    pub user_code: String,
    /// URL the human opens on any other device.
    pub verification_uri: String,
    /// Optional URL with the user code already embedded, for IdPs that
    /// support one-click approval.
    #[serde(default)]
    pub verification_uri_complete: Option<String>,
    /// Lifetime of the device code in seconds. The poll loop gives up
    /// once its requested sleeps would exceed this budget.
    pub expires_in: u64,
    /// Seconds to wait between token polls. RFC 8628 section 3.2 says
    /// clients must assume 5 when the IdP omits it.
    #[serde(default = "default_interval")]
    pub interval: u64,
}

fn default_interval() -> u64 {
    5
}

/// Inputs to [`poll_device_token`]. Mirrors the shape of the other
/// token-endpoint request structs in this module family.
#[derive(Debug, Clone)]
pub struct DeviceTokenRequest {
    /// Token endpoint discovered from the IdP metadata.
    pub token_endpoint: String,
    /// `client_id` from static config or DCR.
    pub client_id: String,
    /// Optional `client_secret`. Public clients omit it.
    pub client_secret: Option<String>,
    /// `device_code` from the device authorization response.
    pub device_code: String,
}

/// POST the device authorization request and parse the response, using
/// a fresh blocking client with the shared 15 second budget. Blocking on
/// purpose: `mcptest login --device` owns the terminal and the request
/// is a single short POST, matching [`super::oauth_pkce::exchange_code`].
pub fn request_device_authorization(
    req: &DeviceAuthorizationRequest,
) -> Result<DeviceAuthorization, OAuthError> {
    let client = Client::builder()
        .timeout(EXCHANGE_TIMEOUT)
        .build()
        .map_err(|e| OAuthError::Network {
            reason: format!("reqwest client construction failed: {e}"),
            retryable: false,
        })?;
    device_authorization_with_client(&client, req)
}

/// Variant of [`request_device_authorization`] taking a pre-built
/// client, so tests can point at a `wiremock` server without rebuilding
/// the TLS stack per case.
pub fn device_authorization_with_client(
    client: &Client,
    req: &DeviceAuthorizationRequest,
) -> Result<DeviceAuthorization, OAuthError> {
    let mut form: Vec<(&str, String)> = vec![("client_id", req.client_id.clone())];
    if !req.scope.is_empty() {
        form.push(("scope", req.scope.clone()));
    }

    let resp = client
        .post(&req.device_authorization_endpoint)
        .form(&form)
        .send()
        .map_err(|e| OAuthError::Network {
            reason: format!("network error: {e}"),
            retryable: e.is_timeout() || e.is_connect(),
        })?;

    let status = resp.status();
    // WOR-1507: surface a body-read failure instead of blanking it to empty;
    // the IdP's error JSON (invalid_grant, slow_down, expired_token) is the
    // actionable part of a failed device-code exchange.
    let body = resp
        .text()
        .unwrap_or_else(|e| format!("<response body unreadable: {e}>"));
    if status.is_success() {
        return serde_json::from_str::<DeviceAuthorization>(&body).map_err(|e| {
            OAuthError::Malformed {
                reason: e.to_string(),
            }
        });
    }
    Err(oauth_error_from_response(status, &body))
}

/// One poll of the token endpoint, classified per RFC 8628 section 3.5.
/// `Pending` and `SlowDown` keep the loop alive; everything else either
/// grants a token or is fatal.
enum PollOutcome {
    /// The human approved; here is the token.
    Granted(Token),
    /// `authorization_pending`: keep polling at the current interval.
    Pending,
    /// `slow_down`: keep polling, but stretch the interval first.
    SlowDown,
}

/// Poll the token endpoint with the device-code grant until the human
/// approves, the IdP returns a terminal error, or the device code's
/// `expires_in` budget is spent. `now_unix` seeds `expires_at_unix` on
/// the granted [`Token`]; `sleep` performs the between-poll waits so
/// production passes `std::thread::sleep` and tests record durations.
pub fn poll_device_token(
    client: &Client,
    req: &DeviceTokenRequest,
    authz: &DeviceAuthorization,
    now_unix: u64,
    sleep: &mut dyn FnMut(Duration),
) -> Result<Token, OAuthError> {
    let mut interval = authz.interval;
    let mut slept: u64 = 0;
    loop {
        match poll_once(client, req, now_unix)? {
            PollOutcome::Granted(token) => return Ok(token),
            PollOutcome::Pending => {}
            PollOutcome::SlowDown => interval = interval.saturating_add(SLOW_DOWN_INCREMENT),
        }
        // Budget the *requested* sleep time against the device code's
        // lifetime: once the next wait would push us past `expires_in`,
        // the code is dead and another poll would only earn an
        // `expired_token` from the IdP anyway.
        slept = slept.saturating_add(interval);
        if slept > authz.expires_in {
            return Err(OAuthError::OAuth {
                code: "expired_token".into(),
                description: format!(
                    "device code expired after {}s without approval",
                    authz.expires_in
                ),
            });
        }
        sleep(Duration::from_secs(interval));
    }
}

/// Run one token-endpoint POST and classify the response. The retryable
/// RFC 8628 codes become [`PollOutcome`] variants; anything else (for
/// example `access_denied` or `expired_token`) surfaces as the error.
fn poll_once(
    client: &Client,
    req: &DeviceTokenRequest,
    now_unix: u64,
) -> Result<PollOutcome, OAuthError> {
    let mut form: Vec<(&str, String)> = vec![
        ("grant_type", DEVICE_GRANT_TYPE.into()),
        ("device_code", req.device_code.clone()),
        ("client_id", req.client_id.clone()),
    ];
    if let Some(secret) = &req.client_secret {
        form.push(("client_secret", secret.clone()));
    }

    let resp = client
        .post(&req.token_endpoint)
        .form(&form)
        .send()
        .map_err(|e| OAuthError::Network {
            reason: format!("network error: {e}"),
            retryable: e.is_timeout() || e.is_connect(),
        })?;

    let status = resp.status();
    // WOR-1507: surface a body-read failure instead of blanking it to empty;
    // the IdP's error JSON (invalid_grant, slow_down, expired_token) is the
    // actionable part of a failed device-code exchange.
    let body = resp
        .text()
        .unwrap_or_else(|e| format!("<response body unreadable: {e}>"));
    if status.is_success() {
        let parsed: TokenResponse =
            serde_json::from_str(&body).map_err(|e| OAuthError::Malformed {
                reason: e.to_string(),
            })?;
        return Ok(PollOutcome::Granted(token_from_response(parsed, now_unix)));
    }
    match oauth_error_from_response(status, &body) {
        OAuthError::OAuth { ref code, .. } if code == "authorization_pending" => {
            Ok(PollOutcome::Pending)
        }
        OAuthError::OAuth { ref code, .. } if code == "slow_down" => Ok(PollOutcome::SlowDown),
        err => Err(err),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use wiremock::matchers::{body_string_contains, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    fn rt() -> tokio::runtime::Runtime {
        tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap()
    }

    fn http() -> Client {
        Client::builder().build().unwrap()
    }

    fn authz(expires_in: u64, interval: u64) -> DeviceAuthorization {
        DeviceAuthorization {
            device_code: "dev-123".into(),
            user_code: "WDJB-MJHT".into(),
            verification_uri: "https://idp.invalid/activate".into(),
            verification_uri_complete: None,
            expires_in,
            interval,
        }
    }

    fn token_req(uri: &str) -> DeviceTokenRequest {
        DeviceTokenRequest {
            token_endpoint: format!("{uri}/token"),
            client_id: "client".into(),
            client_secret: None,
            device_code: "dev-123".into(),
        }
    }

    fn pending_mock(times: u64) -> Mock {
        Mock::given(method("POST"))
            .and(path("/token"))
            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
                "error": "authorization_pending"
            })))
            .up_to_n_times(times)
            .with_priority(1)
    }

    fn granted_mock() -> Mock {
        Mock::given(method("POST"))
            .and(path("/token"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "access_token": "DEV.123",
                "refresh_token": "RT.456",
                "expires_in": 600,
                "token_type": "Bearer"
            })))
            .with_priority(5)
    }

    #[test]
    fn device_authorization_parses_response_and_defaults_interval_to_five() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/device"))
                .and(body_string_contains("client_id=client"))
                .and(body_string_contains("scope=mcp%3Aread"))
                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                    "device_code": "dev-123",
                    "user_code": "WDJB-MJHT",
                    "verification_uri": "https://idp.invalid/activate",
                    "verification_uri_complete":
                        "https://idp.invalid/activate?user_code=WDJB-MJHT",
                    "expires_in": 1800
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let resp = request_device_authorization(&DeviceAuthorizationRequest {
            device_authorization_endpoint: format!("{uri}/device"),
            client_id: "client".into(),
            scope: "mcp:read".into(),
        })
        .unwrap();
        assert_eq!(resp.device_code, "dev-123");
        assert_eq!(resp.user_code, "WDJB-MJHT");
        assert_eq!(resp.verification_uri, "https://idp.invalid/activate");
        assert_eq!(
            resp.verification_uri_complete.as_deref(),
            Some("https://idp.invalid/activate?user_code=WDJB-MJHT")
        );
        assert_eq!(resp.expires_in, 1800);
        assert_eq!(resp.interval, 5, "missing interval must default to 5");
    }

    #[test]
    fn poll_returns_token_after_authorization_pending() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            pending_mock(2).mount(&server).await;
            granted_mock().mount(&server).await;
            (server.uri(), server)
        });

        let mut sleeps: Vec<Duration> = Vec::new();
        let now = 1_700_000_000u64;
        let token = poll_device_token(&http(), &token_req(&uri), &authz(1800, 5), now, &mut |d| {
            sleeps.push(d)
        })
        .unwrap();
        assert_eq!(token.access_token, "DEV.123");
        assert_eq!(token.refresh_token.as_deref(), Some("RT.456"));
        assert_eq!(token.expires_at_unix, now + 600);
        assert_eq!(
            sleeps,
            vec![Duration::from_secs(5), Duration::from_secs(5)],
            "one sleep per pending response, at the IdP's interval"
        );
    }

    #[test]
    fn poll_slow_down_adds_five_seconds_to_interval() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            pending_mock(1).mount(&server).await;
            Mock::given(method("POST"))
                .and(path("/token"))
                .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
                    "error": "slow_down"
                })))
                .up_to_n_times(1)
                .with_priority(2)
                .mount(&server)
                .await;
            Mock::given(method("POST"))
                .and(path("/token"))
                .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
                    "error": "authorization_pending"
                })))
                .up_to_n_times(1)
                .with_priority(3)
                .mount(&server)
                .await;
            granted_mock().mount(&server).await;
            (server.uri(), server)
        });

        let mut sleeps: Vec<Duration> = Vec::new();
        let token = poll_device_token(
            &http(),
            &token_req(&uri),
            &authz(1800, 5),
            1_700_000_000,
            &mut |d| sleeps.push(d),
        )
        .unwrap();
        assert_eq!(token.access_token, "DEV.123");
        assert_eq!(
            sleeps,
            vec![
                Duration::from_secs(5),
                Duration::from_secs(10),
                Duration::from_secs(10),
            ],
            "slow_down must stretch the interval by 5s and keep it there"
        );
    }

    #[test]
    fn poll_surfaces_access_denied_immediately() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/token"))
                .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
                    "error": "access_denied",
                    "error_description": "user refused the request"
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let mut sleeps: Vec<Duration> = Vec::new();
        let err = poll_device_token(
            &http(),
            &token_req(&uri),
            &authz(1800, 5),
            1_700_000_000,
            &mut |d| sleeps.push(d),
        )
        .unwrap_err();
        match err {
            OAuthError::OAuth { code, description } => {
                assert_eq!(code, "access_denied");
                assert!(description.contains("refused"));
            }
            other => panic!("expected access_denied, got {other:?}"),
        }
        assert!(sleeps.is_empty(), "terminal errors must not sleep");
    }

    #[test]
    fn poll_surfaces_expired_token_immediately() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/token"))
                .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
                    "error": "expired_token"
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let mut sleeps: Vec<Duration> = Vec::new();
        let err = poll_device_token(
            &http(),
            &token_req(&uri),
            &authz(1800, 5),
            1_700_000_000,
            &mut |d| sleeps.push(d),
        )
        .unwrap_err();
        match err {
            OAuthError::OAuth { code, .. } => assert_eq!(code, "expired_token"),
            other => panic!("expected expired_token, got {other:?}"),
        }
        assert!(sleeps.is_empty(), "terminal errors must not sleep");
    }

    #[test]
    fn poll_gives_up_when_expires_in_budget_is_spent() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/token"))
                .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
                    "error": "authorization_pending"
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let mut sleeps: Vec<Duration> = Vec::new();
        // expires_in 10 at interval 5: two sleeps fit the budget, the
        // third would overrun it, so the loop bails before sleeping again.
        let err = poll_device_token(
            &http(),
            &token_req(&uri),
            &authz(10, 5),
            1_700_000_000,
            &mut |d| sleeps.push(d),
        )
        .unwrap_err();
        match err {
            OAuthError::OAuth { code, description } => {
                assert_eq!(code, "expired_token");
                assert!(description.contains("10s"));
            }
            other => panic!("expected synthesized expired_token, got {other:?}"),
        }
        assert_eq!(sleeps, vec![Duration::from_secs(5), Duration::from_secs(5)]);
    }

    #[test]
    fn poll_treats_unknown_error_code_as_fatal() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/token"))
                .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
                    "error": "unauthorized_client",
                    "error_description": "client lacks the device grant"
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let mut sleeps: Vec<Duration> = Vec::new();
        let err = poll_device_token(
            &http(),
            &token_req(&uri),
            &authz(1800, 5),
            1_700_000_000,
            &mut |d| sleeps.push(d),
        )
        .unwrap_err();
        match err {
            OAuthError::OAuth { code, .. } => assert_eq!(code, "unauthorized_client"),
            other => panic!("expected unauthorized_client, got {other:?}"),
        }
        assert!(sleeps.is_empty(), "unknown codes must not be retried");
    }
}
