use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};

use hjkl_buffer::View;

use crate::logic::{PickerAction, PickerLogic, RequeryMode};
use crate::preview::load_preview;

/// File-source: gitignore-aware cwd walker. Items are paths relative to
/// `root`, preview reads from disk capped at `PREVIEW_MAX_BYTES` with a
/// binary-byte heuristic.
///
/// Bonsai-agnostic — the preview returns just the file contents. The
/// host (e.g. apps/hjkl) is responsible for layering syntax highlight
/// spans by reading [`PickerLogic::preview_path`] and feeding the
/// buffer bytes through its own highlighter.
pub struct FileSource {
    pub root: PathBuf,
    pub items: Arc<Mutex<Vec<PathBuf>>>,
    scan_done: Arc<AtomicBool>,
}

impl FileSource {
    pub fn new(root: PathBuf) -> Self {
        Self {
            root,
            items: Arc::new(Mutex::new(Vec::new())),
            scan_done: Arc::new(AtomicBool::new(false)),
        }
    }
}

impl PickerLogic for FileSource {
    fn title(&self) -> &str {
        "files"
    }

    fn item_count(&self) -> usize {
        self.items.lock().map_or(0, |g| g.len())
    }

    fn label(&self, idx: usize) -> String {
        // Two-cell prefix matches BufferSource's marker column so labels
        // stay vertically aligned across pickers.
        self.items
            .lock()
            .ok()
            .and_then(|g| g.get(idx).map(|p| format!("  {}", p.to_string_lossy())))
            .unwrap_or_default()
    }

    fn match_text(&self, idx: usize) -> String {
        self.label(idx)
    }

    fn preview(&self, idx: usize) -> (View, String) {
        let Some(path) = self.items.lock().ok().and_then(|g| g.get(idx).cloned()) else {
            return (View::new(), String::new());
        };
        let abs = self.root.join(&path);
        let (content, status) = load_preview(&abs);
        (View::from_str(&content), status)
    }

    fn preview_path(&self, idx: usize) -> Option<PathBuf> {
        let rel = self.items.lock().ok()?.get(idx).cloned()?;
        Some(self.root.join(rel))
    }

    fn select(&self, _idx: usize) -> PickerAction {
        // FileSource is always wrapped by an app-side source that
        // overrides `select` and boxes an app-specific `AppAction`.
        // This base impl is never called directly.
        PickerAction::None
    }

    fn requery_mode(&self) -> RequeryMode {
        RequeryMode::FilterInMemory
    }

    fn enumerate(
        &mut self,
        _query: Option<&str>,
        cancel: Arc<AtomicBool>,
    ) -> Option<JoinHandle<()>> {
        let items = Arc::clone(&self.items);
        let done = Arc::clone(&self.scan_done);
        let root = self.root.clone();
        // Reset for re-enumerate.
        if let Ok(mut g) = items.lock() {
            g.clear();
        }
        done.store(false, Ordering::Release);
        thread::Builder::new()
            .name("hjkl-picker-scan".into())
            .spawn(move || scan_walk(&root, &items, &done, &cancel))
            .ok()
    }
}

/// Background walker — streams `is_file()` entries into `items`, over the
/// files [`hjkl_fs::project`] considers part of the project.
///
/// The policy lives there rather than here because the explorer tree and both
/// grep paths have to reach the same verdict: a file you can see in the tree
/// but cannot find in this picker is the bug that shared module exists to stop.
fn scan_walk(
    root: &Path,
    items: &Arc<Mutex<Vec<PathBuf>>>,
    done: &Arc<AtomicBool>,
    cancel: &Arc<AtomicBool>,
) {
    let walk = hjkl_fs::project::walk_builder(root).build();
    let mut batch: Vec<PathBuf> = Vec::with_capacity(256);
    let mut total = 0usize;
    const HARD_CAP: usize = 50_000;
    for entry in walk {
        if cancel.load(Ordering::Acquire) {
            break;
        }
        let Ok(entry) = entry else { continue };
        let Some(ft) = entry.file_type() else {
            continue;
        };
        if !ft.is_file() {
            continue;
        }
        let path = entry.into_path();
        let rel = path
            .strip_prefix(root)
            .map(|p| p.to_path_buf())
            .unwrap_or(path);
        batch.push(rel);
        total += 1;
        if batch.len() >= 256
            && let Ok(mut g) = items.lock()
        {
            g.extend(batch.drain(..));
        }
        if total >= HARD_CAP {
            break;
        }
    }
    if let Ok(mut g) = items.lock() {
        g.extend(batch.drain(..));
    }
    done.store(true, Ordering::Release);
}

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

    #[test]
    fn txt_preview_returns_buffer_and_path() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("notes.txt");
        std::fs::write(&path, "hello world\nthis is plain text\n").unwrap();

        let mut source = FileSource::new(tmp.path().to_path_buf());
        let cancel = Arc::new(AtomicBool::new(false));
        let _handle = source.enumerate(None, Arc::clone(&cancel));
        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
        loop {
            if source.item_count() > 0 {
                break;
            }
            if std::time::Instant::now() >= deadline {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(5));
        }

        let count = source.item_count();
        let mut found_idx = None;
        for i in 0..count {
            if source.label(i).contains("notes.txt") {
                found_idx = Some(i);
                break;
            }
        }
        let idx = found_idx.expect("notes.txt should appear in FileSource");
        let (_buf, status) = source.preview(idx);
        assert!(status.is_empty(), "unexpected status: {status:?}");
        let preview_path = source.preview_path(idx).expect("preview_path");
        assert!(preview_path.ends_with("notes.txt"));
    }

    /// Collect every label the source enumerates, or give up after `2s`.
    fn labels_of(root: &Path) -> Vec<String> {
        let mut source = FileSource::new(root.to_path_buf());
        let cancel = Arc::new(AtomicBool::new(false));
        let handle = source.enumerate(None, Arc::clone(&cancel));
        if let Some(h) = handle {
            let _ = h.join();
        }
        (0..source.item_count()).map(|i| source.label(i)).collect()
    }

    /// The picker enumerates what `hjkl_fs::project` calls the project: a
    /// dotfile is findable, an ignored one is not. Before the policy was
    /// shared, the first half of this was false — the walk skipped hidden
    /// files, so a file the explorer showed could not be opened from here.
    #[test]
    fn enumerates_dotfiles_and_skips_ignored_files() {
        let tmp = tempfile::tempdir().unwrap();
        let r = tmp.path();
        std::fs::create_dir_all(r.join(".git")).unwrap();
        std::fs::write(r.join(".git").join("config"), "x").unwrap();
        std::fs::write(r.join(".gitignore"), "ignored.txt\n").unwrap();
        std::fs::write(r.join(".env.example"), "x").unwrap();
        std::fs::write(r.join("ignored.txt"), "x").unwrap();
        std::fs::write(r.join("kept.txt"), "x").unwrap();

        let labels = labels_of(r);

        assert!(
            labels.iter().any(|l| l.contains(".env.example")),
            "a dotfile must be findable: {labels:?}"
        );
        assert!(labels.iter().any(|l| l.contains("kept.txt")), "{labels:?}");
        assert!(
            !labels.iter().any(|l| l.contains("ignored.txt")),
            "gitignored files must not be listed: {labels:?}"
        );
        assert!(
            !labels
                .iter()
                .any(|l| l.contains(".git/") || l.contains(".git\\")),
            "`.git` internals must not be listed: {labels:?}"
        );
    }
}
