"""
tools.py — Every tool implementation + the tool registry + run_tool dispatcher.

Rules:
  • Each tool is an async function accepting **kwargs, returning dict.
  • Tools NEVER raise to the caller — all errors become {"error": "..."}.
  • TOOLS registry maps name → {fn, description, danger}.
  • run_tool(name, args) is the single dispatch entry point.
  • tool_list_for_prompt() returns a plain-text block for the AI system prompt.
  • Reminder-type tools use _send_callbacks; bots register via register_send_callback().
"""
from __future__ import annotations

import asyncio
import datetime
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.parse
import urllib.request
import urllib.error
from pathlib import Path
from typing import Any, Callable, Coroutine

import psutil

from .config import TOOL_TIMEOUT
from .logger import get_logger, log_task

log = get_logger("tools")


# ---------------------------------------------------------------------------
# Telegram callback registration
# ---------------------------------------------------------------------------

_send_callbacks: list[Callable[[str], Coroutine]] = []


def register_send_callback(fn: Callable[[str], Coroutine]) -> None:
    """Register an async callable that pushes a Telegram message to the user."""
    _send_callbacks.append(fn)


async def _push(message: str) -> None:
    """Broadcast *message* to all registered send callbacks."""
    for cb in _send_callbacks:
        try:
            await cb(message)
        except Exception as exc:
            log.warning("Send callback failed: %s", exc)


# ---------------------------------------------------------------------------
# Low-level helpers
# ---------------------------------------------------------------------------

def _run_subprocess(
    cmd: list[str] | str,
    timeout: float = TOOL_TIMEOUT,
    shell: bool = False,
    cwd: str | None = None,
    input_data: bytes | None = None,
) -> dict:
    """
    Run a subprocess and return {exit_code, stdout, stderr}.
    Never raises — all exceptions become {error: ...}.
    """
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
            shell=shell,
            cwd=cwd,
            input=input_data.decode() if input_data else None,
        )
        return {
            "exit_code": result.returncode,
            "stdout": result.stdout.strip(),
            "stderr": result.stderr.strip(),
        }
    except subprocess.TimeoutExpired:
        return {"error": f"Command timed out after {timeout:.0f}s"}
    except FileNotFoundError as exc:
        return {"error": f"Command not found: {exc}"}
    except PermissionError as exc:
        return {"error": f"Permission denied: {exc}"}
    except Exception as exc:
        return {"error": f"Subprocess error: {exc}"}


def _clamp(value: Any, lo: float, hi: float) -> float:
    """Clamp value to [lo, hi]."""
    return max(lo, min(hi, float(value)))


def _human_size(size_bytes: int) -> str:
    """Convert bytes to human-readable string."""
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if size_bytes < 1024.0:
            return f"{size_bytes:.1f} {unit}"
        size_bytes /= 1024.0
    return f"{size_bytes:.1f} PB"


# ===========================================================================
# TOOL IMPLEMENTATIONS
# ===========================================================================

# ---------------------------------------------------------------------------
# System health
# ---------------------------------------------------------------------------

async def tool_system_health(**kwargs) -> dict:
    """Return CPU, RAM, disk usage and the top-5 processes by CPU."""
    try:
        # cpu_percent(interval=1) blocks for 1 second — run in executor
        loop = asyncio.get_event_loop()
        cpu = await loop.run_in_executor(None, psutil.cpu_percent, 1)
        cpu_count = psutil.cpu_count(logical=True)
        mem = psutil.virtual_memory()
        disk = psutil.disk_usage("/")
        swap = psutil.swap_memory()

        procs: list[dict] = []
        # Two-pass: call cpu_percent(interval=None) after a short sleep for accuracy
        all_procs = list(psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent", "status"]))
        for p in all_procs:
            try:
                info = p.info
                procs.append({
                    "pid": info["pid"],
                    "name": info.get("name") or "?",
                    "cpu_pct": round(info.get("cpu_percent") or 0.0, 1),
                    "mem_pct": round(info.get("memory_percent") or 0.0, 1),
                    "status": info.get("status", "?"),
                })
            except (psutil.NoSuchProcess, psutil.AccessDenied):
                pass
        top = sorted(procs, key=lambda x: x["cpu_pct"], reverse=True)[:5]

        return {
            "cpu_percent": round(cpu, 1),
            "cpu_count": cpu_count,
            "ram_total_gb": round(mem.total / 1e9, 2),
            "ram_used_gb": round(mem.used / 1e9, 2),
            "ram_percent": round(mem.percent, 1),
            "swap_total_gb": round(swap.total / 1e9, 2),
            "swap_used_gb": round(swap.used / 1e9, 2),
            "disk_total_gb": round(disk.total / 1e9, 2),
            "disk_used_gb": round(disk.used / 1e9, 2),
            "disk_percent": round(disk.percent, 1),
            "top_processes": top,
        }
    except Exception as exc:
        return {"error": f"system_health failed: {exc}"}


async def tool_uptime(**kwargs) -> dict:
    """Return system uptime and boot time."""
    try:
        boot_ts = psutil.boot_time()
        delta = datetime.timedelta(seconds=time.time() - boot_ts)
        days = delta.days
        hours, rem = divmod(delta.seconds, 3600)
        minutes, seconds = divmod(rem, 60)
        return {
            "uptime": f"{days}d {hours}h {minutes}m {seconds}s",
            "boot_time": datetime.datetime.fromtimestamp(boot_ts).strftime("%Y-%m-%d %H:%M:%S"),
            "uptime_seconds": int(time.time() - boot_ts),
        }
    except Exception as exc:
        return {"error": f"uptime failed: {exc}"}


async def tool_battery(**kwargs) -> dict:
    """Return battery status."""
    try:
        bat = psutil.sensors_battery()
        if bat is None:
            return {"error": "No battery detected (desktop machine or no battery sensor)"}
        secs = bat.secsleft
        if secs == psutil.POWER_TIME_UNLIMITED:
            time_left = "unlimited (charging)"
        elif secs == psutil.POWER_TIME_UNKNOWN or secs < 0:
            time_left = "unknown"
        else:
            h, m = divmod(secs // 60, 60)
            time_left = f"{h}h {m}m"
        return {
            "percent": round(bat.percent, 1),
            "plugged_in": bool(bat.power_plugged),
            "time_left": time_left,
        }
    except Exception as exc:
        return {"error": f"battery failed: {exc}"}


async def tool_network_stats(**kwargs) -> dict:
    """Return network I/O counters, interface addresses, and active connections count."""
    try:
        counters = psutil.net_io_counters()
        ifaces: dict[str, str] = {}
        for name, addrs in psutil.net_if_addrs().items():
            for a in addrs:
                family_name = getattr(a.family, "name", str(a.family))
                if family_name in ("AF_INET", "AF_INET6") and not a.address.startswith("127."):
                    ifaces[name] = a.address
                    break
        try:
            conn_count = len(psutil.net_connections())
        except (psutil.AccessDenied, Exception):
            conn_count = -1
        return {
            "bytes_sent_mb": round(counters.bytes_sent / 1e6, 3),
            "bytes_recv_mb": round(counters.bytes_recv / 1e6, 3),
            "packets_sent": counters.packets_sent,
            "packets_recv": counters.packets_recv,
            "active_connections": conn_count,
            "interfaces": ifaces,
        }
    except Exception as exc:
        return {"error": f"network_stats failed: {exc}"}


# ---------------------------------------------------------------------------
# Shell / process
# ---------------------------------------------------------------------------

async def tool_run_shell(command: str = "", timeout: float = 30.0, **kwargs) -> dict:
    """Run an arbitrary shell command and return stdout/stderr/exit_code."""
    if not command or not command.strip():
        return {"error": "No command provided"}
    timeout = _clamp(timeout, 1.0, 120.0)
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(
        None,
        lambda: _run_subprocess(command, shell=True, timeout=timeout),
    )
    return result


async def tool_kill_process(pid: int = 0, name: str = "", signal_num: int = 15, **kwargs) -> dict:
    """Kill a process by PID or name. signal_num: 15=SIGTERM (default), 9=SIGKILL."""
    import signal as _signal
    killed: list[str] = []
    try:
        if pid and pid > 0:
            proc = psutil.Process(int(pid))
            pname = proc.name()
            proc.send_signal(signal_num)
            killed.append(f"PID {pid} ({pname})")
        elif name:
            matched = False
            for p in psutil.process_iter(["pid", "name"]):
                try:
                    if name.lower() in (p.info.get("name") or "").lower():
                        p.send_signal(signal_num)
                        killed.append(f"PID {p.info['pid']} ({p.info['name']})")
                        matched = True
                except (psutil.NoSuchProcess, psutil.AccessDenied):
                    pass
            if not matched:
                return {"error": f"No process matched name='{name}'"}
        else:
            return {"error": "Provide pid (int) or name (str)"}
    except psutil.NoSuchProcess:
        return {"error": f"Process not found: pid={pid} name={name}"}
    except psutil.AccessDenied:
        return {"error": "Access denied — try running as administrator/root"}
    except Exception as exc:
        return {"error": f"kill_process failed: {exc}"}

    if not killed:
        return {"error": "No processes were killed"}
    return {"killed": killed}


async def tool_list_processes(sort_by: str = "cpu", limit: int = 50, **kwargs) -> dict:
    """List running processes sorted by cpu or memory usage."""
    try:
        procs: list[dict] = []
        attrs = ["pid", "name", "status", "cpu_percent", "memory_percent", "username"]
        for p in psutil.process_iter(attrs):
            try:
                info = p.info
                procs.append({
                    "pid": info["pid"],
                    "name": info.get("name") or "?",
                    "status": info.get("status", "?"),
                    "cpu_pct": round(info.get("cpu_percent") or 0.0, 1),
                    "mem_pct": round(info.get("memory_percent") or 0.0, 2),
                    "user": info.get("username", "?"),
                })
            except (psutil.NoSuchProcess, psutil.AccessDenied):
                pass

        sort_key = "mem_pct" if sort_by.startswith("mem") else "cpu_pct"
        procs.sort(key=lambda x: x[sort_key], reverse=True)
        limit = int(_clamp(limit, 1, 200))
        return {"processes": procs[:limit], "total": len(procs)}
    except Exception as exc:
        return {"error": f"list_processes failed: {exc}"}


# ---------------------------------------------------------------------------
# File system
# ---------------------------------------------------------------------------

async def tool_list_directory(path: str = "~", show_hidden: bool = False, **kwargs) -> dict:
    """List files in a directory with size and modification time."""
    try:
        p = Path(path).expanduser().resolve()
        if not p.exists():
            return {"error": f"Path does not exist: {path}"}
        if not p.is_dir():
            return {"error": f"Not a directory: {p}"}

        items: list[dict] = []
        try:
            entries = sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name.lower()))
        except PermissionError:
            return {"error": f"Permission denied reading directory: {p}"}

        for entry in entries:
            if not show_hidden and entry.name.startswith("."):
                continue
            try:
                st = entry.stat()
                items.append({
                    "name": entry.name,
                    "type": "file" if entry.is_file() else "dir",
                    "size_bytes": st.st_size if entry.is_file() else None,
                    "modified": datetime.datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M"),
                    "readable": os.access(entry, os.R_OK),
                })
            except (PermissionError, OSError):
                items.append({"name": entry.name, "type": "unknown", "size_bytes": None, "modified": None, "readable": False})

        return {"path": str(p), "items": items, "count": len(items)}
    except Exception as exc:
        return {"error": f"list_directory failed: {exc}"}


async def tool_read_file(path: str = "", max_lines: int = 150, encoding: str = "utf-8", **kwargs) -> dict:
    """Read a text file and return its contents (up to max_lines lines)."""
    if not path:
        return {"error": "No path provided"}
    try:
        p = Path(path).expanduser().resolve()
        if not p.exists():
            return {"error": f"File not found: {path}"}
        if not p.is_file():
            return {"error": f"Not a file: {p}"}
        if not os.access(p, os.R_OK):
            return {"error": f"Permission denied reading file: {p}"}

        size = p.stat().st_size
        if size > 2_000_000:
            return {"error": f"File too large ({_human_size(size)}). Use a more specific read."}

        max_lines = int(_clamp(max_lines, 1, 1000))
        try:
            text = p.read_text(encoding=encoding, errors="replace")
        except LookupError:
            text = p.read_text(encoding="utf-8", errors="replace")

        lines = text.splitlines()
        truncated = len(lines) > max_lines
        return {
            "path": str(p),
            "lines": len(lines),
            "content": "\n".join(lines[:max_lines]),
            "truncated": truncated,
            "size_bytes": size,
            "encoding": encoding,
        }
    except Exception as exc:
        return {"error": f"read_file failed: {exc}"}


async def tool_write_file(
    path: str = "",
    content: str = "",
    append: bool = False,
    encoding: str = "utf-8",
    **kwargs,
) -> dict:
    """Write or append text to a file. Creates parent directories as needed."""
    if not path:
        return {"error": "No path provided"}
    try:
        p = Path(path).expanduser().resolve()
        p.parent.mkdir(parents=True, exist_ok=True)
        mode = "a" if append else "w"
        with open(p, mode, encoding=encoding) as f:
            f.write(content)
        return {
            "path": str(p),
            "bytes_written": len(content.encode(encoding)),
            "mode": "append" if append else "overwrite",
        }
    except PermissionError as exc:
        return {"error": f"Permission denied writing to {path}: {exc}"}
    except Exception as exc:
        return {"error": f"write_file failed: {exc}"}


async def tool_delete_file(path: str = "", force: bool = False, **kwargs) -> dict:
    """Delete a file or directory tree."""
    if not path:
        return {"error": "No path provided"}
    try:
        p = Path(path).expanduser().resolve()
        if not p.exists():
            return {"error": f"Path does not exist: {path}"}
        if not os.access(p, os.W_OK) and not force:
            return {"error": f"No write permission for {p}. Use force=true to override."}
        if p.is_file() or p.is_symlink():
            p.unlink(missing_ok=True)
            return {"deleted": str(p), "type": "file"}
        elif p.is_dir():
            shutil.rmtree(p)
            return {"deleted": str(p), "type": "directory"}
        else:
            return {"error": f"Unknown path type: {p}"}
    except PermissionError as exc:
        return {"error": f"Permission denied deleting {path}: {exc}"}
    except Exception as exc:
        return {"error": f"delete_file failed: {exc}"}


async def tool_copy_file(src: str = "", dst: str = "", **kwargs) -> dict:
    """Copy a file or directory tree."""
    if not src or not dst:
        return {"error": "Provide both src and dst paths"}
    try:
        s = Path(src).expanduser().resolve()
        d = Path(dst).expanduser().resolve()
        if not s.exists():
            return {"error": f"Source not found: {src}"}
        d.parent.mkdir(parents=True, exist_ok=True)
        if s.is_file():
            shutil.copy2(s, d)
        elif s.is_dir():
            if d.exists():
                shutil.rmtree(d)
            shutil.copytree(s, d)
        else:
            return {"error": f"Cannot copy: {s} is not a file or directory"}
        return {"copied": str(s), "to": str(d)}
    except PermissionError as exc:
        return {"error": f"Permission denied: {exc}"}
    except Exception as exc:
        return {"error": f"copy_file failed: {exc}"}


async def tool_move_file(src: str = "", dst: str = "", **kwargs) -> dict:
    """Move or rename a file or directory."""
    if not src or not dst:
        return {"error": "Provide both src and dst paths"}
    try:
        s = Path(src).expanduser().resolve()
        d = Path(dst).expanduser().resolve()
        if not s.exists():
            return {"error": f"Source not found: {src}"}
        d.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(str(s), str(d))
        return {"moved": str(s), "to": str(d)}
    except PermissionError as exc:
        return {"error": f"Permission denied: {exc}"}
    except Exception as exc:
        return {"error": f"move_file failed: {exc}"}


async def tool_search_files(
    query: str = "",
    path: str = "~",
    extension: str = "",
    max_results: int = 50,
    **kwargs,
) -> dict:
    """Search for files by name pattern under a directory."""
    if not query and not extension:
        return {"error": "Provide query (name pattern) or extension (e.g. '.py')"}
    try:
        root = Path(path).expanduser().resolve()
        if not root.exists():
            return {"error": f"Search path not found: {path}"}

        # Build glob pattern
        if extension and not extension.startswith("."):
            extension = f".{extension}"
        if query and extension:
            pattern = f"*{query}*{extension}"
        elif query:
            pattern = f"*{query}*"
        else:
            pattern = f"*{extension}"

        matches: list[dict] = []
        max_results = int(_clamp(max_results, 1, 200))
        try:
            for p in root.rglob(pattern):
                try:
                    matches.append({
                        "path": str(p),
                        "type": "file" if p.is_file() else "dir",
                        "size_bytes": p.stat().st_size if p.is_file() else None,
                        "modified": datetime.datetime.fromtimestamp(
                            p.stat().st_mtime
                        ).strftime("%Y-%m-%d %H:%M"),
                    })
                except (PermissionError, OSError):
                    pass
                if len(matches) >= max_results:
                    break
        except PermissionError:
            pass  # Skip inaccessible directories

        return {"matches": matches, "count": len(matches), "root": str(root), "pattern": pattern}
    except Exception as exc:
        return {"error": f"search_files failed: {exc}"}


async def tool_create_directory(path: str = "", **kwargs) -> dict:
    """Create a directory (and all parent directories)."""
    if not path:
        return {"error": "No path provided"}
    try:
        p = Path(path).expanduser().resolve()
        p.mkdir(parents=True, exist_ok=True)
        return {"created": str(p)}
    except PermissionError as exc:
        return {"error": f"Permission denied: {exc}"}
    except Exception as exc:
        return {"error": f"create_directory failed: {exc}"}


async def tool_disk_usage(path: str = "/", **kwargs) -> dict:
    """Return disk space usage for a given path."""
    try:
        p = Path(path).expanduser().resolve()
        if not p.exists():
            return {"error": f"Path not found: {path}"}
        usage = shutil.disk_usage(p)
        used_pct = round(usage.used / usage.total * 100, 1) if usage.total > 0 else 0
        return {
            "path": str(p),
            "total_gb": round(usage.total / 1e9, 3),
            "used_gb": round(usage.used / 1e9, 3),
            "free_gb": round(usage.free / 1e9, 3),
            "used_percent": used_pct,
        }
    except Exception as exc:
        return {"error": f"disk_usage failed: {exc}"}


# ---------------------------------------------------------------------------
# Screenshot
# ---------------------------------------------------------------------------

async def tool_screenshot(quality: int = 90, **kwargs) -> dict:
    """Take a screenshot of the primary display. Returns the file path."""
    try:
        out = Path(tempfile.mktemp(suffix=".png", prefix="callme_screenshot_"))
        sys_name = platform.system()
        res: dict = {}

        if sys_name == "Darwin":
            res = _run_subprocess(["screencapture", "-x", "-t", "png", str(out)])
        elif sys_name == "Windows":
            ps_script = (
                "Add-Type -AssemblyName System.Windows.Forms;"
                "Add-Type -AssemblyName System.Drawing;"
                "$screen = [System.Windows.Forms.SystemInformation]::VirtualScreen;"
                "$bmp = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height);"
                "$g = [System.Drawing.Graphics]::FromImage($bmp);"
                "$g.CopyFromScreen($screen.Left, $screen.Top, 0, 0, $screen.Size);"
                f"$bmp.Save('{out}', [System.Drawing.Imaging.ImageFormat]::Png);"
                "$g.Dispose(); $bmp.Dispose()"
            )
            res = _run_subprocess(["powershell", "-NonInteractive", "-Command", ps_script], timeout=30)
        else:
            # Linux — try tools in priority order
            screenshot_tools = [
                (["scrot", "-z", str(out)], "scrot"),
                (["gnome-screenshot", "-f", str(out)], "gnome-screenshot"),
                (["import", "-window", "root", str(out)], "import"),
                (["maim", str(out)], "maim"),
                (["xwd", "-root", "-silent"], "xwd"),  # handled separately
            ]
            took_screenshot = False
            for cmd, tool_name in screenshot_tools:
                if not shutil.which(cmd[0]):
                    continue
                if tool_name == "xwd":
                    # xwd → convert (ImageMagick)
                    if shutil.which("convert"):
                        xwd_res = _run_subprocess(["xwd", "-root", "-silent"], timeout=15)
                        if "error" not in xwd_res:
                            with open(out, "wb") as f:
                                f.write(xwd_res.get("stdout", "").encode())
                            took_screenshot = True
                    continue
                res = _run_subprocess(cmd, timeout=20)
                if "error" not in res and out.exists():
                    took_screenshot = True
                    break

            if not took_screenshot and not out.exists():
                return {
                    "error": (
                        "No screenshot tool found. Install one:\n"
                        "  sudo apt install scrot  # or gnome-screenshot, maim, imagemagick"
                    )
                }

        if "error" in res:
            return res
        if not out.exists() or out.stat().st_size == 0:
            return {"error": "Screenshot file was not created or is empty"}

        size = out.stat().st_size
        return {
            "path": str(out),
            "size_bytes": size,
            "size_kb": round(size / 1024, 1),
        }
    except Exception as exc:
        return {"error": f"screenshot failed: {exc}"}


# ---------------------------------------------------------------------------
# Volume control
# ---------------------------------------------------------------------------

async def tool_get_volume(**kwargs) -> dict:
    """Get the current system volume level (0–100)."""
    try:
        sys_name = platform.system()
        if sys_name == "Darwin":
            res = _run_subprocess(["osascript", "-e", "output volume of (get volume settings)"])
            if "error" in res:
                return res
            vol = res["stdout"].strip().split("\n")[0].strip()
            return {"volume": int(vol)}
        elif sys_name == "Windows":
            # Use PowerShell's Get-Volume equivalent via COM
            ps = (
                "$vol = (New-Object -com WScript.Shell);"
                "$wmp = New-Object -ComObject WMPlayer.OCX;"
                "Write-Output ([int]($wmp.settings.volume))"
            )
            res = _run_subprocess(["powershell", "-NonInteractive", "-Command", ps])
            if "error" not in res and res["stdout"].strip().isdigit():
                return {"volume": int(res["stdout"].strip())}
            return {"error": "Could not get volume on Windows. Install nircmd for better support."}
        else:
            # Linux: try amixer first, then pactl
            if shutil.which("amixer"):
                res = _run_subprocess(["amixer", "-D", "pulse", "get", "Master"])
                if "error" not in res:
                    m = re.search(r"\[(\d+)%\]", res["stdout"])
                    if m:
                        return {"volume": int(m.group(1))}
            if shutil.which("pactl"):
                res = _run_subprocess(["pactl", "get-sink-volume", "@DEFAULT_SINK@"])
                if "error" not in res:
                    m = re.search(r"(\d+)%", res["stdout"])
                    if m:
                        return {"volume": int(m.group(1))}
            return {"error": "No volume control found. Install alsa-utils or pulseaudio-utils."}
    except Exception as exc:
        return {"error": f"get_volume failed: {exc}"}


async def tool_set_volume(level: int = 50, **kwargs) -> dict:
    """Set the system volume (0–100)."""
    level = int(_clamp(int(level), 0, 100))
    try:
        sys_name = platform.system()
        if sys_name == "Darwin":
            res = _run_subprocess(["osascript", "-e", f"set volume output volume {level}"])
        elif sys_name == "Windows":
            if shutil.which("nircmd"):
                vol_nircmd = int(level * 655.35)  # nircmd scale: 0–65535
                res = _run_subprocess(["nircmd", "setsysvolume", str(vol_nircmd)])
            else:
                ps = f"(New-Object -comobject WScript.Shell).SendKeys([char]175 * 50); exit"
                return {"error": "Set volume on Windows requires nircmd. Download from nirsoft.net."}
        else:
            if shutil.which("pactl"):
                res = _run_subprocess(["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{level}%"])
            elif shutil.which("amixer"):
                res = _run_subprocess(["amixer", "-D", "pulse", "sset", "Master", f"{level}%"])
            else:
                return {"error": "No volume control found. Install pulseaudio-utils or alsa-utils."}
        if "error" in res:
            return res
        return {"volume_set": level}
    except Exception as exc:
        return {"error": f"set_volume failed: {exc}"}


async def tool_mute_volume(**kwargs) -> dict:
    """Mute system audio."""
    try:
        sys_name = platform.system()
        if sys_name == "Darwin":
            res = _run_subprocess(["osascript", "-e", "set volume with output muted"])
        elif sys_name == "Windows":
            res = _run_subprocess(
                ["powershell", "-NonInteractive", "-Command",
                 "(New-Object -ComObject WScript.Shell).SendKeys([char]173)"]
            )
        else:
            if shutil.which("pactl"):
                res = _run_subprocess(["pactl", "set-sink-mute", "@DEFAULT_SINK@", "1"])
            elif shutil.which("amixer"):
                res = _run_subprocess(["amixer", "-D", "pulse", "sset", "Master", "mute"])
            else:
                return {"error": "No volume control found. Install pulseaudio-utils or alsa-utils."}
        if "error" in res:
            return res
        return {"muted": True}
    except Exception as exc:
        return {"error": f"mute_volume failed: {exc}"}


# ---------------------------------------------------------------------------
# Clipboard
# ---------------------------------------------------------------------------

async def tool_get_clipboard(**kwargs) -> dict:
    """Return the current clipboard text content."""
    try:
        sys_name = platform.system()
        if sys_name == "Darwin":
            res = _run_subprocess(["pbpaste"])
        elif sys_name == "Windows":
            res = _run_subprocess(["powershell", "-NonInteractive", "-Command", "Get-Clipboard"])
        else:
            for tool, args in [
                ("xclip", ["xclip", "-selection", "clipboard", "-o"]),
                ("xsel",  ["xsel", "--clipboard", "--output"]),
                ("wl-paste", ["wl-paste"]),
            ]:
                if shutil.which(tool):
                    res = _run_subprocess(args)
                    break
            else:
                return {"error": "No clipboard tool found. Install xclip: sudo apt install xclip"}

        if "error" in res:
            return res
        content = res.get("stdout", "")
        return {"clipboard": content, "length": len(content)}
    except Exception as exc:
        return {"error": f"get_clipboard failed: {exc}"}


async def tool_set_clipboard(text: str = "", **kwargs) -> dict:
    """Copy text to the system clipboard."""
    if text is None:
        return {"error": "No text provided"}
    try:
        encoded = text.encode("utf-8")
        sys_name = platform.system()
        if sys_name == "Darwin":
            proc = subprocess.run(["pbcopy"], input=encoded, capture_output=True, timeout=10)
            if proc.returncode != 0:
                return {"error": proc.stderr.decode(errors="replace")}
        elif sys_name == "Windows":
            # PowerShell Set-Clipboard
            proc = subprocess.run(
                ["powershell", "-NonInteractive", "-Command", "Set-Clipboard -Value $input"],
                input=encoded, capture_output=True, timeout=10,
            )
        else:
            for tool, args in [
                ("xclip", ["xclip", "-selection", "clipboard"]),
                ("xsel",  ["xsel", "--clipboard", "--input"]),
                ("wl-copy", ["wl-copy"]),
            ]:
                if shutil.which(tool):
                    proc = subprocess.run(args, input=encoded, capture_output=True, timeout=10)
                    break
            else:
                return {"error": "No clipboard tool found. Install xclip: sudo apt install xclip"}
        preview = text[:80] + ("…" if len(text) > 80 else "")
        return {"clipboard_set": preview, "length": len(text)}
    except subprocess.TimeoutExpired:
        return {"error": "Clipboard operation timed out"}
    except Exception as exc:
        return {"error": f"set_clipboard failed: {exc}"}


# ---------------------------------------------------------------------------
# Weather — open-meteo (free, no API key required)
# ---------------------------------------------------------------------------

async def tool_weather(city: str = "", lat: float = 0.0, lon: float = 0.0, **kwargs) -> dict:
    """
    Fetch current weather via open-meteo (100% free, no key).
    Geocodes city names using the open-meteo geocoding API.
    """
    try:
        loop = asyncio.get_event_loop()

        if city and not (lat and lon):
            geo_url = (
                "https://geocoding-api.open-meteo.com/v1/search?"
                + urllib.parse.urlencode({"name": city.strip(), "count": 1, "language": "en", "format": "json"})
            )
            def _geo():
                req = urllib.request.Request(geo_url, headers={"User-Agent": "CallMe/1.0"})
                with urllib.request.urlopen(req, timeout=15) as r:
                    return json.loads(r.read())
            try:
                geo_data = await loop.run_in_executor(None, _geo)
            except urllib.error.URLError as exc:
                return {"error": f"Could not reach geocoding API: {exc}"}

            results = geo_data.get("results")
            if not results:
                return {"error": f"City not found: '{city}'. Try spelling it differently."}
            lat = float(results[0]["latitude"])
            lon = float(results[0]["longitude"])
            city = results[0].get("name", city)
            country = results[0].get("country", "")
            if country:
                city = f"{city}, {country}"

        if not (lat and lon):
            return {"error": "Provide city name or lat/lon coordinates (e.g. lat=51.5 lon=-0.1)"}

        wx_url = (
            "https://api.open-meteo.com/v1/forecast?"
            + urllib.parse.urlencode({
                "latitude": round(lat, 4),
                "longitude": round(lon, 4),
                "current_weather": True,
                "hourly": "relativehumidity_2m,apparent_temperature",
                "forecast_days": 1,
            })
        )
        def _wx():
            req = urllib.request.Request(wx_url, headers={"User-Agent": "CallMe/1.0"})
            with urllib.request.urlopen(req, timeout=15) as r:
                return json.loads(r.read())
        try:
            wx = await loop.run_in_executor(None, _wx)
        except urllib.error.URLError as exc:
            return {"error": f"Could not reach weather API: {exc}"}

        cw = wx.get("current_weather", {})
        wmo_codes = {
            0: "Clear sky ☀️", 1: "Mainly clear 🌤️", 2: "Partly cloudy ⛅", 3: "Overcast ☁️",
            45: "Fog 🌫️", 48: "Icy fog 🌫️",
            51: "Light drizzle 🌦️", 53: "Drizzle 🌧️", 55: "Heavy drizzle 🌧️",
            61: "Light rain 🌧️", 63: "Rain 🌧️", 65: "Heavy rain 🌊",
            71: "Light snow 🌨️", 73: "Snow ❄️", 75: "Heavy snow 🌨️",
            77: "Snow grains ❄️",
            80: "Light showers 🌦️", 81: "Showers 🌦️", 82: "Heavy showers ⛈️",
            85: "Snow showers 🌨️", 86: "Heavy snow showers 🌨️",
            95: "Thunderstorm ⛈️", 96: "Thunderstorm + hail ⛈️", 99: "Thunderstorm + heavy hail ⛈️",
        }
        humidity = None
        feels_like = None
        if "hourly" in wx:
            rh = wx["hourly"].get("relativehumidity_2m", [])
            if rh:
                humidity = rh[0]
            at = wx["hourly"].get("apparent_temperature", [])
            if at:
                feels_like = at[0]

        weather_code = cw.get("weathercode", -1)
        return {
            "city": city,
            "temperature_c": cw.get("temperature"),
            "feels_like_c": feels_like,
            "windspeed_kmh": cw.get("windspeed"),
            "wind_direction_deg": cw.get("winddirection"),
            "conditions": wmo_codes.get(weather_code, f"Code {weather_code}"),
            "humidity_percent": humidity,
            "is_day": bool(cw.get("is_day", 1)),
            "lat": round(lat, 4),
            "lon": round(lon, 4),
        }
    except Exception as exc:
        return {"error": f"weather failed: {exc}"}


# ---------------------------------------------------------------------------
# Reminder
# ---------------------------------------------------------------------------

async def tool_reminder(
    message: str = "",
    delay_seconds: int = 0,
    delay_minutes: int = 0,
    delay_hours: int = 0,
    **kwargs,
) -> dict:
    """Set a reminder that fires after the specified delay and sends a Telegram message."""
    if not message or not message.strip():
        return {"error": "No reminder message provided"}

    total_secs = int(delay_seconds) + int(delay_minutes) * 60 + int(delay_hours) * 3600
    if total_secs <= 0:
        return {"error": "Provide a positive delay (delay_seconds, delay_minutes, or delay_hours)"}
    if total_secs > 86400 * 7:  # cap at 1 week
        return {"error": "Maximum reminder delay is 7 days"}

    async def _fire():
        await asyncio.sleep(total_secs)
        await _push(f"⏰ *Reminder:* {message}")

    asyncio.ensure_future(_fire())
    eta = datetime.datetime.now() + datetime.timedelta(seconds=total_secs)
    h, rem = divmod(total_secs, 3600)
    m, s = divmod(rem, 60)
    human = f"{h}h {m}m {s}s" if h else (f"{m}m {s}s" if m else f"{s}s")

    return {
        "reminder_set": message.strip(),
        "fires_at": eta.strftime("%H:%M:%S"),
        "fires_at_full": eta.strftime("%Y-%m-%d %H:%M:%S"),
        "delay_human": human,
        "delay_seconds": total_secs,
    }


# ---------------------------------------------------------------------------
# Open URL / app
# ---------------------------------------------------------------------------

async def tool_open_url(url: str = "", **kwargs) -> dict:
    """Open a URL in the default browser."""
    if not url or not url.strip():
        return {"error": "No URL provided"}
    url = url.strip()
    if not re.match(r"^https?://", url, re.IGNORECASE):
        url = f"https://{url}"
    import webbrowser
    try:
        loop = asyncio.get_event_loop()
        success = await loop.run_in_executor(None, webbrowser.open, url)
        if not success:
            return {"error": f"Browser could not open: {url}"}
        return {"opened": url}
    except Exception as exc:
        return {"error": f"open_url failed: {exc}"}


async def tool_open_application(name: str = "", **kwargs) -> dict:
    """Launch an application by name."""
    if not name or not name.strip():
        return {"error": "No application name provided"}
    name = name.strip()
    try:
        sys_name = platform.system()
        if sys_name == "Darwin":
            res = _run_subprocess(["open", "-a", name], timeout=15)
            if "error" in res or res.get("exit_code", 1) != 0:
                # Fallback: try direct command
                if shutil.which(name.lower()):
                    subprocess.Popen([name.lower()], start_new_session=True)
                    return {"launched": name}
                return res if "error" in res else {"error": f"Could not open '{name}'"}
        elif sys_name == "Windows":
            subprocess.Popen(["start", "", name], shell=True, start_new_session=True)
        else:
            if shutil.which(name.lower()):
                subprocess.Popen([name.lower()], start_new_session=True)
            elif shutil.which("xdg-open"):
                subprocess.Popen(["xdg-open", name], start_new_session=True)
            elif shutil.which("gio"):
                subprocess.Popen(["gio", "open", name], start_new_session=True)
            else:
                return {"error": f"Application '{name}' not found and no xdg-open available"}
        return {"launched": name}
    except Exception as exc:
        return {"error": f"open_application failed: {exc}"}


# ---------------------------------------------------------------------------
# Internet / ping
# ---------------------------------------------------------------------------

async def tool_ping(host: str = "8.8.8.8", count: int = 4, **kwargs) -> dict:
    """Ping a host and return packet loss and average latency."""
    if not host or not host.strip():
        return {"error": "No host provided"}
    host = host.strip()
    count = int(_clamp(int(count), 1, 20))
    try:
        sys_name = platform.system()
        cmd = ["ping", "-n" if sys_name == "Windows" else "-c", str(count), host]
        loop = asyncio.get_event_loop()
        res = await loop.run_in_executor(
            None, lambda: _run_subprocess(cmd, timeout=count * 5 + 10)
        )
        if "error" in res:
            return res

        stdout = res.get("stdout", "")
        # Parse average latency — handles Linux/macOS and Windows formats
        avg: float | None = None
        for pattern in [
            r"(?:avg|mean)[^=]+=\s*[\d.]+/([\d.]+)",   # Linux/macOS: min/avg/max/mdev
            r"Average\s*=\s*([\d.]+)\s*ms",             # Windows
            r"time[<=]([\d.]+)\s*ms",                   # Single-packet
        ]:
            m = re.search(pattern, stdout, re.IGNORECASE)
            if m:
                avg = float(m.group(1))
                break

        # Parse packet loss
        loss: str | None = None
        m2 = re.search(r"(\d+(?:\.\d+)?)\s*%\s*packet loss", stdout, re.IGNORECASE)
        if m2:
            loss = m2.group(1) + "%"

        return {
            "host": host,
            "packets": count,
            "avg_ms": avg,
            "packet_loss": loss,
            "reachable": res.get("exit_code", 1) == 0,
            "output": stdout[:600],
        }
    except Exception as exc:
        return {"error": f"ping failed: {exc}"}


async def tool_internet_speed(**kwargs) -> dict:
    """Check internet connectivity and measure latency to major public endpoints."""
    results: dict[str, dict] = {}
    loop = asyncio.get_event_loop()
    endpoints = [
        ("google.com", "https://www.google.com"),
        ("cloudflare", "https://1.1.1.1"),
        ("github.com", "https://github.com"),
    ]

    async def _check_one(name: str, url: str) -> None:
        start = time.monotonic()
        try:
            def _req():
                req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "CallMe/1.0"})
                with urllib.request.urlopen(req, timeout=8):
                    pass
            await loop.run_in_executor(None, _req)
            latency = round((time.monotonic() - start) * 1000, 1)
            results[name] = {"reachable": True, "latency_ms": latency}
        except Exception as exc:
            results[name] = {"reachable": False, "error": str(exc)[:80]}

    await asyncio.gather(*[_check_one(n, u) for n, u in endpoints])
    return {"connectivity": results}


# ---------------------------------------------------------------------------
# Date / time
# ---------------------------------------------------------------------------

async def tool_datetime(**kwargs) -> dict:
    """Return current date, time, timezone, week number, and UTC."""
    now = datetime.datetime.now()
    utc = datetime.datetime.utcnow()
    return {
        "local": now.isoformat(timespec="seconds"),
        "utc": utc.isoformat(timespec="seconds"),
        "date": now.strftime("%A, %B %d %Y"),
        "time": now.strftime("%H:%M:%S"),
        "timestamp": int(time.time()),
        "timezone": time.tzname[0],
        "utc_offset_hours": round(-time.timezone / 3600, 1),
        "week_number": now.isocalendar()[1],
        "day_of_year": now.timetuple().tm_yday,
    }


# ---------------------------------------------------------------------------
# Dev helpers
# ---------------------------------------------------------------------------

async def tool_python_info(**kwargs) -> dict:
    """Return Python version, executable path, and installed packages count."""
    try:
        loop = asyncio.get_event_loop()
        res = await loop.run_in_executor(
            None,
            lambda: _run_subprocess([sys.executable, "-m", "pip", "list", "--format=json"], timeout=30),
        )
        pkgs: list[dict] = []
        if "error" not in res and res.get("stdout"):
            try:
                pkgs = json.loads(res["stdout"])
            except json.JSONDecodeError:
                pass
        return {
            "python_version": sys.version,
            "executable": sys.executable,
            "platform": sys.platform,
            "implementation": platform.python_implementation(),
            "packages_installed": len(pkgs),
            "packages_sample": [p["name"] for p in pkgs[:10]],
        }
    except Exception as exc:
        return {"error": f"python_info failed: {exc}"}


async def tool_env_vars(filter_key: str = "", **kwargs) -> dict:
    """Return environment variables (sensitive values automatically redacted)."""
    try:
        _SENSITIVE = {"key", "secret", "token", "password", "passwd", "pwd", "api", "auth", "cred", "cert"}
        safe: dict[str, str] = {}
        for k, v in os.environ.items():
            k_low = k.lower()
            if any(s in k_low for s in _SENSITIVE):
                safe[k] = "***REDACTED***"
            else:
                safe[k] = v
        if filter_key:
            safe = {k: v for k, v in safe.items() if filter_key.lower() in k.lower()}
        return {"env": safe, "count": len(safe)}
    except Exception as exc:
        return {"error": f"env_vars failed: {exc}"}


async def tool_git_status(path: str = ".", **kwargs) -> dict:
    """Run git status in a directory."""
    try:
        p = Path(path).expanduser().resolve()
        if not p.exists():
            return {"error": f"Path not found: {path}"}
        loop = asyncio.get_event_loop()
        res = await loop.run_in_executor(
            None,
            lambda: _run_subprocess(["git", "status", "--short", "--branch"], cwd=str(p), timeout=15),
        )
        if "error" in res:
            return res
        if res.get("exit_code", 1) != 0:
            stderr = res.get("stderr", "")
            if "not a git repository" in stderr.lower():
                return {"error": f"{p} is not a git repository"}
            return {"error": stderr or "git status failed"}
        return {"path": str(p), "status": res["stdout"] or "(clean — nothing to commit)"}
    except Exception as exc:
        return {"error": f"git_status failed: {exc}"}


async def tool_git_log(path: str = ".", count: int = 10, **kwargs) -> dict:
    """Show recent git commits."""
    try:
        p = Path(path).expanduser().resolve()
        count = int(_clamp(count, 1, 50))
        loop = asyncio.get_event_loop()
        res = await loop.run_in_executor(
            None,
            lambda: _run_subprocess(
                ["git", "log", f"--max-count={count}", "--oneline", "--no-color",
                 "--pretty=format:%h\t%s\t%cr\t%an"],
                cwd=str(p),
                timeout=15,
            ),
        )
        if "error" in res:
            return res
        if res.get("exit_code", 1) != 0:
            return {"error": res.get("stderr", "git log failed")}
        commits: list[dict] = []
        for line in res["stdout"].splitlines():
            parts = line.split("\t", 3)
            if len(parts) >= 2:
                commits.append({
                    "hash": parts[0],
                    "message": parts[1],
                    "when": parts[2] if len(parts) > 2 else "",
                    "author": parts[3] if len(parts) > 3 else "",
                })
        return {"commits": commits, "path": str(p)}
    except Exception as exc:
        return {"error": f"git_log failed: {exc}"}


async def tool_pip_install(package: str = "", **kwargs) -> dict:
    """Install a Python package via pip."""
    if not package or not package.strip():
        return {"error": "No package name provided"}
    # Basic safety check: reject obviously malicious patterns
    if re.search(r"[;&|`$()]", package):
        return {"error": f"Invalid package name: '{package}'"}
    package = package.strip()
    loop = asyncio.get_event_loop()
    res = await loop.run_in_executor(
        None,
        lambda: _run_subprocess(
            [sys.executable, "-m", "pip", "install", "--break-system-packages", package],
            timeout=120,
        ),
    )
    return res


async def tool_pip_list(**kwargs) -> dict:
    """List all installed Python packages."""
    loop = asyncio.get_event_loop()
    res = await loop.run_in_executor(
        None,
        lambda: _run_subprocess([sys.executable, "-m", "pip", "list", "--format=json"], timeout=30),
    )
    if "error" in res:
        return res
    try:
        pkgs = json.loads(res["stdout"])
        return {"packages": pkgs, "count": len(pkgs)}
    except json.JSONDecodeError:
        return {"error": "Could not parse pip list output", "raw": res.get("stdout", "")[:500]}


# ---------------------------------------------------------------------------
# Math / text helpers
# ---------------------------------------------------------------------------

# Safe math namespace for eval
_MATH_GLOBALS: dict = {"__builtins__": {}, "abs": abs, "round": round, "min": min, "max": max,
                        "pow": pow, "sum": sum}
try:
    import math as _math
    _MATH_GLOBALS.update({
        "sqrt": _math.sqrt, "floor": _math.floor, "ceil": _math.ceil,
        "log": _math.log, "log10": _math.log10, "log2": _math.log2,
        "sin": _math.sin, "cos": _math.cos, "tan": _math.tan,
        "pi": _math.pi, "e": _math.e, "inf": _math.inf,
    })
except ImportError:
    pass


async def tool_calculate(expression: str = "", **kwargs) -> dict:
    """Evaluate a safe mathematical expression."""
    if not expression or not expression.strip():
        return {"error": "No expression provided"}

    clean = expression.strip()
    # Replace ^ with ** for exponentiation
    clean = clean.replace("^", "**")
    # Allow only safe characters: digits, operators, parens, dots, spaces, math func names
    allowed_pattern = re.compile(
        r"^[\d\s\+\-\*\/\(\)\.\%\*]"
        r"|sqrt|floor|ceil|log|log10|log2|sin|cos|tan|pi|abs|round|min|max|pow|sum|inf|e"
    )
    # Remove all allowed characters and check if anything dangerous remains
    stripped = re.sub(
        r"[\d\s\+\-\*\/\(\)\.\%]|sqrt|floor|ceil|log10|log2|log|sin|cos|tan|pi|abs|round|min|max|pow|sum|inf\b|\be\b",
        "", clean
    )
    if stripped.strip():
        return {"error": f"Expression contains disallowed characters or functions: '{stripped.strip()}'"}

    try:
        result = eval(clean, _MATH_GLOBALS)  # noqa: S307 — sanitised above
        # Format nicely
        if isinstance(result, float) and result == int(result) and abs(result) < 1e15:
            result = int(result)
        elif isinstance(result, float):
            result = round(result, 10)
        return {"expression": expression, "result": result}
    except ZeroDivisionError:
        return {"error": "Division by zero"}
    except OverflowError:
        return {"error": "Result is too large to compute"}
    except Exception as exc:
        return {"error": f"Could not evaluate '{expression}': {exc}"}


async def tool_word_count(text: str = "", path: str = "", **kwargs) -> dict:
    """Count words, lines, characters, and paragraphs in text or a file."""
    try:
        if path:
            p = Path(path).expanduser().resolve()
            if not p.exists():
                return {"error": f"File not found: {path}"}
            text = p.read_text(encoding="utf-8", errors="replace")
        if not text and text != "":
            return {"error": "Provide text or path"}
        lines = text.splitlines()
        words = text.split()
        sentences = len([s for s in re.split(r"[.!?]+", text) if s.strip()])
        paragraphs = len([p for p in text.split("\n\n") if p.strip()])
        return {
            "characters": len(text),
            "characters_no_spaces": len(text.replace(" ", "").replace("\n", "")),
            "words": len(words),
            "lines": len(lines),
            "sentences": sentences,
            "paragraphs": paragraphs,
        }
    except Exception as exc:
        return {"error": f"word_count failed: {exc}"}


# ---------------------------------------------------------------------------
# Clarify fallback
# ---------------------------------------------------------------------------

async def tool_clarify(message: str = "", **kwargs) -> dict:
    """Return a clarification message to the user."""
    return {"clarify": (message.strip() or "I didn't understand that. Could you rephrase?")}


# ===========================================================================
# TOOL REGISTRY
# ===========================================================================

TOOLS: dict[str, dict] = {
    # System monitoring
    "system_health":    {"fn": tool_system_health,    "description": "CPU, RAM, disk, swap usage and top processes by CPU",     "danger": False},
    "uptime":           {"fn": tool_uptime,            "description": "System uptime and exact boot timestamp",                  "danger": False},
    "battery":          {"fn": tool_battery,           "description": "Battery percentage, charging status, and time remaining", "danger": False},
    "network_stats":    {"fn": tool_network_stats,     "description": "Network I/O counters, interfaces, active connections",   "danger": False},
    "list_processes":   {"fn": tool_list_processes,    "description": "List running processes with CPU/memory usage",           "danger": False},
    "kill_process":     {"fn": tool_kill_process,      "description": "Terminate a process by PID or name",                    "danger": True},

    # Shell execution
    "run_shell":        {"fn": tool_run_shell,         "description": "Execute an arbitrary shell command",                     "danger": True},

    # File system
    "list_directory":   {"fn": tool_list_directory,   "description": "List files in a directory with sizes and dates",         "danger": False},
    "read_file":        {"fn": tool_read_file,         "description": "Read contents of a text file",                          "danger": False},
    "write_file":       {"fn": tool_write_file,        "description": "Write or append text to a file",                        "danger": True},
    "delete_file":      {"fn": tool_delete_file,       "description": "Delete a file or directory permanently",                 "danger": True},
    "copy_file":        {"fn": tool_copy_file,         "description": "Copy a file or directory tree",                         "danger": False},
    "move_file":        {"fn": tool_move_file,         "description": "Move or rename a file or directory",                    "danger": True},
    "search_files":     {"fn": tool_search_files,      "description": "Search for files by name pattern recursively",          "danger": False},
    "create_directory": {"fn": tool_create_directory,  "description": "Create a directory and all parent directories",         "danger": False},
    "disk_usage":       {"fn": tool_disk_usage,        "description": "Show disk space usage for a given path",                "danger": False},

    # Screen / audio
    "screenshot":       {"fn": tool_screenshot,        "description": "Take a full screenshot of the display",                 "danger": False},
    "get_volume":       {"fn": tool_get_volume,        "description": "Get current system volume level (0–100)",              "danger": False},
    "set_volume":       {"fn": tool_set_volume,        "description": "Set system volume to a given level (0–100)",           "danger": False},
    "mute_volume":      {"fn": tool_mute_volume,       "description": "Mute system audio",                                     "danger": False},

    # Clipboard
    "get_clipboard":    {"fn": tool_get_clipboard,     "description": "Read the current clipboard text content",               "danger": False},
    "set_clipboard":    {"fn": tool_set_clipboard,     "description": "Copy text to the system clipboard",                    "danger": False},

    # Internet
    "weather":          {"fn": tool_weather,           "description": "Get current weather for a city (no API key needed)",    "danger": False},
    "ping":             {"fn": tool_ping,              "description": "Ping a host and report latency and packet loss",        "danger": False},
    "internet_speed":   {"fn": tool_internet_speed,    "description": "Check internet connectivity to multiple endpoints",     "danger": False},
    "open_url":         {"fn": tool_open_url,          "description": "Open a URL in the default web browser",                "danger": False},
    "open_application": {"fn": tool_open_application,  "description": "Launch an application by name",                        "danger": False},

    # Time / scheduling
    "datetime":         {"fn": tool_datetime,          "description": "Current date, time, timezone, week number, UTC",       "danger": False},
    "reminder":         {"fn": tool_reminder,          "description": "Set a timed reminder that fires via Telegram",         "danger": False},

    # Development tools
    "python_info":      {"fn": tool_python_info,       "description": "Python version, executable, and installed packages",   "danger": False},
    "env_vars":         {"fn": tool_env_vars,          "description": "List environment variables (sensitive ones redacted)",  "danger": False},
    "git_status":       {"fn": tool_git_status,        "description": "git status for a repository directory",                "danger": False},
    "git_log":          {"fn": tool_git_log,           "description": "Show recent git commits with author and time",         "danger": False},
    "pip_install":      {"fn": tool_pip_install,       "description": "Install a Python package via pip",                     "danger": True},
    "pip_list":         {"fn": tool_pip_list,          "description": "List all installed Python packages",                   "danger": False},

    # Math / text
    "calculate":        {"fn": tool_calculate,         "description": "Evaluate a mathematical expression safely",            "danger": False},
    "word_count":       {"fn": tool_word_count,        "description": "Count words, lines, characters in text or a file",    "danger": False},

    # Fallback
    "clarify":          {"fn": tool_clarify,           "description": "Ask the user to clarify their request",               "danger": False},
}


# ===========================================================================
# DISPATCHER
# ===========================================================================

async def run_tool(name: str, args: dict) -> dict:
    """
    Look up *name* in TOOLS, call its function with **args,
    record the invocation in the task log, and return the result.
    Never raises — all errors become {"error": "..."}.
    """
    if not isinstance(args, dict):
        args = {}

    entry = TOOLS.get(name)
    if entry is None:
        available = ", ".join(sorted(TOOLS.keys()))
        return {"error": f"Unknown tool: '{name}'. Available: {available}"}

    fn = entry["fn"]
    t_start = time.monotonic()
    result: dict

    try:
        result = await asyncio.wait_for(fn(**args), timeout=TOOL_TIMEOUT + 15)
    except asyncio.TimeoutError:
        result = {"error": f"Tool '{name}' timed out after {TOOL_TIMEOUT:.0f}s"}
    except TypeError as exc:
        result = {"error": f"Wrong arguments for tool '{name}': {exc}"}
    except Exception as exc:
        log.exception("Tool '%s' crashed unexpectedly", name)
        result = {"error": f"Tool '{name}' crashed: {exc}"}

    duration_ms = (time.monotonic() - t_start) * 1000
    log_task(name, args, result, duration_ms)

    if "error" in result:
        log.warning("Tool '%s' error (%.1fms): %s", name, duration_ms, result["error"])
    else:
        log.debug("Tool '%s' ok (%.1fms)", name, duration_ms)

    return result


def tool_list_for_prompt() -> str:
    """Return a plain-text list of all tools for the AI system prompt."""
    lines: list[str] = []
    for name, entry in TOOLS.items():
        danger = " ⚠️ DANGEROUS — requires confirm=true" if entry["danger"] else ""
        lines.append(f"  {name}: {entry['description']}{danger}")
    return "\n".join(lines)
