//! Single-instance advisory lock for the TUI.
//!
//! Prevents concurrent `lore-tui` launches from corrupting state.
//! Uses an advisory lock file with PID. Stale locks (dead PID) are
//! automatically recovered.

use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

/// Advisory lock preventing concurrent TUI launches.
///
/// On `acquire()`, writes the current PID to the lock file.
/// On `Drop`, removes the lock file (best-effort).
#[derive(Debug)]
pub struct InstanceLock {
    path: PathBuf,
}

/// Error returned when another instance is already running.
#[derive(Debug)]
pub struct LockConflict {
    pub pid: u32,
    pub path: PathBuf,
}

impl std::fmt::Display for LockConflict {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "Another lore-tui instance is running (PID {}). Lock file: {}",
            self.pid,
            self.path.display()
        )
    }
}

impl std::error::Error for LockConflict {}

impl InstanceLock {
    /// Try to acquire the instance lock.
    ///
    /// - If the lock file doesn't exist, creates it with our PID.
    /// - If the lock file exists with a live PID, returns `LockConflict`.
    /// - If the lock file exists with a dead PID, removes the stale lock and acquires.
    pub fn acquire(lock_dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
        // Ensure lock directory exists.
        fs::create_dir_all(lock_dir)?;

        let path = lock_dir.join("tui.lock");

        // Check for existing lock.
        if path.exists() {
            let contents = fs::read_to_string(&path).unwrap_or_default();
            if let Ok(pid) = contents.trim().parse::<u32>()
                && is_process_alive(pid)
            {
                return Err(Box::new(LockConflict {
                    pid,
                    path: path.clone(),
                }));
            }
            // Stale lock — PID is dead, or corrupt file. Remove and re-acquire.
            fs::remove_file(&path)?;
        }

        // Write our PID.
        let mut file = fs::File::create(&path)?;
        write!(file, "{}", std::process::id())?;
        file.sync_all()?;

        Ok(Self { path })
    }

    /// Path to the lock file.
    #[must_use]
    pub fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for InstanceLock {
    fn drop(&mut self) {
        // Best-effort cleanup. If it fails, the stale lock will be
        // recovered on next launch via the dead-PID check.
        let _ = fs::remove_file(&self.path);
    }
}

/// Check whether a process with the given PID is alive.
///
/// Uses `kill -0 <pid>` on Unix (exit 0 = alive, non-zero = dead).
/// On non-Unix, conservatively assumes alive.
#[cfg(unix)]
fn is_process_alive(pid: u32) -> bool {
    std::process::Command::new("kill")
        .args(["-0", &pid.to_string()])
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .is_ok_and(|s| s.success())
}

#[cfg(not(unix))]
fn is_process_alive(_pid: u32) -> bool {
    // Conservative fallback: assume alive.
    true
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    #[test]
    fn test_acquire_and_release() {
        let dir = tempfile::tempdir().unwrap();
        let lock_path = dir.path().join("tui.lock");

        {
            let _lock = InstanceLock::acquire(dir.path()).unwrap();
            assert!(lock_path.exists());

            // Lock file should contain our PID.
            let contents = fs::read_to_string(&lock_path).unwrap();
            assert_eq!(contents, format!("{}", std::process::id()));
        }
        // After drop, lock file should be removed.
        assert!(!lock_path.exists());
    }

    #[test]
    fn test_double_acquire_fails() {
        let dir = tempfile::tempdir().unwrap();
        let _lock = InstanceLock::acquire(dir.path()).unwrap();

        // Second acquire should fail because our PID is still alive.
        let result = InstanceLock::acquire(dir.path());
        assert!(result.is_err());

        let err = result.unwrap_err();
        let conflict = err.downcast_ref::<LockConflict>().unwrap();
        assert_eq!(conflict.pid, std::process::id());
    }

    #[test]
    fn test_stale_lock_recovery() {
        let dir = tempfile::tempdir().unwrap();
        let lock_path = dir.path().join("tui.lock");

        // Write a lock file with a dead PID (PID 1 is init, but PID 99999999
        // almost certainly doesn't exist).
        let dead_pid = 99_999_999u32;
        fs::write(&lock_path, dead_pid.to_string()).unwrap();

        // Should succeed — stale lock is recovered.
        let _lock = InstanceLock::acquire(dir.path()).unwrap();
        assert!(lock_path.exists());

        // Lock file now contains our PID, not the dead one.
        let contents = fs::read_to_string(&lock_path).unwrap();
        assert_eq!(contents, format!("{}", std::process::id()));
    }

    #[test]
    fn test_corrupt_lock_file_recovered() {
        let dir = tempfile::tempdir().unwrap();
        let lock_path = dir.path().join("tui.lock");

        // Write garbage to the lock file.
        fs::write(&lock_path, "not-a-pid").unwrap();

        // Should succeed — corrupt lock is treated as stale.
        let lock = InstanceLock::acquire(dir.path()).unwrap();
        let contents = fs::read_to_string(lock.path()).unwrap();
        assert_eq!(contents, format!("{}", std::process::id()));
    }

    #[test]
    fn test_creates_lock_directory() {
        let dir = tempfile::tempdir().unwrap();
        let nested = dir.path().join("a").join("b").join("c");

        let lock = InstanceLock::acquire(&nested).unwrap();
        assert!(nested.join("tui.lock").exists());
        drop(lock);
    }

    #[test]
    fn test_lock_conflict_display() {
        let conflict = LockConflict {
            pid: 12345,
            path: PathBuf::from("/tmp/tui.lock"),
        };
        let msg = format!("{conflict}");
        assert!(msg.contains("12345"));
        assert!(msg.contains("/tmp/tui.lock"));
    }
}
