//! Encrypted vault read/write — the core data layer.
//!
//! A vault is a single JSON file on disk that stores secrets as Argon2id-derived
//! XChaCha20-Poly1305 ciphertexts.  Opening a vault decrypts all values into
//! memory; saving re-encrypts everything and writes atomically (temp file → rename).
//!
//! # Key contract
//! Secret keys must pass [`validate_secret_key`] before being stored.  Valid keys
//! are 1–256 ASCII characters matching `[A-Za-z_][A-Za-z0-9_\-./]*` with no
//! consecutive separators.

use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use zeroize::{Zeroize, Zeroizing};

use tracing::{instrument, warn};

use crate::crypto::{
    self, CipherKind, KeyPurpose, KeySchedule, VaultKey, VAULT_KDF_M_COST, VAULT_KDF_P_COST,
    VAULT_KDF_T_COST,
};
use crate::errors::{SafeError, SafeResult};
use crate::rbac::RbacProfile;
use crate::snapshot;

const VAULT_SCHEMA: &str = "tsafe/vault/v1";
const VAULT_KDF_ALGORITHM: &str = "argon2id";
/// Known plaintext used to verify the vault key on open.
pub(crate) const VAULT_CHALLENGE_PLAINTEXT: &[u8] = b"tsafe-vault-challenge-v1";

// Bounds for KDF parameters to prevent DoS (extreme memory) or downgrade (trivial brute-force).
const KDF_M_COST_MIN: u32 = 8_192; // 8 MiB — schema minimum
const KDF_M_COST_MAX: u32 = 131_072; // 128 MiB
const KDF_T_COST_MIN: u32 = 1;
const KDF_T_COST_MAX: u32 = 20;
const KDF_P_COST_MIN: u32 = 1;
const KDF_P_COST_MAX: u32 = 16;

/// Validate a secret key name.
///
/// Rules:
/// - 1–256 characters, ASCII only
/// - Must start with a letter or underscore
/// - Remaining chars: letters, digits, `_`, `-`, `.`, `/`
/// - No consecutive separators (`..`, `--`, `//`, `.-`, `/-`, etc.)
/// - Must not end with a separator
///
/// This allows namespaced keys like `myapp/DB_PASSWORD`, `github.com.password`,
/// `db-prod.PASSWORD`.
pub fn validate_secret_key(key: &str) -> SafeResult<()> {
    if key.is_empty() {
        return Err(SafeError::InvalidVault {
            reason: "secret key must not be empty".into(),
        });
    }
    if key.len() > 256 {
        return Err(SafeError::InvalidVault {
            reason: format!("secret key too long ({} chars, max 256)", key.len()),
        });
    }
    let mut chars = key.chars().peekable();
    let first = chars.next().unwrap();
    if !(first.is_ascii_alphabetic() || first == '_') {
        return Err(SafeError::InvalidVault {
            reason: format!("secret key '{key}' must start with a letter or underscore"),
        });
    }
    let is_sep = |c: char| c == '.' || c == '-' || c == '/';
    let mut prev = first;
    for c in chars {
        if !(c.is_ascii_alphanumeric() || c == '_' || is_sep(c)) {
            return Err(SafeError::InvalidVault {
                reason: format!("secret key '{key}' contains invalid character '{c}' — use letters, digits, _, -, ., /"),
            });
        }
        if is_sep(c) && is_sep(prev) {
            return Err(SafeError::InvalidVault {
                reason: format!("secret key '{key}' has consecutive separators"),
            });
        }
        prev = c;
    }
    if is_sep(prev) {
        return Err(SafeError::InvalidVault {
            reason: format!("secret key '{key}' must not end with a separator"),
        });
    }
    Ok(())
}

// ── on-disk types (serde) ────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KdfParams {
    pub algorithm: String,
    pub m_cost: u32,
    pub t_cost: u32,
    pub p_cost: u32,
    pub salt: String,
}

/// A previous version of a secret's encrypted value.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
    pub nonce: String,
    pub ciphertext: String,
    pub updated_at: DateTime<Utc>,
}

/// How many previous versions to retain per secret.
pub const DEFAULT_HISTORY_KEEP: usize = 5;

/// An encrypted secret entry stored in the vault file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretEntry {
    pub nonce: String,
    pub ciphertext: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    #[serde(default)]
    pub tags: HashMap<String, String>,
    /// Previous versions of this secret (oldest first). Backward-compatible via serde default.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub history: Vec<HistoryEntry>,
}

/// The vault's key-verification challenge (encrypted known plaintext).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultChallenge {
    pub nonce: String,
    pub ciphertext: String,
}

/// The full vault file as written to disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultFile {
    #[serde(rename = "_schema")]
    pub schema: String,
    pub kdf: KdfParams,
    pub cipher: String,
    pub vault_challenge: VaultChallenge,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub secrets: HashMap<String, SecretEntry>,
    /// age X25519 recipient public keys (team vaults only). Backward-compatible via serde default.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub age_recipients: Vec<String>,
    /// age-wrapped data encryption key (team vaults only). Backward-compatible via serde default.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub wrapped_dek: Option<String>,
}

// ── in-memory vault (unlocked) ───────────────────────────────────────────────

/// An open, authenticated vault. `key` is held in memory and zeroed on drop.
/// Holds an advisory lock file (`<name>.vault.lock`) to prevent concurrent access.
/// The lock is acquired before any read or snapshot-restore work begins and is
/// only removed by the process that created it.
pub struct Vault {
    path: PathBuf,
    pub(crate) file: VaultFile,
    key: VaultKey,
    cipher: CipherKind,
    key_schedule: KeySchedule,
    access_profile: RbacProfile,
    /// Advisory lock owned by this vault handle.
    _lock: Option<LockGuard>,
}

struct LockGuard {
    path: PathBuf,
    contents: String,
    _file: File,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct LockFileContents {
    version: u8,
    id: String,
    pid: u32,
    created_at: DateTime<Utc>,
}

impl LockFileContents {
    fn new() -> Self {
        Self {
            version: 1,
            id: Uuid::new_v4().to_string(),
            pid: std::process::id(),
            created_at: Utc::now(),
        }
    }
}

/// Acquire an advisory lock for the vault at `path`.
/// Creates `<path>.vault.lock`; returns an ownership-aware guard.
/// New-format lock files can be recovered when their owning process is gone;
/// legacy opaque lock files remain manual-only to avoid accidental lock stealing.
fn acquire_lock(path: &Path) -> SafeResult<LockGuard> {
    let lock_path = lock_path_for(path);
    if let Some(parent) = lock_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    for _ in 0..2 {
        let contents = serde_json::to_string(&LockFileContents::new())?;
        match OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&lock_path)
        {
            Ok(mut file) => {
                file.write_all(contents.as_bytes())?;
                file.flush()?;
                return Ok(LockGuard {
                    path: lock_path.clone(),
                    contents,
                    _file: file,
                });
            }
            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
                if try_recover_dead_lock(&lock_path)? {
                    continue;
                }
                return Err(lock_held_error(&lock_path));
            }
            Err(e) => return Err(SafeError::Io(e)),
        }
    }
    Err(lock_held_error(&lock_path))
}

fn lock_path_for(vault_path: &Path) -> PathBuf {
    vault_path.with_extension("vault.lock")
}

fn lock_held_error(lock_path: &Path) -> SafeError {
    SafeError::InvalidVault {
        reason: format!(
            "vault is locked by another process: {}",
            lock_path.display()
        ),
    }
}

fn try_recover_dead_lock(lock_path: &Path) -> SafeResult<bool> {
    let original_contents = match std::fs::read_to_string(lock_path) {
        Ok(contents) => contents,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true),
        Err(e) => return Err(SafeError::Io(e)),
    };
    let lock: LockFileContents = match serde_json::from_str(&original_contents) {
        Ok(lock) => lock,
        Err(_) => return Ok(false),
    };
    if process_is_running(lock.pid) {
        return Ok(false);
    }

    let current_contents = match std::fs::read_to_string(lock_path) {
        Ok(contents) => contents,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true),
        Err(e) => return Err(SafeError::Io(e)),
    };
    if current_contents != original_contents {
        return Ok(false);
    }

    match std::fs::remove_file(lock_path) {
        Ok(()) => Ok(true),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true),
        Err(e) => Err(SafeError::Io(e)),
    }
}

#[cfg(unix)]
fn process_is_running(pid: u32) -> bool {
    let Ok(pid) = i32::try_from(pid) else {
        return false;
    };
    // kill(pid, 0) performs an existence/permission probe without sending a signal.
    // SAFETY: kill(2) with signal 0 is a pure existence/permission probe — no signal
    // is delivered, no memory is read or written through pointers, and the FFI takes
    // only by-value scalar arguments. The call is sound for any `pid: pid_t` value;
    // negative pids hit process-group semantics, which is well-defined POSIX
    // behavior, not UB.
    let rc = unsafe { libc::kill(pid, 0) };
    if rc == 0 {
        true
    } else {
        std::io::Error::last_os_error()
            .raw_os_error()
            .map(|code| code != libc::ESRCH)
            .unwrap_or(true)
    }
}

#[cfg(windows)]
fn process_is_running(pid: u32) -> bool {
    use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
    use windows_sys::Win32::System::Threading::{
        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
    };

    // SAFETY: OpenProcess takes by-value scalar arguments only (no pointers); all
    // three values are well-defined Windows constants/u32 inputs. Returns either
    // a NULL handle (on failure — checked immediately below) or a fresh owning
    // HANDLE that we close via CloseHandle further down. No memory is read or
    // written through pointers.
    let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
    if handle.is_null() {
        let code = std::io::Error::last_os_error()
            .raw_os_error()
            .unwrap_or_default();
        return code != windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER as i32;
    }

    let mut exit_code = 0u32;
    // SAFETY: `handle` was returned by OpenProcess above and verified non-NULL,
    // so it is a valid, owning HANDLE with PROCESS_QUERY_LIMITED_INFORMATION
    // access (sufficient for GetExitCodeProcess per MSDN). `&mut exit_code`
    // points to a properly-initialized u32 on this stack frame, alive for the
    // call duration; GetExitCodeProcess writes a u32 there and does not retain
    // the pointer.
    let ok = unsafe { GetExitCodeProcess(handle, &mut exit_code) };
    // SAFETY: `handle` is the same owning HANDLE returned by OpenProcess and has
    // not been closed yet — this is its single CloseHandle. After this point we
    // do not use `handle` again, so there is no double-free or use-after-close.
    unsafe {
        CloseHandle(handle);
    }
    ok != 0 && exit_code == STILL_ACTIVE as u32
}

#[cfg(not(any(unix, windows)))]
fn process_is_running(_pid: u32) -> bool {
    // Unknown platforms stay conservative and require manual unlock.
    true
}

impl Drop for LockGuard {
    fn drop(&mut self) {
        let should_remove = std::fs::read_to_string(&self.path)
            .map(|contents| contents == self.contents)
            .unwrap_or(false);
        if should_remove {
            let _ = std::fs::remove_file(&self.path);
        }
    }
}

impl Vault {
    // ── constructors ─────────────────────────────────────────────────────────

    /// Create a new empty vault at `path`, protected by `password`.
    #[instrument(skip(password, path))]
    pub fn create(path: &Path, password: &[u8]) -> SafeResult<Self> {
        Self::create_with_access_profile(path, password, RbacProfile::ReadWrite)
    }

    /// Create a new vault with an explicit access profile.
    ///
    /// This is mainly a future-facing policy hook. A `read_only` profile is
    /// rejected because creation is inherently a write operation.
    #[instrument(skip(password, path))]
    pub fn create_with_access_profile(
        path: &Path,
        password: &[u8],
        access_profile: RbacProfile,
    ) -> SafeResult<Self> {
        access_profile.ensure_write_allowed()?;
        if path.exists() {
            return Err(SafeError::VaultAlreadyExists {
                path: path.display().to_string(),
            });
        }
        let salt = crypto::random_salt();
        let key = crypto::derive_key(
            password,
            &salt,
            VAULT_KDF_M_COST,
            VAULT_KDF_T_COST,
            VAULT_KDF_P_COST,
        )?;
        let now = Utc::now();
        let cipher = crypto::default_vault_cipher();
        let key_schedule = KeySchedule::HkdfSha256V1;

        let (ch_nonce, ch_ct) = crypto::encrypt_with_key_schedule(
            &key,
            key_schedule,
            KeyPurpose::VaultChallenge,
            cipher,
            VAULT_CHALLENGE_PLAINTEXT,
        )?;
        let file = VaultFile {
            schema: VAULT_SCHEMA.to_string(),
            kdf: KdfParams {
                algorithm: "argon2id".to_string(),
                m_cost: VAULT_KDF_M_COST,
                t_cost: VAULT_KDF_T_COST,
                p_cost: VAULT_KDF_P_COST,
                salt: crypto::encode_b64(&salt),
            },
            cipher: cipher.as_str().to_string(),
            vault_challenge: VaultChallenge {
                nonce: crypto::encode_b64(&ch_nonce),
                ciphertext: crypto::encode_b64(&ch_ct),
            },
            created_at: now,
            updated_at: now,
            secrets: HashMap::new(),
            age_recipients: Vec::new(),
            wrapped_dek: None,
        };
        let lock = acquire_lock(path)?;
        let vault = Self {
            path: path.to_path_buf(),
            file,
            key,
            cipher,
            key_schedule,
            access_profile,
            _lock: Some(lock),
        };
        vault.save()?;
        Ok(vault)
    }

    /// Open and authenticate an existing vault.
    /// If the vault file is missing or corrupt, auto-heals from the latest snapshot.
    #[instrument(skip(password, path))]
    pub fn open(path: &Path, password: &[u8]) -> SafeResult<Self> {
        Self::open_with_access_profile(path, password, RbacProfile::ReadWrite)
    }

    /// Open an existing vault in read-only mode.
    ///
    /// Unlike the normal open path, read-only opens do not auto-heal missing or
    /// corrupt vaults from snapshots because that would mutate on-disk state.
    #[instrument(skip(password, path))]
    pub fn open_read_only(path: &Path, password: &[u8]) -> SafeResult<Self> {
        Self::open_with_access_profile(path, password, RbacProfile::ReadOnly)
    }

    /// Open and authenticate an existing vault with an explicit access profile.
    #[instrument(skip(password, path))]
    pub fn open_with_access_profile(
        path: &Path,
        password: &[u8],
        access_profile: RbacProfile,
    ) -> SafeResult<Self> {
        let lock = acquire_lock(path)?;
        // Auto-heal: restore from latest snapshot if file is absent.
        if !path.exists() {
            if access_profile.allows_write() {
                if let Some(profile) = Self::profile_name_from_path(path) {
                    let snap = snapshot::restore_latest(path, &profile).map_err(|_| {
                        SafeError::VaultNotFound {
                            path: path.display().to_string(),
                        }
                    })?;
                    warn!(
                        snapshot = %snap.display(),
                        "vault file was missing — restored from snapshot"
                    );
                } else {
                    return Err(SafeError::VaultNotFound {
                        path: path.display().to_string(),
                    });
                }
            } else {
                return Err(SafeError::VaultNotFound {
                    path: path.display().to_string(),
                });
            }
        }

        // Auto-heal: restore from latest snapshot if file is unreadable or unparseable.
        let json = match std::fs::read_to_string(path) {
            Ok(s) => s,
            Err(e) => {
                if !access_profile.allows_write() {
                    return Err(SafeError::Io(e));
                }
                let profile = Self::profile_name_from_path(path).ok_or(SafeError::Io(e))?;
                let snap = snapshot::restore_latest(path, &profile).map_err(|_| {
                    SafeError::VaultCorrupted {
                        reason: "io error and no usable snapshot found".into(),
                    }
                })?;
                warn!(
                    snapshot = %snap.display(),
                    "vault file was unreadable — restored from snapshot"
                );
                std::fs::read_to_string(path)?
            }
        };

        let file: VaultFile = match serde_json::from_str(&json) {
            Ok(f) => f,
            Err(_) => {
                if !access_profile.allows_write() {
                    return Err(SafeError::VaultCorrupted {
                        reason: "vault JSON is invalid".into(),
                    });
                }
                let profile = Self::profile_name_from_path(path).ok_or_else(|| {
                    SafeError::VaultCorrupted {
                        reason: "vault JSON is invalid and profile name could not be inferred"
                            .into(),
                    }
                })?;
                let snap = snapshot::restore_latest(path, &profile).map_err(|_| {
                    SafeError::VaultCorrupted {
                        reason: "vault JSON is invalid and no usable snapshot was found".into(),
                    }
                })?;
                warn!(snapshot = %snap.display(), "vault JSON was corrupt — restored from snapshot");
                let recovered = std::fs::read_to_string(path)?;
                serde_json::from_str(&recovered).map_err(|e| SafeError::VaultCorrupted {
                    reason: format!("snapshot also failed to parse: {e}"),
                })?
            }
        };

        if file.schema != VAULT_SCHEMA {
            return Err(SafeError::InvalidVault {
                reason: format!("unknown schema: {}", file.schema),
            });
        }
        let cipher = crypto::parse_cipher_kind(&file.cipher)?;
        if file.kdf.algorithm != VAULT_KDF_ALGORITHM {
            return Err(SafeError::InvalidVault {
                reason: format!(
                    "unsupported KDF algorithm: '{}' (expected '{VAULT_KDF_ALGORITHM}')",
                    file.kdf.algorithm
                ),
            });
        }
        // Reject KDF params outside safe bounds to prevent DoS or brute-force downgrade.
        if file.kdf.m_cost < KDF_M_COST_MIN || file.kdf.m_cost > KDF_M_COST_MAX {
            return Err(SafeError::InvalidVault {
                reason: format!(
                    "KDF m_cost {} is outside allowed range [{KDF_M_COST_MIN}, {KDF_M_COST_MAX}]",
                    file.kdf.m_cost
                ),
            });
        }
        if file.kdf.t_cost < KDF_T_COST_MIN || file.kdf.t_cost > KDF_T_COST_MAX {
            return Err(SafeError::InvalidVault {
                reason: format!(
                    "KDF t_cost {} is outside allowed range [{KDF_T_COST_MIN}, {KDF_T_COST_MAX}]",
                    file.kdf.t_cost
                ),
            });
        }
        if file.kdf.p_cost < KDF_P_COST_MIN || file.kdf.p_cost > KDF_P_COST_MAX {
            return Err(SafeError::InvalidVault {
                reason: format!(
                    "KDF p_cost {} is outside allowed range [{KDF_P_COST_MIN}, {KDF_P_COST_MAX}]",
                    file.kdf.p_cost
                ),
            });
        }
        let salt = crypto::decode_b64(&file.kdf.salt)?;
        let key = crypto::derive_key(
            password,
            &salt,
            file.kdf.m_cost,
            file.kdf.t_cost,
            file.kdf.p_cost,
        )?;

        // Authenticate the key by decrypting the challenge.
        let ch_nonce = crypto::decode_b64(&file.vault_challenge.nonce)?;
        let ch_ct = crypto::decode_b64(&file.vault_challenge.ciphertext)?;
        let key_schedule = crypto::detect_key_schedule(
            &key,
            KeyPurpose::VaultChallenge,
            cipher,
            &ch_nonce,
            &ch_ct,
            VAULT_CHALLENGE_PLAINTEXT,
        )?;
        Ok(Self {
            path: path.to_path_buf(),
            file,
            key,
            cipher,
            key_schedule,
            access_profile,
            _lock: Some(lock),
        })
    }

    /// Open a vault with a pre-derived key (e.g. a DEK unwrapped from an age header).
    /// Skips KDF validation — the caller is responsible for providing a valid key.
    pub fn open_with_key(path: &Path, key: crypto::VaultKey) -> SafeResult<Self> {
        Self::open_with_key_with_access_profile(path, key, RbacProfile::ReadWrite)
    }

    /// Open a vault with a pre-derived key in read-only mode.
    pub fn open_with_key_read_only(path: &Path, key: crypto::VaultKey) -> SafeResult<Self> {
        Self::open_with_key_with_access_profile(path, key, RbacProfile::ReadOnly)
    }

    /// Open a vault with a pre-derived key and an explicit access profile.
    pub fn open_with_key_with_access_profile(
        path: &Path,
        key: crypto::VaultKey,
        access_profile: RbacProfile,
    ) -> SafeResult<Self> {
        let lock = acquire_lock(path)?;
        if !path.exists() {
            return Err(SafeError::VaultNotFound {
                path: path.display().to_string(),
            });
        }
        let json = std::fs::read_to_string(path)?;
        let file: VaultFile =
            serde_json::from_str(&json).map_err(|e| SafeError::VaultCorrupted {
                reason: format!("vault JSON parse error: {e}"),
            })?;
        let allowed_schema = matches!(file.schema.as_str(), "tsafe/vault/v1" | "tsafe/vault/v2");
        if !allowed_schema {
            return Err(SafeError::InvalidVault {
                reason: format!("unknown schema: {}", file.schema),
            });
        }
        let cipher = crypto::parse_cipher_kind(&file.cipher)?;
        // Verify the key against the challenge.
        let ch_nonce = crypto::decode_b64(&file.vault_challenge.nonce)?;
        let ch_ct = crypto::decode_b64(&file.vault_challenge.ciphertext)?;
        let key_schedule = crypto::detect_key_schedule(
            &key,
            KeyPurpose::VaultChallenge,
            cipher,
            &ch_nonce,
            &ch_ct,
            VAULT_CHALLENGE_PLAINTEXT,
        )?;
        Ok(Self {
            path: path.to_path_buf(),
            file,
            key,
            cipher,
            key_schedule,
            access_profile,
            _lock: Some(lock),
        })
    }

    /// Check if a vault file on disk is a team vault (has age recipients).
    /// Reads only the metadata — does not require authentication.
    pub fn is_team_vault(path: &Path) -> bool {
        std::fs::read_to_string(path)
            .ok()
            .and_then(|json| serde_json::from_str::<VaultFile>(&json).ok())
            .map(|f| !f.age_recipients.is_empty() && f.wrapped_dek.is_some())
            .unwrap_or(false)
    }

    // ── persistence ───────────────────────────────────────────────────────────

    /// Atomically write vault to disk (write-to-tmp then rename).
    /// A snapshot of the previous state is taken before overwriting.
    #[instrument(skip(self), fields(secrets = self.file.secrets.len()))]
    pub fn save(&self) -> SafeResult<()> {
        self.ensure_write_allowed()?;
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        // Snapshot the existing file before overwriting so we can recover.
        if self.path.exists() {
            if let Some(profile) = self.profile_name() {
                let _ = snapshot::take(&self.path, &profile, snapshot::DEFAULT_SNAPSHOT_KEEP);
            }
        }
        let json = serde_json::to_string_pretty(&self.file)?;
        let tmp = self.path.with_extension("vault.tmp");
        // Create the temp file with owner-only (0o600) permissions on Unix so the
        // vault — which carries the KDF salt, Argon2 cost params, and the
        // encrypted blob — is never readable by other local users, even
        // transiently. The audit log (audit.rs) already does this; the vault is
        // the more sensitive artifact and must match.
        crate::fsperm::write_owner_only(&tmp, json.as_bytes())?;
        std::fs::rename(&tmp, &self.path)?;
        // The rename preserves the tmp file's mode on Unix, but if a vault file
        // already existed at the destination with looser perms (e.g. a pre-fix
        // 0o644 vault), tighten it explicitly to be safe.
        crate::fsperm::set_owner_only(&self.path)?;
        Ok(())
    }

    // ── secret operations ────────────────────────────────────────────────────

    /// Insert or update a secret. Idempotent — repeated calls with same value are safe.
    /// Key must match `[A-Za-z_][A-Za-z0-9_]*` (valid env-var name) and be ≤ 256 chars.
    #[instrument(skip(self, value, tags, key))]
    pub fn set(&mut self, key: &str, value: &str, tags: HashMap<String, String>) -> SafeResult<()> {
        self.ensure_write_allowed()?;
        validate_secret_key(key)?;
        let (nonce, ct) = crypto::encrypt_with_key_schedule(
            &self.key,
            self.key_schedule,
            KeyPurpose::SecretData,
            self.cipher,
            value.as_bytes(),
        )?;
        let now = Utc::now();
        let (created_at, history, tags) = match self.file.secrets.get(key) {
            Some(existing) => {
                let mut h = existing.history.clone();
                h.push(HistoryEntry {
                    nonce: existing.nonce.clone(),
                    ciphertext: existing.ciphertext.clone(),
                    updated_at: existing.updated_at,
                });
                if h.len() > DEFAULT_HISTORY_KEEP {
                    h.drain(..h.len() - DEFAULT_HISTORY_KEEP);
                }
                let merged_tags = if tags.is_empty() {
                    existing.tags.clone()
                } else {
                    tags
                };
                (existing.created_at, h, merged_tags)
            }
            None => (now, Vec::new(), tags),
        };
        self.file.secrets.insert(
            key.to_string(),
            SecretEntry {
                nonce: crypto::encode_b64(&nonce),
                ciphertext: crypto::encode_b64(&ct),
                created_at,
                updated_at: now,
                tags,
                history,
            },
        );
        self.file.updated_at = now;
        self.save()
    }

    /// Decrypt and return a secret value wrapped in `Zeroizing` so it is
    /// automatically wiped from memory when dropped.
    #[instrument(skip(self, key))]
    pub fn get(&self, key: &str) -> SafeResult<Zeroizing<String>> {
        let entry = self
            .file
            .secrets
            .get(key)
            .ok_or_else(|| SafeError::SecretNotFound {
                key: key.to_string(),
            })?;
        let nonce = crypto::decode_b64(&entry.nonce)?;
        let ct = crypto::decode_b64(&entry.ciphertext)?;
        let pt = crypto::decrypt_with_key_schedule(
            &self.key,
            self.key_schedule,
            KeyPurpose::SecretData,
            self.cipher,
            &nonce,
            &ct,
        )?;
        // Convert Vec<u8> → String without cloning. On error, FromUtf8Error
        // returns the bytes via into_bytes() so we can zeroize them.
        match String::from_utf8(pt) {
            Ok(s) => Ok(Zeroizing::new(s)),
            Err(e) => {
                let mut bytes = e.into_bytes();
                bytes.zeroize();
                Err(SafeError::InvalidVault {
                    reason: "secret is not valid UTF-8".into(),
                })
            }
        }
    }

    /// Remove a secret. Returns `SecretNotFound` if the key does not exist.
    pub fn delete(&mut self, key: &str) -> SafeResult<()> {
        self.ensure_write_allowed()?;
        if !self.file.secrets.contains_key(key) {
            return Err(SafeError::SecretNotFound {
                key: key.to_string(),
            });
        }
        self.file.secrets.remove(key);
        self.file.updated_at = Utc::now();
        self.save()
    }

    /// Rename / move a secret key within this vault.
    ///
    /// The full entry (ciphertext, tags, history) is preserved under `new_key`.
    /// Returns `SecretNotFound` if `old_key` does not exist, `SecretAlreadyExists`
    /// if `new_key` is already occupied and `overwrite` is false.
    pub fn rename_key(&mut self, old_key: &str, new_key: &str, overwrite: bool) -> SafeResult<()> {
        self.ensure_write_allowed()?;
        validate_secret_key(new_key)?;
        if !self.file.secrets.contains_key(old_key) {
            return Err(SafeError::SecretNotFound {
                key: old_key.to_string(),
            });
        }
        if !overwrite && self.file.secrets.contains_key(new_key) {
            return Err(SafeError::SecretAlreadyExists {
                key: new_key.to_string(),
            });
        }
        let entry = self.file.secrets.remove(old_key).unwrap();
        self.file.secrets.insert(new_key.to_string(), entry);
        self.file.updated_at = Utc::now();
        self.save()
    }

    /// List all secret key names in sorted order.
    pub fn list(&self) -> Vec<&str> {
        let mut keys: Vec<&str> = self.file.secrets.keys().map(String::as_str).collect();
        keys.sort_unstable();
        keys
    }

    /// Decrypt and return all secrets as a plain map.  Prefer `get` for single access.
    /// Values are plain `String`s (not `Zeroizing`) for ergonomic iteration;
    /// callers should drop the map promptly after use.
    pub fn export_all(&self) -> SafeResult<HashMap<String, String>> {
        self.list()
            .iter()
            .map(|k| {
                let val = self.get(k)?;
                // Unwrap from Zeroizing — caller owns the HashMap and should drop it promptly.
                Ok((k.to_string(), (*val).clone()))
            })
            .collect()
    }

    // ── versioning ─────────────────────────────────────────────────────────

    /// Decrypt a specific version of a secret. Version 0 is the current value,
    /// version 1 is the most recent previous value, etc.
    pub fn get_version(&self, key: &str, version: usize) -> SafeResult<Zeroizing<String>> {
        if version == 0 {
            return self.get(key);
        }
        let entry = self
            .file
            .secrets
            .get(key)
            .ok_or_else(|| SafeError::SecretNotFound {
                key: key.to_string(),
            })?;
        let hist_idx =
            entry
                .history
                .len()
                .checked_sub(version)
                .ok_or_else(|| SafeError::InvalidVault {
                    reason: format!(
                        "version {version} does not exist for '{key}' (max {})",
                        entry.history.len()
                    ),
                })?;
        let h = &entry.history[hist_idx];
        let nonce = crypto::decode_b64(&h.nonce)?;
        let ct = crypto::decode_b64(&h.ciphertext)?;
        let pt = crypto::decrypt_with_key_schedule(
            &self.key,
            self.key_schedule,
            KeyPurpose::SecretData,
            self.cipher,
            &nonce,
            &ct,
        )?;
        match String::from_utf8(pt) {
            Ok(s) => Ok(Zeroizing::new(s)),
            Err(e) => {
                let mut bytes = e.into_bytes();
                bytes.zeroize();
                Err(SafeError::InvalidVault {
                    reason: "secret is not valid UTF-8".into(),
                })
            }
        }
    }

    /// List version metadata for a key. Returns `(version_number, updated_at)` pairs,
    /// newest first. Version 0 is the current value.
    pub fn history(&self, key: &str) -> SafeResult<Vec<(usize, DateTime<Utc>)>> {
        let entry = self
            .file
            .secrets
            .get(key)
            .ok_or_else(|| SafeError::SecretNotFound {
                key: key.to_string(),
            })?;
        let mut versions = vec![(0usize, entry.updated_at)];
        for (i, h) in entry.history.iter().rev().enumerate() {
            versions.push((i + 1, h.updated_at));
        }
        Ok(versions)
    }

    /// Revert a secret to a previous version. `version` follows the same numbering as
    /// `history()`: 0 is current, 1 is the most recent previous value, etc.
    ///
    /// The reverted value becomes the new current version, and the old current value
    /// is pushed onto the history stack (capped at `DEFAULT_HISTORY_KEEP`).
    pub fn revert_to_version(&mut self, key: &str, version: usize) -> SafeResult<()> {
        self.ensure_write_allowed()?;
        if version == 0 {
            // Already at the requested version — nothing to do.
            return Ok(());
        }
        // Decrypt the target version first while the entry is immutably borrowed.
        let target_value = self.get_version(key, version)?;
        let now = Utc::now();
        let entry = self
            .file
            .secrets
            .get_mut(key)
            .ok_or_else(|| SafeError::SecretNotFound {
                key: key.to_string(),
            })?;
        // Push current value onto the history stack before replacing it.
        let current_nonce = entry.nonce.clone();
        let current_ciphertext = entry.ciphertext.clone();
        let current_updated_at = entry.updated_at;
        entry.history.push(HistoryEntry {
            nonce: current_nonce,
            ciphertext: current_ciphertext,
            updated_at: current_updated_at,
        });
        if entry.history.len() > DEFAULT_HISTORY_KEEP {
            entry
                .history
                .drain(..entry.history.len() - DEFAULT_HISTORY_KEEP);
        }
        let _ = entry; // release the mutable borrow before re-borrowing below

        // Re-encrypt the target value and store it as the new current version.
        let (nonce, ct) = crypto::encrypt_with_key_schedule(
            &self.key,
            self.key_schedule,
            KeyPurpose::SecretData,
            self.cipher,
            target_value.as_bytes(),
        )?;
        let entry = self.file.secrets.get_mut(key).unwrap();
        entry.nonce = crypto::encode_b64(&nonce);
        entry.ciphertext = crypto::encode_b64(&ct);
        entry.updated_at = now;
        self.file.updated_at = now;
        self.save()
    }

    /// Prune the version history for a secret to keep at most `keep_n` previous versions.
    /// If the secret has fewer than `keep_n` history entries nothing changes.
    /// `keep_n == 0` clears all history.
    pub fn prune_history(&mut self, key: &str, keep_n: usize) -> SafeResult<()> {
        self.ensure_write_allowed()?;
        let entry = self
            .file
            .secrets
            .get_mut(key)
            .ok_or_else(|| SafeError::SecretNotFound {
                key: key.to_string(),
            })?;
        if entry.history.len() > keep_n {
            entry.history.drain(..entry.history.len() - keep_n);
        }
        self.file.updated_at = Utc::now();
        self.save()
    }

    // ── key rotation ─────────────────────────────────────────────────────────

    /// Re-encrypt all secrets under a new master password. Atomic — vault is
    /// only updated on-disk after all secrets are successfully re-encrypted.
    #[instrument(skip(self, new_password), fields(secret_count = self.file.secrets.len()))]
    pub fn rotate(&mut self, new_password: &[u8]) -> SafeResult<()> {
        self.ensure_write_allowed()?;
        // Decrypt all current values and history entries before re-keying.
        let all = self.export_all()?;
        let meta: HashMap<String, _> = self
            .file
            .secrets
            .iter()
            .map(|(k, e)| (k.clone(), (e.tags.clone(), e.created_at, e.history.clone())))
            .collect();

        // Decrypt all history entries under the old key.
        let mut history_plaintext: HashMap<String, Vec<(String, DateTime<Utc>)>> = HashMap::new();
        for (key, entry) in &self.file.secrets {
            let mut pts = Vec::new();
            for h in &entry.history {
                let nonce = crypto::decode_b64(&h.nonce)?;
                let ct = crypto::decode_b64(&h.ciphertext)?;
                let pt = crypto::decrypt_with_key_schedule(
                    &self.key,
                    self.key_schedule,
                    KeyPurpose::SecretData,
                    self.cipher,
                    &nonce,
                    &ct,
                )?;
                let s = String::from_utf8(pt).map_err(|_| SafeError::InvalidVault {
                    reason: "history entry is not valid UTF-8".into(),
                })?;
                pts.push((s, h.updated_at));
            }
            history_plaintext.insert(key.clone(), pts);
        }

        let new_salt = crypto::random_salt();
        let new_key = crypto::derive_key(
            new_password,
            &new_salt,
            VAULT_KDF_M_COST,
            VAULT_KDF_T_COST,
            VAULT_KDF_P_COST,
        )?;
        let new_cipher = crypto::default_vault_cipher();
        let new_key_schedule = KeySchedule::HkdfSha256V1;

        let now = Utc::now();
        let mut new_secrets = HashMap::with_capacity(all.len());
        for (key, value) in &all {
            let (nonce, ct) = crypto::encrypt_with_key_schedule(
                &new_key,
                new_key_schedule,
                KeyPurpose::SecretData,
                new_cipher,
                value.as_bytes(),
            )?;
            let (ref tags, created_at, _) = meta[key];

            // Re-encrypt history entries under the new key.
            let mut new_history = Vec::new();
            if let Some(pts) = history_plaintext.get(key) {
                for (pt, updated_at) in pts {
                    let (hn, hct) = crypto::encrypt_with_key_schedule(
                        &new_key,
                        new_key_schedule,
                        KeyPurpose::SecretData,
                        new_cipher,
                        pt.as_bytes(),
                    )?;
                    new_history.push(HistoryEntry {
                        nonce: crypto::encode_b64(&hn),
                        ciphertext: crypto::encode_b64(&hct),
                        updated_at: *updated_at,
                    });
                }
            }

            new_secrets.insert(
                key.clone(),
                SecretEntry {
                    nonce: crypto::encode_b64(&nonce),
                    ciphertext: crypto::encode_b64(&ct),
                    created_at,
                    updated_at: now,
                    tags: tags.clone(),
                    history: new_history,
                },
            );
        }

        let (ch_nonce, ch_ct) = crypto::encrypt_with_key_schedule(
            &new_key,
            new_key_schedule,
            KeyPurpose::VaultChallenge,
            new_cipher,
            VAULT_CHALLENGE_PLAINTEXT,
        )?;
        self.file.kdf = KdfParams {
            algorithm: "argon2id".to_string(),
            m_cost: VAULT_KDF_M_COST,
            t_cost: VAULT_KDF_T_COST,
            p_cost: VAULT_KDF_P_COST,
            salt: crypto::encode_b64(&new_salt),
        };
        self.file.vault_challenge = VaultChallenge {
            nonce: crypto::encode_b64(&ch_nonce),
            ciphertext: crypto::encode_b64(&ch_ct),
        };
        self.file.cipher = new_cipher.as_str().to_string();
        self.file.secrets = new_secrets;
        self.file.updated_at = now;
        self.key = new_key;
        self.cipher = new_cipher;
        self.key_schedule = new_key_schedule;
        self.save()
    }

    // ── accessors ────────────────────────────────────────────────────────────

    pub fn path(&self) -> &Path {
        &self.path
    }
    pub fn secret_count(&self) -> usize {
        self.file.secrets.len()
    }

    pub fn access_profile(&self) -> RbacProfile {
        self.access_profile
    }

    /// Relabel the current handle with a different access profile.
    pub fn with_access_profile(mut self, access_profile: RbacProfile) -> Self {
        self.access_profile = access_profile;
        self
    }

    /// Read-only access to the raw vault file metadata.
    /// Use `get()`, `list()`, `export_all()` etc. for secret access.
    pub fn file(&self) -> &VaultFile {
        &self.file
    }

    pub fn ensure_write_allowed(&self) -> SafeResult<()> {
        self.access_profile.ensure_write_allowed()
    }

    /// Derive the profile name from the vault file path (`<name>.vault`).
    fn profile_name(&self) -> Option<String> {
        Self::profile_name_from_path(&self.path)
    }

    fn profile_name_from_path(path: &Path) -> Option<String> {
        path.file_stem()
            .and_then(|s| s.to_str())
            .map(|s| s.to_string())
    }
}

// ── rotation policies ───────────────────────────────────────────────────────

/// Parse a rotation policy duration string like `"90d"` into days.
pub fn parse_rotation_days(policy: &str) -> Option<i64> {
    let s = policy.trim();
    s.strip_suffix('d')
        .and_then(|prefix| prefix.parse::<i64>().ok())
        .filter(|&d| d > 0)
}

/// Return secrets that are overdue for rotation based on their `rotate_policy` tag.
/// Returns `Vec<(key, days_overdue, policy_string)>`, sorted by key.
pub fn rotation_due(file: &VaultFile) -> Vec<(String, i64, String)> {
    let now = Utc::now();
    let mut due = Vec::new();
    for (key, entry) in &file.secrets {
        if let Some(policy) = entry.tags.get("rotate_policy") {
            if let Some(days) = parse_rotation_days(policy) {
                let age = (now - entry.updated_at).num_days();
                if age >= days {
                    due.push((key.clone(), age - days, policy.clone()));
                }
            }
        }
    }
    due.sort_by(|a, b| a.0.cmp(&b.0));
    due
}

// ── tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;
    use tempfile::tempdir;

    fn pw() -> &'static [u8] {
        b"test-master-password"
    }

    #[test]
    fn create_and_reopen() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "val", HashMap::new()).unwrap();
        drop(v);
        let v2 = Vault::open(&path, pw()).unwrap();
        assert_eq!(&*v2.get("K").unwrap(), "val");
    }

    #[test]
    fn read_only_open_blocks_save_and_mutation_paths() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut writable = Vault::create(&path, pw()).unwrap();
        writable.set("K", "value", HashMap::new()).unwrap();
        drop(writable);

        let mut vault = Vault::open_read_only(&path, pw()).unwrap();
        assert_eq!(vault.access_profile(), RbacProfile::ReadOnly);
        assert_eq!(&*vault.get("K").unwrap(), "value");

        for result in [
            vault.save(),
            vault.set("NEW", "value", HashMap::new()),
            vault.delete("K"),
            vault.rename_key("K", "RENAMED", false),
            vault.rotate(b"new-password"),
        ] {
            match result {
                Err(SafeError::InvalidVault { reason }) => {
                    assert!(reason.contains("read_only"));
                }
                other => panic!("expected read-only write denial, got {other:?}"),
            }
        }
    }

    #[test]
    fn read_only_open_does_not_restore_missing_snapshot() {
        let dir = tempdir().unwrap();
        let profile_dir = dir.path().join("profiles").join("default");
        std::fs::create_dir_all(&profile_dir).unwrap();
        let path = profile_dir.join("vault.vault");
        let snapshots = dir.path().join("snapshots").join("default");
        std::fs::create_dir_all(&snapshots).unwrap();
        std::fs::write(
            snapshots.join("default-20260407-0000000000000.0000.snap"),
            "{}",
        )
        .unwrap();

        match Vault::open_read_only(&path, pw()) {
            Err(SafeError::VaultNotFound { .. }) => {}
            Ok(_) => panic!("expected read-only open to refuse snapshot restore"),
            Err(other) => panic!("expected VaultNotFound, got {other:?}"),
        }
        assert!(!path.exists(), "read-only open must not restore snapshots");
    }

    fn root_key_from_file(file: &VaultFile, password: &[u8]) -> VaultKey {
        let salt = crypto::decode_b64(&file.kdf.salt).unwrap();
        crypto::derive_key(
            password,
            &salt,
            file.kdf.m_cost,
            file.kdf.t_cost,
            file.kdf.p_cost,
        )
        .unwrap()
    }

    fn legacy_vault_file(password: &[u8], value: &str) -> VaultFile {
        let salt = crypto::random_salt();
        let key = crypto::derive_key(
            password,
            &salt,
            VAULT_KDF_M_COST,
            VAULT_KDF_T_COST,
            VAULT_KDF_P_COST,
        )
        .unwrap();
        let now = Utc::now();
        let (ch_nonce, ch_ct) = crypto::encrypt(&key, VAULT_CHALLENGE_PLAINTEXT).unwrap();
        let (nonce, ciphertext) = crypto::encrypt(&key, value.as_bytes()).unwrap();
        let mut secrets = HashMap::new();
        secrets.insert(
            "LEGACY".into(),
            SecretEntry {
                nonce: crypto::encode_b64(&nonce),
                ciphertext: crypto::encode_b64(&ciphertext),
                created_at: now,
                updated_at: now,
                tags: HashMap::new(),
                history: Vec::new(),
            },
        );
        VaultFile {
            schema: VAULT_SCHEMA.to_string(),
            kdf: KdfParams {
                algorithm: VAULT_KDF_ALGORITHM.to_string(),
                m_cost: VAULT_KDF_M_COST,
                t_cost: VAULT_KDF_T_COST,
                p_cost: VAULT_KDF_P_COST,
                salt: crypto::encode_b64(&salt),
            },
            cipher: CipherKind::XChaCha20Poly1305.as_str().to_string(),
            vault_challenge: VaultChallenge {
                nonce: crypto::encode_b64(&ch_nonce),
                ciphertext: crypto::encode_b64(&ch_ct),
            },
            created_at: now,
            updated_at: now,
            secrets,
            age_recipients: Vec::new(),
            wrapped_dek: None,
        }
    }

    #[test]
    fn second_open_fails_while_lock_is_held() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let _v = Vault::create(&path, pw()).unwrap();

        match Vault::open(&path, pw()) {
            Err(SafeError::InvalidVault { reason }) => {
                assert!(reason.contains("vault is locked by another process"));
            }
            Ok(_) => panic!("expected lock error, got open vault"),
            Err(other) => panic!("expected lock error, got {other:?}"),
        }
    }

    #[test]
    fn wrong_password_fails() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "v", HashMap::new()).unwrap();
        drop(v);
        assert!(matches!(
            Vault::open(&path, b"wrong"),
            Err(SafeError::DecryptionFailed)
        ));
    }

    #[test]
    fn empty_vault_wrong_password_fails() {
        // Regression: challenge must be verified even with zero secrets.
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        Vault::create(&path, pw()).unwrap();
        assert!(Vault::open(&path, b"wrong").is_err());
    }

    /// Secret *keys* are ASCII-only by contract; reject Unicode in key material.
    #[test]
    fn validate_secret_key_rejects_non_ascii() {
        assert!(validate_secret_key("café_KEY").is_err());
        assert!(validate_secret_key("emoji_🔑").is_err());
        assert!(validate_secret_key("K_日本").is_err());
    }

    /// Secret *values* may be arbitrary UTF-8 (Track 5 / findings testing gaps).
    #[test]
    fn set_get_roundtrip_unicode_secret_value() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        let val = "snowman☃café日本語";
        v.set("UNICODE_VAL", val, HashMap::new()).unwrap();
        assert_eq!(&*v.get("UNICODE_VAL").unwrap(), val);
    }

    /// Empty master password is allowed by the engine (weak; callers may forbid in UX).
    #[test]
    fn empty_master_password_vault_roundtrip() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, b"").unwrap();
        v.set("K", "v", HashMap::new()).unwrap();
        drop(v);
        let v2 = Vault::open(&path, b"").unwrap();
        assert_eq!(&*v2.get("K").unwrap(), "v");
    }

    #[test]
    fn create_twice_fails() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        Vault::create(&path, pw()).unwrap();
        assert!(matches!(
            Vault::create(&path, pw()),
            Err(SafeError::VaultAlreadyExists { .. })
        ));
    }

    #[test]
    fn set_get_delete_roundtrip() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("DB_PASS", "s3cr3t", HashMap::new()).unwrap();
        assert_eq!(&*v.get("DB_PASS").unwrap(), "s3cr3t");
        v.delete("DB_PASS").unwrap();
        assert!(matches!(
            v.get("DB_PASS"),
            Err(SafeError::SecretNotFound { .. })
        ));
    }

    #[test]
    fn list_is_sorted() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("ZZZ", "z", HashMap::new()).unwrap();
        v.set("AAA", "a", HashMap::new()).unwrap();
        v.set("MMM", "m", HashMap::new()).unwrap();
        assert_eq!(v.list(), vec!["AAA", "MMM", "ZZZ"]);
    }

    #[test]
    fn export_all_decrypts_all() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("A", "alpha", HashMap::new()).unwrap();
        v.set("B", "beta", HashMap::new()).unwrap();
        let all = v.export_all().unwrap();
        assert_eq!(all["A"], "alpha");
        assert_eq!(all["B"], "beta");
    }

    #[test]
    fn rotate_re_encrypts_under_new_password() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("SECRET", "value", HashMap::new()).unwrap();
        v.rotate(b"new-password").unwrap();
        drop(v);
        assert!(Vault::open(&path, pw()).is_err());
        let v2 = Vault::open(&path, b"new-password").unwrap();
        assert_eq!(&*v2.get("SECRET").unwrap(), "value");
    }

    #[test]
    fn new_vault_uses_hkdf_scoped_keys_for_challenge_and_secret_data() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut vault = Vault::create(&path, pw()).unwrap();
        vault.set("SECRET", "value", HashMap::new()).unwrap();

        let root_key = root_key_from_file(vault.file(), pw());
        let challenge_nonce = crypto::decode_b64(&vault.file.vault_challenge.nonce).unwrap();
        let challenge_ct = crypto::decode_b64(&vault.file.vault_challenge.ciphertext).unwrap();
        assert!(matches!(
            crypto::decrypt_for_cipher(vault.cipher, &root_key, &challenge_nonce, &challenge_ct),
            Err(SafeError::DecryptionFailed)
        ));
        assert_eq!(
            crypto::decrypt_with_key_schedule(
                &root_key,
                KeySchedule::HkdfSha256V1,
                KeyPurpose::VaultChallenge,
                vault.cipher,
                &challenge_nonce,
                &challenge_ct
            )
            .unwrap(),
            VAULT_CHALLENGE_PLAINTEXT
        );

        let entry = &vault.file().secrets["SECRET"];
        let secret_nonce = crypto::decode_b64(&entry.nonce).unwrap();
        let secret_ct = crypto::decode_b64(&entry.ciphertext).unwrap();
        assert!(matches!(
            crypto::decrypt_for_cipher(vault.cipher, &root_key, &secret_nonce, &secret_ct),
            Err(SafeError::DecryptionFailed)
        ));
        assert_eq!(
            crypto::decrypt_with_key_schedule(
                &root_key,
                KeySchedule::HkdfSha256V1,
                KeyPurpose::SecretData,
                vault.cipher,
                &secret_nonce,
                &secret_ct
            )
            .unwrap(),
            b"value"
        );
    }

    #[test]
    fn open_legacy_vault_detects_legacy_schedule_and_keeps_writes_consistent() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("legacy.vault");
        let file = legacy_vault_file(pw(), "legacy-value");
        std::fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();

        let mut vault = Vault::open(&path, pw()).unwrap();
        assert_eq!(vault.key_schedule, KeySchedule::LegacyDirect);
        assert_eq!(vault.cipher, CipherKind::XChaCha20Poly1305);
        assert_eq!(&*vault.get("LEGACY").unwrap(), "legacy-value");

        vault
            .set("NEW_SECRET", "new-value", HashMap::new())
            .unwrap();
        let root_key = root_key_from_file(vault.file(), pw());
        let entry = &vault.file().secrets["NEW_SECRET"];
        let nonce = crypto::decode_b64(&entry.nonce).unwrap();
        let ciphertext = crypto::decode_b64(&entry.ciphertext).unwrap();
        assert_eq!(
            crypto::decrypt_for_cipher(vault.cipher, &root_key, &nonce, &ciphertext).unwrap(),
            b"new-value"
        );
    }

    #[test]
    fn rotating_legacy_vault_migrates_it_to_hkdf_schedule() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("legacy.vault");
        let file = legacy_vault_file(pw(), "legacy-value");
        std::fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();

        let mut vault = Vault::open(&path, pw()).unwrap();
        assert_eq!(vault.key_schedule, KeySchedule::LegacyDirect);
        assert_eq!(vault.cipher, CipherKind::XChaCha20Poly1305);
        vault.rotate(b"new-password").unwrap();
        assert_eq!(vault.key_schedule, KeySchedule::HkdfSha256V1);
        assert_eq!(vault.cipher, crypto::default_vault_cipher());
        drop(vault);

        let reopened = Vault::open(&path, b"new-password").unwrap();
        assert_eq!(reopened.key_schedule, KeySchedule::HkdfSha256V1);
        assert_eq!(reopened.cipher, crypto::default_vault_cipher());
        assert_eq!(&*reopened.get("LEGACY").unwrap(), "legacy-value");

        let root_key = root_key_from_file(reopened.file(), b"new-password");
        let challenge_nonce = crypto::decode_b64(&reopened.file.vault_challenge.nonce).unwrap();
        let challenge_ct = crypto::decode_b64(&reopened.file.vault_challenge.ciphertext).unwrap();
        assert!(matches!(
            crypto::decrypt_for_cipher(reopened.cipher, &root_key, &challenge_nonce, &challenge_ct),
            Err(SafeError::DecryptionFailed)
        ));
        assert_eq!(
            crypto::decrypt_with_key_schedule(
                &root_key,
                KeySchedule::HkdfSha256V1,
                KeyPurpose::VaultChallenge,
                reopened.cipher,
                &challenge_nonce,
                &challenge_ct
            )
            .unwrap(),
            VAULT_CHALLENGE_PLAINTEXT
        );
    }

    #[cfg(feature = "fips")]
    #[test]
    fn fips_build_creates_aes256gcm_vaults() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut vault = Vault::create(&path, pw()).unwrap();
        vault.set("SECRET", "value", HashMap::new()).unwrap();
        assert_eq!(vault.cipher, CipherKind::Aes256Gcm);
        assert_eq!(vault.file.cipher, CipherKind::Aes256Gcm.as_str());
    }

    #[test]
    fn set_preserves_created_at_on_update() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "v1", HashMap::new()).unwrap();
        let created = v.file.secrets["K"].created_at;
        v.set("K", "v2", HashMap::new()).unwrap();
        assert_eq!(v.file.secrets["K"].created_at, created);
        assert_ne!(v.file.secrets["K"].updated_at, created); // may be equal in very fast tests; best effort
    }

    #[test]
    fn delete_missing_key_returns_error() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        assert!(matches!(
            v.delete("NOPE"),
            Err(SafeError::SecretNotFound { .. })
        ));
    }

    #[test]
    fn key_validation_allows_dot_and_hyphen_namespaces() {
        // Namespaced keys like github.com.token or db-prod.PASSWORD must be accepted.
        for key in &[
            "github.com.token",
            "db-prod.PASSWORD",
            "_under.score-mix",
            "A.b-c.D",
        ] {
            assert!(validate_secret_key(key).is_ok(), "expected ok for '{key}'");
        }
    }

    #[test]
    fn key_validation_rejects_invalid_forms() {
        let bad = [
            "",             // empty
            "123abc",       // starts with digit
            "-starts-bad",  // starts with separator
            ".starts-bad",  // starts with separator
            "ends.",        // ends with separator
            "ends-",        // ends with separator
            "double..dot",  // consecutive separators
            "double--dash", // consecutive separators
            "dot.-dash",    // consecutive mixed separators
            "has space",    // space not allowed
        ];
        for key in &bad {
            assert!(
                validate_secret_key(key).is_err(),
                "expected error for '{key}'"
            );
        }
    }

    // ── versioning tests ────────────────────────────────────────────────────

    #[test]
    fn set_builds_history() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "v1", HashMap::new()).unwrap();
        v.set("K", "v2", HashMap::new()).unwrap();
        v.set("K", "v3", HashMap::new()).unwrap();
        assert_eq!(v.file.secrets["K"].history.len(), 2);
        assert_eq!(&*v.get("K").unwrap(), "v3");
    }

    #[test]
    fn get_version_returns_previous_values() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "v1", HashMap::new()).unwrap();
        v.set("K", "v2", HashMap::new()).unwrap();
        v.set("K", "v3", HashMap::new()).unwrap();
        assert_eq!(&*v.get_version("K", 0).unwrap(), "v3");
        assert_eq!(&*v.get_version("K", 1).unwrap(), "v2");
        assert_eq!(&*v.get_version("K", 2).unwrap(), "v1");
    }

    #[test]
    fn get_version_out_of_range_errors() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "v1", HashMap::new()).unwrap();
        assert!(v.get_version("K", 1).is_err());
    }

    #[test]
    fn history_capped_at_default() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        for i in 0..10 {
            v.set("K", &format!("v{i}"), HashMap::new()).unwrap();
        }
        assert_eq!(v.file.secrets["K"].history.len(), DEFAULT_HISTORY_KEEP);
        // Most recent history entry should be v8 (v9 is current)
        assert_eq!(&*v.get_version("K", 1).unwrap(), "v8");
    }

    #[test]
    fn history_metadata_lists_versions() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "v1", HashMap::new()).unwrap();
        v.set("K", "v2", HashMap::new()).unwrap();
        let versions = v.history("K").unwrap();
        assert_eq!(versions.len(), 2); // current + 1 history
        assert_eq!(versions[0].0, 0); // current
        assert_eq!(versions[1].0, 1); // previous
    }

    #[test]
    fn rotate_preserves_history() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "v1", HashMap::new()).unwrap();
        v.set("K", "v2", HashMap::new()).unwrap();
        v.rotate(b"new-pw").unwrap();
        drop(v);
        let v2 = Vault::open(&path, b"new-pw").unwrap();
        assert_eq!(&*v2.get("K").unwrap(), "v2");
        assert_eq!(&*v2.get_version("K", 1).unwrap(), "v1");
    }

    // ── rotation policy tests ───────────────────────────────────────────────

    #[test]
    fn parse_rotation_days_valid() {
        assert_eq!(parse_rotation_days("90d"), Some(90));
        assert_eq!(parse_rotation_days("30d"), Some(30));
        assert_eq!(parse_rotation_days("1d"), Some(1));
        assert_eq!(parse_rotation_days(" 7d "), Some(7));
    }

    #[test]
    fn parse_rotation_days_invalid() {
        assert_eq!(parse_rotation_days("invalid"), None);
        assert_eq!(parse_rotation_days("0d"), None);
        assert_eq!(parse_rotation_days("-1d"), None);
        assert_eq!(parse_rotation_days(""), None);
        assert_eq!(parse_rotation_days("d"), None);
    }

    #[test]
    fn rotation_due_finds_overdue_secrets() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        let mut tags = HashMap::new();
        tags.insert("rotate_policy".into(), "1d".into());
        v.set("OLD_KEY", "val", tags).unwrap();
        // Backdate the entry to 3 days ago.
        v.file.secrets.get_mut("OLD_KEY").unwrap().updated_at =
            Utc::now() - chrono::Duration::days(3);
        let due = rotation_due(v.file());
        assert_eq!(due.len(), 1);
        assert_eq!(due[0].0, "OLD_KEY");
        assert!(due[0].1 >= 2); // at least 2 days overdue
    }

    #[test]
    fn rotation_due_ignores_fresh_secrets() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        let mut tags = HashMap::new();
        tags.insert("rotate_policy".into(), "90d".into());
        v.set("FRESH", "val", tags).unwrap();
        let due = rotation_due(v.file());
        assert!(due.is_empty());
    }

    #[test]
    fn set_preserves_existing_tags_when_update_has_no_tags() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        let mut tags = HashMap::new();
        tags.insert("env".into(), "prod".into());
        tags.insert("rotate_policy".into(), "30d".into());
        v.set("KEY", "v1", tags.clone()).unwrap();
        v.set("KEY", "v2", HashMap::new()).unwrap();
        assert_eq!(v.file.secrets["KEY"].tags, tags);
    }

    #[test]
    fn lock_guard_drop_keeps_replaced_lockfile() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let lock_path = lock_path_for(&path);
        let guard = acquire_lock(&path).unwrap();
        std::fs::write(&lock_path, "different-owner").unwrap();
        drop(guard);
        assert_eq!(
            std::fs::read_to_string(&lock_path).unwrap(),
            "different-owner"
        );
    }

    #[test]
    fn dead_owner_lockfile_is_recovered_for_new_format() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let lock_path = lock_path_for(&path);
        let stale = LockFileContents {
            version: 1,
            id: "stale-owner".into(),
            pid: u32::MAX,
            created_at: Utc::now(),
        };
        std::fs::write(&lock_path, serde_json::to_string(&stale).unwrap()).unwrap();

        let guard = acquire_lock(&path).unwrap();
        let contents = std::fs::read_to_string(&lock_path).unwrap();
        let recovered: LockFileContents = serde_json::from_str(&contents).unwrap();
        assert_eq!(recovered.version, 1);
        assert_eq!(recovered.pid, std::process::id());
        assert_ne!(recovered.id, stale.id);
        drop(guard);
        assert!(!lock_path.exists());
    }

    #[test]
    fn opaque_legacy_lockfile_is_not_removed_implicitly() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let lock_path = lock_path_for(&path);
        std::fs::write(&lock_path, "legacy-uuid-without-metadata").unwrap();

        match acquire_lock(&path) {
            Err(SafeError::InvalidVault { reason }) => {
                assert!(reason.contains("vault is locked by another process"));
            }
            Ok(_) => panic!("expected lock error, got recovered lock"),
            Err(other) => panic!("expected lock error, got {other:?}"),
        }
        assert_eq!(
            std::fs::read_to_string(&lock_path).unwrap(),
            "legacy-uuid-without-metadata"
        );
    }

    #[test]
    fn process_is_running_sees_current_process() {
        assert!(process_is_running(std::process::id()));
    }

    #[test]
    fn process_is_running_rejects_impossible_pid() {
        assert!(!process_is_running(u32::MAX));
    }

    #[test]
    fn missing_vault_with_existing_lock_does_not_restore_snapshot() {
        let dir = tempdir().unwrap();
        let profile_dir = dir.path().join("profiles").join("default");
        std::fs::create_dir_all(&profile_dir).unwrap();
        let path = profile_dir.join("vault.vault");
        let snapshots = dir.path().join("snapshots").join("default");
        std::fs::create_dir_all(&snapshots).unwrap();
        std::fs::write(
            snapshots.join("default-20260407-0000000000000.0000.snap"),
            "{}",
        )
        .unwrap();

        let _guard = acquire_lock(&path).unwrap();
        match Vault::open(&path, pw()) {
            Err(SafeError::InvalidVault { reason }) => {
                assert!(reason.contains("vault is locked by another process"));
            }
            Ok(_) => panic!("expected lock error, got open vault"),
            Err(other) => panic!("expected lock error, got {other:?}"),
        }
        assert!(
            !path.exists(),
            "open should not restore under another process's lock"
        );
    }

    // ── secret_count ─────────────────────────────────────────────────────────

    #[test]
    fn secret_count_reflects_set_and_delete() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        assert_eq!(v.secret_count(), 0);

        v.set("A", "1", HashMap::new()).unwrap();
        assert_eq!(v.secret_count(), 1);

        v.set("B", "2", HashMap::new()).unwrap();
        assert_eq!(v.secret_count(), 2);

        v.delete("A").unwrap();
        assert_eq!(v.secret_count(), 1);
    }

    // ── Vault::is_team_vault (path-based check) ───────────────────────────────

    #[test]
    fn vault_is_team_vault_returns_false_for_regular_vault() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let _v = Vault::create(&path, pw()).unwrap();
        drop(_v);
        assert!(!Vault::is_team_vault(&path));
    }

    #[test]
    fn vault_is_team_vault_returns_false_for_nonexistent_path() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("does_not_exist.vault");
        assert!(!Vault::is_team_vault(&path));
    }

    #[test]
    fn vault_is_team_vault_returns_true_for_team_vault_on_disk() {
        use crate::{age_crypto, team};

        let dir = tempdir().unwrap();
        let path = dir.path().join("team.vault");

        let (_secret, recipient) = age_crypto::generate_identity();
        let (file, _dek) = team::create_team_vault(&[recipient]).unwrap();

        // Write team vault to disk (bypassing Vault::save which needs auth).
        let json = serde_json::to_string_pretty(&file).unwrap();
        std::fs::write(&path, json).unwrap();

        assert!(Vault::is_team_vault(&path));
    }

    // ── open_with_key ─────────────────────────────────────────────────────────

    #[test]
    fn open_with_key_using_team_dek_succeeds() {
        use crate::{age_crypto, team};

        let dir = tempdir().unwrap();
        let path = dir.path().join("team.vault");

        let (secret, recipient) = age_crypto::generate_identity();
        let identities = age::IdentityFile::from_buffer(secret.as_bytes())
            .unwrap()
            .into_identities()
            .unwrap();

        let (file, _) = team::create_team_vault(&[recipient]).unwrap();
        let json = serde_json::to_string_pretty(&file).unwrap();
        std::fs::write(&path, &json).unwrap();

        // Unwrap the DEK via age identity, then open the vault with it.
        let dek = team::unwrap_dek(&file, &identities).unwrap();
        let vault = Vault::open_with_key(&path, dek).unwrap();
        assert_eq!(vault.secret_count(), 0);
    }

    #[test]
    fn open_with_key_read_only_blocks_mutation() {
        use crate::{age_crypto, team};

        let dir = tempdir().unwrap();
        let path = dir.path().join("team.vault");

        let (secret, recipient) = age_crypto::generate_identity();
        let identities = age::IdentityFile::from_buffer(secret.as_bytes())
            .unwrap()
            .into_identities()
            .unwrap();

        let (file, _dek) = team::create_team_vault(&[recipient]).unwrap();
        std::fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();

        let dek = team::unwrap_dek(&file, &identities).unwrap();
        let mut writable = Vault::open_with_key(&path, dek).unwrap();
        writable
            .set("TEAM_SECRET", "value", HashMap::new())
            .unwrap();
        drop(writable);

        let dek = team::unwrap_dek(&file, &identities).unwrap();
        let mut vault = Vault::open_with_key_read_only(&path, dek).unwrap();
        assert_eq!(vault.access_profile(), RbacProfile::ReadOnly);
        assert_eq!(&*vault.get("TEAM_SECRET").unwrap(), "value");
        assert!(matches!(
            vault.set("NEW_SECRET", "blocked", HashMap::new()),
            Err(SafeError::InvalidVault { .. })
        ));
    }

    #[test]
    fn open_with_key_with_wrong_key_returns_decryption_failed() {
        use crate::crypto::VaultKey;
        use crate::{age_crypto, team};

        let dir = tempdir().unwrap();
        let path = dir.path().join("team.vault");

        let (_secret, recipient) = age_crypto::generate_identity();
        let (file, _) = team::create_team_vault(&[recipient]).unwrap();
        let json = serde_json::to_string_pretty(&file).unwrap();
        std::fs::write(&path, &json).unwrap();

        // Use a random wrong key.
        let wrong_key = VaultKey::from_bytes(crypto::random_salt());
        let result = Vault::open_with_key(&path, wrong_key);
        assert!(matches!(result, Err(SafeError::DecryptionFailed)));
    }

    // ── property-based roundtrip tests ───────────────────────────────────────
    //
    // Limit to 32 cases per property to keep KDF overhead tolerable in CI.

    proptest! {
        #![proptest_config(ProptestConfig::with_cases(32))]

        /// set → get returns exactly the same value (in-memory, no persist).
        #[test]
        fn prop_set_get_roundtrip(
            key   in "[A-Za-z_][A-Za-z0-9_]{0,63}",
            value in any::<String>(),
        ) {
            let dir = tempdir().unwrap();
            let path = dir.path().join("v.vault");
            let mut v = Vault::create(&path, pw()).unwrap();
            v.set(&key, &value, HashMap::new()).unwrap();
            prop_assert_eq!(&*v.get(&key).unwrap(), value.as_str());
        }

        /// set → delete → get returns SecretNotFound.
        #[test]
        fn prop_set_delete_not_found(
            key   in "[A-Za-z_][A-Za-z0-9_]{0,63}",
            value in any::<String>(),
        ) {
            let dir = tempdir().unwrap();
            let path = dir.path().join("v.vault");
            let mut v = Vault::create(&path, pw()).unwrap();
            v.set(&key, &value, HashMap::new()).unwrap();
            v.delete(&key).unwrap();
            let is_not_found = v.get(&key).is_err();
            prop_assert!(is_not_found);
        }

        /// Multiple distinct keys: list() contains every inserted key.
        #[test]
        fn prop_multi_set_list_contains_all(
            // Generate up to 8 distinct-ish keys; dedup to avoid duplicates.
            keys in proptest::collection::vec("[A-Za-z_][A-Za-z0-9_]{0,30}", 1..=8),
        ) {
            let dir = tempdir().unwrap();
            let path = dir.path().join("v.vault");
            let mut v = Vault::create(&path, pw()).unwrap();
            let mut deduped = keys.clone();
            deduped.sort();
            deduped.dedup();
            for k in &deduped {
                v.set(k, "x", HashMap::new()).unwrap();
            }
            let listed = v.list();
            for k in &deduped {
                prop_assert!(listed.contains(&k.as_str()), "key {k} missing from list()");
            }
        }

        /// Persist roundtrip: values survive drop-and-reopen with correct password.
        #[test]
        fn prop_persist_roundtrip(
            key   in "[A-Za-z_][A-Za-z0-9_]{0,63}",
            value in any::<String>(),
        ) {
            let dir = tempdir().unwrap();
            let path = dir.path().join("v.vault");
            {
                let mut v = Vault::create(&path, pw()).unwrap();
                v.set(&key, &value, HashMap::new()).unwrap();
            }
            let v2 = Vault::open(&path, pw()).unwrap();
            prop_assert_eq!(&*v2.get(&key).unwrap(), value.as_str());
        }
    }

    // ── concurrent lock stress tests ─────────────────────────────────────────

    /// N threads simultaneously attempt Vault::open on the same locked vault.
    /// All must fail with a lock error — no panics, no silent data corruption.
    #[test]
    fn concurrent_opens_all_fail_while_lock_held() {
        use std::sync::{Arc, Barrier};

        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        // Main thread holds the vault (and thus the lock file).
        let _owner = Vault::create(&path, pw()).unwrap();

        const N: usize = 8;
        let barrier = Arc::new(Barrier::new(N + 1));
        let path_arc = Arc::new(path);

        let handles: Vec<_> = (0..N)
            .map(|_| {
                let p = Arc::clone(&path_arc);
                let b = Arc::clone(&barrier);
                std::thread::spawn(move || {
                    b.wait(); // synchronise start so all N hit the lock together
                    match Vault::open(&p, pw()) {
                        Err(SafeError::InvalidVault { reason }) => {
                            assert!(
                                reason.contains("vault is locked by another process"),
                                "unexpected lock reason: {reason}"
                            );
                        }
                        Ok(_) => panic!("concurrent open should not succeed while lock is held"),
                        Err(e) => panic!("unexpected error variant: {e:?}"),
                    }
                })
            })
            .collect();

        barrier.wait(); // release all threads simultaneously
        for h in handles {
            h.join().expect("concurrent open thread panicked");
        }
    }

    /// After the vault owner drops its handle the lock is released;
    /// the next open (even from a spawn) must succeed without error.
    #[test]
    fn lock_released_after_drop_then_reopen_succeeds() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        {
            let mut owner = Vault::create(&path, pw()).unwrap();
            owner.set("K", "v", HashMap::new()).unwrap();
            // While held a second open fails.
            assert!(
                Vault::open(&path, pw()).is_err(),
                "second open should fail while lock held"
            );
        } // lock released here via Drop
          // After drop the lock file is gone; a fresh open must succeed.
        let v = Vault::open(&path, pw()).unwrap();
        assert_eq!(&*v.get("K").unwrap(), "v");
    }

    // ── revert_to_version / prune_history ────────────────────────────────────

    #[test]
    fn revert_to_version_restores_previous_value() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "v1", HashMap::new()).unwrap();
        v.set("K", "v2", HashMap::new()).unwrap();
        v.set("K", "v3", HashMap::new()).unwrap();
        // v3 is current (version 0), v2 is version 1, v1 is version 2.
        v.revert_to_version("K", 1).unwrap();
        assert_eq!(&*v.get("K").unwrap(), "v2");
    }

    #[test]
    fn revert_to_version_zero_is_noop() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "v1", HashMap::new()).unwrap();
        v.set("K", "v2", HashMap::new()).unwrap();
        v.revert_to_version("K", 0).unwrap();
        assert_eq!(&*v.get("K").unwrap(), "v2");
    }

    #[test]
    fn revert_to_version_out_of_range_errors() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "v1", HashMap::new()).unwrap();
        // Only current exists; no history to revert to.
        assert!(v.revert_to_version("K", 1).is_err());
    }

    #[test]
    fn revert_to_version_survives_persist_roundtrip() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "original", HashMap::new()).unwrap();
        v.set("K", "updated", HashMap::new()).unwrap();
        v.revert_to_version("K", 1).unwrap();
        drop(v);
        let v2 = Vault::open(&path, pw()).unwrap();
        assert_eq!(&*v2.get("K").unwrap(), "original");
    }

    #[test]
    fn prune_history_limits_depth() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        for i in 0..6 {
            v.set("K", &format!("v{i}"), HashMap::new()).unwrap();
        }
        // Now history has DEFAULT_HISTORY_KEEP (5) entries; current is v5.
        v.prune_history("K", 2).unwrap();
        let versions = v.history("K").unwrap();
        // 1 current + 2 history = 3 total entries
        assert_eq!(versions.len(), 3);
        // Current value is still v5
        assert_eq!(&*v.get("K").unwrap(), "v5");
    }

    #[test]
    fn prune_history_to_zero_clears_all_history() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        v.set("K", "v1", HashMap::new()).unwrap();
        v.set("K", "v2", HashMap::new()).unwrap();
        v.prune_history("K", 0).unwrap();
        assert_eq!(v.file.secrets["K"].history.len(), 0);
        // Current value is still intact
        assert_eq!(&*v.get("K").unwrap(), "v2");
    }

    #[test]
    fn prune_history_missing_key_returns_error() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("v.vault");
        let mut v = Vault::create(&path, pw()).unwrap();
        assert!(matches!(
            v.prune_history("NOPE", 3),
            Err(SafeError::SecretNotFound { .. })
        ));
    }
}
