"""Tool definitions and implementations for an LLM agent."""

import base64
import contextlib
import fnmatch
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import uuid
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath

from .a2a_types import A2A_META_PREFIX

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": (
                "Read the contents of a file or list a directory. "
                "For files, returns lines prefixed with line numbers. "
                "Use offset/limit to paginate forward, or tail=N to start from the last N lines. "
                "If output is truncated, a continuation hint shows the offset for the next page. "
                "For directories, returns a listing with / suffix for subdirectories."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "file_path": {
                        "type": "string",
                        "description": "Path to the file or directory to read.",
                    },
                    "offset": {
                        "type": "integer",
                        "description": (
                            "1-based line number to start reading from. Defaults to 1."
                        ),
                        "default": 1,
                    },
                    "limit": {
                        "type": "integer",
                        "description": (
                            "Maximum number of lines to return. Defaults to 2000."
                        ),
                        "default": 2000,
                    },
                    "tail": {
                        "type": "integer",
                        "minimum": 1,
                        "description": (
                            "Return the last N lines of the file. "
                            "When set, offset is ignored. "
                            "To paginate within the tail, use the offset from the continuation hint "
                            "in a follow-up call (without tail)."
                        ),
                    },
                },
                "required": ["file_path"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "read_multiple_files",
            "description": (
                "Read multiple files in one call. Returns line-numbered contents "
                "grouped by file. Each file can have its own offset/limit/tail. "
                "Per-file errors are reported inline without failing the batch. "
                "Use this instead of multiple read_file calls when you already "
                "know which files you need."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "files": {
                        "type": "array",
                        "description": "List of file read requests.",
                        "items": {
                            "type": "object",
                            "properties": {
                                "file_path": {
                                    "type": "string",
                                    "description": "Path to the file to read.",
                                },
                                "offset": {
                                    "type": "integer",
                                    "description": (
                                        "1-based line number to start from. Defaults to 1."
                                    ),
                                    "default": 1,
                                },
                                "limit": {
                                    "type": "integer",
                                    "description": (
                                        "Maximum number of lines to return. Defaults to 2000."
                                    ),
                                    "default": 2000,
                                },
                                "tail": {
                                    "type": "integer",
                                    "minimum": 1,
                                    "description": (
                                        "Return the last N lines. When set, offset is ignored."
                                    ),
                                },
                            },
                            "required": ["file_path"],
                        },
                        "maxItems": 20,
                    },
                },
                "required": ["files"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "write_file",
            "description": (
                "Create or overwrite a file. Parent directories are created automatically. "
                "Either provide content to write, or move_from to atomically rename a file — not both."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "file_path": {
                        "type": "string",
                        "description": "Destination path.",
                    },
                    "content": {
                        "type": "string",
                        "description": "The content to write. Do not set with move_from.",
                    },
                    "move_from": {
                        "type": "string",
                        "description": (
                            "Source path to atomically rename to file_path. Do not set with content. "
                            "If the destination already exists, it must have been read or written first."
                        ),
                    },
                },
                "required": ["file_path"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "edit_file",
            "description": (
                "Make a targeted edit to an existing file by replacing old_string with new_string. "
                "For creating new files, use write_file instead."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "file_path": {
                        "type": "string",
                        "description": "Path to the file to edit.",
                    },
                    "old_string": {
                        "type": "string",
                        "description": "The exact text to find and replace.",
                    },
                    "new_string": {
                        "type": "string",
                        "description": "The replacement text.",
                    },
                    "replace_all": {
                        "type": "boolean",
                        "description": "Replace all occurrences.",
                        "default": False,
                    },
                },
                "required": ["file_path", "old_string", "new_string"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "list_files",
            "description": (
                "Recursively list files matching a glob pattern. "
                "Returns paths sorted by modification time (newest first), "
                "relative to the base directory."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "pattern": {
                        "type": "string",
                        "description": (
                            'Glob pattern to match files, e.g. "**/*.py", '
                            '"src/**/*.ts".'
                        ),
                    },
                    "path": {
                        "type": "string",
                        "description": (
                            "File or directory to search in, relative to base "
                            'directory. Defaults to "." (base directory).'
                        ),
                        "default": ".",
                    },
                },
                "required": ["pattern"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "grep",
            "description": (
                "Search file contents for a regex pattern. "
                "Returns matches grouped by file with line numbers, "
                "sorted by file modification time (newest first)."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "pattern": {
                        "type": "string",
                        "description": "Python regex pattern to search for.",
                    },
                    "path": {
                        "type": "string",
                        "description": (
                            "File or directory to search in, relative to base "
                            'directory. Defaults to "." (base directory).'
                        ),
                        "default": ".",
                    },
                    "include": {
                        "type": "string",
                        "description": (
                            'Glob pattern to filter filenames, e.g. "*.py".'
                        ),
                    },
                    "case_insensitive": {
                        "type": "boolean",
                        "description": ("If true, search case-insensitively."),
                        "default": False,
                    },
                    "context_lines": {
                        "type": "integer",
                        "description": (
                            "Number of surrounding lines to show before "
                            "and after each match."
                        ),
                        "minimum": 0,
                        "default": 0,
                    },
                },
                "required": ["pattern"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "think",
            "description": (
                "Think step-by-step before acting. This is your scratchpad for reasoning — "
                "use it to plan, debug, weigh alternatives, or track what you've learned. "
                "Using think before complex actions leads to better outcomes."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "thought": {
                        "type": "string",
                        "description": "Your current thinking step. Max 10000 characters.",
                    },
                    "thought_number": {
                        "type": "integer",
                        "description": (
                            "Current step number (1-based). "
                            "Optional — auto-increments if omitted."
                        ),
                        "minimum": 1,
                    },
                    "total_thoughts": {
                        "type": "integer",
                        "description": (
                            "Estimated total steps needed. "
                            "Optional — defaults to 3 on first call, then carries forward."
                        ),
                        "minimum": 1,
                    },
                    "next_thought_needed": {
                        "type": "boolean",
                        "description": (
                            "true if you need more thinking steps, false when done. "
                            "Optional — defaults to true."
                        ),
                    },
                    "mode": {
                        "type": "string",
                        "enum": ["new", "revision", "branch"],
                        "description": (
                            "Type of thought. "
                            "'new' (default): normal thinking step. "
                            "'revision': correct a previous thought (set revises_thought). "
                            "'branch': explore an alternative (set branch_from_thought + branch_id)."
                        ),
                    },
                    "revises_thought": {
                        "type": "integer",
                        "description": (
                            "Which thought number is being revised. "
                            'Used with mode="revision".'
                        ),
                        "minimum": 1,
                    },
                    "branch_from_thought": {
                        "type": "integer",
                        "description": (
                            "Thought number to branch from. "
                            'Used with mode="branch", together with branch_id.'
                        ),
                        "minimum": 1,
                    },
                    "branch_id": {
                        "type": "string",
                        "description": (
                            "Label for the branch (e.g. 'approach-b'). "
                            'Used with mode="branch", together with branch_from_thought.'
                        ),
                        "minLength": 1,
                        "maxLength": 50,
                    },
                },
                "required": ["thought"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "todo",
            "description": (
                "Manage a persistent checklist. Use this to track work items as you "
                "discover them and mark them done as you complete them. Every action "
                "returns the current list. The list survives context compaction."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["add", "done", "remove", "clear", "list"],
                        "description": (
                            "add: Add a new task (duplicate tasks are ignored). "
                            "done: Mark a task as completed (no-op if already done). "
                            "remove: Remove a task entirely. "
                            "clear: Remove all tasks and start fresh. "
                            "list: Return the current list (no other params needed)."
                        ),
                    },
                    "tasks": {
                        "oneOf": [
                            {"type": "string"},
                            {"type": "array", "items": {"type": "string"}},
                        ],
                        "description": (
                            "For 'add': one or more task descriptions (each max 500 chars). "
                            "For 'done'/'remove': text matching existing tasks "
                            "(prefix match, case-insensitive). "
                            "Not needed for 'list' or 'clear'. "
                            "Accepts a single string or a list of strings."
                        ),
                    },
                },
                "required": ["action"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "view_image",
            "description": (
                "View an image file from the filesystem. "
                "You cannot see images without this tool — "
                "read_file does not work on image files. "
                "Supports PNG, JPEG, GIF, WebP, and BMP."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "image_path": {
                        "type": "string",
                        "description": "Path to the image file.",
                    },
                    "question": {
                        "type": "string",
                        "description": (
                            "Optional question about the image. "
                            "If omitted, describe what you see."
                        ),
                    },
                },
                "required": ["image_path"],
            },
        },
    },
]

_TOOL_ALIASES = {
    "run_shell_command": "run_command",
    "execute_command": "run_command",
    "execute_shell_command": "run_command",
    "shell_command": "run_command",
    "shell": "run_command",
    "bash": "run_command",
    "terminal": "run_command",
    "search": "grep",
    "search_files": "grep",
    "find_files": "list_files",
    "create_file": "write_file",
    "file_read": "read_file",
    "read_files": "read_multiple_files",
    "file_write": "write_file",
    "file_edit": "edit_file",
}

DELETE_FILE_TOOL = {
    "type": "function",
    "function": {
        "name": "delete_file",
        "description": (
            "Delete a file by moving it to the trash. Cannot delete directories."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "Path to the file to delete.",
                },
            },
            "required": ["file_path"],
        },
    },
}

TOOLS.append(DELETE_FILE_TOOL)

FETCH_URL_TOOL = {
    "type": "function",
    "function": {
        "name": "fetch_url",
        "description": (
            "Fetch the content of a URL and return it as markdown, plain text, or raw HTML. "
            "Use this to read documentation, APIs, web pages, or any HTTP-accessible resource."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "The URL to fetch (must start with http:// or https://).",
                },
                "format": {
                    "type": "string",
                    "enum": ["markdown", "text", "html"],
                    "description": (
                        "Output format. 'markdown' converts HTML to readable markdown (default). "
                        "'text' extracts plain text. 'html' returns raw HTML."
                    ),
                },
                "timeout": {
                    "type": "integer",
                    "description": "Request timeout in seconds (1-120, default 30).",
                },
            },
            "required": ["url"],
        },
    },
}

TOOLS.append(FETCH_URL_TOOL)

SNAPSHOT_TOOL = {
    "type": "function",
    "function": {
        "name": "snapshot",
        "description": (
            "Context management: collapse exploration into a compact summary. "
            "After reading files, grepping, or investigating, call restore to "
            "replace the exploration turns with your summary, freeing context. "
            "Summaries survive compaction."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["save", "restore", "cancel", "status"],
                    "description": (
                        "save: Set an explicit checkpoint (requires label). "
                        "restore: Collapse turns since checkpoint into summary. "
                        "cancel: Clear explicit checkpoint. "
                        "status: Report current state."
                    ),
                },
                "label": {
                    "type": "string",
                    "description": "Label for save checkpoint (max 100 chars). Required for save.",
                    "maxLength": 100,
                },
                "summary": {
                    "type": "string",
                    "description": (
                        "Summary to replace the collapsed turns (max 4000 chars). "
                        "Required for restore. Include file paths, function names, "
                        "line numbers, and key decisions."
                    ),
                    "maxLength": 4000,
                },
                "force": {
                    "type": "boolean",
                    "description": (
                        "Override dirty scope protection. Use when the scope "
                        "contains writes/commands and you're confident the "
                        "summary captures them. Default false."
                    ),
                },
            },
            "required": ["action"],
        },
    },
}

TOOLS.append(SNAPSHOT_TOOL)

USE_SKILL_TOOL = {
    "type": "function",
    "function": {
        "name": "use_skill",
        "description": "Activate a skill to get detailed instructions for a specific task.",
        "parameters": {
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "description": "The skill name to activate.",
                }
            },
            "required": ["name"],
        },
    },
}

RUN_COMMAND_TOOL = {
    "type": "function",
    "function": {
        "name": "run_command",
        "description": "Run a command and return its output. Only whitelisted commands are allowed.",
        "parameters": {
            "type": "object",
            "properties": {
                "command": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": 'Command as an array of strings (NOT a single string). Each argument is a separate element. Correct: ["ls", "-la", "src/"]. Wrong: "ls -la src/".',
                },
                "timeout": {
                    "type": "integer",
                    "description": "Timeout in seconds (1-120). Defaults to 30.",
                    "default": 30,
                },
            },
            "required": ["command"],
        },
    },
}

_TOOL_NAMES = [t["function"]["name"] for t in TOOLS] + [
    USE_SKILL_TOOL["function"]["name"],
    RUN_COMMAND_TOOL["function"]["name"],
]

MAX_OUTPUT_BYTES = 50 * 1024  # 50 KB
MAX_LINE_LENGTH = 2000
BINARY_CHECK_BYTES = 8 * 1024  # 8 KB

IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
IMAGE_MIME = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".gif": "image/gif",
    ".webp": "image/webp",
    ".bmp": "image/bmp",
}
MAX_IMAGE_BYTES = 20 * 1024 * 1024  # 20 MB


def safe_resolve(
    file_path: str,
    base_dir: str,
    extra_read_roots: list[Path] = (),
    extra_write_roots: list[Path] = (),
    unrestricted: bool = False,
) -> Path:
    """Resolve a file path, ensuring it stays within allowed roots.

    Resolves symlinks for both the base directory and the target path,
    then checks containment against base_dir first, then each extra_read_roots
    entry. extra_read_roots is only used for read operations.

    When unrestricted is True, resolves the path but skips containment checks.

    Raises:
        ValueError: If the resolved path escapes all allowed roots (when not unrestricted).
    """
    base = Path(base_dir).resolve()

    # For absolute paths, resolve directly; for relative, resolve against base_dir
    if Path(file_path).is_absolute():
        resolved = Path(file_path).resolve()
    else:
        resolved = (base / file_path).resolve()

    if unrestricted:
        # Even in unrestricted mode, block the filesystem root
        if resolved == Path(resolved.anchor):
            raise ValueError(
                f"Path {file_path!r} resolves to the filesystem root, "
                f"which is not allowed even in unrestricted mode"
            )
        return resolved

    # Check base_dir first
    if resolved.is_relative_to(base):
        return resolved

    # Check extra read roots
    for root in extra_read_roots:
        if resolved.is_relative_to(root):
            return resolved

    # Check extra write roots (--add-dir paths, read+write access)
    for root in extra_write_roots:
        if resolved.is_relative_to(root):
            return resolved

    raise ValueError(
        f"Path {file_path!r} resolves to {resolved}, "
        f"which is outside base directory {base}"
    )


MAX_LIST_RESULTS = 100
MAX_GREP_MATCHES = 100


def _check_pattern(pattern: str) -> str | None:
    """Reject patterns that are absolute or contain '..'."""
    if PurePosixPath(pattern).is_absolute() or PureWindowsPath(pattern).is_absolute():
        return f"error: pattern {pattern!r} must be relative, not absolute"
    # Check both POSIX and Windows path splitting so that both
    # "../foo" and "..\\foo" are caught.
    posix_parts = PurePosixPath(pattern).parts
    win_parts = PureWindowsPath(pattern).parts
    if ".." in posix_parts or ".." in win_parts:
        return f"error: pattern {pattern!r} contains '..', which is not allowed"
    return None


def _is_within_base(
    path: Path,
    base: Path,
    unrestricted: bool = False,
    extra_read_roots: list[Path] = (),
    extra_write_roots: list[Path] = (),
) -> bool:
    """Check that a resolved path is within the base directory or extra roots."""
    if unrestricted:
        return True
    try:
        resolved = path.resolve()
    except (OSError, ValueError):
        return False
    if resolved.is_relative_to(base.resolve()):
        return True
    # extra roots are already resolved at startup, no need to resolve again
    for root in extra_read_roots:
        if resolved.is_relative_to(root):
            return True
    for root in extra_write_roots:
        if resolved.is_relative_to(root):
            return True
    return False


def _split_absolute_glob(pattern: str) -> tuple[str, str]:
    """Split an absolute glob into (directory_root, relative_pattern).

    Walks the pattern parts until we hit a component with glob metacharacters,
    then splits there.  E.g. "/opt/zig/lib/std/**/*.zig" → ("/opt/zig/lib/std", "**/*.zig").

    Handles both POSIX and Windows paths: r"C:\\Users\\alice\\*.py" →
    ("C:\\Users\\alice", "*.py").
    """
    # Pick the right PurePath class based on which style recognises this as absolute.
    if (
        PureWindowsPath(pattern).is_absolute()
        and not PurePosixPath(pattern).is_absolute()
    ):
        cls = PureWindowsPath
    else:
        cls = PurePosixPath

    parts = cls(pattern).parts
    root_parts: list[str] = []
    glob_start = len(parts)
    for i, part in enumerate(parts):
        if any(c in part for c in ("*", "?", "[", "]")):
            glob_start = i
            break
        root_parts.append(part)
    root = str(cls(*root_parts)) if root_parts else str(cls(parts[0]))
    rel = str(PurePosixPath(*parts[glob_start:])) if glob_start < len(parts) else "*"
    return root, rel


def _list_files(
    pattern: str,
    path: str,
    base_dir: str,
    extra_read_roots: list[Path] = (),
    extra_write_roots: list[Path] = (),
    unrestricted: bool = False,
) -> str:
    """Recursively list files matching a glob pattern."""
    # When the pattern is an absolute glob, split it into a root directory
    # and a relative pattern.  safe_resolve() will then authorize the root
    # against base_dir / extra roots (or skip checks in unrestricted mode).
    if PurePosixPath(pattern).is_absolute() or PureWindowsPath(pattern).is_absolute():
        path, pattern = _split_absolute_glob(pattern)
    else:
        err = _check_pattern(pattern)
        if err:
            return err

    try:
        root = safe_resolve(
            path,
            base_dir,
            extra_read_roots=extra_read_roots,
            extra_write_roots=extra_write_roots,
            unrestricted=unrestricted,
        )
    except ValueError as exc:
        return f"error: {exc}"

    if not root.exists():
        return f"error: path does not exist: {path}"

    base = Path(base_dir).resolve()

    if root.is_file():
        # Single file — just return it, ignore pattern.
        if not _is_within_base(
            root,
            base,
            unrestricted=unrestricted,
            extra_read_roots=extra_read_roots,
            extra_write_roots=extra_write_roots,
        ):
            return f"error: {path} is outside allowed roots"
        try:
            rel = str(root.relative_to(base))
        except ValueError:
            rel = str(root)
        return rel

    if not root.is_dir():
        return f"error: path is not a directory: {path}"

    # Walk the tree, pruning .git directories
    matched: list[Path] = []
    for dirpath, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d != ".git"]
        for filename in files:
            filepath = Path(dirpath) / filename
            # Match the relative path (from root) against the glob pattern
            rel_to_root = filepath.relative_to(root)
            if not PurePath(rel_to_root).full_match(pattern):
                continue
            # Per-match containment check
            if not _is_within_base(
                filepath,
                base,
                unrestricted=unrestricted,
                extra_read_roots=extra_read_roots,
                extra_write_roots=extra_write_roots,
            ):
                continue
            matched.append(filepath)

    if not matched:
        return "No files matched the pattern."

    # Sort by modification time, newest first
    matched.sort(key=lambda f: f.stat().st_mtime, reverse=True)

    # Cap results and format output
    truncated = len(matched) > MAX_LIST_RESULTS
    matched = matched[:MAX_LIST_RESULTS]

    output_parts: list[str] = []
    total_bytes = 0
    byte_truncated = False
    for filepath in matched:
        try:
            rel = str(filepath.relative_to(base))
        except ValueError:
            rel = str(filepath)
        encoded_len = len(rel.encode("utf-8")) + 1
        if total_bytes + encoded_len > MAX_OUTPUT_BYTES:
            byte_truncated = True
            break
        output_parts.append(rel)
        total_bytes += encoded_len

    result = "\n".join(output_parts)
    if truncated or byte_truncated:
        result += (
            "\n(Results truncated: showing first 100 results. "
            "Use a more specific pattern or path.)"
        )
    return result


def _grep(
    pattern: str,
    path: str,
    base_dir: str,
    include: str | None = None,
    case_insensitive: bool = False,
    context_lines: int = 0,
    extra_read_roots: list[Path] = (),
    extra_write_roots: list[Path] = (),
    unrestricted: bool = False,
) -> str:
    """Search file contents for a regex pattern."""
    context_lines = max(0, context_lines)  # defensive clamp

    # Validate include pattern — only enforce in sandboxed mode
    if include is not None and not unrestricted:
        err = _check_pattern(include)
        if err:
            return err

    # Compile regex
    try:
        flags = re.IGNORECASE if case_insensitive else 0
        regex = re.compile(pattern, flags)
    except re.error as exc:
        return f"error: invalid regex {pattern!r}: {exc}"

    try:
        root = safe_resolve(
            path,
            base_dir,
            extra_read_roots=extra_read_roots,
            extra_write_roots=extra_write_roots,
            unrestricted=unrestricted,
        )
    except ValueError as exc:
        return f"error: {exc}"

    if not root.exists():
        return f"error: path does not exist: {path}"

    base = Path(base_dir).resolve()

    # Collect ALL matches as (file_path, line_no, line_text, mtime),
    # then sort and cap — so the cap always picks the newest files.
    matches: list[tuple[Path, int, str, float]] = []
    # Cache file lines for context expansion (avoids re-reading files)
    file_lines: dict[Path, list[str]] = {}

    def _search_file(fp: Path, mtime: float, text: str) -> None:
        lines = text.splitlines()
        if context_lines > 0:
            file_lines[fp] = lines
        for line_no, line in enumerate(lines, start=1):
            if regex.search(line):
                matches.append((fp, line_no, line, mtime))

    if root.is_file():
        # Single file — ignore include, grep it directly.
        if not _is_within_base(
            root,
            base,
            unrestricted=unrestricted,
            extra_read_roots=extra_read_roots,
            extra_write_roots=extra_write_roots,
        ):
            return f"error: {path} is outside allowed roots"
        # Skip binary (silent, consistent with directory mode)
        try:
            with open(root, "rb") as f:
                chunk = f.read(BINARY_CHECK_BYTES)
        except (PermissionError, OSError):
            chunk = b""
        if b"\x00" not in chunk:
            try:
                text = root.read_text(encoding="utf-8")
            except (UnicodeDecodeError, PermissionError, OSError):
                text = ""
            _search_file(root, root.stat().st_mtime, text)

    elif not root.is_dir():
        return f"error: path is not a directory: {path}"

    else:
        # Walk the tree, pruning .git directories
        for dirpath, dirs, files in os.walk(root):
            dirs[:] = [d for d in dirs if d != ".git"]
            for filename in files:
                # Filter by include pattern
                if include:
                    rel = (Path(dirpath) / filename).relative_to(root)
                    # PurePath.match handles ** globs (Python 3.12+), but
                    # '**/*.ext' won't match root-level files (no dir
                    # component).  Fall back to fnmatch on the bare
                    # filename so '**/*.zig' still matches 'a.zig'.
                    if not rel.match(include) and not fnmatch.fnmatch(
                        filename,
                        include.split("/")[-1] if "/" in include else include,
                    ):
                        continue

                filepath = Path(dirpath) / filename

                # Per-file containment check
                if not _is_within_base(
                    filepath,
                    base,
                    unrestricted=unrestricted,
                    extra_read_roots=extra_read_roots,
                    extra_write_roots=extra_write_roots,
                ):
                    continue

                # Skip binary files
                try:
                    with open(filepath, "rb") as f:
                        chunk = f.read(BINARY_CHECK_BYTES)
                except (PermissionError, OSError):
                    continue
                if b"\x00" in chunk:
                    continue

                # Read and search
                try:
                    text = filepath.read_text(encoding="utf-8")
                except (UnicodeDecodeError, PermissionError, OSError):
                    continue

                _search_file(filepath, filepath.stat().st_mtime, text)

    if not matches:
        return "No matches found."

    # Sort by file mtime (newest first), then by line number within each file
    matches.sort(key=lambda m: (-m[3], m[0], m[1]))

    # Cap after sorting so the top-100 are truly the newest
    total_found = len(matches)
    truncated = total_found > MAX_GREP_MATCHES
    matches = matches[:MAX_GREP_MATCHES]

    # Group capped matches by file, preserving mtime-based order.
    # Build context blocks: list of (line_no, line_text, is_match) triples.
    # When context_lines == 0, each match is its own single-entry block.
    file_match_map: dict[Path, list[tuple[int, str]]] = {}
    for filepath, line_no, line_text, _ in matches:
        file_match_map.setdefault(filepath, []).append((line_no, line_text))

    grouped: dict[Path, list[list[tuple[int, str, bool]]]] = {}
    for filepath, file_match_tuples in file_match_map.items():
        if context_lines > 0:
            all_lines = file_lines.get(filepath, [])
            match_line_nos = {ln for ln, _ in file_match_tuples}
            blocks: list[list[tuple[int, str, bool]]] = []
            for line_no, _ in file_match_tuples:
                start = max(0, line_no - 1 - context_lines)
                end = min(len(all_lines), line_no + context_lines)
                window = [
                    (i + 1, all_lines[i], (i + 1) in match_line_nos)
                    for i in range(start, end)
                ]
                # Merge with previous block if overlapping/adjacent
                if blocks and window[0][0] <= blocks[-1][-1][0] + 1:
                    prev = blocks[-1]
                    prev_end = prev[-1][0]
                    for entry in window:
                        if entry[0] > prev_end:
                            prev.append(entry)
                else:
                    blocks.append(window)
            grouped[filepath] = blocks
        else:
            grouped[filepath] = [[(ln, lt, True)] for ln, lt in file_match_tuples]

    output_parts: list[str] = []
    total_bytes = 0
    byte_truncated = False

    header = f"Found {total_found} match{'es' if total_found != 1 else ''}"
    output_parts.append(header)
    total_bytes += len(header.encode("utf-8")) + 1

    for filepath, blocks in grouped.items():
        try:
            rel = str(filepath.relative_to(base))
        except ValueError:
            rel = str(filepath)
        file_header = f"\n{rel}:"
        encoded_len = len(file_header.encode("utf-8")) + 1
        if total_bytes + encoded_len > MAX_OUTPUT_BYTES:
            byte_truncated = True
            break
        output_parts.append(file_header)
        total_bytes += encoded_len

        for block_idx, block in enumerate(blocks):
            if block_idx > 0 and context_lines > 0:
                sep = "  --"
                encoded_len = len(sep.encode("utf-8")) + 1
                if total_bytes + encoded_len > MAX_OUTPUT_BYTES:
                    byte_truncated = True
                    break
                output_parts.append(sep)
                total_bytes += encoded_len
            for line_no, line_text, is_match in block:
                if len(line_text) > MAX_LINE_LENGTH:
                    line_text = line_text[:MAX_LINE_LENGTH]
                marker = "  <<<" if context_lines > 0 and is_match else ""
                entry = f"  Line {line_no}: {line_text}{marker}"
                encoded_len = len(entry.encode("utf-8")) + 1
                if total_bytes + encoded_len > MAX_OUTPUT_BYTES:
                    byte_truncated = True
                    break
                output_parts.append(entry)
                total_bytes += encoded_len
            if byte_truncated:
                break
        if byte_truncated:
            break

    result = "\n".join(output_parts)
    if truncated or byte_truncated:
        result += (
            "\n(Results truncated: showing first 100 matches. "
            "Use a more specific pattern or path.)"
        )
    return result


def _read_file(
    file_path: str,
    base_dir: str,
    offset: int = 1,
    limit: int = 2000,
    tail: int | None = None,
    extra_read_roots: list[Path] = (),
    extra_write_roots: list[Path] = (),
    unrestricted: bool = False,
    tracker=None,
) -> str:
    """Read a file or list a directory."""
    try:
        resolved = safe_resolve(
            file_path,
            base_dir,
            extra_read_roots=extra_read_roots,
            extra_write_roots=extra_write_roots,
            unrestricted=unrestricted,
        )
    except ValueError as exc:
        return f"error: {exc}"

    if not resolved.exists():
        return f"error: path does not exist: {file_path}"

    # Directory listing
    if resolved.is_dir():
        output_parts = []
        total_bytes = 0
        truncated = False
        try:
            for child in sorted(resolved.iterdir()):
                name = child.name + ("/" if child.is_dir() else "")
                encoded_len = len(name.encode("utf-8")) + 1  # +1 for newline
                if total_bytes + encoded_len > MAX_OUTPUT_BYTES:
                    truncated = True
                    break
                output_parts.append(name)
                total_bytes += encoded_len
        except PermissionError as exc:
            return f"error: {exc}"
        result = "\n".join(output_parts)
        if truncated:
            result += "\n[truncated at 50KB]"
        return result

    # Binary detection: check first 8 KB for null bytes
    try:
        with open(resolved, "rb") as f:
            chunk = f.read(BINARY_CHECK_BYTES)
    except FileNotFoundError:
        return f"error: file not found (removed after check): {file_path}"
    except PermissionError as exc:
        return f"error: {exc}"

    if b"\x00" in chunk:
        ext = resolved.suffix.lower()
        if ext in IMAGE_EXTENSIONS:
            return f"error: {file_path} is an image file. Use view_image to analyze it."
        return f"error: binary file detected: {file_path}"

    # Read as UTF-8 text
    try:
        text = resolved.read_text(encoding="utf-8")
    except FileNotFoundError:
        return f"error: file not found (removed after check): {file_path}"
    except UnicodeDecodeError as exc:
        return f"error: failed to decode {file_path} as UTF-8: {exc}"
    except PermissionError as exc:
        return f"error: {exc}"

    lines = text.splitlines()

    # Apply tail or offset (1-based) and limit
    if tail is not None:
        if not isinstance(tail, int):
            return f"error: tail must be an integer, got {type(tail).__name__}"
        tail = max(tail, 1)
        if tail == 1 and limit > 1:
            tail = limit
        start = max(len(lines) - tail, 0)
    else:
        start = max(offset - 1, 0)
    end = start + limit
    selected = lines[start:end]

    # Build output with line numbers, truncating long lines
    output_parts = []
    total_bytes = 0
    lines_emitted = 0

    for i, line in enumerate(selected, start=start + 1):
        if len(line) > MAX_LINE_LENGTH:
            line = line[:MAX_LINE_LENGTH]
        numbered = f"{i}: {line}"
        encoded_len = len(numbered.encode("utf-8")) + 1  # +1 for newline
        if total_bytes + encoded_len > MAX_OUTPUT_BYTES:
            break
        output_parts.append(numbered)
        total_bytes += encoded_len
        lines_emitted += 1

    total_lines = len(lines)
    remaining = total_lines - (start + lines_emitted)

    if tracker is not None and (lines_emitted > 0 or total_lines == 0):
        tracker.record_read(str(resolved))

    result = "\n".join(output_parts)
    if remaining > 0:
        next_offset = start + lines_emitted + 1  # 1-based
        result += f"\n[{remaining} more lines, use offset={next_offset} to continue]"
    return result


_MAX_READ_FILES = 20
_READ_FILES_BUDGET = MAX_OUTPUT_BYTES  # total bytes across all files


def _format_read_request(offset: int, limit: int, tail: int | None) -> str:
    if tail is not None:
        return f"tail={tail}"
    return f"offset={offset} limit={limit}"


def _split_read_result(result: str) -> tuple[str, bool, str | None]:
    lines = result.splitlines()
    next_offset = None
    content_truncated = False
    if lines and lines[-1].startswith("[") and lines[-1].endswith("]"):
        trailer = lines[-1]
        if "use offset=" in trailer:
            content_truncated = True
            match = re.search(r"offset=(\d+)", trailer)
            if match:
                next_offset = match.group(1)
            lines = lines[:-1]
    content = "\n".join(lines)
    return content, content_truncated, next_offset


def _build_read_multiple_files_section(
    title: str,
    status: str,
    request: str,
    body: str,
    *,
    content_truncated: bool | None = None,
    next_offset: str | None = None,
) -> str:
    parts = [
        f"=== FILE: {title} ===",
        f"status: {status}",
        f"request: {request}",
    ]
    if status == "ok":
        parts.append(f"content_truncated: {'true' if content_truncated else 'false'}")
        if body:
            parts.append(body)
        if next_offset is not None:
            parts.append(f"[next_offset={next_offset}]")
    else:
        parts.append(body)
    return "\n".join(parts)


def _view_image(
    image_path: str,
    base_dir: str,
    image_stash: list,
    question: str | None = None,
    extra_read_roots: list[Path] = (),
    extra_write_roots: list[Path] = (),
    unrestricted: bool = False,
) -> str:
    """Load an image file and stash it for injection into the message stream."""
    try:
        resolved = safe_resolve(
            image_path,
            base_dir,
            extra_read_roots=extra_read_roots,
            extra_write_roots=extra_write_roots,
            unrestricted=unrestricted,
        )
    except ValueError as exc:
        return f"error: {exc}"

    if not resolved.exists():
        return f"error: file not found: {image_path}"
    if resolved.is_dir():
        return f"error: {image_path} is a directory, not an image file"

    ext = resolved.suffix.lower()
    if ext not in IMAGE_EXTENSIONS:
        return (
            f"error: unsupported image format ({ext}). "
            "Supported: PNG, JPEG, GIF, WebP (and BMP on some providers)."
        )

    try:
        size = resolved.stat().st_size
    except OSError as exc:
        return f"error: {exc}"
    if size == 0:
        return "error: image file is empty"
    if size > MAX_IMAGE_BYTES:
        return f"error: image too large ({size:,} bytes, max {MAX_IMAGE_BYTES:,})"

    try:
        data = resolved.read_bytes()
    except OSError as exc:
        return f"error: {exc}"

    b64 = base64.b64encode(data).decode("ascii")
    mime = IMAGE_MIME[ext]
    data_url = f"data:{mime};base64,{b64}"

    image_stash.append(
        {
            "data_url": data_url,
            "question": question or "",
            "path": str(image_path),
        }
    )

    size_kb = len(data) / 1024
    return (
        f"Image loaded: {resolved.name} ({size_kb:.0f} KB). "
        "The image has been attached and will be visible on your next turn. "
        "Proceed to analyze it."
    )


def _read_files(
    files: list[dict],
    base_dir: str,
    extra_read_roots: list[Path] = (),
    extra_write_roots: list[Path] = (),
    unrestricted: bool = False,
    tracker=None,
) -> str:
    """Read multiple files and return results grouped by file."""
    if not files:
        return "error: files list is empty"
    if len(files) > _MAX_READ_FILES:
        return f"error: too many files requested ({len(files)}), maximum is {_MAX_READ_FILES}"

    sections = []
    total_bytes = 0
    files_with_errors = 0
    files_succeeded = 0
    skipped_files = 0

    for i, spec in enumerate(files):
        if isinstance(spec, str):
            spec = {"file_path": spec}
        if not isinstance(spec, dict):
            sections.append(
                _build_read_multiple_files_section(
                    f"file {i + 1}",
                    "error",
                    _format_read_request(1, 2000, None),
                    f"error: expected object or string, got {type(spec).__name__}",
                )
            )
            files_with_errors += 1
            continue
        file_path = spec.get("file_path")
        title = file_path or f"file {i + 1}"
        if not file_path:
            request = _format_read_request(1, 2000, None)
            sections.append(
                _build_read_multiple_files_section(
                    title,
                    "error",
                    request,
                    "error: missing file_path",
                )
            )
            files_with_errors += 1
            continue

        offset = spec.get("offset", 1)
        limit = spec.get("limit", 2000)
        tail = spec.get("tail")

        try:
            offset = int(offset)
        except (ValueError, TypeError):
            sections.append(
                _build_read_multiple_files_section(
                    file_path,
                    "error",
                    _format_read_request(1, 2000, None),
                    "error: offset must be an integer",
                )
            )
            files_with_errors += 1
            continue
        try:
            limit = int(limit)
        except (ValueError, TypeError):
            sections.append(
                _build_read_multiple_files_section(
                    file_path,
                    "error",
                    _format_read_request(offset, 2000, None),
                    "error: limit must be an integer",
                )
            )
            files_with_errors += 1
            continue
        if tail is not None:
            try:
                tail = int(tail)
            except (ValueError, TypeError):
                sections.append(
                    _build_read_multiple_files_section(
                        file_path,
                        "error",
                        _format_read_request(offset, limit, None),
                        "error: tail must be an integer",
                    )
                )
                files_with_errors += 1
                continue
            offset = 1

        request = _format_read_request(offset, limit, tail)

        # Reject directories — this tool is for files only.
        try:
            resolved = safe_resolve(
                file_path,
                base_dir,
                extra_read_roots=extra_read_roots,
                extra_write_roots=extra_write_roots,
                unrestricted=unrestricted,
            )
            if resolved.exists() and resolved.is_dir():
                sections.append(
                    _build_read_multiple_files_section(
                        file_path,
                        "error",
                        request,
                        "error: is a directory, use read_file to list directories",
                    )
                )
                files_with_errors += 1
                continue
        except ValueError:
            pass  # Let _read_file return the path error in the normal format.

        result = _read_file(
            file_path=file_path,
            base_dir=base_dir,
            offset=offset,
            limit=limit,
            tail=tail,
            extra_read_roots=extra_read_roots,
            extra_write_roots=extra_write_roots,
            unrestricted=unrestricted,
            tracker=tracker,
        )

        if result.startswith("error: "):
            section = _build_read_multiple_files_section(
                file_path,
                "error",
                request,
                result,
            )
            files_with_errors += 1
        else:
            content, content_truncated, next_offset = _split_read_result(result)
            section = _build_read_multiple_files_section(
                file_path,
                "ok",
                request,
                content,
                content_truncated=content_truncated,
                next_offset=next_offset,
            )
            files_succeeded += 1

        section_bytes = len(section.encode("utf-8")) + 2
        if total_bytes + section_bytes > _READ_FILES_BUDGET:
            if not sections:
                sections.append(section)
                total_bytes += section_bytes
            skipped_files = len(files) - (i + 1)
            break

        sections.append(section)
        total_bytes += section_bytes

    header = "\n".join(
        [
            f"files_succeeded: {files_succeeded}",
            f"files_with_errors: {files_with_errors}",
            f"batch_truncated: {'true' if skipped_files > 0 else 'false'}",
        ]
    )
    output = header
    if sections:
        output += "\n\n" + "\n\n".join(sections)
    if skipped_files > 0:
        output += (
            f"\n\n[batch_truncated: {skipped_files} file(s) skipped due to size limit]"
        )
    return output


def _write_file(
    file_path: str,
    content: str | None,
    base_dir: str,
    move_from: str | None = None,
    extra_write_roots: list[Path] = (),
    unrestricted: bool = False,
    tracker=None,
) -> str:
    """Create or overwrite a file, or atomically rename one.

    Exactly one of content or move_from must be set.
    """
    # Treat empty strings as "not provided".
    if content is not None and not content and move_from:
        content = None
    if move_from is not None and not move_from:
        move_from = None

    if content is not None and move_from is not None:
        return "error: set content or move_from, not both"
    if content is None and move_from is None:
        return "error: set content or move_from"

    # Resolve destination.
    try:
        resolved = safe_resolve(
            file_path,
            base_dir,
            extra_write_roots=extra_write_roots,
            unrestricted=unrestricted,
        )
    except ValueError as exc:
        return f"error: {exc}"

    # Read guard on destination (only blocks if the file already exists).
    if tracker is not None:
        error = tracker.check_write_allowed(str(resolved), resolved.exists())
        if error:
            return error

    # --- Rename path ---
    if move_from is not None:
        try:
            move_from_resolved = safe_resolve(
                move_from,
                base_dir,
                extra_write_roots=extra_write_roots,
                unrestricted=unrestricted,
            )
        except ValueError as exc:
            return f"error: {exc}"

        if move_from_resolved == resolved:
            return "error: move_from and file_path resolve to the same path"

        # Use the original (pre-resolved) path for existence/type checks so
        # that dangling symlinks are handled consistently with delete_file.
        move_from_original = (
            Path(base_dir) / move_from
            if not Path(move_from).is_absolute()
            else Path(move_from)
        )

        if not move_from_original.exists() and not move_from_original.is_symlink():
            return f"error: move_from not found: {move_from}"

        if not move_from_original.is_symlink() and move_from_original.is_dir():
            return "error: move_from is a directory (cannot move directories)"

        resolved.parent.mkdir(parents=True, exist_ok=True)
        try:
            move_from_original.rename(resolved)
        except OSError:
            shutil.move(str(move_from_original), str(resolved))
        if tracker is not None:
            tracker.record_write(str(resolved))
        return f"Moved {move_from} -> {file_path}"

    # --- Write path ---
    resolved.parent.mkdir(parents=True, exist_ok=True)
    if not isinstance(content, str):
        content = json.dumps(content, indent=2, ensure_ascii=False)
    data = content.encode("utf-8")
    resolved.write_bytes(data)
    if tracker is not None:
        tracker.record_write(str(resolved))
    return f"Wrote {len(data)} bytes to {file_path}"


def _edit_file(
    file_path: str,
    old_string: str,
    new_string: str,
    base_dir: str,
    replace_all: bool = False,
    extra_write_roots: list[Path] = (),
    unrestricted: bool = False,
    tracker=None,
    verbose: bool = False,
) -> str:
    """Replace old_string with new_string in an existing file."""
    from .edit import replace

    try:
        resolved = safe_resolve(
            file_path,
            base_dir,
            extra_write_roots=extra_write_roots,
            unrestricted=unrestricted,
        )
    except ValueError as exc:
        return f"error: {exc}"

    if not resolved.exists():
        return f"error: file does not exist: {file_path}"

    if tracker is not None:
        error = tracker.check_write_allowed(str(resolved), exists=True)
        if error:
            return error

    if not old_string:
        return "error: old_string must not be empty"

    try:
        content = resolved.read_text(encoding="utf-8")
    except (UnicodeDecodeError, PermissionError, OSError) as exc:
        return f"error: {exc}"

    try:
        new_content = replace(content, old_string, new_string, replace_all=replace_all)
    except ValueError as exc:
        return f"error: {exc}"

    resolved.write_text(new_content, encoding="utf-8")

    if verbose:
        try:
            from . import fmt

            fmt.tool_diff(file_path, content, new_content)
        except Exception as exc:
            import logging

            logging.getLogger(__name__).debug("tool_diff failed: %s", exc)

    return f"Edited {file_path}"


# ---------------------------------------------------------------------------
# delete_file (soft-delete to .swival/trash/)
# ---------------------------------------------------------------------------

TRASH_MAX_AGE = 7 * 24 * 3600  # seconds
TRASH_MAX_BYTES = 50 * 1024 * 1024


def _dir_size(p: Path) -> int:
    """Total size of all files under directory *p* (no symlink following)."""
    total = 0
    try:
        for f in p.rglob("*"):
            try:
                total += f.lstat().st_size
            except (OSError, FileNotFoundError):
                pass
    except (OSError, FileNotFoundError):
        pass
    return total


@contextlib.contextmanager
def _trash_lock(base_dir: str):
    """Acquire an advisory file lock on .swival/trash/.lock.

    Serializes the entire trash critical section (cleanup + move + index
    append) so concurrent contexts don't race.  Falls back to a no-op on
    platforms without fcntl (Windows) or when the lock file cannot be opened.
    """
    try:
        import fcntl
    except ImportError:
        yield
        return

    lock_dir = Path(base_dir) / SWIVAL_DIR / "trash"
    try:
        lock_dir.mkdir(parents=True, exist_ok=True)
    except OSError:
        yield
        return
    lock_path = lock_dir / ".lock"

    try:
        fd = os.open(str(lock_path), os.O_WRONLY | os.O_CREAT, 0o644)
    except OSError:
        # Can't create lock file — proceed without lock.
        yield
        return

    try:
        fcntl.flock(fd, fcntl.LOCK_EX)
        yield
    finally:
        try:
            fcntl.flock(fd, fcntl.LOCK_UN)
        except OSError:
            pass
        os.close(fd)


def _cleanup_trash(base_dir: str, exclude: str | None = None) -> None:
    """Enforce retention limits on .swival/trash/.

    Args:
        base_dir: Project base directory.
        exclude: Trash ID to protect from eviction (just added). Its size
                 still counts against the budget.
    """
    import time as _time

    trash_root = Path(base_dir) / SWIVAL_DIR / "trash"
    if not trash_root.is_dir():
        return

    now = _time.time()
    entries: list[tuple[float, int, Path]] = []  # (mtime, size, path)

    for entry in trash_root.iterdir():
        if entry.name == "index.jsonl":
            continue
        try:
            if not entry.is_dir():
                continue
            st = entry.stat()
        except FileNotFoundError:
            continue
        entries.append((st.st_mtime, _dir_size(entry), entry))

    # Pass 1: remove entries older than TRASH_MAX_AGE.
    remaining = []
    for mtime, size, path in entries:
        if now - mtime > TRASH_MAX_AGE and path.name != exclude:
            try:
                shutil.rmtree(path)
            except FileNotFoundError:
                pass
        else:
            remaining.append((mtime, size, path))

    # Pass 2: enforce size cap, oldest first.
    remaining.sort(key=lambda t: t[0])  # oldest first
    total = sum(s for _, s, _ in remaining)
    for mtime, size, path in remaining:
        if total <= TRASH_MAX_BYTES:
            break
        if path.name == exclude:
            continue
        try:
            shutil.rmtree(path)
        except FileNotFoundError:
            pass
        total -= size


def _delete_file(
    file_path: str,
    base_dir: str,
    extra_write_roots: list[Path] = (),
    unrestricted: bool = False,
    tracker=None,
    tool_call_id: str = "",
) -> str:
    """Soft-delete a file by moving it to .swival/trash/."""
    from . import fmt as _fmt

    # 1. Sandbox check.
    try:
        resolved = safe_resolve(
            file_path,
            base_dir,
            extra_write_roots=extra_write_roots,
            unrestricted=unrestricted,
        )
    except ValueError as exc:
        return f"error: {exc}"

    # 2. Build pre-resolution path for existence/type checks.
    original = (
        Path(base_dir) / file_path
        if not Path(file_path).is_absolute()
        else Path(file_path)
    )

    # 3. Existence check (is_symlink catches dangling symlinks).
    if not original.exists() and not original.is_symlink():
        return f"error: file not found: {file_path}"

    # 4. Type check: symlinks (even to dirs) are OK, actual dirs are not.
    if not original.is_symlink() and original.is_dir():
        return "error: is a directory (delete individual files instead)"

    # 5. Read guard.
    if tracker is not None and not original.is_symlink():
        error = tracker.check_write_allowed(str(resolved), exists=True)
        if error:
            return error

    # Capture before move (original is gone after rename).
    original_was_symlink = original.is_symlink()

    # 6–10 are serialized via file lock to prevent concurrent contexts
    # from racing on cleanup / move / index-append.
    with _trash_lock(base_dir):
        # 6. Pre-move cleanup.
        _cleanup_trash(base_dir)

        # 7. Generate trash ID.
        trash_id = uuid.uuid4().hex

        # 8. Move to trash.
        trash_root = Path(base_dir) / SWIVAL_DIR / "trash"
        trash_dir = trash_root / trash_id
        trash_dir.mkdir(parents=True, exist_ok=True)
        dest = trash_dir / original.name

        try:
            original.rename(dest)
        except OSError:
            # Cross-filesystem fallback.
            shutil.move(str(original), str(dest))

        # Record in tracker so recreating the same path is allowed.
        # For symlinks, record the link path (not the resolved target) to avoid
        # leaking write authorization to the target file.
        if tracker is not None and not original_was_symlink:
            tracker.record_write(str(resolved))

        # 9. Post-move cleanup (enforce cap including new entry).
        _cleanup_trash(base_dir, exclude=trash_id)

        # 10. Append to index.jsonl.
        from datetime import datetime, timezone

        index_path = trash_root / "index.jsonl"
        entry = json.dumps(
            {
                "trash_id": trash_id,
                "original_path": file_path,
                "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
                "tool_call_id": tool_call_id,
            }
        )
        try:
            fd = os.open(str(index_path), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
            try:
                os.write(fd, (entry + "\n").encode("utf-8"))
            finally:
                os.close(fd)
        except OSError:
            _fmt.warning(f"failed to append to trash index: {index_path}")

    # 11. Success.
    return f"Trashed {file_path} -> .swival/trash/{trash_id}"


MAX_INLINE_OUTPUT = 10 * 1024  # 10KB — max output returned inline
MAX_FILE_OUTPUT = 1 * 1024 * 1024  # 1MB — max output saved to file
MCP_INLINE_LIMIT = 20 * 1024  # 20KB — MCP tool inline threshold
MCP_FILE_LIMIT = 10 * 1024 * 1024  # 10MB — MCP tool max saved to file
SWIVAL_DIR = ".swival"
OUTPUT_FILE_TTL = 600  # seconds before temp file cleanup
MAX_TIMEOUT = 120


def cleanup_old_cmd_outputs(base_dir: str) -> int:
    """Remove cmd_output_* files older than OUTPUT_FILE_TTL from .swival/.

    Returns the number of files removed.
    """
    import time

    scratch = Path(base_dir) / SWIVAL_DIR
    if not scratch.is_dir():
        return 0
    cutoff = time.time() - OUTPUT_FILE_TTL
    removed = 0
    for f in scratch.glob("cmd_output_*.txt"):
        try:
            if f.stat().st_mtime < cutoff:
                f.unlink()
                removed += 1
        except OSError:
            pass
    return removed


_KILL_WAIT_TIMEOUT = 5  # seconds to wait for process to die after kill signals


def _kill_process_tree(proc: subprocess.Popen) -> None:
    """Kill a process and its descendants, then wait for exit.

    On Unix, uses process groups (via start_new_session=True) to kill the
    entire tree. On Windows, uses taskkill /T /F to kill the process tree.
    """
    if sys.platform != "win32":
        import signal

        try:
            os.killpg(proc.pid, signal.SIGKILL)
        except OSError:
            pass  # already exited
    else:
        # taskkill /T kills the entire process tree rooted at the PID
        try:
            subprocess.run(
                ["taskkill", "/T", "/F", "/PID", str(proc.pid)],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                timeout=5,
            )
        except (OSError, subprocess.TimeoutExpired):
            pass  # best-effort
    try:
        proc.kill()
    except OSError:
        pass  # already dead
    try:
        proc.wait(timeout=_KILL_WAIT_TIMEOUT)
    except subprocess.TimeoutExpired:
        pass  # give up — process is unkillable


def _save_large_output(
    output: str,
    base_dir: str,
    *,
    tool_name: str | None = None,
    was_truncated: bool = False,
    scratch_dir: str | None = None,
) -> str:
    """Save large output to a temp file and return a summary message.

    When *scratch_dir* is set (A2A serve mode), files are written there
    instead of base_dir/.swival/ so each context gets its own temp space.
    Falls back to inline-truncated output on disk write failure.
    """
    size_bytes = len(output.encode("utf-8"))
    size_kb = size_bytes / 1024

    scratch = Path(scratch_dir) if scratch_dir else Path(base_dir) / SWIVAL_DIR
    try:
        scratch.mkdir(parents=True, exist_ok=True)
    except OSError:
        # Can't create dir — fall back to truncated inline
        return _safe_truncate(
            output,
            MAX_INLINE_OUTPUT,
            "\n[output truncated — failed to create .swival/ directory]",
        )

    filename = f"cmd_output_{uuid.uuid4().hex[:12]}.txt"
    filepath = scratch / filename
    # Compute the path the LLM should use with read_file.  When scratch_dir
    # is set it may be outside .swival/, so we need the path relative to
    # base_dir (which is what read_file resolves against).
    try:
        rel_path = str(filepath.resolve().relative_to(Path(base_dir).resolve()))
    except ValueError:
        # scratch_dir is outside base_dir — use absolute path as fallback
        rel_path = str(filepath.resolve())

    try:
        filepath.write_text(output, encoding="utf-8")
    except OSError:
        return _safe_truncate(
            output,
            MAX_INLINE_OUTPUT,
            "\n[output truncated — failed to write temp file]",
        )

    def _cleanup():
        try:
            filepath.unlink(missing_ok=True)
        except OSError:
            pass

    timer = threading.Timer(OUTPUT_FILE_TTL, _cleanup)
    timer.daemon = True
    timer.start()

    source_label = f"Tool output from {tool_name}" if tool_name else "Command output"
    saved_label = (
        "Output (possibly truncated) saved to"
        if was_truncated
        else "Full output saved to"
    )

    return (
        f"{source_label} too large for context ({size_kb:.1f}KB).\n"
        f"{saved_label}: {rel_path}\n"
        f"Use read_file to examine the output (supports offset and limit for pagination)."
    )


def _guard_mcp_output(
    result: str, base_dir: str, tool_name: str, scratch_dir: str | None = None
) -> str:
    """Truncate and/or save MCP tool output that exceeds the inline limit.

    Two-tier approach:
    - Hard cap at ``MCP_FILE_LIMIT`` (10 MB) before writing to disk.
    - Save to file when over ``MCP_INLINE_LIMIT`` (20 KB).
    """
    size = len(result.encode("utf-8"))
    if size <= MCP_INLINE_LIMIT:
        return result

    was_truncated = False
    if size > MCP_FILE_LIMIT:
        result = result.encode("utf-8")[:MCP_FILE_LIMIT].decode(
            "utf-8", errors="replace"
        )
        was_truncated = True

    return _save_large_output(
        result,
        base_dir,
        tool_name=tool_name,
        was_truncated=was_truncated,
        scratch_dir=scratch_dir,
    )


def _guard_a2a_output(
    result: str, base_dir: str, tool_name: str, scratch_dir: str | None = None
) -> str:
    """Size-guard A2A tool output, preserving continuation metadata.

    A2A results encode contextId/taskId in the first line as
    ``[input-required] contextId=... taskId=...`` or ``[contextId=...]``.
    If the payload exceeds the inline limit, only those specific headers
    are extracted before saving the body to file; arbitrary bracketed
    content (e.g. a large JSON array) is not treated as metadata.
    """
    size = len(result.encode("utf-8"))
    if size <= MCP_INLINE_LIMIT:
        return result

    # Extract metadata header only if the first line is a recognised A2A header
    meta_header = ""
    body = result
    first_nl = result.find("\n")
    if first_nl > 0:
        first_line = result[:first_nl]
        if A2A_META_PREFIX.match(first_line):
            meta_header = first_line
            body = result[first_nl + 1 :]

    was_truncated = False
    body_encoded = body.encode("utf-8")
    if len(body_encoded) > MCP_FILE_LIMIT:
        body = body_encoded[:MCP_FILE_LIMIT].decode("utf-8", errors="replace")
        was_truncated = True

    saved_notice = _save_large_output(
        body,
        base_dir,
        tool_name=tool_name,
        was_truncated=was_truncated,
        scratch_dir=scratch_dir,
    )

    if meta_header:
        return f"{meta_header}\n{saved_notice}"
    return saved_notice


def _capture_process(
    proc: subprocess.Popen, timeout: int, base_dir: str, scratch_dir: str | None = None
) -> str:
    """Capture output from a running subprocess with timeout enforcement."""
    output_chunks: list[bytes] = []
    output_total = 0
    output_truncated = False

    def _reader():
        nonlocal output_total, output_truncated
        try:
            while True:
                chunk = proc.stdout.read(4096)
                if not chunk:
                    break
                if output_truncated:
                    continue  # keep draining to prevent pipe backpressure
                remaining = MAX_FILE_OUTPUT - output_total
                output_chunks.append(chunk[:remaining])
                output_total += len(output_chunks[-1])
                if output_total >= MAX_FILE_OUTPUT:
                    output_truncated = True
        except (OSError, ValueError):
            pass  # pipe closed/broken after kill

    reader_thread = threading.Thread(target=_reader, daemon=True)
    reader_thread.start()

    timed_out = False
    try:
        proc.wait(timeout=timeout)
    except subprocess.TimeoutExpired:
        timed_out = True
        _kill_process_tree(proc)

    reader_thread.join(timeout=2)
    if reader_thread.is_alive():
        # Reader is still blocked on proc.stdout.read() — a backgrounded
        # child likely inherited the pipe.  Kill the whole process group so
        # the pipe closes and the reader can finish.
        _kill_process_tree(proc)
        reader_thread.join(timeout=2)
    proc.stdout.close()

    # Build result
    raw_output = b"".join(output_chunks).decode("utf-8", errors="replace")
    parts: list[str] = []

    if timed_out:
        parts.append(f"error: command timed out after {timeout}s")
    elif proc.returncode != 0:
        parts.append(f"Exit code: {proc.returncode}")

    if raw_output:
        parts.append(raw_output)

    if output_truncated:
        parts.append("[output truncated at 1MB]")

    result = "\n".join(parts) if parts else "(no output)"

    # Save large output to file instead of stuffing the context
    if len(result.encode("utf-8")) > MAX_INLINE_OUTPUT:
        exit_info = ""
        if timed_out:
            exit_info = f"\nerror: command timed out after {timeout}s"
        elif proc.returncode != 0:
            exit_info = f"\nExit code: {proc.returncode}"
        saved = _save_large_output(
            result, base_dir, was_truncated=output_truncated, scratch_dir=scratch_dir
        )
        result = saved + exit_info

    return result


_SHELL_CHARS = set("|&;><$`\\\"'*?~#!{}()[]\n\r")


def _safe_truncate(text: str, limit: int, suffix: str) -> str:
    """Truncate text to *limit* bytes (UTF-8 safe) and append *suffix*."""
    return text.encode("utf-8")[:limit].decode("utf-8", errors="replace") + suffix


def _run_shell_command(
    command: str, base_dir: str, timeout: int, scratch_dir: str | None = None
) -> str:
    """Execute a shell string via sh -c (Unix) or cmd.exe /c (Windows)."""
    base_path = Path(base_dir)
    if not base_path.exists():
        return f"error: base directory does not exist: {base_dir}"
    if not base_path.is_dir():
        return f"error: base directory is not a directory: {base_dir}"

    timeout = max(1, min(timeout, MAX_TIMEOUT))

    if sys.platform == "win32":
        shell_cmd = ["cmd.exe", "/c", command]
    else:
        shell_cmd = ["/bin/sh", "-c", command]

    try:
        popen_kwargs: dict = dict(
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            stdin=subprocess.DEVNULL,
            cwd=base_dir,
        )
        if sys.platform != "win32":
            popen_kwargs["start_new_session"] = True

        proc = subprocess.Popen(shell_cmd, **popen_kwargs)
    except OSError as e:
        return f"error: failed to start shell command: {e}"

    return _capture_process(proc, timeout, base_dir, scratch_dir=scratch_dir)


def _run_command(
    command: list[str] | str,
    base_dir: str,
    resolved_commands: dict[str, str],
    timeout: int = 30,
    unrestricted: bool = False,
    scratch_dir: str | None = None,
) -> str:
    """Execute a command and return its output.

    When unrestricted is False, only whitelisted commands are allowed.
    When unrestricted is True, any command can be run.
    """
    was_repaired = False

    def _finalize(result: str, repaired: bool) -> str:
        if not repaired:
            return result
        return (
            f"{result}\n"
            "(auto-corrected: command was passed as a string, converted to array)"
        )

    if isinstance(command, str):
        repaired_command = None
        try:
            parsed = json.loads(command)
            if isinstance(parsed, list) and all(isinstance(x, str) for x in parsed):
                repaired_command = parsed
        except (json.JSONDecodeError, TypeError):
            pass

        if repaired_command is None:
            if unrestricted:
                return _run_shell_command(
                    command, base_dir, timeout, scratch_dir=scratch_dir
                )
            # No shell metacharacters — safe to split on whitespace.
            if not (_SHELL_CHARS & set(command)):
                repaired_command = command.split()
            else:
                return (
                    'error: "command" must be a JSON array of strings, '
                    "not a single string.\n"
                    'Wrong: "command": "grep -n pattern file.py"\n'
                    'Right: "command": ["grep", "-n", "pattern", "file.py"]\n'
                    "Each argument must be a separate element in the array.\n"
                    "Shell syntax (&&, |, >, 2>&1, etc.) is not supported — "
                    "run one command at a time."
                )

        if repaired_command is not None:
            command = repaired_command
            was_repaired = True

    if not command:
        return "error: command list is empty"

    base_path = Path(base_dir)
    if not base_path.exists():
        return _finalize(
            f"error: base directory does not exist: {base_dir}", was_repaired
        )
    if not base_path.is_dir():
        return _finalize(
            f"error: base directory is not a directory: {base_dir}", was_repaired
        )

    cmd_name = command[0]

    if unrestricted:
        # In unrestricted mode, resolve the command without whitelist checks
        if "/" in cmd_name or "\\" in cmd_name:
            # Absolute or relative path — resolve against base_dir
            candidate = Path(cmd_name)
            if not candidate.is_absolute():
                candidate = Path(base_dir) / candidate
            resolved_path = str(candidate.resolve())
        else:
            # Bare name — look up on PATH
            found = shutil.which(cmd_name)
            if found is None:
                return _finalize(
                    f"error: command not found on PATH: {cmd_name!r}", was_repaired
                )
            resolved_path = str(Path(found).resolve())
    else:
        # Reject paths in command[0]
        if "/" in cmd_name or "\\" in cmd_name:
            allowed = ", ".join(sorted(resolved_commands)) or "(none)"
            return _finalize(
                f"error: command must be a bare name, not a path: {cmd_name!r}. "
                f"Allowed commands: {allowed}",
                was_repaired,
            )

        # Look up pinned path
        resolved_path = resolved_commands.get(cmd_name)
        if resolved_path is None:
            allowed = ", ".join(sorted(resolved_commands)) or "(none)"
            return _finalize(
                f"error: command {cmd_name!r} is not allowed. Allowed commands: {allowed}",
                was_repaired,
            )

    # Clamp timeout
    timeout = max(1, min(timeout, MAX_TIMEOUT))

    try:
        popen_kwargs: dict = dict(
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            stdin=subprocess.DEVNULL,
            cwd=base_dir,
        )
        if sys.platform != "win32":
            popen_kwargs["start_new_session"] = True

        proc = subprocess.Popen([resolved_path] + command[1:], **popen_kwargs)
    except FileNotFoundError:
        return _finalize(
            f"error: command executable not found: {resolved_path}", was_repaired
        )
    except PermissionError:
        return _finalize(
            f"error: permission denied executing: {resolved_path}", was_repaired
        )
    except OSError as e:
        return _finalize(f"error: failed to start command: {e}", was_repaired)

    return _finalize(
        _capture_process(proc, timeout, base_dir, scratch_dir=scratch_dir), was_repaired
    )


def dispatch(name: str, args: dict, base_dir: str, **kwargs) -> str:
    """Route a tool call to the appropriate implementation.

    Args:
        name: The tool name to invoke.
        args: Dictionary of arguments for the tool.
        base_dir: Base directory for path resolution.
        **kwargs: Extra context (e.g. thinking_state for the think tool).

    Returns:
        String result from the tool.

    Raises:
        KeyError: If the tool name is not recognized.
    """
    yolo = kwargs.get("yolo", False)
    extra_write_roots = kwargs.get("extra_write_roots", ())
    skill_read_roots = kwargs.get("skill_read_roots", ())
    file_tracker = kwargs.get("file_tracker")
    scratch_dir = kwargs.get("scratch_dir")

    # MCP / A2A tool dispatch
    for prefix, manager_key, guard_fn in (
        ("mcp__", "mcp_manager", _guard_mcp_output),
        ("a2a__", "a2a_manager", _guard_a2a_output),
    ):
        if name.startswith(prefix):
            manager = kwargs.get(manager_key)
            if manager is None:
                kind = prefix.rstrip("_").upper()
                return f"error: {kind} tool {name!r} called but no {kind} manager is active"
            result, is_error = manager.call_tool(name, args)
            if is_error:
                if len(result.encode("utf-8")) > MCP_INLINE_LIMIT:
                    result = _safe_truncate(
                        result, MCP_INLINE_LIMIT, "\n[error output truncated]"
                    )
                return result
            return guard_fn(result, base_dir, name, scratch_dir=scratch_dir)

    if name == "think":
        thinking_state = kwargs.get("thinking_state")
        if thinking_state is None:
            return "error: think tool is not available"
        return thinking_state.process(args)
    elif name == "read_file":
        try:
            offset = int(args.get("offset", 1))
        except (ValueError, TypeError):
            return "error: offset must be an integer"
        try:
            limit = int(args.get("limit", 2000))
        except (ValueError, TypeError):
            return "error: limit must be an integer"
        tail = args.get("tail")
        if tail is not None:
            try:
                tail = int(tail)
            except (ValueError, TypeError):
                return "error: tail must be an integer"
            offset = 1  # tail takes precedence; ignore any offset the model sent
        return _read_file(
            file_path=args["file_path"],
            base_dir=base_dir,
            offset=offset,
            limit=limit,
            tail=tail,
            extra_read_roots=skill_read_roots,
            extra_write_roots=extra_write_roots,
            unrestricted=yolo,
            tracker=file_tracker,
        )
    elif name == "read_multiple_files":
        files = args.get("files")
        if isinstance(files, str):
            files = [files]
        if not isinstance(files, list):
            return "error: 'files' must be an array"
        return _read_files(
            files=files,
            base_dir=base_dir,
            extra_read_roots=skill_read_roots,
            extra_write_roots=extra_write_roots,
            unrestricted=yolo,
            tracker=file_tracker,
        )
    elif name == "write_file":
        return _write_file(
            file_path=args["file_path"],
            content=args.get("content"),
            base_dir=base_dir,
            move_from=args.get("move_from"),
            extra_write_roots=extra_write_roots,
            unrestricted=yolo,
            tracker=file_tracker,
        )
    elif name == "edit_file":
        return _edit_file(
            file_path=args["file_path"],
            old_string=args["old_string"],
            new_string=args["new_string"],
            base_dir=base_dir,
            replace_all=args.get("replace_all", False),
            extra_write_roots=extra_write_roots,
            unrestricted=yolo,
            tracker=file_tracker,
            verbose=kwargs.get("verbose", False),
        )
    elif name == "delete_file":
        return _delete_file(
            args["file_path"],
            base_dir,
            extra_write_roots=extra_write_roots,
            unrestricted=yolo,
            tracker=file_tracker,
            tool_call_id=kwargs.get("tool_call_id", ""),
        )
    elif name == "list_files":
        return _list_files(
            pattern=args["pattern"],
            path=args.get("path", "."),
            base_dir=base_dir,
            extra_read_roots=skill_read_roots,
            extra_write_roots=extra_write_roots,
            unrestricted=yolo,
        )
    elif name == "grep":
        return _grep(
            pattern=args["pattern"],
            path=args.get("path", "."),
            base_dir=base_dir,
            include=args.get("include"),
            case_insensitive=args.get("case_insensitive", False),
            context_lines=args.get("context_lines", 0),
            extra_read_roots=skill_read_roots,
            extra_write_roots=extra_write_roots,
            unrestricted=yolo,
        )
    elif name == "todo":
        todo_state = kwargs.get("todo_state")
        if todo_state is None:
            return "error: todo tool is not available"
        return todo_state.process(args)
    elif name == "snapshot":
        snapshot_state = kwargs.get("snapshot_state")
        if snapshot_state is None:
            return "error: snapshot tool is not available"
        return snapshot_state.process(
            args,
            messages=kwargs.get("messages"),
            tool_call_id=kwargs.get("tool_call_id"),
        )
    elif name == "fetch_url":
        from .fetch import fetch_url as _fetch_url

        return _fetch_url(
            url=args.get("url", ""),
            format=args.get("format", "markdown"),
            timeout=args.get("timeout", 30),
            base_dir=base_dir,
            scratch_dir=scratch_dir,
        )
    elif name == "use_skill":
        from .skills import activate_skill

        catalog = kwargs.get("skills_catalog", {})
        read_roots = kwargs.get("skill_read_roots", [])
        return activate_skill(args["name"], catalog, read_roots)
    elif name == "run_command":
        resolved = kwargs.get("resolved_commands", {})
        return _run_command(
            command=args["command"],
            base_dir=base_dir,
            resolved_commands=resolved,
            timeout=args.get("timeout", 30),
            unrestricted=yolo,
            scratch_dir=scratch_dir,
        )
    elif name == "view_image":
        image_stash = kwargs.get("image_stash")
        if image_stash is None:
            return "error: view_image tool is not available"
        return _view_image(
            image_path=args["image_path"],
            base_dir=base_dir,
            image_stash=image_stash,
            question=args.get("question"),
            extra_read_roots=skill_read_roots,
            extra_write_roots=extra_write_roots,
            unrestricted=yolo,
        )
    else:
        suggestion = _TOOL_ALIASES.get(name)
        if suggestion:
            raise KeyError(f"Unknown tool: {name!r}. Did you mean '{suggestion}'?")
        raise KeyError(
            f"Unknown tool: {name!r}. Available tools: {', '.join(_TOOL_NAMES)}"
        )
