use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};

use crate::config::ResolvedTarget;
use crate::index::ignore_rules::IgnoreMatcher;

/// Walk one target's directories, honoring the repo's compiled `.gitignore` rules (root + nested)
/// plus the hardcoded floor (see [`IgnoreMatcher`]). `ignore` is compiled once per index pass and
/// shared across targets so the walker and watcher classify paths identically (issue #62).
pub fn walk_target(
    root: &Path,
    target: &ResolvedTarget,
    ignore: &IgnoreMatcher,
) -> anyhow::Result<Vec<PathBuf>> {
    let mut files = BTreeSet::new();
    for directory in &target.directories {
        let dir = root.join(directory);
        // A configured target directory may be ABSENT under `root` — most importantly when the
        // config came from a linked worktree that added a BRANCH-ONLY target dir, but `Config.root`
        // is anchored to the MAIN checkout (so the shared index has one base commit) (#219 review).
        // Base discovery over main must SKIP the missing dir, not hard-error: there is nothing to
        // index there on main, and the linked worktree's overlay pass picks up the branch-only
        // files separately. Without this, a hook/maintenance launched from such a branch
        // aborted before `refresh_worktree_overlays` could run.
        if !dir.is_dir() {
            continue;
        }
        walk_dir(root, &dir, target, ignore, &mut files)?;
    }
    Ok(files.into_iter().collect())
}

fn walk_dir(
    root: &Path,
    dir: &Path,
    target: &ResolvedTarget,
    ignore: &IgnoreMatcher,
    files: &mut BTreeSet<PathBuf>,
) -> anyhow::Result<()> {
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        let file_type = entry.file_type()?;
        if file_type.is_symlink() {
            continue;
        }
        // Honor `.gitignore` (root + nested) and the hardcoded floor. We test the full path with
        // its dir-ness so nested-gitignore scoping and `foo/`-style dir-only rules resolve
        // correctly; the floor (`.git`, `.rag-rat`, `target`, …) short-circuits inside
        // `is_ignored`.
        if ignore.is_ignored(&path, file_type.is_dir()) {
            continue;
        }
        if file_type.is_dir() {
            walk_dir(root, &path, target, ignore, files)?;
        } else if file_type.is_file() && is_target_file(root, &path, target) {
            files.insert(path);
        }
    }
    Ok(())
}

fn is_target_file(root: &Path, path: &Path, target: &ResolvedTarget) -> bool {
    // The target's language must CLAIM this extension (not bare `from_path` detection): that's what
    // lets a `cpp` binding index its `.h` headers, which bare detection resolves to C. The file is
    // then parsed as the target's language (see `discovery::target_for_path`).
    if !target.language.claims_path(path) {
        return false;
    }
    let relative = path.strip_prefix(root).unwrap_or(path);
    let relative = relative.to_string_lossy().replace('\\', "/");
    if target.exclude.iter().any(|pattern| matches_simple_pattern(&relative, pattern)) {
        return false;
    }
    target.include.iter().any(|pattern| matches_simple_pattern(&relative, pattern))
}

fn matches_simple_pattern(path: &str, pattern: &str) -> bool {
    if let Some(extension) = pattern.strip_prefix("**/*.") {
        return path.ends_with(&format!(".{extension}"));
    }
    if let Some(prefix) = pattern.strip_suffix("/**") {
        return path.starts_with(prefix);
    }
    path == pattern || path.contains(pattern.trim_matches('*'))
}

#[cfg(test)]
mod tests {
    use std::fs;

    use super::*;
    use crate::config::TargetKind;
    use crate::language::Language;

    fn rust_target() -> ResolvedTarget {
        ResolvedTarget {
            name: "rust".to_string(),
            language: Language::Rust,
            directories: vec![PathBuf::from(".")],
            include: vec!["**/*.rs".to_string()],
            exclude: Vec::new(),
            kind: TargetKind::Source,
        }
    }

    fn write(path: &Path, contents: &str) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, contents).unwrap();
    }

    fn tempdir() -> PathBuf {
        use std::sync::atomic::{AtomicU64, Ordering};
        static N: AtomicU64 = AtomicU64::new(0);
        let id = N.fetch_add(1, Ordering::Relaxed);
        let dir = std::env::temp_dir().join(format!("ragrat-walk-{}-{id}", std::process::id()));
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    fn cpp_target() -> ResolvedTarget {
        ResolvedTarget {
            name: "cpp".to_string(),
            language: Language::Cpp,
            directories: vec![PathBuf::from(".")],
            include: Language::Cpp.default_include_globs(),
            exclude: Vec::new(),
            kind: TargetKind::Source,
        }
    }

    #[test]
    fn cpp_target_indexes_h_headers_but_not_c_sources() {
        let root = tempdir();
        write(&root.join("include/lib.h"), "class C { void f(); };\n");
        write(&root.join("src/lib.cpp"), "#include \"lib.h\"\nvoid C::f() {}\n");
        write(&root.join("legacy.c"), "int g(void){return 0;}\n");

        let target = cpp_target();
        let ignore = IgnoreMatcher::compile(&root, &target.directories);
        let rel: Vec<String> = walk_target(&root, &target, &ignore)
            .unwrap()
            .iter()
            .map(|p| p.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
            .collect();

        // The `.h` header is claimed by the cpp binding (the header-resolution fix)...
        assert!(rel.contains(&"include/lib.h".to_string()), "cpp must claim .h: {rel:?}");
        assert!(rel.contains(&"src/lib.cpp".to_string()), "{rel:?}");
        // ...but a plain `.c` file is NOT a C++ source.
        assert!(!rel.contains(&"legacy.c".to_string()), "cpp must not claim .c: {rel:?}");

        fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn walk_skips_gitignored_and_nested_gitignored_files() {
        let root = tempdir();
        // Root gitignore hides `generated/`; a nested gitignore hides `skip.rs` under `crates/app`.
        write(&root.join(".gitignore"), "generated/\n");
        write(&root.join("crates/app/.gitignore"), "skip.rs\n");
        write(&root.join("crates/app/keep.rs"), "fn a() {}\n");
        write(&root.join("crates/app/skip.rs"), "fn b() {}\n");
        write(&root.join("generated/out.rs"), "fn c() {}\n");
        // A floor dir (target/) must be skipped even without a gitignore entry for it.
        write(&root.join("target/debug/built.rs"), "fn d() {}\n");
        // A sibling named `skip.rs` at the root is NOT covered by the nested gitignore.
        write(&root.join("skip.rs"), "fn e() {}\n");

        let target = rust_target();
        let ignore = IgnoreMatcher::compile(&root, &target.directories);
        let mut found = walk_target(&root, &target, &ignore).unwrap();
        found.sort();
        let rel: Vec<String> = found
            .iter()
            .map(|p| p.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
            .collect();

        assert!(rel.contains(&"crates/app/keep.rs".to_string()), "kept: {rel:?}");
        assert!(rel.contains(&"skip.rs".to_string()), "root skip.rs not nested-ignored: {rel:?}");
        assert!(!rel.contains(&"crates/app/skip.rs".to_string()), "nested gitignore: {rel:?}");
        assert!(!rel.contains(&"generated/out.rs".to_string()), "root gitignore: {rel:?}");
        assert!(!rel.iter().any(|p| p.starts_with("target/")), "floor dir: {rel:?}");

        fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn walk_skips_a_missing_target_directory() {
        // #219 review: a config carrying a BRANCH-ONLY target dir is anchored to the MAIN root, so
        // base discovery over main must SKIP the absent dir, not hard-error on `read_dir` — else a
        // hook/maintenance launched from such a branch aborts before the overlay pass can run.
        let root = tempdir();
        write(&root.join("present/lib.rs"), "fn a() {}\n");
        let target = ResolvedTarget {
            name: "rust".to_string(),
            language: Language::Rust,
            directories: vec![PathBuf::from("present"), PathBuf::from("branch_only")],
            include: vec!["**/*.rs".to_string()],
            exclude: Vec::new(),
            kind: TargetKind::Source,
        };
        let ignore = IgnoreMatcher::compile(&root, &target.directories);

        let found = walk_target(&root, &target, &ignore).expect("missing dir must not error");
        let rel: Vec<String> = found
            .iter()
            .map(|p| p.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
            .collect();
        assert_eq!(rel, vec!["present/lib.rs".to_string()], "present dir indexed, missing skipped");

        fs::remove_dir_all(&root).ok();
    }
}
