//! Dream lock file and session counting infrastructure.
//!
//! Coordination primitives for background memory consolidation ("dream"):
//! - [`DreamLock`]: PID-based lock file with mtime tracking
//! - [`sessions_since`]: counts session files modified after a given timestamp

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

const LOCK_FILE_NAME: &str = ".dream-lock";

/// Whether a process with the given PID is alive.
///
/// Local copy kept dependency-free of `crate::util` so the memory subsystem
/// can be extracted into its own crate. Mirrors `crate::util::is_process_alive`.
#[cfg(unix)]
fn is_process_alive(pid: u32) -> bool {
    use nix::errno::Errno;
    use nix::sys::signal::kill;
    use nix::unistd::Pid;

    // Signal 0 probes existence; EPERM means alive under a different UID.
    match kill(Pid::from_raw(pid as i32), None) {
        Ok(()) => true,
        Err(Errno::ESRCH) => false,
        Err(_) => true,
    }
}

#[cfg(windows)]
fn is_process_alive(pid: u32) -> bool {
    use windows::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
    use windows::Win32::System::Threading::{
        OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject,
    };

    // SAFETY: OpenProcess returns Err on absence/permission failure;
    // PROCESS_SYNCHRONIZE is the minimum right needed for WaitForSingleObject.
    let Ok(handle) = (unsafe { OpenProcess(PROCESS_SYNCHRONIZE, false, pid) }) else {
        return false;
    };

    // SAFETY: handle is valid; timeout 0 means "poll, don't block."
    let wait_result = unsafe { WaitForSingleObject(handle, 0) };
    // SAFETY: handle is owned by us; close regardless of wait result.
    let _ = unsafe { CloseHandle(handle) };

    wait_result == WAIT_TIMEOUT
}

pub struct DreamLock {
    path: PathBuf,
}

impl DreamLock {
    pub fn new(workspace_dir: &Path) -> Self {
        Self {
            path: workspace_dir.join(LOCK_FILE_NAME),
        }
    }

    /// Read the last consolidation timestamp (lock file mtime).
    /// Returns `None` if the lock file doesn't exist.
    pub fn last_consolidated_at(&self) -> io::Result<Option<SystemTime>> {
        match fs::metadata(&self.path) {
            Ok(meta) => Ok(Some(meta.modified()?)),
            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(e),
        }
    }

    /// Try to acquire the lock for consolidation.
    ///
    /// Returns `Ok(Some(prior))` on success, where `prior` is the previous mtime
    /// (`None` if the file didn't exist). Pass to [`Self::rollback`] on failure.
    /// Returns `Ok(None)` if held by a live, non-stale process.
    ///
    /// Reclaims stale locks when the holder PID is dead or age exceeds `stale_secs`.
    ///
    /// Note: this is best-effort coordination, not mutual exclusion. The
    /// write-then-verify protocol reduces but cannot eliminate races — two
    /// processes may rarely both believe they acquired. Callers must tolerate
    /// duplicate consolidation (dream is idempotent).
    pub fn try_acquire(&self, stale_secs: u64) -> io::Result<Option<Option<SystemTime>>> {
        let prior = match fs::metadata(&self.path) {
            Ok(meta) => {
                let mtime = meta.modified()?;
                if let Ok(content) = fs::read_to_string(&self.path)
                    && let Ok(pid) = content.trim().parse::<u32>()
                {
                    let age = SystemTime::now()
                        .duration_since(mtime)
                        .unwrap_or_default()
                        .as_secs();
                    if age < stale_secs && is_process_alive(pid) {
                        return Ok(None);
                    }
                }
                Some(mtime)
            }
            Err(e) if e.kind() == io::ErrorKind::NotFound => None,
            Err(e) => return Err(e),
        };

        if let Some(parent) = self.path.parent() {
            fs::create_dir_all(parent)?;
        }
        let our_pid = std::process::id();
        fs::write(&self.path, our_pid.to_string())?;

        // Re-read to verify we won the race
        let content = fs::read_to_string(&self.path)?;
        if content.trim().parse::<u32>().ok() == Some(our_pid) {
            Ok(Some(prior))
        } else {
            Ok(None)
        }
    }

    /// Restore lock state after a failed dream.
    /// If `prior` is `None` (no prior file), deletes the lock file.
    pub fn rollback(&self, prior: Option<SystemTime>) -> io::Result<()> {
        match prior {
            None => {
                if let Err(e) = fs::remove_file(&self.path)
                    && e.kind() != io::ErrorKind::NotFound
                {
                    return Err(e);
                }
                Ok(())
            }
            Some(mtime) => {
                // Clear the PID body so our alive PID doesn't block future reclaimers.
                fs::write(&self.path, "")?;
                let file = fs::File::options().write(true).open(&self.path)?;
                file.set_times(fs::FileTimes::new().set_modified(mtime))
            }
        }
    }

    /// Stamp the lock file with the current time to record a consolidation.
    pub fn record_consolidation(&self) -> io::Result<()> {
        if let Some(parent) = self.path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(&self.path, std::process::id().to_string())
    }
}

/// Count session files modified after `since`, excluding the current session.
///
/// Returns sorted file stems of matching `.md` files in `sessions_dir`.
pub fn sessions_since(
    sessions_dir: &Path,
    since: SystemTime,
    exclude_sid8: Option<&str>,
) -> io::Result<Vec<String>> {
    let entries = match fs::read_dir(sessions_dir) {
        Ok(entries) => entries,
        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(e) => return Err(e),
    };

    let mut result = Vec::new();
    for entry in entries {
        let entry = entry?;
        let path = entry.path();

        if path.extension().and_then(|e| e.to_str()) != Some("md") {
            continue;
        }

        if let Some(exclude) = exclude_sid8
            && path
                .file_stem()
                .and_then(|s| s.to_str())
                .is_some_and(|stem| stem.ends_with(exclude))
        {
            continue;
        }

        if entry.metadata()?.modified()? > since
            && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
        {
            result.push(stem.to_owned());
        }
    }

    result.sort();
    Ok(result)
}

#[cfg(test)]
mod tests {
    use super::*;
    use filetime::FileTime;
    use std::time::Duration;
    use tempfile::TempDir;

    // --- DreamLock tests ---

    #[test]
    fn no_file_means_no_prior_consolidation() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        assert!(lock.last_consolidated_at().unwrap().is_none());
    }

    #[test]
    fn acquire_on_empty_dir_writes_pid() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        let prior = lock.try_acquire(300).unwrap().expect("should acquire");
        assert!(prior.is_none(), "no prior file existed");

        let content = fs::read_to_string(&lock.path).unwrap();
        assert_eq!(content, std::process::id().to_string());
        assert!(lock.last_consolidated_at().unwrap().is_some());
    }

    #[test]
    fn rollback_none_deletes_file() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        let prior = lock.try_acquire(300).unwrap().unwrap();
        assert!(lock.path.exists());

        lock.rollback(prior).unwrap();
        assert!(!lock.path.exists());
        assert!(lock.last_consolidated_at().unwrap().is_none());
    }

    #[test]
    fn rollback_restores_prior_mtime() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        let old_time = SystemTime::now() - Duration::from_secs(7200);
        fs::write(&lock.path, "4000000000").unwrap(); // dead PID
        filetime::set_file_mtime(&lock.path, FileTime::from_system_time(old_time)).unwrap();

        let prior = lock
            .try_acquire(300)
            .unwrap()
            .expect("should reclaim dead PID");
        let prior_mtime = prior.expect("prior file existed");

        // mtime after acquire is fresh (from fs::write)
        let fresh = lock.last_consolidated_at().unwrap().unwrap();
        let fresh_age = SystemTime::now().duration_since(fresh).unwrap_or_default();
        assert!(fresh_age.as_secs() < 5);

        // Rollback restores old mtime
        lock.rollback(Some(prior_mtime)).unwrap();
        let restored = lock.last_consolidated_at().unwrap().unwrap();
        let drift = restored
            .duration_since(old_time)
            .or_else(|_| old_time.duration_since(restored))
            .unwrap();
        assert!(drift.as_secs() < 2, "mtime should be restored");
    }

    #[test]
    fn dead_pid_is_reclaimed() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        fs::write(&lock.path, "4000000000").unwrap();
        assert!(
            lock.try_acquire(300).unwrap().is_some(),
            "dead PID should be reclaimable"
        );

        let content = fs::read_to_string(&lock.path).unwrap();
        assert_eq!(content, std::process::id().to_string());
    }

    #[test]
    fn live_pid_blocks_acquisition() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        assert!(lock.try_acquire(300).unwrap().is_some(), "first acquire");
        assert!(
            lock.try_acquire(300).unwrap().is_none(),
            "second acquire should be blocked by live PID"
        );
    }

    #[test]
    fn stale_age_allows_reclaim_even_if_alive() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        fs::write(&lock.path, std::process::id().to_string()).unwrap();
        let old = SystemTime::now() - Duration::from_secs(600);
        filetime::set_file_mtime(&lock.path, FileTime::from_system_time(old)).unwrap();

        // stale_secs=300, age=600 → stale, should reclaim
        assert!(
            lock.try_acquire(300).unwrap().is_some(),
            "stale lock should be reclaimable"
        );
    }

    #[test]
    fn record_consolidation_creates_file() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        lock.record_consolidation().unwrap();
        assert!(lock.path.exists());

        let age = SystemTime::now()
            .duration_since(lock.last_consolidated_at().unwrap().unwrap())
            .unwrap_or_default();
        assert!(age.as_secs() < 5);
    }

    #[test]
    fn record_consolidation_updates_mtime() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        fs::write(&lock.path, "12345").unwrap();
        let old = SystemTime::now() - Duration::from_secs(7200);
        filetime::set_file_mtime(&lock.path, FileTime::from_system_time(old)).unwrap();

        lock.record_consolidation().unwrap();

        let age = SystemTime::now()
            .duration_since(lock.last_consolidated_at().unwrap().unwrap())
            .unwrap_or_default();
        assert!(age.as_secs() < 5, "mtime should be ~now");
    }

    #[test]
    fn full_lifecycle_acquire_consolidate_blocks_reacquire() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        let prior = lock.try_acquire(300).unwrap().unwrap();
        assert!(prior.is_none());

        lock.record_consolidation().unwrap();

        assert!(
            lock.try_acquire(300).unwrap().is_none(),
            "fresh consolidation should block re-acquire"
        );
    }

    #[test]
    fn rollback_on_nonexistent_file_is_noop() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        lock.rollback(None).unwrap(); // no file to delete, should be fine
    }

    #[test]
    fn corrupted_lock_body_is_reclaimable() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        fs::write(&lock.path, "not-a-pid").unwrap();
        assert!(
            lock.try_acquire(300).unwrap().is_some(),
            "unparseable PID should be reclaimable"
        );
    }

    #[test]
    fn empty_lock_body_is_reclaimable() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        fs::write(&lock.path, "").unwrap();
        assert!(
            lock.try_acquire(300).unwrap().is_some(),
            "empty body should be reclaimable"
        );
    }

    // --- sessions_since tests ---

    fn write_session(dir: &Path, name: &str, age_secs: u64) {
        fs::create_dir_all(dir).unwrap();
        let path = dir.join(format!("{name}.md"));
        fs::write(&path, "test").unwrap();
        let t = SystemTime::now() - Duration::from_secs(age_secs);
        filetime::set_file_mtime(&path, FileTime::from_system_time(t)).unwrap();
    }

    #[test]
    fn filters_by_mtime() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        let cutoff = SystemTime::now() - Duration::from_secs(3600);

        write_session(&sessions, "2026-01-01-proj-aaa11111", 1800); // 30min ago, after cutoff
        write_session(&sessions, "2025-12-31-proj-bbb22222", 7200); // 2h ago, before cutoff

        let result = sessions_since(&sessions, cutoff, None).unwrap();
        assert_eq!(result, vec!["2026-01-01-proj-aaa11111"]);
    }

    #[test]
    fn mtime_at_exact_cutoff_is_excluded() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        let cutoff = SystemTime::now() - Duration::from_secs(3600);

        // Set mtime to the exact cutoff value (not strictly after)
        fs::create_dir_all(&sessions).unwrap();
        let path = sessions.join("2026-01-01-proj-exact000.md");
        fs::write(&path, "test").unwrap();
        filetime::set_file_mtime(&path, FileTime::from_system_time(cutoff)).unwrap();

        let result = sessions_since(&sessions, cutoff, None).unwrap();
        assert!(
            result.is_empty(),
            "mtime == cutoff should be excluded (strict >)"
        );
    }

    #[test]
    fn excludes_current_session() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        let cutoff = SystemTime::now() - Duration::from_secs(86400);

        write_session(&sessions, "2026-01-01-proj-aaa11111", 100);
        write_session(&sessions, "2026-01-01-proj-bbb22222", 100);

        let result = sessions_since(&sessions, cutoff, Some("bbb22222")).unwrap();
        assert_eq!(result, vec!["2026-01-01-proj-aaa11111"]);
    }

    #[test]
    fn empty_dir_returns_empty_vec() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        fs::create_dir_all(&sessions).unwrap();

        let result = sessions_since(&sessions, SystemTime::UNIX_EPOCH, None).unwrap();
        assert!(result.is_empty());
    }

    #[test]
    fn nonexistent_dir_returns_empty_vec() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("nonexistent");

        let result = sessions_since(&sessions, SystemTime::UNIX_EPOCH, None).unwrap();
        assert!(result.is_empty());
    }

    #[test]
    fn ignores_non_md_files() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        fs::create_dir_all(&sessions).unwrap();

        write_session(&sessions, "2026-01-01-proj-aaa11111", 0);
        fs::write(sessions.join("notes.txt"), "not a session").unwrap();
        fs::write(sessions.join("data.json"), "{}").unwrap();

        let result = sessions_since(&sessions, SystemTime::UNIX_EPOCH, None).unwrap();
        assert_eq!(result, vec!["2026-01-01-proj-aaa11111"]);
    }

    #[test]
    fn returns_sorted_stems() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");

        write_session(&sessions, "zzz-session", 0);
        write_session(&sessions, "aaa-session", 0);
        write_session(&sessions, "mmm-session", 0);

        let result = sessions_since(&sessions, SystemTime::UNIX_EPOCH, None).unwrap();
        assert_eq!(result, vec!["aaa-session", "mmm-session", "zzz-session"]);
    }
}
