//! Process-wide advisory lock for the effective IdeA app-data directory.

use std::fs::{self, File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};

use fs4::fs_std::FileExt;
use thiserror::Error;

const LOCK_FILE_NAME: &str = "idea.lock";

/// RAII guard holding the exclusive advisory lock on `<app_data_dir>/idea.lock`.
///
/// The lock is released when this value is dropped or the process exits.
#[derive(Debug)]
pub struct AppDataDirLock {
    lock_path: PathBuf,
    file: File,
}

impl AppDataDirLock {
    /// Path of the lock file currently held by this guard.
    #[must_use]
    pub fn lock_path(&self) -> &Path {
        &self.lock_path
    }
}

impl Drop for AppDataDirLock {
    fn drop(&mut self) {
        let _ = self.file.unlock();
    }
}

/// Error returned while acquiring the app-data lock.
#[derive(Debug, Error)]
pub enum AppDataDirLockError {
    /// Another process already holds the lock for this app-data directory.
    #[error("app-data lock already held: {lock_path}")]
    Contended {
        /// Lock file path.
        lock_path: PathBuf,
    },
    /// The lock file or its parent directory could not be accessed.
    #[error("failed to access app-data lock {lock_path}: {source}")]
    Io {
        /// Lock file path, or the intended path when directory creation failed.
        lock_path: PathBuf,
        /// Underlying I/O error.
        #[source]
        source: io::Error,
    },
}

/// Acquires an exclusive non-blocking advisory lock for `app_data_dir`.
///
/// The effective lock file is `<app_data_dir>/idea.lock`; different app-data
/// directories therefore do not block each other.
///
/// # Errors
/// [`AppDataDirLockError::Contended`] when another process already holds the
/// same lock, or [`AppDataDirLockError::Io`] for filesystem access failures.
pub fn acquire_app_data_dir_lock(
    app_data_dir: impl AsRef<Path>,
) -> Result<AppDataDirLock, AppDataDirLockError> {
    let app_data_dir = app_data_dir.as_ref();
    let lock_path = app_data_dir.join(LOCK_FILE_NAME);
    fs::create_dir_all(app_data_dir).map_err(|source| AppDataDirLockError::Io {
        lock_path: lock_path.clone(),
        source,
    })?;
    let file = OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .open(&lock_path)
        .map_err(|source| AppDataDirLockError::Io {
            lock_path: lock_path.clone(),
            source,
        })?;
    match file.try_lock_exclusive() {
        Ok(()) => Ok(AppDataDirLock { lock_path, file }),
        Err(source) if source.kind() == io::ErrorKind::WouldBlock => {
            Err(AppDataDirLockError::Contended { lock_path })
        }
        Err(source) => Err(AppDataDirLockError::Io { lock_path, source }),
    }
}

#[cfg(test)]
mod tests {
    use std::env;
    use std::process::{Command, Stdio};
    use std::thread;
    use std::time::{Duration, Instant};

    use super::*;

    struct TempDir(PathBuf);

    impl TempDir {
        fn new(label: &str) -> Self {
            let path =
                env::temp_dir().join(format!("idea-app-lock-{label}-{}", uuid::Uuid::new_v4()));
            fs::create_dir_all(&path).unwrap();
            Self(path)
        }

        fn path(&self) -> &Path {
            &self.0
        }
    }

    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.0);
        }
    }

    #[test]
    fn contention_on_same_path_is_reported() {
        let tmp = TempDir::new("same-path");
        let _guard = acquire_app_data_dir_lock(tmp.path()).unwrap();

        let err = acquire_app_data_dir_lock(tmp.path()).unwrap_err();

        assert!(matches!(err, AppDataDirLockError::Contended { .. }));
    }

    #[test]
    fn drop_releases_lock_and_allows_reacquire() {
        let tmp = TempDir::new("reacquire");
        let guard = acquire_app_data_dir_lock(tmp.path()).unwrap();
        drop(guard);

        let reacquired = acquire_app_data_dir_lock(tmp.path()).unwrap();

        assert_eq!(reacquired.lock_path(), tmp.path().join(LOCK_FILE_NAME));
    }

    #[test]
    fn different_app_data_dirs_can_coexist() {
        let a = TempDir::new("a");
        let b = TempDir::new("b");

        let lock_a = acquire_app_data_dir_lock(a.path()).unwrap();
        let lock_b = acquire_app_data_dir_lock(b.path()).unwrap();

        assert_ne!(lock_a.lock_path(), lock_b.lock_path());
    }

    #[test]
    fn child_process_holds_app_data_lock_until_killed() {
        let Some(dir) = env::var_os("IDEA_LOCK_TEST_CHILD_DIR") else {
            return;
        };
        let Some(ready) = env::var_os("IDEA_LOCK_TEST_CHILD_READY") else {
            return;
        };

        let _guard = acquire_app_data_dir_lock(PathBuf::from(dir)).unwrap();
        fs::write(ready, b"ready").unwrap();
        thread::sleep(Duration::from_secs(30));
    }

    #[test]
    fn second_os_process_fails_and_brutal_kill_releases_lock() {
        let tmp = TempDir::new("process");
        let ready = tmp.path().join("child-ready");
        let exe = env::current_exe().unwrap();
        let mut child = Command::new(exe)
            .arg("--exact")
            .arg("lock::tests::child_process_holds_app_data_lock_until_killed")
            .arg("--nocapture")
            .env("IDEA_LOCK_TEST_CHILD_DIR", tmp.path())
            .env("IDEA_LOCK_TEST_CHILD_READY", &ready)
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .unwrap();

        let deadline = Instant::now() + Duration::from_secs(5);
        while !ready.exists() && Instant::now() < deadline {
            thread::sleep(Duration::from_millis(20));
        }
        assert!(ready.exists(), "child did not acquire the lock in time");

        let err = acquire_app_data_dir_lock(tmp.path()).unwrap_err();
        assert!(matches!(err, AppDataDirLockError::Contended { .. }));

        child.kill().unwrap();
        let status = child.wait().unwrap();
        assert!(!status.success());

        let reacquired = acquire_app_data_dir_lock(tmp.path()).unwrap();
        assert_eq!(reacquired.lock_path(), tmp.path().join(LOCK_FILE_NAME));
    }
}
