//! p4ignore / gitignore-style matcher for the depot's `ignore.txt` — a
//! straight port of `TauriClient/src/data/ignore.js` (`compileIgnore`).
//!
//! `ignore.txt` is a normal versioned file at the repo root; the client reads
//! its head content and compiles it into a predicate over depot-relative
//! paths ('Assets/Art/foo.exe'). Files that match are kept out of the shared
//! repo: they show no "not added" status, can't be marked for add, and
//! aren't pulled.
//!
//! Supported syntax (matches the p4ignore/gitignore family):
//!   - blank lines and lines starting with '#' are ignored
//!   - '!' at the start re-includes a previously-ignored path (last rule wins)
//!   - '*' matches any run of characters except '/'
//!   - '**' matches across '/' (any number of directories)
//!   - '?' matches a single character except '/'
//!   - '[abc]' / '[!abc]' character classes
//!   - a trailing '/' matches a directory (and everything under it)
//!   - a leading '/', or any '/' inside the pattern, anchors it to the repo
//!     root; a pattern with no '/' matches by name at any depth
//! Matching is case-sensitive, consistent with gitignore.

use regex::Regex;

struct Rule {
    re: Regex,
    negate: bool,
}

/// Compiled `ignore.txt` predicate over depot-relative paths.
pub struct IgnoreMatcher {
    rules: Vec<Rule>,
}

/// Characters JS escapes verbatim outside of `*`/`?`/`[...]`: `. + ^ $ { } ( ) | \`.
const REGEX_SPECIAL: &[char] = &['.', '+', '^', '$', '{', '}', '(', ')', '|', '\\'];

/// Translate one ignore-file pattern (already split off its leading `!` and
/// stripped of surrounding whitespace) into an anchored regex, mirroring
/// `ignore.js`'s `translate`. Returns `None` if the pattern doesn't compile
/// to a valid regex (mirrors the JS `try/catch` that skips such a rule).
fn translate(pattern: &str) -> Option<Regex> {
    let mut p = pattern;
    let mut dir_only = false;
    if let Some(stripped) = p.strip_suffix('/') {
        dir_only = true;
        p = stripped;
    }
    let mut anchored = false;
    if let Some(stripped) = p.strip_prefix('/') {
        anchored = true;
        p = stripped;
    }
    let has_slash = p.contains('/');

    // NOTE: iterated as Unicode scalar values (Rust `char`), whereas the JS
    // original walks UTF-16 code units. This only differs for patterns
    // containing astral characters (outside the BMP), which ignore-file
    // patterns practically never do — see the report's PARITY section.
    let chars: Vec<char> = p.chars().collect();
    let mut re = String::new();
    let mut i = 0usize;
    while i < chars.len() {
        let c = chars[i];
        if c == '*' {
            if chars.get(i + 1) == Some(&'*') {
                i += 1;
                if chars.get(i + 1) == Some(&'/') {
                    i += 1;
                    re.push_str("(?:.*/)?"); // '**/' -> any number of leading directories
                } else {
                    re.push_str(".*"); // '**' -> anything, including separators
                }
            } else {
                re.push_str("[^/]*"); // '*' -> anything within a single path segment
            }
        } else if c == '?' {
            re.push_str("[^/]");
        } else if c == '[' {
            let mut j = i + 1;
            let mut cls = String::from("[");
            if chars.get(j) == Some(&'!') {
                cls.push('^');
                j += 1;
            }
            while j < chars.len() && chars[j] != ']' {
                if chars[j] == '\\' || chars[j] == '^' {
                    cls.push('\\');
                }
                cls.push(chars[j]);
                j += 1;
            }
            cls.push(']');
            re.push_str(&cls);
            i = j;
        } else {
            if REGEX_SPECIAL.contains(&c) {
                re.push('\\');
            }
            re.push(c);
        }
        i += 1;
    }

    // A pattern with a slash is rooted; a bare name matches at any depth.
    let prefix = if anchored || has_slash { "^" } else { "(?:^|/)" };
    // Directory patterns only match things *under* the directory; file
    // patterns match the node itself or anything under it.
    let suffix = if dir_only { "/" } else { "(?:/|$)" };
    Regex::new(&format!("{prefix}{re}{suffix}")).ok()
}

impl IgnoreMatcher {
    /// Compile ignore-file text into a matcher. An empty or missing file
    /// yields a matcher that ignores nothing.
    pub fn compile(text: &str) -> Self {
        let mut rules = Vec::new();
        for raw in text.lines() {
            let line = raw.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let (pattern, negate) = match line.strip_prefix('!') {
                Some(rest) => (rest, true),
                None => (line, false),
            };
            if pattern.is_empty() {
                continue;
            }
            if let Some(re) = translate(pattern) {
                rules.push(Rule { re, negate });
            }
        }
        IgnoreMatcher { rules }
    }

    /// Whether `path` matches the compiled rule set — last matching rule
    /// wins (a later `!`-negation can re-include an earlier match).
    pub fn is_ignored(&self, path: &str) -> bool {
        let mut ignored = false;
        for rule in &self.rules {
            if rule.re.is_match(path) {
                ignored = !rule.negate;
            }
        }
        ignored
    }
}

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

    #[test]
    fn empty_text_ignores_nothing() {
        let m = IgnoreMatcher::compile("");
        assert!(!m.is_ignored("anything.txt"));
    }

    #[test]
    fn blank_lines_and_comments_are_skipped() {
        let m = IgnoreMatcher::compile("\n  \n# a comment\n*.tmp\n");
        assert!(m.is_ignored("foo.tmp"));
        assert!(!m.is_ignored("# a comment"));
    }

    #[test]
    fn bare_name_pattern_matches_at_any_depth() {
        let m = IgnoreMatcher::compile("*.tmp");
        assert!(m.is_ignored("foo.tmp"));
        assert!(m.is_ignored("Assets/Art/foo.tmp"));
        assert!(!m.is_ignored("foo.txt"));
    }

    #[test]
    fn star_does_not_cross_directory_boundary() {
        let m = IgnoreMatcher::compile("Assets/*.tmp");
        assert!(m.is_ignored("Assets/foo.tmp"));
        assert!(!m.is_ignored("Assets/Sub/foo.tmp"));
    }

    #[test]
    fn double_star_crosses_directory_boundaries() {
        let m = IgnoreMatcher::compile("Assets/**/foo.tmp");
        assert!(m.is_ignored("Assets/foo.tmp"));
        assert!(m.is_ignored("Assets/A/B/foo.tmp"));
    }

    #[test]
    fn bare_double_star_matches_anything() {
        let m = IgnoreMatcher::compile("Assets/**");
        assert!(m.is_ignored("Assets/foo.tmp"));
        assert!(m.is_ignored("Assets/A/B/foo.tmp"));
        assert!(!m.is_ignored("Other/foo.tmp"));
    }

    #[test]
    fn question_mark_matches_single_char_not_slash() {
        let m = IgnoreMatcher::compile("fo?.txt");
        assert!(m.is_ignored("foo.txt"));
        assert!(!m.is_ignored("fo/o.txt"));
    }

    #[test]
    fn character_class_and_negated_class() {
        let m = IgnoreMatcher::compile("[Aa]ssets/*.tmp");
        assert!(m.is_ignored("Assets/foo.tmp"));
        assert!(m.is_ignored("assets/foo.tmp"));

        let m2 = IgnoreMatcher::compile("[!0-9]file.txt");
        assert!(m2.is_ignored("afile.txt"));
        assert!(!m2.is_ignored("1file.txt"));
    }

    #[test]
    fn leading_slash_anchors_to_repo_root() {
        let m = IgnoreMatcher::compile("/foo.txt");
        assert!(m.is_ignored("foo.txt"));
        assert!(!m.is_ignored("Sub/foo.txt"));
    }

    #[test]
    fn slash_anywhere_in_pattern_anchors_it() {
        // A pattern containing '/' (even without a leading one) is rooted,
        // per ignore.js's `anchored || hasSlash` rule.
        let m = IgnoreMatcher::compile("Sub/foo.txt");
        assert!(m.is_ignored("Sub/foo.txt"));
        assert!(!m.is_ignored("Other/Sub/foo.txt"));
    }

    #[test]
    fn trailing_slash_matches_directory_and_contents_only() {
        let m = IgnoreMatcher::compile("Temp/");
        assert!(!m.is_ignored("Temp")); // the directory node itself is not "under" it
        assert!(m.is_ignored("Temp/a.txt"));
        assert!(m.is_ignored("Temp/Sub/a.txt"));
    }

    #[test]
    fn negation_re_includes_a_previously_ignored_path() {
        let m = IgnoreMatcher::compile("*.tmp\n!keep.tmp\n");
        assert!(m.is_ignored("foo.tmp"));
        assert!(!m.is_ignored("keep.tmp"));
    }

    #[test]
    fn last_matching_rule_wins() {
        let m = IgnoreMatcher::compile("*.tmp\n!keep.tmp\nkeep.tmp\n");
        assert!(m.is_ignored("keep.tmp"));
    }

    #[test]
    fn matching_is_case_sensitive() {
        let m = IgnoreMatcher::compile("Foo.txt");
        assert!(m.is_ignored("Foo.txt"));
        assert!(!m.is_ignored("foo.txt"));
    }

    #[test]
    fn crlf_line_endings_are_handled_like_lf() {
        let m = IgnoreMatcher::compile("*.tmp\r\n!keep.tmp\r\n");
        assert!(m.is_ignored("foo.tmp"));
        assert!(!m.is_ignored("keep.tmp"));
    }
}
