"""Connector class for sending requests and receiving responses."""

import asyncio
import contextlib
import logging
import sys
from uuid import uuid4

import slixmpp
from async_timeout import timeout
from slixmpp.exceptions import IqTimeout
from slixmpp.xmlstream import ET
from slixmpp.xmlstream.handler.callback import Callback
from slixmpp.xmlstream.matcher import MatchXPath

import aioharmony.exceptions as aioexc
from aioharmony.const import DEFAULT_XMPP_HUB_PORT as DEFAULT_HUB_PORT
from aioharmony.const import ConnectorCallbackType
from aioharmony.helpers import call_callback
from aioharmony.json import JSONDecodeError, json_loads

DEFAULT_DOMAIN = "svcs.myharmony.com"
DEFAULT_TIMEOUT = 5
DEFAULT_USER = "user@connect.logitech.com/gatorade."
DEFAULT_PASSWORD = "password"  # noqa: S105
DEFAULT_NS = "connect.logitech.com"

_LOGGER = logging.getLogger(__name__)


# TODO: Add docstyle comments
# TODO: Clean up code styling


# pylint: disable=too-many-instance-attributes
class HubConnector(slixmpp.ClientXMPP):
    """An XMPP client for connecting to the Logitech Harmony devices."""

    def __init__(
        self,
        ip_address: str,
        response_queue: asyncio.Queue,
        callbacks: ConnectorCallbackType = None,
        auto_reconnect=True,
    ) -> None:
        self._ip_address = ip_address
        self._response_queue = response_queue
        self._callbacks = (
            callbacks if callbacks is not None else ConnectorCallbackType(None, None)
        )
        self._auto_reconnect = auto_reconnect

        self._domain = DEFAULT_DOMAIN

        self._connect_disconnect_lock = asyncio.Lock()

        self._listener_task = None
        self._listener_message_received = None

        self._connected = False
        # Set by hub_disconnect() so an in-flight reconnect loop stops even
        # while _connected is already False for the duration of the reconnect.
        self._disconnect_requested = False

        self._plugin_config = {
            # Enables PLAIN authentication which is off by default.
            "feature_mechanisms": {"unencrypted_plain": True},
        }
        self._init_super()

    def _init_super(self):
        super().__init__(
            DEFAULT_USER, DEFAULT_PASSWORD, plugin_config=self._plugin_config
        )

        # Set keep-alive to 30 seconds.
        self.whitespace_keepalive_interval = 30

        # Register all the event handlers and callbacks within XMPP
        self._register_handlers()

    @property
    def callbacks(self) -> ConnectorCallbackType:
        """Return callbacks."""
        return self._callbacks

    @callbacks.setter
    def callbacks(self, value: ConnectorCallbackType) -> None:
        """Set callbacks."""
        self._callbacks = value

    def _register_handlers(self):
        """Register XMPP handlers for incoming messages and events."""
        _LOGGER.debug("%s: Registering internal handlers.", self._ip_address)
        # Register the callback for messages being received
        self._listener()

        # Register callback for connection.
        self.add_event_handler(
            "connected",
            self._connected_handler,
            disposable=False,
        )

        # Register callback for disconnections.
        self.add_event_handler(
            "disconnected",
            self._disconnected_handler,
            disposable=False,
        )

    def _deregister_handlers(self):
        # Remove handlers.
        _LOGGER.debug("%s: Removing internal handlers.", self._ip_address)
        self.del_event_handler("connected", self._connected_handler)
        self.del_event_handler("disconnected", self._disconnected_handler)
        self.remove_handler("listener")

    async def close(self):
        """Close all connections and tasks

        This should be called to ensure everything is stopped and
        cancelled out.
        """
        # Close connections.
        await self.hub_disconnect()

    async def hub_connect(self, is_reconnect: bool = False) -> bool:
        """Connect to Hub"""
        # Acquire the lock.
        if self._connect_disconnect_lock.locked():
            _LOGGER.debug("%s: Waiting for other connect", self._ip_address)

        async with self._connect_disconnect_lock:
            # Return connected if we are already connected.
            if self._connected:
                return True

            _LOGGER.debug("%s: Connecting to hub", self._ip_address)

            log_level = 10 if is_reconnect else 40
            loop = asyncio.get_running_loop()
            connected = loop.create_future()

            def connection_success(_):
                self.del_event_handler("connection_failed", connection_failed)
                connected.set_result(True)

            def connection_failed(event):
                connected.set_exception(event)
                self.cancel_connection_attempt()
                self.del_event_handler("connected", connection_success)

            def remove_handlers():
                # Remove the handlers.
                self.del_event_handler("connection_failed", connection_failed)
                self.del_event_handler("connected", connection_success)

            self.add_event_handler(
                "connected",
                connection_success,
                disposable=True,
            )

            self.add_event_handler(
                "connection_failed",
                connection_failed,
                disposable=True,
            )

            # Harmony Hubs speak unencrypted XMPP on the local LAN; turn off
            # the TLS negotiation slixmpp would otherwise attempt.
            self.enable_starttls = False
            self.enable_direct_tls = False

            try:
                super().connect(
                    host=self._ip_address,
                    port=int(DEFAULT_HUB_PORT),
                )

            except IqTimeout:
                _LOGGER.log(
                    log_level, "%s: Connection timed out for hub", self._ip_address
                )

                # Remove the handlers.
                remove_handlers()
                return False

            # Wait till we're connected.
            try:
                await connected
            except (asyncio.TimeoutError, TimeoutError):
                _LOGGER.log(
                    log_level,
                    "%s: Timeout waiting for connecting to hub",
                    self._ip_address,
                )
                # Remove the handlers.
                remove_handlers()
                raise aioexc.TimeOut
            except asyncio.CancelledError:
                _LOGGER.debug(
                    "%s: Connecting to hub has been cancelled", self._ip_address
                )
                # Remove the handlers.
                remove_handlers()
                if (
                    sys.version_info >= (3, 11)
                    and (task := asyncio.current_task())
                    and task.cancelling()
                ):
                    raise
                return False
            except OSError as exc:
                _LOGGER.log(
                    log_level,
                    "%s: Connecting to HUB failed with error: %s",
                    self._ip_address,
                    exc,
                )
                # Remove the handlers.
                remove_handlers()
                return False

            # Remove the handlers.
            self._connected = True
            self._disconnect_requested = False
            remove_handlers()
            _LOGGER.debug("%s: Connected to hub", self._ip_address)
            return True

    async def hub_disconnect(self) -> None:
        """Disconnect from Hub"""
        _LOGGER.debug("%s: Disconnecting", self._ip_address)
        # Record intent before the _connected guard so a disconnect that
        # arrives mid-reconnect (when _connected is already False) still
        # tells the retry loop to give up.
        self._disconnect_requested = True
        # Acquire the lock.
        async with self._connect_disconnect_lock:
            if not self._connected:
                return

            # Set connected to false preventing reconnect from trying to
            # reconnect.
            self._connected = False

            loop = asyncio.get_running_loop()
            disconnected = loop.create_future()

            def disconnect_result(_):
                disconnected.set_result(True)

            self._deregister_handlers()

            self.add_event_handler(
                "disconnected",
                disconnect_result,
                disposable=True,
            )
            super().disconnect()

            # Wait till we're disconnected.
            try:
                async with timeout(DEFAULT_TIMEOUT):
                    await disconnected
            except asyncio.TimeoutError:
                _LOGGER.debug("%s: Timeout trying to disconnect.", self._ip_address)
                self.del_event_handler("disconnected", disconnect_result)
                raise aioexc.TimeOut

    def _connected_handler(self, _) -> None:
        """Call handler for connection."""
        self._connected = True
        call_callback(
            callback_handler=self._callbacks.connect,
            result=self._ip_address,
            callback_uuid=self._ip_address,
            callback_name="connected",
        )

    async def _disconnected_handler(self, _) -> None:
        """Perform reconnect to HUB if connection failed"""
        call_callback(
            callback_handler=self._callbacks.disconnect,
            result=self._ip_address,
            callback_uuid=self._ip_address,
            callback_name="disconnected",
        )
        if not self._connected:
            _LOGGER.debug(
                "%s: Connection was closed through disconnect, not reconnecting",
                self._ip_address,
            )
            return

        if not self._auto_reconnect:
            _LOGGER.debug(
                "%s: Connection closed, auto-reconnect disabled", self._ip_address
            )
            return

        _LOGGER.debug("%s: Connection closed, reconnecting", self._ip_address)
        self._connected = False
        is_reconnect = False

        self._deregister_handlers()
        self._init_super()

        def _stop_reason() -> str | None:
            # Re-read the flags so a concurrent hub_disconnect() (or
            # auto_reconnect being turned off) actually stops the retry loop.
            # Unlike the websocket connector, _connected is already False here
            # for the whole reconnect, so the explicit _disconnect_requested
            # flag carries the caller's intent.
            if self._disconnect_requested:
                return "Disconnect requested during reconnect, stopping"
            if not self._auto_reconnect:
                return "Auto-reconnect disabled during reconnect, stopping"
            return None

        sleep_time = 1
        await asyncio.sleep(sleep_time)
        while True:
            if reason := _stop_reason():
                _LOGGER.debug("%s: %s", self._ip_address, reason)
                return
            try:
                if await self.hub_connect(is_reconnect=is_reconnect):
                    # Exit loop if connected.
                    return
            except IqTimeout:
                pass
            is_reconnect = True
            # Re-check intent after the attempt too: intent that changed during
            # hub_connect() must not wait out the (up to 30s) backoff sleep.
            if reason := _stop_reason():
                _LOGGER.debug("%s: %s", self._ip_address, reason)
                return
            # Wait and try again, doubling the backoff up to 30s.
            await asyncio.sleep(sleep_time)
            sleep_time = min(sleep_time * 2, 30)

    async def hub_send(
        self, command, iq_type="get", params=None, msgid=None, post=False
    ) -> str | None:
        """Send a payload request to Harmony Hub and return json response."""
        # Make sure we're connected.
        if not await self.hub_connect():
            return None

        def result_callback(future_result):
            # This is done to ensure that any time out exceptions are
            # captured
            with contextlib.suppress(IqTimeout):
                future_result.result()

        if not msgid:
            msgid = str(uuid4())

        if iq_type == "query":
            iq_stanza = self.make_iq_query()
        elif iq_type == "set":
            iq_stanza = self.make_iq_set()
        elif iq_type == "result":
            iq_stanza = self.make_iq_result()
        elif iq_type == "error":
            iq_stanza = self.make_iq_error(id=msgid)
        else:
            iq_stanza = self.make_iq_get()
        iq_stanza["id"] = msgid

        payload = ET.Element("oa")
        payload.attrib["xmlns"] = DEFAULT_NS
        payload.attrib["mime"] = command

        payload_text = None
        for key in params:
            if payload_text is None:
                payload_text = key + "=" + str(params[key])
            else:
                payload_text = payload_text + ":" + key + "=" + str(params[key])

        payload.text = payload_text
        iq_stanza.set_payload(payload)

        _LOGGER.debug(
            "%s: Sending payload: %s %s", self._ip_address, payload.attrib, payload.text
        )

        result = iq_stanza.send(timeout=1)

        # Add done callback to capture any timeout exceptions.
        result.add_done_callback(result_callback)

        return msgid

    def _listener(self) -> None:
        """Enable callback"""

        def message_received(event):
            payload = event.get_payload()
            if len(payload) == 0:
                _LOGGER.error(
                    "%s: Invalid payload length of 0 received.",
                    self._ip_address,
                )
                return

            for message in payload:
                data = {}
                # Try to convert JSON object if JSON object was received
                if message.text is not None and message.text != "":
                    try:
                        data = json_loads(message.text)
                    except JSONDecodeError:
                        # Should mean only a single value was received.
                        for item in message.text.split(":"):
                            item_split = item.split("=")
                            if len(item_split) == 2:
                                data.update({item_split[0]: item_split[1]})

                # Create response dictionary
                response = {
                    "id": event.get("id"),
                    "xmlns": message.attrib.get("xmlns"),
                    "cmd": message.attrib.get("mime"),
                    "type": message.attrib.get("type"),
                    "code": int(message.attrib.get("errorcode", "0")),
                    "codestring": message.attrib.get("errorstring"),
                    "data": data,
                }
                _LOGGER.debug("%s: Response payload: %s", self._ip_address, response)
                # Put response on queue.
                self._response_queue.put_nowait(response)

        self._listener_message_received = message_received

        # Register our callback.
        self.register_handler(
            Callback(
                "Listener",
                MatchXPath(f"{{{self.default_ns}}}iq/{{{DEFAULT_NS}}}oa"),
                message_received,
            )
        )

        self.register_handler(
            Callback(
                "Listener",
                MatchXPath(f"{{{self.default_ns}}}message/{{{DEFAULT_NS}}}event"),
                message_received,
            )
        )
