//! Local snapshot management — keeps the last N vault file copies so secrets
//! are never permanently lost due to corruption or accidental deletion.
//!
//! Snapshot file naming convention:
//!   `<profile>.vault.<timestamp_utc_millis>.<sequence>.snap`
//!
//! Snapshots are stored in `<vault_dir>/snapshots/<profile>/`.
//! They are encrypted identical copies of the vault file — no additional
//! credentials are needed to restore them.
//!
//! # Snapshot lifecycle
//!
//! The three key lifecycle operations beyond basic take/restore are:
//!
//! - **Export** — copy a snapshot to an arbitrary destination path (e.g. an
//!   external backup drive or a CI artifact directory).  The destination file
//!   is an encrypted copy of the vault file and requires the same vault
//!   password to use.
//!
//! - **Timestamp-targeted diff** — compare the current vault file (on-disk)
//!   against the most-recent snapshot that was taken at or before a given UTC
//!   millisecond timestamp.  Returns `Added`, `Removed`, and `Changed` key
//!   sets by examining the raw JSON secret maps without decrypting values.
//!
//! - **Configurable retention** — callers pass an explicit `keep` count to
//!   [`take`] so that different use-cases (CI ephemeral, long-running desktop)
//!   can manage snapshot depth independently.  The default is
//!   [`DEFAULT_SNAPSHOT_KEEP`].

use std::path::{Path, PathBuf};

use chrono::Utc;

use crate::errors::{SafeError, SafeResult};
use crate::profile::vault_dir;

/// How many snapshots to retain per profile.
pub const DEFAULT_SNAPSHOT_KEEP: usize = 10;

/// Return the directory that holds snapshots for `profile`.
pub fn snapshot_dir(profile: &str) -> PathBuf {
    vault_dir().join("snapshots").join(profile)
}

/// Take a snapshot of `vault_path`. Prunes oldest snapshots beyond `keep`.
pub fn take(vault_path: &Path, profile: &str, keep: usize) -> SafeResult<PathBuf> {
    take_at_timestamp_millis(vault_path, profile, keep, Utc::now().timestamp_millis())
}

fn take_at_timestamp_millis(
    vault_path: &Path,
    profile: &str,
    keep: usize,
    ts_millis: i64,
) -> SafeResult<PathBuf> {
    let dir = snapshot_dir(profile);
    std::fs::create_dir_all(&dir)?;

    let snap_path = next_snapshot_path(&dir, profile, ts_millis);

    // Atomic copy: write to .tmp, then rename.
    let tmp = snap_path.with_extension("snap.tmp");
    std::fs::copy(vault_path, &tmp)?;
    // Snapshots are encrypted full copies of the vault; restrict them to
    // owner-only (0o600) on Unix, matching the vault and audit log. fs::copy
    // generally inherits the source mode, but set it explicitly so a snapshot
    // is never left group/world-readable regardless of the source's mode.
    crate::fsperm::set_owner_only(&tmp)?;
    std::fs::rename(&tmp, &snap_path)?;
    crate::fsperm::set_owner_only(&snap_path)?;

    prune(&dir, profile, keep)?;

    Ok(snap_path)
}

fn next_snapshot_path(dir: &Path, profile: &str, ts_millis: i64) -> PathBuf {
    for seq in 0_u32.. {
        let snap_name = format!("{profile}.vault.{ts_millis}.{seq:04}.snap");
        let snap_path = dir.join(&snap_name);
        if !snap_path.exists() {
            return snap_path;
        }
    }

    unreachable!("u32 sequence exhausted while generating snapshot path")
}

/// Return all snapshot paths for `profile`, sorted oldest-first.
pub fn list(profile: &str) -> SafeResult<Vec<PathBuf>> {
    let dir = snapshot_dir(profile);
    if !dir.exists() {
        return Ok(Vec::new());
    }
    let suffix = format!("{profile}.vault.");
    let mut snaps: Vec<PathBuf> = std::fs::read_dir(&dir)?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| {
            p.file_name()
                .and_then(|n| n.to_str())
                .map(|n| n.starts_with(&suffix) && n.ends_with(".snap"))
                .unwrap_or(false)
        })
        .collect();
    snaps.sort();
    Ok(snaps)
}

/// Restore the most-recent snapshot over `vault_path`. Returns the snapshot used.
pub fn restore_latest(vault_path: &Path, profile: &str) -> SafeResult<PathBuf> {
    let snaps = list(profile)?;
    let latest = snaps.last().ok_or_else(|| SafeError::NoSnapshotAvailable {
        profile: profile.to_string(),
    })?;
    restore(vault_path, latest)
}

/// Restore a specific snapshot file over `vault_path`. Atomic.
pub fn restore(vault_path: &Path, snap_path: &Path) -> SafeResult<PathBuf> {
    if !snap_path.exists() {
        return Err(SafeError::SnapshotNotFound {
            path: snap_path.display().to_string(),
        });
    }
    if let Some(parent) = vault_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let tmp = vault_path.with_extension("vault.restore.tmp");
    std::fs::copy(snap_path, &tmp)?;
    std::fs::rename(&tmp, vault_path)?;
    Ok(snap_path.to_path_buf())
}

// ── extended lifecycle ───────────────────────────────────────────────────────

/// Export a snapshot to an arbitrary destination path (e.g. an external backup
/// or a CI artifact directory).
///
/// The exported file is an encrypted byte-for-byte copy of the snapshot — it
/// requires the same vault password to use and can be safely stored on
/// untrusted media.
///
/// The copy is atomic: the data is written to `<dest>.snap.export.tmp` first
/// and then renamed to `dest`.
///
/// Returns `SnapshotNotFound` if `snap_path` does not exist.
pub fn export(snap_path: &Path, dest: &Path) -> SafeResult<PathBuf> {
    if !snap_path.exists() {
        return Err(SafeError::SnapshotNotFound {
            path: snap_path.display().to_string(),
        });
    }
    if let Some(parent) = dest.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent)?;
        }
    }
    let tmp = dest.with_extension("snap.export.tmp");
    std::fs::copy(snap_path, &tmp)?;
    std::fs::rename(&tmp, dest)?;
    Ok(dest.to_path_buf())
}

/// A single key difference between the current vault file and a snapshot.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SnapDiffEntry {
    /// The key exists in the snapshot but not in the current vault.
    Removed(String),
    /// The key exists in the current vault but not in the snapshot.
    Added(String),
    /// The key exists in both but the ciphertext differs (value was changed).
    Changed(String),
}

/// Compare the on-disk vault file at `vault_path` against the most-recent
/// snapshot taken at or before `at_millis` (UTC milliseconds since epoch).
///
/// The diff is computed by comparing the raw JSON `secrets` maps; secret values
/// are **not** decrypted — only key presence and ciphertext equality are
/// checked.
///
/// Returns `NoSnapshotAvailable` if no snapshot exists at or before
/// `at_millis`.
pub fn diff_at(vault_path: &Path, profile: &str, at_millis: i64) -> SafeResult<Vec<SnapDiffEntry>> {
    let snaps = list(profile)?;
    // Find the latest snapshot whose timestamp (milliseconds in filename) is ≤ at_millis.
    let snap = snaps
        .iter()
        .rfind(|p| {
            snapshot_ts_millis(p)
                .map(|ts| ts <= at_millis)
                .unwrap_or(false)
        })
        .ok_or_else(|| SafeError::NoSnapshotAvailable {
            profile: profile.to_string(),
        })?;

    diff_files(vault_path, snap)
}

/// Compare the on-disk vault file at `vault_path` against the most-recent
/// snapshot for `profile`.
///
/// Returns `NoSnapshotAvailable` if no snapshots exist.
pub fn diff_latest(vault_path: &Path, profile: &str) -> SafeResult<Vec<SnapDiffEntry>> {
    let snaps = list(profile)?;
    let snap = snaps.last().ok_or_else(|| SafeError::NoSnapshotAvailable {
        profile: profile.to_string(),
    })?;
    diff_files(vault_path, snap)
}

/// Parse the UTC millisecond timestamp embedded in a snapshot filename.
///
/// Expected format: `<profile>.vault.<ts_millis>.<seq>.snap`
fn snapshot_ts_millis(snap_path: &Path) -> Option<i64> {
    let name = snap_path.file_name()?.to_str()?;
    // Strip trailing ".snap"
    let stem = name.strip_suffix(".snap")?;
    // Split on '.' — expect last two segments to be <seq> and <ts>
    let parts: Vec<&str> = stem.split('.').collect();
    // Format: <profile>.vault.<ts>.<seq>  → parts[-2] is ts
    if parts.len() < 4 {
        return None;
    }
    let ts_part = parts[parts.len() - 2];
    ts_part.parse::<i64>().ok()
}

/// Diff two vault files (current and snapshot) at the JSON level.
///
/// Compares only the `secrets` map keys and ciphertext values — no decryption
/// is performed.
fn diff_files(current_path: &Path, snap_path: &Path) -> SafeResult<Vec<SnapDiffEntry>> {
    let current_json = std::fs::read_to_string(current_path)?;
    let snap_json = std::fs::read_to_string(snap_path).map_err(|e| {
        if e.kind() == std::io::ErrorKind::NotFound {
            SafeError::SnapshotNotFound {
                path: snap_path.display().to_string(),
            }
        } else {
            SafeError::Io(e)
        }
    })?;

    let current_val: serde_json::Value =
        serde_json::from_str(&current_json).map_err(|e| SafeError::VaultCorrupted {
            reason: format!("current vault JSON parse error: {e}"),
        })?;
    let snap_val: serde_json::Value =
        serde_json::from_str(&snap_json).map_err(|e| SafeError::VaultCorrupted {
            reason: format!("snapshot JSON parse error: {e}"),
        })?;

    let current_secrets = current_val
        .get("secrets")
        .and_then(|v| v.as_object())
        .cloned()
        .unwrap_or_default();
    let snap_secrets = snap_val
        .get("secrets")
        .and_then(|v| v.as_object())
        .cloned()
        .unwrap_or_default();

    let mut diff = Vec::new();

    // Keys in snapshot but not in current → Removed.
    for key in snap_secrets.keys() {
        if !current_secrets.contains_key(key) {
            diff.push(SnapDiffEntry::Removed(key.clone()));
        }
    }

    // Keys in current vault.
    for (key, current_entry) in &current_secrets {
        match snap_secrets.get(key) {
            None => diff.push(SnapDiffEntry::Added(key.clone())),
            Some(snap_entry) => {
                // Compare ciphertext fields to detect value changes.
                let current_ct = current_entry.get("ciphertext");
                let snap_ct = snap_entry.get("ciphertext");
                if current_ct != snap_ct {
                    diff.push(SnapDiffEntry::Changed(key.clone()));
                }
            }
        }
    }

    diff.sort_by(|a, b| {
        let key = |e: &SnapDiffEntry| match e {
            SnapDiffEntry::Removed(k) | SnapDiffEntry::Added(k) | SnapDiffEntry::Changed(k) => {
                k.clone()
            }
        };
        key(a).cmp(&key(b))
    });

    Ok(diff)
}

/// Delete snapshots beyond `keep` (oldest removed first).
fn prune(dir: &Path, profile: &str, keep: usize) -> SafeResult<()> {
    let suffix = format!("{profile}.vault.");
    let mut snaps: Vec<PathBuf> = std::fs::read_dir(dir)?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| {
            p.file_name()
                .and_then(|n| n.to_str())
                .map(|n| n.starts_with(&suffix) && n.ends_with(".snap"))
                .unwrap_or(false)
        })
        .collect();
    snaps.sort();
    if snaps.len() > keep {
        for old in &snaps[..snaps.len() - keep] {
            let _ = std::fs::remove_file(old); // best-effort
        }
    }
    Ok(())
}

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

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

    #[test]
    fn take_and_list_snapshots() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("test.vault");
        std::fs::write(&vault_path, b"vault-content-v1").unwrap();

        temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            take(&vault_path, "test", DEFAULT_SNAPSHOT_KEEP).unwrap();
            let snaps = list("test").unwrap();
            assert_eq!(snaps.len(), 1);
        });
    }

    #[test]
    fn same_timestamp_creates_distinct_snapshot_names() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("test.vault");
        std::fs::write(&vault_path, b"vault-content-v1").unwrap();

        let ts = 1_744_000_000_000;
        let (first, second) = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            let first =
                take_at_timestamp_millis(&vault_path, "test", DEFAULT_SNAPSHOT_KEEP, ts).unwrap();
            let second =
                take_at_timestamp_millis(&vault_path, "test", DEFAULT_SNAPSHOT_KEEP, ts).unwrap();
            (first, second)
        });

        assert_ne!(first, second);
        assert_eq!(
            first.file_name().and_then(|n| n.to_str()),
            Some("test.vault.1744000000000.0000.snap")
        );
        assert_eq!(
            second.file_name().and_then(|n| n.to_str()),
            Some("test.vault.1744000000000.0001.snap")
        );
    }

    #[test]
    fn restore_latest_roundtrip() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("restore.vault");
        std::fs::write(&vault_path, b"original-content").unwrap();

        temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            take(&vault_path, "restore", DEFAULT_SNAPSHOT_KEEP).unwrap();
            // Corrupt the vault.
            std::fs::write(&vault_path, b"corrupted!").unwrap();
            restore_latest(&vault_path, "restore").unwrap();
            let recovered = std::fs::read(&vault_path).unwrap();
            assert_eq!(recovered, b"original-content");
        });
    }

    #[test]
    fn restore_latest_prefers_highest_sequence_for_same_timestamp() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("restore-seq.vault");
        let ts = 1_744_000_000_000;

        temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            std::fs::write(&vault_path, b"snapshot-a").unwrap();
            let first =
                take_at_timestamp_millis(&vault_path, "restore-seq", DEFAULT_SNAPSHOT_KEEP, ts)
                    .unwrap();

            std::fs::write(&vault_path, b"snapshot-b").unwrap();
            let second =
                take_at_timestamp_millis(&vault_path, "restore-seq", DEFAULT_SNAPSHOT_KEEP, ts)
                    .unwrap();

            assert!(first < second);

            std::fs::write(&vault_path, b"corrupted").unwrap();
            let restored = restore_latest(&vault_path, "restore-seq").unwrap();
            let recovered = std::fs::read(&vault_path).unwrap();

            assert_eq!(restored, second);
            assert_eq!(recovered, b"snapshot-b");
        });
    }

    #[test]
    fn prune_keeps_n_snapshots() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("prune.vault");
        std::fs::write(&vault_path, b"data").unwrap();

        temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            for _ in 0..5 {
                // Sleep 10ms to ensure distinct timestamps.
                std::thread::sleep(std::time::Duration::from_millis(10));
                take(&vault_path, "prune", 3).unwrap();
            }
            let snaps = list("prune").unwrap();
            assert!(
                snaps.len() <= 3,
                "expected ≤3 snapshots, got {}",
                snaps.len()
            );
        });
    }

    #[test]
    fn restore_missing_snapshot_errors() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("v.vault");
        let snap_path = tmp.path().join("ghost.snap");
        let err = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            restore(&vault_path, &snap_path).unwrap_err()
        });
        assert!(matches!(err, SafeError::SnapshotNotFound { .. }));
    }

    // ── Task 1.3: export ─────────────────────────────────────────────────────

    #[test]
    fn export_copies_snapshot_to_destination() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("export.vault");
        std::fs::write(&vault_path, b"original-vault-bytes").unwrap();

        temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            let snap = take(&vault_path, "export", DEFAULT_SNAPSHOT_KEEP).unwrap();

            let dest = tmp.path().join("backups").join("export-backup.snap");
            let exported = export(&snap, &dest).unwrap();

            assert_eq!(exported, dest);
            assert!(dest.exists());
            assert_eq!(
                std::fs::read(&dest).unwrap(),
                b"original-vault-bytes",
                "exported content must match the original snapshot"
            );
        });
    }

    #[test]
    fn export_missing_snapshot_returns_error() {
        let tmp = tempdir().unwrap();
        let ghost = tmp.path().join("ghost.snap");
        let dest = tmp.path().join("out.snap");
        let err = export(&ghost, &dest).unwrap_err();
        assert!(matches!(err, SafeError::SnapshotNotFound { .. }));
        assert!(!dest.exists());
    }

    #[test]
    fn export_is_atomic_and_does_not_leave_partial_files_on_simulated_overwrite() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("atomic-export.vault");
        std::fs::write(&vault_path, b"vault-v1").unwrap();

        temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            let snap = take(&vault_path, "atomic-export", DEFAULT_SNAPSHOT_KEEP).unwrap();
            let dest = tmp.path().join("dest.snap");

            // First export succeeds.
            export(&snap, &dest).unwrap();
            assert_eq!(std::fs::read(&dest).unwrap(), b"vault-v1");

            // Overwrite with updated content succeeds (rename is atomic).
            std::fs::write(&vault_path, b"vault-v2").unwrap();
            let snap2 = take(&vault_path, "atomic-export", DEFAULT_SNAPSHOT_KEEP).unwrap();
            export(&snap2, &dest).unwrap();
            assert_eq!(std::fs::read(&dest).unwrap(), b"vault-v2");
        });
    }

    // ── Task 1.3: configurable retention ────────────────────────────────────

    #[test]
    fn configurable_retention_keeps_exactly_n_snapshots() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("ret.vault");
        std::fs::write(&vault_path, b"data").unwrap();

        temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            // Write 7 snapshots with keep=3.
            for i in 0..7_u64 {
                std::thread::sleep(std::time::Duration::from_millis(5));
                // Vary content so filenames differ (timestamps are distinct).
                std::fs::write(&vault_path, format!("v{i}").as_bytes()).unwrap();
                take(&vault_path, "ret", 3).unwrap();
            }
            let snaps = list("ret").unwrap();
            assert_eq!(snaps.len(), 3, "expected exactly 3 snapshots after keep=3");
            // The 3 most recent (highest index) must survive.
            for snap in &snaps {
                let content = std::fs::read_to_string(snap).unwrap();
                assert!(
                    content.as_str() >= "v4",
                    "only the 3 most recent snapshots should survive, got: {content}"
                );
            }
        });
    }

    #[test]
    fn retention_of_zero_removes_all_snapshots() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("zero-ret.vault");
        std::fs::write(&vault_path, b"data").unwrap();

        temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            take(&vault_path, "zero-ret", DEFAULT_SNAPSHOT_KEEP).unwrap();
            take(&vault_path, "zero-ret", 0).unwrap();
            let snaps = list("zero-ret").unwrap();
            // After keep=0 the prune removes all existing snapshots including
            // the one just written (the new snap is counted then pruned).
            // Behaviour: the newly written snap itself may be pruned.
            // We accept 0 or 1 (implementation-detail of whether the new snap
            // is written before or after the prune call — both are valid).
            assert!(
                snaps.len() <= 1,
                "keep=0 should remove all but at most the just-written snap; got {}",
                snaps.len()
            );
        });
    }

    // ── Task 1.3: timestamp-targeted diff ───────────────────────────────────

    /// Build a minimal vault JSON string with the given secret keys.
    fn make_vault_json(keys: &[&str]) -> String {
        let secrets: serde_json::Value = serde_json::Value::Object(
            keys.iter()
                .map(|k| {
                    (
                        k.to_string(),
                        serde_json::json!({
                            "nonce": "abc",
                            "ciphertext": format!("ct-{k}"),
                            "created_at": "2026-01-01T00:00:00Z",
                            "updated_at": "2026-01-01T00:00:00Z"
                        }),
                    )
                })
                .collect(),
        );
        serde_json::json!({
            "_schema": "tsafe/vault/v1",
            "kdf": { "algorithm": "argon2id", "m_cost": 65536, "t_cost": 3, "p_cost": 4, "salt": "abc" },
            "cipher": "xchacha20poly1305",
            "vault_challenge": { "nonce": "abc", "ciphertext": "abc" },
            "created_at": "2026-01-01T00:00:00Z",
            "updated_at": "2026-01-01T00:00:00Z",
            "secrets": secrets
        })
        .to_string()
    }

    #[test]
    fn diff_at_detects_added_removed_and_changed_keys() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("diff.vault");

        // Snapshot state: A and B exist.
        std::fs::write(&vault_path, make_vault_json(&["A", "B"])).unwrap();

        let ts_snap = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            let ts = Utc::now().timestamp_millis();
            take_at_timestamp_millis(&vault_path, "diff", DEFAULT_SNAPSHOT_KEEP, ts).unwrap();
            ts
        });

        // Current vault: A (changed ciphertext), C added, B removed.
        let current_json = serde_json::json!({
            "_schema": "tsafe/vault/v1",
            "kdf": { "algorithm": "argon2id", "m_cost": 65536, "t_cost": 3, "p_cost": 4, "salt": "abc" },
            "cipher": "xchacha20poly1305",
            "vault_challenge": { "nonce": "abc", "ciphertext": "abc" },
            "created_at": "2026-01-01T00:00:00Z",
            "updated_at": "2026-01-01T00:01:00Z",
            "secrets": {
                "A": { "nonce": "abc", "ciphertext": "ct-A-updated", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:01:00Z" },
                "C": { "nonce": "abc", "ciphertext": "ct-C", "created_at": "2026-01-01T00:01:00Z", "updated_at": "2026-01-01T00:01:00Z" }
            }
        })
        .to_string();
        std::fs::write(&vault_path, &current_json).unwrap();

        temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            let diff = diff_at(&vault_path, "diff", ts_snap).unwrap();

            assert!(
                diff.contains(&SnapDiffEntry::Removed("B".to_string())),
                "B should be removed: {diff:?}"
            );
            assert!(
                diff.contains(&SnapDiffEntry::Added("C".to_string())),
                "C should be added: {diff:?}"
            );
            assert!(
                diff.contains(&SnapDiffEntry::Changed("A".to_string())),
                "A should be changed: {diff:?}"
            );
            assert_eq!(diff.len(), 3, "expected exactly 3 diff entries: {diff:?}");
        });
    }

    #[test]
    fn diff_at_no_changes_returns_empty() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("nodiff.vault");
        std::fs::write(&vault_path, make_vault_json(&["A", "B"])).unwrap();

        temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            let ts = Utc::now().timestamp_millis();
            take_at_timestamp_millis(&vault_path, "nodiff", DEFAULT_SNAPSHOT_KEEP, ts).unwrap();

            // No changes to vault.
            let diff = diff_at(&vault_path, "nodiff", ts).unwrap();
            assert!(
                diff.is_empty(),
                "identical vault should have no diff: {diff:?}"
            );
        });
    }

    #[test]
    fn diff_at_returns_error_when_no_snapshot_at_or_before_timestamp() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("early.vault");
        std::fs::write(&vault_path, make_vault_json(&["A"])).unwrap();

        temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            // Take a snapshot at a "future" timestamp.
            take_at_timestamp_millis(
                &vault_path,
                "early",
                DEFAULT_SNAPSHOT_KEEP,
                9_999_999_999_999,
            )
            .unwrap();

            // Requesting diff before that timestamp should return NoSnapshotAvailable.
            let err = diff_at(&vault_path, "early", 0).unwrap_err();
            assert!(
                matches!(err, SafeError::NoSnapshotAvailable { .. }),
                "expected NoSnapshotAvailable, got {err:?}"
            );
        });
    }

    #[test]
    fn diff_latest_returns_added_keys() {
        let tmp = tempdir().unwrap();
        let vault_path = tmp.path().join("dl.vault");
        std::fs::write(&vault_path, make_vault_json(&["EXISTING"])).unwrap();

        temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            take(&vault_path, "dl", DEFAULT_SNAPSHOT_KEEP).unwrap();

            // Add a new key to the current vault.
            std::fs::write(&vault_path, make_vault_json(&["EXISTING", "NEW"])).unwrap();

            let diff = diff_latest(&vault_path, "dl").unwrap();
            assert!(
                diff.contains(&SnapDiffEntry::Added("NEW".to_string())),
                "NEW should be in diff: {diff:?}"
            );
            assert_eq!(diff.len(), 1);
        });
    }

    #[test]
    fn snapshot_ts_millis_parses_correctly() {
        use std::path::PathBuf;
        let p = PathBuf::from("myprofile.vault.1744000000000.0000.snap");
        assert_eq!(snapshot_ts_millis(&p), Some(1_744_000_000_000));
        let p2 = PathBuf::from("myprofile.vault.99.0001.snap");
        assert_eq!(snapshot_ts_millis(&p2), Some(99));
        let bad = PathBuf::from("notasnap.txt");
        assert_eq!(snapshot_ts_millis(&bad), None);
    }
}
