"""Connection to the ISY."""
import asyncio
import ssl
import sys
from urllib.parse import quote, urlencode

import aiohttp

from .constants import (
    ATTR_ACTION,
    METHOD_GET,
    SYSTEM_BUSY,
    SYSTEM_IDLE,
    SYSTEM_NOT_BUSY,
    SYSTEM_STATUS,
    URL_CLOCK,
    URL_CONFIG,
    URL_DEFINITIONS,
    URL_MEMBERS,
    URL_NETWORK,
    URL_NODES,
    URL_PING,
    URL_PROGRAMS,
    URL_RESOURCES,
    URL_STATUS,
    URL_SUBFOLDERS,
    URL_VARIABLES,
    VAR_INTEGER,
    VAR_STATE,
    XML_FALSE,
    XML_TRUE,
)
from .exceptions import ISYConnectionError, ISYInvalidAuthError
from .helpers import EventEmitter, value_from_xml
from .logging import _LOGGER, enable_logging

MAX_HTTPS_CONNECTIONS_ISY = 2
MAX_HTTP_CONNECTIONS_ISY = 5
MAX_HTTPS_CONNECTIONS_IOX = 20
MAX_HTTP_CONNECTIONS_IOX = 50

MAX_RETRIES = 5
RETRY_BACKOFF = [0.01, 0.10, 0.25, 1, 2]  # Seconds

HTTP_OK = 200  # Valid request received, will run it
HTTP_UNAUTHORIZED = 401  # User authentication failed
HTTP_NOT_FOUND = 404  # Unrecognized request received and ignored
HTTP_SERVICE_UNAVAILABLE = 503  # Valid request received, system too busy to run it

HTTP_TIMEOUT = 30

HTTP_HEADERS = {
    "Connection": "keep-alive",
    "Keep-Alive": "5000",
    "Accept-Encoding": "gzip, deflate",
}

EMPTY_XML_RESPONSE = '<?xml version="1.0" encoding="UTF-8"?>'


class ISYSystemStatus:
    """Event manager class for ISY System Status."""

    _event = asyncio.Event()
    _status = SYSTEM_IDLE

    def __init__(self):
        """Initialize a system status class."""
        self.status_events = EventEmitter()
        self._event.set()

    @property
    def status(self):
        """Return the system status property."""
        return self._status

    @status.setter
    def status(self, val):
        """Update the system status property."""
        self._status = val
        if val == SYSTEM_BUSY:
            self._event.clear()
        elif val in (SYSTEM_IDLE, SYSTEM_NOT_BUSY):
            self._event.set()
        else:
            raise ISYConnectionError("ISY status unknown")

    async def is_ready(self):
        """Wait for the ISY to be ready."""
        return await self._event.wait()

    def change_received(self, xmldoc):
        """Handle System Status events from an event stream message."""
        action = value_from_xml(xmldoc, ATTR_ACTION)
        if not action or action not in SYSTEM_STATUS:
            return
        self.status = action
        self.status_events.notify(action)


class Connection:
    """Connection object to manage connection to and interaction with ISY."""

    def __init__(
        self,
        address,
        port,
        username,
        password,
        use_https=False,
        tls_ver=1.1,
        webroot="",
        websession=None,
    ):
        """Initialize the Connection object."""
        if len(_LOGGER.handlers) == 0:
            enable_logging(add_null_handler=True)

        self._address = address
        self._port = port
        self._username = username
        self._password = password
        self._auth = aiohttp.BasicAuth(self._username, self._password)
        self._webroot = webroot.rstrip("/")
        self.req_session = websession
        self._tls_ver = tls_ver
        self.use_https = use_https
        self._url = f"http{'s' if self.use_https else ''}://{self._address}:{self._port}{self._webroot}"
        self.system_status = ISYSystemStatus()

        self.semaphore = asyncio.Semaphore(
            MAX_HTTPS_CONNECTIONS_ISY if use_https else MAX_HTTP_CONNECTIONS_ISY
        )

        if websession is None:
            websession = get_new_client_session(use_https, tls_ver)
        self.req_session = websession
        self.sslcontext = get_sslcontext(use_https, tls_ver)

    async def test_connection(self):
        """Test the connection and get the config for the ISY."""
        config = await self.get_config(retries=None)
        if not config:
            _LOGGER.error("Could not connect to the ISY with the parameters provided.")
            raise ISYConnectionError()
        return config

    def increase_available_connections(self):
        """Increase the number of allowed connections for newer hardware."""
        _LOGGER.debug("Increasing available simultaneous connections")
        self.semaphore = asyncio.Semaphore(
            MAX_HTTPS_CONNECTIONS_IOX if self.use_https else MAX_HTTP_CONNECTIONS_IOX
        )

    async def close(self):
        """Cleanup connections and prepare for exit."""
        await self.req_session.close()

    @property
    def connection_info(self):
        """Return the connection info required to connect to the ISY."""
        connection_info = {}
        connection_info["auth"] = self._auth.encode()
        connection_info["addr"] = self._address
        connection_info["port"] = int(self._port)
        connection_info["passwd"] = self._password
        connection_info["webroot"] = self._webroot
        if self.use_https and self._tls_ver:
            connection_info["tls"] = self._tls_ver

        return connection_info

    @property
    def url(self):
        """Return the full connection url."""
        return self._url

    # COMMON UTILITIES
    def compile_url(self, path, query=None):
        """Compile the URL to fetch from the ISY."""
        url = self.url
        if path is not None:
            url += "/rest/" + "/".join([quote(item) for item in path])

        if query is not None:
            url += "?" + urlencode(query)

        return url

    async def request(self, url, retries=0, ok404=False, delay=0):
        """Execute request to ISY REST interface."""
        _LOGGER.debug("ISY Request: %s", url)
        if delay:
            await asyncio.sleep(delay)
        try:
            async with self.semaphore, self.req_session.get(
                url,
                auth=self._auth,
                headers=HTTP_HEADERS,
                timeout=HTTP_TIMEOUT,
                ssl=self.sslcontext,
            ) as res:
                endpoint = url.split("rest", 1)[1]
                if res.status == HTTP_OK:
                    _LOGGER.debug("ISY Response Received: %s", endpoint)
                    results = await res.text(encoding="utf-8", errors="ignore")
                    if results != EMPTY_XML_RESPONSE:
                        return results
                    _LOGGER.debug("Invalid empty XML returned: %s", endpoint)
                    res.release()
                if res.status == HTTP_NOT_FOUND:
                    if ok404:
                        _LOGGER.debug("ISY Response Received %s", endpoint)
                        res.release()
                        return ""
                    _LOGGER.error(
                        "ISY Reported an Invalid Command Received %s", endpoint
                    )
                    res.release()
                    # ISY may report 404 error for valid command if busy
                    if self.system_status.status != SYSTEM_BUSY:
                        return None
                if res.status == HTTP_UNAUTHORIZED:
                    _LOGGER.error("Invalid credentials provided for ISY connection.")
                    res.release()
                    raise ISYInvalidAuthError(
                        "Invalid credentials provided for ISY connection."
                    )
                if res.status == HTTP_SERVICE_UNAVAILABLE:
                    _LOGGER.warning("ISY too busy to process request %s", endpoint)
                    res.release()

        except asyncio.TimeoutError:
            _LOGGER.warning("Timeout while trying to connect to the ISY.")
        except (
            aiohttp.ClientOSError,
            aiohttp.ServerDisconnectedError,
        ):
            _LOGGER.debug("ISY not ready or closed connection.")
        except aiohttp.ClientResponseError as err:
            _LOGGER.error(
                "Client Response Error from ISY: %s %s.", err.status, err.message
            )
        except aiohttp.ClientError as err:
            _LOGGER.error(
                "ISY Could not receive response from device because of a network issue: %s",
                type(err),
            )

        if retries is None:
            raise ISYConnectionError()
        if retries < MAX_RETRIES:
            if self.system_status.status == SYSTEM_BUSY:
                _LOGGER.debug("ISY is busy, waiting for system to be ready")
                try:
                    await asyncio.wait_for(self.system_status.is_ready(), timeout=5.0)
                except asyncio.TimeoutError as exc:
                    raise ISYConnectionError() from exc
            else:
                _LOGGER.debug(
                    "Retrying ISY Request in %ss, retry %s.",
                    RETRY_BACKOFF[retries],
                    retries + 1,
                )
                # sleep to allow the ISY to catch up
                await asyncio.sleep(RETRY_BACKOFF[retries])
            # recurse to try again
            retry_result = await self.request(url, retries + 1, ok404=ok404)
            return retry_result
        # fail for good
        _LOGGER.error(
            "Bad ISY Request: (%s) Failed after %s retries.",
            url,
            retries,
        )
        return None

    async def ping(self):
        """Test connection to the ISY and return True if alive."""
        req_url = self.compile_url([URL_PING])
        result = await self.request(req_url, ok404=True)
        return result is not None

    async def get_description(self):
        """Fetch the services description from the ISY."""
        url = "https://" if self.use_https else "http://"
        url += f"{self._address}:{self._port}{self._webroot}/desc"
        result = await self.request(url)
        return result

    async def get_config(self, retries=0):
        """Fetch the configuration from the ISY."""
        req_url = self.compile_url([URL_CONFIG])
        result = await self.request(req_url, retries=retries)
        return result

    async def get_programs(self, address=None):
        """Fetch the list of programs from the ISY."""
        addr = [URL_PROGRAMS]
        if address is not None:
            addr.append(str(address))
        req_url = self.compile_url(addr, {URL_SUBFOLDERS: XML_TRUE})
        result = await self.request(req_url)
        return result

    async def get_nodes(self):
        """Fetch the list of nodes/groups/scenes from the ISY."""
        req_url = self.compile_url([URL_NODES], {URL_MEMBERS: XML_FALSE})
        result = await self.request(req_url)
        return result

    async def get_status(self):
        """Fetch the status of nodes/groups/scenes from the ISY."""
        req_url = self.compile_url([URL_STATUS])
        result = await self.request(req_url)
        return result

    async def get_variable_defs(self):
        """Fetch the list of variables from the ISY."""
        req_list = [
            [URL_VARIABLES, URL_DEFINITIONS, VAR_INTEGER],
            [URL_VARIABLES, URL_DEFINITIONS, VAR_STATE],
        ]
        req_urls = [self.compile_url(req) for req in req_list]
        results = await asyncio.gather(
            *[self.request(req_url) for req_url in req_urls], return_exceptions=True
        )
        return results

    async def get_variables(self):
        """Fetch the variable details from the ISY to update local copy."""
        req_list = [
            [URL_VARIABLES, METHOD_GET, VAR_INTEGER],
            [URL_VARIABLES, METHOD_GET, VAR_STATE],
        ]
        req_urls = [self.compile_url(req) for req in req_list]
        results = await asyncio.gather(
            *[self.request(req_url) for req_url in req_urls], return_exceptions=True
        )
        results = [r for r in results if r is not None]  # Strip any bad requests.
        result = "".join(results)
        result = result.replace(
            '</vars><?xml version="1.0" encoding="UTF-8"?><vars>', ""
        )
        return result

    async def get_network(self):
        """Fetch the list of network resources from the ISY."""
        req_url = self.compile_url([URL_NETWORK, URL_RESOURCES])
        result = await self.request(req_url)
        return result

    async def get_time(self):
        """Fetch the system time info from the ISY."""
        req_url = self.compile_url([URL_CLOCK])
        result = await self.request(req_url)
        return result


def get_new_client_session(use_https, tls_ver=1.1):
    """Create a new Client Session for Connecting."""
    if use_https:
        if not can_https(tls_ver):
            raise (
                ValueError(
                    "PyISY could not connect to the ISY. "
                    "Check log for SSL/TLS error."
                )
            )

        return aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True))

    return aiohttp.ClientSession()


def get_sslcontext(use_https, tls_ver=1.1):
    """Create an SSLContext object to use for the connections."""
    if not use_https:
        return None
    if tls_ver == 1.1:
        context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_1)
    elif tls_ver == 1.2:
        context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)

    # Allow older ciphers for older ISYs
    context.set_ciphers(
        "DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSK"
    )
    return context


def can_https(tls_ver):
    """
    Verify minimum requirements to use an HTTPS connection.

    Returns boolean indicating whether HTTPS is available.
    """
    output = True

    # check python version
    if sys.version_info < (3, 7):
        _LOGGER.error("PyISY cannot use HTTPS: Invalid Python version. See docs.")
        output = False

    # check that Python was compiled against correct OpenSSL lib
    if "PROTOCOL_TLSv1_1" not in dir(ssl):
        _LOGGER.error(
            "PyISY cannot use HTTPS: Compiled against old OpenSSL library. See docs."
        )
        output = False

    # check the requested TLS version
    if tls_ver not in [1.1, 1.2]:
        _LOGGER.error(
            "PyISY cannot use HTTPS: Only TLS 1.1 and 1.2 are supported by the ISY controller."
        )
        output = False

    return output
