//! Boundary-checked host canonicalisation and the typed
//! [`HazPath`]-valued entry point.
//!
//! Implements rule `DISC-022` (workspace boundary) and rule
//! `DISC-023` (project boundary) by composing
//! [`Filesystem::canonicalize`] with a containment check on the
//! per-impl [`F::CanonicalPath`](Filesystem::CanonicalPath).
//!
//! [`HazPath`]: haz_domain::path::HazPath

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

use haz_domain::path::{CanonicalPath as DomainCanonicalPath, HazPath};
use haz_vfs::{Filesystem, FsError};
use snafu::Snafu;

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

/// Errors produced by
/// [`Discovery::verify_within`] and
/// [`Discovery::verify_haz_path`].
///
/// Diagnostic codes follow the `haz::dsc::NNN` namespace; they are
/// kept in the variant docstrings so callers can route on them
/// without round-tripping through [`Display`](std::fmt::Display)
/// text.
#[derive(Debug, Snafu)]
pub enum ResolveError {
    /// The target path supplied to
    /// [`Discovery::verify_within`] is not absolute. Discovery
    /// rejects relative inputs rather than canonicalising them
    /// against the current working directory: process-global state
    /// has no place inside the discovery layer.
    ///
    /// Diagnostic code: `haz::dsc::008`.
    #[snafu(display(
        "expected absolute target path, got: {}",
        path.display()
    ))]
    RelativeTarget {
        /// The relative path that was rejected.
        path: PathBuf,
    },

    /// The target path (or some component of it) does not exist on
    /// the host filesystem. Produced when
    /// [`Filesystem::canonicalize`] returns [`FsError::NotFound`].
    ///
    /// Diagnostic code: `haz::dsc::009`.
    #[snafu(display("path not found: {}", path.display()))]
    NotFound {
        /// Path being canonicalised when not-found was reported.
        path: PathBuf,
    },

    /// Canonicalisation succeeded but the resulting host-canonical
    /// path is not a descendant of `boundary`. This is how
    /// `DISC-022` (workspace boundary) and `DISC-023` (project
    /// boundary) violations surface, including the case of a
    /// symlink whose target escapes the boundary (symlinks are
    /// followed by [`Filesystem::canonicalize`], so an escape
    /// shows up here rather than as a separate symlink error).
    ///
    /// Diagnostic code: `haz::dsc::010`.
    #[snafu(display(
        "canonical path {} escapes boundary {}",
        canonical.display(),
        boundary.display()
    ))]
    OutsideBoundary {
        /// The original target path (pre-canonicalisation), for
        /// diagnostic context.
        target: PathBuf,
        /// The canonical path produced by
        /// [`Filesystem::canonicalize`].
        canonical: PathBuf,
        /// The boundary the canonical path was expected to be
        /// inside.
        boundary: PathBuf,
    },

    /// [`Filesystem::canonicalize`] returned an error other than
    /// [`FsError::NotFound`] (which has its own variant). Common
    /// sources: [`FsError::SymlinkLoop`], [`FsError::Io`],
    /// [`FsError::UnknownEntryKind`].
    ///
    /// Diagnostic code: `haz::dsc::011`.
    #[snafu(display(
        "filesystem error while canonicalising {}: {source}",
        path.display()
    ))]
    CanonicalizeFailed {
        /// Path being canonicalised when the error surfaced.
        path: PathBuf,
        /// Underlying filesystem error.
        source: FsError,
    },
}

impl<F: Filesystem> Discovery<'_, F> {
    /// Verify that `target` canonicalises to a path within
    /// `boundary`, returning the host-canonical form.
    ///
    /// The boundary check is `canonical.as_ref().starts_with(
    /// boundary.as_ref())`. A `target` whose canonical form is
    /// byte-equal to the boundary passes (the boundary is a
    /// descendant of itself).
    ///
    /// Symlink resolution is delegated to
    /// [`Filesystem::canonicalize`] (`DISC-020`,
    /// `DISC-021`). A symlink whose target escapes the boundary
    /// surfaces as [`ResolveError::OutsideBoundary`] on the
    /// canonical result.
    ///
    /// # Errors
    ///
    /// - [`ResolveError::RelativeTarget`] when `target` is not
    ///   absolute.
    /// - [`ResolveError::NotFound`] when `target` or one of its
    ///   ancestors does not exist.
    /// - [`ResolveError::OutsideBoundary`] when the canonical
    ///   path is not a descendant of `boundary`.
    /// - [`ResolveError::CanonicalizeFailed`] for any other
    ///   [`FsError`] surfaced by
    ///   [`Filesystem::canonicalize`].
    pub fn verify_within(
        &self,
        target: &Path,
        boundary: &F::CanonicalPath,
    ) -> Result<F::CanonicalPath, ResolveError> {
        if !target.is_absolute() {
            return Err(ResolveError::RelativeTarget {
                path: target.to_path_buf(),
            });
        }

        let canonical = self
            .fs
            .canonicalize(target)
            .map_err(|source| match source {
                FsError::NotFound { path } => ResolveError::NotFound { path },
                other => ResolveError::CanonicalizeFailed {
                    path: target.to_path_buf(),
                    source: other,
                },
            })?;

        if !canonical.as_ref().starts_with(boundary.as_ref()) {
            return Err(ResolveError::OutsideBoundary {
                target: target.to_path_buf(),
                canonical: canonical.as_ref().to_path_buf(),
                boundary: boundary.as_ref().to_path_buf(),
            });
        }

        Ok(canonical)
    }

    /// Verify that `path` resolves to a host location inside the
    /// supplied `workspace`, returning the host-canonical form.
    ///
    /// The pipeline is: resolve `path` against
    /// `bearing_project_root` via
    /// [`HazPath::resolve`] to obtain the workspace-internal
    /// canonical form; join the resulting segments onto the host
    /// workspace root carried by `workspace`; delegate to
    /// [`Self::verify_within`].
    ///
    /// `bearing_project_root` is required even when `path` is
    /// [`HazPath::WorkspaceAbsolute`] (in which case
    /// [`HazPath::resolve`] ignores it). Forcing the value to
    /// flow through the call removes an ambiguous
    /// "absent bearing root" state from the API; the caller in
    /// practice always has a bearing project (the project that
    /// owns the task or pattern declaration).
    ///
    /// # Errors
    ///
    /// Surfaces the [`ResolveError`] variants produced by
    /// [`Self::verify_within`] on the joined host path.
    pub fn verify_haz_path(
        &self,
        path: &HazPath,
        bearing_project_root: &DomainCanonicalPath,
        workspace: &WorkspaceRoot<F>,
    ) -> Result<F::CanonicalPath, ResolveError> {
        let internal = path.resolve(bearing_project_root);
        let mut host = workspace.canonical().as_ref().to_path_buf();
        for seg in internal.segments().iter() {
            host.push(seg.as_str());
        }
        self.verify_within(&host, workspace.canonical())
    }
}

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

    use haz_domain::path::{CanonicalPath as DomainCanonicalPath, HazPath, WorkspaceRootPath};
    use haz_vfs::Filesystem;
    use haz_vfs_testing::MemFilesystem;

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

    use super::ResolveError;

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

    // verify_within

    #[test]
    fn verify_within_rejects_relative_target() {
        let fs = MemFilesystem::new();
        let boundary = fs.canonicalize(Path::new("/")).unwrap();
        let err = Discovery::new(&fs)
            .verify_within(Path::new("relative/path"), &boundary)
            .unwrap_err();
        assert!(matches!(err, ResolveError::RelativeTarget { .. }));
    }

    #[test]
    fn verify_within_returns_not_found_for_missing_target() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        let boundary = fs.canonicalize(Path::new("/ws")).unwrap();
        let err = Discovery::new(&fs)
            .verify_within(Path::new("/ws/missing.txt"), &boundary)
            .unwrap_err();
        assert!(matches!(err, ResolveError::NotFound { .. }));
    }

    #[test]
    fn verify_within_accepts_strict_descendant() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib").unwrap();
        fs.add_file("/ws/lib/main.rs", b"".to_vec()).unwrap();
        let boundary = fs.canonicalize(Path::new("/ws")).unwrap();
        let canon = Discovery::new(&fs)
            .verify_within(Path::new("/ws/lib/main.rs"), &boundary)
            .unwrap();
        assert_eq!(canon.as_path(), Path::new("/ws/lib/main.rs"));
    }

    #[test]
    fn verify_within_accepts_target_equal_to_boundary() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        let boundary = fs.canonicalize(Path::new("/ws")).unwrap();
        let canon = Discovery::new(&fs)
            .verify_within(Path::new("/ws"), &boundary)
            .unwrap();
        assert_eq!(canon.as_path(), Path::new("/ws"));
    }

    #[test]
    fn verify_within_rejects_path_outside_boundary() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/elsewhere").unwrap();
        fs.add_file("/elsewhere/data.txt", b"".to_vec()).unwrap();
        let boundary = fs.canonicalize(Path::new("/ws")).unwrap();
        let err = Discovery::new(&fs)
            .verify_within(Path::new("/elsewhere/data.txt"), &boundary)
            .unwrap_err();
        assert!(matches!(err, ResolveError::OutsideBoundary { .. }));
    }

    #[test]
    fn verify_within_rejects_symlink_target_outside_boundary() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/elsewhere").unwrap();
        fs.add_file("/elsewhere/secret", b"".to_vec()).unwrap();
        fs.add_symlink("/ws/link", "/elsewhere/secret").unwrap();
        let boundary = fs.canonicalize(Path::new("/ws")).unwrap();
        let err = Discovery::new(&fs)
            .verify_within(Path::new("/ws/link"), &boundary)
            .unwrap_err();
        assert!(matches!(err, ResolveError::OutsideBoundary { .. }));
    }

    #[test]
    fn verify_within_follows_symlink_inside_boundary() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib").unwrap();
        fs.add_file("/ws/lib/main.rs", b"".to_vec()).unwrap();
        fs.add_symlink("/ws/link", "/ws/lib/main.rs").unwrap();
        let boundary = fs.canonicalize(Path::new("/ws")).unwrap();
        let canon = Discovery::new(&fs)
            .verify_within(Path::new("/ws/link"), &boundary)
            .unwrap();
        assert_eq!(canon.as_path(), Path::new("/ws/lib/main.rs"));
    }

    // verify_haz_path

    fn project_root(haz_abs: &str) -> DomainCanonicalPath {
        let hp = HazPath::parse(haz_abs).unwrap();
        DomainCanonicalPath::from_absolute(&hp).expect("workspace-absolute")
    }

    #[test]
    fn verify_haz_path_resolves_workspace_absolute() {
        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 ws = workspace(&fs, "/ws");
        let prj = project_root("/other_project");
        let hp = HazPath::parse("/lib_core/src/main.rs").unwrap();
        let canon = Discovery::new(&fs).verify_haz_path(&hp, &prj, &ws).unwrap();
        assert_eq!(canon.as_path(), Path::new("/ws/lib_core/src/main.rs"));
    }

    #[test]
    fn verify_haz_path_resolves_project_relative() {
        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 ws = workspace(&fs, "/ws");
        let prj = project_root("/lib_core");
        let hp = HazPath::parse("src/main.rs").unwrap();
        let canon = Discovery::new(&fs).verify_haz_path(&hp, &prj, &ws).unwrap();
        assert_eq!(canon.as_path(), Path::new("/ws/lib_core/src/main.rs"));
    }

    #[test]
    fn verify_haz_path_returns_not_found_for_missing_target() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core/src").unwrap();
        let ws = workspace(&fs, "/ws");
        let prj = project_root("/lib_core");
        let hp = HazPath::parse("src/missing.rs").unwrap();
        let err = Discovery::new(&fs)
            .verify_haz_path(&hp, &prj, &ws)
            .unwrap_err();
        assert!(matches!(err, ResolveError::NotFound { .. }));
    }

    #[test]
    fn verify_haz_path_rejects_symlink_escape_workspace_absolute() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/elsewhere").unwrap();
        fs.add_file("/elsewhere/secret", b"".to_vec()).unwrap();
        fs.add_symlink("/ws/leak", "/elsewhere/secret").unwrap();
        let ws = workspace(&fs, "/ws");
        let prj = project_root("/leak");
        let hp = HazPath::parse("/leak").unwrap();
        let err = Discovery::new(&fs)
            .verify_haz_path(&hp, &prj, &ws)
            .unwrap_err();
        assert!(matches!(err, ResolveError::OutsideBoundary { .. }));
    }

    #[test]
    fn disc_023_verify_haz_path_rejects_symlink_escape_project_relative() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws/lib_core").unwrap();
        fs.add_dir("/elsewhere").unwrap();
        fs.add_file("/elsewhere/secret", b"".to_vec()).unwrap();
        fs.add_symlink("/ws/lib_core/leak", "/elsewhere/secret")
            .unwrap();
        let ws = workspace(&fs, "/ws");
        let prj = project_root("/lib_core");
        let hp = HazPath::parse("leak").unwrap();
        let err = Discovery::new(&fs)
            .verify_haz_path(&hp, &prj, &ws)
            .unwrap_err();
        assert!(matches!(err, ResolveError::OutsideBoundary { .. }));
    }
}
