//! Local auth pre-flight for `mcptest doctor`.
//!
//! Why this exists: a misconfigured transport-level auth scheme (a missing
//! mTLS cert, an expired client cert, no resolvable AWS credentials, an
//! unsupported Web Bot Auth algorithm) only surfaces today when the runner
//! actually connects. This module runs the checks that can be answered from
//! local state alone, with no network call, so `mcptest doctor` reports them
//! up front. It appends [`LayerCheck`] rows to the doctor report.
//!
//! Hard rule: no check message ever prints private key material, AWS secret
//! keys, or signing keys. The rows carry only non-secret facts (paths,
//! expiry dates, key ids, algorithm names, "credentials found in
//! environment"). See .

use mcptest_config::auth_schemes::AuthSchemes;
use mcptest_core::auth::{self, MtlsConfig, SigV4Config, SigV4Credentials, WebBotAuthConfig};
use mcptest_core::network::Layer;

use super::doctor::LayerCheck;

/// Seconds in a day, used to convert the 30-day near-expiry window and to
/// render an expiry date without pulling a date-formatting dependency.
const SECONDS_PER_DAY: i64 = 86_400;

/// Days before `notAfter` at which doctor warns the operator the cert is
/// about to expire. Below zero days means already expired (a FAIL).
const NEAR_EXPIRY_WARN_DAYS: i64 = 30;

/// Build the local auth pre-flight rows for one server's configured
/// schemes.
///
/// `now_unix` is passed in (not read from the clock) so the expiry checks
/// are deterministic under test. Each configured scheme contributes one or
/// more [`LayerCheck`] rows on the [`Layer::Authentication`] layer. A scheme
/// that is not configured contributes nothing.
pub fn auth_preflight_checks(schemes: &AuthSchemes, now_unix: i64) -> Vec<LayerCheck> {
    let mut checks = Vec::new();
    if let Some(mtls) = &schemes.mtls {
        checks.extend(check_mtls(mtls, now_unix));
    }
    if let Some(sigv4) = &schemes.sigv4 {
        checks.push(check_sigv4(sigv4));
    }
    if let Some(web_bot_auth) = &schemes.web_bot_auth {
        checks.push(check_web_bot_auth(web_bot_auth));
    }
    checks
}

/// mTLS: confirm the cert and key load, the cert is not expired (warn near
/// expiry), and the key file mode is not over-permissive on unix.
fn check_mtls(config: &MtlsConfig, now_unix: i64) -> Vec<LayerCheck> {
    let mut checks = Vec::new();
    // First prove the cert + key pair actually loads through the same path
    // the transport uses. A parse failure is a hard FAIL.
    if let Err(err) = auth::identity(config) {
        checks.push(fail(
            "mtls",
            format!("client certificate or key failed to load: {err}"),
        ));
        return checks;
    }
    checks.push(pass(
        "mtls",
        "client certificate and key loaded".to_string(),
    ));
    checks.push(mtls_expiry_check(config, now_unix));
    if let Some(row) = mtls_key_permission_check(config) {
        checks.push(row);
    }
    checks
}

/// Build the cert-expiry row: FAIL when already expired, warn-shaped PASS
/// when within the near-expiry window, plain PASS otherwise.
fn mtls_expiry_check(config: &MtlsConfig, now_unix: i64) -> LayerCheck {
    match auth::certificate_validity(config) {
        Ok(validity) => {
            let not_after = validity.not_after_unix;
            let days_left = (not_after - now_unix) / SECONDS_PER_DAY;
            let date = epoch_to_date(not_after);
            if not_after <= now_unix {
                fail("mtls", format!("client certificate expired on {date}"))
            } else if days_left <= NEAR_EXPIRY_WARN_DAYS {
                // A near-expiry cert is still usable, so this is a PASS row
                // carrying a warning rather than a FAIL.
                pass(
                    "mtls",
                    format!(
                        "client certificate expires on {date} (in {days_left} day(s)); renew soon"
                    ),
                )
            } else {
                pass("mtls", format!("client certificate valid until {date}"))
            }
        }
        Err(err) => fail(
            "mtls",
            format!("could not read client certificate expiry: {err}"),
        ),
    }
}

/// Warn (never fail) when the key file mode is more permissive than 0600.
/// Returns `None` when the key comes from an env var (no file to inspect)
/// or the platform has no POSIX mode.
fn mtls_key_permission_check(config: &MtlsConfig) -> Option<LayerCheck> {
    let key_path = config.client_key_path.as_ref()?;
    match auth::check_key_file_permissions(key_path) {
        Ok(()) => None,
        Err(err) => Some(pass("mtls", format!("private key file permissions: {err}"))),
    }
}

/// SigV4: report which credential source resolves and confirm a region is
/// set. FAIL only when no credential source can be resolved at all. Never
/// performs a live STS call (deferred) and never prints secret values.
fn check_sigv4(config: &SigV4Config) -> LayerCheck {
    let region = config.region.trim();
    let region_note = if region.is_empty() {
        // The config validator already rejects an empty region at load, so
        // this branch is defensive; report it plainly if it ever surfaces.
        "region not resolved".to_string()
    } else {
        format!("region {region}")
    };
    match credential_source_summary(&config.credentials) {
        Some(source) => pass(
            "sigv4",
            format!("AWS credentials resolvable ({source}); {region_note}"),
        ),
        None => fail(
            "sigv4",
            format!("no AWS credential source resolved; {region_note}. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or the explicit env vars named in the config."),
        ),
    }
}

/// Describe the resolvable credential source without revealing any value.
/// Returns `None` when nothing resolves (the only FAIL case for SigV4).
fn credential_source_summary(credentials: &SigV4Credentials) -> Option<String> {
    match credentials {
        SigV4Credentials::FromEnvironment => {
            if env_present("AWS_ACCESS_KEY_ID") && env_present("AWS_SECRET_ACCESS_KEY") {
                Some("credentials found in environment".to_string())
            } else {
                None
            }
        }
        SigV4Credentials::Explicit {
            access_key_id_env,
            secret_access_key_env,
            ..
        } => {
            if env_present(access_key_id_env) && env_present(secret_access_key_env) {
                Some(format!(
                    "explicit env vars {access_key_id_env} / {secret_access_key_env} present"
                ))
            } else {
                None
            }
        }
        // Named-profile resolution reads `~/.aws/credentials` locally (no
        // network), so doctor can confirm the profile resolves. A failure to
        // resolve is the FAIL case (None); a success reports the profile name
        // without ever printing the resolved secret.
        SigV4Credentials::Profile { name } => match auth::resolve_sigv4_profile(name) {
            Ok(()) => Some(format!("named profile `{name}` resolved from ~/.aws")),
            Err(_) => None,
        },
    }
}

/// Web Bot Auth: confirm the signing key loads and parses for the configured
/// algorithm (Ed25519 or RSA-PSS). Never fetches the key directory and never
/// prints the key (only the derived public key id).
fn check_web_bot_auth(config: &WebBotAuthConfig) -> LayerCheck {
    let algorithm = match config.algorithm {
        mcptest_core::auth::SignatureAlgorithm::Ed25519 => "Ed25519",
        mcptest_core::auth::SignatureAlgorithm::RsaPss => "RSA-PSS",
    };
    match auth::web_bot_auth_preflight_key(config) {
        Ok(key_id) => pass(
            "web_bot_auth",
            format!("{algorithm} signing key loaded (key id {key_id})"),
        ),
        Err(err) => fail("web_bot_auth", format!("signing key failed to load: {err}")),
    }
}

/// True when the named env var is set to a non-empty value.
fn env_present(name: &str) -> bool {
    std::env::var(name).map(|v| !v.is_empty()).unwrap_or(false)
}

/// Render a Unix timestamp as a `YYYY-MM-DD` UTC date for a check message.
/// Reuses the civil-date math so doctor needs no date-formatting crate.
fn epoch_to_date(unix_secs: i64) -> String {
    let days = unix_secs.div_euclid(SECONDS_PER_DAY);
    let (y, m, d) = auth::epoch_days_to_ymd(days);
    format!("{y:04}-{m:02}-{d:02}")
}

/// Build a passing [`LayerCheck`] for `scheme` on the authentication layer.
fn pass(scheme: &str, message: String) -> LayerCheck {
    LayerCheck {
        layer: Layer::Authentication,
        passed: true,
        message: format!("{scheme}: {message}"),
        diagnostic: None,
    }
}

/// Build a failing [`LayerCheck`] for `scheme` on the authentication layer.
fn fail(scheme: &str, message: String) -> LayerCheck {
    use mcptest_core::network::NetworkDiagnostic;
    let full = format!("{scheme}: {message}");
    LayerCheck {
        layer: Layer::Authentication,
        passed: false,
        diagnostic: Some(
            NetworkDiagnostic::new(
                Layer::Authentication,
                format!("auth:{scheme}"),
                full.clone(),
            )
            .with_suggestion("fix the auth configuration before running against the server"),
        ),
        message: full,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    /// `AWS_SHARED_CREDENTIALS_FILE` / `AWS_CONFIG_FILE` are process-global, so
    /// the tests that point them at a temp credentials file must not run
    /// concurrently or they clobber each other's env. Serialize them on one
    /// lock (the suite runs tests in parallel by default).
    fn aws_env_lock() -> std::sync::MutexGuard<'static, ()> {
        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
        M.lock().unwrap_or_else(|e| e.into_inner())
    }

    /// Generate a throwaway self-signed cert + key PEM pair for the mTLS
    /// pre-flight tests. Uses rcgen so no key is committed.
    fn cert_and_key_pem() -> (String, String) {
        let cert = rcgen::generate_simple_self_signed(vec!["mcptest.invalid".to_string()])
            .expect("generate self-signed cert");
        (cert.cert.pem(), cert.signing_key.serialize_pem())
    }

    fn write_pair(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
        let (cert_pem, key_pem) = cert_and_key_pem();
        let cert_path = dir.join("client.pem");
        let key_path = dir.join("client.key");
        std::fs::write(&cert_path, cert_pem).unwrap();
        std::fs::write(&key_path, key_pem).unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&key_path).unwrap().permissions();
            perms.set_mode(0o600);
            std::fs::set_permissions(&key_path, perms).unwrap();
        }
        (cert_path, key_path)
    }

    #[test]
    fn mtls_missing_cert_file_fails() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = MtlsConfig {
            client_cert_path: Some(dir.path().join("nope.pem")),
            client_key_path: Some(dir.path().join("nope.key")),
            ..Default::default()
        };
        let checks = check_mtls(&cfg, 0);
        assert!(checks.iter().any(|c| !c.passed));
    }

    #[test]
    fn mtls_valid_pair_passes_with_future_expiry() {
        let dir = tempfile::tempdir().unwrap();
        let (cert_path, key_path) = write_pair(dir.path());
        let cfg = MtlsConfig {
            client_cert_path: Some(cert_path),
            client_key_path: Some(key_path),
            ..Default::default()
        };
        // now = epoch, so the rcgen default validity (years into the future)
        // is comfortably valid.
        let checks = check_mtls(&cfg, 0);
        assert!(
            checks.iter().all(|c| c.passed),
            "all mtls rows should pass: {:?}",
            checks.iter().map(|c| &c.message).collect::<Vec<_>>()
        );
        assert!(checks.iter().any(|c| c.message.contains("valid until")));
    }

    #[test]
    fn mtls_detects_expired_certificate() {
        let dir = tempfile::tempdir().unwrap();
        let (cert_path, key_path) = write_pair(dir.path());
        let cfg = MtlsConfig {
            client_cert_path: Some(cert_path.clone()),
            client_key_path: Some(key_path),
            ..Default::default()
        };
        // A "now" one day past the cert's own notAfter forces an expired
        // reading regardless of how far in the future the generator sets it.
        let validity = auth::certificate_validity(&cfg).unwrap();
        let far_future = validity.not_after_unix + SECONDS_PER_DAY;
        let checks = check_mtls(&cfg, far_future);
        assert!(
            checks
                .iter()
                .any(|c| !c.passed && c.message.contains("expired")),
            "expected an expired-cert FAIL row: {:?}",
            checks.iter().map(|c| &c.message).collect::<Vec<_>>()
        );
    }

    #[test]
    fn mtls_near_expiry_warns_without_failing() {
        let dir = tempfile::tempdir().unwrap();
        let (cert_path, key_path) = write_pair(dir.path());
        let cfg = MtlsConfig {
            client_cert_path: Some(cert_path.clone()),
            client_key_path: Some(key_path),
            ..Default::default()
        };
        let validity = auth::certificate_validity(&cfg).unwrap();
        // Pick a "now" 10 days before notAfter: inside the 30-day window
        // but not yet expired.
        let now = validity.not_after_unix - 10 * SECONDS_PER_DAY;
        let checks = check_mtls(&cfg, now);
        assert!(checks.iter().all(|c| c.passed), "near-expiry must not FAIL");
        assert!(checks.iter().any(|c| c.message.contains("renew soon")));
    }

    #[cfg(unix)]
    #[test]
    fn mtls_warns_on_permissive_key_file() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::tempdir().unwrap();
        let (cert_path, key_path) = write_pair(dir.path());
        let mut perms = std::fs::metadata(&key_path).unwrap().permissions();
        perms.set_mode(0o644);
        std::fs::set_permissions(&key_path, perms).unwrap();
        let cfg = MtlsConfig {
            client_cert_path: Some(cert_path),
            client_key_path: Some(key_path),
            ..Default::default()
        };
        let row = mtls_key_permission_check(&cfg).expect("permissive mode produces a row");
        assert!(row.passed, "permission issue is a warning, not a FAIL");
        assert!(row.message.contains("permissions"));
    }

    #[test]
    fn sigv4_reports_credentials_found_in_environment() {
        // Use explicit env-var names unique to this test so we never touch
        // the real AWS env vars or race other tests.
        let access_var = format!("MCPTEST_SIGV4_AK_{}", std::process::id());
        let secret_var = format!("MCPTEST_SIGV4_SK_{}", std::process::id());
        std::env::set_var(&access_var, "AKIDEXAMPLE");
        std::env::set_var(&secret_var, "not-a-real-secret");
        let cfg = SigV4Config {
            region: "us-east-1".into(),
            service: "execute-api".into(),
            credentials: SigV4Credentials::Explicit {
                access_key_id_env: access_var.clone(),
                secret_access_key_env: secret_var.clone(),
                session_token_env: None,
            },
            signature_lifetime: None,
        };
        let check = check_sigv4(&cfg);
        std::env::remove_var(&access_var);
        std::env::remove_var(&secret_var);
        assert!(check.passed);
        assert!(check.message.contains("present"));
        // The secret value must never appear in the row.
        assert!(!check.message.contains("not-a-real-secret"));
    }

    #[test]
    fn sigv4_fails_when_no_source_resolves() {
        let cfg = SigV4Config {
            region: "us-east-1".into(),
            service: "execute-api".into(),
            credentials: SigV4Credentials::Explicit {
                access_key_id_env: "MCPTEST_SIGV4_DEFINITELY_UNSET_AK".into(),
                secret_access_key_env: "MCPTEST_SIGV4_DEFINITELY_UNSET_SK".into(),
                session_token_env: None,
            },
            signature_lifetime: None,
        };
        let check = check_sigv4(&cfg);
        assert!(!check.passed);
        assert!(check.message.contains("no AWS credential source"));
    }

    #[test]
    fn sigv4_named_profile_passes_when_it_resolves_locally() {
        let _lock = aws_env_lock();
        // Point the AWS file env overrides at a temp credentials file so the
        // profile resolves without touching the user's `~/.aws`.
        let dir = tempfile::tempdir().unwrap();
        let creds = dir.path().join("credentials");
        std::fs::write(
            &creds,
            "[prod]\naws_access_key_id = AKIDEXAMPLE\naws_secret_access_key = not-a-real-secret\n",
        )
        .unwrap();
        std::env::set_var("AWS_SHARED_CREDENTIALS_FILE", &creds);
        std::env::set_var("AWS_CONFIG_FILE", dir.path().join("config"));
        let cfg = SigV4Config {
            region: "eu-west-1".into(),
            service: "lambda".into(),
            credentials: SigV4Credentials::Profile {
                name: "prod".into(),
            },
            signature_lifetime: None,
        };
        let check = check_sigv4(&cfg);
        std::env::remove_var("AWS_SHARED_CREDENTIALS_FILE");
        std::env::remove_var("AWS_CONFIG_FILE");
        assert!(check.passed, "profile should resolve: {}", check.message);
        assert!(check.message.contains("prod"));
        // The secret never leaks into the row.
        assert!(!check.message.contains("not-a-real-secret"));
    }

    #[test]
    fn sigv4_named_profile_fails_when_it_cannot_resolve() {
        let _lock = aws_env_lock();
        let dir = tempfile::tempdir().unwrap();
        // An empty credentials file: the profile is absent, so resolution
        // fails and the row is a FAIL.
        let creds = dir.path().join("credentials");
        std::fs::write(&creds, "[other]\naws_access_key_id = x\n").unwrap();
        std::env::set_var("AWS_SHARED_CREDENTIALS_FILE", &creds);
        std::env::set_var("AWS_CONFIG_FILE", dir.path().join("config"));
        let cfg = SigV4Config {
            region: "eu-west-1".into(),
            service: "lambda".into(),
            credentials: SigV4Credentials::Profile {
                name: "missing".into(),
            },
            signature_lifetime: None,
        };
        let check = check_sigv4(&cfg);
        std::env::remove_var("AWS_SHARED_CREDENTIALS_FILE");
        std::env::remove_var("AWS_CONFIG_FILE");
        assert!(!check.passed);
        assert!(check.message.contains("no AWS credential source"));
    }

    fn ed25519_pem() -> String {
        use ed25519_dalek::pkcs8::EncodePrivateKey;
        let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
        key.to_pkcs8_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
            .unwrap()
            .to_string()
    }

    fn wba_config() -> WebBotAuthConfig {
        WebBotAuthConfig {
            key_path: None,
            key_env: None,
            directory_url: "https://mcptest.sh/dir".into(),
            signature_agent: "https://mcptest.sh".into(),
            algorithm: mcptest_core::auth::SignatureAlgorithm::Ed25519,
            components: vec!["@authority".into()],
            expires_in_seconds: 30,
        }
    }

    #[test]
    fn web_bot_auth_loads_ed25519_key_and_reports_key_id() {
        let dir = tempfile::tempdir().unwrap();
        let key_path = dir.path().join("bot.ed25519");
        let mut file = std::fs::File::create(&key_path).unwrap();
        file.write_all(ed25519_pem().as_bytes()).unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&key_path).unwrap().permissions();
            perms.set_mode(0o600);
            std::fs::set_permissions(&key_path, perms).unwrap();
        }
        let cfg = WebBotAuthConfig {
            key_path: Some(key_path),
            ..wba_config()
        };
        let check = check_web_bot_auth(&cfg);
        assert!(check.passed, "ed25519 key should load: {}", check.message);
        assert!(check.message.contains("key id"));
        // The PEM body must never leak into the message.
        assert!(!check.message.contains("PRIVATE KEY"));
    }

    fn rsa_pem() -> String {
        use rsa::pkcs8::EncodePrivateKey;
        let mut rng = rand::rngs::OsRng;
        let key = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("generate rsa key");
        key.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
            .expect("encode rsa pem")
            .to_string()
    }

    #[test]
    fn web_bot_auth_loads_rsa_pss_key_and_reports_key_id() {
        let dir = tempfile::tempdir().unwrap();
        let key_path = dir.path().join("bot.rsa");
        let mut file = std::fs::File::create(&key_path).unwrap();
        file.write_all(rsa_pem().as_bytes()).unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&key_path).unwrap().permissions();
            perms.set_mode(0o600);
            std::fs::set_permissions(&key_path, perms).unwrap();
        }
        let cfg = WebBotAuthConfig {
            key_path: Some(key_path),
            algorithm: mcptest_core::auth::SignatureAlgorithm::RsaPss,
            ..wba_config()
        };
        let check = check_web_bot_auth(&cfg);
        assert!(check.passed, "rsa-pss key should load: {}", check.message);
        assert!(check.message.contains("RSA-PSS"));
        assert!(check.message.contains("key id"));
        // The PEM body must never leak into the message.
        assert!(!check.message.contains("PRIVATE KEY"));
    }

    #[test]
    fn web_bot_auth_missing_key_fails() {
        let cfg = WebBotAuthConfig {
            key_env: Some("MCPTEST_WBA_PREFLIGHT_DEFINITELY_UNSET".into()),
            algorithm: mcptest_core::auth::SignatureAlgorithm::RsaPss,
            ..wba_config()
        };
        let check = check_web_bot_auth(&cfg);
        assert!(!check.passed);
        assert!(check.message.contains("failed to load"));
    }

    #[test]
    fn empty_schemes_produce_no_checks() {
        let schemes = AuthSchemes::default();
        assert!(auth_preflight_checks(&schemes, 0).is_empty());
    }
}
