"""
Abstract messaging gateway: loads config, starts adapters, dispatches incoming
messages to the agent, and sends replies. Uses core config and existing agent
invocation (run_raw_agent) for path-safety and personality.

Config (mugi_config.toml) example:
  [messaging]
  enabled = true
  rate_limit_seconds = 5

  [messaging.discord]
  enabled = true
  token = "Bot MTEy..."
  allowed_channels = [123456789012345678]
  command_prefix = "!agent "
  mention_required = false
"""
from __future__ import annotations

import asyncio
import base64
import logging
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Callable, Awaitable
import ssl
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

# Lazy imports for adapters to avoid loading discord.py when disabled
_adapter_registry: dict[str, type] = {}

def _gateway_ssl_context() -> ssl.SSLContext:
    try:
        import certifi
        return ssl.create_default_context(cafile=certifi.where())
    except ImportError:
        return ssl.create_default_context()


_GATEWAY_SSL_CTX = _gateway_ssl_context()


def _url_to_data_url(url: str, log: logging.Logger | None = None, timeout: int = 15) -> str | None:
    """Download URL and return a data: URL (base64) so backends don't need to fetch Discord CDN. Returns None on failure."""
    try:
        req = Request(url, headers={"User-Agent": "DiscordBot (gateway)"})
        with urlopen(req, timeout=timeout, context=_GATEWAY_SSL_CTX) as resp:
            data = resp.read()
            ct = resp.headers.get_content_type() or "application/octet-stream"
            if not ct.startswith("image/"):
                ct = "image/png"  # fallback
            b64 = base64.b64encode(data).decode("ascii")
            return f"data:{ct};base64,{b64}"
    except (URLError, HTTPError, OSError) as e:
        if log:
            log.debug("Could not fetch image for data URL: %s", e)
        return None


def get_gateway_logger() -> logging.Logger:
    """Return the gateway logger. Uses same style as runner (file + no propagate)."""
    log = logging.getLogger("messaging.gateway")
    if not log.handlers:
        import os
        from persistence import MUGI_LOGS_DIR
        os.makedirs(MUGI_LOGS_DIR, exist_ok=True)
        path = os.path.join(MUGI_LOGS_DIR, "background.log")
        h = logging.FileHandler(path, encoding="utf-8")
        h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
        log.addHandler(h)
        log.setLevel(logging.DEBUG)
        log.propagate = False
    return log


@dataclass
class NormalizedMessage:
    """Normalized incoming message from any adapter."""
    user_id: str
    channel_id: str
    thread_id: str | None
    content: str
    is_dm: bool
    adapter_name: str  # e.g. "discord"
    attachment_urls: list[str] = field(default_factory=list)  # image URLs for vision


class MessagingAdapter(ABC):
    """Abstract base for Discord, Telegram, etc. All I/O is adapter-specific."""

    @property
    @abstractmethod
    def name(self) -> str:
        """Adapter identifier (e.g. 'discord')."""
        pass

    @abstractmethod
    async def start(self, on_message: Callable[[NormalizedMessage], Awaitable[None]]) -> None:
        """Start the client and call on_message(normalized_msg) for each eligible message."""
        pass

    @abstractmethod
    async def send_message(
        self,
        target_id: str,
        text: str,
        thread_id: str | None = None,
    ) -> str | None:
        """Send text to the given target (channel or user). thread_id for threads. Returns message id if the adapter supports editing (for placeholder→reply), else None."""
        pass

    async def edit_message(
        self,
        target_id: str,
        message_id: str,
        text: str,
        thread_id: str | None = None,
    ) -> None:
        """Replace an existing message (e.g. placeholder) with new text. No-op if not supported."""
        pass

    async def stop(self) -> None:
        """Optional cleanup. Default no-op."""
        pass


class Gateway:
    """
    Loads messaging config, starts enabled adapters, and dispatches incoming
    messages to the agent. Runs agent invocation in an executor (sync run_raw_agent).
    """

    def __init__(self, loop: asyncio.AbstractEventLoop):
        self._loop = loop
        self._log = get_gateway_logger()
        self._adapters: list[MessagingAdapter] = []
        self._on_message_callback: Callable[[NormalizedMessage], Awaitable[None]] | None = None
        self._running = False

    def _load_config(self) -> dict:
        from core import get_messaging_config
        return get_messaging_config()

    async def start(self) -> None:
        """Load config, start all enabled adapters. Idempotent."""
        if self._running:
            self._log.debug("Gateway already running")
            return
        cfg = self._load_config()
        enabled = cfg.get("enabled", False)
        if not enabled:
            self._log.info("Messaging disabled in config; gateway not started")
            return

        self._on_message_callback = self._handle_message
        self._running = True
        self._log.info("Starting messaging gateway")

        # Discord
        discord_cfg = cfg.get("discord") or {}
        if discord_cfg.get("enabled"):
            try:
                from messaging.discord_adapter import DiscordAdapter
                adapter = DiscordAdapter(discord_cfg, self._log)
                self._adapters.append(adapter)
                await adapter.start(self._on_message_callback)
                self._log.info("Discord adapter started")
            except Exception as e:
                self._log.exception("Failed to start Discord adapter: %s", e)
        else:
            self._log.debug("Discord adapter disabled in config")

        # Future: Telegram, Signal, etc.
        if not self._adapters:
            self._log.warning("Messaging enabled but no adapters started")

    async def stop(self) -> None:
        """Stop all adapters."""
        self._running = False
        for a in self._adapters:
            try:
                await a.stop()
            except Exception as e:
                self._log.warning("Adapter %s stop error: %s", a.name, e)
        self._adapters.clear()
        self._log.info("Messaging gateway stopped")

    async def _handle_message(self, msg: NormalizedMessage) -> None:
        """Process one incoming message: run agent in executor, send reply via adapter."""
        cfg = self._load_config()
        # Rate limit (e.g. 1 per 5 seconds per user)
        rate_sec = float((cfg.get("rate_limit_seconds")) or 5)
        if not hasattr(self, "_last_by_user"):
            self._last_by_user = {}
        now = time.monotonic()
        last = self._last_by_user.get(msg.user_id, 0)
        if now - last < rate_sec:
            self._log.debug("Rate limited user %s", msg.user_id)
            await self._send_reply(msg, "Please wait a moment before sending another command.")
            return
        self._last_by_user[msg.user_id] = now

        self._log.info("Received message from %s in %s (dm=%s): %s",
                       msg.user_id, msg.channel_id, msg.is_dm, (msg.content[:80] + "..." if len(msg.content) > 80 else msg.content))

        # Send a placeholder so the user sees the bot is working (then replace with real reply)
        placeholder_id: str | None = None
        try:
            placeholder_id = await self._send_reply(msg, "Thinking…", return_message_id=True)
        except Exception as e:
            self._log.debug("Could not send placeholder: %s", e)

        reply_text: str
        try:
            reply_text = await self._run_agent_sync(msg)
        except Exception as e:
            self._log.exception("Agent run failed: %s", e)
            self._log.info("Check ~/.mugi/logs/background.log for full traceback.")
            reply_text = "Something went wrong while processing your message. Please try again later."
        if not (reply_text and reply_text.strip()):
            reply_text = "(no response)"

        if placeholder_id:
            await self._edit_reply(msg, placeholder_id, reply_text)
        else:
            await self._send_reply(msg, reply_text)

    def _run_agent_sync_impl(self, msg: NormalizedMessage) -> str:
        """Synchronous agent invocation (called in executor). Reuses run_raw_agent like heartbeat."""
        import aiagent
        from core import get_config
        from persistence import ensure_mugi_workspace

        config = get_config()
        ensure_mugi_workspace(config)
        thread_id = f"messaging_{msg.adapter_name}_{msg.channel_id}_{msg.user_id}"
        if msg.thread_id:
            thread_id += f"_{msg.thread_id}"

        # Build user message; support vision via attachment_urls (OpenAI-style content parts).
        # Download images to data URLs so backends don't need to fetch Discord CDN (which can fail).
        if msg.attachment_urls:
            content_parts = []
            text = (msg.content or "").strip() or "(no text)"
            content_parts.append({"type": "text", "text": text})
            for url in msg.attachment_urls:
                data_url = _url_to_data_url(url, self._log)
                image_url = data_url if data_url else url
                content_parts.append({"type": "image_url", "image_url": {"url": image_url}})
            messages = [{"role": "user", "content": content_parts}]
        else:
            messages = [{"role": "user", "content": msg.content}]
        # No streaming; we only need final text. Include media tools when message has attachments.
        has_media = bool(msg.attachment_urls)
        # Security / spend surface (messaging → agent):
        # - Uses the full main-agent path (run_raw_agent), not run_subagent: same tool surface as the
        #   interactive app, including the drive prompt block when drives are enabled.
        # - has_media_attachment=True when Discord (etc.) attachments are present, enabling media/OCR-tier tools.
        # - Remote-triggered LLM/tool spend; not routed through consume_proactive_budget.
        # Optional hardening later: stricter thread_id namespace (e.g. messaging_*), a reduced tool allowlist,
        # turning off the drive block for this entrypoint, and forcing has_media_attachment=False.
        from tool_policy import context_from_prefs_and_flags
        _msg_policy_ctx = context_from_prefs_and_flags(unattended_external=True, messaging=True)
        result = aiagent.run_raw_agent(
            messages,
            thread_id,
            config,
            on_chunk=lambda _: None,
            on_log=None,
            has_media_attachment=has_media,
            policy_context=_msg_policy_ctx,
        )
        return result or ""

    async def _run_agent_sync(self, msg: NormalizedMessage) -> str:
        """Run sync agent in executor and return final text."""
        return await self._loop.run_in_executor(
            None,
            lambda: self._run_agent_sync_impl(msg),
        )

    async def _send_reply(self, msg: NormalizedMessage, text: str, return_message_id: bool = False) -> str | None:
        """Find the adapter and send the reply to the same channel/thread. If return_message_id True, returns the adapter's message id (or None)."""
        for a in self._adapters:
            if a.name == msg.adapter_name:
                try:
                    msg_id = await a.send_message(
                        msg.channel_id,
                        text,
                        thread_id=msg.thread_id,
                    )
                    if not return_message_id:
                        self._log.info("Sent reply to %s (len=%d)", msg.channel_id, len(text))
                    return msg_id if return_message_id else None
                except Exception as e:
                    self._log.exception("Send reply failed: %s", e)
                return None
        self._log.warning("No adapter found for %s", msg.adapter_name)
        return None

    async def _edit_reply(self, msg: NormalizedMessage, message_id: str, text: str) -> None:
        """Replace an existing message (placeholder) with the real reply."""
        for a in self._adapters:
            if a.name == msg.adapter_name:
                try:
                    await a.edit_message(
                        msg.channel_id,
                        message_id,
                        text,
                        thread_id=msg.thread_id,
                    )
                    self._log.info("Edited reply in %s (len=%d)", msg.channel_id, len(text))
                except Exception as e:
                    self._log.exception("Edit reply failed: %s", e)
                    await self._send_reply(msg, text)
                return
        await self._send_reply(msg, text)
