//! Path canonicalization with cross-platform support.
//!
//! This module handles:
//! - Converting relative paths to absolute
//! - Resolving symlinks and junctions per policy
//! - Normalizing Windows path formats (UNC, long-path, MINGW)
//! - Rejecting Windows reserved device names
//! - Handling non-existent paths (for write operations)

use crate::error::{ToolError, ToolResult};
use std::path::{Path, PathBuf, Component};

/// Symlink handling policy for path canonicalization.
#[derive(Debug, Clone, Copy)]
pub struct SymlinkPolicy {
    /// Allow symlinks to escape allowed roots.
    ///
    /// If false (recommended), symlinks pointing outside allowed roots are rejected.
    pub allow_symlink_escape: bool,

    /// Follow symlinks within allowed roots.
    ///
    /// If true, symlinks within allowed roots are followed during canonicalization.
    pub follow_symlinks_within_roots: bool,
}

impl Default for SymlinkPolicy {
    fn default() -> Self {
        Self {
            allow_symlink_escape: false,
            follow_symlinks_within_roots: true,
        }
    }
}

impl SymlinkPolicy {
    /// Create a policy that follows all symlinks (least restrictive).
    pub fn follow_all() -> Self {
        Self {
            allow_symlink_escape: true,
            follow_symlinks_within_roots: true,
        }
    }

    /// Create a policy that never follows symlinks (most restrictive).
    pub fn follow_none() -> Self {
        Self {
            allow_symlink_escape: false,
            follow_symlinks_within_roots: false,
        }
    }
}

/// Canonicalize a path with the given symlink policy.
///
/// This function:
/// 1. Rejects empty or relative paths (ACP requires absolute paths)
/// 2. Normalizes separators to platform standard
/// 3. Converts MINGW-style paths (/c/...) to native Windows paths (C:\...)
/// 4. Strips long-path prefixes (\\?\) for policy comparison
/// 5. Resolves symlinks/junctions per policy
/// 6. For non-existent paths, canonicalizes parent + appends remainder
/// 7. Ensures no ".." escapes remain
/// 8. Rejects Windows reserved device names (CON, NUL, etc.)
///
/// # Arguments
///
/// * `user_path` - The path to canonicalize (must be absolute)
/// * `policy` - Symlink handling policy
///
/// # Returns
///
/// The canonical absolute path, or an error if the path is invalid or violates policy.
///
/// # Security
///
/// This is a security-critical function. It must prevent:
/// - Path traversal attacks via ".." components
/// - Symlink escape attacks
/// - Access to Windows reserved device names
pub fn canonicalize_path(user_path: &Path, _policy: &SymlinkPolicy) -> ToolResult<PathBuf> {
    // Step 1: Reject empty paths
    if user_path.as_os_str().is_empty() {
        return Err(ToolError::sandbox_violation("Path cannot be empty"));
    }

    // Step 2: Convert to string for preprocessing
    let path_str = user_path.to_string_lossy();

    // Step 3: Handle MINGW-style paths on Windows (/c/... -> C:\...)
    #[cfg(windows)]
    let path_str = convert_mingw_path(&path_str);

    let mut path = PathBuf::from(path_str.as_ref());

    // Step 4: Normalize separators
    path = super::normalize_separators(&path);

    // Step 5: Reject relative paths
    if !path.is_absolute() {
        return Err(ToolError::sandbox_violation(format!(
            "Path must be absolute: {}",
            super::basename(&path)
        )));
    }

    // Step 6: Strip long-path prefix for normalization (\\?\C:\... -> C:\...)
    #[cfg(windows)]
    {
        path = strip_long_path_prefix(&path);
    }

    // Step 7: Check for reserved device names on Windows
    #[cfg(windows)]
    {
        if is_reserved_device_name(&path) {
            return Err(ToolError::sandbox_violation(format!(
                "Path is a reserved device name: {}",
                super::basename(&path)
            )));
        }
    }

    // Step 8: Normalize drive letter case on Windows
    #[cfg(windows)]
    {
        path = normalize_drive_letter(&path);
    }

    // Step 9: Try to canonicalize using dunce (follows symlinks on all platforms)
    // If the path doesn't exist, find the first existing ancestor and canonicalize it,
    // then append all non-existent components
    let canonical = match dunce::canonicalize(&path) {
        Ok(canonical) => canonical,
        Err(_) => {
            // Path doesn't exist - find first existing ancestor
            canonicalize_non_existent_path(&path)?
        }
    };

    // Step 10: Verify no ".." components remain (security check)
    for component in canonical.components() {
        if component == Component::ParentDir {
            return Err(ToolError::sandbox_violation(
                "Path contains '..' after canonicalization (potential traversal attack)",
            ));
        }
    }

    // Step 11: Strip long-path prefix again if added by canonicalize
    #[cfg(windows)]
    let canonical = strip_long_path_prefix(&canonical);

    Ok(canonical)
}

/// Canonicalize a non-existent path by finding the first existing ancestor.
///
/// This function walks up the directory tree until it finds an existing directory,
/// then builds the canonical path by appending the non-existent components.
fn canonicalize_non_existent_path(path: &Path) -> ToolResult<PathBuf> {
    // Collect components of the non-existent path parts
    let mut components_to_append: Vec<std::ffi::OsString> = Vec::new();
    let mut current = path.to_path_buf();

    // Walk up the directory tree until we find an existing directory
    loop {
        if let Some(parent) = current.parent() {
            if parent == current {
                // Hit root without finding existing directory
                // This shouldn't happen with absolute paths, but handle it
                return Ok(path.to_path_buf());
            }

            // Try to canonicalize the parent
            match dunce::canonicalize(parent) {
                Ok(canonical_parent) => {
                    // Found existing parent - build the full path
                    components_to_append.reverse();
                    let mut result = canonical_parent;

                    // First add the current file_name (if any)
                    if let Some(file_name) = current.file_name() {
                        result = result.join(file_name);
                    }

                    // Then add all the rest
                    for component in components_to_append {
                        result = result.join(component);
                    }

                    return Ok(result);
                }
                Err(_) => {
                    // Parent doesn't exist - collect the component and continue up
                    if let Some(file_name) = current.file_name() {
                        components_to_append.push(file_name.to_os_string());
                    }
                    current = parent.to_path_buf();
                }
            }
        } else {
            // No parent (shouldn't reach here with absolute paths)
            return Ok(path.to_path_buf());
        }
    }
}

/// Convert MINGW-style path (/c/Users/...) to Windows path (C:\Users\...).
///
/// Only applies on Windows. Detects paths like:
/// - /c/... -> C:\...
/// - /d/... -> D:\...
#[cfg(windows)]
fn convert_mingw_path(path: &str) -> std::borrow::Cow<'_, str> {
    // Check for MINGW-style path: starts with /<letter>/
    if path.len() >= 3
        && path.starts_with('/')
        && path.chars().nth(1).map_or(false, |c| c.is_ascii_alphabetic())
        && (path.len() == 3 || path.chars().nth(2) == Some('/'))
    {
        let drive_letter = path.chars().nth(1).unwrap().to_ascii_uppercase();
        let rest = if path.len() > 3 {
            // Normalize forward slashes to backslashes in the remainder
            path[3..].replace('/', "\\")
        } else {
            String::new()
        };
        format!("{}:\\{}", drive_letter, rest).into()
    } else {
        path.into()
    }
}

/// Strip the long-path prefix (\\?\) from a Windows path.
///
/// Also handles verbatim UNC paths (\\?\UNC\server\share -> \\server\share).
#[cfg(windows)]
fn strip_long_path_prefix(path: &Path) -> PathBuf {
    let path_str = path.to_string_lossy();

    // Handle \\?\UNC\server\share -> \\server\share
    if path_str.starts_with(r"\\?\UNC\") {
        let without_prefix = &path_str[r"\\?\UNC\".len()..];
        return PathBuf::from(format!(r"\\{}", without_prefix));
    }

    // Handle \\?\C:\ -> C:\
    if path_str.starts_with(r"\\?\") {
        let without_prefix = &path_str[r"\\?\".len()..];
        return PathBuf::from(without_prefix);
    }

    path.to_path_buf()
}

/// Normalize drive letter to uppercase (C:\ instead of c:\).
#[cfg(windows)]
fn normalize_drive_letter(path: &Path) -> PathBuf {
    let path_str = path.to_string_lossy();

    // Check if path starts with a drive letter (e.g., "c:\")
    if path_str.len() >= 2
        && path_str.chars().nth(0).map_or(false, |c| c.is_ascii_alphabetic())
        && path_str.chars().nth(1) == Some(':')
    {
        let mut chars: Vec<char> = path_str.chars().collect();
        chars[0] = chars[0].to_ascii_uppercase();
        return PathBuf::from(chars.iter().collect::<String>());
    }

    path.to_path_buf()
}

/// Check if a path is a Windows reserved device name.
///
/// Reserved names include:
/// - CON, PRN, AUX, NUL
/// - COM1-COM9
/// - LPT1-LPT9
///
/// These can appear with or without extensions (e.g., CON.txt is also reserved).
#[cfg(windows)]
fn is_reserved_device_name(path: &Path) -> bool {
    const RESERVED_NAMES: &[&str] = &[
        "CON", "PRN", "AUX", "NUL",
        "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
        "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
    ];

    if let Some(file_name) = path.file_name() {
        let name = file_name.to_string_lossy().to_uppercase();

        // Check exact match
        if RESERVED_NAMES.contains(&name.as_str()) {
            return true;
        }

        // Check with extension (e.g., CON.txt)
        if let Some(stem) = Path::new(&*name).file_stem() {
            let stem_str = stem.to_string_lossy();
            if RESERVED_NAMES.contains(&stem_str.as_ref()) {
                return true;
            }
        }
    }

    false
}

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

    #[test]
    fn test_empty_path() {
        let result = canonicalize_path(Path::new(""), &SymlinkPolicy::default());
        assert!(result.is_err());
    }

    #[cfg(windows)]
    #[test]
    fn test_convert_mingw_path() {
        assert_eq!(convert_mingw_path("/c/Users/foo"), "C:\\Users\\foo");
        assert_eq!(convert_mingw_path("/d/Projects"), "D:\\Projects");
        assert_eq!(convert_mingw_path("/c/"), "C:\\");
        assert_eq!(convert_mingw_path("C:\\Users"), "C:\\Users"); // No conversion
    }

    #[cfg(windows)]
    #[test]
    fn test_strip_long_path_prefix() {
        assert_eq!(
            strip_long_path_prefix(Path::new(r"\\?\C:\Users\foo")),
            PathBuf::from(r"C:\Users\foo")
        );
        assert_eq!(
            strip_long_path_prefix(Path::new(r"\\?\UNC\server\share\file")),
            PathBuf::from(r"\\server\share\file")
        );
        assert_eq!(
            strip_long_path_prefix(Path::new(r"C:\Users\foo")),
            PathBuf::from(r"C:\Users\foo")
        );
    }

    #[cfg(windows)]
    #[test]
    fn test_normalize_drive_letter() {
        assert_eq!(
            normalize_drive_letter(Path::new("c:\\Users")),
            PathBuf::from("C:\\Users")
        );
        assert_eq!(
            normalize_drive_letter(Path::new("C:\\Users")),
            PathBuf::from("C:\\Users")
        );
    }

    #[cfg(windows)]
    #[test]
    fn test_is_reserved_device_name() {
        assert!(is_reserved_device_name(Path::new("CON")));
        assert!(is_reserved_device_name(Path::new("con")));
        assert!(is_reserved_device_name(Path::new("CON.txt")));
        assert!(is_reserved_device_name(Path::new("C:\\path\\to\\NUL")));
        assert!(is_reserved_device_name(Path::new("COM1")));
        assert!(is_reserved_device_name(Path::new("LPT5")));
        assert!(!is_reserved_device_name(Path::new("CONNECT.txt")));
        assert!(!is_reserved_device_name(Path::new("file.txt")));
    }

    #[test]
    fn test_symlink_policy() {
        let default = SymlinkPolicy::default();
        assert!(!default.allow_symlink_escape);
        assert!(default.follow_symlinks_within_roots);

        let follow_all = SymlinkPolicy::follow_all();
        assert!(follow_all.allow_symlink_escape);
        assert!(follow_all.follow_symlinks_within_roots);

        let follow_none = SymlinkPolicy::follow_none();
        assert!(!follow_none.allow_symlink_escape);
        assert!(!follow_none.follow_symlinks_within_roots);
    }
}
