"""Discord adapter — connects to the Discord Gateway and forwards messages.

Platform-specific machinery only: Gateway connection, message event handling,
thread creation, typing, button rendering. All platform-agnostic logic lives
in the core handler. Slash commands live in commands.py.
"""

import io
import logging
import re
from typing import Optional

import discord
from discord import app_commands

from backend.copilot.bot import threads
from backend.copilot.bot.bot_backend import BotBackend

from ..base import (
    ChannelType,
    FileAttachment,
    MessageCallback,
    MessageContext,
    MessageHistoryEntry,
    PlatformAdapter,
)
from . import commands, config, intro

logger = logging.getLogger(__name__)

# When the bot is @-ed into an existing thread it pulls the prior messages in as
# context. Read the whole thread (up to this hard cap, ~10 Discord API pages)
# rather than just the last handful — skipping older messages makes the bot act
# on a partial conversation. THREAD_HISTORY_CHAR_BUDGET then keeps the assembled
# context within the copilot request size limit, preferring the most recent
# messages when a thread is very long.
THREAD_HISTORY_LIMIT = 1000
THREAD_HISTORY_CHAR_BUDGET = 24000
_HISTORY_TRUNCATION_MARKER = "\n… [message truncated]"


class DiscordAdapter(PlatformAdapter):
    def __init__(self, api: BotBackend):
        intents = discord.Intents.default()
        intents.message_content = True
        # AutoPilot output is untrusted w.r.t. mentions — suppress @everyone,
        # role, and user pings the LLM might produce. Client-level default
        # applies to every send() + reply() below.
        self._client = discord.Client(
            intents=intents,
            allowed_mentions=discord.AllowedMentions.none(),
        )
        self._tree = app_commands.CommandTree(self._client)
        self._api = api
        self._on_message_callback: Optional[MessageCallback] = None
        self._commands_synced = False

        self._register_events()
        commands.register(self._tree, self._api)

    @property
    def platform_name(self) -> str:
        return "discord"

    @property
    def max_message_length(self) -> int:
        return config.MAX_MESSAGE_LENGTH

    @property
    def chunk_flush_at(self) -> int:
        return config.CHUNK_FLUSH_AT

    @property
    def max_attachment_bytes(self) -> int:
        return config.MAX_ATTACHMENT_BYTES

    def on_message(self, callback: MessageCallback) -> None:
        self._on_message_callback = callback

    async def start(self) -> None:
        await self._client.start(config.get_bot_token())

    async def stop(self) -> None:
        if not self._client.is_closed():
            await self._client.close()

    async def _resolve_channel(self, channel_id: str):
        """Return the channel for ``channel_id``, falling back to a REST fetch.

        ``Client.get_channel`` only reads the in-memory cache, so it misses
        threads the bot hasn't seen since its last restart. Fall back to
        ``fetch_channel`` (REST) so long-lived threads keep working.
        """
        channel = self._client.get_channel(int(channel_id))
        if channel is not None:
            return channel
        try:
            return await self._client.fetch_channel(int(channel_id))
        except (discord.NotFound, discord.Forbidden, discord.HTTPException):
            logger.warning("Channel %s not found or inaccessible", channel_id)
            return None

    async def send_message(
        self,
        channel_id: str,
        text: str,
        mentionable_users: tuple[tuple[str, str], ...] = (),
    ) -> None:
        channel = await self._resolve_channel(channel_id)
        if channel and isinstance(channel, discord.abc.Messageable):
            rendered, allowed = _resolve_mentions(text, mentionable_users)
            # tts=False is the default but we pin it explicitly — AutoPilot
            # output is untrusted and should never blast through voice.
            await channel.send(rendered, tts=False, allowed_mentions=allowed)

    async def send_link(
        self, channel_id: str, text: str, link_label: str, link_url: str
    ) -> None:
        channel = await self._resolve_channel(channel_id)
        if channel is None or not isinstance(channel, discord.abc.Messageable):
            return
        view = discord.ui.View()
        view.add_item(
            discord.ui.Button(
                style=discord.ButtonStyle.link,
                label=link_label[:80],  # Discord button label max
                url=link_url,
            )
        )
        await channel.send(text, view=view, tts=False)

    async def send_file(self, channel_id: str, text: str, file: FileAttachment) -> None:
        channel = await self._resolve_channel(channel_id)
        if channel is None or not isinstance(channel, discord.abc.Messageable):
            return
        # spoiler=False — AutoPilot output is untrusted but spoilering every
        # generated file would be noisy; the workspace fetcher already
        # validated user ownership before we got bytes.
        attachment = discord.File(
            io.BytesIO(file.content),
            filename=file.filename or "file",
            spoiler=False,
        )
        await channel.send(text or None, file=attachment, tts=False)

    async def send_reply(
        self,
        channel_id: str,
        text: str,
        reply_to_message_id: str,
        mentionable_users: tuple[tuple[str, str], ...] = (),
    ) -> None:
        channel = await self._resolve_channel(channel_id)
        if not channel or not isinstance(channel, discord.abc.Messageable):
            return
        rendered, allowed = _resolve_mentions(text, mentionable_users)
        try:
            msg = await channel.fetch_message(int(reply_to_message_id))
            await msg.reply(rendered, tts=False, allowed_mentions=allowed)
        except discord.NotFound:
            await channel.send(rendered, tts=False, allowed_mentions=allowed)

    async def send_ephemeral(self, channel_id: str, user_id: str, text: str) -> None:
        # Ephemeral messages are only possible via interaction responses.
        # Fall back to a normal message for non-interaction contexts.
        await self.send_message(channel_id, text)

    async def start_typing(self, channel_id: str) -> None:
        channel = await self._resolve_channel(channel_id)
        if channel and isinstance(channel, discord.abc.Messageable):
            await channel.typing()

    async def stop_typing(self, channel_id: str) -> None:
        pass  # Discord typing auto-expires after ~10s

    async def create_thread(
        self, channel_id: str, message_id: str, name: str
    ) -> Optional[str]:
        channel = await self._resolve_channel(channel_id)
        if channel is None or not isinstance(channel, discord.TextChannel):
            logger.warning("Cannot create thread in non-text channel %s", channel_id)
            return None
        try:
            msg = await channel.fetch_message(int(message_id))
            thread = await msg.create_thread(name=name[:100])
            return str(thread.id)
        except discord.HTTPException:
            logger.exception("Failed to create thread in channel %s", channel_id)
            return None

    async def rename_thread(self, thread_id: str, name: str) -> bool:
        channel = await self._resolve_channel(thread_id)
        if channel is None or not isinstance(channel, discord.Thread):
            logger.warning("Cannot rename non-thread channel %s", thread_id)
            return False
        try:
            await channel.edit(name=name[:100])
            return True
        except discord.HTTPException:
            logger.exception("Failed to rename thread %s", thread_id)
            return False

    # -- Internal --

    def _register_events(self) -> None:
        @self._client.event
        async def on_ready() -> None:
            logger.info(f"Discord bot connected as {self._client.user}")
            # Refresh display names for every guild we're currently in — keeps
            # the Bots settings page in sync with renames and backfills any
            # rows that pre-date name capture. Cheap: in-memory cache only,
            # no Discord API calls.
            await self._refresh_known_server_names()
            # Reconcile presence analytics: record every server we're currently
            # in and mark any we've left while disconnected. Drives the admin
            # server-count / sharding-prediction charts.
            self._api.sync_guilds(
                "discord",
                [(str(guild.id), guild.name) for guild in self._client.guilds],
            )
            # Sync slash commands once per process — on_ready fires on every
            # gateway reconnect, but the command tree only needs uploading once.
            if self._commands_synced:
                return
            try:
                synced = await self._tree.sync()
                self._commands_synced = True
                logger.info(f"Synced {len(synced)} slash commands")
            except Exception:
                logger.exception("Failed to sync slash commands")

        @self._client.event
        async def on_guild_join(guild: discord.Guild) -> None:
            await self._refresh_server_name(guild)
            self._api.track_guild_joined("discord", str(guild.id), guild.name)
            channel = intro.pick_intro_channel(guild)
            if channel is None:
                logger.info(
                    "No sendable channel in guild %s for intro message", guild.id
                )
                return
            try:
                await channel.send(
                    intro.intro_message(),
                    tts=False,
                    allowed_mentions=discord.AllowedMentions.none(),
                )
            except discord.HTTPException:
                logger.exception("Failed to post intro message in guild %s", guild.id)

        @self._client.event
        async def on_guild_update(before: discord.Guild, after: discord.Guild) -> None:
            if before.name != after.name:
                await self._refresh_server_name(after)

        @self._client.event
        async def on_guild_remove(guild: discord.Guild) -> None:
            # Bot was kicked or the server was deleted — mark presence as left
            # so the live server count and sharding prediction stay accurate.
            self._api.track_guild_left("discord", str(guild.id))

        @self._client.event
        async def on_thread_remove(thread: discord.Thread) -> None:
            # Fires when a thread is removed from the client's cache — the
            # typical path is the bot being kicked or the thread being made
            # private without us in it. Drop the auto-reply subscription so a
            # subsequent re-add by @mention starts in the @-only mode again.
            # Unlike on_thread_member_remove, this event only requires the
            # default Intents.guilds and not the privileged members intent.
            try:
                await threads.unsubscribe("discord", str(thread.id))
            except Exception:
                logger.exception(
                    f"Failed to unsubscribe thread {thread.id} after removal"
                )

        @self._client.event
        async def on_message(message: discord.Message) -> None:
            if self._should_ignore_message(message):
                return
            if self._on_message_callback is None:
                return

            # A locked thread rejects bot sends — processing the message would
            # just burn a turn and error on every reply. Skip until it's
            # unlocked. (`locked` is set on archive-locked threads too.)
            channel = message.channel
            if isinstance(channel, discord.Thread) and channel.locked:
                return

            channel_type = self._channel_type(message)
            bot_mentioned = self._is_mentioned(message)

            # Channels require an explicit @mention; DMs and threads always forward
            # (handler checks thread subscription).
            if channel_type == "channel" and not bot_mentioned:
                return

            thread_history = ()
            if channel_type == "thread" and bot_mentioned:
                thread_history = await self._thread_history(message)

            ctx = MessageContext(
                platform="discord",
                channel_type=channel_type,
                server_id=str(message.guild.id) if message.guild else None,
                channel_id=str(message.channel.id),
                message_id=str(message.id),
                user_id=str(message.author.id),
                username=message.author.display_name,
                text=self._message_text(message),
                bot_mentioned=bot_mentioned,
                thread_history=thread_history,
                mentionable_users=self._collect_mentionable_users(message),
            )
            await self._on_message_callback(ctx, self)

    async def _refresh_known_server_names(self) -> None:
        """Push current display names for every guild the bot is in."""
        for guild in self._client.guilds:
            await self._refresh_server_name(guild)

    async def _refresh_server_name(self, guild: discord.Guild) -> None:
        if not guild.name:
            return
        try:
            await self._api.refresh_server_name(
                platform="discord",
                platform_server_id=str(guild.id),
                server_name=guild.name,
            )
        except Exception:
            logger.exception("Failed to refresh display name for guild %s", guild.id)

    def _should_ignore_message(self, message: discord.Message) -> bool:
        if self._client.user is not None and message.author.id == self._client.user.id:
            return True
        # Other bots reach us only by @mentioning us; without this gate two
        # bots in a shared thread (our own dev + prod included) loop forever.
        if message.author.bot:
            return not self._is_mentioned(message)
        return False

    def _is_mentioned(self, message: discord.Message) -> bool:
        if message.guild is None:
            return True  # DMs always count
        bot_user = self._client.user
        if bot_user is None:
            return False
        return any(user.id == bot_user.id for user in message.mentions)

    @staticmethod
    def _channel_type(message: discord.Message) -> ChannelType:
        if message.guild is None:
            return "dm"
        if isinstance(message.channel, discord.Thread):
            return "thread"
        return "channel"

    def _message_text(self, message: discord.Message) -> str:
        """The full message body the LLM should see.

        A Discord *forward* carries the forwarded message in
        ``message.message_snapshots`` — separate from ``content``, which only
        holds the comment the user typed alongside the forward. Reading
        ``content`` alone (as ``_strip_mentions`` does) drops the forwarded
        message entirely, so the LLM acts on just the comment and can badly
        misread the request. Stitch the forwarded content back in here.
        """
        own = self._strip_mentions(message)
        forwarded = self._forwarded_text(message)
        if not forwarded:
            return own
        if own:
            return f"{own}\n\n[Forwarded message]\n{forwarded}"
        return f"[Forwarded message]\n{forwarded}"

    @staticmethod
    def _forwarded_text(message: discord.Message) -> str:
        """Flatten any forwarded message snapshots into labelled text."""
        # ``message_snapshots`` is a real runtime attribute (in ``Message``'s
        # slots) but isn't in discord.py 2.6's type stubs yet; read it through
        # getattr so the type checker is happy and older builds without message
        # forwarding degrade to "no snapshots".
        snapshots = getattr(message, "message_snapshots", [])
        parts: list[str] = []
        for snapshot in snapshots:
            content = (snapshot.content or "").strip()
            if content:
                parts.append(content)
            # Surface forwarded attachments by name so the LLM knows files came
            # with the forward, even though we can't inline their bytes here.
            for attachment in snapshot.attachments:
                parts.append(f"[Attached file: {attachment.filename}]")
        return "\n\n".join(parts).strip()

    def _strip_mentions(self, message: discord.Message) -> str:
        """Strip the bot's own mention; replace other users' raw mention
        tokens with `@displayname` so the LLM keeps the context.
        """
        text = message.content
        bot_id = self._client.user.id if self._client.user else None
        for user in message.mentions:
            raw_tokens = (f"<@{user.id}>", f"<@!{user.id}>")
            replacement = "" if user.id == bot_id else f"@{user.display_name}"
            for token in raw_tokens:
                text = text.replace(token, replacement)
        return text.strip()

    def _collect_mentionable_users(
        self, message: discord.Message
    ) -> tuple[tuple[str, str], ...]:
        """Users from the inbound message the bot may ping back this turn."""
        bot_id = self._client.user.id if self._client.user else None
        return tuple(
            (user.display_name, str(user.id))
            for user in message.mentions
            if user.id != bot_id
        )

    async def _thread_history(
        self, message: discord.Message
    ) -> tuple[MessageHistoryEntry, ...]:
        if not isinstance(message.channel, discord.Thread):
            return ()

        entries: list[MessageHistoryEntry] = []
        used_chars = 0
        bot_user_id = self._client.user.id if self._client.user else None
        try:
            # Newest-first so that when a long thread exceeds the size budget we
            # keep the most recent (most relevant) messages instead of the
            # oldest; we reverse back to chronological order before returning.
            async for prior in message.channel.history(
                limit=THREAD_HISTORY_LIMIT,
                before=message,
                oldest_first=False,
            ):
                # Skip our own outputs — copilot has its own transcript for
                # that side. Other bots' messages are kept as context.
                if bot_user_id is not None and prior.author.id == bot_user_id:
                    continue
                text = self._strip_mentions(prior)
                if not text:
                    continue
                remaining = THREAD_HISTORY_CHAR_BUDGET - used_chars
                if remaining <= 0:
                    break
                oversized = len(text) > remaining
                if oversized and entries:
                    # No room for another whole message — stop and keep what we
                    # have (the more recent messages).
                    break
                if oversized:
                    # Lone most-recent message is itself over budget: keep a
                    # truncated head of it.
                    text = _truncate_to_budget(text, remaining)
                used_chars += len(text)
                entries.append(
                    MessageHistoryEntry(
                        username=prior.author.display_name,
                        user_id=str(prior.author.id),
                        text=text,
                    )
                )
                if oversized:
                    # We just truncated the newest message to the budget; older
                    # messages are both less relevant and would otherwise sneak
                    # into the whitespace `rstrip` freed up. Stop here.
                    break
        except (discord.Forbidden, discord.HTTPException):
            logger.warning("Could not fetch Discord thread history", exc_info=True)
            return ()

        entries.reverse()  # chronological order for the prompt
        return tuple(entries)


def _truncate_to_budget(text: str, limit: int) -> str:
    """Trim ``text`` to at most ``limit`` characters, leaving a visible marker.

    Used only when a single thread message is itself larger than the history
    budget — keep a head of it (with context that it was cut) rather than emit
    an oversized payload or drop the message entirely.
    """
    if len(text) <= limit:
        return text
    keep = max(0, limit - len(_HISTORY_TRUNCATION_MARKER))
    return text[:keep].rstrip() + _HISTORY_TRUNCATION_MARKER


def _resolve_mentions(
    text: str,
    mentionable_users: tuple[tuple[str, str], ...],
) -> tuple[str, discord.AllowedMentions]:
    """Substitute `@DisplayName` in `text` with `<@id>` markup for users on
    the allowlist, and return an AllowedMentions that pings exactly those
    users (and nobody else).

    Anyone not on the allowlist stays as plain text — even if the LLM produces
    `@everyone`, `@here`, or `@SomeRandomUser`. This keeps the bot from
    pinging users it learned about elsewhere or hallucinated entirely.
    """
    if not mentionable_users:
        return text, discord.AllowedMentions.none()

    rendered = text
    pinged_ids: list[int] = []
    # Longest names first so e.g. "@John Smith" matches before "@John".
    for display_name, user_id in sorted(
        mentionable_users, key=lambda pair: -len(pair[0])
    ):
        # Word-bounded so "@Name" inside emails/URLs is left alone.
        pattern = re.compile(
            rf"(?<![\w@]){re.escape(f'@{display_name}')}(?!\w)",
            re.IGNORECASE,
        )
        if not pattern.search(rendered):
            continue
        # Callable replacement avoids backref interpretation of user_id.
        rendered = pattern.sub(lambda _m, uid=user_id: f"<@{uid}>", rendered)
        try:
            pinged_ids.append(int(user_id))
        except ValueError:
            continue

    if not pinged_ids:
        return rendered, discord.AllowedMentions.none()

    return rendered, discord.AllowedMentions(
        everyone=False,
        users=[discord.Object(id=uid) for uid in pinged_ids],
        roles=False,
        replied_user=False,
    )
