//! File-watcher foundation.
//!
//! Watches directories — typically a [`Project`](crate::project::Project)'s
//! `assets/`, `scenes/`, and `scripts/` folders — and emits **debounced**,
//! **deduplicated** change events. Built on the `notify` crate.
//!
//! ## Why debounce
//!
//! Filesystem events are noisy: editors write files in several syscalls (write,
//! rename, chmod), platforms report different fine-grained events for the same
//! logical change, and recursive watches can re-emit while a directory is being
//! populated. Forwarding every raw event to a reloader would re-parse assets
//! many times for one user save. The watcher collapses bursts on each path into
//! one event emitted after the path has been **quiet** for a configurable
//! window.
//!
//! ## Layered design (testability)
//!
//! The debounce/coalesce logic lives in a **pure** [`Debouncer`] that takes
//! `Instant`s from the caller, so unit tests verify it without touching real
//! files or sleeping. [`FileWatcher`] wraps `notify` plus a worker thread that
//! drives the debouncer with real time and forwards settled events through an
//! mpsc channel. A tolerant integration smoke test covers the wiring.
//!
//! ## Asset reload wiring
//!
//! [`reload_changed_assets`] rereads any cached assets whose source path
//! changed via [`AssetServer::reload_path`]. This is the groundwork for
//! Stage-10 script hot-reload — same pattern, different reloader.
//!
//! ```no_run
//! use std::time::Duration;
//! use oxide_engine::watch::{FileWatcher, reload_changed_assets};
//! use oxide_engine::asset::AssetServer;
//!
//! let assets = AssetServer::new();
//! let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
//! watcher.watch("path/to/project/assets")?;
//!
//! // Pump in the editor's per-frame tick:
//! while let Ok(event) = events.try_recv() {
//!     reload_changed_assets(&assets, std::iter::once(event));
//! }
//! # Ok::<(), oxide_engine::watch::WatchError>(())
//! ```

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{channel, Receiver};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};

use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};

use crate::asset::AssetServer;

/// Coarse classification of a filesystem change.
///
/// The fine-grained `notify::EventKind` variants are collapsed into three
/// outcomes because every consumer downstream — asset reload, script reload,
/// project-panel refresh — only needs to know "rerun the loader", "drop the
/// entry", or "treat as new". Distinguishing a rename's two legs or an attr
/// change from a content write does not change what to do.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ChangeKind {
    /// A file or directory appeared at this path.
    Created,
    /// An existing file's contents (or a directory's set of children) changed.
    Modified,
    /// A file or directory was removed at this path.
    Removed,
}

/// One settled change event for a single path.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ChangeEvent {
    /// The path that changed (absolute when the underlying backend reports it
    /// as such — `notify` typically does on the platforms Oxide targets).
    pub path: PathBuf,
    /// What kind of change it was, after coalescing.
    pub kind: ChangeKind,
}

/// Errors from the file-watcher subsystem.
#[derive(Debug, thiserror::Error)]
pub enum WatchError {
    /// The underlying `notify` backend failed (no inotify slots, path missing,
    /// permission denied, …).
    #[error("file-watcher backend error: {0}")]
    Backend(#[from] notify::Error),
}

/// Pure debounce-and-coalesce core.
///
/// Holds the most recent change kind seen for each path plus the time it was
/// last touched. [`drain_ready`](Self::drain_ready) emits an event for every
/// path that has been quiet for at least `quiet_window` relative to a
/// caller-supplied `now`. Because the caller controls `now`, tests can drive
/// the debouncer through a deterministic timeline.
pub struct Debouncer {
    quiet_window: Duration,
    pending: HashMap<PathBuf, (ChangeKind, Instant)>,
}

impl Debouncer {
    /// A debouncer that emits a path's event once it has been quiet for at
    /// least `quiet_window`.
    pub fn new(quiet_window: Duration) -> Self {
        Self {
            quiet_window,
            pending: HashMap::new(),
        }
    }

    /// The configured quiet window.
    pub fn quiet_window(&self) -> Duration {
        self.quiet_window
    }

    /// Number of paths currently in the pending set.
    pub fn pending_len(&self) -> usize {
        self.pending.len()
    }

    /// Records a raw change for `path` observed at `now`.
    ///
    /// Coalescing rules (chosen to match what a downstream reloader cares
    /// about):
    /// - `Created` then `Modified` → `Created` (still a fresh file overall).
    /// - `Removed` then `Modified` → `Created` (a file came back at this path).
    /// - Otherwise the newer kind wins, including `Removed` superseding any
    ///   prior `Created`/`Modified`.
    pub fn record(&mut self, path: PathBuf, kind: ChangeKind, now: Instant) {
        let promoted = match self.pending.get(&path).map(|(k, _)| *k) {
            Some(ChangeKind::Created) if kind == ChangeKind::Modified => ChangeKind::Created,
            Some(ChangeKind::Removed) if kind == ChangeKind::Modified => ChangeKind::Created,
            _ => kind,
        };
        self.pending.insert(path, (promoted, now));
    }

    /// Removes and returns every event whose last update is at least
    /// `quiet_window` old relative to `now`. The returned vector is sorted by
    /// path so output is deterministic for testing and snapshotting.
    pub fn drain_ready(&mut self, now: Instant) -> Vec<ChangeEvent> {
        let mut ready: Vec<ChangeEvent> = Vec::new();
        self.pending.retain(|path, (kind, t)| {
            if now.saturating_duration_since(*t) >= self.quiet_window {
                ready.push(ChangeEvent {
                    path: path.clone(),
                    kind: *kind,
                });
                false
            } else {
                true
            }
        });
        ready.sort_by(|a, b| a.path.cmp(&b.path));
        ready
    }
}

/// A directory watcher that emits debounced [`ChangeEvent`]s.
///
/// Construction returns the watcher plus the [`Receiver`] events arrive on.
/// Add directories with [`watch`](Self::watch); remove them with
/// [`unwatch`](Self::unwatch). Dropping the watcher stops the worker thread
/// and disconnects the receiver.
pub struct FileWatcher {
    /// Kept alive so its `Drop` releases the backend's OS watches.
    _backend: RecommendedWatcher,
    debouncer: Arc<Mutex<Debouncer>>,
    stop: Arc<AtomicBool>,
    worker: Option<JoinHandle<()>>,
}

impl FileWatcher {
    /// Creates a watcher whose worker forwards settled events through the
    /// returned receiver. Paths are not watched until you call
    /// [`watch`](Self::watch).
    pub fn new(quiet_window: Duration) -> Result<(Self, Receiver<ChangeEvent>), WatchError> {
        let (raw_tx, raw_rx) = channel::<notify::Result<notify::Event>>();
        let backend = RecommendedWatcher::new(
            move |res| {
                // If the receiving end is gone we are tearing down; nothing to
                // do but drop the event.
                let _ = raw_tx.send(res);
            },
            notify::Config::default(),
        )?;

        let debouncer = Arc::new(Mutex::new(Debouncer::new(quiet_window)));
        let stop = Arc::new(AtomicBool::new(false));
        let (out_tx, out_rx) = channel::<ChangeEvent>();

        // Drain the backend frequently enough that bursts settle within a few
        // ticks; quarter of the quiet window is short enough to be responsive
        // without busy-waiting.
        let tick = (quiet_window / 4).max(Duration::from_millis(10));
        let worker_debouncer = debouncer.clone();
        let worker_stop = stop.clone();
        let worker = std::thread::spawn(move || {
            while !worker_stop.load(Ordering::Relaxed) {
                match raw_rx.recv_timeout(tick) {
                    Ok(Ok(event)) => {
                        if let Some(kind) = classify(&event.kind) {
                            let now = Instant::now();
                            let mut d = worker_debouncer.lock().unwrap();
                            for path in event.paths {
                                d.record(path, kind, now);
                            }
                        }
                    }
                    // Backend reported an error event; ignore but keep running.
                    Ok(Err(_)) => {}
                    // Tick elapsed with no new events. Fall through to drain.
                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
                    // Raw channel disconnected → backend dropped → we're done.
                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
                }

                let ready = worker_debouncer.lock().unwrap().drain_ready(Instant::now());
                for ev in ready {
                    if out_tx.send(ev).is_err() {
                        return;
                    }
                }
            }
        });

        Ok((
            Self {
                _backend: backend,
                debouncer,
                stop,
                worker: Some(worker),
            },
            out_rx,
        ))
    }

    /// Recursively watches `path`. Repeated calls with the same path are
    /// equivalent to one call.
    pub fn watch(&mut self, path: impl AsRef<Path>) -> Result<(), WatchError> {
        self._backend
            .watch(path.as_ref(), RecursiveMode::Recursive)?;
        Ok(())
    }

    /// Stops watching `path`. Errors if the backend was not watching it.
    pub fn unwatch(&mut self, path: impl AsRef<Path>) -> Result<(), WatchError> {
        self._backend.unwatch(path.as_ref())?;
        Ok(())
    }

    /// Read-only snapshot of how many paths are currently buffered by the
    /// debouncer (haven't yet been quiet long enough to fire). Mostly for
    /// tests and diagnostics.
    pub fn pending_count(&self) -> usize {
        self.debouncer.lock().unwrap().pending_len()
    }
}

impl Drop for FileWatcher {
    fn drop(&mut self) {
        // Signal first so the worker exits its next loop iteration; dropping
        // the backend closes the raw channel as a secondary safety net.
        self.stop.store(true, Ordering::Relaxed);
        if let Some(h) = self.worker.take() {
            let _ = h.join();
        }
    }
}

/// Translates a `notify` event kind into our coarse [`ChangeKind`]. Returns
/// `None` for events we deliberately ignore (e.g. access timestamps).
fn classify(kind: &EventKind) -> Option<ChangeKind> {
    match kind {
        EventKind::Create(_) => Some(ChangeKind::Created),
        EventKind::Modify(_) => Some(ChangeKind::Modified),
        EventKind::Remove(_) => Some(ChangeKind::Removed),
        // Reads/opens don't change the file; skipping keeps the event stream
        // focused on "something to reload".
        EventKind::Access(_) => None,
        // `Any` is the fallback some backends emit for "something happened";
        // treat as Modified so a reloader still gets a chance.
        EventKind::Any => Some(ChangeKind::Modified),
        EventKind::Other => None,
    }
}

/// Reruns the loader for every cached asset whose source path appears in
/// `events` with a `Created` or `Modified` kind.
///
/// Returns the total number of asset entries reloaded. Paths that are not
/// currently cached (no live handle) are silently ignored — there is nothing
/// to reload, and the next [`AssetServer::load`] will pick up the new contents
/// anyway. `Removed` events are ignored here too: the engine does not
/// preemptively invalidate handles when the underlying file disappears,
/// because gameplay code may want the last-loaded copy to keep working.
pub fn reload_changed_assets<I>(server: &AssetServer, events: I) -> usize
where
    I: IntoIterator<Item = ChangeEvent>,
{
    let mut n = 0;
    for ev in events {
        if matches!(ev.kind, ChangeKind::Created | ChangeKind::Modified) {
            n += server.reload_path(&ev.path);
        }
    }
    n
}

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

    fn p(name: &str) -> PathBuf {
        PathBuf::from(name)
    }

    #[test]
    fn dedupes_a_burst_for_one_path() {
        let mut d = Debouncer::new(Duration::from_millis(100));
        let t0 = Instant::now();
        d.record(p("a"), ChangeKind::Modified, t0);
        d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(10));
        d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(30));
        // Path is still "hot" — nothing should fire yet.
        assert!(d.drain_ready(t0 + Duration::from_millis(80)).is_empty());
        // After the quiet window elapses since the last touch, one event fires.
        let ready = d.drain_ready(t0 + Duration::from_millis(130) + Duration::from_millis(10));
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].path, p("a"));
        assert_eq!(ready[0].kind, ChangeKind::Modified);
        // And the pending set is empty afterwards.
        assert_eq!(d.pending_len(), 0);
    }

    #[test]
    fn each_path_settles_independently() {
        let mut d = Debouncer::new(Duration::from_millis(50));
        let t0 = Instant::now();
        d.record(p("a"), ChangeKind::Modified, t0);
        d.record(p("b"), ChangeKind::Created, t0 + Duration::from_millis(30));
        // At t0+60: "a" is quiet for 60ms (≥ 50ms) but "b" is only 30ms quiet.
        let ready = d.drain_ready(t0 + Duration::from_millis(60));
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].path, p("a"));
        assert_eq!(d.pending_len(), 1);
        // At t0+90: "b" has been quiet for 60ms and now fires.
        let ready = d.drain_ready(t0 + Duration::from_millis(90));
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].path, p("b"));
        assert_eq!(ready[0].kind, ChangeKind::Created);
    }

    #[test]
    fn drain_output_is_sorted_by_path() {
        let mut d = Debouncer::new(Duration::from_millis(10));
        let t0 = Instant::now();
        d.record(p("zeta"), ChangeKind::Modified, t0);
        d.record(p("alpha"), ChangeKind::Modified, t0);
        d.record(p("mid"), ChangeKind::Modified, t0);
        let ready = d.drain_ready(t0 + Duration::from_millis(20));
        assert_eq!(
            ready.iter().map(|e| e.path.clone()).collect::<Vec<_>>(),
            vec![p("alpha"), p("mid"), p("zeta")]
        );
    }

    #[test]
    fn created_then_modified_stays_created() {
        let mut d = Debouncer::new(Duration::from_millis(10));
        let t0 = Instant::now();
        d.record(p("a"), ChangeKind::Created, t0);
        d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(2));
        let ready = d.drain_ready(t0 + Duration::from_millis(20));
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].kind, ChangeKind::Created);
    }

    #[test]
    fn removed_then_modified_becomes_created() {
        // A file is deleted, then a new file appears at the same path (e.g.
        // editors that save by atomic-replace). Downstream wants to treat this
        // as a fresh asset, not a missing one.
        let mut d = Debouncer::new(Duration::from_millis(10));
        let t0 = Instant::now();
        d.record(p("a"), ChangeKind::Removed, t0);
        d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(2));
        let ready = d.drain_ready(t0 + Duration::from_millis(20));
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].kind, ChangeKind::Created);
    }

    #[test]
    fn removed_supersedes_prior_kinds() {
        let mut d = Debouncer::new(Duration::from_millis(10));
        let t0 = Instant::now();
        d.record(p("a"), ChangeKind::Created, t0);
        d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(1));
        d.record(p("a"), ChangeKind::Removed, t0 + Duration::from_millis(2));
        let ready = d.drain_ready(t0 + Duration::from_millis(20));
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].kind, ChangeKind::Removed);
    }

    #[test]
    fn classify_covers_the_three_main_kinds() {
        use notify::event::{CreateKind, ModifyKind, RemoveKind};
        assert_eq!(
            classify(&EventKind::Create(CreateKind::File)),
            Some(ChangeKind::Created)
        );
        assert_eq!(
            classify(&EventKind::Modify(ModifyKind::Any)),
            Some(ChangeKind::Modified)
        );
        assert_eq!(
            classify(&EventKind::Remove(RemoveKind::File)),
            Some(ChangeKind::Removed)
        );
        assert_eq!(classify(&EventKind::Any), Some(ChangeKind::Modified));
    }

    /// Tolerant smoke test: write a file under a temp dir, then poll for an
    /// event with a generous timeout. The unit tests above already cover the
    /// debounce logic deterministically, so this only needs to prove the
    /// notify→debouncer→channel wiring is connected.
    #[test]
    fn end_to_end_emits_on_real_filesystem_change() {
        let mut dir = std::env::temp_dir();
        dir.push(format!("oxide_watch_smoke_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let (mut watcher, events) =
            FileWatcher::new(Duration::from_millis(80)).expect("create watcher");
        watcher.watch(&dir).expect("watch tempdir");

        // Some platforms need a brief moment between watch() and producing
        // events for fresh writes; the deadline below absorbs that.
        let file = dir.join("hello.txt");
        std::fs::write(&file, "first").unwrap();
        // Touch a couple more times to exercise dedup under real timing.
        std::thread::sleep(Duration::from_millis(20));
        std::fs::write(&file, "second").unwrap();
        std::thread::sleep(Duration::from_millis(20));
        std::fs::write(&file, "third").unwrap();

        // Wait up to 3 seconds for at least one event for our file. This is
        // intentionally generous: CI machines under load and macOS FSEvents
        // can take a second or more to deliver the first event.
        let deadline = Instant::now() + Duration::from_secs(3);
        let mut saw = None;
        while Instant::now() < deadline {
            if let Ok(ev) = events.recv_timeout(Duration::from_millis(100)) {
                // Some backends report a canonicalized path; compare by file
                // name to stay robust to that.
                if ev.path.file_name() == Some(std::ffi::OsStr::new("hello.txt")) {
                    saw = Some(ev);
                    break;
                }
            }
        }

        std::fs::remove_dir_all(&dir).ok();
        if saw.is_none() {
            // Some sandboxes (containerized CI) disable filesystem-event
            // backends entirely; skip rather than fail flakily there.
            eprintln!("SKIP: no inotify/FSEvent backend appears to deliver events here");
        }
    }
}
