//! Local auth pre-flight wiring for `mcptest doctor`.
//!
//! Why this is its own module: the doctor handler stays a thin orchestrator,
//! and the auth pre-flight needs its own config-discovery and per-server
//! iteration. This module finds the resolved config, parses each server's
//! transport-level auth blocks, runs the local (no-network) checks in
//! [`crate::commands::auth_preflight`], prints the rows, and reports whether
//! any check failed so the handler can set a non-zero exit.

use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use mcptest_config::auth_schemes::{parse_auth_schemes, AuthSchemes};
use serde_yaml::Value as YamlValue;

use crate::cli::GlobalArgs;
use crate::commands::auth_preflight_checks;
use crate::commands::auth_probe::{
    probe_sigv4, probe_web_bot_auth, ProbeRequest, ProbeResponse, ProbeSender,
};

/// Run the local auth pre-flight against the resolved config and print the
/// per-scheme rows. Returns `true` when every check passed (or no auth
/// scheme is configured), `false` when any check failed, so the caller can
/// fold the result into the doctor exit code.
///
/// `probe` opts into live network probes per scheme. When `false`
/// (the default) no network call is ever made: every check is answered from
/// local files and environment state. When no config file exists, the
/// pre-flight is a no-op and returns `true`.
pub(crate) fn run_auth_preflight(global: &GlobalArgs, cwd: &Path, probe: bool) -> bool {
    let config_path = resolve_config_path(global, cwd);
    let Some(path) = config_path else {
        return true;
    };
    let yaml = match read_yaml(&path) {
        Some(y) => y,
        None => return true,
    };
    let now_unix = now_unix();
    let mut any_failed = false;
    let mut printed_header = false;
    let sender = ReqwestProbeSender::new();
    for (server_name, schemes) in collect_server_schemes(&yaml) {
        let checks = auth_preflight_checks(&schemes, now_unix);
        if checks.is_empty() {
            continue;
        }
        if !printed_header && !global.quiet {
            println!("auth pre-flight (local, no network):");
            printed_header = true;
        }
        for check in &checks {
            if !check.passed {
                any_failed = true;
            }
            if !global.quiet {
                let marker = if check.passed { "OK" } else { "FAIL" };
                println!("  [{server_name}] {marker} {}", check.message);
            }
        }
        if probe && !run_live_probes(global, &server_name, &schemes, &sender) {
            any_failed = true;
        }
    }
    !any_failed
}

/// Run the opt-in live probes for one server's schemes and print a row per
/// probe. Returns `true` when every probe passed. The sender is injected so
/// tests can stub it; production uses [`ReqwestProbeSender`].
fn run_live_probes(
    global: &GlobalArgs,
    server_name: &str,
    schemes: &AuthSchemes,
    sender: &dyn ProbeSender,
) -> bool {
    let mut all_passed = true;
    if !global.quiet {
        println!("  [{server_name}] live probes (network):");
    }
    if let Some(sigv4) = &schemes.sigv4 {
        let outcome = probe_sigv4(sigv4, sender);
        all_passed &= outcome.passed;
        print_probe(
            global,
            server_name,
            "sigv4",
            outcome.passed,
            &outcome.summary,
        );
    }
    if let Some(wba) = &schemes.web_bot_auth {
        let outcome = probe_web_bot_auth(wba, sender);
        all_passed &= outcome.passed;
        print_probe(
            global,
            server_name,
            "web_bot_auth",
            outcome.passed,
            &outcome.summary,
        );
    }
    all_passed
}

/// Print one probe result row, secret-free.
fn print_probe(global: &GlobalArgs, server_name: &str, scheme: &str, passed: bool, summary: &str) {
    if !global.quiet {
        let marker = if passed { "OK" } else { "FAIL" };
        println!("    [{server_name}] {marker} {scheme}: {summary}");
    }
}

/// Production probe sender backed by a blocking reqwest client.
///
/// Built once per pre-flight so probes share a connection pool. A short
/// timeout keeps a slow or hanging endpoint from stalling doctor.
struct ReqwestProbeSender {
    client: Option<reqwest::blocking::Client>,
}

impl ReqwestProbeSender {
    fn new() -> Self {
        let client = reqwest::blocking::Client::builder()
            .timeout(Duration::from_secs(10))
            .build()
            .ok();
        Self { client }
    }
}

impl ProbeSender for ReqwestProbeSender {
    fn send(&self, request: &ProbeRequest) -> Result<ProbeResponse, String> {
        let client = self
            .client
            .as_ref()
            .ok_or_else(|| "could not build HTTP client".to_string())?;
        let method = reqwest::Method::from_bytes(request.method.as_bytes())
            .map_err(|_| format!("invalid HTTP method `{}`", request.method))?;
        let mut builder = client.request(method, &request.url);
        for (name, value) in &request.headers {
            builder = builder.header(name, value);
        }
        if !request.body.is_empty() {
            builder = builder.body(request.body.clone());
        }
        // Map a transport error to a secret-free message; reqwest's Display
        // never includes request headers or body.
        let response = builder.send().map_err(|e| e.to_string())?;
        Ok(ProbeResponse {
            status: response.status().as_u16(),
        })
    }
}

/// Pick the config path the same way `mcptest run` does: the explicit
/// `--config` flag, else the shared default resolution (`mcptest.yaml`,
/// then `mcptest.yml`, per WOR-1350). Returns `None` when no file
/// resolves so the pre-flight stays a no-op; the ambiguous case is also
/// a no-op here because doctor is diagnostics and must keep running, but
/// it leaves a note so the operator learns about the clash.
fn resolve_config_path(global: &GlobalArgs, cwd: &Path) -> Option<PathBuf> {
    use crate::cli::default_config::{resolve_default_config, DefaultConfig};
    if let Some(path) = &global.config {
        return path.exists().then(|| path.clone());
    }
    match resolve_default_config(cwd) {
        Ok(DefaultConfig::Found(path)) => Some(path),
        Ok(DefaultConfig::Missing(_)) => None,
        Err(ambiguous) => {
            eprintln!(
                "mcptest doctor: skipping auth pre-flight: {}",
                ambiguous.message()
            );
            None
        }
    }
}

/// Read and parse the config YAML. A read or parse failure is not fatal to
/// `mcptest doctor` (the dotenv and url checks still ran), so we return
/// `None` and let the pre-flight no-op rather than abort the command.
fn read_yaml(path: &Path) -> Option<YamlValue> {
    let raw = std::fs::read_to_string(path).ok()?;
    serde_yaml::from_str(&raw).ok()
}

/// Parse each `servers:` entry's transport-level auth blocks. A server spec
/// that fails to parse is skipped (validation already runs in the main
/// `mcptest run` / `validate` gate); the pre-flight only reports on schemes
/// that parsed cleanly.
fn collect_server_schemes(
    yaml: &YamlValue,
) -> Vec<(String, mcptest_config::auth_schemes::AuthSchemes)> {
    let mut out = Vec::new();
    let Some(servers) = yaml.get("servers").and_then(serde_yaml::Value::as_mapping) else {
        return out;
    };
    for (name, spec) in servers {
        let server_name = name.as_str().unwrap_or("<server>").to_string();
        if let Ok(schemes) = parse_auth_schemes(spec) {
            if !schemes.is_empty() {
                out.push((server_name, schemes));
            }
        }
    }
    out
}

/// Current Unix time in seconds, defaulting to 0 if the clock is before the
/// epoch (which the expiry math then treats as "everything is in the
/// future" rather than panicking).
fn now_unix() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

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

    #[test]
    fn no_config_file_is_a_passing_noop() {
        let dir = tempdir().unwrap();
        let global = GlobalArgs::default();
        assert!(run_auth_preflight(&global, dir.path(), false));
    }

    #[test]
    fn config_without_auth_schemes_passes() {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("mcptest.yaml"),
            "servers:\n  api:\n    url: https://mcp.example.com/v1\n",
        )
        .unwrap();
        let global = GlobalArgs::default();
        assert!(run_auth_preflight(&global, dir.path(), false));
    }

    #[test]
    fn collect_finds_sigv4_scheme() {
        let yaml: YamlValue = serde_yaml::from_str(
            r#"
servers:
  awsg:
    url: https://abc.execute-api.us-east-1.amazonaws.com/prod/mcp
    auth:
      aws_sigv4:
        region: us-east-1
        service: execute-api
        credentials:
          source: from_environment
"#,
        )
        .unwrap();
        let schemes = collect_server_schemes(&yaml);
        assert_eq!(schemes.len(), 1);
        assert_eq!(schemes[0].0, "awsg");
        assert!(schemes[0].1.sigv4.is_some());
    }

    #[test]
    fn web_bot_auth_missing_rsa_pss_key_fails_preflight() {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("mcptest.yaml"),
            r#"
servers:
  bot:
    url: https://mcp.example.com/v1
    auth:
      web_bot_auth:
        key_env: MCPTEST_DOCTOR_WBA_UNUSED
        directory_url: https://mcptest.sh/dir
        signature_agent: https://mcptest.sh
        algorithm: rsa-pss
"#,
        )
        .unwrap();
        let global = GlobalArgs::default();
        // The configured env var is unset, so the key cannot load and the
        // pre-flight fails (not because RSA-PSS is unsupported, which it now
        // is, but because there is no key to load).
        assert!(!run_auth_preflight(&global, dir.path(), false));
    }

    #[test]
    fn web_bot_auth_rsa_pss_with_real_key_passes_preflight() {
        use rsa::pkcs8::EncodePrivateKey;
        let mut rng = rand::rngs::OsRng;
        let key = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("generate rsa key");
        let pem = key
            .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
            .expect("encode pem")
            .to_string();
        let env_name = format!("MCPTEST_DOCTOR_RSA_{}", std::process::id());
        std::env::set_var(&env_name, pem);
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("mcptest.yaml"),
            format!(
                r#"
servers:
  bot:
    url: https://mcp.example.com/v1
    auth:
      web_bot_auth:
        key_env: {env_name}
        directory_url: https://mcptest.sh/dir
        signature_agent: https://mcptest.sh
        algorithm: rsa-pss
"#
            ),
        )
        .unwrap();
        let global = GlobalArgs::default();
        let ok = run_auth_preflight(&global, dir.path(), false);
        std::env::remove_var(&env_name);
        assert!(ok, "a valid RSA-PSS key should pass the pre-flight");
    }

    /// A stub sender so the live-probe wiring is testable without the
    /// network. It answers every request with the same canned status.
    struct StatusStub {
        status: u16,
    }

    impl ProbeSender for StatusStub {
        fn send(&self, _request: &ProbeRequest) -> Result<ProbeResponse, String> {
            Ok(ProbeResponse {
                status: self.status,
            })
        }
    }

    #[test]
    fn live_probe_web_bot_auth_directory_2xx_passes() {
        let schemes: AuthSchemes = parse_auth_schemes(
            &serde_yaml::from_str::<YamlValue>(
                "url: https://mcp.example.com/v1\nauth:\n  web_bot_auth:\n    key_env: WBA_UNUSED\n    directory_url: https://mcptest.sh/dir\n    signature_agent: https://mcptest.sh\n",
            )
            .unwrap(),
        )
        .unwrap();
        let global = GlobalArgs::default();
        let sender = StatusStub { status: 200 };
        assert!(run_live_probes(&global, "bot", &schemes, &sender));
    }

    #[test]
    fn live_probe_web_bot_auth_directory_404_fails() {
        let schemes: AuthSchemes = parse_auth_schemes(
            &serde_yaml::from_str::<YamlValue>(
                "url: https://mcp.example.com/v1\nauth:\n  web_bot_auth:\n    key_env: WBA_UNUSED\n    directory_url: https://mcptest.sh/dir\n    signature_agent: https://mcptest.sh\n",
            )
            .unwrap(),
        )
        .unwrap();
        let global = GlobalArgs::default();
        let sender = StatusStub { status: 404 };
        assert!(!run_live_probes(&global, "bot", &schemes, &sender));
    }

    #[test]
    fn live_probe_sigv4_signs_and_passes_on_200() {
        let access = format!("DOCTOR_PROBE_AK_{}", std::process::id());
        let secret = format!("DOCTOR_PROBE_SK_{}", std::process::id());
        std::env::set_var(&access, "AKIDEXAMPLE");
        std::env::set_var(&secret, "must-not-leak");
        let schemes: AuthSchemes = parse_auth_schemes(
            &serde_yaml::from_str::<YamlValue>(&format!(
                "url: https://abc.execute-api.us-east-1.amazonaws.com/prod/mcp\nauth:\n  aws_sigv4:\n    region: us-east-1\n    service: execute-api\n    credentials:\n      source: explicit\n      access_key_id_env: {access}\n      secret_access_key_env: {secret}\n",
            ))
            .unwrap(),
        )
        .unwrap();
        let global = GlobalArgs::default();
        let sender = StatusStub { status: 200 };
        let ok = run_live_probes(&global, "awsg", &schemes, &sender);
        std::env::remove_var(&access);
        std::env::remove_var(&secret);
        assert!(ok, "a 200 from the stubbed STS endpoint should pass");
    }
}
