//! Glob-pattern analysis: literal prefix and walk-depth bound.
//!
//! For a [`GlobPattern`] with body `apps/**/web`, the literal
//! prefix is `["apps"]` and the depth is unbounded (because of
//! `**`). For `apps/*`, the literal prefix is `["apps"]` and the
//! depth bound is 1.
//!
//! The walker uses these to start descent at
//! `workspace_root + literal_prefix` and to stop before reaching
//! pointless depths.

use haz_domain::path::GlobPattern;

/// Walk depth bound derived from a glob pattern.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Depth {
    /// Walk at most this many levels past the start directory.
    Bounded(usize),
    /// A `**` segment appears past the literal prefix; descent is
    /// unbounded. Termination is guaranteed by the visited set.
    Unbounded,
}

/// Analysis of a [`GlobPattern`] for pruned walking.
#[derive(Debug, Clone)]
pub(crate) struct GlobAnalysis {
    /// Leading run of segments with no glob meta-characters.
    /// Used to compute the walk's starting directory.
    pub(crate) literal_prefix: Vec<String>,
    /// Walk depth past the starting directory.
    pub(crate) depth: Depth,
}

/// Analyse a [`GlobPattern`] for pruned walking.
///
/// Segments are taken from [`GlobPattern::body`], split on `/`.
/// A segment is part of the literal prefix iff it contains none
/// of `*`, `?`, `[`, `{`, or `\` (the glob meta-character set
/// inherited from `globset`).
pub(crate) fn analyse(glob: &GlobPattern) -> GlobAnalysis {
    let segments: Vec<&str> = glob.body().split('/').collect();

    let mut literal_count = 0;
    for s in &segments {
        if has_glob_meta(s) {
            break;
        }
        literal_count += 1;
    }

    let literal_prefix: Vec<String> = segments[..literal_count]
        .iter()
        .map(|s| (*s).to_owned())
        .collect();

    let has_double_star = segments[literal_count..].contains(&"**");

    let depth = if has_double_star {
        Depth::Unbounded
    } else {
        Depth::Bounded(segments.len() - literal_count)
    };

    GlobAnalysis {
        literal_prefix,
        depth,
    }
}

fn has_glob_meta(s: &str) -> bool {
    s.chars().any(|c| matches!(c, '*' | '?' | '[' | '{' | '\\'))
}

#[cfg(test)]
mod tests {
    use haz_domain::path::PathPattern;

    use super::{Depth, analyse};

    fn glob(s: &str) -> haz_domain::path::GlobPattern {
        match PathPattern::parse(s).unwrap() {
            PathPattern::Glob(g) => g,
            PathPattern::Literal(_) => panic!("expected glob: {s}"),
        }
    }

    #[test]
    fn prefix_for_simple_star() {
        let a = analyse(&glob("/apps/*"));
        assert_eq!(a.literal_prefix, vec!["apps"]);
        assert_eq!(a.depth, Depth::Bounded(1));
    }

    #[test]
    fn prefix_for_two_level_star() {
        let a = analyse(&glob("/apps/*/src"));
        assert_eq!(a.literal_prefix, vec!["apps"]);
        assert_eq!(a.depth, Depth::Bounded(2));
    }

    #[test]
    fn prefix_for_double_star() {
        let a = analyse(&glob("/apps/**"));
        assert_eq!(a.literal_prefix, vec!["apps"]);
        assert_eq!(a.depth, Depth::Unbounded);
    }

    #[test]
    fn prefix_for_double_star_with_trailing() {
        let a = analyse(&glob("/services/**/api"));
        assert_eq!(a.literal_prefix, vec!["services"]);
        assert_eq!(a.depth, Depth::Unbounded);
    }

    #[test]
    fn no_literal_prefix_when_first_segment_is_wildcard() {
        let a = analyse(&glob("/lib_*/src"));
        assert!(a.literal_prefix.is_empty());
        assert_eq!(a.depth, Depth::Bounded(2));
    }

    #[test]
    fn no_literal_prefix_when_first_segment_is_double_star() {
        let a = analyse(&glob("/**/web"));
        assert!(a.literal_prefix.is_empty());
        assert_eq!(a.depth, Depth::Unbounded);
    }

    #[test]
    fn brace_alternation_is_not_literal() {
        let a = analyse(&glob("/apps/{web,api}"));
        assert_eq!(a.literal_prefix, vec!["apps"]);
        assert_eq!(a.depth, Depth::Bounded(1));
    }

    #[test]
    fn char_class_is_not_literal() {
        let a = analyse(&glob("/apps/[ab]*"));
        assert_eq!(a.literal_prefix, vec!["apps"]);
        assert_eq!(a.depth, Depth::Bounded(1));
    }

    #[test]
    fn question_mark_is_not_literal() {
        let a = analyse(&glob("/apps/?"));
        assert_eq!(a.literal_prefix, vec!["apps"]);
        assert_eq!(a.depth, Depth::Bounded(1));
    }
}
