//! Integration tests for vault locking posture, integrity boundary, and
//! snapshot rename continuity — covering tasks 1.1, 1.2, and 1.4 of the
//! whole-product maturity program.
//!
//! # Task 1.1 — Vault locking posture (advisory, documented ceiling)
//!
//! tsafe uses an advisory lock file (`<profile>.vault.lock`) to prevent
//! concurrent access.  This is **not** a kernel-enforced `flock(2)` /
//! `LockFileEx` guard; it is a best-effort exclusion mechanism that:
//!
//! - Prevents two processes from opening the same vault simultaneously.
//! - Recovers stale lock files when the owning PID is no longer running.
//! - Writes atomically (temp file → rename) to prevent partial-write corruption.
//!
//! This is the accepted custody ceiling for the local-first single-user-per-profile
//! scenario.  It is NOT a claim of:
//!
//! - Kernel-enforced exclusive access (no `flock`/`LockFileEx`).
//! - Network-share-safe coordination (NFS/SMB are explicitly out of scope).
//! - Multi-host or multi-user concurrent write safety.
//!
//! See `docs/decisions/vault-locking-and-integrity-boundary.md` for the
//! accepted decision record.
//!
//! # Task 1.2 — Whole-file integrity boundary (hardened non-goal)
//!
//! The current integrity contract is:
//!
//! - **Per-secret AEAD** — each secret's ciphertext is individually authenticated
//!   by XChaCha20-Poly1305 (or AES-256-GCM in FIPS mode).  Any bit flip in a
//!   ciphertext is detected and rejected on decrypt.
//!
//! - **Vault-key challenge** — a known plaintext (`tsafe-vault-challenge-v1`) is
//!   encrypted under the same vault key.  A wrong password or a swapped vault
//!   file is detected at open time before any secret is accessed.
//!
//! What the current contract does **not** cover:
//!
//! - Whole-file HMAC or signature over the full JSON envelope.
//! - Detection of envelope-level edits such as entry reordering, row deletion,
//!   or rollback to an older valid vault file encrypted under the same key.
//! - Anti-replay / monotonic-version proof.
//!
//! This boundary is explicitly accepted in
//! `docs/decisions/vault-locking-and-integrity-boundary.md` and
//! `docs/decisions/vault-custody-residuals.md`.
//!
//! # Task 1.4 — Snapshot rename continuity
//!
//! When a profile is renamed, its snapshot history is migrated with it via
//! `profile::rename_profile_snapshot_history`.  After migration:
//!
//! - Old snapshot files are moved to the destination profile's snapshot dir.
//! - Snapshot filenames are re-prefixed so `snapshot::list` returns them under
//!   the new profile name.
//! - The old snapshot directory is removed.
//! - Subsequent `Vault::save` calls on the new profile name take new snapshots
//!   correctly in the new location.

use std::collections::HashMap;

use tempfile::tempdir;
use tsafe_core::errors::SafeError;
use tsafe_core::profile::{rename_profile_snapshot_history, vault_path};
use tsafe_core::snapshot;
use tsafe_core::vault::Vault;

const PW: &[u8] = b"test-locking-integrity-pw";

// ── Task 1.1: vault locking posture documentation tests ─────────────────────

/// Advisory lock: a second open of the same vault fails while the first holds
/// the lock.  This is the primary locking guarantee — concurrent access is
/// rejected before any I/O occurs.
///
/// This test documents that the lock is **advisory** (implemented via a lock
/// file, not a kernel descriptor lock).  The guarantee holds within a single
/// host under normal conditions.
#[test]
fn task1_1_advisory_lock_rejects_concurrent_open() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("locking.vault");
    let _v = Vault::create(&path, PW).unwrap();

    // A second open while the first handle is held must fail.
    match Vault::open(&path, PW) {
        Err(SafeError::InvalidVault { reason }) => {
            assert!(
                reason.contains("vault is locked by another process"),
                "unexpected lock reason: {reason}"
            );
        }
        Ok(_) => panic!("advisory lock must reject concurrent open"),
        Err(e) => panic!("expected InvalidVault lock error, got {e:?}"),
    }
}

/// Advisory lock: after the owning handle is dropped the lock file is removed
/// and a subsequent open succeeds.
///
/// This proves lock release on drop is working — the locking mechanism does
/// not permanently block access.
#[test]
fn task1_1_advisory_lock_releases_on_drop() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("release.vault");
    {
        let mut v = Vault::create(&path, PW).unwrap();
        v.set("K", "v", HashMap::new()).unwrap();
        // Lock is held here — second open must fail.
        assert!(Vault::open(&path, PW).is_err());
    } // lock released via Drop

    // After drop, a fresh open must succeed.
    let v2 = Vault::open(&path, PW).unwrap();
    assert_eq!(&*v2.get("K").unwrap(), "v");
}

/// Stale lock recovery: when the owning PID is no longer running, the advisory
/// lock can be reclaimed without manual intervention.
///
/// This tests the dead-lock recovery path that makes the advisory lock safe
/// for crash recovery scenarios.
#[test]
fn task1_1_stale_advisory_lock_is_recovered_from_dead_pid() {
    use tsafe_core::vault::Vault;

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

    // Create the vault first so it exists on disk.
    {
        let _v = Vault::create(&path, PW).unwrap();
    }

    // Manually write a lock file with a dead PID (u32::MAX is always invalid).
    let lock_path = path.with_extension("vault.lock");
    let stale_lock = serde_json::json!({
        "version": 1,
        "id": "stale-test-id",
        "pid": u32::MAX,
        "created_at": "2026-01-01T00:00:00Z"
    });
    std::fs::write(&lock_path, stale_lock.to_string()).unwrap();
    assert!(lock_path.exists(), "stale lock file must exist");

    // Opening the vault must succeed by recovering the dead lock.
    let v =
        Vault::open(&path, PW).expect("open must succeed after recovering a stale advisory lock");
    assert_eq!(v.secret_count(), 0);
    drop(v);

    // The lock file should be gone after the handle is dropped.
    assert!(
        !lock_path.exists(),
        "lock file must be released after vault handle is dropped"
    );
}

/// Documents the advisory-only ceiling: two separate processes that race
/// between the lock existence check and the creation step may both succeed
/// if they land in the exact same millisecond window before either writes the
/// lock file.  This is an explicit, accepted residual risk — not a bug.
///
/// This test is intentionally written as a documentation assertion rather than
/// a race-reproduction test (which would be flaky) to make the accepted
/// boundary explicit in the test suite.
#[test]
fn task1_1_documents_accepted_advisory_ceiling() {
    // The following facts are the accepted contract for this boundary.
    // These assertions serve as machine-verifiable documentation.

    // 1. The lock is implemented as a file, not a kernel descriptor.
    //    Evidence: lock file path uses `.vault.lock` extension.
    let dir = tempdir().unwrap();
    let path = dir.path().join("v.vault");
    let _v = Vault::create(&path, PW).unwrap();
    let lock_path = path.with_extension("vault.lock");
    assert!(
        lock_path.exists(),
        "advisory lock must be a file on disk (not a kernel descriptor)"
    );

    // 2. The lock file contains a structured JSON payload with PID and ID.
    let contents = std::fs::read_to_string(&lock_path).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
    assert!(
        parsed.get("pid").is_some(),
        "lock file must contain PID for stale-lock recovery"
    );
    assert!(
        parsed.get("id").is_some(),
        "lock file must contain unique ID to detect lock theft"
    );

    // 3. Accepted residuals (not asserted, but documented here for clarity):
    //    - No flock(2)/LockFileEx kernel handle is held.
    //    - Network share (NFS/SMB) safety is explicitly out of scope.
    //    - Multi-host concurrent write safety is out of scope.
    //    See: docs/decisions/vault-locking-and-integrity-boundary.md
}

// ── Task 1.2: vault-key authentication tests ────────────────────────────────

/// The vault-challenge mechanism detects a wrong password at open time,
/// before any secret is accessed.  This is the core per-key-authentication
/// guarantee.
#[test]
fn task1_2_wrong_password_fails_at_challenge() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("challenge.vault");
    let mut v = Vault::create(&path, PW).unwrap();
    v.set("SECRET", "value", HashMap::new()).unwrap();
    drop(v);

    let result = Vault::open(&path, b"wrong-password");
    assert!(
        matches!(result, Err(SafeError::DecryptionFailed)),
        "wrong password must fail with DecryptionFailed at challenge verification"
    );
}

/// The vault-challenge mechanism detects a swapped vault file: opening a
/// valid vault file with the wrong (but valid for another vault) password
/// fails.
///
/// This proves that the challenge is tied to the specific vault key, not just
/// that AEAD is used somewhere.
#[test]
fn task1_2_vault_challenge_detects_wrong_key_for_this_vault() {
    let dir = tempdir().unwrap();
    let path_a = dir.path().join("vault-a.vault");
    let path_b = dir.path().join("vault-b.vault");

    let pw_a = b"password-for-vault-a";
    let pw_b = b"password-for-vault-b";

    Vault::create(&path_a, pw_a).unwrap();
    Vault::create(&path_b, pw_b).unwrap();

    drop(Vault::open(&path_a, pw_a).unwrap());
    drop(Vault::open(&path_b, pw_b).unwrap());

    // Try to open vault-b using vault-a's password → must fail.
    let result = Vault::open(&path_b, pw_a);
    assert!(
        matches!(result, Err(SafeError::DecryptionFailed)),
        "vault-key challenge must reject a valid password that belongs to a different vault"
    );

    // And vice versa.
    let result = Vault::open(&path_a, pw_b);
    assert!(
        matches!(result, Err(SafeError::DecryptionFailed)),
        "vault-key challenge must reject a valid password that belongs to a different vault"
    );
}

/// Documents the current integrity ceiling: the vault-challenge verifies the
/// master key but does NOT protect against whole-file envelope manipulation
/// by an attacker with write access to the vault file.
///
/// This test is a documentation assertion proving the accepted non-goal
/// boundary.  The per-secret AEAD + challenge is the current mature contract;
/// whole-file HMAC is a post-v1 hardening item.
///
/// See: docs/decisions/vault-locking-and-integrity-boundary.md
///      docs/decisions/vault-custody-residuals.md
#[test]
fn task1_2_documents_per_secret_aead_plus_challenge_is_integrity_contract() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("integrity-boundary.vault");
    let mut v = Vault::create(&path, PW).unwrap();
    v.set("A", "alpha", HashMap::new()).unwrap();
    v.set("B", "beta", HashMap::new()).unwrap();
    drop(v);

    // Open and verify the vault works correctly.
    let v2 = Vault::open(&path, PW).unwrap();
    assert_eq!(&*v2.get("A").unwrap(), "alpha");
    assert_eq!(&*v2.get("B").unwrap(), "beta");
    drop(v2);

    // Current contract: per-secret AEAD + challenge.
    // Read the raw vault JSON and verify structural properties.
    let json = std::fs::read_to_string(&path).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();

    // The vault has a challenge ciphertext.
    assert!(
        parsed.get("vault_challenge").is_some(),
        "vault must contain a vault_challenge for key authentication"
    );

    // Each secret has its own nonce and ciphertext (per-secret AEAD).
    let secrets = parsed.get("secrets").and_then(|s| s.as_object()).unwrap();
    for (_key, entry) in secrets {
        assert!(
            entry.get("nonce").is_some() && entry.get("ciphertext").is_some(),
            "each secret must have its own nonce and ciphertext (per-secret AEAD)"
        );
    }

    // The vault does NOT have a whole-file MAC or signature field.
    // This is the documented accepted non-goal for the current contract.
    assert!(
        parsed.get("file_mac").is_none() && parsed.get("file_hmac").is_none(),
        "whole-file MAC is not part of the current contract (post-v1 hardening)"
    );
}

/// Verify that each secret is encrypted with a unique nonce (no nonce reuse).
/// This is a basic property of the per-secret AEAD contract.
#[test]
fn task1_2_per_secret_aead_uses_unique_nonces() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("nonce-unique.vault");
    let mut v = Vault::create(&path, PW).unwrap();

    for i in 0..5 {
        v.set(&format!("K{i}"), &format!("value{i}"), HashMap::new())
            .unwrap();
    }
    drop(v);

    let json = std::fs::read_to_string(&path).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
    let secrets = parsed.get("secrets").and_then(|s| s.as_object()).unwrap();

    let nonces: Vec<&str> = secrets
        .values()
        .map(|e| e.get("nonce").and_then(|n| n.as_str()).unwrap())
        .collect();

    let mut seen = std::collections::HashSet::new();
    for nonce in &nonces {
        assert!(
            seen.insert(*nonce),
            "duplicate nonce detected — each secret must use a unique nonce: {nonce}"
        );
    }
}

// ── Task 1.4: snapshot rename continuity ────────────────────────────────────

/// When a profile is renamed, its snapshot history is migrated so that
/// subsequent snapshot operations work correctly under the new name.
///
/// This test proves the full rename continuity guarantee:
/// 1. Existing snapshots are moved and re-prefixed.
/// 2. `snapshot::list` returns snapshots under the new name.
/// 3. The old snapshot directory is removed.
/// 4. A new `Vault::save` on the renamed profile creates snapshots under the
///    new name (not the old one).
#[test]
fn task1_4_snapshot_history_migrates_on_profile_rename() {
    let dir = tempdir().unwrap();
    let vaults = dir.path().join("vaults");

    temp_env::with_var(
        "TSAFE_VAULT_DIR",
        Some(vaults.as_os_str().to_str().unwrap()),
        || {
            // Create a vault under profile "oldname" and write some secrets.
            let old_path = vault_path("oldname");
            std::fs::create_dir_all(old_path.parent().unwrap()).unwrap();
            let mut v = Vault::create(&old_path, PW).unwrap();
            v.set("K1", "val1", HashMap::new()).unwrap();
            v.set("K2", "val2", HashMap::new()).unwrap();
            drop(v);

            // Snapshots should exist for "oldname" now.
            let old_snaps = snapshot::list("oldname").unwrap();
            assert!(
                !old_snaps.is_empty(),
                "snapshots must exist for 'oldname' after vault writes"
            );

            // Simulate a profile rename: migrate snapshot history first.
            let migrated = rename_profile_snapshot_history("oldname", "newname").unwrap();
            assert!(
                migrated,
                "migration must return true when snapshots existed"
            );

            // Old snapshot dir must be gone.
            let old_snap_dir = snapshot::snapshot_dir("oldname");
            assert!(
                !old_snap_dir.exists(),
                "old snapshot directory must be removed after migration"
            );

            // New snapshot dir must have the migrated snapshots.
            let new_snaps = snapshot::list("newname").unwrap();
            assert_eq!(
                new_snaps.len(),
                old_snaps.len(),
                "all snapshots must be migrated to the new profile"
            );

            // All migrated snapshots must use the new profile prefix.
            for snap in &new_snaps {
                let name = snap.file_name().unwrap().to_string_lossy();
                assert!(
                    name.starts_with("newname.vault."),
                    "migrated snapshot must use new profile prefix: {name}"
                );
            }

            // Old snapshots must no longer appear under the old name.
            assert!(
                snapshot::list("oldname").unwrap().is_empty(),
                "no snapshots should remain under 'oldname'"
            );
        },
    );
}

/// When a profile is renamed but has no snapshot history, the migration is a
/// no-op and the new profile name has no snapshots yet.
#[test]
fn task1_4_rename_with_no_snapshots_is_noop() {
    let dir = tempdir().unwrap();
    let vaults = dir.path().join("vaults");

    temp_env::with_var(
        "TSAFE_VAULT_DIR",
        Some(vaults.as_os_str().to_str().unwrap()),
        || {
            let migrated = rename_profile_snapshot_history("ghost-profile", "new-ghost").unwrap();
            assert!(
                !migrated,
                "migration must return false when source has no snapshot directory"
            );
            assert!(
                snapshot::list("new-ghost").unwrap().is_empty(),
                "new profile must have no snapshots when source had none"
            );
        },
    );
}

/// After renaming, a vault saved under the new profile name creates snapshots
/// in the new profile's snapshot directory (not the old one).
#[test]
fn task1_4_new_snapshots_after_rename_use_new_profile_name() {
    let dir = tempdir().unwrap();
    let vaults = dir.path().join("vaults");

    temp_env::with_var(
        "TSAFE_VAULT_DIR",
        Some(vaults.as_os_str().to_str().unwrap()),
        || {
            // Create vault under "src-profile".
            let src_path = vault_path("src-profile");
            std::fs::create_dir_all(src_path.parent().unwrap()).unwrap();
            {
                let mut v = Vault::create(&src_path, PW).unwrap();
                v.set("INIT", "value", HashMap::new()).unwrap();
            }

            // Migrate snapshots.
            rename_profile_snapshot_history("src-profile", "dst-profile").unwrap();

            // Simulate vault rename: move the vault file.
            let dst_path = vault_path("dst-profile");
            std::fs::rename(&src_path, &dst_path).unwrap();

            // Open and write to the vault under the new name.
            let mut v = Vault::open(&dst_path, PW).unwrap();
            v.set("AFTER_RENAME", "new-value", HashMap::new()).unwrap();
            drop(v);

            // New snapshots must be under "dst-profile".
            let new_snaps = snapshot::list("dst-profile").unwrap();
            assert!(
                !new_snaps.is_empty(),
                "snapshots must exist under the new profile name"
            );
            for snap in &new_snaps {
                let name = snap.file_name().unwrap().to_string_lossy();
                assert!(
                    name.starts_with("dst-profile.vault."),
                    "all snapshots must use the new profile prefix: {name}"
                );
            }

            // No snapshots should exist under the old name.
            assert!(
                snapshot::list("src-profile").unwrap().is_empty(),
                "no snapshots should remain under the old profile name"
            );
        },
    );
}

/// Rename is rejected when the destination profile already has a snapshot
/// directory, preventing accidental history loss.
#[test]
fn task1_4_rename_rejected_when_destination_has_existing_snapshots() {
    let dir = tempdir().unwrap();
    let vaults = dir.path().join("vaults");

    temp_env::with_var(
        "TSAFE_VAULT_DIR",
        Some(vaults.as_os_str().to_str().unwrap()),
        || {
            // Create snapshot directories for both source and destination.
            std::fs::create_dir_all(snapshot::snapshot_dir("from")).unwrap();
            std::fs::create_dir_all(snapshot::snapshot_dir("to")).unwrap();

            let err = rename_profile_snapshot_history("from", "to").unwrap_err();
            assert!(
                matches!(err, SafeError::InvalidVault { .. }),
                "must reject rename when destination snapshot dir already exists: {err:?}"
            );
        },
    );
}
