//! Install **identity** — a record minted at install time that distinguishes
//! (a) the same machine, (b) a later reinstall on the same machine, and (c) a
//! different machine. Identity is NOT a credential: the SSH key / node cert are
//! credentials the identity only *references* by fingerprint, so a key can rotate
//! without the identity changing.
//!
//! Two parts with different lifetimes:
//!
//! - [`Identity::machine_fp`] — DERIVED from the host, never stored as source,
//!   STABLE across an app reinstall. Source chain: `/etc/machine-id` →
//!   `/var/lib/dbus/machine-id` → DMI `product_uuid` → a generated-and-persisted
//!   random (flagged non-authoritative). Hashed app-specifically (systemd's
//!   `sd_id128_get_machine_app_specific` pattern):
//!   `machine_fp = first 8 bytes of BLAKE3(machine_id ++ "skidbladnir-identity-v1"
//!   ++ scope ++ uid)`, hex-encoded. Folding uid+scope keeps it symmetric across
//!   user/system scope while distinguishing per-user installs.
//! - [`Identity::install_id`] — a **UUIDv7** minted fresh each install
//!   (time-ordered → a reinstall timeline). This is the reinstall signal:
//!   [`ensure`] loads an existing `identity.toml` (idempotent — a reinstall that
//!   KEEPS the data keeps its id); a purge removes the file so the next `ensure`
//!   mints a NEW (later) `install_id` while `machine_fp` stays equal.

use std::path::Path;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::systemd::Scope;

/// Wire/record scheme tag (also the wire-string prefix `nid1:…`).
pub const SCHEME: &str = "nid1";
/// App-specific hashing domain separator (systemd `sd_id128` pattern).
const DOMAIN: &[u8] = b"skidbladnir-identity-v1";
/// The record file, written under the install data dir.
const FILE: &str = "identity.toml";

/// An install identity record — see the module docs. Serialized to
/// `<data_dir>/identity.toml`. The credential fingerprints (`ssh_pubkey_fp`,
/// `node_cert_fp`) and the signature (`signed_by`/`sig`) are best-effort and may
/// be absent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Identity {
    /// Scheme tag — always [`SCHEME`] (`nid1`) for this version.
    pub scheme: String,
    /// Host-derived, reinstall-stable fingerprint (hex, 8 bytes). See module docs.
    pub machine_fp: String,
    /// UUIDv7 minted fresh each install (time-ordered).
    pub install_id: String,
    /// RFC3339 mint timestamp.
    pub minted_at: String,
    /// Best-effort hostname at mint time.
    pub host: String,
    /// Install scope (`system` / `user`).
    pub scope: String,
    /// User the identity was minted for (system user or invoking user).
    pub user: String,
    /// Which source anchored `machine_fp` (`machine-id`, `dbus-machine-id`,
    /// `dmi-product-uuid`, or `generated:non-authoritative`).
    pub hw_anchor: Option<String>,
    /// Fingerprint of an `ssh/*.pub` credential found in the data dir, if any.
    pub ssh_pubkey_fp: Option<String>,
    /// Fingerprint of a node cert found in the data dir, if any.
    pub node_cert_fp: Option<String>,
    /// The signing node public key (`ed25519:<hex>`) when signing succeeded.
    pub signed_by: Option<String>,
    /// Detached signature (hex) over the canonical identity bytes.
    pub sig: Option<String>,
}

impl Identity {
    /// The wire string `nid1:<machine_fp>:<install_id>`.
    pub fn wire(&self) -> String {
        format!("{}:{}:{}", self.scheme, self.machine_fp, self.install_id)
    }

    /// Canonical, signature-free byte encoding used for signing/verification.
    /// Order and content are fixed; the `signed_by`/`sig` fields are excluded.
    fn canonical(&self) -> Vec<u8> {
        fn opt(o: &Option<String>) -> &str {
            o.as_deref().unwrap_or("")
        }
        format!(
            "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
            self.scheme,
            self.machine_fp,
            self.install_id,
            self.minted_at,
            self.host,
            self.scope,
            self.user,
            opt(&self.hw_anchor),
            opt(&self.ssh_pubkey_fp),
            opt(&self.node_cert_fp),
        )
        .into_bytes()
    }
}

impl std::fmt::Display for Identity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.wire())
    }
}

/// A parsed wire string (`nid1:<machine_fp>:<install_id>`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WireId {
    pub scheme: String,
    pub machine_fp: String,
    pub install_id: String,
}

impl std::str::FromStr for WireId {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self> {
        let mut it = s.splitn(3, ':');
        let scheme = it.next().unwrap_or_default();
        let machine_fp = it.next();
        let install_id = it.next();
        match (machine_fp, install_id) {
            (Some(m), Some(i)) if !scheme.is_empty() && !m.is_empty() && !i.is_empty() => Ok(WireId {
                scheme: scheme.to_string(),
                machine_fp: m.to_string(),
                install_id: i.to_string(),
            }),
            _ => anyhow::bail!("not a `nid1:<machine_fp>:<install_id>` wire string: {s:?}"),
        }
    }
}

/// Load `<data_dir>/identity.toml` if it exists, else MINT a new identity, write
/// it, and return it. Idempotent: a reinstall that KEEPS the data dir keeps the
/// same `install_id`; a purge (which removes `identity.toml`) makes the next call
/// mint a NEW, later `install_id` while `machine_fp` stays equal.
pub fn ensure(data_dir: &Path, app: &str, scope: Scope, user: &str) -> Result<Identity> {
    std::fs::create_dir_all(data_dir)
        .with_context(|| format!("create identity data dir {}", data_dir.display()))?;
    let path = data_dir.join(FILE);
    if path.exists() {
        let text = std::fs::read_to_string(&path)
            .with_context(|| format!("read {}", path.display()))?;
        return toml_de::parse(&text).with_context(|| format!("parse {}", path.display()));
    }

    let id = mint(data_dir, app, scope, user)?;
    let text = toml_ser::render(&id);
    std::fs::write(&path, text).with_context(|| format!("write {}", path.display()))?;
    // Best-effort 0600 — the record is not secret, but the node key beside it is.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
    }
    Ok(id)
}

/// Mint a fresh identity (no IO except the machine-anchor fallback + best-effort
/// node-key persistence for signing).
fn mint(data_dir: &Path, _app: &str, scope: Scope, user: &str) -> Result<Identity> {
    let (machine_fp, hw_anchor) = machine_fp(data_dir, scope, user)?;
    let mut id = Identity {
        scheme: SCHEME.to_string(),
        machine_fp,
        install_id: uuid::Uuid::now_v7().to_string(),
        minted_at: chrono::Utc::now().to_rfc3339(),
        host: hostname(),
        scope: scope_tag(scope).to_string(),
        user: user.to_string(),
        hw_anchor: Some(hw_anchor),
        ssh_pubkey_fp: find_ssh_fp(data_dir),
        node_cert_fp: find_cert_fp(data_dir),
        signed_by: None,
        sig: None,
    };

    // Best-effort signing — never blocks minting the core identity.
    #[cfg(feature = "pki")]
    match signing::sign(data_dir, &id.canonical()) {
        Ok((pubkey_hex, sig_hex)) => {
            id.signed_by = Some(format!("ed25519:{pubkey_hex}"));
            id.sig = Some(sig_hex);
        }
        Err(e) => eprintln!("identity: signing skipped ({e})"),
    }

    Ok(id)
}

/// Derive `machine_fp` (hex, 8 bytes) + the source label that anchored it.
///
/// `machine_fp = first 8 bytes of BLAKE3(machine_id ++ DOMAIN ++ scope ++ uid)`.
pub fn machine_fp(data_dir: &Path, scope: Scope, _user: &str) -> Result<(String, String)> {
    let (machine_id, source) = machine_id_source(data_dir)?;
    let mut h = blake3::Hasher::new();
    h.update(&machine_id);
    h.update(DOMAIN);
    h.update(scope_tag(scope).as_bytes());
    h.update(uid_string().as_bytes());
    let digest = h.finalize();
    Ok((hex::encode(&digest.as_bytes()[..8]), source))
}

/// The stable machine-id source chain. Returns the raw id bytes + a source label.
fn machine_id_source(data_dir: &Path) -> Result<(Vec<u8>, String)> {
    let read_trimmed = |p: &str| -> Option<Vec<u8>> {
        std::fs::read_to_string(p).ok().map(|s| s.trim().as_bytes().to_vec()).filter(|b| !b.is_empty())
    };
    if let Some(b) = read_trimmed("/etc/machine-id") {
        return Ok((b, "machine-id".to_string()));
    }
    if let Some(b) = read_trimmed("/var/lib/dbus/machine-id") {
        return Ok((b, "dbus-machine-id".to_string()));
    }
    if let Some(b) = read_trimmed("/sys/class/dmi/id/product_uuid") {
        return Ok((b, "dmi-product-uuid".to_string()));
    }
    // Non-authoritative fallback: a random anchor, generated once and persisted
    // beside the identity so repeated calls agree. Flagged so callers know it is
    // NOT a durable hardware anchor (a full data purge regenerates it).
    let anchor = data_dir.join("machine-anchor");
    if let Some(b) = read_trimmed(&anchor.to_string_lossy()) {
        return Ok((b, "generated:non-authoritative".to_string()));
    }
    let mut seed = [0u8; 16];
    fill_random(&mut seed)?;
    let hexed = hex::encode(seed);
    std::fs::create_dir_all(data_dir).ok();
    std::fs::write(&anchor, &hexed)
        .with_context(|| format!("persist machine anchor {}", anchor.display()))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&anchor, std::fs::Permissions::from_mode(0o600));
    }
    Ok((hexed.into_bytes(), "generated:non-authoritative".to_string()))
}

/// Fingerprint the first `ssh/*.pub` credential in the data dir, if any.
fn find_ssh_fp(data_dir: &Path) -> Option<String> {
    let dir = std::fs::read_dir(data_dir.join("ssh")).ok()?;
    let mut pubs: Vec<_> = dir
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.extension().map(|x| x == "pub").unwrap_or(false))
        .collect();
    pubs.sort();
    let bytes = std::fs::read(pubs.first()?).ok()?;
    Some(format!("blake3:{}", hex::encode(&blake3::hash(&bytes).as_bytes()[..16])))
}

/// Fingerprint a node cert in the data dir (`node.crt`, else first `*.crt`).
fn find_cert_fp(data_dir: &Path) -> Option<String> {
    let node = data_dir.join("node.crt");
    let path = if node.exists() {
        node
    } else {
        let mut crts: Vec<_> = std::fs::read_dir(data_dir)
            .ok()?
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().map(|x| x == "crt").unwrap_or(false))
            .collect();
        crts.sort();
        crts.into_iter().next()?
    };
    let bytes = std::fs::read(path).ok()?;
    Some(format!("blake3:{}", hex::encode(&blake3::hash(&bytes).as_bytes()[..16])))
}

fn scope_tag(scope: Scope) -> &'static str {
    match scope {
        Scope::System => "system",
        Scope::User => "user",
    }
}

fn hostname() -> String {
    if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
        let h = h.trim();
        if !h.is_empty() {
            return h.to_string();
        }
    }
    std::env::var("HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
}

#[cfg(unix)]
unsafe extern "C" {
    fn geteuid() -> u32;
}

/// The effective uid string folded into `machine_fp` (distinguishes per-user
/// installs). Non-unix: a stable placeholder.
fn uid_string() -> String {
    #[cfg(unix)]
    {
        (unsafe { geteuid() }).to_string()
    }
    #[cfg(not(unix))]
    {
        "na".to_string()
    }
}

/// Fill `buf` with OS CSPRNG bytes. Uses `getrandom` when the `pki` feature (which
/// already vendors it) is on; otherwise a best-effort `/dev/urandom` read.
fn fill_random(buf: &mut [u8]) -> Result<()> {
    #[cfg(feature = "pki")]
    {
        getrandom::getrandom(buf).map_err(|e| anyhow::anyhow!("getrandom: {e}"))?;
        return Ok(());
    }
    #[cfg(not(feature = "pki"))]
    {
        use std::io::Read;
        std::fs::File::open("/dev/urandom")
            .and_then(|mut f| f.read_exact(buf))
            .context("read /dev/urandom for machine anchor")?;
        Ok(())
    }
}

/// Best-effort ed25519 signing over the canonical identity bytes. Persists a node
/// key under `<data_dir>/node_key` (0600) so the identity's signer is stable — the
/// key is a CREDENTIAL the identity references, not the identity itself. Reuses the
/// `pki` feature's `ed25519-dalek`/`getrandom` (the audit flagged pki minting a key
/// then discarding it; here it is persisted).
#[cfg(feature = "pki")]
mod signing {
    use super::*;
    use ed25519_dalek::{Signer, SigningKey};

    pub fn sign(data_dir: &Path, canonical: &[u8]) -> Result<(String, String)> {
        let key = ensure_node_key(data_dir)?;
        let sig = key.sign(canonical);
        Ok((
            hex::encode(key.verifying_key().to_bytes()),
            hex::encode(sig.to_bytes()),
        ))
    }

    fn ensure_node_key(data_dir: &Path) -> Result<SigningKey> {
        let path = data_dir.join("node_key");
        if let Ok(bytes) = std::fs::read(&path) {
            let seed: [u8; 32] = bytes
                .get(..32)
                .and_then(|s| s.try_into().ok())
                .context("node_key is too short (want 32-byte ed25519 seed)")?;
            return Ok(SigningKey::from_bytes(&seed));
        }
        let mut seed = [0u8; 32];
        getrandom::getrandom(&mut seed).map_err(|e| anyhow::anyhow!("getrandom node key: {e}"))?;
        std::fs::write(&path, seed)
            .with_context(|| format!("persist node key {}", path.display()))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
                .with_context(|| format!("chmod 600 {}", path.display()))?;
        }
        Ok(SigningKey::from_bytes(&seed))
    }
}

/// Minimal flat-table TOML serializer for [`Identity`] — deterministic key order,
/// basic-string values, `None` fields omitted. (A hand-rolled writer keeps the
/// dependency set to exactly `uuid`/`blake3`/`hex`.)
mod toml_ser {
    use super::Identity;

    fn esc(s: &str) -> String {
        let mut out = String::with_capacity(s.len() + 2);
        out.push('"');
        for c in s.chars() {
            match c {
                '"' => out.push_str("\\\""),
                '\\' => out.push_str("\\\\"),
                '\n' => out.push_str("\\n"),
                '\t' => out.push_str("\\t"),
                '\r' => out.push_str("\\r"),
                _ => out.push(c),
            }
        }
        out.push('"');
        out
    }

    pub fn render(id: &Identity) -> String {
        let mut s = String::new();
        let mut kv = |k: &str, v: &str| s.push_str(&format!("{k} = {}\n", esc(v)));
        kv("scheme", &id.scheme);
        kv("machine_fp", &id.machine_fp);
        kv("install_id", &id.install_id);
        kv("minted_at", &id.minted_at);
        kv("host", &id.host);
        kv("scope", &id.scope);
        kv("user", &id.user);
        let mut opt = |k: &str, v: &Option<String>| {
            if let Some(v) = v {
                s.push_str(&format!("{k} = {}\n", esc(v)));
            }
        };
        opt("hw_anchor", &id.hw_anchor);
        opt("ssh_pubkey_fp", &id.ssh_pubkey_fp);
        opt("node_cert_fp", &id.node_cert_fp);
        opt("signed_by", &id.signed_by);
        opt("sig", &id.sig);
        s
    }
}

/// Matching flat-table TOML reader for [`Identity`] — parses the `key = "value"`
/// lines [`toml_ser::render`] writes.
mod toml_de {
    use super::Identity;
    use anyhow::{bail, Result};
    use std::collections::HashMap;

    fn unesc(s: &str) -> Result<String> {
        let s = s.trim();
        let inner = s
            .strip_prefix('"')
            .and_then(|s| s.strip_suffix('"'))
            .ok_or_else(|| anyhow::anyhow!("value is not a basic string: {s:?}"))?;
        let mut out = String::with_capacity(inner.len());
        let mut chars = inner.chars();
        while let Some(c) = chars.next() {
            if c == '\\' {
                match chars.next() {
                    Some('"') => out.push('"'),
                    Some('\\') => out.push('\\'),
                    Some('n') => out.push('\n'),
                    Some('t') => out.push('\t'),
                    Some('r') => out.push('\r'),
                    Some(other) => {
                        out.push('\\');
                        out.push(other);
                    }
                    None => out.push('\\'),
                }
            } else {
                out.push(c);
            }
        }
        Ok(out)
    }

    pub fn parse(text: &str) -> Result<Identity> {
        let mut m: HashMap<String, String> = HashMap::new();
        for line in text.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let (k, v) = line
                .split_once('=')
                .ok_or_else(|| anyhow::anyhow!("not a key = value line: {line:?}"))?;
            m.insert(k.trim().to_string(), unesc(v)?);
        }
        let req = |k: &str| -> Result<String> {
            m.get(k).cloned().ok_or_else(|| anyhow::anyhow!("identity.toml missing `{k}`"))
        };
        let opt = |k: &str| m.get(k).cloned();
        let id = Identity {
            scheme: req("scheme")?,
            machine_fp: req("machine_fp")?,
            install_id: req("install_id")?,
            minted_at: req("minted_at")?,
            host: req("host")?,
            scope: req("scope")?,
            user: req("user")?,
            hw_anchor: opt("hw_anchor"),
            ssh_pubkey_fp: opt("ssh_pubkey_fp"),
            node_cert_fp: opt("node_cert_fp"),
            signed_by: opt("signed_by"),
            sig: opt("sig"),
        };
        if id.scheme != super::SCHEME {
            bail!("unknown identity scheme {:?} (want {:?})", id.scheme, super::SCHEME);
        }
        Ok(id)
    }
}

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

    #[test]
    fn machine_fp_is_stable_across_calls() {
        let dir = tempfile::tempdir().unwrap();
        let (a, sa) = machine_fp(dir.path(), Scope::System, "svc").unwrap();
        let (b, sb) = machine_fp(dir.path(), Scope::System, "svc").unwrap();
        assert_eq!(a, b, "machine_fp must be stable across calls");
        assert_eq!(sa, sb);
        assert_eq!(a.len(), 16, "8 bytes hex-encoded");
    }

    #[test]
    fn install_id_is_uuid_v7() {
        let dir = tempfile::tempdir().unwrap();
        let id = ensure(dir.path(), "holger", Scope::System, "svc").unwrap();
        let uuid = uuid::Uuid::parse_str(&id.install_id).expect("install_id parses as UUID");
        assert_eq!(uuid.get_version(), Some(uuid::Version::SortRand), "UUIDv7");
    }

    #[test]
    fn ensure_is_idempotent() {
        let dir = tempfile::tempdir().unwrap();
        let first = ensure(dir.path(), "holger", Scope::System, "svc").unwrap();
        let second = ensure(dir.path(), "holger", Scope::System, "svc").unwrap();
        assert_eq!(first.install_id, second.install_id, "kept data → same install_id");
        assert_eq!(first, second);
    }

    #[test]
    fn fresh_dirs_mint_different_install_ids() {
        // Two fresh data dirs = a reinstall (data purged): same machine_fp, but a
        // NEW, later install_id.
        let a = tempfile::tempdir().unwrap();
        let b = tempfile::tempdir().unwrap();
        let ida = ensure(a.path(), "holger", Scope::System, "svc").unwrap();
        let idb = ensure(b.path(), "holger", Scope::System, "svc").unwrap();
        assert_ne!(ida.install_id, idb.install_id, "reinstall → distinct install_id");
        assert_eq!(ida.machine_fp, idb.machine_fp, "same machine → same machine_fp");
    }

    #[test]
    fn wire_string_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        let id = ensure(dir.path(), "holger", Scope::User, "rickard").unwrap();
        let wire = id.wire();
        assert_eq!(wire, format!("nid1:{}:{}", id.machine_fp, id.install_id));
        let parsed = WireId::from_str(&wire).unwrap();
        assert_eq!(parsed.scheme, id.scheme);
        assert_eq!(parsed.machine_fp, id.machine_fp);
        assert_eq!(parsed.install_id, id.install_id);
    }

    #[test]
    fn toml_record_round_trips_on_disk() {
        let dir = tempfile::tempdir().unwrap();
        let minted = ensure(dir.path(), "holger", Scope::System, "svc").unwrap();
        // Re-load goes through the on-disk TOML parser.
        let loaded = ensure(dir.path(), "holger", Scope::System, "svc").unwrap();
        assert_eq!(minted, loaded, "TOML serialize→parse preserves every field");
    }

    #[cfg(feature = "pki")]
    #[test]
    fn signing_is_filled_and_verifies() {
        use ed25519_dalek::{Signature, Verifier, VerifyingKey};
        let dir = tempfile::tempdir().unwrap();
        let id = ensure(dir.path(), "holger", Scope::System, "svc").unwrap();
        let signed_by = id.signed_by.as_ref().expect("signed_by filled");
        let sig_hex = id.sig.as_ref().expect("sig filled");
        let pk_hex = signed_by.strip_prefix("ed25519:").expect("ed25519: prefix");
        let pk: [u8; 32] = hex::decode(pk_hex).unwrap().try_into().unwrap();
        let sig: [u8; 64] = hex::decode(sig_hex).unwrap().try_into().unwrap();
        let vk = VerifyingKey::from_bytes(&pk).unwrap();
        vk.verify(&id.canonical(), &Signature::from_bytes(&sig))
            .expect("signature verifies over the canonical identity bytes");
        // The node key was persisted (0600) — a credential the identity references.
        assert!(dir.path().join("node_key").exists());
    }
}
