pub mod auto_source_detection;
pub mod detect_sources;
pub mod init_tracing;
pub mod sources;

use crate::extractor::{Extracted, Extractor};
use crate::glob::optimize_patterns;
use crate::scanner::detect_sources::resolve_globs;
use crate::scanner::sources::{
    public_source_entries_to_private_source_entries, PublicSourceEntry, SourceEntry, Sources,
};
use crate::GlobEntry;
use auto_source_detection::BINARY_EXTENSIONS_GLOB;
use bstr::ByteSlice;
use fast_glob::glob_match;
use fxhash::{FxHashMap, FxHashSet};
use ignore::{gitignore::GitignoreBuilder, WalkBuilder};
use init_tracing::{init_tracing, SHOULD_TRACE};
use rayon::prelude::*;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use tracing::event;

// @source "some/folder";               // This is auto source detection
// @source "some/folder/**/*";          // This is auto source detection
// @source "some/folder/*.html";        // This is just a glob, but new files matching this should be included
// @source "node_modules/my-ui-lib";    // Auto source detection but since node_modules is explicit we allow it
//                                      // Maybe could be considered `external(…)` automatically if:
//                                      // 1. It's git ignored but listed explicitly
//                                      // 2. It exists outside of the current working directory (do we know that?)
//
// @source "do-include-me.bin";         // `.bin` is typically ignored, but now it's explicit so should be included
// @source "git-ignored.html";          // A git ignored file that is listed explicitly, should be scanned

#[derive(Debug, Clone)]
pub enum ChangedContent {
    File(PathBuf, String),
    Content(String, String),
}

#[derive(Debug, Clone)]
pub struct ScanOptions {
    /// Base path to start scanning from
    pub base: Option<String>,

    /// Glob sources
    pub sources: Vec<GlobEntry>,
}

#[derive(Debug, Clone)]
pub struct ScanResult {
    pub candidates: Vec<String>,
    pub files: Vec<String>,
    pub globs: Vec<GlobEntry>,
}

#[derive(Debug, Clone, Default)]
pub struct Scanner {
    /// Content sources
    sources: Sources,

    /// The walker to detect all files that we have to scan
    walker: Option<WalkBuilder>,

    /// All found extensions
    extensions: FxHashSet<String>,

    /// All files that we have to scan
    files: FxHashSet<PathBuf>,

    /// All directories, sub-directories, etc… we saw during source detection
    dirs: FxHashSet<PathBuf>,

    /// All generated globs, used for setting up watchers
    globs: Option<Vec<GlobEntry>>,

    /// Track unique set of candidates
    candidates: FxHashSet<String>,

    /// Track mtimes for files so incremental scans can skip unchanged files.
    mtimes: FxHashMap<PathBuf, SystemTime>,

    /// Files that were scanned during the last `scan()` call.
    scanned_files: Vec<String>,

    /// Whether we've completed at least one full scan. When false, we skip
    /// mtime tracking entirely so the initial build stays fast.
    has_scanned_once: bool,

    /// Whether sources have been scanned since the last `scan()` call
    sources_scanned: bool,
}

impl Scanner {
    pub fn new(sources: Vec<PublicSourceEntry>) -> Self {
        init_tracing();

        if *SHOULD_TRACE {
            event!(tracing::Level::INFO, "Provided sources:");
            for source in &sources {
                event!(tracing::Level::INFO, "Source: {:?}", source);
            }
        }

        let sources = Sources::new(public_source_entries_to_private_source_entries(sources));
        if *SHOULD_TRACE {
            event!(tracing::Level::INFO, "Optimized sources:");
            for source in sources.iter() {
                event!(tracing::Level::INFO, "Source: {:?}", source);
            }
        }

        let walker = create_walker(&sources);

        Self {
            sources,
            walker,
            ..Default::default()
        }
    }

    pub fn scan(&mut self) -> Vec<String> {
        self.sources_scanned = false;

        let (scanned_blobs, css_files, files) = self.discover_sources();

        self.extract_candidates(scanned_blobs, css_files);
        self.scanned_files = files;

        // Return all candidates sorted
        let mut result = self.candidates.iter().cloned().collect::<Vec<_>>();
        result.par_sort_unstable();
        result
    }

    #[tracing::instrument(skip_all)]
    pub fn scan_content(&mut self, changed_content: Vec<ChangedContent>) -> Vec<String> {
        let (changed_files, changed_contents) =
            changed_content
                .into_iter()
                .partition::<Vec<_>, _>(|x| match x {
                    ChangedContent::File(_, _) => true,
                    ChangedContent::Content(_, _) => false,
                });

        // Raw content can be parsed directly, no need to verify if the file exists and is allowed
        // to be scanned.
        let mut content_to_scan: Vec<ChangedContent> = changed_contents;

        // Fully resolve all files
        let changed_files = changed_files
            .into_iter()
            .filter_map(|changed_content| match changed_content {
                ChangedContent::File(file, extension) => {
                    let Ok(file) = dunce::canonicalize(file) else {
                        return None;
                    };
                    Some(ChangedContent::File(file, extension))
                }
                _ => unreachable!(),
            })
            .collect::<Vec<_>>();

        let (known_files, mut new_unknown_files) = changed_files
            .into_iter()
            .partition::<Vec<_>, _>(|changed_file| match changed_file {
                ChangedContent::Content(_, _) => unreachable!(),
                ChangedContent::File(file, _) => self.files.contains(file),
            });

        // All known files are allowed to be scanned
        content_to_scan.extend(known_files);

        // Figure out if the new unknown files are allowed to be scanned
        if !new_unknown_files.is_empty() {
            if let Some(walk_builder) = &mut self.walker {
                for entry in walk_builder.build().filter_map(Result::ok) {
                    let path = entry.path();
                    if !path.is_file() {
                        continue;
                    }

                    // The walked path can contain symlinks, while the changed files have already
                    // been canonicalized. Lazily canonicalize the walked path so we can compare
                    // the real paths as well.
                    let mut canonical_path: Option<PathBuf> = None;

                    let mut drop_file_indexes = vec![];
                    for (idx, changed_file) in new_unknown_files.iter().enumerate().rev() {
                        let ChangedContent::File(file, _) = changed_file else {
                            continue;
                        };

                        // When the file is found on disk it means that all the rules pass. We can
                        // extract the current file and remove it from the list of passed in files.
                        let matches = file == path
                            || (file.file_name() == path.file_name() && {
                                if canonical_path.is_none() {
                                    canonical_path = dunce::canonicalize(path).ok();
                                }
                                canonical_path.as_deref() == Some(file.as_path())
                            });

                        if matches {
                            self.files.insert(path.to_path_buf()); // Track for future use
                            content_to_scan.push(changed_file.clone()); // Track for parsing
                            drop_file_indexes.push(idx);
                        }
                    }

                    // Remove all files that we found on disk
                    if !drop_file_indexes.is_empty() {
                        drop_file_indexes.into_iter().for_each(|idx| {
                            new_unknown_files.remove(idx);
                        });
                    }

                    // We can stop walking the file system if all files we are interested in have
                    // been found.
                    if new_unknown_files.is_empty() {
                        break;
                    }
                }
            }
        }

        // Read all content into blobs for extraction
        let blobs = read_all_files(content_to_scan);
        self.extract_candidates(blobs, vec![])
    }

    #[tracing::instrument(skip_all)]
    fn extract_candidates(&mut self, blobs: Vec<Vec<u8>>, css_files: Vec<PathBuf>) -> Vec<String> {
        // Extract all candidates from the pre-read blobs
        let mut new_candidates = parse_all_blobs(blobs);

        // Extract all CSS variables from the CSS files
        if !css_files.is_empty() {
            let css_variables = extract_css_variables(read_all_files(
                css_files
                    .into_iter()
                    .map(|file| ChangedContent::File(file, "css".into()))
                    .collect(),
            ));

            new_candidates.extend(css_variables);
        }

        // Only keep candidates we haven't seen before
        for existing in self.candidates.iter() {
            new_candidates.remove(existing);
        }

        // Track new candidates for subsequent calls
        self.candidates.extend(new_candidates.iter().cloned());

        let mut result: Vec<String> = new_candidates.into_iter().collect();
        result.par_sort_unstable();

        result
    }

    #[tracing::instrument(skip_all)]
    pub fn get_files(&mut self) -> Vec<String> {
        let _ = self.discover_sources();

        self.files
            .par_iter()
            .filter_map(|x| x.clone().into_os_string().into_string().ok())
            .collect()
    }

    #[tracing::instrument(skip_all)]
    pub fn get_scanned_files(&self) -> Vec<String> {
        self.scanned_files.clone()
    }

    #[tracing::instrument(skip_all)]
    pub fn get_globs(&mut self) -> Vec<GlobEntry> {
        if let Some(globs) = &self.globs {
            return globs.clone();
        }

        let _ = self.discover_sources();

        let mut globs = vec![];
        for source in self.sources.iter() {
            match source {
                SourceEntry::Auto { base } | SourceEntry::External { base } => {
                    globs.extend(resolve_globs(
                        base.to_path_buf(),
                        &self.dirs,
                        &self.extensions,
                    ));
                }
                SourceEntry::Pattern { base, pattern } => {
                    globs.push(GlobEntry {
                        base: base.to_string_lossy().to_string(),
                        pattern: pattern.to_string(),
                    });
                }
                _ => {}
            }
        }

        // Re-optimize the globs to reduce the number of patterns we have to scan.
        globs = optimize_patterns(&globs);

        // Track the globs for subsequent calls
        self.globs = Some(globs.clone());

        globs
    }

    #[tracing::instrument(skip_all)]
    pub fn get_normalized_sources(&self) -> Vec<GlobEntry> {
        self.sources
            .iter()
            .filter_map(|source| match source {
                SourceEntry::Auto { base } | SourceEntry::External { base } => Some(GlobEntry {
                    base: base.to_string_lossy().to_string(),
                    pattern: "**/*".to_string(),
                }),
                SourceEntry::Pattern { base, pattern } => Some(GlobEntry {
                    base: base.to_string_lossy().to_string(),
                    pattern: pattern.to_string(),
                }),
                _ => None,
            })
            .collect()
    }

    #[tracing::instrument(skip_all)]
    pub fn get_candidates_with_positions(
        &mut self,
        changed_content: ChangedContent,
    ) -> Vec<(String, usize)> {
        let content = read_changed_content(changed_content).unwrap_or_default();
        let original_content = &content;

        // Workaround for legacy upgrades:
        //
        // `-[]` won't parse in the new parser (`[…]` must contain _something_), but we do need it
        // for people using `group-[]` (which we will later replace with `in-[.group]` instead).
        let content = content.replace("-[]", "XYZ");
        let offset = content.as_ptr() as usize;

        let mut extractor = Extractor::new(&content[..]);

        extractor
            .extract()
            .into_par_iter()
            .flat_map(|extracted| match extracted {
                Extracted::Candidate(s) => {
                    let i = s.as_ptr() as usize - offset;
                    let original = &original_content[i..i + s.len()];
                    if original.contains_str("-[]") {
                        return String::from_utf8(original.to_vec())
                            .ok()
                            .map(|candidate| (candidate, i));
                    }

                    String::from_utf8(s.to_vec())
                        .ok()
                        .map(|candidate| (candidate, i))
                }

                _ => None,
            })
            .collect()
    }

    #[tracing::instrument(skip_all)]
    fn discover_sources(&mut self) -> (Vec<Vec<u8>>, Vec<PathBuf>, Vec<String>) {
        if self.sources_scanned {
            return (vec![], vec![], vec![]);
        }
        self.sources_scanned = true;

        let Some(walker) = &mut self.walker else {
            return (vec![], vec![], vec![]);
        };

        // Use synchronous walk for the initial build (lower overhead) and parallel
        // walk for subsequent calls (watch mode) where the overhead is amortised.
        let all_entries = if self.has_scanned_once {
            walk_parallel(walker)
        } else {
            walk_synchronous(walker)
        };

        let mut css_files: Vec<PathBuf> = vec![];
        let mut content_paths: Vec<(PathBuf, String)> = vec![];
        let mut changed_files = vec![];

        // Fresh state
        self.files.clear();
        self.dirs.clear();
        self.extensions.clear();
        self.globs = None;

        // Cache canonicalized folders in case a file itself is not symlinked, but any of the parent
        // folders are symlinked.
        let mut cached_canonical_dirs: FxHashMap<PathBuf, PathBuf> = FxHashMap::default();

        for entry in all_entries {
            match entry {
                WalkEntry::Dir(path) => {
                    self.dirs.insert(path);
                }
                WalkEntry::File {
                    path,
                    mtime,
                    is_symlink,
                } => {
                    // Deduplicate: parallel walk can visit the same file from multiple threads
                    if !self.files.insert(path.clone()) {
                        continue;
                    }

                    // Track canonicalized paths in addition to potentially symlinked file paths
                    let canonical = if is_symlink {
                        dunce::canonicalize(&path).ok()
                    } else {
                        path.parent().and_then(|parent| {
                            // Perf: cache the canonicalized parent path such that sibling files don't
                            // have to canonicalize over and over again.
                            let canonical_parent = cached_canonical_dirs
                                .entry(parent.to_path_buf())
                                .or_insert_with(|| {
                                    dunce::canonicalize(parent)
                                        .unwrap_or_else(|_| parent.to_path_buf())
                                });

                            if canonical_parent.as_path() != parent {
                                path.file_name()
                                    .map(|file_name| canonical_parent.join(file_name))
                            } else {
                                None
                            }
                        })
                    };

                    if let Some(canonical) = canonical {
                        if canonical != path {
                            self.files.insert(canonical);
                        }
                    }
                    let extension = path
                        .extension()
                        .and_then(|x| x.to_str())
                        .unwrap_or_default()
                        .to_owned();

                    self.extensions.insert(extension.to_owned());

                    // On incremental scans, check mtime to skip unchanged files.
                    // On the first scan, track mtimes while still scanning every file.
                    let changed = if self.has_scanned_once {
                        match mtime {
                            Some(mtime) => {
                                let prev = self.mtimes.insert(path.clone(), mtime);
                                prev.is_none_or(|prev| prev != mtime)
                            }
                            None => true,
                        }
                    } else {
                        if let Some(mtime) = mtime {
                            self.mtimes.insert(path.clone(), mtime);
                        }

                        true
                    };

                    if !changed {
                        continue;
                    }

                    if let Ok(file) = path.clone().into_os_string().into_string() {
                        changed_files.push(file);
                    }

                    match extension.as_str() {
                        // Special handing for CSS files, we don't want to extract candidates from
                        // these files, but we do want to extract used CSS variables.
                        "css" => css_files.push(path),
                        _ => content_paths.push((path, extension)),
                    }
                }
            }
        }

        // Ensure `mtimes` don't include stale files
        self.mtimes.retain(|path, _| self.files.contains(path));

        // Read + preprocess all discovered files in parallel
        let scanned_blobs: Vec<Vec<u8>> = content_paths
            .into_par_iter()
            .filter_map(|(path, ext)| {
                let content = std::fs::read(&path).ok()?;
                event!(tracing::Level::INFO, "Reading {:?}", path);
                let processed = pre_process_input(content, &ext);
                if processed.is_empty() {
                    None
                } else {
                    Some(processed)
                }
            })
            .collect();

        if !self.has_scanned_once {
            self.has_scanned_once = true;
        }

        changed_files.par_sort_unstable();

        (scanned_blobs, css_files, changed_files)
    }
}

fn read_changed_content(c: ChangedContent) -> Option<Vec<u8>> {
    let (content, extension) = match c {
        ChangedContent::File(file, extension) => match std::fs::read(&file) {
            Ok(content) => {
                event!(tracing::Level::INFO, "Reading {:?}", file);
                (content, extension)
            }
            Err(e) => {
                event!(tracing::Level::ERROR, "Failed to read file: {:?}", e);
                return None;
            }
        },

        ChangedContent::Content(contents, extension) => (contents.into_bytes(), extension),
    };

    Some(pre_process_input(content, &extension))
}

pub fn pre_process_input(content: Vec<u8>, extension: &str) -> Vec<u8> {
    use crate::extractor::pre_processors::*;

    match extension {
        "clj" | "cljs" | "cljc" => Clojure.process(&content),
        "heex" | "eex" | "ex" | "exs" => Elixir.process(&content),
        "cshtml" | "razor" => Razor.process(&content),
        "haml" => Haml.process(&content),
        "json" | "jsonl" | "ndjson" => Json.process(&content),
        "md" | "mdx" => Markdown.process(&content),
        "pug" => Pug.process(&content),
        "rb" | "erb" => Ruby.process(&content),
        "slim" | "slang" => Slim.process(&content),
        "svelte" => Svelte.process(&content),
        "rs" => Rust.process(&content),
        "tt" | "tt2" | "tx" => TemplateToolkit.process(&content),
        "twig" => Twig.process(&content),
        "vue" => Vue.process(&content),
        _ => content,
    }
}

#[tracing::instrument(skip_all)]
fn read_all_files(changed_content: Vec<ChangedContent>) -> Vec<Vec<u8>> {
    event!(
        tracing::Level::INFO,
        "Reading {:?} file(s)",
        changed_content.len()
    );

    changed_content
        .into_par_iter()
        .filter_map(read_changed_content)
        .collect()
}

#[tracing::instrument(skip_all)]
fn extract_css_variables(blobs: Vec<Vec<u8>>) -> FxHashSet<String> {
    extract(blobs, |mut extractor| {
        extractor.extract_variables_from_css()
    })
}

#[tracing::instrument(skip_all)]
fn parse_all_blobs(blobs: Vec<Vec<u8>>) -> FxHashSet<String> {
    extract(blobs, |mut extractor| extractor.extract())
}

#[tracing::instrument(skip_all)]
fn extract<H>(blobs: Vec<Vec<u8>>, handle: H) -> FxHashSet<String>
where
    H: Fn(Extractor) -> Vec<Extracted> + std::marker::Sync,
{
    blobs
        .par_iter()
        .flat_map(|blob| blob.par_split(|x| *x == b'\n'))
        .filter_map(|blob| {
            if blob.is_empty() {
                return None;
            }

            let extracted = handle(crate::extractor::Extractor::new(blob));
            if extracted.is_empty() {
                return None;
            }

            Some(FxHashSet::from_iter(extracted.into_iter().map(
                |x| match x {
                    Extracted::Candidate(bytes) => bytes,
                    Extracted::CssVariable(bytes) => bytes,
                },
            )))
        })
        .reduce(Default::default, |mut a, b| {
            a.extend(b);
            a
        })
        .into_iter()
        .filter_map(|s| String::from_utf8(s.to_vec()).ok())
        .collect()
}

#[derive(Debug)]
enum WalkEntry {
    Dir(PathBuf),
    File {
        path: PathBuf,
        mtime: Option<SystemTime>,

        /// Whether the path itself is a symlink
        is_symlink: bool,
    },
}

impl From<ignore::DirEntry> for WalkEntry {
    fn from(entry: ignore::DirEntry) -> Self {
        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
        let is_symlink = entry.path_is_symlink();
        let path = entry.into_path();

        if is_dir {
            WalkEntry::Dir(path)
        } else {
            let mtime = path.metadata().ok().and_then(|m| m.modified().ok());
            WalkEntry::File {
                path,
                mtime,
                is_symlink,
            }
        }
    }
}

/// Walk the file system synchronously. Used for the initial build where the overhead of spawning
/// parallel walker threads is not worth it.
#[tracing::instrument(skip_all)]
fn walk_synchronous(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
    walker
        .build()
        .filter_map(Result::ok)
        .map(WalkEntry::from)
        .collect()
}

/// Walk the file system in parallel. Used in watch mode where the parallel walker overhead is
/// amortised across many rebuilds and subsequent calls are much faster.
#[tracing::instrument(skip_all)]
fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
    struct FlushOnDrop {
        local: Vec<WalkEntry>,
        shared: Arc<Mutex<Vec<WalkEntry>>>,
    }

    impl Drop for FlushOnDrop {
        fn drop(&mut self) {
            if !self.local.is_empty() {
                self.shared.lock().unwrap().append(&mut self.local);
            }
        }
    }

    let collected: Arc<Mutex<Vec<WalkEntry>>> = Arc::new(Mutex::new(vec![]));

    walker.build_parallel().run(|| {
        let mut buf = FlushOnDrop {
            local: Vec::with_capacity(256),
            shared: collected.clone(),
        };

        Box::new(move |entry| {
            let Ok(entry) = entry else {
                return ignore::WalkState::Continue;
            };

            buf.local.push(WalkEntry::from(entry));

            if buf.local.len() >= 256 {
                buf.shared.lock().unwrap().append(&mut buf.local);
            }

            ignore::WalkState::Continue
        })
    });

    // All threads have finished and flushed their buffers via FlushOnDrop::drop
    Arc::try_unwrap(collected).unwrap().into_inner().unwrap()
}

/// Sets up a WalkBuilder with all source roots, gitignore rules, and source pattern matching.
///
/// This is the common setup shared between the full walker (with mtime tracking for re-scans)
/// and the parallel walker (without mtime tracking for the initial scan).
fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
    let mut other_roots: FxHashSet<&PathBuf> = FxHashSet::default();
    let mut first_root: Option<&PathBuf> = None;

    let mut ignores: Vec<(&PathBuf, Vec<String>)> = Default::default();
    let mut emit = |base, pattern| match ignores.last_mut() {
        Some((prev_base, patterns)) if *prev_base == base => {
            patterns.push(pattern);
        }
        _ => {
            ignores.push((base, vec![pattern]));
        }
    };

    for source in sources.iter() {
        match source {
            SourceEntry::Auto { base } => {
                if first_root.is_none() {
                    first_root = Some(base);
                } else {
                    other_roots.insert(base);
                }
            }
            SourceEntry::Pattern { base, pattern } => {
                let pattern = pattern.to_owned();

                if first_root.is_none() {
                    first_root = Some(base);
                } else {
                    other_roots.insert(base);
                }

                if !pattern.contains("**") {
                    // Specific patterns should take precedence even over git-ignored files:
                    emit(base, format!("!{}", pattern));
                } else {
                    // Assumption: the pattern we receive will already be brace expanded. So
                    // `*.{html,jsx}` will result in two separate patterns: `*.html` and `*.jsx`.
                    if let Some(extension) = Path::new(&pattern).extension() {
                        // Extend auto source detection to include the extension
                        emit(base, format!("!*.{}", extension.to_string_lossy()));
                    }
                }
            }
            SourceEntry::Ignored { base, pattern } => {
                emit(base, pattern.to_owned());
            }
            SourceEntry::External { base } => {
                if first_root.is_none() {
                    first_root = Some(base);
                } else {
                    other_roots.insert(base);
                }

                // External sources should take precedence even over git-ignored files:
                emit(base, "!/**/*".to_owned());

                // External sources should still disallow binary extensions:
                emit(base, BINARY_EXTENSIONS_GLOB.clone());
            }
        }
    }

    let mut builder = WalkBuilder::new(first_root?);

    // We have to follow symlinks
    builder.follow_links(true);

    // Scan hidden files / directories
    builder.hidden(false);

    // Don't respect global gitignore files
    builder.git_global(false);

    // By default, allow .gitignore files to be used regardless of whether or not
    // a .git directory is present. This is an optimization for when projects
    // are first created and may not be in a git repo yet.
    builder.require_git(false);

    // If we are in a git repo then require it to ensure that only rules within
    // the repo are used. For example, we don't want to consider a .gitignore file
    // in the user's home folder if we're in a git repo.
    //
    // The alternative is using a call like `.parents(false)` but that will
    // prevent looking at parent directories for .gitignore files from within
    // the repo and that's not what we want.
    //
    // For example, in a project with this structure:
    //
    // home
    // .gitignore
    //  my-project
    //   .gitignore
    //   apps
    //     .gitignore
    //     web
    //       {root}
    //
    // We do want to consider all .gitignore files listed:
    // - home/.gitignore
    // - my-project/.gitignore
    // - my-project/apps/.gitignore
    //
    // However, if a repo is initialized inside my-project then only the following
    // make sense for consideration:
    // - my-project/.gitignore
    // - my-project/apps/.gitignore
    //
    // Setting the require_git(true) flag conditionally allows us to do this.
    for parent in first_root?.ancestors() {
        if parent.join(".git").exists() {
            builder.require_git(true);
            break;
        }
    }

    for root in other_roots {
        builder.add(root);
    }

    // Setup auto source detection rules
    for ignore in auto_source_detection::RULES.iter() {
        builder.add_gitignore(ignore.clone());
    }

    // Setup ignores based on `@source` definitions
    for (base, patterns) in ignores {
        let mut ignore_builder = GitignoreBuilder::new(base);
        for pattern in patterns {
            ignore_builder.add_line(None, &pattern).unwrap();
        }
        let ignore = ignore_builder.build().unwrap();
        builder.add_gitignore(ignore);
    }

    // Pre-compute source matching data to avoid allocations in the hot filter_entry path
    let auto_bases: Vec<PathBuf> = sources
        .iter()
        .filter_map(|source| match source {
            SourceEntry::Auto { base } | SourceEntry::External { base } => Some(base.clone()),
            _ => None,
        })
        .collect();

    let pattern_sources: Vec<(PathBuf, String)> = sources
        .iter()
        .filter_map(|source| match source {
            SourceEntry::Pattern { base, pattern } => Some((base.into(), pattern.into())),
            _ => None,
        })
        .collect();

    // Source pattern matching filter (lock-free, safe for parallel walking)
    builder.filter_entry(move |entry| {
        let path = entry.path();

        // Ensure the entries are matching any of the provided source patterns (this is
        // necessary for manual-patterns that can filter the file extension)
        if path.is_file() {
            let mut matches = false;

            for base in &auto_bases {
                if path.starts_with(base) {
                    matches = true;
                    break;
                }
            }

            if !matches {
                for (base, pattern) in &pattern_sources {
                    let remainder = path.strip_prefix(base);
                    if remainder.is_ok_and(|remainder| {
                        let mut path_str = remainder.to_string_lossy().to_string();
                        if !path_str.starts_with("/") {
                            path_str = format!("/{path_str}");
                        }
                        glob_match(pattern, path_str.as_bytes())
                    }) {
                        matches = true;
                        break;
                    }
                }
            }

            if !matches {
                return false;
            }
        }

        true
    });

    Some(builder)
}

#[cfg(test)]
mod tests {
    use super::{ChangedContent, Scanner};
    use pretty_assertions::assert_eq;

    #[test]
    fn test_positions() {
        let mut scanner = Scanner::new(vec![]);

        for (input, expected) in [
            // Before migrations
            (
                r#"<div class="!tw__flex sm:!tw__block tw__bg-gradient-to-t flex tw:[color:red] group-[]:tw__flex"#,
                vec![
                    ("class".to_string(), 5),
                    ("!tw__flex".to_string(), 12),
                    ("sm:!tw__block".to_string(), 22),
                    ("tw__bg-gradient-to-t".to_string(), 36),
                    ("flex".to_string(), 57),
                    ("tw:[color:red]".to_string(), 62),
                    ("group-[]:tw__flex".to_string(), 77),
                ],
            ),
            // After migrations
            (
                r#"<div class="tw:flex! tw:sm:block! tw:bg-linear-to-t flex tw:[color:red] tw:in-[.tw\:group]:flex"></div>"#,
                vec![
                    ("class".to_string(), 5),
                    ("tw:flex!".to_string(), 12),
                    ("tw:sm:block!".to_string(), 21),
                    ("tw:bg-linear-to-t".to_string(), 34),
                    ("flex".to_string(), 52),
                    ("tw:[color:red]".to_string(), 57),
                    ("tw:in-[.tw\\:group]:flex".to_string(), 72),
                ],
            ),
        ] {
            let candidates = scanner.get_candidates_with_positions(ChangedContent::Content(
                input.to_string(),
                "html".into(),
            ));
            assert_eq!(candidates, expected);
        }
    }
}