//! File watcher: emits a debounced `Reload` event whenever the
//! watched `.canvas` file changes on disk.
//!
//! `notify::RecommendedWatcher` picks the platform-native backend
//! (FSEvents on macOS, inotify on Linux). We coalesce bursts inside
//! a ~100 ms window so a "save once, write twice" editor only
//! triggers one reparse.

use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::thread;
use std::time::{Duration, Instant};

use anyhow::{Context, Result};
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};

pub const DEBOUNCE: Duration = Duration::from_millis(100);

/// One per filesystem change burst.
#[derive(Debug, Clone)]
pub struct Reload {
    pub path: PathBuf,
}

/// Owns the watcher thread + handle. Drop stops the watcher.
pub struct FileWatcher {
    _watcher: RecommendedWatcher,
    _join: thread::JoinHandle<()>,
}

impl FileWatcher {
    pub fn spawn(path: &Path) -> Result<(Self, Receiver<Reload>)> {
        let path = path.to_path_buf();
        let (tx_app, rx_app) = channel::<Reload>();
        let (tx_raw, rx_raw) = channel::<notify::Result<Event>>();

        let mut watcher = RecommendedWatcher::new(
            move |res| {
                let _ = tx_raw.send(res);
            },
            Config::default(),
        )
        .context("create watcher")?;

        // Watch the parent dir so atomic rename-on-save (common in editors)
        // doesn't break the watch.
        let watch_target = path
            .parent()
            .unwrap_or_else(|| Path::new("."))
            .to_path_buf();
        watcher
            .watch(&watch_target, RecursiveMode::NonRecursive)
            .with_context(|| format!("watch {}", watch_target.display()))?;

        let path_for_thread = path.clone();
        let join = thread::Builder::new()
            .name("jcv-watcher".to_string())
            .spawn(move || debounce_loop(rx_raw, tx_app, path_for_thread))
            .context("spawn watcher thread")?;

        Ok((
            Self {
                _watcher: watcher,
                _join: join,
            },
            rx_app,
        ))
    }
}

fn debounce_loop(rx: Receiver<notify::Result<Event>>, tx: Sender<Reload>, target: PathBuf) {
    let mut pending: Option<Instant> = None;
    loop {
        let recv_timeout = pending.map(|p| {
            let elapsed = p.elapsed();
            if elapsed >= DEBOUNCE {
                Duration::from_millis(0)
            } else {
                DEBOUNCE - elapsed
            }
        });

        let msg = match recv_timeout {
            Some(t) => rx.recv_timeout(t),
            None => rx.recv().map_err(std::sync::mpsc::RecvTimeoutError::from),
        };

        match msg {
            Ok(Ok(ev)) => {
                if event_touches(&ev, &target) {
                    pending = Some(Instant::now());
                }
            }
            Ok(Err(_)) => {} // notify error; ignore and keep going
            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
                if pending.take().is_some()
                    && tx
                        .send(Reload {
                            path: target.clone(),
                        })
                        .is_err()
                {
                    return;
                }
            }
            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return,
        }
    }
}

fn event_touches(ev: &Event, target: &Path) -> bool {
    if !matches!(
        ev.kind,
        EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
    ) {
        return false;
    }
    let canon_target = target.canonicalize().ok();
    ev.paths.iter().any(|p| {
        if p == target {
            return true;
        }
        if let (Some(t), Ok(c)) = (canon_target.as_ref(), p.canonicalize()) {
            return &c == t;
        }
        p.file_name() == target.file_name()
    })
}

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

    fn tmp_dir(name: &str) -> PathBuf {
        let mut p = std::env::temp_dir();
        p.push(format!(
            "jcv-watch-{}-{}-{}",
            name,
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        fs::create_dir_all(&p).unwrap();
        p
    }

    #[test]
    fn fires_reload_after_save() {
        let dir = tmp_dir("reload");
        let path = dir.join("x.canvas");
        fs::write(&path, "{}").unwrap();
        let (_w, rx) = FileWatcher::spawn(&path).expect("spawn");

        // give the watcher a moment to register
        std::thread::sleep(Duration::from_millis(50));
        fs::write(&path, "{\"nodes\":[]}").unwrap();

        let started = Instant::now();
        let r = rx
            .recv_timeout(Duration::from_secs(2))
            .expect("should get reload");
        assert_eq!(r.path, path);
        assert!(
            started.elapsed() < Duration::from_secs(2),
            "should be quick"
        );
    }

    #[test]
    fn coalesces_burst_into_single_reload() {
        let dir = tmp_dir("burst");
        let path = dir.join("x.canvas");
        fs::write(&path, "{}").unwrap();
        let (_w, rx) = FileWatcher::spawn(&path).expect("spawn");
        std::thread::sleep(Duration::from_millis(50));

        for i in 0..5 {
            fs::write(&path, format!("{{\"v\":{i}}}")).unwrap();
            std::thread::sleep(Duration::from_millis(10));
        }

        let _first = rx.recv_timeout(Duration::from_secs(2)).expect("first");
        let extra = rx.recv_timeout(Duration::from_millis(200));
        assert!(
            extra.is_err(),
            "expected debounce to coalesce; got extra: {extra:?}"
        );
    }
}
