"""
bot.py — Telegram long-polling bot.

Classes:
  TelegramAPI  — Raw HTTP wrapper (stdlib urllib, async via executor)
  TelegramBot  — Full bot: routing, confirmations, command dispatch, formatting

All HTTP calls include automatic retry with exponential back-off.
Messages longer than Telegram's 4096-char limit are auto-split into chunks.
"""
from __future__ import annotations

import asyncio
import json
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any

from .ai import AIEngine
from .config import (
    MAX_RETRIES,
    RETRY_BASE_DELAY,
    RETRY_MAX_DELAY,
    TELEGRAM_CAPTION_MAX_LENGTH,
    TELEGRAM_MAX_MESSAGE_LENGTH,
)
from .logger import get_logger
from .tools import run_tool

log = get_logger("bot")


# ---------------------------------------------------------------------------
# Telegram HTTP wrapper
# ---------------------------------------------------------------------------

class TelegramAPI:
    """
    Minimal stdlib-only Telegram Bot API client.
    Every method runs the HTTP call in asyncio's thread executor so
    the event loop is never blocked.
    """

    BASE = "https://api.telegram.org/bot{token}/{method}"

    def __init__(self, token: str) -> None:
        if not token or not token.strip():
            raise ValueError("Telegram bot token cannot be empty")
        self._token = token.strip()

    def _url(self, method: str) -> str:
        return self.BASE.format(token=self._token, method=method)

    def _post_sync(self, method: str, payload: dict, timeout: float = 35.0) -> dict:
        """Synchronous POST — always returns a dict. Never raises."""
        url = self._url(method)
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        headers = {"Content-Type": "application/json; charset=utf-8"}
        req = urllib.request.Request(url, data=body, headers=headers, method="POST")
        try:
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                return json.loads(resp.read())
        except urllib.error.HTTPError as exc:
            body_bytes = exc.read()
            try:
                data = json.loads(body_bytes)
                return data  # Telegram error body is still valid JSON
            except Exception:
                return {"ok": False, "description": body_bytes.decode(errors="replace")[:400]}
        except urllib.error.URLError as exc:
            return {"ok": False, "description": f"URLError: {exc.reason}"}
        except Exception as exc:
            return {"ok": False, "description": str(exc)[:400]}

    async def _post(self, method: str, payload: dict, timeout: float = 35.0) -> dict:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, lambda: self._post_sync(method, payload, timeout))

    async def _post_with_retry(self, method: str, payload: dict) -> dict:
        """POST with exponential back-off retry on transient failures."""
        last: dict = {}
        for attempt in range(MAX_RETRIES):
            result = await self._post(method, payload)
            if result.get("ok"):
                return result
            desc = result.get("description", "")
            error_code = result.get("error_code", 0)
            # Permanent failures — don't retry
            if error_code in (400, 401, 403, 404):
                log.warning("Telegram %s permanent error %s: %s", method, error_code, desc)
                return result
            # Rate limit (429) — wait and retry
            retry_after = result.get("parameters", {}).get("retry_after", 0)
            delay = retry_after if retry_after > 0 else min(RETRY_BASE_DELAY * (2 ** attempt), RETRY_MAX_DELAY)
            log.warning("Telegram %s error (attempt %d/%d): %s — retrying in %.1fs",
                        method, attempt + 1, MAX_RETRIES, desc, delay)
            await asyncio.sleep(delay)
            last = result
        return last

    # ------------------------------------------------------------------
    # Bot API methods
    # ------------------------------------------------------------------

    async def get_updates(self, offset: int = 0, timeout: int = 30) -> list[dict]:
        data = await self._post(
            "getUpdates",
            {"offset": offset, "timeout": timeout, "allowed_updates": ["message", "callback_query"]},
            timeout=timeout + 10,
        )
        return data.get("result", []) if data.get("ok") else []

    async def send_message(
        self,
        chat_id: int,
        text: str,
        parse_mode: str = "Markdown",
        reply_markup: dict | None = None,
        disable_web_page_preview: bool = True,
    ) -> dict:
        """Send a text message, auto-splitting if longer than Telegram's limit."""
        chunks = _split_message(text, TELEGRAM_MAX_MESSAGE_LENGTH)
        last: dict = {}
        for i, chunk in enumerate(chunks):
            payload: dict[str, Any] = {
                "chat_id": chat_id,
                "text": chunk,
                "parse_mode": parse_mode,
                "disable_web_page_preview": disable_web_page_preview,
            }
            if reply_markup and i == 0:
                payload["reply_markup"] = reply_markup
            result = await self._post_with_retry("sendMessage", payload)
            if not result.get("ok"):
                # Retry without parse_mode (Markdown may have bad characters)
                plain_payload: dict[str, Any] = {"chat_id": chat_id, "text": _strip_markdown(chunk)}
                if reply_markup and i == 0:
                    plain_payload["reply_markup"] = reply_markup
                result = await self._post_with_retry("sendMessage", plain_payload)
            last = result
        return last

    async def send_photo(self, chat_id: int, photo_path: str, caption: str = "") -> dict:
        loop = asyncio.get_event_loop()
        caption = caption[:TELEGRAM_CAPTION_MAX_LENGTH]
        return await loop.run_in_executor(
            None, lambda: self._send_file_sync(chat_id, photo_path, caption, "photo", "sendPhoto")
        )

    async def send_document(self, chat_id: int, file_path: str, caption: str = "") -> dict:
        loop = asyncio.get_event_loop()
        caption = caption[:TELEGRAM_CAPTION_MAX_LENGTH]
        return await loop.run_in_executor(
            None, lambda: self._send_file_sync(chat_id, file_path, caption, "document", "sendDocument")
        )

    def _send_file_sync(
        self,
        chat_id: int,
        file_path: str,
        caption: str,
        field_name: str,
        method: str,
    ) -> dict:
        """Multipart upload helper — works for photos and documents."""
        import mimetypes, os
        url = self._url(method)
        boundary = f"callme_boundary_{secrets.token_hex(8)}"
        filename = os.path.basename(file_path)
        try:
            with open(file_path, "rb") as f:
                file_bytes = f.read()
        except OSError as exc:
            return {"ok": False, "description": f"Cannot read file: {exc}"}

        mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
        CRLF = b"\r\n"

        def _part(name: str, value: str) -> bytes:
            return (
                f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}".encode("utf-8")
            )

        parts: list[bytes] = [
            _part("chat_id", str(chat_id)),
            (
                f'--{boundary}\r\nContent-Disposition: form-data; name="{field_name}"; '
                f'filename="{filename}"\r\nContent-Type: {mime}\r\n\r\n'.encode("utf-8")
                + file_bytes
            ),
        ]
        if caption:
            parts.append(_part("caption", caption))
        parts.append(f"--{boundary}--".encode("utf-8"))

        body = CRLF.join(parts)
        headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"}
        req = urllib.request.Request(url, data=body, headers=headers, method="POST")
        try:
            with urllib.request.urlopen(req, timeout=60) as resp:
                return json.loads(resp.read())
        except urllib.error.HTTPError as exc:
            return {"ok": False, "description": exc.read().decode(errors="replace")[:400]}
        except Exception as exc:
            return {"ok": False, "description": str(exc)[:400]}

    async def edit_message_text(
        self, chat_id: int, message_id: int, text: str, parse_mode: str = "Markdown"
    ) -> dict:
        text = text[:TELEGRAM_MAX_MESSAGE_LENGTH]
        payload: dict[str, Any] = {
            "chat_id": chat_id,
            "message_id": message_id,
            "text": text,
            "parse_mode": parse_mode,
        }
        result = await self._post_with_retry("editMessageText", payload)
        if not result.get("ok"):
            # Retry as plain text
            payload2: dict[str, Any] = {
                "chat_id": chat_id,
                "message_id": message_id,
                "text": _strip_markdown(text),
            }
            result = await self._post_with_retry("editMessageText", payload2)
        return result

    async def send_chat_action(self, chat_id: int, action: str = "typing") -> dict:
        return await self._post("sendChatAction", {"chat_id": chat_id, "action": action})

    async def answer_callback_query(self, callback_query_id: str, text: str = "") -> dict:
        return await self._post(
            "answerCallbackQuery",
            {"callback_query_id": callback_query_id, "text": text[:200]},
        )

    async def get_me(self) -> dict:
        return await self._post_with_retry("getMe", {})

    async def delete_webhook(self) -> dict:
        return await self._post_with_retry("deleteWebhook", {"drop_pending_updates": False})


# ---------------------------------------------------------------------------
# Text utilities
# ---------------------------------------------------------------------------

def _strip_markdown(text: str) -> str:
    """Remove Telegram Markdown v1 special characters."""
    for ch in ["*", "_", "`", "[", "]", "(", ")"]:
        text = text.replace(ch, "")
    return text


def _split_message(text: str, max_len: int = TELEGRAM_MAX_MESSAGE_LENGTH) -> list[str]:
    """Split a message into chunks ≤ max_len chars, splitting at newlines when possible."""
    if len(text) <= max_len:
        return [text]
    chunks: list[str] = []
    while text:
        if len(text) <= max_len:
            chunks.append(text)
            break
        # Try to split at a newline
        split_at = text.rfind("\n", 0, max_len)
        if split_at <= 0:
            split_at = max_len
        chunks.append(text[:split_at])
        text = text[split_at:].lstrip("\n")
    return [c for c in chunks if c.strip()]


def _bar(pct: float, width: int = 10) -> str:
    """Build a text progress bar from block characters."""
    pct = max(0.0, min(100.0, float(pct)))
    filled = round(pct / 100 * width)
    return "█" * filled + "░" * (width - filled)


# ---------------------------------------------------------------------------
# Formatter
# ---------------------------------------------------------------------------

def fmt(result: dict) -> str:
    """Convert any tool result dict into a readable Telegram message string."""

    if not isinstance(result, dict):
        return f"⚠️ Unexpected result type: {type(result).__name__}"

    # Error
    if "error" in result:
        err = str(result["error"])
        if len(err) > 1000:
            err = err[:1000] + "…"
        return f"❌ *Error:* {err}"

    # Clarify
    if "clarify" in result:
        return f"🤔 {result['clarify']}"

    # System health
    if "cpu_percent" in result and "ram_percent" in result:
        cpu = result["cpu_percent"]
        ram = result["ram_percent"]
        disk = result.get("disk_percent", 0)
        top = result.get("top_processes", [])
        lines = [
            "🖥️ *System Health*",
            f"CPU:  {_bar(cpu)} {cpu}%  ({result.get('cpu_count', '?')} cores)",
            f"RAM:  {_bar(ram)} {ram}%  ({result.get('ram_used_gb')} / {result.get('ram_total_gb')} GB)",
            f"Disk: {_bar(disk)} {disk}%  ({result.get('disk_used_gb')} / {result.get('disk_total_gb')} GB)",
            f"Swap: {result.get('swap_used_gb', 0)} GB used",
        ]
        if top:
            lines.append("\n📊 *Top Processes:*")
            for p in top:
                lines.append(f"  `{p['name'][:20]:<20}` CPU {p['cpu_pct']}%  MEM {p['mem_pct']}%")
        return "\n".join(lines)

    # Uptime
    if "uptime" in result and "boot_time" in result:
        return f"⏱️ *Uptime:* {result['uptime']}\n🕐 *Boot time:* {result['boot_time']}"

    # Battery
    if "percent" in result and "plugged_in" in result:
        pct = result["percent"]
        plug = "🔌 Charging" if result["plugged_in"] else "🔋 On battery"
        icon = "🔋" if pct > 20 else "🪫"
        return f"{icon} *Battery:* {_bar(pct)} {pct}%\n{plug}\n⏳ Time left: {result['time_left']}"

    # Shell output
    if "exit_code" in result:
        code = result["exit_code"]
        icon = "✅" if code == 0 else "⚠️"
        out = str(result.get("stdout", ""))
        err = str(result.get("stderr", ""))
        parts = [f"{icon} *Exit code:* `{code}`"]
        if out:
            out_display = out if len(out) <= 3000 else out[:3000] + "\n[…truncated]"
            parts.append(f"```\n{out_display}\n```")
        if err:
            err_display = err if len(err) <= 800 else err[:800] + "\n[…truncated]"
            parts.append(f"⚠️ *stderr:*\n```\n{err_display}\n```")
        if not out and not err:
            parts.append("_(no output)_")
        return "\n".join(parts)

    # Volume (current)
    if "volume" in result and len(result) <= 2:
        v = result["volume"]
        icon = "🔇" if v == 0 else ("🔈" if v < 40 else ("🔉" if v < 70 else "🔊"))
        return f"{icon} *Volume:* {_bar(v)} {v}%"

    if "volume_set" in result:
        v = result["volume_set"]
        return f"🔊 *Volume set:* {_bar(v)} {v}%"

    if "muted" in result:
        return "🔇 *Audio muted*"

    # Screenshot (just metadata — actual photo sent separately)
    if "path" in result and "size_kb" in result:
        return f"📸 Screenshot: `{result['path']}` — {result['size_kb']} KB"

    # Directory listing
    if "items" in result and "path" in result:
        items: list[dict] = result["items"]
        path_str = result["path"]
        count = result.get("count", len(items))
        lines = [f"📁 *{path_str}* ({count} items)\n"]
        for item in items[:40]:
            icon = "📂" if item.get("type") == "dir" else "📄"
            size = f"  {item['size_bytes']:,}B" if item.get("size_bytes") is not None else ""
            mod = f"  {item['modified']}" if item.get("modified") else ""
            lines.append(f"{icon} `{item['name']}`{size}{mod}")
        if count > 40:
            lines.append(f"…and {count - 40} more")
        return "\n".join(lines)

    # File read
    if "content" in result and "lines" in result:
        content = str(result["content"])
        trunc = " *(truncated)*" if result.get("truncated") else ""
        header = f"📄 *{result.get('path', 'file')}*{trunc} — {result['lines']} lines\n"
        body = content[:3500] if len(content) > 3500 else content
        return header + f"```\n{body}\n```"

    # File write
    if "bytes_written" in result:
        return (
            f"✅ *File saved:* `{result['path']}`\n"
            f"💾 {result['bytes_written']} bytes ({result.get('mode', 'write')})"
        )

    # Deleted
    if "deleted" in result:
        icon = "🗂️" if result.get("type") == "directory" else "🗑️"
        return f"{icon} *Deleted:* `{result['deleted']}`"

    # Copied / moved
    if "copied" in result:
        return f"📋 *Copied*\n  From: `{result['copied']}`\n  To: `{result['to']}`"
    if "moved" in result:
        return f"🚚 *Moved*\n  From: `{result['moved']}`\n  To: `{result['to']}`"

    # Created directory
    if "created" in result:
        return f"📁 *Directory created:* `{result['created']}`"

    # Search results
    if "matches" in result and "root" in result:
        matches: list[dict] = result["matches"]
        count = result["count"]
        pattern = result.get("pattern", "")
        lines = [f"🔍 *Found {count} matches* in `{result['root']}`"]
        if pattern:
            lines[0] += f" (pattern: `{pattern}`)"
        lines.append("")
        for m in matches[:25]:
            icon = "📂" if m.get("type") == "dir" else "📄"
            size = f"  {m['size_bytes']:,}B" if m.get("size_bytes") is not None else ""
            lines.append(f"{icon} `{m['path']}`{size}")
        if count > 25:
            lines.append(f"…and {count - 25} more")
        return "\n".join(lines)

    # Weather
    if "temperature_c" in result:
        temp = result["temperature_c"]
        feels = result.get("feels_like_c")
        city = result.get("city", "")
        wind = result.get("windspeed_kmh", "?")
        hum = result.get("humidity_percent")
        day_icon = "☀️" if result.get("is_day") else "🌙"
        lines = [
            f"{day_icon} *Weather in {city}*",
            f"🌡️ Temperature: *{temp}°C*" + (f"  (feels like {feels}°C)" if feels is not None else ""),
            f"🌤️ {result.get('conditions', 'Unknown')}",
            f"💨 Wind: {wind} km/h",
        ]
        if hum is not None:
            lines.append(f"💧 Humidity: {hum}%")
        return "\n".join(lines)

    # Ping
    if "host" in result and "packets" in result:
        avg = result.get("avg_ms")
        avg_str = f"{avg} ms" if avg is not None else "N/A"
        loss = result.get("packet_loss", "?")
        reach = "✅ Reachable" if result.get("reachable") else "❌ Unreachable"
        return (
            f"🏓 *Ping {result['host']}*\n"
            f"📊 Packets: {result['packets']}  |  Loss: {loss}\n"
            f"⏱️ Avg latency: {avg_str}\n"
            f"{reach}"
        )

    # Internet connectivity
    if "connectivity" in result:
        lines = ["🌐 *Internet Connectivity*"]
        for name, info in result["connectivity"].items():
            if info.get("reachable"):
                lines.append(f"  ✅ {name}: {info.get('latency_ms')} ms")
            else:
                lines.append(f"  ❌ {name}: unreachable")
        return "\n".join(lines)

    # Network stats
    if "bytes_sent_mb" in result:
        lines = [
            "📡 *Network Stats*",
            f"⬆️ Sent: {result['bytes_sent_mb']} MB  |  Packets: {result.get('packets_sent', '?')}",
            f"⬇️ Received: {result['bytes_recv_mb']} MB  |  Packets: {result.get('packets_recv', '?')}",
        ]
        conns = result.get("active_connections", -1)
        if conns >= 0:
            lines.append(f"🔗 Active connections: {conns}")
        ifaces = result.get("interfaces", {})
        if ifaces:
            lines.append("🌐 *Interfaces:*")
            for name, addr in ifaces.items():
                lines.append(f"  {name}: `{addr}`")
        return "\n".join(lines)

    # Datetime
    if "local" in result and "date" in result:
        return (
            f"🕐 *Date & Time*\n"
            f"📅 {result['date']}\n"
            f"⏰ {result['time']}  ({result.get('timezone', '?')}  UTC{result.get('utc_offset_hours', ''):+g})\n"
            f"🌍 UTC: {result['utc']}\n"
            f"🗓️ Week {result.get('week_number')}  ·  Day {result.get('day_of_year')} of year"
        )

    # Reminder set
    if "reminder_set" in result:
        return (
            f"⏰ *Reminder set!*\n"
            f"💬 \"{result['reminder_set']}\"\n"
            f"⏱️ Fires at: {result['fires_at']}  ({result.get('delay_human', '?')} from now)"
        )

    # Clipboard
    if "clipboard" in result:
        text = str(result["clipboard"])
        display = text[:1000] + ("…" if len(text) > 1000 else "")
        return f"📋 *Clipboard* ({result.get('length', len(text))} chars):\n```\n{display}\n```"
    if "clipboard_set" in result:
        return f"📋 *Copied to clipboard:*\n`{result['clipboard_set']}`"

    # URL / app
    if "opened" in result:
        return f"🌐 *Opened:* {result['opened']}"
    if "launched" in result:
        return f"🚀 *Launched:* {result['launched']}"

    # Kill process
    if "killed" in result:
        killed = "\n".join(f"  • {k}" for k in result["killed"])
        return f"💀 *Killed:*\n{killed}"

    # Process list
    if "processes" in result:
        procs: list[dict] = result["processes"]
        total = result.get("total", len(procs))
        lines = [f"⚙️ *Processes* ({total} total, top {len(procs)}):"]
        for p in procs[:25]:
            lines.append(
                f"  `{p['pid']:>6}` `{p['name'][:22]:<22}` CPU {p['cpu_pct']:>5}%  MEM {p['mem_pct']:>5}%"
            )
        return "\n".join(lines)

    # Disk usage
    if "total_gb" in result and "free_gb" in result:
        used_pct = result.get("used_percent", 0)
        return (
            f"💾 *Disk* `{result.get('path', '/')}`\n"
            f"{_bar(used_pct)} {used_pct}%\n"
            f"  Used: {result.get('used_gb')} GB  /  Free: {result.get('free_gb')} GB  /  Total: {result.get('total_gb')} GB"
        )

    # Python info
    if "python_version" in result:
        ver = result["python_version"].split()[0]
        return (
            f"🐍 *Python {ver}* ({result.get('implementation', '')})\n"
            f"📍 `{result.get('executable', '?')}`\n"
            f"🖥️ {result.get('platform', '?')}\n"
            f"📦 {result.get('packages_installed', '?')} packages  ·  "
            f"Sample: {', '.join(result.get('packages_sample', []))}"
        )

    # pip list
    if "packages" in result:
        pkgs: list[dict] = result["packages"]
        total = result.get("count", len(pkgs))
        lines = [f"📦 *{total} packages installed:*"]
        for p in pkgs[:30]:
            lines.append(f"  `{p.get('name', '?')}` {p.get('version', '')}")
        if total > 30:
            lines.append(f"  … and {total - 30} more")
        return "\n".join(lines)

    # Calculate
    if "expression" in result and "result" in result:
        return f"🧮 `{result['expression']}` = *{result['result']}*"

    # Word count
    if "words" in result and "characters" in result:
        return (
            f"📝 *Word Count*\n"
            f"  Words: {result['words']}\n"
            f"  Characters: {result['characters']} ({result.get('characters_no_spaces', '?')} excl. spaces)\n"
            f"  Lines: {result['lines']}\n"
            f"  Sentences: {result.get('sentences', '?')}  |  Paragraphs: {result.get('paragraphs', '?')}"
        )

    # Git
    if "status" in result and "path" in result:
        return f"🔀 *Git status* `{result['path']}`\n```\n{result['status']}\n```"
    if "commits" in result:
        lines = [f"📜 *Git log* `{result.get('path', '.')}`"]
        for c in result["commits"][:15]:
            when = f"  ({c['when']})" if c.get("when") else ""
            lines.append(f"  `{c['hash']}` {c['message']}{when}")
        return "\n".join(lines)

    # Created directory
    if "created" in result:
        return f"📁 *Created:* `{result['created']}`"

    # Env vars
    if "env" in result:
        env: dict = result["env"]
        count = result.get("count", len(env))
        lines = [f"🌿 *Environment Variables ({count}):*"]
        for k, v in list(env.items())[:25]:
            v_display = str(v)[:100]
            lines.append(f"  `{k}` = `{v_display}`")
        if count > 25:
            lines.append(f"  … and {count - 25} more")
        return "\n".join(lines)

    # Generic fallback — render key/value pairs
    lines = ["📊 *Result:*"]
    for k, v in result.items():
        val_str = str(v)
        if len(val_str) > 300:
            val_str = val_str[:300] + "…"
        lines.append(f"  *{k}:* `{val_str}`")
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# TelegramBot
# ---------------------------------------------------------------------------

class TelegramBot:
    """
    Full Telegram bot. Receives updates via long-polling, routes to
    slash command handlers or the AI engine, and manages confirmations.
    """

    def __init__(self, token: str, user_id: int, engine: AIEngine) -> None:
        self._api = TelegramAPI(token)
        self._user_id = int(user_id)
        self._chat_id: int = int(user_id)
        self._engine = engine
        self._offset: int = 0
        self._running: bool = False

        # Pending confirmations: key → {tool, args, description}
        self._pending: dict[str, dict] = {}

        # Slash command dispatch table
        self._commands: dict[str, Any] = {
            "start":      self._cmd_start,
            "help":       self._cmd_help,
            "health":     self._cmd_health,
            "status":     self._cmd_status,
            "time":       self._cmd_time,
            "screenshot": self._cmd_screenshot,
            "weather":    self._cmd_weather,
            "ls":         self._cmd_ls,
            "run":        self._cmd_run,
            "ps":         self._cmd_ps,
            "battery":    self._cmd_battery,
            "ping":       self._cmd_ping,
            "memory":     self._cmd_memory,
            "clear":      self._cmd_clear_memory,
            "disk":       self._cmd_disk,
        }

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    async def start(self) -> None:
        """Start the long-polling loop. Runs until stop() is called."""
        self._running = True
        # Clear pending webhook to avoid conflicts
        await self._api.delete_webhook()

        me_data = await self._api.get_me()
        bot_name = me_data.get("result", {}).get("username", "unknown")
        log.info("Bot @%s started, polling for updates…", bot_name)
        print(f"[callme] Bot @{bot_name} started, polling…")

        consecutive_errors = 0
        while self._running:
            try:
                updates = await self._api.get_updates(offset=self._offset, timeout=30)
                consecutive_errors = 0
                for update in updates:
                    self._offset = update["update_id"] + 1
                    try:
                        await self._route(update)
                    except Exception as exc:
                        log.exception("Error routing update %s", update.get("update_id"))
            except Exception as exc:
                consecutive_errors += 1
                delay = min(3 * consecutive_errors, 60)
                log.warning("Polling error (consecutive %d): %s — waiting %.0fs", consecutive_errors, exc, delay)
                await asyncio.sleep(delay)

    def stop(self) -> None:
        """Signal the polling loop to stop."""
        self._running = False

    async def send_message(self, text: str) -> None:
        """Send a message to the configured user (used by reminders, startup, etc.)."""
        await self._api.send_message(self._chat_id, text)

    # ------------------------------------------------------------------
    # Routing
    # ------------------------------------------------------------------

    async def _route(self, update: dict) -> None:
        # Inline keyboard button tap
        if "callback_query" in update:
            await self._handle_callback(update["callback_query"])
            return

        message = update.get("message", {})
        if not message:
            return

        # Security: only serve the configured owner
        sender = message.get("from", {}).get("id")
        if sender != self._user_id:
            log.warning("Ignoring message from unauthorised user %s", sender)
            return

        text = message.get("text", "").strip()
        if not text:
            return

        # Update chat_id (supports both DMs and groups if added)
        self._chat_id = message["chat"]["id"]

        if text.startswith("/"):
            await self._handle_command(text, message)
        else:
            await self._handle_freetext(text, message)

    # ------------------------------------------------------------------
    # Callback handler (confirm / cancel buttons)
    # ------------------------------------------------------------------

    async def _handle_callback(self, cq: dict) -> None:
        await self._api.answer_callback_query(cq["id"])
        data = cq.get("data", "")
        chat_id = cq["message"]["chat"]["id"]
        msg_id = cq["message"]["message_id"]

        if data.startswith("confirm:"):
            key = data[len("confirm:"):]
            pending = self._pending.pop(key, None)
            if not pending:
                await self._api.edit_message_text(chat_id, msg_id, "⚠️ Confirmation expired or already used.")
                return
            await self._api.edit_message_text(chat_id, msg_id, "⏳ Running…")
            await self._api.send_chat_action(chat_id, "typing")
            result = await run_tool(pending["tool"], pending["args"])

            if pending["tool"] == "screenshot" and "path" in result:
                await self._api.send_photo(
                    chat_id, result["path"],
                    caption=f"📸 {pending.get('description', 'Screenshot')} — {result.get('size_kb', '?')} KB",
                )
                await self._api.edit_message_text(chat_id, msg_id, "✅ Done")
            else:
                await self._api.edit_message_text(chat_id, msg_id, fmt(result))

        elif data.startswith("cancel:"):
            key = data[len("cancel:"):]
            self._pending.pop(key, None)
            await self._api.edit_message_text(chat_id, msg_id, "❌ Cancelled.")

    # ------------------------------------------------------------------
    # Slash command dispatch
    # ------------------------------------------------------------------

    async def _handle_command(self, text: str, message: dict) -> None:
        parts = text.lstrip("/").split(None, 1)
        cmd = parts[0].lower().split("@")[0]  # strip @botname suffix
        args_str = parts[1].strip() if len(parts) > 1 else ""

        handler = self._commands.get(cmd)
        if handler:
            await handler(args_str, message)
        else:
            await self._api.send_message(
                self._chat_id,
                f"❓ Unknown command `/{cmd}`. Try /help for the full list.",
            )

    # ------------------------------------------------------------------
    # Free text → AI → tool dispatch
    # ------------------------------------------------------------------

    async def _handle_freetext(self, text: str, message: dict) -> None:
        await self._api.send_chat_action(self._chat_id, "typing")
        action = await self._engine.parse_intent(text)

        tool_name = action.get("tool", "clarify")
        args = action.get("args", {})
        description = action.get("description", "")
        confirm = action.get("confirm", False)

        if confirm:
            await self._send_confirmation(tool_name, args, description)
            return

        await self._api.send_chat_action(self._chat_id, "typing")
        result = await run_tool(tool_name, args)

        if tool_name == "screenshot" and "path" in result:
            await self._api.send_photo(
                self._chat_id, result["path"],
                caption=f"📸 {description or 'Screenshot'} — {result.get('size_kb', '?')} KB",
            )
            return

        await self._api.send_message(self._chat_id, fmt(result))

    async def _send_confirmation(self, tool: str, args: dict, description: str) -> None:
        """Send an inline keyboard asking the user to confirm a dangerous action."""
        key = secrets.token_hex(8)
        self._pending[key] = {"tool": tool, "args": args, "description": description}
        # Limit pending queue to 20 entries to prevent memory growth
        if len(self._pending) > 20:
            oldest = next(iter(self._pending))
            del self._pending[oldest]

        args_preview = ", ".join(f"{k}={repr(v)[:50]}" for k, v in args.items())
        text = (
            f"⚠️ *Confirmation required*\n\n"
            f"🔧 *Tool:* `{tool}`\n"
            f"📝 *Action:* {description}\n"
            f"🔩 *Args:* `{args_preview or '(none)'}`\n\n"
            f"Are you sure you want to proceed?"
        )
        markup = {
            "inline_keyboard": [[
                {"text": "✅ Yes, do it", "callback_data": f"confirm:{key}"},
                {"text": "❌ Cancel",     "callback_data": f"cancel:{key}"},
            ]]
        }
        await self._api.send_message(self._chat_id, text, reply_markup=markup)

    # ------------------------------------------------------------------
    # Slash command handlers
    # ------------------------------------------------------------------

    async def _cmd_start(self, args: str, message: dict) -> None:
        await self._api.send_message(
            self._chat_id,
            "👋 *CallMe agent is running!*\n\n"
            "Send me natural language commands and I'll execute them on your computer.\n\n"
            "*Examples:*\n"
            "  • `how's my cpu?`\n"
            "  • `take a screenshot`\n"
            "  • `what's the weather in Tokyo?`\n"
            "  • `remind me in 10 minutes to drink water`\n"
            "  • `list files in ~/Documents`\n"
            "  • `run echo hello`\n\n"
            "Type /help to see all slash commands.",
        )

    async def _cmd_help(self, args: str, message: dict) -> None:
        cmds = "\n".join(f"  /{cmd}" for cmd in sorted(self._commands))
        await self._api.send_message(
            self._chat_id,
            f"*CallMe Commands:*\n\n{cmds}\n\n"
            "Or just type anything in natural language — the AI will figure it out!",
        )

    async def _cmd_health(self, args: str, message: dict) -> None:
        await self._api.send_chat_action(self._chat_id, "typing")
        result = await run_tool("system_health", {})
        await self._api.send_message(self._chat_id, fmt(result))

    async def _cmd_status(self, args: str, message: dict) -> None:
        await self._cmd_health(args, message)

    async def _cmd_time(self, args: str, message: dict) -> None:
        result = await run_tool("datetime", {})
        await self._api.send_message(self._chat_id, fmt(result))

    async def _cmd_screenshot(self, args: str, message: dict) -> None:
        await self._api.send_chat_action(self._chat_id, "upload_photo")
        result = await run_tool("screenshot", {})
        if "error" in result:
            await self._api.send_message(self._chat_id, fmt(result))
        else:
            await self._api.send_photo(
                self._chat_id, result["path"],
                caption=f"📸 Screenshot — {result.get('size_kb', '?')} KB",
            )

    async def _cmd_weather(self, args: str, message: dict) -> None:
        if not args:
            await self._api.send_message(self._chat_id, "❓ Usage: /weather <city>")
            return
        await self._api.send_chat_action(self._chat_id, "typing")
        result = await run_tool("weather", {"city": args.strip()})
        await self._api.send_message(self._chat_id, fmt(result))

    async def _cmd_ls(self, args: str, message: dict) -> None:
        path = args.strip() or "~"
        result = await run_tool("list_directory", {"path": path})
        await self._api.send_message(self._chat_id, fmt(result))

    async def _cmd_run(self, args: str, message: dict) -> None:
        if not args:
            await self._api.send_message(self._chat_id, "❓ Usage: /run <shell command>")
            return
        key = secrets.token_hex(8)
        self._pending[key] = {
            "tool": "run_shell",
            "args": {"command": args},
            "description": f"Run shell: {args[:80]}",
        }
        markup = {
            "inline_keyboard": [[
                {"text": "✅ Run it", "callback_data": f"confirm:{key}"},
                {"text": "❌ Cancel", "callback_data": f"cancel:{key}"},
            ]]
        }
        await self._api.send_message(
            self._chat_id,
            f"⚠️ *Run shell command?*\n```\n{args[:400]}\n```",
            reply_markup=markup,
        )

    async def _cmd_ps(self, args: str, message: dict) -> None:
        await self._api.send_chat_action(self._chat_id, "typing")
        result = await run_tool("list_processes", {})
        await self._api.send_message(self._chat_id, fmt(result))

    async def _cmd_battery(self, args: str, message: dict) -> None:
        result = await run_tool("battery", {})
        await self._api.send_message(self._chat_id, fmt(result))

    async def _cmd_ping(self, args: str, message: dict) -> None:
        host = args.strip() or "8.8.8.8"
        await self._api.send_chat_action(self._chat_id, "typing")
        result = await run_tool("ping", {"host": host})
        await self._api.send_message(self._chat_id, fmt(result))

    async def _cmd_memory(self, args: str, message: dict) -> None:
        result = await run_tool("system_health", {})
        if "error" in result:
            await self._api.send_message(self._chat_id, fmt(result))
            return
        ram = result["ram_percent"]
        msg = (
            f"🧠 *Memory Usage*\n"
            f"{_bar(ram)} {ram}%\n"
            f"  Used: {result['ram_used_gb']} GB\n"
            f"  Total: {result['ram_total_gb']} GB\n"
            f"  Swap: {result.get('swap_used_gb', 0)} GB used"
        )
        await self._api.send_message(self._chat_id, msg)

    async def _cmd_disk(self, args: str, message: dict) -> None:
        path = args.strip() or "/"
        result = await run_tool("disk_usage", {"path": path})
        await self._api.send_message(self._chat_id, fmt(result))

    async def _cmd_clear_memory(self, args: str, message: dict) -> None:
        await self._engine.memory.clear()
        await self._api.send_message(self._chat_id, "🧹 *Conversation memory cleared.*")
