//! Lexical path normalization.
//!
//! A [`Normalizer`] resolves paths relative to an origin base and returns a
//! [`NormalizedPath`] with both absolute and root-relative representations.

use core::fmt;
use std::{
    env,
    path::{Component, Path, PathBuf},
};

/// A normalized path with two equivalent representations.
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub struct NormalizedPath {
    /// Absolute normalized path, suitable for filesystem access.
    pub absolute: PathBuf,
    /// Normalized path made relative to the configured root.
    pub root_relative: PathBuf,
}

/// Errors that can occur while normalizing paths.
#[derive(Debug)]
pub enum NormalizeError {
    /// The input path was empty.
    EmptyInput,
    /// Normalization would escape above the filesystem root.
    EscapesFilesystemRoot(PathBuf),
    /// Failed to read the current working directory.
    FailedToGetCurDir,
    /// The path contained an invalid additional platform-specific prefix.
    InvalidMultiplePrefix(PathBuf),
}

impl fmt::Display for NormalizeError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EmptyInput => {
                write!(f, "Encountered an empty filename")
            }
            Self::EscapesFilesystemRoot(path) => {
                write!(f, "Path escapes filesystem root: {:?}", path)
            }
            Self::FailedToGetCurDir => {
                write!(f, "Failed to get current directory")
            }
            Self::InvalidMultiplePrefix(path) => {
                write!(f, "Path invalid multiple prefixes: {:?}", path)
            }
        }
    }
}

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

/// Resolves and normalizes paths relative to a root and origin base.
pub struct Normalizer {
    root: PathBuf,
    origin_base: PathBuf,
}

impl Normalizer {
    /// Creates a normalizer.
    ///
    /// Relative `root` and `origin_base` values are resolved against the current directory.
    pub fn new(root: &Path, origin_base: &Path) -> Result<Self, NormalizeError> {
        let cwd = env::current_dir().map_err(|_| NormalizeError::FailedToGetCurDir)?;
        Self::new_with_cwd(root, origin_base, &cwd)
    }

    fn new_with_cwd(root: &Path, origin_base: &Path, cwd: &Path) -> Result<Self, NormalizeError> {
        Ok(Self {
            root: normalize_components(&absolutize(root, cwd))?,
            origin_base: normalize_components(&absolutize(origin_base, cwd))?,
        })
    }

    /// Resolves and normalizes `input`.
    ///
    /// Relative inputs are resolved against this normalizer's origin base.
    pub fn normalize(&self, input: &Path) -> Result<NormalizedPath, NormalizeError> {
        if input.as_os_str().is_empty() {
            return Err(NormalizeError::EmptyInput);
        }
        let input = absolutize(input, &self.origin_base);
        let normalized_input = normalize_components(&input)?;
        Ok(NormalizedPath {
            root_relative: make_relative_to_root(&normalized_input, &self.root),
            absolute: normalized_input,
        })
    }
}

fn absolutize(path: &Path, base: &Path) -> PathBuf {
    if path.is_absolute() {
        path.to_path_buf()
    } else {
        base.join(path)
    }
}

/// # Invariant
/// `path` must be an absolute path.
/// Violations indicate a bug in the caller.
fn normalize_components(path: &Path) -> Result<PathBuf, NormalizeError> {
    debug_assert!(
        path.is_absolute(),
        "Input must be an absolute path: {:?}",
        path
    );
    let mut normalized = PathBuf::new();
    let mut iter = path.components().peekable();

    // Find the root, if any, and add it to the lexical path.
    // Here we treat the Windows path "C:\" as a single "root" even though
    // `components` splits it into two: (Prefix, RootDir).
    let root = match iter.peek() {
        Some(p @ Component::RootDir) => {
            normalized.push(p);
            iter.next();
            normalized.as_os_str().len()
        }
        Some(Component::Prefix(prefix)) => {
            normalized.push(prefix.as_os_str());
            iter.next();
            if let Some(p @ Component::RootDir) = iter.peek() {
                normalized.push(p);
                iter.next();
            }
            normalized.as_os_str().len()
        }
        _ => unreachable!(
            "normalize_components received a non-absolute path: {:?}",
            path
        ),
    };

    for component in iter {
        match component {
            Component::RootDir => unreachable!(),
            Component::Prefix(_) => {
                return Err(NormalizeError::InvalidMultiplePrefix(path.to_path_buf()));
            }
            Component::CurDir => continue,
            Component::ParentDir => {
                // It's an error if ParentDir causes us to go above the "root".
                if normalized.as_os_str().len() == root {
                    return Err(NormalizeError::EscapesFilesystemRoot(path.to_path_buf()));
                } else {
                    normalized.pop();
                }
            }
            Component::Normal(path) => normalized.push(path),
        }
    }
    Ok(normalized)
}

/// # Invariant
/// `target` and `root` must be absolute paths.
/// Violations indicate a bug in the caller.
fn make_relative_to_root(target: &Path, mut root: &Path) -> PathBuf {
    debug_assert!(
        target.is_absolute(),
        "Target must be an absolute path: {:?}",
        target
    );
    debug_assert!(
        root.is_absolute(),
        "Root must be an absolute path: {:?}",
        root
    );
    let mut upward = PathBuf::new();
    loop {
        if let Ok(suffix) = target.strip_prefix(root) {
            return upward.join(suffix);
        }
        if let Some(new_root) = root.parent() {
            upward.push("..");
            root = new_root;
        } else {
            return target.to_path_buf();
        }
    }
}

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

    use super::{NormalizeError, Normalizer};

    #[test]
    fn empty_path_returns_error() {
        let fake_cwd = Path::new("/sandbox");
        let normalizer = Normalizer::new_with_cwd(Path::new(""), Path::new(""), fake_cwd).unwrap();
        let result = normalizer.normalize(Path::new(""));
        assert!(matches!(result, Err(NormalizeError::EmptyInput)));
    }

    #[test]
    fn plain_filename_with_root_at_cwd_returns_filename() {
        let fake_cwd = Path::new("/sandbox");
        let normalizer = Normalizer::new_with_cwd(Path::new(""), Path::new(""), fake_cwd).unwrap();
        let result = normalizer.normalize(Path::new("main.rs"));
        assert_eq!(result.unwrap().root_relative, Path::new("main.rs"));
    }

    #[test]
    fn relative_path_from_origin_is_resolved() {
        let fake_cwd = Path::new("/sandbox");
        let root = Path::new("");
        let origin_base = Path::new("src");
        let normalizer = Normalizer::new_with_cwd(root, origin_base, fake_cwd).unwrap();
        let result = normalizer.normalize(Path::new("../main.rs"));
        assert_eq!(result.unwrap().root_relative, Path::new("main.rs"));
    }

    #[test]
    fn path_is_made_relative_to_root() {
        let fake_cwd = Path::new("/sandbox");
        let root = Path::new("/project");
        let origin_base = Path::new("/project/src");
        let normalizer = Normalizer::new_with_cwd(root, origin_base, fake_cwd).unwrap();
        let result = normalizer.normalize(Path::new("main.rs"));
        assert_eq!(result.unwrap().root_relative, Path::new("src/main.rs"));
    }

    #[test]
    fn path_is_made_relative_to_root_from_outside() {
        let fake_cwd = Path::new("/sandbox");
        let root = Path::new("/project");
        let origin_base = Path::new("/outside");
        let normalizer = Normalizer::new_with_cwd(root, origin_base, fake_cwd).unwrap();
        let result = normalizer.normalize(Path::new("main.rs"));
        assert_eq!(
            result.unwrap().root_relative,
            Path::new("../outside/main.rs")
        );
    }

    #[test]
    fn path_is_made_relative_to_root_even_if_root_dir_is_relative() {
        let fake_cwd = Path::new("/sandbox");
        let root = Path::new("project");
        let origin_base = Path::new("/sandbox/outside");
        let normalizer = Normalizer::new_with_cwd(root, origin_base, fake_cwd).unwrap();
        let result = normalizer.normalize(Path::new("main.rs"));
        assert_eq!(
            result.unwrap().root_relative,
            Path::new("../outside/main.rs")
        );
    }

    #[test]
    fn absolute_inputs_work() {
        let fake_cwd = Path::new("/sandbox");
        let root = Path::new("project");
        let origin_base = Path::new("/sandbox/outside");
        let normalizer = Normalizer::new_with_cwd(root, origin_base, fake_cwd).unwrap();
        let result = normalizer.normalize(Path::new("/sandbox/main.rs"));
        assert_eq!(result.unwrap().root_relative, Path::new("../main.rs"));
    }

    #[test]
    fn path_is_made_relative_to_root_even_if_origin_base_is_relative() {
        let fake_cwd = Path::new("/sandbox");
        let root = Path::new("/sandbox/project");
        let origin_base = Path::new("outside");
        let normalizer = Normalizer::new_with_cwd(root, origin_base, fake_cwd).unwrap();
        let result = normalizer.normalize(Path::new("main.rs"));
        assert_eq!(
            result.unwrap().root_relative,
            Path::new("../outside/main.rs")
        );
    }

    #[test]
    fn input_cannot_go_above_root() {
        let fake_cwd = Path::new("/sandbox");
        let root = Path::new("project");
        let origin_base = Path::new("outside");
        let normalizer = Normalizer::new_with_cwd(root, origin_base, fake_cwd).unwrap();
        let result = normalizer.normalize(Path::new("../../../main.rs"));
        assert!(matches!(
            result,
            Err(NormalizeError::EscapesFilesystemRoot(_))
        ));
    }

    #[test]
    fn should_handle_unnormalized_root() {
        let fake_cwd = Path::new("/sandbox");
        let root = Path::new("../project");
        let origin_base = Path::new("outside");
        let normalizer = Normalizer::new_with_cwd(root, origin_base, fake_cwd).unwrap();
        let result = normalizer.normalize(Path::new("main.rs"));
        assert_eq!(
            result.unwrap().root_relative,
            Path::new("../sandbox/outside/main.rs")
        );
    }
}
