//! Project lock file (ADR-0015 §10).
//!
//! When a project is opened, we drop a `.rdbms-playground.lock`
//! file in its directory containing the owning process's PID
//! and hostname. On open, we either:
//!
//! - take the lock if no lock file exists,
//! - refuse if the existing lock points at a live PID on this
//!   host (another rdbms-playground TUI is running on this
//!   project),
//! - take over if the existing lock's PID is dead, or the
//!   hostname differs from ours (clean handover from a crashed
//!   prior instance, or a stale lock left on a shared
//!   filesystem by a different machine).
//!
//! The lock is removed on clean exit (the `Drop` impl). A
//! crash leaves the lock file behind; the next open detects
//! the dead PID and reclaims it.

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

use sysinfo::{Pid, System};
use tracing::{debug, warn};

/// On-disk lock file name. Lives directly under the project
/// directory.
const LOCK_FILE_NAME: &str = ".rdbms-playground.lock";

/// Acquired project lock. Releases on drop.
#[derive(Debug)]
pub struct Lock {
    path: PathBuf,
}

#[derive(Debug)]
pub enum LockError {
    AlreadyHeld {
        pid: u32,
        hostname: String,
        path: PathBuf,
    },
    Write {
        path: PathBuf,
        source: std::io::Error,
    },
    Read {
        path: PathBuf,
        source: std::io::Error,
    },
}

impl std::fmt::Display for LockError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::AlreadyHeld {
                pid,
                hostname,
                path,
            } => f.write_str(&crate::t!(
                "project.lock.already_held",
                pid = pid,
                hostname = hostname,
                path = path.display(),
            )),
            Self::Write { path, source } => f.write_str(&crate::t!(
                "project.lock.write",
                path = path.display(),
                source = source,
            )),
            Self::Read { path, source } => f.write_str(&crate::t!(
                "project.lock.read",
                path = path.display(),
                source = source,
            )),
        }
    }
}

impl std::error::Error for LockError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::AlreadyHeld { .. } => None,
            Self::Write { source, .. } | Self::Read { source, .. } => Some(source),
        }
    }
}

impl Lock {
    /// Attempt to acquire the lock for `project_dir`. The
    /// directory itself must exist; `Project::open` /
    /// `Project::create_temp` create it before calling here.
    pub fn acquire(project_dir: &Path) -> Result<Self, LockError> {
        let path = project_dir.join(LOCK_FILE_NAME);
        let our_pid = std::process::id();
        let our_host = local_hostname();

        match fs::read_to_string(&path) {
            Ok(content) => {
                let info = parse(&content);
                if let Some((pid, host)) = info
                    && host == our_host
                    && pid_is_alive(pid)
                {
                    return Err(LockError::AlreadyHeld {
                        pid,
                        hostname: host,
                        path,
                    });
                }
                debug!(?path, "reclaiming stale lock");
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                // No existing lock; we'll create it below.
            }
            Err(e) => {
                return Err(LockError::Read { path, source: e });
            }
        }

        let body = format!("{our_pid}|{our_host}\n");
        fs::write(&path, body).map_err(|e| LockError::Write {
            path: path.clone(),
            source: e,
        })?;
        Ok(Self { path })
    }

    /// Path of the underlying lock file (mainly for tests and
    /// diagnostics).
    #[cfg(test)]
    pub fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for Lock {
    fn drop(&mut self) {
        if let Err(e) = fs::remove_file(&self.path) {
            warn!(path = %self.path.display(), error = %e, "failed to remove project lock file");
        }
    }
}

/// Parse a lock file body into `(pid, hostname)`. Tolerates a
/// trailing newline. Returns `None` if the format is anything
/// other than `<digits>|<hostname>` — in that case the lock is
/// treated as stale and reclaimed.
fn parse(content: &str) -> Option<(u32, String)> {
    let line = content.lines().next()?.trim();
    let (pid_str, host) = line.split_once('|')?;
    let pid: u32 = pid_str.parse().ok()?;
    if host.is_empty() {
        return None;
    }
    Some((pid, host.to_string()))
}

/// Hostname of the machine, as a UTF-8 string. Falls back to
/// `"unknown-host"` if the OS won't tell us; a fallback string
/// only weakens the cross-host reclaim heuristic, it doesn't
/// break correctness.
fn local_hostname() -> String {
    gethostname::gethostname()
        .into_string()
        .unwrap_or_else(|_| "unknown-host".to_string())
}

/// Is a process with this PID currently running on this host?
/// Uses `sysinfo` to query the OS process table.
fn pid_is_alive(pid: u32) -> bool {
    let mut sys = System::new();
    sys.refresh_processes(
        sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]),
        true,
    );
    sys.process(Pid::from_u32(pid)).is_some()
}

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

    fn tempdir() -> tempfile::TempDir {
        tempfile::tempdir().expect("create tempdir")
    }

    #[test]
    fn acquires_lock_when_none_exists() {
        let dir = tempdir();
        let lock = Lock::acquire(dir.path()).expect("lock");
        assert!(lock.path().exists());
        let content = fs::read_to_string(lock.path()).unwrap();
        let (pid, host) = parse(&content).expect("parseable body");
        assert_eq!(pid, std::process::id());
        assert!(!host.is_empty());
    }

    #[test]
    fn drop_removes_lock_file() {
        let dir = tempdir();
        let path = {
            let lock = Lock::acquire(dir.path()).expect("lock");
            lock.path().to_path_buf()
        };
        assert!(!path.exists(), "lock file should have been removed on drop");
    }

    #[test]
    fn refuses_when_lock_points_at_live_self() {
        let dir = tempdir();
        let _first = Lock::acquire(dir.path()).expect("first lock");
        // The first lock writes our own PID; a second attempt
        // should refuse because the PID is alive on this host.
        let err = Lock::acquire(dir.path()).expect_err("should refuse second acquisition");
        assert!(
            matches!(err, LockError::AlreadyHeld { .. }),
            "unexpected: {err:?}"
        );
    }

    #[test]
    fn reclaims_stale_lock_with_dead_pid() {
        let dir = tempdir();
        // PID 1 is init/launchd and is alive. We need a PID that
        // is essentially guaranteed not to exist; use the maximum
        // u32 value, which is far above any realistic PID.
        let stale = format!("{pid}|{host}\n", pid = u32::MAX, host = local_hostname());
        fs::write(dir.path().join(LOCK_FILE_NAME), stale).unwrap();

        let lock = Lock::acquire(dir.path()).expect("should reclaim stale lock");
        let body = fs::read_to_string(lock.path()).unwrap();
        let (pid, _) = parse(&body).unwrap();
        assert_eq!(pid, std::process::id());
    }

    #[test]
    fn reclaims_lock_from_different_host() {
        let dir = tempdir();
        // A lock from another host: even if the PID happens to
        // be live here, we reclaim because we can't tell whether
        // *that* PID on *that* host is alive.
        let foreign = "1|some-other-machine\n".to_string();
        fs::write(dir.path().join(LOCK_FILE_NAME), foreign).unwrap();

        let lock = Lock::acquire(dir.path()).expect("should reclaim cross-host lock");
        let body = fs::read_to_string(lock.path()).unwrap();
        let (_, host) = parse(&body).unwrap();
        assert_eq!(host, local_hostname());
    }

    #[test]
    fn reclaims_unparseable_lock() {
        let dir = tempdir();
        fs::write(dir.path().join(LOCK_FILE_NAME), "not a real lock\n").unwrap();
        let lock = Lock::acquire(dir.path()).expect("should reclaim unparseable lock");
        assert!(lock.path().exists());
    }

    #[test]
    fn parses_valid_body() {
        let (pid, host) = parse("12345|myhost\n").unwrap();
        assert_eq!(pid, 12345);
        assert_eq!(host, "myhost");
    }

    #[test]
    fn rejects_bad_bodies() {
        assert!(parse("").is_none());
        assert!(parse("nopipe\n").is_none());
        assert!(parse("notanumber|host\n").is_none());
        assert!(parse("123|\n").is_none());
    }
}
