//! Persistent node identity at `~/.config/peerspeak/identity.key`.
//!
//! Historically peerspeak called `SecretKey::generate()` once per process, so a
//! peer's `EndpointId` changed on every launch. The friends-first contacts model
//! (`docs/contacts-plan.md`) identifies people by that id and keeps a saved
//! address for each, so the id must stay **stable across launches** — that's the
//! foundation that makes a saved address worth keeping. iroh does not force
//! rolling ids; we just pass it a persistent key.
//!
//! The key is the ed25519 secret (32 bytes) stored as hex on its own line, in a
//! `0600` file separate from `config.json`. It's a secret, not a preference:
//! keeping it out of the config means a config reset / hand-edit can't clobber
//! your identity, and the restrictive perms keep it from being world-readable.
//!
//! A `Regenerate identity` action (Settings) deliberately overwrites this file
//! with a fresh key — the intended "unlink / fresh start." After that, peers who
//! saved the old id can no longer recognise or reach this node until a new
//! exchange happens.

use anyhow::{Context, Result, bail};
use iroh::SecretKey;
use std::fs;
use std::io::Write;
use std::path::PathBuf;

/// Returns `~/.config/peerspeak/identity.key` (or the XDG equivalent). Shares the
/// config directory with [`crate::config`]; the parent is created on save.
pub fn identity_path() -> Option<PathBuf> {
    dirs::config_dir().map(|mut p| {
        p.push("peerspeak");
        p.push("identity.key");
        p
    })
}

/// Load the persisted secret key, or generate-and-save one on first run.
///
/// A *malformed* file is a hard error rather than a silent regenerate: quietly
/// minting a new identity would orphan every friend who saved the old id, so we
/// fail loud and let the user notice (and decide) instead of losing it silently.
/// A *missing* file (first ever run, or right after a reset) is the normal
/// create path.
pub fn load_or_create() -> Result<SecretKey> {
    let path =
        identity_path().context("could not determine a config directory for the identity key")?;
    load_or_create_at(&path)
}

/// Mint a brand-new identity, overwrite the key file, and return it. This is the
/// deliberate "Regenerate identity" / unlink action — the old id is discarded and
/// unrecoverable, so callers should confirm with the user first.
pub fn regenerate() -> Result<SecretKey> {
    let path =
        identity_path().context("could not determine a config directory for the identity key")?;
    let key = SecretKey::generate();
    save_at(&path, &key)?;
    Ok(key)
}

/// Atomic, `0600` write at the default identity path. See [`save_at`].
pub fn save(key: &SecretKey) -> Result<()> {
    let path =
        identity_path().context("could not determine a config directory for the identity key")?;
    save_at(&path, key)
}

/// Path-injectable core of [`load_or_create`], so the filesystem round-trip is
/// testable in a temp dir without touching the real config.
fn load_or_create_at(path: &std::path::Path) -> Result<SecretKey> {
    match fs::read_to_string(path) {
        Ok(s) => parse_key(s.trim())
            .with_context(|| format!("failed to parse the identity key at {}", path.display())),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            let key = SecretKey::generate();
            save_at(path, &key)?;
            Ok(key)
        }
        Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
    }
}

/// Atomic, `0600` write: tempfile-in-same-dir, chmod, then rename. Restrictive
/// perms are applied before the rename so the secret is never briefly
/// world-readable.
fn save_at(path: &std::path::Path, key: &SecretKey) -> Result<()> {
    let parent = path
        .parent()
        .context("identity path has no parent directory")?;
    fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;

    let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id()));
    {
        let mut f = fs::File::create(&tmp)
            .with_context(|| format!("failed to create {}", tmp.display()))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            f.set_permissions(fs::Permissions::from_mode(0o600))
                .with_context(|| format!("failed to chmod {}", tmp.display()))?;
        }
        f.write_all(encode_hex(&key.to_bytes()).as_bytes())
            .with_context(|| format!("failed to write {}", tmp.display()))?;
        f.write_all(b"\n").ok();
        f.sync_all().ok();
    }
    fs::rename(&tmp, path)
        .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
    Ok(())
}

fn parse_key(hex: &str) -> Result<SecretKey> {
    let bytes = decode_hex(hex)?;
    let arr: [u8; 32] = bytes
        .try_into()
        .map_err(|_| anyhow::anyhow!("identity key must be 32 bytes (64 hex chars)"))?;
    Ok(SecretKey::from_bytes(&arr))
}

fn encode_hex(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

fn decode_hex(s: &str) -> Result<Vec<u8>> {
    if !s.len().is_multiple_of(2) {
        bail!("hex string has an odd length");
    }
    (0..s.len())
        .step_by(2)
        .map(|i| {
            u8::from_str_radix(&s[i..i + 2], 16)
                .with_context(|| format!("invalid hex byte at offset {i}"))
        })
        .collect()
}

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

    #[test]
    fn hex_round_trips() {
        let bytes: Vec<u8> = (0u8..=255).collect();
        let encoded = encode_hex(&bytes);
        assert_eq!(encoded.len(), bytes.len() * 2);
        assert_eq!(decode_hex(&encoded).unwrap(), bytes);
    }

    #[test]
    fn key_round_trips_through_hex() {
        let key = SecretKey::generate();
        let hex = encode_hex(&key.to_bytes());
        let parsed = parse_key(&hex).unwrap();
        assert_eq!(parsed.to_bytes(), key.to_bytes());
        assert_eq!(parsed.public(), key.public());
    }

    #[test]
    fn rejects_wrong_length() {
        // 2 hex chars = 1 byte, not 32.
        assert!(parse_key("dead").is_err());
        assert!(parse_key("").is_err());
    }

    #[test]
    fn rejects_odd_and_nonhex() {
        assert!(decode_hex("abc").is_err());
        assert!(decode_hex("zz").is_err());
    }

    /// A unique temp path under the system temp dir (no extra deps). The parent
    /// is the temp dir itself; `save_at` creates a nested subdir to exercise the
    /// `create_dir_all` path.
    fn temp_key_path(tag: &str) -> PathBuf {
        let mut p = std::env::temp_dir();
        p.push(format!("peerspeak-idtest-{}-{}", std::process::id(), tag));
        p.push("identity.key");
        p
    }

    #[test]
    fn missing_file_creates_then_persists() {
        let path = temp_key_path("create");
        let _ = fs::remove_dir_all(path.parent().unwrap());
        // First call creates a fresh key + writes the file.
        let k1 = load_or_create_at(&path).unwrap();
        assert!(path.exists(), "key file should have been created");
        // Second call loads the SAME key back (stable across "launches").
        let k2 = load_or_create_at(&path).unwrap();
        assert_eq!(k1.to_bytes(), k2.to_bytes());
        assert_eq!(k1.public(), k2.public());
        let _ = fs::remove_dir_all(path.parent().unwrap());
    }

    #[test]
    fn malformed_file_is_a_hard_error_not_a_silent_regenerate() {
        let path = temp_key_path("malformed");
        let _ = fs::remove_dir_all(path.parent().unwrap());
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(&path, "not-valid-hex").unwrap();
        // Must error rather than mint a new id (which would orphan saved friends).
        assert!(load_or_create_at(&path).is_err());
        let _ = fs::remove_dir_all(path.parent().unwrap());
    }

    #[test]
    fn regenerate_changes_the_key_on_disk() {
        let path = temp_key_path("regen");
        let _ = fs::remove_dir_all(path.parent().unwrap());
        let original = load_or_create_at(&path).unwrap();
        // save_at with a fresh key models the regenerate overwrite.
        let fresh = SecretKey::generate();
        save_at(&path, &fresh).unwrap();
        let reloaded = load_or_create_at(&path).unwrap();
        assert_eq!(reloaded.to_bytes(), fresh.to_bytes());
        assert_ne!(reloaded.to_bytes(), original.to_bytes());
        let _ = fs::remove_dir_all(path.parent().unwrap());
    }

    #[cfg(unix)]
    #[test]
    fn key_file_is_0600() {
        use std::os::unix::fs::PermissionsExt;
        let path = temp_key_path("perms");
        let _ = fs::remove_dir_all(path.parent().unwrap());
        load_or_create_at(&path).unwrap();
        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode, 0o600, "identity key must not be group/world readable");
        let _ = fs::remove_dir_all(path.parent().unwrap());
    }
}
