"""Discord gateway — wraps discord.py Bot for a single bot connection.

This is the peon-grukk version. AI responses use Grukk's personality prompt
instead of the DiscordAgent that was in peon-api.
"""

from __future__ import annotations

import asyncio
import logging
import os
from typing import TYPE_CHECKING, Any

logger = logging.getLogger(__name__)

import discord
import discord.ext.commands as _discord_commands

if TYPE_CHECKING:
    from peon_mcp.grukk.discord.commands import CommandRegistry


async def _log_activity(
    api_base: str,
    config_id: int,
    event_type: str,
    channel_id: str = "",
    user_id: str = "",
    detail: str = "",
) -> None:
    """Post a Discord activity log entry to the REST API (fire-and-forget).

    Swallows all errors so it never interrupts the gateway's main event
    handling path.  Callers should use ``asyncio.create_task()`` for
    non-blocking fire-and-forget behaviour.
    """
    import httpx as _httpx

    url = f"{api_base.rstrip('/')}/api/discord/activity"
    _headers: dict[str, str] = {}
    _key = os.environ.get("PEON_API_KEY", "")
    if _key:
        _headers["X-API-Key"] = _key
    try:
        async with _httpx.AsyncClient(timeout=3.0, headers=_headers) as client:
            await client.post(
                url,
                json={
                    "config_id": config_id,
                    "event_type": event_type,
                    "channel_id": channel_id,
                    "user_id": user_id,
                    "detail": detail,
                },
            )
    except Exception as exc:
        logger.debug("Failed to log Discord activity (%s): %s", event_type, exc)


class DiscordGateway:
    """Manages a single Discord bot connection (one per discord_config row).

    The gateway wraps a ``discord.ext.commands.Bot`` instance and runs it as
    an asyncio task inside the FastAPI event loop.  It is created and owned by
    :class:`BotManager`.

    Slash commands are registered via an optional :class:`CommandRegistry`
    (from :mod:`peon_mcp.discord.commands`).  When the bot's ``on_ready``
    event fires, commands are synced to Discord via :meth:`sync_commands`.

    AI responses are generated by :class:`DiscordAgent` using the Anthropic SDK
    with tool_use. Conversation history is managed by :class:`SessionManager`.
    """

    def __init__(self, token: str, config: dict[str, Any]) -> None:
        """Initialise the gateway.

        Args:
            token: Discord bot token (plain-text; never logged).
            config: The discord_config row as a dict (id, guild_id, …).
        """
        self._token = token
        self.config = config
        self._api_base: str = os.environ.get("PEON_API_URL", "http://127.0.0.1:8420")
        self._client: Any | None = None
        self._task: asyncio.Task | None = None
        self._error: str | None = None
        self._command_registry: CommandRegistry | None = None
        self._session_manager: Any | None = None
        self._agent: Any | None = None

    # ------------------------------------------------------------------
    # Public interface
    # ------------------------------------------------------------------

    @property
    def is_connected(self) -> bool:
        """Return True if the underlying Bot is currently connected."""
        if self._client is None:
            return False
        return getattr(self._client, "is_ready", lambda: False)()

    async def start(self) -> None:
        """Connect to Discord and begin processing events.

        The bot runs in a background asyncio task so this coroutine
        returns immediately.
        """
        if self._task and not self._task.done():
            logger.debug("Gateway for config %s already running.", self.config.get("id"))
            return

        intents = discord.Intents.default()
        intents.message_content = True
        intents.members = True

        # Use commands.Bot so the built-in CommandTree is available.
        bot: Any = _discord_commands.Bot(
            command_prefix=lambda *_: [],
            intents=intents,
        )
        self._client = bot
        self._error = None

        # Build and register slash commands if discord.py is available.
        self._setup_commands(bot)

        # Register event handlers on this bot instance.
        self._register_events(bot)

        self._task = asyncio.get_event_loop().create_task(
            self._run(bot),
            name=f"discord-gateway-{self.config.get('id')}",
        )

    async def stop(self) -> None:
        """Gracefully disconnect from Discord."""
        config_id = self.config.get("id")
        if self._client is not None:
            try:
                await self._client.close()
            except Exception:
                pass
        if self._task and not self._task.done():
            self._task.cancel()
            try:
                await asyncio.wait_for(asyncio.shield(self._task), timeout=5.0)
            except (asyncio.CancelledError, asyncio.TimeoutError):
                pass
        self._client = None
        self._task = None
        if config_id:
            await _log_activity(self._api_base, int(config_id), "bot_disconnected")

    async def send_message(
        self,
        channel_id: str,
        content: str = "",
        embed_title: str = "",
        embed_description: str = "",
    ) -> bool:
        """Send a message to a Discord channel.

        Args:
            channel_id: Discord channel snowflake ID.
            content: Plain-text message content.
            embed_title: Optional embed title for rich formatting.
            embed_description: Optional embed description.

        Returns:
            True if the message was sent successfully, False otherwise.
        """
        if self._client is None or not self.is_connected:
            logger.warning(
                "Cannot send message: Discord unavailable or bot not connected "
                "(config_id=%s)", self.config.get("id")
            )
            return False

        try:
            channel = self._client.get_channel(int(channel_id))
            if channel is None:
                channel = await self._client.fetch_channel(int(channel_id))

            if embed_title or embed_description:
                embed = discord.Embed(
                    title=embed_title or None,
                    description=embed_description or None,
                    color=0x00FF00,
                )
                await channel.send(content=content or None, embed=embed)
            else:
                await channel.send(content=content)
            asyncio.create_task(
                _log_activity(
                    self._api_base,
                    int(self.config.get("id") or 0),
                    "message_sent",
                    channel_id=channel_id,
                    detail=f"len={len(content)}" if content else "",
                )
            )
            return True
        except Exception as exc:
            logger.error(
                "Failed to send Discord message to channel %s (config_id=%s): %s",
                channel_id, self.config.get("id"), exc,
            )
            return False

    async def sync_commands(self) -> int:
        """Sync slash commands with Discord.

        This is called automatically from ``on_ready`` but can also be
        triggered manually (e.g., from a REST endpoint) to re-sync after
        command changes.

        Returns the number of commands synced, or 0 if the registry has not
        been set up or the bot is not connected.
        """
        if self._command_registry is None or self._client is None:
            return 0
        return await self._command_registry.sync(self._client)

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _setup_commands(self, bot: Any) -> None:
        """Instantiate and register :class:`CommandRegistry` on *bot*."""
        try:
            from peon_mcp.grukk.discord.commands import CommandRegistry
        except ImportError:
            logger.debug("commands module not available — skipping slash command setup")
            return

        api_base = os.environ.get("PEON_API_URL", "http://127.0.0.1:8420")
        guild_id = self.config.get("guild_id", "")
        config_id = int(self.config.get("id") or 0)

        registry = CommandRegistry(
            api_base=api_base,
            guild_id=guild_id,
            config_id=config_id,
        )
        registry.register(bot.tree)
        self._command_registry = registry
        logger.debug(
            "Slash commands registered for config_id=%s",
            self.config.get("id"),
        )

    async def _run(self, bot: Any) -> None:
        """Run Bot.start() and capture any exception."""
        try:
            await bot.start(self._token)
        except asyncio.CancelledError:
            pass
        except Exception as exc:
            self._error = str(exc)
            logger.error(
                "Discord gateway error for config %s: %s",
                self.config.get("id"),
                exc,
            )

    def _register_events(self, bot: Any) -> None:
        """Register event handlers on the discord.ext.commands.Bot instance."""

        config_id = self.config.get("id")
        gateway = self
        api_base = self._api_base

        @bot.event
        async def on_ready() -> None:
            guild_count = len(bot.guilds)
            logger.info(
                "Discord bot ready: %s (config_id=%s, guilds=%d)",
                bot.user,
                config_id,
                guild_count,
            )
            asyncio.create_task(
                _log_activity(
                    api_base,
                    int(config_id or 0),
                    "bot_connected",
                    detail=f"guilds={guild_count}",
                )
            )
            # Sync slash commands now that the bot is connected.
            await gateway.sync_commands()

            # Join all active threads so the bot receives on_message events.
            # guild.threads only has cached threads — fetch_active_threads()
            # hits the API and returns all active threads in the guild.
            joined = 0
            for guild in bot.guilds:
                try:
                    result = await guild.fetch_active_threads()
                    threads = result.threads if hasattr(result, "threads") else result
                    for thread in threads:
                        try:
                            await thread.join()
                            joined += 1
                        except Exception:
                            pass
                except Exception as exc:
                    logger.debug("Could not fetch threads for guild %s: %s", guild.id, exc)
            if joined:
                logger.info(
                    "Joined %d active threads on startup (config_id=%s)",
                    joined, config_id,
                )

        @bot.event
        async def on_thread_create(thread: Any) -> None:
            """Auto-join new threads so the bot receives on_message events."""
            try:
                await thread.join()
                logger.debug("Joined thread %s (config_id=%s)", thread.id, config_id)
            except Exception as exc:
                logger.debug("Could not join thread %s: %s", thread.id, exc)

        @bot.event
        async def on_message(message: Any) -> None:
            # Ignore messages from any bot (including self and sibling bots).
            if message.author.bot:
                return

            config = gateway.config
            dm_policy: str = config.get("dm_policy", "disabled")
            guild_policy: str = config.get("guild_policy", "open")
            require_mention: bool = bool(config.get("require_mention", False))

            is_dm: bool = message.guild is None
            is_thread: bool = isinstance(message.channel, discord.Thread)
            channel_id: str = str(message.channel.id)
            user_id: str = str(message.author.id)
            guild_id: str = str(message.guild.id) if not is_dm else ""
            member_roles: list[str] = [str(r.id) for r in getattr(message.author, "roles", [])]
            bot_user_id: str = str(bot.user.id) if bot.user else ""

            # In threads, skip the mention requirement — threads are focused
            # conversations and the bot should respond naturally if it has
            # access to the parent channel.
            if is_thread:
                require_mention = False
            replied_to: str = (
                str(message.reference.message_id)
                if getattr(message, "reference", None) and message.reference
                else ""
            )

            # Build routing inputs
            try:
                from peon_mcp.grukk.discord.router import DiscordMessage, MessageRouter
            except ImportError:
                await bot.process_commands(message)
                return

            discord_msg = DiscordMessage(
                config_id=config_id,
                channel_id=channel_id,
                user_id=user_id,
                username=str(message.author),
                content=message.content,
                message_id=str(message.id),
                replied_to=replied_to,
                guild_id=guild_id,
                bot_user_id=bot_user_id,
                member_roles=member_roles,
            )

            api_base: str = os.environ.get("PEON_API_URL", "http://127.0.0.1:8420").rstrip("/")
            paired_user_ids: list[str] = []
            channel_mapping: dict | None = None

            # For allowlist lookups, threads inherit from their parent channel.
            lookup_channel_id = channel_id
            if is_thread and hasattr(message.channel, "parent_id") and message.channel.parent_id:
                lookup_channel_id = str(message.channel.parent_id)

            try:
                import httpx as _httpx

                _ak = os.environ.get("PEON_API_KEY", "")
                _ah: dict[str, str] = {"X-API-Key": _ak} if _ak else {}
                async with _httpx.AsyncClient(headers=_ah) as _client:
                    if is_dm and dm_policy == "pairing":
                        resp = await _client.get(
                            f"{api_base}/api/discord/configs/{config_id}/paired-users",
                            timeout=5.0,
                        )
                        if resp.status_code == 200:
                            paired_user_ids = [u["user_id"] for u in resp.json()]
                    elif not is_dm:
                        resp = await _client.get(
                            f"{api_base}/api/discord/configs/{config_id}/channels",
                            timeout=5.0,
                        )
                        if resp.status_code == 200:
                            channels = resp.json()
                            channel_mapping = next(
                                (c for c in channels if c.get("channel_id") == lookup_channel_id),
                                None,
                            )
                            # If this config has explicit channel mappings but the
                            # incoming channel is not among them, drop the message
                            # silently. This prevents duplicate storage when multiple
                            # configs share the same bot token — only the config that
                            # owns the channel should store and process it.
                            if channels and channel_mapping is None:
                                return
            except Exception as exc:
                logger.warning(
                    "Gateway: failed to fetch access data for config %s: %s", config_id, exc
                )

            router = MessageRouter()
            result = router.route_message(
                discord_msg,
                dm_policy=dm_policy,
                guild_policy=guild_policy,
                require_mention=require_mention,
                channel_mapping=channel_mapping,
                paired_user_ids=paired_user_ids,
            )

            # Log every message (accepted and denied) to the inbound messages table.
            try:
                import httpx as _httpx

                _ak2 = os.environ.get("PEON_API_KEY", "")
                _ah2: dict[str, str] = {"X-API-Key": _ak2} if _ak2 else {}
                async with _httpx.AsyncClient(headers=_ah2) as _client:
                    await _client.post(
                        f"{api_base}/api/discord/messages/inbound",
                        json={
                            "config_id": config_id,
                            "channel_id": channel_id,
                            "user_id": user_id,
                            "username": str(message.author),
                            "content": message.content,
                            "message_id": str(message.id),
                            "replied_to": replied_to,
                            "denied": not result.accepted,
                            "deny_reason": result.reason if not result.accepted else "",
                            "member_roles": member_roles,
                        },
                        timeout=5.0,
                    )
            except Exception as exc:
                logger.warning(
                    "Gateway: failed to log inbound message for config %s: %s", config_id, exc
                )

            if not result.accepted:
                asyncio.create_task(
                    _log_activity(
                        api_base,
                        int(config_id or 0),
                        "message_denied",
                        channel_id=channel_id,
                        user_id=user_id,
                        detail=result.reason or "",
                    )
                )
                # For pairing policy DMs: inform the user they need to be paired.
                if is_dm and dm_policy == "pairing":
                    try:
                        await message.channel.send(
                            "You are not yet approved to use this bot via DM. "
                            "Please contact an administrator to request pairing."
                        )
                    except Exception:
                        pass
                # Do not process denied messages further.
                return

            asyncio.create_task(
                _log_activity(
                    api_base,
                    int(config_id or 0),
                    "message_received",
                    channel_id=channel_id,
                    user_id=user_id,
                )
            )

            # Message accepted — process built-in prefix commands and proceed.
            await bot.process_commands(message)

            # --- AI response generation ---
            # Strip bot mention from content to get the actual message
            normalized_content = message.content.strip()
            if bot.user:
                normalized_content = normalized_content.replace(f"<@{bot.user.id}>", "").replace(f"<@!{bot.user.id}>", "").strip()

            if not normalized_content:
                return

            try:
                from peon_mcp.grukk.discord.commands import _resolve_project_id
                from peon_mcp.grukk.discord.sessions import SessionManager

                # Lazily init session manager
                if gateway._session_manager is None:
                    gateway._session_manager = SessionManager()

                history_limit = int(config.get("history_limit", 10))
                session = gateway._session_manager.get_or_create_session(
                    channel_id=channel_id,
                    user_id=user_id,
                    is_dm=is_dm,
                    history_limit=history_limit,
                )

                session.add_turn(
                    role="user",
                    content=normalized_content,
                    user_id=user_id,
                    username=str(message.author),
                )

                # Resolve the project_id for this channel
                project_id = await _resolve_project_id(
                    api_base, int(config_id or 0), channel_id
                )

                # Lazily init the appropriate agent for this config
                if gateway._agent is None:
                    agent_model = config.get("model", "") or "claude-sonnet-4-20250514"
                    custom_prompt = config.get("system_prompt", "")

                    if config_id == 2:  # Sprocket
                        from peon_mcp.goblin.discord_agent import SprocketAgent

                        gateway._agent = SprocketAgent(
                            project_id=project_id,
                            api_base=api_base,
                            model=agent_model,
                            system_prompt=custom_prompt,
                        )
                    else:  # Grukk (default)
                        from peon_mcp.grukk.discord.agent import DiscordAgent

                        gateway._agent = DiscordAgent(
                            project_id=project_id,
                            api_base=api_base,
                            model=agent_model,
                            system_prompt=custom_prompt,
                        )

                history = session.get_history()

                # Show typing indicator while generating (auto-refreshes during tool rounds)
                async with message.channel.typing():
                    # Collect server member names for system prompt injection
                    member_names: list[str] | None = None
                    if message.guild:
                        member_names = [
                            m.display_name
                            for m in message.guild.members
                            if not m.bot
                        ]

                    response_text = await gateway._agent.generate_response(
                        conversation_history=history,
                        server_members=member_names,
                        project_id=project_id,
                    )

                if not response_text:
                    response_text = "Zug zug."

                session.add_turn(role="assistant", content=response_text)

                asyncio.create_task(
                    _log_activity(
                        api_base,
                        int(config_id or 0),
                        "ai_response",
                        channel_id=channel_id,
                        user_id=user_id,
                        detail=f"len={len(response_text)}",
                    )
                )

                # Send response, chunking if >2000 chars (Discord limit)
                for i in range(0, len(response_text), 2000):
                    chunk = response_text[i : i + 2000]
                    await message.channel.send(chunk)

            except Exception as exc:
                logger.error(
                    "AI response error for config %s: %s", config_id, exc
                )

        @bot.event
        async def on_interaction(interaction: Any) -> None:
            from peon_mcp.grukk.discord.interactions import on_interaction as _dispatch
            await _dispatch(interaction)
