"""Ergonomic coding tools for LLM agents.

Provides file read/write/edit, grep, find, and ls operations with
smart truncation, fuzzy matching, and actionable pagination hints.

Unlike the generic ``file`` tool (agno's FileTools), this toolkit is
optimised for coding agents: line-numbered reads with pagination,
search-and-replace edits with fuzzy matching, ripgrep-backed grep,
and gitignore-aware file discovery. Prefer this tool over ``file`` for
coding-heavy agents; keep ``file`` for backward compatibility.
"""

from __future__ import annotations

import bisect
import difflib
import json
import re
import shutil
import subprocess
import unicodedata
from dataclasses import dataclass
from pathlib import Path

from agno.tools import Toolkit

from mindroom.tools.path_safety import (
    format_path_for_output,
    is_within_base_dir,
    resolve_base_dir_path,
    split_search_pattern,
)

_MAX_LINES = 2000
_MAX_BYTES = 50 * 1024  # 50KB
_MAX_LINE_CHARS = 500  # Per-line truncation for grep output
_DEFAULT_GREP_LIMIT = 100
_DEFAULT_FIND_LIMIT = 1000
_DEFAULT_LS_LIMIT = 500
_DIFF_CONTEXT_LINES = 4


@dataclass
class _TruncateResult:
    """Result of a truncation operation."""

    content: str
    was_truncated: bool
    total_lines: int


def _truncate_head(
    content: str,
    max_lines: int = _MAX_LINES,
    max_bytes: int = _MAX_BYTES,
) -> _TruncateResult:
    """Keep the first N lines / max_bytes of content."""
    lines = content.splitlines(keepends=True)
    total = len(lines)
    kept: list[str] = []
    byte_count = 0
    for line in lines:
        line_bytes = len(line.encode("utf-8", errors="replace"))
        if len(kept) >= max_lines or byte_count + line_bytes > max_bytes:
            break
        kept.append(line)
        byte_count += line_bytes
    result = "".join(kept)
    return _TruncateResult(
        content=result,
        was_truncated=len(kept) < total,
        total_lines=total,
    )


def _truncate_to_max_bytes(text: str, max_bytes: int) -> str:
    """Truncate text to a maximum encoded byte length."""
    encoded = text.encode("utf-8", errors="replace")
    if len(encoded) <= max_bytes:
        return text
    truncated = encoded[:max_bytes]
    return truncated.decode("utf-8", errors="ignore")


# ── Fuzzy matching helpers ──────────────────────────────────────────

# Smart quotes / dashes / special spaces -> ASCII equivalents
# Matches PI coding editor's normalization (edit.ts)
_NORMALIZE_MAP = str.maketrans(
    {
        # Quotes
        "\u2018": "'",  # left single curly
        "\u2019": "'",  # right single curly
        "\u201c": '"',  # left double curly
        "\u201d": '"',  # right double curly
        "\u201e": '"',  # double low-9
        "\u201f": '"',  # double high-reversed-9
        # Dashes
        "\u2010": "-",  # hyphen
        "\u2011": "-",  # non-breaking hyphen
        "\u2012": "-",  # figure dash
        "\u2013": "-",  # en dash
        "\u2014": "-",  # em dash
        "\u2015": "-",  # horizontal bar
        "\u2212": "-",  # minus sign
        # Special spaces
        "\u00a0": " ",  # non-breaking space
        "\u2002": " ",  # en space
        "\u2003": " ",  # em space
        "\u2004": " ",  # three-per-em space
        "\u2005": " ",  # four-per-em space
        "\u2006": " ",  # six-per-em space
        "\u2007": " ",  # figure space
        "\u2008": " ",  # punctuation space
        "\u2009": " ",  # thin space
        "\u200a": " ",  # hair space
        "\u202f": " ",  # narrow no-break space
        "\u205f": " ",  # medium mathematical space
        "\u3000": " ",  # ideographic space
        # BOM
        "\ufeff": "",
    },
)


def _normalize_for_fuzzy(text: str) -> str:
    """Normalize text for fuzzy matching.

    - Strip trailing whitespace per line
    - Normalize Unicode quotes/dashes/spaces to ASCII
    - Normalize NFC
    """
    text = unicodedata.normalize("NFC", text)
    text = text.translate(_NORMALIZE_MAP)
    normalized_lines: list[str] = []
    for line in text.splitlines(keepends=True):
        body, line_ending = _split_line_body_ending(line)
        normalized_lines.append(body.rstrip() + line_ending)
    return "".join(normalized_lines)


@dataclass
class _MatchResult:
    """Result of a fuzzy find operation."""

    start: int
    end: int
    was_fuzzy: bool


@dataclass
class _NormalizedLineMap:
    """Per-line normalized-to-original offset mapping for fuzzy matches."""

    norm_to_orig: list[int]
    norm_len: int


def _split_line_body_ending(line: str) -> tuple[str, str]:
    """Split a line into body and line ending, preserving CRLF."""
    if line.endswith("\r\n"):
        return line[:-2], "\r\n"
    if line.endswith(("\n", "\r")):
        return line[:-1], line[-1]
    return line, ""


def _normalize_prefix_map(text: str) -> tuple[str, list[int]]:
    """Return normalized text and a map from normalized offsets to original offsets.

    Fast path is O(n): when input is already NFC (common for source files), translation
    is applied per character and offsets are accumulated linearly.
    Slow path keeps the exact prefix-based behavior for non-NFC inputs.
    """
    if unicodedata.is_normalized("NFC", text):
        return _normalize_prefix_map_linear(text)
    return _normalize_prefix_map_slow(text)


def _normalize_prefix_map_linear(text: str) -> tuple[str, list[int]]:
    """Build normalized text and offset map in O(n) for NFC input."""
    normalized_parts: list[str] = []
    offset_map = [0]

    for i, ch in enumerate(text, 1):
        mapped = ch.translate(_NORMALIZE_MAP)
        if not mapped:
            continue
        normalized_parts.append(mapped)
        offset_map.extend([i] * len(mapped))

    return "".join(normalized_parts), offset_map


def _normalize_prefix_map_slow(text: str) -> tuple[str, list[int]]:
    """Exact prefix-based mapping for non-NFC input."""
    normalized = unicodedata.normalize("NFC", text).translate(_NORMALIZE_MAP)
    offset_map = [0] * (len(normalized) + 1)
    prev_len = 0

    for i in range(1, len(text) + 1):
        current_len = len(unicodedata.normalize("NFC", text[:i]).translate(_NORMALIZE_MAP))
        current_len = min(current_len, len(normalized))
        prev_len = min(prev_len, current_len)
        for offset in range(prev_len + 1, current_len + 1):
            offset_map[offset] = i
        offset_map[current_len] = i
        prev_len = current_len

    for offset in range(1, len(offset_map)):
        offset_map[offset] = max(offset_map[offset], offset_map[offset - 1])

    return normalized, offset_map


def _build_normalized_line_maps(orig_lines: list[str]) -> list[_NormalizedLineMap]:
    """Build per-line maps from normalized offsets to original offsets."""
    line_maps: list[_NormalizedLineMap] = []
    for line in orig_lines:
        body, line_ending = _split_line_body_ending(line)
        normalized_body_full, body_offset_map = _normalize_prefix_map(body)
        normalized_body = normalized_body_full.rstrip()
        normalized_body_len = len(normalized_body)

        norm_to_orig = body_offset_map[: normalized_body_len + 1]
        # Consume stripped trailing whitespace in fuzzy replacements.
        norm_to_orig[normalized_body_len] = len(body)
        for i in range(1, len(line_ending) + 1):
            norm_to_orig.append(len(body) + i)

        line_maps.append(
            _NormalizedLineMap(
                norm_to_orig=norm_to_orig,
                norm_len=normalized_body_len + len(line_ending),
            ),
        )
    return line_maps


def _find_all_matches(content: str, old_text: str) -> list[_MatchResult]:
    """Find all non-overlapping occurrences, trying exact match first then fuzzy.

    Unified function: both counting and locating use the same code path,
    preventing disagreements between separate count and find operations.
    """
    if not old_text:
        return []

    # Try exact matches first
    matches: list[_MatchResult] = []
    pos = 0
    while True:
        idx = content.find(old_text, pos)
        if idx == -1:
            break
        matches.append(_MatchResult(start=idx, end=idx + len(old_text), was_fuzzy=False))
        pos = idx + len(old_text)
    if matches:
        return matches

    # Fuzzy: normalize both sides and find in normalized space
    norm_content = _normalize_for_fuzzy(content)
    norm_old = _normalize_for_fuzzy(old_text)
    if not norm_old or not norm_old.strip():
        return []

    orig_lines = content.splitlines(keepends=True)
    norm_lines = norm_content.splitlines(keepends=True)
    orig_offsets = _cumulative_offsets(orig_lines)
    norm_offsets = _cumulative_offsets(norm_lines)
    line_maps = _build_normalized_line_maps(orig_lines)

    pos = 0
    while True:
        idx = norm_content.find(norm_old, pos)
        if idx == -1:
            break
        norm_end = idx + len(norm_old)
        orig_start = _norm_to_orig_offset(idx, norm_offsets, orig_offsets, line_maps, len(content))
        orig_end = _norm_to_orig_offset(norm_end, norm_offsets, orig_offsets, line_maps, len(content))
        matches.append(
            _MatchResult(
                start=orig_start,
                end=orig_end,
                was_fuzzy=True,
            ),
        )
        pos = norm_end

    return matches


def _cumulative_offsets(lines: list[str]) -> list[int]:
    """Return cumulative character offsets for line boundaries."""
    offsets = [0]
    for line in lines:
        offsets.append(offsets[-1] + len(line))
    return offsets


def _norm_to_orig_offset(
    norm_offset: int,
    norm_offsets: list[int],
    orig_offsets: list[int],
    line_maps: list[_NormalizedLineMap],
    content_len: int,
) -> int:
    """Map a character offset in normalized text to the original text.

    Uses line boundaries and per-line maps that account for NFC normalization,
    quote/dash translation, BOM removal, and trailing-whitespace stripping.
    """
    if not line_maps:
        return 0

    line_idx = bisect.bisect_right(norm_offsets, norm_offset) - 1
    line_idx = max(0, min(line_idx, len(line_maps) - 1))

    char_in_norm_line = norm_offset - norm_offsets[line_idx]
    line_map = line_maps[line_idx]
    char_in_norm_line = max(0, min(char_in_norm_line, line_map.norm_len))
    orig_in_line = line_map.norm_to_orig[char_in_norm_line]

    return min(orig_offsets[line_idx] + orig_in_line, content_len)


def _make_diff(
    old_content: str,
    new_content: str,
    context: int = _DIFF_CONTEXT_LINES,
) -> str:
    """Create a unified diff between old and new content."""
    diff_lines = difflib.unified_diff(
        old_content.splitlines(),
        new_content.splitlines(),
        fromfile="before",
        tofile="after",
        lineterm="",
        n=context,
    )
    return "\n".join(diff_lines)


def _is_git_repo(base_dir: Path) -> bool:
    """Check whether base_dir is inside a git working tree."""
    current = base_dir.resolve()
    return any((parent / ".git").exists() for parent in (current, *current.parents))


def _gitignored_paths(paths: list[Path], base_dir: Path) -> set[Path]:
    """Return the subset of paths ignored by git using a single batched call."""
    if not paths or not _is_git_repo(base_dir):
        return set()

    base_resolved = base_dir.resolve()
    path_map: dict[str, list[Path]] = {}
    for candidate in paths:
        try:
            token = str(candidate.resolve().relative_to(base_resolved))
        except ValueError:
            token = str(candidate)
        path_map.setdefault(token, []).append(candidate)

    payload = "\0".join(path_map.keys()) + "\0"
    try:
        result = subprocess.run(
            ["git", "check-ignore", "--stdin", "-z"],
            check=False,
            cwd=str(base_dir),
            input=payload.encode("utf-8"),
            capture_output=True,
            timeout=5,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        return set()

    if result.returncode not in (0, 1):
        return set()

    ignored: set[Path] = set()
    for token in result.stdout.decode("utf-8", errors="replace").split("\0"):
        if not token:
            continue
        ignored.update(path_map.get(token, []))
    return ignored


def _format_read_output(content: str, offset: int | None, limit: int | None) -> str:
    """Format file content with line numbers and pagination hints."""
    all_lines = content.splitlines()
    total_lines = len(all_lines)

    if offset is not None and offset < 1:
        return "Error: offset must be >= 1."
    if limit is not None and limit < 1:
        return "Error: limit must be >= 1."

    start = offset or 1
    if total_lines == 0:
        if start > 1:
            return f"Error: offset {start} exceeds file length (0 lines)."
        return ""
    if start > total_lines:
        return f"Error: offset {start} exceeds file length ({total_lines} lines)."

    max_lines = limit if limit is not None else _MAX_LINES
    end = min(start + max_lines - 1, total_lines)
    selected = all_lines[start - 1 : end]

    selected, end = _apply_byte_limit(selected, start, end)

    width = len(str(start + len(selected) - 1))
    numbered = "\n".join(f"{start + i:>{width}}| {line}" for i, line in enumerate(selected))
    return numbered + _pagination_hint(start, end, total_lines)


def _apply_byte_limit(selected: list[str], start: int, end: int) -> tuple[list[str], int]:
    """Re-truncate selected lines if they exceed the max byte limit."""
    selected_text = "\n".join(selected)
    if len(selected_text.encode("utf-8", errors="replace")) <= _MAX_BYTES:
        return selected, end

    trunc = _truncate_head(selected_text, max_lines=len(selected), max_bytes=_MAX_BYTES)
    selected = trunc.content.splitlines()
    if not selected and selected_text:
        partial = _truncate_to_max_bytes(selected_text, _MAX_BYTES).rstrip("\n")
        selected = [f"{partial} [truncated]"]
    return selected, start + len(selected) - 1


def _pagination_hint(start: int, end: int, total: int) -> str:
    """Build a pagination hint suffix for truncated file reads."""
    if end < total:
        return f"\n\n[Showing lines {start}-{end} of {total}. Use offset={end + 1} to continue.]"
    if start > 1:
        return f"\n\n[Showing lines {start}-{end} of {total}.]"
    return ""


def _list_directory(target: Path, limit: int) -> str:
    """List directory contents with directory indicators."""
    entries: list[str] = []
    try:
        for item in sorted(target.iterdir(), key=lambda p: p.name.lower()):
            name = item.name + ("/" if item.is_dir() else "")
            entries.append(name)
            if len(entries) > limit:
                break
    except OSError as e:
        return f"Error listing directory: {e}"

    if not entries:
        return "Directory is empty."

    was_limited = len(entries) > limit
    if was_limited:
        entries = entries[:limit]

    result = "\n".join(entries)
    if was_limited:
        result += f"\n\n[Listing limited to {limit} entries.]"
    return result


def _find_files_in(
    search_path: Path,
    base_dir: Path,
    pattern: str,
    limit: int,
    restrict_to_base_dir: bool = True,
) -> str:
    """Glob for files, filter gitignored, and format results."""
    glob_error = _validate_glob_pattern(pattern)
    if glob_error:
        return glob_error

    try:
        candidates = [p for p in sorted(search_path.glob(pattern)) if p.is_file()]
    except (NotImplementedError, ValueError) as e:
        return f"Error: Invalid glob pattern '{pattern}': {e}"

    filter_root = base_dir if restrict_to_base_dir else search_path
    filtered = _filter_hidden_and_ignored(candidates, filter_root)
    matches: list[str] = []
    for candidate in filtered:
        try:
            rel = candidate.relative_to(base_dir)
        except ValueError:
            rel = candidate
        matches.append(str(rel))
        if len(matches) > limit:
            break

    if not matches:
        return f"No files matching '{pattern}' found."

    was_limited = len(matches) > limit
    if was_limited:
        matches = matches[:limit]

    result = "\n".join(matches)
    if was_limited:
        result += f"\n\n[Results limited to {limit}. Narrow the pattern to see more.]"
    return result


def _resolve_and_read(base_dir: Path, path: str, restrict_to_base_dir: bool = True) -> tuple[Path, str] | str:
    """Resolve path and read file content. Returns (resolved, content) or error string."""
    try:
        resolved = resolve_base_dir_path(base_dir, path, restrict_to_base_dir)
    except ValueError as e:
        return f"Error: {e}"

    if not resolved.exists():
        return f"Error: File not found: {path}"
    if not resolved.is_file():
        return f"Error: Not a file: {path}"

    try:
        return resolved, resolved.read_text(encoding="utf-8", errors="replace")
    except OSError as e:
        return f"Error reading file: {e}"


class CodingTools(Toolkit):
    """Ergonomic coding tools for LLM agents.

    Provides file read/write/edit, grep, find, and ls operations with
    smart truncation, fuzzy matching, and actionable pagination hints.
    """

    def __init__(self, base_dir: str | None = None, restrict_to_base_dir: bool = True) -> None:
        self.base_dir = Path(base_dir).resolve() if base_dir else Path.cwd().resolve()
        self.restrict_to_base_dir = restrict_to_base_dir
        super().__init__(
            name="coding",
            tools=[
                self.read_file,
                self.edit_file,
                self.write_file,
                self.grep,
                self.find_files,
                self.ls,
            ],
        )

    def read_file(
        self,
        path: str,
        offset: int | None = None,
        limit: int | None = None,
    ) -> str:
        """Read a file with line numbers. Large files are automatically truncated with pagination hints.

        Args:
            path: File path (relative to working directory or absolute).
            offset: Starting line number (1-based). Use this to paginate through large files.
            limit: Maximum number of lines to return. Defaults to 2000.

        Returns:
            Line-numbered file content with pagination hints if truncated.

        """
        result = _resolve_and_read(self.base_dir, path, self.restrict_to_base_dir)
        if isinstance(result, str):
            return result
        return _format_read_output(result[1], offset, limit)

    def edit_file(self, path: str, old_text: str, new_text: str) -> str:
        """Replace a specific text occurrence in a file. Uses fuzzy matching to handle whitespace/Unicode differences.

        The old_text must match exactly one location in the file. If it matches
        zero or more than one location, an error is returned.

        Args:
            path: File path (relative to working directory or absolute).
            old_text: The text to find and replace. Must be unique in the file.
            new_text: The replacement text.

        Returns:
            A diff showing the change, or an error message.

        """
        if not old_text:
            return "Error: old_text must be non-empty."

        result = _resolve_and_read(self.base_dir, path, self.restrict_to_base_dir)
        if isinstance(result, str):
            return result
        resolved, content = result

        matches = _find_all_matches(content, old_text)
        if len(matches) == 0:
            return "Error: old_text not found in file."
        if len(matches) > 1:
            return f"Error: old_text matches {len(matches)} locations. Provide more context to make the match unique."

        match = matches[0]
        new_content = content[: match.start] + new_text + content[match.end :]

        try:
            resolved.write_text(new_content, encoding="utf-8")
        except OSError as e:
            return f"Error writing file: {e}"

        diff = _make_diff(content, new_content)
        fuzzy_note = " (fuzzy match: whitespace/Unicode normalized)" if match.was_fuzzy else ""
        line_num = content[: match.start].count("\n") + 1
        return f"Applied edit at line {line_num}{fuzzy_note}:\n\n{diff}"

    def write_file(self, path: str, content: str) -> str:
        """Write content to a file, creating parent directories if needed.

        Args:
            path: File path (relative to working directory or absolute).
            content: The full file content to write.

        Returns:
            Confirmation with byte count written.

        """
        try:
            resolved = resolve_base_dir_path(self.base_dir, path, self.restrict_to_base_dir)
        except ValueError as e:
            return f"Error: {e}"

        try:
            resolved.parent.mkdir(parents=True, exist_ok=True)
            resolved.write_text(content, encoding="utf-8")
        except OSError as e:
            return f"Error writing file: {e}"

        byte_count = len(content.encode("utf-8"))
        lines = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
        return f"Wrote {byte_count} bytes ({lines} lines) to {path}"

    def grep(
        self,
        pattern: str,
        path: str | None = None,
        glob: str | None = None,
        ignore_case: bool = False,
        literal: bool = False,
        context: int = 0,
        limit: int = _DEFAULT_GREP_LIMIT,
    ) -> str:
        """Search file contents for a pattern. Uses ripgrep if available, falls back to Python re.

        Args:
            pattern: Regex pattern (or literal string if literal=True) to search for.
            path: Directory or file to search in. Defaults to working directory.
            glob: File glob pattern to filter (e.g., "*.py", "*.ts").
            ignore_case: Whether to ignore case in matching.
            literal: Treat pattern as a literal string instead of regex.
            context: Number of context lines before and after each match.
            limit: Maximum number of matches to return. Defaults to 100.

        Returns:
            Matching lines with file paths and line numbers.
            During recursive search, hidden files/directories and gitignored
            files are automatically excluded. Explicit path targets are not
            filtered.

        """
        try:
            search_path = (
                resolve_base_dir_path(self.base_dir, path, self.restrict_to_base_dir) if path else self.base_dir
            )
        except ValueError as e:
            return f"Error: {e}"
        effective_glob = glob
        if not self.restrict_to_base_dir and glob is not None:
            normalized_path, normalized_glob = split_search_pattern(search_path, glob)
            if normalized_path.exists():
                search_path = normalized_path
                effective_glob = normalized_glob

        validation_error = _validate_grep_request(search_path, path, effective_glob, limit, context)
        if validation_error:
            return validation_error

        # Try ripgrep first
        rg_result = _run_ripgrep(
            pattern,
            search_path,
            self.base_dir,
            effective_glob,
            ignore_case,
            literal,
            context,
            limit,
        )
        if rg_result is None:
            # Python fallback
            return _python_grep_fallback(
                pattern,
                search_path,
                self.base_dir,
                effective_glob,
                ignore_case,
                literal,
                context,
                limit,
            )
        return rg_result

    def find_files(
        self,
        pattern: str,
        path: str | None = None,
        limit: int = _DEFAULT_FIND_LIMIT,
    ) -> str:
        """Find files matching a glob pattern.

        Args:
            pattern: Glob pattern (e.g., "*.py", "**/*.ts", "src/**/*.jsx").
            path: Directory to search in. Defaults to working directory.
            limit: Maximum number of results. Defaults to 1000.

        Returns:
            List of matching file paths, one per line.
            Hidden files/directories and gitignored files are automatically
            excluded from results.

        """
        try:
            search_path = (
                resolve_base_dir_path(self.base_dir, path, self.restrict_to_base_dir) if path else self.base_dir
            )
        except ValueError as e:
            return f"Error: {e}"
        search_pattern = pattern
        if not self.restrict_to_base_dir:
            normalized_path, normalized_pattern = split_search_pattern(search_path, pattern)
            if normalized_path.exists():
                search_path = normalized_path
                search_pattern = normalized_pattern

        if not search_path.exists():
            return f"Error: Path not found: {path or '.'}"
        if limit < 1:
            return "Error: limit must be >= 1."

        return _find_files_in(
            search_path,
            self.base_dir,
            search_pattern,
            limit,
            restrict_to_base_dir=self.restrict_to_base_dir,
        )

    def ls(self, path: str | None = None, limit: int = _DEFAULT_LS_LIMIT) -> str:
        """List directory contents with directory indicators.

        Args:
            path: Directory to list. Defaults to working directory.
            limit: Maximum number of entries. Defaults to 500.

        Returns:
            Sorted directory listing with '/' suffix on directories.
            Dotfiles are intentionally included.

        """
        try:
            target = resolve_base_dir_path(self.base_dir, path, self.restrict_to_base_dir) if path else self.base_dir
        except ValueError as e:
            return f"Error: {e}"

        if not target.exists():
            return f"Error: Path not found: {path or '.'}"
        if not target.is_dir():
            return f"Error: Not a directory: {path}"
        if limit < 1:
            return "Error: limit must be >= 1."

        return _list_directory(target, limit)


# ── Grep helpers ────────────────────────────────────────────────────


def _truncate_line(line: str, max_chars: int = _MAX_LINE_CHARS) -> str:
    """Truncate a single line if it exceeds max_chars."""
    if len(line) <= max_chars:
        return line
    return line[:max_chars] + " [truncated]"


@dataclass
class _RgEvent:
    """Parsed ripgrep JSON event."""

    event_type: str
    path_text: str
    line_text: str
    line_number: int


def _parse_rg_event(raw: str) -> _RgEvent | None:
    """Parse a single ripgrep JSON event line. Returns None for non-match/context events."""
    try:
        event = json.loads(raw)
    except json.JSONDecodeError:
        return None

    event_type = event.get("type")
    if event_type not in {"match", "context"}:
        return None

    data = event.get("data")
    if not isinstance(data, dict):
        return None
    path_data = data.get("path")
    lines_data = data.get("lines")
    line_number = data.get("line_number")
    if not isinstance(path_data, dict) or not isinstance(lines_data, dict) or not isinstance(line_number, int):
        return None
    path_text = path_data.get("text")
    line_text = lines_data.get("text")
    if not isinstance(path_text, str) or not isinstance(line_text, str):
        return None

    return _RgEvent(event_type=event_type, path_text=path_text, line_text=line_text, line_number=line_number)


def _run_ripgrep(
    pattern: str,
    search_path: Path,
    base_dir: Path,
    glob_filter: str | None,
    ignore_case: bool,
    literal: bool,
    context: int,
    limit: int,
) -> str | None:
    """Run ripgrep and return formatted results. Returns None if rg is not available."""
    rg_binary = shutil.which("rg")
    if rg_binary is None:
        return None

    args = [rg_binary, "--json", "--color=never"]
    if ignore_case:
        args.append("-i")
    if literal:
        args.append("-F")
    if context > 0:
        args.extend(["-C", str(context)])
    if glob_filter:
        args.extend(["--glob", glob_filter])
    args.extend(["--", pattern, str(search_path)])

    try:
        result = subprocess.run(args, check=False, capture_output=True, text=True, timeout=30)
    except subprocess.TimeoutExpired:
        return "Error: grep timed out after 30 seconds."

    if result.returncode == 1:
        return "No matches found."
    if result.returncode not in (0, 1):
        stderr = result.stderr.strip()
        return f"Error running grep: {stderr}" if stderr else "Error running grep."

    return _format_rg_output(result.stdout, limit, context, base_dir)


def _format_rg_line(event: _RgEvent, base_dir: Path) -> str:
    """Format a ripgrep event as one output line."""
    marker = ":" if event.event_type == "match" else "-"
    rel_path = format_path_for_output(event.path_text, base_dir)
    return f"{rel_path}{marker}{event.line_number}{marker}{_truncate_line(event.line_text.rstrip())}"


def _append_trailing_context(
    output_lines: list[str],
    pending_context: list[_RgEvent],
    last_match: _RgEvent | None,
    context: int,
    base_dir: Path,
) -> None:
    """Append only after-context for the last accepted match."""
    if context <= 0 or last_match is None:
        pending_context.clear()
        return

    for event in pending_context:
        if event.path_text != last_match.path_text:
            continue
        if last_match.line_number < event.line_number <= last_match.line_number + context:
            output_lines.append(_format_rg_line(event, base_dir))
    pending_context.clear()


def _format_rg_output(stdout: str, limit: int, context: int, base_dir: Path) -> str:
    """Parse ripgrep JSON output and format as text lines."""
    output_lines: list[str] = []
    pending_context: list[_RgEvent] = []
    match_count = 0
    was_limited = False
    last_match: _RgEvent | None = None

    for raw_line in stdout.splitlines():
        event = _parse_rg_event(raw_line)
        if event is None:
            continue

        if event.event_type == "context":
            pending_context.append(event)
            continue

        if match_count >= limit:
            was_limited = True
            _append_trailing_context(output_lines, pending_context, last_match, context, base_dir)
            break

        if pending_context:
            output_lines.extend(_format_rg_line(context_event, base_dir) for context_event in pending_context)
            pending_context.clear()

        match_count += 1
        last_match = event
        output_lines.append(_format_rg_line(event, base_dir))

    if not was_limited:
        _append_trailing_context(output_lines, pending_context, last_match, context, base_dir)

    if not output_lines:
        return "No matches found."

    output = "\n".join(output_lines)
    if was_limited:
        output += f"\n\n[Results limited to {limit} matches.]"

    trunc = _truncate_head(output)
    if trunc.was_truncated:
        return trunc.content.rstrip() + f"\n\n[Output truncated. {trunc.total_lines} total lines.]"
    return output.rstrip()


def _validate_glob_pattern(glob_filter: str | None) -> str | None:
    """Validate optional glob filter and return an error string when invalid."""
    if glob_filter is None:
        return None
    if Path(glob_filter).is_absolute():
        return f"Error: Invalid glob pattern '{glob_filter}': absolute paths are not allowed."
    return None


def _validate_grep_request(
    search_path: Path,
    path: str | None,
    glob_filter: str | None,
    limit: int,
    context: int,
) -> str | None:
    """Validate grep request arguments and return an error string when invalid."""
    if not search_path.exists():
        return f"Error: Path not found: {path or '.'}"
    if limit < 1:
        return "Error: limit must be >= 1."
    if context < 0:
        return "Error: context must be >= 0."
    return _validate_glob_pattern(glob_filter)


def _grep_file(
    filepath: Path,
    base_dir: Path,
    regex: re.Pattern[str],
    context: int,
    limit: int,
    results: list[str],
    match_count: int,
) -> int:
    """Search a single file for regex matches. Returns updated match_count."""
    try:
        text = filepath.read_text(encoding="utf-8", errors="strict")
    except (OSError, UnicodeDecodeError):
        return match_count

    try:
        rel = filepath.relative_to(base_dir)
    except ValueError:
        rel = filepath

    lines = text.splitlines()
    emitted_lines: set[int] = set()
    for i, line in enumerate(lines):
        if match_count > limit:
            break
        if not regex.search(line):
            continue
        if match_count >= limit:
            # Signal truncation without emitting output for the extra match.
            return match_count + 1
        match_count += 1
        if context > 0:
            start = max(0, i - context)
            end = min(len(lines), i + context + 1)
            for j in range(start, end):
                if j in emitted_lines:
                    continue
                emitted_lines.add(j)
                marker = ":" if j == i else "-"
                results.append(f"{rel}{marker}{j + 1}{marker}{_truncate_line(lines[j])}")
        else:
            results.append(f"{rel}:{i + 1}:{_truncate_line(line)}")
    return match_count


def _filter_hidden_and_ignored(files: list[Path], search_path: Path) -> list[Path]:
    """Filter out hidden files (dotfiles) and gitignored files."""
    search_root = search_path.resolve()
    visible: list[Path] = []
    for filepath in files:
        if not is_within_base_dir(filepath, search_root):
            continue
        if not filepath.is_file():
            continue
        try:
            rel = filepath.relative_to(search_path)
        except ValueError:
            rel = filepath.resolve()
        if any(part.startswith(".") for part in rel.parts):
            continue
        visible.append(filepath)

    ignored = _gitignored_paths(visible, search_path)
    return [f for f in visible if f not in ignored]


def _collect_grep_files(search_path: Path, glob_filter: str | None) -> list[Path] | str:
    """Collect and filter files for grep. Returns file list or error string."""
    if search_path.is_file():
        return [search_path]

    if glob_filter:
        patterns = [glob_filter]
        if "/" not in glob_filter and "\\" not in glob_filter and not glob_filter.startswith("**/"):
            # pathlib's "*.ext" only matches the top level, while ripgrep's --glob
            # applies recursively; include both patterns for parity in fallback mode.
            patterns.append(f"**/{glob_filter}")
    else:
        patterns = ["**/*"]

    files: list[Path] = []
    seen: set[Path] = set()
    for glob_pat in patterns:
        try:
            glob_matches = sorted(search_path.glob(glob_pat))
        except (NotImplementedError, ValueError) as e:
            return f"Error: Invalid glob pattern '{glob_pat}': {e}"
        for match in glob_matches:
            if match not in seen:
                seen.add(match)
                files.append(match)

    return _filter_hidden_and_ignored(files, search_path)


def _python_grep_fallback(
    pattern: str,
    search_path: Path,
    base_dir: Path,
    glob_filter: str | None,
    ignore_case: bool,
    literal: bool,
    context: int,
    limit: int,
) -> str:
    """Pure Python grep fallback when ripgrep is not available."""
    if literal:
        pattern = re.escape(pattern)
    flags = re.IGNORECASE if ignore_case else 0
    try:
        regex = re.compile(pattern, flags)
    except re.error as e:
        return f"Error: Invalid regex pattern: {e}"

    files_or_error = _collect_grep_files(search_path, glob_filter)
    if isinstance(files_or_error, str):
        return files_or_error

    results: list[str] = []
    match_count = 0
    for filepath in files_or_error:
        match_count = _grep_file(filepath, base_dir, regex, context, limit, results, match_count)
        if match_count > limit:
            break

    if not results:
        return "No matches found."

    was_limited = match_count > limit
    output = "\n".join(results)
    if was_limited:
        output += f"\n\n[Results limited to {limit} matches.]"
    trunc = _truncate_head(output)
    if trunc.was_truncated:
        return trunc.content + f"\n\n[Output truncated. {trunc.total_lines} total lines.]"
    return output.rstrip()
