//! `mcptest run --watch` filesystem watcher loop.
//!
//! The watch loop runs the suite once, then waits for the configured
//! files to change before re-running. Event sources are debounced at
//! 200ms so a typical editor save (which often emits a `Remove + Create`
//! pair) triggers one rerun, not two. Ctrl-C breaks out of the loop and
//! drops the watcher cleanly.

use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::Result;
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::mpsc;

/// Debounce window between filesystem events. Editors often emit
/// `Remove + Create` for one save; 200ms is large enough to coalesce
/// both without making the loop feel sluggish.
pub const DEBOUNCE: Duration = Duration::from_millis(200);

/// Outcome of a single watch tick. The dispatcher matches on this so
/// integration tests can drive the loop without spawning a real
/// subprocess.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Tick {
    /// One or more watched files changed. The first changed path is
    /// included for the "Changed: PATH" banner.
    Changed {
        /// Representative changed path; the banner prints just this one.
        path: PathBuf,
    },
    /// The watcher channel closed before any event arrived. Treated as
    /// an end-of-loop signal so the caller can break out.
    Closed,
    /// The user asked the loop to stop (Ctrl-C, test harness drop).
    Interrupted,
}

/// Watcher wrapping `notify` plus a debounced tokio mpsc channel.
///
/// Held by the run command for the lifetime of the watch session. The
/// watcher releases OS handles when dropped. The channel is tokio so
/// the run loop can race it against `tokio::signal::ctrl_c` without
/// blocking the runtime.
pub struct WatchSession {
    _watcher: RecommendedWatcher,
    rx: mpsc::UnboundedReceiver<PathBuf>,
}

impl WatchSession {
    /// Begin watching every path in `paths`. Each path is registered as
    /// `RecursiveMode::NonRecursive` so we do not flood the channel
    /// with edits in unrelated subdirectories. Missing paths are
    /// silently skipped (the user can edit a not-yet-existing file
    /// into existence and the loop still works on the YAML config
    /// itself).
    pub fn new(paths: &[PathBuf]) -> Result<Self> {
        let (tx, rx) = mpsc::unbounded_channel();
        let mut watcher = RecommendedWatcher::new(
            move |res: notify::Result<Event>| {
                if let Ok(event) = res {
                    let first = event
                        .paths
                        .into_iter()
                        .next()
                        .unwrap_or_else(|| PathBuf::from("(unknown)"));
                    // Best-effort: if the consumer dropped we are tearing down anyway.
                    let _ = tx.send(first);
                }
            },
            Config::default(),
        )?;
        for path in paths {
            if path.exists() {
                watcher.watch(path, RecursiveMode::NonRecursive)?;
            }
        }
        Ok(Self {
            _watcher: watcher,
            rx,
        })
    }

    /// Block until at least one event arrives, then drain anything else
    /// that lands inside the debounce window. Returns a [`Tick`] the
    /// caller can act on. Awaits Ctrl-C in parallel; whichever arm
    /// fires first wins.
    pub async fn next_tick(&mut self) -> Tick {
        tokio::select! {
            ev = self.rx.recv() => match ev {
                Some(path) => self.drain_debounce(path).await,
                None => Tick::Closed,
            },
            _ = tokio::signal::ctrl_c() => Tick::Interrupted,
        }
    }

    /// After the first event lands, swallow follow-ups for the
    /// configured debounce window. The "winner" path stays the first
    /// one so the banner is stable across coalesced edits.
    async fn drain_debounce(&mut self, first: PathBuf) -> Tick {
        let until = tokio::time::sleep(DEBOUNCE);
        tokio::pin!(until);
        loop {
            tokio::select! {
                _ = &mut until => break,
                ev = self.rx.recv() => {
                    if ev.is_none() {
                        break;
                    }
                }
            }
        }
        Tick::Changed { path: first }
    }
}

/// Clear the terminal using the standard ANSI sequence and reposition
/// the cursor at row 1, column 1. We deliberately avoid `crossterm` so
/// the binary stays one less crate heavier; the sequence works on every
/// modern Unix terminal and on Windows 10+ with VT processing enabled.
pub fn clear_terminal<W: Write>(out: &mut W) -> std::io::Result<()> {
    out.write_all(b"\x1B[2J\x1B[1;1H")?;
    out.flush()
}

/// Print the "Changed: PATH" banner before re-running the suite.
pub fn print_change_banner<W: Write>(out: &mut W, path: &Path) -> std::io::Result<()> {
    writeln!(out, "Changed: {}", path.display())
}

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

    #[tokio::test]
    async fn watch_session_fires_on_file_change() {
        let dir = tempfile::tempdir().unwrap();
        let yaml_path = dir.path().join("mcptest.yml");
        std::fs::write(&yaml_path, "tests: []\n").unwrap();
        let mut session = WatchSession::new(std::slice::from_ref(&yaml_path)).unwrap();

        // Modify in a background thread so next_tick blocks long enough
        // to register a real event.
        let p = yaml_path.clone();
        let handle = std::thread::spawn(move || {
            std::thread::sleep(Duration::from_millis(120));
            use std::io::Write as _;
            let mut f = std::fs::OpenOptions::new()
                .append(true)
                .open(&p)
                .expect("open for append");
            writeln!(f, "# touch").expect("write");
            f.sync_all().expect("sync");
        });

        let tick = tokio::time::timeout(Duration::from_secs(5), session.next_tick())
            .await
            .expect("watcher fires within 5s");
        handle.join().unwrap();
        match tick {
            Tick::Changed { .. } => (),
            other => panic!("expected Changed, got {other:?}"),
        }
    }

    #[test]
    fn watch_session_skips_missing_paths_without_error() {
        // A missing path should not abort construction; the watcher
        // simply has nothing to observe yet.
        WatchSession::new(&[PathBuf::from("/definitely/does/not/exist")]).unwrap();
    }

    #[test]
    fn clear_terminal_writes_ansi_sequence() {
        let mut buf = Vec::new();
        clear_terminal(&mut buf).unwrap();
        assert_eq!(buf, b"\x1B[2J\x1B[1;1H");
    }

    #[test]
    fn print_change_banner_renders_the_path() {
        let mut buf = Vec::new();
        print_change_banner(&mut buf, Path::new("tests/foo.yml")).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("Changed: tests/foo.yml"));
    }
}
