//! Opt-in live auth probes for `mcptest doctor --probe`.
//!
//! Why this exists: the default doctor auth pre-flight is local and offline.
//! `--probe` opts into a real network call per scheme so an operator can
//! confirm the credentials actually authenticate, not just that they parse:
//! SigV4 signs and sends an `sts:GetCallerIdentity`, and Web Bot Auth fetches
//! the configured key directory. Both are off by default so the normal doctor
//! run stays deterministic and offline.
//!
//! Secret discipline: a probe outcome carries only a non-secret summary
//! (HTTP status, a one-line network error). Signed SigV4 headers, the AWS
//! secret key, and the signing key never appear in a [`ProbeOutcome`]. The
//! network call goes through the [`ProbeSender`] trait so gate-runnable tests
//! stub it and never touch the real network.

use mcptest_core::auth::{sigv4_sign_request, SigV4Config, WebBotAuthConfig};

/// One outbound probe request the [`ProbeSender`] performs.
///
/// Holds the method, URL, headers, and body. The headers may include a
/// SigV4 `Authorization` header, so a sender must never log them.
#[derive(Debug, Clone)]
pub struct ProbeRequest {
    /// HTTP method (`POST` for STS, `GET` for the directory fetch).
    pub method: String,
    /// Absolute request URL.
    pub url: String,
    /// Headers to attach. Treated as sensitive (may carry a signature).
    pub headers: Vec<(String, String)>,
    /// Request body bytes.
    pub body: Vec<u8>,
}

/// The non-secret result of one probe.
///
/// `passed` is the operator-facing pass/fail; `summary` is a one-line,
/// secret-free description (an HTTP status or a transport error).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbeOutcome {
    /// True when the probe authenticated and the endpoint answered as
    /// expected.
    pub passed: bool,
    /// One-line, secret-free summary for the doctor row.
    pub summary: String,
}

/// What a sent probe returned: the HTTP status code and nothing else we
/// surface. The body is intentionally not captured (an STS error body can
/// echo request metadata; we report only the status).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProbeResponse {
    /// HTTP status code.
    pub status: u16,
}

/// Performs the actual network call for a probe.
///
/// Production wires a blocking reqwest sender (in the doctor handler); tests
/// supply a stub so the gate never hits the network. The error is a
/// secret-free transport message.
pub trait ProbeSender {
    /// Send the request and return its status, or a secret-free error.
    fn send(&self, request: &ProbeRequest) -> Result<ProbeResponse, String>;
}

/// Probe AWS SigV4 by signing and sending an `sts:GetCallerIdentity`.
///
/// A `200` proves the credentials authenticate. A `403` means the request
/// signed but the identity was rejected (still a useful, secret-free
/// signal). Any other status or a transport error fails the probe. The
/// signed headers never leave this function.
pub fn probe_sigv4(config: &SigV4Config, sender: &dyn ProbeSender) -> ProbeOutcome {
    let region = sigv4_region(config);
    let url = format!("https://sts.{region}.amazonaws.com/");
    let body = b"Action=GetCallerIdentity&Version=2011-06-15";
    let sts_config = SigV4Config {
        region: region.clone(),
        service: "sts".to_string(),
        credentials: config.credentials.clone(),
        signature_lifetime: None,
    };
    let mut headers = match sigv4_sign_request("POST", &url, body, &sts_config) {
        Ok(signed) => signed
            .into_iter()
            .map(|h| (h.name, h.value))
            .collect::<Vec<_>>(),
        Err(err) => {
            return ProbeOutcome {
                passed: false,
                summary: format!("could not sign STS request: {err}"),
            }
        }
    };
    headers.push((
        "Content-Type".to_string(),
        "application/x-www-form-urlencoded".to_string(),
    ));
    let request = ProbeRequest {
        method: "POST".to_string(),
        url,
        headers,
        body: body.to_vec(),
    };
    interpret_sts(sender.send(&request))
}

/// Probe Web Bot Auth by fetching the configured key directory URL.
///
/// A `2xx` proves the directory is reachable and served. The request carries
/// no signature (a directory is public), so there is nothing secret to leak.
pub fn probe_web_bot_auth(config: &WebBotAuthConfig, sender: &dyn ProbeSender) -> ProbeOutcome {
    let request = ProbeRequest {
        method: "GET".to_string(),
        url: config.directory_url.clone(),
        headers: Vec::new(),
        body: Vec::new(),
    };
    match sender.send(&request) {
        Ok(resp) if (200..300).contains(&resp.status) => ProbeOutcome {
            passed: true,
            summary: format!("key directory reachable (HTTP {})", resp.status),
        },
        Ok(resp) => ProbeOutcome {
            passed: false,
            summary: format!("key directory returned HTTP {}", resp.status),
        },
        Err(err) => ProbeOutcome {
            passed: false,
            summary: format!("could not fetch key directory: {err}"),
        },
    }
}

/// Map an STS send result to a probe outcome. `200` passes; `403` is a
/// signed-but-rejected signal reported without failing hard is too lenient,
/// so we fail it but say the request signed. Other statuses and transport
/// errors fail.
fn interpret_sts(result: Result<ProbeResponse, String>) -> ProbeOutcome {
    match result {
        Ok(resp) if resp.status == 200 => ProbeOutcome {
            passed: true,
            summary: "sts:GetCallerIdentity succeeded (HTTP 200)".to_string(),
        },
        Ok(resp) if resp.status == 403 => ProbeOutcome {
            passed: false,
            summary: "sts:GetCallerIdentity signed but was rejected (HTTP 403); check the credentials and clock".to_string(),
        },
        Ok(resp) => ProbeOutcome {
            passed: false,
            summary: format!("sts:GetCallerIdentity returned HTTP {}", resp.status),
        },
        Err(err) => ProbeOutcome {
            passed: false,
            summary: format!("sts:GetCallerIdentity request failed: {err}"),
        },
    }
}

/// Pick the region for the STS endpoint: the config region when set, else
/// `us-east-1` (the global STS default region).
fn sigv4_region(config: &SigV4Config) -> String {
    let region = config.region.trim();
    if region.is_empty() {
        "us-east-1".to_string()
    } else {
        region.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use mcptest_core::auth::SigV4Credentials;
    use std::cell::RefCell;

    /// A stub sender that records the request it received and returns a
    /// canned response, so a test can assert the probe never hits the
    /// network and inspect what it would have sent.
    struct StubSender {
        response: Result<ProbeResponse, String>,
        seen: RefCell<Option<ProbeRequest>>,
    }

    impl StubSender {
        fn ok(status: u16) -> Self {
            Self {
                response: Ok(ProbeResponse { status }),
                seen: RefCell::new(None),
            }
        }

        fn err(message: &str) -> Self {
            Self {
                response: Err(message.to_string()),
                seen: RefCell::new(None),
            }
        }
    }

    impl ProbeSender for StubSender {
        fn send(&self, request: &ProbeRequest) -> Result<ProbeResponse, String> {
            *self.seen.borrow_mut() = Some(request.clone());
            self.response.clone()
        }
    }

    /// Build a config whose explicit env-var names are unique per test
    /// (`suffix`), so the process-wide env mutations never race between the
    /// parallel probe tests.
    fn sigv4_with_env_creds(suffix: &str) -> SigV4Config {
        SigV4Config {
            region: "us-east-1".into(),
            service: "execute-api".into(),
            credentials: SigV4Credentials::Explicit {
                access_key_id_env: format!("PROBE_AK_{suffix}"),
                secret_access_key_env: format!("PROBE_SK_{suffix}"),
                session_token_env: None,
            },
            signature_lifetime: None,
        }
    }

    fn set_probe_creds(cfg: &SigV4Config) {
        if let SigV4Credentials::Explicit {
            access_key_id_env,
            secret_access_key_env,
            ..
        } = &cfg.credentials
        {
            std::env::set_var(access_key_id_env, "AKIDEXAMPLE");
            std::env::set_var(secret_access_key_env, "this-secret-must-not-leak");
        }
    }

    fn clear_probe_creds(cfg: &SigV4Config) {
        if let SigV4Credentials::Explicit {
            access_key_id_env,
            secret_access_key_env,
            ..
        } = &cfg.credentials
        {
            std::env::remove_var(access_key_id_env);
            std::env::remove_var(secret_access_key_env);
        }
    }

    #[test]
    fn sigv4_probe_passes_on_200() {
        let cfg = sigv4_with_env_creds("pass200");
        set_probe_creds(&cfg);
        let sender = StubSender::ok(200);
        let outcome = probe_sigv4(&cfg, &sender);
        clear_probe_creds(&cfg);
        assert!(outcome.passed, "200 should pass: {}", outcome.summary);
        assert!(outcome.summary.contains("HTTP 200"));
    }

    #[test]
    fn sigv4_probe_targets_sts_and_never_leaks_the_secret() {
        let cfg = sigv4_with_env_creds("leak");
        set_probe_creds(&cfg);
        let sender = StubSender::ok(200);
        let outcome = probe_sigv4(&cfg, &sender);
        let seen = sender.seen.borrow().clone().expect("request was sent");
        clear_probe_creds(&cfg);
        // The probe targets the regional STS endpoint.
        assert_eq!(seen.url, "https://sts.us-east-1.amazonaws.com/");
        assert_eq!(seen.method, "POST");
        // It signs with an Authorization header.
        assert!(seen
            .headers
            .iter()
            .any(|(n, _)| n.eq_ignore_ascii_case("authorization")));
        // The secret value never appears in any header or in the summary.
        assert!(!seen
            .headers
            .iter()
            .any(|(_, v)| v.contains("this-secret-must-not-leak")));
        assert!(!outcome.summary.contains("this-secret-must-not-leak"));
    }

    #[test]
    fn sigv4_probe_fails_on_403_but_says_it_signed() {
        let cfg = sigv4_with_env_creds("f403");
        set_probe_creds(&cfg);
        let sender = StubSender::ok(403);
        let outcome = probe_sigv4(&cfg, &sender);
        clear_probe_creds(&cfg);
        assert!(!outcome.passed);
        assert!(outcome.summary.contains("signed but was rejected"));
    }

    #[test]
    fn sigv4_probe_fails_clearly_on_transport_error() {
        let cfg = sigv4_with_env_creds("transport");
        set_probe_creds(&cfg);
        let sender = StubSender::err("connection refused");
        let outcome = probe_sigv4(&cfg, &sender);
        clear_probe_creds(&cfg);
        assert!(!outcome.passed);
        assert!(outcome.summary.contains("connection refused"));
    }

    #[test]
    fn sigv4_probe_reports_a_signing_failure_when_creds_missing() {
        // Unique env-var names that this test never sets, so signing fails
        // before any send regardless of what the other probe tests set.
        let cfg = sigv4_with_env_creds("missing_unset");
        let sender = StubSender::ok(200);
        let outcome = probe_sigv4(&cfg, &sender);
        assert!(!outcome.passed);
        assert!(outcome.summary.contains("could not sign STS request"));
        assert!(sender.seen.borrow().is_none(), "no request should be sent");
    }

    fn wba_config(dir_url: &str) -> WebBotAuthConfig {
        serde_yaml::from_str(&format!(
            "key_env: PROBE_WBA_KEY\ndirectory_url: {dir_url}\nsignature_agent: https://mcptest.sh\n"
        ))
        .unwrap()
    }

    #[test]
    fn web_bot_auth_probe_passes_on_2xx() {
        let cfg = wba_config("https://mcptest.sh/.well-known/http-message-signatures-directory");
        let sender = StubSender::ok(200);
        let outcome = probe_web_bot_auth(&cfg, &sender);
        let seen = sender.seen.borrow().clone().expect("request sent");
        assert_eq!(seen.method, "GET");
        assert_eq!(
            seen.url,
            "https://mcptest.sh/.well-known/http-message-signatures-directory"
        );
        // The directory fetch carries no signature header.
        assert!(seen.headers.is_empty());
        assert!(outcome.passed);
        assert!(outcome.summary.contains("reachable"));
    }

    #[test]
    fn web_bot_auth_probe_fails_on_404() {
        let cfg = wba_config("https://mcptest.sh/dir");
        let sender = StubSender::ok(404);
        let outcome = probe_web_bot_auth(&cfg, &sender);
        assert!(!outcome.passed);
        assert!(outcome.summary.contains("HTTP 404"));
    }

    #[test]
    fn web_bot_auth_probe_fails_on_transport_error() {
        let cfg = wba_config("https://mcptest.sh/dir");
        let sender = StubSender::err("dns failure");
        let outcome = probe_web_bot_auth(&cfg, &sender);
        assert!(!outcome.passed);
        assert!(outcome.summary.contains("dns failure"));
    }
}
