"""
File categorization and path validation for sync operations.

This module handles categorizing files based on patterns, determining
destinations, and validating paths for security. All functions validate
inputs for security to prevent path traversal and injection attacks.
"""

from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path

from persuade.constants import FILE_RULES, SKIP_PATTERNS, FileCategory

__all__ = [
    "FileInfo",
    "should_skip",
    "validate_destination_path",
    "get_default_destination",
    "categorize_file",
]


@dataclass
class FileInfo:
    """Information about a file's sync configuration.

    Attributes:
        category: The sync behavior category
            (SYNC_ALWAYS, MERGE_SMART, SKIP_ALWAYS, COPY_IF_MISSING)
        destination: Optional destination path relative to project root. If None,
            uses default destination logic (files in .claude/ keep same path,
            others require explicit mapping)
    """

    category: FileCategory
    destination: str | None = None


def should_skip(path: Path, base_path: Path, exclude_patterns: list[str] | None = None) -> bool:
    """Check if file should be skipped during sync.

    Supports both simple glob patterns (*.py, foo/*) and recursive patterns (foo/**/*).

    Args:
        path: File path to check
        base_path: Base directory (for relative path calculation)
        exclude_patterns: Additional patterns to exclude

    Returns:
        True if file should be skipped
    """
    # Get relative path
    try:
        rel_path = path.relative_to(base_path)
    except ValueError:
        return False

    rel_str = str(rel_path)

    # Check against skip patterns
    for pattern in SKIP_PATTERNS:
        # Use Path.match() for patterns with **, fnmatch for simple patterns
        if "**" in pattern:
            if rel_path.match(pattern):
                return True
        else:
            if fnmatch(rel_str, pattern):
                return True

    # Check against user-provided exclude patterns
    if exclude_patterns:
        for pattern in exclude_patterns:
            if "**" in pattern:
                if rel_path.match(pattern):
                    return True
            else:
                if fnmatch(rel_str, pattern):
                    return True

    return False


def validate_destination_path(destination: str, project_root: Path) -> bool:
    """Validate that a destination path is safe and doesn't escape project root.

    Args:
        destination: Destination path relative to project root
        project_root: Path to the project root directory

    Returns:
        True if path is valid and safe

    Raises:
        ValueError: If path tries to escape project root or contains invalid characters

    Note:
        Special handling for .git/hooks/ paths: In git worktrees, .git is a file
        containing a gitdir reference to the main repo's .git directory. When resolved,
        .git/hooks/ would point outside the worktree's project root. This is expected
        git behavior, not a security issue, so we validate these paths without resolution.
    """
    # Reject paths ending with "/" (empty final component)
    # pathlib strips trailing slashes, so we check the raw string first
    if destination.endswith("/"):
        raise ValueError(
            f"Destination path '{destination}' cannot end with '/' "
            "(final path component cannot be empty)."
        )

    # Check for suspicious patterns BEFORE resolution (prevents TOCTOU vulnerability)
    # Use path components to avoid false positives (e.g., "..foo" vs actual ".." component)
    dest_parts = Path(destination).parts
    if ".." in dest_parts:
        raise ValueError(f"Destination path '{destination}' contains '..' which is not allowed.")

    # Normalize components by removing redundant "." segments (without resolving symlinks).
    # This is used for safe prefix checks like the .git/hooks/ special case below.
    normalized_parts = tuple(p for p in dest_parts if p != ".")

    # Special case: .git/hooks/ paths are valid even in worktrees
    # In worktrees, .git is a file pointing to the main repo's git dir, so resolve()
    # would make the path appear to escape the project root. This is expected behavior.
    # We already validated no ".." components above, so the path is safe.
    # Use normalized path components instead of a raw string prefix check.
    if (
        len(normalized_parts) >= 2
        and normalized_parts[0] == ".git"
        and normalized_parts[1] == "hooks"
    ):
        # Additional defense-in-depth: ensure no ".." components after the .git/hooks prefix.
        if ".." in normalized_parts[2:]:
            raise ValueError(
                f"Git hooks path '{destination}' contains '..' after '.git/hooks', "
                "which is not allowed."
            )

        # Get path components after .git/hooks
        # Must have at least 3 parts: .git, hooks, <hook_name or subdir>
        if len(normalized_parts) < 3:
            raise ValueError("Git hook name cannot be empty.")

        # Validate ALL path components after .git/hooks (not just the last one)
        # This prevents shell injection in intermediate directories like .git/hooks/foo;bar/
        hook_path_components = normalized_parts[2:]

        for component in hook_path_components:
            # Reject "." and ".." as path components (defense-in-depth)
            if component in (".", ".."):
                raise ValueError(
                    f"Git hook path component '{component}' is not allowed. "
                    "Path components cannot be '.' or '..'."
                )

            # Reject consecutive dots within a component (defense-in-depth)
            # Prevents potential path traversal via names like "a..b"
            if ".." in component:
                raise ValueError(
                    f"Git hook path component '{component}' is not allowed. "
                    "Path components cannot contain consecutive dots ('..')."
                )

            # Validate each component for safe characters (alphanumeric, hyphens,
            # underscores, dots only)
            if not all(c.isalnum() or c in "-_." for c in component):
                raise ValueError(
                    f"Git hook path component '{component}' contains invalid characters. "
                    "Only alphanumeric characters, hyphens, underscores, and dots "
                    "are allowed."
                )

        return True

    # Now safe to resolve and validate
    dest_path = (project_root / destination).resolve()
    root_resolved = project_root.resolve()

    # Check if the resolved path is within project root
    try:
        dest_path.relative_to(root_resolved)
    except ValueError as e:
        raise ValueError(
            f"Destination path '{destination}' escapes project root. "
            "This is a security risk and is not allowed."
        ) from e

    return True


def get_default_destination(source_path: str) -> str:
    """Determine default destination for a file.

    Files in .claude/ directory default to the same path in target.
    Files outside .claude/ MUST have explicit destination mapping.

    Args:
        source_path: Source path relative to repository root

    Returns:
        Default destination path relative to target project root

    Raises:
        ValueError: If file is outside .claude/ without explicit destination
    """
    # Files in .claude/ go to target/.claude/
    if source_path.startswith(".claude/"):
        return source_path  # Keep same relative path

    # Files outside .claude/ MUST have explicit destination
    else:
        raise ValueError(
            f"Files outside .claude/ require explicit destination mapping: {source_path}"
        )


def categorize_file(rel_path: str) -> FileInfo:
    """Determine sync category and destination for a file.

    Supports both simple glob patterns (*.py, foo/*) and recursive patterns (foo/**/*).

    Args:
        rel_path: Relative path from repository root
            (e.g., ".claude/scripts/foo.py" or ".github/workflows/ci.yml")

    Returns:
        FileInfo with category and optional destination path
    """
    path_obj = Path(rel_path)

    for pattern, rule in FILE_RULES.items():
        # Use Path.match() for patterns with **, fnmatch for simple patterns
        pattern_matches = path_obj.match(pattern) if "**" in pattern else fnmatch(rel_path, pattern)

        if pattern_matches:
            # Handle dict-based rules with destination
            if isinstance(rule, dict):
                # Check if this file is excluded from the rule
                exclude_patterns = rule.get("exclude", [])
                if exclude_patterns:
                    filename = Path(rel_path).name
                    if any(fnmatch(filename, excl) for excl in exclude_patterns):
                        continue  # Skip this rule, try next one

                category = rule.get("category", FileCategory.SYNC_ALWAYS)
                destination_template = rule.get("destination")

                # Resolve destination template if present
                if destination_template:
                    filename = Path(rel_path).name

                    # Support {filename} placeholder (full filename with extension)
                    if "{filename}" in destination_template:
                        destination = destination_template.replace("{filename}", filename)
                    else:
                        destination = destination_template
                else:
                    destination = None

                return FileInfo(category=category, destination=destination)
            # Handle simple FileCategory rules
            else:
                return FileInfo(category=rule, destination=None)

    # Default: skip files that don't match any pattern (explicit whitelist approach)
    return FileInfo(category=FileCategory.SKIP_ALWAYS, destination=None)
