"""File operation tools with Aider-inspired patterns.

Provides secure file I/O with:
- Path traversal prevention (all paths resolve within repo_root)
- Symlink escape prevention
- Binary file detection
- Image file detection
- Configurable encoding with fallbacks (UTF-8 -> Latin-1)
- Size limits (100KB max, truncated to 100,000 chars)
- Line limits for bounded reads
- Write with retry (exponential backoff for locked files)
- Hidden file warnings
- Silent mode for error suppression
- Error handling via ToolResult (not exceptions)
"""

from __future__ import annotations

import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .executor import ToolResult

logger = logging.getLogger(__name__)

# Constants
MAX_FILE_SIZE = 100 * 1024  # 100KB
MAX_CHARS = 100_000  # 100,000 characters for truncation
DEFAULT_MAX_LINES = 500

# Encoding configuration (Aider-inspired)
DEFAULT_ENCODING = "utf-8"
FALLBACK_ENCODING = "latin-1"

# Image file extensions (Aider-inspired)
IMAGE_EXTENSIONS = frozenset({
    ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif",
    ".webp", ".svg", ".ico", ".heic", ".heif", ".avif",
})

# Hidden file patterns
HIDDEN_FILE_PREFIXES = (".",)
SENSITIVE_FILE_NAMES = {".env", ".env.local", ".env.production", ".secrets"}


def _get_tool_result() -> type:
    """Import ToolResult lazily to avoid circular imports."""
    from .executor import ToolResult
    return ToolResult


def _is_binary_file(path: Path, sample_size: int = 8192) -> bool:
    """Check if a file appears to be binary.

    Reads the first sample_size bytes and checks for null bytes.

    Args:
        path: Path to the file to check.
        sample_size: Number of bytes to sample (default: 8192).

    Returns:
        True if the file appears to be binary, False otherwise.
    """
    try:
        with open(path, "rb") as f:
            sample = f.read(sample_size)
            # Check for null bytes which indicate binary content
            return b"\x00" in sample
    except Exception:
        return False


def _is_hidden_file(path: Path) -> bool:
    """Check if a file is hidden (starts with dot).

    Args:
        path: Path to check.

    Returns:
        True if the file is hidden, False otherwise.
    """
    return path.name.startswith(".")


def is_image_file(path: str | Path) -> bool:
    """Check if a file is an image based on extension.

    Aider-inspired pattern for detecting image files that need special handling.

    Args:
        path: File path to check.

    Returns:
        True if the file has an image extension, False otherwise.

    Example:
        >>> is_image_file("screenshot.png")
        True
        >>> is_image_file("code.py")
        False
    """
    return Path(path).suffix.lower() in IMAGE_EXTENSIONS


def _resolve_safe_path(path: str | Path, repo_root: Path, check_symlinks: bool = True) -> Path | str:
    """Resolve path safely within repo_root.

    Args:
        path: The path to resolve (absolute or relative)
        repo_root: The repository root that all paths must resolve within
        check_symlinks: Whether to check for symlink escape (default: True)

    Returns:
        Resolved Path if safe, or error string if:
        - Path is absolute and outside repo_root
        - Path traversal detected (../ escaping repo_root)
        - Symlink points outside repo_root
    """
    try:
        repo_root = repo_root.resolve()

        # Handle both absolute and relative paths
        path_obj = Path(path)
        if path_obj.is_absolute():
            # Reject absolute paths that are outside repo_root
            resolved = path_obj.resolve()
            try:
                resolved.relative_to(repo_root)
            except ValueError:
                return f"Error: Absolute path outside repository: {path}"
        else:
            resolved = (repo_root / path_obj).resolve()

        # Security check: ensure path is within repo_root
        try:
            resolved.relative_to(repo_root)
        except ValueError:
            return f"Error: Path outside repository: {path}"

        # Check for symlink escape - the symlink target must also be within repo_root
        if check_symlinks:
            # Check the original path (before resolution) for symlinks
            original_path = repo_root / path_obj if not path_obj.is_absolute() else path_obj
            if original_path.is_symlink():
                # The resolved path is where the symlink points
                # If it's outside repo_root, that's an escape
                try:
                    resolved.relative_to(repo_root)
                except ValueError:
                    return f"Error: Symlink escape detected: {path}"

        return resolved
    except Exception as e:
        return f"Error: Invalid path '{path}': {e}"


async def read_file(
    path: str,
    repo_root: Path,
    max_lines: int = DEFAULT_MAX_LINES,
    encoding: str = DEFAULT_ENCODING,
    silent: bool = False,
) -> "ToolResult":
    """Read file with bounded line count.

    Reads up to max_lines from the file. Enforces path safety, symlink checks,
    binary detection, and size limits. Uses configurable encoding with fallback.
    Logs warnings for hidden/sensitive files (unless silent mode).

    Aider-inspired patterns:
    - Configurable encoding with fallback
    - Silent mode for error suppression
    - Image file detection
    - Better error categorization

    Args:
        path: File path (absolute or relative to repo_root)
        repo_root: Repository root directory for path safety validation
        max_lines: Maximum number of lines to read (default: 500)
        encoding: Primary encoding to use (default: UTF-8)
        silent: If True, suppress warning/info logging (default: False)

    Returns:
        ToolResult with success=True and data=content on success,
        or success=False and error message on failure.

    Example:
        >>> result = await read_file("large_file.py", Path("/repo"), max_lines=100)
        >>> if result.success:
        ...     process(result.data)
    """
    ToolResult = _get_tool_result()

    # Check for absolute paths outside repo - reject with specific message
    path_obj = Path(path)
    if path_obj.is_absolute():
        try:
            path_obj.resolve().relative_to(repo_root.resolve())
        except ValueError:
            return ToolResult(success=False, error=f"Absolute path outside repository: {path}")

    # Resolve and validate path (includes symlink check)
    resolved = _resolve_safe_path(path, repo_root, check_symlinks=True)
    if isinstance(resolved, str):
        error_msg = resolved.replace("Error: ", "")
        # Provide specific error messages for different cases
        if "symlink" in error_msg.lower():
            return ToolResult(success=False, error=f"Symlink escape detected: {path}")
        if "outside" in error_msg.lower() or "traversal" in error_msg.lower():
            return ToolResult(success=False, error=f"Path outside repository: {path}")
        return ToolResult(success=False, error=error_msg)

    # Check file exists (Aider-inspired error categorization)
    if not resolved.exists():
        if not silent:
            logger.warning(f"File not found: {path}")
        return ToolResult(success=False, error=f"File not found: {path}")

    if not resolved.is_file():
        if not silent:
            logger.warning(f"Not a file: {path}")
        return ToolResult(success=False, error=f"Not a file: {path}")

    # Check for permission issues
    try:
        if not os.access(resolved, os.R_OK):
            if not silent:
                logger.warning(f"Permission denied: {path}")
            return ToolResult(success=False, error=f"Permission denied: {path}")
    except OSError:
        pass  # Will be caught when trying to read

    # Log warning for hidden files (unless silent)
    if _is_hidden_file(resolved) and not silent:
        logger.warning(f"Reading hidden file: {path}")

    # Check for image file (Aider-inspired)
    if is_image_file(resolved):
        if not silent:
            logger.info(f"Image file detected: {path}")
        return ToolResult(success=False, error=f"Image file detected (use image handler): {path}")

    # Check for binary file
    if _is_binary_file(resolved):
        if not silent:
            logger.info(f"Binary file detected: {path}")
        return ToolResult(success=False, error=f"Binary file detected: {path}")

    # Check file size
    try:
        size = resolved.stat().st_size
    except PermissionError:
        if not silent:
            logger.warning(f"Permission denied: {path}")
        return ToolResult(success=False, error=f"Permission denied: {path}")
    except OSError as e:
        if not silent:
            logger.error(f"Cannot stat file '{path}': {e}")
        return ToolResult(success=False, error=f"Cannot stat file '{path}': {e}")

    # Read with encoding fallback and apply limits (Aider-inspired)
    try:
        # Try primary encoding first
        try:
            text = resolved.read_text(encoding=encoding)
        except UnicodeDecodeError:
            # Fallback to secondary encoding
            try:
                text = resolved.read_text(encoding=FALLBACK_ENCODING)
            except UnicodeDecodeError as e:
                if not silent:
                    logger.error(f"Cannot decode file '{path}': {e}")
                    logger.info(f"Try specifying encoding explicitly for '{path}'")
                return ToolResult(success=False, error=f"Cannot decode file '{path}': {e}")
        except PermissionError:
            if not silent:
                logger.warning(f"Permission denied: {path}")
            return ToolResult(success=False, error=f"Permission denied: {path}")
        except IsADirectoryError:
            if not silent:
                logger.warning(f"Is a directory: {path}")
            return ToolResult(success=False, error=f"Is a directory: {path}")

        # Apply character limit (truncate at MAX_CHARS)
        truncated_msg = None
        if len(text) > MAX_CHARS:
            text = text[:MAX_CHARS]
            truncated_msg = f"Truncated at {MAX_CHARS} characters"

        # Apply line limit
        lines = text.splitlines(keepends=True)
        if len(lines) > max_lines:
            text = "".join(lines[:max_lines])
            truncated_msg = f"Truncated at {max_lines} lines"

        return ToolResult(
            success=True,
            data=text,
            error=truncated_msg  # Use error field for truncation info
        )

    except PermissionError:
        if not silent:
            logger.warning(f"Permission denied: {path}")
        return ToolResult(success=False, error=f"Permission denied: {path}")
    except FileNotFoundError:
        if not silent:
            logger.warning(f"File not found: {path}")
        return ToolResult(success=False, error=f"File not found: {path}")
    except OSError as e:
        if not silent:
            logger.error(f"Cannot read file '{path}': {e}")
        return ToolResult(success=False, error=f"Cannot read file '{path}': {e}")


async def read_file_chunked(
    path: str,
    repo_root: Path,
    start_line: int = 1,
    max_lines: int = 100,
    encoding: str = DEFAULT_ENCODING,
    silent: bool = False,
) -> "ToolResult":
    """Read a specific chunk of lines from a file.

    Useful for reading large files in chunks to avoid token limits.
    Lines are 1-indexed (start_line=1 means first line).

    Args:
        path: File path (absolute or relative to repo_root)
        repo_root: Repository root directory for path safety validation
        start_line: Line number to start reading from (1-indexed, default: 1)
        max_lines: Maximum number of lines to read (default: 100)
        encoding: Primary encoding to use (default: UTF-8)
        silent: If True, suppress warning/info logging (default: False)

    Returns:
        ToolResult with success=True and data=content on success,
        or success=False and error message on failure.
        The content includes header with line range and navigation hints.

    Example:
        >>> result = await read_file_chunked("large_file.py", Path("/repo"), start_line=100, max_lines=50)
        >>> if result.success:
        ...     process(result.data)  # Lines 100-149
    """
    ToolResult = _get_tool_result()

    # Validate start_line
    if start_line < 1:
        start_line = 1

    # Use read_file to get all content, then extract the chunk
    full_result = await read_file(
        path=path,
        repo_root=repo_root,
        max_lines=start_line + max_lines + 100,  # Read extra to detect if there's more
        encoding=encoding,
        silent=silent,
    )

    if not full_result.success:
        return full_result

    # Split into lines and extract chunk
    lines = full_result.data.splitlines()
    total_lines = len(lines)

    # Validate start_line against file length
    if start_line > total_lines:
        return ToolResult(
            success=False,
            error=f"start_line {start_line} is beyond file length ({total_lines} lines)"
        )

    # Calculate actual range (0-indexed for array access)
    start_idx = start_line - 1
    end_idx = min(start_idx + max_lines, total_lines)
    end_line = end_idx  # 1-indexed for display

    # Extract chunk with line numbers
    chunk_lines = []
    for i in range(start_idx, end_idx):
        line_num = i + 1
        chunk_lines.append(f"{line_num:6}| {lines[i]}")

    chunk_content = "\n".join(chunk_lines)

    # Build header
    header = f"# File: {path}\n"
    header += f"# Lines {start_line}-{end_line} of {total_lines}\n"
    header += "=" * 50 + "\n"

    # Build footer with navigation hints
    footer = "\n" + "=" * 50 + "\n"
    if start_line > 1:
        prev_start = max(1, start_line - max_lines)
        footer += f"Previous chunk: read_file_chunked('{path}', start_line={prev_start}, max_lines={max_lines})\n"
    if end_line < total_lines:
        footer += f"Next chunk: read_file_chunked('{path}', start_line={end_line + 1}, max_lines={max_lines})\n"

    return ToolResult(success=True, data=header + chunk_content + footer)


async def list_files(path: str, repo_root: Path, pattern: str = "*") -> "ToolResult":
    """List files in directory matching pattern.

    Returns relative paths from repo_root. Enforces path safety.

    Args:
        path: Directory path (absolute or relative to repo_root)
        repo_root: Repository root directory for path safety validation
        pattern: Glob pattern for matching files (default: "*")

    Returns:
        ToolResult with success=True and data=list[str] of relative file paths,
        or success=False and error message on failure.

    Example:
        >>> result = await list_files("src", Path("/repo"), "*.py")
        >>> if result.success:
        ...     for f in result.data:
        ...         print(f)
    """
    ToolResult = _get_tool_result()

    # Resolve and validate path
    resolved = _resolve_safe_path(path, repo_root)
    if isinstance(resolved, str):
        return ToolResult(success=False, error=resolved.replace("Error: ", ""))

    # Check directory exists
    if not resolved.exists():
        return ToolResult(success=False, error=f"Directory not found: {path}")

    if not resolved.is_dir():
        return ToolResult(success=False, error=f"Not a directory: {path}")

    try:
        repo_root_resolved = repo_root.resolve()
        results: list[str] = []

        for match in resolved.glob(pattern):
            # Only include files, not directories
            if match.is_file():
                # Return paths relative to repo_root
                try:
                    rel_path = match.relative_to(repo_root_resolved)
                    results.append(str(rel_path))
                except ValueError:
                    # Skip files outside repo_root (shouldn't happen with safe path)
                    continue

        return ToolResult(success=True, data=sorted(results))

    except Exception as e:
        return ToolResult(success=False, error=f"Cannot list directory '{path}': {e}")


