use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{mpsc, Arc};
use std::thread;

use crate::cache::Cache;
use crate::plugin::{CleanAction, CleanTarget, DirChild, Plugin};

pub enum ScanEvent {
    Target(CleanTarget),
    Error(String),
    Done,
}

pub struct LiveScan {
    pub rx: mpsc::Receiver<ScanEvent>,
    pub plugin_count: usize,
}

fn target_cache_key(target: &CleanTarget) -> String {
    match &target.action {
        CleanAction::DeletePath(p) | CleanAction::DeleteContents(p) => {
            p.to_string_lossy().to_string()
        }
        CleanAction::RunCommand { description, .. } => format!("__cmd__:{}", description),
    }
}

/// Directories that no plugin cares about — skip to avoid wasted I/O.
const SYSTEM_SKIP: &[&str] = &[
    ".Trash",
    "Applications",
    "Music",
    "Movies",
    "Pictures",
    "Photos Library.photoslibrary",
];

/// Returns true if the path is a symbolic link.
fn is_symlink(path: &Path) -> bool {
    path.symlink_metadata()
        .map(|m| m.file_type().is_symlink())
        .unwrap_or(false)
}

/// Start scanning and return a live handle.
/// Uses a single parallel walk (jwalk) and fans out each directory to interested plugins
/// based on declared markers, avoiding redundant filesystem traversals.
#[allow(clippy::too_many_arguments)]
pub fn start_scan(
    roots: &[&Path],
    plugins: Vec<Box<dyn Plugin>>,
    min_size: u64,
    include_global: bool,
    max_depth: usize,
    cache: &Cache,
    exclude: &[PathBuf],
) -> LiveScan {
    let (tx, rx) = mpsc::channel();
    let plugin_names: Vec<&str> = plugins.iter().map(|p| p.name()).collect();

    // Send cached targets immediately, collect their keys for dedup
    let mut cached_keys: HashSet<String> = HashSet::new();
    if cache.has_entries(&plugin_names) {
        let cached = cache.load_targets(&plugin_names, min_size);
        for t in cached {
            cached_keys.insert(target_cache_key(&t));
            let _ = tx.send(ScanEvent::Target(t));
        }
    }

    let roots_owned: Vec<PathBuf> = roots.iter().map(|p| p.to_path_buf()).collect();
    let exclude_owned: Vec<PathBuf> = exclude.to_vec();

    // Build skip set: plugin-declared + system directories
    let mut skip_set: HashSet<String> = plugins
        .iter()
        .flat_map(|p| p.skip_subdirectories())
        .map(|s| s.to_string())
        .collect();
    for s in SYSTEM_SKIP {
        skip_set.insert(s.to_string());
    }

    // Build marker index for efficient plugin dispatch.
    // Plugins with markers: only called when a marker file/dir is present.
    // Plugins with scans_all_directories: called for every directory.
    // Global-only plugins (no markers, no scans_all_directories): skip the walk entirely.
    let mut marker_to_plugins: HashMap<String, Vec<usize>> = HashMap::new();
    let mut all_dir_plugins: Vec<usize> = Vec::new();
    for (i, plugin) in plugins.iter().enumerate() {
        let markers = plugin.markers();
        if markers.is_empty() {
            if plugin.scans_all_directories() {
                all_dir_plugins.push(i);
            }
        } else {
            for marker in markers {
                marker_to_plugins
                    .entry(marker.to_string())
                    .or_default()
                    .push(i);
            }
        }
    }
    let unique_markers: Vec<String> = marker_to_plugins.keys().cloned().collect();

    let skip = Arc::new(skip_set);
    let known = Arc::new(cached_keys);
    let plugins: Arc<Vec<Box<dyn Plugin>>> = Arc::new(plugins);

    // Single walk thread — jwalk parallelizes readdir internally
    let walk_tx = tx.clone();
    let needs_walk = !unique_markers.is_empty() || !all_dir_plugins.is_empty();
    thread::spawn(move || {
        // Skip the entire directory walk when no plugin needs it (e.g. --only docker).
        // Global-only plugins are handled in the global_targets pass below.
        if needs_walk {
            for root in &roots_owned {
                let skip_for_walker = Arc::clone(&skip);
                let exclude_for_walker = exclude_owned.clone();
                let walker = jwalk::WalkDir::new(root)
                    .max_depth(max_depth)
                    .follow_links(false)
                    .skip_hidden(false)
                    .process_read_dir(move |_depth, parent_path, _state, children| {
                        children.retain(|entry_result| {
                            entry_result.as_ref().map_or(true, |entry| {
                                if entry.file_type().is_dir() {
                                    let name = entry.file_name.to_string_lossy();
                                    if skip_for_walker.contains(name.as_ref()) {
                                        return false;
                                    }
                                    // Skip excluded paths
                                    let full_path = parent_path.join(&entry.file_name);
                                    if exclude_for_walker
                                        .iter()
                                        .any(|ex| full_path.starts_with(ex))
                                    {
                                        return false;
                                    }
                                }
                                true
                            })
                        });
                    });

                for entry in walker {
                    let entry = match entry {
                        Ok(e) => e,
                        Err(e) => {
                            let _ = walk_tx.send(ScanEvent::Error(format!("{}", e)));
                            continue;
                        }
                    };

                    if !entry.file_type().is_dir() {
                        continue;
                    }

                    let dir = entry.path();

                    // Read directory entries once — replaces N per-marker stat() calls
                    // with a single readdir + hash lookups. The OS page cache makes this
                    // essentially free since jwalk just read the same directory.
                    let read_entries: Vec<std::fs::DirEntry> = match std::fs::read_dir(&dir) {
                        Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
                        Err(_) => continue,
                    };

                    let entry_names: HashSet<String> = read_entries
                        .iter()
                        .map(|e| e.file_name().to_string_lossy().to_string())
                        .collect();

                    // Determine which plugins to call using marker index
                    let mut plugins_to_call = all_dir_plugins.clone();
                    for marker in &unique_markers {
                        if entry_names.contains(marker.as_str()) {
                            if let Some(indices) = marker_to_plugins.get(marker) {
                                plugins_to_call.extend(indices);
                            }
                        }
                    }

                    if plugins_to_call.is_empty() {
                        continue;
                    }

                    plugins_to_call.sort_unstable();
                    plugins_to_call.dedup();

                    // Build DirChild entries only when all_dir_plugins are in the call list
                    let needs_entries = all_dir_plugins
                        .iter()
                        .any(|idx| plugins_to_call.contains(idx));
                    let dir_children: Vec<DirChild> = if needs_entries {
                        read_entries
                            .iter()
                            .filter_map(|e| {
                                let ft = e.file_type().ok()?;
                                Some(DirChild {
                                    name: e.file_name().to_string_lossy().to_string(),
                                    path: e.path(),
                                    is_file: ft.is_file(),
                                })
                            })
                            .collect()
                    } else {
                        Vec::new()
                    };

                    for idx in plugins_to_call {
                        let found = plugins[idx].scan_with_entries(&dir, &dir_children);
                        for target in found {
                            // Skip targets that are symlinks — deleting them won't free real space
                            if let CleanAction::DeletePath(ref p)
                            | CleanAction::DeleteContents(ref p) = target.action
                            {
                                if is_symlink(p) {
                                    continue;
                                }
                            }

                            if target.size_bytes >= min_size {
                                let key = target_cache_key(&target);
                                if !known.contains(&key) {
                                    let _ = walk_tx.send(ScanEvent::Target(target));
                                }
                            }
                        }
                    }
                }
            }
        } // needs_walk

        // Global targets from all plugins
        if include_global {
            for plugin in plugins.iter() {
                for target in plugin.global_targets() {
                    // Skip symlink targets
                    if let CleanAction::DeletePath(ref p) | CleanAction::DeleteContents(ref p) =
                        target.action
                    {
                        if is_symlink(p) {
                            continue;
                        }
                    }

                    if target.size_bytes >= min_size {
                        let key = target_cache_key(&target);
                        if !known.contains(&key) {
                            let _ = walk_tx.send(ScanEvent::Target(target));
                        }
                    }
                }
            }
        }

        let _ = walk_tx.send(ScanEvent::Done);
    });

    drop(tx);
    LiveScan {
        rx,
        plugin_count: 1,
    }
}
