"""
PatchTool - Apply unified diff patches to files

Following OpenCode architecture, provides patch application with:
- Unified diff format support
- Dry-run mode for validation
- Context line verification
- Fuzz factor for fuzzy matching
- Type-safe implementation
"""

import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional

from groknroll.tools.base_tool import BaseTool


@dataclass
class HunkHeader:
    """Parsed hunk header from unified diff"""

    old_start: int
    old_count: int
    new_start: int
    new_count: int

    @classmethod
    def parse(cls, line: str) -> Optional["HunkHeader"]:
        """
        Parse a hunk header line

        Args:
            line: Line like "@@ -1,5 +1,6 @@"

        Returns:
            HunkHeader or None if not a valid header
        """
        match = re.match(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", line)
        if not match:
            return None

        old_start = int(match.group(1))
        old_count = int(match.group(2)) if match.group(2) else 1
        new_start = int(match.group(3))
        new_count = int(match.group(4)) if match.group(4) else 1

        return cls(
            old_start=old_start,
            old_count=old_count,
            new_start=new_start,
            new_count=new_count,
        )


@dataclass
class PatchHunk:
    """A single hunk from a patch"""

    header: HunkHeader
    lines: list[str]  # Lines with +/- /space prefix

    def get_old_lines(self) -> list[str]:
        """Get lines from original file (context and removed)"""
        result = []
        for line in self.lines:
            if line.startswith(" ") or line.startswith("-"):
                result.append(line[1:] if len(line) > 1 else "")
        return result

    def get_new_lines(self) -> list[str]:
        """Get lines for new file (context and added)"""
        result = []
        for line in self.lines:
            if line.startswith(" ") or line.startswith("+"):
                result.append(line[1:] if len(line) > 1 else "")
        return result


@dataclass
class PatchFile:
    """Parsed patch for a single file"""

    old_path: str
    new_path: str
    hunks: list[PatchHunk]


@dataclass
class PatchResult:
    """Result of a patch operation"""

    success: bool
    path: str
    applied_hunks: int
    total_hunks: int
    dry_run: bool
    backup_path: Optional[str] = None
    error: Optional[str] = None
    failed_hunks: list[int] = None

    def __post_init__(self):
        if self.failed_hunks is None:
            self.failed_hunks = []

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary"""
        return {
            "success": self.success,
            "path": self.path,
            "applied_hunks": self.applied_hunks,
            "total_hunks": self.total_hunks,
            "dry_run": self.dry_run,
            "backup_path": self.backup_path,
            "error": self.error,
            "failed_hunks": self.failed_hunks,
        }


class PatchTool(BaseTool):
    """
    Tool for applying unified diff patches

    Accepts:
        patch: str - The unified diff patch content
        path: str (optional) - Target file path (overrides patch header)
        dry_run: bool (optional) - Validate without applying (default: False)
        backup: bool (optional) - Create backup before patching (default: True)
        fuzz: int (optional) - Fuzz factor for fuzzy matching (default: 0)

    Returns:
        PatchResult - Result of patch operation

    Raises:
        FileNotFoundError: If target file doesn't exist
        ValueError: If patch format is invalid

    Example:
        tool = PatchTool()

        patch = '''
        --- a/file.py
        +++ b/file.py
        @@ -1,3 +1,4 @@
         first line
        +new line
         second line
         third line
        '''

        result = await tool.execute(patch=patch, dry_run=True)
        if result.success:
            result = await tool.execute(patch=patch)
    """

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

        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 "patch"

    @property
    def description(self) -> str:
        """Human-readable description"""
        return "Apply unified diff patches to files"

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

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

        Returns:
            Validated parameters dict

        Raises:
            ValueError: If parameters are invalid
        """
        if "patch" not in kwargs:
            raise ValueError("patch parameter is required")

        patch = kwargs["patch"]
        if not isinstance(patch, str):
            raise ValueError(f"patch must be a string, got {type(patch).__name__}")

        if not patch.strip():
            raise ValueError("patch cannot be empty")

        # Validate fuzz factor
        fuzz = kwargs.get("fuzz", 0)
        if not isinstance(fuzz, int):
            raise ValueError(f"fuzz must be an integer, got {type(fuzz).__name__}")
        if fuzz < 0:
            raise ValueError(f"fuzz must be non-negative, got {fuzz}")

        return kwargs

    async def execute(self, **kwargs) -> PatchResult:
        """
        Execute patch application

        Args:
            **kwargs: Patch parameters

        Returns:
            PatchResult with operation status
        """
        # Validate parameters
        validated = self.validate_params(**kwargs)
        patch_content = validated["patch"]
        target_path = validated.get("path")
        dry_run = validated.get("dry_run", False)
        backup = validated.get("backup", True)
        fuzz = validated.get("fuzz", 0)

        # Parse patch
        try:
            patch_files = self._parse_patch(patch_content)
        except ValueError as e:
            return PatchResult(
                success=False,
                path=target_path or "",
                applied_hunks=0,
                total_hunks=0,
                dry_run=dry_run,
                error=f"Invalid patch format: {e}",
            )

        if not patch_files:
            return PatchResult(
                success=False,
                path=target_path or "",
                applied_hunks=0,
                total_hunks=0,
                dry_run=dry_run,
                error="No patches found in input",
            )

        # Apply first patch file (TODO: support multiple files)
        patch_file = patch_files[0]

        # Determine target file
        file_path_str = target_path or patch_file.new_path
        # Remove a/ or b/ prefix if present
        if file_path_str.startswith("a/") or file_path_str.startswith("b/"):
            file_path_str = file_path_str[2:]

        file_path = Path(file_path_str)
        if not file_path.is_absolute():
            file_path = self._workspace_root / file_path

        # Check file exists
        if not file_path.exists():
            return PatchResult(
                success=False,
                path=str(file_path),
                applied_hunks=0,
                total_hunks=len(patch_file.hunks),
                dry_run=dry_run,
                error=f"File not found: {file_path}",
            )

        # Read current content
        try:
            content = file_path.read_text(encoding="utf-8")
        except Exception as e:
            return PatchResult(
                success=False,
                path=str(file_path),
                applied_hunks=0,
                total_hunks=len(patch_file.hunks),
                dry_run=dry_run,
                error=f"Failed to read file: {e}",
            )

        # Apply hunks
        lines = content.splitlines(keepends=True)
        applied_hunks = 0
        failed_hunks = []
        offset = 0  # Track line offset from previous hunks

        for i, hunk in enumerate(patch_file.hunks):
            result = self._apply_hunk(lines, hunk, offset, fuzz)
            if result is not None:
                lines = result
                applied_hunks += 1
                # Update offset based on hunk size change
                offset += hunk.header.new_count - hunk.header.old_count
            else:
                failed_hunks.append(i + 1)

        # Check if all hunks applied
        if failed_hunks:
            return PatchResult(
                success=False,
                path=str(file_path),
                applied_hunks=applied_hunks,
                total_hunks=len(patch_file.hunks),
                dry_run=dry_run,
                error=f"Failed to apply hunks: {failed_hunks}",
                failed_hunks=failed_hunks,
            )

        # If dry run, don't write
        if dry_run:
            return PatchResult(
                success=True,
                path=str(file_path),
                applied_hunks=applied_hunks,
                total_hunks=len(patch_file.hunks),
                dry_run=True,
            )

        # Create backup if requested
        backup_path = None
        if backup:
            backup_path = str(file_path) + ".orig"
            try:
                file_path.with_suffix(file_path.suffix + ".orig").write_text(
                    content, encoding="utf-8"
                )
                backup_path = str(file_path.with_suffix(file_path.suffix + ".orig"))
            except Exception:
                # Non-fatal - continue without backup
                backup_path = None

        # Write patched content
        try:
            new_content = "".join(lines)
            file_path.write_text(new_content, encoding="utf-8")
        except Exception as e:
            return PatchResult(
                success=False,
                path=str(file_path),
                applied_hunks=applied_hunks,
                total_hunks=len(patch_file.hunks),
                dry_run=False,
                backup_path=backup_path,
                error=f"Failed to write file: {e}",
            )

        return PatchResult(
            success=True,
            path=str(file_path),
            applied_hunks=applied_hunks,
            total_hunks=len(patch_file.hunks),
            dry_run=False,
            backup_path=backup_path,
        )

    def _parse_patch(self, patch_content: str) -> list[PatchFile]:
        """
        Parse unified diff patch content

        Args:
            patch_content: Unified diff content

        Returns:
            List of PatchFile objects
        """
        lines = patch_content.splitlines()
        patch_files = []

        current_file = None
        current_hunk = None
        old_path = None
        new_path = None

        for line in lines:
            # File headers
            if line.startswith("--- "):
                old_path = line[4:].strip()
                # Remove timestamps if present
                if "\t" in old_path:
                    old_path = old_path.split("\t")[0]
                continue

            if line.startswith("+++ "):
                new_path = line[4:].strip()
                if "\t" in new_path:
                    new_path = new_path.split("\t")[0]

                # Create new patch file
                if old_path and new_path:
                    current_file = PatchFile(old_path=old_path, new_path=new_path, hunks=[])
                    patch_files.append(current_file)
                continue

            # Hunk header
            header = HunkHeader.parse(line)
            if header:
                if current_file is not None:
                    current_hunk = PatchHunk(header=header, lines=[])
                    current_file.hunks.append(current_hunk)
                continue

            # Hunk content
            if current_hunk is not None:
                if line.startswith("+") or line.startswith("-") or line.startswith(" "):
                    current_hunk.lines.append(line)
                elif line == "" or line == "\\ No newline at end of file":
                    # Handle empty lines and no-newline markers
                    continue

        return patch_files

    def _apply_hunk(
        self,
        lines: list[str],
        hunk: PatchHunk,
        offset: int,
        fuzz: int,
    ) -> Optional[list[str]]:
        """
        Apply a single hunk to file lines

        Args:
            lines: Current file lines
            hunk: Hunk to apply
            offset: Line offset from previous hunks
            fuzz: Fuzz factor for matching

        Returns:
            Modified lines if successful, None if failed
        """
        # Calculate actual start position (1-indexed to 0-indexed)
        start = hunk.header.old_start - 1 + offset
        old_lines = hunk.get_old_lines()
        new_lines = hunk.get_new_lines()

        # Try exact match first
        if self._verify_context(lines, start, old_lines):
            return self._replace_lines(lines, start, len(old_lines), new_lines)

        # Try fuzzy matching with fuzz factor
        for f in range(1, fuzz + 1):
            # Try before
            if start - f >= 0 and self._verify_context(lines, start - f, old_lines):
                return self._replace_lines(lines, start - f, len(old_lines), new_lines)
            # Try after
            if self._verify_context(lines, start + f, old_lines):
                return self._replace_lines(lines, start + f, len(old_lines), new_lines)

        return None

    def _verify_context(self, lines: list[str], start: int, context_lines: list[str]) -> bool:
        """
        Verify context lines match

        Args:
            lines: File lines
            start: Start position
            context_lines: Expected context lines

        Returns:
            True if context matches
        """
        if start < 0 or start + len(context_lines) > len(lines):
            return False

        for i, ctx in enumerate(context_lines):
            file_line = lines[start + i].rstrip("\n\r")
            ctx_stripped = ctx.rstrip("\n\r")
            if file_line != ctx_stripped:
                return False

        return True

    def _replace_lines(
        self,
        lines: list[str],
        start: int,
        count: int,
        new_lines: list[str],
    ) -> list[str]:
        """
        Replace lines in file

        Args:
            lines: Current file lines
            start: Start position
            count: Number of lines to replace
            new_lines: New lines to insert

        Returns:
            Modified lines
        """
        result = lines[:start]

        # Add new lines with proper line endings
        for i, line in enumerate(new_lines):
            if not line.endswith("\n"):
                # Add newline unless it's the last line of the file
                if start + count < len(lines) or i < len(new_lines) - 1:
                    line = line + "\n"
            result.append(line)

        result.extend(lines[start + count :])

        return result
