"""
Discord adapter: discord.py bot that receives messages, normalizes to NormalizedMessage,
and sends replies. Only responds in DMs or allowed channel IDs; optional prefix/mention.
"""
from __future__ import annotations

import asyncio
import logging
import os
from typing import Any, Callable, Awaitable

from messaging.gateway import NormalizedMessage, MessagingAdapter

# aiohttp (used by discord.py) may fail SSL on macOS if the system cert store
# is incomplete.  Point it at certifi's CA bundle before any connections start.
try:
    import certifi
    if "SSL_CERT_FILE" not in os.environ:
        os.environ["SSL_CERT_FILE"] = certifi.where()
except ImportError:
    pass


class DiscordAdapter(MessagingAdapter):
    """Discord bot adapter. Implements MessagingAdapter using discord.py."""

    def __init__(self, config: dict, log: logging.Logger | None = None):
        self._config = config
        self._log = log or logging.getLogger("messaging.discord")
        self._bot = None
        self._on_message: Callable[[NormalizedMessage], Awaitable[None]] | None = None
        self._task: asyncio.Task | None = None
        self._last_by_user: dict[str, float] = {}  # user_id -> last message timestamp (rate limiting)

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

    def _allowed_channels(self) -> set[int]:
        raw = self._config.get("allowed_channels") or self._config.get("channels") or []
        out = set()
        for x in raw:
            try:
                out.add(int(x))
            except (TypeError, ValueError):
                continue
        return out

    def _command_prefix(self) -> str:
        raw = self._config.get("command_prefix") or self._config.get("prefix") or "!agent "
        return (raw or "!agent ").strip() or "!agent "

    def _mention_required(self) -> bool:
        return bool(self._config.get("mention_required", False))

    def _token(self) -> str:
        """Token from Keychain (preferred) or config fallback."""
        try:
            from persistence import keychain_get_discord_token
            val = keychain_get_discord_token()
            if val:
                return val
        except Exception:
            pass
        return (self._config.get("token") or "").strip()

    def _token_for_gateway(self) -> str:
        """Token for discord.py Client.start(): must be the raw token without 'Bot ' prefix.
        Uses _token() so Keychain is preferred over config fallback."""
        raw = self._token()
        if raw.lower().startswith("bot "):
            return raw[4:].strip()
        return raw

    def _content_stripped(self, content: str, bot_user_id: int) -> str | None:
        """
        If message is eligible (prefix or mention if required), return stripped content;
        otherwise None.
        """
        prefix = self._command_prefix()
        mention_required = self._mention_required()
        content = (content or "").strip()

        # Check mention: <@BOT_ID> or <@!BOT_ID>
        mention = f"<@{bot_user_id}>"
        mention_alt = f"<@!{bot_user_id}>"
        has_mention = mention in content or mention_alt in content
        if mention_required and not has_mention:
            return None
        if has_mention:
            content = content.replace(mention_alt, "").replace(mention, "").strip()

        if prefix and not content.startswith(prefix) and not has_mention:
            return None
        if prefix and content.startswith(prefix):
            content = content[len(prefix):].strip()
        if not content:
            return None
        return content

    async def start(self, on_message: Callable[[NormalizedMessage], Awaitable[None]]) -> None:
        self._on_message = on_message
        token_raw = self._token()
        if not token_raw:
            self._log.warning("Discord token missing; adapter will not connect")
            return
        # discord.py Gateway expects the raw token without "Bot " prefix
        token = self._token_for_gateway()
        if not token:
            self._log.warning("Discord token empty after stripping prefix")
            return

        try:
            import discord
        except ImportError:
            self._log.error("discord.py not installed; pip install discord.py")
            return

        intents = discord.Intents.default()
        intents.message_content = True
        intents.messages = True
        self._bot = discord.Client(intents=intents)
        allowed = self._allowed_channels()
        prefix = self._command_prefix()

        @self._bot.event
        async def on_ready():
            self._log.info("Discord connected as %s (id=%s)", self._bot.user, self._bot.user.id if self._bot.user else "?")

        @self._bot.event
        async def on_message(message: discord.Message):
            if message.author.bot or not self._on_message:
                return
            channel = message.channel
            is_dm = channel.type == discord.ChannelType.private
            channel_id = str(channel.id)
            thread_id = str(message.thread.id) if getattr(message, "thread", None) and message.thread else None
            if not thread_id and hasattr(channel, "id") and getattr(channel, "parent_id", None):
                thread_id = str(channel.id)

            # Allowed: DMs or channel in allowed list
            if not is_dm:
                try:
                    ch_id = channel.id
                    if channel.parent_id:
                        ch_id = channel.parent_id  # thread: check parent channel
                    if allowed and ch_id not in allowed:
                        return
                except Exception:
                    pass

            bot_user_id = self._bot.user.id if self._bot.user else 0
            raw_content = message.content or ""
            # Collect image attachment URLs for vision (content_type image/* or image-like extensions)
            image_extensions = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
            attachment_urls = []
            for att in getattr(message, "attachments", []) or []:
                url = getattr(att, "url", None)
                if not url:
                    continue
                ct = (getattr(att, "content_type", None) or "").lower()
                fn = (getattr(att, "filename", None) or "").lower()
                if ct.startswith("image/") or any(fn.endswith(ext) for ext in image_extensions):
                    attachment_urls.append(url)

            # In DMs, every message is for the bot (no prefix required). Allow image-only (no text).
            if is_dm:
                content = raw_content.strip()
                if not content and not attachment_urls:
                    return
            else:
                content = self._content_stripped(raw_content, bot_user_id)
                if content is None:
                    # Allow image-only in channel if they used prefix/mention (e.g. "!agent " + image)
                    mention = f"<@{bot_user_id}>"
                    mention_alt = f"<@!{bot_user_id}>"
                    has_mention = mention in raw_content or mention_alt in raw_content
                    prefix = self._command_prefix()
                    has_prefix = bool(prefix and (raw_content.startswith(prefix) or raw_content.strip().startswith(prefix.strip())))
                    if attachment_urls and (has_mention or has_prefix):
                        content = ""
                    else:
                        self._log.debug(
                            "Discord: message ignored (no prefix/mention or empty); channel=%s raw_len=%d",
                            channel_id, len(raw_content),
                        )
                        return

            normalized = NormalizedMessage(
                user_id=str(message.author.id),
                channel_id=channel_id,
                thread_id=thread_id,
                content=content or "",
                is_dm=is_dm,
                adapter_name=self.name,
                attachment_urls=attachment_urls,
            )
            try:
                await self._on_message(normalized)
            except Exception as e:
                self._log.exception("on_message callback error: %s", e)

        def _log_task_result(task: asyncio.Task) -> None:
            try:
                task.result()
            except asyncio.CancelledError:
                self._log.info("Discord bot task was cancelled")
            except Exception as e:
                err_str = str(e)
                if "privileged intents" in err_str.lower():
                    self._log.error(
                        "Discord bot requires privileged intents. Go to "
                        "https://discord.com/developers/applications/ → your bot → Bot → "
                        "enable 'Message Content Intent' under Privileged Gateway Intents, then restart."
                    )
                else:
                    self._log.exception("Discord bot connection failed: %s", e)

        self._task = asyncio.create_task(self._bot.start(token))
        self._task.add_done_callback(_log_task_result)
        self._log.info("Discord adapter start() called (bot connecting to Gateway)")

        # Route discord.py's own logs to our log file for connection debugging
        _discord_logger = logging.getLogger("discord")
        if self._log.handlers and not getattr(_discord_logger, "_gateway_handler_added", False):
            for h in self._log.handlers:
                _discord_logger.addHandler(h)
                _discord_logger.setLevel(logging.INFO)
                _discord_logger.propagate = False
                _discord_logger._gateway_handler_added = True
                break

    async def _resolve_channel(self, target_id: str, thread_id: str | None) -> Any:
        if not self._bot:
            return None
        channel = self._bot.get_channel(int(target_id))
        if channel is None:
            try:
                channel = await self._bot.fetch_channel(int(target_id))
            except Exception:
                pass
        if channel is None and hasattr(self._bot, "private_channels"):
            for ch in self._bot.private_channels:
                if ch.id == int(target_id):
                    channel = ch
                    break
        if thread_id and channel is not None:
            thread = self._bot.get_channel(int(thread_id))
            if thread is not None:
                channel = thread
        return channel

    async def send_message(
        self,
        target_id: str,
        text: str,
        thread_id: str | None = None,
    ) -> str | None:
        if not self._bot:
            self._log.warning("Discord bot not ready; cannot send")
            return None
        try:
            channel = await self._resolve_channel(target_id, thread_id)
            if channel is None:
                self._log.warning("Discord channel not found: %s", target_id)
                return None
            # Discord message limit is 2000 chars; chunk long responses instead of silently truncating.
            _DISCORD_MAX = 1990
            if len(text) <= _DISCORD_MAX:
                sent = await channel.send(text)
            else:
                parts = [text[i:i + _DISCORD_MAX] for i in range(0, len(text), _DISCORD_MAX)]
                sent = None
                for part in parts:
                    sent = await channel.send(part)
            return str(sent.id) if sent else None
        except Exception as e:
            self._log.exception("Discord send_message failed: %s", e)
            raise

    async def edit_message(
        self,
        target_id: str,
        message_id: str,
        text: str,
        thread_id: str | None = None,
    ) -> None:
        if not self._bot:
            self._log.warning("Discord bot not ready; cannot edit")
            return
        try:
            channel = await self._resolve_channel(target_id, thread_id)
            if channel is None:
                self._log.warning("Discord channel not found for edit: %s", target_id)
                return
            msg = await channel.fetch_message(int(message_id))
            # Discord edit limit: chunk into separate messages if needed.
            _DISCORD_MAX = 1990
            if len(text) <= _DISCORD_MAX:
                await msg.edit(content=text)
            else:
                await msg.edit(content=text[:_DISCORD_MAX])
                remainder = text[_DISCORD_MAX:]
                parts = [remainder[i:i + _DISCORD_MAX] for i in range(0, len(remainder), _DISCORD_MAX)]
                for part in parts:
                    await channel.send(part)
        except Exception as e:
            self._log.exception("Discord edit_message failed: %s", e)
            raise

    async def stop(self) -> None:
        if self._bot and self._bot.is_ready():
            await self._bot.close()
        if self._task and not self._task.done():
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass
        self._bot = None
        self._task = None
