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

import asyncio
import logging
import socket
import sys
from contextlib import suppress
from urllib.parse import urlparse
from uuid import uuid4

import aiohttp
from aiohttp.client_ws import ClientWSTimeout
from async_timeout import timeout

from aioharmony.const import DEFAULT_WS_HUB_PORT as DEFAULT_HUB_PORT
from aioharmony.const import ConnectorCallbackType
from aioharmony.helpers import call_callback

from .json import json_dumps, json_loads

DEFAULT_DOMAIN = "svcs.myharmony.com"
DEFAULT_TIMEOUT = 5

_LOGGER = logging.getLogger(__name__)


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

_WS_TIMEOUT = ClientWSTimeout(ws_receive=None, ws_close=DEFAULT_TIMEOUT)
_WS_HEARTBEAT = 30  # Send ping every 30s, expect pong within 15s
_WS_CLOSE_TYPES = frozenset(
    {
        aiohttp.WSMsgType.CLOSED,
        aiohttp.WSMsgType.CLOSE,
        aiohttp.WSMsgType.CLOSING,
    }
)


# pylint: disable=too-many-instance-attributes
class HubConnector:
    """An websocket 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._remote_id = None
        self._domain = DEFAULT_DOMAIN

        self._aiohttp_session = None
        self._websocket = None

        self._connect_disconnect_lock = asyncio.Lock()

        self._listener_task = None

        self._connected = False

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

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

    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()

    @property
    def _session(self):
        """Create the aiohttp client session if not existing."""
        # Set connection timeout. Default total timeout is 5 minutes.
        if self._aiohttp_session:
            return self._aiohttp_session

        # Specify socket
        conn = aiohttp.TCPConnector(
            family=socket.AF_INET,
            verify_ssl=False,
            force_close=True,
            enable_cleanup_closed=True,
        )

        session_timeout = aiohttp.ClientTimeout(connect=DEFAULT_TIMEOUT)
        self._aiohttp_session = aiohttp.ClientSession(
            connector=conn, timeout=session_timeout, json_serialize=json_dumps
        )
        return self._aiohttp_session

    async def get_remote_id(self) -> str | None:
        """Retrieve remote id from the HUB."""
        if self._remote_id is None:
            # We do not have the remoteId yet, get it first.
            response = await self._retrieve_hub_info()
            if response is not None:
                self._remote_id = response.get("activeRemoteId")
                domain = urlparse(response.get("discoveryServer"))
                self._domain = domain.netloc or DEFAULT_DOMAIN
        return self._remote_id

    async def async_close_session(self) -> None:
        """Close the aiohttp session."""
        if self._aiohttp_session is None:
            return
        with suppress(asyncio.TimeoutError):
            async with timeout(DEFAULT_TIMEOUT):
                await self._aiohttp_session.close()
                self._aiohttp_session = None

    async def hub_connect(self, is_reconnect: bool = False) -> bool:
        """Connect to Hub Web Socket"""
        # 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._websocket is not None and not self._websocket.closed:
                return True

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

            log_level = 10 if is_reconnect else 40

            if await self.get_remote_id() is None:
                # No remote ID means no connect.
                _LOGGER.log(
                    log_level, "%s: Unable to retrieve HUB id", self._ip_address
                )
                return False

            _LOGGER.debug(
                "%s: Connecting for hub %s", self._ip_address, self._remote_id
            )
            try:
                self._websocket = await self._session.ws_connect(
                    f"ws://{self._ip_address}:{DEFAULT_HUB_PORT}/?domain={self._domain}&hubId={self._remote_id}",
                    timeout=_WS_TIMEOUT,  # close timeout
                    heartbeat=_WS_HEARTBEAT,
                )
            except (
                aiohttp.ServerTimeoutError,
                aiohttp.ClientError,
                aiohttp.WSServerHandshakeError,
            ) as exc:
                if isinstance(exc, aiohttp.ServerTimeoutError):
                    _LOGGER.log(
                        log_level,
                        "%s: Connection timed out for hub %s",
                        self._ip_address,
                        self._remote_id,
                    )
                elif isinstance(exc, aiohttp.WSServerHandshakeError):
                    _LOGGER.log(
                        log_level,
                        "%s: Invalid status code %s received trying to connect for hub %s: %s",
                        self._ip_address,
                        exc.status,
                        self._remote_id,
                        exc,
                    )
                else:
                    _LOGGER.log(
                        log_level,
                        "%s: Exception trying to establish web socket connection for hub %s: %s",
                        self._ip_address,
                        self._remote_id,
                        exc,
                    )
                self._websocket = None
                return False

            # Now put the listener on the loop.
            if not self._listener_task:
                self._listener_task = asyncio.create_task(
                    self._listener(self._websocket)
                )

            # Set connected to True, disconnect sets this to False to
            # prevent automatic reconnect when disconnect is explicitly called
            self._connected = True
            call_callback(
                callback_handler=self._callbacks.connect,
                result=self._ip_address,
                callback_uuid=self._ip_address,
                callback_name="connected",
            )
            _LOGGER.debug("%s: Connected to hub %s", self._ip_address, self._remote_id)
            return True

    async def hub_disconnect(self) -> None:
        """Disconnect from Hub"""
        _LOGGER.debug("%s: Disconnecting", self._ip_address)
        # Acquire the lock.
        async with self._connect_disconnect_lock:
            # Set connected to false preventing reconnect from trying to
            # reconnect.
            self._connected = False

            if self._websocket:
                with suppress(asyncio.TimeoutError):
                    async with timeout(DEFAULT_TIMEOUT):
                        await self._websocket.close()

                await self._session.close()
                # Zero-sleep to allow underlying connections to close.
                await asyncio.sleep(0)

            # Stop our listener if still running
            if self._listener_task and not self._listener_task.done():
                self._listener_task.cancel()
                # One more wait ensuring that our tasks are cancelled.
                await asyncio.sleep(0)

            self._websocket = None

    async def _reconnect(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)

        async with self._connect_disconnect_lock:
            # It is possible that the web socket hasn't been closed yet,
            # if this is the case then close it now.
            if self._websocket is not None and not self._websocket.closed:
                _LOGGER.debug(
                    "%s: Web Socket half-closed, closing first", self._ip_address
                )
                with suppress(asyncio.TimeoutError):
                    async with timeout(DEFAULT_TIMEOUT):
                        await self._websocket.close()

            if self._aiohttp_session is not None and not self._aiohttp_session.closed:
                _LOGGER.debug("%s: Closing sessions", self._ip_address)
                await self.async_close_session()

        # Set web socket to none allowing for reconnect.
        self._websocket = None

        is_reconnect = False
        sleep_time = 1
        while True:
            await asyncio.sleep(sleep_time)
            # Re-check between attempts so a concurrent hub_disconnect() (or
            # auto_reconnect being turned off) actually stops the retry loop;
            # _connected reflects "caller wants us connected".
            if not self._connected:
                _LOGGER.debug(
                    "%s: Disconnect requested during reconnect, stopping",
                    self._ip_address,
                )
                return
            if not self._auto_reconnect:
                _LOGGER.debug(
                    "%s: Auto-reconnect disabled during reconnect, stopping",
                    self._ip_address,
                )
                return
            if await self.hub_connect(is_reconnect=is_reconnect):
                return
            sleep_time = min(sleep_time * 2, 30)
            is_reconnect = True

    async def hub_send(
        self, command, params, msgid=None, post=False
    ) -> str | dict | None:
        """Send a payload request to Harmony Hub and return json response."""
        if not msgid:
            msgid = str(uuid4())

        if post:
            url = f"http://{self._ip_address}:{DEFAULT_HUB_PORT}/"
            headers = {
                "Origin": "http://sl.dhg.myharmony.com",
                "Content-Type": "application/json",
                "Accept": "application/json",
                "Accept-Charset": "utf-8",
            }
            json_request = {"id ": msgid, "cmd": command, "params": {}}
            return asyncio.create_task(self.hub_post(url, json_request, headers))

        # Make sure we're connected.
        if not await self.hub_connect():
            return None

        payload = {
            "hubId": self._remote_id,
            "timeout": DEFAULT_TIMEOUT,
            "hbus": {"cmd": command, "id": msgid, "params": params},
        }

        _LOGGER.debug("%s: Sending payload: %s", self._ip_address, payload)
        try:
            await self._websocket.send_json(payload, dumps=json_dumps)
        except aiohttp.ClientError:
            _LOGGER.exception("%s: Exception sending payload", self._ip_address)
            return None

        return msgid

    async def hub_post(self, url, json_request, headers=None) -> dict | None:
        """Post a json request and return the response."""
        _LOGGER.debug("%s: Sending post request: %s", self._ip_address, json_request)
        try:
            async with self._session.post(
                url, json=json_request, headers=headers
            ) as response:
                json_response = await response.json(content_type=None, loads=json_loads)
                _LOGGER.debug("%s: Post response: %s", self._ip_address, json_response)
        except aiohttp.ClientError:
            _LOGGER.exception("%s: Exception on post", self._ip_address)
        else:
            return json_response

        return None

    # pylint: disable=broad-except
    async def _listener(self, websocket=None) -> None:
        """Listen for  messages on web socket"""
        _LOGGER.debug("%s: Listener started", self._ip_address)
        if not websocket:
            websocket = self._websocket

        if not websocket:
            _LOGGER.error("%s: No web socket to listen on", self._ip_address)
        # This is a continuous loop until socket is closed.
        have_connection = True
        while have_connection:
            # Put everything here in a try block, we do not want this
            # to stop running out due to an exception.
            try:
                if not websocket or websocket.closed:
                    _LOGGER.debug("%s: Web socket has been closed", self._ip_address)
                    have_connection = False
                    break

                try:
                    response = await websocket.receive()
                except aiohttp.ClientError:
                    # Heartbeat timeout / connection reset surfaces here.
                    # Must trigger reconnect, otherwise the integration stays offline.
                    _LOGGER.warning(
                        "%s: Exception during receive, will reconnect",
                        self._ip_address,
                    )
                    have_connection = False
                    break

                _LOGGER.debug(
                    "%s: Response payload: %s", self._ip_address, response.data
                )

                if response.type in _WS_CLOSE_TYPES:
                    close_code = (
                        ""
                        if response.data is None
                        else "with code " + aiohttp.WSCloseCode(response.data).name
                    )
                    _LOGGER.debug(
                        "%s: Web socket closed %s", self._ip_address, close_code
                    )
                    # Issue reconnect event if enabled
                    have_connection = False
                    break

                if response.type is aiohttp.WSMsgType.ERROR:
                    _LOGGER.warning(
                        "%s: Response error, will reconnect: %s",
                        self._ip_address,
                        response.data,
                    )
                    have_connection = False
                    break

                if response.type is not aiohttp.WSMsgType.TEXT:
                    continue

                response_json = response.json(loads=json_loads)
                if not response_json:
                    continue

                # Put response on queue.
                self._response_queue.put_nowait(response_json)

            except asyncio.CancelledError:
                _LOGGER.debug("%s: Received STOP for listener", self._ip_address)
                if (
                    sys.version_info >= (3, 11)
                    and (task := asyncio.current_task())
                    and task.cancelling()
                ):
                    raise
                break

            # pylint: disable=broad-except
            # Need to catch everything here to prevent an issue in a
            # callback from ever causing the handler to exit.
            except Exception:
                _LOGGER.exception(
                    "%s: Exception in listener, will reconnect", self._ip_address
                )
                have_connection = False
                break

        self._listener_task = None
        _LOGGER.debug("%s: Listener stopped.", self._ip_address)

        # If we exited the loop due to connection closed then
        # call reconnect to determine if we should reconnect again.
        if not have_connection:
            await self._reconnect()

    async def _retrieve_hub_info(self) -> dict | None:
        """Retrieve the harmony Hub information."""
        _LOGGER.debug("%s: Retrieving Harmony Hub information.", self._ip_address)

        url = f"http://{self._ip_address}:{DEFAULT_HUB_PORT}/"
        headers = {
            "Origin": "http://sl.dhg.myharmony.com",
            "Content-Type": "application/json",
            "Accept": "application/json",
            "Accept-Charset": "utf-8",
        }
        json_request = {"id ": 1, "cmd": "setup.account?getProvisionInfo", "params": {}}

        response = await self.hub_post(url, json_request, headers)

        if response is not None:
            return response.get("data")

        return None
