//! Workspace-internal projection: strip the workspace prefix from
//! a host-canonical path and validate each remaining component as
//! a [`PathSegment`], yielding a [`DomainCanonicalPath`].
//!
//! The two host-canonical inputs are typed as
//! [`F::CanonicalPath`](haz_vfs::Filesystem::CanonicalPath) and so
//! must come from the same filesystem implementation. The
//! resulting [`DomainCanonicalPath`] carries the workspace-internal
//! invariant: a [`NonEmpty<PathSegment>`](nonempty::NonEmpty)
//! anchored at the workspace root, suitable for stable internal
//! keys (cache, diagnostics).
//!
//! [`DomainCanonicalPath`]: haz_domain::path::CanonicalPath
//! [`PathSegment`]: haz_domain::path::PathSegment

use std::path::{Component, Path, PathBuf};

use haz_domain::path::{CanonicalPath as DomainCanonicalPath, HazPath, PathSegment, SegmentError};
use haz_vfs::Filesystem;
use nonempty::NonEmpty;
use snafu::Snafu;

use crate::discovery::Discovery;

/// Errors produced by
/// [`Discovery::project_into_workspace`].
///
/// Diagnostic codes follow the `haz::dsc::NNN` namespace and are
/// recorded in the variant docstrings.
#[derive(Debug, Snafu)]
pub enum ProjectionError {
    /// The supplied `host` path is not a descendant of `boundary`.
    /// Callers obtained their host-canonical values from the same
    /// filesystem implementation but the prefix relationship does
    /// not hold; this is a precondition violation, not a
    /// filesystem-level failure.
    ///
    /// Diagnostic code: `haz::dsc::012`.
    #[snafu(display(
        "host path {} is not a descendant of workspace boundary {}",
        host.display(),
        boundary.display()
    ))]
    NotWithinWorkspace {
        /// The host-canonical path being projected.
        host: PathBuf,
        /// The workspace-root boundary used for the projection.
        boundary: PathBuf,
    },

    /// The supplied `host` path is byte-equal to `boundary`. The
    /// workspace root itself has no workspace-internal
    /// representation as a [`DomainCanonicalPath`] (which carries a
    /// non-empty segment list); callers asking for the workspace
    /// root must handle this case explicitly.
    ///
    /// Diagnostic code: `haz::dsc::013`.
    #[snafu(display(
        "host path equals workspace boundary {}",
        boundary.display()
    ))]
    IsWorkspaceRoot {
        /// The boundary path that `host` equalled.
        boundary: PathBuf,
    },

    /// A component of the post-prefix-strip remainder failed
    /// validation as a [`PathSegment`] (`PATH-002`). Surfaces both
    /// rule violations (`.`, `..`, separator, forbidden Unicode
    /// category) and unexpected non-`Normal` components in the
    /// canonical bytes.
    ///
    /// Diagnostic code: `haz::dsc::014`.
    #[snafu(display(
        "component {} is not a valid path segment: {source}",
        component.display()
    ))]
    InvalidSegment {
        /// The offending component.
        component: PathBuf,
        /// The segment-level rejection reason.
        source: SegmentError,
    },

    /// A component of the post-prefix-strip remainder is not
    /// valid UTF-8. The workspace-internal [`PathSegment`] type
    /// requires UTF-8; host filesystems on platforms that allow
    /// arbitrary bytes in filenames may yield components that
    /// cannot be projected.
    ///
    /// Diagnostic code: `haz::dsc::015`.
    #[snafu(display(
        "component {} is not valid UTF-8",
        component.display()
    ))]
    NonUtf8Component {
        /// The offending component.
        component: PathBuf,
    },
}

impl<F: Filesystem> Discovery<'_, F> {
    /// Project a host-canonical path into workspace-internal
    /// coordinates.
    ///
    /// Both inputs are
    /// [`F::CanonicalPath`](haz_vfs::Filesystem::CanonicalPath) and
    /// therefore originate from the same filesystem
    /// implementation. The projection strips `boundary` as a
    /// byte-prefix of `host`, then validates each component of
    /// the remainder as a [`PathSegment`].
    ///
    /// The boundary check is a precondition: callers should
    /// typically pair this method with
    /// [`Self::verify_within`] (which establishes the prefix
    /// relationship) or [`Self::verify_haz_path`].
    ///
    /// # Errors
    ///
    /// - [`ProjectionError::NotWithinWorkspace`] when `host` does
    ///   not start with `boundary`.
    /// - [`ProjectionError::IsWorkspaceRoot`] when `host` is
    ///   byte-equal to `boundary` (no segments remain).
    /// - [`ProjectionError::NonUtf8Component`] when a remainder
    ///   component is not valid UTF-8.
    /// - [`ProjectionError::InvalidSegment`] when a remainder
    ///   component fails [`PathSegment`] validation.
    ///
    /// # Panics
    ///
    /// Does not panic on any reachable input: an internal
    /// invariant assertion exists (the remainder is non-empty
    /// after the `strip_prefix` success and the equality-with-
    /// boundary check have ruled out the degenerate cases) but is
    /// structurally unreachable.
    ///
    /// [`PathSegment`]: haz_domain::path::PathSegment
    pub fn project_into_workspace(
        &self,
        host: &F::CanonicalPath,
        boundary: &F::CanonicalPath,
    ) -> Result<DomainCanonicalPath, ProjectionError> {
        let host_path: &Path = host.as_ref();
        let boundary_path: &Path = boundary.as_ref();

        let remainder = host_path.strip_prefix(boundary_path).map_err(|_| {
            ProjectionError::NotWithinWorkspace {
                host: host_path.to_path_buf(),
                boundary: boundary_path.to_path_buf(),
            }
        })?;

        if remainder.as_os_str().is_empty() {
            return Err(ProjectionError::IsWorkspaceRoot {
                boundary: boundary_path.to_path_buf(),
            });
        }

        let mut segments: Vec<PathSegment> = Vec::new();
        for component in remainder.components() {
            let component_path: PathBuf = Path::new(component.as_os_str()).to_path_buf();
            let Component::Normal(raw) = component else {
                return Err(ProjectionError::InvalidSegment {
                    component: component_path,
                    source: SegmentError::ContainsSeparator,
                });
            };
            let s = raw
                .to_str()
                .ok_or_else(|| ProjectionError::NonUtf8Component {
                    component: component_path.clone(),
                })?;
            let segment = PathSegment::try_new(s.to_owned()).map_err(|source| {
                ProjectionError::InvalidSegment {
                    component: component_path,
                    source,
                }
            })?;
            segments.push(segment);
        }

        let nonempty = NonEmpty::from_vec(segments)
            .expect("remainder was non-empty after strip_prefix and emptiness check");
        let hp = HazPath::WorkspaceAbsolute(nonempty);
        Ok(DomainCanonicalPath::from_absolute(&hp)
            .expect("HazPath::WorkspaceAbsolute always projects to a CanonicalPath"))
    }
}

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

    use haz_vfs::Filesystem;
    use haz_vfs_testing::MemFilesystem;

    use crate::discovery::Discovery;

    use super::ProjectionError;

    #[test]
    fn projects_single_segment_descendant() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core").unwrap();
        let host = fs.canonicalize(Path::new("/ws/lib_core")).unwrap();
        let boundary = fs.canonicalize(Path::new("/ws")).unwrap();
        let proj = Discovery::new(&fs)
            .project_into_workspace(&host, &boundary)
            .unwrap();
        assert_eq!(proj.to_string(), "/lib_core");
    }

    #[test]
    fn projects_multi_segment_descendant() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core/src").unwrap();
        fs.add_file("/ws/lib_core/src/main.rs", b"".to_vec())
            .unwrap();
        let host = fs
            .canonicalize(Path::new("/ws/lib_core/src/main.rs"))
            .unwrap();
        let boundary = fs.canonicalize(Path::new("/ws")).unwrap();
        let proj = Discovery::new(&fs)
            .project_into_workspace(&host, &boundary)
            .unwrap();
        assert_eq!(proj.to_string(), "/lib_core/src/main.rs");
    }

    #[test]
    fn rejects_path_not_within_boundary() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/elsewhere").unwrap();
        let host = fs.canonicalize(Path::new("/elsewhere")).unwrap();
        let boundary = fs.canonicalize(Path::new("/ws")).unwrap();
        let err = Discovery::new(&fs)
            .project_into_workspace(&host, &boundary)
            .unwrap_err();
        assert!(matches!(err, ProjectionError::NotWithinWorkspace { .. }));
    }

    #[test]
    fn rejects_host_equal_to_boundary() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        let boundary = fs.canonicalize(Path::new("/ws")).unwrap();
        let host = boundary.clone();
        let err = Discovery::new(&fs)
            .project_into_workspace(&host, &boundary)
            .unwrap_err();
        assert!(matches!(err, ProjectionError::IsWorkspaceRoot { .. }));
    }
}
