"""
tools.py — Full agent tool suite for OneFirewall AI.

Tools implemented:
  BashTool       — Shell command execution (with git safety rules)
  FileReadTool   — Read file with line numbers
  FileWriteTool  — Create/overwrite files
  FileEditTool   — Surgical string replace (exact match required)
  GlobTool       — Find files by pattern
  GrepTool       — Search file contents (ripgrep fallback to grep)
  LSDir          — List directory
  WebFetchTool   — Fetch URLs
  AskUserTool    — Clarifying questions
  TodoWriteTool  — Session task management for multi-step work

Permission system:
  - Dangerous tools (bash, write, edit) require explicit user confirmation
  - User can approve once, deny, or approve for the whole session ("always")
"""

from __future__ import annotations

import glob as _glob
import json
import os
import platform
import re
import subprocess
import sys
import textwrap
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Callable

from rich.console import Console
from rich.panel import Panel
from rich.syntax import Syntax
from rich.table import Table
from rich import box

console = Console(force_terminal=True)

# ─── Permission System (OneFirewall session approvals) ─────────────────────────

_always_allow: set[str] = set()   # tool names OK'd for this session
_always_deny: set[str] = set()    # tool names denied for this session
_recent_background_commands: dict[tuple[str, str], float] = {}


def reset_session_permissions() -> None:
    """Clear permission memory between agent runs."""
    _always_allow.clear()
    _always_deny.clear()
    _recent_background_commands.clear()


def _ask_permission(tool_name: str, display_lines: list[str]) -> bool:
    """
    Prompt user to allow/deny a dangerous tool call.
    Returns True if allowed.
    """
    if tool_name in _always_allow:
        return True
    if tool_name in _always_deny:
        return False

    console.print()
    console.print(Panel(
        "\n".join(display_lines),
        title=f"[bold yellow]⚡ Agent wants to run: {tool_name}[/bold yellow]",
        border_style="yellow",
        padding=(0, 1),
    ))
    choice = console.input(
        "  Allow? [[bold green]y[/bold green]=yes / "
        "[bold red]n[/bold red]=no / "
        "[bold cyan]a[/bold cyan]=always / "
        "[bold magenta]d[/bold magenta]=deny-always]: "
    ).strip().lower()

    if choice in ("a", "always"):
        _always_allow.add(tool_name)
        return True
    if choice in ("d", "deny-always"):
        _always_deny.add(tool_name)
        return False
    return choice in ("y", "yes")


# ─── Git safety helpers (OneFirewall bash safety rules) ───────────────────────

_DESTRUCTIVE_GIT_PATTERNS = [
    r"git\s+push\s+.*--force",
    r"git\s+reset\s+--hard",
    r"git\s+checkout\s+\.",
    r"git\s+restore\s+\.",
    r"git\s+clean\s+-[a-z]*f",
    r"git\s+branch\s+-D",
    r"git\s+commit\s+.*--amend",
]

_SENSITIVE_FILE_PATTERNS = [
    r"\.env$", r"\.env\.", r"credentials\.json", r"id_rsa", r"\.pem$",
    r"secret[s_]", r"password[s_]", r"api[_-]key",
]

_RM_RF_PATTERN = re.compile(r"rm\s+-[a-z]*r[a-z]*f|rm\s+-[a-z]*f[a-z]*r", re.IGNORECASE)
_TRAILING_BACKGROUND_OPERATOR = re.compile(r"(?<!&)&\s*$")


def _check_bash_safety(command: str) -> list[str]:
    """Return list of warnings for a bash command. Empty = safe."""
    warnings = []
    for pat in _DESTRUCTIVE_GIT_PATTERNS:
        if re.search(pat, command):
            warnings.append(f"⚠️  Destructive git operation detected: {pat}")
    if _RM_RF_PATTERN.search(command):
        warnings.append("⚠️  Recursive force delete (rm -rf) detected")
    if "git config" in command:
        warnings.append("⚠️  git config modification — could change global settings")
    for pat in _SENSITIVE_FILE_PATTERNS:
        if re.search(pat, command, re.IGNORECASE):
            warnings.append(f"⚠️  May touch sensitive file matching: {pat}")
    return warnings


def _normalize_background_command(command: str, run_in_background: bool) -> tuple[str, bool]:
    """
    Convert a trailing shell-style "&" into explicit background execution.

    Models often emit `npm run dev &` for long-running commands. That is not safe
    cross-platform behaviour, especially on Windows, so we normalize it here.
    """
    normalized = command.rstrip()
    background = run_in_background

    if _TRAILING_BACKGROUND_OPERATOR.search(normalized):
        normalized = _TRAILING_BACKGROUND_OPERATOR.sub("", normalized).rstrip()
        background = True

    return normalized or command, background


# ─── Tool: Bash ───────────────────────────────────────────────────────────────

def run_bash(command: str, timeout: int = 120, cwd: str | None = None, run_in_background: bool = False) -> str:
    """
    Execute a shell command and return stdout+stderr.
    Matches the OneFirewall BashTool behavior including timeout and cwd.
    """
    cwd = cwd or os.getcwd()
    command, run_in_background = _normalize_background_command(command, run_in_background)
    try:
        if run_in_background:
            background_key = (str(Path(cwd).resolve()), command)
            now = time.monotonic()
            previous = _recent_background_commands.get(background_key)
            if previous is not None and now - previous < 30:
                return (
                    "[ERROR] Similar background command was already started recently. "
                    "Report the current server status instead of restarting it again unless the user asked you to debug or restart."
                )

            creationflags = 0
            start_new_session = platform.system() != "Windows"
            if platform.system() == "Windows":
                creationflags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
                creationflags |= getattr(subprocess, "DETACHED_PROCESS", 0)

            subprocess.Popen(
                command,
                shell=True,
                cwd=cwd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                start_new_session=start_new_session,
                creationflags=creationflags,
                close_fds=True,
            )
            _recent_background_commands[background_key] = now
            return "OK Command started in background. You will not see the output here."

        result = subprocess.run(
            command,
            shell=True,
            capture_output=True,
            text=True,
            timeout=timeout,
            cwd=cwd,
            env={**os.environ},
        )
        out = result.stdout or ""
        err = result.stderr or ""
        combined = out
        if err:
            combined += ("\n" if out else "") + f"[stderr]\n{err}"
        if not combined.strip():
            combined = f"(exit code {result.returncode})"
        lines = combined.splitlines()
        if len(lines) > 500:
            head = "\n".join(lines[:250])
            tail = "\n".join(lines[-250:])
            combined = f"{head}\n\n... [{len(lines)-500} lines omitted] ...\n\n{tail}"
        return combined.strip()
    except subprocess.TimeoutExpired:
        return (
            f"[ERROR] Command timed out after {timeout}s. "
            "If this is a dev server or watcher, rerun it with run_in_background=true instead of using '&'."
        )
    except FileNotFoundError as e:
        return f"[ERROR] Command not found: {e}"
    except Exception as e:
        return f"[ERROR] {e}"


def read_file(
    path: str,
    start_line: int | None = None,
    end_line: int | None = None,
) -> str:
    """
    Read a file with line numbers.
    Supports partial reads via start_line/end_line.
    Returns numbered lines: '   1 → content'
    """
    try:
        p = Path(path)
        if not p.exists():
            return f"[ERROR] File not found: {path}"
        if p.is_dir():
            return f"[ERROR] {path} is a directory — use list_directory instead"
        if p.stat().st_size > 5 * 1024 * 1024:  # 5MB guard
            return f"[ERROR] File too large ({p.stat().st_size // 1024}KB). Use start_line/end_line."

        content = p.read_text(encoding="utf-8", errors="replace")
        all_lines = content.splitlines()
        total = len(all_lines)

        s = max(0, (start_line or 1) - 1)
        e = min(total, end_line or total)
        selected = all_lines[s:e]

        if not selected:
            return "(empty file)"

        # Numbered output matching the REPL display format
        width = len(str(e))
        numbered = "\n".join(
            f"{s + i + 1:>{width}} \u2192 {line}"
            for i, line in enumerate(selected)
        )

        header = f"File: {path} (lines {s+1}-{e} of {total})"
        if e - s < total:
            header += f" — showing {e - s} of {total} lines"

        return f"{header}\n{'─' * 60}\n{numbered}"
    except UnicodeDecodeError:
        return f"[ERROR] Cannot read {path} — binary or non-UTF-8 file"
    except PermissionError:
        return f"[ERROR] Permission denied: {path}"
    except Exception as e:
        return f"[ERROR] {e}"


# ─── Tool: File Write ─────────────────────────────────────────────────────────

def write_file(path: str, content: str) -> str:
    """
    Write (create or overwrite) a file.
    Creates parent directories automatically.
    """
    try:
        p = Path(path)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(content, encoding="utf-8")
        lines = len(content.splitlines())
        size = len(content.encode("utf-8"))
        return (
            f"✓ Written: {path}\n"
            f"  {lines} lines, {size} bytes"
        )
    except PermissionError:
        return f"[ERROR] Permission denied writing to: {path}"
    except Exception as e:
        return f"[ERROR] {e}"


# ─── Tool: File Edit ──────────────────────────────────────────────────────────

def edit_file(
    path: str,
    old_string: str,
    new_string: str,
    replace_all: bool = False,
) -> str:
    """
    Replace an exact string in a file.

    Rules:
    - old_string must appear exactly once (unless replace_all=True)
    - Use read_file first to get exact text including indentation
    - NEVER include line number prefixes in old_string
    - Supports replace_all=True for renaming across the file
    """
    try:
        p = Path(path)
        if not p.exists():
            return f"[ERROR] File not found: {path}. Use write_file to create new files."

        content = p.read_text(encoding="utf-8")

        if old_string not in content:
            # Give helpful context: show surrounding lines
            hint = ""
            if old_string.strip():
                first_line = old_string.strip().splitlines()[0][:60]
                hint = f"\n  First line searched: {repr(first_line)}"
            return (
                f"[ERROR] old_string not found in {path}.{hint}\n"
                f"  Use read_file to verify the exact text and indentation."
            )

        count = content.count(old_string)
        if count > 1 and not replace_all:
            return (
                f"[ERROR] Found {count} matches for old_string in {path}.\n"
                f"  Provide more surrounding context to make it unique, or use replace_all=true."
            )

        if replace_all:
            new_content = content.replace(old_string, new_string)
            replaced = count
        else:
            new_content = content.replace(old_string, new_string, 1)
            replaced = 1

        p.write_text(new_content, encoding="utf-8")
        return f"✓ Edited {path}: replaced {replaced} occurrence(s)"
    except PermissionError:
        return f"[ERROR] Permission denied: {path}"
    except Exception as e:
        return f"[ERROR] {e}"


# ─── Tool: Glob ───────────────────────────────────────────────────────────────

def glob_files(
    pattern: str,
    cwd: str | None = None,
    exclude: str | None = None,
) -> str:
    """
    Find files matching a glob pattern.
    Supports ** recursive matching.
    """
    try:
        base = Path(cwd or os.getcwd())
        full_pat = str(base / pattern) if not os.path.isabs(pattern) else pattern
        matches = sorted(_glob.glob(full_pat, recursive=True))

        # Filter excludes
        if exclude:
            exc_pat = str(base / exclude) if not os.path.isabs(exclude) else exclude
            excluded = set(_glob.glob(exc_pat, recursive=True))
            matches = [m for m in matches if m not in excluded]

        # Filter common noise
        matches = [
            m for m in matches
            if not any(
                part in m for part in [
                    "node_modules", ".git", "__pycache__",
                    ".venv", "venv", "dist", ".next",
                ]
            )
        ]

        if not matches:
            return f"No files matched: {pattern}"

        # Show relative paths when possible
        rel_matches = []
        for m in matches[:500]:
            try:
                rel_matches.append(str(Path(m).relative_to(base)))
            except ValueError:
                rel_matches.append(m)

        result = "\n".join(rel_matches)
        if len(matches) > 500:
            result += f"\n\n... ({len(matches)-500} more not shown)"
        return result
    except Exception as e:
        return f"[ERROR] {e}"


# ─── Tool: Grep ───────────────────────────────────────────────────────────────

def grep_files(
    pattern: str,
    path: str = ".",
    include: str | None = None,
    case_insensitive: bool = False,
    context_lines: int = 2,
) -> str:
    """
    Search file contents. Uses ripgrep, falls back to grep.
    Returns matched lines with file:line numbers.
    """
    try:
        # Build ripgrep command (preferred — fast, respects .gitignore)
        cmd = [
            "rg",
            "--line-number",
            "--no-heading",
            f"--context={context_lines}",
            "--color=never",
            pattern,
            path,
        ]
        if include:
            cmd += ["--glob", include]
        if case_insensitive:
            cmd += ["--ignore-case"]

        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)

        if result.returncode == 0:
            output = result.stdout.strip()
            lines = output.splitlines()
            if len(lines) > 300:
                output = "\n".join(lines[:300]) + f"\n\n... ({len(lines)-300} more matches)"
            return output or f"No matches for '{pattern}'"
        elif result.returncode == 1:
            return f"No matches for '{pattern}' in {path}"
        # rg not found — fallback to grep
        raise FileNotFoundError("rg not available")

    except (FileNotFoundError, subprocess.TimeoutExpired):
        try:
            cmd = ["grep", "-rn", "--color=never"]
            if include:
                cmd += [f"--include={include}"]
            if case_insensitive:
                cmd += ["-i"]
            if context_lines:
                cmd += [f"-C{context_lines}"]
            cmd += [pattern, path]
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
            output = result.stdout.strip()
            return output or f"No matches for '{pattern}' in {path}"
        except Exception as e:
            return f"[ERROR] Search failed: {e}"
    except Exception as e:
        return f"[ERROR] {e}"


# ─── Tool: List Directory ─────────────────────────────────────────────────────

def list_directory(path: str = ".") -> str:
    """
    List directory contents (like ls -la but structured).
    Shows dirs first, then files with sizes.
    """
    try:
        p = Path(path)
        if not p.exists():
            return f"[ERROR] Path not found: {path}"
        if p.is_file():
            stat = p.stat()
            return (
                f"File: {p.name}\n"
                f"  Size: {stat.st_size:,} bytes\n"
                f"  Modified: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M')}"
            )

        entries = list(p.iterdir())
        dirs = sorted([e for e in entries if e.is_dir()], key=lambda e: e.name.lower())
        files = sorted([e for e in entries if e.is_file()], key=lambda e: e.name.lower())

        lines = [f"Directory: {p.resolve()}", f"  {len(dirs)} dirs, {len(files)} files", ""]

        for d in dirs:
            # Count children
            try:
                n = sum(1 for _ in d.iterdir())
                lines.append(f"  📁  {d.name}/  ({n} items)")
            except PermissionError:
                lines.append(f"  📁  {d.name}/  (access denied)")

        for f in files:
            stat = f.stat()
            sz = stat.st_size
            sz_str = (
                f"{sz:,} B" if sz < 1024
                else f"{sz/1024:.1f} KB" if sz < 1024*1024
                else f"{sz/1024/1024:.1f} MB"
            )
            mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
            lines.append(f"  📄  {f.name:<40} {sz_str:>10}  {mtime}")

        return "\n".join(lines)
    except PermissionError:
        return f"[ERROR] Permission denied: {path}"
    except Exception as e:
        return f"[ERROR] {e}"


# ─── Tool: Web Fetch ──────────────────────────────────────────────────────────

def web_fetch(url: str, timeout: int = 15) -> str:
    """
    Fetch content from a URL. Returns first 8000 chars.
    For HTML, attempts basic text extraction.
    """
    try:
        import requests
        headers = {
            "User-Agent": "onefirewall-ai/1.0 (compatible; like Mozilla/5.0)",
            "Accept": "text/html,application/xhtml+xml,text/plain,*/*",
        }
        r = requests.get(url, timeout=timeout, headers=headers, allow_redirects=True)
        r.raise_for_status()

        content_type = r.headers.get("Content-Type", "")
        text = r.text

        # Basic HTML → text extraction
        if "html" in content_type:
            # Remove script/style blocks
            text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL | re.IGNORECASE)
            text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
            # Remove tags
            text = re.sub(r"<[^>]+>", " ", text)
            # Collapse whitespace
            text = re.sub(r"\s+", " ", text).strip()
            # Unescape common HTML entities
            text = (text.replace("&amp;", "&").replace("&lt;", "<")
                    .replace("&gt;", ">").replace("&quot;", '"')
                    .replace("&#39;", "'").replace("&nbsp;", " "))

        # Truncate
        MAX = 8000
        if len(text) > MAX:
            text = text[:MAX] + f"\n\n... [truncated — total {len(r.text):,} chars]"

        return f"URL: {url}\nStatus: {r.status_code}\n{'─'*60}\n{text}"
    except Exception as e:
        return f"[ERROR] Failed to fetch {url}: {e}"


# ─── Tool: Ask User ───────────────────────────────────────────────────────────

def ask_user(question: str) -> str:
    """
    Ask the user a clarifying question.
    Only use when genuinely unclear — avoid excessive questions.
    """
    console.print()
    console.print(Panel(
        f"[bold white]{question}[/bold white]",
        title="[bold cyan]? Agent needs clarification[/bold cyan]",
        border_style="cyan",
        padding=(0, 1),
    ))
    answer = console.input("  [dim]Your answer:[/dim] ").strip()
    return answer or "(no answer provided)"


# ─── Tool: Todo Write ─────────────────────────────────────────────────────────
# In-memory session todo list (persisted in agent state)

_session_todos: list[dict] = []


def todo_write(todos: list[dict]) -> str:
    """
    Update the session todo list.

    Each todo item:
      { "id": str, "content": str, "status": "pending"|"in_progress"|"completed",
        "priority": "high"|"medium"|"low" }

    Rules:
    - Exactly ONE task should be in_progress at a time
    - Mark tasks complete IMMEDIATELY after finishing
    - Only mark completed when FULLY accomplished
    """
    global _session_todos
    old = list(_session_todos)
    _session_todos = todos

    # Build display
    status_icons = {
        "completed": "[green]✓[/green]",
        "in_progress": "[yellow]→[/yellow]",
        "pending": "[dim]○[/dim]",
    }

    lines = []
    for t in todos:
        icon = status_icons.get(t.get("status", "pending"), "○")
        pri = t.get("priority", "medium")
        pri_tag = {"high": "[red][H][/red]", "medium": "[yellow][M][/yellow]", "low": "[dim][L][/dim]"}.get(pri, "")
        lines.append(f"  {icon} {pri_tag} {t.get('content', '?')}")

    if lines:
        console.print()
        console.print(Panel(
            "\n".join(lines),
            title="[bold cyan]📋 Task List[/bold cyan]",
            border_style="dim",
        ))

    all_done = all(t.get("status") == "completed" for t in todos)
    if all_done and todos:
        _session_todos = []  # Clear when all done

    return (
        f"Todo list updated: {len(todos)} tasks "
        f"({sum(1 for t in todos if t.get('status') == 'completed')} done, "
        f"{sum(1 for t in todos if t.get('status') == 'in_progress')} in progress, "
        f"{sum(1 for t in todos if t.get('status') == 'pending')} pending).\n"
        "Continue with your current tasks."
    )


def get_session_todos() -> list[dict]:
    return list(_session_todos)


def reset_session_todos() -> None:
    global _session_todos
    _session_todos = []


# ─── Tool Registry ────────────────────────────────────────────────────────────
# JSON Schema format (OpenAI function-calling compatible)

TOOLS: list[dict] = [
    {
        "type": "function",
        "function": {
            "name": "bash",
            "description": textwrap.dedent("""\
                Executes a shell command and returns its output (stdout + stderr).

                IMPORTANT: Prefer dedicated tools over bash when available:
                - File search: use glob_files (NOT find or ls)
                - Content search: use grep_files (NOT grep or rg)
                - Read files: use read_file (NOT cat/head/tail)
                - Edit files: use edit_file (NOT sed/awk)
                - Write files: use write_file (NOT echo >)

                Use bash for: building, testing, running servers, git operations,
                installing packages, and anything requiring real shell execution.

                Git safety rules:
                - NEVER use --force push, reset --hard, checkout . unless user asks
                - NEVER skip hooks (--no-verify)
                - NEVER commit unless user explicitly asks
                - Create NEW commits rather than amending
                - Stage specific files, not git add -A
                - Pass commit messages via heredoc for correct formatting

                Running multiple commands:
                - Independent commands: run in parallel (multiple bash calls)
                - Sequential commands: use && to chain
                - Use ; only when you don't care if earlier commands fail
                - NEVER use newlines to separate commands

                Avoid:
                - `sleep` between commands that can run immediately
                - Polling loops — diagnose root cause instead
                - Interactive flags like -i (git rebase -i, git add -i)
            """).strip(),
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "description": "The shell command to execute. Use && to chain sequential commands.",
                    },
                    "timeout": {
                        "type": "integer",
                        "description": "Timeout in seconds (default 120, max 600)",
                        "default": 120,
                    },
                    "cwd": {
                        "type": "string",
                        "description": "Working directory (default: current directory). Prefer absolute paths.",
                    },
                    "run_in_background": {
                        "type": "boolean",
                        "description": "Run the command in the background without waiting for output. Use this instead of appending & for long-running servers or background tasks.",
                        "default": False,
                    },
                },
                "required": ["command"],
            },
        },
        "_dangerous": True,
        "_danger_display": lambda args: [
            f"Command: [bold]{args.get('command', '')}[/bold]",
            *(f"[yellow]{w}[/yellow]" for w in _check_bash_safety(args.get("command", ""))),
        ],
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": textwrap.dedent("""\
                Read the contents of a file with line numbers.

                Always use this before edit_file to confirm exact text and indentation.
                Use start_line/end_line for large files.
                The line number prefix format is: '   N → content'
                When copying text for edit_file, NEVER include the line number prefix.
            """).strip(),
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Absolute or relative file path",
                    },
                    "start_line": {
                        "type": "integer",
                        "description": "First line to read (1-indexed, inclusive)",
                    },
                    "end_line": {
                        "type": "integer",
                        "description": "Last line to read (inclusive)",
                    },
                },
                "required": ["path"],
            },
        },
        "_dangerous": False,
    },
    {
        "type": "function",
        "function": {
            "name": "write_file",
            "description": textwrap.dedent("""\
                Write full content to a file, creating it if it doesn't exist.
                Creates parent directories automatically.

                IMPORTANT: For existing files, prefer edit_file for targeted changes.
                Only use write_file when creating NEW files or doing a complete rewrite.
                Do NOT use echo/cat heredoc via bash — use this tool instead.
            """).strip(),
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "File path to write (absolute or relative)",
                    },
                    "content": {
                        "type": "string",
                        "description": "Complete file content to write",
                    },
                },
                "required": ["path", "content"],
            },
        },
        "_dangerous": True,
        "_danger_display": lambda args: [
            f"Path: [bold]{args.get('path', '')}[/bold]",
            f"Content: {len(args.get('content', '').splitlines())} lines, "
            f"{len(args.get('content', '').encode())} bytes",
        ],
    },
    {
        "type": "function",
        "function": {
            "name": "edit_file",
            "description": textwrap.dedent("""\
                Performs exact string replacement in a file.

                Rules:
                - You MUST use read_file at least once before editing
                - old_string must appear exactly once (use more context if ambiguous)
                - Preserve exact indentation (tabs/spaces) as shown in read_file
                - NEVER include line number prefixes in old_string or new_string
                - Use replace_all=true for renaming variables across the file
                - PREFER editing over write_file for existing files
                - Only use emojis if user explicitly requests them
                - Edit fails if old_string appears 0 or 2+ times (without replace_all)
            """).strip(),
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "File path to edit",
                    },
                    "old_string": {
                        "type": "string",
                        "description": (
                            "Exact text to replace. Must match the file exactly "
                            "including whitespace and indentation. "
                            "Include 2-4 surrounding lines for uniqueness."
                        ),
                    },
                    "new_string": {
                        "type": "string",
                        "description": "Replacement text with correct indentation",
                    },
                    "replace_all": {
                        "type": "boolean",
                        "description": "Replace all occurrences (for renaming). Default false.",
                        "default": False,
                    },
                },
                "required": ["path", "old_string", "new_string"],
            },
        },
        "_dangerous": True,
        "_danger_display": lambda args: [
            f"Path: [bold]{args.get('path', '')}[/bold]",
            f"Replace: [dim]{repr(args.get('old_string', '')[:80])}[/dim]",
            f"   With: [dim]{repr(args.get('new_string', '')[:80])}[/dim]",
        ],
    },
    {
        "type": "function",
        "function": {
            "name": "glob_files",
            "description": textwrap.dedent("""\
                Find files matching a glob pattern. Use this instead of bash find/ls.

                Examples:
                  '**/*.py'          — all Python files recursively
                  'src/**/*.ts'      — TypeScript in src/
                  '*.{json,toml}'    — config files in current dir
                  'test_*.py'        — test files

                Automatically excludes: node_modules, .git, __pycache__, .venv, dist
            """).strip(),
            "parameters": {
                "type": "object",
                "properties": {
                    "pattern": {
                        "type": "string",
                        "description": "Glob pattern (supports ** for recursive)",
                    },
                    "cwd": {
                        "type": "string",
                        "description": "Base directory (default: current dir)",
                    },
                },
                "required": ["pattern"],
            },
        },
        "_dangerous": False,
    },
    {
        "type": "function",
        "function": {
            "name": "grep_files",
            "description": textwrap.dedent("""\
                Search for text patterns in files. Use this instead of bash grep/rg.
                Returns matching lines with file path and line numbers.
                Supports regex patterns.

                Examples:
                  pattern='def main'           — find function definitions
                  pattern='TODO|FIXME'         — find markers
                  pattern='import.*requests'   — find imports
                  include='*.py'               — restrict to Python files
            """).strip(),
            "parameters": {
                "type": "object",
                "properties": {
                    "pattern": {
                        "type": "string",
                        "description": "Search pattern (regex supported)",
                    },
                    "path": {
                        "type": "string",
                        "description": "Directory or file to search (default: .)",
                    },
                    "include": {
                        "type": "string",
                        "description": "Glob filter e.g. '*.py', '*.ts'",
                    },
                    "case_insensitive": {
                        "type": "boolean",
                        "description": "Case-insensitive search (default false)",
                        "default": False,
                    },
                    "context_lines": {
                        "type": "integer",
                        "description": "Lines of context around each match (default 2)",
                        "default": 2,
                    },
                },
                "required": ["pattern"],
            },
        },
        "_dangerous": False,
    },
    {
        "type": "function",
        "function": {
            "name": "list_directory",
            "description": textwrap.dedent("""\
                List files and subdirectories at a path with sizes and timestamps.
                Use before creating files to verify the correct location exists.
            """).strip(),
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Directory to list (default: current dir)",
                    },
                },
                "required": [],
            },
        },
        "_dangerous": False,
    },
    {
        "type": "function",
        "function": {
            "name": "web_fetch",
            "description": textwrap.dedent("""\
                Fetch and return content from a URL (first 8000 chars).
                Handles HTML → plain text extraction automatically.
                Use for reading documentation, API specs, GitHub files, etc.
            """).strip(),
            "parameters": {
                "type": "object",
                "properties": {
                    "url": {
                        "type": "string",
                        "description": "URL to fetch (http:// or https://)",
                    },
                },
                "required": ["url"],
            },
        },
        "_dangerous": False,
    },
    {
        "type": "function",
        "function": {
            "name": "ask_user",
            "description": textwrap.dedent("""\
                Ask the user a clarifying question when genuinely needed.

                Use sparingly — only when:
                - The task is truly ambiguous in a way that affect outcome
                - You need a decision between fundamentally different approaches
                - You need credentials or information you cannot infer

                Do NOT use for:
                - Confirming obvious interpretations
                - Asking permission for normal operations (use bash for that)
                - Checking if the user wants you to proceed
                - Social niceties
            """).strip(),
            "parameters": {
                "type": "object",
                "properties": {
                    "question": {
                        "type": "string",
                        "description": "A clear, specific question for the user",
                    },
                },
                "required": ["question"],
            },
        },
        "_dangerous": False,
    },
    {
        "type": "function",
        "function": {
            "name": "todo_write",
            "description": textwrap.dedent("""\
                Create and manage a structured task list for the current session.
                Use this proactively to track multi-step tasks.

                WHEN TO USE:
                - Complex tasks requiring 3+ distinct steps
                - When the user provides multiple tasks
                - Before starting work (capture requirements)
                - After completing a step (mark it done, move to next)

                WHEN NOT TO USE:
                - Single simple tasks
                - Purely conversational requests
                - Tasks completable in 1-2 trivial steps

                RULES:
                - Mark tasks in_progress BEFORE starting work
                - Only ONE task in_progress at a time
                - Mark completed IMMEDIATELY after finishing (don't batch)
                - Never mark completed if tests fail or implementation is partial
                - Break large tasks into smaller specific steps
            """).strip(),
            "parameters": {
                "type": "object",
                "properties": {
                    "todos": {
                        "type": "array",
                        "description": "Complete updated todo list",
                        "items": {
                            "type": "object",
                            "properties": {
                                "id": {
                                    "type": "string",
                                    "description": "Unique identifier (e.g. '1', '2', 'setup-db')",
                                },
                                "content": {
                                    "type": "string",
                                    "description": "Task description in imperative form (e.g. 'Run tests')",
                                },
                                "status": {
                                    "type": "string",
                                    "enum": ["pending", "in_progress", "completed"],
                                    "description": "Current task status",
                                },
                                "priority": {
                                    "type": "string",
                                    "enum": ["high", "medium", "low"],
                                    "description": "Task priority",
                                },
                            },
                            "required": ["id", "content", "status", "priority"],
                        },
                    },
                },
                "required": ["todos"],
            },
        },
        "_dangerous": False,
    },
]

# Name → tool definition lookup
_TOOL_MAP: dict[str, dict] = {t["function"]["name"]: t for t in TOOLS}


def get_tool_schemas() -> list[dict]:
    """Return tool list in OpenAI function-calling format (strips internal _ keys)."""
    return [
        {k: v for k, v in t.items() if not k.startswith("_")}
        for t in TOOLS
    ]


def execute_tool(
    name: str,
    args: dict[str, Any],
    on_before_execute: Callable[[], None] | None = None,
) -> str:
    """
    Dispatch a tool call by name. Handles permission prompts for dangerous tools.
    Returns tool output as a string.
    """
    tool_def = _TOOL_MAP.get(name)
    if not tool_def:
        return f"[ERROR] Unknown tool: '{name}'. Available: {', '.join(_TOOL_MAP)}"

    is_dangerous = tool_def.get("_dangerous", False)

    if is_dangerous:
        display_fn = tool_def.get("_danger_display")
        if callable(display_fn):
            try:
                display_lines = display_fn(args)
            except Exception:
                display_lines = [f"{k}={repr(str(v)[:80])}" for k, v in args.items()]
        else:
            display_lines = [f"{k}={repr(str(v)[:80])}" for k, v in args.items()]

        if not _ask_permission(name, display_lines):
            return "[DENIED] User did not allow this operation. Try an alternative approach."

    try:
        if on_before_execute is not None:
            try:
                on_before_execute()
            except Exception:
                pass

        match name:
            case "bash":
                return run_bash(
                    args["command"],
                    args.get("timeout", 120),
                    args.get("cwd"),
                    args.get("run_in_background", False),
                )
            case "read_file":
                return read_file(
                    args["path"],
                    args.get("start_line"),
                    args.get("end_line"),
                )
            case "write_file":
                return write_file(args["path"], args["content"])
            case "edit_file":
                return edit_file(
                    args["path"],
                    args["old_string"],
                    args["new_string"],
                    args.get("replace_all", False),
                )
            case "glob_files":
                return glob_files(args["pattern"], args.get("cwd"))
            case "grep_files":
                return grep_files(
                    args["pattern"],
                    args.get("path", "."),
                    args.get("include"),
                    args.get("case_insensitive", False),
                    args.get("context_lines", 2),
                )
            case "list_directory":
                return list_directory(args.get("path", "."))
            case "web_fetch":
                return web_fetch(args["url"])
            case "ask_user":
                return ask_user(args["question"])
            case "todo_write":
                return todo_write(args["todos"])
            case _:
                return f"[ERROR] Tool '{name}' has no implementation."

    except KeyError as e:
        return f"[ERROR] Missing required argument: {e} for tool '{name}'"
    except Exception as e:
        return f"[ERROR] Tool '{name}' crashed: {type(e).__name__}: {e}"
