"""
tools.py — BySalim v2 — Every command fully functional, real implementations.

Design principles:
  • Zero mock code. Every tool actually works or explains exactly why it can't.
  • Multi-strategy fallback: prefer best available tool, degrade gracefully.
  • All optional Python libs auto-installed on first use via pip.
  • Never returns a vague error like "not supported" — always tries all options.
"""
from __future__ import annotations

import asyncio
import ctypes
import datetime
import json
import os
import platform
import re
import shutil
import signal
import struct
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
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")
SYS = platform.system()  # "Darwin" | "Linux" | "Windows"


# ─────────────────────────────────────────────────────────────────────────────
# Telegram push callbacks
# ─────────────────────────────────────────────────────────────────────────────

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


def register_send_callback(fn: Callable[[str], Coroutine]) -> None:
    _send_callbacks.append(fn)


def register_send_file_callback(fn: Callable[[str, str, str], Coroutine]) -> None:
    _send_file_callbacks.append(fn)


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


async def _push_file(path: str, caption: str = "", file_type: str = "document") -> None:
    for cb in _send_file_callbacks:
        try:
            await cb(path, caption, file_type)
        except Exception as exc:
            log.warning("send_file callback failed: %s", exc)


# ─────────────────────────────────────────────────────────────────────────────
# Auto-install helper
# ─────────────────────────────────────────────────────────────────────────────

_installed: set[str] = set()


def _pip(package: str, import_as: str | None = None) -> Any:
    """Import package, auto-installing via pip if missing. Returns module or None."""
    name = import_as or package.split("[")[0].split(">=")[0].strip()
    try:
        import importlib
        return importlib.import_module(name)
    except ImportError:
        if package in _installed:
            return None  # already tried and failed
        log.info("Auto-installing %s …", package)
        try:
            subprocess.run(
                [sys.executable, "-m", "pip", "install", "--quiet",
                 "--break-system-packages", package],
                check=True, capture_output=True, timeout=90,
            )
            _installed.add(package)
            import importlib
            return importlib.import_module(name)
        except Exception as exc:
            log.warning("pip install %s failed: %s", package, exc)
            _installed.add(package)
            return None


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

def _run(cmd: list | str, timeout: float = TOOL_TIMEOUT,
         shell: bool = False, cwd: str | None = None,
         input_bytes: bytes | None = None) -> dict:
    try:
        r = subprocess.run(
            cmd, capture_output=True, text=True,
            timeout=timeout, shell=shell, cwd=cwd,
            input=input_bytes.decode() if input_bytes else None,
        )
        return {"exit_code": r.returncode,
                "stdout": r.stdout.strip(),
                "stderr": r.stderr.strip()}
    except subprocess.TimeoutExpired:
        return {"error": f"timed out after {timeout:.0f}s"}
    except FileNotFoundError as e:
        return {"error": f"not found: {e}"}
    except Exception as e:
        return {"error": str(e)}


def _clamp(v: Any, lo: float, hi: float) -> float:
    return max(lo, min(hi, float(v)))


def _sz(b: int) -> str:
    for u in ("B", "KB", "MB", "GB", "TB"):
        if b < 1024.0:
            return f"{b:.1f} {u}"
        b /= 1024.0
    return f"{b:.1f} PB"


def _resolve(path: str) -> Path:
    return Path(path).expanduser().resolve()


def _media_type(path: str) -> str:
    """Detect media type: image/video/audio/document. Uses magic bytes + extension."""
    ext = Path(path).suffix.lower()
    IMG  = {".jpg",".jpeg",".png",".gif",".bmp",".webp",".tiff",".tif",".heic",".heif",".ico",".avif",".psd"}
    VID  = {".mp4",".mkv",".avi",".mov",".wmv",".flv",".webm",".m4v",".3gp",".ts",".ogv"}
    AUD  = {".mp3",".flac",".wav",".aac",".ogg",".m4a",".wma",".opus",".aiff",".alac"}
    if ext in IMG: return "image"
    if ext in VID: return "video"
    if ext in AUD: return "audio"
    # Magic bytes
    try:
        with open(path, "rb") as f:
            h = f.read(16)
        sigs = [
            (b"\xff\xd8", "image"), (b"\x89PNG", "image"),
            (b"GIF8", "image"), (b"BM", "image"),
            (b"ID3", "audio"), (b"fLaC", "audio"), (b"OggS", "audio"),
            (b"\x1a\x45\xdf\xa3", "video"),
        ]
        for sig, t in sigs:
            if h[:len(sig)] == sig:
                return t
        if h[:4] == b"RIFF":
            return "audio" if h[8:12] == b"WAVE" else "video"
        if h[4:8] == b"ftyp":
            return "video"
    except Exception:
        pass
    return "document"


async def _run_async(fn, *args, **kwargs):
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))


# ═════════════════════════════════════════════════════════════════════════════
# SYSTEM TOOLS
# ═════════════════════════════════════════════════════════════════════════════

async def tool_system_health(**_) -> dict:
    try:
        loop = asyncio.get_event_loop()
        cpu = await loop.run_in_executor(None, psutil.cpu_percent, 1)
        mem  = psutil.virtual_memory()
        disk = psutil.disk_usage("/")
        swap = psutil.swap_memory()
        procs = []
        for p in psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]):
            try:
                i = p.info
                procs.append({"pid": i["pid"], "name": i.get("name") or "?",
                               "cpu_pct": round(i.get("cpu_percent") or 0, 1),
                               "mem_pct": round(i.get("memory_percent") or 0, 1)})
            except Exception:
                pass
        return {
            "cpu_percent": round(cpu, 1), "cpu_count": psutil.cpu_count(logical=True),
            "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": sorted(procs, key=lambda x: x["cpu_pct"], reverse=True)[:5],
        }
    except Exception as e:
        return {"error": f"system_health: {e}"}


async def tool_uptime(**_) -> dict:
    try:
        boot_ts = psutil.boot_time()
        d = datetime.timedelta(seconds=time.time() - boot_ts)
        h, r = divmod(d.seconds, 3600); m, s = divmod(r, 60)
        return {"uptime": f"{d.days}d {h}h {m}m {s}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 e:
        return {"error": f"uptime: {e}"}


async def tool_battery(**_) -> dict:
    try:
        bat = psutil.sensors_battery()
        if bat is None:
            return {"error": "No battery sensor found (desktop machine?)"}
        secs = bat.secsleft
        if secs == psutil.POWER_TIME_UNLIMITED:
            tl = "unlimited (AC)"
        elif secs == psutil.POWER_TIME_UNKNOWN or secs < 0:
            tl = "unknown"
        else:
            h, m = divmod(secs // 60, 60); tl = f"{h}h {m}m"
        return {"percent": round(bat.percent, 1),
                "plugged_in": bool(bat.power_plugged), "time_left": tl}
    except Exception as e:
        return {"error": f"battery: {e}"}


async def tool_network_stats(**_) -> dict:
    try:
        c = psutil.net_io_counters()
        ifaces: dict[str, str] = {}
        for name, addrs in psutil.net_if_addrs().items():
            for a in addrs:
                fname = getattr(a.family, "name", str(a.family))
                if fname in ("AF_INET", "AF_INET6") and not a.address.startswith("127."):
                    ifaces[name] = a.address; break
        try:
            conn_count = len(psutil.net_connections())
        except Exception:
            conn_count = -1
        return {"bytes_sent_mb": round(c.bytes_sent / 1e6, 3),
                "bytes_recv_mb": round(c.bytes_recv / 1e6, 3),
                "packets_sent": c.packets_sent, "packets_recv": c.packets_recv,
                "active_connections": conn_count, "interfaces": ifaces}
    except Exception as e:
        return {"error": f"network_stats: {e}"}


async def tool_run_shell(command: str = "", timeout: float = 30.0, **_) -> dict:
    if not command.strip():
        return {"error": "No command provided"}
    timeout = _clamp(timeout, 1.0, 120.0)
    return await _run_async(_run, command, timeout, True)


async def tool_kill_process(pid: int = 0, name: str = "", signal_num: int = 15, **_) -> dict:
    killed = []
    try:
        if pid > 0:
            proc = psutil.Process(int(pid))
            pname = proc.name()
            proc.send_signal(signal_num)
            killed.append(f"PID {pid} ({pname})")
        elif name:
            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']})")
                except Exception:
                    pass
            if not killed:
                return {"error": f"No process matched '{name}'"}
        else:
            return {"error": "Provide pid or name"}
    except psutil.NoSuchProcess:
        return {"error": f"Process not found"}
    except psutil.AccessDenied:
        return {"error": "Permission denied — run as root/admin"}
    except Exception as e:
        return {"error": f"kill_process: {e}"}
    return {"killed": killed}


async def tool_list_processes(sort_by: str = "cpu", limit: int = 50, **_) -> dict:
    try:
        procs = []
        for p in psutil.process_iter(["pid","name","status","cpu_percent","memory_percent","username"]):
            try:
                i = p.info
                procs.append({"pid": i["pid"], "name": i.get("name") or "?",
                               "status": i.get("status","?"),
                               "cpu_pct": round(i.get("cpu_percent") or 0, 1),
                               "mem_pct": round(i.get("memory_percent") or 0, 2),
                               "user": i.get("username","?")})
            except Exception:
                pass
        key = "mem_pct" if sort_by.startswith("mem") else "cpu_pct"
        procs.sort(key=lambda x: x[key], reverse=True)
        return {"processes": procs[:int(_clamp(limit,1,200))], "total": len(procs)}
    except Exception as e:
        return {"error": f"list_processes: {e}"}


# ═════════════════════════════════════════════════════════════════════════════
# FILE SYSTEM CRUD
# ═════════════════════════════════════════════════════════════════════════════

async def tool_list_directory(path: str = "~", show_hidden: bool = False, **_) -> dict:
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        if not p.is_dir():  return {"error": f"Not a directory: {p}"}
        items = []
        for e in sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name.lower())):
            if not show_hidden and e.name.startswith("."): continue
            try:
                st = e.stat()
                items.append({"name": e.name,
                               "type": "file" if e.is_file() else "dir",
                               "media_type": _media_type(str(e)) if e.is_file() else None,
                               "size_bytes": st.st_size if e.is_file() else None,
                               "size_human": _sz(st.st_size) if e.is_file() else None,
                               "modified": datetime.datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M")})
            except Exception:
                items.append({"name": e.name, "type": "unknown"})
        return {"path": str(p), "items": items, "count": len(items)}
    except Exception as e:
        return {"error": f"list_directory: {e}"}


async def tool_read_file(path: str = "", max_lines: int = 150, encoding: str = "utf-8", **_) -> dict:
    if not path: return {"error": "No path"}
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        if not p.is_file():return {"error": f"Not a file: {p}"}
        size = p.stat().st_size
        if size > 2_000_000: return {"error": f"Too large ({_sz(size)})"}
        text = p.read_text(encoding=encoding, errors="replace")
        lines = text.splitlines()
        ml = int(_clamp(max_lines, 1, 1000))
        return {"path": str(p), "lines": len(lines),
                "content": "\n".join(lines[:ml]),
                "truncated": len(lines) > ml, "size_human": _sz(size)}
    except Exception as e:
        return {"error": f"read_file: {e}"}


async def tool_write_file(path: str = "", content: str = "", append: bool = False, encoding: str = "utf-8", **_) -> dict:
    if not path: return {"error": "No path"}
    try:
        p = _resolve(path)
        p.parent.mkdir(parents=True, exist_ok=True)
        with open(p, "a" if append else "w", encoding=encoding) as f:
            f.write(content)
        return {"path": str(p), "bytes_written": len(content.encode(encoding)),
                "mode": "append" if append else "overwrite"}
    except Exception as e:
        return {"error": f"write_file: {e}"}


async def tool_delete_file(path: str = "", force: bool = False, trash: bool = True, **_) -> dict:
    if not path: return {"error": "No path"}
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        if trash and not force:
            s2t = _pip("send2trash")
            if s2t:
                try:
                    s2t.send2trash(str(p))
                    return {"deleted": str(p), "type": "file" if p.is_file() else "directory", "method": "trash"}
                except Exception as ex:
                    log.warning("send2trash: %s", ex)
        if p.is_file() or p.is_symlink():
            p.unlink(missing_ok=True)
            return {"deleted": str(p), "type": "file", "method": "permanent"}
        elif p.is_dir():
            shutil.rmtree(p)
            return {"deleted": str(p), "type": "directory", "method": "permanent"}
        return {"error": f"Unknown type: {p}"}
    except Exception as e:
        return {"error": f"delete_file: {e}"}


async def tool_copy_file(src: str = "", dst: str = "", **_) -> dict:
    if not src or not dst: return {"error": "Provide src and dst"}
    try:
        s, d = _resolve(src), _resolve(dst)
        if not s.exists(): return {"error": f"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)
        return {"copied": str(s), "to": str(d)}
    except Exception as e:
        return {"error": f"copy_file: {e}"}


async def tool_move_file(src: str = "", dst: str = "", **_) -> dict:
    if not src or not dst: return {"error": "Provide src and dst"}
    try:
        s, d = _resolve(src), _resolve(dst)
        if not s.exists(): return {"error": f"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 Exception as e:
        return {"error": f"move_file: {e}"}


async def tool_rename_file(path: str = "", new_name: str = "", **_) -> dict:
    if not path or not new_name: return {"error": "Provide path and new_name"}
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        dest = p.parent / new_name
        if dest.exists(): return {"error": f"Destination exists: {dest}"}
        p.rename(dest)
        return {"renamed": str(p), "to": str(dest)}
    except Exception as e:
        return {"error": f"rename_file: {e}"}


async def tool_search_files(query: str = "", path: str = "~", extension: str = "", max_results: int = 50, **_) -> dict:
    if not query and not extension: return {"error": "Provide query or extension"}
    try:
        root = _resolve(path)
        if not root.exists(): return {"error": f"Not found: {path}"}
        if extension and not extension.startswith("."): extension = f".{extension}"
        pattern = f"*{query}*" if query else "*"
        matches = []
        limit = int(_clamp(max_results, 1, 500))
        for f in root.rglob(pattern):
            if extension and f.suffix.lower() != extension.lower(): continue
            try:
                st = f.stat()
                matches.append({"path": str(f),
                                 "type": "file" if f.is_file() else "dir",
                                 "media_type": _media_type(str(f)) if f.is_file() else None,
                                 "size_human": _sz(st.st_size) if f.is_file() else None,
                                 "modified": datetime.datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M")})
            except Exception:
                pass
            if len(matches) >= limit: break
        return {"matches": matches, "count": len(matches), "root": str(root)}
    except Exception as e:
        return {"error": f"search_files: {e}"}


async def tool_create_directory(path: str = "", **_) -> dict:
    if not path: return {"error": "No path"}
    try:
        p = _resolve(path)
        p.mkdir(parents=True, exist_ok=True)
        return {"created": str(p)}
    except Exception as e:
        return {"error": f"create_directory: {e}"}


async def tool_disk_usage(path: str = "/", **_) -> dict:
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        u = shutil.disk_usage(p)
        pct = round(u.used / u.total * 100, 1) if u.total else 0
        return {"path": str(p), "total_gb": round(u.total/1e9,3),
                "used_gb": round(u.used/1e9,3), "free_gb": round(u.free/1e9,3),
                "used_percent": pct}
    except Exception as e:
        return {"error": f"disk_usage: {e}"}


# ═════════════════════════════════════════════════════════════════════════════
# SCREENSHOT — every available method, full fallback chain
# ═════════════════════════════════════════════════════════════════════════════

async def tool_screenshot(**_) -> dict:
    out = Path(tempfile.mktemp(suffix=".png", prefix="bysalim_shot_"))
    loop = asyncio.get_event_loop()

    async def _try(fn) -> bool:
        try:
            await loop.run_in_executor(None, fn)
            return out.exists() and out.stat().st_size > 0
        except Exception:
            return False

    if SYS == "Darwin":
        r = _run(["screencapture", "-x", "-t", "png", str(out)])
        if not out.exists():
            return {"error": f"screencapture failed: {r.get('stderr','')}"}

    elif SYS == "Windows":
        # Method 1: PowerShell + System.Drawing
        ps = (
            "Add-Type -AssemblyName System.Windows.Forms,System.Drawing;"
            "$s=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds;"
            "$b=New-Object System.Drawing.Bitmap($s.Width,$s.Height);"
            "$g=[System.Drawing.Graphics]::FromImage($b);"
            "$g.CopyFromScreen($s.Location,[System.Drawing.Point]::Empty,$s.Size);"
            f"$b.Save('{out}',[System.Drawing.Imaging.ImageFormat]::Png);"
            "$g.Dispose();$b.Dispose()"
        )
        r = _run(["powershell", "-NonInteractive", "-Command", ps], timeout=30)
        if not out.exists():
            # Method 2: pyscreenshot (wraps multiple backends)
            pss = _pip("pyscreenshot")
            if pss:
                try:
                    img = pss.grab()
                    img.save(str(out))
                except Exception:
                    pass
        if not out.exists():
            # Method 3: Pillow ImageGrab
            PIL = _pip("Pillow", "PIL")
            if PIL:
                try:
                    from PIL import ImageGrab
                    ImageGrab.grab().save(str(out))
                except Exception:
                    pass
        if not out.exists():
            return {"error": "Screenshot failed on Windows. All methods exhausted."}

    else:  # Linux
        took = False
        # External tools
        for cmd in [
            ["scrot", "-z", str(out)],
            ["maim", str(out)],
            ["gnome-screenshot", "-f", str(out)],
            ["import", "-window", "root", str(out)],
            ["grim", str(out)],
            ["spectacle", "-b", "-n", "-o", str(out)],
            ["xwd", "-root", "-silent", "-out", str(out.with_suffix(".xwd"))],
        ]:
            if not shutil.which(cmd[0]): continue
            if cmd[0] == "xwd":
                r = _run(cmd, timeout=15)
                xwd = out.with_suffix(".xwd")
                if xwd.exists() and shutil.which("convert"):
                    _run(["convert", str(xwd), str(out)], timeout=15)
                    xwd.unlink(missing_ok=True)
            else:
                _run(cmd, timeout=20)
            if out.exists() and out.stat().st_size > 0:
                took = True; break

        if not took:
            # pyscreenshot (pure Python, wraps scrot/imagemagick/xlib)
            pss = _pip("pyscreenshot")
            if pss:
                try:
                    img = pss.grab()
                    img.save(str(out))
                    took = out.exists() and out.stat().st_size > 0
                except Exception:
                    pass

        if not took:
            # Pillow ImageGrab (works on X11 with Xlib)
            PIL = _pip("Pillow", "PIL")
            if PIL:
                try:
                    from PIL import ImageGrab
                    ImageGrab.grab().save(str(out))
                    took = out.exists() and out.stat().st_size > 0
                except Exception:
                    pass

        if not took:
            # D-Bus / GNOME screenshot portal (Wayland without grim)
            try:
                r = _run(["gdbus", "call", "--session",
                          "--dest", "org.gnome.Shell.Screenshot",
                          "--object-path", "/org/gnome/Shell/Screenshot",
                          "--method", "org.gnome.Shell.Screenshot.Screenshot",
                          "true", "true", str(out)], timeout=20)
                took = out.exists() and out.stat().st_size > 0
            except Exception:
                pass

        if not took:
            return {"error":
                "No screenshot tool available. Install one:\n"
                "  X11:     sudo apt install scrot\n"
                "  Wayland: sudo apt install grim\n"
                "  Or:      pip install pyscreenshot Pillow"}

    size = out.stat().st_size
    return {"path": str(out), "size_bytes": size, "size_kb": round(size / 1024, 1)}


# ═════════════════════════════════════════════════════════════════════════════
# VOLUME — every platform, every library
# ═════════════════════════════════════════════════════════════════════════════

async def tool_get_volume(**_) -> dict:
    try:
        if SYS == "Darwin":
            r = _run(["osascript", "-e", "output volume of (get volume settings)"])
            if "error" not in r and r.get("stdout","").strip():
                try: return {"volume": int(r["stdout"].strip().splitlines()[0])}
                except ValueError: pass
            return {"error": "osascript volume query failed"}

        elif SYS == "Windows":
            # Method 1: pycaw (COM, works on Win 7+)
            pycaw = _pip("pycaw")
            if pycaw:
                try:
                    from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
                    from comtypes import CLSCTX_ALL
                    devices = AudioUtilities.GetSpeakers()
                    interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
                    volume = interface.QueryInterface(IAudioEndpointVolume)
                    scalar = volume.GetMasterVolumeLevelScalar()
                    return {"volume": round(scalar * 100)}
                except Exception as ex:
                    log.debug("pycaw get_volume: %s", ex)

            # Method 2: PowerShell audio module
            ps = (
                "$vol = (New-Object -ComObject WMPlayer.OCX.7).settings.volume;"
                "Write-Output $vol"
            )
            r = _run(["powershell", "-NonInteractive", "-Command", ps], timeout=10)
            if "error" not in r and r.get("stdout","").strip().isdigit():
                return {"volume": int(r["stdout"].strip())}

            # Method 3: nircmd
            if shutil.which("nircmd"):
                # nircmd doesn't have a get, but we can query registry
                ps2 = "Get-ItemPropertyValue -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Applets\\Volume' -Name Volume 2>$null"
                r2 = _run(["powershell", "-NonInteractive", "-Command", ps2], timeout=10)
                if r2.get("stdout","").strip().isdigit():
                    raw = int(r2["stdout"].strip())  # 0-65535
                    return {"volume": round(raw / 655.35)}

            return {"error": "Could not read volume. Install pycaw: pip install pycaw"}

        else:  # Linux
            for cmd, parse in [
                (["pactl", "get-sink-volume", "@DEFAULT_SINK@"],
                 lambda o: int(re.search(r"(\d+)%", o).group(1)) if re.search(r"(\d+)%", o) else None),
                (["amixer", "-D", "pulse", "get", "Master"],
                 lambda o: int(re.search(r"\[(\d+)%\]", o).group(1)) if re.search(r"\[(\d+)%\]", o) else None),
                (["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"],
                 lambda o: int(float(re.search(r"[\d.]+", o).group(0)) * 100) if re.search(r"[\d.]+", o) else None),
                (["amixer", "get", "Master"],
                 lambda o: int(re.search(r"\[(\d+)%\]", o).group(1)) if re.search(r"\[(\d+)%\]", o) else None),
            ]:
                if not shutil.which(cmd[0]): continue
                r = _run(cmd)
                if "error" not in r:
                    vol = parse(r.get("stdout", ""))
                    if vol is not None: return {"volume": vol}

            # Last resort: pulsectl (Python bindings)
            pulsectl = _pip("pulsectl")
            if pulsectl:
                try:
                    import pulsectl
                    with pulsectl.Pulse("bysalim") as pulse:
                        sink = pulse.sink_list()[0]
                        return {"volume": round(sink.volume.value_flat * 100)}
                except Exception:
                    pass

            return {"error": "No volume control found. Install: sudo apt install pulseaudio-utils"}
    except Exception as e:
        return {"error": f"get_volume: {e}"}


async def tool_set_volume(level: int = 50, **_) -> dict:
    level = int(_clamp(int(level), 0, 100))
    try:
        if SYS == "Darwin":
            r = _run(["osascript", "-e", f"set volume output volume {level}"])
            return {"error": r.get("stderr","")} if "error" in r else {"volume_set": level}

        elif SYS == "Windows":
            # Method 1: pycaw
            pycaw = _pip("pycaw")
            if pycaw:
                try:
                    from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
                    from comtypes import CLSCTX_ALL
                    devices = AudioUtilities.GetSpeakers()
                    interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
                    volume = interface.QueryInterface(IAudioEndpointVolume)
                    volume.SetMasterVolumeLevelScalar(level / 100.0, None)
                    return {"volume_set": level}
                except Exception as ex:
                    log.debug("pycaw set_volume: %s", ex)

            # Method 2: nircmd
            if shutil.which("nircmd"):
                _run(["nircmd", "setsysvolume", str(int(level * 655.35))])
                return {"volume_set": level}

            # Method 3: PowerShell WScript (limited, but functional for some systems)
            ps = f"$wmp = New-Object -COM WMPlayer.OCX.7; $wmp.settings.volume = {level}"
            r = _run(["powershell", "-NonInteractive", "-Command", ps], timeout=10)
            if r.get("exit_code", 1) == 0:
                return {"volume_set": level}

            return {"error": "Cannot set volume. Install pycaw (pip install pycaw) or nircmd."}

        else:  # Linux
            for cmd, name in [
                (["pactl", "set-sink-volume", "@DEFAULT_AUDIO_SINK@", f"{level}%"], "pactl"),
                (["amixer", "-D", "pulse", "sset", "Master", f"{level}%"], "amixer"),
                (["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", f"{level/100:.2f}"], "wpctl"),
                (["amixer", "sset", "Master", f"{level}%"], "amixer-hw"),
            ]:
                if not shutil.which(cmd[0]): continue
                r = _run(cmd)
                if "error" not in r and r.get("exit_code", 1) == 0:
                    return {"volume_set": level}

            pulsectl = _pip("pulsectl")
            if pulsectl:
                try:
                    import pulsectl
                    with pulsectl.Pulse("bysalim") as pulse:
                        sink = pulse.sink_list()[0]
                        pulse.volume_set_all_chans(sink, level / 100.0)
                    return {"volume_set": level}
                except Exception:
                    pass

            return {"error": "No volume control found. Install: sudo apt install pulseaudio-utils"}
    except Exception as e:
        return {"error": f"set_volume: {e}"}


async def tool_mute_volume(**_) -> dict:
    try:
        if SYS == "Darwin":
            _run(["osascript", "-e", "set volume with output muted"])
            return {"muted": True}

        elif SYS == "Windows":
            pycaw = _pip("pycaw")
            if pycaw:
                try:
                    from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
                    from comtypes import CLSCTX_ALL
                    devices = AudioUtilities.GetSpeakers()
                    interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
                    vol = interface.QueryInterface(IAudioEndpointVolume)
                    vol.SetMute(1, None)
                    return {"muted": True}
                except Exception:
                    pass
            if shutil.which("nircmd"):
                _run(["nircmd", "mutesysvolume", "1"])
                return {"muted": True}
            _run(["powershell", "-NonInteractive", "-Command",
                  "(New-Object -ComObject WScript.Shell).SendKeys([char]173)"])
            return {"muted": True}

        else:  # Linux
            for cmd in [
                ["pactl", "set-sink-mute", "@DEFAULT_AUDIO_SINK@", "1"],
                ["amixer", "-D", "pulse", "sset", "Master", "mute"],
                ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "1"],
                ["amixer", "sset", "Master", "mute"],
            ]:
                if not shutil.which(cmd[0]): continue
                r = _run(cmd)
                if "error" not in r: return {"muted": True}

            pulsectl = _pip("pulsectl")
            if pulsectl:
                try:
                    import pulsectl
                    with pulsectl.Pulse("bysalim") as pulse:
                        sink = pulse.sink_list()[0]
                        pulse.mute(sink, True)
                    return {"muted": True}
                except Exception:
                    pass

            return {"error": "No mute control found."}
    except Exception as e:
        return {"error": f"mute_volume: {e}"}


# ═════════════════════════════════════════════════════════════════════════════
# CLIPBOARD — pyperclip + native fallbacks
# ═════════════════════════════════════════════════════════════════════════════

async def tool_get_clipboard(**_) -> dict:
    try:
        # pyperclip works on all platforms
        pyperclip = _pip("pyperclip")
        if pyperclip:
            try:
                content = pyperclip.paste()
                if content is not None:
                    return {"clipboard": content, "length": len(content)}
            except Exception:
                pass

        if SYS == "Darwin":
            r = _run(["pbpaste"])
        elif SYS == "Windows":
            r = _run(["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):
                    r = _run(args); break
            else:
                return {"error": "No clipboard tool. Install: sudo apt install xclip  OR  pip install pyperclip"}

        if "error" in r: return r
        c = r.get("stdout", "")
        return {"clipboard": c, "length": len(c)}
    except Exception as e:
        return {"error": f"get_clipboard: {e}"}


async def tool_set_clipboard(text: str = "", **_) -> dict:
    if text is None: return {"error": "No text provided"}
    try:
        pyperclip = _pip("pyperclip")
        if pyperclip:
            try:
                pyperclip.copy(text)
                return {"clipboard_set": text[:80], "length": len(text)}
            except Exception:
                pass

        enc = text.encode("utf-8")
        if SYS == "Darwin":
            subprocess.run(["pbcopy"], input=enc, timeout=10)
        elif SYS == "Windows":
            subprocess.run(["powershell", "-NonInteractive", "-Command",
                            f'Set-Clipboard -Value @"\n{text}\n"@'], timeout=10, capture_output=True)
        else:
            for tool, args in [
                ("xclip", ["xclip", "-selection", "clipboard"]),
                ("xsel",  ["xsel", "--clipboard", "--input"]),
                ("wl-copy", ["wl-copy"]),
            ]:
                if shutil.which(tool):
                    subprocess.run(args, input=enc, timeout=10); break
            else:
                return {"error": "No clipboard tool. Install: sudo apt install xclip  OR  pip install pyperclip"}
        return {"clipboard_set": text[:80], "length": len(text)}
    except Exception as e:
        return {"error": f"set_clipboard: {e}"}


# ═════════════════════════════════════════════════════════════════════════════
# IMAGE CRUD — Pillow (auto-installed)
# ═════════════════════════════════════════════════════════════════════════════

def _pil():
    m = _pip("Pillow", "PIL")
    if m is None:
        raise ImportError("Pillow could not be installed. Try: pip install Pillow")
    from PIL import Image
    return Image


async def tool_image_info(path: str = "", **_) -> dict:
    if not path: return {"error": "No path"}
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        Image = _pil()
        from PIL import ExifTags
        def _do():
            with Image.open(str(p)) as img:
                img.load()
                info: dict = {"path": str(p), "format": img.format, "mode": img.mode,
                               "width": img.width, "height": img.height,
                               "megapixels": round(img.width * img.height / 1e6, 2),
                               "size_bytes": p.stat().st_size, "size_human": _sz(p.stat().st_size)}
                # EXIF
                exif = {}
                try:
                    raw = img._getexif()
                    if raw:
                        for tid, val in raw.items():
                            tag = ExifTags.TAGS.get(tid, str(tid))
                            if not isinstance(val, bytes):
                                exif[tag] = str(val)[:100]
                except Exception:
                    pass
                # piexif for deeper EXIF
                if not exif:
                    piexif = _pip("piexif")
                    if piexif:
                        try:
                            ed = piexif.load(str(p))
                            for ifd_name, ifd in ed.items():
                                if isinstance(ifd, dict):
                                    for k, v in ifd.items():
                                        tag = piexif.TAGS.get(ifd_name, {}).get(k, {}).get("name", str(k))
                                        if isinstance(v, bytes): continue
                                        exif[f"{ifd_name}.{tag}"] = str(v)[:80]
                        except Exception:
                            pass
                if exif: info["exif"] = exif
                # Color stats
                try:
                    stat_mod = _pip("Pillow", "PIL")
                    from PIL import ImageStat
                    s = ImageStat.Stat(img)
                    info["mean_brightness"] = round(sum(s.mean) / len(s.mean), 1)
                except Exception:
                    pass
                return info
        return await _run_async(_do)
    except Exception as e:
        return {"error": f"image_info: {e}"}


async def tool_image_resize(path: str = "", width: int = 0, height: int = 0,
                             output: str = "", keep_aspect: bool = True, **_) -> dict:
    if not path: return {"error": "No path"}
    if not width and not height: return {"error": "Provide width and/or height"}
    try:
        Image = _pil()
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        out = _resolve(output) if output else p

        def _do():
            with Image.open(str(p)) as img:
                # Preserve EXIF
                exif = img.info.get("exif", b"")
                ow, oh = img.size
                if keep_aspect:
                    if width and height: img.thumbnail((width, height), Image.LANCZOS)
                    elif width: img = img.resize((width, int(oh * width / ow)), Image.LANCZOS)
                    else: img = img.resize((int(ow * height / oh), height), Image.LANCZOS)
                else:
                    img = img.resize((width or ow, height or oh), Image.LANCZOS)
                out.parent.mkdir(parents=True, exist_ok=True)
                fmt = img.format or p.suffix.lstrip(".").upper() or "PNG"
                save_kw: dict = {"format": fmt}
                if exif and fmt in ("JPEG", "WEBP"): save_kw["exif"] = exif
                img.save(str(out), **save_kw)
                return {"resized": str(out), "new_width": img.width, "new_height": img.height,
                        "orig_width": ow, "orig_height": oh, "size_human": _sz(out.stat().st_size)}
        return await _run_async(_do)
    except Exception as e:
        return {"error": f"image_resize: {e}"}


async def tool_image_rotate(path: str = "", angle: float = 90, output: str = "", expand: bool = True, **_) -> dict:
    if not path: return {"error": "No path"}
    try:
        Image = _pil()
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        out = _resolve(output) if output else p
        def _do():
            with Image.open(str(p)) as img:
                rotated = img.rotate(float(angle), expand=expand, resample=Image.BICUBIC)
                out.parent.mkdir(parents=True, exist_ok=True)
                rotated.save(str(out))
                return {"rotated": str(out), "angle": angle, "width": rotated.width, "height": rotated.height}
        return await _run_async(_do)
    except Exception as e:
        return {"error": f"image_rotate: {e}"}


async def tool_image_crop(path: str = "", left: int = 0, top: int = 0,
                           right: int = 0, bottom: int = 0, output: str = "", **_) -> dict:
    if not path: return {"error": "No path"}
    if not any([left, top, right, bottom]): return {"error": "Provide crop box: left top right bottom (pixels)"}
    try:
        Image = _pil()
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        out = _resolve(output) if output else p
        def _do():
            with Image.open(str(p)) as img:
                w, h = img.size
                r = right or w; b = bottom or h
                cropped = img.crop((left, top, r, b))
                out.parent.mkdir(parents=True, exist_ok=True)
                cropped.save(str(out))
                return {"cropped": str(out), "box": [left,top,r,b], "width": cropped.width, "height": cropped.height}
        return await _run_async(_do)
    except Exception as e:
        return {"error": f"image_crop: {e}"}


async def tool_image_convert(path: str = "", format: str = "", output: str = "", quality: int = 90, **_) -> dict:
    if not path: return {"error": "No path"}
    if not format and not output: return {"error": "Provide format or output path"}
    try:
        Image = _pil()
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        if output:
            out = _resolve(output)
            fmt = out.suffix.lstrip(".").upper() or format.upper()
        else:
            fmt = format.upper()
            ext = fmt.lower().replace("jpeg","jpg")
            out = p.with_suffix(f".{ext}")
        fmt = fmt.replace("JPG","JPEG")
        def _do():
            with Image.open(str(p)) as img:
                if img.mode in ("RGBA","P","LA") and fmt == "JPEG":
                    bg = Image.new("RGB", img.size, (255,255,255))
                    if img.mode == "P": img = img.convert("RGBA")
                    bg.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
                    img = bg
                elif img.mode not in ("RGB","L","RGBA") and fmt != "JPEG":
                    img = img.convert("RGBA" if fmt == "PNG" else "RGB")
                out.parent.mkdir(parents=True, exist_ok=True)
                kw: dict = {"format": fmt}
                if fmt in ("JPEG","WEBP"): kw["quality"] = quality
                if fmt == "PNG": kw["optimize"] = True
                img.save(str(out), **kw)
                return {"converted": str(out), "format": fmt, "size_human": _sz(out.stat().st_size)}
        return await _run_async(_do)
    except Exception as e:
        return {"error": f"image_convert: {e}"}


# ═════════════════════════════════════════════════════════════════════════════
# AUDIO — mutagen (pip), stdlib wave, ID3 parser, ffprobe
# ═════════════════════════════════════════════════════════════════════════════

async def tool_audio_info(path: str = "", **_) -> dict:
    if not path: return {"error": "No path"}
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}

        def _do() -> dict:
            info: dict = {"path": str(p), "format": p.suffix.upper().lstrip("."),
                          "size_bytes": p.stat().st_size, "size_human": _sz(p.stat().st_size)}

            # Strategy 1: ffprobe (handles everything + tags)
            if shutil.which("ffprobe"):
                r = _run(["ffprobe", "-v", "quiet", "-print_format", "json",
                           "-show_streams", "-show_format", str(p)], timeout=15)
                if "error" not in r and r.get("stdout"):
                    try:
                        d = json.loads(r["stdout"])
                        fmt = d.get("format", {})
                        dur = float(fmt.get("duration", 0) or 0)
                        if dur > 0:
                            info["duration_seconds"] = round(dur, 2)
                            m, s = divmod(int(dur), 60)
                            info["duration"] = f"{m}:{s:02d}"
                        br = int(fmt.get("bit_rate", 0) or 0)
                        if br: info["bitrate_kbps"] = round(br / 1000, 1)
                        for stream in d.get("streams", []):
                            if stream.get("codec_type") == "audio":
                                info["codec"] = stream.get("codec_name","")
                                sr = stream.get("sample_rate")
                                if sr: info["sample_rate_hz"] = int(sr)
                                ch = stream.get("channels")
                                if ch: info["channels"] = ch
                                break
                        for k, v in fmt.get("tags", {}).items():
                            if k.lower() in ("title","artist","album","date","genre","track","comment"):
                                info[k.lower()] = str(v)
                        return info
                    except Exception:
                        pass

            # Strategy 2: mutagen (pure Python, reads tags for most formats)
            mutagen = _pip("mutagen")
            if mutagen:
                try:
                    from mutagen import File as MFile
                    f = MFile(str(p), easy=True)
                    if f is not None:
                        if hasattr(f, "info"):
                            dur = getattr(f.info, "length", None)
                            if dur:
                                info["duration_seconds"] = round(float(dur), 2)
                                m, s = divmod(int(dur), 60)
                                info["duration"] = f"{m}:{s:02d}"
                            br = getattr(f.info, "bitrate", None)
                            if br: info["bitrate_kbps"] = round(br / 1000, 1)
                            sr = getattr(f.info, "sample_rate", None)
                            if sr: info["sample_rate_hz"] = sr
                            ch = getattr(f.info, "channels", None)
                            if ch: info["channels"] = ch
                        for tag in ("title","artist","album","date","genre","tracknumber"):
                            v = f.get(tag)
                            if v: info[tag] = str(v[0]) if isinstance(v, list) else str(v)
                        return info
                except Exception:
                    pass

            # Strategy 3: stdlib wave (WAV only)
            if p.suffix.lower() == ".wav":
                import wave
                try:
                    with wave.open(str(p), "r") as wf:
                        frames = wf.getnframes()
                        rate = wf.getframerate()
                        channels = wf.getnchannels()
                        width = wf.getsampwidth()
                        dur = frames / rate if rate else 0
                        info.update({"duration_seconds": round(dur, 2),
                                     "duration": f"{int(dur)//60}:{int(dur)%60:02d}",
                                     "sample_rate_hz": rate, "channels": channels,
                                     "bit_depth": width * 8,
                                     "bitrate_kbps": round(rate * channels * width * 8 / 1000, 1)})
                    return info
                except Exception:
                    pass

            # Strategy 4: Raw ID3 scan for MP3
            if p.suffix.lower() == ".mp3":
                try:
                    with open(str(p), "rb") as f:
                        raw = f.read(min(p.stat().st_size, 5 * 1024 * 1024))
                    # ID3v2 tags
                    if raw[:3] == b"ID3":
                        sz_b = raw[6:10]
                        tag_sz = (sz_b[0]<<21)|(sz_b[1]<<14)|(sz_b[2]<<7)|sz_b[3]
                        body = raw[10:10+tag_sz]
                        pos = 0
                        name_map = {"TIT2":"title","TPE1":"artist","TALB":"album","TDRC":"date","TCON":"genre"}
                        while pos < len(body) - 10:
                            fid = body[pos:pos+4]
                            if not fid or fid[0:1] == b"\x00": break
                            try:
                                fid_str = fid.decode("latin-1")
                                fsize = struct.unpack(">I", body[pos+4:pos+8])[0]
                                if fid_str in name_map:
                                    fdata = body[pos+10:pos+10+fsize]
                                    enc = fdata[0] if fdata else 0
                                    text = fdata[1:].decode("utf-16" if enc in (1,2) else "utf-8",
                                                             errors="replace").strip("\x00").strip()
                                    if text: info[name_map[fid_str]] = text
                                pos += 10 + fsize
                            except Exception:
                                break
                    # Frame scan for bitrate/duration
                    br_table = [0,32,40,48,56,64,80,96,112,128,160,192,224,256,320,0]
                    sr_table = [44100,48000,32000,0]
                    fsize = p.stat().st_size
                    for i in range(len(raw)-3):
                        b = raw[i:i+4]
                        if b[0]==0xFF and (b[1]&0xE0)==0xE0:
                            bi = (b[2]>>4)&0xF
                            si = (b[2]>>2)&0x3
                            if 0 < bi < 15 and si < 3:
                                br = br_table[bi] * 1000
                                sr = sr_table[si]
                                if br > 0 and sr > 0:
                                    dur = (fsize * 8) / br
                                    info["duration_seconds"] = round(dur, 2)
                                    info["duration"] = f"{int(dur)//60}:{int(dur)%60:02d}"
                                    info["bitrate_kbps"] = br // 1000
                                    info["sample_rate_hz"] = sr
                                    break
                except Exception:
                    pass
                return info

            info["note"] = "Install ffmpeg for full audio metadata: sudo apt install ffmpeg"
            return info

        return await _run_async(_do)
    except Exception as e:
        return {"error": f"audio_info: {e}"}


async def tool_audio_convert(path: str = "", format: str = "mp3", output: str = "", bitrate: str = "", **_) -> dict:
    if not path: return {"error": "No path"}
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        fmt = format.lower().lstrip(".")
        out = _resolve(output) if output else p.with_suffix(f".{fmt}")
        out.parent.mkdir(parents=True, exist_ok=True)

        # Method 1: ffmpeg (best quality, all formats)
        if shutil.which("ffmpeg"):
            cmd = ["ffmpeg", "-y", "-i", str(p)]
            if bitrate: cmd += ["-b:a", bitrate]
            cmd.append(str(out))
            r = await _run_async(_run, cmd, 180)
            if out.exists() and out.stat().st_size > 0:
                return {"converted": str(out), "format": fmt, "size_human": _sz(out.stat().st_size)}
            return {"error": f"ffmpeg failed: {r.get('stderr','')[:400]}"}

        # Method 2: pydub (Python audio library, uses ffmpeg internally but optional)
        pydub = _pip("pydub")
        if pydub:
            try:
                from pydub import AudioSegment
                audio = AudioSegment.from_file(str(p))
                kw = {}
                if bitrate: kw["bitrate"] = bitrate
                audio.export(str(out), format=fmt, **kw)
                if out.exists(): return {"converted": str(out), "format": fmt, "size_human": _sz(out.stat().st_size)}
            except Exception as ex:
                log.debug("pydub convert: %s", ex)

        return {"error": "ffmpeg not found. Install: sudo apt install ffmpeg  (or brew install ffmpeg on Mac)"}
    except Exception as e:
        return {"error": f"audio_convert: {e}"}


async def tool_audio_trim(path: str = "", start: float = 0, duration: float = 0, output: str = "", **_) -> dict:
    if not path: return {"error": "No path"}
    if not duration: return {"error": "Provide duration (seconds)"}
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        out = _resolve(output) if output else p.with_stem(p.stem + "_trimmed")
        out.parent.mkdir(parents=True, exist_ok=True)

        if shutil.which("ffmpeg"):
            cmd = ["ffmpeg", "-y", "-i", str(p), "-ss", str(start), "-t", str(duration), str(out)]
            r = await _run_async(_run, cmd, 180)
            if out.exists() and out.stat().st_size > 0:
                return {"trimmed": str(out), "start_sec": start, "duration_sec": duration,
                        "size_human": _sz(out.stat().st_size)}
            return {"error": f"ffmpeg failed: {r.get('stderr','')[:300]}"}

        pydub = _pip("pydub")
        if pydub:
            try:
                from pydub import AudioSegment
                audio = AudioSegment.from_file(str(p))
                trimmed = audio[int(start*1000):int((start+duration)*1000)]
                trimmed.export(str(out), format=out.suffix.lstrip("."))
                return {"trimmed": str(out), "start_sec": start, "duration_sec": duration,
                        "size_human": _sz(out.stat().st_size)}
            except Exception as ex:
                return {"error": f"pydub trim failed: {ex}"}

        return {"error": "ffmpeg not found. Install: sudo apt install ffmpeg"}
    except Exception as e:
        return {"error": f"audio_trim: {e}"}


# ═════════════════════════════════════════════════════════════════════════════
# VIDEO — ffprobe + hachoir fallback
# ═════════════════════════════════════════════════════════════════════════════

async def tool_video_info(path: str = "", **_) -> dict:
    if not path: return {"error": "No path"}
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}

        def _do():
            info: dict = {"path": str(p), "size_bytes": p.stat().st_size, "size_human": _sz(p.stat().st_size)}

            # Strategy 1: ffprobe
            if shutil.which("ffprobe"):
                r = _run(["ffprobe", "-v", "quiet", "-print_format", "json",
                           "-show_streams", "-show_format", str(p)], timeout=20)
                if "error" not in r and r.get("stdout"):
                    try:
                        d = json.loads(r["stdout"])
                        fmt = d.get("format", {})
                        dur = float(fmt.get("duration", 0) or 0)
                        info["duration_seconds"] = round(dur, 2)
                        h_, rem = divmod(int(dur), 3600); m_, s_ = divmod(rem, 60)
                        info["duration"] = f"{h_}:{m_:02d}:{s_:02d}" if h_ else f"{m_}:{s_:02d}"
                        info["bit_rate_kbps"] = round(int(fmt.get("bit_rate", 0) or 0) / 1000, 1)
                        info["format_name"] = fmt.get("format_long_name", fmt.get("format_name", ""))
                        for stream in d.get("streams", []):
                            ct = stream.get("codec_type")
                            if ct == "video" and "video_codec" not in info:
                                info.update({"video_codec": stream.get("codec_name",""),
                                             "width": stream.get("width"),
                                             "height": stream.get("height"),
                                             "resolution": f"{stream.get('width')}x{stream.get('height')}"})
                                rfr = stream.get("r_frame_rate", "0/1")
                                try:
                                    n, dd = rfr.split("/")
                                    info["fps"] = round(int(n)/int(dd),2) if int(dd) else 0
                                except Exception:
                                    info["fps"] = rfr
                            elif ct == "audio" and "audio_codec" not in info:
                                info.update({"audio_codec": stream.get("codec_name",""),
                                             "audio_channels": stream.get("channels"),
                                             "audio_sample_rate": stream.get("sample_rate")})
                        return info
                    except Exception:
                        pass

            # Strategy 2: hachoir (pure Python)
            hachoir = _pip("hachoir")
            if hachoir:
                try:
                    from hachoir.parser import createParser
                    from hachoir.metadata import extractMetadata
                    parser = createParser(str(p))
                    if parser:
                        meta = extractMetadata(parser)
                        if meta:
                            for line in meta.exportPlaintext():
                                if "Duration" in line:
                                    info["duration"] = line.split(":")[-1].strip()
                                if "Image width" in line:
                                    try: info["width"] = int(line.split(":")[-1].strip().split()[0])
                                    except: pass
                                if "Image height" in line:
                                    try: info["height"] = int(line.split(":")[-1].strip().split()[0])
                                    except: pass
                                if "Frame rate" in line:
                                    try: info["fps"] = float(line.split(":")[-1].strip().split()[0])
                                    except: pass
                            if "width" in info and "height" in info:
                                info["resolution"] = f"{info['width']}x{info['height']}"
                            return info
                except Exception as ex:
                    log.debug("hachoir video_info: %s", ex)

            # Strategy 3: mediainfo (if installed)
            if shutil.which("mediainfo"):
                r = _run(["mediainfo", "--Output=JSON", str(p)], timeout=15)
                if "error" not in r and r.get("stdout"):
                    try:
                        d = json.loads(r["stdout"])
                        for track in d.get("media", {}).get("track", []):
                            if track.get("@type") == "General":
                                info["duration_seconds"] = round(float(track.get("Duration", 0)), 2)
                                m_, s_ = divmod(int(info.get("duration_seconds",0)), 60)
                                info["duration"] = f"{m_}:{s_:02d}"
                                info["bit_rate_kbps"] = round(int(track.get("OverallBitRate",0)) / 1000, 1)
                            if track.get("@type") == "Video":
                                info.update({"video_codec": track.get("Format",""),
                                             "width": int(track.get("Width", 0)),
                                             "height": int(track.get("Height", 0)),
                                             "fps": float(track.get("FrameRate", 0))})
                                info["resolution"] = f"{info['width']}x{info['height']}"
                            if track.get("@type") == "Audio":
                                info["audio_codec"] = track.get("Format","")
                        return info
                    except Exception:
                        pass

            info["note"] = "Install ffmpeg for full video metadata: sudo apt install ffmpeg"
            return info

        return await _run_async(_do)
    except Exception as e:
        return {"error": f"video_info: {e}"}


async def tool_video_thumbnail(path: str = "", time_sec: float = 1.0, output: str = "", **_) -> dict:
    if not path: return {"error": "No path"}
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        out = _resolve(output) if output else p.with_suffix(".thumb.jpg")
        out.parent.mkdir(parents=True, exist_ok=True)

        if shutil.which("ffmpeg"):
            cmd = ["ffmpeg", "-y", "-ss", str(time_sec), "-i", str(p),
                   "-vframes", "1", "-q:v", "2", str(out)]
            r = await _run_async(_run, cmd, 30)
            if out.exists() and out.stat().st_size > 0:
                return {"thumbnail": str(out), "at_sec": time_sec, "size_human": _sz(out.stat().st_size)}
            return {"error": f"ffmpeg thumbnail failed: {r.get('stderr','')[:200]}"}

        # Fallback: OpenCV
        cv2 = _pip("opencv-python", "cv2")
        if cv2:
            import cv2
            loop = asyncio.get_event_loop()
            def _grab():
                cap = cv2.VideoCapture(str(p))
                cap.set(cv2.CAP_PROP_POS_MSEC, time_sec * 1000)
                ret, frame = cap.read()
                cap.release()
                if ret:
                    cv2.imwrite(str(out), frame)
                    return True
                return False
            ok = await loop.run_in_executor(None, _grab)
            if ok and out.exists():
                return {"thumbnail": str(out), "at_sec": time_sec, "size_human": _sz(out.stat().st_size)}

        return {"error": "ffmpeg not found. Install: sudo apt install ffmpeg"}
    except Exception as e:
        return {"error": f"video_thumbnail: {e}"}


async def tool_video_convert(path: str = "", format: str = "mp4", output: str = "", **_) -> dict:
    if not path: return {"error": "No path"}
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        fmt = format.lower().lstrip(".")
        out = _resolve(output) if output else p.with_suffix(f".{fmt}")
        out.parent.mkdir(parents=True, exist_ok=True)

        if not shutil.which("ffmpeg"):
            return {"error": "ffmpeg required for video conversion. Install: sudo apt install ffmpeg"}
        cmd = ["ffmpeg", "-y", "-i", str(p), str(out)]
        r = await _run_async(_run, cmd, 600)
        if out.exists() and out.stat().st_size > 0:
            return {"converted": str(out), "format": fmt, "size_human": _sz(out.stat().st_size)}
        return {"error": f"ffmpeg failed: {r.get('stderr','')[:400]}"}
    except Exception as e:
        return {"error": f"video_convert: {e}"}


async def tool_video_trim(path: str = "", start: float = 0, duration: float = 0, output: str = "", **_) -> dict:
    if not path: return {"error": "No path"}
    if not duration: return {"error": "Provide duration (seconds)"}
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        out = _resolve(output) if output else p.with_stem(p.stem + "_trimmed")
        out.parent.mkdir(parents=True, exist_ok=True)

        if not shutil.which("ffmpeg"):
            return {"error": "ffmpeg required. Install: sudo apt install ffmpeg"}
        cmd = ["ffmpeg", "-y", "-i", str(p), "-ss", str(start), "-t", str(duration), "-c", "copy", str(out)]
        r = await _run_async(_run, cmd, 600)
        if out.exists() and out.stat().st_size > 0:
            return {"trimmed": str(out), "start_sec": start, "duration_sec": duration, "size_human": _sz(out.stat().st_size)}
        # Retry without -c copy (re-encode, slower but more compatible)
        cmd2 = ["ffmpeg", "-y", "-i", str(p), "-ss", str(start), "-t", str(duration), str(out)]
        r2 = await _run_async(_run, cmd2, 600)
        if out.exists() and out.stat().st_size > 0:
            return {"trimmed": str(out), "start_sec": start, "duration_sec": duration, "size_human": _sz(out.stat().st_size)}
        return {"error": f"ffmpeg failed: {r2.get('stderr','')[:400]}"}
    except Exception as e:
        return {"error": f"video_trim: {e}"}


# ═════════════════════════════════════════════════════════════════════════════
# SEND FILE
# ═════════════════════════════════════════════════════════════════════════════

async def tool_send_file(path: str = "", caption: str = "", **_) -> dict:
    if not path: return {"error": "No path"}
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        if not p.is_file(): return {"error": f"Not a file: {p}"}
        mt = _media_type(str(p))
        size = p.stat().st_size
        if size > 50 * 1024 * 1024:
            return {"error": f"File too large ({_sz(size)}). Telegram max is 50 MB."}
        cap = caption or f"📎 {p.name} ({_sz(size)})"
        await _push_file(str(p), cap, mt)
        return {"sent": str(p), "media_type": mt, "size_human": _sz(size)}
    except Exception as e:
        return {"error": f"send_file: {e}"}


# ═════════════════════════════════════════════════════════════════════════════
# APP LAUNCHER — complete cross-platform implementation
# ═════════════════════════════════════════════════════════════════════════════

_ALIASES: dict[str, dict[str, str | list]] = {
    "chrome":      {"Linux": ["google-chrome","google-chrome-stable","chromium-browser"],
                    "Darwin": "Google Chrome", "Windows": "chrome"},
    "chromium":    {"Linux": ["chromium","chromium-browser"],    "Darwin": "Chromium",          "Windows": "chromium"},
    "firefox":     {"Linux": "firefox",                         "Darwin": "Firefox",           "Windows": "firefox"},
    "safari":      {"Linux": "",                                 "Darwin": "Safari",            "Windows": ""},
    "edge":        {"Linux": ["microsoft-edge","microsoft-edge-stable"],
                    "Darwin": "Microsoft Edge", "Windows": "msedge"},
    "vscode":      {"Linux": "code",                            "Darwin": "Visual Studio Code","Windows": "code"},
    "vs code":     {"Linux": "code",                            "Darwin": "Visual Studio Code","Windows": "code"},
    "code":        {"Linux": "code",                            "Darwin": "Visual Studio Code","Windows": "code"},
    "terminal":    {"Linux": ["x-terminal-emulator","gnome-terminal","xterm","konsole","alacritty"],
                    "Darwin": "Terminal", "Windows": "cmd"},
    "calculator":  {"Linux": ["gnome-calculator","kcalc","galculator"],
                    "Darwin": "Calculator", "Windows": "calc"},
    "vlc":         {"Linux": "vlc",                             "Darwin": "VLC",               "Windows": "vlc"},
    "spotify":     {"Linux": "spotify",                         "Darwin": "Spotify",           "Windows": "spotify"},
    "slack":       {"Linux": "slack",                           "Darwin": "Slack",             "Windows": "slack"},
    "zoom":        {"Linux": ["zoom","ZoomLauncher"],           "Darwin": "zoom.us",           "Windows": "zoom"},
    "discord":     {"Linux": ["discord","Discord"],             "Darwin": "Discord",           "Windows": "discord"},
    "telegram":    {"Linux": ["telegram-desktop","Telegram"],   "Darwin": "Telegram",          "Windows": "telegram"},
    "files":       {"Linux": ["nautilus","dolphin","thunar","nemo"],
                    "Darwin": "Finder", "Windows": "explorer"},
    "finder":      {"Linux": "nautilus",                        "Darwin": "Finder",            "Windows": "explorer"},
    "file manager":{"Linux": ["nautilus","dolphin","thunar","nemo"],
                    "Darwin": "Finder", "Windows": "explorer"},
    "gimp":        {"Linux": "gimp",                            "Darwin": "GIMP",              "Windows": "gimp"},
    "inkscape":    {"Linux": "inkscape",                        "Darwin": "Inkscape",          "Windows": "inkscape"},
    "notepad":     {"Linux": ["gedit","mousepad","kate","nano"],"Darwin": "TextEdit",          "Windows": "notepad"},
    "textedit":    {"Linux": "gedit",                           "Darwin": "TextEdit",          "Windows": "notepad"},
    "gedit":       {"Linux": "gedit",                           "Darwin": "TextEdit",          "Windows": "notepad"},
    "obs":         {"Linux": "obs",                             "Darwin": "OBS",               "Windows": "obs64"},
    "audacity":    {"Linux": "audacity",                        "Darwin": "Audacity",          "Windows": "audacity"},
    "libreoffice": {"Linux": "libreoffice",                     "Darwin": "LibreOffice",       "Windows": "soffice"},
    "thunderbird": {"Linux": "thunderbird",                     "Darwin": "Thunderbird",       "Windows": "thunderbird"},
    "nautilus":    {"Linux": "nautilus",                        "Darwin": "Finder",            "Windows": "explorer"},
    "dolphin":     {"Linux": "dolphin",                         "Darwin": "Finder",            "Windows": "explorer"},
}


async def tool_open_application(name: str = "", file_path: str = "", **_) -> dict:
    if not name and not file_path:
        return {"error": "Provide name or file_path"}
    try:
        # Open file with default app
        if file_path and not name:
            fp = _resolve(file_path)
            if not fp.exists(): return {"error": f"File not found: {file_path}"}
            return await _default_open(str(fp))

        key = name.strip().lower()
        aliases = _ALIASES.get(key, {})
        candidates_raw = aliases.get(SYS, name.strip())
        candidates: list[str] = candidates_raw if isinstance(candidates_raw, list) else [candidates_raw] if candidates_raw else []
        if name.strip() not in candidates: candidates.append(name.strip())
        extra_args = [str(_resolve(file_path))] if file_path else []

        if SYS == "Darwin":
            return await _launch_mac(candidates, extra_args)
        elif SYS == "Windows":
            return await _launch_win(candidates, extra_args)
        else:
            return await _launch_linux(candidates, name.strip(), extra_args)
    except Exception as e:
        return {"error": f"open_application: {e}"}


async def _default_open(path: str) -> dict:
    if SYS == "Darwin":
        subprocess.Popen(["open", path], start_new_session=True)
        return {"opened_file": path}
    elif SYS == "Windows":
        try:
            os.startfile(path)  # type: ignore
            return {"opened_file": path}
        except Exception as e:
            subprocess.Popen(["explorer", path], start_new_session=True)
            return {"opened_file": path}
    else:
        for opener in ["xdg-open", "gio"]:
            if shutil.which(opener):
                subprocess.Popen([opener, path], start_new_session=True)
                return {"opened_file": path}
        return {"error": "No file opener. Install: sudo apt install xdg-utils"}


async def _launch_mac(candidates: list, extra_args: list) -> dict:
    for c in candidates:
        # Try 'open -a'
        cmd = ["open", "-a", c] + (["--args"] + extra_args if extra_args else [])
        r = _run(cmd, timeout=10)
        if r.get("exit_code") == 0:
            return {"launched": c}
        # Try direct binary
        if shutil.which(c.lower()):
            subprocess.Popen([c.lower()] + extra_args, start_new_session=True)
            return {"launched": c}
    return {"error": f"Could not launch '{candidates[0]}' on macOS"}


async def _launch_win(candidates: list, extra_args: list) -> dict:
    for c in candidates:
        if shutil.which(c):
            subprocess.Popen([c] + extra_args, start_new_session=True)
            return {"launched": c}
    # Shell 'start' command
    app = candidates[0]
    subprocess.Popen(f'start "" "{app}"', shell=True, start_new_session=True)
    return {"launched": app}


async def _launch_linux(candidates: list, original: str, extra_args: list) -> dict:
    # 1. Direct binary
    for c in candidates:
        if c and shutil.which(c):
            subprocess.Popen([c] + extra_args, start_new_session=True)
            return {"launched": c}

    # 2. .desktop file scan
    desktop_dirs = [
        Path("/usr/share/applications"), Path("/usr/local/share/applications"),
        Path.home() / ".local/share/applications",
        Path("/var/lib/flatpak/exports/share/applications"),
        Path.home() / ".local/share/flatpak/exports/share/applications",
        Path("/snap/bin"),
    ]
    search = original.lower().replace(" ", "")
    for ddir in desktop_dirs:
        if not ddir.exists(): continue
        for df in ddir.glob("*.desktop"):
            if search in df.stem.lower() or search in df.name.lower():
                try:
                    text = df.read_text(errors="replace")
                    for line in text.splitlines():
                        if line.startswith("Exec="):
                            exec_str = re.sub(r"%[A-Za-z]", "", line[5:]).strip()
                            parts = exec_str.split()
                            if parts and shutil.which(parts[0]):
                                subprocess.Popen(parts + extra_args, start_new_session=True)
                                return {"launched": original, "via": ".desktop", "binary": parts[0]}
                except Exception:
                    pass

    # 3. Snap
    if shutil.which("snap"):
        r = _run(["snap", "run", original.lower()] + extra_args, timeout=10)
        if r.get("exit_code") == 0:
            return {"launched": original, "via": "snap"}

    # 4. Flatpak
    if shutil.which("flatpak"):
        r = _run(["flatpak", "run", original] + extra_args, timeout=10)
        if r.get("exit_code") == 0:
            return {"launched": original, "via": "flatpak"}

    # 5. xdg-open (opens file managers, associations, etc.)
    if shutil.which("xdg-open") and extra_args:
        subprocess.Popen(["xdg-open"] + extra_args, start_new_session=True)
        return {"launched": original, "via": "xdg-open"}

    return {"error": f"'{original}' not found. Check the name or install it."}


async def tool_open_url(url: str = "", **_) -> dict:
    if not url: return {"error": "No URL"}
    url = url.strip()
    if not re.match(r"^https?://", url, re.I): url = f"https://{url}"
    import webbrowser
    loop = asyncio.get_event_loop()
    success = await loop.run_in_executor(None, webbrowser.open, url)
    return {"opened": url} if success else {"error": f"Could not open {url}"}


# ═════════════════════════════════════════════════════════════════════════════
# WEATHER, PING, INTERNET, DATETIME, REMINDER
# ═════════════════════════════════════════════════════════════════════════════

async def tool_weather(city: str = "", lat: float = 0.0, lon: float = 0.0, **_) -> dict:
    try:
        loop = asyncio.get_event_loop()
        if city and not (lat and lon):
            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(url, headers={"User-Agent": "BySalim/2.0"})
                with urllib.request.urlopen(req, timeout=15) as r: return json.loads(r.read())
            geo = await loop.run_in_executor(None, _geo)
            res = geo.get("results")
            if not res: return {"error": f"City not found: '{city}'"}
            lat, lon = float(res[0]["latitude"]), float(res[0]["longitude"])
            city = f"{res[0].get('name',city)}, {res[0].get('country','')}"

        if not (lat and lon): return {"error": "Provide city name or lat/lon"}
        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": "BySalim/2.0"})
            with urllib.request.urlopen(req, timeout=15) as r: return json.loads(r.read())
        wx = await loop.run_in_executor(None, _wx)
        cw = wx.get("current_weather", {})
        wmo = {0:"Clear ☀️",1:"Mainly clear 🌤️",2:"Partly cloudy ⛅",3:"Overcast ☁️",
               45:"Fog 🌫️",51:"Drizzle 🌦️",61:"Rain 🌧️",71:"Snow ❄️",80:"Showers 🌦️",95:"Thunderstorm ⛈️"}
        hourly = wx.get("hourly", {})
        return {"city": city, "temperature_c": cw.get("temperature"),
                "feels_like_c": (hourly.get("apparent_temperature") or [None])[0],
                "windspeed_kmh": cw.get("windspeed"),
                "conditions": wmo.get(cw.get("weathercode",-1), f"Code {cw.get('weathercode')}"),
                "humidity_percent": (hourly.get("relativehumidity_2m") or [None])[0],
                "is_day": bool(cw.get("is_day", 1))}
    except Exception as e:
        return {"error": f"weather: {e}"}


async def tool_ping(host: str = "8.8.8.8", count: int = 4, **_) -> dict:
    host = host.strip()
    count = int(_clamp(int(count), 1, 20))
    try:
        cmd = ["ping", "-n" if SYS == "Windows" else "-c", str(count), host]
        r = await _run_async(_run, cmd, count * 5 + 10)
        if "error" in r: return r
        out = r.get("stdout", "")
        avg = None
        for pat in [r"(?:avg|mean)[^=]+=\s*[\d.]+/([\d.]+)", r"Average\s*=\s*([\d.]+)\s*ms"]:
            m = re.search(pat, out, re.I)
            if m: avg = float(m.group(1)); break
        loss = None
        m2 = re.search(r"(\d+(?:\.\d+)?)\s*%\s*packet loss", out, re.I)
        if m2: loss = m2.group(1) + "%"
        return {"host": host, "packets": count, "avg_ms": avg, "packet_loss": loss,
                "reachable": r.get("exit_code", 1) == 0}
    except Exception as e:
        return {"error": f"ping: {e}"}


async def tool_internet_speed(**_) -> dict:
    results: 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 _chk(name, url):
        t = time.monotonic()
        try:
            def _req():
                req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "BySalim/2.0"})
                with urllib.request.urlopen(req, timeout=8): pass
            await loop.run_in_executor(None, _req)
            results[name] = {"reachable": True, "latency_ms": round((time.monotonic()-t)*1000,1)}
        except Exception as e:
            results[name] = {"reachable": False, "error": str(e)[:60]}
    await asyncio.gather(*[_chk(n,u) for n,u in endpoints])
    return {"connectivity": results}


async def tool_reminder(message: str = "", delay_seconds: int = 0,
                         delay_minutes: int = 0, delay_hours: int = 0, **_) -> dict:
    if not message: return {"error": "No message"}
    total = int(delay_seconds) + int(delay_minutes)*60 + int(delay_hours)*3600
    if total <= 0: return {"error": "Provide positive delay"}
    if total > 86400*7: return {"error": "Max delay is 7 days"}
    async def _fire():
        await asyncio.sleep(total)
        await _push(f"⏰ *Reminder:* {message}")
    asyncio.ensure_future(_fire())
    eta = datetime.datetime.now() + datetime.timedelta(seconds=total)
    h, r = divmod(total, 3600); m, s = divmod(r, 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}


async def tool_datetime(**_) -> dict:
    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 TOOLS
# ═════════════════════════════════════════════════════════════════════════════

async def tool_python_info(**_) -> dict:
    try:
        r = await _run_async(_run, [sys.executable, "-m", "pip", "list", "--format=json"], 30)
        pkgs = json.loads(r.get("stdout","[]")) if "error" not in r else []
        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 e:
        return {"error": f"python_info: {e}"}


async def tool_env_vars(filter_key: str = "", **_) -> dict:
    SENS = {"key","secret","token","password","passwd","pwd","api","auth","cred"}
    safe = {k: ("***" if any(s in k.lower() for s in SENS) else v)
            for k,v in os.environ.items()}
    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)}


async def tool_git_status(path: str = ".", **_) -> dict:
    try:
        p = _resolve(path)
        if not p.exists(): return {"error": f"Not found: {path}"}
        r = await _run_async(_run, ["git", "status", "--short", "--branch"], 15, False, str(p))
        if "error" in r: return r
        if r.get("exit_code",1) != 0:
            stderr = r.get("stderr","")
            return {"error": "Not a git repository" if "not a git" in stderr.lower() else stderr}
        return {"path": str(p), "status": r["stdout"] or "(clean)"}
    except Exception as e:
        return {"error": f"git_status: {e}"}


async def tool_git_log(path: str = ".", count: int = 10, **_) -> dict:
    try:
        p = _resolve(path)
        count = int(_clamp(count, 1, 50))
        r = await _run_async(_run,
            ["git","log",f"--max-count={count}","--no-color","--pretty=format:%h\t%s\t%cr\t%an"],
            15, False, str(p))
        if "error" in r: return r
        commits = []
        for line in r["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 e:
        return {"error": f"git_log: {e}"}


async def tool_pip_install(package: str = "", **_) -> dict:
    if not package: return {"error": "No package"}
    if re.search(r"[;&|`$()]", package): return {"error": "Invalid package name"}
    return await _run_async(_run,
        [sys.executable, "-m", "pip", "install", "--break-system-packages", package.strip()], 120)


async def tool_pip_list(**_) -> dict:
    r = await _run_async(_run, [sys.executable, "-m", "pip", "list", "--format=json"], 30)
    if "error" in r: return r
    try:
        pkgs = json.loads(r["stdout"])
        return {"packages": pkgs, "count": len(pkgs)}
    except Exception:
        return {"error": "Could not parse pip list"}


async def tool_calculate(expression: str = "", **_) -> dict:
    if not expression: return {"error": "No expression"}
    clean = expression.strip().replace("^", "**")
    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"Unsafe: '{stripped.strip()}'"}
    import math as _m
    ns = {"__builtins__":{},"abs":abs,"round":round,"min":min,"max":max,"pow":pow,"sum":sum,
          "sqrt":_m.sqrt,"floor":_m.floor,"ceil":_m.ceil,"log":_m.log,"log10":_m.log10,
          "log2":_m.log2,"sin":_m.sin,"cos":_m.cos,"tan":_m.tan,"pi":_m.pi,"e":_m.e,"inf":_m.inf}
    try:
        result = eval(clean, ns)
        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 Exception as e:
        return {"error": f"Cannot evaluate: {e}"}


async def tool_word_count(text: str = "", path: str = "", **_) -> dict:
    try:
        if path:
            p = _resolve(path)
            if not p.exists(): return {"error": f"Not found: {path}"}
            text = p.read_text(encoding="utf-8", errors="replace")
        lines = text.splitlines()
        words = text.split()
        sentences = len([s for s in re.split(r"[.!?]+", text) if s.strip()])
        paragraphs = len([pp for pp in text.split("\n\n") if pp.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 e:
        return {"error": f"word_count: {e}"}


async def tool_clarify(message: str = "", **_) -> dict:
    return {"clarify": message.strip() or "Could you rephrase that?"}


# ═════════════════════════════════════════════════════════════════════════════
# TOOL REGISTRY
# ═════════════════════════════════════════════════════════════════════════════

TOOLS: dict[str, dict] = {
    "system_health":    {"fn": tool_system_health,    "description": "CPU, RAM, disk, swap and top processes",                         "danger": False},
    "uptime":           {"fn": tool_uptime,            "description": "System uptime and boot time",                                    "danger": False},
    "battery":          {"fn": tool_battery,           "description": "Battery percent, charging status, time remaining",               "danger": False},
    "network_stats":    {"fn": tool_network_stats,     "description": "Network I/O, interfaces, active connections",                    "danger": False},
    "list_processes":   {"fn": tool_list_processes,    "description": "Running processes with CPU/memory",                             "danger": False},
    "kill_process":     {"fn": tool_kill_process,      "description": "Kill process by PID or name",                                   "danger": True},
    "run_shell":        {"fn": tool_run_shell,         "description": "Execute shell command",                                         "danger": True},
    "list_directory":   {"fn": tool_list_directory,    "description": "List files in directory",                                       "danger": False},
    "read_file":        {"fn": tool_read_file,         "description": "Read text file contents",                                       "danger": False},
    "write_file":       {"fn": tool_write_file,        "description": "Write or append to file",                                       "danger": True},
    "delete_file":      {"fn": tool_delete_file,       "description": "Delete file or directory (trash by default)",                   "danger": True},
    "copy_file":        {"fn": tool_copy_file,         "description": "Copy file or directory",                                        "danger": False},
    "move_file":        {"fn": tool_move_file,         "description": "Move or rename file",                                           "danger": True},
    "rename_file":      {"fn": tool_rename_file,       "description": "Rename file or folder",                                         "danger": False},
    "search_files":     {"fn": tool_search_files,      "description": "Search files by name pattern",                                  "danger": False},
    "create_directory": {"fn": tool_create_directory,  "description": "Create directory",                                              "danger": False},
    "disk_usage":       {"fn": tool_disk_usage,        "description": "Disk space usage",                                              "danger": False},
    "send_file":        {"fn": tool_send_file,         "description": "Send any file to Telegram (image/audio/video/document)",        "danger": False},
    "screenshot":       {"fn": tool_screenshot,        "description": "Take screenshot",                                               "danger": False},
    "get_volume":       {"fn": tool_get_volume,        "description": "Get system volume (0-100)",                                     "danger": False},
    "set_volume":       {"fn": tool_set_volume,        "description": "Set system volume (0-100)",                                     "danger": False},
    "mute_volume":      {"fn": tool_mute_volume,       "description": "Mute system audio",                                             "danger": False},
    "get_clipboard":    {"fn": tool_get_clipboard,     "description": "Read clipboard text",                                           "danger": False},
    "set_clipboard":    {"fn": tool_set_clipboard,     "description": "Copy text to clipboard",                                        "danger": False},
    "image_info":       {"fn": tool_image_info,        "description": "Image metadata: dimensions, format, EXIF, color stats",         "danger": False},
    "image_resize":     {"fn": tool_image_resize,      "description": "Resize image to width/height",                                  "danger": False},
    "image_rotate":     {"fn": tool_image_rotate,      "description": "Rotate image by angle degrees",                                 "danger": False},
    "image_crop":       {"fn": tool_image_crop,        "description": "Crop image to pixel box",                                       "danger": False},
    "image_convert":    {"fn": tool_image_convert,     "description": "Convert image to jpg/png/webp/bmp/gif/tiff",                    "danger": False},
    "audio_info":       {"fn": tool_audio_info,        "description": "Audio metadata: title, artist, album, duration, bitrate",       "danger": False},
    "audio_convert":    {"fn": tool_audio_convert,     "description": "Convert audio format (mp3/flac/wav/ogg/m4a) via ffmpeg/pydub",  "danger": False},
    "audio_trim":       {"fn": tool_audio_trim,        "description": "Trim audio: start=sec, duration=sec",                          "danger": False},
    "video_info":       {"fn": tool_video_info,        "description": "Video metadata: duration, resolution, codec, fps",              "danger": False},
    "video_thumbnail":  {"fn": tool_video_thumbnail,   "description": "Extract video thumbnail at timestamp",                          "danger": False},
    "video_convert":    {"fn": tool_video_convert,     "description": "Convert video format (mp4/mkv/webm/avi) via ffmpeg",            "danger": False},
    "video_trim":       {"fn": tool_video_trim,        "description": "Trim video: start=sec, duration=sec",                           "danger": False},
    "open_url":         {"fn": tool_open_url,          "description": "Open URL in browser",                                           "danger": False},
    "open_application": {"fn": tool_open_application,  "description": "Launch app or open file with default app",                     "danger": False},
    "weather":          {"fn": tool_weather,           "description": "Current weather for city",                                      "danger": False},
    "ping":             {"fn": tool_ping,              "description": "Ping host, report latency and packet loss",                     "danger": False},
    "internet_speed":   {"fn": tool_internet_speed,    "description": "Check internet connectivity to multiple endpoints",             "danger": False},
    "datetime":         {"fn": tool_datetime,          "description": "Current date, time, timezone, week number",                     "danger": False},
    "reminder":         {"fn": tool_reminder,          "description": "Set timed reminder via Telegram",                               "danger": False},
    "python_info":      {"fn": tool_python_info,       "description": "Python version, executable, packages",                          "danger": False},
    "env_vars":         {"fn": tool_env_vars,          "description": "Environment variables (sensitive redacted)",                    "danger": False},
    "git_status":       {"fn": tool_git_status,        "description": "git status",                                                    "danger": False},
    "git_log":          {"fn": tool_git_log,           "description": "Recent git commits",                                            "danger": False},
    "pip_install":      {"fn": tool_pip_install,       "description": "Install Python package",                                        "danger": True},
    "pip_list":         {"fn": tool_pip_list,          "description": "List installed packages",                                       "danger": False},
    "calculate":        {"fn": tool_calculate,         "description": "Evaluate math expression",                                      "danger": False},
    "word_count":       {"fn": tool_word_count,        "description": "Count words/lines/chars in text or file",                       "danger": False},
    "clarify":          {"fn": tool_clarify,           "description": "Ask user to clarify",                                           "danger": False},
}


# ═════════════════════════════════════════════════════════════════════════════
# DISPATCHER
# ═════════════════════════════════════════════════════════════════════════════

async def run_tool(name: str, args: dict) -> dict:
    if not isinstance(args, dict): args = {}
    entry = TOOLS.get(name)
    if entry is None:
        return {"error": f"Unknown tool '{name}'. Available: {', '.join(sorted(TOOLS))}"}
    t0 = time.monotonic()
    try:
        result = await asyncio.wait_for(entry["fn"](**args), timeout=TOOL_TIMEOUT + 15)
    except asyncio.TimeoutError:
        result = {"error": f"Tool '{name}' timed out"}
    except TypeError as e:
        result = {"error": f"Bad args for '{name}': {e}"}
    except Exception as e:
        log.exception("Tool '%s' crashed", name)
        result = {"error": f"Tool '{name}' crashed: {e}"}
    ms = (time.monotonic() - t0) * 1000
    log_task(name, args, result, ms)
    return result


def tool_list_for_prompt() -> str:
    return "\n".join(
        f"  {name}: {e['description']}" + (" ⚠️ DANGEROUS" if e["danger"] else "")
        for name, e in TOOLS.items()
    )
