"""
Implements Discord chat channel integration.

The Discord API uses a mix of websockets and REST API endpoints.

In order for this integration to work, you need to have your own
discord bot set up via https://discord.com/developers/applications
with the MESSAGE CONTENT toggle switched on, and your bot token
added to `server/conf/secret_settings.py` as your  DISCORD_BOT_TOKEN
"""

import json
import os
from io import BytesIO
from random import random

from autobahn.twisted.websocket import (
    WebSocketClientFactory,
    WebSocketClientProtocol,
    connectWS,
)
from django.conf import settings
from twisted.internet import protocol, reactor, ssl, task
from twisted.web.client import Agent, FileBodyProducer, HTTPConnectionPool, readBody
from twisted.web.http_headers import Headers

from evennia.server.session import Session
from evennia.utils import class_from_module, get_evennia_version, logger
from evennia.utils.utils import delay

_BASE_SESSION_CLASS = class_from_module(settings.BASE_SESSION_CLASS)

DISCORD_API_VERSION = 10
# include version number to prevent automatically updating to breaking changes
DISCORD_API_BASE_URL = f"https://discord.com/api/v{DISCORD_API_VERSION}"

DISCORD_USER_AGENT = f"Evennia (https://www.evennia.com, {get_evennia_version(mode='short')})"
DISCORD_BOT_TOKEN = settings.DISCORD_BOT_TOKEN
DISCORD_BOT_INTENTS = settings.DISCORD_BOT_INTENTS

# Discord OP codes, alphabetic
OP_DISPATCH = 0
OP_HEARTBEAT = 1
OP_HEARTBEAT_ACK = 11
OP_HELLO = 10
OP_IDENTIFY = 2
OP_INVALID_SESSION = 9
OP_RECONNECT = 7
OP_RESUME = 6


# create quiet HTTP pool to muffle GET/POST requests
class QuietConnectionPool(HTTPConnectionPool):
    """
    A quiet version of the HTTPConnectionPool which sets the factory's
    `noisy` property to False to muffle log output.
    """

    def __init__(self, reactor, persistent=True):
        super().__init__(reactor, persistent)
        self._factory.noisy = False


_AGENT = Agent(reactor, pool=QuietConnectionPool(reactor))


def should_retry(status_code):
    """
    Helper function to check if the request should be retried later.

    Args:
        status_code (int) - The HTTP status code

    Returns:
        retry (bool) - True if request should be retried False otherwise
    """
    if status_code >= 500 and status_code <= 504:
        # these are common server error codes when the server is temporarily malfunctioning
        # in these cases, we should retry
        return True
    else:
        # handle all other cases; this can be expanded later if needed for special cases
        return False


class DiscordWebsocketServerFactory(WebSocketClientFactory, protocol.ReconnectingClientFactory):
    """
    A customized websocket client factory that navigates the Discord gateway process.

    """

    initialDelay = 1
    factor = 1.5
    maxDelay = 60
    noisy = False
    gateway = None
    resume_url = None
    is_connecting = False

    def __init__(self, sessionhandler, *args, **kwargs):
        self.uid = kwargs.get("uid")
        self.sessionhandler = sessionhandler
        self.port = None
        self.bot = None

    def get_gateway_url(self, *args, **kwargs):
        # get the websocket gateway URL from Discord
        d = _AGENT.request(
            b"GET",
            f"{DISCORD_API_BASE_URL}/gateway".encode("utf-8"),
            Headers(
                {
                    "User-Agent": [DISCORD_USER_AGENT],
                    "Authorization": [f"Bot {DISCORD_BOT_TOKEN}"],
                    "Content-Type": ["application/json"],
                }
            ),
            None,
        )

        def cbResponse(response):
            if response.code == 200:
                d = readBody(response)
                d.addCallback(self.websocket_init, *args, **kwargs)
                return d
            else:
                logger.log_warn("Discord gateway request failed.")

        d.addCallback(cbResponse)

    def websocket_init(self, payload, *args, **kwargs):
        """
        callback for when the URL is gotten
        """
        data = json.loads(str(payload, "utf-8"))
        self.is_connecting = False
        if url := data.get("url"):
            self.gateway = f"{url}/?v={DISCORD_API_VERSION}&encoding=json".encode("utf-8")
            useragent = kwargs.pop("useragent", DISCORD_USER_AGENT)
            headers = kwargs.pop(
                "headers",
                {
                    "Authorization": [f"Bot {DISCORD_BOT_TOKEN}"],
                    "Content-Type": ["application/json"],
                },
            )

            logger.log_info("Connecting to Discord Gateway...")
            WebSocketClientFactory.__init__(
                self, url, *args, headers=headers, useragent=useragent, **kwargs
            )
            self.start()
        else:
            logger.log_err("Discord did not return a websocket URL; connection cancelled.")

    def buildProtocol(self, addr):
        """
        Build new instance of protocol

        Args:
            addr (str): Not used, using factory/settings data

        """
        if hasattr(settings, "DISCORD_SESSION_CLASS"):
            protocol_class = class_from_module(
                settings.DISCORD_SESSION_CLASS, fallback=DiscordClient
            )
            protocol = protocol_class()
        else:
            protocol = DiscordClient()

        protocol.factory = self
        protocol.sessionhandler = self.sessionhandler
        return protocol

    def startedConnecting(self, connector):
        """
        Tracks reconnections for debugging.

        Args:
            connector (Connector): Represents the connection.

        """
        logger.log_info("Connecting to Discord...")

    def reconnect(self):
        """
        Force a reconnection of the bot protocol. This requires
        de-registering the session and then reattaching a new one.

        """
        # set up the reconnection
        if self.resume_url:
            self.url = self.resume_url
        elif self.gateway:
            self.url = self.gateway
        else:
            # we don't know where to reconnect to! we'll start from the beginning
            self.url = None
        # reset the internal delay, since this is a deliberate disconnect
        self.delay = self.initialDelay
        # disconnect to allow the reconnection process to kick in
        self.bot.sendClose()
        self.sessionhandler.server_disconnect(self.bot)

    def start(self):
        "Connect protocol to remote server"

        if not self.gateway:
            # we don't know where to connect to
            # get the gateway URL from Discord
            self.is_connecting = True
            self.get_gateway_url()
        elif not self.is_connecting:
            # everything is good, connect
            connectWS(self)


class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS):
    """
    Implements the Discord client
    """

    nextHeartbeatCall = None
    pending_heartbeat = False
    heartbeat_interval = None
    last_sequence = 0
    session_id = None
    discord_id = None

    def __init__(self):
        WebSocketClientProtocol.__init__(self)
        _BASE_SESSION_CLASS.__init__(self)

    def at_login(self):
        pass

    def onOpen(self):
        """
        Called when connection is established.

        """
        logger.log_msg("Discord connection established.")
        self.factory.bot = self

        self.init_session("discord", "discord.gg", self.factory.sessionhandler)
        self.uid = int(self.factory.uid)
        self.logged_in = True
        self.sessionhandler.connect(self)

    def onMessage(self, payload, isBinary):
        """
        Callback fired when a complete WebSocket message was received.

        Args:
            payload (bytes): The WebSocket message received.
            isBinary (bool): Flag indicating whether payload is binary or
                             UTF-8 encoded text.

        """
        if isBinary:
            logger.log_info("DISCORD: got a binary payload for some reason")
            return
        data = json.loads(str(payload, "utf-8"))
        if seqid := data.get("s"):
            self.last_sequence = seqid

        # not sure if that error json format is for websockets, so
        # check for it just in case
        if "errors" in data:
            self.handle_error(data)
            return

        # check for discord gateway API op codes first
        if data["op"] == OP_HELLO:
            self.interval = data["d"]["heartbeat_interval"] / 1000  # convert millisec to seconds
            if self.nextHeartbeatCall:
                self.nextHeartbeatCall.cancel()
            self.nextHeartbeatCall = self.factory._batched_timer.call_later(
                self.interval * random(),
                self.doHeartbeat,
            )
            if self.session_id:
                # we already have a session; try to resume instead
                self.resume()
            else:
                self.identify()
        elif data["op"] == OP_HEARTBEAT_ACK:
            # our last heartbeat was acknowledged, so reset the "pending" flag
            self.pending_heartbeat = False
        elif data["op"] == OP_HEARTBEAT:
            # Discord wants us to send a heartbeat immediately
            self.doHeartbeat(force=True)
        elif data["op"] == OP_INVALID_SESSION:
            # Discord doesn't like our current session; reconnect for a new one
            logger.log_msg("Discord: received 'Invalid Session' opcode. Reconnecting.")
            if data["d"] == False:
                # can't resume, clear existing resume data
                self.session_id = None
                self.factory.resume_url = None
            self.factory.reconnect()
        elif data["op"] == OP_RECONNECT:
            # reconnect as requested; Discord does this regularly for server load balancing
            logger.log_msg("Discord: received 'Reconnect' opcode. Reconnecting.")
            self.factory.reconnect()
        elif data["op"] == OP_DISPATCH:
            # handle the general dispatch opcode events by type
            if data["t"] == "READY":
                # our recent identification is valid; process new session info
                self.connection_ready(data["d"])
            else:
                # general message, pass on to data_in
                self.data_in(data=data)

    def onClose(self, wasClean, code=None, reason=None):
        """
        This is executed when the connection is lost for whatever
        reason. it can also be called directly, from the disconnect
        method.

        Args:
            wasClean (bool): ``True`` if the WebSocket was closed cleanly.
            code (int or None): Close status as sent by the WebSocket peer.
            reason (str or None): Close reason as sent by the WebSocket peer.

        """
        self.sessionhandler.disconnect(self)
        if self.nextHeartbeatCall:
            self.nextHeartbeatCall.cancel()
            self.nextHeartbeatCall = None
        if wasClean:
            logger.log_info(f"Discord connection closed ({code}) reason: {reason}")
        else:
            logger.log_info(f"Discord connection lost.")

    def _send_json(self, data):
        """
        Post JSON data to the websocket

        Args:
            data (dict): content to send.

        """
        return self.sendMessage(json.dumps(data).encode("utf-8"))

    def _post_json(self, url, data, **kwargs):
        """
        Post JSON data to a REST API endpoint

        Args:
            url (str) - The API path which is being posted to
            data (dict) - Content to be sent
        """
        url = f"{DISCORD_API_BASE_URL}/{url}"
        body = FileBodyProducer(BytesIO(json.dumps(data).encode("utf-8")))
        request_type = kwargs.pop("type", "POST")

        d = _AGENT.request(
            request_type.encode("utf-8"),
            url.encode("utf-8"),
            Headers(
                {
                    "User-Agent": [DISCORD_USER_AGENT],
                    "Authorization": [f"Bot {DISCORD_BOT_TOKEN}"],
                    "Content-Type": ["application/json"],
                }
            ),
            body,
        )

        def cbResponse(response):
            if response.code == 200 or response.code == 204:
                d = readBody(response)
                d.addCallback(self.post_response)
                return d
            elif should_retry(response.code):
                delay(300, self._post_json, url, data, **kwargs)

        d.addCallback(cbResponse)

    def post_response(self, body, **kwargs):
        """
        Process the response from sending a POST request

        Args:
            body (bytes) - The post response body
        """
        data = json.loads(body)
        if "errors" in data:
            self.handle_error(data)

    def handle_error(self, data, **kwargs):
        """
        General hook for processing errors.

        Args:
            data (dict) - The received error data

        """
        logger.log_err(str(data))

    def resume(self):
        """
        Called after a reconnection to re-identify and replay missed events

        """
        if not self.last_sequence or not self.session_id:
            # we have no known state to resume from, identify normally
            self.identify()

        # build a RESUME request for Discord and send it
        data = {
            "op": OP_RESUME,
            "d": {
                "token": DISCORD_BOT_TOKEN,
                "session_id": self.session_id,
                "s": self.sequence_id,
            },
        }
        self._send_json(data)

    def disconnect(self, reason=None):
        """
        Generic hook for the engine to call in order to
        disconnect this protocol.

        Args:
            reason (str or None): Motivation for the disconnection.

        """
        self.sendClose(self.CLOSE_STATUS_CODE_NORMAL, reason)

    def identify(self, *args, **kwargs):
        """
        Send Discord authentication. This should be sent once heartbeats begin.

        """
        data = {
            "op": 2,
            "d": {
                "token": DISCORD_BOT_TOKEN,
                "intents": DISCORD_BOT_INTENTS,
                "properties": {
                    "os": os.name,
                    "browser": DISCORD_USER_AGENT,
                    "device": DISCORD_USER_AGENT,
                },
            },
        }
        self._send_json(data)

    def connection_ready(self, data):
        """
        Process READY data for relevant bot info.
        """
        self.factory.resume_url = data["resume_gateway_url"]
        self.session_id = data["session_id"]
        self.discord_id = data["user"]["id"]

    def doHeartbeat(self, *args, **kwargs):
        """
        Send heartbeat to Discord.

        """
        if not self.pending_heartbeat or kwargs.get("force"):
            if self.nextHeartbeatCall:
                self.nextHeartbeatCall.cancel()
            # send the heartbeat
            data = {"op": 1, "d": self.last_sequence}
            self._send_json(data)
            # track that we sent a heartbeat, in case we don't receive an ACK
            self.pending_heartbeat = True
            self.nextHeartbeatCall = self.factory._batched_timer.call_later(
                self.interval,
                self.doHeartbeat,
            )
        else:
            # we didn't get a response since the last heartbeat; reconnect
            self.factory.reconnect()

    def send_channel(self, text, channel_id, **kwargs):
        """
        Send a message from an Evennia channel to a Discord channel.

        Use with session.msg(channel=(message, channel, sender))

        """

        data = {"content": text}
        data.update(kwargs)
        self._post_json(f"channels/{channel_id}/messages", data)

    def send_nickname(self, text, guild_id, user_id, **kwargs):
        """
        Changes a user's nickname on a Discord server.

        Use with session.msg(nickname=(new_nickname, guild_id, user_id))
        """

        data = {"nick": text}
        data.update(kwargs)
        self._post_json(f"guilds/{guild_id}/members/{user_id}", data, type="PATCH")

    def send_role(self, role_id, guild_id, user_id, **kwargs):
        data = kwargs
        self._post_json(f"guilds/{guild_id}/members/{user_id}/roles/{role_id}", data, type="PUT")

    def send_default(self, *args, **kwargs):
        """
        Ignore other outputfuncs

        """
        pass

    def data_in(self, data, **kwargs):
        """
        Process incoming data from Discord and sent to the Evennia server

        Args:
            data (dict): Converted json data.

        """
        action_type = data.get("t", "UNKNOWN")

        if action_type == "MESSAGE_CREATE":
            # someone posted a message on Discord that the bot can see
            data = data["d"]
            if data["author"]["id"] == self.discord_id:
                # it's by the bot itself! disregard
                return
            message = data["content"]
            channel_id = data["channel_id"]
            keywords = {"channel_id": channel_id}
            if "guild_id" in data:
                # message received to a Discord channel
                keywords["type"] = "channel"
                author = data["member"]["nick"] or data["author"]["username"]
                author_id = data["author"]["id"]
                keywords["sender"] = (author_id, author)
                keywords["guild_id"] = data["guild_id"]

            else:
                # message sent directly to the bot account via DM
                keywords["type"] = "direct"
                author = data["author"]["username"]
                author_id = data["author"]["id"]
                keywords["sender"] = (author_id, author)

            # pass the processed data to the server
            self.sessionhandler.data_in(self, bot_data_in=(message, keywords))

        elif action_type in ("GUILD_CREATE", "GUILD_UPDATE"):
            # we received the current status of a guild the bot is on; process relevant info
            data = data["d"]
            keywords = {"type": "guild", "guild_id": data["id"], "guild_name": data["name"]}
            keywords["channels"] = {
                chan["id"]: {"name": chan["name"], "guild": data["name"]}
                for chan in data["channels"]
                if chan["type"] == 0
            }
            # send the possibly-updated guild and channel data to the server
            self.sessionhandler.data_in(self, bot_data_in=("", keywords))

        elif "DELETE" in action_type:
            # deletes should possibly be handled separately to check for channel removal
            # for now, just ignore
            pass

        else:
            # send the data for any other action types on to the bot as-is for optional server-side handling
            keywords = {"type": action_type}
            keywords.update(data["d"])
            self.sessionhandler.data_in(self, bot_data_in=("", keywords))
