//! Real-time directory watcher for p3ak-vault.
//!
//! Polls a directory for new or modified supported files and auto-ingests them
//! into the vault. Uses polling instead of OS-level inotify/FSEvents to keep
//! the implementation cross-platform, dependency-light, and inspectable.
//!
//! Supported formats: .md .txt .html .pdf .docx .csv .tsv .json .svg
//!
//! # Output format
//!
//! Each event is a JSON object on its own line (newline-delimited JSON):
//! ```json
//! {"event":"started","dir":"/path/to/dir","room":"work","interval_secs":5}
//! {"event":"ingest","file":"report.pdf","doc_id":42,"action":"added","format":"pdf","elapsed_ms":120}
//! {"event":"ingest","file":"old-doc.md","doc_id":43,"action":"skipped","format":"markdown","elapsed_ms":2}
//! {"event":"tick","scanned":12,"new":0,"modified":0}
//! {"event":"stopped","ingested":3}
//! ```
//!
//! # Shutdown
//!
//! Send SIGINT (Ctrl-C) to exit cleanly. The process exits with code 0.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime};

use anyhow::{Context, Result};
use vault_core::binary_store;
use vault_core::ingest;

/// State tracked per file: last-known modification time.
type FileStates = HashMap<PathBuf, SystemTime>;

/// Run the directory watcher until SIGINT or a fatal error.
pub fn run_watch(
    vault_path: &Path,
    watch_dir: &Path,
    room: Option<&str>,
    interval_secs: u64,
    passphrase: Option<&str>,
) -> Result<()> {
    // Validate inputs up-front
    if !watch_dir.is_dir() {
        anyhow::bail!("watch directory does not exist: {}", watch_dir.display());
    }

    // Set up Ctrl-C handler
    let running = Arc::new(AtomicBool::new(true));
    let r = running.clone();
    ctrlc::set_handler(move || {
        r.store(false, Ordering::SeqCst);
    })
    .context("failed to set Ctrl-C handler")?;

    let room_str = room.unwrap_or("default").to_string();

    emit_event(serde_json::json!({
        "event": "started",
        "vault": vault_path.display().to_string(),
        "dir": watch_dir.display().to_string(),
        "room": room_str,
        "interval_secs": interval_secs,
    }));

    let mut known: FileStates = HashMap::new();
    let mut total_ingested = 0u64;

    // Initial scan — ingest all existing files
    let initial_files = collect_supported_files(watch_dir)?;
    for file in &initial_files {
        let mtime = mtime_of(file);
        known.insert(file.clone(), mtime);
        if let Some(result) = ingest_file(vault_path, file, &room_str, passphrase) {
            if matches!(
                result.get("action").and_then(|s| s.as_str()),
                Some("added" | "updated")
            ) {
                total_ingested += 1;
            }
            emit_event(result);
        }
    }

    emit_event(serde_json::json!({
        "event": "initial_scan_complete",
        "files_found": initial_files.len(),
        "ingested": total_ingested,
    }));

    // Poll loop
    let interval = Duration::from_secs(interval_secs.max(1));
    while running.load(Ordering::SeqCst) {
        std::thread::sleep(interval);

        if !running.load(Ordering::SeqCst) {
            break;
        }

        let current_files = collect_supported_files(watch_dir)?;
        let mut new_count = 0u64;
        let mut modified_count = 0u64;

        for file in &current_files {
            let mtime = mtime_of(file);
            match known.get(file) {
                None => {
                    // New file
                    known.insert(file.clone(), mtime);
                    if let Some(result) = ingest_file(vault_path, file, &room_str, passphrase) {
                        match result.get("action").and_then(|s| s.as_str()) {
                            Some("added") => {
                                total_ingested += 1;
                                new_count += 1;
                            }
                            Some("updated") => {
                                total_ingested += 1;
                                modified_count += 1;
                            }
                            _ => {}
                        }
                        emit_event(result);
                    }
                }
                Some(&prev_mtime) if mtime > prev_mtime => {
                    // Modified file
                    known.insert(file.clone(), mtime);
                    if let Some(result) = ingest_file(vault_path, file, &room_str, passphrase) {
                        match result.get("action").and_then(|s| s.as_str()) {
                            Some("added") => {
                                total_ingested += 1;
                                new_count += 1;
                            }
                            Some("updated") => {
                                total_ingested += 1;
                                modified_count += 1;
                            }
                            _ => {}
                        }
                        emit_event(result);
                    }
                }
                _ => {} // unchanged
            }
        }

        emit_event(serde_json::json!({
            "event": "tick",
            "scanned": current_files.len(),
            "new": new_count,
            "modified": modified_count,
        }));
    }

    emit_event(serde_json::json!({
        "event": "stopped",
        "total_ingested": total_ingested,
    }));

    Ok(())
}

// ─── helpers ──────────────────────────────────────────────────────────────────

/// Collect all files with supported ingest extensions in a directory (non-recursive, sorted).
fn collect_supported_files(dir: &Path) -> Result<Vec<PathBuf>> {
    use vault_core::convert::SUPPORTED_EXTENSIONS;
    let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
        .with_context(|| format!("cannot read directory: {}", dir.display()))?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| {
            if !p.is_file() {
                return false;
            }
            let ext = p
                .extension()
                .and_then(|e| e.to_str())
                .map(|e| e.to_lowercase())
                .unwrap_or_default();
            SUPPORTED_EXTENSIONS.contains(&ext.as_str())
        })
        .collect();
    files.sort();
    Ok(files)
}

/// Get the last modification time of a file, returning UNIX_EPOCH on failure.
fn mtime_of(path: &Path) -> SystemTime {
    path.metadata()
        .and_then(|m| m.modified())
        .unwrap_or(SystemTime::UNIX_EPOCH)
}

/// Attempt to ingest a file, returning a JSON event object.
///
/// Returns `None` if the file has no name (shouldn't happen in practice).
/// Errors are captured into the JSON rather than propagated.
fn ingest_file(
    vault_path: &Path,
    file: &Path,
    room: &str,
    passphrase: Option<&str>,
) -> Option<serde_json::Value> {
    let filename = file.file_name()?.to_string_lossy().to_string();
    let start = std::time::Instant::now();

    // Load vault, ingest, save (only saves when vault was modified)
    let result = (|| -> Result<ingest::IngestReport> {
        let mut vault = binary_store::load(vault_path, passphrase)?;
        let report = ingest::ingest_file(&mut vault, file, room)?;
        if report.action != ingest::IngestAction::Skipped {
            binary_store::save(&vault, vault_path, passphrase)?;
        }
        Ok(report)
    })();

    let elapsed_ms = start.elapsed().as_millis();

    Some(match result {
        Ok(report) => {
            let action = match report.action {
                ingest::IngestAction::Added => "added",
                ingest::IngestAction::Updated => "updated",
                ingest::IngestAction::Skipped => "skipped",
            };
            serde_json::json!({
                "event": "ingest",
                "file": filename,
                "doc_id": report.doc_id,
                "action": action,
                "format": report.source_format,
                "elapsed_ms": elapsed_ms,
            })
        }
        Err(e) => serde_json::json!({
            "event": "ingest",
            "file": filename,
            "action": "error",
            "error": e.to_string(),
            "elapsed_ms": elapsed_ms,
        }),
    })
}

/// Print a JSON event to stdout (newline-delimited JSON).
fn emit_event(value: serde_json::Value) {
    println!("{}", value);
}

// ─── unit tests ───────────────────────────────────────────────────────────────

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

    #[test]
    fn test_collect_supported_empty_dir() {
        let dir = TempDir::new().unwrap();
        let files = collect_supported_files(dir.path()).unwrap();
        assert!(files.is_empty());
    }

    #[test]
    fn test_collect_supported_filters_unsupported() {
        let dir = TempDir::new().unwrap();
        // Supported formats (Tier 1) — all should be collected
        std::fs::write(dir.path().join("doc.md"), "# Hello").unwrap();
        std::fs::write(dir.path().join("note.txt"), "plain text").unwrap();
        std::fs::write(dir.path().join("data.json"), "{}").unwrap();
        // Supported Tier 3 formats — also collected (conversion may fail at runtime
        // if the tool is missing, but the file is still in scope for watch)
        std::fs::write(dir.path().join("photo.png"), b"\x89PNG" as &[u8]).unwrap();
        // Genuinely unsupported — should be excluded
        std::fs::write(dir.path().join("archive.zip"), b"PK" as &[u8]).unwrap();
        std::fs::write(dir.path().join("data.xyz"), b"binary" as &[u8]).unwrap();
        let files = collect_supported_files(dir.path()).unwrap();
        assert_eq!(
            files.len(),
            4,
            "zip and xyz should be filtered out, png included (Tier 3)"
        );
        let names: Vec<_> = files
            .iter()
            .map(|f| f.file_name().unwrap().to_str().unwrap().to_string())
            .collect();
        assert!(names.contains(&"doc.md".to_string()));
        assert!(names.contains(&"note.txt".to_string()));
        assert!(names.contains(&"data.json".to_string()));
        assert!(
            names.contains(&"photo.png".to_string()),
            "png is Tier 3 — included by watch"
        );
    }

    #[test]
    fn test_collect_supported_sorted() {
        let dir = TempDir::new().unwrap();
        for name in ["c.md", "a.md", "b.md"] {
            std::fs::write(dir.path().join(name), "# doc").unwrap();
        }
        let files = collect_supported_files(dir.path()).unwrap();
        let names: Vec<_> = files
            .iter()
            .map(|f: &std::path::PathBuf| f.file_name().unwrap().to_str().unwrap().to_string())
            .collect();
        assert_eq!(names, ["a.md", "b.md", "c.md"]);
    }

    #[test]
    fn test_mtime_of_existing_file() {
        let dir = TempDir::new().unwrap();
        let f = dir.path().join("test.md");
        std::fs::write(&f, "# Test").unwrap();
        let mtime = mtime_of(&f);
        assert!(mtime > SystemTime::UNIX_EPOCH);
    }

    #[test]
    fn test_mtime_of_nonexistent_returns_unix_epoch() {
        let mtime = mtime_of(Path::new("/no/such/file.md"));
        assert_eq!(mtime, SystemTime::UNIX_EPOCH);
    }

    #[test]
    fn test_collect_nonexistent_dir_errors() {
        let result = collect_supported_files(Path::new("/no/such/directory"));
        assert!(result.is_err());
    }

    #[test]
    fn test_watch_nonexistent_dir_errors() {
        let dir = TempDir::new().unwrap();
        let vault_path = dir.path().join("v.vault");
        // create minimal vault
        std::fs::write(&vault_path, b"").unwrap();
        let result = run_watch(&vault_path, Path::new("/no/such/watch-dir"), None, 5, None);
        assert!(result.is_err());
    }
}
