//! Path containment checking for sandbox enforcement.
//!
//! This module verifies that a canonical path is within allowed sandbox roots.

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

/// Check if a canonical path is contained within allowed roots.
///
/// This function performs component-wise prefix matching (not string matching)
/// to ensure the path is strictly within at least one allowed root.
///
/// # Arguments
///
/// * `canonical_path` - The canonical path to check (must already be canonicalized)
/// * `allowed_roots` - List of allowed root paths (must already be canonical)
///
/// # Returns
///
/// Ok(()) if the path is contained, or an error if it's outside all roots.
///
/// # Security
///
/// This is a security-critical function. It uses component-wise comparison to prevent:
/// - String prefix attacks (e.g., "/a" should not contain "/ab")
/// - Path traversal attacks
pub fn check_containment(
    canonical_path: &Path,
    allowed_roots: &[PathBuf],
) -> ToolResult<()> {
    // If no roots configured, deny all paths
    if allowed_roots.is_empty() {
        return Err(ToolError::sandbox_violation(
            "No allowed roots configured - all paths denied",
        ));
    }

    // Check if path is contained by at least one root
    for root in allowed_roots {
        if is_contained_by(canonical_path, root) {
            return Ok(());
        }
    }

    // Path is outside all roots
    Err(ToolError::sandbox_violation(format!(
        "Path is outside allowed roots: {}",
        super::basename(canonical_path)
    )))
}

/// Check if a path is strictly contained by a root.
///
/// This performs component-wise comparison to ensure proper containment:
/// - "/a/b/c" is contained by "/a"
/// - "/a/b/c" is NOT contained by "/a/b/c" (must be strict)
/// - "/ab" is NOT contained by "/a" (not a string prefix match)
///
/// On Windows, comparison is case-insensitive to match filesystem behavior.
///
/// # Arguments
///
/// * `path` - The path to check (must be canonical)
/// * `root` - The root path (must be canonical)
///
/// # Returns
///
/// `true` if path is strictly contained within root, `false` otherwise.
pub fn is_contained_by(path: &Path, root: &Path) -> bool {
    // Get components
    let path_components: Vec<_> = path.components().collect();
    let root_components: Vec<_> = root.components().collect();

    // Path must have MORE components than root (strict containment)
    // Equal components means path == root, which is not strict containment
    if path_components.len() <= root_components.len() {
        return false;
    }

    // Check if all root components match path components (prefix)
    for (i, root_comp) in root_components.iter().enumerate() {
        let path_comp = &path_components[i];

        if !components_equal(path_comp, root_comp) {
            return false;
        }
    }

    true
}

/// Compare two path components for equality.
///
/// On Windows, this is case-insensitive.
/// On Unix, this is case-sensitive.
fn components_equal(a: &Component, b: &Component) -> bool {
    #[cfg(windows)]
    {
        // Case-insensitive comparison on Windows
        let a_str = component_to_string(a).to_lowercase();
        let b_str = component_to_string(b).to_lowercase();
        a_str == b_str
    }

    #[cfg(not(windows))]
    {
        // Case-sensitive comparison on Unix
        a == b
    }
}

/// Convert a path component to a string for comparison.
#[cfg(windows)]
fn component_to_string(comp: &Component) -> String {
    match comp {
        Component::Prefix(prefix) => prefix.as_os_str().to_string_lossy().to_string(),
        Component::RootDir => "/".to_string(),
        Component::CurDir => ".".to_string(),
        Component::ParentDir => "..".to_string(),
        Component::Normal(s) => s.to_string_lossy().to_string(),
    }
}

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

    #[test]
    fn test_is_contained_by_unix() {
        // Strict containment (child must be strictly inside parent)
        assert!(is_contained_by(
            Path::new("/a/b/c"),
            Path::new("/a")
        ));
        assert!(is_contained_by(
            Path::new("/a/b/c"),
            Path::new("/a/b")
        ));

        // Equal paths are NOT strictly contained
        assert!(!is_contained_by(
            Path::new("/a/b"),
            Path::new("/a/b")
        ));

        // Parent is not contained by child
        assert!(!is_contained_by(
            Path::new("/a"),
            Path::new("/a/b")
        ));

        // Different trees
        assert!(!is_contained_by(
            Path::new("/a/b"),
            Path::new("/c")
        ));

        // String prefix but not path prefix
        assert!(!is_contained_by(
            Path::new("/ab"),
            Path::new("/a")
        ));
    }

    #[cfg(windows)]
    #[test]
    fn test_is_contained_by_windows_case_insensitive() {
        // Windows is case-insensitive
        assert!(is_contained_by(
            Path::new("C:\\Users\\foo\\bar"),
            Path::new("C:\\Users")
        ));
        assert!(is_contained_by(
            Path::new("c:\\users\\foo\\bar"),
            Path::new("C:\\Users")
        ));
        assert!(is_contained_by(
            Path::new("C:\\Users\\foo"),
            Path::new("c:\\users")
        ));
    }

    #[test]
    fn test_check_containment_no_roots() {
        let result = check_containment(Path::new("/a/b/c"), &[]);
        assert!(result.is_err());
    }

    #[test]
    fn test_check_containment_allowed() {
        let roots = vec![
            PathBuf::from("/home/user/project"),
            PathBuf::from("/tmp"),
        ];

        // Inside first root
        assert!(check_containment(
            Path::new("/home/user/project/src/main.rs"),
            &roots
        ).is_ok());

        // Inside second root
        assert!(check_containment(
            Path::new("/tmp/foo.txt"),
            &roots
        ).is_ok());
    }

    #[test]
    fn test_check_containment_denied() {
        let roots = vec![
            PathBuf::from("/home/user/project"),
        ];

        // Outside all roots
        assert!(check_containment(
            Path::new("/etc/passwd"),
            &roots
        ).is_err());

        // Sibling directory (string prefix but not path prefix)
        assert!(check_containment(
            Path::new("/home/user/project2/file.txt"),
            &roots
        ).is_err());

        // Parent directory
        assert!(check_containment(
            Path::new("/home/user/other.txt"),
            &roots
        ).is_err());

        // Equal to root (not strictly contained)
        assert!(check_containment(
            Path::new("/home/user/project"),
            &roots
        ).is_err());
    }

    #[cfg(windows)]
    #[test]
    fn test_check_containment_windows() {
        let roots = vec![
            PathBuf::from("C:\\Users\\foo"),
        ];

        // Allowed
        assert!(check_containment(
            Path::new("C:\\Users\\foo\\Documents\\file.txt"),
            &roots
        ).is_ok());

        // Denied - different drive
        assert!(check_containment(
            Path::new("D:\\file.txt"),
            &roots
        ).is_err());

        // Denied - outside root
        assert!(check_containment(
            Path::new("C:\\Windows\\system32"),
            &roots
        ).is_err());
    }
}
