import asyncio
import re
import threading
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple, Union
from urllib.parse import quote

import discord
from discord import app_commands
import httpx

from app.core.config import settings
from app.core.context import MediaInfo, Context
from app.core.metainfo import MetaInfo
from app.log import logger
from app.schemas.types import NotificationType
from app.utils.string import StringUtils

# Discord embed 字段解析白名单
# 只有这些消息类型会使用复杂的字段解析逻辑
PARSE_FIELD_TYPES = {
    NotificationType.Download,  # 资源下载
    NotificationType.Organize,  # 整理入库
    NotificationType.Subscribe,  # 订阅
    NotificationType.Manual,  # 手动处理
}


class Discord:
    """
    Discord Bot 通知与交互实现（基于 discord.py 2.6.4）
    """

    _MAX_SLASH_COMMANDS = 100

    def __init__(
        self,
        DISCORD_BOT_TOKEN: Optional[str] = None,
        DISCORD_GUILD_ID: Optional[Union[str, int]] = None,
        DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None,
        **kwargs,
    ):
        logger.debug(
            f"[Discord] 初始化 Discord 实例: name={kwargs.get('name')}, "
            f"GUILD_ID={DISCORD_GUILD_ID}, CHANNEL_ID={DISCORD_CHANNEL_ID}, "
            f"TOKEN={'已配置' if DISCORD_BOT_TOKEN else '未配置'}"
        )
        if not DISCORD_BOT_TOKEN:
            logger.error("Discord Bot Token 未配置！")
            return

        self._token = DISCORD_BOT_TOKEN
        self._guild_id = self._to_int(DISCORD_GUILD_ID)
        self._channel_id = self._to_int(DISCORD_CHANNEL_ID)
        logger.debug(
            f"[Discord] 解析后的 ID: _guild_id={self._guild_id}, _channel_id={self._channel_id}"
        )
        base_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message/"
        self._ds_url = f"{base_ds_url}?token={settings.API_TOKEN}"
        if kwargs.get("name"):
            # URL encode the source name to handle special characters in config names
            encoded_name = quote(kwargs.get("name"), safe="")
            self._ds_url = f"{self._ds_url}&source={encoded_name}"
        logger.debug(f"[Discord] 消息回调 URL: {self._ds_url}")

        intents = discord.Intents.default()
        intents.message_content = True
        intents.messages = True
        intents.guilds = True

        self._client: Optional[discord.Client] = discord.Client(
            intents=intents, proxy=settings.PROXY_HOST
        )
        self._tree: Optional[app_commands.CommandTree] = app_commands.CommandTree(self._client)
        self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
        self._thread: Optional[threading.Thread] = None
        self._ready_event = threading.Event()
        self._user_dm_cache: Dict[str, discord.DMChannel] = {}
        self._user_chat_mapping: Dict[
            str, str
        ] = {}  # userid -> chat_id mapping for reply targeting
        self._broadcast_channel = None
        self._bot_user_id: Optional[int] = None
        self._typing_tasks: Dict[str, asyncio.Task] = {}
        self._typing_stop_events: Dict[str, asyncio.Event] = {}
        self._typing_interval_seconds = 5
        self._typing_initial_delay_seconds = 1
        self._typing_max_duration_seconds = 10 * 60
        self._registered_commands: Optional[Dict[str, dict]] = None

        self._register_events()
        self._start()

    @staticmethod
    def _to_int(val: Optional[Union[str, int]]) -> Optional[int]:
        try:
            return int(val) if val is not None and str(val).strip() else None
        except ValueError:
            return None

    def _register_events(self):
        @self._client.event
        async def on_ready():
            self._bot_user_id = self._client.user.id if self._client.user else None
            self._ready_event.set()
            logger.info(f"Discord Bot 已登录：{self._client.user}")
            if self._registered_commands is not None:
                try:
                    await self._sync_registered_commands()
                except Exception as err:
                    logger.error(f"同步 Discord 斜杠命令失败：{err}")

        @self._client.event
        async def on_message(message: discord.Message):
            if message.author.bot:
                return
            if not self._should_process_message(message):
                return

            # Update user-chat mapping for reply targeting
            self._update_user_chat_mapping(
                str(message.author.id), str(message.channel.id)
            )

            cleaned_text = self._clean_bot_mention(message.content or "")
            username = (
                message.author.display_name
                or message.author.global_name
                or message.author.name
            )
            payload = {
                "type": "message",
                "userid": str(message.author.id),
                "username": username,
                "user_tag": str(message.author),
                "text": cleaned_text,
                "message_id": str(message.id),
                "chat_id": str(message.channel.id),
                "channel_type": "dm"
                if isinstance(message.channel, discord.DMChannel)
                else "guild",
            }
            if message.attachments:
                payload["attachments"] = [
                    {
                        "id": str(attachment.id),
                        "filename": attachment.filename,
                        "content_type": attachment.content_type,
                        "url": attachment.url,
                        "proxy_url": attachment.proxy_url,
                        "size": attachment.size,
                        "height": attachment.height,
                        "width": attachment.width,
                    }
                    for attachment in message.attachments
                ]
            await self._post_to_ds(payload)

        @self._client.event
        async def on_interaction(interaction: discord.Interaction):
            if interaction.type == discord.InteractionType.component:
                data = interaction.data or {}
                callback_data = data.get("custom_id")
                if not callback_data:
                    return
                try:
                    await interaction.response.defer(ephemeral=True)
                except Exception as e:
                    logger.error(f"处理 Discord 交互响应失败：{e}")

                # Update user-chat mapping for reply targeting
                if interaction.user and interaction.channel:
                    self._update_user_chat_mapping(
                        str(interaction.user.id), str(interaction.channel.id)
                    )

                username = (
                    (
                        interaction.user.display_name
                        or interaction.user.global_name
                        or interaction.user.name
                    )
                    if interaction.user
                    else None
                )
                payload = {
                    "type": "interaction",
                    "userid": str(interaction.user.id) if interaction.user else None,
                    "username": username,
                    "user_tag": str(interaction.user) if interaction.user else None,
                    "callback_data": callback_data,
                    "message_id": str(interaction.message.id)
                    if interaction.message
                    else None,
                    "chat_id": str(interaction.channel.id)
                    if interaction.channel
                    else None,
                }
                await self._post_to_ds(payload)

    def _start(self):
        if self._thread:
            return

        def runner():
            asyncio.set_event_loop(self._loop)
            try:
                self._loop.create_task(self._client.start(self._token))
                self._loop.run_forever()
            except Exception as err:
                logger.error(f"Discord Bot 启动失败：{err}")
            finally:
                try:
                    self._loop.run_until_complete(self._client.close())
                except Exception as err:
                    logger.debug(f"Discord Bot 关闭失败：{err}")

        self._thread = threading.Thread(target=runner, daemon=True)
        self._thread.start()

    def stop(self):
        if not self._client or not self._loop or not self._thread:
            return
        try:
            asyncio.run_coroutine_threadsafe(
                self._stop_all_typing_tasks(), self._loop
            ).result(timeout=5)
            asyncio.run_coroutine_threadsafe(self._client.close(), self._loop).result(
                timeout=10
            )
        except Exception as err:
            logger.error(f"关闭 Discord Bot 失败：{err}")
        finally:
            try:
                self._loop.call_soon_threadsafe(self._loop.stop)
            except Exception as err:
                logger.error(f"停止 Discord 事件循环失败：{err}")
            self._ready_event.clear()

    def get_state(self) -> bool:
        return self._ready_event.is_set() and self._client is not None

    def register_commands(self, commands: Dict[str, dict]) -> bool:
        """
        注册 Discord 斜杠命令。

        :param commands: 命令字典，键为斜杠命令，值包含描述和分类等元数据
        :return: 是否成功提交同步任务
        """
        self._registered_commands = dict(commands or {})
        return self._schedule_command_sync()

    def delete_commands(self) -> bool:
        """
        清理 Discord 斜杠命令。

        :return: 是否成功提交同步任务
        """
        self._registered_commands = {}
        return self._schedule_command_sync()

    def _schedule_command_sync(self) -> bool:
        """在 Discord 事件循环中提交命令同步任务。"""
        if not self._tree or not self._loop:
            return False
        if not self.get_state():
            logger.debug("Discord Bot 未就绪，斜杠命令将在登录后同步")
            return True
        try:
            future = asyncio.run_coroutine_threadsafe(
                self._sync_registered_commands(), self._loop
            )
            return bool(future.result(timeout=30))
        except Exception as err:
            logger.error(f"同步 Discord 斜杠命令失败：{err}")
            return False

    async def _sync_registered_commands(self) -> bool:
        """将当前命令集合同步到 Discord 应用命令树。"""
        if not self._tree or not self._client:
            return False
        if not self._client.is_ready():
            await self._client.wait_until_ready()

        guild = discord.Object(id=self._guild_id) if self._guild_id else None
        self._tree.clear_commands(guild=guild)

        commands = self._registered_commands or {}
        registered_count = 0
        seen_names = set()
        for command_text, command_data in commands.items():
            if registered_count >= self._MAX_SLASH_COMMANDS:
                logger.warning(
                    f"Discord 斜杠命令数量超过 {self._MAX_SLASH_COMMANDS} 个，后续命令已跳过"
                )
                break
            command_name = self._normalize_slash_command_name(command_text)
            if not command_name or command_name in seen_names:
                logger.warning(f"跳过无效或重复的 Discord 斜杠命令：{command_text}")
                continue
            seen_names.add(command_name)
            description = self._normalize_slash_command_description(
                command_data.get("description") if isinstance(command_data, dict) else None,
                command_name,
            )
            self._tree.add_command(
                self._build_slash_command(command_text, command_name, description),
                guild=guild,
                override=True,
            )
            registered_count += 1

        synced_commands = await self._tree.sync(guild=guild)
        logger.info(f"Discord 斜杠命令已同步：{len(synced_commands)} 个")
        return True

    @staticmethod
    def _normalize_slash_command_name(command_text: str) -> str:
        """转换为 Discord 允许的斜杠命令名称。"""
        command_name = str(command_text or "").strip().lstrip("/").lower()
        if not re.fullmatch(r"[a-z0-9_-]{1,32}", command_name):
            return ""
        return command_name

    @staticmethod
    def _normalize_slash_command_description(
        description: Optional[str],
        fallback: str,
    ) -> str:
        """整理 Discord 斜杠命令描述，满足长度要求。"""
        normalized = str(description or fallback or "MoviePilot").strip()
        return normalized[:100] or "MoviePilot"

    def _build_slash_command(
        self,
        command_text: str,
        command_name: str,
        description: str,
    ) -> app_commands.Command:
        """构建 Discord 斜杠命令对象。"""

        async def _callback(
            interaction: discord.Interaction,
            args: Optional[str] = None,
        ) -> None:
            await self._handle_slash_command(interaction, command_text, args)

        _callback.__name__ = f"moviepilot_{command_name}"
        _callback = app_commands.describe(args="命令参数")(_callback)
        return app_commands.Command(
            name=command_name,
            description=description,
            callback=_callback,
        )

    async def _handle_slash_command(
        self,
        interaction: discord.Interaction,
        command_text: str,
        args: Optional[str] = None,
    ) -> None:
        """处理 Discord 斜杠命令回调，并转发到统一消息入口。"""
        try:
            await interaction.response.defer(ephemeral=True, thinking=True)
        except Exception as err:
            logger.debug(f"延迟响应 Discord 斜杠命令失败：{err}")

        userid = str(interaction.user.id) if interaction.user else None
        chat_id = str(interaction.channel.id) if interaction.channel else None
        username = None
        if interaction.user:
            username = (
                getattr(interaction.user, "display_name", None)
                or getattr(interaction.user, "global_name", None)
                or getattr(interaction.user, "name", None)
            )
        if userid and chat_id:
            self._update_user_chat_mapping(userid, chat_id)

        arg_text = str(args or "").strip()
        payload = {
            "type": "message",
            "userid": userid,
            "username": username,
            "user_tag": str(interaction.user) if interaction.user else None,
            "text": f"{command_text} {arg_text}".strip(),
            "message_id": str(interaction.id),
            "chat_id": chat_id,
            "channel_type": "dm"
            if isinstance(interaction.channel, discord.DMChannel)
            else "guild",
        }
        await self._post_to_ds(payload)

        try:
            if interaction.response.is_done():
                await interaction.followup.send("命令已提交，请稍等...", ephemeral=True)
            else:
                await interaction.response.send_message(
                    "命令已提交，请稍等...",
                    ephemeral=True,
                )
        except Exception as err:
            logger.debug(f"发送 Discord 斜杠命令确认失败：{err}")

    def send_msg(
        self,
        title: str,
        text: Optional[str] = None,
        image: Optional[str] = None,
        userid: Optional[str] = None,
        link: Optional[str] = None,
        buttons: Optional[List[List[dict]]] = None,
        original_message_id: Optional[Union[int, str]] = None,
        original_chat_id: Optional[str] = None,
        mtype: Optional["NotificationType"] = None,
    ) -> Optional[bool]:
        logger.debug(
            f"[Discord] send_msg 被调用: userid={userid}, title={title[:50] if title else None}..."
        )
        logger.debug(
            f"[Discord] get_state() = {self.get_state()}, "
            f"_ready_event.is_set() = {self._ready_event.is_set()}, "
            f"_client = {self._client is not None}"
        )
        if not self.get_state():
            logger.warning("[Discord] get_state() 返回 False，Bot 未就绪，无法发送消息")
            return False
        if not title and not text:
            logger.warn("标题和内容不能同时为空")
            return False

        try:
            logger.debug(f"[Discord] 准备异步发送消息...")
            future = asyncio.run_coroutine_threadsafe(
                self._send_message(
                    title=title,
                    text=text,
                    image=image,
                    userid=userid,
                    link=link,
                    buttons=buttons,
                    original_message_id=original_message_id,
                    original_chat_id=original_chat_id,
                    mtype=mtype,
                ),
                self._loop,
            )
            result = future.result(timeout=30)
            logger.debug(f"[Discord] 异步发送完成，结果: {result}")
            return result
        except Exception as err:
            logger.error(f"发送 Discord 消息失败：{err}")
            return False

    def send_file(
        self,
        file_path: str,
        title: Optional[str] = None,
        text: Optional[str] = None,
        userid: Optional[str] = None,
        file_name: Optional[str] = None,
        original_chat_id: Optional[str] = None,
    ) -> Optional[bool]:
        if not self.get_state():
            return False
        if not file_path:
            return False

        try:
            future = asyncio.run_coroutine_threadsafe(
                self._send_file(
                    file_path=file_path,
                    title=title,
                    text=text,
                    userid=userid,
                    file_name=file_name,
                    original_chat_id=original_chat_id,
                ),
                self._loop,
            )
            return future.result(timeout=30)
        except Exception as err:
            logger.error(f"发送 Discord 文件失败：{err}")
            return False

    def send_medias_msg(
        self,
        medias: List[MediaInfo],
        userid: Optional[str] = None,
        title: Optional[str] = None,
        buttons: Optional[List[List[dict]]] = None,
        original_message_id: Optional[Union[int, str]] = None,
        original_chat_id: Optional[str] = None,
    ) -> Optional[bool]:
        if not self.get_state() or not medias:
            return False
        title = title or "媒体列表"
        try:
            future = asyncio.run_coroutine_threadsafe(
                self._send_list_message(
                    embeds=self._build_media_embeds(medias, title),
                    userid=userid,
                    buttons=self._build_default_buttons(len(medias))
                    if not buttons
                    else buttons,
                    fallback_buttons=buttons,
                    original_message_id=original_message_id,
                    original_chat_id=original_chat_id,
                ),
                self._loop,
            )
            return future.result(timeout=30)
        except Exception as err:
            logger.error(f"发送 Discord 媒体列表失败：{err}")
            return False

    def send_torrents_msg(
        self,
        torrents: List[Context],
        userid: Optional[str] = None,
        title: Optional[str] = None,
        buttons: Optional[List[List[dict]]] = None,
        original_message_id: Optional[Union[int, str]] = None,
        original_chat_id: Optional[str] = None,
    ) -> Optional[bool]:
        if not self.get_state() or not torrents:
            return False
        title = title or "种子列表"
        try:
            future = asyncio.run_coroutine_threadsafe(
                self._send_list_message(
                    embeds=self._build_torrent_embeds(torrents, title),
                    userid=userid,
                    buttons=self._build_default_buttons(len(torrents))
                    if not buttons
                    else buttons,
                    fallback_buttons=buttons,
                    original_message_id=original_message_id,
                    original_chat_id=original_chat_id,
                ),
                self._loop,
            )
            return future.result(timeout=30)
        except Exception as err:
            logger.error(f"发送 Discord 种子列表失败：{err}")
            return False

    def start_typing(
        self,
        userid: Optional[str] = None,
        chat_id: Optional[str] = None,
        max_duration_seconds: Optional[float] = None,
        initial_delay_seconds: Optional[float] = None,
    ) -> bool:
        """
        持续发送 Discord typing 指示，直到显式停止或达到最大续期。
        """
        if not self.get_state():
            return False
        typing_key = self._typing_key(userid=userid, chat_id=chat_id)
        if not typing_key:
            return False
        try:
            future = asyncio.run_coroutine_threadsafe(
                self._start_typing_task(
                    typing_key=typing_key,
                    userid=userid,
                    chat_id=chat_id,
                    max_duration_seconds=max_duration_seconds,
                    initial_delay_seconds=initial_delay_seconds,
                ),
                self._loop,
            )
            return future.result(timeout=10)
        except Exception as err:
            logger.error(f"发送 Discord typing 状态失败：{err}")
            return False

    def stop_typing(
        self,
        userid: Optional[str] = None,
        chat_id: Optional[str] = None,
    ) -> bool:
        """
        停止 Discord typing 续发任务。
        """
        typing_key = self._typing_key(userid=userid, chat_id=chat_id)
        if not typing_key or not self._loop:
            return False
        try:
            future = asyncio.run_coroutine_threadsafe(
                self._stop_typing_task(typing_key), self._loop
            )
            return future.result(timeout=5)
        except Exception as err:
            logger.error(f"停止 Discord typing 状态失败：{err}")
            return False

    @staticmethod
    def _typing_key(userid: Optional[str] = None, chat_id: Optional[str] = None) -> str:
        """优先按频道维度管理 typing 状态，缺失时退回用户维度。"""
        if chat_id:
            return f"chat:{chat_id}"
        if userid:
            return f"user:{userid}"
        return ""

    async def _start_typing_task(
        self,
        typing_key: str,
        userid: Optional[str] = None,
        chat_id: Optional[str] = None,
        max_duration_seconds: Optional[float] = None,
        initial_delay_seconds: Optional[float] = None,
    ) -> bool:
        await self._stop_typing_task(typing_key)
        channel = await self._resolve_channel(userid=userid, chat_id=chat_id)
        if not channel:
            return False
        stop_event = asyncio.Event()
        max_duration = max_duration_seconds or self._typing_max_duration_seconds
        initial_delay = (
            self._typing_initial_delay_seconds
            if initial_delay_seconds is None
            else max(initial_delay_seconds, 0)
        )

        async def _typing_worker() -> None:
            started_at = self._loop.time()
            try:
                # Discord typing 触发后也会在客户端自然保留一段时间，
                # 先给短响应一个取消窗口，避免回复后残留输入状态。
                if initial_delay:
                    try:
                        await asyncio.wait_for(
                            stop_event.wait(),
                            timeout=initial_delay,
                        )
                        return
                    except asyncio.TimeoutError:
                        pass
                while not stop_event.is_set():
                    if self._loop.time() - started_at >= max_duration:
                        logger.warning(
                            "Discord typing状态超过最大续期，自动停止: key=%s",
                            typing_key,
                        )
                        break
                    try:
                        await channel.trigger_typing()
                    except Exception as err:
                        logger.debug(f"触发 Discord typing 状态失败：{err}")
                    try:
                        await asyncio.wait_for(
                            stop_event.wait(),
                            timeout=self._typing_interval_seconds,
                        )
                    except asyncio.TimeoutError:
                        pass
            finally:
                current_task = asyncio.current_task()
                if self._typing_tasks.get(typing_key) is current_task:
                    self._typing_tasks.pop(typing_key, None)
                    self._typing_stop_events.pop(typing_key, None)

        self._typing_stop_events[typing_key] = stop_event
        self._typing_tasks[typing_key] = asyncio.create_task(_typing_worker())
        return True

    async def _stop_typing_task(self, typing_key: str) -> bool:
        stop_event = self._typing_stop_events.pop(typing_key, None)
        task = self._typing_tasks.pop(typing_key, None)
        if stop_event:
            stop_event.set()
        if task and task is not asyncio.current_task() and not task.done():
            try:
                await asyncio.wait_for(asyncio.shield(task), timeout=1)
            except asyncio.TimeoutError:
                pass
        return bool(stop_event or task)

    async def _stop_all_typing_tasks(self) -> None:
        for typing_key in list(self._typing_tasks.keys()):
            await self._stop_typing_task(typing_key)

    def delete_msg(
        self, message_id: Union[str, int], chat_id: Optional[str] = None
    ) -> Optional[bool]:
        if not self.get_state():
            return False
        try:
            future = asyncio.run_coroutine_threadsafe(
                self._delete_message(message_id=message_id, chat_id=chat_id), self._loop
            )
            return future.result(timeout=15)
        except Exception as err:
            logger.error(f"删除 Discord 消息失败：{err}")
            return False

    async def _send_message(
        self,
        title: str,
        text: Optional[str],
        image: Optional[str],
        userid: Optional[str],
        link: Optional[str],
        buttons: Optional[List[List[dict]]],
        original_message_id: Optional[Union[int, str]],
        original_chat_id: Optional[str],
        mtype: Optional["NotificationType"] = None,
    ) -> Tuple[bool, Optional[Dict[str, str]]]:
        logger.debug(
            f"[Discord] _send_message: userid={userid}, original_chat_id={original_chat_id}"
        )
        channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)
        logger.debug(
            f"[Discord] _resolve_channel 返回: {channel}, type={type(channel)}"
        )
        if not channel:
            logger.error("未找到可用的 Discord 频道或私聊")
            return False, None

        embed = self._build_embed(
            title=title, text=text, image=image, link=link, mtype=mtype
        )
        view = self._build_view(buttons=buttons, link=link)
        content = None

        if original_message_id and original_chat_id:
            logger.debug(f"[Discord] 编辑现有消息: message_id={original_message_id}")
            success = await self._edit_message(
                chat_id=original_chat_id,
                message_id=original_message_id,
                content=content,
                embed=embed,
                view=view,
            )
            return (
                success,
                {
                    "message_id": str(original_message_id),
                    "chat_id": str(original_chat_id),
                }
                if success and original_message_id and original_chat_id
                else None,
            )

        logger.debug(f"[Discord] 发送新消息到频道: {channel}")
        try:
            sent_message = await channel.send(content=content, embed=embed, view=view)
            logger.debug("[Discord] 消息发送成功")
            return (
                True,
                {
                    "message_id": str(sent_message.id),
                    "chat_id": str(channel.id),
                }
                if sent_message and getattr(channel, "id", None) is not None
                else None,
            )
        except Exception as e:
            logger.error(f"[Discord] 发送消息到频道失败: {e}")
            return False, None

    async def _send_file(
        self,
        file_path: str,
        title: Optional[str],
        text: Optional[str],
        userid: Optional[str],
        file_name: Optional[str],
        original_chat_id: Optional[str],
    ) -> Tuple[bool, Optional[Dict[str, str]]]:
        channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)
        if not channel:
            logger.error("未找到可用的 Discord 频道或私聊")
            return False, None

        local_file = Path(file_path)
        if not local_file.exists() or not local_file.is_file():
            logger.error(f"Discord发送文件失败，文件不存在: {local_file}")
            return False, None

        content_parts = [part for part in [title, text] if part]
        content = "\n".join(content_parts) if content_parts else None
        if content and len(content) > 1900:
            content = content[:1900] + "..."

        try:
            discord_file = discord.File(
                str(local_file), filename=file_name or local_file.name
            )
            sent_message = await channel.send(content=content, file=discord_file)
            return (
                True,
                {
                    "message_id": str(sent_message.id),
                    "chat_id": str(channel.id),
                },
            )
        except Exception as err:
            logger.error(f"Discord发送文件失败: {err}")
            return False, None

    async def _send_list_message(
        self,
        embeds: List[discord.Embed],
        userid: Optional[str],
        buttons: Optional[List[List[dict]]],
        fallback_buttons: Optional[List[List[dict]]],
        original_message_id: Optional[Union[int, str]],
        original_chat_id: Optional[str],
    ) -> bool:
        channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)
        if not channel:
            logger.error("未找到可用的 Discord 频道或私聊")
            return False

        view = self._build_view(buttons=buttons if buttons else fallback_buttons)
        embeds = embeds[:10] if embeds else []  # Discord 单条消息最多 10 个 embed

        if original_message_id and original_chat_id:
            return await self._edit_message(
                chat_id=original_chat_id,
                message_id=original_message_id,
                content=None,
                embed=None,
                view=view,
                embeds=embeds,
            )

        await channel.send(
            embed=embeds[0] if len(embeds) == 1 else None,
            embeds=embeds if len(embeds) > 1 else None,
            view=view,
        )
        return True

    async def _edit_message(
        self,
        chat_id: Union[str, int],
        message_id: Union[str, int],
        content: Optional[str],
        embed: Optional[discord.Embed],
        view: Optional[discord.ui.View],
        embeds: Optional[List[discord.Embed]] = None,
    ) -> bool:
        channel = await self._resolve_channel(chat_id=str(chat_id))
        if not channel:
            logger.error(f"未找到要编辑的 Discord 频道：{chat_id}")
            return False
        try:
            message = await channel.fetch_message(int(message_id))
            kwargs: Dict[str, Any] = {"content": content, "view": view}
            if embeds:
                if len(embeds) == 1:
                    kwargs["embed"] = embeds[0]
                else:
                    kwargs["embeds"] = embeds
            elif embed:
                kwargs["embed"] = embed
            await message.edit(**kwargs)
            return True
        except Exception as err:
            logger.error(f"编辑 Discord 消息失败：{err}")
            return False

    async def _delete_message(
        self, message_id: Union[str, int], chat_id: Optional[str]
    ) -> bool:
        channel = await self._resolve_channel(chat_id=chat_id)
        if not channel:
            logger.error("删除 Discord 消息时未找到频道")
            return False
        try:
            message = await channel.fetch_message(int(message_id))
            await message.delete()
            return True
        except Exception as err:
            logger.error(f"删除 Discord 消息失败：{err}")
            return False

    @staticmethod
    def _build_embed(
        title: str,
        text: Optional[str],
        image: Optional[str],
        link: Optional[str],
        mtype: Optional["NotificationType"] = None,
    ) -> discord.Embed:
        fields: List[Dict[str, str]] = []
        desc_lines: List[str] = []
        should_parse_fields = mtype in PARSE_FIELD_TYPES if mtype else False

        def _collect_spans(s: str, left: str, right: str) -> List[Tuple[int, int]]:
            spans: List[Tuple[int, int]] = []
            start = 0
            while True:
                l_idx = s.find(left, start)
                if l_idx == -1:
                    break
                r_idx = s.find(right, l_idx + 1)
                if r_idx == -1:
                    break
                spans.append((l_idx, r_idx))
                start = r_idx + 1
            return spans

        def _find_colon_index(s: str, m: re.Match) -> Optional[int]:
            segment = s[m.start(): m.end()]
            for i, ch in enumerate(segment):
                if ch in (":", "："):
                    return m.start() + i
            return None

        if text:
            # 处理上游未反序列化的 "\n" 等转义换行，避免被当成普通字符
            if "\\n" in text or "\\r" in text:
                text = (
                    text.replace("\\r\\n", "\n")
                    .replace("\\n", "\n")
                    .replace("\\r", "\n")
                )
            if not should_parse_fields:
                desc_lines.append(text.strip())
            else:
                # 匹配形如 "字段：值" 的片段，字段名不允许包含常见分隔符；
                # 下一个字段需以顿号/逗号/分号等分隔开，且不能是 URL 协议开头，避免值里出现 URL 的":" 被误拆
                # 字段名允许 emoji 等 Unicode 字符，但排除空白/分隔符/冒号
                name_re = r"[^\s:：，,。；;、]+"
                pair_pattern = re.compile(
                    rf"({name_re})[：:](.*?)(?=(?:[，,。；;、]+\s*(?!https?://|ftp://|ftps://|magnet:){name_re}[：:])|$)",
                    re.IGNORECASE,
                )
                for line in text.splitlines():
                    line = line.strip()
                    if not line:
                        continue
                    matches = list(pair_pattern.finditer(line))
                    if matches:
                        book_spans = _collect_spans(line, "《", "》") + _collect_spans(
                            line, "【", "】"
                        )
                        if book_spans:
                            has_book_colon = False
                            for m in matches:
                                colon_idx = _find_colon_index(line, m)
                                if colon_idx is not None and any(
                                    l < colon_idx < r for l, r in book_spans
                                ):
                                    has_book_colon = True
                                    break
                            if has_book_colon:
                                desc_lines.append(line)
                                continue
                        # 若整行只是 URL/时间等自然包含":"的内容，则不当作字段
                        url_like_names = {"http", "https", "ftp", "ftps", "magnet"}
                        if all(
                            m.group(1).lower() in url_like_names or m.group(1).isdigit()
                            for m in matches
                        ):
                            desc_lines.append(line)
                            continue
                        last_end = 0
                        for m in matches:
                            # 追加匹配前的非空文本到描述
                            prefix = line[last_end: m.start()].strip(" ，,;；。、")
                            # 仅当前缀不全是分隔符/空白时才记录
                            if prefix and prefix.strip(" ，,;；。、"):
                                desc_lines.append(prefix)
                            name = m.group(1).strip()
                            value = m.group(2).strip(" ，,;；。、\t") or "-"
                            if name:
                                fields.append(
                                    {"name": name, "value": value, "inline": False}
                                )
                            last_end = m.end()
                        # 匹配末尾后的文本
                        suffix = line[last_end:].strip(" ，,;；。、")
                        if suffix and suffix.strip(" ，,;；。、"):
                            desc_lines.append(suffix)
                    else:
                        desc_lines.append(line)
        description = "\n".join(desc_lines).strip()
        if not description and not fields and text:
            description = text.strip()
        embed = discord.Embed(
            title=title,
            url=link or "https://github.com/jxxghp/MoviePilot",
            description=description if description else None,
            color=0xE67E22,
        )
        for field in fields:
            embed.add_field(name=field["name"], value=field["value"], inline=False)
        if image:
            embed.set_image(url=image)
        return embed

    @staticmethod
    def _build_media_embeds(medias: List[MediaInfo], title: str) -> List[discord.Embed]:
        embeds: List[discord.Embed] = []
        for index, media in enumerate(medias[:10], start=1):
            overview = media.get_overview_string(80)
            desc_parts = [
                f"{media.type.value} | {media.vote_star}"
                if media.vote_star
                else media.type.value,
                overview,
            ]
            embed = discord.Embed(
                title=f"{index}. {media.title_year}",
                url=media.detail_link or discord.Embed.Empty,
                description="\n".join([p for p in desc_parts if p]),
                color=0x5865F2,
            )
            if media.get_poster_image():
                embed.set_thumbnail(url=media.get_poster_image())
            embeds.append(embed)
        if embeds:
            embeds[0].set_author(name=title)
        return embeds

    @staticmethod
    def _build_torrent_embeds(
        torrents: List[Context], title: str
    ) -> List[discord.Embed]:
        embeds: List[discord.Embed] = []
        for index, context in enumerate(torrents[:10], start=1):
            torrent = context.torrent_info
            meta = MetaInfo(torrent.title, torrent.description)
            title_text = f"{meta.season_episode} {meta.resource_term} {meta.video_term} {meta.release_group}"
            title_text = re.sub(r"\s+", " ", title_text).strip()
            detail = [
                f"{torrent.site_name} | {StringUtils.str_filesize(torrent.size)} | {torrent.volume_factor} | {torrent.seeders}↑",
                meta.resource_term,
                meta.video_term,
            ]
            embed = discord.Embed(
                title=f"{index}. {title_text or torrent.title}",
                url=torrent.page_url or discord.Embed.Empty,
                description="\n".join([d for d in detail if d]),
                color=0x00A86B,
            )
            poster = getattr(torrent, "poster", None)
            if poster:
                embed.set_thumbnail(url=poster)
            embeds.append(embed)
        if embeds:
            embeds[0].set_author(name=title)
        return embeds

    @staticmethod
    def _build_default_buttons(count: int) -> List[List[dict]]:
        buttons: List[List[dict]] = []
        max_rows = 5
        max_per_row = 5
        capped = min(count, max_rows * max_per_row)
        for idx in range(1, capped + 1):
            row_idx = (idx - 1) // max_per_row
            if len(buttons) <= row_idx:
                buttons.append([])
            buttons[row_idx].append({"text": f"选择 {idx}", "callback_data": str(idx)})
        if count > capped:
            logger.warn(f"按钮数量超过 Discord 限制，仅展示前 {capped} 个")
        return buttons

    @staticmethod
    def _build_view(
        buttons: Optional[List[List[dict]]], link: Optional[str] = None
    ) -> Optional[discord.ui.View]:
        has_buttons = buttons and any(buttons)
        if not has_buttons and not link:
            return None

        view = discord.ui.View(timeout=None)
        if buttons:
            for row_index, button_row in enumerate(buttons[:5]):
                for button in button_row[:5]:
                    if "url" in button:
                        btn = discord.ui.Button(
                            label=button.get("text", "链接"),
                            url=button["url"],
                            style=discord.ButtonStyle.link,
                        )
                    else:
                        custom_id = (
                            button.get("callback_data")
                            or button.get("text")
                            or f"btn-{row_index}"
                        )[:99]
                        btn = discord.ui.Button(
                            label=button.get("text", "选择")[:80],
                            custom_id=custom_id,
                            style=discord.ButtonStyle.primary,
                        )
                    view.add_item(btn)
        elif link:
            view.add_item(
                discord.ui.Button(
                    label="查看详情", url=link, style=discord.ButtonStyle.link
                )
            )
        return view

    async def _resolve_channel(
        self, userid: Optional[str] = None, chat_id: Optional[str] = None
    ):
        """
        Resolve the channel to send messages to.
        Priority order:
        1. `chat_id` (original channel where user sent the message) - for contextual replies
        2. `userid` mapping (channel where user last sent a message) - for contextual replies
        3. Configured `_channel_id` (broadcast channel) - for system notifications
        4. Any available text channel in configured guild - fallback
        5. `userid` (DM) - for private conversations as a final fallback
        """
        logger.debug(
            f"[Discord] _resolve_channel: userid={userid}, chat_id={chat_id}, "
            f"_channel_id={self._channel_id}, _guild_id={self._guild_id}"
        )

        # Priority 1: Use explicit chat_id (reply to the same channel where user sent message)
        if chat_id:
            logger.debug(f"[Discord] 尝试通过 chat_id={chat_id} 获取原始频道")
            channel = self._client.get_channel(int(chat_id))
            if channel:
                logger.debug(f"[Discord] 通过 get_channel 找到频道: {channel}")
                return channel
            try:
                channel = await self._client.fetch_channel(int(chat_id))
                logger.debug(f"[Discord] 通过 fetch_channel 找到频道: {channel}")
                return channel
            except Exception as err:
                logger.warn(f"通过 chat_id 获取 Discord 频道失败：{err}")

        # Priority 2: Use user-chat mapping (reply to where the user last sent a message)
        if userid:
            mapped_chat_id = self._get_user_chat_id(str(userid))
            if mapped_chat_id:
                logger.debug(f"[Discord] 从用户映射获取 chat_id={mapped_chat_id}")
                channel = self._client.get_channel(int(mapped_chat_id))
                if channel:
                    logger.debug(f"[Discord] 通过映射找到频道: {channel}")
                    return channel
                try:
                    channel = await self._client.fetch_channel(int(mapped_chat_id))
                    logger.debug(
                        f"[Discord] 通过 fetch_channel 找到映射频道: {channel}"
                    )
                    return channel
                except Exception as err:
                    logger.warn(f"通过映射的 chat_id 获取 Discord 频道失败：{err}")

        # Priority 3: Use configured broadcast channel (for system notifications)
        if self._broadcast_channel:
            logger.debug(f"[Discord] 使用缓存的广播频道: {self._broadcast_channel}")
            return self._broadcast_channel
        if self._channel_id:
            logger.debug(
                f"[Discord] 尝试通过配置的 _channel_id={self._channel_id} 获取频道"
            )
            channel = self._client.get_channel(self._channel_id)
            if not channel:
                try:
                    channel = await self._client.fetch_channel(self._channel_id)
                except Exception as err:
                    logger.warn(f"通过配置的频道ID获取 Discord 频道失败：{err}")
                    channel = None
            self._broadcast_channel = channel
            if channel:
                logger.debug(f"[Discord] 通过配置的频道ID找到频道: {channel}")
                return channel

        # Priority 4: Find any available text channel in guild (fallback)
        logger.debug(f"[Discord] 尝试在 Guild 中寻找可用频道")
        target_guilds = []
        if self._guild_id:
            guild = self._client.get_guild(self._guild_id)
            if guild:
                target_guilds.append(guild)
        else:
            target_guilds = list(self._client.guilds)
        logger.debug(f"[Discord] 目标 Guilds 数量: {len(target_guilds)}")

        for guild in target_guilds:
            for channel in guild.text_channels:
                if guild.me and channel.permissions_for(guild.me).send_messages:
                    logger.debug(f"[Discord] 在 Guild 中找到可用频道: {channel}")
                    self._broadcast_channel = channel
                    return channel

        # Priority 5: Fallback to DM (only if no channel available)
        if userid:
            logger.debug(f"[Discord] 回退到私聊: userid={userid}")
            dm = await self._get_dm_channel(str(userid))
            if dm:
                logger.debug(f"[Discord] 获取到私聊频道: {dm}")
                return dm
            else:
                logger.debug(f"[Discord] 无法获取用户 {userid} 的私聊频道")

        return None

    async def _get_dm_channel(self, userid: str) -> Optional[discord.DMChannel]:
        logger.debug(f"[Discord] _get_dm_channel: userid={userid}")
        if userid in self._user_dm_cache:
            logger.debug(
                f"[Discord] 从缓存获取私聊频道: {self._user_dm_cache.get(userid)}"
            )
            return self._user_dm_cache.get(userid)
        try:
            logger.debug(f"[Discord] 尝试获取/创建用户 {userid} 的私聊频道")
            user_obj = self._client.get_user(int(userid))
            logger.debug(f"[Discord] get_user 结果: {user_obj}")
            if not user_obj:
                user_obj = await self._client.fetch_user(int(userid))
                logger.debug(f"[Discord] fetch_user 结果: {user_obj}")
            if not user_obj:
                logger.debug(f"[Discord] 无法找到用户 {userid}")
                return None
            dm = user_obj.dm_channel
            logger.debug(f"[Discord] 用户现有 dm_channel: {dm}")
            if not dm:
                dm = await user_obj.create_dm()
                logger.debug(f"[Discord] 创建新的 dm_channel: {dm}")
            if dm:
                self._user_dm_cache[userid] = dm
            return dm
        except Exception as err:
            logger.error(f"获取 Discord 私聊失败：{err}")
            return None

    def _update_user_chat_mapping(self, userid: str, chat_id: str) -> None:
        """
        Update user-chat mapping for reply targeting.
        This ensures replies go to the same channel where the user sent the message.
        :param userid: User ID
        :param chat_id: Channel/Chat ID where the user sent the message
        """
        if userid and chat_id:
            self._user_chat_mapping[userid] = chat_id
            logger.debug(
                f"[Discord] 更新用户频道映射: userid={userid} -> chat_id={chat_id}"
            )

    def _get_user_chat_id(self, userid: str) -> Optional[str]:
        """
        Get the chat ID where the user last sent a message.
        :param userid: User ID
        :return: Chat ID or None if not found
        """
        return self._user_chat_mapping.get(userid)

    def _should_process_message(self, message: discord.Message) -> bool:
        if isinstance(message.channel, discord.DMChannel):
            return True
        content = message.content or ""
        # 仅处理 @Bot 或斜杠命令
        if self._client.user and self._client.user.mentioned_in(message):
            return True
        if content.startswith("/"):
            return True
        return False

    def _clean_bot_mention(self, content: str) -> str:
        if not content:
            return ""
        if self._bot_user_id:
            mention_pattern = rf"<@!?{self._bot_user_id}>"
            content = re.sub(mention_pattern, "", content).strip()
        return content

    async def _post_to_ds(self, payload: Dict[str, Any]) -> None:
        try:
            proxy = None
            if settings.PROXY:
                proxy = settings.PROXY.get("https") or settings.PROXY.get("http")
            async with httpx.AsyncClient(
                timeout=10, verify=False, proxy=proxy
            ) as client:
                await client.post(self._ds_url, json=payload)
        except Exception as err:
            logger.error(f"转发 Discord 消息失败：{err}")
