"""
ReadTool - Read file contents from the filesystem

Following OpenCode architecture, provides file reading capabilities with:
- Path validation (absolute and relative)
- Path traversal protection (SEC-003)
- Async file I/O (PERF-009)
- Memory-mapped files for large files (OPT-003)
- Error handling (file not found, encoding errors)
- .gitignore pattern respect
- Image file support (PNG, JPG, GIF, WebP) via PIL
- PDF file support via pypdf
- Type-safe implementation
"""

import asyncio
import base64
import io
import mmap
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from pathlib import Path
from typing import Any, Optional

from groknroll.tools.base_tool import BaseTool
from groknroll.tools.descriptions import get_enhanced_description
from groknroll.tools.validators import (
    validate_path_within_workspace,
    validate_path_input,
)

# Optional image support
try:
    from PIL import Image

    PIL_AVAILABLE = True
except ImportError:
    PIL_AVAILABLE = False

# Optional PDF support
try:
    from pypdf import PdfReader

    PYPDF_AVAILABLE = True
except ImportError:
    PYPDF_AVAILABLE = False


# Supported media file extensions
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".ico"}
PDF_EXTENSIONS = {".pdf"}


# OPT-003: Threshold for using memory-mapped files (10MB)
MMAP_THRESHOLD_BYTES = 10 * 1024 * 1024


# PERF-009: Shared thread pool for async file operations
_file_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="file_io")


class ReadTool(BaseTool):
    """
    Tool for reading file contents from the filesystem

    Accepts:
        path: str - File path (absolute or relative)

    Returns:
        str - File contents

    Raises:
        FileNotFoundError: If file doesn't exist
        PermissionError: If file can't be read
        UnicodeDecodeError: If file encoding fails

    Example:
        tool = ReadTool()
        content = await tool.execute(path="app.py")
    """

    def __init__(self, workspace_root: Optional[str] = None):
        """
        Initialize ReadTool

        Args:
            workspace_root: Optional root directory for relative paths.
                           Defaults to current working directory.
        """
        self._workspace_root = Path(workspace_root) if workspace_root else Path.cwd()

    @property
    def name(self) -> str:
        """Tool identifier"""
        return "read"

    @property
    def description(self) -> str:
        """Human-readable description"""
        return get_enhanced_description("read", "Read file contents from the filesystem")

    def get_parameters_schema(self) -> dict[str, Any]:
        """JSON Schema for tool parameters - Anthropic API compatible"""
        return {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "File path (absolute or relative to workspace root)",
                },
                "offset": {
                    "type": "integer",
                    "description": "Line number to start reading from (1-indexed). Optional.",
                },
                "limit": {
                    "type": "integer",
                    "description": "Maximum number of lines to read. Optional.",
                },
            },
            "required": ["path"],
        }

    def validate_params(self, **kwargs) -> dict[str, Any]:
        """
        Validate parameters before execution

        Args:
            **kwargs: Must contain 'path' parameter

        Returns:
            Validated parameters dict

        Raises:
            ValueError: If path parameter is missing or invalid
        """
        if "path" not in kwargs:
            raise ValueError("path parameter is required")

        # SEC-008: Use standardized validation
        validate_path_input(kwargs["path"])

        return kwargs

    async def execute(self, **kwargs) -> str:
        """
        Execute file read operation

        Args:
            **kwargs: Must contain 'path' parameter

        Returns:
            File contents as string (or description for media files)

        Raises:
            FileNotFoundError: If file doesn't exist
            PermissionError: If file can't be read
            UnicodeDecodeError: If file encoding fails
        """
        # Validate parameters
        validated = self.validate_params(**kwargs)
        path_str = validated["path"]

        # Convert to Path object
        file_path = Path(path_str)

        # Handle relative paths
        if not file_path.is_absolute():
            file_path = self._workspace_root / file_path

        # Normalize path (resolve symlinks, remove ..)
        try:
            file_path = file_path.resolve()
        except (OSError, RuntimeError) as e:
            raise FileNotFoundError(f"Cannot resolve path {path_str}: {e}")

        # SEC-003: Validate path is within workspace after resolution
        validate_path_within_workspace(file_path, self._workspace_root)

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

        # Check it's a file (not directory)
        if not file_path.is_file():
            raise ValueError(f"Path is not a file: {file_path}")

        # Check if file should be ignored based on .gitignore
        # Note: Full .gitignore support would require pathspec library
        # For now, we'll implement basic ignore patterns
        if self._should_ignore(file_path):
            raise PermissionError(f"File matches ignore patterns and cannot be read: {file_path}")

        # Check for media files
        suffix = file_path.suffix.lower()

        # Handle image files
        if suffix in IMAGE_EXTENSIONS:
            return await self._read_image(file_path)

        # Handle PDF files
        if suffix in PDF_EXTENSIONS:
            return await self._read_pdf(file_path)

        # PERF-009 + OPT-003: Read file asynchronously, use mmap for large files
        try:
            loop = asyncio.get_event_loop()

            # OPT-003: Check file size to decide on read strategy
            file_size = file_path.stat().st_size

            if file_size > MMAP_THRESHOLD_BYTES:
                # Use memory-mapped file for large files
                content = await loop.run_in_executor(
                    _file_executor,
                    partial(self._read_with_mmap, file_path)
                )
            else:
                # Standard read for smaller files
                content = await loop.run_in_executor(
                    _file_executor,
                    partial(file_path.read_text, encoding="utf-8")
                )
            return content
        except UnicodeDecodeError as e:
            # Try reading as binary and provide helpful error
            raise UnicodeDecodeError(
                e.encoding,
                e.object,
                e.start,
                e.end,
                f"Failed to decode {file_path} as UTF-8. File may be binary.",
            )
        except PermissionError:
            raise PermissionError(f"Permission denied reading file: {file_path}")

    def _read_with_mmap(self, file_path: Path) -> str:
        """
        Read file using memory-mapped I/O.

        OPT-003: Uses mmap for efficient reading of large files.
        - Lazy loading: only pages accessed are loaded into memory
        - Automatic cleanup: mmap is closed after reading
        - Memory efficient: OS handles page caching

        Args:
            file_path: Path to the file to read

        Returns:
            File contents as string

        Raises:
            UnicodeDecodeError: If file cannot be decoded as UTF-8
        """
        with open(file_path, "rb") as f:
            # Create memory-mapped file
            with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
                # Read entire content (mmap handles paging efficiently)
                content_bytes = mm[:]

        # Decode as UTF-8
        return content_bytes.decode("utf-8")

    async def _read_image(self, file_path: Path) -> str:
        """
        Read image file and return metadata with base64 data.

        For use with multimodal LLMs that can process images.

        Args:
            file_path: Path to the image file

        Returns:
            String with image metadata and base64-encoded data
        """
        if not PIL_AVAILABLE:
            return (
                f"[Image file: {file_path.name}]\n"
                f"Cannot read image contents - PIL not installed.\n"
                f"Install with: pip install pillow"
            )

        loop = asyncio.get_event_loop()

        def read_image_sync() -> str:
            try:
                with Image.open(file_path) as img:
                    # Get image metadata
                    width, height = img.size
                    mode = img.mode
                    format_name = img.format or file_path.suffix.upper().strip(".")

                    # Convert to bytes for base64 encoding
                    buffer = io.BytesIO()
                    # Save as PNG for consistent format
                    img_rgb = img.convert("RGB") if mode == "RGBA" else img
                    img_rgb.save(buffer, format="PNG")
                    img_bytes = buffer.getvalue()

                    # Base64 encode
                    b64_data = base64.b64encode(img_bytes).decode("utf-8")

                    # Return structured data
                    return (
                        f"[Image: {file_path.name}]\n"
                        f"Format: {format_name}\n"
                        f"Size: {width}x{height} pixels\n"
                        f"Mode: {mode}\n"
                        f"File size: {file_path.stat().st_size:,} bytes\n"
                        f"\n"
                        f"[Base64 Data (PNG)]\n"
                        f"data:image/png;base64,{b64_data}"
                    )
            except Exception as e:
                return f"[Image file: {file_path.name}]\nError reading image: {str(e)}"

        return await loop.run_in_executor(_file_executor, read_image_sync)

    async def _read_pdf(self, file_path: Path) -> str:
        """
        Read PDF file and extract text content.

        Args:
            file_path: Path to the PDF file

        Returns:
            String with PDF metadata and extracted text
        """
        if not PYPDF_AVAILABLE:
            return (
                f"[PDF file: {file_path.name}]\n"
                f"Cannot read PDF contents - pypdf not installed.\n"
                f"Install with: pip install pypdf"
            )

        loop = asyncio.get_event_loop()

        def read_pdf_sync() -> str:
            try:
                reader = PdfReader(file_path)
                num_pages = len(reader.pages)

                # Get metadata
                metadata = reader.metadata
                title = metadata.title if metadata and metadata.title else "Unknown"
                author = metadata.author if metadata and metadata.author else "Unknown"

                # Extract text from all pages
                text_parts = []
                for i, page in enumerate(reader.pages, 1):
                    page_text = page.extract_text()
                    if page_text:
                        text_parts.append(f"--- Page {i} ---\n{page_text}")

                text_content = "\n\n".join(text_parts) if text_parts else "(No text content extracted)"

                # Return structured data
                return (
                    f"[PDF: {file_path.name}]\n"
                    f"Title: {title}\n"
                    f"Author: {author}\n"
                    f"Pages: {num_pages}\n"
                    f"File size: {file_path.stat().st_size:,} bytes\n"
                    f"\n"
                    f"[Extracted Text]\n"
                    f"{text_content}"
                )
            except Exception as e:
                return f"[PDF file: {file_path.name}]\nError reading PDF: {str(e)}"

        return await loop.run_in_executor(_file_executor, read_pdf_sync)

    def _should_ignore(self, file_path: Path) -> bool:
        """
        Check if file matches ignore patterns

        Args:
            file_path: Path to check

        Returns:
            True if file should be ignored, False otherwise

        Note:
            Basic implementation. Full .gitignore support would use pathspec library.
        """
        # Common patterns to ignore
        ignore_patterns = [
            ".git",
            "__pycache__",
            ".pyc",
            ".pyo",
            ".pyd",
            ".so",
            ".dylib",
            ".dll",
            ".exe",
            "node_modules",
            ".venv",
            "venv",
            ".env",
            ".DS_Store",
        ]

        # Check if any part of the path matches ignore patterns
        parts = file_path.parts
        for part in parts:
            for pattern in ignore_patterns:
                if pattern in part:
                    return True

        # Check file extension
        if file_path.suffix in [".pyc", ".pyo", ".pyd", ".so", ".dylib", ".dll"]:
            return True

        # Check if .gitignore exists and use it
        # For now, just check for .git directory
        gitignore_path = self._workspace_root / ".gitignore"
        if gitignore_path.exists():
            # Basic .gitignore support: check for exact matches
            # Full support would require pathspec library
            try:
                gitignore_content = gitignore_path.read_text(encoding="utf-8")
                relative_path = file_path.relative_to(self._workspace_root)

                for line in gitignore_content.splitlines():
                    line = line.strip()
                    # Skip comments and empty lines
                    if not line or line.startswith("#"):
                        continue

                    # Basic pattern matching (not full gitignore spec)
                    if line in str(relative_path):
                        return True
            except Exception:
                # If .gitignore parsing fails, continue without it
                pass

        return False
