"""
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-style tools that need to push Telegram messages use the
    _send_callbacks list; 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
from pathlib import Path
from typing import Any, Callable, Coroutine

import psutil

# ---------------------------------------------------------------------------
# Telegram callback registration (for tools that need to push messages)
# ---------------------------------------------------------------------------

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


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


async def _push(message: str) -> None:
    for cb in _send_callbacks:
        try:
            await cb(message)
        except Exception:
            pass


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _safe_read(path: str, max_bytes: int = 8192) -> str:
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            return f.read(max_bytes)
    except Exception as exc:
        return f"[read error: {exc}]"


def _run_subprocess(
    cmd: list[str] | str,
    timeout: float = 20.0,
    shell: bool = False,
    cwd: str | None = None,
) -> dict:
    """Run a subprocess and return {exit_code, stdout, stderr}."""
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
            shell=shell,
            cwd=cwd,
        )
        return {
            "exit_code": result.returncode,
            "stdout": result.stdout.strip(),
            "stderr": result.stderr.strip(),
        }
    except subprocess.TimeoutExpired:
        return {"error": f"Command timed out after {timeout}s"}
    except FileNotFoundError as exc:
        return {"error": f"Command not found: {exc}"}
    except Exception as exc:
        return {"error": str(exc)}


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

# ---------------------------------------------------------------------------
# System info
# ---------------------------------------------------------------------------

async def tool_system_health(**kwargs) -> dict:
    """Return CPU, RAM, disk usage and top processes."""
    try:
        cpu = psutil.cpu_percent(interval=1)
        mem = psutil.virtual_memory()
        disk = psutil.disk_usage("/")
        swap = psutil.swap_memory()

        procs = []
        for p in sorted(psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]),
                         key=lambda x: x.info.get("cpu_percent") or 0,
                         reverse=True)[:5]:
            procs.append({
                "pid": p.info["pid"],
                "name": p.info["name"],
                "cpu_pct": round(p.info.get("cpu_percent") or 0, 1),
                "mem_pct": round(p.info.get("memory_percent") or 0, 1),
            })

        return {
            "cpu_percent": cpu,
            "ram_total_gb": round(mem.total / 1e9, 1),
            "ram_used_gb": round(mem.used / 1e9, 1),
            "ram_percent": mem.percent,
            "swap_used_gb": round(swap.used / 1e9, 1),
            "disk_total_gb": round(disk.total / 1e9, 1),
            "disk_used_gb": round(disk.used / 1e9, 1),
            "disk_percent": disk.percent,
            "top_processes": procs,
        }
    except Exception as exc:
        return {"error": str(exc)}


async def tool_uptime(**kwargs) -> dict:
    """Return system uptime."""
    try:
        boot = psutil.boot_time()
        delta = datetime.timedelta(seconds=time.time() - boot)
        days = delta.days
        hours, rem = divmod(delta.seconds, 3600)
        minutes = rem // 60
        return {
            "uptime": f"{days}d {hours}h {minutes}m",
            "boot_time": datetime.datetime.fromtimestamp(boot).strftime("%Y-%m-%d %H:%M:%S"),
        }
    except Exception as exc:
        return {"error": str(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 or battery info unavailable)"}
        secs = bat.secsleft
        if secs == psutil.POWER_TIME_UNLIMITED:
            time_left = "charging / unlimited"
        elif secs == psutil.POWER_TIME_UNKNOWN:
            time_left = "unknown"
        else:
            time_left = f"{secs // 3600}h {(secs % 3600) // 60}m"
        return {
            "percent": round(bat.percent, 1),
            "plugged_in": bat.power_plugged,
            "time_left": time_left,
        }
    except Exception as exc:
        return {"error": str(exc)}


async def tool_network_stats(**kwargs) -> dict:
    """Return network interface stats and active connections count."""
    try:
        counters = psutil.net_io_counters()
        ifaces = {}
        for name, addrs in psutil.net_if_addrs().items():
            for a in addrs:
                if a.family.name in ("AF_INET", "AF_INET6"):
                    ifaces[name] = a.address
                    break
        conns = len(psutil.net_connections())
        return {
            "bytes_sent_mb": round(counters.bytes_sent / 1e6, 2),
            "bytes_recv_mb": round(counters.bytes_recv / 1e6, 2),
            "packets_sent": counters.packets_sent,
            "packets_recv": counters.packets_recv,
            "active_connections": conns,
            "interfaces": ifaces,
        }
    except Exception as exc:
        return {"error": str(exc)}


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

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


async def tool_kill_process(pid: int = 0, name: str = "", **kwargs) -> dict:
    """Kill a process by PID or name."""
    import signal
    killed = []
    errors = []
    try:
        if pid:
            proc = psutil.Process(pid)
            proc.terminate()
            killed.append(f"PID {pid} ({proc.name()})")
        elif name:
            for p in psutil.process_iter(["pid", "name"]):
                if name.lower() in p.info["name"].lower():
                    p.terminate()
                    killed.append(f"PID {p.info['pid']} ({p.info['name']})")
        else:
            return {"error": "Provide pid or name"}
    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": str(exc)}

    if not killed:
        return {"error": f"No process matched name='{name}'"}
    return {"killed": killed}


async def tool_list_processes(**kwargs) -> dict:
    """List all running processes."""
    try:
        procs = []
        for p in psutil.process_iter(["pid", "name", "status", "cpu_percent", "memory_percent"]):
            try:
                procs.append({
                    "pid": p.info["pid"],
                    "name": p.info["name"],
                    "status": p.info["status"],
                    "cpu_pct": round(p.info.get("cpu_percent") or 0, 1),
                    "mem_pct": round(p.info.get("memory_percent") or 0, 1),
                })
            except (psutil.NoSuchProcess, psutil.AccessDenied):
                pass
        procs.sort(key=lambda x: x["cpu_pct"], reverse=True)
        return {"processes": procs[:50], "total": len(procs)}
    except Exception as exc:
        return {"error": str(exc)}


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

async def tool_list_directory(path: str = "~", **kwargs) -> dict:
    """List files in a directory."""
    try:
        p = Path(path).expanduser().resolve()
        if not p.exists():
            return {"error": f"Path does not exist: {p}"}
        if not p.is_dir():
            return {"error": f"Not a directory: {p}"}
        items = []
        for entry in sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name.lower())):
            try:
                stat = entry.stat()
                items.append({
                    "name": entry.name,
                    "type": "file" if entry.is_file() else "dir",
                    "size_bytes": stat.st_size if entry.is_file() else None,
                    "modified": datetime.datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
                })
            except PermissionError:
                items.append({"name": entry.name, "type": "unknown", "size_bytes": None, "modified": None})
        return {"path": str(p), "items": items, "count": len(items)}
    except Exception as exc:
        return {"error": str(exc)}


async def tool_read_file(path: str = "", max_lines: int = 100, **kwargs) -> dict:
    """Read a text file and return its contents."""
    if not path:
        return {"error": "No path provided"}
    try:
        p = Path(path).expanduser().resolve()
        if not p.exists():
            return {"error": f"File not found: {p}"}
        if not p.is_file():
            return {"error": f"Not a file: {p}"}
        size = p.stat().st_size
        if size > 1_000_000:
            return {"error": f"File too large ({size // 1024} KB). Use a more specific read."}
        lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
        truncated = len(lines) > max_lines
        return {
            "path": str(p),
            "lines": len(lines),
            "content": "\n".join(lines[:max_lines]),
            "truncated": truncated,
            "size_bytes": size,
        }
    except Exception as exc:
        return {"error": str(exc)}


async def tool_write_file(path: str = "", content: str = "", append: bool = False, **kwargs) -> dict:
    """Write or append text to a file."""
    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="utf-8") as f:
            f.write(content)
        return {
            "path": str(p),
            "bytes_written": len(content.encode()),
            "mode": "append" if append else "overwrite",
        }
    except Exception as exc:
        return {"error": str(exc)}


async def tool_delete_file(path: str = "", **kwargs) -> dict:
    """Delete a file or empty directory."""
    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: {p}"}
        if p.is_file():
            p.unlink()
            return {"deleted": str(p), "type": "file"}
        elif p.is_dir():
            shutil.rmtree(p)
            return {"deleted": str(p), "type": "directory"}
        else:
            return {"error": f"Unknown file type: {p}"}
    except Exception as exc:
        return {"error": str(exc)}


async def tool_copy_file(src: str = "", dst: str = "", **kwargs) -> dict:
    """Copy a file or directory."""
    if not src or not dst:
        return {"error": "Provide 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: {s}"}
        d.parent.mkdir(parents=True, exist_ok=True)
        if s.is_file():
            shutil.copy2(s, d)
        else:
            shutil.copytree(s, d)
        return {"copied": str(s), "to": str(d)}
    except Exception as exc:
        return {"error": str(exc)}


async def tool_move_file(src: str = "", dst: str = "", **kwargs) -> dict:
    """Move / rename a file or directory."""
    if not src or not dst:
        return {"error": "Provide 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: {s}"}
        d.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(str(s), str(d))
        return {"moved": str(s), "to": str(d)}
    except Exception as exc:
        return {"error": str(exc)}


async def tool_search_files(
    query: str = "",
    path: str = "~",
    extension: str = "",
    **kwargs,
) -> dict:
    """Search for files by name pattern under a directory."""
    if not query and not extension:
        return {"error": "Provide query or extension"}
    try:
        root = Path(path).expanduser().resolve()
        pattern = f"*{query}*" if query else f"*{extension}"
        matches = []
        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,
                })
            except PermissionError:
                pass
            if len(matches) >= 50:
                break
        return {"matches": matches, "count": len(matches), "root": str(root)}
    except Exception as exc:
        return {"error": str(exc)}


async def tool_create_directory(path: str = "", **kwargs) -> dict:
    """Create a directory (and parents)."""
    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 Exception as exc:
        return {"error": str(exc)}


async def tool_disk_usage(path: str = "/", **kwargs) -> dict:
    """Return disk usage for a path."""
    try:
        p = Path(path).expanduser().resolve()
        usage = shutil.disk_usage(p)
        return {
            "path": str(p),
            "total_gb": round(usage.total / 1e9, 2),
            "used_gb": round(usage.used / 1e9, 2),
            "free_gb": round(usage.free / 1e9, 2),
            "used_percent": round(usage.used / usage.total * 100, 1),
        }
    except Exception as exc:
        return {"error": str(exc)}


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

async def tool_screenshot(**kwargs) -> dict:
    """Take a screenshot and save to a temp file. Returns file path."""
    try:
        out = Path(tempfile.mktemp(suffix=".png", prefix="doit_screenshot_"))
        sys_name = platform.system()

        if sys_name == "Darwin":
            res = _run_subprocess(["screencapture", "-x", str(out)])
        elif sys_name == "Windows":
            # Use PowerShell
            ps = (
                "Add-Type -AssemblyName System.Windows.Forms;"
                "[System.Windows.Forms.Screen]::PrimaryScreen | "
                "ForEach-Object { "
                "$bmp = New-Object System.Drawing.Bitmap($_.Bounds.Width, $_.Bounds.Height);"
                "$g = [System.Drawing.Graphics]::FromImage($bmp);"
                "$g.CopyFromScreen($_.Bounds.Location, [System.Drawing.Point]::Empty, $_.Bounds.Size);"
                f"$bmp.Save('{out}') }}"
            )
            res = _run_subprocess(["powershell", "-Command", ps])
        else:
            # Linux — try scrot, then gnome-screenshot, then import (ImageMagick)
            for cmd in [
                ["scrot", str(out)],
                ["gnome-screenshot", "-f", str(out)],
                ["import", "-window", "root", str(out)],
            ]:
                if shutil.which(cmd[0]):
                    res = _run_subprocess(cmd)
                    break
            else:
                return {"error": "No screenshot tool found. Install scrot: sudo apt install scrot"}

        if "error" in res:
            return res
        if not out.exists():
            return {"error": "Screenshot file was not created"}
        size = out.stat().st_size
        return {
            "path": str(out),
            "size_bytes": size,
            "size_kb": round(size / 1024, 1),
        }
    except Exception as exc:
        return {"error": str(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
            return {"volume": int(res["stdout"].strip())}
        elif sys_name == "Windows":
            # Uses nircmd or PowerShell
            ps = "(Get-AudioDevice -Playback).Volume"
            res = _run_subprocess(["powershell", "-Command", ps])
            if "error" in res:
                return res
            return {"volume": int(float(res["stdout"].strip()))}
        else:
            # Linux — amixer
            res = _run_subprocess(["amixer", "get", "Master"])
            if "error" in res:
                # Try pactl
                res2 = _run_subprocess(["pactl", "get-sink-volume", "@DEFAULT_SINK@"])
                if "error" in res2:
                    return {"error": "Could not get volume. Install alsa-utils or pulseaudio-utils."}
                m = re.search(r"(\d+)%", res2["stdout"])
                if m:
                    return {"volume": int(m.group(1))}
                return {"error": "Could not parse volume"}
            m = re.search(r"\[(\d+)%\]", res["stdout"])
            if m:
                return {"volume": int(m.group(1))}
            return {"error": "Could not parse amixer output"}
    except Exception as exc:
        return {"error": str(exc)}


async def tool_set_volume(level: int = 50, **kwargs) -> dict:
    """Set the system volume (0-100)."""
    level = max(0, min(100, int(level)))
    try:
        sys_name = platform.system()
        if sys_name == "Darwin":
            res = _run_subprocess(["osascript", "-e", f"set volume output volume {level}"])
        elif sys_name == "Windows":
            ps = f"(New-Object -ComObject WScript.Shell).SendKeys([char]173); Add-Type -TypeDefinition '...'"
            # Simpler approach via nircmd if available
            if shutil.which("nircmd"):
                vol_nircmd = int(level * 655.35)
                res = _run_subprocess(["nircmd", "setsysvolume", str(vol_nircmd)])
            else:
                ps = f"$vol = {level}/100; $obj = new-object -com wscript.shell; "
                res = _run_subprocess(["powershell", "-Command", f"[audio]::Volume = {level}/100"])
        else:
            if shutil.which("amixer"):
                res = _run_subprocess(["amixer", "-D", "pulse", "sset", "Master", f"{level}%"])
            elif shutil.which("pactl"):
                res = _run_subprocess(["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{level}%"])
            else:
                return {"error": "No volume control found. Install alsa-utils or pulseaudio."}

        if "error" in res:
            return res
        return {"volume_set": level}
    except Exception as exc:
        return {"error": str(exc)}


async def tool_mute_volume(**kwargs) -> dict:
    """Mute the 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", "-Command",
                                   "(New-Object -ComObject WScript.Shell).SendKeys([char]173)"])
        else:
            if shutil.which("amixer"):
                res = _run_subprocess(["amixer", "-D", "pulse", "sset", "Master", "mute"])
            else:
                res = _run_subprocess(["pactl", "set-sink-mute", "@DEFAULT_SINK@", "1"])
        if "error" in res:
            return res
        return {"muted": True}
    except Exception as exc:
        return {"error": str(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", "-Command", "Get-Clipboard"])
        else:
            if shutil.which("xclip"):
                res = _run_subprocess(["xclip", "-selection", "clipboard", "-o"])
            elif shutil.which("xsel"):
                res = _run_subprocess(["xsel", "--clipboard", "--output"])
            elif shutil.which("wl-paste"):
                res = _run_subprocess(["wl-paste"])
            else:
                return {"error": "No clipboard tool found. Install xclip: sudo apt install xclip"}
        if "error" in res:
            return res
        return {"clipboard": res["stdout"]}
    except Exception as exc:
        return {"error": str(exc)}


async def tool_set_clipboard(text: str = "", **kwargs) -> dict:
    """Copy text to the clipboard."""
    if not text:
        return {"error": "No text provided"}
    try:
        sys_name = platform.system()
        if sys_name == "Darwin":
            proc = subprocess.run(["pbcopy"], input=text.encode(), capture_output=True)
            if proc.returncode != 0:
                return {"error": proc.stderr.decode()}
        elif sys_name == "Windows":
            proc = subprocess.run(
                ["powershell", "-Command", f"Set-Clipboard -Value '{text}'"],
                capture_output=True,
            )
        else:
            if shutil.which("xclip"):
                proc = subprocess.run(
                    ["xclip", "-selection", "clipboard"],
                    input=text.encode(),
                    capture_output=True,
                )
            elif shutil.which("xsel"):
                proc = subprocess.run(
                    ["xsel", "--clipboard", "--input"],
                    input=text.encode(),
                    capture_output=True,
                )
            elif shutil.which("wl-copy"):
                proc = subprocess.run(["wl-copy", text], capture_output=True)
            else:
                return {"error": "No clipboard tool. Install xclip: sudo apt install xclip"}
        return {"clipboard_set": text[:100] + ("..." if len(text) > 100 else "")}
    except Exception as exc:
        return {"error": str(exc)}


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

async def tool_weather(city: str = "", lat: float = 0.0, lon: float = 0.0, **kwargs) -> dict:
    """
    Fetch current weather.
    Geocodes the city name via open-meteo geocoding API (free, no key).
    Falls back to lat/lon if provided.
    """
    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, "count": 1, "language": "en", "format": "json"})
            )
            def _geo():
                with urllib.request.urlopen(geo_url, timeout=10) as r:
                    return json.loads(r.read())
            geo_data = await loop.run_in_executor(None, _geo)
            results = geo_data.get("results")
            if not results:
                return {"error": f"City not found: {city}"}
            lat = results[0]["latitude"]
            lon = results[0]["longitude"]
            city = results[0].get("name", city)

        if not (lat and lon):
            return {"error": "Provide city name or lat/lon coordinates"}

        wx_url = (
            "https://api.open-meteo.com/v1/forecast?"
            + urllib.parse.urlencode({
                "latitude": lat,
                "longitude": lon,
                "current_weather": True,
                "hourly": "relativehumidity_2m",
                "forecast_days": 1,
            })
        )
        def _wx():
            with urllib.request.urlopen(wx_url, timeout=10) as r:
                return json.loads(r.read())
        wx = await loop.run_in_executor(None, _wx)

        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",
            80: "Light showers", 81: "Showers", 82: "Heavy showers",
            95: "Thunderstorm", 96: "Thunderstorm + hail",
        }
        humidity = None
        if "hourly" in wx and "relativehumidity_2m" in wx["hourly"]:
            vals = wx["hourly"]["relativehumidity_2m"]
            if vals:
                humidity = vals[0]

        return {
            "city": city,
            "temperature_c": cw.get("temperature"),
            "windspeed_kmh": cw.get("windspeed"),
            "wind_direction_deg": cw.get("winddirection"),
            "conditions": wmo_codes.get(cw.get("weathercode", -1), "Unknown"),
            "humidity_percent": humidity,
            "is_day": bool(cw.get("is_day", 1)),
            "lat": lat,
            "lon": lon,
        }
    except Exception as exc:
        return {"error": str(exc)}


# ---------------------------------------------------------------------------
# Reminder (async timer, pushes Telegram message)
# ---------------------------------------------------------------------------

async def tool_reminder(message: str = "", delay_seconds: int = 0,
                         delay_minutes: int = 0, **kwargs) -> dict:
    """Set a reminder that fires after delay_seconds or delay_minutes."""
    if not message:
        return {"error": "No reminder message provided"}
    total = delay_seconds + delay_minutes * 60
    if total <= 0:
        return {"error": "Provide delay_seconds or delay_minutes > 0"}

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

    asyncio.ensure_future(_fire())
    eta = datetime.datetime.now() + datetime.timedelta(seconds=total)
    return {
        "reminder_set": message,
        "fires_at": eta.strftime("%H:%M:%S"),
        "delay_seconds": total,
    }


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

async def tool_open_url(url: str = "", **kwargs) -> dict:
    """Open a URL in the default browser."""
    if not url:
        return {"error": "No URL provided"}
    import webbrowser
    try:
        loop = asyncio.get_event_loop()
        await loop.run_in_executor(None, lambda: webbrowser.open(url))
        return {"opened": url}
    except Exception as exc:
        return {"error": str(exc)}


async def tool_open_application(name: str = "", **kwargs) -> dict:
    """Launch an application by name."""
    if not name:
        return {"error": "No application name provided"}
    try:
        sys_name = platform.system()
        if sys_name == "Darwin":
            res = _run_subprocess(["open", "-a", name])
        elif sys_name == "Windows":
            res = _run_subprocess(["start", name], shell=True)
        else:
            # Try direct command then xdg-open
            if shutil.which(name.lower()):
                subprocess.Popen([name.lower()], start_new_session=True)
                res = {"exit_code": 0, "stdout": "", "stderr": ""}
            elif shutil.which("xdg-open"):
                subprocess.Popen(["xdg-open", name], start_new_session=True)
                res = {"exit_code": 0, "stdout": "", "stderr": ""}
            else:
                res = {"error": f"Application '{name}' not found"}
        if "error" in res:
            return res
        return {"launched": name}
    except Exception as exc:
        return {"error": str(exc)}


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

async def tool_ping(host: str = "8.8.8.8", count: int = 4, **kwargs) -> dict:
    """Ping a host and return latency stats."""
    if not host:
        return {"error": "No host provided"}
    count = min(max(1, int(count)), 10)
    try:
        sys_name = platform.system()
        if sys_name == "Windows":
            cmd = ["ping", "-n", str(count), host]
        else:
            cmd = ["ping", "-c", str(count), host]
        loop = asyncio.get_event_loop()
        res = await loop.run_in_executor(None, lambda: _run_subprocess(cmd, timeout=30))
        if "error" in res:
            return res
        # Parse average latency
        avg = None
        for pattern in [r"avg[^=]*=\s*[\d.]+/([\d.]+)", r"Average\s*=\s*([\d.]+)ms"]:
            m = re.search(pattern, res["stdout"], re.IGNORECASE)
            if m:
                avg = float(m.group(1))
                break
        return {
            "host": host,
            "packets": count,
            "avg_ms": avg,
            "output": res["stdout"][:500],
        }
    except Exception as exc:
        return {"error": str(exc)}


async def tool_internet_speed(**kwargs) -> dict:
    """Check internet connectivity and rough latency to major endpoints."""
    results = {}
    loop = asyncio.get_event_loop()
    for name, url in [
        ("google", "https://www.google.com"),
        ("cloudflare", "https://1.1.1.1"),
    ]:
        start = time.monotonic()
        try:
            def _check(u=url):
                req = urllib.request.Request(u, method="HEAD")
                with urllib.request.urlopen(req, timeout=5):
                    pass
            await loop.run_in_executor(None, _check)
            results[name] = {"reachable": True, "latency_ms": round((time.monotonic() - start) * 1000, 1)}
        except Exception as exc:
            results[name] = {"reachable": False, "error": str(exc)}
    return {"connectivity": results}


# ---------------------------------------------------------------------------
# Environment / Python info
# ---------------------------------------------------------------------------

async def tool_python_info(**kwargs) -> dict:
    """Return Python version, executable, and installed packages count."""
    try:
        res = _run_subprocess([sys.executable, "-m", "pip", "list", "--format=json"])
        pkgs = []
        if "error" not in res and res["stdout"]:
            pkgs = json.loads(res["stdout"])
        return {
            "python_version": sys.version,
            "executable": sys.executable,
            "platform": sys.platform,
            "packages_installed": len(pkgs),
            "packages_sample": [p["name"] for p in pkgs[:10]],
        }
    except Exception as exc:
        return {"error": str(exc)}


async def tool_env_vars(filter_key: str = "", **kwargs) -> dict:
    """Return environment variables (optionally filtered)."""
    try:
        env = dict(os.environ)
        # Remove sensitive values
        safe = {}
        sensitive = {"key", "secret", "token", "password", "passwd", "pwd", "api"}
        for k, v in env.items():
            if any(s in k.lower() 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": str(exc)}


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

async def tool_datetime(**kwargs) -> dict:
    """Return current date and time info."""
    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],
        "week_number": now.isocalendar()[1],
        "day_of_year": now.timetuple().tm_yday,
    }


# ---------------------------------------------------------------------------
# Text / calculation helpers (pure, no IO)
# ---------------------------------------------------------------------------

async def tool_calculate(expression: str = "", **kwargs) -> dict:
    """Evaluate a safe mathematical expression."""
    if not expression:
        return {"error": "No expression provided"}
    # Only allow safe chars: digits, operators, parentheses, dots, spaces
    clean = expression.strip()
    allowed = re.compile(r"^[\d\s\+\-\*\/\(\)\.\^%]+$")
    if not allowed.match(clean):
        # Try to handle ^ as **
        clean = clean.replace("^", "**")
        if not re.match(r"^[\d\s\+\-\*\/\(\)\.\*%]+$", clean):
            return {"error": f"Unsafe expression — only basic math allowed: {expression}"}
    try:
        result = eval(clean, {"__builtins__": {}})  # noqa: S307  # safe — we sanitised
        return {"expression": expression, "result": result}
    except ZeroDivisionError:
        return {"error": "Division by zero"}
    except Exception as exc:
        return {"error": f"Could not evaluate: {exc}"}


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


# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------

async def tool_git_status(path: str = ".", **kwargs) -> dict:
    """Run git status in a directory."""
    loop = asyncio.get_event_loop()
    res = await loop.run_in_executor(
        None,
        lambda: _run_subprocess(["git", "status", "--short"], cwd=str(Path(path).expanduser())),
    )
    if "error" in res:
        return res
    return {"path": str(Path(path).expanduser()), "status": res["stdout"] or "(clean)"}


async def tool_git_log(path: str = ".", count: int = 10, **kwargs) -> dict:
    """Show recent git commits."""
    loop = asyncio.get_event_loop()
    res = await loop.run_in_executor(
        None,
        lambda: _run_subprocess(
            ["git", "log", f"--max-count={count}", "--oneline", "--no-color"],
            cwd=str(Path(path).expanduser()),
        ),
    )
    if "error" in res:
        return res
    commits = []
    for line in res["stdout"].splitlines():
        parts = line.split(" ", 1)
        if len(parts) == 2:
            commits.append({"hash": parts[0], "message": parts[1]})
    return {"commits": commits, "path": str(Path(path).expanduser())}


# ---------------------------------------------------------------------------
# Package management helpers
# ---------------------------------------------------------------------------

async def tool_pip_install(package: str = "", **kwargs) -> dict:
    """Install a Python package with pip."""
    if not package:
        return {"error": "No package name provided"}
    loop = asyncio.get_event_loop()
    res = await loop.run_in_executor(
        None,
        lambda: _run_subprocess(
            [sys.executable, "-m", "pip", "install", package],
            timeout=120,
        ),
    )
    return res


async def tool_pip_list(**kwargs) -> dict:
    """List 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"]),
    )
    if "error" in res:
        return res
    try:
        pkgs = json.loads(res["stdout"])
        return {"packages": pkgs, "count": len(pkgs)}
    except Exception:
        return {"output": res["stdout"]}


# ---------------------------------------------------------------------------
# Clarify / unknown intent
# ---------------------------------------------------------------------------

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


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

TOOLS: dict[str, dict] = {
    # System
    "system_health":    {"fn": tool_system_health,    "description": "CPU, RAM, disk usage and top processes",             "danger": False},
    "uptime":           {"fn": tool_uptime,            "description": "System uptime and boot time",                        "danger": False},
    "battery":          {"fn": tool_battery,           "description": "Battery percentage and charging status",             "danger": False},
    "network_stats":    {"fn": tool_network_stats,     "description": "Network I/O counters and active connections",        "danger": False},
    "list_processes":   {"fn": tool_list_processes,    "description": "List all 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
    "run_shell":        {"fn": tool_run_shell,         "description": "Execute a shell command and return output",          "danger": True},

    # File system
    "list_directory":   {"fn": tool_list_directory,    "description": "List files in a directory",                         "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 to a new location",        "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",                  "danger": False},
    "create_directory": {"fn": tool_create_directory,  "description": "Create a new directory",                            "danger": False},
    "disk_usage":       {"fn": tool_disk_usage,        "description": "Show disk space usage for a path",                  "danger": False},

    # Screenshot / audio
    "screenshot":       {"fn": tool_screenshot,        "description": "Take a screenshot of the current screen",           "danger": False},
    "get_volume":       {"fn": tool_get_volume,        "description": "Get current system volume level",                   "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",                   "danger": False},
    "set_clipboard":    {"fn": tool_set_clipboard,     "description": "Copy text to the 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 measure latency",                   "danger": False},
    "internet_speed":   {"fn": tool_internet_speed,    "description": "Check internet connectivity and latency",           "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": "Return current date, time, and timezone info",      "danger": False},
    "reminder":         {"fn": tool_reminder,          "description": "Set a reminder that fires after N seconds/minutes", "danger": False},

    # Development
    "python_info":      {"fn": tool_python_info,       "description": "Python version 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",                           "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 and call its function with **args.
    Never raises — all errors are returned as {"error": "..."}.
    """
    entry = TOOLS.get(name)
    if entry is None:
        available = ", ".join(TOOLS.keys())
        return {"error": f"Unknown tool: '{name}'. Available: {available}"}
    fn = entry["fn"]
    try:
        result = await fn(**args)
        return result
    except TypeError as exc:
        return {"error": f"Wrong arguments for tool '{name}': {exc}"}
    except Exception as exc:
        return {"error": f"Tool '{name}' crashed unexpectedly: {exc}"}


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