"""DexFatherBot — Main bot class for the Dexster Bot API.

Supports both polling and webhook modes for receiving updates.
"""

from __future__ import annotations

import asyncio
import functools
import hashlib
import hmac as hmac_mod
import json
import signal
import warnings
from typing import Any, BinaryIO, Callable, Optional, Union

from ._logging import get_logger, setup_logging
from .client import DexFatherClient
from .exceptions import APIError
from .handlers import Handler
from .keyboards import (
    InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply,
)
from .middleware import BaseMiddleware
from .types import (
    ApiResponse, Update, Message, BotInfo, Poll, PollAnswer,
    ChatMember, ChatInfo, ChatPermissions, MenuButton, WebhookInfo,
    Dice, Contact, Venue, Location, InlineQuery,
    InlineQueryResultArticle, InlineQueryResultPhoto,
    BotFile, UserProfilePhotos,
)

# Backwards-compatible alias — old code using `from dexfather.bot import ApiError` still works.
ApiError = APIError

logger = get_logger("dexfather")

# Type alias for reply markup
ReplyMarkup = Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply]

# Default Dexster API base URL
DEFAULT_API_BASE = "https://dexster.io/api/bot"


def deprecated(message: str):
    """Decorator to mark a function or method as deprecated.

    Emits a ``DeprecationWarning`` on each call with the provided message.
    Works for both sync and async functions.
    """
    def decorator(func):
        @functools.wraps(func)
        async def async_wrapper(*args, **kwargs):
            warnings.warn(
                f"{func.__name__} is deprecated: {message}",
                DeprecationWarning,
                stacklevel=2,
            )
            return await func(*args, **kwargs)

        @functools.wraps(func)
        def sync_wrapper(*args, **kwargs):
            warnings.warn(
                f"{func.__name__} is deprecated: {message}",
                DeprecationWarning,
                stacklevel=2,
            )
            return func(*args, **kwargs)

        if asyncio.iscoroutinefunction(func):
            return async_wrapper
        return sync_wrapper
    return decorator


def verify_webhook_signature(body: str, secret: str, signature: str) -> bool:
    """Verify an HMAC-SHA256 webhook signature.

    Args:
        body: The raw request body string.
        secret: The webhook secret (typically the bot token).
        signature: The signature value from the ``X-Dexster-Signature`` header.

    Returns:
        ``True`` if the signature is valid, ``False`` otherwise.
    """
    if not body or not secret or not signature:
        return False
    expected = hmac_mod.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
    return hmac_mod.compare_digest(expected, signature)


class MiddlewareContext:
    """Restricted bot proxy passed to middleware -- hides raw token.

    Exposes all public API methods (send_message, etc.) but raises
    AttributeError on ``token`` and ``_api`` access. This prevents
    third-party middleware from exfiltrating the bot token.
    """

    __slots__ = ('_bot',)

    def __init__(self, bot: 'DexFatherBot') -> None:
        object.__setattr__(self, '_bot', bot)

    def __getattr__(self, name: str) -> Any:
        if name in ('token', '_api', '_token'):
            raise AttributeError(
                f"Middleware cannot access '{name}' -- use bot API methods instead"
            )
        return getattr(object.__getattribute__(self, '_bot'), name)


class DexFatherBot:
    """Dexster Bot API client.

    Usage:
        bot = DexFatherBot("dxt_your_token_here")

        @bot.command("start")
        async def on_start(update, bot):
            await bot.send_message(update.message.chat.id, "Hello!")

        bot.run_polling()
    """

    def __init__(
        self,
        token: str,
        api_base: str = DEFAULT_API_BASE,
        timeout: float = 30.0,
    ) -> None:
        """Initialize the bot with an API token.

        Args:
            token: Bot API token in ``dxt_...`` format.
            api_base: Base URL for the Bot API (default: ``https://dexster.io/api/bot``).
            timeout: HTTP request timeout in seconds (default: 30).
        """
        self.token = token
        self.api_base = api_base.rstrip("/")
        self._api = DexFatherClient(token, api_base, timeout)
        self._handlers: list[Handler] = []
        self._middlewares: list[BaseMiddleware] = []
        self._running = False
        self._offset: int = 0
        self._error_handler: Optional[Callable] = None

    # ── Async Context Manager ────────────────────────────────────────

    async def __aenter__(self):
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        await self.stop()
        return False

    # ── Error Handler ────────────────────────────────────────────────

    def catch(self, handler: Callable) -> None:
        """Register a global error handler for unhandled handler exceptions.

        The handler is called with ``(update, exception)`` when an exception
        propagates out of a handler callback.  If no error handler is
        registered, errors are logged and swallowed (default behaviour).

        Args:
            handler: An async or sync callable ``(update, error) -> None``.
        """
        self._error_handler = handler

    # ── Properties (backwards-compat proxies to DexFatherClient) ──────

    @property
    def _client(self) -> Any:
        """Proxy to the underlying httpx.AsyncClient for backwards compat."""
        return self._api._client

    @property
    def _ws(self) -> Any:
        """Proxy to the WebSocket connection."""
        return self._api._ws

    @_ws.setter
    def _ws(self, value: Any) -> None:
        self._api._ws = value

    @property
    def _pending_responses(self) -> dict[str, asyncio.Future[Any]]:
        """Proxy to pending WS response futures."""
        return self._api._pending_responses

    # ── Middleware ─────────────────────────────────────────────────────

    def add_middleware(self, middleware: BaseMiddleware) -> None:
        """Register a middleware for update processing.

        Middlewares execute in registration order (first registered =
        outermost).  Each middleware wraps the dispatch pipeline and can
        run pre/post processing around handler execution.

        Args:
            middleware: A :class:`~dexfather.middleware.BaseMiddleware`
                subclass instance.
        """
        self._middlewares.append(middleware)

    # ── Handler Registration ──────────────────────────────────────────

    def add_handler(self, handler: Handler) -> None:
        """Register an update handler."""
        self._handlers.append(handler)

    def command(self, cmd: Union[str, list[str]]) -> Callable:
        """Decorator to register a command handler.

        Usage:
            @bot.command("start")
            async def on_start(update, bot):
                ...
        """
        from .handlers import CommandHandler

        def decorator(func: Callable) -> Callable:
            self.add_handler(CommandHandler(cmd, func))
            return func

        return decorator

    def on_message(self, pattern: Optional[str] = None, *, filters=None) -> Callable:
        """Decorator to register a message handler.

        Usage:
            @bot.on_message()
            async def on_text(update, bot): ...

            @bot.on_message(filters=TEXT & ~COMMAND)
            async def on_non_cmd(update, bot): ...
        """
        from .handlers import MessageHandler

        def decorator(func: Callable) -> Callable:
            self.add_handler(MessageHandler(func, pattern=pattern, filters=filters))
            return func

        return decorator

    def on_callback_query(self, pattern: Optional[str] = None) -> Callable:
        """Decorator to register a callback query handler.

        Usage:
            @bot.on_callback_query(r"^confirm:")
            async def on_confirm(update, bot):
                ...
        """
        from .handlers import CallbackQueryHandler

        def decorator(func: Callable) -> Callable:
            self.add_handler(CallbackQueryHandler(func, pattern=pattern))
            return func

        return decorator

    def on_inline_query(self, pattern: Optional[str] = None) -> Callable:
        """Decorator to register an inline query handler.

        Usage:
            @bot.on_inline_query()
            async def on_inline(update, bot):
                ...
        """
        from .handlers import InlineQueryHandler

        def decorator(func: Callable) -> Callable:
            self.add_handler(InlineQueryHandler(func, pattern=pattern))
            return func

        return decorator

    def on_poll_answer(self) -> Callable:
        """Decorator to register a poll answer handler.

        Usage:
            @bot.on_poll_answer()
            async def on_vote(update, bot):
                ...
        """
        from .handlers import PollAnswerHandler

        def decorator(func: Callable) -> Callable:
            self.add_handler(PollAnswerHandler(func))
            return func

        return decorator

    def on_chat_member(self) -> Callable:
        """Decorator to register a chat member update handler.

        Usage:
            @bot.on_chat_member()
            async def on_member(update, bot):
                ...
        """
        from .handlers import ChatMemberHandler

        def decorator(func: Callable) -> Callable:
            self.add_handler(ChatMemberHandler(func))
            return func

        return decorator

    def on_chat_join_request(self) -> Callable:
        """Decorator to register a chat join request handler.

        Usage:
            @bot.on_chat_join_request()
            async def on_join(update, bot):
                ...
        """
        from .handlers import ChatJoinRequestHandler

        def decorator(func: Callable) -> Callable:
            self.add_handler(ChatJoinRequestHandler(func))
            return func

        return decorator

    def on_chosen_inline_result(self) -> Callable:
        """Decorator to register a chosen inline result handler.

        Usage:
            @bot.on_chosen_inline_result()
            async def on_chosen(update, bot):
                ...
        """
        from .handlers import ChosenInlineResultHandler

        def decorator(func: Callable) -> Callable:
            self.add_handler(ChosenInlineResultHandler(func))
            return func

        return decorator

    def on_message_reaction(self) -> Callable:
        """Register a handler for message reaction updates.

        Usage::

            @bot.on_message_reaction()
            async def handle_reaction(update, bot):
                print(f"Reaction: {update.message_reaction}")
        """
        from .handlers import TypeHandler

        def decorator(func: Callable) -> Callable:
            self.add_handler(TypeHandler("message_reaction", func))
            return func

        return decorator

    def on_component_event(self) -> Callable:
        """Register a handler for component_interaction updates.

        Fires when a user interacts with a rich component (button click,
        form submit, select change, tab change).

        Usage::

            @bot.on_component_event()
            async def handle_interaction(update, bot):
                ci = update.component_interaction
                print(f"Component {ci['component_id']}: {ci['interaction_type']}")
        """
        from .handlers import TypeHandler

        def decorator(func: Callable) -> Callable:
            self.add_handler(TypeHandler("component_interaction", func))
            return func

        return decorator

    # ── API Methods ───────────────────────────────────────────────────

    async def _request(self, method: str, **kwargs: Any) -> ApiResponse:
        """Make an API request — delegates to DexFatherClient."""
        return await self._api._request(method, **kwargs)

    # ── Identity & Configuration (API-01) ─────────────────────────────

    async def get_me(self) -> BotInfo:
        """Get information about the bot itself.

        Returns the bot's ID, username, display name, and capabilities.
        Useful for verifying the token is valid and discovering the bot's identity.

        Returns:
            BotInfo with id, username, display_name, and can_join_groups.

        Raises:
            Unauthorized: If the bot token is invalid or revoked.

        Example::

            me = await bot.get_me()
            print(f"I am @{me.username} (ID: {me.id})")
        """
        resp = await self._request("getMe")
        return BotInfo.model_validate(resp.result)

    async def set_my_name(self, name: str) -> bool:
        """Set the bot's display name."""
        resp = await self._request("setMyName", name=name)
        return resp.ok

    async def get_my_name(self) -> str:
        """Get the bot's display name."""
        resp = await self._request("getMyName")
        return resp.result.get("name", "")

    async def set_my_description(self, description: str) -> bool:
        """Set the bot's description (shown in empty chat)."""
        resp = await self._request("setMyDescription", description=description)
        return resp.ok

    async def get_my_description(self) -> str:
        """Get the bot's description."""
        resp = await self._request("getMyDescription")
        return resp.result.get("description", "")

    async def set_my_short_description(self, short_description: str) -> bool:
        """Set the bot's short description (shown in profile)."""
        resp = await self._request("setMyShortDescription", short_description=short_description)
        return resp.ok

    async def get_my_short_description(self) -> str:
        """Get the bot's short description."""
        resp = await self._request("getMyShortDescription")
        return resp.result.get("short_description", "")

    async def set_my_profile_photo(self, photo_url: str) -> bool:
        """Set the bot's profile photo by URL."""
        resp = await self._request("setMyProfilePhoto", photo_url=photo_url)
        return resp.ok

    async def remove_my_profile_photo(self) -> bool:
        """Remove the bot's profile photo."""
        resp = await self._request("removeMyProfilePhoto")
        return resp.ok

    async def log_out(self) -> bool:
        """Log out from the bot API (revokes token)."""
        resp = await self._request("logOut")
        return resp.ok

    async def close(self) -> bool:
        """Close the bot instance (soft-deactivate)."""
        resp = await self._request("close")
        return resp.ok

    # ── Updates ───────────────────────────────────────────────────────

    async def get_updates(
        self,
        offset: Optional[int] = None,
        limit: int = 100,
        timeout: int = 30,
    ) -> list[Update]:
        """Long-poll the server for new updates.

        This is the low-level method used internally by polling mode. Most bots
        should use :meth:`run_polling` instead of calling this directly.

        Args:
            offset: Identifier of the first update to return. Used for pagination.
            limit: Maximum number of updates to retrieve (1-100, default 100).
            timeout: Long-polling timeout in seconds (default 30). Use 0 for short polling.

        Returns:
            List of Update objects. Empty list if no new updates.

        Raises:
            Unauthorized: If the bot token is invalid.
        """
        resp = await self._request(
            "getUpdates",
            offset=offset,
            limit=limit,
            timeout=timeout,
        )
        if not resp.result:
            return []
        return [Update.model_validate(u) for u in resp.result]

    # ── Messaging ─────────────────────────────────────────────────────

    async def send_message(
        self,
        chat_id: int,
        text: str,
        reply_markup: Optional[ReplyMarkup] = None,
        reply_to_message_id: Optional[int] = None,
        parse_mode: Optional[str] = None,
    ) -> Message:
        """Send a text message to a chat.

        Args:
            chat_id: Unique identifier for the target chat.
            text: Text of the message to be sent, 1-4096 characters.
            reply_markup: InlineKeyboardMarkup, ReplyKeyboardMarkup,
                ReplyKeyboardRemove, or ForceReply.
            reply_to_message_id: If the message is a reply, ID of the original message.
            parse_mode: Text formatting mode: ``'HTML'``, ``'Markdown'``, or ``'MarkdownV2'``.

        Returns:
            The sent Message with convenience methods (``.reply()``, ``.edit()``, ``.delete()``).

        Raises:
            BadRequest: If text is empty or exceeds 4096 characters.
            Forbidden: If the bot was removed from the chat or lacks write permission.

        Example::

            msg = await bot.send_message(chat_id, "Hello!")
            await msg.reply("Hi back!")
        """
        resp = await self._request(
            "sendMessage",
            chat_id=chat_id,
            text=text,
            reply_markup=reply_markup,
            reply_to_message_id=reply_to_message_id,
            parse_mode=parse_mode,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def edit_message_text(
        self,
        chat_id: int,
        message_id: int,
        text: str,
        reply_markup: Optional[InlineKeyboardMarkup] = None,
        parse_mode: Optional[str] = None,
    ) -> Message:
        """Edit the text of an existing message sent by the bot.

        Args:
            chat_id: Chat containing the message.
            message_id: Identifier of the message to edit.
            text: New text of the message, 1-4096 characters.
            reply_markup: New inline keyboard markup to attach.
            parse_mode: Text formatting mode.

        Returns:
            The edited Message.

        Raises:
            BadRequest: If the message cannot be edited (e.g., too old or not sent by the bot).

        Example::

            msg = await bot.send_message(chat_id, "Loading...")
            await bot.edit_message_text(chat_id, msg.message_id, "Done!")
        """
        resp = await self._request(
            "editMessageText",
            chat_id=chat_id,
            message_id=message_id,
            text=text,
            reply_markup=reply_markup,
            parse_mode=parse_mode,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def edit_message_reply_markup(
        self,
        chat_id: int,
        message_id: int,
        reply_markup: Optional[InlineKeyboardMarkup] = None,
    ) -> Message:
        """Edit the inline keyboard markup of an existing message.

        Args:
            chat_id: Chat containing the message.
            message_id: Identifier of the message to edit.
            reply_markup: New inline keyboard markup, or None to remove the keyboard.

        Returns:
            The edited Message.

        Raises:
            BadRequest: If the message was not sent by the bot.
        """
        resp = await self._request(
            "editMessageReplyMarkup",
            chat_id=chat_id,
            message_id=message_id,
            reply_markup=reply_markup,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def edit_message_caption(
        self,
        chat_id: int,
        message_id: int,
        caption: Optional[str] = None,
        reply_markup: Optional[InlineKeyboardMarkup] = None,
        parse_mode: Optional[str] = None,
    ) -> Message:
        """Edit the caption of a media message (photo, video, document, animation).

        Args:
            chat_id: Chat containing the message.
            message_id: Identifier of the message to edit.
            caption: New caption text, 0-1024 characters. Pass None to remove.
            reply_markup: New inline keyboard markup.
            parse_mode: Caption formatting mode.

        Returns:
            The edited Message.

        Raises:
            BadRequest: If the message has no media or was not sent by the bot.
        """
        resp = await self._request(
            "editMessageCaption",
            chat_id=chat_id,
            message_id=message_id,
            caption=caption,
            reply_markup=reply_markup,
            parse_mode=parse_mode,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def edit_message_media(
        self,
        chat_id: int,
        message_id: int,
        media_type: str,
        media_url: str,
        caption: Optional[str] = None,
        reply_markup: Optional[InlineKeyboardMarkup] = None,
        parse_mode: Optional[str] = None,
    ) -> Message:
        """Replace the media on an existing message.

        Args:
            media_type: One of 'photo', 'video', 'document', 'animation'.
            media_url: URL of the new media file.
        """
        resp = await self._request(
            "editMessageMedia",
            chat_id=chat_id,
            message_id=message_id,
            media={"type": media_type, "url": media_url, "caption": caption, "parse_mode": parse_mode},
            reply_markup=reply_markup,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def send_sticker(
        self,
        chat_id: int,
        sticker_url: str,
        emoji: Optional[str] = None,
        reply_to_message_id: Optional[int] = None,
        reply_markup: Optional[InlineKeyboardMarkup] = None,
    ) -> Message:
        """Send a sticker message.

        Args:
            chat_id: Target chat ID.
            sticker_url: URL of the sticker file (WebP, animated TGSWEBP, or video WEBM).
            emoji: Emoji associated with the sticker.
            reply_to_message_id: ID of the message to reply to.
            reply_markup: Inline keyboard to attach.

        Returns:
            The sent Message.

        Raises:
            BadRequest: If the sticker URL is invalid.
        """
        resp = await self._request(
            "sendSticker",
            chat_id=chat_id,
            sticker_url=sticker_url,
            emoji=emoji,
            reply_to_message_id=reply_to_message_id,
            reply_markup=reply_markup,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def get_user_profile_photos(self, user_id: int) -> UserProfilePhotos:
        """Get a user's profile photos.

        Returns:
            UserProfilePhotos with total_count and photos array.
        """
        resp = await self._request(
            "getUserProfilePhotos",
            user_id=user_id,
        )
        return UserProfilePhotos.model_validate(resp.result)

    async def get_file(self, file_id: str) -> BotFile:
        """Get file download URL by file ID.

        Returns:
            BotFile with file_id, file_url, and optional metadata.
        """
        resp = await self._request(
            "getFile",
            file_id=file_id,
        )
        return BotFile.model_validate(resp.result)

    async def upload_file(
        self,
        file: Union[str, bytes, BinaryIO],
        file_type: Optional[str] = None,
        filename: Optional[str] = None,
    ) -> BotFile:
        """Upload a file to the server and get a BotFile back.

        Args:
            file: File to upload. Accepts a local file path (str), raw bytes,
                or any file-like object with a ``.read()`` method (e.g. BytesIO).
            file_type: Optional explicit type hint (photo, document, video,
                audio, voice, animation, sticker, video_note).
            filename: Optional filename for the upload. Inferred from path
                when *file* is a string; defaults to ``"upload"`` otherwise.

        Returns:
            BotFile with file_id, file_url, and metadata.
        """
        import os

        if isinstance(file, str):
            # file is a filesystem path
            if filename is None:
                filename = os.path.basename(file)
            with open(file, "rb") as f:
                file_data = f.read()
        elif isinstance(file, bytes):
            file_data = file
            if filename is None:
                filename = "upload"
        elif hasattr(file, "read"):
            file_data = file.read()
            if isinstance(file_data, str):
                file_data = file_data.encode("utf-8")
            # Try to get name from file-like object
            if filename is None:
                filename = getattr(file, "name", None)
                if filename:
                    filename = os.path.basename(filename)
                else:
                    filename = "upload"
        else:
            raise TypeError(
                f"file must be a str path, bytes, or file-like object, got {type(file).__name__}"
            )

        # Use httpx multipart upload
        files = {"file": (filename, file_data)}
        data = {}
        if file_type:
            data["file_type"] = file_type

        resp = await self._api._client.post(
            f"{self.api_base}/uploadFile",
            files=files,
            data=data,
            headers={"Authorization": f"Bot {self.token}"},
        )
        result = resp.json()
        if not result.get("ok"):
            raise APIError(result.get("error_code", 500), result.get("description", "Upload failed"))
        return BotFile.model_validate(result["result"])

    async def set_chat_photo(self, chat_id: int, photo_url: str) -> bool:
        """Set a chat's profile photo."""
        resp = await self._request(
            "setChatPhoto",
            chat_id=chat_id,
            photo_url=photo_url,
        )
        return resp.ok

    async def delete_chat_photo(self, chat_id: int) -> bool:
        """Delete a chat's profile photo."""
        resp = await self._request("deleteChatPhoto", chat_id=chat_id)
        return resp.ok

    async def set_my_default_admin_rights(
        self,
        rights: Optional[dict] = None,
        for_channels: bool = False,
    ) -> bool:
        """Set the default admin rights requested by the bot when added as an administrator.

        Args:
            rights: Dict of admin right names to boolean values.
            for_channels: If True, change the default rights for channels. Defaults to groups.
        """
        resp = await self._request(
            "setMyDefaultAdminRights",
            rights=rights,
            for_channels=for_channels,
        )
        return resp.ok

    async def get_my_default_admin_rights(self, for_channels: bool = False) -> dict:
        """Get the default admin rights of the bot.

        Args:
            for_channels: If True, get rights for channels. Defaults to groups.
        """
        resp = await self._request(
            "getMyDefaultAdminRights",
            for_channels=for_channels,
        )
        return resp.result

    async def create_chat_invite_link(
        self,
        chat_id: int,
        name: Optional[str] = None,
        expire_date: Optional[int] = None,
        member_limit: Optional[int] = None,
        creates_join_request: Optional[bool] = None,
    ) -> dict:
        """Create a new invite link for a chat.

        Args:
            expire_date: Unix timestamp when the link expires.
            member_limit: Max number of users that can join via this link.
            creates_join_request: If True, users need admin approval to join.
        """
        resp = await self._request(
            "createChatInviteLink",
            chat_id=chat_id,
            name=name,
            expire_date=expire_date,
            member_limit=member_limit,
            creates_join_request=creates_join_request,
        )
        return resp.result

    async def edit_chat_invite_link(
        self,
        chat_id: int,
        invite_link: str,
        name: Optional[str] = None,
        expire_date: Optional[int] = None,
        member_limit: Optional[int] = None,
        creates_join_request: Optional[bool] = None,
    ) -> dict:
        """Edit an existing invite link."""
        resp = await self._request(
            "editChatInviteLink",
            chat_id=chat_id,
            invite_link=invite_link,
            name=name,
            expire_date=expire_date,
            member_limit=member_limit,
            creates_join_request=creates_join_request,
        )
        return resp.result

    async def revoke_chat_invite_link(self, chat_id: int, invite_link: str) -> dict:
        """Revoke an existing invite link."""
        resp = await self._request(
            "revokeChatInviteLink",
            chat_id=chat_id,
            invite_link=invite_link,
        )
        return resp.result

    async def delete_message(self, chat_id: int, message_id: int) -> bool:
        """Delete a single message.

        The bot can delete messages it sent (any chat) and messages in groups
        where it has the ``can_delete_messages`` admin right.

        Args:
            chat_id: Chat containing the message.
            message_id: Identifier of the message to delete.

        Returns:
            True on success.

        Raises:
            BadRequest: If the message does not exist.
            Forbidden: If the bot lacks delete permissions.

        Example::

            await bot.delete_message(chat_id, message_id)
        """
        resp = await self._request(
            "deleteMessages",
            chat_id=chat_id,
            message_ids=[message_id],
        )
        return resp.ok

    async def answer_callback_query(
        self,
        callback_query_id: str,
        text: Optional[str] = None,
        show_alert: bool = False,
    ) -> bool:
        """Answer a callback query triggered by an inline keyboard button press.

        Must be called after a user presses a button, otherwise the button
        spinner will keep spinning. Shows a brief notification or alert.

        Args:
            callback_query_id: Unique ID for the callback query (from ``update.callback_query.id``).
            text: Text to display as a toast (up to 200 characters).
            show_alert: If True, show a modal alert dialog instead of a toast.

        Returns:
            True on success.

        Example::

            @bot.on_callback_query()
            async def on_cb(update, bot):
                cq = update.callback_query
                await bot.answer_callback_query(cq.id, text="Got it!")
        """
        resp = await self._request(
            "answerCallbackQuery",
            callback_query_id=callback_query_id,
            text=text,
            show_alert=show_alert,
        )
        return resp.ok

    async def send_chat_action(self, chat_id: int, action: str = "typing") -> bool:
        """Send a chat action to show the bot is performing an operation.

        Displays a status indicator (e.g., "typing...") in the chat header.
        The action disappears after 5 seconds or when the bot sends a message.

        Args:
            chat_id: Target chat ID.
            action: Type of action: ``'typing'``, ``'upload_photo'``, ``'upload_video'``,
                ``'upload_document'``, ``'record_voice'``, ``'record_video'``, ``'find_location'``.

        Returns:
            True on success.

        Example::

            await bot.send_chat_action(chat_id, "typing")
            # ... process
            await bot.send_message(chat_id, "Here are your results!")
        """
        resp = await self._request(
            "sendChatAction",
            chat_id=chat_id,
            action=action,
        )
        return resp.ok

    async def send_document(
        self,
        chat_id: int,
        document_url: str,
        caption: Optional[str] = None,
        file_name: Optional[str] = None,
        reply_markup: Optional[ReplyMarkup] = None,
        reply_to_message_id: Optional[int] = None,
    ) -> Message:
        """Send a document (file) by URL.

        Sends a general file displayed with a document icon and download button.

        Args:
            chat_id: Target chat ID.
            document_url: URL of the document file, or a ``dxf_`` file ID.
            caption: Document caption, 0-1024 characters.
            file_name: Custom filename to display.
            reply_markup: Keyboard markup to attach.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            The sent Message.

        Example::

            await bot.send_document(chat_id, "https://example.com/report.pdf",
                                    caption="Monthly report")
        """
        resp = await self._request(
            "sendDocument",
            chat_id=chat_id,
            document_url=document_url,
            caption=caption,
            file_name=file_name,
            reply_markup=reply_markup,
            reply_to_message_id=reply_to_message_id,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def send_animation(
        self,
        chat_id: int,
        animation_url: str,
        caption: Optional[str] = None,
        reply_markup: Optional[ReplyMarkup] = None,
        reply_to_message_id: Optional[int] = None,
    ) -> Message:
        """Send a GIF or animation by URL.

        Args:
            chat_id: Target chat ID.
            animation_url: URL of the GIF/MP4 animation, or a ``dxf_`` file ID.
            caption: Animation caption, 0-1024 characters.
            reply_markup: Keyboard markup to attach.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            The sent Message.
        """
        resp = await self._request(
            "sendAnimation",
            chat_id=chat_id,
            animation_url=animation_url,
            caption=caption,
            reply_markup=reply_markup,
            reply_to_message_id=reply_to_message_id,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def send_video(
        self,
        chat_id: int,
        video_url: str,
        caption: Optional[str] = None,
        reply_markup: Optional[ReplyMarkup] = None,
        reply_to_message_id: Optional[int] = None,
    ) -> Message:
        """Send a video by URL.

        Supports MP4 and WEBM formats.

        Args:
            chat_id: Target chat ID.
            video_url: URL of the video file, or a ``dxf_`` file ID.
            caption: Video caption, 0-1024 characters.
            reply_markup: Keyboard markup to attach.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            The sent Message.
        """
        resp = await self._request(
            "sendVideo",
            chat_id=chat_id,
            video_url=video_url,
            caption=caption,
            reply_markup=reply_markup,
            reply_to_message_id=reply_to_message_id,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def send_location(
        self,
        chat_id: int,
        latitude: float,
        longitude: float,
        live_period: Optional[int] = None,
        heading: Optional[int] = None,
        horizontal_accuracy: Optional[float] = None,
        proximity_alert_radius: Optional[int] = None,
        name: Optional[str] = None,
        address: Optional[str] = None,
        reply_markup: Optional[ReplyMarkup] = None,
        reply_to_message_id: Optional[int] = None,
    ) -> Message:
        """Send a location point on the map.

        If ``live_period`` is set, sends a live location that can be updated
        in real-time using :meth:`edit_message_live_location`.

        Args:
            chat_id: Target chat ID.
            latitude: Latitude of the location (-90 to 90).
            longitude: Longitude of the location (-180 to 180).
            live_period: Period in seconds for live location updates (60-86400).
            heading: Direction of movement in degrees (1-360).
            horizontal_accuracy: Accuracy radius in meters (0-1500).
            proximity_alert_radius: Distance in meters for proximity alerts.
            name: Name of the venue or place.
            address: Address of the location.
            reply_markup: Keyboard markup to attach.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            The sent Message.

        Example::

            # Static location
            await bot.send_location(chat_id, 59.3293, 18.0686)

            # Live location (updates for 1 hour)
            msg = await bot.send_location(chat_id, 59.3293, 18.0686, live_period=3600)
        """
        resp = await self._request(
            "sendLocation",
            chat_id=chat_id,
            latitude=latitude,
            longitude=longitude,
            live_period=live_period,
            heading=heading,
            horizontal_accuracy=horizontal_accuracy,
            proximity_alert_radius=proximity_alert_radius,
            name=name,
            address=address,
            reply_markup=reply_markup,
            reply_to_message_id=reply_to_message_id,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def edit_message_live_location(
        self,
        chat_id: int,
        message_id: int,
        latitude: float,
        longitude: float,
        heading: Optional[int] = None,
        horizontal_accuracy: Optional[float] = None,
        proximity_alert_radius: Optional[int] = None,
        reply_markup: Optional[ReplyMarkup] = None,
    ) -> Message:
        """Update the coordinates of a live location message.

        A live location must have been sent with :meth:`send_location` using a ``live_period``.

        Args:
            chat_id: Chat containing the live location message.
            message_id: Identifier of the live location message.
            latitude: New latitude (-90 to 90).
            longitude: New longitude (-180 to 180).
            heading: New direction of movement in degrees (1-360).
            horizontal_accuracy: New accuracy radius in meters (0-1500).
            proximity_alert_radius: New proximity alert radius.
            reply_markup: New inline keyboard markup.

        Returns:
            The edited Message.

        Raises:
            BadRequest: If the message is not a live location or the live period expired.
        """
        resp = await self._request(
            "editMessageLiveLocation",
            chat_id=chat_id,
            message_id=message_id,
            latitude=latitude,
            longitude=longitude,
            heading=heading,
            horizontal_accuracy=horizontal_accuracy,
            proximity_alert_radius=proximity_alert_radius,
            reply_markup=reply_markup,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def stop_message_live_location(
        self,
        chat_id: int,
        message_id: int,
        reply_markup: Optional[ReplyMarkup] = None,
    ) -> Message:
        """Stop updating a live location message. The location pin becomes static.

        Args:
            chat_id: Chat containing the live location message.
            message_id: Identifier of the live location message to stop.
            reply_markup: New inline keyboard to display after stopping.

        Returns:
            The final Message with the last known location.

        Raises:
            BadRequest: If the message is not an active live location.
        """
        resp = await self._request(
            "stopMessageLiveLocation",
            chat_id=chat_id,
            message_id=message_id,
            reply_markup=reply_markup,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def forward_message(
        self,
        chat_id: int,
        from_chat_id: int,
        message_id: int,
    ) -> Message:
        """Forward a message from one chat to another.

        The forwarded message shows a "Forwarded from" label. Use
        :meth:`copy_message` to send without the forward label.

        Args:
            chat_id: Target chat ID to forward the message to.
            from_chat_id: Chat ID where the original message was sent.
            message_id: Identifier of the message to forward.

        Returns:
            The forwarded Message in the target chat.

        Raises:
            Forbidden: If the bot cannot access the source or target chat.
        """
        resp = await self._request(
            "forwardMessage",
            chat_id=chat_id,
            from_chat_id=from_chat_id,
            message_id=message_id,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def send_photo(self, chat_id: int, photo_url: str, caption: Optional[str] = None,
                         parse_mode: Optional[str] = None, reply_markup: Optional[ReplyMarkup] = None,
                         reply_to_message_id: Optional[int] = None) -> Message:
        """Send a photo by URL.

        Supports JPEG, PNG, GIF, and WebP formats. Displayed inline in the chat.

        Args:
            chat_id: Target chat ID.
            photo_url: URL of the photo, or a ``dxf_`` file ID.
            caption: Photo caption, 0-1024 characters.
            parse_mode: Caption formatting mode.
            reply_markup: Keyboard markup to attach.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            The sent Message.

        Example::

            await bot.send_photo(chat_id, "https://example.com/chart.png",
                                 caption="SOL/USD 24h chart")
        """
        resp = await self._request("sendPhoto", chat_id=chat_id, photo_url=photo_url,
                                   caption=caption, parse_mode=parse_mode, reply_markup=reply_markup,
                                   reply_to_message_id=reply_to_message_id)
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def send_voice(self, chat_id: int, voice_url: str, caption: Optional[str] = None,
                         duration: Optional[int] = None, reply_markup: Optional[ReplyMarkup] = None,
                         reply_to_message_id: Optional[int] = None) -> Message:
        """Send a voice message by URL.

        The file must be in OGG format encoded with OPUS codec.

        Args:
            chat_id: Target chat ID.
            voice_url: URL of the voice file, or a ``dxf_`` file ID.
            caption: Voice message caption, 0-1024 characters.
            duration: Duration of the voice message in seconds.
            reply_markup: Keyboard markup to attach.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            The sent Message.
        """
        resp = await self._request("sendVoice", chat_id=chat_id, voice_url=voice_url,
                                   caption=caption, duration=duration, reply_markup=reply_markup,
                                   reply_to_message_id=reply_to_message_id)
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def send_audio(self, chat_id: int, audio_url: str, caption: Optional[str] = None,
                         duration: Optional[int] = None, performer: Optional[str] = None,
                         title: Optional[str] = None, reply_markup: Optional[ReplyMarkup] = None,
                         reply_to_message_id: Optional[int] = None) -> Message:
        """Send an audio file by URL.

        Displayed as a playable music track with performer and title metadata.
        For voice messages, use :meth:`send_voice` instead.

        Args:
            chat_id: Target chat ID.
            audio_url: URL of the audio file (MP3, M4A), or a ``dxf_`` file ID.
            caption: Audio caption, 0-1024 characters.
            duration: Duration of the audio in seconds.
            performer: Performer name for the audio track.
            title: Track title.
            reply_markup: Keyboard markup to attach.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            The sent Message.
        """
        resp = await self._request("sendAudio", chat_id=chat_id, audio_url=audio_url,
                                   caption=caption, duration=duration, performer=performer,
                                   title=title, reply_markup=reply_markup,
                                   reply_to_message_id=reply_to_message_id)
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def send_video_note(self, chat_id: int, video_note_url: str,
                              duration: Optional[int] = None, length: Optional[int] = None,
                              reply_markup: Optional[ReplyMarkup] = None,
                              reply_to_message_id: Optional[int] = None) -> Message:
        """Send a video note (circular video message).

        Video notes are displayed as circular previews and do not support captions.

        Args:
            chat_id: Target chat ID.
            video_note_url: URL of the video note file, or a ``dxf_`` file ID.
            duration: Duration of the video in seconds.
            length: Video width and height (diameter of the circle).
            reply_markup: Keyboard markup to attach.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            The sent Message.
        """
        resp = await self._request("sendVideoNote", chat_id=chat_id, video_note_url=video_note_url,
                                   duration=duration, length=length, reply_markup=reply_markup,
                                   reply_to_message_id=reply_to_message_id)
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def send_media_group(self, chat_id: int, media: list[dict],
                               reply_to_message_id: Optional[int] = None) -> list[Message]:
        """Send a group of 2-10 photos or videos as an album.

        Args:
            chat_id: Target chat ID.
            media: Array of media objects with ``type``, ``url``, and optional ``caption``.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            List of sent Message objects (one per media item).

        Raises:
            BadRequest: If fewer than 2 or more than 10 items are provided.
        """
        resp = await self._request("sendMediaGroup", chat_id=chat_id, media=media,
                                   reply_to_message_id=reply_to_message_id)
        msgs = [Message.model_validate(m) for m in resp.result]
        for m in msgs:
            m._bind(self)
        return msgs

    async def send_contact(self, chat_id: int, phone_number: str, first_name: str,
                           last_name: Optional[str] = None, reply_markup: Optional[ReplyMarkup] = None,
                           reply_to_message_id: Optional[int] = None) -> Message:
        """Send a contact card with phone number and name.

        Args:
            chat_id: Target chat ID.
            phone_number: Contact's phone number.
            first_name: Contact's first name.
            last_name: Contact's last name.
            reply_markup: Keyboard markup to attach.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            The sent Message.
        """
        resp = await self._request("sendContact", chat_id=chat_id, phone_number=phone_number,
                                   first_name=first_name, last_name=last_name, reply_markup=reply_markup,
                                   reply_to_message_id=reply_to_message_id)
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def send_venue(self, chat_id: int, latitude: float, longitude: float,
                         title: str, address: str, reply_markup: Optional[ReplyMarkup] = None,
                         reply_to_message_id: Optional[int] = None) -> Message:
        """Send a venue (a named location with address).

        Args:
            chat_id: Target chat ID.
            latitude: Latitude of the venue (-90 to 90).
            longitude: Longitude of the venue (-180 to 180).
            title: Name of the venue.
            address: Address of the venue.
            reply_markup: Keyboard markup to attach.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            The sent Message.

        Example::

            await bot.send_venue(chat_id, 59.3293, 18.0686,
                                 "Dexster HQ", "Stockholm, Sweden")
        """
        resp = await self._request("sendVenue", chat_id=chat_id, latitude=latitude, longitude=longitude,
                                   title=title, address=address, reply_markup=reply_markup,
                                   reply_to_message_id=reply_to_message_id)
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def send_dice(self, chat_id: int, emoji: str = "\U0001f3b2",
                        reply_markup: Optional[ReplyMarkup] = None,
                        reply_to_message_id: Optional[int] = None) -> Message:
        """Send an animated dice/random value message.

        Args:
            chat_id: Target chat ID.
            emoji: Dice emoji variant. Defaults to the die emoji.
            reply_markup: Keyboard markup to attach.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            The sent Message with ``dice.value`` containing the random result.
        """
        resp = await self._request("sendDice", chat_id=chat_id, emoji=emoji, reply_markup=reply_markup,
                                   reply_to_message_id=reply_to_message_id)
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def send_poll(self, chat_id: int, question: str, options: list[str],
                        type: str = "regular", allows_multiple_answers: bool = False,
                        correct_option_id: Optional[int] = None, explanation: Optional[str] = None,
                        open_period: Optional[int] = None, is_anonymous: bool = True,
                        reply_markup: Optional[ReplyMarkup] = None,
                        reply_to_message_id: Optional[int] = None) -> Poll:
        """Send a native poll.

        Supports regular polls (multiple choice) and quiz polls (single correct answer).

        Args:
            chat_id: Target chat ID.
            question: Poll question, 1-300 characters.
            options: List of answer options, 2-10 strings (1-100 characters each).
            type: Poll type: ``'regular'`` (default) or ``'quiz'``.
            allows_multiple_answers: If True, allows selecting multiple options.
            correct_option_id: 0-based index of the correct answer (quiz polls only).
            explanation: Text shown after answering the quiz (0-200 characters).
            open_period: Time in seconds the poll is active (5-600).
            is_anonymous: If True (default), votes are anonymous.
            reply_markup: Keyboard markup to attach.
            reply_to_message_id: ID of the message to reply to.

        Returns:
            The Poll object.

        Example::

            # Regular poll
            await bot.send_poll(chat_id, "Favorite chain?",
                                ["Solana", "Ethereum", "Bitcoin"])

            # Quiz
            await bot.send_poll(chat_id, "What is 2+2?", ["3", "4", "5"],
                                type="quiz", correct_option_id=1)
        """
        resp = await self._request("sendPoll", chat_id=chat_id, question=question, options=options,
                                   type=type, allows_multiple_answers=allows_multiple_answers,
                                   correct_option_id=correct_option_id, explanation=explanation,
                                   open_period=open_period, is_anonymous=is_anonymous, reply_markup=reply_markup,
                                   reply_to_message_id=reply_to_message_id)
        return Poll.model_validate(resp.result["poll"])

    async def stop_poll(self, chat_id: int, message_id: int) -> Poll:
        """Stop an active poll and freeze its results.

        After stopping, users can no longer vote but can still see the final results.

        Args:
            chat_id: Chat containing the poll message.
            message_id: Identifier of the poll message.

        Returns:
            The final Poll with vote counts.

        Raises:
            BadRequest: If the message is not a poll or is already stopped.
        """
        resp = await self._request("stopPoll", chat_id=chat_id, message_id=message_id)
        return Poll.model_validate(resp.result)

    async def set_message_reaction(
        self,
        chat_id: int,
        message_id: int,
        reaction: Optional[str] = None,
    ) -> bool:
        """Set a reaction on a message.

        Args:
            chat_id: Chat containing the message.
            message_id: Message to react to.
            reaction: Emoji reaction string, or None to remove reaction.

        Returns:
            True on success.
        """
        resp = await self._request(
            "setMessageReaction",
            chat_id=chat_id,
            message_id=message_id,
            reaction=reaction,
        )
        return resp.ok

    async def copy_message(self, chat_id: int, from_chat_id: int, message_id: int,
                           caption: Optional[str] = None) -> dict:
        """Copy a message to another chat without the "Forwarded from" label.

        Unlike :meth:`forward_message`, this creates a new message that looks
        like it was sent directly.

        Args:
            chat_id: Target chat ID.
            from_chat_id: Source chat ID.
            message_id: Identifier of the message to copy.
            caption: New caption (replaces the original).

        Returns:
            Dict with the new ``message_id`` in the target chat.
        """
        resp = await self._request("copyMessage", chat_id=chat_id, from_chat_id=from_chat_id,
                                   message_id=message_id, caption=caption)
        return resp.result

    # ── Batch Operations ──────────────────────────────────────────────

    async def delete_messages(self, chat_id: int, message_ids: list[int]) -> bool:
        """Delete multiple messages."""
        resp = await self._request("deleteMessages", chat_id=chat_id, message_ids=message_ids)
        return resp.ok

    async def forward_messages(self, chat_id: int, from_chat_id: int, message_ids: list[int]) -> list[Message]:
        """Forward multiple messages."""
        resp = await self._request("forwardMessages", chat_id=chat_id, from_chat_id=from_chat_id,
                                   message_ids=message_ids)
        msgs = [Message.model_validate(m) for m in resp.result]
        for m in msgs:
            m._bind(self)
        return msgs

    async def copy_messages(self, chat_id: int, from_chat_id: int, message_ids: list[int]) -> list[dict]:
        """Copy multiple messages to another chat."""
        resp = await self._request("copyMessages", chat_id=chat_id, from_chat_id=from_chat_id,
                                   message_ids=message_ids)
        return resp.result

    # ── Rich Components ────────────────────────────────────────────────

    async def send_rich_component(
        self,
        chat_id: int,
        component: dict[str, Any],
        text: Optional[str] = None,
        reply_to_message_id: Optional[int] = None,
        reply_markup: Optional[ReplyMarkup] = None,
        height: Optional[str] = None,
    ) -> Message:
        """Send a rich component message.

        Args:
            chat_id: Target chat ID.
            component: Component props dict with a ``type`` discriminator.
            text: Optional text to accompany the component.
            reply_to_message_id: Optional message to reply to.
            reply_markup: Optional keyboard markup.
            height: Component height hint (``compact``, ``standard``, ``tall``).

        Returns:
            The sent Message, bound to this bot.
        """
        resp = await self._request(
            "sendRichComponent",
            chat_id=chat_id,
            component=component,
            text=text,
            reply_to_message_id=reply_to_message_id,
            reply_markup=reply_markup,
            height=height,
        )
        msg = Message.model_validate(resp.result)
        msg._bind(self)
        return msg

    async def reply_table(
        self,
        chat_id: int,
        headers: list[dict[str, Any]],
        rows: list[dict[str, str]],
        text: Optional[str] = None,
        page_size: Optional[int] = None,
        **kwargs: Any,
    ) -> Message:
        """Convenience: send a table component in one call.

        Args:
            chat_id: Target chat ID.
            headers: Column definitions (``key``, ``label``, optional ``sortable``).
            rows: Array of row objects keyed by header key.
            text: Optional text to accompany the table.
            page_size: Optional page size for pagination.
            **kwargs: Additional parameters (reply_to_message_id, reply_markup, height).
        """
        component: dict[str, Any] = {"type": "table", "headers": headers, "rows": rows}
        if page_size is not None:
            component["page_size"] = page_size
        return await self.send_rich_component(chat_id, component, text=text, **kwargs)

    async def reply_form(
        self,
        chat_id: int,
        fields: list[dict[str, Any]],
        submit_label: Optional[str] = None,
        form_id: Optional[str] = None,
        text: Optional[str] = None,
        **kwargs: Any,
    ) -> Message:
        """Convenience: send a form component in one call.

        Args:
            chat_id: Target chat ID.
            fields: Form field components (text_input, select_menu, checkbox, textarea).
            submit_label: Label for the submit button.
            form_id: Unique form identifier (auto-generated if omitted).
            text: Optional text to accompany the form.
            **kwargs: Additional parameters (reply_to_message_id, reply_markup, height).
        """
        import time
        component: dict[str, Any] = {
            "type": "form",
            "form_id": form_id or f"form_{int(time.time() * 1000)}",
            "elements": fields,
        }
        if submit_label is not None:
            component["submit_label"] = submit_label
        return await self.send_rich_component(chat_id, component, text=text, **kwargs)

    async def reply_tasks(
        self,
        chat_id: int,
        title: str,
        tasks: list[dict[str, Any]],
        text: Optional[str] = None,
        **kwargs: Any,
    ) -> Message:
        """Convenience: send a task list as a stats component in one call.

        Args:
            chat_id: Target chat ID.
            title: Header text for the task list.
            tasks: List of dicts with ``label``, ``value``, optional ``change``, ``change_type``.
            text: Optional additional text.
            **kwargs: Additional parameters (reply_to_message_id, reply_markup, height).
        """
        combined_text = title + (f"\n{text}" if text else "")
        component: dict[str, Any] = {"type": "stats", "items": tasks}
        return await self.send_rich_component(chat_id, component, text=combined_text, **kwargs)

    async def update_component(
        self,
        chat_id: int,
        message_id: int,
        component_id: str,
        props: dict[str, Any],
    ) -> bool:
        """Update an existing rich component's props in-place.

        Args:
            chat_id: Chat containing the message.
            message_id: Message containing the component.
            component_id: The component's unique ID.
            props: Partial props to merge into the component.

        Returns:
            True if the update succeeded.
        """
        resp = await self._request(
            "updateComponent",
            chat_id=chat_id,
            message_id=message_id,
            component_id=component_id,
            props=props,
        )
        return resp.ok

    # ── Inline Query ──────────────────────────────────────────────────

    async def answer_inline_query(self, inline_query_id: str, results: list,
                                   cache_time: int = 300, is_personal: bool = False,
                                   next_offset: Optional[str] = None,
                                   button: Optional[dict] = None) -> bool:
        """Answer an inline query with an array of results.

        Called when a user types ``@botusername query`` in any chat. Results
        are displayed in an overlay above the keyboard.

        Args:
            inline_query_id: Unique ID of the answered query.
            results: List of inline result objects (max 50). Pydantic models are
                automatically serialized via ``model_dump()``.
            cache_time: Time in seconds results can be cached (default 300).
            is_personal: If True, results are cached per-user.
            next_offset: Offset for pagination.
            button: Button to display above the results.

        Returns:
            True on success.

        Example::

            @bot.on_inline_query()
            async def on_inline(update, bot):
                q = update.inline_query
                results = [InlineQueryResultArticle(
                    id="1", title="Result",
                    input_message_content={"message_text": q.query}
                )]
                await bot.answer_inline_query(q.id, results)
        """
        # Serialize Pydantic result objects if needed
        serialized = []
        for r in results:
            if hasattr(r, "model_dump"):
                serialized.append(r.model_dump(exclude_none=True))
            else:
                serialized.append(r)
        resp = await self._request("answerInlineQuery", inline_query_id=inline_query_id,
                                   results=serialized, cache_time=cache_time,
                                   is_personal=is_personal, next_offset=next_offset, button=button)
        return resp.ok

    # ── Scoped Commands ───────────────────────────────────────────────

    async def set_my_commands(self, commands: list[dict[str, str]],
                              scope: Optional[dict] = None,
                              language_code: Optional[str] = None) -> bool:
        """Set the bot's command list, optionally scoped."""
        resp = await self._request("setMyCommands", commands=commands,
                                   scope=scope, language_code=language_code)
        return resp.ok

    async def get_my_commands(self, scope: Optional[dict] = None,
                              language_code: Optional[str] = None) -> list[dict[str, str]]:
        """Get the bot's registered commands."""
        resp = await self._request("getMyCommands", scope=scope, language_code=language_code)
        return resp.result or []

    async def delete_my_commands(self, scope: Optional[dict] = None,
                                 language_code: Optional[str] = None) -> bool:
        """Delete the bot's commands, optionally scoped."""
        resp = await self._request("deleteMyCommands", scope=scope, language_code=language_code)
        return resp.ok

    # ── Menu Button ───────────────────────────────────────────────────

    async def set_chat_menu_button(self, chat_id: Optional[int] = None,
                                    menu_button: Optional[dict] = None) -> bool:
        """Set the menu button for a chat or the default menu button."""
        resp = await self._request("setChatMenuButton", chat_id=chat_id, menu_button=menu_button)
        return resp.ok

    async def get_chat_menu_button(self, chat_id: Optional[int] = None) -> MenuButton:
        """Get the menu button for a chat."""
        resp = await self._request("getChatMenuButton", chat_id=chat_id)
        return MenuButton.model_validate(resp.result)

    # ── Chat Management ───────────────────────────────────────────────

    async def ban_chat_member(self, chat_id: int, user_id: int,
                              until_date: Optional[int] = None,
                              revoke_messages: bool = False) -> bool:
        """Ban a user from a group or channel.

        The user will be unable to rejoin unless unbanned.

        Args:
            chat_id: Chat ID to ban the user from.
            user_id: User ID to ban.
            until_date: Unix timestamp when the ban expires. 0 for permanent.
            revoke_messages: If True, delete all messages from the banned user.

        Returns:
            True on success.

        Raises:
            Forbidden: If the bot lacks the ``can_restrict_members`` admin right.

        Example::

            await bot.ban_chat_member(chat_id, spammer_id)
        """
        resp = await self._request("banChatMember", chat_id=chat_id, user_id=user_id,
                                   until_date=until_date, revoke_messages=revoke_messages)
        return resp.ok

    async def unban_chat_member(self, chat_id: int, user_id: int,
                                only_if_banned: bool = False) -> bool:
        """Unban a user from a chat."""
        resp = await self._request("unbanChatMember", chat_id=chat_id, user_id=user_id,
                                   only_if_banned=only_if_banned)
        return resp.ok

    async def restrict_chat_member(self, chat_id: int, user_id: int,
                                    permissions: dict, until_date: Optional[int] = None) -> bool:
        """Restrict a chat member's permissions."""
        resp = await self._request("restrictChatMember", chat_id=chat_id, user_id=user_id,
                                   permissions=permissions, until_date=until_date)
        return resp.ok

    async def promote_chat_member(self, chat_id: int, user_id: int, **rights: bool) -> bool:
        """Promote a user to administrator with specified rights.

        Pass admin permission flags as keyword arguments.

        Args:
            chat_id: Chat ID.
            user_id: User ID to promote.
            **rights: Admin permission flags: ``can_delete_messages``,
                ``can_ban_users``, ``can_manage_chat``, ``can_change_info``,
                ``can_invite_users``, ``can_pin_messages``, ``can_manage_topics``.

        Returns:
            True on success.

        Raises:
            Forbidden: If the bot lacks the ``can_promote_members`` admin right.

        Example::

            await bot.promote_chat_member(chat_id, user_id,
                                          can_delete_messages=True,
                                          can_ban_users=True)
        """
        resp = await self._request("promoteChatMember", chat_id=chat_id, user_id=user_id, **rights)
        return resp.ok

    async def set_chat_administrator_custom_title(self, chat_id: int, user_id: int,
                                                   custom_title: str) -> bool:
        """Set a custom title for a chat administrator."""
        resp = await self._request("setChatAdministratorCustomTitle", chat_id=chat_id,
                                   user_id=user_id, custom_title=custom_title)
        return resp.ok

    async def get_chat(self, chat_id: int) -> ChatInfo:
        """Get full information about a chat (group, channel, or private).

        Returns detailed info including title, description, member count,
        permissions, invite link, pinned message, and more.

        Args:
            chat_id: Target chat ID.

        Returns:
            ChatInfo with full chat details.

        Raises:
            BadRequest: If the chat does not exist.
            Forbidden: If the bot is not a member of the chat.

        Example::

            chat = await bot.get_chat(chat_id)
            print(f"Chat: {chat.title} ({chat.member_count} members)")
        """
        resp = await self._request("getChat", chat_id=chat_id)
        return ChatInfo.model_validate(resp.result)

    async def get_chat_member(self, chat_id: int, user_id: int) -> ChatMember:
        """Get a chat member's info."""
        resp = await self._request("getChatMember", chat_id=chat_id, user_id=user_id)
        return ChatMember.model_validate(resp.result)

    async def get_chat_member_count(self, chat_id: int) -> int:
        """Get the number of members in a chat."""
        resp = await self._request("getChatMemberCount", chat_id=chat_id)
        return resp.result

    async def get_chat_administrators(self, chat_id: int) -> list[ChatMember]:
        """Get a list of chat administrators."""
        resp = await self._request("getChatAdministrators", chat_id=chat_id)
        return [ChatMember.model_validate(m) for m in resp.result]

    async def set_chat_title(self, chat_id: int, title: str) -> bool:
        """Set the chat title."""
        resp = await self._request("setChatTitle", chat_id=chat_id, title=title)
        return resp.ok

    async def set_chat_description(self, chat_id: int, description: str) -> bool:
        """Set the chat description."""
        resp = await self._request("setChatDescription", chat_id=chat_id, description=description)
        return resp.ok

    async def pin_chat_message(self, chat_id: int, message_id: int,
                                disable_notification: bool = False) -> bool:
        """Pin a message in a chat."""
        resp = await self._request("pinChatMessage", chat_id=chat_id, message_id=message_id,
                                   disable_notification=disable_notification)
        return resp.ok

    async def unpin_chat_message(self, chat_id: int, message_id: int) -> bool:
        """Unpin a message in a chat."""
        resp = await self._request("unpinChatMessage", chat_id=chat_id, message_id=message_id)
        return resp.ok

    async def unpin_all_chat_messages(self, chat_id: int) -> bool:
        """Unpin all messages in a chat."""
        resp = await self._request("unpinAllChatMessages", chat_id=chat_id)
        return resp.ok

    async def leave_chat(self, chat_id: int) -> bool:
        """Leave a chat."""
        resp = await self._request("leaveChat", chat_id=chat_id)
        return resp.ok

    async def set_chat_permissions(self, chat_id: int, permissions: dict) -> bool:
        """Set default permissions for all members in a chat."""
        resp = await self._request("setChatPermissions", chat_id=chat_id, permissions=permissions)
        return resp.ok

    async def export_chat_invite_link(self, chat_id: int) -> str:
        """Generate a new primary invite link for a chat."""
        resp = await self._request("exportChatInviteLink", chat_id=chat_id)
        # API returns the link as a plain string, not an object
        if isinstance(resp.result, str):
            return resp.result
        return resp.result.get("invite_link", "")

    async def approve_chat_join_request(self, chat_id: int, user_id: int) -> bool:
        """Approve a chat join request."""
        resp = await self._request("approveChatJoinRequest", chat_id=chat_id, user_id=user_id)
        return resp.ok

    async def decline_chat_join_request(self, chat_id: int, user_id: int) -> bool:
        """Decline a chat join request."""
        resp = await self._request("declineChatJoinRequest", chat_id=chat_id, user_id=user_id)
        return resp.ok

    # ── Webhook ───────────────────────────────────────────────────────

    async def set_webhook(self, url: str, secret_token: Optional[str] = None,
                          allowed_updates: Optional[list[str]] = None,
                          max_connections: Optional[int] = None,
                          drop_pending_updates: bool = False) -> bool:
        """Set a webhook URL for the bot to receive updates via HTTP POST.

        When set, the server pushes updates to this URL instead of holding
        them for polling. Use :meth:`delete_webhook` to switch back.

        Args:
            url: HTTPS URL to send updates to.
            secret_token: Secret for ``X-Dexster-Bot-Api-Secret-Token`` header verification.
            allowed_updates: List of update types to receive (e.g., ``['message', 'callback_query']``).
            max_connections: Maximum concurrent connections (1-100, default 40).
            drop_pending_updates: If True, discard all pending updates.

        Returns:
            True on success.

        Example::

            await bot.set_webhook("https://myapp.com/webhook",
                                  secret_token="my-secret")
        """
        resp = await self._request("setWebhook", url=url, secret_token=secret_token,
                                   allowed_updates=allowed_updates, max_connections=max_connections,
                                   drop_pending_updates=drop_pending_updates)
        return resp.ok

    async def delete_webhook(self, drop_pending_updates: bool = False) -> bool:
        """Remove the webhook (switch to polling mode)."""
        resp = await self._request("deleteWebhook", drop_pending_updates=drop_pending_updates)
        return resp.ok

    async def get_webhook_info(self) -> WebhookInfo:
        """Get current webhook configuration."""
        resp = await self._request("getWebhookInfo")
        return WebhookInfo.model_validate(resp.result)

    # ── Dispatch ──────────────────────────────────────────────────────

    def _bind_update(self, update: Update) -> None:
        """Bind bot reference to all models in an update for convenience methods."""
        if update.message:
            update.message._bind(self)
        if update.edited_message:
            update.edited_message._bind(self)
        if update.callback_query:
            update.callback_query._bind(self)
            if update.callback_query.message:
                update.callback_query.message._bind(self)

    async def _dispatch(self, update: Update) -> None:
        """Dispatch an update to matching handlers, wrapped by middlewares."""
        # Bind bot reference to update models for convenience methods
        self._bind_update(update)

        async def _inner_dispatch(update: Update, bot: DexFatherBot) -> Any:
            for handler in self._handlers:
                try:
                    if handler.check(update):
                        result = await handler.handle(update, bot)
                        return result  # First matching handler wins
                except Exception as e:
                    if self._error_handler is not None:
                        result = self._error_handler(update, e)
                        if asyncio.iscoroutine(result):
                            await result
                    else:
                        logger.error("handler_error", error=str(e), exc_info=True)
            return None

        # Build middleware chain (last registered = innermost)
        chain = _inner_dispatch
        for mw in reversed(self._middlewares):
            prev = chain
            chain = lambda u, b, _mw=mw, _prev=prev: _mw(_prev, u, b)

        # Pass restricted context to middleware (hides token), full bot to inner dispatch
        ctx = MiddlewareContext(self) if self._middlewares else self
        await chain(update, ctx)

    # ── Polling ───────────────────────────────────────────────────────

    async def _poll_loop(self) -> None:
        """Internal polling loop."""
        logger.info("polling_started")
        while self._running:
            try:
                updates = await self.get_updates(
                    offset=self._offset,
                    timeout=25,
                )
                for update in updates:
                    self._offset = update.update_id + 1
                    await self._dispatch(update)
            except Exception as e:
                if "timeout" in str(e).lower() or "timed out" in str(e).lower():
                    continue
                logger.error("polling_error", error=str(e))
                await asyncio.sleep(1)

    def run_polling(self, log_level: int = 20) -> None:
        """Start the bot in polling mode (blocking).

        Args:
            log_level: Logging level (default: 20 = INFO).

        Usage:
            bot.run_polling()
        """
        import logging as _logging
        setup_logging(level=_logging.getLevelName(log_level))
        self._running = True
        loop = asyncio.new_event_loop()

        def _shutdown(sig, frame):
            self._running = False
            logger.info("shutdown_signal", signal=sig)

        signal.signal(signal.SIGINT, _shutdown)
        signal.signal(signal.SIGTERM, _shutdown)

        try:
            loop.run_until_complete(self._poll_loop())
        finally:
            loop.run_until_complete(self.stop())
            loop.close()

    # ── WebSocket Mode ─────────────────────────────────────────────

    async def _ws_loop(self) -> None:
        """Internal WebSocket event loop -- receives updates as push, sends API calls as WS messages."""
        try:
            import websockets
        except ImportError:
            raise ImportError("WebSocket mode requires 'websockets'. Install with: pip install websockets")

        # Convert HTTP API base to WS URL
        ws_url = self.api_base.replace("http://", "ws://").replace("https://", "wss://")
        ws_url = ws_url.rsplit("/api/bot", 1)[0] + "/ws"

        logger.info("ws_connecting", url=ws_url)

        self._api._pending_responses = {}

        _ws_retries = 0

        while self._running:
            try:
                async with websockets.connect(ws_url, ping_interval=None, ping_timeout=None, close_timeout=5) as ws:
                    self._api._ws = ws

                    # Wait for auth_required signal, then send bot token
                    first_msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10))
                    if first_msg.get("type") == "auth_required":
                        logger.debug("ws_auth_required")
                    # Send auth regardless (server may or may not send auth_required first)
                    await ws.send(json.dumps({"type": "auth", "token": f"Bot {self.token}"}))

                    # Wait for auth response
                    auth_resp = json.loads(await asyncio.wait_for(ws.recv(), timeout=10))
                    if auth_resp.get("type") == "bot:authenticated":
                        logger.info("ws_authenticated", username=auth_resp.get("data", {}).get("username", "?"))
                    elif auth_resp.get("type") == "auth_failed":
                        logger.error("ws_auth_failed", error=auth_resp.get("error"))
                        return
                    else:
                        logger.warning("ws_unexpected_auth_response", type=auth_resp.get("type"))

                    # Reset backoff after successful connection
                    _ws_retries = 0

                    # Start app-level keepalive ping (server marks us alive on receiving ping)
                    async def _keepalive():
                        while self._running and self._api._ws is not None:
                            try:
                                await asyncio.sleep(20)
                                if self._api._ws:
                                    await self._api._ws.send(json.dumps({"type": "ping"}))
                            except Exception:
                                break
                    keepalive_task = asyncio.create_task(_keepalive())

                    try:
                        # Main receive loop
                        async for raw in ws:
                            msg = json.loads(raw)
                            msg_type = msg.get("type")

                            if msg_type == "bot:update":
                                # Incoming update -- dispatch to handlers
                                update = Update.model_validate(msg["data"])
                                self._offset = update.update_id + 1
                                asyncio.create_task(self._dispatch(update))

                            elif msg_type == "bot:api_response":
                                # Response to an API call we made
                                req_id = msg.get("id")
                                if req_id and req_id in self._api._pending_responses:
                                    self._api._pending_responses[req_id].set_result(msg)

                            elif msg_type in ("pong", "authenticated", "auth_required"):
                                pass  # Keepalive pong or initial messages
                    finally:
                        keepalive_task.cancel()

            except Exception as e:
                if not self._running:
                    break
                logger.warning("ws_connection_lost", error=str(e))
                self._api._ws = None
                backoff = min(2 ** _ws_retries, 30)  # 1, 2, 4, 8, 16, 30, 30...
                _ws_retries += 1
                logger.info("ws_reconnecting", delay=backoff, attempt=_ws_retries)
                await asyncio.sleep(backoff)

        self._api._ws = None

    def run_websocket(self, log_level: int = 20) -> None:
        """Start the bot in WebSocket mode (blocking).

        WebSocket mode provides instant update delivery -- no polling delay.
        Updates are pushed directly from the server. API calls are sent
        over the same connection. Falls back to HTTP if WS disconnects.

        Requires: pip install websockets

        Args:
            log_level: Logging level (default: 20 = INFO).

        Usage:
            bot.run_websocket()
        """
        import logging as _logging
        setup_logging(level=_logging.getLevelName(log_level))
        self._running = True
        loop = asyncio.new_event_loop()

        def _shutdown(sig, frame):
            self._running = False
            logger.info("shutdown_signal", signal=sig)

        signal.signal(signal.SIGINT, _shutdown)
        signal.signal(signal.SIGTERM, _shutdown)

        try:
            loop.run_until_complete(self._ws_loop())
        finally:
            loop.run_until_complete(self.stop())
            loop.close()

    async def start_polling(self) -> None:
        """Start polling (non-blocking, for use in async contexts)."""
        self._running = True
        asyncio.create_task(self._poll_loop())

    async def stop(self) -> None:
        """Stop the bot and close connections."""
        self._running = False
        await self._api.aclose()

    # ── Webhook Server ────────────────────────────────────────────────

    def run_webhook(
        self,
        host: str = "0.0.0.0",
        port: int = 8443,
        path: str = "/webhook",
        webhook_secret: Optional[str] = None,
        log_level: int = 20,
    ) -> None:
        """Start the bot in webhook mode with a local HTTP server (blocking).

        Requires the ``webhook`` extra: pip install dexfather[webhook]

        Args:
            host: Bind address.
            port: Bind port.
            path: URL path to listen on.
            webhook_secret: If set, verify X-Dexster-Bot-Api-Secret-Token header
                            using constant-time comparison before dispatching.
            log_level: Logging level.
        """
        try:
            import uvicorn
            from starlette.applications import Starlette
            from starlette.requests import Request
            from starlette.responses import JSONResponse
            from starlette.routing import Route
        except ImportError:
            raise ImportError(
                "Webhook mode requires extra dependencies. "
                "Install with: pip install dexfather[webhook]"
            )

        setup_logging()

        async def handle_webhook(request: Request) -> JSONResponse:
            body = await request.body()

            # Verify X-Dexster-Bot-Api-Secret-Token if webhook_secret is set (SDK-02)
            if webhook_secret:
                token_header = request.headers.get("X-Dexster-Bot-Api-Secret-Token", "")
                if not hmac_mod.compare_digest(token_header, webhook_secret):
                    logger.warning("webhook_signature_failed")
                    return JSONResponse({"ok": False, "description": "Forbidden"}, status_code=403)

            data = json.loads(body)
            update = Update.model_validate(data)
            await self._dispatch(update)
            return JSONResponse({"ok": True})

        app = Starlette(routes=[Route(path, handle_webhook, methods=["POST"])])
        logger.info("webhook_started", host=host, port=port, path=path)
        uvicorn.run(app, host=host, port=port)
