"""Filesystem tools for reading, writing, and editing files."""

import collections
import difflib
import logging
import pathlib
from typing import Annotated, Any, Literal

from arcade_tdk import ToolContext, tool

from cade_mcp_local.config import (
    MAX_LIST_DEPTH,
    MAX_LIST_RESULTS,
    MAX_PREVIEW_BYTES,
    get_project_root,
)
from cade_mcp_local.errors import (
    FileOperationError,
    InvalidInputError,
    PathNotFoundError,
    PathSecurityError,
)
from cade_mcp_local.utils import make_relative, resolve_path, should_ignore

log = logging.getLogger(__name__)


def _read_text_file(file_path: pathlib.Path) -> str:
    """Read a text file, attempting common encodings."""
    encodings = ["utf-8", "latin-1", "cp1252"]
    for encoding in encodings:
        try:
            return file_path.read_text(encoding=encoding)
        except UnicodeDecodeError:
            continue

    # Fallback: read as binary and decode with replacement
    log.warning(f"Could not decode {file_path} with standard encodings, using replacement")
    return file_path.read_bytes().decode("utf-8", errors="replace")


def _write_text_file(file_path: pathlib.Path, content: str) -> None:
    """Write text content to a file, ensuring it's within project root."""
    project_root = get_project_root()
    resolved = file_path.resolve()
    resolved_root = project_root.resolve()

    # Security check: must be within project root
    # Use string prefix check as fallback for symlink/worktree edge cases
    try:
        resolved.relative_to(resolved_root)
    except ValueError:
        # Fallback: compare resolved string paths
        if not str(resolved).startswith(str(resolved_root) + "/"):
            raise PathSecurityError(
                str(file_path),
                f"outside project root (resolved={resolved}, root={resolved_root})",
            )

    # Create parent directories if needed
    resolved.parent.mkdir(parents=True, exist_ok=True)
    resolved.write_text(content, encoding="utf-8")


def _generate_diff(
    original: str,
    new: str,
    filename: str = "file",
    context_lines: int = 3,
) -> str:
    """Generate unified diff between two strings."""
    diff = difflib.unified_diff(
        original.splitlines(),
        new.splitlines(),
        fromfile=f"a/{filename}",
        tofile=f"b/{filename}",
        n=context_lines,
        lineterm="",
    )
    return "\n".join(diff)


# ===========================================================================
# ListFiles
# ===========================================================================


@tool(
    name="ListFiles",
    desc="List files in a directory (recursively up to a depth limit). Local filesystem only.",
)
def list_files_tool(
    context: ToolContext,
    directory: Annotated[
        str,
        "Directory path to list (relative to project root). Defaults to current directory.",
    ] = ".",
    recursive: Annotated[bool, "Whether to list files recursively."] = True,
    depth: Annotated[
        int,
        "Maximum depth for listing files. 0 means no depth limit.",
    ] = MAX_LIST_DEPTH,
) -> Annotated[dict[str, Any], "List of files and optional message."]:
    """List files and directories within a specified path."""
    if not 0 <= depth <= MAX_LIST_DEPTH:
        raise InvalidInputError("depth", f"must be between 0 and {MAX_LIST_DEPTH}")

    base_path = resolve_path(directory, confine_to_root=True)
    if not base_path.is_dir():
        raise PathNotFoundError(directory, f"Not a directory: {directory}")

    project_root = get_project_root()
    listed_paths: list[str] = []

    if not recursive:
        for item in base_path.iterdir():
            if should_ignore(item):
                continue
            listed_paths.append(make_relative(item, project_root))
            if len(listed_paths) >= MAX_LIST_RESULTS:
                break
    else:
        # BFS traversal with depth tracking
        queue: collections.deque[tuple[pathlib.Path, int]] = collections.deque(
            (child, 0) for child in base_path.iterdir() if not should_ignore(child)
        )

        while queue and len(listed_paths) < MAX_LIST_RESULTS:
            current, current_depth = queue.popleft()

            if current_depth > depth:
                continue

            listed_paths.append(make_relative(current, project_root))

            if current.is_dir() and current_depth < depth:
                for child in current.iterdir():
                    if not should_ignore(child):
                        queue.append((child, current_depth + 1))

    listed_paths.sort()

    message = "Files listed successfully."
    if len(listed_paths) >= MAX_LIST_RESULTS:
        message = f"Truncated at {MAX_LIST_RESULTS} entries."

    return {"files": listed_paths, "message": message}


# ===========================================================================
# ReadFile
# ===========================================================================


@tool(
    name="ReadFile",
    desc=(
        "Read a text file, optionally a specific line range. "
        "Use start_line/end_line to read portions of large files. "
        "Lines are 1-indexed. Local filesystem only."
    ),
)
def read_file_tool(
    context: ToolContext,
    file_path_arg: Annotated[str, "Path to the file to read (relative or absolute)."],
    start_line: Annotated[
        int,
        "First line to return, 1-indexed. Omit to start from the beginning.",
    ] = 0,
    end_line: Annotated[
        int,
        "Last line to return, 1-indexed. Omit to read to end of file.",
    ] = 0,
) -> Annotated[dict[str, Any], "File content, line numbers, and message."]:
    """Read content of a specified file, optionally a specific line range."""
    if not file_path_arg:
        raise InvalidInputError("file_path_arg", "file path is required")
    file_path = resolve_path(file_path_arg, confine_to_root=True)

    if not file_path.is_file():
        raise PathNotFoundError(file_path_arg)

    try:
        size = file_path.stat().st_size

        # Line-range mode: read specific lines (0 = not specified)
        if start_line > 0 or end_line > 0:
            content = _read_text_file(file_path)
            all_lines = content.splitlines(keepends=True)
            total_lines = len(all_lines)

            s = max(1, start_line) if start_line > 0 else 1
            e = min(total_lines, end_line) if end_line > 0 else total_lines

            if s > total_lines:
                return {
                    "content": "",
                    "total_lines": total_lines,
                    "message": f"start_line {s} exceeds file length ({total_lines} lines).",
                }

            selected = all_lines[s - 1 : e]
            # Add line numbers for context
            numbered = "".join(f"{i}: {line}" for i, line in enumerate(selected, start=s))
            return {
                "content": numbered,
                "start_line": s,
                "end_line": e,
                "total_lines": total_lines,
                "message": f"Lines {s}-{e} of {total_lines}.",
            }

        if size > MAX_PREVIEW_BYTES:
            log.warning(f"File {file_path_arg} exceeds limit, truncating")
            with file_path.open("rb") as f:
                start = f.read(MAX_PREVIEW_BYTES // 2).decode("utf-8", errors="replace")
                f.seek(max(0, size - MAX_PREVIEW_BYTES // 2))
                end = f.read().decode("utf-8", errors="replace")
            content = f"{start}\n... (truncated, {size} bytes total) ...\n{end}"
            return {
                "content": content,
                "total_lines": None,
                "message": (
                    "File truncated due to size. Use start_line/end_line to read specific sections."
                ),
            }

        content = _read_text_file(file_path)
        total_lines = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
        return {
            "content": content,
            "total_lines": total_lines,
            "message": "File read successfully.",
        }

    except OSError as e:
        raise FileOperationError("read", file_path_arg, str(e))


# ===========================================================================
# WriteFile
# ===========================================================================


@tool(
    name="WriteFile",
    desc=(
        "Create a new file or overwrite an existing file with the given content. "
        "For partial edits use Edit; for appending use mode='append'."
    ),
)
def write_file_tool(
    context: ToolContext,
    file_path_arg: Annotated[str, "Path of the file to write."],
    content: Annotated[str, "The complete content to write into the file."],
    mode: Annotated[
        Literal["overwrite", "append"],
        "How to write: 'overwrite' replaces the file, 'append' adds to end.",
    ] = "overwrite",
) -> Annotated[dict[str, Any], "Result of the write operation."]:
    """Write content to a file."""
    if not file_path_arg:
        raise InvalidInputError("file_path_arg", "file path is required")
    resolved = resolve_path(file_path_arg)

    try:
        if mode == "append" and resolved.exists():
            existing = _read_text_file(resolved)
            content = existing + content

        _write_text_file(resolved, content)
        return {"success": True, "message": f"Successfully wrote to {file_path_arg}"}

    except PathSecurityError:
        raise
    except Exception as e:
        raise FileOperationError("write", file_path_arg, str(e))


# ===========================================================================
# Edit  (find-and-replace)
# ===========================================================================


@tool(
    name="Edit",
    desc=(
        "Find and replace text in a file. "
        "Provide old_string (the exact text to find) and new_string (the replacement). "
        "The old_string must match the file content exactly, including whitespace and indentation. "
        "Use ReadFile first to see the exact content. "
        "Returns a diff showing what changed."
    ),
)
def edit_tool(
    context: ToolContext,
    file_path_arg: Annotated[str, "Path to the file to edit."],
    old_string: Annotated[
        str,
        "The exact text to find in the file. Must match verbatim including whitespace.",
    ],
    new_string: Annotated[
        str,
        "The replacement text. Use an empty string to delete the matched text.",
    ],
) -> Annotated[dict[str, Any], "Edit result with diff."]:
    """Find old_string in the file and replace it with new_string."""
    if not file_path_arg:
        raise InvalidInputError("file_path_arg", "file path is required")
    resolved = resolve_path(file_path_arg)

    if not resolved.is_file():
        raise PathNotFoundError(file_path_arg)

    try:
        original = _read_text_file(resolved)
    except Exception as e:
        raise FileOperationError("read", file_path_arg, str(e))

    if not old_string:
        raise InvalidInputError("old_string", "cannot be empty — provide the text to replace")

    count = original.count(old_string)

    if count == 0:
        # Provide helpful debugging info
        preview = old_string[:120] + ("..." if len(old_string) > 120 else "")
        lines = original.splitlines()
        return {
            "success": False,
            "error": "old_string not found in file",
            "old_string_preview": preview,
            "file_lines": len(lines),
            "file_chars": len(original),
            "suggestion": (
                "The text must match exactly including whitespace and indentation. "
                "Use ReadFile to see the current content, then copy the exact text."
            ),
        }

    new_content = original.replace(old_string, new_string)
    diff = _generate_diff(original, new_content, file_path_arg)

    try:
        _write_text_file(resolved, new_content)
    except Exception as e:
        raise FileOperationError("write", file_path_arg, str(e))

    return {
        "success": True,
        "message": f"Replaced {count} occurrence(s) in {file_path_arg}",
        "replacements": count,
        "diff": diff or "(no visible diff)",
    }


# ===========================================================================
# InsertText  (line-based insertion)
# ===========================================================================


@tool(
    name="InsertText",
    desc=(
        "Insert text at a specific line number in a file. "
        "Use ReadFile first to find the right line number. "
        "Lines are 1-indexed. Set line_number=0 to append at end of file."
    ),
)
def insert_text_tool(
    context: ToolContext,
    file_path_arg: Annotated[str, "Path to the file to edit."],
    line_number: Annotated[
        int,
        "Line number to insert at (1-indexed). 0 appends at end of file.",
    ],
    content: Annotated[str, "The text to insert."],
    position: Annotated[
        Literal["before", "after"],
        "Insert before or after the specified line.",
    ] = "before",
) -> Annotated[dict[str, Any], "Insert result with diff."]:
    """Insert text at a specific line in a file."""
    if not file_path_arg:
        raise InvalidInputError("file_path_arg", "file path is required")
    resolved = resolve_path(file_path_arg)

    if not resolved.is_file():
        raise PathNotFoundError(file_path_arg)

    try:
        original = _read_text_file(resolved)
    except Exception as e:
        raise FileOperationError("read", file_path_arg, str(e))

    if not content:
        raise InvalidInputError("content", "cannot be empty — provide the text to insert")

    lines = original.splitlines(keepends=True)

    # Ensure content ends with newline
    insert_content = content if content.endswith("\n") else content + "\n"

    if line_number == 0:
        # Append at end
        if lines and not lines[-1].endswith("\n"):
            lines[-1] += "\n"
        lines.append(insert_content)
    elif line_number < 0 or line_number > len(lines) + 1:
        raise InvalidInputError(
            "line_number",
            f"{line_number} out of range (file has {len(lines)} lines)",
        )
    else:
        idx = line_number - 1
        if position == "before":
            lines.insert(idx, insert_content)
        else:
            lines.insert(idx + 1, insert_content)

    new_content = "".join(lines)
    diff = _generate_diff(original, new_content, file_path_arg)

    try:
        _write_text_file(resolved, new_content)
    except Exception as e:
        raise FileOperationError("write", file_path_arg, str(e))

    return {
        "success": True,
        "message": f"Inserted content at line {line_number} in {file_path_arg}",
        "diff": diff,
    }
