"""Sophisticated file editing tool with multiple replacement strategies.

This module implements a robust file editing tool that uses multiple fallback
strategies for finding and replacing text, similar to OpenCode's edit tool.
"""

from __future__ import annotations

import difflib
from pathlib import Path
import re
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel, Field, field_validator

from llmling_agent.tools.base import Tool


if TYPE_CHECKING:
    from collections.abc import Generator

    from llmling_agent.agent import AgentContext


class EditParams(BaseModel):
    """Parameters for the edit tool."""

    file_path: str = Field(description="The path to the file to modify")
    old_string: str = Field(description="The text to replace")
    new_string: str = Field(description="The text to replace it with")
    replace_all: bool = Field(
        default=False, description="Replace all occurrences of old_string (default false)"
    )

    @field_validator("file_path")
    @classmethod
    def validate_file_path(cls, v: str) -> str:
        """Validate file path is not empty."""
        if not v.strip():
            msg = "file_path cannot be empty"
            raise ValueError(msg)
        return v

    def model_post_init(self, __context: Any, /) -> None:
        """Post-initialization validation."""
        if self.old_string == self.new_string:
            msg = "old_string and new_string must be different"
            raise ValueError(msg)


def _levenshtein_distance(a: str, b: str, /) -> int:
    """Calculate Levenshtein distance between two strings."""
    if not a:
        return len(b)
    if not b:
        return len(a)

    matrix = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]

    for i in range(len(a) + 1):
        matrix[i][0] = i
    for j in range(len(b) + 1):
        matrix[0][j] = j

    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            cost = 0 if a[i - 1] == b[j - 1] else 1
            matrix[i][j] = min(
                matrix[i - 1][j] + 1,  # deletion
                matrix[i][j - 1] + 1,  # insertion
                matrix[i - 1][j - 1] + cost,  # substitution
            )

    return matrix[len(a)][len(b)]


def _simple_replacer(content: str, find: str) -> Generator[str]:
    """Direct string matching replacer."""
    if find in content:
        yield find


def _line_trimmed_replacer(content: str, find: str) -> Generator[str]:
    """Line-by-line matching with trimmed whitespace."""
    original_lines = content.split("\n")
    search_lines = find.split("\n")

    # Remove trailing empty line if present
    if search_lines and search_lines[-1] == "":
        search_lines.pop()

    for i in range(len(original_lines) - len(search_lines) + 1):
        matches = True

        for j in range(len(search_lines)):
            if i + j >= len(original_lines):
                matches = False
                break

            original_trimmed = original_lines[i + j].strip()
            search_trimmed = search_lines[j].strip()

            if original_trimmed != search_trimmed:
                matches = False
                break

        if matches:
            # Calculate the actual substring in the original content
            start_index = sum(len(original_lines[k]) + 1 for k in range(i))
            end_index = start_index

            for k in range(len(search_lines)):
                end_index += len(original_lines[i + k])
                if k < len(search_lines) - 1:
                    end_index += 1  # newline

            yield content[start_index:end_index]


def _calculate_block_similarity(
    original_lines: list[str],
    search_lines: list[str],
    start_line: int,
    end_line: int,
    search_block_size: int,
) -> float:
    """Calculate similarity between search block and candidate block."""
    actual_block_size = end_line - start_line + 1
    lines_to_check = min(search_block_size - 2, actual_block_size - 2)

    if lines_to_check <= 0:
        return 1.0

    similarity = 0.0
    for j in range(1, min(search_block_size - 1, actual_block_size - 1)):
        original_line = original_lines[start_line + j].strip()
        search_line = search_lines[j].strip()
        max_len = max(len(original_line), len(search_line))

        if max_len == 0:
            continue

        distance = _levenshtein_distance(original_line, search_line)
        similarity += (1 - distance / max_len) / lines_to_check

    return similarity


def _block_anchor_replacer(content: str, find: str) -> Generator[str]:
    """Multi-line block matching using first/last line anchors with similarity scoring."""
    single_candidate_threshold = 0.0
    multiple_candidates_threshold = 0.3
    min_lines_for_block = 3

    original_lines = content.split("\n")
    search_lines = find.split("\n")

    if len(search_lines) < min_lines_for_block:
        return

    # Remove trailing empty line if present
    if search_lines and search_lines[-1] == "":
        search_lines.pop()

    first_line_search = search_lines[0].strip()
    last_line_search = search_lines[-1].strip()
    search_block_size = len(search_lines)

    # Find all candidate positions
    candidates: list[tuple[int, int]] = []
    for i in range(len(original_lines)):
        if original_lines[i].strip() != first_line_search:
            continue

        # Look for matching last line
        for j in range(i + 2, len(original_lines)):
            if original_lines[j].strip() == last_line_search:
                candidates.append((i, j))
                break

    if not candidates:
        return

    if len(candidates) == 1:
        start_line, end_line = candidates[0]
        similarity = _calculate_block_similarity(
            original_lines, search_lines, start_line, end_line, search_block_size
        )

        if similarity >= single_candidate_threshold:
            start_index = sum(len(original_lines[k]) + 1 for k in range(start_line))
            end_index = start_index + sum(
                len(original_lines[k]) + (1 if k < end_line else 0)
                for k in range(start_line, end_line + 1)
            )
            yield content[start_index:end_index]
    else:
        # Multiple candidates - find best match
        best_match = None
        max_similarity = -1.0

        for start_line, end_line in candidates:
            similarity = _calculate_block_similarity(
                original_lines, search_lines, start_line, end_line, search_block_size
            )
            if similarity > max_similarity:
                max_similarity = similarity
                best_match = (start_line, end_line)

        if max_similarity >= multiple_candidates_threshold and best_match:
            start_line, end_line = best_match
            start_index = sum(len(original_lines[k]) + 1 for k in range(start_line))
            end_index = start_index + sum(
                len(original_lines[k]) + (1 if k < end_line else 0)
                for k in range(start_line, end_line + 1)
            )
            yield content[start_index:end_index]


def _whitespace_normalized_replacer(content: str, find: str) -> Generator[str]:
    """Whitespace-normalized matching replacer."""

    def normalize_whitespace(text: str) -> str:
        # Normalize multiple whitespace to single space
        text = re.sub(r"\s+", " ", text).strip()
        # Normalize spacing around common punctuation
        # Normalize spacing around common punctuation
        replacements = [
            (r"\s*:\s*", ":"),
            (r"\s*;\s*", ";"),
            (r"\s*,\s*", ","),
            (r"\s*\(\s*", "("),
            (r"\s*\)\s*", ")"),
            (r"\s*\[\s*", "["),
            (r"\s*\]\s*", "]"),
            (r"\s*\{\s*", "{"),
            (r"\s*\}\s*", "}"),
        ]
        for pattern, replacement in replacements:
            text = re.sub(pattern, replacement, text)
        return text

    normalized_find = normalize_whitespace(find)
    found_matches = set()  # Track matches to avoid duplicates

    # Try to match the entire content first
    if normalize_whitespace(content) == normalized_find and content not in found_matches:
        found_matches.add(content)
        yield content
        return

    lines = content.split("\n")
    find_lines = find.split("\n")

    # Multi-line matches
    if len(find_lines) > 1:
        for i in range(len(lines) - len(find_lines) + 1):
            block = lines[i : i + len(find_lines)]
            block_content = "\n".join(block)
            if (
                normalize_whitespace(block_content) == normalized_find
                and block_content not in found_matches
            ):
                found_matches.add(block_content)
                yield block_content
    else:
        # Single line matches
        for line in lines:
            if (
                normalize_whitespace(line) == normalized_find
                and line not in found_matches
            ):
                found_matches.add(line)
                yield line
            else:
                # Check for substring matches
                normalized_line = normalize_whitespace(line)
                if normalized_find in normalized_line:
                    # Find actual substring using regex
                    words = find.strip().split()
                    if words:
                        pattern = r"\s+".join(re.escape(word) for word in words)
                        match = re.search(pattern, line)
                        if match and match.group(0) not in found_matches:
                            found_matches.add(match.group(0))
                            yield match.group(0)


def _indentation_flexible_replacer(content: str, find: str) -> Generator[str]:
    """Indentation-flexible matching replacer."""

    def remove_common_indentation(text: str) -> str:
        lines = text.split("\n")
        non_empty_lines = [line for line in lines if line.strip()]

        if not non_empty_lines:
            return text

        min_indent = min(len(line) - len(line.lstrip()) for line in non_empty_lines)

        return "\n".join(line[min_indent:] if line.strip() else line for line in lines)

    normalized_find = remove_common_indentation(find)
    content_lines = content.split("\n")
    find_lines = find.split("\n")

    for i in range(len(content_lines) - len(find_lines) + 1):
        block = "\n".join(content_lines[i : i + len(find_lines)])
        if remove_common_indentation(block) == normalized_find:
            yield block


def _escape_normalized_replacer(content: str, find: str) -> Generator[str]:
    """Escape sequence normalized matching replacer."""

    def unescape_string(text: str) -> str:
        # Handle common escape sequences
        # Handle common escape sequences
        replacements = [
            ("\\n", "\n"),
            ("\\t", "\t"),
            ("\\r", "\r"),
            ("\\'", "'"),
            ('\\"', '"'),
            ("\\`", "`"),
            ("\\\\", "\\"),
            ("\\$", "$"),
        ]
        for escaped, unescaped in replacements:
            text = text.replace(escaped, unescaped)
        return text

    unescaped_find = unescape_string(find)

    # Try direct match with unescaped find
    if unescaped_find in content:
        yield unescaped_find

    # Look for content that when unescaped matches our find string
    # This handles the case where content has escaped chars but find doesn't
    for i in range(len(content) - len(find) + 1):
        for length in range(len(find), min(len(content) - i + 1, len(find) * 3)):
            substring = content[i : i + length]
            if unescape_string(substring) == unescaped_find:
                yield substring
                break

    # Try finding escaped versions in content line by line
    lines = content.split("\n")
    find_lines = unescaped_find.split("\n")

    for i in range(len(lines) - len(find_lines) + 1):
        block = "\n".join(lines[i : i + len(find_lines)])
        if unescape_string(block) == unescaped_find:
            yield block


def _trimmed_boundary_replacer(content: str, find: str) -> Generator[str]:
    """Trimmed boundary matching replacer."""
    trimmed_find = find.strip()

    if trimmed_find == find:
        return  # Already trimmed

    # Try trimmed version
    if trimmed_find in content:
        yield trimmed_find

    # Try finding blocks where trimmed content matches
    lines = content.split("\n")
    find_lines = find.split("\n")

    for i in range(len(lines) - len(find_lines) + 1):
        block = "\n".join(lines[i : i + len(find_lines)])
        if block.strip() == trimmed_find:
            yield block


def _context_aware_replacer(content: str, find: str) -> Generator[str]:
    """Context-aware matching using anchor lines."""
    find_lines = find.split("\n")
    min_context_lines = 3
    if len(find_lines) < min_context_lines:
        return

    # Remove trailing empty line if present
    if find_lines and find_lines[-1] == "":
        find_lines.pop()

    content_lines = content.split("\n")
    first_line = find_lines[0].strip()
    last_line = find_lines[-1].strip()

    # Find blocks with matching first and last lines
    for i in range(len(content_lines)):
        if content_lines[i].strip() != first_line:
            continue

        for j in range(i + 2, len(content_lines)):
            if content_lines[j].strip() == last_line:
                block_lines = content_lines[i : j + 1]

                # Check similarity of middle content
                if len(block_lines) == len(find_lines):
                    matching_lines = 0
                    total_non_empty = 0

                    for k in range(1, len(block_lines) - 1):
                        block_line = block_lines[k].strip()
                        find_line = find_lines[k].strip()

                        if block_line or find_line:
                            total_non_empty += 1
                            if block_line == find_line:
                                matching_lines += 1

                    # Require at least 50% similarity
                    min_similarity_ratio = 0.5
                    if (
                        total_non_empty == 0
                        or matching_lines / total_non_empty >= min_similarity_ratio
                    ):
                        yield "\n".join(block_lines)
                        break
                break


def _multi_occurrence_replacer(content: str, find: str) -> Generator[str]:
    """Multi-occurrence replacer that yields all exact matches."""
    start_index = 0
    while True:
        index = content.find(find, start_index)
        if index == -1:
            break
        yield find
        start_index = index + len(find)


def _trim_diff(diff_text: str) -> str:
    """Trim common indentation from diff output."""
    lines = diff_text.split("\n")
    content_lines = [
        line
        for line in lines
        if line.startswith(("+", "-", " ")) and not line.startswith(("---", "+++"))
    ]

    if not content_lines:
        return diff_text

    # Find minimum indentation
    min_indent = float("inf")
    for line in content_lines:
        content = line[1:]  # Remove +/- prefix
        if content.strip():
            indent = len(content) - len(content.lstrip())
            min_indent = min(min_indent, indent)

    if min_indent == float("inf") or min_indent == 0:
        return diff_text

    # Trim indentation
    trimmed_lines = []
    for line in lines:
        if line.startswith(("+", "-", " ")) and not line.startswith(("---", "+++")):
            prefix = line[0]
            content = line[1:]
            trimmed_lines.append(prefix + content[min_indent:])
        else:
            trimmed_lines.append(line)

    return "\n".join(trimmed_lines)


def replace_content(
    content: str, old_string: str, new_string: str, replace_all: bool = False
) -> str:
    """Replace content using multiple fallback strategies."""
    if old_string == new_string:
        msg = "old_string and new_string must be different"
        raise ValueError(msg)

    replacers = [
        _simple_replacer,
        _line_trimmed_replacer,
        _block_anchor_replacer,
        _whitespace_normalized_replacer,
        _indentation_flexible_replacer,
        _escape_normalized_replacer,
        _trimmed_boundary_replacer,
        _context_aware_replacer,
        _multi_occurrence_replacer,
    ]

    found_matches = False

    for replacer in replacers:
        matches = list(replacer(content, old_string))
        if not matches:
            continue

        found_matches = True

        for search_text in matches:
            index = content.find(search_text)
            if index == -1:
                continue

            if replace_all:
                return content.replace(search_text, new_string)

            # Check if there are multiple occurrences
            last_index = content.rfind(search_text)
            if index != last_index:
                continue  # Multiple occurrences, need more context

            # Single occurrence - replace it
            return content[:index] + new_string + content[index + len(search_text) :]

    if not found_matches:
        msg = "old_string not found in content"
        raise ValueError(msg)

    msg = (
        "old_string found multiple times and requires more code context "
        "to uniquely identify the intended match"
    )
    raise ValueError(msg)


async def edit_file_tool(
    file_path: str,
    old_string: str,
    new_string: str,
    replace_all: bool = False,
    context: AgentContext | None = None,
) -> dict[str, Any]:
    """Perform exact string replacements in files using sophisticated matching strategies.

    Supports multiple fallback approaches including whitespace normalization,
    indentation flexibility, and context-aware matching.

    Args:
        file_path: Path to the file to modify
        old_string: Text to replace
        new_string: Text to replace it with
        replace_all: Whether to replace all occurrences
        context: Agent execution context

    Returns:
        Dict with operation results including diff and any errors
    """
    if old_string == new_string:
        msg = "old_string and new_string must be different"
        raise ValueError(msg)

    # Resolve file path
    path = Path(file_path)
    if not path.is_absolute():
        # Make relative to current working directory
        path = Path.cwd() / path

    # Validate file exists and is a file
    if not path.exists():
        msg = f"File not found: {path}"
        raise FileNotFoundError(msg)

    if not path.is_file():
        msg = f"Path is a directory, not a file: {path}"
        raise ValueError(msg)

    # Read current content
    try:
        original_content = path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        # Try with different encoding
        original_content = path.read_text(encoding="latin-1")

    # Handle empty file case
    if old_string == "" and original_content == "":
        new_content = new_string
    else:
        new_content = replace_content(
            original_content, old_string, new_string, replace_all
        )

    # Generate diff
    diff_lines = list(
        difflib.unified_diff(
            original_content.splitlines(keepends=True),
            new_content.splitlines(keepends=True),
            fromfile=str(path),
            tofile=str(path),
            lineterm="",
        )
    )
    diff_text = "".join(diff_lines)
    trimmed_diff = _trim_diff(diff_text) if diff_text else ""

    # Write new content
    try:
        path.write_text(new_content, encoding="utf-8")
    except Exception as e:
        msg = f"Failed to write file: {e}"
        raise RuntimeError(msg) from e

    return {
        "success": True,
        "file_path": str(path),
        "diff": trimmed_diff,
        "message": f"Successfully edited {path.name}",
        "lines_changed": len([
            line for line in diff_lines if line.startswith(("+", "-"))
        ]),
    }


# Create the tool instance
edit_tool = Tool.from_callable(edit_file_tool, name_override="edit_file")
