"""Telegram channel implementation using python-telegram-bot."""

from __future__ import annotations

import asyncio
import logging
import re
from collections.abc import Awaitable, Callable
from contextlib import contextmanager
from pathlib import Path
from typing import Any, TypeVar

from loguru import logger
from telegram import BotCommand, ReplyParameters, Update
from telegram.error import BadRequest, NetworkError, TelegramError
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
from telegram.request import HTTPXRequest

from bao.bus.events import OutboundMessage
from bao.bus.queue import MessageBus
from bao.channels.base import BaseChannel
from bao.channels.progress_text import EditingProgress
from bao.config.schema import TelegramConfig
from bao.utils.helpers import get_media_path

_T = TypeVar("_T")
_UPDATER_CLEANUP_LOG = (
    "Error while calling `get_updates` one more time to mark all fetched updates."
)


def _markdown_to_telegram_html(text: str) -> str:
    """
    Convert markdown to Telegram-safe HTML.
    """
    if not text:
        return ""

    # 1. Extract and protect code blocks (preserve content from other processing)
    code_blocks: list[str] = []

    def save_code_block(m: re.Match[str]) -> str:
        code_blocks.append(m.group(1))
        return f"\x00CB{len(code_blocks) - 1}\x00"

    text = re.sub(r"```[\w]*\n?([\s\S]*?)```", save_code_block, text)

    # 2. Extract and protect inline code
    inline_codes: list[str] = []

    def save_inline_code(m: re.Match[str]) -> str:
        inline_codes.append(m.group(1))
        return f"\x00IC{len(inline_codes) - 1}\x00"

    text = re.sub(r"`([^`]+)`", save_inline_code, text)

    # 3. Headers # Title -> just the title text
    text = re.sub(r"^#{1,6}\s+(.+)$", r"\1", text, flags=re.MULTILINE)

    # 4. Blockquotes > text -> just the text (before HTML escaping)
    text = re.sub(r"^>\s*(.*)$", r"\1", text, flags=re.MULTILINE)

    # 5. Escape HTML special characters
    text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

    # 6. Links [text](url) - must be before bold/italic to handle nested cases
    text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', text)

    # 7. Bold **text** or __text__
    text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
    text = re.sub(r"__(.+?)__", r"<b>\1</b>", text)

    # 8. Italic _text_ (avoid matching inside words like some_var_name)
    text = re.sub(r"(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9])", r"<i>\1</i>", text)

    # 9. Strikethrough ~~text~~
    text = re.sub(r"~~(.+?)~~", r"<s>\1</s>", text)

    # 10. Bullet lists - item -> • item
    text = re.sub(r"^[-*]\s+", "• ", text, flags=re.MULTILINE)

    # 11. Restore inline code with HTML tags
    for i, code in enumerate(inline_codes):
        # Escape HTML in code content
        escaped = code.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        text = text.replace(f"\x00IC{i}\x00", f"<code>{escaped}</code>")

    # 12. Restore code blocks with HTML tags
    for i, code in enumerate(code_blocks):
        # Escape HTML in code content
        escaped = code.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        text = text.replace(f"\x00CB{i}\x00", f"<pre><code>{escaped}</code></pre>")

    return text


def _is_telegram_parse_error(error: BadRequest) -> bool:
    message = str(error).lower()
    return "parse" in message or "entity" in message or "tag" in message


def _is_message_not_modified(error: BadRequest) -> bool:
    return "message is not modified" in str(error).lower()


def _split_message(content: str, max_len: int = 4000) -> list[str]:
    """Split content into chunks within max_len, preferring line breaks."""
    if len(content) <= max_len:
        return [content]
    chunks: list[str] = []
    while content:
        if len(content) <= max_len:
            chunks.append(content)
            break
        cut = content[:max_len]
        pos = cut.rfind("\n")
        if pos == -1:
            pos = cut.rfind(" ")
        if pos == -1:
            pos = max_len
        chunks.append(content[:pos])
        content = content[pos:].lstrip()
    return chunks


def _on_polling_error(exc: TelegramError) -> None:
    if isinstance(exc, NetworkError):
        logger.warning("⚠️ Telegram polling 网络波动 / polling network issue: {}", exc)
        return
    logger.error("❌ Telegram polling 异常 / polling error: {}", exc)


async def _run_start_step(label: str, operation: Callable[[], Awaitable[_T]]) -> _T:
    try:
        return await operation()
    except Exception as exc:
        raise RuntimeError(f"{label}: {exc.__class__.__name__}: {exc}") from exc


@contextmanager
def _suppress_updater_cleanup_log() -> Any:
    logger = logging.getLogger("telegram.ext.Updater")

    class _CleanupFilter(logging.Filter):
        def filter(self, record: logging.LogRecord) -> bool:
            return _UPDATER_CLEANUP_LOG not in record.getMessage()

    cleanup_filter = _CleanupFilter()
    logger.addFilter(cleanup_filter)
    try:
        yield
    finally:
        logger.removeFilter(cleanup_filter)


class TelegramChannel(BaseChannel):
    """
    Telegram channel using long polling.

    Simple and reliable - no webhook/public IP needed.
    """

    name = "telegram"

    # Commands registered with Telegram's command menu
    BOT_COMMANDS = [
        BotCommand("start", "Start the bot"),
        BotCommand("new", "Start a new conversation"),
        BotCommand("session", "Switch conversation"),
        BotCommand("delete", "Delete current conversation"),
        BotCommand("stop", "Stop the current task"),
        BotCommand("help", "Show available commands"),
    ]

    def __init__(
        self,
        config: TelegramConfig,
        bus: MessageBus,
        groq_api_key: str = "",
    ):
        super().__init__(config, bus)
        self.config: TelegramConfig = config
        self.groq_api_key = groq_api_key
        self._app: Any = None
        self._chat_ids: dict[str, int] = {}  # Map sender_id to chat_id for replies
        self._typing_tasks: dict[str, asyncio.Task[None]] = {}  # chat_id -> typing loop task
        self._media_group_buffers: dict[str, list[dict[str, object]]] = {}
        self._media_group_tasks: dict[str, asyncio.Task[None]] = {}
        self._progress_reply_params: dict[str, ReplyParameters | None] = {}
        self._progress_thread_kwargs: dict[str, dict[str, int]] = {}
        self._message_threads: dict[tuple[str, int], int] = {}
        self._bot_user_id: int | None = None
        self._bot_username: str | None = None
        self._progress_handler = EditingProgress(
            self._create_progress_text,
            self._update_progress_text,
            self._send_text,
            split_fn=_split_message,
        )

    async def start(self) -> None:
        """Start the Telegram bot with long polling."""
        self.mark_not_ready()
        token = self.config.token.get_secret_value()
        if not token:
            logger.error("❌ 未配置 / not configured: Telegram token")
            self.mark_ready()
            return

        self._start_lifecycle()

        api_req = HTTPXRequest(
            connection_pool_size=16,
            pool_timeout=5.0,
            connect_timeout=30.0,
            read_timeout=30.0,
            proxy=self.config.proxy or None,
        )
        poll_req = HTTPXRequest(
            connection_pool_size=1,
            pool_timeout=5.0,
            connect_timeout=30.0,
            read_timeout=30.0,
            proxy=self.config.proxy or None,
        )
        builder = Application.builder().token(token).request(api_req).get_updates_request(poll_req)
        self._app = builder.build()
        app = self._app
        app.add_error_handler(self._on_error)

        # Add command handlers
        app.add_handler(CommandHandler("start", self._on_start))
        app.add_handler(CommandHandler("new", self._forward_command))
        app.add_handler(CommandHandler("session", self._forward_command))
        app.add_handler(CommandHandler("delete", self._forward_command))
        app.add_handler(CommandHandler("stop", self._forward_command))
        app.add_handler(CommandHandler("help", self._on_help))

        # Add message handler for text, photos, voice, documents
        app.add_handler(
            MessageHandler(
                (
                    filters.TEXT
                    | filters.PHOTO
                    | filters.VOICE
                    | filters.AUDIO
                    | filters.Document.ALL
                )
                & ~filters.COMMAND,
                self._on_message,
            )
        )

        logger.info("📡 启动通道 / starting: Telegram polling")

        # Initialize and start polling
        await _run_start_step("bot_initialize", app.bot.initialize)
        await _run_start_step("application_initialize", app.initialize)
        await _run_start_step("start", app.start)

        # Get bot info and register command menu
        bot_info = await _run_start_step("get_me", app.bot.get_me)
        self._bot_user_id = getattr(bot_info, "id", None)
        self._bot_username = getattr(bot_info, "username", None)
        logger.info("✅ 连接成功 / connected: @{}", bot_info.username)
        self.mark_ready()

        try:
            await app.bot.set_my_commands(self.BOT_COMMANDS)
            logger.debug("Telegram bot commands registered")
        except Exception as e:
            logger.warning("⚠️ 注册失败 / register failed: {}", e)

        # Start polling (this runs until stopped)
        updater = app.updater
        assert updater is not None
        await _run_start_step(
            "start_polling",
            lambda: updater.start_polling(
                allowed_updates=["message"],
                drop_pending_updates=True,
                error_callback=_on_polling_error,
            ),
        )

        await self._wait_until_stopped()

    async def stop(self) -> None:
        """Stop the Telegram bot."""
        self._stop_lifecycle()
        self.mark_not_ready()
        self._clear_progress()
        self._progress_reply_params.clear()
        self._progress_thread_kwargs.clear()

        # Cancel all typing indicators
        for chat_id in list(self._typing_tasks):
            self._stop_typing(chat_id)

        flush_tasks = list(self._media_group_tasks.values())
        for task in flush_tasks:
            if not task.done():
                task.cancel()
        if flush_tasks:
            await asyncio.gather(*flush_tasks, return_exceptions=True)
        self._media_group_tasks.clear()
        self._media_group_buffers.clear()

        if self._app:
            app = self._app
            logger.info("📡 停止通道 / stopping: Telegram bot")
            updater = getattr(app, "updater", None)
            if updater is not None and getattr(updater, "running", False):
                with _suppress_updater_cleanup_log():
                    await updater.stop()
            await app.stop()
            await app.shutdown()
            self._app = None
        self._reset_lifecycle()

    @staticmethod
    def _get_media_type(path: str) -> str:
        """Guess media type from file extension."""
        ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
        if ext in ("jpg", "jpeg", "png", "gif", "webp"):
            return "photo"
        if ext == "ogg":
            return "voice"
        if ext in ("mp3", "m4a", "wav", "aac"):
            return "audio"
        return "document"

    async def send(self, msg: OutboundMessage) -> None:
        """Send a message through Telegram."""
        if not self._app:
            logger.warning("⚠️ 未运行 / not running: Telegram bot")
            return

        meta = msg.metadata or {}
        if not bool(meta.get("_progress")):
            self._stop_typing(msg.chat_id)

        try:
            chat_id = int(msg.chat_id)
        except ValueError:
            logger.error("❌ 参数无效 / invalid chat_id: {}", msg.chat_id)
            return

        reply_to_message_id = self._coerce_int(msg.reply_to or meta.get("message_id"))
        message_thread_id = self._coerce_int(meta.get("message_thread_id"))
        if message_thread_id is None and reply_to_message_id is not None:
            message_thread_id = self._message_threads.get((msg.chat_id, reply_to_message_id))
        thread_kwargs: dict[str, int] = {}
        if message_thread_id is not None:
            thread_kwargs["message_thread_id"] = message_thread_id

        reply_params = None
        if self.config.reply_to_message:
            if reply_to_message_id is not None:
                reply_params = ReplyParameters(
                    message_id=reply_to_message_id, allow_sending_without_reply=True
                )

        # Send media files
        for media_path in msg.media or []:
            try:
                media_type = self._get_media_type(media_path)
                sender = {
                    "photo": self._app.bot.send_photo,
                    "voice": self._app.bot.send_voice,
                    "audio": self._app.bot.send_audio,
                }.get(media_type, self._app.bot.send_document)
                param = (
                    "photo"
                    if media_type == "photo"
                    else media_type
                    if media_type in ("voice", "audio")
                    else "document"
                )
                with open(media_path, "rb") as f:
                    await sender(
                        chat_id=chat_id,
                        **{param: f},
                        reply_parameters=reply_params,
                        **thread_kwargs,
                    )
            except Exception as e:
                filename = media_path.rsplit("/", 1)[-1]
                logger.error("❌ 发送失败 / media failed: {}: {}", media_path, e)
                await self._app.bot.send_message(
                    chat_id=chat_id,
                    text=f"[Failed to send: {filename}]",
                    reply_parameters=reply_params,
                    **thread_kwargs,
                )

        # Send text content
        self._progress_reply_params[msg.chat_id] = reply_params
        self._progress_thread_kwargs[msg.chat_id] = dict(thread_kwargs)
        await self._dispatch_progress_text(msg, flush_progress=True)
        if bool(meta.get("_progress_clear")) or not meta.get("_progress"):
            self._progress_reply_params.pop(msg.chat_id, None)
            self._progress_thread_kwargs.pop(msg.chat_id, None)

    async def _send_text(self, chat_id: str, text: str) -> None:
        if not self._app or not text:
            return

        chat_id_int = self._parse_chat_id(chat_id)
        if chat_id_int is None:
            return

        reply_params = self._progress_reply_params.get(chat_id)
        thread_kwargs = self._progress_thread_kwargs.get(chat_id, {})
        for chunk in _split_message(text):
            await self._send_text_message(chat_id_int, chunk, reply_params, thread_kwargs)

    async def _create_progress_text(self, chat_id: str, text: str) -> int | None:
        if not self._app or not text:
            return None

        chat_id_int = self._parse_chat_id(chat_id)
        if chat_id_int is None:
            return None

        reply_params = self._progress_reply_params.get(chat_id)
        thread_kwargs = self._progress_thread_kwargs.get(chat_id, {})
        message = await self._send_text_message(chat_id_int, text, reply_params, thread_kwargs)
        return int(message.message_id)

    async def _update_progress_text(
        self, chat_id: str, handle: int | None, text: str
    ) -> int | None:
        if not self._app or handle is None or not text:
            return handle

        chat_id_int = self._parse_chat_id(chat_id)
        if chat_id_int is None:
            return handle

        html = _markdown_to_telegram_html(text)
        try:
            await self._app.bot.edit_message_text(
                chat_id=chat_id_int,
                message_id=handle,
                text=html,
                parse_mode="HTML",
            )
        except BadRequest as e:
            if _is_message_not_modified(e):
                return handle
            if not _is_telegram_parse_error(e):
                raise
            logger.warning("⚠️ HTML 更新失败 / html edit failed: {}", e)
            await self._app.bot.edit_message_text(
                chat_id=chat_id_int,
                message_id=handle,
                text=text,
            )
        return handle

    @staticmethod
    def _parse_chat_id(chat_id: str) -> int | None:
        try:
            return int(chat_id)
        except ValueError:
            logger.error("❌ 参数无效 / invalid chat_id: {}", chat_id)
            return None

    async def _send_text_message(
        self,
        chat_id: int,
        text: str,
        reply_params: ReplyParameters | None,
        thread_kwargs: dict[str, int] | None = None,
    ) -> Any:
        assert self._app is not None
        html = _markdown_to_telegram_html(text)
        try:
            return await self._app.bot.send_message(
                chat_id=chat_id,
                text=html,
                parse_mode="HTML",
                reply_parameters=reply_params,
                **(thread_kwargs or {}),
            )
        except BadRequest as e:
            if not _is_telegram_parse_error(e):
                raise
            logger.warning("⚠️ HTML 发送失败 / html send failed: {}", e)
            return await self._app.bot.send_message(
                chat_id=chat_id,
                text=text,
                reply_parameters=reply_params,
                **(thread_kwargs or {}),
            )

    async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
        """Handle /start command."""
        if not update.message or not update.effective_user:
            return

        user = update.effective_user
        await update.message.reply_text(
            f"👋 Hi {user.first_name}! I'm Bao.\n\n"
            "Send me a message and I'll respond!\n"
            "Type /help to see available commands."
        )

    async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
        """Handle /help command, bypassing ACL so all users can access it."""
        if not update.message:
            return
        await update.message.reply_text(
            "🐈 Bao commands:\n"
            "/new — Start a new conversation\n"
            "/stop — Stop the current task\n"
            "/session — Switch between conversations\n"
            "/delete — Delete current conversation\n"
            "/model — Switch model\n"
            "/help — Show available commands"
        )

    def is_allowed(self, sender_id: str) -> bool:
        """Preserve Telegram's legacy id|username allowlist matching."""
        if super().is_allowed(sender_id):
            return True

        allow_list = getattr(self.config, "allow_from", [])
        if not allow_list:
            return False

        sender_str = str(sender_id)
        if sender_str.count("|") != 1:
            return False

        sid, username = sender_str.split("|", 1)
        if not sid.isdigit() or not username:
            return False
        return sid in allow_list or username in allow_list

    @staticmethod
    def _sender_id(user) -> str:
        """Build sender_id with username for allowlist matching."""
        sid = str(user.id)
        return f"{sid}|{user.username}" if user.username else sid

    @staticmethod
    def _coerce_int(value: object) -> int | None:
        if isinstance(value, int) and not isinstance(value, bool):
            return value
        if isinstance(value, str):
            raw = value.strip()
            if raw.lstrip("-").isdigit():
                return int(raw)
        return None

    @staticmethod
    def _derive_topic_session_key(message: Any) -> str | None:
        message_thread_id = getattr(message, "message_thread_id", None)
        if message.chat.type == "private" or message_thread_id is None:
            return None
        return f"telegram:{message.chat_id}:topic:{message_thread_id}"

    @staticmethod
    def _extract_reply_context(message: Any) -> str | None:
        reply = getattr(message, "reply_to_message", None)
        if not reply:
            return None
        text = getattr(reply, "text", None) or getattr(reply, "caption", None) or ""
        return f"[Reply to: {text}]" if text else None

    def _build_message_metadata(self, message: Any, user: Any) -> dict[str, Any]:
        reply_to = getattr(message, "reply_to_message", None)
        metadata: dict[str, Any] = {
            "message_id": message.message_id,
            "user_id": user.id,
            "username": user.username,
            "first_name": user.first_name,
            "is_group": message.chat.type != "private",
            "message_thread_id": getattr(message, "message_thread_id", None),
            "is_forum": bool(getattr(message.chat, "is_forum", False)),
            "reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None,
        }
        session_key = self._derive_topic_session_key(message)
        if session_key is not None:
            metadata["session_key"] = session_key
        return metadata

    async def _download_message_media(
        self,
        message: Any,
        *,
        add_failure_content: bool = False,
    ) -> tuple[list[str], list[str]]:
        media_file = None
        media_type = None
        if getattr(message, "photo", None):
            media_file = message.photo[-1]
            media_type = "image"
        elif getattr(message, "voice", None):
            media_file = message.voice
            media_type = "voice"
        elif getattr(message, "audio", None):
            media_file = message.audio
            media_type = "audio"
        elif getattr(message, "document", None):
            media_file = message.document
            media_type = "file"
        elif getattr(message, "video", None):
            media_file = message.video
            media_type = "video"
        elif getattr(message, "video_note", None):
            media_file = message.video_note
            media_type = "video"
        elif getattr(message, "animation", None):
            media_file = message.animation
            media_type = "animation"

        if media_file is None or not self._app:
            return [], []

        try:
            file = await self._app.bot.get_file(media_file.file_id)
            media_kind = media_type or "file"
            ext = self._get_extension(
                media_kind,
                getattr(media_file, "mime_type", None),
                getattr(media_file, "file_name", None),
            )
            media_dir = get_media_path()
            file_path = media_dir / f"{media_file.file_id[:16]}{ext}"
            await file.download_to_drive(str(file_path))

            path_str = str(file_path)
            if media_type in ("voice", "audio"):
                from bao.providers.transcription import GroqTranscriptionProvider

                transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key)
                transcription = await transcriber.transcribe(file_path)
                if transcription:
                    logger.info(
                        "🎙️ 转写完成 / transcribed: {}: {}...", media_type, transcription[:50]
                    )
                    return [path_str], [f"[transcription: {transcription}]"]
            return [path_str], [f"[{media_kind}: {path_str}]"]
        except Exception as e:
            logger.error("❌ 下载失败 / download failed: {}", e)
            if add_failure_content and media_type is not None:
                return [], [f"[{media_type}: download failed]"]
            return [], []

    async def _ensure_bot_identity(self) -> tuple[int | None, str | None]:
        if self._bot_user_id is not None or self._bot_username is not None:
            return self._bot_user_id, self._bot_username
        if not self._app:
            return None, None
        bot_info = await self._app.bot.get_me()
        self._bot_user_id = getattr(bot_info, "id", None)
        self._bot_username = getattr(bot_info, "username", None)
        return self._bot_user_id, self._bot_username

    @staticmethod
    def _has_mention_entity(
        text: str,
        entities: Any,
        bot_username: str,
        bot_id: int | None,
    ) -> bool:
        handle = f"@{bot_username}".lower()
        for entity in entities or []:
            entity_type = getattr(entity, "type", None)
            if entity_type == "text_mention":
                user = getattr(entity, "user", None)
                if user is not None and bot_id is not None and getattr(user, "id", None) == bot_id:
                    return True
                continue
            if entity_type != "mention":
                continue
            offset = getattr(entity, "offset", None)
            length = getattr(entity, "length", None)
            if offset is None or length is None:
                continue
            if text[offset : offset + length].lower() == handle:
                return True
        return handle in text.lower()

    async def _is_group_message_for_bot(self, message: Any) -> bool:
        if message.chat.type == "private" or self.config.group_policy == "open":
            return True

        bot_id, bot_username = await self._ensure_bot_identity()
        if bot_username:
            text = message.text or ""
            caption = message.caption or ""
            if self._has_mention_entity(text, getattr(message, "entities", None), bot_username, bot_id):
                return True
            if self._has_mention_entity(
                caption,
                getattr(message, "caption_entities", None),
                bot_username,
                bot_id,
            ):
                return True

        reply_user = getattr(getattr(message, "reply_to_message", None), "from_user", None)
        return bool(bot_id and reply_user and reply_user.id == bot_id)

    def _remember_thread_context(self, message: Any) -> None:
        message_thread_id = self._coerce_int(getattr(message, "message_thread_id", None))
        message_id = self._coerce_int(getattr(message, "message_id", None))
        if message_thread_id is None or message_id is None:
            return
        self._message_threads[(str(message.chat_id), message_id)] = message_thread_id
        if len(self._message_threads) > 1000:
            self._message_threads.pop(next(iter(self._message_threads)))

    async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
        """Forward slash commands to the bus for unified handling in AgentLoop."""
        if not update.message or not update.effective_user:
            return
        message = update.message
        user = update.effective_user
        self._remember_thread_context(message)
        await self._handle_message(
            sender_id=self._sender_id(user),
            chat_id=str(message.chat_id),
            content=message.text or "",
            metadata=self._build_message_metadata(message, user),
        )

    async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
        """Handle incoming messages (text, photos, voice, documents)."""
        if not update.message or not update.effective_user:
            return

        message = update.message
        user = update.effective_user
        chat_id = message.chat_id
        sender_id = self._sender_id(user)
        self._remember_thread_context(message)

        if not self.is_allowed(sender_id):
            logger.warning(
                "⚠️ 访问拒绝 / access denied: sender={} channel=telegram",
                sender_id,
            )
            return

        # Store chat_id for replies
        self._chat_ids[sender_id] = chat_id

        if not await self._is_group_message_for_bot(message):
            return

        # Build content from text and/or media
        content_parts: list[str] = []
        media_paths: list[str] = []

        # Text content
        if message.text:
            content_parts.append(message.text)
        if message.caption:
            content_parts.append(message.caption)

        current_media_paths, current_media_parts = await self._download_message_media(
            message, add_failure_content=True
        )
        media_paths.extend(current_media_paths)
        content_parts.extend(current_media_parts)

        reply = getattr(message, "reply_to_message", None)
        if reply is not None:
            reply_context = self._extract_reply_context(message)
            reply_media_paths, reply_media_parts = await self._download_message_media(reply)
            if reply_media_paths:
                media_paths = reply_media_paths + media_paths
            reply_tag = (
                reply_context
                or (f"[Reply to: {reply_media_parts[0]}]" if reply_media_parts else None)
            )
            if reply_tag:
                content_parts.insert(0, reply_tag)

        content = "\n".join(content_parts) if content_parts else "[empty message]"

        logger.debug("Telegram message from {}: {}...", sender_id, content[:50])

        str_chat_id = str(chat_id)

        metadata = self._build_message_metadata(message, user)

        media_group_id = getattr(message, "media_group_id", None)
        if media_group_id:
            key = f"{str_chat_id}:{media_group_id}"
            self._media_group_buffers.setdefault(key, []).append(
                {
                    "sender_id": sender_id,
                    "chat_id": str_chat_id,
                    "content": content,
                    "media": media_paths,
                    "metadata": metadata,
                }
            )
            if key not in self._media_group_tasks or self._media_group_tasks[key].done():
                self._media_group_tasks[key] = asyncio.create_task(self._flush_media_group(key))
            return

        # Start typing indicator before processing
        self._start_typing(str_chat_id)

        # Forward to the message bus
        await self._handle_message(
            sender_id=sender_id,
            chat_id=str_chat_id,
            content=content,
            media=media_paths,
            metadata=metadata,
        )

    async def _flush_media_group(self, key: str) -> None:
        try:
            await asyncio.sleep(0.6)
            items = self._media_group_buffers.pop(key, [])
            if not items:
                return

            sender_id = str(items[-1]["sender_id"])
            chat_id = str(items[-1]["chat_id"])
            metadata_obj = items[-1].get("metadata", {})
            metadata = metadata_obj if isinstance(metadata_obj, dict) else {}

            contents = [str(it.get("content", "")).strip() for it in items]
            merged_content = "\n".join(c for c in contents if c and c != "[empty message]")
            if not merged_content:
                merged_content = "[empty message]"

            merged_media: list[str] = []
            for it in items:
                media_obj = it.get("media", [])
                if isinstance(media_obj, list):
                    for media_path in media_obj:
                        if isinstance(media_path, str):
                            merged_media.append(media_path)
            merged_media = list(dict.fromkeys(merged_media))

            await self._handle_message(
                sender_id=sender_id,
                chat_id=chat_id,
                content=merged_content,
                media=merged_media,
                metadata=metadata,
            )
        except asyncio.CancelledError:
            self._media_group_buffers.pop(key, None)
            raise
        except Exception as e:
            logger.error("❌ 媒体组处理失败 / media-group flush failed: {}", e)
        finally:
            self._media_group_tasks.pop(key, None)

    def _start_typing(self, chat_id: str) -> None:
        """Start sending 'typing...' indicator for a chat."""
        # Cancel any existing typing task for this chat
        self._stop_typing(chat_id)
        self._typing_tasks[chat_id] = asyncio.create_task(self._typing_loop(chat_id))

    def _stop_typing(self, chat_id: str) -> None:
        """Stop the typing indicator for a chat."""
        task = self._typing_tasks.pop(chat_id, None)
        if task and not task.done():
            task.cancel()

    async def _typing_loop(self, chat_id: str) -> None:
        """Repeatedly send 'typing' action until cancelled."""
        try:
            while self._app:
                await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing")
                await asyncio.sleep(4)
        except asyncio.CancelledError:
            pass
        except Exception as e:
            logger.debug("Typing indicator stopped for {}: {}", chat_id, e)

    async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
        """Log polling / handler errors instead of silently swallowing them."""
        logger.error("❌ 处理异常 / handler error: {}", context.error)

    def _get_extension(
        self,
        media_type: str,
        mime_type: str | None,
        filename: str | None = None,
    ) -> str:
        """Get file extension based on media type or original filename."""
        if mime_type:
            ext_map = {
                "image/jpeg": ".jpg",
                "image/png": ".png",
                "image/gif": ".gif",
                "audio/ogg": ".ogg",
                "audio/mpeg": ".mp3",
                "audio/mp4": ".m4a",
            }
            if mime_type in ext_map:
                return ext_map[mime_type]

        type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3"}
        if media_type in type_map:
            return type_map[media_type]

        if filename:
            return "".join(Path(filename).suffixes)
        return ""
