//! Scan, parse, and assemble `.haz/tasks/*.yml` overlay files.
//!
//! [`Discovery::load_overlays`] reads the workspace's
//! `.haz/tasks/` directory, filters its entries per `CFG-023`,
//! parses each surviving file via
//! [`haz_config::mapper::overlay::map_overlay_text`], computes
//! the matched-projects set per `CFG-026` + `DAG-001`, and
//! bundles the outcome into a [`LoadedOverlayList`].
//!
//! Per `CFG-023`, files whose name does not satisfy the overlay
//! filename convention (extension is not `.yml`, stem is not a
//! valid [`OverlayName`], non-regular-file entries) are silently
//! ignored: they do NOT produce an error. Errors are reserved
//! for files that pass the filename gate but fail at I/O, UTF-8
//! decoding, or mapping.
//!
//! Per-file errors are accumulated into [`LoadOverlayErrors`]:
//! a single discovery invocation surfaces every problematic
//! overlay file at once, matching the error-aggregation pattern
//! established by [`crate::projects::load`].
//!
//! Implicit-mode workspaces have no `.haz/` directory by
//! definition and therefore no overlays; the orchestrator
//! ([`crate::discover`]) constructs an empty
//! [`LoadedOverlayList`] for that mode and does not call this
//! loader.

use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::str::FromStr;
use std::string::FromUtf8Error;

use haz_config::mapper::overlay::{OverlayMapError, map_overlay_text};
use haz_domain::name::{OverlayName, ProjectName};
use haz_domain::overlay::Overlay;
use haz_vfs::{EntryKind, Filesystem, FsError};
use snafu::Snafu;

use crate::discovery::Discovery;
use crate::projects::load::LoadedProjectList;
use crate::root::WorkspaceRoot;

/// One overlay file successfully loaded from disk.
///
/// Bundles the validated domain [`Overlay`] with two pieces of
/// discovery-layer metadata:
///
/// - `host_path`: the absolute path of the overlay file on disk,
///   useful for diagnostics that need to reference the source
///   file by location.
/// - `source_text`: the file's UTF-8 contents, retained so
///   downstream layers can attach a [`miette::NamedSource`] when
///   rendering errors that span back into the file.
pub struct LoadedOverlay<F: Filesystem> {
    overlay: Overlay,
    host_path: PathBuf,
    source_text: String,
    _marker: std::marker::PhantomData<fn() -> F>,
}

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

    /// Borrow the absolute on-disk path of the overlay file.
    #[must_use]
    pub fn host_path(&self) -> &PathBuf {
        &self.host_path
    }

    /// Borrow the UTF-8 contents of the overlay file.
    #[must_use]
    pub fn source_text(&self) -> &str {
        &self.source_text
    }
}

// Manual trait impls: deriving would add bounds on `F` itself
// even though the fields are not parameterised by `F`. The
// `PhantomData<fn() -> F>` makes the type generic in `F` so the
// API surface can grow `F`-dependent fields later without churn.

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

impl<F: Filesystem> Clone for LoadedOverlay<F> {
    fn clone(&self) -> Self {
        Self {
            overlay: self.overlay.clone(),
            host_path: self.host_path.clone(),
            source_text: self.source_text.clone(),
            _marker: std::marker::PhantomData,
        }
    }
}

impl<F: Filesystem> PartialEq for LoadedOverlay<F> {
    fn eq(&self, other: &Self) -> bool {
        self.overlay == other.overlay
            && self.host_path == other.host_path
            && self.source_text == other.source_text
    }
}

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

/// All overlay files loaded for one workspace.
///
/// The list is sorted by [`OverlayName`]; per-file iteration is
/// deterministic across runs.
pub struct LoadedOverlayList<F: Filesystem> {
    entries: Vec<LoadedOverlay<F>>,
}

impl<F: Filesystem> LoadedOverlayList<F> {
    /// Construct an empty list (e.g. for implicit-mode workspaces
    /// that have no `.haz/tasks/` directory).
    #[must_use]
    pub fn new_empty() -> Self {
        Self {
            entries: Vec::new(),
        }
    }

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

    /// `true` when no overlays were loaded.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Iterate over the loaded overlays in deterministic order.
    pub fn iter(&self) -> impl Iterator<Item = &LoadedOverlay<F>> {
        self.entries.iter()
    }
}

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

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

impl<F: Filesystem> Default for LoadedOverlayList<F> {
    fn default() -> Self {
        Self::new_empty()
    }
}

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

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

/// One per-overlay-file failure surfaced by
/// [`Discovery::load_overlays`].
///
/// Files that fail the `CFG-023` filename gate (wrong extension,
/// invalid stem, non-file entries) are silently ignored per the
/// spec; only files that pass the gate but fail downstream
/// produce a variant here.
///
/// Diagnostic codes follow the `haz::dsc::NNN` namespace,
/// continuing the allocation after the project loader
/// ([`Discovery::load_projects`]) and the
/// [`Discovery::discover_workspace`] orchestrator (033..).
#[derive(Debug, Snafu)]
pub enum LoadOverlayError {
    /// Reading the `.haz/tasks/` directory itself failed with
    /// something other than [`FsError::NotFound`]. Missing
    /// directory is NOT an error (the workspace simply has no
    /// overlays).
    ///
    /// Diagnostic code: `haz::dsc::033`.
    #[snafu(display(
        "failed to list overlay directory at {}",
        path.display()
    ))]
    ReadOverlayDir {
        /// Directory that was being listed.
        path: PathBuf,
        /// Underlying filesystem error.
        source: FsError,
    },

    /// Reading the bytes of an individual overlay file failed.
    ///
    /// Diagnostic code: `haz::dsc::034`.
    #[snafu(display(
        "failed to read overlay file at {}",
        path.display()
    ))]
    Read {
        /// Overlay file that was being read.
        path: PathBuf,
        /// Underlying filesystem error.
        source: FsError,
    },

    /// The bytes of an overlay file are not valid UTF-8.
    ///
    /// Diagnostic code: `haz::dsc::035`.
    #[snafu(display(
        "overlay file at {} is not valid UTF-8",
        path.display()
    ))]
    InvalidUtf8 {
        /// Overlay file that was being decoded.
        path: PathBuf,
        /// Underlying UTF-8 conversion error.
        source: FromUtf8Error,
    },

    /// Mapping the YAML text to an [`Overlay`] failed.
    ///
    /// Diagnostic code: `haz::dsc::036`.
    #[snafu(display(
        "failed to parse overlay file at {} (name `{name}`)",
        path.display()
    ))]
    Parse {
        /// Overlay file that was being parsed.
        path: PathBuf,
        /// The overlay's name (stem of `path`).
        name: OverlayName,
        /// UTF-8 contents of `path`, retained so callers can
        /// build a [`miette::NamedSource`] without re-reading.
        source_text: String,
        /// Underlying mapper error.
        source: Box<OverlayMapError>,
    },
}

/// All overlay-loading errors from a single
/// [`Discovery::load_overlays`] invocation.
///
/// Following the established pattern of [`crate::projects::load`]:
/// the loader does not short-circuit on the first per-file
/// failure but instead accumulates every per-file failure into
/// this wrapper.
#[derive(Debug, Snafu)]
#[snafu(display("{} overlay-loading error(s)", self.errors.len()))]
pub struct LoadOverlayErrors {
    /// The accumulated errors.
    pub errors: Vec<LoadOverlayError>,
}

impl LoadOverlayErrors {
    /// View the accumulated errors as a slice.
    #[must_use]
    pub fn as_slice(&self) -> &[LoadOverlayError] {
        &self.errors
    }

    /// Number of accumulated errors. Always `>= 1` for a value
    /// produced by [`Discovery::load_overlays`].
    #[must_use]
    pub fn len(&self) -> usize {
        self.errors.len()
    }

    /// Always `false`: a value returned from
    /// [`Discovery::load_overlays`] is non-empty by
    /// construction (an empty error list is represented as the
    /// `Ok` arm).
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.errors.is_empty()
    }
}

impl IntoIterator for LoadOverlayErrors {
    type Item = LoadOverlayError;
    type IntoIter = std::vec::IntoIter<LoadOverlayError>;

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

// Empty Diagnostic impls so callers can construct
// `miette::Report::new(err)` directly. Rich `code`/`labels`/
// `help`/`source_code` belong to the inner `OverlayMapError`
// reached via [`LoadOverlayError::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 LoadOverlayError {}
impl miette::Diagnostic for LoadOverlayErrors {}

impl<F: Filesystem> Discovery<'_, F> {
    /// Scan `<workspace-root>/.haz/tasks/` and load every overlay
    /// file that survives the `CFG-023` filename gate.
    ///
    /// The `projects` argument supplies the workspace's loaded
    /// projects; the matched-projects set of each overlay is
    /// computed against those (`CFG-026` + `DAG-001`).
    ///
    /// # Errors
    ///
    /// Returns a [`LoadOverlayErrors`] containing every per-file
    /// failure if at least one overlay failed at I/O, UTF-8, or
    /// mapping. Silently-ignored entries (wrong extension,
    /// invalid stem, non-file) do NOT contribute to errors.
    /// Missing overlay directory is NOT an error (an empty list
    /// is returned instead).
    pub fn load_overlays(
        &self,
        workspace: &WorkspaceRoot<F>,
        projects: &LoadedProjectList<F>,
    ) -> Result<LoadedOverlayList<F>, LoadOverlayErrors> {
        let overlay_dir = workspace.lexical().as_path().join(".haz").join("tasks");

        let entries = match self.fs.read_dir(&overlay_dir) {
            Ok(entries) => entries,
            Err(FsError::NotFound { .. }) => {
                // No `.haz/tasks/` directory at all: a valid
                // workspace with no overlays.
                return Ok(LoadedOverlayList::new_empty());
            }
            Err(source) => {
                return Err(LoadOverlayErrors {
                    errors: vec![LoadOverlayError::ReadOverlayDir {
                        path: overlay_dir,
                        source,
                    }],
                });
            }
        };

        // Sort entries by path for deterministic processing across
        // filesystems whose `read_dir` ordering is unspecified.
        let mut entries = entries;
        entries.sort_by(|a, b| a.path.cmp(&b.path));

        let mut errors: Vec<LoadOverlayError> = Vec::new();
        let mut loaded: Vec<LoadedOverlay<F>> = Vec::new();

        for entry in entries {
            // CFG-023 filename gate: only regular files with a
            // `.yml` extension and a stem that validates as an
            // OverlayName are eligible. Failures here are silent.
            if entry.metadata.kind != EntryKind::File {
                continue;
            }
            let Some(file_name) = entry.path.file_name().and_then(|n| n.to_str()) else {
                continue;
            };
            let Some(stem_str) = file_name.strip_suffix(".yml") else {
                continue;
            };
            let Ok(name) = OverlayName::from_str(stem_str) else {
                continue;
            };

            // Past the filename gate: failures from here on are
            // accumulated as typed errors.
            let bytes = match self.fs.read(&entry.path) {
                Ok(bytes) => bytes,
                Err(source) => {
                    errors.push(LoadOverlayError::Read {
                        path: entry.path.clone(),
                        source,
                    });
                    continue;
                }
            };

            let source_text = match String::from_utf8(bytes) {
                Ok(text) => text,
                Err(source) => {
                    errors.push(LoadOverlayError::InvalidUtf8 {
                        path: entry.path.clone(),
                        source,
                    });
                    continue;
                }
            };

            let config = match map_overlay_text(&source_text) {
                Ok(config) => config,
                Err(source) => {
                    errors.push(LoadOverlayError::Parse {
                        path: entry.path.clone(),
                        name: name.clone(),
                        source_text,
                        source: Box::new(source),
                    });
                    continue;
                }
            };

            let matched_projects = compute_matched_projects(config.tags_filter.as_ref(), projects);

            loaded.push(LoadedOverlay {
                overlay: Overlay {
                    name,
                    matched_projects,
                    tasks: config.tasks,
                },
                host_path: entry.path,
                source_text,
                _marker: std::marker::PhantomData,
            });
        }

        if errors.is_empty() {
            // Already sorted by path, which is already sorted by
            // stem since both file names share the `.yml`
            // extension. Re-sort by `overlay.name` to make the
            // invariant explicit.
            loaded.sort_by(|a, b| a.overlay.name.cmp(&b.overlay.name));
            Ok(LoadedOverlayList { entries: loaded })
        } else {
            Err(LoadOverlayErrors { errors })
        }
    }
}

/// Compute the set of project names this overlay attaches to
/// per `CFG-026` and `DAG-001`.
///
/// - `tags_filter == None` (absent): every project in the
///   workspace.
/// - `tags_filter == Some(f)`: every project whose tags are a
///   superset of `f`. May produce the empty set when `f`
///   mentions tags that no project carries; this is a valid
///   state, not an error.
fn compute_matched_projects<F: Filesystem>(
    tags_filter: Option<&BTreeSet<haz_domain::name::TagName>>,
    projects: &LoadedProjectList<F>,
) -> BTreeSet<ProjectName> {
    let mut matched: BTreeSet<ProjectName> = BTreeSet::new();
    for lp in projects {
        let project = lp.project();
        let included = match tags_filter {
            None => true,
            Some(filter) => filter.iter().all(|t| project.tags.contains(t)),
        };
        if included {
            matched.insert(project.name.clone());
        }
    }
    matched
}

/// Convert a successful [`LoadedOverlayList`] into the
/// `BTreeMap<OverlayName, Overlay>` shape expected by
/// [`haz_domain::workspace::Workspace::overlays`].
#[must_use]
pub fn overlays_map<F: Filesystem>(list: &LoadedOverlayList<F>) -> BTreeMap<OverlayName, Overlay> {
    let mut out = BTreeMap::new();
    for entry in list.iter() {
        out.insert(entry.overlay.name.clone(), entry.overlay.clone());
    }
    out
}

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

    use indoc::indoc;

    use haz_vfs_testing::MemFilesystem;

    use crate::discovery::Discovery;

    use super::{LoadOverlayError, LoadedOverlayList};

    fn run_overlays(
        fs_setup: impl FnOnce(&mut MemFilesystem),
    ) -> Result<LoadedOverlayList<MemFilesystem>, Vec<LoadOverlayError>> {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.haz").unwrap();
        fs.add_file(
            "/ws/.haz/workspace.yml",
            indoc! {"
                projects:
                  - /ts
                  - /rs
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();
        fs.add_dir("/ws/ts").unwrap();
        fs.add_file(
            "/ws/ts/haz.yml",
            indoc! {"
                name: ts_proj
                tags:
                  - typescript
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();
        fs.add_dir("/ws/rs").unwrap();
        fs.add_file(
            "/ws/rs/haz.yml",
            indoc! {"
                name: rs_proj
                tags:
                  - rust
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();
        fs_setup(&mut fs);

        // Drive the discovery pipeline phase-by-phase so we can
        // call load_overlays directly and observe its result
        // independently of the orchestrator's all-or-nothing
        // wrapping. Using discover_workspace here would surface
        // overlay failures through DiscoveryError::LoadOverlays
        // before we get a chance to inspect them.
        let discovery = Discovery::new(&fs);
        let workspace = discovery
            .find_workspace_root(Path::new("/ws"))
            .expect("workspace root");
        let config = discovery
            .load_workspace_config(workspace.lexical())
            .expect("workspace config");
        let resolved = discovery
            .expand_project_list(&workspace, &config.config.projects)
            .expect("project expansion");
        let projects = discovery
            .load_projects(&workspace, &resolved)
            .expect("project load");
        discovery
            .load_overlays(&workspace, &projects)
            .map_err(|errs| errs.errors)
    }

    #[test]
    fn no_overlays_directory_yields_empty_list() {
        let list = run_overlays(|_| {}).unwrap();
        assert!(list.is_empty());
    }

    #[test]
    fn empty_overlays_directory_yields_empty_list() {
        let list = run_overlays(|fs| {
            fs.add_dir("/ws/.haz/tasks").unwrap();
        })
        .unwrap();
        assert!(list.is_empty());
    }

    #[test]
    fn cfg_026_global_overlay_matches_all_projects() {
        let list = run_overlays(|fs| {
            fs.add_dir("/ws/.haz/tasks").unwrap();
            fs.add_file(
                "/ws/.haz/tasks/lint.yml",
                indoc! {"
                    tasks:
                      lint:
                        command: [echo, hi]
                "}
                .as_bytes()
                .to_vec(),
            )
            .unwrap();
        })
        .unwrap();

        assert_eq!(list.len(), 1);
        let lo = list.iter().next().unwrap();
        assert_eq!(lo.overlay().name.as_ref(), "lint");
        let matched: Vec<&str> = lo
            .overlay()
            .matched_projects
            .iter()
            .map(AsRef::as_ref)
            .collect();
        assert_eq!(matched, vec!["rs_proj", "ts_proj"]);
        assert_eq!(lo.overlay().tasks.len(), 1);
    }

    #[test]
    fn filtered_overlay_matches_only_tagged_projects() {
        let list = run_overlays(|fs| {
            fs.add_dir("/ws/.haz/tasks").unwrap();
            fs.add_file(
                "/ws/.haz/tasks/ts_pipeline.yml",
                indoc! {"
                    tagsFilter:
                      - typescript
                    tasks:
                      compile:
                        command: [tsc]
                "}
                .as_bytes()
                .to_vec(),
            )
            .unwrap();
        })
        .unwrap();

        assert_eq!(list.len(), 1);
        let lo = list.iter().next().unwrap();
        let matched: Vec<&str> = lo
            .overlay()
            .matched_projects
            .iter()
            .map(AsRef::as_ref)
            .collect();
        assert_eq!(matched, vec!["ts_proj"]);
    }

    #[test]
    fn filter_with_unused_tag_yields_empty_matched_set() {
        let list = run_overlays(|fs| {
            fs.add_dir("/ws/.haz/tasks").unwrap();
            fs.add_file(
                "/ws/.haz/tasks/ghost.yml",
                indoc! {"
                    tagsFilter:
                      - ghost
                    tasks:
                      noop:
                        command: [true]
                "}
                .as_bytes()
                .to_vec(),
            )
            .unwrap();
        })
        .unwrap();

        assert_eq!(list.len(), 1);
        let lo = list.iter().next().unwrap();
        assert!(lo.overlay().matched_projects.is_empty());
    }

    #[test]
    fn multiple_overlays_loaded_sorted_by_name() {
        let list = run_overlays(|fs| {
            fs.add_dir("/ws/.haz/tasks").unwrap();
            fs.add_file(
                "/ws/.haz/tasks/zlast.yml",
                indoc! {"
                    tasks:
                      tz:
                        command: [true]
                "}
                .as_bytes()
                .to_vec(),
            )
            .unwrap();
            fs.add_file(
                "/ws/.haz/tasks/afirst.yml",
                indoc! {"
                    tasks:
                      ta:
                        command: [true]
                "}
                .as_bytes()
                .to_vec(),
            )
            .unwrap();
        })
        .unwrap();

        let names: Vec<&str> = list.iter().map(|lo| lo.overlay().name.as_ref()).collect();
        assert_eq!(names, vec!["afirst", "zlast"]);
    }

    // CFG-023 silent-skip cases.

    #[test]
    fn cfg_023_invalid_stem_is_silently_skipped() {
        // Leading `_` violates ID-002; the file must be ignored.
        let list = run_overlays(|fs| {
            fs.add_dir("/ws/.haz/tasks").unwrap();
            fs.add_file(
                "/ws/.haz/tasks/_bad.yml",
                indoc! {"
                    tasks:
                      x:
                        command: [true]
                "}
                .as_bytes()
                .to_vec(),
            )
            .unwrap();
        })
        .unwrap();
        assert!(list.is_empty());
    }

    #[test]
    fn cfg_023_non_yml_extension_is_silently_skipped() {
        let list = run_overlays(|fs| {
            fs.add_dir("/ws/.haz/tasks").unwrap();
            fs.add_file("/ws/.haz/tasks/readme.md", b"hello\n".to_vec())
                .unwrap();
        })
        .unwrap();
        assert!(list.is_empty());
    }

    #[test]
    fn cfg_023_subdirectory_is_silently_skipped() {
        let list = run_overlays(|fs| {
            fs.add_dir("/ws/.haz/tasks").unwrap();
            fs.add_dir("/ws/.haz/tasks/nested").unwrap();
            fs.add_file(
                "/ws/.haz/tasks/nested/inner.yml",
                indoc! {"
                    tasks:
                      x:
                        command: [true]
                "}
                .as_bytes()
                .to_vec(),
            )
            .unwrap();
        })
        .unwrap();
        assert!(list.is_empty());
    }

    // Per-file failures accumulated.

    #[test]
    fn parse_error_in_one_file_does_not_block_others() {
        let errs = run_overlays(|fs| {
            fs.add_dir("/ws/.haz/tasks").unwrap();
            fs.add_file(
                "/ws/.haz/tasks/good.yml",
                indoc! {"
                    tasks:
                      x:
                        command: [true]
                "}
                .as_bytes()
                .to_vec(),
            )
            .unwrap();
            fs.add_file(
                "/ws/.haz/tasks/bad.yml",
                indoc! {"
                    tagsFilter: []
                    tasks: {}
                "}
                .as_bytes()
                .to_vec(),
            )
            .unwrap();
        })
        .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(matches!(errs[0], LoadOverlayError::Parse { .. }));
    }

    #[test]
    fn cfg_001_invalid_utf8_in_overlay_file_is_reported() {
        let errs = run_overlays(|fs| {
            fs.add_dir("/ws/.haz/tasks").unwrap();
            fs.add_file("/ws/.haz/tasks/bad.yml", vec![0xff, 0xfe, 0xfd])
                .unwrap();
        })
        .unwrap_err();
        assert_eq!(errs.len(), 1);
        assert!(matches!(errs[0], LoadOverlayError::InvalidUtf8 { .. }));
    }
}
