use crate::TokenModeConfig;
use ignore::Match;
use ignore::WalkBuilder;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::Mutex;

const TOKEN_IGNORE_LOCKFILES: &[&str] = &[
    "Cargo.lock",
    "package-lock.json",
    "npm-shrinkwrap.json",
    "yarn.lock",
    "pnpm-lock.yaml",
    "bun.lock",
    "bun.lockb",
    "poetry.lock",
    "Pipfile.lock",
    "uv.lock",
    "requirements.lock",
    "pdm.lock",
    "Gemfile.lock",
    "composer.lock",
    "Podfile.lock",
    "go.sum",
    "mix.lock",
    "flake.lock",
    "pubspec.lock",
];

const TOKEN_IGNORE_EDITOR: &[&str] = &[".idea", ".vs"];

const TOKEN_IGNORE_MINIFIED: &[&str] = &["*.min.js", "*.min.css", "*.map"];

const TOKEN_IGNORE_BUILD: &[&str] = &["target", "build", "dist", "node_modules", "vendor"];

const TOKEN_IGNORE_AI_AGENT_CONFIG: &[&str] = &[".claude", ".codex", ".gemini", ".vscode"];

const TOKEN_DEFAULT_RULE_SOURCE: &str = ".dua-token-default-ignore";
const TOKEN_CLI_RULE_SOURCE: &str = ".dua-token-cli-ignore";

#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct MatcherCacheKey {
    root: PathBuf,
    config: TokenModeConfig,
}

static TOKEN_IGNORE_MATCHER_CACHE: Lazy<Mutex<HashMap<MatcherCacheKey, TokenIgnoreMatcher>>> =
    Lazy::new(|| Mutex::new(HashMap::new()));

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum TokenIgnoreReason {
    DotGitDirectory,
    Pattern {
        pattern: String,
        source: Option<PathBuf>,
    },
}

impl fmt::Display for TokenIgnoreReason {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            TokenIgnoreReason::DotGitDirectory => f.write_str("inside .git directory"),
            TokenIgnoreReason::Pattern { pattern, source } => {
                if let Some(source) = source {
                    write!(f, "matched pattern '{pattern}' from {}", source.display())
                } else {
                    write!(f, "matched pattern '{pattern}'")
                }
            }
        }
    }
}

#[derive(Clone)]
struct ScopedGitignoreMatcher {
    root: PathBuf,
    depth: usize,
    matcher: Arc<Gitignore>,
}

#[derive(Clone)]
pub(crate) struct TokenIgnoreMatcher {
    scoped_matchers: Arc<Vec<ScopedGitignoreMatcher>>,
    enabled: bool,
}

impl TokenIgnoreMatcher {
    pub(crate) fn ignored_reason(&self, path: &Path, is_dir: bool) -> Option<TokenIgnoreReason> {
        if !self.enabled {
            return None;
        }

        if is_in_git_directory(path) {
            return Some(TokenIgnoreReason::DotGitDirectory);
        }

        let mut best_match: Option<(usize, usize, Match<&ignore::gitignore::Glob>)> = None;
        for (order, scoped_matcher) in self.scoped_matchers.iter().enumerate() {
            if !is_within_scope(path, &scoped_matcher.root) {
                continue;
            }

            let match_result = scoped_matcher.matcher.matched(path, is_dir);
            if match_result.is_none() {
                continue;
            }

            let should_replace = match best_match.as_ref() {
                None => true,
                Some((best_depth, best_order, _)) => {
                    scoped_matcher.depth > *best_depth
                        || (scoped_matcher.depth == *best_depth && order >= *best_order)
                }
            };

            if should_replace {
                best_match = Some((scoped_matcher.depth, order, match_result));
            }
        }

        let final_match = best_match
            .map(|(_, _, matched)| matched)
            .unwrap_or(Match::None);
        match final_match {
            Match::Ignore(glob) => Some(TokenIgnoreReason::Pattern {
                pattern: glob.original().to_string(),
                source: glob.from().map(ToOwned::to_owned),
            }),
            Match::None | Match::Whitelist(_) => None,
        }
    }
}

fn is_ignore_file(path: &Path, read_dustignore: bool) -> bool {
    matches!(
        path.file_name(),
        Some(name)
            if name == OsStr::new(".gitignore")
                || name == OsStr::new(".ignore")
                || (read_dustignore && name == OsStr::new(".dustignore"))
                || (read_dustignore && name == OsStr::new(".duaignore"))
    )
}

fn is_in_git_directory(path: &Path) -> bool {
    path.components()
        .any(|component| component.as_os_str() == OsStr::new(".git"))
}

fn strip_leading_dot_component(path: &Path) -> &Path {
    path.strip_prefix(".").unwrap_or(path)
}

fn is_within_scope(path: &Path, scope_root: &Path) -> bool {
    let path = strip_leading_dot_component(path);
    let scope_root = strip_leading_dot_component(scope_root);

    if scope_root.as_os_str().is_empty() {
        return true;
    }

    path == scope_root || path.starts_with(scope_root)
}

fn path_depth(path: &Path) -> usize {
    strip_leading_dot_component(path).components().count()
}

fn cache_root_path(root: &Path) -> PathBuf {
    root.canonicalize().unwrap_or_else(|_| root.to_path_buf())
}

pub(crate) fn default_token_ignore_patterns(config: &TokenModeConfig) -> Vec<String> {
    let mut patterns: Vec<String> = vec![".dustignore".to_string(), ".duaignore".to_string()];

    if !config.include_lockfiles {
        patterns.extend(
            TOKEN_IGNORE_LOCKFILES
                .iter()
                .map(|pattern| pattern.to_string()),
        );
    }

    if !config.include_editor {
        patterns.extend(
            TOKEN_IGNORE_EDITOR
                .iter()
                .map(|pattern| pattern.to_string()),
        );
    }

    if !config.include_minified {
        patterns.extend(
            TOKEN_IGNORE_MINIFIED
                .iter()
                .map(|pattern| pattern.to_string()),
        );
    }

    if !config.include_build {
        patterns.extend(TOKEN_IGNORE_BUILD.iter().map(|pattern| pattern.to_string()));
    }

    if !config.include_ai_agent_config {
        patterns.extend(
            TOKEN_IGNORE_AI_AGENT_CONFIG
                .iter()
                .map(|pattern| pattern.to_string()),
        );
    }

    patterns
}

pub(crate) fn build_token_ignore_matcher(
    root: &Path,
    config: &TokenModeConfig,
) -> TokenIgnoreMatcher {
    if !config.apply_ignore_rules {
        return TokenIgnoreMatcher {
            scoped_matchers: Arc::new(Vec::new()),
            enabled: false,
        };
    }

    let cache_key = MatcherCacheKey {
        root: cache_root_path(root),
        config: config.clone(),
    };
    if let Some(cached_matcher) = TOKEN_IGNORE_MATCHER_CACHE
        .lock()
        .expect("token ignore cache lock should not be poisoned")
        .get(&cache_key)
        .cloned()
    {
        return cached_matcher;
    }

    let mut ignore_files_by_dir: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
    ignore_files_by_dir.entry(root.to_path_buf()).or_default();

    let git_info_exclude = root.join(".git").join("info").join("exclude");
    if git_info_exclude.exists() {
        ignore_files_by_dir
            .entry(root.to_path_buf())
            .or_default()
            .push(git_info_exclude);
    }

    if root.is_dir() {
        let walker = WalkBuilder::new(root)
            .hidden(false)
            .git_ignore(false)
            .git_global(false)
            .git_exclude(false)
            .ignore(false)
            .parents(false)
            .build();

        for entry in walker.flatten() {
            let entry_path = entry.path();
            if entry.file_type().is_some_and(|ft| ft.is_file())
                && is_ignore_file(entry_path, config.read_dustignore)
            {
                let parent_dir = entry_path.parent().unwrap_or(root).to_path_buf();
                ignore_files_by_dir
                    .entry(parent_dir)
                    .or_default()
                    .push(entry_path.to_path_buf());
            }
        }
    }

    let mut matcher_entries: Vec<_> = ignore_files_by_dir.into_iter().collect();
    matcher_entries.sort_by(|(left_root, _), (right_root, _)| {
        path_depth(left_root)
            .cmp(&path_depth(right_root))
            .then_with(|| left_root.cmp(right_root))
    });

    let mut scoped_matchers = Vec::new();
    for (scope_root, mut ignore_files) in matcher_entries {
        let mut builder = GitignoreBuilder::new(&scope_root);

        ignore_files.sort();
        ignore_files.dedup();
        for ignore_file in ignore_files {
            if let Some(err) = builder.add(&ignore_file) {
                eprintln!(
                    "Ignoring invalid ignore file '{}': {err}",
                    ignore_file.display()
                );
            }
        }

        if scope_root == root {
            let default_rule_source = Some(PathBuf::from(TOKEN_DEFAULT_RULE_SOURCE));
            for pattern in default_token_ignore_patterns(config) {
                if let Err(err) = builder.add_line(default_rule_source.clone(), &pattern) {
                    eprintln!("Ignoring invalid default token ignore pattern '{pattern}': {err}");
                }
            }

            let cli_rule_source = Some(PathBuf::from(TOKEN_CLI_RULE_SOURCE));
            for pattern in &config.additional_ignore_patterns {
                if let Err(err) = builder.add_line(cli_rule_source.clone(), pattern) {
                    eprintln!("Ignoring invalid --ignore-pattern '{pattern}': {err}");
                }
            }
        }

        let matcher = builder.build().unwrap_or_else(|err| {
            eprintln!(
                "Ignoring gitignore rules for '{}': {err}",
                scope_root.display()
            );
            GitignoreBuilder::new(&scope_root)
                .build()
                .expect("an empty gitignore builder should always be valid")
        });

        scoped_matchers.push(ScopedGitignoreMatcher {
            depth: path_depth(&scope_root),
            root: scope_root,
            matcher: Arc::new(matcher),
        });
    }

    let matcher = TokenIgnoreMatcher {
        scoped_matchers: Arc::new(scoped_matchers),
        enabled: true,
    };
    TOKEN_IGNORE_MATCHER_CACHE
        .lock()
        .expect("token ignore cache lock should not be poisoned")
        .insert(cache_key, matcher.clone());
    matcher
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::{Path, PathBuf};
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_dir(prefix: &str) -> PathBuf {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("valid clock")
            .as_nanos();
        let pid = std::process::id();
        let path = std::env::temp_dir().join(format!("dua-{prefix}-{pid}-{ts}"));
        fs::create_dir_all(&path).expect("create temp dir");
        path
    }

    #[test]
    fn invalid_additional_pattern_does_not_crash() {
        let dir = temp_dir("token-ignore-invalid");
        fs::write(dir.join("keep.txt"), "keep").expect("write file");

        let mut config = TokenModeConfig::default();
        config.additional_ignore_patterns = vec!["[".to_string()];
        let matcher = build_token_ignore_matcher(&dir, &config);

        assert!(
            matcher
                .ignored_reason(&dir.join("keep.txt"), false)
                .is_none()
        );

        fs::remove_dir_all(dir).ok();
    }

    #[test]
    fn reads_dustignore_by_default() {
        let dir = temp_dir("token-ignore-dustignore-default");
        fs::write(dir.join(".dustignore"), "ignored.txt\n").expect("write dustignore");
        fs::write(dir.join("ignored.txt"), "ignore me").expect("write file");

        let config = TokenModeConfig::default();
        let matcher = build_token_ignore_matcher(&dir, &config);

        assert!(
            matcher
                .ignored_reason(&dir.join("ignored.txt"), false)
                .is_some()
        );

        fs::remove_dir_all(dir).ok();
    }

    #[test]
    fn reads_duaignore_by_default() {
        let dir = temp_dir("token-ignore-duaignore-default");
        fs::write(dir.join(".duaignore"), "ignored.txt\n").expect("write duaignore");
        fs::write(dir.join("ignored.txt"), "ignore me").expect("write file");

        let config = TokenModeConfig::default();
        let matcher = build_token_ignore_matcher(&dir, &config);

        assert!(
            matcher
                .ignored_reason(&dir.join("ignored.txt"), false)
                .is_some()
        );

        fs::remove_dir_all(dir).ok();
    }

    #[test]
    fn lockfiles_are_ignored_by_default_and_included_when_requested() {
        let dir = temp_dir("token-ignore-lockfiles");
        fs::write(dir.join("Cargo.lock"), "[[package]]\nname = \"demo\"\n")
            .expect("write Cargo.lock");
        fs::write(
            dir.join("uv.lock"),
            "version = 1\nrequires-python = \">=3.12\"\n",
        )
        .expect("write uv.lock");

        let default_matcher = build_token_ignore_matcher(&dir, &TokenModeConfig::default());
        assert!(
            default_matcher
                .ignored_reason(&dir.join("Cargo.lock"), false)
                .is_some()
        );
        assert!(
            default_matcher
                .ignored_reason(&dir.join("uv.lock"), false)
                .is_some()
        );

        let include_lockfiles_matcher = build_token_ignore_matcher(
            &dir,
            &TokenModeConfig {
                include_lockfiles: true,
                ..TokenModeConfig::default()
            },
        );
        assert!(
            include_lockfiles_matcher
                .ignored_reason(&dir.join("Cargo.lock"), false)
                .is_none()
        );
        assert!(
            include_lockfiles_matcher
                .ignored_reason(&dir.join("uv.lock"), false)
                .is_none()
        );

        fs::remove_dir_all(dir).ok();
    }

    #[test]
    fn tokens_no_dustignore_keeps_dustignore_rules_disabled() {
        let dir = temp_dir("token-ignore-dustignore-disabled");
        fs::write(dir.join(".dustignore"), "ignored.txt\n").expect("write dustignore");
        fs::write(dir.join(".duaignore"), "ignored-too.txt\n").expect("write duaignore");
        fs::write(dir.join("ignored.txt"), "ignore me").expect("write file");
        fs::write(dir.join("ignored-too.txt"), "ignore me too").expect("write file");

        let config = TokenModeConfig {
            read_dustignore: false,
            ..TokenModeConfig::default()
        };
        let matcher = build_token_ignore_matcher(&dir, &config);

        assert!(
            matcher
                .ignored_reason(&dir.join("ignored.txt"), false)
                .is_none()
        );
        assert!(
            matcher
                .ignored_reason(&dir.join("ignored-too.txt"), false)
                .is_none()
        );
        assert!(
            matcher
                .ignored_reason(&dir.join(".dustignore"), false)
                .is_some()
        );
        assert!(
            matcher
                .ignored_reason(&dir.join(".duaignore"), false)
                .is_some()
        );

        fs::remove_dir_all(dir).ok();
    }

    #[test]
    fn reads_git_info_exclude() {
        let dir = temp_dir("token-ignore-git-info-exclude");
        let git_info = dir.join(".git").join("info");
        fs::create_dir_all(&git_info).expect("create .git/info");
        fs::write(git_info.join("exclude"), "ignored-by-git-info.txt\n").expect("write exclude");
        fs::write(dir.join("ignored-by-git-info.txt"), "ignore me").expect("write file");

        let config = TokenModeConfig::default();
        let matcher = build_token_ignore_matcher(&dir, &config);

        assert!(
            matcher
                .ignored_reason(&dir.join("ignored-by-git-info.txt"), false)
                .is_some()
        );

        fs::remove_dir_all(dir).ok();
    }

    #[test]
    fn always_ignores_dot_git_directory() {
        let dir = temp_dir("token-ignore-dot-git");
        fs::create_dir_all(dir.join(".git")).expect("create .git");
        fs::write(dir.join(".git").join("HEAD"), "ref: refs/heads/main\n")
            .expect("write .git/HEAD");

        let config = TokenModeConfig::default();
        let matcher = build_token_ignore_matcher(&dir, &config);

        assert!(
            matcher
                .ignored_reason(&dir.join(".git").join("HEAD"), false)
                .is_some()
        );

        fs::remove_dir_all(dir).ok();
    }

    #[test]
    fn disable_ignore_rules_keeps_all_paths_included() {
        let dir = temp_dir("token-ignore-disabled");
        fs::write(dir.join(".duaignore"), "ignored.txt\n").expect("write duaignore");
        fs::write(dir.join("ignored.txt"), "ignore me").expect("write file");
        fs::create_dir_all(dir.join(".git")).expect("create .git");
        fs::write(dir.join(".git").join("HEAD"), "ref: refs/heads/main\n")
            .expect("write .git/HEAD");

        let config = TokenModeConfig {
            apply_ignore_rules: false,
            ..TokenModeConfig::default()
        };
        let matcher = build_token_ignore_matcher(&dir, &config);

        assert!(
            matcher
                .ignored_reason(&dir.join("ignored.txt"), false)
                .is_none()
        );
        assert!(
            matcher
                .ignored_reason(&dir.join(".git").join("HEAD"), false)
                .is_none()
        );

        fs::remove_dir_all(dir).ok();
    }

    #[test]
    fn ignored_reason_reports_source_pattern() {
        let dir = temp_dir("token-ignore-reason");
        fs::write(dir.join(".duaignore"), "ignored.txt\n").expect("write duaignore");
        fs::write(dir.join("ignored.txt"), "ignore me").expect("write file");

        let config = TokenModeConfig::default();
        let matcher = build_token_ignore_matcher(&dir, &config);

        let Some(TokenIgnoreReason::Pattern { pattern, source }) =
            matcher.ignored_reason(&dir.join("ignored.txt"), false)
        else {
            panic!("expected ignored pattern reason");
        };

        assert_eq!(pattern, "ignored.txt");
        let source = source.expect("expected ignore source file path");
        assert!(source.ends_with(Path::new(".duaignore")));

        fs::remove_dir_all(dir).ok();
    }

    #[test]
    fn nested_gitignore_does_not_leak_to_parent_paths() {
        let dir = temp_dir("token-ignore-nested-scope");
        let ruff_cache = dir.join(".ruff_cache");
        fs::create_dir_all(&ruff_cache).expect("create .ruff_cache");
        fs::write(ruff_cache.join(".gitignore"), "*\n").expect("write nested gitignore");
        fs::write(ruff_cache.join("inside.txt"), "inside").expect("write nested file");
        fs::write(dir.join("outside.txt"), "outside").expect("write top-level file");

        let config = TokenModeConfig::default();
        let matcher = build_token_ignore_matcher(&dir, &config);

        assert!(
            matcher
                .ignored_reason(&ruff_cache.join("inside.txt"), false)
                .is_some()
        );
        assert!(
            matcher
                .ignored_reason(&dir.join("outside.txt"), false)
                .is_none()
        );

        fs::remove_dir_all(dir).ok();
    }

    #[test]
    fn nested_gitignore_does_not_leak_with_relative_root() {
        let dir = temp_dir("token-ignore-relative-root");
        let ruff_cache = dir.join(".ruff_cache");
        fs::create_dir_all(&ruff_cache).expect("create .ruff_cache");
        fs::write(ruff_cache.join(".gitignore"), "*\n").expect("write nested gitignore");
        fs::write(ruff_cache.join("inside.txt"), "inside").expect("write nested file");
        fs::write(dir.join("outside.txt"), "outside").expect("write top-level file");

        let old_cwd = std::env::current_dir().expect("get cwd");
        std::env::set_current_dir(&dir).expect("enter temp dir");

        let config = TokenModeConfig::default();
        let matcher = build_token_ignore_matcher(Path::new("."), &config);

        assert!(
            matcher
                .ignored_reason(Path::new("./.ruff_cache/inside.txt"), false)
                .is_some()
        );
        assert!(
            matcher
                .ignored_reason(Path::new("./outside.txt"), false)
                .is_none()
        );

        std::env::set_current_dir(old_cwd).expect("restore cwd");
        fs::remove_dir_all(dir).ok();
    }
}
