//! Integration tests for `mcptest login --device` (WOR-1286).
//!
//! These tests exercise the CLI surface of the RFC 8628 device-code
//! grant: the happy path through metadata discovery, device
//! authorization, one `authorization_pending` poll, and the token cache
//! write; the error when the IdP does not advertise the endpoint; and a
//! terminal `access_denied` from the token endpoint. The IdP is a
//! `wiremock` server; the device authorization response uses
//! `interval: 0` so the real binary polls without sleeping.

use std::time::Duration;

use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::json;
use tempfile::tempdir;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn mcptest() -> Command {
    Command::cargo_bin("mcptest").expect("binary builds")
}

/// Mount RFC 8414 metadata that advertises the device grant.
async fn mount_metadata_with_device_endpoint(idp: &MockServer) {
    let uri = idp.uri();
    Mock::given(method("GET"))
        .and(path("/.well-known/oauth-authorization-server"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "issuer": uri,
            "authorization_endpoint": format!("{uri}/authorize"),
            "token_endpoint": format!("{uri}/token"),
            "device_authorization_endpoint": format!("{uri}/device_authorization"),
        })))
        .mount(idp)
        .await;
}

/// Mount the device authorization response. `interval: 0` keeps the
/// real binary's poll loop fast; `expires_in` is generous enough that
/// the budget never trips in the happy path.
async fn mount_device_authorization(idp: &MockServer) {
    let uri = idp.uri();
    Mock::given(method("POST"))
        .and(path("/device_authorization"))
        .and(body_string_contains("client_id=test-client"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "device_code": "dev-code-123",
            "user_code": "WDJB-MJHT",
            "verification_uri": format!("{uri}/activate"),
            "interval": 0,
            "expires_in": 60,
        })))
        .mount(idp)
        .await;
}

#[tokio::test(flavor = "current_thread")]
async fn login_device_happy_path_caches_token() {
    let idp = MockServer::start().await;
    let idp_uri = idp.uri();
    mount_metadata_with_device_endpoint(&idp).await;
    mount_device_authorization(&idp).await;

    // First poll: authorization_pending. The higher-priority mock is
    // consumed once, then the success mock answers the retry.
    Mock::given(method("POST"))
        .and(path("/token"))
        .and(body_string_contains("device_code=dev-code-123"))
        .respond_with(ResponseTemplate::new(400).set_body_json(json!({
            "error": "authorization_pending",
        })))
        .up_to_n_times(1)
        .with_priority(1)
        .mount(&idp)
        .await;
    Mock::given(method("POST"))
        .and(path("/token"))
        .and(body_string_contains(
            "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code",
        ))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "access_token": "DEVICE_ACCESS",
            "token_type": "Bearer",
            "expires_in": 3600,
        })))
        .with_priority(5)
        .mount(&idp)
        .await;

    let dir = tempdir().unwrap();
    let auth_dir = dir.path().join("auth");
    std::fs::create_dir_all(&auth_dir).unwrap();

    mcptest()
        .env("MCPTEST_AUTH_DIR", &auth_dir)
        .args([
            "login",
            "--url",
            &idp_uri,
            "--device",
            "--client-id",
            "test-client",
        ])
        .timeout(Duration::from_secs(30))
        .assert()
        .success()
        .stdout(predicate::str::contains("WDJB-MJHT"))
        .stdout(predicate::str::contains(format!("{idp_uri}/activate")))
        .stdout(predicate::str::contains("login successful"));

    let key = sha256_hex(&idp_uri);
    let cache_file = auth_dir.join(format!("{key}.json"));
    assert!(
        cache_file.exists(),
        "expected token cache at {}",
        cache_file.display()
    );
    let body = std::fs::read_to_string(&cache_file).unwrap();
    assert!(
        body.contains("DEVICE_ACCESS"),
        "cache should contain access token, got: {body}"
    );
}

#[tokio::test(flavor = "current_thread")]
async fn login_device_errors_when_endpoint_not_advertised() {
    let idp = MockServer::start().await;
    let idp_uri = idp.uri();
    // Metadata without device_authorization_endpoint: the device flow
    // must refuse up front and point at --no-browser.
    Mock::given(method("GET"))
        .and(path("/.well-known/oauth-authorization-server"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "issuer": idp_uri,
            "authorization_endpoint": format!("{idp_uri}/authorize"),
            "token_endpoint": format!("{idp_uri}/token"),
        })))
        .mount(&idp)
        .await;

    let dir = tempdir().unwrap();
    mcptest()
        .env("MCPTEST_AUTH_DIR", dir.path())
        .args([
            "login",
            "--url",
            &idp_uri,
            "--device",
            "--client-id",
            "test-client",
        ])
        .timeout(Duration::from_secs(30))
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "does not advertise device_authorization_endpoint",
        ));
}

#[tokio::test(flavor = "current_thread")]
async fn login_device_surfaces_access_denied() {
    let idp = MockServer::start().await;
    let idp_uri = idp.uri();
    mount_metadata_with_device_endpoint(&idp).await;
    mount_device_authorization(&idp).await;

    Mock::given(method("POST"))
        .and(path("/token"))
        .respond_with(ResponseTemplate::new(400).set_body_json(json!({
            "error": "access_denied",
            "error_description": "the user refused the request",
        })))
        .mount(&idp)
        .await;

    let dir = tempdir().unwrap();
    mcptest()
        .env("MCPTEST_AUTH_DIR", dir.path())
        .args([
            "login",
            "--url",
            &idp_uri,
            "--device",
            "--client-id",
            "test-client",
        ])
        .timeout(Duration::from_secs(30))
        .assert()
        .failure()
        .stderr(predicate::str::contains("access_denied"));
}

/// Hex SHA-256 of `s`. Matches `TokenCache::key_for` so tests can
/// reconstruct on-disk paths without exposing private helpers.
fn sha256_hex(s: &str) -> String {
    use sha2::{Digest, Sha256};
    let mut hasher = Sha256::new();
    hasher.update(s.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
}
