// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Per-wallet nostr identity: a random standalone nsec (or an imported one),
//! deliberately independent of the wallet seed — the seed proves nothing about
//! the identity and cannot resurrect it; the nsec is its own backup.
//! Stored at rest as NIP-49 ncryptsec encrypted with the wallet password.

use nostr_sdk::nips::nip44;
use nostr_sdk::nips::nip49::{EncryptedSecretKey, KeySecurity};
use nostr_sdk::{FromBech32, Keys, SecretKey, ToBech32};
use serde_derive::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;

/// Where the keys came from. The legacy NIP-06 `Derived` source is gone: a
/// pre-Build-8 `"source":"Derived"` file no longer parses, so `load()` returns
/// `None` and wallet init writes a fresh random identity (the wanted behavior;
/// binding a messaging identity to the money seed was the dangerous design).
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum IdentitySource {
	/// Imported nsec.
	Imported,
	/// Freshly generated random key, independent of the wallet seed: the
	/// seed proves nothing about the identity and cannot resurrect it.
	Random,
}

/// Identity file stored at `wallet_data/nostr/identity.json`.
// TODO(audit L2): redact secret material (the ncryptsec) from Debug output.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct NostrIdentity {
	pub ver: u8,
	pub source: IdentitySource,
	/// NIP-49 encrypted secret key (bech32 ncryptsec).
	pub ncryptsec: String,
	/// Public key, bech32 npub (plaintext so the UI can render pre-unlock).
	pub npub: String,
	/// Registered NIP-05 identifier (user@goblin.st).
	pub nip05: Option<String>,
	/// User chose to stay anonymous (no NIP-05, no kind-0 metadata).
	pub anonymous: bool,
	/// Previous npubs from key rotations (newest last), for reference.
	#[serde(default)]
	pub prev_npubs: Vec<String>,
	/// Advertised DM relays (kind 10050): the Goblin relay plus 1-2 pool
	/// relays picked once for this identity and kept sticky — no timer
	/// rotation, since 10050 churn breaks payers' cached routing. Empty until
	/// the first service start selects them.
	#[serde(default)]
	pub dm_relays: Vec<String>,
	/// PRIVATE, app-only label the user sets to name this identity for
	/// themselves. Stays local: it lives in this 0600 file (and rides inside the
	/// NIP-44-sealed .backup envelope, which serializes the whole struct), and is
	/// NEVER published — not in kind-0 metadata, not in any event.
	#[serde(default)]
	pub private_tag: Option<String>,
}

/// NIP-49 scrypt work factor (~64 MiB, interactive-grade).
const NCRYPTSEC_LOG_N: u8 = 16;

/// Write a file with owner-only (0600) permissions on Unix.
fn write_private(path: &PathBuf, data: &[u8]) -> std::io::Result<()> {
	#[cfg(unix)]
	{
		use std::io::Write;
		use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
		let mut f = std::fs::OpenOptions::new()
			.write(true)
			.create(true)
			.truncate(true)
			.mode(0o600)
			.open(path)?;
		f.write_all(data)?;
		// Also fix the mode if the file already existed with looser perms.
		let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
		Ok(())
	}
	#[cfg(not(unix))]
	{
		std::fs::write(path, data)
	}
}

/// Restrict a directory to owner-only access on Unix.
fn restrict_dir(dir: &PathBuf) {
	#[cfg(unix)]
	{
		use std::os::unix::fs::PermissionsExt;
		let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
	}
	#[cfg(not(unix))]
	{
		let _ = dir;
	}
}

#[derive(Debug, thiserror::Error)]
pub enum IdentityError {
	#[error("identity io error: {0}")]
	Io(#[from] std::io::Error),
	#[error("identity parse error: {0}")]
	Parse(#[from] serde_json::Error),
	#[error("key error: {0}")]
	Key(String),
	#[error("wrong password")]
	WrongPassword,
}

impl NostrIdentity {
	pub const FILE_NAME: &'static str = "identity.json";

	/// Identity file path inside the wallet nostr directory.
	pub fn path(nostr_dir: &PathBuf) -> PathBuf {
		let mut path = nostr_dir.clone();
		path.push(Self::FILE_NAME);
		path
	}

	/// Load the identity file if it exists.
	pub fn load(nostr_dir: &PathBuf) -> Option<NostrIdentity> {
		let path = Self::path(nostr_dir);
		let raw = fs::read_to_string(path).ok()?;
		serde_json::from_str(&raw).ok()
	}

	/// Persist the identity file with owner-only permissions (the ncryptsec
	/// blob must not be world-readable: a local attacker could grind the
	/// wallet password offline otherwise).
	pub fn save(&self, nostr_dir: &PathBuf) -> Result<(), IdentityError> {
		fs::create_dir_all(nostr_dir)?;
		restrict_dir(nostr_dir);
		let raw = serde_json::to_string_pretty(self)?;
		let path = Self::path(nostr_dir);
		write_private(&path, raw.as_bytes())?;
		Ok(())
	}

	/// Delete the identity file (used when the user discards the identity).
	pub fn delete(nostr_dir: &PathBuf) {
		let _ = fs::remove_file(Self::path(nostr_dir));
	}

	/// Load an identity from an explicit file path — a member of the held
	/// identity set (see [`crate::nostr::identities`]), which stores each
	/// additional identity in its own `identities/<hex>/identity.json`.
	pub fn load_at(path: &PathBuf) -> Option<NostrIdentity> {
		let raw = fs::read_to_string(path).ok()?;
		serde_json::from_str(&raw).ok()
	}

	/// Persist this identity to an explicit file path with owner-only (0600)
	/// permissions, creating (and 0700-restricting) the parent directory. Used
	/// by the held identity set for the non-legacy identities; the ncryptsec
	/// blob must never be world-readable.
	pub fn save_at(&self, path: &PathBuf) -> Result<(), IdentityError> {
		if let Some(dir) = path.parent() {
			fs::create_dir_all(dir)?;
			restrict_dir(&dir.to_path_buf());
		}
		let raw = serde_json::to_string_pretty(self)?;
		write_private(path, raw.as_bytes())?;
		Ok(())
	}

	/// The identity's public key as lowercase hex — the stable id used to key it
	/// in the held-identity index and on disk. `None` if the stored npub is
	/// malformed (never expected for an identity we wrote).
	pub fn pubkey_hex(&self) -> Option<String> {
		use nostr_sdk::PublicKey;
		PublicKey::from_bech32(&self.npub)
			.ok()
			.map(|pk| pk.to_hex())
	}

	/// Build an identity from already-unlocked keys under a (possibly
	/// different) password — used when importing a backup that was exported
	/// under another wallet's password.
	pub fn from_unlocked_keys(
		keys: &Keys,
		password: &str,
		source: IdentitySource,
	) -> Result<NostrIdentity, IdentityError> {
		Self::from_keys(keys, password, source)
	}

	/// Create a brand-new random identity, independent of the wallet seed.
	pub fn create_random(password: &str) -> Result<(NostrIdentity, Keys), IdentityError> {
		let keys = Keys::generate();
		let identity = Self::from_keys(&keys, password, IdentitySource::Random)?;
		Ok((identity, keys))
	}

	/// Create an imported identity from an nsec string.
	pub fn create_imported(
		nsec: &str,
		password: &str,
	) -> Result<(NostrIdentity, Keys), IdentityError> {
		let secret = SecretKey::parse(nsec.trim())
			.map_err(|e| IdentityError::Key(format!("invalid nsec: {e}")))?;
		let keys = Keys::new(secret);
		let identity = Self::from_keys(&keys, password, IdentitySource::Imported)?;
		Ok((identity, keys))
	}

	fn from_keys(
		keys: &Keys,
		password: &str,
		source: IdentitySource,
	) -> Result<NostrIdentity, IdentityError> {
		let encrypted = EncryptedSecretKey::new(
			keys.secret_key(),
			password,
			NCRYPTSEC_LOG_N,
			KeySecurity::Medium,
		)
		.map_err(|e| IdentityError::Key(format!("encrypt failed: {e}")))?;
		let ncryptsec = encrypted
			.to_bech32()
			.map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?;
		let npub = keys
			.public_key()
			.to_bech32()
			.map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?;
		Ok(NostrIdentity {
			ver: 1,
			source,
			ncryptsec,
			npub,
			nip05: None,
			anonymous: true,
			prev_npubs: Vec::new(),
			dm_relays: Vec::new(),
			private_tag: None,
		})
	}

	/// Decrypt the stored key with the wallet password.
	pub fn unlock(&self, password: &str) -> Result<Keys, IdentityError> {
		let encrypted = EncryptedSecretKey::from_bech32(&self.ncryptsec)
			.map_err(|e| IdentityError::Key(format!("invalid ncryptsec: {e}")))?;
		let secret = encrypted
			.decrypt(password)
			.map_err(|_| IdentityError::WrongPassword)?;
		Ok(Keys::new(secret))
	}

	/// Re-encrypt the stored key under a new password.
	pub fn reencrypt(&mut self, old: &str, new: &str) -> Result<(), IdentityError> {
		let keys = self.unlock(old)?;
		let encrypted =
			EncryptedSecretKey::new(keys.secret_key(), new, NCRYPTSEC_LOG_N, KeySecurity::Medium)
				.map_err(|e| IdentityError::Key(format!("encrypt failed: {e}")))?;
		self.ncryptsec = encrypted
			.to_bech32()
			.map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?;
		Ok(())
	}

	/// A single, fully-encrypted, portable backup of this identity (the contents
	/// of a `GOBLIN-*.backup` file). Two sealed layers, no plaintext: the secret
	/// key is the password-protected NIP-49 ncryptsec, and the rest of the
	/// identity (username, history, source) is NIP-44-sealed to our own key. An
	/// outside party sees only ciphertext — no npub, no name. Any Goblin wallet
	/// reopens it with the backup's password. `keys` must be this identity's
	/// unlocked keys (the caller unlocks with the password first).
	pub fn to_encrypted_backup(&self, keys: &Keys) -> Result<String, IdentityError> {
		let json = serde_json::to_string(self)?;
		let sealed = nip44::encrypt(
			keys.secret_key(),
			&keys.public_key(),
			json,
			nip44::Version::V2,
		)
		.map_err(|e| IdentityError::Key(format!("seal failed: {e}")))?;
		let envelope = serde_json::json!({
			"goblin_backup": 1,
			"k": self.ncryptsec,
			"d": sealed,
		});
		serde_json::to_string(&envelope).map_err(IdentityError::from)
	}

	/// True if `s` is a Goblin encrypted-backup envelope (vs a bare nsec or the
	/// legacy plaintext identity JSON).
	pub fn is_encrypted_backup(s: &str) -> bool {
		serde_json::from_str::<serde_json::Value>(s.trim())
			.ok()
			.and_then(|v| v.get("goblin_backup").cloned())
			.is_some()
	}

	/// Open an encrypted backup with its password, returning the embedded
	/// identity and its unlocked keys.
	pub fn from_encrypted_backup(
		envelope: &str,
		password: &str,
	) -> Result<(NostrIdentity, Keys), IdentityError> {
		let v: serde_json::Value = serde_json::from_str(envelope.trim())?;
		let k = v
			.get("k")
			.and_then(|x| x.as_str())
			.ok_or_else(|| IdentityError::Key("backup missing key".into()))?;
		let d = v
			.get("d")
			.and_then(|x| x.as_str())
			.ok_or_else(|| IdentityError::Key("backup missing data".into()))?;
		// Unlock the wrapper key with the password, then open the sealed JSON.
		let enc = EncryptedSecretKey::from_bech32(k)
			.map_err(|e| IdentityError::Key(format!("invalid backup: {e}")))?;
		let secret = enc
			.decrypt(password)
			.map_err(|_| IdentityError::WrongPassword)?;
		let keys = Keys::new(secret);
		let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), d)
			.map_err(|_| IdentityError::WrongPassword)?;
		let identity: NostrIdentity = serde_json::from_str(&json)?;
		Ok((identity, keys))
	}
}

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

	#[test]
	fn backup_restores_under_new_password() {
		// Export under one wallet password, restore on a device with another.
		let (a, _) = NostrIdentity::create_random("old-pw").unwrap();
		let json = serde_json::to_string(&a).unwrap();
		let parsed: NostrIdentity = serde_json::from_str(&json).unwrap();
		let keys = parsed.unlock("old-pw").unwrap();
		let b = NostrIdentity::from_unlocked_keys(&keys, "new-pw", parsed.source).unwrap();
		assert_eq!(b.npub, a.npub);
		assert!(b.unlock("new-pw").is_ok());
		assert!(b.unlock("old-pw").is_err());
	}

	#[test]
	fn encrypted_backup_roundtrips_and_is_opaque() {
		// A .backup file: sealed under one password, reopened with it. The
		// envelope must carry no plaintext npub/name, and a wrong password fails.
		let (mut a, keys) = NostrIdentity::create_random("pw-1").unwrap();
		a.nip05 = Some("jimbob@goblin.st".to_string());
		a.anonymous = false;
		let envelope = a.to_encrypted_backup(&keys).unwrap();
		assert!(NostrIdentity::is_encrypted_backup(&envelope));
		// Opaque: neither the public key nor the username leaks in the file.
		assert!(!envelope.contains(&a.npub));
		assert!(!envelope.contains("jimbob"));
		// Reopen with the password → same identity.
		let (restored, rkeys) = NostrIdentity::from_encrypted_backup(&envelope, "pw-1").unwrap();
		assert_eq!(restored.npub, a.npub);
		assert_eq!(restored.nip05.as_deref(), Some("jimbob@goblin.st"));
		assert_eq!(rkeys.public_key(), keys.public_key());
		// Wrong password can't open it.
		assert!(NostrIdentity::from_encrypted_backup(&envelope, "wrong").is_err());
	}

	#[test]
	fn random_identities_are_unlinked_and_unlock() {
		let (a, ka) = NostrIdentity::create_random("pw-1").unwrap();
		let (b, _) = NostrIdentity::create_random("pw-1").unwrap();
		// Fresh entropy every time: no chain between identities.
		assert_ne!(a.npub, b.npub);
		assert_eq!(a.source, IdentitySource::Random);
		// NIP-49 roundtrip with the right password; wrong one fails.
		let unlocked = a.unlock("pw-1").unwrap();
		assert_eq!(unlocked.public_key(), ka.public_key());
		assert!(a.unlock("wrong").is_err());
	}

	#[test]
	fn encrypt_unlock_roundtrip() {
		let (identity, keys) = NostrIdentity::create_random("hunter2").unwrap();
		assert_eq!(identity.source, IdentitySource::Random);
		assert!(identity.anonymous);
		let unlocked = identity.unlock("hunter2").unwrap();
		assert_eq!(unlocked.public_key(), keys.public_key());
		assert!(identity.unlock("wrong").is_err());
	}

	#[test]
	fn import_nsec_roundtrip() {
		let keys = Keys::generate();
		let nsec = keys.secret_key().to_bech32().unwrap();
		let (identity, imported) = NostrIdentity::create_imported(&nsec, "pw").unwrap();
		assert_eq!(identity.source, IdentitySource::Imported);
		assert_eq!(imported.public_key(), keys.public_key());
		let unlocked = identity.unlock("pw").unwrap();
		assert_eq!(unlocked.public_key(), keys.public_key());
	}

	#[cfg(unix)]
	#[test]
	fn identity_file_is_owner_only() {
		use std::os::unix::fs::PermissionsExt;
		let dir = std::env::temp_dir().join(format!("goblin-id-test-{}", std::process::id()));
		let (identity, _) = NostrIdentity::create_random("pw").unwrap();
		identity.save(&dir).unwrap();
		let meta = std::fs::metadata(NostrIdentity::path(&dir)).unwrap();
		// The ncryptsec blob must never be group/world readable.
		assert_eq!(
			meta.permissions().mode() & 0o077,
			0,
			"identity.json must be 0600"
		);
		let _ = std::fs::remove_dir_all(&dir);
	}

	#[test]
	fn reencrypt_changes_password() {
		let (mut identity, keys) = NostrIdentity::create_random("old").unwrap();
		identity.reencrypt("old", "new").unwrap();
		assert!(identity.unlock("old").is_err());
		assert_eq!(
			identity.unlock("new").unwrap().public_key(),
			keys.public_key()
		);
	}
}
