//! Integration tests for `mcptest login`.
//!
//! These tests exercise the CLI surface for the OAuth 2.1 + PKCE login
//! flow: argument parsing, YAML server resolution, `--force` cache
//! eviction, the `--no-browser` happy path, and the state-mismatch
//! guard. The IdP is stood up via `wiremock` so we never hit the
//! network; the loopback callback is driven by an in-test reqwest
//! client running on a background thread.

use std::process::{Command as StdCommand, Stdio};
use std::time::{Duration, Instant};

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

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

#[test]
fn login_help_lists_server_arg_and_all_force_no_browser() {
    let assertion = mcptest().args(["login", "--help"]).assert().success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    assert!(stdout.contains("--all"), "help missing --all");
    assert!(stdout.contains("--force"), "help missing --force");
    assert!(stdout.contains("--no-browser"), "help missing --no-browser");
    assert!(stdout.contains("--device"), "help missing --device");
    assert!(stdout.contains("--url"), "help missing --url");
    assert!(
        stdout.contains("SERVER") || stdout.contains("[SERVER]"),
        "help missing positional [SERVER]"
    );
}

#[test]
fn login_errors_when_no_url_and_no_config() {
    let dir = tempdir().unwrap();
    mcptest()
        .current_dir(dir.path())
        .env("MCPTEST_AUTH_DIR", dir.path())
        .args(["login"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("mcptest login needs a server"));
}

#[test]
fn login_named_server_missing_from_yaml_errors() {
    let dir = tempdir().unwrap();
    let yaml = "servers:\n  alpha:\n    url: https://example.invalid\n";
    std::fs::write(dir.path().join("mcptest.yml"), yaml).unwrap();
    mcptest()
        .current_dir(dir.path())
        .env("MCPTEST_AUTH_DIR", dir.path())
        .args(["login", "nonesuch"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("no URL server named `nonesuch`"));
}

#[test]
fn login_all_errors_when_yaml_has_only_stdio_servers() {
    let dir = tempdir().unwrap();
    let yaml = "servers:\n  stdio_only:\n    command: [\"./fake\"]\n";
    std::fs::write(dir.path().join("mcptest.yml"), yaml).unwrap();
    mcptest()
        .current_dir(dir.path())
        .env("MCPTEST_AUTH_DIR", dir.path())
        .args(["login", "--all"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("--all"));
}

#[test]
fn login_rejects_server_and_url_together() {
    // clap surfaces the conflict before any work happens; we just
    // need the parser to refuse, not to produce a specific message.
    mcptest()
        .args(["login", "alpha", "--url", "https://example.invalid"])
        .assert()
        .failure();
}

#[tokio::test(flavor = "current_thread")]
async fn login_force_clears_existing_token_cache() {
    let dir = tempdir().unwrap();
    // Stand up a wiremock that 500s every metadata fetch. The login
    // will fail; the test only cares that the pre-existing cache
    // file is gone afterward.
    let idp = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/.well-known/oauth-authorization-server"))
        .respond_with(ResponseTemplate::new(500))
        .mount(&idp)
        .await;
    let server_url = idp.uri();

    // Compute the same on-disk key the login flow will use, so we
    // can pre-seed a token and verify --force evicts it.
    let cache_root = dir.path();
    let key = sha256_hex(&server_url);
    let cache_file = cache_root.join(format!("{key}.json"));
    std::fs::write(&cache_file, "{\"stale\":true}").unwrap();
    assert!(cache_file.exists(), "preseed failed");

    mcptest()
        .env("MCPTEST_AUTH_DIR", cache_root)
        .args(["login", "--url", &server_url, "--force", "--no-browser"])
        .assert()
        .failure();

    assert!(
        !cache_file.exists(),
        "expected --force to remove {} but it still exists",
        cache_file.display()
    );
}

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

    // OAuth metadata. We point authorization_endpoint at a stub
    // (the test never GETs it because we drive the callback
    // ourselves) and the token_endpoint at a wiremock route that
    // returns a fixed access token.
    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;
    Mock::given(method("POST"))
        .and(path("/token"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "access_token": "FAKE_ACCESS",
            "token_type": "Bearer",
            "expires_in": 3600,
        })))
        .mount(&idp)
        .await;

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

    // Spawn the binary with --no-browser. The CLI prints the
    // authorization URL on stdout; we parse it, extract `state` +
    // `redirect_uri`, then drive the callback ourselves.
    let bin = assert_cmd::cargo_bin!("mcptest");
    let mut child = StdCommand::new(bin)
        .args([
            "login",
            "--url",
            &idp_uri,
            "--no-browser",
            "--client-id",
            "test-client",
        ])
        .env("MCPTEST_AUTH_DIR", &auth_dir)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn mcptest");

    let stdout = child.stdout.take().expect("child stdout");
    // Pump stdout in a background thread so we (a) read the URL line,
    // and (b) keep draining the rest of stdout until EOF. Otherwise
    // the child's later `println!` on the success path hits a closed
    // pipe and panics with EPIPE.
    let (url_tx, url_rx) = std::sync::mpsc::channel::<String>();
    let pump = std::thread::spawn(move || {
        use std::io::{BufRead, BufReader};
        let reader = BufReader::new(stdout);
        let mut sent = false;
        for line in reader.lines().map_while(|r| r.ok()) {
            let trimmed = line.trim_start();
            if !sent && (trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
                let _ = url_tx.send(trimmed.to_string());
                sent = true;
            }
        }
    });
    let auth_url = url_rx
        .recv_timeout(Duration::from_secs(10))
        .expect("authorize url on stdout");
    let parsed = url::Url::parse(&auth_url).expect("parse authorize url");
    let mut state: Option<String> = None;
    let mut redirect: Option<String> = None;
    for (k, v) in parsed.query_pairs() {
        match k.as_ref() {
            "state" => state = Some(v.into_owned()),
            "redirect_uri" => redirect = Some(v.into_owned()),
            _ => {}
        }
    }
    let state = state.expect("state on authorize url");
    let redirect = redirect.expect("redirect_uri on authorize url");

    // Drive the callback. Loop briefly because the binary needs a
    // moment to start its listener after printing the URL.
    let callback = format!("{redirect}?code=test-code&state={state}");
    poll_callback(&callback);

    let (status, stderr_text) =
        wait_for_exit_capture(child, Duration::from_secs(15)).expect("child exits");
    let _ = pump.join();
    assert!(
        status.success(),
        "binary exited non-zero: {status:?}\nstderr: {stderr_text}"
    );

    // The token cache should now hold a single JSON file for the
    // server URL. We check existence rather than parsing the file
    // since `TokenCache` is the source of truth.
    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("FAKE_ACCESS"),
        "cache should contain access token, got: {body}"
    );
}

#[tokio::test(flavor = "current_thread")]
async fn login_no_browser_rejects_state_mismatch() {
    let idp = MockServer::start().await;
    let idp_uri = idp.uri();
    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();
    let auth_dir = dir.path().join("auth");
    std::fs::create_dir_all(&auth_dir).unwrap();

    let bin = assert_cmd::cargo_bin!("mcptest");
    let mut child = StdCommand::new(bin)
        .args([
            "login",
            "--url",
            &idp_uri,
            "--no-browser",
            "--client-id",
            "test-client",
        ])
        .env("MCPTEST_AUTH_DIR", &auth_dir)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn mcptest");

    let stdout = child.stdout.take().expect("child stdout");
    let (url_tx, url_rx) = std::sync::mpsc::channel::<String>();
    let pump = std::thread::spawn(move || {
        use std::io::{BufRead, BufReader};
        let reader = BufReader::new(stdout);
        let mut sent = false;
        for line in reader.lines().map_while(|r| r.ok()) {
            let trimmed = line.trim_start();
            if !sent && (trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
                let _ = url_tx.send(trimmed.to_string());
                sent = true;
            }
        }
    });
    let auth_url = url_rx
        .recv_timeout(Duration::from_secs(10))
        .expect("authorize url on stdout");
    let parsed = url::Url::parse(&auth_url).expect("parse authorize url");
    let redirect = parsed
        .query_pairs()
        .find(|(k, _)| k == "redirect_uri")
        .map(|(_, v)| v.into_owned())
        .expect("redirect_uri");

    // Bad state value to trigger the CSRF guard.
    let callback = format!("{redirect}?code=test-code&state=WRONG");
    poll_callback(&callback);

    let status = wait_for_exit(child, Duration::from_secs(15)).expect("child exits");
    let _ = pump.join();
    assert!(
        !status.success(),
        "expected non-zero exit on state mismatch"
    );
}

/// Hit the loopback callback URL with retries. The child binary
/// binds its listener a beat after printing the URL, so a single
/// blocking connect sometimes races the listener. We use a raw
/// `TcpStream` write so the call does not need a `reqwest::blocking`
/// runtime (which cannot be dropped inside a tokio context).
fn poll_callback(callback: &str) {
    use std::io::Write;
    use std::net::TcpStream;
    let url = url::Url::parse(callback).expect("parse callback url");
    let host = url.host_str().expect("callback host").to_string();
    let port = url.port().expect("callback port");
    let mut path_and_query = url.path().to_string();
    if let Some(q) = url.query() {
        path_and_query.push('?');
        path_and_query.push_str(q);
    }
    let deadline = Instant::now() + Duration::from_secs(5);
    while Instant::now() < deadline {
        if let Ok(mut stream) = TcpStream::connect((host.as_str(), port)) {
            let req = format!(
                "GET {path_and_query} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"
            );
            if stream.write_all(req.as_bytes()).is_ok() {
                return;
            }
        }
        std::thread::sleep(Duration::from_millis(100));
    }
    panic!("could not reach loopback callback at {callback}");
}

/// Block on the child process up to `timeout`. Returns the exit
/// status on a clean exit, `None` on timeout (after killing the
/// child so the test does not hang the suite).
fn wait_for_exit(
    mut child: std::process::Child,
    timeout: Duration,
) -> Option<std::process::ExitStatus> {
    let deadline = Instant::now() + timeout;
    loop {
        if let Some(status) = child.try_wait().ok().flatten() {
            return Some(status);
        }
        if Instant::now() >= deadline {
            let _ = child.kill();
            return None;
        }
        std::thread::sleep(Duration::from_millis(50));
    }
}

/// Variant of [`wait_for_exit`] that also drains stderr so failure
/// messages from the child surface in the test panic output.
fn wait_for_exit_capture(
    mut child: std::process::Child,
    timeout: Duration,
) -> Option<(std::process::ExitStatus, String)> {
    use std::io::Read;
    let stderr = child.stderr.take();
    let deadline = Instant::now() + timeout;
    let status = loop {
        if let Some(status) = child.try_wait().ok().flatten() {
            break status;
        }
        if Instant::now() >= deadline {
            let _ = child.kill();
            return None;
        }
        std::thread::sleep(Duration::from_millis(50));
    };
    let mut buf = String::new();
    if let Some(mut s) = stderr {
        let _ = s.read_to_string(&mut buf);
    }
    Some((status, buf))
}

/// 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
}
