//! The native plane's host identity — the identity split (rust-safety programme).
//!
//! History: ONE RSA-2048 identity (`gamestream::cert`) served every plane, because Moonlight
//! mandates RSA and the planes grew out of the GameStream host. The native punktfunk/1 QUIC
//! plane and the management API now share THIS identity instead: **ECDSA P-256** — generated by
//! rcgen on the workspace's aws-lc-rs backend (no `rsa` crate on the native path, so the accepted
//! Marvin advisory RUSTSEC-2023-0071 stops applying to native-only hosts once WP19 gates the
//! compat planes), browser-compatible (an operator opens `/api/docs` in one, and no mainstream
//! browser accepts an Ed25519 server cert), and carrying real SANs (localhost, loopback, the
//! machine hostname) — which the legacy cert never had. The GameStream plane keeps the RSA
//! identity untouched: Moonlight pins it and its pairing hashes bind its X.509 signature bytes.
//!
//! ## Migration — the rule that keeps every pinned fingerprint valid
//!
//! Clients TOFU-pin the SHA-256 of the leaf cert DER at pairing and use that one pin for BOTH the
//! QUIC plane and the mgmt/library API, so the two must always present the same identity, and it
//! must never change under a client that pinned it. Hence [`load_or_adopt`]:
//!
//!  * `native-cert.pem` + `native-key.pem` exist → use them (steady state);
//!  * else, the native trust store is EMPTY (nobody has ever pinned this host) → generate P-256,
//!    persist, adopt — fresh installs get the split immediately;
//!  * else (an upgraded host with live native pairings) → keep presenting the **legacy RSA**
//!    identity those clients pinned, and log how to migrate (unpair all native clients, restart).
//!    Nothing breaks silently; the operator chooses the re-pair moment.

use anyhow::{Context, Result};
use pf_paths::config_dir;
use std::fs;

/// The cert/key the native QUIC plane and the management API present. Plain PEMs — every
/// consumer (quinn via `punktfunk_core::quic::endpoint`, the mgmt rustls config) parses PEM
/// generically, so RSA (legacy fallback) and P-256 (the split identity) both fit.
#[derive(Clone)]
pub struct NativeIdentity {
    pub cert_pem: String,
    pub key_pem: String,
}

/// Resolve the native identity per the migration rule in the module docs. Call it ONCE per
/// process (in `serve`, before the planes start) and hand the result to both consumers — two
/// concurrent callers could otherwise race the first-run file writes.
pub fn load_or_adopt(np: &crate::native_pairing::NativePairing) -> Result<NativeIdentity> {
    let dir = config_dir();
    let cert_path = dir.join("native-cert.pem");
    let key_path = dir.join("native-key.pem");
    // Harden the directory BEFORE the first read (the same 2026-08-05 M-1 rationale as the legacy
    // identity): a pre-planted cert/key pair must not be adoptable out of a user-writable dir.
    pf_paths::create_private_dir(&dir).ok();
    if let (Ok(c), Ok(k)) = (
        fs::read_to_string(&cert_path),
        fs::read_to_string(&key_path),
    ) {
        if !c.trim().is_empty() && !k.trim().is_empty() {
            return Ok(NativeIdentity {
                cert_pem: c,
                key_pem: k,
            });
        }
    }
    if np.list().is_empty() {
        let (cert_pem, key_pem) = generate()?;
        // The key is the trust root for the QUIC identity and the mgmt TLS — owner-only, exactly
        // like the legacy key. The cert is public but written the same way for consistency.
        pf_paths::write_secret_file(&key_path, key_pem.as_bytes())
            .with_context(|| format!("write {}", key_path.display()))?;
        pf_paths::write_secret_file(&cert_path, cert_pem.as_bytes())
            .with_context(|| format!("write {}", cert_path.display()))?;
        tracing::info!(
            path = %cert_path.display(),
            "generated the native host identity (ECDSA P-256, SANs, key 0600)"
        );
        return Ok(NativeIdentity { cert_pem, key_pem });
    }
    // Live native pairings pinned the legacy RSA cert — switching identities now would strand
    // every one of them (the pin is the SHA-256 of the leaf DER). Keep serving what they pinned.
    // A pem-only read on purpose (WP19): rustls/aws-lc-rs can SERVE an existing RSA cert without
    // the `rsa` crate, so the native-only build never links it — the crate exists solely behind
    // the `gamestream` feature (generation + the pairing signer).
    if let (Ok(c), Ok(k)) = (
        fs::read_to_string(dir.join("cert.pem")),
        fs::read_to_string(dir.join("key.pem")),
    ) {
        if !c.trim().is_empty() && !k.trim().is_empty() {
            tracing::info!(
                "native identity: keeping the legacy RSA cert — paired native clients pinned it. \
                 To migrate to the P-256 identity: unpair ALL native clients, restart the host, \
                 re-pair."
            );
            return Ok(NativeIdentity {
                cert_pem: c,
                key_pem: k,
            });
        }
    }
    // Degenerate: native pairings exist but the cert they pinned is gone from disk — those
    // clients are stranded whatever we serve, so mint the P-256 identity and say so.
    tracing::warn!(
        "native identity: paired native clients exist but the legacy cert.pem/key.pem they \
         pinned is missing — minting the P-256 identity; those clients must re-pair"
    );
    let (cert_pem, key_pem) = generate()?;
    pf_paths::write_secret_file(&key_path, key_pem.as_bytes())
        .with_context(|| format!("write {}", key_path.display()))?;
    pf_paths::write_secret_file(&cert_path, cert_pem.as_bytes())
        .with_context(|| format!("write {}", cert_path.display()))?;
    Ok(NativeIdentity { cert_pem, key_pem })
}

/// Throwaway in-memory identity — nothing touches the config dir (tests).
#[cfg(test)]
pub fn ephemeral() -> Result<NativeIdentity> {
    let (cert_pem, key_pem) = generate()?;
    Ok(NativeIdentity { cert_pem, key_pem })
}

/// Generate the P-256 identity: rcgen CAN generate EC keys, so it does the whole thing here —
/// unlike RSA, which no rcgen backend will generate (see `gamestream::cert`'s note, where the key
/// comes from the `rsa` crate and rcgen only self-signs it). SANs cover the names a browser or a
/// loopback poller actually dials; LAN IPs are deliberately absent (they change, and the native
/// clients pin the fingerprint rather than verify names).
fn generate() -> Result<(String, String)> {
    let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
        .context("generate P-256 host key")?;
    let mut sans = vec![
        "localhost".to_string(),
        "127.0.0.1".to_string(),
        "::1".to_string(),
    ];
    // The machine hostname, when it is a clean DNS label (a display name with spaces is not).
    let hn = crate::gamestream::machine_hostname();
    if !hn.is_empty()
        && hn.len() <= 63
        && hn
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
    {
        sans.insert(0, hn);
    }
    let mut params = rcgen::CertificateParams::new(sans).context("cert params")?;
    params
        .distinguished_name
        .push(rcgen::DnType::CommonName, "punktfunk");
    // Mirror the legacy identity's fixed window: no clock-skew surprises, and clients pin the
    // fingerprint rather than check validity.
    params.not_before = rcgen::date_time_ymd(2020, 1, 1);
    params.not_after = rcgen::date_time_ymd(2040, 1, 1);
    let cert = params.self_signed(&key).context("self-sign cert")?;
    Ok((cert.pem(), key.serialize_pem()))
}

/// Serializes every test in this crate that overrides `PUNKTFUNK_CONFIG_DIR` — the env var is
/// process-global, so two such tests running in parallel threads would read each other's
/// throwaway config dirs. Lock it BEFORE installing the override.
#[cfg(test)]
pub(crate) static CONFIG_DIR_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

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

    /// Scoped `PUNKTFUNK_CONFIG_DIR` override, restored on drop even if an assertion panics —
    /// these tests write identity files and must never touch a real config dir.
    struct EnvGuard(Option<std::ffi::OsString>);
    impl EnvGuard {
        fn set(dir: &std::path::Path) -> EnvGuard {
            let prev = std::env::var_os("PUNKTFUNK_CONFIG_DIR");
            // SAFETY: only called by tests that hold CONFIG_DIR_TEST_LOCK, which serializes
            // every test that writes or reads this variable across the whole test binary.
            unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", dir) };
            EnvGuard(prev)
        }
    }
    impl Drop for EnvGuard {
        fn drop(&mut self) {
            match self.0.take() {
                // SAFETY: dropped while the owning test still holds CONFIG_DIR_TEST_LOCK — the
                // same serialization as `EnvGuard::set`.
                Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
                // SAFETY: as above.
                None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
            }
        }
    }

    fn empty_store(dir: &std::path::Path) -> crate::native_pairing::NativePairing {
        crate::native_pairing::NativePairing::load_with(
            Some(dir.join("native-trust.json")),
            None,
            false,
        )
        .unwrap()
    }

    #[test]
    fn adopts_p256_when_no_client_ever_pinned() {
        let _serial = super::CONFIG_DIR_TEST_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let tmp = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set(tmp.path());
        let np = empty_store(tmp.path());
        let id = load_or_adopt(&np).unwrap();
        // A P-256 (id-ecPublicKey) cert with the promised SANs, persisted…
        let (_, pem) = x509_parser::pem::parse_x509_pem(id.cert_pem.as_bytes()).unwrap();
        let x509 = pem.parse_x509().unwrap();
        assert_eq!(
            x509.public_key().algorithm.algorithm.to_id_string(),
            "1.2.840.10045.2.1", // id-ecPublicKey
        );
        assert!(id.cert_pem.contains("BEGIN CERTIFICATE"));
        assert!(tmp.path().join("native-cert.pem").exists());
        assert!(tmp.path().join("native-key.pem").exists());
        // …and stable: a second resolve loads the same identity (same fingerprint forever).
        let again = load_or_adopt(&np).unwrap();
        assert_eq!(again.cert_pem, id.cert_pem);
    }

    #[test]
    fn keeps_legacy_rsa_while_native_pairings_exist() {
        let _serial = super::CONFIG_DIR_TEST_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let tmp = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set(tmp.path());
        let np = empty_store(tmp.path());
        np.add("old-client", &"ab".repeat(32)).unwrap();
        // The legacy identity that client pinned. Contents are opaque to the fallback — it
        // serves the files verbatim (pem-only read; no `rsa` crate involved, WP19).
        std::fs::write(tmp.path().join("cert.pem"), "legacy cert pem").unwrap();
        std::fs::write(tmp.path().join("key.pem"), "legacy key pem").unwrap();
        let id = load_or_adopt(&np).unwrap();
        // The legacy RSA identity is what that client pinned — it must be what we serve…
        assert_eq!(id.cert_pem, "legacy cert pem");
        // …and no P-256 identity may be minted while the pin is live.
        assert!(!tmp.path().join("native-cert.pem").exists());
    }

    /// Degenerate: pairings exist but the legacy cert they pinned is gone — those clients are
    /// stranded whatever we serve, so the P-256 identity is minted rather than failing to start.
    #[test]
    fn mints_p256_when_legacy_files_vanished() {
        let _serial = super::CONFIG_DIR_TEST_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let tmp = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set(tmp.path());
        let np = empty_store(tmp.path());
        np.add("stranded-client", &"cd".repeat(32)).unwrap();
        let id = load_or_adopt(&np).unwrap();
        assert!(id.cert_pem.contains("BEGIN CERTIFICATE"));
        assert!(tmp.path().join("native-cert.pem").exists());
    }
}
