//! Per-project `haz.yml` loader.
//!
//! For each [`ResolvedProject`] produced by
//! [`Discovery::expand_project_list`] this module:
//!
//! 1. Projects the host-canonical project root back into
//!    workspace-internal coordinates via
//!    [`Discovery::project_into_workspace`].
//! 2. Reads `<host_root>/haz.yml` via [`Filesystem::read`].
//! 3. Decodes the bytes as UTF-8.
//! 4. Maps the text via
//!    [`haz_config::mapper::project::map_project_text`].
//!
//! Successful outcomes are bundled into a [`LoadedProject`] (the
//! domain [`Project`] plus the originating host-canonical root and
//! the source-pattern index that produced it). Failures are
//! accumulated across the input list and returned as
//! [`LoadErrors`], matching the [`Discovery::expand_project_list`]
//! error-aggregation pattern so a single discovery invocation
//! surfaces every broken `haz.yml` at once. Project names are checked for pairwise
//! distinctness across the loaded list (`DISC-010`); duplicates
//! drop the second-observed root from the success list and
//! surface a [`LoadError::DuplicateName`].
//!
//! Implements `DISC-008` and `DISC-010`.
//!
//! [`Project`]: haz_domain::project::Project
//! [`Filesystem::read`]: haz_vfs::Filesystem::read
//! [`Discovery::project_into_workspace`]:
//! crate::discovery::Discovery::project_into_workspace

use std::collections::HashMap;
use std::path::PathBuf;
use std::string::FromUtf8Error;

use haz_config::mapper::project::{ProjectMapError, map_project_text};
use haz_domain::name::ProjectName;
use haz_domain::project::Project;
use haz_vfs::{Filesystem, FsError};
use snafu::Snafu;

use crate::discovery::Discovery;
use crate::path::ProjectionError;
use crate::projects::expand::{ResolvedProject, ResolvedProjectList};
use crate::root::WorkspaceRoot;

/// One project successfully loaded from its `haz.yml`.
///
/// Bundles the validated domain [`Project`] with two pieces of
/// discovery-layer metadata:
///
/// - `host_root`: the host-canonical path of the project's root
///   directory ([`F::CanonicalPath`]), cached so downstream
///   consumers do not have to re-project from `project.root` via
///   [`Discovery::verify_haz_path`](crate::discovery::Discovery::verify_haz_path).
/// - `source_index`: the index of the
///   [`PathPattern`](haz_domain::path::PathPattern) entry in the
///   originating `workspace.yml` `projects` list that produced
///   this root. Lets diagnostics attribute back to the source
///   entry.
///
/// The struct has no public constructor; the only producer is
/// [`Discovery::load_projects`].
///
/// [`F::CanonicalPath`]: haz_vfs::Filesystem::CanonicalPath
pub struct LoadedProject<F: Filesystem> {
    project: Project,
    host_root: F::CanonicalPath,
    source_index: usize,
}

impl<F: Filesystem> LoadedProject<F> {
    /// Borrow the validated domain [`Project`].
    #[must_use]
    pub fn project(&self) -> &Project {
        &self.project
    }

    /// Borrow the host-canonical project root.
    #[must_use]
    pub fn host_root(&self) -> &F::CanonicalPath {
        &self.host_root
    }

    /// Index of the originating `workspace.yml` `projects` entry.
    #[must_use]
    pub fn source_index(&self) -> usize {
        self.source_index
    }
}

// Manual trait impls: 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 LoadedProject<F> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("LoadedProject")
            .field("project", &self.project)
            .field("host_root", &self.host_root)
            .field("source_index", &self.source_index)
            .finish()
    }
}

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

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

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

/// A list of successfully loaded projects, with no public
/// constructor.
///
/// Produced only by [`Discovery::load_projects`]. That pipeline
/// guarantees, per [`Discovery::load_projects`]:
///
/// - Every entry parsed and validated its `haz.yml` cleanly.
/// - Every [`Project::name`](haz_domain::project::Project::name)
///   is pairwise distinct across the list (`DISC-010`).
///
/// Iteration order is the source order of the input
/// [`ResolvedProjectList`].
pub struct LoadedProjectList<F: Filesystem> {
    entries: Vec<LoadedProject<F>>,
}

impl<F: Filesystem> LoadedProjectList<F> {
    /// Number of loaded projects.
    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

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

    /// Iterator over the loaded projects in source-input order.
    pub fn iter(&self) -> std::slice::Iter<'_, LoadedProject<F>> {
        self.entries.iter()
    }
}

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

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

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

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

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

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

/// One error produced during [`Discovery::load_projects`].
///
/// 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 LoadError {
    /// Reading `<root>/haz.yml` failed. The underlying
    /// [`FsError`] distinguishes the failure mode (missing file
    /// per `DISC-008`, wrong entry kind, symlink loop, I/O
    /// failure, etc.).
    ///
    /// Diagnostic code: `haz::dsc::023`.
    #[snafu(display(
        "failed to read haz.yml at {} (project root {}, entry #{source_index})",
        path.display(),
        project_root.display()
    ))]
    Read {
        /// Host-canonical project root that was being loaded.
        project_root: PathBuf,
        /// Full path to the `haz.yml` file that was being read.
        path: PathBuf,
        /// Index of the originating `workspace.yml` entry.
        source_index: usize,
        /// Underlying filesystem error.
        source: FsError,
    },

    /// The bytes of `<root>/haz.yml` are not valid UTF-8. YAML
    /// 1.2 requires UTF-8 (or UTF-16/UTF-32, which this loader
    /// does not accept).
    ///
    /// Diagnostic code: `haz::dsc::024`.
    #[snafu(display(
        "haz.yml at {} (project root {}, entry #{source_index}) is not valid UTF-8",
        path.display(),
        project_root.display()
    ))]
    InvalidUtf8 {
        /// Host-canonical project root that was being loaded.
        project_root: PathBuf,
        /// Full path to the `haz.yml` file that was being decoded.
        path: PathBuf,
        /// Index of the originating `workspace.yml` entry.
        source_index: usize,
        /// Underlying UTF-8 conversion error.
        source: FromUtf8Error,
    },

    /// Mapping the YAML text to a [`Project`] failed. The
    /// underlying [`ProjectMapError`] carries byte-offset spans
    /// into `source_text`. Render via:
    ///
    /// ```
    /// # use haz_discovery::projects::load::LoadError;
    /// # fn render(err: LoadError) -> Option<miette::Report> {
    /// #     let LoadError::Parse { path, source_text, source, .. } = err
    /// #     else {
    /// #         return None;
    /// #     };
    /// let report = miette::Report::new(*source).with_source_code(
    ///     miette::NamedSource::new(path.display().to_string(), source_text),
    /// );
    /// #     Some(report)
    /// # }
    /// ```
    ///
    /// Diagnostic code: `haz::dsc::025`.
    #[snafu(display(
        "failed to parse haz.yml at {} (project root {}, entry #{source_index})",
        path.display(),
        project_root.display()
    ))]
    Parse {
        /// Host-canonical project root that was being loaded.
        project_root: PathBuf,
        /// Full path to the `haz.yml` file that was being parsed.
        path: PathBuf,
        /// Index of the originating `workspace.yml` entry.
        source_index: usize,
        /// UTF-8 contents of `path`, retained so callers can
        /// build a [`miette::NamedSource`] without re-reading.
        source_text: String,
        /// Underlying mapper error. Boxed to keep the
        /// [`LoadError`] variant compact: the inner
        /// [`ProjectMapError`] carries `String`-bearing variants
        /// and a recursive `TaskMapError` which together push the
        /// unboxed variant past the `result_large_err`
        /// threshold.
        source: Box<ProjectMapError>,
    },

    /// Projecting the host-canonical project root back to a
    /// workspace-internal [`CanonicalPath`](haz_domain::path::CanonicalPath)
    /// failed. Reaches this arm only via the rare cases the
    /// projector can produce given that the root was already
    /// verified-within by [`Discovery::expand_project_list`]: a
    /// non-UTF-8 host component, or a host component that fails
    /// [`PathSegment`](haz_domain::path::PathSegment) validation.
    ///
    /// Diagnostic code: `haz::dsc::026`.
    #[snafu(display(
        "failed to project project root {} (entry #{source_index}) into workspace coordinates",
        project_root.display()
    ))]
    Projection {
        /// Host-canonical project root that was being projected.
        project_root: PathBuf,
        /// Index of the originating `workspace.yml` entry.
        source_index: usize,
        /// Underlying projection error.
        source: ProjectionError,
    },

    /// Two distinct project roots produced the same project name
    /// (`DISC-010`). The first-observed root admits to the
    /// success list; the second is dropped from the success list
    /// and surfaced as this error. Both source indices and both
    /// host-canonical roots are carried so the diagnostic can
    /// attribute back to the originating `workspace.yml` entries.
    ///
    /// Diagnostic code: `haz::dsc::027`.
    #[snafu(display(
        "duplicate project name {:?}: entries #{first_source_index} ({}) and #{second_source_index} ({})",
        name.as_ref(),
        first_root.display(),
        second_root.display()
    ))]
    DuplicateName {
        /// The duplicated project name.
        name: ProjectName,
        /// Source index of the first-observed root that claimed
        /// this name.
        first_source_index: usize,
        /// Source index of the second-observed root that
        /// collided.
        second_source_index: usize,
        /// Host-canonical root of the first-observed project.
        first_root: PathBuf,
        /// Host-canonical root of the second-observed project.
        second_root: PathBuf,
    },
}

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

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

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

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

impl IntoIterator for LoadErrors {
    type Item = LoadError;
    type IntoIter = std::vec::IntoIter<LoadError>;

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

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

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

// Empty Diagnostic impls so callers can construct
// `miette::Report::new(err)` directly. Rich `code`/`labels`/
// `help`/`source_code` belong to the inner `ProjectMapError`
// reached via [`LoadError::Parse`]; the caller that wants
// those is responsible for promoting the inner diagnostic to
// a fresh `Report` (see `apps/haz-cli/src/workspace_load.rs`
// for the canonical example).
impl miette::Diagnostic for LoadError {}
impl miette::Diagnostic for LoadErrors {}

impl<F: Filesystem> Discovery<'_, F> {
    /// Load every resolved project's `haz.yml` and return the
    /// successful list, or the accumulated errors.
    ///
    /// For each [`ResolvedProject`] in `resolved`, runs the
    /// per-root pipeline (project the root into workspace
    /// coordinates, read `<root>/haz.yml`, decode UTF-8, map to
    /// [`Project`]). After a per-root load succeeds, the
    /// resulting [`Project::name`](haz_domain::project::Project::name)
    /// is checked against every previously admitted project's
    /// name (`DISC-010`): on a collision the duplicate root is
    /// dropped from the success list and surfaced as
    /// [`LoadError::DuplicateName`]. Errors are *accumulated*
    /// rather than fail-fast: a call where multiple `haz.yml`
    /// files are broken (or several roots collide on names)
    /// returns all of them in one [`LoadErrors`].
    ///
    /// # Errors
    ///
    /// Returns [`LoadErrors`] when one or more roots fail to
    /// load or collide on a name. The error vector is non-empty
    /// in this arm.
    pub fn load_projects(
        &self,
        workspace: &WorkspaceRoot<F>,
        resolved: &ResolvedProjectList<F>,
    ) -> Result<LoadedProjectList<F>, LoadErrors> {
        let mut entries: Vec<LoadedProject<F>> = Vec::new();
        let mut errors: Vec<LoadError> = Vec::new();
        let mut first_by_name: HashMap<ProjectName, (usize, PathBuf)> = HashMap::new();

        for rp in resolved {
            match self.load_one(workspace, rp) {
                Ok(loaded) => {
                    let name = loaded.project.name.clone();
                    if let Some((first_idx, first_root)) = first_by_name.get(&name) {
                        errors.push(LoadError::DuplicateName {
                            name,
                            first_source_index: *first_idx,
                            second_source_index: loaded.source_index,
                            first_root: first_root.clone(),
                            second_root: loaded.host_root.as_ref().to_path_buf(),
                        });
                    } else {
                        first_by_name.insert(
                            name,
                            (loaded.source_index, loaded.host_root.as_ref().to_path_buf()),
                        );
                        entries.push(loaded);
                    }
                }
                Err(err) => errors.push(err),
            }
        }

        if errors.is_empty() {
            Ok(LoadedProjectList { entries })
        } else {
            Err(LoadErrors { errors })
        }
    }

    /// Load the singleton implicit-mode project at the workspace
    /// root.
    ///
    /// Implements `DISC-003`: the implicit-mode workspace
    /// contains exactly one project whose root is the workspace
    /// root itself, with configuration in
    /// `<workspace-root>/haz.yml`. The resulting [`Project::root`]
    /// is [`haz_domain::path::ProjectRoot::WorkspaceRoot`].
    ///
    /// `workspace_canonical` is the host-canonical path of the
    /// workspace root, as produced by
    /// [`Discovery::find_workspace_root`]. The returned
    /// [`LoadedProjectList`] is always of length 1.
    ///
    /// The singleton entry's
    /// [`LoadedProject::source_index`] is `0` by convention;
    /// implicit mode has no `workspace.yml` `projects` list to
    /// index into, but the value is harmless and downstream
    /// consumers can recognise implicit mode by inspecting
    /// `loaded.project().root` (a
    /// [`haz_domain::path::ProjectRoot::WorkspaceRoot`] can only
    /// originate from this method).
    ///
    /// # Errors
    ///
    /// - [`LoadError::Read`] when reading `<root>/haz.yml`
    ///   fails (the file is missing per `DISC-003`, is not a
    ///   regular file, or the underlying filesystem surfaces an
    ///   I/O error).
    /// - [`LoadError::InvalidUtf8`] when the file's bytes are
    ///   not valid UTF-8.
    /// - [`LoadError::Parse`] when the mapper rejects the text.
    ///
    /// [`Project::root`]: haz_domain::project::Project::root
    pub fn load_implicit_project(
        &self,
        workspace_canonical: &F::CanonicalPath,
    ) -> Result<LoadedProjectList<F>, LoadError> {
        let project_root_path: PathBuf = workspace_canonical.as_ref().to_path_buf();
        let source_index: usize = 0;
        let haz_yml_path = project_root_path.join("haz.yml");

        let bytes = self
            .fs
            .read(&haz_yml_path)
            .map_err(|source| LoadError::Read {
                project_root: project_root_path.clone(),
                path: haz_yml_path.clone(),
                source_index,
                source,
            })?;

        let source_text = String::from_utf8(bytes).map_err(|source| LoadError::InvalidUtf8 {
            project_root: project_root_path.clone(),
            path: haz_yml_path.clone(),
            source_index,
            source,
        })?;

        let project = map_project_text(&source_text, haz_domain::path::ProjectRoot::WorkspaceRoot)
            .map_err(|source| LoadError::Parse {
                project_root: project_root_path.clone(),
                path: haz_yml_path,
                source_index,
                source_text,
                source: Box::new(source),
            })?;

        let entry = LoadedProject {
            project,
            host_root: workspace_canonical.clone(),
            source_index,
        };

        Ok(LoadedProjectList {
            entries: vec![entry],
        })
    }

    fn load_one(
        &self,
        workspace: &WorkspaceRoot<F>,
        resolved: &ResolvedProject<F>,
    ) -> Result<LoadedProject<F>, LoadError> {
        let project_root_path: PathBuf = resolved.root().as_ref().to_path_buf();
        let source_index = resolved.source_index();
        let haz_yml_path = project_root_path.join("haz.yml");

        let workspace_internal = self
            .project_into_workspace(resolved.root(), workspace.canonical())
            .map_err(|source| LoadError::Projection {
                project_root: project_root_path.clone(),
                source_index,
                source,
            })?;

        let bytes = self
            .fs
            .read(&haz_yml_path)
            .map_err(|source| LoadError::Read {
                project_root: project_root_path.clone(),
                path: haz_yml_path.clone(),
                source_index,
                source,
            })?;

        let source_text = String::from_utf8(bytes).map_err(|source| LoadError::InvalidUtf8 {
            project_root: project_root_path.clone(),
            path: haz_yml_path.clone(),
            source_index,
            source,
        })?;

        let project = map_project_text(
            &source_text,
            haz_domain::path::ProjectRoot::Nested(workspace_internal),
        )
        .map_err(|source| LoadError::Parse {
            project_root: project_root_path.clone(),
            path: haz_yml_path,
            source_index,
            source_text,
            source: Box::new(source),
        })?;

        Ok(LoadedProject {
            project,
            host_root: resolved.root().clone(),
            source_index,
        })
    }
}

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

    use indoc::indoc;

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

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

    use super::{LoadError, LoadedProjectList};

    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()
    }

    fn load(
        fs: &MemFilesystem,
        ws_root: &str,
        patterns: &[PathPattern],
    ) -> Result<LoadedProjectList<MemFilesystem>, super::LoadErrors> {
        let ws = workspace(fs, ws_root);
        let discovery = Discovery::new(fs);
        let resolved = discovery
            .expand_project_list(&ws, patterns)
            .expect("expansion succeeded");
        discovery.load_projects(&ws, &resolved)
    }

    // Happy paths --------------------------------------------------------

    #[test]
    fn disc_008_loads_single_project_happy_path() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core").unwrap();
        fs.add_file(
            "/ws/lib_core/haz.yml",
            indoc! {"
                name: lib_core
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();

        let out = load(&fs, "/ws", &[parse("/lib_core")]).unwrap();
        assert_eq!(out.len(), 1);
        let lp = out.iter().next().unwrap();
        assert_eq!(lp.project().name.as_ref(), "lib_core");
        assert_eq!(lp.project().root.to_string(), "/lib_core");
        assert_eq!(lp.host_root().as_path(), Path::new("/ws/lib_core"));
        assert_eq!(lp.source_index(), 0);
    }

    #[test]
    fn disc_008_loads_two_projects_in_source_order() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/a").unwrap();
        fs.add_dir("/ws/b").unwrap();
        fs.add_file("/ws/a/haz.yml", b"name: a\n".to_vec()).unwrap();
        fs.add_file("/ws/b/haz.yml", b"name: b\n".to_vec()).unwrap();

        let out = load(&fs, "/ws", &[parse("/b"), parse("/a")]).unwrap();
        let names: Vec<_> = out
            .iter()
            .map(|lp| (lp.project().name.as_ref().to_owned(), lp.source_index()))
            .collect();
        assert_eq!(names, vec![("b".to_owned(), 0), ("a".to_owned(), 1)]);
    }

    // Read-side failures (DISC-008) --------------------------------------

    #[test]
    fn disc_008_missing_haz_yml_surfaces_read_with_not_found() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core").unwrap();

        let errs = load(&fs, "/ws", &[parse("/lib_core")]).unwrap_err();
        assert_eq!(errs.len(), 1);
        match &errs.errors()[0] {
            LoadError::Read {
                project_root,
                path,
                source_index,
                source,
            } => {
                assert_eq!(project_root, &PathBuf::from("/ws/lib_core"));
                assert_eq!(path, &PathBuf::from("/ws/lib_core/haz.yml"));
                assert_eq!(*source_index, 0);
                assert!(matches!(source, FsError::NotFound { .. }));
            }
            other => panic!("expected Read, got {other:?}"),
        }
    }

    #[test]
    fn disc_008_haz_yml_as_directory_surfaces_read_with_not_a_file() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core").unwrap();
        fs.add_dir("/ws/lib_core/haz.yml").unwrap();

        let errs = load(&fs, "/ws", &[parse("/lib_core")]).unwrap_err();
        assert_eq!(errs.len(), 1);
        match &errs.errors()[0] {
            LoadError::Read { source, .. } => {
                assert!(matches!(source, FsError::NotAFile { .. }));
            }
            other => panic!("expected Read with NotAFile, got {other:?}"),
        }
    }

    // UTF-8 failure -------------------------------------------------------

    #[test]
    fn disc_008_invalid_utf8_in_haz_yml_surfaces_invalid_utf8() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core").unwrap();
        // 0xFF is not a valid UTF-8 leading byte.
        fs.add_file("/ws/lib_core/haz.yml", vec![0xFF, b'n', b'a', b'm', b'e'])
            .unwrap();

        let errs = load(&fs, "/ws", &[parse("/lib_core")]).unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(matches!(
            &errs.errors()[0],
            LoadError::InvalidUtf8 {
                source_index: 0,
                ..
            }
        ));
    }

    // Parse-side failures -------------------------------------------------

    #[test]
    fn disc_008_malformed_yaml_surfaces_parse_yaml() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core").unwrap();
        fs.add_file("/ws/lib_core/haz.yml", b"name: [unterminated\n".to_vec())
            .unwrap();

        let errs = load(&fs, "/ws", &[parse("/lib_core")]).unwrap_err();
        assert_eq!(errs.len(), 1);
        match &errs.errors()[0] {
            LoadError::Parse {
                project_root,
                path,
                source_index,
                source_text,
                source,
            } => {
                assert_eq!(project_root, &PathBuf::from("/ws/lib_core"));
                assert_eq!(path, &PathBuf::from("/ws/lib_core/haz.yml"));
                assert_eq!(*source_index, 0);
                assert_eq!(source_text, "name: [unterminated\n");
                assert!(matches!(
                    **source,
                    haz_config::mapper::project::ProjectMapError::Yaml { .. }
                ));
            }
            other => panic!("expected Parse, got {other:?}"),
        }
    }

    #[test]
    fn disc_008_invalid_project_name_surfaces_parse_invalid_project_name() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core").unwrap();
        fs.add_file(
            "/ws/lib_core/haz.yml",
            indoc! {"
                name: 'has space'
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();

        let errs = load(&fs, "/ws", &[parse("/lib_core")]).unwrap_err();
        assert_eq!(errs.len(), 1);
        match &errs.errors()[0] {
            LoadError::Parse { source, .. } => {
                assert!(matches!(
                    **source,
                    haz_config::mapper::project::ProjectMapError::InvalidProjectName { .. }
                ));
            }
            other => panic!("expected Parse with InvalidProjectName, got {other:?}"),
        }
    }

    #[test]
    fn parse_error_renders_via_named_source() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core").unwrap();
        fs.add_file(
            "/ws/lib_core/haz.yml",
            indoc! {"
                name: 'has space'
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();

        let errs = load(&fs, "/ws", &[parse("/lib_core")]).unwrap_err();
        let LoadError::Parse {
            path,
            source_text,
            source,
            ..
        } = errs.into_iter().next().unwrap()
        else {
            panic!("expected Parse");
        };

        let report = miette::Report::new(*source).with_source_code(miette::NamedSource::new(
            path.display().to_string(),
            source_text,
        ));
        let rendered = format!("{report:?}");
        assert!(
            rendered.contains("has space"),
            "rendered report should include offending text; got: {rendered}"
        );
        assert!(
            rendered.contains("haz::cfg::013"),
            "rendered report should include the rule code; got: {rendered}"
        );
    }

    // Multiple errors accumulate ----------------------------------------

    #[test]
    fn disc_008_two_roots_both_missing_haz_yml_accumulate() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/a").unwrap();
        fs.add_dir("/ws/b").unwrap();

        let errs = load(&fs, "/ws", &[parse("/a"), parse("/b")]).unwrap_err();
        assert_eq!(errs.len(), 2);
        for (idx, err) in errs.errors().iter().enumerate() {
            match err {
                LoadError::Read { source_index, .. } => assert_eq!(*source_index, idx),
                other => panic!("expected Read, got {other:?}"),
            }
        }
    }

    // DISC-010 -- project-name uniqueness --------------------------------

    #[test]
    fn cfg_012_duplicate_project_name_across_two_roots_surfaces_duplicate_name() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/first").unwrap();
        fs.add_dir("/ws/second").unwrap();
        fs.add_file("/ws/first/haz.yml", b"name: shared\n".to_vec())
            .unwrap();
        fs.add_file("/ws/second/haz.yml", b"name: shared\n".to_vec())
            .unwrap();

        let errs = load(&fs, "/ws", &[parse("/first"), parse("/second")]).unwrap_err();
        assert_eq!(errs.len(), 1);
        match &errs.errors()[0] {
            LoadError::DuplicateName {
                name,
                first_source_index,
                second_source_index,
                first_root,
                second_root,
            } => {
                assert_eq!(name.as_ref(), "shared");
                assert_eq!(*first_source_index, 0);
                assert_eq!(*second_source_index, 1);
                assert_eq!(first_root, &PathBuf::from("/ws/first"));
                assert_eq!(second_root, &PathBuf::from("/ws/second"));
            }
            other => panic!("expected DuplicateName, got {other:?}"),
        }
    }

    #[test]
    fn three_roots_one_duplicate_admits_unique_and_first_observed() {
        // A:foo, B:foo, C:bar. Expected:
        // - Err arm (one DuplicateName for A,B)
        // - the success list is dropped under accumulate
        //   semantics, so the Ok arm is never reached.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/a").unwrap();
        fs.add_dir("/ws/b").unwrap();
        fs.add_dir("/ws/c").unwrap();
        fs.add_file("/ws/a/haz.yml", b"name: foo\n".to_vec())
            .unwrap();
        fs.add_file("/ws/b/haz.yml", b"name: foo\n".to_vec())
            .unwrap();
        fs.add_file("/ws/c/haz.yml", b"name: bar\n".to_vec())
            .unwrap();

        let errs = load(&fs, "/ws", &[parse("/a"), parse("/b"), parse("/c")]).unwrap_err();
        assert_eq!(errs.len(), 1);
        match &errs.errors()[0] {
            LoadError::DuplicateName {
                first_source_index,
                second_source_index,
                ..
            } => {
                assert_eq!(*first_source_index, 0);
                assert_eq!(*second_source_index, 1);
            }
            other => panic!("expected DuplicateName, got {other:?}"),
        }
    }

    #[test]
    fn three_distinct_names_load_cleanly() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/a").unwrap();
        fs.add_dir("/ws/b").unwrap();
        fs.add_dir("/ws/c").unwrap();
        fs.add_file("/ws/a/haz.yml", b"name: a\n".to_vec()).unwrap();
        fs.add_file("/ws/b/haz.yml", b"name: b\n".to_vec()).unwrap();
        fs.add_file("/ws/c/haz.yml", b"name: c\n".to_vec()).unwrap();

        let out = load(&fs, "/ws", &[parse("/a"), parse("/b"), parse("/c")]).unwrap();
        assert_eq!(out.len(), 3);
    }

    // Combined accumulation across all failure flavours -----------------

    #[test]
    fn combined_errors_across_load_and_disc_010() {
        // Four resolved roots in a single discovery run:
        //   /a -- valid haz.yml, name "a"  -> admitted
        //   /b -- haz.yml missing          -> LoadError::Read
        //   /c -- malformed YAML           -> LoadError::Parse
        //   /d -- valid haz.yml, name "a"  -> LoadError::DuplicateName
        // All three failures must surface in one LoadErrors;
        // the Ok arm is never reached under accumulate
        // semantics.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/a").unwrap();
        fs.add_dir("/ws/b").unwrap();
        fs.add_dir("/ws/c").unwrap();
        fs.add_dir("/ws/d").unwrap();
        fs.add_file("/ws/a/haz.yml", b"name: a\n".to_vec()).unwrap();
        // /ws/b has no haz.yml.
        fs.add_file("/ws/c/haz.yml", b"name: [unterminated\n".to_vec())
            .unwrap();
        fs.add_file("/ws/d/haz.yml", b"name: a\n".to_vec()).unwrap();

        let errs = load(
            &fs,
            "/ws",
            &[parse("/a"), parse("/b"), parse("/c"), parse("/d")],
        )
        .unwrap_err();
        assert_eq!(errs.len(), 3);

        let kinds: Vec<&str> = errs
            .errors()
            .iter()
            .map(|e| match e {
                LoadError::Read { .. } => "read",
                LoadError::InvalidUtf8 { .. } => "utf8",
                LoadError::Parse { .. } => "parse",
                LoadError::Projection { .. } => "projection",
                LoadError::DuplicateName { .. } => "duplicate",
            })
            .collect();
        assert_eq!(kinds, vec!["read", "parse", "duplicate"]);

        // Source indices are intact on each variant.
        match &errs.errors()[0] {
            LoadError::Read { source_index, .. } => assert_eq!(*source_index, 1),
            other => panic!("expected Read at #1, got {other:?}"),
        }
        match &errs.errors()[1] {
            LoadError::Parse { source_index, .. } => assert_eq!(*source_index, 2),
            other => panic!("expected Parse at #2, got {other:?}"),
        }
        match &errs.errors()[2] {
            LoadError::DuplicateName {
                first_source_index,
                second_source_index,
                ..
            } => {
                assert_eq!(*first_source_index, 0);
                assert_eq!(*second_source_index, 3);
            }
            other => panic!("expected DuplicateName at #3, got {other:?}"),
        }
    }

    // Implicit-mode load (DISC-003) -------------------------------------

    fn implicit_workspace_canonical(
        fs: &MemFilesystem,
        p: &str,
    ) -> <MemFilesystem as Filesystem>::CanonicalPath {
        fs.canonicalize(Path::new(p)).unwrap()
    }

    #[test]
    fn disc_004_implicit_load_happy_path() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_file("/ws/haz.yml", b"name: root_proj\n".to_vec())
            .unwrap();

        let canonical = implicit_workspace_canonical(&fs, "/ws");
        let out = Discovery::new(&fs)
            .load_implicit_project(&canonical)
            .unwrap();

        assert_eq!(out.len(), 1);
        let lp = out.iter().next().unwrap();
        assert_eq!(lp.project().name.as_ref(), "root_proj");
        assert!(lp.project().root.is_workspace_root());
        assert_eq!(lp.project().root.to_string(), "/");
        assert_eq!(lp.host_root().as_path(), Path::new("/ws"));
        assert_eq!(lp.source_index(), 0);
    }

    #[test]
    fn disc_004_implicit_load_missing_haz_yml_surfaces_read_not_found() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();

        let canonical = implicit_workspace_canonical(&fs, "/ws");
        let err = Discovery::new(&fs)
            .load_implicit_project(&canonical)
            .unwrap_err();

        match err {
            LoadError::Read {
                project_root,
                path,
                source,
                ..
            } => {
                assert_eq!(project_root, PathBuf::from("/ws"));
                assert_eq!(path, PathBuf::from("/ws/haz.yml"));
                assert!(matches!(source, FsError::NotFound { .. }));
            }
            other => panic!("expected Read with NotFound, got {other:?}"),
        }
    }

    #[test]
    fn disc_004_implicit_load_invalid_utf8_surfaces_invalid_utf8() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_file("/ws/haz.yml", vec![0xFF, b'n']).unwrap();

        let canonical = implicit_workspace_canonical(&fs, "/ws");
        let err = Discovery::new(&fs)
            .load_implicit_project(&canonical)
            .unwrap_err();
        assert!(matches!(err, LoadError::InvalidUtf8 { .. }));
    }

    #[test]
    fn implicit_load_malformed_yaml_surfaces_parse() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_file("/ws/haz.yml", b"name: [unterminated\n".to_vec())
            .unwrap();

        let canonical = implicit_workspace_canonical(&fs, "/ws");
        let err = Discovery::new(&fs)
            .load_implicit_project(&canonical)
            .unwrap_err();
        match err {
            LoadError::Parse {
                project_root,
                source_text,
                source,
                ..
            } => {
                assert_eq!(project_root, PathBuf::from("/ws"));
                assert_eq!(source_text, "name: [unterminated\n");
                assert!(matches!(
                    *source,
                    haz_config::mapper::project::ProjectMapError::Yaml { .. }
                ));
            }
            other => panic!("expected Parse, got {other:?}"),
        }
    }
}
