//! mTLS client-certificate authentication for URL targets.
//!
//! The TLS-layer wiring reuses the existing reqwest `rustls-tls` stack:
//! [`identity`] loads the client cert plus private key into a
//! `reqwest::Identity`, and the HTTP transports attach it at
//! client-build time. An optional CA bundle becomes an extra
//! root certificate via [`root_certificate`]. mTLS composes with bearer
//! tokens and static headers (the trust anchor is the TLS handshake; a
//! token still rides on top).
//!
//! See `docs/guides/auth/auth.md` for the YAML shape and `docs/adr/0032-mtls-auth.md`
//! for design rationale.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use super::AuthError;

/// mTLS configuration for a URL-target server.
///
/// The private key may be loaded from a file path or from an environment
/// variable that contains the PEM bytes directly (CI common pattern). The
/// CA bundle is optional and overrides the platform trust store when set.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct MtlsConfig {
    /// PEM file containing the client certificate (and intermediates).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub client_cert_path: Option<PathBuf>,
    /// PEM file containing the client private key.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub client_key_path: Option<PathBuf>,
    /// Env var holding the PEM-encoded private key. Mutually exclusive
    /// with `client_key_path` at runtime.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub client_key_env: Option<String>,
    /// Optional PEM bundle that overrides the platform trust store
    /// for server certificate validation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ca_bundle_path: Option<PathBuf>,
}

impl MtlsConfig {
    /// Validate the config without touching the filesystem. Both the
    /// certificate and one of the two key sources must be set when mTLS
    /// is enabled.
    pub fn validate(&self) -> Result<(), AuthError> {
        let has_key = self.client_key_path.is_some() || self.client_key_env.is_some();
        let has_cert = self.client_cert_path.is_some();
        match (has_cert, has_key) {
            (true, true) => {}
            (false, false) => {} // mTLS not enabled; nothing to validate.
            (true, false) => {
                return Err(AuthError::InvalidConfig {
                    reason: "mtls.client_cert_path set without client_key_path or client_key_env"
                        .into(),
                });
            }
            (false, true) => {
                return Err(AuthError::InvalidConfig {
                    reason: "mtls.client_key_* set without client_cert_path".into(),
                });
            }
        }
        if self.client_key_path.is_some() && self.client_key_env.is_some() {
            return Err(AuthError::InvalidConfig {
                reason: "mtls.client_key_path and client_key_env are mutually exclusive".into(),
            });
        }
        Ok(())
    }

    /// Returns true when the config asks the runner to enable mTLS.
    pub fn is_enabled(&self) -> bool {
        self.client_cert_path.is_some()
    }
}

/// Validate `config` and confirm an identity can be built from it.
///
/// A no-op when mTLS is not enabled. When it is, this loads the cert and
/// key the same way [`identity`] does so a misconfiguration surfaces at
/// connect time rather than on the first request. The built identity is
/// discarded; the transport calls [`identity`] for the value it attaches.
pub fn build_tls_config(config: &MtlsConfig) -> Result<(), AuthError> {
    config.validate()?;
    if !config.is_enabled() {
        return Ok(());
    }
    identity(config).map(|_| ())
}

/// Build a `reqwest::Identity` from the configured client certificate and
/// private key.
///
/// The cert comes from `client_cert_path`; the key comes from
/// `client_key_path` or, when set, the PEM bytes in `client_key_env`.
/// reqwest's `Identity::from_pem` wants the key and cert concatenated in
/// one PEM buffer, so we read both and join them. Errors are typed and
/// never echo key material.
pub fn identity(config: &MtlsConfig) -> Result<reqwest::Identity, AuthError> {
    config.validate()?;
    let cert_path = config
        .client_cert_path
        .as_ref()
        .ok_or_else(|| crypto_error("client_cert_path is required for mTLS"))?;
    let cert_pem = read_pem(cert_path)?;
    let key_pem = load_key_pem(config)?;

    // reqwest::Identity::from_pem expects the private key followed by the
    // certificate chain in a single PEM buffer.
    let mut bundle = key_pem;
    if !bundle.ends_with(b"\n") {
        bundle.push(b'\n');
    }
    bundle.extend_from_slice(&cert_pem);

    reqwest::Identity::from_pem(&bundle)
        .map_err(|e| crypto_error(&format!("failed to build client identity: {e}")))
}

/// Build a `reqwest::Certificate` from the configured `ca_bundle_path`,
/// when one is set. The transport adds it as a root certificate so an
/// internal CA can be trusted without touching the platform store.
pub fn root_certificate(config: &MtlsConfig) -> Result<Option<reqwest::Certificate>, AuthError> {
    let Some(ca_path) = config.ca_bundle_path.as_ref() else {
        return Ok(None);
    };
    let pem = read_pem(ca_path)?;
    let cert = reqwest::Certificate::from_pem(&pem)
        .map_err(|e| crypto_error(&format!("failed to parse ca_bundle_path: {e}")))?;
    Ok(Some(cert))
}

/// Validity window of an X.509 client certificate, in Unix seconds.
///
/// Exposed so `mcptest doctor` can report a cert's expiry and flag a
/// near-expiry or already-expired cert during local pre-flight without
/// re-parsing the PEM itself. Only the non-secret notBefore/notAfter
/// bounds are carried; no key material is ever included.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CertValidity {
    /// `notBefore` as seconds since the Unix epoch.
    pub not_before_unix: i64,
    /// `notAfter` as seconds since the Unix epoch.
    pub not_after_unix: i64,
}

/// Parse the configured client certificate and return its validity window.
///
/// Why this lives next to [`identity`]: the doctor pre-flight wants the
/// cert's expiry, and reusing the same `client_cert_path` read path keeps
/// the "what counts as the cert" logic in one place. Reads only the
/// certificate file (never the key), parses the first certificate in the
/// PEM, and returns its `notBefore`/`notAfter`. Errors are typed and never
/// echo file contents.
pub fn certificate_validity(config: &MtlsConfig) -> Result<CertValidity, AuthError> {
    use x509_cert::der::DecodePem;

    let cert_path = config
        .client_cert_path
        .as_ref()
        .ok_or_else(|| crypto_error("client_cert_path is required to read certificate expiry"))?;
    let cert_pem = read_pem(cert_path)?;
    let cert = x509_cert::Certificate::from_pem(&cert_pem)
        .map_err(|e| crypto_error(&format!("failed to parse client certificate: {e}")))?;
    let validity = cert.tbs_certificate.validity;
    Ok(CertValidity {
        not_before_unix: validity.not_before.to_unix_duration().as_secs() as i64,
        not_after_unix: validity.not_after.to_unix_duration().as_secs() as i64,
    })
}

/// Read the private key PEM bytes from the configured source. A file
/// path source also runs the permission check so a permissive key file
/// surfaces a warning-shaped error.
fn load_key_pem(config: &MtlsConfig) -> Result<Vec<u8>, AuthError> {
    if let Some(key_path) = config.client_key_path.as_ref() {
        check_key_file_permissions(key_path)?;
        return read_pem(key_path);
    }
    if let Some(env_name) = config.client_key_env.as_ref() {
        let pem = std::env::var(env_name).map_err(|_| AuthError::MissingEnvVar {
            name: env_name.clone(),
        })?;
        return Ok(pem.into_bytes());
    }
    Err(crypto_error(
        "no client key source (client_key_path or client_key_env)",
    ))
}

/// Read a PEM file into bytes, mapping I/O failures to a typed error.
fn read_pem(path: &Path) -> Result<Vec<u8>, AuthError> {
    std::fs::read(path).map_err(|source| AuthError::Io {
        path: path.to_path_buf(),
        source,
    })
}

fn crypto_error(reason: &str) -> AuthError {
    AuthError::Crypto {
        scheme: "mtls",
        reason: reason.to_string(),
    }
}

/// Verify a private-key file is not group- or world-readable on Unix.
/// Warns (returns [`AuthError::PermissiveKeyFile`]) when the file mode
/// has bits outside `0600`. No-op on Windows where the POSIX mode is
/// not meaningful.
pub fn check_key_file_permissions(path: &Path) -> Result<(), AuthError> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        let metadata = std::fs::metadata(path).map_err(|source| AuthError::Io {
            path: path.to_path_buf(),
            source,
        })?;
        let mode = metadata.permissions().mode() & 0o777;
        if mode & 0o077 != 0 {
            return Err(AuthError::PermissiveKeyFile {
                path: path.to_path_buf(),
                mode,
            });
        }
        Ok(())
    }
    #[cfg(not(unix))]
    {
        let _ = path;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    // Only the `#[cfg(unix)]` permission tests below write to a temp file, so
    // gate the import to match or it is unused on Windows (-D warnings).
    #[cfg(unix)]
    use std::io::Write;

    #[test]
    fn empty_config_is_valid_disabled() {
        let cfg = MtlsConfig::default();
        assert!(cfg.validate().is_ok());
        assert!(!cfg.is_enabled());
    }

    #[test]
    fn cert_without_key_rejected() {
        let cfg = MtlsConfig {
            client_cert_path: Some("client.pem".into()),
            ..Default::default()
        };
        assert!(matches!(
            cfg.validate(),
            Err(AuthError::InvalidConfig { .. })
        ));
    }

    #[test]
    fn key_without_cert_rejected() {
        let cfg = MtlsConfig {
            client_key_path: Some("client.key".into()),
            ..Default::default()
        };
        assert!(matches!(
            cfg.validate(),
            Err(AuthError::InvalidConfig { .. })
        ));
    }

    #[test]
    fn cert_and_key_path_pair_validates() {
        let cfg = MtlsConfig {
            client_cert_path: Some("client.pem".into()),
            client_key_path: Some("client.key".into()),
            ..Default::default()
        };
        assert!(cfg.validate().is_ok());
        assert!(cfg.is_enabled());
    }

    #[test]
    fn cert_and_key_env_pair_validates() {
        let cfg = MtlsConfig {
            client_cert_path: Some("client.pem".into()),
            client_key_env: Some("MCPTEST_CLIENT_KEY".into()),
            ..Default::default()
        };
        assert!(cfg.validate().is_ok());
    }

    #[test]
    fn key_path_and_env_both_set_rejected() {
        let cfg = MtlsConfig {
            client_cert_path: Some("client.pem".into()),
            client_key_path: Some("client.key".into()),
            client_key_env: Some("MCPTEST_CLIENT_KEY".into()),
            ..Default::default()
        };
        assert!(matches!(
            cfg.validate(),
            Err(AuthError::InvalidConfig { .. })
        ));
    }

    #[test]
    fn build_tls_config_noop_when_disabled() {
        let cfg = MtlsConfig::default();
        assert!(build_tls_config(&cfg).is_ok());
    }

    /// Generate a throwaway self-signed cert + key PEM pair for identity
    /// round-trip tests. Uses rcgen so the fixture is never committed.
    fn generate_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())
    }

    #[test]
    fn identity_loads_from_cert_and_key_paths() {
        let (cert_pem, key_pem) = generate_cert_and_key_pem();
        let dir = tempfile::tempdir().unwrap();
        let cert_path = dir.path().join("client.pem");
        let key_path = dir.path().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();
        }
        let cfg = MtlsConfig {
            client_cert_path: Some(cert_path),
            client_key_path: Some(key_path),
            ..Default::default()
        };
        assert!(
            identity(&cfg).is_ok(),
            "identity should build from PEM pair"
        );
        assert!(build_tls_config(&cfg).is_ok());
    }

    #[test]
    fn identity_loads_key_from_env() {
        let (cert_pem, key_pem) = generate_cert_and_key_pem();
        let dir = tempfile::tempdir().unwrap();
        let cert_path = dir.path().join("client.pem");
        std::fs::write(&cert_path, cert_pem).unwrap();
        // Use a process-unique env var name so parallel tests do not clash.
        let env_name = format!("MCPTEST_MTLS_KEY_{}", std::process::id());
        std::env::set_var(&env_name, key_pem);
        let cfg = MtlsConfig {
            client_cert_path: Some(cert_path),
            client_key_env: Some(env_name.clone()),
            ..Default::default()
        };
        let result = identity(&cfg);
        std::env::remove_var(&env_name);
        assert!(result.is_ok(), "identity should build from env-sourced key");
    }

    #[test]
    fn identity_missing_env_var_is_typed_error() {
        let dir = tempfile::tempdir().unwrap();
        let cert_path = dir.path().join("client.pem");
        std::fs::write(&cert_path, "not-a-real-cert").unwrap();
        let cfg = MtlsConfig {
            client_cert_path: Some(cert_path),
            client_key_env: Some("MCPTEST_MTLS_DEFINITELY_UNSET".into()),
            ..Default::default()
        };
        match identity(&cfg) {
            Err(AuthError::MissingEnvVar { name }) => {
                assert_eq!(name, "MCPTEST_MTLS_DEFINITELY_UNSET");
            }
            other => panic!("expected MissingEnvVar, got {other:?}"),
        }
    }

    #[test]
    fn certificate_validity_reads_not_after_from_generated_cert() {
        let (cert_pem, _key) = generate_cert_and_key_pem();
        let dir = tempfile::tempdir().unwrap();
        let cert_path = dir.path().join("client.pem");
        std::fs::write(&cert_path, cert_pem).unwrap();
        let cfg = MtlsConfig {
            client_cert_path: Some(cert_path),
            ..Default::default()
        };
        let validity = certificate_validity(&cfg).expect("validity parses");
        // rcgen default validity ends in the future, so not_after must be
        // greater than not_before and a positive epoch value.
        assert!(validity.not_after_unix > validity.not_before_unix);
        assert!(validity.not_after_unix > 0);
    }

    #[test]
    fn certificate_validity_rejects_garbage_pem() {
        let dir = tempfile::tempdir().unwrap();
        let cert_path = dir.path().join("client.pem");
        std::fs::write(&cert_path, "not a certificate").unwrap();
        let cfg = MtlsConfig {
            client_cert_path: Some(cert_path),
            ..Default::default()
        };
        assert!(matches!(
            certificate_validity(&cfg),
            Err(AuthError::Crypto { .. })
        ));
    }

    #[test]
    fn root_certificate_is_none_without_bundle() {
        let cfg = MtlsConfig::default();
        assert!(root_certificate(&cfg).unwrap().is_none());
    }

    #[test]
    fn root_certificate_loads_from_bundle() {
        let (cert_pem, _key) = generate_cert_and_key_pem();
        let dir = tempfile::tempdir().unwrap();
        let ca_path = dir.path().join("ca.pem");
        std::fs::write(&ca_path, cert_pem).unwrap();
        let cfg = MtlsConfig {
            ca_bundle_path: Some(ca_path),
            ..Default::default()
        };
        assert!(root_certificate(&cfg).unwrap().is_some());
    }

    #[cfg(unix)]
    #[test]
    fn permission_check_accepts_0600() {
        use std::os::unix::fs::PermissionsExt;

        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        tmp.write_all(b"key").unwrap();
        let path = tmp.path().to_path_buf();
        let mut perms = std::fs::metadata(&path).unwrap().permissions();
        perms.set_mode(0o600);
        std::fs::set_permissions(&path, perms).unwrap();
        assert!(check_key_file_permissions(&path).is_ok());
    }

    #[cfg(unix)]
    #[test]
    fn permission_check_warns_on_0644() {
        use std::os::unix::fs::PermissionsExt;

        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        tmp.write_all(b"key").unwrap();
        let path = tmp.path().to_path_buf();
        let mut perms = std::fs::metadata(&path).unwrap().permissions();
        perms.set_mode(0o644);
        std::fs::set_permissions(&path, perms).unwrap();
        let err = check_key_file_permissions(&path).unwrap_err();
        assert!(matches!(
            err,
            AuthError::PermissiveKeyFile { mode: 0o644, .. }
        ));
    }
}
