//! Shell patterns

use crate::{error, regex, sys, trace_categories};
use std::{collections::VecDeque, path::Path};

/// Represents a piece of a shell pattern.
#[derive(Clone, Debug)]
pub(crate) enum PatternPiece {
    /// A pattern that should be interpreted as a shell pattern.
    Pattern(String),
    /// A literal string that should be matched exactly.
    Literal(String),
}

impl PatternPiece {
    pub fn as_str(&self) -> &str {
        match self {
            Self::Pattern(s) => s,
            Self::Literal(s) => s,
        }
    }
}

type PatternWord = Vec<PatternPiece>;

/// Options for filename expansion.
#[derive(Clone, Debug, Default)]
pub(crate) struct FilenameExpansionOptions {
    pub require_dot_in_pattern_to_match_dot_files: bool,
}

/// Result of a pattern expansion, distinguishing "no glob metacharacters" from
/// "glob expansion attempted but found no matches".
#[derive(Debug, Default)]
pub(crate) enum PatternExpansionResult {
    /// No glob metacharacters found; no expansion was attempted.
    #[default]
    NoGlob,
    /// Glob expansion was attempted. Contains matching paths (may be empty).
    Expanded(Vec<String>),
}

impl PatternExpansionResult {
    /// Returns the expansion results, regardless of variant.
    pub fn into_paths(self) -> Vec<String> {
        match self {
            Self::NoGlob => vec![],
            Self::Expanded(paths) => paths,
        }
    }

    /// Returns true if glob expansion was attempted but produced no results.
    pub const fn is_unmatched_glob(&self) -> bool {
        matches!(self, Self::Expanded(paths) if paths.is_empty())
    }
}

/// Encapsulates a shell pattern.
#[derive(Clone, Debug)]
pub struct Pattern {
    pieces: PatternWord,
    enable_extended_globbing: bool,
    multiline: bool,
    case_insensitive: bool,
}

impl Default for Pattern {
    fn default() -> Self {
        Self {
            pieces: vec![],
            enable_extended_globbing: false,
            multiline: true,
            case_insensitive: false,
        }
    }
}

impl From<PatternWord> for Pattern {
    fn from(pieces: PatternWord) -> Self {
        Self {
            pieces,
            ..Default::default()
        }
    }
}

impl From<&PatternWord> for Pattern {
    fn from(value: &PatternWord) -> Self {
        Self {
            pieces: value.clone(),
            ..Default::default()
        }
    }
}

impl From<&str> for Pattern {
    fn from(value: &str) -> Self {
        Self {
            pieces: vec![PatternPiece::Pattern(value.to_owned())],
            ..Default::default()
        }
    }
}

impl From<String> for Pattern {
    fn from(value: String) -> Self {
        Self {
            pieces: vec![PatternPiece::Pattern(value)],
            ..Default::default()
        }
    }
}

impl Pattern {
    /// Enables (or disables) extended globbing support for this pattern.
    ///
    /// # Arguments
    ///
    /// * `value` - Whether or not to enable extended globbing (extglob).
    #[must_use]
    pub const fn set_extended_globbing(mut self, value: bool) -> Self {
        self.enable_extended_globbing = value;
        self
    }

    /// Enables (or disables) multiline support for this pattern.
    ///
    /// # Arguments
    ///
    /// * `value` - Whether or not to enable multiline matching.
    #[must_use]
    pub const fn set_multiline(mut self, value: bool) -> Self {
        self.multiline = value;
        self
    }

    /// Enables (or disables) case-insensitive matching for this pattern.
    ///
    /// # Arguments
    ///
    /// * `value` - Whether or not to enable case-insensitive matching.
    #[must_use]
    pub const fn set_case_insensitive(mut self, value: bool) -> Self {
        self.case_insensitive = value;
        self
    }

    /// Returns whether or not the pattern is empty.
    pub fn is_empty(&self) -> bool {
        self.pieces.iter().all(|p| p.as_str().is_empty())
    }

    /// Placeholder function that always returns true.
    pub(crate) const fn accept_all_expand_filter(_path: &Path) -> bool {
        true
    }

    /// Expands the pattern into a list of matching file paths.
    ///
    /// # Arguments
    ///
    /// * `working_dir` - The current working directory, used for relative paths.
    /// * `path_filter` - Optionally provides a function that filters paths after expansion.
    #[expect(clippy::too_many_lines)]
    pub(crate) fn expand<PF>(
        &self,
        working_dir: &Path,
        path_filter: Option<&PF>,
        options: &FilenameExpansionOptions,
    ) -> Result<PatternExpansionResult, error::Error>
    where
        PF: Fn(&Path) -> bool,
    {
        // If the pattern has no pieces at all, short-circuit; there's nothing to expand.
        // Note: we intentionally do NOT short-circuit when pieces are present but empty
        // (e.g. from a quoted empty string ""); those fall through to the literal branch
        // below which correctly returns Expanded([""]) instead of NoGlob, preserving
        // the argument even when nullglob is enabled.
        if self.pieces.is_empty() {
            return Ok(PatternExpansionResult::NoGlob);

        // Similarly, if we're *confident* the pattern doesn't require expansion, then we
        // know there's a single expansion (before filtering).
        } else if !self.pieces.iter().any(|piece| {
            matches!(piece, PatternPiece::Pattern(_))
                && requires_expansion(piece.as_str(), self.enable_extended_globbing)
        }) {
            let concatenated: String = self.pieces.iter().map(|piece| piece.as_str()).collect();

            if let Some(filter) = path_filter
                && !filter(Path::new(&concatenated))
            {
                // No globs, but the literal was filtered out. Return NoGlob
                // (not Expanded) so that callers don't mistake this for a
                // failed glob match (which would trigger failglob).
                return Ok(PatternExpansionResult::NoGlob);
            }

            return Ok(PatternExpansionResult::Expanded(vec![concatenated]));
        }

        tracing::debug!(target: trace_categories::PATTERN, "expanding pattern: {self:?}");

        let mut components: Vec<PatternWord> = vec![];
        for piece in &self.pieces {
            let mut split_result: VecDeque<_> = sys::fs::split_path_for_pattern(piece.as_str())
                .map(|s| match piece {
                    PatternPiece::Pattern(_) => PatternPiece::Pattern(s.to_owned()),
                    PatternPiece::Literal(_) => PatternPiece::Literal(s.to_owned()),
                })
                .collect();

            if let Some(first_piece) = split_result.pop_front() {
                if let Some(last_component) = components.last_mut() {
                    last_component.push(first_piece);
                } else {
                    components.push(vec![first_piece]);
                }
            }

            while let Some(piece) = split_result.pop_front() {
                components.push(vec![piece]);
            }
        }

        // Check if the path appears to be absolute by inspecting the first component.
        // On Unix, a leading `/` produces an empty first component. On Windows, a
        // drive-letter prefix like `c:` is also recognized. The platform-specific
        // logic lives in `sys::fs::pattern_path_root`.
        let absolute_root = components.first().and_then(|first_component| {
            let flattened: String = first_component.iter().map(|p| p.as_str()).collect();
            sys::fs::pattern_path_root(&flattened)
        });

        let prefix_to_remove;
        let mut paths_so_far = if let Some(root) = absolute_root {
            prefix_to_remove = None;
            // Skip the first component; it was consumed to determine the root.
            components.remove(0);
            vec![root]
        } else {
            // Build a prefix to remove after glob expansion so results are
            // returned relative to the working directory. The prefix is
            // normalized to use `/` separators because `push_path_for_pattern`
            // also uses `/` on Windows (to avoid `PathBuf::push` drive-letter
            // semantics) — if we left `\` here, the strip_prefix below would
            // miss on Windows and leave results as absolute paths.
            let working_dir_str = working_dir.to_string_lossy();
            let mut working_dir_str =
                sys::fs::normalize_path_separators(&working_dir_str).into_owned();
            if !working_dir_str.ends_with('/') {
                working_dir_str.push('/');
            }

            prefix_to_remove = Some(working_dir_str);
            vec![working_dir.to_path_buf()]
        };

        for component in components {
            if !component.iter().any(|piece| {
                matches!(piece, PatternPiece::Pattern(_))
                    && requires_expansion(piece.as_str(), self.enable_extended_globbing)
            }) {
                for p in &mut paths_so_far {
                    let flattened = component
                        .iter()
                        .map(|piece| piece.as_str())
                        .collect::<String>();
                    sys::fs::push_path_for_pattern(p, &flattened);
                }
                continue;
            }

            let current_paths = std::mem::take(&mut paths_so_far);
            for current_path in current_paths {
                let subpattern = Self::from(&component)
                    .set_extended_globbing(self.enable_extended_globbing)
                    .set_case_insensitive(self.case_insensitive);

                let subpattern_starts_with_dot = subpattern
                    .pieces
                    .first()
                    .is_some_and(|piece| piece.as_str().starts_with('.'));

                let allow_dot_files = !options.require_dot_in_pattern_to_match_dot_files
                    || subpattern_starts_with_dot;

                let matches_dotfile_policy = |dir_entry: &std::fs::DirEntry| {
                    !dir_entry.file_name().to_string_lossy().starts_with('.') || allow_dot_files
                };

                let regex = subpattern.to_regex(true, true)?;
                let matches_regex = |dir_entry: &std::fs::DirEntry| {
                    regex
                        .is_match(dir_entry.file_name().to_string_lossy().as_ref())
                        .unwrap_or(false)
                };

                let mut matching_paths_in_dir: Vec<_> = current_path
                    .read_dir()
                    .map_or_else(|_| vec![], |dir| dir.into_iter().collect())
                    .into_iter()
                    .filter_map(|result| result.ok())
                    .filter(matches_regex)
                    .filter(matches_dotfile_policy)
                    .map(|entry| entry.path())
                    .collect();

                matching_paths_in_dir.sort();

                paths_so_far.append(&mut matching_paths_in_dir);
            }
        }

        let results: Vec<_> = paths_so_far
            .into_iter()
            .filter_map(|path| {
                if let Some(filter) = path_filter
                    && !filter(path.as_path())
                {
                    return None;
                }

                // Normalize separators *before* stripping the working-dir
                // prefix so that `prefix_to_remove` (already normalized to
                // use `/`) matches paths that may contain a mix of `\` and
                // `/` on Windows.
                let path_str = path.to_string_lossy();
                let normalized = sys::fs::normalize_path_separators(&path_str);
                let mut path_ref: &str = normalized.as_ref();

                if let Some(prefix_to_remove) = &prefix_to_remove
                    && let Some(stripped) = path_ref.strip_prefix(prefix_to_remove.as_str())
                {
                    path_ref = stripped;
                }

                Some(path_ref.to_string())
            })
            .collect();

        tracing::debug!(target: trace_categories::PATTERN, "  => results: {results:?}");

        Ok(PatternExpansionResult::Expanded(results))
    }

    /// Converts the pattern to a regular expression string.
    ///
    /// # Arguments
    ///
    /// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of
    ///   the string.
    /// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the
    ///   string.
    pub(crate) fn to_regex_str(
        &self,
        strict_prefix_match: bool,
        strict_suffix_match: bool,
    ) -> Result<String, error::Error> {
        let mut regex_str = String::new();

        if strict_prefix_match {
            regex_str.push('^');
        }

        let mut current_pattern = String::new();
        for piece in &self.pieces {
            match piece {
                PatternPiece::Pattern(s) => {
                    current_pattern.push_str(s);
                }
                PatternPiece::Literal(s) => {
                    for c in s.chars() {
                        if crate::regex::regex_char_is_special(c) {
                            current_pattern.push('\\');
                        }
                        current_pattern.push(c);
                    }
                }
            }
        }

        let regex_piece =
            pattern_to_regex_str(current_pattern.as_str(), self.enable_extended_globbing)?;
        regex_str.push_str(regex_piece.as_str());

        if strict_suffix_match {
            regex_str.push('$');
        }

        Ok(regex_str)
    }

    /// Converts the pattern to a regular expression.
    ///
    /// # Arguments
    ///
    /// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of
    ///   the string.
    /// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the
    ///   string.
    pub(crate) fn to_regex(
        &self,
        strict_prefix_match: bool,
        strict_suffix_match: bool,
    ) -> Result<fancy_regex::Regex, error::Error> {
        let regex_str = self.to_regex_str(strict_prefix_match, strict_suffix_match)?;

        tracing::debug!(target: trace_categories::PATTERN, "pattern: '{self:?}' => regex: '{regex_str}'");

        let re = regex::compile_regex(regex_str, self.case_insensitive, self.multiline)?;
        Ok(re)
    }

    /// Checks if the pattern exactly matches the given string. An error result
    /// is returned if the pattern is found to be invalid or malformed
    /// during processing.
    ///
    /// # Arguments
    ///
    /// * `value` - The string to check for a match.
    pub fn exactly_matches(&self, value: &str) -> Result<bool, error::Error> {
        let re = self.to_regex(true, true)?;
        Ok(re.is_match(value)?)
    }
}

/// Checks whether a string contains glob metacharacters that would trigger
/// pathname expansion. Delegates to the pattern parser's grammar, which is
/// the single source of truth for what constitutes a glob metacharacter.
fn requires_expansion(s: &str, enable_extended_globbing: bool) -> bool {
    brush_parser::pattern::pattern_has_glob_metacharacters(s, enable_extended_globbing)
}

fn pattern_to_regex_str(
    pattern: &str,
    enable_extended_globbing: bool,
) -> Result<String, error::Error> {
    Ok(brush_parser::pattern::pattern_to_regex_str(
        pattern,
        enable_extended_globbing,
    )?)
}

/// Removes the largest matching prefix from a string that matches the given pattern.
///
/// # Arguments
///
/// * `s` - The string to remove the prefix from.
/// * `pattern` - The pattern to match.
pub(crate) fn remove_largest_matching_prefix<'a>(
    s: &'a str,
    pattern: Option<&Pattern>,
) -> Result<&'a str, error::Error> {
    if let Some(pattern) = pattern {
        let re = pattern.to_regex(true, true)?;
        let indices = s.char_indices().rev();
        let mut last_idx = s.len();

        #[allow(
            clippy::string_slice,
            reason = "because we get the indices from char_indices()"
        )]
        for (idx, _) in indices {
            let prefix = &s[0..last_idx];
            if re.is_match(prefix)? {
                return Ok(&s[last_idx..]);
            }

            last_idx = idx;
        }
    }
    Ok(s)
}

/// Removes the smallest matching prefix from a string that matches the given pattern.
///
/// # Arguments
///
/// * `s` - The string to remove the prefix from.
/// * `pattern` - The pattern to match.
pub(crate) fn remove_smallest_matching_prefix<'a>(
    s: &'a str,
    pattern: Option<&Pattern>,
) -> Result<&'a str, error::Error> {
    if let Some(pattern) = pattern {
        let re = pattern.to_regex(true, true)?;
        let mut indices = s.char_indices();

        #[allow(
            clippy::string_slice,
            reason = "because we get the indices from char_indices()"
        )]
        while indices.next().is_some() {
            let next_index = indices.offset();
            let prefix = &s[0..next_index];
            if re.is_match(prefix)? {
                return Ok(&s[next_index..]);
            }
        }
    }
    Ok(s)
}

/// Removes the largest matching suffix from a string that matches the given pattern.
///
/// # Arguments
///
/// * `s` - The string to remove the suffix from.
/// * `pattern` - The pattern to match.
pub(crate) fn remove_largest_matching_suffix<'a>(
    s: &'a str,
    pattern: Option<&Pattern>,
) -> Result<&'a str, error::Error> {
    if let Some(pattern) = pattern {
        let re = pattern.to_regex(true, true)?;
        #[allow(
            clippy::string_slice,
            reason = "because we get the indices from char_indices()"
        )]
        for (idx, _) in s.char_indices() {
            let suffix = &s[idx..];
            if re.is_match(suffix)? {
                return Ok(&s[..idx]);
            }
        }
    }
    Ok(s)
}

/// Removes the smallest matching suffix from a string that matches the given pattern.
///
/// # Arguments
///
/// * `s` - The string to remove the suffix from.
/// * `pattern` - The pattern to match.
pub(crate) fn remove_smallest_matching_suffix<'a>(
    s: &'a str,
    pattern: Option<&Pattern>,
) -> Result<&'a str, error::Error> {
    if let Some(pattern) = pattern {
        let re = pattern.to_regex(true, true)?;
        #[allow(
            clippy::string_slice,
            reason = "because we get the indices from char_indices()"
        )]
        for (idx, _) in s.char_indices().rev() {
            let suffix = &s[idx..];
            if re.is_match(suffix)? {
                return Ok(&s[..idx]);
            }
        }
    }
    Ok(s)
}

#[cfg(test)]
#[expect(clippy::panic_in_result_fn)]
mod tests {
    use super::*;
    use anyhow::Result;

    fn pattern_to_exact_regex_str<P>(pattern: P) -> Result<String, error::Error>
    where
        P: Into<Pattern>,
    {
        let pattern: Pattern = pattern
            .into()
            .set_extended_globbing(true)
            .set_multiline(false);

        pattern.to_regex_str(true, true)
    }

    #[test]
    fn test_pattern_translation() -> Result<()> {
        assert_eq!(pattern_to_exact_regex_str("a")?.as_str(), "^a$");
        assert_eq!(pattern_to_exact_regex_str("a*")?.as_str(), "^a.*$");
        assert_eq!(pattern_to_exact_regex_str("a?")?.as_str(), "^a.$");
        assert_eq!(pattern_to_exact_regex_str("a@(b|c)")?.as_str(), "^a(b|c)$");
        assert_eq!(pattern_to_exact_regex_str("a?(b|c)")?.as_str(), "^a(b|c)?$");
        assert_eq!(
            pattern_to_exact_regex_str("a*(ab|ac)")?.as_str(),
            "^a(ab|ac)*$"
        );
        assert_eq!(
            pattern_to_exact_regex_str("a+(ab|ac)")?.as_str(),
            "^a(ab|ac)+$"
        );
        assert_eq!(pattern_to_exact_regex_str("[ab]")?.as_str(), "^[ab]$");
        assert_eq!(pattern_to_exact_regex_str("[ab]*")?.as_str(), "^[ab].*$");
        assert_eq!(
            pattern_to_exact_regex_str("[<{().[]*")?.as_str(),
            r"^[<{().\[].*$"
        );
        assert_eq!(pattern_to_exact_regex_str("[a-d]")?.as_str(), "^[a-d]$");
        assert_eq!(pattern_to_exact_regex_str(r"\*")?.as_str(), r"^\*$");

        Ok(())
    }

    #[test]
    fn test_pattern_word_translation() -> Result<()> {
        assert_eq!(
            pattern_to_exact_regex_str(vec![PatternPiece::Pattern("a*".to_owned())])?.as_str(),
            "^a.*$"
        );
        assert_eq!(
            pattern_to_exact_regex_str(vec![
                PatternPiece::Pattern("a*".to_owned()),
                PatternPiece::Literal("b".to_owned()),
            ])?
            .as_str(),
            "^a.*b$"
        );
        assert_eq!(
            pattern_to_exact_regex_str(vec![
                PatternPiece::Literal("a*".to_owned()),
                PatternPiece::Pattern("b".to_owned()),
            ])?
            .as_str(),
            r"^a\*b$"
        );

        Ok(())
    }

    #[test]
    fn test_remove_largest_matching_prefix() -> Result<()> {
        assert_eq!(
            remove_largest_matching_prefix("ooof", Some(&Pattern::from("")))?,
            "ooof"
        );
        assert_eq!(
            remove_largest_matching_prefix("ooof", Some(&Pattern::from("x")))?,
            "ooof"
        );
        assert_eq!(
            remove_largest_matching_prefix("ooof", Some(&Pattern::from("o")))?,
            "oof"
        );
        assert_eq!(
            remove_largest_matching_prefix("ooof", Some(&Pattern::from("o*o")))?,
            "f"
        );
        assert_eq!(
            remove_largest_matching_prefix("ooof", Some(&Pattern::from("o*")))?,
            ""
        );
        assert_eq!(
            remove_largest_matching_prefix("🚀🚀🚀rocket", Some(&Pattern::from("🚀")))?,
            "🚀🚀rocket"
        );
        Ok(())
    }

    #[test]
    fn test_remove_smallest_matching_prefix() -> Result<()> {
        assert_eq!(
            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("")))?,
            "ooof"
        );
        assert_eq!(
            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("x")))?,
            "ooof"
        );
        assert_eq!(
            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o")))?,
            "oof"
        );
        assert_eq!(
            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o*o")))?,
            "of"
        );
        assert_eq!(
            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o*")))?,
            "oof"
        );
        assert_eq!(
            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("ooof")))?,
            ""
        );
        assert_eq!(
            remove_smallest_matching_prefix("🚀🚀🚀rocket", Some(&Pattern::from("🚀")))?,
            "🚀🚀rocket"
        );
        Ok(())
    }

    #[test]
    fn test_remove_largest_matching_suffix() -> Result<()> {
        assert_eq!(
            remove_largest_matching_suffix("foo", Some(&Pattern::from("")))?,
            "foo"
        );
        assert_eq!(
            remove_largest_matching_suffix("foo", Some(&Pattern::from("x")))?,
            "foo"
        );
        assert_eq!(
            remove_largest_matching_suffix("foo", Some(&Pattern::from("o")))?,
            "fo"
        );
        assert_eq!(
            remove_largest_matching_suffix("foo", Some(&Pattern::from("o*")))?,
            "f"
        );
        assert_eq!(
            remove_largest_matching_suffix("foo", Some(&Pattern::from("foo")))?,
            ""
        );
        assert_eq!(
            remove_largest_matching_suffix("rocket🚀🚀🚀", Some(&Pattern::from("🚀")))?,
            "rocket🚀🚀"
        );
        Ok(())
    }

    #[test]
    fn test_remove_smallest_matching_suffix() -> Result<()> {
        assert_eq!(
            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("")))?,
            "fooo"
        );
        assert_eq!(
            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("x")))?,
            "fooo"
        );
        assert_eq!(
            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o")))?,
            "foo"
        );
        assert_eq!(
            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o*o")))?,
            "fo"
        );
        assert_eq!(
            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o*")))?,
            "foo"
        );
        assert_eq!(
            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("fooo")))?,
            ""
        );
        assert_eq!(
            remove_smallest_matching_suffix("rocket🚀🚀🚀", Some(&Pattern::from("🚀")))?,
            "rocket🚀🚀"
        );
        Ok(())
    }

    #[test]
    #[expect(clippy::cognitive_complexity)]
    fn test_matching() -> Result<()> {
        assert!(Pattern::from("abc").exactly_matches("abc")?);

        assert!(!Pattern::from("abc").exactly_matches("ABC")?);
        assert!(!Pattern::from("abc").exactly_matches("xabcx")?);
        assert!(!Pattern::from("abc").exactly_matches("")?);
        assert!(!Pattern::from("abc").exactly_matches("abcd")?);
        assert!(!Pattern::from("abc").exactly_matches("def")?);

        assert!(Pattern::from("*").exactly_matches("")?);
        assert!(Pattern::from("*").exactly_matches("abc")?);
        assert!(Pattern::from("*").exactly_matches(" ")?);

        assert!(Pattern::from("a*").exactly_matches("a")?);
        assert!(Pattern::from("a*").exactly_matches("ab")?);
        assert!(Pattern::from("a*").exactly_matches("a ")?);

        assert!(!Pattern::from("a*").exactly_matches("A")?);
        assert!(!Pattern::from("a*").exactly_matches("")?);
        assert!(!Pattern::from("a*").exactly_matches("bc")?);
        assert!(!Pattern::from("a*").exactly_matches("xax")?);
        assert!(!Pattern::from("a*").exactly_matches(" a")?);

        assert!(Pattern::from("*a").exactly_matches("a")?);
        assert!(Pattern::from("*a").exactly_matches("ba")?);
        assert!(Pattern::from("*a").exactly_matches("aa")?);
        assert!(Pattern::from("*a").exactly_matches(" a")?);

        assert!(!Pattern::from("*a").exactly_matches("BA")?);
        assert!(!Pattern::from("*a").exactly_matches("")?);
        assert!(!Pattern::from("*a").exactly_matches("ab")?);
        assert!(!Pattern::from("*a").exactly_matches("xax")?);

        Ok(())
    }

    fn make_extglob(s: &str) -> Pattern {
        let pattern = Pattern::from(s).set_extended_globbing(true);
        let regex_str = pattern.to_regex_str(true, true).unwrap();
        eprintln!("pattern: '{s}' => regex: '{regex_str}'");

        pattern
    }

    #[test]
    fn test_extglob_or_matching() -> Result<()> {
        assert!(make_extglob("@(a|b)").exactly_matches("a")?);
        assert!(make_extglob("@(a|b)").exactly_matches("b")?);

        assert!(!make_extglob("@(a|b)").exactly_matches("")?);
        assert!(!make_extglob("@(a|b)").exactly_matches("c")?);
        assert!(!make_extglob("@(a|b)").exactly_matches("ab")?);

        assert!(!make_extglob("@(a|b)").exactly_matches("")?);
        assert!(make_extglob("@(a*b|b)").exactly_matches("ab")?);
        assert!(make_extglob("@(a*b|b)").exactly_matches("axb")?);
        assert!(make_extglob("@(a*b|b)").exactly_matches("b")?);

        assert!(!make_extglob("@(a*b|b)").exactly_matches("a")?);

        Ok(())
    }

    #[test]
    fn test_extglob_not_matching() -> Result<()> {
        // Basic cases.
        assert!(make_extglob("!(a)").exactly_matches("")?);
        assert!(make_extglob("!(a)").exactly_matches(" ")?);
        assert!(make_extglob("!(a)").exactly_matches("x")?);
        assert!(make_extglob("!(a)").exactly_matches(" a ")?);
        assert!(make_extglob("!(a)").exactly_matches("a ")?);
        assert!(make_extglob("!(a)").exactly_matches("aa")?);
        assert!(!make_extglob("!(a)").exactly_matches("a")?);

        assert!(make_extglob("a!(a)a").exactly_matches("aa")?);
        assert!(make_extglob("a!(a)a").exactly_matches("aaaa")?);
        assert!(make_extglob("a!(a)a").exactly_matches("aba")?);
        assert!(!make_extglob("a!(a)a").exactly_matches("a")?);
        assert!(!make_extglob("a!(a)a").exactly_matches("aaa")?);
        assert!(!make_extglob("a!(a)a").exactly_matches("baaa")?);

        // Alternates.
        assert!(make_extglob("!(a|b)").exactly_matches("c")?);
        assert!(make_extglob("!(a|b)").exactly_matches("ab")?);
        assert!(make_extglob("!(a|b)").exactly_matches("aa")?);
        assert!(make_extglob("!(a|b)").exactly_matches("bb")?);
        assert!(!make_extglob("!(a|b)").exactly_matches("a")?);
        assert!(!make_extglob("!(a|b)").exactly_matches("b")?);

        Ok(())
    }

    #[test]
    fn test_extglob_advanced_not_matching() -> Result<()> {
        assert!(make_extglob("!(a*)").exactly_matches("b")?);
        assert!(make_extglob("!(a*)").exactly_matches("")?);
        assert!(!make_extglob("!(a*)").exactly_matches("a")?);
        assert!(!make_extglob("!(a*)").exactly_matches("abc")?);
        assert!(!make_extglob("!(a*)").exactly_matches("aabc")?);

        Ok(())
    }

    #[test]
    fn test_extglob_not_degenerate_matching() -> Result<()> {
        // Degenerate case.
        assert!(make_extglob("!()").exactly_matches("a")?);
        assert!(!make_extglob("!()").exactly_matches("")?);

        Ok(())
    }

    #[test]
    fn test_extglob_zero_or_more_matching() -> Result<()> {
        assert!(make_extglob("x*(a)x").exactly_matches("xx")?);
        assert!(make_extglob("x*(a)x").exactly_matches("xax")?);
        assert!(make_extglob("x*(a)x").exactly_matches("xaax")?);

        assert!(!make_extglob("x*(a)x").exactly_matches("x")?);
        assert!(!make_extglob("x*(a)x").exactly_matches("xa")?);
        assert!(!make_extglob("x*(a)x").exactly_matches("xxx")?);

        assert!(make_extglob("*(a|b)").exactly_matches("")?);
        assert!(make_extglob("*(a|b)").exactly_matches("a")?);
        assert!(make_extglob("*(a|b)").exactly_matches("b")?);
        assert!(make_extglob("*(a|b)").exactly_matches("aba")?);
        assert!(make_extglob("*(a|b)").exactly_matches("aaa")?);

        assert!(!make_extglob("*(a|b)").exactly_matches("c")?);
        assert!(!make_extglob("*(a|b)").exactly_matches("ca")?);

        Ok(())
    }

    #[test]
    fn test_extglob_one_or_more_matching() -> Result<()> {
        fn make_extglob(s: &str) -> Pattern {
            Pattern::from(s).set_extended_globbing(true)
        }

        assert!(make_extglob("x+(a)x").exactly_matches("xax")?);
        assert!(make_extglob("x+(a)x").exactly_matches("xaax")?);

        assert!(!make_extglob("x+(a)x").exactly_matches("xx")?);
        assert!(!make_extglob("x+(a)x").exactly_matches("x")?);
        assert!(!make_extglob("x+(a)x").exactly_matches("xa")?);
        assert!(!make_extglob("x+(a)x").exactly_matches("xxx")?);

        assert!(make_extglob("+(a|b)").exactly_matches("a")?);
        assert!(make_extglob("+(a|b)").exactly_matches("b")?);
        assert!(make_extglob("+(a|b)").exactly_matches("aba")?);
        assert!(make_extglob("+(a|b)").exactly_matches("aaa")?);

        assert!(!make_extglob("+(a|b)").exactly_matches("")?);
        assert!(!make_extglob("+(a|b)").exactly_matches("c")?);
        assert!(!make_extglob("+(a|b)").exactly_matches("ca")?);

        assert!(make_extglob("+(x+(ab)y)").exactly_matches("xaby")?);
        assert!(make_extglob("+(x+(ab)y)").exactly_matches("xababy")?);
        assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababy")?);
        assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababyxabababyxabababy")?);

        assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xy")?);
        assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xay")?);
        assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xyxy")?);

        Ok(())
    }

    #[test]
    fn test_requires_expansion() {
        // Delegates to the PEG grammar; thorough coverage is in brush-parser.
        // Here we just verify the integration works.
        assert!(requires_expansion("*", false));
        assert!(requires_expansion("[abc]", false));
        assert!(!requires_expansion("]", false));
        assert!(!requires_expansion("hello", false));
        assert!(!requires_expansion("@(a)", false));
        assert!(requires_expansion("@(a)", true));
    }

    /// Extracts the `Expanded` payload from a `PatternExpansionResult`,
    /// failing the test via `anyhow::bail!` otherwise. Avoids `panic!` which
    /// is forbidden by the workspace clippy config.
    fn expect_expanded(result: PatternExpansionResult) -> Result<Vec<String>> {
        let PatternExpansionResult::Expanded(paths) = result else {
            anyhow::bail!("expected Expanded, got {result:?}");
        };
        Ok(paths)
    }

    /// Regression test for the Windows prefix-strip mismatch fix.
    ///
    /// On Windows (pre-fix), `expand` would build the `prefix_to_remove`
    /// using the platform `MAIN_SEPARATOR` (`\`) while `push_path_for_pattern`
    /// appended components with `/`. The resulting mismatch made the
    /// `strip_prefix` call a no-op, so relative globs produced absolute
    /// paths. This test exercises the expansion path and verifies results
    /// are returned relative to the working directory.
    ///
    /// On Unix, the same code path is exercised (both builds go through the
    /// shared `normalize_path_separators` helpers), so this test serves as a
    /// regression guard on both platforms.
    #[test]
    fn test_relative_glob_returns_relative_paths() -> Result<()> {
        let scratch = tempfile::tempdir()?;
        let sub = scratch.path().join("sub");
        std::fs::create_dir_all(&sub)?;
        std::fs::write(sub.join("a.txt"), "")?;
        std::fs::write(sub.join("b.txt"), "")?;

        let pattern = Pattern::from("sub/*.txt").set_extended_globbing(false);
        let result = pattern.expand::<fn(&Path) -> bool>(
            scratch.path(),
            None,
            &FilenameExpansionOptions::default(),
        )?;

        let paths = expect_expanded(result)?;

        let mut sorted = paths.clone();
        sorted.sort();
        assert_eq!(
            sorted,
            vec!["sub/a.txt".to_string(), "sub/b.txt".to_string()]
        );

        // None of the results should contain the absolute scratch path.
        let scratch_str: String = scratch.path().to_string_lossy().into_owned();
        for p in &paths {
            assert!(
                !p.contains(scratch_str.as_str()),
                "result {p:?} still contains absolute working-dir prefix {scratch_str:?}"
            );
        }

        Ok(())
    }

    /// Verifies absolute-pattern expansion still works after the prefix
    /// handling changes.
    #[test]
    fn test_absolute_glob_returns_absolute_paths() -> Result<()> {
        let scratch = tempfile::tempdir()?;
        std::fs::write(scratch.path().join("one.log"), "")?;
        std::fs::write(scratch.path().join("two.log"), "")?;

        let abs_pattern = format!("{}/*.log", scratch.path().to_string_lossy());
        // Normalize to forward slashes so the test works consistently across
        // platforms; the expander's `pattern_path_root` handles both.
        let abs_pattern = abs_pattern.replace('\\', "/");

        let pattern = Pattern::from(abs_pattern.as_str()).set_extended_globbing(false);
        let result = pattern.expand::<fn(&Path) -> bool>(
            Path::new("/"),
            None,
            &FilenameExpansionOptions::default(),
        )?;

        let paths = expect_expanded(result)?;

        assert_eq!(paths.len(), 2, "unexpected results: {paths:?}");
        let scratch_normalized: String = scratch.path().to_string_lossy().replace('\\', "/");
        for p in &paths {
            // Use a plain byte-level suffix check rather than `Path::extension`
            // since the results are strings and clippy flags `ends_with(".log")`
            // as potentially case-sensitive. We explicitly wrote lowercase files.
            assert!(p.as_bytes().ends_with(b".log"), "unexpected result {p:?}");
            // Should still reference the scratch directory (i.e., absolute).
            assert!(
                p.contains(scratch_normalized.as_str()),
                "absolute result {p:?} should contain {scratch_normalized:?}"
            );
        }

        Ok(())
    }
}
