//! Public types, errors, and the
//! [`Discovery::expand_project_list`] method.
//!
//! Resolves a list of [`PathPattern`] entries into a
//! deduplicated, source-indexed
//! [`ResolvedProjectList`] of host-canonical project root
//! directories. Implements `DISC-005`..`DISC-007`, `DISC-009`,
//! and the symlink rules `DISC-020`..`DISC-022` for the
//! glob-expansion layer.

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use haz_domain::path::{GlobPattern, HazPath, PathPattern};
use haz_vfs::{EntryKind, Filesystem, FsError};
use snafu::Snafu;

use crate::discovery::Discovery;
use crate::path::ResolveError;
use crate::projects::prefix::{Depth, analyse};
use crate::projects::walk::WalkEvent;
use crate::root::WorkspaceRoot;

/// One resolved project root.
///
/// Carries the host-canonical directory ([`F::CanonicalPath`]) and
/// the index of the [`PathPattern`] entry (in the input slice
/// passed to [`Discovery::expand_project_list`]) that produced it.
/// The index lets downstream layers point diagnostics at the
/// originating `workspace.yml` entry rather than at the bare
/// canonical path.
///
/// [`F::CanonicalPath`]: haz_vfs::Filesystem::CanonicalPath
pub struct ResolvedProject<F: Filesystem> {
    root: F::CanonicalPath,
    source_index: usize,
}

impl<F: Filesystem> ResolvedProject<F> {
    /// Host-canonical project root.
    #[must_use]
    pub fn root(&self) -> &F::CanonicalPath {
        &self.root
    }

    /// Index into the input `patterns` slice of the entry that
    /// produced this project root.
    #[must_use]
    pub fn source_index(&self) -> usize {
        self.source_index
    }
}

// Manual trait impls below: deriving would add bounds on `F`
// itself; the data only depends on `F::CanonicalPath`, whose
// bounds the [`Filesystem`] trait already supplies.

impl<F: Filesystem> std::fmt::Debug for ResolvedProject<F> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ResolvedProject")
            .field("root", &self.root)
            .field("source_index", &self.source_index)
            .finish()
    }
}

impl<F: Filesystem> Clone for ResolvedProject<F> {
    fn clone(&self) -> Self {
        Self {
            root: self.root.clone(),
            source_index: self.source_index,
        }
    }
}

impl<F: Filesystem> PartialEq for ResolvedProject<F> {
    fn eq(&self, other: &Self) -> bool {
        self.root == other.root && self.source_index == other.source_index
    }
}

impl<F: Filesystem> Eq for ResolvedProject<F> {}

impl<F: Filesystem> std::hash::Hash for ResolvedProject<F> {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        self.root.hash(state);
        self.source_index.hash(state);
    }
}

/// Deduplicated list of resolved project roots.
///
/// The only public producer is
/// [`Discovery::expand_project_list`]; consumers therefore know
/// statically that every [`ResolvedProject::root`] in the list is
/// pairwise distinct (`DISC-009`). Iteration order is
/// first-observation order in the input `patterns` slice.
pub struct ResolvedProjectList<F: Filesystem> {
    entries: Vec<ResolvedProject<F>>,
}

impl<F: Filesystem> ResolvedProjectList<F> {
    /// Number of resolved project roots.
    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// `true` when no projects were resolved (only possible when
    /// the input `patterns` slice was itself empty).
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Iterator over the resolved projects in first-observation
    /// order.
    pub fn iter(&self) -> std::slice::Iter<'_, ResolvedProject<F>> {
        self.entries.iter()
    }
}

impl<'a, F: Filesystem> IntoIterator for &'a ResolvedProjectList<F> {
    type Item = &'a ResolvedProject<F>;
    type IntoIter = std::slice::Iter<'a, ResolvedProject<F>>;

    fn into_iter(self) -> Self::IntoIter {
        self.entries.iter()
    }
}

impl<F: Filesystem> std::fmt::Debug for ResolvedProjectList<F> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ResolvedProjectList")
            .field("entries", &self.entries)
            .finish()
    }
}

impl<F: Filesystem> Clone for ResolvedProjectList<F> {
    fn clone(&self) -> Self {
        Self {
            entries: self.entries.clone(),
        }
    }
}

impl<F: Filesystem> PartialEq for ResolvedProjectList<F> {
    fn eq(&self, other: &Self) -> bool {
        self.entries == other.entries
    }
}

impl<F: Filesystem> Eq for ResolvedProjectList<F> {}

/// One error produced during [`Discovery::expand_project_list`].
///
/// Diagnostic codes follow the `haz::dsc::NNN` namespace and are
/// kept in the variant docstrings so callers can route on them
/// without parsing [`Display`](std::fmt::Display) text.
#[derive(Debug, Snafu)]
pub enum ExpandError {
    /// A glob pattern matched zero directories (`DISC-006`).
    ///
    /// Diagnostic code: `haz::dsc::016`.
    #[snafu(display("glob pattern {pattern} (entry #{source_index}) matched no directories"))]
    EmptyGlobMatch {
        /// Index into the input `patterns` slice.
        source_index: usize,
        /// The offending pattern.
        pattern: PathPattern,
    },

    /// A literal entry's target does not exist (`DISC-007`).
    ///
    /// Diagnostic code: `haz::dsc::017`.
    #[snafu(display(
        "literal entry {pattern} (entry #{source_index}) targets {} which does not exist",
        target.display()
    ))]
    MissingLiteralTarget {
        /// Index into the input `patterns` slice.
        source_index: usize,
        /// The offending pattern.
        pattern: PathPattern,
        /// Host path that was probed.
        target: PathBuf,
    },

    /// A literal entry's target exists but is not a directory
    /// (`DISC-007`).
    ///
    /// Diagnostic code: `haz::dsc::018`.
    #[snafu(display(
        "literal entry {pattern} (entry #{source_index}) targets {} which is {kind:?}, not a directory",
        target.display()
    ))]
    LiteralTargetNotADirectory {
        /// Index into the input `patterns` slice.
        source_index: usize,
        /// The offending pattern.
        pattern: PathPattern,
        /// Host path that was probed.
        target: PathBuf,
        /// The actual entry kind observed.
        kind: EntryKind,
    },

    /// Two distinct entries resolved (after canonicalisation) to
    /// the same project root (`DISC-009`).
    ///
    /// Diagnostic code: `haz::dsc::019`.
    #[snafu(display(
        "entries #{} and #{} both resolve to project root {}",
        sources.0,
        sources.1,
        canonical.display()
    ))]
    DuplicateProjectRoot {
        /// The two source indices that produced the same canonical
        /// root, in the order they were observed.
        sources: (usize, usize),
        /// The canonical project root they both produced.
        canonical: PathBuf,
    },

    /// A symlink (or other path component) resolved to a target
    /// outside the workspace root (`DISC-022`).
    ///
    /// Errors of this variant are deduplicated by `canonical`
    /// before being surfaced: two patterns whose walks both cross
    /// the same offending symlink produce one error, not two.
    ///
    /// Diagnostic code: `haz::dsc::020`.
    #[snafu(display(
        "path {} (from entry #{source_index}) canonicalises outside the workspace root",
        canonical.display()
    ))]
    BoundaryEscape {
        /// The source pattern index whose walk first observed the
        /// escape.
        source_index: usize,
        /// The offending canonical target.
        canonical: PathBuf,
        /// Underlying boundary error.
        source: ResolveError,
    },

    /// A symlink cycle was detected during the walk (`DISC-021`).
    ///
    /// Errors of this variant are deduplicated by `revisited`
    /// before being surfaced.
    ///
    /// Diagnostic code: `haz::dsc::021`.
    #[snafu(display(
        "symlink cycle detected at {} (from entry #{source_index})",
        revisited.display()
    ))]
    SymlinkCycle {
        /// The source pattern index whose walk first observed the
        /// cycle.
        source_index: usize,
        /// The canonical path that was about to be revisited.
        revisited: PathBuf,
    },

    /// Canonicalising a path during expansion failed with an
    /// [`FsError`] other than [`FsError::NotFound`] (which maps to
    /// [`ExpandError::MissingLiteralTarget`] for literals) and
    /// other than the boundary case (which maps to
    /// [`ExpandError::BoundaryEscape`]).
    ///
    /// Diagnostic code: `haz::dsc::022`.
    #[snafu(display(
        "filesystem error while canonicalising {} (from entry #{source_index}): {source}",
        target.display()
    ))]
    CanonicalizeFailed {
        /// Index into the input `patterns` slice.
        source_index: usize,
        /// Host path that was being canonicalised.
        target: PathBuf,
        /// Underlying filesystem error.
        source: FsError,
    },
}

/// Non-empty container of [`ExpandError`] values produced by a
/// single call to [`Discovery::expand_project_list`].
///
/// Returned in the `Err` arm; the `Ok` arm carries a
/// [`ResolvedProjectList`]. The wrapper is non-empty by
/// construction: an empty error list is represented as
/// `Ok(ResolvedProjectList { ... })`.
#[derive(Debug)]
pub struct ExpandErrors {
    errors: Vec<ExpandError>,
}

impl ExpandErrors {
    /// Borrow the accumulated errors.
    #[must_use]
    pub fn errors(&self) -> &[ExpandError] {
        &self.errors
    }

    /// Number of accumulated errors.
    #[must_use]
    pub fn len(&self) -> usize {
        self.errors.len()
    }

    /// Always `false`: [`ExpandErrors`] is non-empty by
    /// construction (an empty error list is represented as
    /// `Ok(ResolvedProjectList { ... })`).
    #[must_use]
    pub fn is_empty(&self) -> bool {
        false
    }
}

impl IntoIterator for ExpandErrors {
    type Item = ExpandError;
    type IntoIter = std::vec::IntoIter<ExpandError>;

    fn into_iter(self) -> Self::IntoIter {
        self.errors.into_iter()
    }
}

impl std::fmt::Display for ExpandErrors {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{} expansion error(s)", self.errors.len())?;
        for e in &self.errors {
            write!(f, "\n  - {e}")?;
        }
        Ok(())
    }
}

impl std::error::Error for ExpandErrors {}

impl<F: Filesystem> Discovery<'_, F> {
    /// Resolve every entry of `patterns` into a project root and
    /// return the deduplicated list, or the accumulated errors.
    ///
    /// `patterns` is the `WorkspaceConfig::projects` field from
    /// `haz-config` (per `CFG-009`); every entry is therefore a
    /// validated [`PathPattern`], workspace-absolute per
    /// `CFG-010`. Literal entries resolve to a single directory
    /// each; glob entries expand against the host filesystem.
    ///
    /// Errors are *accumulated* rather than fail-fast: a call
    /// with multiple invalid entries returns all of them in one
    /// [`ExpandErrors`]. Cycle (`DISC-021`) and boundary
    /// (`DISC-022`) errors are deduplicated by canonical key so
    /// one offending symlink reachable from N patterns produces
    /// one error rather than N.
    ///
    /// # Errors
    ///
    /// Returns [`ExpandErrors`] when one or more entries fail to
    /// resolve. The error vector is non-empty in this arm.
    pub fn expand_project_list(
        &self,
        workspace: &WorkspaceRoot<F>,
        patterns: &[PathPattern],
    ) -> Result<ResolvedProjectList<F>, ExpandErrors> {
        let mut accumulator = Accumulator::<F>::new();

        for (source_index, pattern) in patterns.iter().enumerate() {
            match pattern {
                PathPattern::Literal(haz_path) => {
                    self.resolve_literal_entry(
                        workspace,
                        haz_path,
                        pattern,
                        source_index,
                        &mut accumulator,
                    );
                }
                PathPattern::Glob(glob) => {
                    self.resolve_glob_entry(
                        workspace,
                        glob,
                        pattern,
                        source_index,
                        &mut accumulator,
                    );
                }
            }
        }

        accumulator.finish()
    }

    fn resolve_glob_entry(
        &self,
        workspace: &WorkspaceRoot<F>,
        glob: &GlobPattern,
        pattern: &PathPattern,
        source_index: usize,
        acc: &mut Accumulator<F>,
    ) {
        let analysis = analyse(glob);

        let mut start_host = workspace.canonical().as_ref().to_path_buf();
        for seg in &analysis.literal_prefix {
            start_host.push(seg);
        }

        let depth_bound = match analysis.depth {
            Depth::Bounded(n) => Some(n),
            Depth::Unbounded => None,
        };

        let compiled = glob.compile();
        let matcher = compiled.compile_matcher();
        let workspace_canonical_path: PathBuf = workspace.canonical().as_ref().to_path_buf();

        let mut match_count: usize = 0;
        let mut walk_visited: HashSet<F::CanonicalPath> = HashSet::new();

        // Decompose `acc` into a callback that doesn't violate the
        // closure-borrow rules of the recursive walker. The walker
        // borrows `self` shared and `walk_visited` exclusively;
        // the closure here borrows `acc` and `match_count`
        // exclusively, which are disjoint.
        self.walk_dirs(
            workspace.canonical(),
            &start_host,
            depth_bound,
            &mut walk_visited,
            &mut |event| match event {
                WalkEvent::Directory { canonical } => {
                    if !matches_glob(&workspace_canonical_path, canonical.as_ref(), &matcher) {
                        return;
                    }
                    match_count += 1;
                    acc.try_insert(canonical, source_index);
                }
                WalkEvent::BoundaryEscape { canonical, source } => {
                    acc.push_boundary_escape(source_index, canonical, source);
                }
                WalkEvent::Cycle { revisited } => {
                    acc.push_symlink_cycle(source_index, revisited);
                }
                WalkEvent::Io { target, source } => {
                    acc.push(ExpandError::CanonicalizeFailed {
                        source_index,
                        target,
                        source,
                    });
                }
            },
        );

        if match_count == 0 {
            acc.push(ExpandError::EmptyGlobMatch {
                source_index,
                pattern: pattern.clone(),
            });
        }
    }

    fn resolve_literal_entry(
        &self,
        workspace: &WorkspaceRoot<F>,
        haz_path: &HazPath,
        pattern: &PathPattern,
        source_index: usize,
        acc: &mut Accumulator<F>,
    ) {
        let mut host = workspace.canonical().as_ref().to_path_buf();
        for seg in haz_path.segments() {
            host.push(seg.as_str());
        }

        match self.verify_within(&host, workspace.canonical()) {
            Ok(canon) => {
                let metadata = match self.fs().metadata(canon.as_ref()) {
                    Ok(m) => m,
                    Err(FsError::NotFound { .. }) => {
                        // verify_within just canonicalised this
                        // path, so a not-found here would be a
                        // TOCTOU race; we report the literal as
                        // missing and move on.
                        acc.push(ExpandError::MissingLiteralTarget {
                            source_index,
                            pattern: pattern.clone(),
                            target: host,
                        });
                        return;
                    }
                    Err(source) => {
                        acc.push(ExpandError::CanonicalizeFailed {
                            source_index,
                            target: host,
                            source,
                        });
                        return;
                    }
                };
                if metadata.kind != EntryKind::Dir {
                    acc.push(ExpandError::LiteralTargetNotADirectory {
                        source_index,
                        pattern: pattern.clone(),
                        target: host,
                        kind: metadata.kind,
                    });
                    return;
                }
                acc.try_insert(canon, source_index);
            }
            Err(ResolveError::NotFound { path }) => {
                acc.push(ExpandError::MissingLiteralTarget {
                    source_index,
                    pattern: pattern.clone(),
                    target: path,
                });
            }
            Err(err @ ResolveError::OutsideBoundary { .. }) => {
                let canonical = match &err {
                    ResolveError::OutsideBoundary { canonical, .. } => canonical.clone(),
                    _ => unreachable!(),
                };
                acc.push_boundary_escape(source_index, canonical, err);
            }
            Err(other) => {
                let (target, source) = match other {
                    ResolveError::CanonicalizeFailed { path, source } => (path, source),
                    ResolveError::RelativeTarget { path } => {
                        // Unreachable: we built `host` from a
                        // workspace-absolute root joined with
                        // segments, so it is absolute. The
                        // verifier's check therefore cannot fire.
                        // Surface defensively as a canonicalise
                        // failure so the rare misconfiguration
                        // does not panic the discovery layer.
                        (
                            path,
                            FsError::Io {
                                path: host.clone(),
                                source: std::io::Error::other(
                                    "verify_within reported relative target on workspace-internal host path",
                                ),
                            },
                        )
                    }
                    ResolveError::NotFound { .. } | ResolveError::OutsideBoundary { .. } => {
                        unreachable!("handled by outer arms")
                    }
                };
                acc.push(ExpandError::CanonicalizeFailed {
                    source_index,
                    target,
                    source,
                });
            }
        }
    }
}

/// Project a host-canonical directory into workspace-internal
/// path text and test it against the compiled glob.
///
/// `workspace_canonical_path` is the workspace root in host
/// coordinates (the `as_ref()` of the workspace's
/// `F::CanonicalPath`). `host_canonical` is the candidate
/// directory in host coordinates.
fn matches_glob(
    workspace_canonical_path: &Path,
    host_canonical: &Path,
    matcher: &globset::GlobMatcher,
) -> bool {
    let Ok(relative) = host_canonical.strip_prefix(workspace_canonical_path) else {
        return false;
    };
    // The compiled glob's pattern text is workspace-absolute (it
    // starts with `/`), so the match target must also start with
    // `/`. `strip_prefix` yields a relative path; we promote it
    // to workspace-absolute by joining onto `/`.
    let mut workspace_internal = PathBuf::from("/");
    workspace_internal.push(relative);
    matcher.is_match(&workspace_internal)
}

/// Internal accumulator: tracks the deduped resolved list, the
/// accumulated errors, and the cycle/boundary dedup tables.
struct Accumulator<F: Filesystem> {
    entries: Vec<ResolvedProject<F>>,
    first_source: HashMap<F::CanonicalPath, usize>,
    errors: Vec<ExpandError>,
    boundary_keys: HashSet<PathBuf>,
    cycle_keys: HashSet<PathBuf>,
}

impl<F: Filesystem> Accumulator<F> {
    fn new() -> Self {
        Self {
            entries: Vec::new(),
            first_source: HashMap::new(),
            errors: Vec::new(),
            boundary_keys: HashSet::new(),
            cycle_keys: HashSet::new(),
        }
    }

    fn push(&mut self, err: ExpandError) {
        self.errors.push(err);
    }

    fn push_boundary_escape(
        &mut self,
        source_index: usize,
        canonical: PathBuf,
        source: ResolveError,
    ) {
        if self.boundary_keys.insert(canonical.clone()) {
            self.errors.push(ExpandError::BoundaryEscape {
                source_index,
                canonical,
                source,
            });
        }
    }

    fn push_symlink_cycle(&mut self, source_index: usize, revisited: PathBuf) {
        if self.cycle_keys.insert(revisited.clone()) {
            self.errors.push(ExpandError::SymlinkCycle {
                source_index,
                revisited,
            });
        }
    }

    fn try_insert(&mut self, canonical: F::CanonicalPath, source_index: usize) {
        if let Some(existing) = self.first_source.get(&canonical) {
            self.errors.push(ExpandError::DuplicateProjectRoot {
                sources: (*existing, source_index),
                canonical: canonical.as_ref().to_path_buf(),
            });
            return;
        }
        self.first_source.insert(canonical.clone(), source_index);
        self.entries.push(ResolvedProject {
            root: canonical,
            source_index,
        });
    }

    fn finish(self) -> Result<ResolvedProjectList<F>, ExpandErrors> {
        if self.errors.is_empty() {
            Ok(ResolvedProjectList {
                entries: self.entries,
            })
        } else {
            Err(ExpandErrors {
                errors: self.errors,
            })
        }
    }
}

#[cfg(test)]
mod tests {
    use std::path::{Path, PathBuf};

    use haz_domain::path::{PathPattern, WorkspaceRootPath};
    use haz_vfs::Filesystem;
    use haz_vfs_testing::MemFilesystem;

    use crate::discovery::Discovery;
    use crate::root::WorkspaceRoot;

    use super::ExpandError;

    fn workspace(fs: &MemFilesystem, p: &str) -> WorkspaceRoot<MemFilesystem> {
        let lexical = WorkspaceRootPath::try_new(PathBuf::from(p)).unwrap();
        let canonical = fs.canonicalize(Path::new(p)).unwrap();
        WorkspaceRoot::Explicit { lexical, canonical }
    }

    fn parse(s: &str) -> PathPattern {
        PathPattern::parse(s).unwrap()
    }

    #[test]
    fn empty_patterns_returns_empty_list() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        let ws = workspace(&fs, "/ws");
        let out = Discovery::new(&fs).expand_project_list(&ws, &[]).unwrap();
        assert!(out.is_empty());
        assert_eq!(out.len(), 0);
    }

    #[test]
    fn disc_005_single_literal_resolves() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core").unwrap();
        let ws = workspace(&fs, "/ws");
        let out = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/lib_core")])
            .unwrap();
        assert_eq!(out.len(), 1);
        let p = out.iter().next().unwrap();
        assert_eq!(p.root().as_path(), Path::new("/ws/lib_core"));
        assert_eq!(p.source_index(), 0);
    }

    #[test]
    fn disc_005_multiple_literals_resolve_in_source_order() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/a").unwrap();
        fs.add_dir("/ws/b").unwrap();
        fs.add_dir("/ws/c").unwrap();
        let ws = workspace(&fs, "/ws");
        let patterns = [parse("/c"), parse("/a"), parse("/b")];
        let out = Discovery::new(&fs)
            .expand_project_list(&ws, &patterns)
            .unwrap();
        let roots: Vec<_> = out
            .iter()
            .map(|p| (p.root().as_path().to_path_buf(), p.source_index()))
            .collect();
        assert_eq!(
            roots,
            vec![
                (PathBuf::from("/ws/c"), 0),
                (PathBuf::from("/ws/a"), 1),
                (PathBuf::from("/ws/b"), 2),
            ]
        );
    }

    #[test]
    fn disc_007_missing_literal_target_errors() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        let ws = workspace(&fs, "/ws");
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/missing")])
            .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(matches!(
            errs.errors()[0],
            ExpandError::MissingLiteralTarget {
                source_index: 0,
                ..
            }
        ));
    }

    #[test]
    fn disc_005_literal_target_that_is_a_file_errors() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_file("/ws/oops", b"".to_vec()).unwrap();
        let ws = workspace(&fs, "/ws");
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/oops")])
            .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(matches!(
            errs.errors()[0],
            ExpandError::LiteralTargetNotADirectory {
                source_index: 0,
                ..
            }
        ));
    }

    #[test]
    fn duplicate_literals_error_via_disc_009() {
        // Two literal entries that name the same canonical
        // directory must produce a DuplicateProjectRoot error.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core").unwrap();
        let ws = workspace(&fs, "/ws");
        let patterns = [parse("/lib_core"), parse("/lib_core")];
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &patterns)
            .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(matches!(
            errs.errors()[0],
            ExpandError::DuplicateProjectRoot {
                sources: (0, 1),
                ..
            }
        ));
    }

    #[test]
    fn disc_020_literal_via_symlink_to_real_dir_resolves() {
        // A literal whose path canonicalises through a symlink
        // (staying inside the workspace) is fine.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/real_lib").unwrap();
        fs.add_symlink("/ws/lib_core", "/ws/real_lib").unwrap();
        let ws = workspace(&fs, "/ws");
        let out = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/lib_core")])
            .unwrap();
        let p = out.iter().next().unwrap();
        assert_eq!(p.root().as_path(), Path::new("/ws/real_lib"));
    }

    #[test]
    fn disc_022_literal_via_symlink_escape_errors_with_boundary_escape() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/elsewhere").unwrap();
        fs.add_symlink("/ws/lib_core", "/elsewhere").unwrap();
        let ws = workspace(&fs, "/ws");
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/lib_core")])
            .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(matches!(
            errs.errors()[0],
            ExpandError::BoundaryEscape {
                source_index: 0,
                ..
            }
        ));
    }

    #[test]
    fn disc_022_two_literals_escaping_via_same_symlink_dedupe_to_one_boundary_error() {
        // Policy 2: same symlink crossed by two entries surfaces
        // as one BoundaryEscape, not two.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/elsewhere").unwrap();
        fs.add_symlink("/ws/leak", "/elsewhere").unwrap();
        let ws = workspace(&fs, "/ws");
        let patterns = [parse("/leak"), parse("/leak")];
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &patterns)
            .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(matches!(
            errs.errors()[0],
            ExpandError::BoundaryEscape { .. }
        ));
    }

    // Glob expansion ------------------------------------------------------

    #[test]
    fn disc_005_single_glob_with_one_match() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/apps/web").unwrap();
        let ws = workspace(&fs, "/ws");
        let out = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/apps/*")])
            .unwrap();
        assert_eq!(out.len(), 1);
        let p = out.iter().next().unwrap();
        assert_eq!(p.root().as_path(), Path::new("/ws/apps/web"));
        assert_eq!(p.source_index(), 0);
    }

    #[test]
    fn disc_005_single_glob_with_multiple_matches() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/apps/web").unwrap();
        fs.add_dir("/ws/apps/api").unwrap();
        fs.add_dir("/ws/apps/cli").unwrap();
        let ws = workspace(&fs, "/ws");
        let out = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/apps/*")])
            .unwrap();
        assert_eq!(out.len(), 3);
        let mut roots: Vec<PathBuf> = out
            .iter()
            .map(|p| p.root().as_path().to_path_buf())
            .collect();
        roots.sort();
        assert_eq!(
            roots,
            vec![
                PathBuf::from("/ws/apps/api"),
                PathBuf::from("/ws/apps/cli"),
                PathBuf::from("/ws/apps/web"),
            ]
        );
    }

    #[test]
    fn glob_does_not_match_files() {
        // `/apps/*` should match directories only (project-pattern
        // expansion deals in candidate project roots, which must be
        // directories).
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/apps").unwrap();
        fs.add_file("/ws/apps/README.md", b"".to_vec()).unwrap();
        fs.add_dir("/ws/apps/web").unwrap();
        let ws = workspace(&fs, "/ws");
        let out = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/apps/*")])
            .unwrap();
        assert_eq!(out.len(), 1);
        assert_eq!(
            out.iter().next().unwrap().root().as_path(),
            Path::new("/ws/apps/web")
        );
    }

    #[test]
    fn empty_glob_match_errors_with_disc_006() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/apps").unwrap();
        let ws = workspace(&fs, "/ws");
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/apps/*")])
            .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(matches!(
            errs.errors()[0],
            ExpandError::EmptyGlobMatch {
                source_index: 0,
                ..
            }
        ));
    }

    #[test]
    fn glob_with_missing_literal_prefix_errors_with_disc_006() {
        // No `/missing_apps` directory exists, so the prefix-pruned
        // walk emits no candidates, and the glob produces an empty
        // match.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        let ws = workspace(&fs, "/ws");
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/missing_apps/*")])
            .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(matches!(
            errs.errors()[0],
            ExpandError::EmptyGlobMatch {
                source_index: 0,
                ..
            }
        ));
    }

    #[test]
    fn glob_with_double_star_descends_arbitrarily() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/services/web/api").unwrap();
        fs.add_dir("/ws/services/internal/api").unwrap();
        let ws = workspace(&fs, "/ws");
        let out = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/services/**/api")])
            .unwrap();
        assert_eq!(out.len(), 2);
        let mut roots: Vec<PathBuf> = out
            .iter()
            .map(|p| p.root().as_path().to_path_buf())
            .collect();
        roots.sort();
        assert_eq!(
            roots,
            vec![
                PathBuf::from("/ws/services/internal/api"),
                PathBuf::from("/ws/services/web/api"),
            ]
        );
    }

    #[test]
    fn glob_with_no_literal_prefix_walks_from_workspace_root() {
        // Pattern `/lib_*/src` has no literal prefix segments;
        // the walk must start at the workspace root.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core/src").unwrap();
        fs.add_dir("/ws/lib_util/src").unwrap();
        fs.add_dir("/ws/other/src").unwrap();
        let ws = workspace(&fs, "/ws");
        let out = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/lib_*/src")])
            .unwrap();
        assert_eq!(out.len(), 2);
        let mut roots: Vec<PathBuf> = out
            .iter()
            .map(|p| p.root().as_path().to_path_buf())
            .collect();
        roots.sort();
        assert_eq!(
            roots,
            vec![
                PathBuf::from("/ws/lib_core/src"),
                PathBuf::from("/ws/lib_util/src"),
            ]
        );
    }

    #[test]
    fn glob_and_literal_mixed_resolve_independently() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/apps/web").unwrap();
        fs.add_dir("/ws/apps/api").unwrap();
        fs.add_dir("/ws/lib_core").unwrap();
        let ws = workspace(&fs, "/ws");
        let out = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/lib_core"), parse("/apps/*")])
            .unwrap();
        assert_eq!(out.len(), 3);
    }

    #[test]
    fn two_globs_producing_same_canonical_error_via_disc_009() {
        // `/apps/*` and `/apps/**` both match `/apps/web`. (The
        // double-star match may also include `/apps/web/inner` if
        // that exists; we keep the test minimal.)
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/apps/web").unwrap();
        let ws = workspace(&fs, "/ws");
        let patterns = [parse("/apps/*"), parse("/apps/**")];
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &patterns)
            .unwrap_err();
        // At least one DuplicateProjectRoot must be present.
        assert!(
            errs.errors()
                .iter()
                .any(|e| matches!(e, ExpandError::DuplicateProjectRoot { .. }))
        );
    }

    #[test]
    fn literal_and_glob_collision_errors_with_disc_009() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/apps/web").unwrap();
        let ws = workspace(&fs, "/ws");
        let patterns = [parse("/apps/web"), parse("/apps/*")];
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &patterns)
            .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(matches!(
            errs.errors()[0],
            ExpandError::DuplicateProjectRoot {
                sources: (0, 1),
                ..
            }
        ));
    }

    #[test]
    fn disc_022_glob_walk_through_symlink_escape_emits_boundary_escape() {
        // A symlink inside the workspace points outside. The
        // glob walk follows it, sees the escape, and records
        // one BoundaryEscape.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/apps").unwrap();
        fs.add_dir("/elsewhere").unwrap();
        fs.add_symlink("/ws/apps/leak", "/elsewhere").unwrap();
        let ws = workspace(&fs, "/ws");
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/apps/*")])
            .unwrap_err();
        assert!(
            errs.errors()
                .iter()
                .any(|e| matches!(e, ExpandError::BoundaryEscape { .. }))
        );
    }

    // Symlink cycle and Policy 2 dedup -----------------------------------

    #[test]
    fn disc_021_glob_walk_detects_self_pointing_symlink_cycle() {
        // DISC-021: a symlink whose canonical target is its own
        // parent directory introduces a length-1 cycle in the
        // walker's visited set. Discovery MUST surface a typed
        // cycle error rather than loop indefinitely.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/apps").unwrap();
        fs.add_symlink("/ws/apps/loop", "/ws/apps").unwrap();
        let ws = workspace(&fs, "/ws");
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/apps/**")])
            .unwrap_err();
        assert!(
            errs.errors()
                .iter()
                .any(|e| matches!(e, ExpandError::SymlinkCycle { .. }))
        );
    }

    #[test]
    fn disc_021_glob_walk_detects_indirect_two_step_symlink_cycle() {
        // DISC-021: cycle detection MUST also fire on indirect
        // cycles whose closing edge crosses more than one symlink.
        // Setup: /ws/a contains symlink `to_b` -> /ws/b; /ws/b
        // contains symlink `to_a` -> /ws/a. A walk that follows
        // either symlink will re-enter the other directory's
        // canonical path on its second hop, closing the cycle.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/a").unwrap();
        fs.add_dir("/ws/b").unwrap();
        fs.add_symlink("/ws/a/to_b", "/ws/b").unwrap();
        fs.add_symlink("/ws/b/to_a", "/ws/a").unwrap();
        let ws = workspace(&fs, "/ws");
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/a/**")])
            .unwrap_err();
        assert!(
            errs.errors()
                .iter()
                .any(|e| matches!(e, ExpandError::SymlinkCycle { .. })),
            "indirect A->B->A symlink cycle must surface SymlinkCycle; got {:?}",
            errs.errors(),
        );
    }

    #[test]
    fn two_globs_crossing_same_cycle_dedupe_to_one_cycle_error() {
        // Policy 2: a single cycle observed by two glob walks
        // surfaces as one SymlinkCycle error.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/apps").unwrap();
        fs.add_symlink("/ws/apps/loop", "/ws/apps").unwrap();
        let ws = workspace(&fs, "/ws");
        let patterns = [parse("/apps/**"), parse("/apps/*")];
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &patterns)
            .unwrap_err();
        let cycle_count = errs
            .errors()
            .iter()
            .filter(|e| matches!(e, ExpandError::SymlinkCycle { .. }))
            .count();
        assert_eq!(cycle_count, 1);
    }

    #[test]
    fn disc_022_two_globs_crossing_same_boundary_escape_dedupe_to_one_boundary_error() {
        // Policy 2: a single escape-symlink observed by two glob
        // walks surfaces as one BoundaryEscape error.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/apps").unwrap();
        fs.add_dir("/elsewhere").unwrap();
        fs.add_symlink("/ws/apps/leak", "/elsewhere").unwrap();
        let ws = workspace(&fs, "/ws");
        let patterns = [parse("/apps/*"), parse("/apps/**")];
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &patterns)
            .unwrap_err();
        let boundary_count = errs
            .errors()
            .iter()
            .filter(|e| matches!(e, ExpandError::BoundaryEscape { .. }))
            .count();
        assert_eq!(boundary_count, 1);
    }

    #[test]
    fn combined_errors_accumulate_in_one_run() {
        // One missing literal + one empty glob + one duplicate
        // root + one boundary escape, all in the same call.
        // Confirms B.2 (accumulate) and that source indices are
        // preserved across error classes.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core").unwrap();
        fs.add_dir("/ws/apps").unwrap();
        fs.add_dir("/elsewhere").unwrap();
        fs.add_symlink("/ws/apps/leak", "/elsewhere").unwrap();
        let ws = workspace(&fs, "/ws");
        let patterns = [
            parse("/lib_core"),   // 0: ok
            parse("/lib_core"),   // 1: dup of 0
            parse("/missing"),    // 2: missing literal
            parse("/no_match/*"), // 3: empty glob
            parse("/apps/*"),     // 4: boundary escape via symlink
        ];
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &patterns)
            .unwrap_err();
        let kinds: Vec<&'static str> = errs
            .errors()
            .iter()
            .map(|e| match e {
                ExpandError::EmptyGlobMatch { .. } => "empty",
                ExpandError::MissingLiteralTarget { .. } => "missing",
                ExpandError::LiteralTargetNotADirectory { .. } => "not_a_directory",
                ExpandError::DuplicateProjectRoot { .. } => "duplicate",
                ExpandError::BoundaryEscape { .. } => "boundary",
                ExpandError::SymlinkCycle { .. } => "cycle",
                ExpandError::CanonicalizeFailed { .. } => "canon",
            })
            .collect();
        assert!(kinds.contains(&"empty"));
        assert!(kinds.contains(&"missing"));
        assert!(kinds.contains(&"duplicate"));
        assert!(kinds.contains(&"boundary"));
    }

    #[test]
    fn glob_with_legitimate_matches_and_sibling_boundary_escape_records_both() {
        // The walk emits matches AND records the escape: legitimate
        // siblings must not be suppressed by the boundary error.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/apps/web").unwrap();
        fs.add_dir("/ws/apps/api").unwrap();
        fs.add_dir("/elsewhere").unwrap();
        fs.add_symlink("/ws/apps/leak", "/elsewhere").unwrap();
        let ws = workspace(&fs, "/ws");
        // The glob produces errors AND would-be matches; the
        // present contract is to return Err(ExpandErrors) whenever
        // any error is recorded. The matches are dropped on the
        // floor in the error path, but the test still asserts that
        // the BoundaryEscape error is present and exactly one
        // such error.
        let errs = Discovery::new(&fs)
            .expand_project_list(&ws, &[parse("/apps/*")])
            .unwrap_err();
        assert_eq!(
            errs.errors()
                .iter()
                .filter(|e| matches!(e, ExpandError::BoundaryEscape { .. }))
                .count(),
            1,
        );
    }
}
