use camino::Utf8PathBuf;
use ignore::WalkBuilder;
use std::path::Path;

use crate::errors::WalkError;

/// Collect all `.sql` files under `root`.
///
/// When `no_gitignore` is `false`, `.gitignore` rules are respected.
/// When `no_gitignore` is `true`, all `.sql` files are returned regardless
/// of any ignore rules.
///
/// Returned paths are canonicalized absolute paths.
///
/// # Errors
///
/// Returns [`WalkError`] if `root` is not a readable directory or a path
/// contains non-UTF-8 bytes.
pub fn collect_sql_files(root: &Path, no_gitignore: bool) -> Result<Vec<Utf8PathBuf>, WalkError> {
    let mut paths = Vec::new();

    for entry in WalkBuilder::new(root)
        .require_git(false)
        .git_ignore(!no_gitignore)
        .build()
    {
        let entry = entry?;
        if !is_sql_file(&entry) {
            continue;
        }
        let path = entry.into_path();
        let utf8 =
            Utf8PathBuf::from_path_buf(path.clone()).map_err(|_| WalkError::NonUtf8(path))?;
        paths.push(utf8);
    }

    paths.sort_unstable();
    tracing::debug!(count = paths.len(), root = %root.display(), "sql file walk complete");
    Ok(paths)
}

fn is_sql_file(entry: &ignore::DirEntry) -> bool {
    entry.file_type().is_some_and(|ft| ft.is_file())
        && entry
            .path()
            .extension()
            .is_some_and(|ext| ext.eq_ignore_ascii_case("sql"))
}

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

    fn write(dir: &Path, rel: &str, content: &str) {
        let full = dir.join(rel);
        if let Some(parent) = full.parent() {
            fs::create_dir_all(parent).expect("create_dir_all must not fail in tests");
        }
        fs::write(&full, content).expect("write must not fail in tests");
    }

    #[test]
    fn discovers_sql_files_respecting_gitignore() {
        let tmp = TempDir::new().expect("tempdir creation must succeed");
        let root = tmp.path();

        write(root, "a.sql", "SELECT 1;");
        write(root, "b.sql", "SELECT 2;");
        write(root, "subdir/c.sql", "SELECT 3;");
        write(root, "not_sql.txt", "irrelevant");
        write(root, ".gitignore", "b.sql\n");

        let files = collect_sql_files(root, false).expect("collect_sql_files must not fail");

        let names: Vec<String> = files
            .iter()
            .map(|p| p.file_name().expect("file must have a name").to_owned())
            .collect();

        assert!(
            names.contains(&"a.sql".to_owned()),
            "a.sql must be included: {names:?}"
        );
        assert!(
            names.contains(&"c.sql".to_owned()),
            "c.sql must be included: {names:?}"
        );
        assert!(
            !names.contains(&"b.sql".to_owned()),
            "b.sql must be excluded by gitignore: {names:?}"
        );

        for path in &files {
            assert!(path.is_absolute(), "path must be absolute: {path}");
        }

        let mut sorted = files.clone();
        sorted.sort_unstable();
        sorted.dedup();
        assert_eq!(sorted.len(), files.len(), "must have no duplicate paths");
    }

    #[test]
    fn returns_empty_for_directory_with_no_sql_files() {
        let tmp = TempDir::new().expect("tempdir creation must succeed");
        write(tmp.path(), "readme.txt", "hello");

        let files = collect_sql_files(tmp.path(), false).expect("collect_sql_files must not fail");
        assert!(
            files.is_empty(),
            "must return empty vec when no .sql files exist"
        );
    }

    #[test]
    fn no_gitignore_includes_gitignored_files() {
        let tmp = TempDir::new().expect("tempdir creation must succeed");
        let root = tmp.path();

        write(root, "included.sql", "SELECT 1;");
        write(root, "ignored.sql", "SELECT 2;");
        write(root, ".gitignore", "ignored.sql\n");

        // With no_gitignore = false the ignored file is absent.
        let with_ignore = collect_sql_files(root, false).expect("collect_sql_files must not fail");
        let names_with: Vec<String> = with_ignore
            .iter()
            .map(|p| p.file_name().expect("file must have a name").to_owned())
            .collect();
        assert!(
            !names_with.contains(&"ignored.sql".to_owned()),
            "ignored.sql must be excluded when no_gitignore=false: {names_with:?}"
        );

        // With no_gitignore = true the ignored file IS present.
        let without_ignore =
            collect_sql_files(root, true).expect("collect_sql_files must not fail");
        let names_without: Vec<String> = without_ignore
            .iter()
            .map(|p| p.file_name().expect("file must have a name").to_owned())
            .collect();
        assert!(
            names_without.contains(&"ignored.sql".to_owned()),
            "ignored.sql must be included when no_gitignore=true: {names_without:?}"
        );
        assert!(
            names_without.contains(&"included.sql".to_owned()),
            "included.sql must still be present when no_gitignore=true: {names_without:?}"
        );
    }
}
