//! Corpus walk, file stat, and posting assembly helpers.
//!
//! These are private helpers used by [`TrigramIndex::build`] and
//! [`TrigramIndex::update`] — there is no separate builder type.

use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use ignore::{
    DirEntry, Error as IgnoreError, ParallelVisitor, ParallelVisitorBuilder, WalkBuilder, WalkState,
};

use super::Trigram;
use super::file_table::FileFingerprint;
use super::storage::lexicon::LexiconEntry;
use super::storage::trigram_sets::TrigramSet;

use crate::index::{CorpusKind, IndexConfig};
use crate::search::filter::{HiddenMode, IgnoreSources};

/// Collected index data ready for persistence.
pub struct IndexTables {
    pub fingerprints: Vec<FileFingerprint>,
    pub file_trigrams: Vec<TrigramSet>,
    pub lexicon: Vec<LexiconEntry>,
    pub postings: Vec<u8>,
}

// ---------------------------------------------------------------------------
// Corpus walk
// ---------------------------------------------------------------------------

/// Per-thread collector for [`WalkParallel::visit`].
struct CorpusPathCollector<'a> {
    root: PathBuf,
    exclude_paths: &'a [PathBuf],
    include_paths: &'a [PathBuf],
    max_filesize: Option<u64>,
    thread_paths: Vec<PathBuf>,
    walk_error: Arc<Mutex<Option<crate::Error>>>,
    consolidated_paths: Arc<Mutex<Vec<PathBuf>>>,
}

impl Drop for CorpusPathCollector<'_> {
    fn drop(&mut self) {
        if self.thread_paths.is_empty() {
            return;
        }
        let mut guard = self
            .consolidated_paths
            .lock()
            .expect("consolidate corpus paths lock");
        guard.append(&mut self.thread_paths);
    }
}

impl ParallelVisitor for CorpusPathCollector<'_> {
    fn visit(&mut self, entry: Result<DirEntry, IgnoreError>) -> WalkState {
        let entry = match entry {
            Ok(entry) => entry,
            Err(err) => {
                let mut guard = self.walk_error.lock().expect("walk error lock");
                if guard.is_none() {
                    *guard = Some(crate::Error::Ignore(err));
                }
                drop(guard);
                return WalkState::Quit;
            }
        };
        let path = entry.path();
        let rel = path.strip_prefix(&self.root).unwrap_or(path);

        // Skip excluded directories before descent.
        if entry.file_type().is_some_and(|ft| ft.is_dir()) {
            if self.is_excluded(rel) {
                return WalkState::Skip;
            }
            return WalkState::Continue;
        }

        if entry.file_type().is_some_and(|ft| ft.is_file()) && !self.is_excluded(rel) {
            if let Some(limit) = self.max_filesize
                && entry.metadata().is_ok_and(|m| m.len() > limit)
            {
                return WalkState::Continue;
            }
            self.thread_paths.push(rel.to_path_buf());
        }
        WalkState::Continue
    }
}

impl CorpusPathCollector<'_> {
    /// True when the path matches an explicit exclude rule or falls outside
    /// the explicit include set.
    ///
    /// For directories this accepts the path if *any* included path lies
    /// under it, so that directory pruning does not skip an ancestor of a
    /// wanted file.
    fn is_excluded(&self, rel: &Path) -> bool {
        if self
            .exclude_paths
            .iter()
            .any(|excluded| rel.starts_with(excluded))
        {
            return true;
        }
        if !self.include_paths.is_empty()
            && !self.include_paths.iter().any(|included| {
                rel == *included || included.starts_with(rel) || rel.starts_with(included)
            })
        {
            return true;
        }
        false
    }
}

struct CorpusPathCollectorBuilder<'a> {
    root: PathBuf,
    exclude_paths: &'a [PathBuf],
    include_paths: &'a [PathBuf],
    max_filesize: Option<u64>,
    walk_error: Arc<Mutex<Option<crate::Error>>>,
    consolidated_paths: Arc<Mutex<Vec<PathBuf>>>,
}

impl<'a> ParallelVisitorBuilder<'a> for CorpusPathCollectorBuilder<'a> {
    fn build(&mut self) -> Box<dyn ParallelVisitor + 'a> {
        Box::new(CorpusPathCollector {
            root: self.root.clone(),
            exclude_paths: self.exclude_paths,
            include_paths: self.include_paths,
            max_filesize: self.max_filesize,
            thread_paths: Vec::new(),
            walk_error: Arc::clone(&self.walk_error),
            consolidated_paths: Arc::clone(&self.consolidated_paths),
        })
    }
}

/// Walks the corpus directory and collects sorted relative file paths.
pub struct CorpusWalker<'a> {
    config: &'a IndexConfig<'a>,
}

impl<'a> CorpusWalker<'a> {
    pub const fn new(config: &'a IndexConfig<'a>) -> Self {
        Self { config }
    }

    fn walk_builder(&self) -> WalkBuilder {
        let sources = self.config.visibility.ignore.sources;
        let mut wb = WalkBuilder::new(self.config.corpus.root);
        wb.follow_links(self.config.corpus.follow_links)
            .same_file_system(self.config.walk.one_file_system)
            .hidden(matches!(self.config.visibility.hidden, HiddenMode::Respect))
            .parents(sources.contains(IgnoreSources::PARENT))
            .ignore(sources.contains(IgnoreSources::DOT))
            .git_ignore(sources.contains(IgnoreSources::VCS))
            .git_exclude(sources.contains(IgnoreSources::EXCLUDE))
            .git_global(sources.contains(IgnoreSources::GLOBAL))
            .require_git(self.config.visibility.ignore.require_git);
        if let Some(d) = self.config.walk.max_depth {
            wb.max_depth(Some(d + 1));
        }
        wb
    }

    pub fn collect(&self) -> crate::Result<Vec<PathBuf>> {
        let walk_error = Arc::new(Mutex::new(None));
        let consolidated_paths = Arc::new(Mutex::new(Vec::new()));

        let mut builder = CorpusPathCollectorBuilder {
            root: self.config.corpus.root.to_path_buf(),
            exclude_paths: self.config.corpus.exclude_paths,
            include_paths: self.config.corpus.include_paths,
            max_filesize: self.config.walk.max_filesize,
            walk_error: Arc::clone(&walk_error),
            consolidated_paths: Arc::clone(&consolidated_paths),
        };

        self.walk_builder().build_parallel().visit(&mut builder);

        {
            let mut err_slot = walk_error.lock().expect("walk error lock");
            if let Some(err) = err_slot.take() {
                return Err(err);
            }
        }

        let merged_paths = {
            let mut guard = consolidated_paths.lock().expect("paths lock");
            guard.sort_unstable();
            std::mem::take(&mut *guard)
        };
        Ok(merged_paths)
    }
}

// ---------------------------------------------------------------------------
// Fingerprint collection
// ---------------------------------------------------------------------------

/// Stats each file to produce fingerprints with mtime and size.
pub struct FingerprintCollector<'a> {
    root: &'a Path,
    paths: &'a [PathBuf],
}

impl<'a> FingerprintCollector<'a> {
    pub const fn new(root: &'a Path, paths: &'a [PathBuf]) -> Self {
        Self { root, paths }
    }

    pub fn collect(&self) -> crate::Result<Vec<FileFingerprint>> {
        use rayon::prelude::*;
        self.paths
            .par_iter()
            .map(|rel| {
                let abs = self.root.join(rel);
                let meta = std::fs::metadata(&abs)?;
                let mtime_secs = meta
                    .modified()
                    .ok()
                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                    .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0));
                let size = meta.len();
                Ok(FileFingerprint {
                    path: rel.clone(),
                    mtime_secs,
                    size,
                })
            })
            .collect()
    }
}

// ---------------------------------------------------------------------------
// Posting assembly
// ---------------------------------------------------------------------------

/// Assembles trigram → file ID posting lists from per-file trigram sets.
pub struct PostingAssembler<'a> {
    file_trigrams: &'a [TrigramSet],
}

impl<'a> PostingAssembler<'a> {
    pub const fn new(file_trigrams: &'a [TrigramSet]) -> Self {
        Self { file_trigrams }
    }

    pub fn assemble(&self) -> crate::Result<(Vec<LexiconEntry>, Vec<u8>)> {
        let total: usize = self.file_trigrams.iter().map(|s| s.as_slice().len()).sum();
        if self.file_trigrams.len() > u32::MAX as usize {
            return Err(crate::Error::Io(std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                "too many indexed files",
            )));
        }

        // Collect (trigram_key, file_id) pairs packed into u64.
        let mut pairs = Vec::with_capacity(total);
        for (fid, set) in self.file_trigrams.iter().enumerate() {
            let fid32 = u32::try_from(fid).expect("file count checked above");
            for tri in set.as_slice() {
                pairs.push((u64::from(tri.as_u24()) << 32) | u64::from(fid32));
            }
        }

        // 2-pass stable radix sort on the trigram key (bits 32-55).
        // File IDs (bits 0-31) are already ordered because we iterate files
        // in ascending order, and a stable sort preserves that order within
        // each trigram group.  This halves the passes compared to sorting
        // the full 56-bit packed value.
        Self::radix_sort_trigram(&mut pairs);

        Self::encode_sorted(&pairs)
    }

    fn radix_sort_trigram(pairs: &mut Vec<u64>) {
        let len = pairs.len();
        if len < 2 {
            return;
        }

        let mut scratch = vec![0u64; len];
        let mut count = vec![0usize; 65_536];

        // Pass 1: sort by bits 32-47 (low 16 of trigram key).
        for &p in pairs.iter() {
            count[((p >> 32) & 0xFFFF) as usize] += 1;
        }
        let mut total = 0usize;
        for c in &mut count {
            let tmp = *c + total;
            *c = total;
            total = tmp;
        }
        for &p in pairs.iter() {
            let bucket = ((p >> 32) & 0xFFFF) as usize;
            scratch[count[bucket]] = p;
            count[bucket] += 1;
        }
        std::mem::swap(pairs, &mut scratch);
        count.fill(0);

        // Pass 2: sort by bits 48-55 (high 8 of 24-bit trigram key).
        // Only 256 values are possible; we still use the 65536 array
        // to avoid a separate code path, but only the first 256 entries
        // are touched.
        for &p in pairs.iter() {
            count[((p >> 48) & 0xFF) as usize] += 1;
        }
        total = 0;
        for c in &mut count {
            let tmp = *c + total;
            *c = total;
            total = tmp;
        }
        for &p in pairs.iter() {
            let bucket = ((p >> 48) & 0xFF) as usize;
            scratch[count[bucket]] = p;
            count[bucket] += 1;
        }
        std::mem::swap(pairs, &mut scratch);
    }

    fn encode_sorted(pairs: &[u64]) -> crate::Result<(Vec<LexiconEntry>, Vec<u8>)> {
        let mut posting_bytes = Vec::with_capacity(pairs.len() * 3);
        let mut lex_entries = Vec::new();
        let mut i = 0;
        while i < pairs.len() {
            let tri_key = (pairs[i] >> 32) as u32;
            let start = i;
            while i < pairs.len() && (pairs[i] >> 32) as u32 == tri_key {
                i += 1;
            }
            let offset: u64 = posting_bytes.len().try_into().map_err(|_| {
                crate::Error::Io(std::io::Error::new(
                    std::io::ErrorKind::InvalidInput,
                    "postings offset overflow",
                ))
            })?;
            let len = u32::try_from(i - start).map_err(|_| {
                crate::Error::Io(std::io::Error::new(
                    std::io::ErrorKind::InvalidInput,
                    "posting list too long",
                ))
            })?;
            let mut prev = 0u64;
            for (j, &p) in pairs[start..i].iter().enumerate() {
                let fid = p & 0xFFFF_FFFF;
                let raw = if j == 0 {
                    fid
                } else {
                    fid.checked_sub(prev).ok_or_else(|| {
                        crate::Error::Io(std::io::Error::new(
                            std::io::ErrorKind::InvalidInput,
                            "non-monotonic posting list",
                        ))
                    })?
                };
                let mut buf = unsigned_varint::encode::u64_buffer();
                let encoded = unsigned_varint::encode::u64(raw, &mut buf);
                posting_bytes.extend_from_slice(encoded);
                prev = fid;
            }
            lex_entries.push(LexiconEntry {
                trigram: Trigram::from_u24(tri_key).to_bytes(),
                offset,
                len,
            });
        }
        Ok((lex_entries, posting_bytes))
    }
}

/// Build in-memory index tables from a corpus configuration.
///
/// When `paths` is empty, discovers files via [`CorpusWalker`]. Otherwise indexes
/// exactly the given corpus-relative paths.
impl IndexTables {
    pub fn build(config: &IndexConfig<'_>, paths: &[PathBuf]) -> crate::Result<Self> {
        use rayon::prelude::*;

        let (paths, root) = match config.corpus.kind {
            CorpusKind::SingleFile => {
                if config.corpus.include_paths.is_empty() {
                    return Err(crate::Error::Io(std::io::Error::new(
                        std::io::ErrorKind::InvalidInput,
                        "SingleFile corpus must specify the file in include_paths",
                    )));
                }
                let paths = config.corpus.include_paths.to_vec();
                (paths, config.corpus.root)
            }
            CorpusKind::Directory => {
                let paths = if paths.is_empty() {
                    CorpusWalker::new(config).collect()?
                } else {
                    paths.to_vec()
                };
                (paths, config.corpus.root)
            }
        };

        let fingerprints = FingerprintCollector::new(root, &paths).collect()?;

        let file_trigrams: Vec<TrigramSet> = fingerprints
            .par_iter()
            .map(|fp| {
                let abs = root.join(&fp.path);
                TrigramSet::from_file(&abs)
            })
            .collect::<std::io::Result<_>>()
            .map_err(crate::Error::Io)?;

        let (lexicon, postings) = PostingAssembler::new(&file_trigrams).assemble()?;

        Ok(Self {
            fingerprints,
            file_trigrams,
            lexicon,
            postings,
        })
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::index::config::WalkOptions;
    use crate::index::{CorpusKind, CorpusSpec, IndexConfig};
    use crate::search::filter::IgnoreConfig;
    use crate::{CandidateFilter, CandidateFilterConfig, VisibilityConfig};
    use std::fs;
    use std::path::PathBuf;
    use tempfile::TempDir;

    struct TablesFixture;

    impl TablesFixture {
        fn no_ignore_config(root: &Path) -> IndexConfig<'_> {
            IndexConfig {
                corpus: CorpusSpec {
                    root,
                    kind: CorpusKind::Directory,
                    follow_links: false,
                    include_paths: &[],
                    exclude_paths: &[],
                },
                walk: WalkOptions::new(false),
                visibility: VisibilityConfig {
                    ignore: IgnoreConfig::disabled(),
                    ..Default::default()
                },
            }
        }

        fn build(root: &Path) -> IndexTables {
            let config = Self::no_ignore_config(root);
            IndexTables::build(&config, &[]).expect("build tables")
        }
    }

    struct PostingCounts;

    impl PostingCounts {
        fn file_occurrences(postings: &[u8], lexicon: &[LexiconEntry], file_id: u32) -> usize {
            lexicon
                .iter()
                .map(|entry| {
                    let start = usize::try_from(entry.offset).unwrap_or(usize::MAX);
                    let end = lexicon
                        .iter()
                        .find(|e| e.offset > entry.offset)
                        .map_or(postings.len(), |e| {
                            usize::try_from(e.offset).unwrap_or(usize::MAX)
                        });
                    let slice = &postings[start..end];
                    crate::index::trigram::storage::postings::Postings::decode_sorted(slice)
                        .unwrap_or_default()
                        .into_iter()
                        .filter(|&id| id == file_id)
                        .count()
                })
                .sum()
        }
    }

    struct FilterCorpus;

    impl FilterCorpus {
        fn write(root: &Path) {
            fs::create_dir_all(root.join("skip")).expect("mkdir skip");
            fs::create_dir_all(root.join("also_skip")).expect("mkdir also_skip");
            fs::write(root.join("keep.txt"), "beta\n").expect("write keep");
            fs::write(root.join("skip/ignored.txt"), "beta\n").expect("write skip");
            fs::write(root.join("also_skip/omit.txt"), "beta\n").expect("write omit");
            fs::write(root.join(".gitignore"), "skip/**\n").expect("write gitignore");
            fs::write(root.join(".ignore"), "also_skip/**\n").expect("write ignore");
        }
    }

    struct FilterParity;

    impl FilterParity {
        fn filter_config(build: &IndexConfig<'_>) -> CandidateFilterConfig {
            CandidateFilterConfig {
                exclude_paths: build.corpus.exclude_paths.to_vec(),
                visibility: build.visibility.clone(),
                follow_links: build.corpus.follow_links,
                ..CandidateFilterConfig::default()
            }
        }

        fn all_corpus_files(root: &Path) -> Vec<PathBuf> {
            let mut files = Vec::new();
            Self::collect_files_recursive(root, root, &mut files);
            files.sort_unstable();
            files
        }

        fn collect_files_recursive(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) {
            let entries = fs::read_dir(dir).expect("read dir");
            for entry in entries.flatten() {
                let path = entry.path();
                if path.is_dir() {
                    Self::collect_files_recursive(root, &path, out);
                } else if path.is_file() {
                    let rel = path.strip_prefix(root).expect("under root").to_path_buf();
                    out.push(rel);
                }
            }
        }
    }

    #[test]
    fn build_index_tables_sorts_file_paths_deterministically() {
        let tmp = TempDir::new().expect("create temp dir");
        fs::write(tmp.path().join("z.txt"), "hello\n").expect("write z");
        fs::write(tmp.path().join("a.txt"), "world\n").expect("write a");
        fs::write(tmp.path().join("m.txt"), "test\n").expect("write m");

        let tables = TablesFixture::build(tmp.path());
        let paths: Vec<PathBuf> = tables
            .fingerprints
            .iter()
            .map(|fp| fp.path.clone())
            .collect();
        let expected = vec![
            PathBuf::from("a.txt"),
            PathBuf::from("m.txt"),
            PathBuf::from("z.txt"),
        ];
        assert_eq!(paths, expected);
    }

    #[test]
    fn build_index_tables_exclude_paths_prevents_indexing() {
        let tmp = TempDir::new().expect("create temp dir");
        fs::write(tmp.path().join("keep.txt"), "hello\n").expect("write keep");
        let excluded_dir = tmp.path().join("excluded");
        fs::create_dir_all(&excluded_dir).expect("create excluded dir");
        fs::write(excluded_dir.join("skip.txt"), "world\n").expect("write skip");

        let config = IndexConfig {
            corpus: CorpusSpec {
                root: tmp.path(),
                kind: CorpusKind::Directory,
                follow_links: false,
                include_paths: &[],
                exclude_paths: &[PathBuf::from("excluded")],
            },
            walk: WalkOptions::new(false),
            visibility: VisibilityConfig {
                ignore: IgnoreConfig::disabled(),
                ..Default::default()
            },
        };
        let tables = IndexTables::build(&config, &[]).expect("build tables");
        assert_eq!(tables.fingerprints.len(), 1);
        assert_eq!(tables.fingerprints[0].path, PathBuf::from("keep.txt"));
    }

    #[test]
    fn build_index_tables_duplicate_trigrams_deduplicated_in_postings() {
        let tmp = TempDir::new().expect("create temp dir");
        fs::write(tmp.path().join("a.txt"), "ababab\n").expect("write file");
        fs::write(tmp.path().join("b.txt"), "ababab\n").expect("write file");

        let tables = TablesFixture::build(tmp.path());
        assert_eq!(tables.fingerprints.len(), 2);

        let unique_trigrams = 3;
        assert_eq!(
            PostingCounts::file_occurrences(&tables.postings, &tables.lexicon, 0),
            unique_trigrams
        );
        assert_eq!(
            PostingCounts::file_occurrences(&tables.postings, &tables.lexicon, 1),
            unique_trigrams
        );
    }

    #[test]
    fn corpus_walk_excludes_gitignored_paths_without_git_repo() {
        let tmp = TempDir::new().expect("create temp dir");
        FilterCorpus::write(tmp.path());

        let config = IndexConfig {
            corpus: CorpusSpec {
                root: tmp.path(),
                kind: CorpusKind::Directory,
                follow_links: false,
                include_paths: &[],
                exclude_paths: &[],
            },
            walk: WalkOptions::new(false),
            visibility: VisibilityConfig::default(),
        };
        let paths = CorpusWalker::new(&config).collect().expect("walk corpus");
        assert!(paths.iter().any(|p| p == Path::new("keep.txt")));
        assert!(!paths.iter().any(|p| p.starts_with("skip")));
        assert!(!paths.iter().any(|p| p.starts_with("also_skip")));
    }

    #[test]
    fn corpus_walk_with_empty_ignore_sources_includes_gitignored() {
        let tmp = TempDir::new().expect("create temp dir");
        FilterCorpus::write(tmp.path());

        let config = IndexConfig {
            corpus: CorpusSpec {
                root: tmp.path(),
                kind: CorpusKind::Directory,
                follow_links: false,
                include_paths: &[],
                exclude_paths: &[],
            },
            walk: WalkOptions::new(false),
            visibility: VisibilityConfig {
                ignore: IgnoreConfig::disabled(),
                ..Default::default()
            },
        };
        let paths = CorpusWalker::new(&config).collect().expect("walk corpus");
        assert!(paths.iter().any(|p| p.starts_with("skip")));
        assert!(paths.iter().any(|p| p.starts_with("also_skip")));
    }

    #[test]
    fn corpus_walk_agrees_with_candidate_filter() {
        let tmp = TempDir::new().expect("create temp dir");
        FilterCorpus::write(tmp.path());

        let config = IndexConfig {
            corpus: CorpusSpec {
                root: tmp.path(),
                kind: CorpusKind::Directory,
                follow_links: false,
                include_paths: &[],
                exclude_paths: &[],
            },
            walk: WalkOptions::new(false),
            visibility: VisibilityConfig::default(),
        };
        let indexed = CorpusWalker::new(&config).collect().expect("walk corpus");
        let filter = CandidateFilter::new(&FilterParity::filter_config(&config), tmp.path())
            .expect("filter");

        for rel in FilterParity::all_corpus_files(tmp.path()) {
            let should_index = filter.matches_path(&rel);
            let is_indexed = indexed.iter().any(|p| p == &rel);
            assert_eq!(
                is_indexed, should_index,
                "path {rel:?}: indexed={is_indexed} filter={should_index}"
            );
        }
    }

    #[test]
    fn build_index_tables_include_paths_filters_to_single_file() {
        let tmp = TempDir::new().expect("create temp dir");
        fs::write(tmp.path().join("keep.txt"), "hello\n").expect("write keep");
        fs::write(tmp.path().join("skip.txt"), "world\n").expect("write skip");

        let config = IndexConfig {
            corpus: CorpusSpec {
                root: tmp.path(),
                kind: CorpusKind::Directory,
                follow_links: false,
                include_paths: &[PathBuf::from("keep.txt")],
                exclude_paths: &[],
            },
            walk: WalkOptions::new(false),
            visibility: VisibilityConfig {
                ignore: IgnoreConfig::disabled(),
                ..Default::default()
            },
        };
        let tables = IndexTables::build(&config, &[]).expect("build tables");
        assert_eq!(tables.fingerprints.len(), 1);
        assert_eq!(tables.fingerprints[0].path, PathBuf::from("keep.txt"));
    }

    #[test]
    fn build_single_file_corpus_ignores_siblings() {
        let tmp = TempDir::new().expect("create temp dir");
        let file = tmp.path().join("only.txt");
        fs::write(&file, "needle\n").expect("write file");
        fs::write(tmp.path().join("other.txt"), "haystack\n").expect("write other");

        let only_txt = PathBuf::from("only.txt");
        let config = IndexConfig {
            corpus: CorpusSpec {
                root: tmp.path(),
                kind: CorpusKind::SingleFile,
                follow_links: false,
                include_paths: &[only_txt],
                exclude_paths: &[],
            },
            walk: WalkOptions::new(false),
            visibility: VisibilityConfig::default(),
        };
        let tables = IndexTables::build(&config, &[]).expect("build tables");
        assert_eq!(tables.fingerprints.len(), 1);
        assert_eq!(
            tables.fingerprints[0].path,
            PathBuf::from("only.txt"),
            "should only index the specified file, not siblings"
        );
    }

    #[test]
    fn fingerprints_have_nonzero_size() {
        let tmp = TempDir::new().expect("create temp dir");
        fs::write(tmp.path().join("a.txt"), "hello world\n").expect("write file");

        let tables = TablesFixture::build(tmp.path());
        assert_eq!(tables.fingerprints.len(), 1);
        assert!(
            tables.fingerprints[0].size > 0,
            "fingerprint should capture file size"
        );
    }

    #[test]
    fn build_respects_gitignore_by_default() {
        let tmp = TempDir::new().expect("create temp dir");
        fs::write(tmp.path().join(".gitignore"), "*.ignored\n").expect("write gitignore");
        fs::write(tmp.path().join("keep.txt"), "hello\n").expect("write keep");
        fs::write(tmp.path().join("skip.ignored"), "secret\n").expect("write ignored");

        let config = IndexConfig {
            corpus: CorpusSpec {
                root: tmp.path(),
                kind: CorpusKind::Directory,
                follow_links: false,
                include_paths: &[],
                exclude_paths: &[],
            },
            walk: WalkOptions::new(false),
            visibility: VisibilityConfig::default(),
        };
        let tables = IndexTables::build(&config, &[]).expect("build tables");
        let paths: Vec<_> = tables.fingerprints.iter().map(|f| f.path.clone()).collect();
        assert!(
            !paths.iter().any(|p| p == "skip.ignored"),
            "gitignored file must not be indexed, got {paths:?}"
        );
        assert!(
            paths.iter().any(|p| p == "keep.txt"),
            "keep.txt must be indexed, got {paths:?}"
        );
    }

    #[test]
    fn build_prunes_gitignored_directory() {
        let tmp = TempDir::new().expect("create temp dir");
        fs::create_dir_all(tmp.path().join("src")).expect("mkdir src");
        fs::create_dir_all(tmp.path().join("target")).expect("mkdir target");
        fs::write(tmp.path().join(".gitignore"), "/target\n").expect("write gitignore");
        fs::write(tmp.path().join("src/keep.txt"), "needle\n").expect("write keep");
        fs::write(tmp.path().join("target/ignored.txt"), "secret\n").expect("write ignored");

        let config = IndexConfig {
            corpus: CorpusSpec {
                root: tmp.path(),
                kind: CorpusKind::Directory,
                follow_links: false,
                include_paths: &[],
                exclude_paths: &[],
            },
            walk: WalkOptions::new(false),
            visibility: VisibilityConfig::default(),
        };
        let tables = IndexTables::build(&config, &[]).expect("build tables");
        let paths: Vec<_> = tables.fingerprints.iter().map(|f| f.path.clone()).collect();
        assert!(
            paths.iter().any(|p| p == Path::new("src/keep.txt")),
            "keep must be indexed, got {paths:?}"
        );
        assert!(
            !paths.iter().any(|p| p.starts_with("target")),
            "target/ must not be indexed, got {paths:?}"
        );
    }
}
