//! Per-path write serialization and atomic file replacement for NCL mutations.
//!
//! `NclWriteLock` holds a `DashMap<PathBuf, Arc<Mutex<()>>>` so every distinct
//! NCL file gets its own mutex. Acquiring a lock serializes all concurrent
//! read-mutate-write sequences on the same path without blocking unrelated
//! files.
//!
//! `atomic_write` replaces file contents via a sibling `.tmp` file and
//! `std::fs::rename`, which is POSIX-atomic on the same filesystem. Callers
//! see either the old content or the new content — never a partial write.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use dashmap::DashMap;
use tokio::sync::{Mutex, OwnedMutexGuard};

/// Registry of per-path mutexes.
///
/// Clone is cheap — the inner `DashMap` is wrapped in `Arc`.
#[derive(Clone, Default)]
pub struct NclWriteLock {
    map: Arc<DashMap<PathBuf, Arc<Mutex<()>>>>,
}

impl NclWriteLock {
    pub fn new() -> Self {
        Self::default()
    }

    /// Acquire the exclusive lock for `path`.
    ///
    /// Creates the mutex on first use. Returns an owned guard that releases the
    /// lock when dropped, so `let _guard = lock.acquire(&path).await;` keeps
    /// the lock alive for the duration of the enclosing scope.
    pub async fn acquire(&self, path: &Path) -> OwnedMutexGuard<()> {
        let mtx = self
            .map
            .entry(path.to_path_buf())
            .or_insert_with(|| Arc::new(Mutex::new(())))
            .clone();
        mtx.lock_owned().await
    }
}

/// Write `content` to `path` atomically.
///
/// Writes to `{path}.tmp` in the same directory, then renames it over `path`.
/// On POSIX, `rename(2)` is atomic when source and destination share the same
/// filesystem, so readers always observe a complete file.
///
/// Returns an `std::io::Error` if either the write or the rename fails.
pub fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> {
    let tmp = path.with_extension(format!(
        "{}.tmp",
        path.extension().and_then(|e| e.to_str()).unwrap_or("ncl")
    ));
    std::fs::write(&tmp, content)?;
    std::fs::rename(&tmp, path)
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use tempfile::NamedTempFile;

    use super::*;

    // ── atomic_write ──────────────────────────────────────────────────────

    #[test]
    fn writes_content_to_target() {
        let file = NamedTempFile::new().unwrap();
        let path = file.path();
        atomic_write(path, "hello ncl").unwrap();
        assert_eq!(std::fs::read_to_string(path).unwrap(), "hello ncl");
    }

    #[test]
    fn overwrites_existing_content() {
        let file = NamedTempFile::new().unwrap();
        let path = file.path();
        atomic_write(path, "first").unwrap();
        atomic_write(path, "second").unwrap();
        assert_eq!(std::fs::read_to_string(path).unwrap(), "second");
    }

    #[test]
    fn tmp_file_does_not_persist() {
        let file = NamedTempFile::new().unwrap();
        let path = file.path();
        atomic_write(path, "content").unwrap();

        let tmp = path.with_extension(format!(
            "{}.tmp",
            path.extension().and_then(|e| e.to_str()).unwrap_or("ncl")
        ));
        assert!(!tmp.exists(), ".tmp sibling must not remain after rename");
    }

    // ── NclWriteLock ──────────────────────────────────────────────────────

    #[tokio::test]
    async fn acquire_returns_guard() {
        let lock = NclWriteLock::new();
        let file = NamedTempFile::new().unwrap();
        let _guard = lock.acquire(file.path()).await;
        // Guard held — no panic, no deadlock.
    }

    #[tokio::test]
    async fn different_paths_do_not_block_each_other() {
        let lock = Arc::new(NclWriteLock::new());
        let f1 = NamedTempFile::new().unwrap();
        let f2 = NamedTempFile::new().unwrap();

        let _g1 = lock.acquire(f1.path()).await;
        // Different path — must not block even while g1 is held.
        let _g2 = lock.acquire(f2.path()).await;
    }

    #[tokio::test]
    async fn same_path_serializes_writes() {
        use std::sync::atomic::{AtomicU32, Ordering};

        let lock = Arc::new(NclWriteLock::new());
        let file = NamedTempFile::new().unwrap();
        let path = file.path().to_path_buf();
        let counter = Arc::new(AtomicU32::new(0));

        let lock2 = Arc::clone(&lock);
        let path2 = path.clone();
        let counter2 = Arc::clone(&counter);

        // Task 1: acquire, increment, release.
        let t1 = tokio::spawn(async move {
            let _g = lock2.acquire(&path2).await;
            counter2.fetch_add(1, Ordering::SeqCst)
        });

        // Task 2: acquire after t1 completes (or races — either way serialized).
        let t2 = tokio::spawn(async move {
            let _g = lock.acquire(&path).await;
            counter.fetch_add(1, Ordering::SeqCst)
        });

        let (r1, r2) = tokio::join!(t1, t2);
        let v1 = r1.unwrap();
        let v2 = r2.unwrap();

        // Both tasks ran and each saw a distinct value (0 and 1).
        assert!(
            (v1 == 0 && v2 == 1) || (v1 == 1 && v2 == 0),
            "expected serial execution: got {v1}, {v2}"
        );
    }
}
