//! Debounced project-content watching shared by editor hosts and automation.

use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};

#[derive(Debug, Default)]
pub struct DebouncedContentChanges {
    paths: BTreeSet<PathBuf>,
    last_change: Option<Instant>,
    suppressed: usize,
}

impl DebouncedContentChanges {
    pub fn begin_transaction(&mut self) {
        self.suppressed += 1;
    }

    pub fn end_transaction(&mut self) {
        self.suppressed = self.suppressed.saturating_sub(1);
    }

    pub fn record(&mut self, path: PathBuf, now: Instant) {
        if self.suppressed == 0 {
            self.paths.insert(path);
            self.last_change = Some(now);
        }
    }

    pub fn take_ready(&mut self, now: Instant, debounce: Duration) -> Vec<PathBuf> {
        if self.suppressed > 0
            || self
                .last_change
                .is_none_or(|last_change| now.duration_since(last_change) < debounce)
        {
            return Vec::new();
        }
        self.last_change = None;
        std::mem::take(&mut self.paths).into_iter().collect()
    }
}

pub struct ContentWatchService {
    _watcher: RecommendedWatcher,
    events: Receiver<notify::Result<notify::Event>>,
    changes: DebouncedContentChanges,
    assets_root: PathBuf,
    suppressed_paths: BTreeMap<PathBuf, Instant>,
}

impl ContentWatchService {
    pub fn watch_project(project_root: &Path) -> Result<Self, String> {
        let (sender, events) = mpsc::channel();
        let mut watcher = notify::recommended_watcher(move |event| {
            let _ = sender.send(event);
        })
        .map_err(|error| format!("could not create content watcher: {error}"))?;
        let assets = project_root.join(crate::ASSETS_DIRECTORY);
        watcher
            .watch(&assets, RecursiveMode::Recursive)
            .map_err(|error| format!("could not watch {}: {error}", assets.display()))?;
        Ok(Self {
            _watcher: watcher,
            events,
            changes: DebouncedContentChanges::default(),
            assets_root: assets,
            suppressed_paths: BTreeMap::new(),
        })
    }

    pub fn begin_transaction(&mut self) {
        self.changes.begin_transaction();
    }

    pub fn end_transaction(&mut self) {
        while self.events.try_recv().is_ok() {}
        self.changes.end_transaction();
    }

    /// Ignores watcher noise for one host-authored file while direct runtime consumers observe
    /// its new revision. This prevents an inline Material slider edit from starting a full project
    /// processing pass without hiding unrelated external changes.
    pub fn suppress_path(&mut self, path: &Path, duration: Duration) {
        let path = if path.is_absolute() {
            path.to_path_buf()
        } else {
            self.assets_root
                .parent()
                .unwrap_or(&self.assets_root)
                .join(path)
        };
        self.suppressed_paths
            .insert(path, Instant::now() + duration);
    }

    /// Drains host events and returns one deterministic path set after the debounce window.
    pub fn poll(&mut self, debounce: Duration) -> Result<Vec<PathBuf>, String> {
        let now = Instant::now();
        self.suppressed_paths.retain(|_, deadline| *deadline >= now);
        while let Ok(event) = self.events.try_recv() {
            let event = event.map_err(|error| format!("content watch failed: {error}"))?;
            for path in event.paths {
                if self
                    .suppressed_paths
                    .get(&path)
                    .is_some_and(|deadline| *deadline >= now)
                {
                    continue;
                }
                if self.is_user_content_path(&path) {
                    self.changes.record(path, now);
                }
            }
        }
        Ok(self.changes.take_ready(now, debounce))
    }

    fn is_user_content_path(&self, path: &Path) -> bool {
        is_user_content_path(&self.assets_root, path)
    }
}

fn is_user_content_path(assets_root: &Path, path: &Path) -> bool {
    let Ok(relative) = path.strip_prefix(assets_root) else {
        return false;
    };
    !crate::is_managed_path(relative)
        && relative != Path::new("content.catalog.ron")
        && relative
            .extension()
            .and_then(|extension| extension.to_str())
            != Some("tmp")
}

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

    #[test]
    fn debounce_deduplicates_and_transaction_suppression_discards_noise() {
        let start = Instant::now();
        let mut changes = DebouncedContentChanges::default();
        changes.record(PathBuf::from("assets/a.png"), start);
        changes.record(PathBuf::from("assets/a.png"), start);
        assert!(changes
            .take_ready(start, Duration::from_millis(50))
            .is_empty());
        assert_eq!(
            changes.take_ready(start + Duration::from_millis(51), Duration::from_millis(50)),
            vec![PathBuf::from("assets/a.png")]
        );

        changes.begin_transaction();
        changes.record(PathBuf::from("assets/internal.ron"), start);
        changes.end_transaction();
        assert!(changes
            .take_ready(start + Duration::from_secs(1), Duration::ZERO)
            .is_empty());
    }

    #[test]
    fn watcher_ignores_managed_and_generated_publication_noise() {
        let root = Path::new("/project/assets");
        assert!(is_user_content_path(
            root,
            Path::new("/project/assets/Props/a.png")
        ));
        assert!(!is_user_content_path(
            root,
            Path::new("/project/assets/.index/registry.ron")
        ));
        assert!(!is_user_content_path(
            root,
            Path::new("/project/assets/content.catalog.ron")
        ));
        assert!(!is_user_content_path(
            root,
            Path::new("/project/assets/meshes/generated/model.static_mesh.ron")
        ));
        assert!(!is_user_content_path(
            root,
            Path::new("/project/assets/Props/a.tmp")
        ));
    }
}
