//! Load and parse `<workspace-root>/.haz/workspace.yml`.
//!
//! This module implements the explicit-mode workspace-config load
//! step (`DISC-004`): given a workspace-root path, read the
//! `workspace.yml` file via the [`Filesystem`] trait, decode it as
//! UTF-8, hand the text to [`map_workspace_text`], and bundle the
//! parsed result with the source path and source text into a
//! [`LoadedWorkspaceConfig`].
//!
//! The function takes a [`WorkspaceRootPath`] and not a
//! [`crate::root::WorkspaceRoot`] sum type: the implicit-mode
//! branch of `WorkspaceRoot` MUST NOT trigger a `workspace.yml`
//! load (`DISC-003` forbids it; the file does not exist by
//! definition). Mode dispatch is the orchestrator's job; the
//! loader's job is just to read and parse from a workspace-root
//! path that is known to be in explicit mode.
//!
//! `LoadedWorkspaceConfig` keeps the source path and source text
//! alongside the parsed config so that downstream layers can
//! render entry-anchored diagnostics without re-reading the file.

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

use haz_config::mapper::workspace::{
    WorkspaceConfig, WorkspaceConfigAnnotations, WorkspaceMapError, map_workspace_text,
};
use haz_domain::path::WorkspaceRootPath;
use snafu::Snafu;

use haz_vfs::{Filesystem, FsError};

use crate::discovery::Discovery;

/// Result of a successful explicit-mode workspace-config load.
///
/// Bundles four values that always travel together for the
/// remainder of discovery:
///
/// - the parsed [`WorkspaceConfig`] (semantic content),
/// - the parallel [`WorkspaceConfigAnnotations`] (parser-produced
///   per-entry metadata),
/// - `source_path`: the full filesystem path of the
///   `workspace.yml` file that was read (i.e.
///   `<workspace-root>/.haz/workspace.yml`),
/// - `source_text`: the file's UTF-8-decoded contents.
///
/// `source_path` and `source_text` are the discovery layer's
/// contribution: the parser does not know which file produced the
/// text it received. Consumers that build `miette` reports
/// against entry annotations use these two fields to construct a
/// [`miette::NamedSource`] at rendering time.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedWorkspaceConfig {
    /// Parsed semantic content of `workspace.yml`.
    pub config: WorkspaceConfig,
    /// Parser-produced per-entry metadata, index-parallel to
    /// `config.projects`.
    pub annotations: WorkspaceConfigAnnotations,
    /// Full filesystem path of the `workspace.yml` file that was
    /// read.
    pub source_path: PathBuf,
    /// UTF-8-decoded contents of `source_path`.
    pub source_text: String,
}

/// Errors produced by
/// [`Discovery::load_workspace_config`](crate::discovery::Discovery::load_workspace_config).
///
/// Diagnostic codes follow the `haz::dsc::NNN` namespace and are
/// kept in the variant docstrings so callers can route on them
/// without round-tripping through [`Display`](std::fmt::Display)
/// text.
///
/// The [`Self::Parse`] variant deliberately carries the source
/// text (in addition to the source path) so callers can attach a
/// [`miette::NamedSource`] at rendering time without re-reading
/// the file. `workspace.yml` files are small in practice; the
/// memory cost is negligible compared to the alternative (a race
/// between load and render, or every caller threading the bytes
/// through itself).
#[derive(Debug, Snafu)]
pub enum WorkspaceLoadError {
    /// Reading `<workspace-root>/.haz/workspace.yml` failed. The
    /// underlying [`FsError`] distinguishes the failure mode:
    /// missing file ([`FsError::NotFound`]), wrong entry kind
    /// (e.g. [`FsError::NotAFile`] when `workspace.yml` resolves
    /// to a directory), symlink loop, etc.
    ///
    /// Diagnostic code: `haz::dsc::005`.
    #[snafu(display(
        "failed to read workspace.yml at {}",
        path.display()
    ))]
    Read {
        /// Path that was being read.
        path: PathBuf,
        /// Underlying filesystem error.
        source: FsError,
    },

    /// The bytes of `workspace.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::006`.
    #[snafu(display(
        "workspace.yml at {} is not valid UTF-8",
        path.display()
    ))]
    InvalidUtf8 {
        /// Path that was being decoded.
        path: PathBuf,
        /// Underlying UTF-8 conversion error.
        source: FromUtf8Error,
    },

    /// Mapping the YAML text to a [`WorkspaceConfig`] failed. The
    /// underlying [`WorkspaceMapError`] carries byte-offset spans
    /// into `source_text`. Render via:
    ///
    /// ```
    /// # use haz_discovery::workspace::load::WorkspaceLoadError;
    /// # fn render(err: WorkspaceLoadError) -> Option<miette::Report> {
    /// #     let WorkspaceLoadError::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::007`.
    #[snafu(display(
        "failed to parse workspace.yml at {}",
        path.display()
    ))]
    Parse {
        /// Path that was being parsed.
        path: PathBuf,
        /// 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
        /// [`WorkspaceLoadError`] variant compact: the inner
        /// [`WorkspaceMapError`] carries `String`-bearing
        /// variants and an inner `PathPatternError` which
        /// together push the unboxed variant past the
        /// `result_large_err` threshold.
        source: Box<WorkspaceMapError>,
    },
}

// Empty Diagnostic impl: callers can construct
// `miette::Report::new(err)` directly. The chain rendering is
// driven by `std::error::Error::source` (which `snafu` derives
// for every variant). Rich `code`/`labels`/`help`/`source_code`
// belong to the inner `WorkspaceMapError` reached via the
// `Parse` variant; the caller that wants those is responsible
// for promoting the inner diagnostic to a fresh `Report`.
impl miette::Diagnostic for WorkspaceLoadError {}

impl<F: Filesystem> Discovery<'_, F> {
    /// Read and parse `<root>/.haz/workspace.yml`.
    ///
    /// Reads the file via [`Filesystem::read`], decodes it as UTF-8,
    /// and hands the resulting text to [`map_workspace_text`].
    /// Bundles the outputs (along with the source path and the
    /// source text) into a [`LoadedWorkspaceConfig`].
    ///
    /// # Errors
    ///
    /// - [`WorkspaceLoadError::Read`] when [`Filesystem::read`]
    ///   surfaces an [`FsError`] (file missing, wrong entry kind,
    ///   symlink loop, I/O failure, etc.).
    /// - [`WorkspaceLoadError::InvalidUtf8`] when the file's bytes
    ///   are not valid UTF-8.
    /// - [`WorkspaceLoadError::Parse`] when [`map_workspace_text`]
    ///   rejects the decoded text.
    pub fn load_workspace_config(
        &self,
        root: &WorkspaceRootPath,
    ) -> Result<LoadedWorkspaceConfig, WorkspaceLoadError> {
        let source_path = root.as_path().join(".haz").join("workspace.yml");

        let bytes = self
            .fs
            .read(&source_path)
            .map_err(|source| WorkspaceLoadError::Read {
                path: source_path.clone(),
                source,
            })?;

        let source_text =
            String::from_utf8(bytes).map_err(|source| WorkspaceLoadError::InvalidUtf8 {
                path: source_path.clone(),
                source,
            })?;

        match map_workspace_text(&source_text) {
            Ok((config, annotations)) => Ok(LoadedWorkspaceConfig {
                config,
                annotations,
                source_path,
                source_text,
            }),
            Err(source) => Err(WorkspaceLoadError::Parse {
                path: source_path,
                source_text,
                source: Box::new(source),
            }),
        }
    }
}

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

    use indoc::indoc;

    use haz_domain::path::{PathPattern, WorkspaceRootPath};

    use crate::discovery::Discovery;
    use crate::workspace::load::{LoadedWorkspaceConfig, WorkspaceLoadError};
    use haz_vfs::FsError;
    use haz_vfs_testing::MemFilesystem;

    fn root_at(s: &str) -> WorkspaceRootPath {
        WorkspaceRootPath::try_new(PathBuf::from(s)).expect("valid workspace root path")
    }

    fn build_fs_with_workspace_yml(root: &str, contents: &[u8]) -> MemFilesystem {
        let mut fs = MemFilesystem::new();
        fs.add_dir(root).unwrap();
        fs.add_dir(format!("{root}/.haz")).unwrap();
        fs.add_file(format!("{root}/.haz/workspace.yml"), contents.to_vec())
            .unwrap();
        fs
    }

    // Happy path: parse a small explicit workspace.

    #[test]
    fn loads_explicit_workspace_yml_into_loaded_config() {
        let yaml = indoc! {"
            projects:
              - /lib_core
              - /libs/*
        "};
        let fs = build_fs_with_workspace_yml("/ws", yaml.as_bytes());
        let root = root_at("/ws");

        let LoadedWorkspaceConfig {
            config,
            annotations,
            source_path,
            source_text,
        } = Discovery::new(&fs).load_workspace_config(&root).unwrap();

        assert_eq!(source_path, PathBuf::from("/ws/.haz/workspace.yml"));
        assert_eq!(source_text, yaml);
        assert_eq!(config.projects.len(), 2);
        assert_eq!(annotations.entries.len(), 2);
        assert!(matches!(config.projects[0], PathPattern::Literal(_)));
        assert!(matches!(config.projects[1], PathPattern::Glob(_)));
        assert_eq!(annotations.entries[0].raw, "/lib_core");
        assert_eq!(annotations.entries[1].raw, "/libs/*");
    }

    #[test]
    fn loads_workspace_with_empty_projects_list() {
        let yaml = "projects: []\n";
        let fs = build_fs_with_workspace_yml("/ws", yaml.as_bytes());
        let root = root_at("/ws");

        let loaded = Discovery::new(&fs).load_workspace_config(&root).unwrap();
        assert!(loaded.config.projects.is_empty());
        assert!(loaded.annotations.entries.is_empty());
    }

    // Read-side failures.

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

        let err = Discovery::new(&fs)
            .load_workspace_config(&root)
            .unwrap_err();
        match err {
            WorkspaceLoadError::Read { path, source } => {
                assert_eq!(path, PathBuf::from("/ws/.haz/workspace.yml"));
                assert!(matches!(source, FsError::NotFound { .. }));
            }
            other => panic!("expected Read with NotFound, got {other:?}"),
        }
    }

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

        let err = Discovery::new(&fs)
            .load_workspace_config(&root)
            .unwrap_err();
        match err {
            WorkspaceLoadError::Read { path, source } => {
                assert_eq!(path, PathBuf::from("/ws/.haz/workspace.yml"));
                assert!(matches!(source, FsError::NotAFile { .. }));
            }
            other => panic!("expected Read with NotAFile, got {other:?}"),
        }
    }

    // UTF-8 decoding failure.

    #[test]
    fn cfg_001_invalid_utf8_bytes_surface_invalid_utf8_error() {
        // 0xFF is not a valid leading byte in UTF-8.
        let bytes: Vec<u8> = vec![0xFF, b'p', b'r', b'o', b'j'];
        let fs = build_fs_with_workspace_yml("/ws", &bytes);
        let root = root_at("/ws");

        let err = Discovery::new(&fs)
            .load_workspace_config(&root)
            .unwrap_err();
        match err {
            WorkspaceLoadError::InvalidUtf8 { path, source: _ } => {
                assert_eq!(path, PathBuf::from("/ws/.haz/workspace.yml"));
            }
            other => panic!("expected InvalidUtf8, got {other:?}"),
        }
    }

    // Parse-side failures: each underlying WorkspaceMapError flavour.

    #[test]
    fn yaml_syntax_error_surfaces_parse_error_with_source_text() {
        let bad_yaml = "projects: [\nfoo";
        let fs = build_fs_with_workspace_yml("/ws", bad_yaml.as_bytes());
        let root = root_at("/ws");

        let err = Discovery::new(&fs)
            .load_workspace_config(&root)
            .unwrap_err();
        match err {
            WorkspaceLoadError::Parse {
                path,
                source_text,
                source,
            } => {
                assert_eq!(path, PathBuf::from("/ws/.haz/workspace.yml"));
                assert_eq!(source_text, bad_yaml);
                assert!(matches!(
                    *source,
                    haz_config::mapper::WorkspaceMapError::Yaml { .. }
                ));
            }
            other => panic!("expected Parse, got {other:?}"),
        }
    }

    #[test]
    fn invalid_project_entry_surfaces_parse_error() {
        let yaml = "projects:\n  - lib_core\n";
        let fs = build_fs_with_workspace_yml("/ws", yaml.as_bytes());
        let root = root_at("/ws");

        let err = Discovery::new(&fs)
            .load_workspace_config(&root)
            .unwrap_err();
        match err {
            WorkspaceLoadError::Parse {
                path,
                source_text,
                source,
            } => {
                assert_eq!(path, PathBuf::from("/ws/.haz/workspace.yml"));
                assert_eq!(source_text, yaml);
                assert!(matches!(
                    *source,
                    haz_config::mapper::WorkspaceMapError::ProjectEntryNotWorkspaceAbsolute { .. }
                ));
            }
            other => panic!("expected Parse, got {other:?}"),
        }
    }

    // Source text and path round-trip into a miette report.

    #[test]
    fn parse_error_renders_with_source_snippet_via_named_source() {
        let yaml = "projects:\n  - lib_core\n";
        let fs = build_fs_with_workspace_yml("/ws", yaml.as_bytes());
        let root = root_at("/ws");

        let err = Discovery::new(&fs)
            .load_workspace_config(&root)
            .unwrap_err();
        let WorkspaceLoadError::Parse {
            path,
            source_text,
            source,
        } = err
        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("lib_core"),
            "rendered report should include the offending text; got: {rendered}"
        );
        assert!(
            rendered.contains("haz::cfg::010"),
            "rendered report should include the rule code; got: {rendered}"
        );
    }
}
