"""DiscordVendor — Discord integration via discord.py.

Implements ChatPlatform with native thread support.  Each conversation
with Timmy gets its own Discord thread, keeping channels clean.

Optional dependency — install with:
    pip install ".[discord]"

Architecture:
    DiscordVendor
        ├── _client (discord.Client)     — handles gateway events
        ├── _thread_map                  — channel_id -> active thread
        ├── _pending_actions             — approval_id -> action details
        └── _message_handler             — bridges to Timmy agent
"""

import asyncio
import json
import logging
from pathlib import Path
from typing import Any

from integrations.chat_bridge.base import (
    ChatMessage,
    ChatPlatform,
    ChatThread,
    PlatformState,
    PlatformStatus,
)
from timmy.session import _clean_response, chat_with_tools, continue_chat
from timmy.tool_safety import format_action_description as _format_action_description
from timmy.tool_safety import get_impact_level as _get_impact_level

logger = logging.getLogger(__name__)

_STATE_FILE = Path(__file__).parent.parent.parent.parent / "discord_state.json"


# ---------------------------------------------------------------------------
# Discord UI components (guarded — discord.py is optional)
# ---------------------------------------------------------------------------

try:
    import discord as _discord_lib

    _DISCORD_UI_AVAILABLE = True
except ImportError:
    _DISCORD_UI_AVAILABLE = False


if _DISCORD_UI_AVAILABLE:

    class ActionConfirmView(_discord_lib.ui.View):
        """Discord UI View with Approve and Reject buttons."""

        def __init__(self, approval_id: str, vendor: "DiscordVendor"):
            from config import settings

            super().__init__(timeout=settings.discord_confirm_timeout)
            self.approval_id = approval_id
            self.vendor = vendor

        @_discord_lib.ui.button(label="Approve", style=_discord_lib.ButtonStyle.green)
        async def approve_button(self, interaction, button):
            await self.vendor._on_action_approved(self.approval_id, interaction)

        @_discord_lib.ui.button(label="Reject", style=_discord_lib.ButtonStyle.red)
        async def reject_button(self, interaction, button):
            await self.vendor._on_action_rejected(self.approval_id, interaction)

        async def on_timeout(self):
            """Auto-reject on timeout."""
            action = self.vendor._pending_actions.pop(self.approval_id, None)
            if not action:
                return
            try:
                from timmy.approvals import reject

                reject(self.approval_id)

                # Reject the requirement and resume so the agent knows
                req = action.get("requirement")
                if req:
                    req.reject(note="Timed out — auto-rejected")
                    await continue_chat(action["run_output"], action.get("session_id"))

                await action["target"].send(
                    f"Action `{action['tool_name']}` timed out and was auto-rejected."
                )
            except Exception as exc:
                logger.warning("Discord action timeout message error: %s", exc)
                pass


# ---------------------------------------------------------------------------
# DiscordVendor
# ---------------------------------------------------------------------------


class DiscordVendor(ChatPlatform):
    """Discord integration with native thread conversations.

    Every user interaction creates or continues a Discord thread,
    keeping channel history clean and conversations organized.
    """

    def __init__(self) -> None:
        self._client = None
        self._token: str | None = None
        self._state: PlatformState = PlatformState.DISCONNECTED
        self._task: asyncio.Task | None = None
        self._guild_count: int = 0
        self._active_threads: dict[str, str] = {}  # channel_id -> thread_id
        self._pending_actions: dict[str, dict] = {}  # approval_id -> action details

    # ── ChatPlatform interface ─────────────────────────────────────────────

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

    @property
    def state(self) -> PlatformState:
        return self._state

    async def start(self, token: str | None = None) -> bool:
        """Start the Discord bot. Returns True on success."""
        if self._state == PlatformState.CONNECTED:
            return True

        tok = token or self.load_token()
        if not tok:
            logger.warning("Discord bot: no token configured, skipping start.")
            return False

        # Clean up any stale client/task from a previous failed attempt
        # so we don't leak background tasks or hold orphaned connections.
        # Must happen before the import check — the old client could exist
        # from when discord.py was available.
        await self._cleanup_stale()

        try:
            import discord
        except ImportError:
            logger.warning(
                'discord.py is not installed — skipping. Install with: pip install ".[discord]"'
            )
            return False

        try:
            self._state = PlatformState.CONNECTING
            self._token = tok

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

            self._client = discord.Client(intents=intents)
            self._register_handlers()

            # Run the client in a background task so we don't block
            self._task = asyncio.create_task(self._run_client(tok))

            # Wait briefly for connection
            for _ in range(30):
                await asyncio.sleep(0.5)
                if self._state == PlatformState.CONNECTED:
                    logger.info("Discord bot connected (%d guilds).", self._guild_count)
                    return True
                if self._state == PlatformState.ERROR:
                    logger.warning("Discord bot: entered ERROR state during connection.")
                    return False

            logger.warning("Discord bot: connection timed out after 15s.")
            self._state = PlatformState.ERROR
            return False

        except Exception as exc:
            logger.error("Discord bot failed to start: %s", exc)
            self._state = PlatformState.ERROR
            self._token = None
            self._client = None
            return False

    async def _cleanup_stale(self) -> None:
        """Close any orphaned client/task from a previous failed start."""
        if self._client and not self._client.is_closed():
            try:
                await self._client.close()
            except Exception as exc:
                logger.warning("Discord client close error: %s", exc)
                pass
            self._client = None

        if self._task and not self._task.done():
            self._task.cancel()
            try:
                await self._task
            except (asyncio.CancelledError, Exception):
                pass
            self._task = None

    async def stop(self) -> None:
        """Gracefully disconnect the Discord bot."""
        if self._client and not self._client.is_closed():
            try:
                await self._client.close()
                logger.info("Discord bot disconnected.")
            except Exception as exc:
                logger.error("Error stopping Discord bot: %s", exc)

        if self._task and not self._task.done():
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass

        self._state = PlatformState.DISCONNECTED
        self._client = None
        self._task = None

    async def send_message(
        self, channel_id: str, content: str, thread_id: str | None = None
    ) -> ChatMessage | None:
        """Send a message to a Discord channel or thread."""
        if not self._client or self._state != PlatformState.CONNECTED:
            return None

        try:
            target_id = int(thread_id) if thread_id else int(channel_id)
            channel = self._client.get_channel(target_id)

            if channel is None:
                channel = await self._client.fetch_channel(target_id)

            msg = await channel.send(content)

            return ChatMessage(
                content=content,
                author=str(self._client.user),
                channel_id=str(msg.channel.id),
                platform="discord",
                message_id=str(msg.id),
                thread_id=thread_id,
            )
        except Exception as exc:
            logger.error("Failed to send Discord message: %s", exc)
            return None

    async def create_thread(
        self, channel_id: str, title: str, initial_message: str | None = None
    ) -> ChatThread | None:
        """Create a new thread in a Discord channel."""
        if not self._client or self._state != PlatformState.CONNECTED:
            return None

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

            thread = await channel.create_thread(
                name=title[:100],  # Discord limits thread names to 100 chars
                auto_archive_duration=1440,  # 24 hours
            )

            if initial_message:
                await thread.send(initial_message)

            self._active_threads[channel_id] = str(thread.id)

            return ChatThread(
                thread_id=str(thread.id),
                title=title[:100],
                channel_id=channel_id,
                platform="discord",
            )
        except Exception as exc:
            logger.error("Failed to create Discord thread: %s", exc)
            return None

    async def join_from_invite(self, invite_code: str) -> bool:
        """Join a Discord server using an invite code.

        Note: Bot accounts cannot use invite links directly.
        This generates an OAuth2 URL for adding the bot to a server.
        The invite_code is validated but the actual join requires
        the server admin to use the bot's OAuth2 authorization URL.
        """
        if not self._client or self._state != PlatformState.CONNECTED:
            logger.warning("Discord bot not connected, cannot process invite.")
            return False

        try:
            invite = await self._client.fetch_invite(invite_code)
            logger.info(
                "Validated invite for server '%s' (code: %s)",
                invite.guild.name if invite.guild else "unknown",
                invite_code,
            )
            return True
        except Exception as exc:
            logger.error("Invalid Discord invite '%s': %s", invite_code, exc)
            return False

    def status(self) -> PlatformStatus:
        return PlatformStatus(
            platform="discord",
            state=self._state,
            token_set=bool(self._token),
            guild_count=self._guild_count,
            thread_count=len(self._active_threads),
        )

    def save_token(self, token: str) -> None:
        """Persist token to state file."""
        try:
            _STATE_FILE.write_text(json.dumps({"token": token}))
        except Exception as exc:
            logger.error("Failed to save Discord token: %s", exc)

    def load_token(self) -> str | None:
        """Load token from config or state file.

        Priority: settings.discord_token (env/.env) > state file.
        The state file is a fallback for tokens set via the /discord/setup UI.
        """
        # 1. Config / env var takes priority
        try:
            from config import settings

            if settings.discord_token:
                return settings.discord_token
        except Exception as exc:
            logger.warning("Discord token load error: %s", exc)
            pass

        # 2. Fall back to state file (set via /discord/setup endpoint)
        try:
            if _STATE_FILE.exists():
                data = json.loads(_STATE_FILE.read_text())
                token = data.get("token")
                if token:
                    return token
        except Exception as exc:
            logger.debug("Could not read discord state file: %s", exc)

        return None

    # ── OAuth2 URL generation ──────────────────────────────────────────────

    def get_oauth2_url(self) -> str | None:
        """Generate the OAuth2 URL for adding this bot to a server.

        Requires the bot to be connected to read its application ID.
        """
        if not self._client or not self._client.user:
            return None

        app_id = self._client.user.id
        # Permissions: Send Messages, Create Public Threads, Manage Threads,
        # Read Message History, Embed Links, Attach Files
        permissions = 397284550656
        return (
            f"https://discord.com/oauth2/authorize"
            f"?client_id={app_id}&scope=bot"
            f"&permissions={permissions}"
        )

    # ── Action confirmation ────────────────────────────────────────────────

    async def _send_confirmation(
        self, target: Any, tool_name: str, tool_args: dict, approval_id: str
    ) -> None:
        """Send a confirmation message with Approve/Reject buttons."""
        description = _format_action_description(tool_name, tool_args)
        impact = _get_impact_level(tool_name)

        if _DISCORD_UI_AVAILABLE:
            import discord

            embed = discord.Embed(
                title="Action Confirmation Required",
                description=description,
                color=discord.Color.orange(),
            )
            embed.add_field(name="Tool", value=f"`{tool_name}`", inline=True)
            embed.add_field(name="Impact", value=impact, inline=True)
            embed.set_footer(text=f"Approval ID: {approval_id[:8]}")

            view = ActionConfirmView(approval_id=approval_id, vendor=self)
            msg = await target.send(embed=embed, view=view)
        else:
            # Fallback when discord.py UI components not available
            msg = await target.send(
                f"**Action Confirmation Required**\n"
                f"{description}\n"
                f"Tool: `{tool_name}` | Impact: {impact}\n"
                f"_Reply 'approve {approval_id[:8]}' or 'reject {approval_id[:8]}'_"
            )

        self._pending_actions[approval_id] = {
            "tool_name": tool_name,
            "tool_args": tool_args,
            "target": target,
            "message": msg,
        }

    async def _on_action_approved(self, approval_id: str, interaction: Any) -> None:
        """Confirm the tool and resume via Agno's continue_run."""
        action = self._pending_actions.pop(approval_id, None)
        if not action:
            await interaction.response.send_message("Action already processed.", ephemeral=True)
            return

        from timmy.approvals import approve

        approve(approval_id)

        await interaction.response.send_message("Approved. Executing...", ephemeral=True)

        target = action["target"]
        tool_name = action["tool_name"]

        # Confirm the requirement — Agno will execute the tool on continue_run
        req = action["requirement"]
        req.confirm()

        try:
            result_run = await continue_chat(action["run_output"], action.get("session_id"))
            # Extract tool result from the resumed run
            tool_result = ""
            for te in getattr(result_run, "tools", None) or []:
                if getattr(te, "tool_name", None) == tool_name and getattr(te, "result", None):
                    tool_result = te.result
                    break
            if not tool_result:
                tool_result = getattr(result_run, "content", None) or "Tool executed successfully."

            result_text = f"**{tool_name}** result:\n```\n{str(tool_result)[:1800]}\n```"
            for chunk in _chunk_message(result_text, 2000):
                await target.send(chunk)
        except Exception as exc:
            logger.error("Discord: tool execution failed: %s", exc)
            await target.send(f"**{tool_name}** failed: `{exc}`")

    async def _on_action_rejected(self, approval_id: str, interaction: Any) -> None:
        """Reject the pending action and notify the agent."""
        action = self._pending_actions.pop(approval_id, None)
        if not action:
            await interaction.response.send_message("Action already processed.", ephemeral=True)
            return

        from timmy.approvals import reject

        reject(approval_id)

        # Reject the requirement and resume so the agent knows
        req = action["requirement"]
        req.reject(note="User rejected from Discord")
        try:
            await continue_chat(action["run_output"], action.get("session_id"))
        except Exception as exc:
            logger.warning("Discord continue chat error: %s", exc)
            pass

        await interaction.response.send_message(
            f"Rejected. `{action['tool_name']}` will not execute.", ephemeral=True
        )

    # ── Internal ───────────────────────────────────────────────────────────

    async def _run_client(self, token: str) -> None:
        """Run the discord.py client (blocking call in a task)."""
        try:
            await self._client.start(token)
        except Exception as exc:
            logger.error("Discord client error: %s", exc)
            self._state = PlatformState.ERROR

    def _register_handlers(self) -> None:
        """Register Discord event handlers on the client."""

        @self._client.event
        async def on_ready():
            self._guild_count = len(self._client.guilds)
            self._state = PlatformState.CONNECTED
            logger.info(
                "Discord ready: %s in %d guild(s)",
                self._client.user,
                self._guild_count,
            )

        @self._client.event
        async def on_message(message):
            # Ignore our own messages
            if message.author == self._client.user:
                return

            # Only respond to mentions or DMs
            is_dm = not hasattr(message.channel, "guild") or message.channel.guild is None
            is_mention = self._client.user in message.mentions

            if not is_dm and not is_mention:
                return

            await self._handle_message(message)

        @self._client.event
        async def on_disconnect():
            if self._state != PlatformState.DISCONNECTED:
                self._state = PlatformState.CONNECTING
                logger.warning("Discord disconnected, will auto-reconnect.")

    async def _handle_message(self, message) -> None:
        """Process an incoming message and respond via a thread."""
        content = self._extract_content(message)
        if not content:
            return

        thread = await self._get_or_create_thread(message)
        target = thread or message.channel
        session_id = f"discord_{thread.id}" if thread else f"discord_{message.channel.id}"

        run_output, response = await self._invoke_agent(content, session_id, target)

        if run_output is not None:
            await self._handle_paused_run(run_output, target, session_id)
            raw_content = run_output.content if hasattr(run_output, "content") else ""
            response = _clean_response(raw_content or "")

        await self._send_response(response, target)

    def _extract_content(self, message) -> str:
        """Strip the bot mention and return clean message text."""
        content = message.content
        if self._client.user:
            content = content.replace(f"<@{self._client.user.id}>", "").strip()
        return content

    async def _invoke_agent(self, content: str, session_id: str, target):
        """Run chat_with_tools with a typing indicator and timeout.

        Returns a (run_output, error_response) tuple.  On success the
        error_response is ``None``; on failure run_output is ``None``.
        """
        run_output = None
        response = None
        try:
            async with target.typing():
                run_output = await asyncio.wait_for(
                    chat_with_tools(content, session_id),
                    timeout=300,
                )
        except TimeoutError:
            logger.error("Discord: chat_with_tools() timed out after 300s")
            response = "Sorry, that took too long. Please try a simpler request."
        except Exception as exc:
            logger.error("Discord: chat_with_tools() failed: %s", exc)
            response = "I'm having trouble reaching my inference backend right now. Please try again shortly."
        return run_output, response

    async def _handle_paused_run(self, run_output, target, session_id: str) -> None:
        """If Agno paused the run for tool confirmation, enqueue approvals."""
        status = getattr(run_output, "status", None)
        is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"

        if not (is_paused and getattr(run_output, "active_requirements", None)):
            return

        from config import settings

        if not settings.discord_confirm_actions:
            return

        for req in run_output.active_requirements:
            if not getattr(req, "needs_confirmation", False):
                continue
            te = req.tool_execution
            tool_name = getattr(te, "tool_name", "unknown")
            tool_args = getattr(te, "tool_args", {}) or {}

            from timmy.approvals import create_item

            item = create_item(
                title=f"Discord: {tool_name}",
                description=_format_action_description(tool_name, tool_args),
                proposed_action=json.dumps({"tool": tool_name, "args": tool_args}),
                impact=_get_impact_level(tool_name),
            )
            self._pending_actions[item.id] = {
                "run_output": run_output,
                "requirement": req,
                "tool_name": tool_name,
                "tool_args": tool_args,
                "target": target,
                "session_id": session_id,
            }
            await self._send_confirmation(target, tool_name, tool_args, item.id)

    @staticmethod
    async def _send_response(response: str | None, target) -> None:
        """Send a response to Discord, chunked to the 2000-char limit."""
        if not response or not response.strip():
            return
        for chunk in _chunk_message(response, 2000):
            try:
                await target.send(chunk)
            except Exception as exc:
                logger.error("Discord: failed to send message chunk: %s", exc)
                break

    async def _get_or_create_thread(self, message):
        """Get the active thread for a channel, or create one.

        If the message is already in a thread, use that thread.
        Otherwise, create a new thread from the message.
        """
        try:
            import discord

            # Already in a thread — just use it
            if isinstance(message.channel, discord.Thread):
                return message.channel

            # DM channels don't support threads
            if isinstance(message.channel, discord.DMChannel):
                return None

            # Create a thread from this message
            from config import settings

            thread_name = f"{settings.agent_name} | {message.author.display_name}"
            thread = await message.create_thread(
                name=thread_name[:100],
                auto_archive_duration=1440,
            )
            channel_id = str(message.channel.id)
            self._active_threads[channel_id] = str(thread.id)
            return thread

        except Exception as exc:
            logger.debug("Could not create thread: %s", exc)
            return None


def _chunk_message(text: str, max_len: int = 2000) -> list[str]:
    """Split a message into chunks that fit Discord's character limit."""
    if len(text) <= max_len:
        return [text]

    chunks = []
    while text:
        if len(text) <= max_len:
            chunks.append(text)
            break
        # Try to split at a newline
        split_at = text.rfind("\n", 0, max_len)
        if split_at == -1:
            split_at = max_len
        chunks.append(text[:split_at])
        text = text[split_at:].lstrip("\n")
    return chunks


# Module-level singleton
discord_bot = DiscordVendor()
