// File watcher with 300ms debounce.
// Watches for source and config file changes and triggers scans.

use std::path::Path;
use std::sync::mpsc;
use std::time::{Duration, Instant};

use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use tracing::{info, warn};

use crate::infra::discovery::IgnoreWalkDiscovery;
use astmap_core::ScanStore;
use astmap_languages::DefaultLanguageRegistry;

#[derive(Debug, thiserror::Error)]
pub enum WatchError {
    #[error("notify error: {0}")]
    Notify(#[from] notify::Error),
    #[error("scan error: {0}")]
    Scan(#[from] astmap_core::ScanError),
    #[error("channel closed")]
    ChannelClosed,
}

pub(crate) const DEBOUNCE_MS: u64 = 300;

/// Config file names that affect scan behavior.
/// Changes to these trigger a scan (the scanner detects the hash mismatch and forces full re-scan).
const CONFIG_FILES: &[&str] = &["tsconfig.json", "pyproject.toml", "go.mod"];

/// Classification of a file change event.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ChangeKind {
    /// Source file changed — incremental scan sufficient.
    Source,
    /// Config file changed — scanner will detect hash mismatch and force full scan.
    Config,
}

/// Classify a file system event. Returns the kind of change if actionable, None if irrelevant.
pub(crate) fn classify_event(event: &notify::Event, project_dir: &Path) -> Option<ChangeKind> {
    match event.kind {
        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {}
        _ => return None,
    }

    let mut result = None;
    for path in &event.paths {
        let rel = path.strip_prefix(project_dir).unwrap_or(path);
        let rel_str = rel.to_string_lossy();
        if rel_str.starts_with("target/")
            || rel_str.starts_with(".astmap/")
            || rel_str.starts_with("vendor/")
        {
            continue;
        }

        // Config files take priority — return immediately
        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
            if CONFIG_FILES.contains(&name) {
                return Some(ChangeKind::Config);
            }
        }

        // Source files
        if result.is_none() {
            let is_source = path
                .extension()
                .and_then(|e| e.to_str())
                .is_some_and(|ext| astmap_languages::supported_extensions().contains(&ext));
            if is_source {
                result = Some(ChangeKind::Source);
            }
        }
    }

    result
}

/// Watch the project directory for file changes and trigger scans.
/// Detects both source file and config file changes.
/// This blocks the current thread. Use in a dedicated thread or tokio::spawn_blocking.
pub fn watch_and_scan(project_dir: &Path, db: &dyn ScanStore) -> Result<(), WatchError> {
    let (tx, rx) = mpsc::channel();

    let mut watcher = RecommendedWatcher::new(
        move |res: Result<notify::Event, notify::Error>| {
            if let Ok(event) = res {
                let _ = tx.send(event);
            }
        },
        notify::Config::default(),
    )?;

    watcher.watch(project_dir, RecursiveMode::Recursive)?;
    info!(
        "watching {} for file changes (300ms debounce)",
        project_dir.display()
    );

    let mut last_event = Instant::now();
    let mut pending = false;

    loop {
        let timeout = if pending {
            Duration::from_millis(DEBOUNCE_MS)
        } else {
            Duration::from_secs(60) // idle poll
        };

        match rx.recv_timeout(timeout) {
            Ok(event) => {
                if classify_event(&event, project_dir).is_some() {
                    pending = true;
                    last_event = Instant::now();
                }
            }
            Err(mpsc::RecvTimeoutError::Timeout) => {
                if pending && last_event.elapsed() >= Duration::from_millis(DEBOUNCE_MS) {
                    info!("file changes detected, running scan...");
                    // The scanner's config_changed() detects config hash mismatches
                    // and forces full re-scan automatically when needed.
                    match astmap_core::scanner::scan(
                        project_dir,
                        db,
                        false,
                        &DefaultLanguageRegistry,
                        &IgnoreWalkDiscovery,
                    ) {
                        Ok(result) => {
                            if result.files_total > 0 {
                                info!(
                                    "scan complete: {} files, {} symbols, {} deps",
                                    result.files_total, result.symbols, result.dependencies
                                );
                            }
                        }
                        Err(e) => warn!("scan failed: {}", e),
                    }
                    pending = false;
                }
            }
            Err(mpsc::RecvTimeoutError::Disconnected) => {
                return Err(WatchError::ChannelClosed);
            }
        }
    }
}

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

    fn make_event(kind: EventKind, paths: Vec<PathBuf>) -> notify::Event {
        notify::Event {
            kind,
            paths,
            attrs: Default::default(),
        }
    }

    #[test]
    fn test_source_modify_rs() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![PathBuf::from("/project/src/main.rs")],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Source));
    }

    #[test]
    fn test_source_modify_ts() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![PathBuf::from("/project/src/app.ts")],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Source));
    }

    #[test]
    fn test_source_modify_tsx() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![PathBuf::from("/project/src/Button.tsx")],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Source));
    }

    #[test]
    fn test_source_create_rs() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Create(notify::event::CreateKind::File),
            vec![PathBuf::from("/project/src/new.rs")],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Source));
    }

    #[test]
    fn test_source_remove_rs() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Remove(notify::event::RemoveKind::File),
            vec![PathBuf::from("/project/src/old.rs")],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Source));
    }

    #[test]
    fn test_config_tsconfig() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![PathBuf::from("/project/tsconfig.json")],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Config));
    }

    #[test]
    fn test_config_pyproject() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![PathBuf::from("/project/pyproject.toml")],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Config));
    }

    #[test]
    fn test_config_priority_over_source() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![
                PathBuf::from("/project/src/main.rs"),
                PathBuf::from("/project/tsconfig.json"),
            ],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Config));
    }

    #[test]
    fn test_config_in_target_ignored() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![PathBuf::from("/project/target/tsconfig.json")],
        );
        assert_eq!(classify_event(&event, &project), None);
    }

    #[test]
    fn test_irrelevant_non_source_file() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![PathBuf::from("/project/Cargo.toml")],
        );
        assert_eq!(classify_event(&event, &project), None);
    }

    #[test]
    fn test_irrelevant_css_file() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![PathBuf::from("/project/src/style.css")],
        );
        assert_eq!(classify_event(&event, &project), None);
    }

    #[test]
    fn test_irrelevant_target_dir() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![PathBuf::from("/project/target/debug/main.rs")],
        );
        assert_eq!(classify_event(&event, &project), None);
    }

    #[test]
    fn test_irrelevant_astmap_dir() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![PathBuf::from("/project/.astmap/index.rs")],
        );
        assert_eq!(classify_event(&event, &project), None);
    }

    #[test]
    fn test_irrelevant_access_event() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Access(notify::event::AccessKind::Read),
            vec![PathBuf::from("/project/src/main.rs")],
        );
        assert_eq!(classify_event(&event, &project), None);
    }

    #[test]
    fn test_irrelevant_other_event() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Other,
            vec![PathBuf::from("/project/src/main.rs")],
        );
        assert_eq!(classify_event(&event, &project), None);
    }

    // ── Additional edge case tests ──

    #[test]
    fn test_source_modify_py() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![PathBuf::from("/project/src/app.py")],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Source));
    }

    #[test]
    fn test_empty_paths_event() {
        let project = PathBuf::from("/project");
        let event = make_event(EventKind::Modify(notify::event::ModifyKind::Any), vec![]);
        assert_eq!(classify_event(&event, &project), None);
    }

    #[test]
    fn test_multiple_source_files_in_event() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![
                PathBuf::from("/project/src/a.rs"),
                PathBuf::from("/project/src/b.ts"),
            ],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Source));
    }

    #[test]
    fn test_mixed_irrelevant_and_source() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![
                PathBuf::from("/project/Cargo.toml"),
                PathBuf::from("/project/src/lib.rs"),
            ],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Source));
    }

    #[test]
    fn test_only_irrelevant_files() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![
                PathBuf::from("/project/README.md"),
                PathBuf::from("/project/Cargo.toml"),
            ],
        );
        assert_eq!(classify_event(&event, &project), None);
    }

    #[test]
    fn test_astmap_dir_source_file_ignored() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Modify(notify::event::ModifyKind::Any),
            vec![PathBuf::from("/project/.astmap/cache.rs")],
        );
        assert_eq!(classify_event(&event, &project), None);
    }

    #[test]
    fn test_create_py_file() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Create(notify::event::CreateKind::File),
            vec![PathBuf::from("/project/src/new_module.py")],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Source));
    }

    #[test]
    fn test_remove_tsx_file() {
        let project = PathBuf::from("/project");
        let event = make_event(
            EventKind::Remove(notify::event::RemoveKind::File),
            vec![PathBuf::from("/project/src/Component.tsx")],
        );
        assert_eq!(classify_event(&event, &project), Some(ChangeKind::Source));
    }

    #[test]
    fn test_watch_error_display() {
        let err = WatchError::ChannelClosed;
        assert_eq!(err.to_string(), "channel closed");
    }

    #[test]
    fn test_change_kind_equality() {
        assert_eq!(ChangeKind::Source, ChangeKind::Source);
        assert_eq!(ChangeKind::Config, ChangeKind::Config);
        assert_ne!(ChangeKind::Source, ChangeKind::Config);
    }

    #[test]
    fn test_debounce_ms_value() {
        assert_eq!(DEBOUNCE_MS, 300);
    }
}
