"""File manager for file system operations scoped to project root."""

import asyncio
import fnmatch
import os
import re
import shutil
import sys
from pathlib import Path, PurePosixPath
from typing import Dict, Optional, Tuple, List
import aiofiles
import logging
import patch_ng

from .structured_log import get_logger as _get_slog

logger = logging.getLogger("skop-runner")
_log = _get_slog("file_manager")

# Maximum file size for read operations (50MB)
# Prevents OOM attacks from malicious users requesting huge files
MAX_FILE_SIZE = 50 * 1024 * 1024


class FileManager:
    """Manages file system operations scoped to project root."""

    CLOUD_ROOT = "/workspace"

    @staticmethod
    def _normalize_output_path(path: str) -> str:
        """Normalize path separators to / for cross-platform client compatibility.

        On Unix this is a TRUE no-op (returns path unchanged, preserving any
        literal backslashes in filenames which are valid on Unix).
        On Windows this converts \\\\ to / so that frontend .split("/") works.
        """
        if sys.platform == "win32":
            return path.replace("\\", "/")
        return path

    def __init__(self, project_root: Optional[str] = None, is_cloud: bool = False):
        self.project_root: Optional[Path] = None
        self._file_locks: dict[str, asyncio.Lock] = {}
        self._project_id: Optional[str] = None
        self.is_cloud = is_cloud

        if project_root:
            self.set_root(project_root)
        else:
            logger.info("FileManager initialized without project root")

    def _get_file_lock(self, path: str) -> asyncio.Lock:
        """Get or create a per-file asyncio.Lock for safe read-modify-write."""
        if path not in self._file_locks:
            self._file_locks[path] = asyncio.Lock()
        return self._file_locks[path]

    def _validate_cloud_path(self, path: Path) -> None:
        """Reject paths outside /workspace for cloud runners."""
        if not self.is_cloud:
            return
        resolved = path.resolve()
        cloud_root = Path(self.CLOUD_ROOT).resolve()
        if resolved != cloud_root and not str(resolved).startswith(str(cloud_root) + os.sep):
            _log.warning("security.path_traversal_blocked", path=str(path), resolved_path=str(resolved), boundary=self.CLOUD_ROOT)
            raise ValueError(f"Cloud runners can only access paths under {self.CLOUD_ROOT}")

    def set_root(self, project_root: str):
        """Set or change the project root directory."""
        new_root = Path(project_root).resolve()

        self._validate_cloud_path(new_root)

        if not new_root.exists():
            raise ValueError(f"Project root does not exist: {project_root}")

        if not new_root.is_dir():
            raise ValueError(f"Project root is not a directory: {project_root}")

        old_root = str(self.project_root) if self.project_root else None
        self.project_root = new_root
        _log.info("file.set_root", old_root=old_root, new_root=str(new_root))

        # Auto-discover existing project identity
        self._project_id = self._read_skop_project_id()
        if self._project_id:
            logger.info(f"Discovered project ID from .skop/project.json: {self._project_id}")

    # ========== Project identity (.skop/project.json) ==========

    def get_project_id(self) -> Optional[str]:
        """Return the cached project ID (from .skop/project.json)."""
        return self._project_id

    def _read_skop_project_id(self) -> Optional[str]:
        """Read projectId from .skop/project.json. Returns None on any failure."""
        if not self.project_root:
            return None
        skop_file = self.project_root / ".skop" / "project.json"
        try:
            import json as _json
            data = _json.loads(skop_file.read_text(encoding="utf-8"))
            project_id = data.get("projectId")
            if isinstance(project_id, str) and project_id:
                return project_id
            return None
        except (FileNotFoundError, OSError, ValueError, KeyError):
            return None

    def write_skop_project_id(self, project_id: str) -> None:
        """Write projectId to .skop/project.json (creates .skop/ dir if needed)."""
        self._require_root()
        import json as _json
        skop_dir = self.project_root / ".skop"
        skop_dir.mkdir(exist_ok=True)
        skop_file = skop_dir / "project.json"
        skop_file.write_text(
            _json.dumps({"projectId": project_id}, indent=2) + "\n",
            encoding="utf-8",
        )
        self._project_id = project_id
        logger.info(f"Wrote .skop/project.json with projectId: {project_id}")

    def _require_root(self):
        """Raise error if no project root is set."""
        if not self.project_root:
            raise ValueError("No project root set. Call workspace.setRoot first.")

    def _is_within_project(self, path: Path) -> bool:
        """Check if a resolved path is within the project root."""
        try:
            path.resolve().relative_to(self.project_root.resolve())
            return True
        except ValueError:
            return False

    def resolve_path(self, relative_path: str) -> Path:
        """
        Resolve a relative path to an absolute path within project root.

        This method is the primary way to safely convert user-provided paths
        to filesystem paths. It enforces security constraints to prevent
        path traversal attacks.

        Args:
            relative_path: Path relative to project root

        Returns:
            Resolved absolute Path within project root

        Raises:
            ValueError: If path is invalid or escapes project root

        Security measures:
        1. Reject None/empty (except for root access)
        2. Reject null bytes
        3. Reject absolute paths
        4. Reject explicit traversal patterns
        5. Resolve symlinks and verify final path
        """
        self._require_root()

        # Handle None
        if relative_path is None:
            relative_path = ""

        # Handle empty or whitespace-only path as project root
        if not relative_path or not relative_path.strip() or relative_path.strip() == ".":
            return self.project_root

        # Use stripped path for further processing
        relative_path = relative_path.strip()

        # Security: Reject null bytes (can bypass checks on some systems)
        if "\x00" in relative_path:
            raise ValueError("Invalid path: contains null byte")

        # Security: Reject absolute paths
        if os.path.isabs(relative_path):
            raise ValueError(f"Absolute paths not allowed: {relative_path}")

        # Security: Reject obvious traversal attempts early
        # Normalize separators so ".." is only rejected when it is a full path part
        normalized = os.path.normpath(relative_path.replace("\\", "/"))
        normalized_parts = [
            part for part in normalized.replace("\\", "/").split("/")
            if part not in ("", ".")
        ]
        if normalized_parts and normalized_parts[0] == "..":
            _log.warning("security.path_traversal_blocked", path=relative_path, project_root=str(self.project_root))
            raise ValueError(f"Path traversal not allowed: {relative_path}")

        # Resolve the full path (this follows symlinks)
        full_path = (self.project_root / relative_path).resolve()

        # Security: Final check - ensure resolved path is within project root
        try:
            full_path.relative_to(self.project_root)
        except ValueError:
            _log.warning("security.path_traversal_blocked", path=relative_path, resolved_path=str(full_path), project_root=str(self.project_root))
            raise ValueError(f"Path escapes project root: {relative_path}")

        return full_path

    def get_project_root(self) -> Optional[str]:
        """Get the project root path, or None if not set."""
        return self._normalize_output_path(str(self.project_root)) if self.project_root else None

    # Sensitive system paths that should not be browsable
    # Note: These are checked case-insensitively on Windows
    _SENSITIVE_PATHS = frozenset([
        # Unix/Linux system directories
        "/etc", "/var", "/tmp", "/proc", "/sys", "/dev",
        "/root", "/boot", "/sbin", "/bin", "/lib", "/lib64",
        "/usr/sbin", "/usr/bin", "/opt", "/home",
        # macOS specific
        "/private/var", "/private/etc", "/private/tmp",
        "/System", "/Library/Preferences", "/Library/Keychains",
        "/Applications",
        # Windows system directories (normalized to lowercase for comparison)
        "c:\\windows", "c:\\program files", "c:\\program files (x86)",
        "c:\\programdata", "c:\\recovery", "c:\\$recycle.bin",
        "c:\\users",  # Windows equivalent of /home
    ])

    # User-specific sensitive directories (relative to home)
    _SENSITIVE_HOME_DIRS = frozenset([
        ".ssh", ".gnupg", ".aws", ".azure", ".config/gcloud",
        ".kube", ".docker", "Library/Keychains",
    ])

    def _validate_absolute_path(self, abs_path: Path) -> None:
        """Validate an absolute path against sensitive directory rules.

        Raises ValueError if the path is in a blocked system or sensitive directory.
        Used by both browse_absolute() and create_folder_absolute().
        """
        path_str = str(abs_path)
        path_str_lower = path_str.lower()
        user_home = str(Path.home())

        for sensitive in self._SENSITIVE_PATHS:
            sensitive_normalized = sensitive.lower() if os.name == "nt" else sensitive
            path_to_check = path_str_lower if os.name == "nt" else path_str
            sep = os.sep

            if path_to_check == sensitive_normalized or path_to_check.startswith(sensitive_normalized + sep):
                if path_str == user_home or path_str.startswith(user_home + sep):
                    continue
                raise ValueError(f"Access to system directory not allowed: {abs_path}")

        # Check sensitive directories within user home
        try:
            home = Path.home()
            rel_to_home = abs_path.relative_to(home)
        except ValueError:
            return  # Path is not relative to home, that's fine

        rel_str = str(rel_to_home).replace("\\", "/")
        for sensitive_home in self._SENSITIVE_HOME_DIRS:
            if rel_str == sensitive_home or rel_str.startswith(sensitive_home + "/"):
                raise ValueError(f"Access to sensitive directory not allowed: {abs_path}")

    def browse_absolute(self, path: str) -> List[Dict]:
        """
        Browse an absolute path for project root selection.

        This method is NOT scoped to project root - it's specifically
        for browsing the filesystem to select a project folder.

        Only returns directories (not files) for security.
        Blocks access to sensitive system directories.
        """
        abs_path = Path(path).resolve()
        self._validate_cloud_path(abs_path)
        self._validate_absolute_path(abs_path)

        if not abs_path.exists():
            raise FileNotFoundError(f"Directory not found: {path}")

        if not abs_path.is_dir():
            raise ValueError(f"Not a directory: {path}")

        items = []

        for item in abs_path.iterdir():
            try:
                # Only include directories
                if not item.is_dir():
                    continue

                # Skip hidden directories (except common ones users might want)
                if item.name.startswith("."):
                    continue

                # Skip system/cache directories
                if item.name in ["__pycache__", "node_modules", ".git", "lost+found"]:
                    continue

                items.append({
                    "name": item.name,
                    "path": self._normalize_output_path(str(item)),
                    "type": "directory",
                })
            except (OSError, PermissionError) as e:
                logger.debug(f"Could not access {item}: {e}")
                continue

        # Sort alphabetically
        items.sort(key=lambda x: x["name"].lower())

        return items

    def create_folder_absolute(self, path: str) -> str:
        """Create a folder at an absolute path (for project root creation).

        Returns the resolved absolute path string.
        """
        if not os.path.isabs(path):
            raise ValueError(f"Path must be absolute: {path}")

        abs_path = Path(path).resolve()
        self._validate_cloud_path(abs_path)
        self._validate_absolute_path(abs_path)

        if abs_path.exists():
            raise FileExistsError(f"Folder already exists: {path}")

        abs_path.mkdir(parents=False, exist_ok=False)
        logger.info(f"Created folder (absolute): {abs_path}")
        return self._normalize_output_path(str(abs_path))

    def get_home_directory(self) -> str:
        """Get the user's home directory. Cloud runners use /workspace."""
        if self.is_cloud:
            return self.CLOUD_ROOT
        return self._normalize_output_path(str(Path.home()))

    async def list_directory(
        self, path: str = "", include_symlink_info: bool = False
    ) -> List[Dict]:
        """List directory contents.

        Args:
            path: Relative path to list (empty for project root)
            include_symlink_info: If True, include isSymlink field for symlinks

        Security: External symlinks (pointing outside project root) are skipped.

        Note: Runs in thread pool to avoid blocking event loop for large directories.
        """
        dir_path = self.resolve_path(path)

        if not dir_path.exists():
            raise FileNotFoundError(f"Directory not found: {path}")

        if not dir_path.is_dir():
            raise ValueError(f"Not a directory: {path}")

        # Run synchronous I/O in thread pool to avoid blocking event loop
        return await asyncio.to_thread(
            self._list_directory_sync, dir_path, include_symlink_info
        )

    def _list_directory_sync(
        self, dir_path: Path, include_symlink_info: bool
    ) -> List[Dict]:
        """Synchronous directory listing implementation."""
        items = []

        for item in dir_path.iterdir():
            # Skip hidden files and common ignore patterns
            if item.name.startswith(".") and item.name not in [".env.example"]:
                continue
            if item.name in ["__pycache__", "node_modules", ".git", ".venv", "venv", "lost+found"]:
                continue

            try:
                # Security: Check if symlink points outside project root
                is_symlink = item.is_symlink()
                if is_symlink:
                    try:
                        target = item.resolve()
                        target.relative_to(self.project_root)
                    except ValueError:
                        # Symlink points outside project root - skip it
                        logger.debug(f"Skipping external symlink: {item}")
                        continue

                stat = item.stat()
                relative = item.relative_to(self.project_root)

                entry = {
                    "name": item.name,
                    "path": self._normalize_output_path(str(relative)),
                    "type": "directory" if item.is_dir() else "file",
                    "size": stat.st_size if item.is_file() else None,
                    "modified": stat.st_mtime,
                }

                if include_symlink_info and is_symlink:
                    entry["isSymlink"] = True

                items.append(entry)
            except (OSError, PermissionError) as e:
                logger.warning(f"Could not stat {item}: {e}")
                continue

        # Sort: directories first, then files alphabetically
        items.sort(key=lambda x: (x["type"] != "directory", x["name"].lower()))

        return items

    async def read_file(self, path: str) -> str:
        """Read file contents as text.

        Tries UTF-8 first, falls back to latin-1 (which accepts any byte).
        This handles files created on Windows with non-UTF-8 characters.

        Security: Enforces maximum file size to prevent OOM attacks.

        Raises:
            FileNotFoundError: If file doesn't exist
            ValueError: If path is not a file or file is too large
        """
        file_path = self.resolve_path(path)

        if not file_path.exists():
            raise FileNotFoundError(f"File not found: {path}")

        if not file_path.is_file():
            raise ValueError(f"Not a file: {path}")

        # Security: Check file size before reading to prevent OOM
        file_size = file_path.stat().st_size
        if file_size > MAX_FILE_SIZE:
            raise ValueError(
                f"File too large: {file_size / (1024*1024):.1f}MB "
                f"(max {MAX_FILE_SIZE / (1024*1024):.0f}MB)"
            )

        # Try UTF-8 first (most common), fall back to latin-1 (accepts any byte)
        for encoding in ("utf-8", "latin-1"):
            try:
                async with aiofiles.open(file_path, "r", encoding=encoding) as f:
                    content = await f.read()
                if encoding != "utf-8":
                    logger.info(f"Read file with {encoding} encoding: {path}")
                else:
                    logger.info(f"Read file: {path}")
                return content
            except UnicodeDecodeError:
                if encoding == "latin-1":
                    raise  # Should never happen, latin-1 accepts all bytes
                continue

        # Fallback should never be reached, but just in case
        raise UnicodeDecodeError("utf-8", b"", 0, 1, "Could not decode file")

    async def read_file_bytes(self, path: str) -> bytes:
        """Read file contents as bytes.

        Security: Enforces maximum file size to prevent OOM attacks.

        Raises:
            FileNotFoundError: If file doesn't exist
            ValueError: If path is not a file or file is too large
        """
        file_path = self.resolve_path(path)

        if not file_path.exists():
            raise FileNotFoundError(f"File not found: {path}")

        if not file_path.is_file():
            raise ValueError(f"Not a file: {path}")

        # Security: Check file size before reading to prevent OOM
        file_size = file_path.stat().st_size
        if file_size > MAX_FILE_SIZE:
            raise ValueError(
                f"File too large: {file_size / (1024*1024):.1f}MB "
                f"(max {MAX_FILE_SIZE / (1024*1024):.0f}MB)"
            )

        async with aiofiles.open(file_path, "rb") as f:
            content = await f.read()

        logger.info(f"Read file (bytes): {path}")
        return content

    async def write_file(self, path: str, content: str):
        """Write text content to file."""
        file_path = self.resolve_path(path)

        # Create parent directories if needed
        file_path.parent.mkdir(parents=True, exist_ok=True)

        async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
            await f.write(content)

        logger.info(f"Wrote file: {path}")

    async def write_file_bytes(self, path: str, content: bytes):
        """Write binary content to file."""
        file_path = self.resolve_path(path)

        # Create parent directories if needed
        file_path.parent.mkdir(parents=True, exist_ok=True)

        async with aiofiles.open(file_path, "wb") as f:
            await f.write(content)

        logger.info(f"Wrote file (bytes): {path}")

    async def create_file(self, path: str):
        """Create empty file."""
        file_path = self.resolve_path(path)

        if file_path.exists():
            raise FileExistsError(f"File already exists: {path}")

        # Create parent directories if needed
        file_path.parent.mkdir(parents=True, exist_ok=True)

        # Create empty file
        file_path.touch()

        logger.info(f"Created file: {path}")

    async def create_folder(self, path: str):
        """Create folder."""
        dir_path = self.resolve_path(path)

        if dir_path.exists():
            raise FileExistsError(f"Folder already exists: {path}")

        dir_path.mkdir(parents=True, exist_ok=True)

        logger.info(f"Created folder: {path}")

    async def delete(self, path: str):
        """Delete file or folder.

        Raises:
            FileNotFoundError: If path doesn't exist
            ValueError: If trying to delete project root
            PermissionError: If lacking permissions to delete
            OSError: If deletion fails for other reasons
        """
        item_path = self.resolve_path(path)

        if not item_path.exists():
            raise FileNotFoundError(f"Path not found: {path}")

        # Prevent deleting project root
        if item_path == self.project_root:
            raise ValueError("Cannot delete project root")

        if item_path.is_file():
            try:
                item_path.unlink()
            except PermissionError:
                raise PermissionError(f"Permission denied: cannot delete {path}")
        else:
            # Track errors during recursive deletion
            errors = []

            def on_error(func, path_str, exc_info):
                """Collect errors during rmtree instead of silently ignoring."""
                errors.append((path_str, exc_info[1]))

            shutil.rmtree(item_path, onerror=on_error)

            if errors:
                # Report the first error encountered
                failed_path, error = errors[0]
                if isinstance(error, PermissionError):
                    raise PermissionError(
                        f"Permission denied while deleting {path}: "
                        f"could not remove {failed_path}"
                    )
                raise OSError(
                    f"Failed to delete {path}: {error} (at {failed_path})"
                )

        logger.info(f"Deleted: {path}")

    async def rename(self, old_path: str, new_path: str):
        """Rename/move file or folder."""
        old = self.resolve_path(old_path)
        new = self.resolve_path(new_path)

        if not old.exists():
            raise FileNotFoundError(f"Path not found: {old_path}")

        if new.exists():
            raise FileExistsError(f"Destination already exists: {new_path}")

        # Create parent directories for new path if needed
        new.parent.mkdir(parents=True, exist_ok=True)

        old.rename(new)

        logger.info(f"Renamed: {old_path} -> {new_path}")

    async def copy_item(self, source_path: str, dest_path: str):
        """Copy file or folder to a new location.

        Args:
            source_path: Relative path of source file/folder
            dest_path: Relative path of destination
        """
        source = self.resolve_path(source_path)
        dest = self.resolve_path(dest_path)

        if not source.exists():
            raise FileNotFoundError(f"Source not found: {source_path}")

        if dest.exists():
            raise FileExistsError(f"Destination already exists: {dest_path}")

        # Prevent copying a directory into itself or its descendants (infinite loop)
        if source.is_dir():
            if dest == source or dest.is_relative_to(source):
                raise ValueError("Cannot copy a folder into itself or its subfolder")

        dest.parent.mkdir(parents=True, exist_ok=True)

        if source.is_file():
            await asyncio.to_thread(shutil.copy2, source, dest)
        else:
            await asyncio.to_thread(shutil.copytree, source, dest)

        logger.info(f"Copied: {source_path} -> {dest_path}")

    async def move_item(self, source_path: str, dest_path: str):
        """Move file or folder to a new location.

        Args:
            source_path: Relative path of source file/folder
            dest_path: Relative path of destination
        """
        source = self.resolve_path(source_path)
        dest = self.resolve_path(dest_path)

        if not source.exists():
            raise FileNotFoundError(f"Source not found: {source_path}")

        if dest.exists():
            raise FileExistsError(f"Destination already exists: {dest_path}")

        if source == self.project_root:
            raise ValueError("Cannot move project root")

        # Prevent moving a directory into itself or its descendants
        if source.is_dir():
            if dest == source or dest.is_relative_to(source):
                raise ValueError("Cannot move a folder into itself or its subfolder")

        dest.parent.mkdir(parents=True, exist_ok=True)
        await asyncio.to_thread(shutil.move, str(source), str(dest))

        logger.info(f"Moved: {source_path} -> {dest_path}")

    def get_file_info(self, path: str) -> Dict:
        """Get file or folder information."""
        item_path = self.resolve_path(path)

        if not item_path.exists():
            raise FileNotFoundError(f"Path not found: {path}")

        stat = item_path.stat()
        relative = item_path.relative_to(self.project_root)

        return {
            "name": item_path.name,
            "path": self._normalize_output_path(str(relative)),
            "type": "directory" if item_path.is_dir() else "file",
            "size": stat.st_size if item_path.is_file() else None,
            "modified": stat.st_mtime,
            "created": stat.st_ctime,
        }

    def exists(self, path: str) -> bool:
        """Check if path exists."""
        try:
            item_path = self.resolve_path(path)
            return item_path.exists()
        except ValueError:
            return False

    async def find_notebooks(self) -> List[Dict]:
        """
        Recursively find all .ipynb files in the project.

        Returns:
            List of notebook dicts with path, name, and folder.

        Note: Runs in thread pool to avoid blocking event loop for large projects.
        """
        self._require_root()

        # Run synchronous I/O in thread pool to avoid blocking event loop
        return await asyncio.to_thread(self._find_notebooks_sync)

    def _find_notebooks_sync(self) -> List[Dict]:
        """Synchronous notebook finding implementation."""
        notebooks = []
        skip_dirs = {
            "__pycache__", "node_modules", ".git", ".venv", "venv",
            ".ipynb_checkpoints", "env", ".env", ".tox", ".nox",
            "dist", "build", ".eggs", "*.egg-info",
        }

        for ipynb in self.project_root.rglob("*.ipynb"):
            # Skip if any parent directory is in skip list
            parts = ipynb.relative_to(self.project_root).parts
            if any(part in skip_dirs or part.startswith(".") for part in parts[:-1]):
                continue

            # Skip checkpoint files
            if ".ipynb_checkpoints" in str(ipynb):
                continue

            try:
                rel_path = ipynb.relative_to(self.project_root)
                stat = ipynb.stat()

                notebooks.append({
                    "path": self._normalize_output_path(str(rel_path)),
                    "name": ipynb.name,
                    "folder": self._normalize_output_path(str(rel_path.parent)) if rel_path.parent != Path(".") else "",
                    "modified": stat.st_mtime,
                    "size": stat.st_size,
                })
            except (OSError, PermissionError) as e:
                logger.warning(f"Could not access notebook {ipynb}: {e}")
                continue

        # Sort by folder, then by name
        notebooks.sort(key=lambda x: (x["folder"], x["name"].lower()))

        return notebooks

    async def edit_file(
        self, path: str, old_string: str, new_string: str, replace_all: bool = False
    ) -> Dict:
        """
        Perform a targeted find-and-replace edit on a file.

        Args:
            path: Relative path to the file
            old_string: The exact text to find
            new_string: The replacement text
            replace_all: If True, replace all occurrences. If False, the old_string
                must appear exactly once (error if 0 or >1 matches).

        Returns:
            Dict with success status and number of replacements made.

        Raises:
            FileNotFoundError: If file doesn't exist
            ValueError: If old_string not found, or found multiple times without replace_all
        """
        file_path = self.resolve_path(path)

        if not file_path.exists():
            raise FileNotFoundError(f"File not found: {path}")
        if not file_path.is_file():
            raise ValueError(f"Not a file: {path}")

        async with self._get_file_lock(str(file_path)):
            content = await self.read_file(path)
            count = content.count(old_string)

            if count == 0:
                raise ValueError("old_string not found in file")
            if count > 1 and not replace_all:
                raise ValueError(
                    f"old_string found {count} times. Use replace_all=true to replace all, "
                    "or provide more context to make it unique."
                )

            new_content = content.replace(old_string, new_string) if replace_all else content.replace(old_string, new_string, 1)
            await self.write_file(path, new_content)

        replacements = count if replace_all else 1
        logger.info(f"Edited file: {path} ({replacements} replacement(s))")
        return {"success": True, "replacements": replacements}

    async def glob_files(self, pattern: str, path: str = "") -> List[str]:
        """
        Find files matching a glob pattern within the project.

        Args:
            pattern: Glob pattern (e.g., "**/*.py", "src/**/*.ts", "*.csv")
            path: Optional subdirectory to search in (relative to project root)

        Returns:
            List of matching file paths relative to project root.

        Security: Only returns files within the project root.
        """
        self._require_root()
        search_root = self.resolve_path(path) if path else self.project_root

        if not search_root.exists():
            raise FileNotFoundError(f"Directory not found: {path}")
        if not search_root.is_dir():
            raise ValueError(f"Not a directory: {path}")

        return await asyncio.to_thread(self._glob_files_sync, pattern, search_root)

    _SKIP_DIRS = frozenset({
        "__pycache__", "node_modules", ".git", ".venv", "venv",
        ".ipynb_checkpoints", "env", ".env", ".tox", ".nox",
        "dist", "build",
    })

    @staticmethod
    def _matches_glob(rel_path: str, pattern: str) -> bool:
        """Match a relative path against a glob pattern, handling ** correctly.

        fnmatch treats * as matching everything (including /) but doesn't
        treat ** as a recursive wildcard. This means fnmatch("file.py", "**/*.py")
        returns False because there's no / in the path. We fix this by stripping
        leading **/ prefixes and retrying.
        """
        if fnmatch.fnmatch(rel_path, pattern):
            return True
        if pattern.startswith("**/"):
            return FileManager._matches_glob(rel_path, pattern[3:])
        return False

    def _glob_files_sync(self, pattern: str, search_root: Path) -> List[str]:
        """Synchronous glob implementation using os.walk for directory pruning."""
        results = []
        max_results = 1000
        resolved_root = self.project_root.resolve()

        for dirpath, dirnames, filenames in os.walk(search_root):
            # Prune skip dirs in-place — os.walk won't descend into them
            dirnames[:] = [
                d for d in dirnames
                if d not in self._SKIP_DIRS and not (d.startswith(".") and d != ".")
            ]

            for filename in filenames:
                if len(results) >= max_results:
                    break

                file_path = Path(dirpath) / filename

                # Match against the pattern (relative to search_root)
                try:
                    rel_to_search = file_path.relative_to(search_root)
                except ValueError:
                    continue
                if not self._matches_glob(self._normalize_output_path(str(rel_to_search)), pattern):
                    continue

                # Security: resolve to prevent traversal
                if not self._is_within_project(file_path):
                    continue
                try:
                    rel = file_path.resolve().relative_to(resolved_root)
                except ValueError:
                    continue

                results.append(self._normalize_output_path(str(rel)))

            if len(results) >= max_results:
                break

        results.sort()
        return results

    async def grep_files(
        self,
        pattern: str,
        path: str = "",
        include: str = "",
        max_matches: int = 100,
    ) -> List[Dict]:
        """
        Search file contents with regex.

        Args:
            pattern: Regex pattern to search for
            path: Optional subdirectory to search in (relative to project root)
            include: Optional glob pattern to filter files (e.g., "*.py", "*.ts")
            max_matches: Maximum number of matches to return (default: 100)

        Returns:
            List of match dicts: {file, line, column, match, context}

        Security: Only searches files within the project root. Enforces max file size.
        """
        self._require_root()
        search_root = self.resolve_path(path) if path else self.project_root

        if not search_root.exists():
            raise FileNotFoundError(f"Directory not found: {path}")
        if not search_root.is_dir():
            raise ValueError(f"Not a directory: {path}")

        try:
            compiled = re.compile(pattern)
        except re.error as e:
            raise ValueError(f"Invalid regex pattern: {e}")

        return await asyncio.to_thread(
            self._grep_files_sync, compiled, search_root, include, max_matches
        )

    # Binary-ish extensions to skip in grep
    _SKIP_EXTENSIONS = frozenset({
        ".pyc", ".pyo", ".so", ".dylib", ".dll", ".exe",
        ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg",
        ".woff", ".woff2", ".ttf", ".eot",
        ".zip", ".tar", ".gz", ".bz2", ".xz",
        ".pdf", ".doc", ".docx", ".xls", ".xlsx",
        ".sqlite", ".db",
    })

    def _grep_files_sync(
        self, compiled: "re.Pattern", search_root: Path, include: str, max_matches: int
    ) -> List[Dict]:
        """Synchronous grep implementation using os.walk for directory pruning."""
        max_file_size = 1 * 1024 * 1024  # 1MB per file for grep
        resolved_root = self.project_root.resolve()
        results = []

        for dirpath, dirnames, filenames in os.walk(search_root):
            # Prune skip dirs in-place — os.walk won't descend into them
            dirnames[:] = [
                d for d in dirnames
                if d not in self._SKIP_DIRS and not (d.startswith(".") and d != ".")
            ]

            for filename in filenames:
                if len(results) >= max_matches:
                    break

                # Skip binary extensions
                ext = os.path.splitext(filename)[1].lower()
                if ext in self._SKIP_EXTENSIONS:
                    continue

                # Apply include filter
                if include and not fnmatch.fnmatch(filename, include):
                    continue

                file_path = Path(dirpath) / filename

                # Security: resolve to catch symlinks and traversal
                if not self._is_within_project(file_path):
                    continue
                try:
                    rel = file_path.resolve().relative_to(resolved_root)
                except ValueError:
                    continue

                # Skip large files
                try:
                    if file_path.stat().st_size > max_file_size:
                        continue
                except OSError:
                    continue

                try:
                    content = file_path.read_text(encoding="utf-8", errors="ignore")
                except (OSError, PermissionError):
                    continue

                for line_num, line in enumerate(content.splitlines(), 1):
                    if len(results) >= max_matches:
                        break
                    m = compiled.search(line[:10_000])
                    if m:
                        results.append({
                            "file": self._normalize_output_path(str(rel)),
                            "line": line_num,
                            "column": m.start() + 1,
                            "match": m.group(),
                            "context": line.rstrip()[:500],
                        })

            if len(results) >= max_matches:
                break

        return results

    async def apply_patch(self, path: str, patch: str) -> Dict:
        """
        Apply a unified diff patch to a file using patch-ng.

        Ensures the patch only modifies the requested path within the project root.
        """
        file_path = self.resolve_path(path)

        if not file_path.exists():
            raise FileNotFoundError(f"File not found: {path}")

        if not file_path.is_file():
            raise ValueError(f"Not a file: {path}")

        try:
            patch_set = patch_ng.fromstring(patch.encode("utf-8"))
        except Exception as exc:
            raise ValueError(f"Invalid patch format: {exc}") from exc

        if not patch_set:
            raise ValueError("Patch did not contain any changes")

        expected_rel_path = str(PurePosixPath(path))
        normalized_targets = set()
        saw_prefixed = False
        saw_unprefixed = False

        for patch_obj in patch_set:
            normalized, prefixed = self._normalize_patch_path(
                patch_obj.target or patch_obj.source
            )
            if normalized:
                normalized_targets.add(normalized)
            if prefixed:
                saw_prefixed = True
            else:
                saw_unprefixed = True

        if not normalized_targets:
            normalized_targets.add(expected_rel_path)

        if normalized_targets != {expected_rel_path}:
            raise ValueError("Patch references unexpected files")

        if saw_prefixed and saw_unprefixed:
            raise ValueError("Patch mixes prefixed and unprefixed paths")

        strip = 1 if saw_prefixed and not saw_unprefixed else 0

        success = patch_set.apply(strip=strip, root=str(self.project_root))
        if not success:
            raise ValueError("Failed to apply patch")

        logger.info(f"Applied patch to: {path}")
        return {"success": True, "path": path}

    def _normalize_patch_path(
        self, raw_path: Optional[bytes]
    ) -> Tuple[Optional[str], bool]:
        """Normalize a patch path and detect VCS-style prefixes."""
        if not raw_path:
            return None, False

        decoded = raw_path.decode("utf-8", errors="ignore").strip()
        prefixed = False

        if decoded.startswith("a/") or decoded.startswith("b/"):
            decoded = decoded[2:]
            prefixed = True

        if decoded.startswith("./"):
            decoded = decoded[2:]

        normalized = str(PurePosixPath(decoded))
        return normalized, prefixed
