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

use eyre::WrapErr;

fn canonicalize_base_dir(base_dir: &Path) -> eyre::Result<PathBuf> {
    if !base_dir.is_absolute() {
        eyre::bail!("Base directory must be absolute: {}", base_dir.display());
    }

    let canonical = std::fs::canonicalize(base_dir).unwrap_or_else(|_| base_dir.to_path_buf());
    Ok(canonical)
}

fn sanitize_relative_path(relative_path: &str) -> eyre::Result<PathBuf> {
    if relative_path.is_empty() {
        eyre::bail!("Relative path cannot be empty");
    }

    if relative_path.contains('\0') {
        eyre::bail!("Path contains null byte");
    }

    // Normalise Windows separators so that component checks work uniformly.
    let normalised = relative_path.replace('\\', "/");

    if normalised.starts_with("//") {
        eyre::bail!("UNC paths are not allowed: {relative_path}");
    }

    if normalised.contains(":/") {
        eyre::bail!("Path contains drive letter: {relative_path}");
    }

    let path = PathBuf::from(&normalised);
    if path.is_absolute() {
        eyre::bail!("Path must be relative, not absolute: {relative_path}");
    }

    Ok(path)
}

/// Validates and sanitizes a relative path to prevent directory traversal attacks.
///
/// # Errors
/// Returns an error if the path attempts to escape the base directory or contains invalid components.
pub fn validate_relative_path(base_dir: &Path, relative_path: &str) -> eyre::Result<PathBuf> {
    let canonical_base = canonicalize_base_dir(base_dir)?;
    let relative = sanitize_relative_path(relative_path)?;

    let mut resolved = canonical_base.clone();

    for component in relative.components() {
        match component {
            Component::Prefix(_) | Component::RootDir => {
                eyre::bail!("Path is not relative: {relative_path}");
            }
            Component::ParentDir => {
                eyre::bail!("Path attempts to escape base directory: {relative_path}");
            }
            Component::CurDir => {}
            Component::Normal(part) => {
                resolved.push(part);

                if let Ok(metadata) = std::fs::symlink_metadata(&resolved)
                    && metadata.file_type().is_symlink()
                {
                    let target = std::fs::canonicalize(&resolved).wrap_err_with(|| {
                        format!("Failed to canonicalize symlink {}", resolved.display())
                    })?;

                    if !target.starts_with(&canonical_base) {
                        eyre::bail!(
                            "Path validation failed: {relative_path} escapes the base directory"
                        );
                    }

                    resolved = target;
                }
            }
        }
    }

    if !resolved.starts_with(&canonical_base) {
        eyre::bail!("Path validation failed: {relative_path} escapes the base directory");
    }

    Ok(resolved)
}

/// Validates a relative path that will be used for accessing files within a game directory.
/// This is a stricter validation that ensures the path is relative and doesn't escape.
///
/// # Errors
/// Returns an error if the path is absolute or attempts directory traversal.
pub fn validate_game_file_path(game_dir: &Path, relative_path: &str) -> eyre::Result<PathBuf> {
    validate_relative_path(game_dir, relative_path)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn create_temp_dir() -> std::io::Result<std::path::PathBuf> {
        let mut dir = std::env::temp_dir();
        let unique = format!(
            "lanspread_test_{}_{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_nanos()
        );
        dir.push(unique);
        std::fs::create_dir_all(&dir)?;
        Ok(dir)
    }

    struct TempDir(std::path::PathBuf);

    impl TempDir {
        fn new() -> std::io::Result<Self> {
            let path = create_temp_dir()?;
            Ok(TempDir(path))
        }

        fn path(&self) -> &std::path::Path {
            &self.0
        }
    }

    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    #[test]
    fn test_valid_paths() {
        let temp_dir = TempDir::new().expect("Failed to create temp dir for test");
        let base = temp_dir.path();

        // Valid relative paths
        assert!(validate_game_file_path(base, "file.txt").is_ok());
        assert!(validate_game_file_path(base, "subdir/file.txt").is_ok());
        assert!(validate_game_file_path(base, "subdir/deep/nested/file.txt").is_ok());
        assert!(validate_game_file_path(base, "file with spaces.txt").is_ok());
        assert!(validate_game_file_path(base, "./dot/./path.txt").is_ok());

        #[allow(clippy::unwrap_used)]
        let windows_style = validate_game_file_path(base, "mix\\windows\\path.txt").unwrap();
        assert_eq!(
            windows_style,
            base.join("mix").join("windows").join("path.txt")
        );
    }

    #[test]
    fn test_traversal_attempts() {
        let temp_dir = TempDir::new().expect("Failed to create temp dir for test");
        let base = temp_dir.path();

        // These should all fail
        assert!(validate_game_file_path(base, "../outside.txt").is_err());
        assert!(validate_game_file_path(base, "subdir/../../outside.txt").is_err());
        assert!(validate_game_file_path(base, "../../etc/passwd").is_err());
    }

    #[test]
    fn test_double_dot_in_filename_allowed() {
        let temp_dir = TempDir::new().expect("Failed to create temp dir for test");
        let base = temp_dir.path();

        assert!(validate_game_file_path(base, "data/file..txt").is_ok());
    }

    #[test]
    fn test_missing_file_stays_within_base() {
        let temp_dir = TempDir::new().expect("Failed to create temp dir for test");
        let base = temp_dir.path();

        #[allow(clippy::unwrap_used)]
        let resolved = validate_game_file_path(base, "new_dir/new_file.bin").unwrap();
        assert_eq!(resolved, base.join("new_dir").join("new_file.bin"));
    }

    #[test]
    fn test_relative_base_dir_rejected() {
        let relative_base = std::path::Path::new("relative/base");
        assert!(validate_game_file_path(relative_base, "file.bin").is_err());
    }

    #[test]
    fn test_absolute_paths() {
        let temp_dir = TempDir::new().expect("Failed to create temp dir for test");
        let base = temp_dir.path();

        // Absolute paths should fail
        assert!(validate_game_file_path(base, "/etc/passwd").is_err());
        if cfg!(windows) {
            assert!(validate_game_file_path(base, "C:\\Windows\\System32\\cmd.exe").is_err());
        }
    }

    #[test]
    fn test_windows_specific() {
        let temp_dir = TempDir::new().expect("Failed to create temp dir for test");
        let base = temp_dir.path();

        // Windows-specific paths that should fail
        assert!(validate_game_file_path(base, "C:\\evil.txt").is_err());
        assert!(validate_game_file_path(base, "D:/evil.txt").is_err());
        assert!(validate_game_file_path(base, "\\\\server\\share\\file.txt").is_err());
    }

    #[cfg(unix)]
    #[test]
    fn test_symlink_escape_rejected() {
        use std::os::unix::fs::symlink;

        let base_dir = TempDir::new().expect("Failed to create base temp dir");
        let outside_dir = TempDir::new().expect("Failed to create outside temp dir");

        let base = base_dir.path();
        let outside = outside_dir.path();

        symlink(outside, base.join("link")).expect("Failed to create symlink");

        assert!(validate_game_file_path(base, "link/escape.txt").is_err());
    }
}
