//! Web Bot Auth public-key directory emission.
//!
//! A server operator publishes a `.well-known/http-message-signatures-directory`
//! document so verifiers can find a bot's PUBLIC key. mcptest is a client, so
//! this module is a helper that builds that document (a JWK Set) for a
//! configured signing key. Only the public half is emitted; the private key
//! is loaded, the public key is derived, and the private key is dropped.
//!
//! Secret discipline: the private key never appears in the output or any
//! error message. The JWK carries only the public key material plus the same
//! non-secret `kid` the signer advertises in `Signature-Input`.
//!
//! The shape follows `draft-meunier-http-message-signatures-directory`: a JWK
//! Set (`{"keys": [...]}`) with one OKP (Ed25519) or RSA JWK whose `kid`
//! matches the signature `keyid`, `alg` names the RFC 9421 algorithm, and
//! `use` is `sig`.

use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL;
use base64::Engine;
use ed25519_dalek::pkcs8::DecodePrivateKey;
use ed25519_dalek::SigningKey;
use rsa::traits::PublicKeyParts;
use rsa::RsaPrivateKey;
use serde_json::{json, Value};

use super::web_bot_auth::{SignatureAlgorithm, WebBotAuthConfig};
use super::web_bot_auth_rsa;
use super::AuthError;

/// Build the public-key directory JSON for the configured signing key.
///
/// Reads the private key from `config.key_path` or `config.key_env`, derives
/// the public key, and returns the JWK Set document. The returned value is
/// safe to print: it contains only public key material and the non-secret
/// key id. The `kid` matches the `keyid` the signer advertises so a verifier
/// resolves the same key.
pub fn build_directory(config: &WebBotAuthConfig) -> Result<Value, AuthError> {
    config.validate()?;
    // The signer derives the canonical key id; reuse it so the directory and
    // the `Signature-Input` agree on the same `keyid`.
    let kid = super::web_bot_auth_preflight_key(config)?;
    let pem = load_key_pem(config)?;
    let jwk = match config.algorithm {
        SignatureAlgorithm::Ed25519 => ed25519_jwk(&pem, &kid)?,
        SignatureAlgorithm::RsaPss => rsa_jwk(&pem, &kid)?,
    };
    Ok(json!({ "keys": [jwk] }))
}

/// Build the directory and serialize it to a pretty-printed JSON string,
/// the form the helper command prints to stdout.
pub fn directory_json_string(config: &WebBotAuthConfig) -> Result<String, AuthError> {
    let value = build_directory(config)?;
    serde_json::to_string_pretty(&value)
        .map_err(|e| crypto_error(&format!("could not serialize directory JSON: {e}")))
}

/// Build the Ed25519 OKP JWK. `x` is the base64url-no-pad raw 32-byte public
/// key; `kid` is the signer's key id passed in by the caller.
fn ed25519_jwk(pem: &str, kid: &str) -> Result<Value, AuthError> {
    let key = SigningKey::from_pkcs8_pem(pem)
        .map_err(|e| crypto_error(&format!("failed to parse Ed25519 private key: {e}")))?;
    let public = key.verifying_key().to_bytes();
    Ok(json!({
        "kty": "OKP",
        "crv": "Ed25519",
        "x": BASE64URL.encode(public),
        "use": "sig",
        "alg": "ed25519",
        "kid": kid,
    }))
}

/// Build the RSA JWK. `n` and `e` are the base64url-no-pad big-endian modulus
/// and exponent; `kid` is the signer's SPKI-fingerprint key id passed in by
/// the caller.
fn rsa_jwk(pem: &str, kid: &str) -> Result<Value, AuthError> {
    let key = RsaPrivateKey::from_pkcs8_pem(pem)
        .map_err(|e| crypto_error(&format!("failed to parse RSA private key: {e}")))?;
    let public = key.to_public_key();
    Ok(json!({
        "kty": "RSA",
        "n": BASE64URL.encode(public.n().to_bytes_be()),
        "e": BASE64URL.encode(public.e().to_bytes_be()),
        "use": "sig",
        "alg": web_bot_auth_rsa::RSA_PSS_ALG,
        "kid": kid,
    }))
}

/// Read the PEM private key from the configured source (file or env) without
/// parsing it. Mirrors the signer's read path so both honor file permissions
/// and never log the key bytes.
fn load_key_pem(config: &WebBotAuthConfig) -> Result<String, AuthError> {
    if let Some(path) = &config.key_path {
        super::check_key_file_permissions(path)?;
        std::fs::read_to_string(path).map_err(|source| AuthError::Io {
            path: path.clone(),
            source,
        })
    } else if let Some(env_name) = &config.key_env {
        std::env::var(env_name).map_err(|_| AuthError::MissingEnvVar {
            name: env_name.clone(),
        })
    } else {
        Err(crypto_error("no key source (key_path or key_env)"))
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use ed25519_dalek::pkcs8::EncodePrivateKey;
    use std::path::PathBuf;

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

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

    fn config_with_env_key(env_name: &str, algorithm: SignatureAlgorithm) -> WebBotAuthConfig {
        WebBotAuthConfig {
            key_path: None,
            key_env: Some(env_name.to_string()),
            directory_url: "https://mcptest.sh/.well-known/http-message-signatures-directory"
                .into(),
            signature_agent: "https://mcptest.sh".into(),
            algorithm,
            components: vec!["@authority".into()],
            expires_in_seconds: 30,
        }
    }

    #[test]
    fn ed25519_directory_has_okp_jwk_shape() {
        let env_name = format!("MCPTEST_DIR_ED_{}", std::process::id());
        std::env::set_var(&env_name, ed25519_pem());
        let cfg = config_with_env_key(&env_name, SignatureAlgorithm::Ed25519);
        let value = build_directory(&cfg);
        std::env::remove_var(&env_name);
        let value = value.expect("directory builds");
        let key = &value["keys"][0];
        assert_eq!(key["kty"], "OKP");
        assert_eq!(key["crv"], "Ed25519");
        assert_eq!(key["use"], "sig");
        assert_eq!(key["alg"], "ed25519");
        assert!(key["x"].as_str().is_some_and(|s| !s.is_empty()));
        assert!(key["kid"].as_str().is_some_and(|s| !s.is_empty()));
    }

    #[test]
    fn rsa_directory_has_rsa_jwk_shape() {
        let env_name = format!("MCPTEST_DIR_RSA_{}", std::process::id());
        std::env::set_var(&env_name, rsa_pem());
        let cfg = config_with_env_key(&env_name, SignatureAlgorithm::RsaPss);
        let value = build_directory(&cfg);
        std::env::remove_var(&env_name);
        let value = value.expect("directory builds");
        let key = &value["keys"][0];
        assert_eq!(key["kty"], "RSA");
        assert_eq!(key["use"], "sig");
        assert_eq!(key["alg"], "rsa-pss-sha512");
        assert!(key["n"].as_str().is_some_and(|s| !s.is_empty()));
        // The common RSA public exponent 65537 encodes to base64url "AQAB".
        assert_eq!(key["e"].as_str(), Some("AQAB"));
        assert!(key["kid"].as_str().is_some_and(|s| !s.is_empty()));
    }

    #[test]
    fn directory_kid_matches_the_signer_keyid_for_ed25519() {
        let env_name = format!("MCPTEST_DIR_KID_{}", std::process::id());
        let pem = ed25519_pem();
        std::env::set_var(&env_name, &pem);
        let cfg = config_with_env_key(&env_name, SignatureAlgorithm::Ed25519);
        let value = build_directory(&cfg).unwrap();
        let signer_keyid = super::super::web_bot_auth_preflight_key(&cfg).unwrap();
        std::env::remove_var(&env_name);
        assert_eq!(
            value["keys"][0]["kid"].as_str(),
            Some(signer_keyid.as_str())
        );
    }

    #[test]
    fn directory_kid_matches_the_signer_keyid_for_rsa() {
        let env_name = format!("MCPTEST_DIR_KID_RSA_{}", std::process::id());
        std::env::set_var(&env_name, rsa_pem());
        let cfg = config_with_env_key(&env_name, SignatureAlgorithm::RsaPss);
        let value = build_directory(&cfg).unwrap();
        let signer_keyid = super::super::web_bot_auth_preflight_key(&cfg).unwrap();
        std::env::remove_var(&env_name);
        assert_eq!(
            value["keys"][0]["kid"].as_str(),
            Some(signer_keyid.as_str())
        );
    }

    #[test]
    fn directory_json_string_is_pretty_and_secret_free() {
        let env_name = format!("MCPTEST_DIR_JSON_{}", std::process::id());
        let pem = ed25519_pem();
        std::env::set_var(&env_name, &pem);
        let cfg = config_with_env_key(&env_name, SignatureAlgorithm::Ed25519);
        let text = directory_json_string(&cfg);
        std::env::remove_var(&env_name);
        let text = text.expect("json renders");
        assert!(text.contains("\"keys\""));
        // No private key marker ever appears in the published directory.
        assert!(!text.contains("PRIVATE KEY"));
    }

    #[test]
    fn missing_key_is_a_typed_error() {
        let cfg = config_with_env_key("MCPTEST_DIR_DEFINITELY_UNSET", SignatureAlgorithm::Ed25519);
        let err = build_directory(&cfg).unwrap_err();
        assert!(matches!(err, AuthError::MissingEnvVar { .. }));
    }

    #[test]
    fn invalid_config_is_rejected_before_reading_a_key() {
        let cfg = WebBotAuthConfig {
            key_path: Some(PathBuf::from("/tmp/a.key")),
            key_env: Some("ALSO_SET".into()),
            directory_url: "https://mcptest.sh/dir".into(),
            signature_agent: "https://mcptest.sh".into(),
            algorithm: SignatureAlgorithm::Ed25519,
            components: vec!["@authority".into()],
            expires_in_seconds: 30,
        };
        // Both key sources set: validation fails before any file read.
        assert!(matches!(
            build_directory(&cfg),
            Err(AuthError::InvalidConfig { .. })
        ));
    }
}
