//! Tests for `CacheLock`.

use std::thread::JoinHandle;

use crate::prelude::*;
use cargo::util::cache_lock::{CacheLockMode, CacheLocker};
use cargo_test_support::paths;
use cargo_test_support::{retry, thread_wait_timeout, threaded_timeout};

use crate::config::GlobalContextBuilder;

/// Helper to verify that it is OK to acquire the given lock (it shouldn't block).
fn verify_lock_is_ok(mode: CacheLockMode) {
    let root = paths::root();
    threaded_timeout(100, move || {
        let gctx = GlobalContextBuilder::new().root(root).build();
        let locker = CacheLocker::new();
        // This would block if it is held.
        let _lock = locker.lock(&gctx, mode).unwrap();
        assert!(locker.is_locked(mode));
    });
}

/// Helper to acquire two locks from the same locker.
fn a_b_nested(a: CacheLockMode, b: CacheLockMode) {
    let gctx = GlobalContextBuilder::new().build();
    let locker = CacheLocker::new();
    let lock1 = locker.lock(&gctx, a).unwrap();
    assert!(locker.is_locked(a));
    let lock2 = locker.lock(&gctx, b).unwrap();
    assert!(locker.is_locked(b));
    drop(lock2);
    drop(lock1);
    // Verify locks were unlocked.
    verify_lock_is_ok(CacheLockMode::Shared);
    verify_lock_is_ok(CacheLockMode::DownloadExclusive);
    verify_lock_is_ok(CacheLockMode::MutateExclusive);
}

/// Helper to acquire two locks from separate lockers, verifying that they
/// don't block each other.
fn a_then_b_separate_not_blocked(a: CacheLockMode, b: CacheLockMode, verify: CacheLockMode) {
    let gctx = GlobalContextBuilder::new().build();
    let locker1 = CacheLocker::new();
    let lock1 = locker1.lock(&gctx, a).unwrap();
    assert!(locker1.is_locked(a));
    let locker2 = CacheLocker::new();
    let lock2 = locker2.lock(&gctx, b).unwrap();
    assert!(locker2.is_locked(b));
    let thread = verify_lock_would_block(verify);
    // Unblock the thread.
    drop(lock1);
    drop(lock2);
    // Verify the thread is unblocked.
    thread_wait_timeout::<()>(100, thread);
}

/// Helper to acquire two locks from separate lockers, verifying that the
/// second one blocks.
fn a_then_b_separate_blocked(a: CacheLockMode, b: CacheLockMode) {
    let gctx = GlobalContextBuilder::new().build();
    let locker = CacheLocker::new();
    let lock = locker.lock(&gctx, a).unwrap();
    assert!(locker.is_locked(a));
    let thread = verify_lock_would_block(b);
    // Unblock the thread.
    drop(lock);
    // Verify the thread is unblocked.
    thread_wait_timeout::<()>(100, thread);
}

/// Helper to verify that acquiring the given mode would block.
///
/// Always call `thread_wait_timeout` on the result.
#[must_use]
fn verify_lock_would_block(mode: CacheLockMode) -> JoinHandle<()> {
    let root = paths::root();
    // Spawn a thread that will block on the lock.
    let thread = std::thread::spawn(move || {
        let gctx = GlobalContextBuilder::new().root(root).build();
        let locker2 = CacheLocker::new();
        let lock2 = locker2.lock(&gctx, mode).unwrap();
        assert!(locker2.is_locked(mode));
        drop(lock2);
    });
    // Verify that it blocked.
    retry(100, || {
        if let Ok(s) = std::fs::read_to_string(paths::root().join("shell.out")) {
            if s.trim().starts_with("Blocking waiting for file lock on") {
                return Some(());
            } else {
                eprintln!("unexpected output: {s}");
                // Try again, it might have been partially written.
            }
        }
        None
    });
    thread
}

#[test]
fn new_is_unlocked() {
    let locker = CacheLocker::new();
    assert!(!locker.is_locked(CacheLockMode::Shared));
    assert!(!locker.is_locked(CacheLockMode::DownloadExclusive));
    assert!(!locker.is_locked(CacheLockMode::MutateExclusive));
}

#[cargo_test]
fn multiple_shared() {
    // Test that two nested shared locks from the same locker are safe to acquire.
    a_b_nested(CacheLockMode::Shared, CacheLockMode::Shared);
}

#[cfg_attr(
    any(target_os = "aix", target_os = "solaris"),
    ignore = "Test fails on AIX due to unsupported flock behaviour and on Solaris due to process-scoped fcntl locks"
)]
#[cargo_test]
fn multiple_shared_separate() {
    // Test that two independent shared locks are safe to acquire at the same time.
    a_then_b_separate_not_blocked(
        CacheLockMode::Shared,
        CacheLockMode::Shared,
        CacheLockMode::MutateExclusive,
    );
}

#[cargo_test]
fn multiple_download() {
    // That that two nested download locks from the same locker are safe to acquire.
    a_b_nested(
        CacheLockMode::DownloadExclusive,
        CacheLockMode::DownloadExclusive,
    );
}

#[cargo_test]
fn multiple_mutate() {
    // That that two nested mutate locks from the same locker are safe to acquire.
    a_b_nested(
        CacheLockMode::MutateExclusive,
        CacheLockMode::MutateExclusive,
    );
}

#[cargo_test]
#[should_panic(expected = "lock is not allowed")]
fn download_then_shared() {
    // This sequence is not supported.
    a_b_nested(CacheLockMode::DownloadExclusive, CacheLockMode::Shared);
}

#[cargo_test]
#[should_panic(expected = "lock upgrade from shared to exclusive not supported")]
fn shared_then_mutate() {
    // This sequence is not supported.
    a_b_nested(CacheLockMode::Shared, CacheLockMode::MutateExclusive);
}

#[cargo_test]
fn shared_then_download() {
    a_b_nested(CacheLockMode::Shared, CacheLockMode::DownloadExclusive);
    // Verify drop actually unlocked.
    verify_lock_is_ok(CacheLockMode::DownloadExclusive);
    verify_lock_is_ok(CacheLockMode::MutateExclusive);
}

#[cargo_test]
fn mutate_then_shared() {
    a_b_nested(CacheLockMode::MutateExclusive, CacheLockMode::Shared);
    // Verify drop actually unlocked.
    verify_lock_is_ok(CacheLockMode::MutateExclusive);
}

#[cargo_test]
fn download_then_mutate() {
    a_b_nested(
        CacheLockMode::DownloadExclusive,
        CacheLockMode::MutateExclusive,
    );
    // Verify drop actually unlocked.
    verify_lock_is_ok(CacheLockMode::DownloadExclusive);
    verify_lock_is_ok(CacheLockMode::MutateExclusive);
}

#[cargo_test]
fn mutate_then_download() {
    a_b_nested(
        CacheLockMode::MutateExclusive,
        CacheLockMode::DownloadExclusive,
    );
    // Verify drop actually unlocked.
    verify_lock_is_ok(CacheLockMode::MutateExclusive);
    verify_lock_is_ok(CacheLockMode::DownloadExclusive);
}

#[cargo_test]
fn readonly() {
    // In a permission denied situation, it should still allow a lock. It just
    // silently behaves as-if it was locked.
    let cargo_home = paths::home().join(".cargo");
    std::fs::create_dir_all(&cargo_home).unwrap();
    let mut perms = std::fs::metadata(&cargo_home).unwrap().permissions();
    perms.set_readonly(true);
    std::fs::set_permissions(&cargo_home, perms).unwrap();
    let gctx = GlobalContextBuilder::new().build();
    let locker = CacheLocker::new();
    for mode in [
        CacheLockMode::Shared,
        CacheLockMode::DownloadExclusive,
        CacheLockMode::MutateExclusive,
    ] {
        let _lock1 = locker.lock(&gctx, mode).unwrap();
        // Make sure it can recursively acquire the lock, too.
        let _lock2 = locker.lock(&gctx, mode).unwrap();
    }
}

#[cfg_attr(
    any(target_os = "aix", target_os = "solaris"),
    ignore = "Test fails on AIX due to unsupported flock behaviour and on Solaris due to process-scoped fcntl locks"
)]
#[cargo_test]
fn download_then_shared_separate() {
    a_then_b_separate_not_blocked(
        CacheLockMode::DownloadExclusive,
        CacheLockMode::Shared,
        CacheLockMode::MutateExclusive,
    );
}

#[cfg_attr(
    any(target_os = "aix", target_os = "solaris"),
    ignore = "Test fails on AIX due to unsupported flock behaviour and on Solaris due to process-scoped fcntl locks"
)]
#[cargo_test]
fn shared_then_download_separate() {
    a_then_b_separate_not_blocked(
        CacheLockMode::Shared,
        CacheLockMode::DownloadExclusive,
        CacheLockMode::MutateExclusive,
    );
}

#[cfg_attr(
    any(target_os = "aix", target_os = "solaris"),
    ignore = "Test fails on AIX due to unsupported flock behaviour and on Solaris due to process-scoped fcntl locks"
)]
#[cargo_test]
fn multiple_download_separate() {
    // Test that with two independent download locks, the second blocks until
    // the first is released.
    a_then_b_separate_blocked(
        CacheLockMode::DownloadExclusive,
        CacheLockMode::DownloadExclusive,
    );
}

#[cfg_attr(
    any(target_os = "aix", target_os = "solaris"),
    ignore = "Test fails on AIX due to unsupported flock behaviour and on Solaris due to process-scoped fcntl locks"
)]
#[cargo_test]
fn multiple_mutate_separate() {
    // Test that with two independent mutate locks, the second blocks until
    // the first is released.
    a_then_b_separate_blocked(
        CacheLockMode::MutateExclusive,
        CacheLockMode::MutateExclusive,
    );
}

#[cfg_attr(
    any(target_os = "aix", target_os = "solaris"),
    ignore = "Test fails on AIX due to unsupported flock behaviour and on Solaris due to process-scoped fcntl locks"
)]
#[cargo_test]
fn shared_then_mutate_separate() {
    a_then_b_separate_blocked(CacheLockMode::Shared, CacheLockMode::MutateExclusive);
}

#[cfg_attr(
    any(target_os = "aix", target_os = "solaris"),
    ignore = "Test fails on AIX due to unsupported flock behaviour and on Solaris due to process-scoped fcntl locks"
)]
#[cargo_test]
fn download_then_mutate_separate() {
    a_then_b_separate_blocked(
        CacheLockMode::DownloadExclusive,
        CacheLockMode::MutateExclusive,
    );
}

#[cfg_attr(
    any(target_os = "aix", target_os = "solaris"),
    ignore = "Test fails on AIX due to unsupported flock behaviour and on Solaris due to process-scoped fcntl locks"
)]
#[cargo_test]
fn mutate_then_download_separate() {
    a_then_b_separate_blocked(
        CacheLockMode::MutateExclusive,
        CacheLockMode::DownloadExclusive,
    );
}

#[cfg_attr(
    any(target_os = "aix", target_os = "solaris"),
    ignore = "Test fails on AIX due to unsupported flock behaviour and on Solaris due to process-scoped fcntl locks"
)]
#[cargo_test]
fn mutate_then_shared_separate() {
    a_then_b_separate_blocked(CacheLockMode::MutateExclusive, CacheLockMode::Shared);
}

#[cargo_test(ignore_windows = "no method to prevent creating or locking a file")]
fn mutate_err_is_atomic() {
    // Verifies that when getting a mutate lock, that if the first lock
    // succeeds, but the second one fails, that the first lock is released.
    let gctx = GlobalContextBuilder::new().build();
    let locker = CacheLocker::new();
    let cargo_home = gctx.home().as_path_unlocked();
    let cache_path = cargo_home.join(".package-cache");
    // This is a hacky way to force an error acquiring the download lock. By
    // making it a directory, it is unable to open it.
    // TODO: Unfortunately this doesn't work on Windows. I don't have any
    // ideas on how to simulate an error on Windows.
    cache_path.mkdir_p();
    match locker.lock(&gctx, CacheLockMode::MutateExclusive) {
        Ok(_) => panic!("did not expect lock to succeed"),
        Err(e) => {
            let msg = format!("{e:?}");
            assert!(msg.contains("failed to open:"), "{msg}");
        }
    }
    assert!(!locker.is_locked(CacheLockMode::MutateExclusive));
    assert!(!locker.is_locked(CacheLockMode::DownloadExclusive));
    assert!(!locker.is_locked(CacheLockMode::Shared));
    cache_path.rm_rf();
    verify_lock_is_ok(CacheLockMode::DownloadExclusive);
    verify_lock_is_ok(CacheLockMode::Shared);
    verify_lock_is_ok(CacheLockMode::MutateExclusive);
}
