""""
Read data from Mi Kettle.
"""

import logging
from bluepy.btle import UUID, Peripheral, DefaultDelegate
from datetime import datetime, timedelta
from threading import Lock

_KEY1 = bytes([0x90, 0xCA, 0x85, 0xDE])
_KEY2 = bytes([0x92, 0xAB, 0x54, 0xFA])

_HANDLE_READ_FIRMWARE_VERSION = 26
_HANDLE_READ_NAME = 20
_HANDLE_AUTH_INIT = 44
_HANDLE_AUTH = 37
_HANDLE_VERSION = 42
_HANDLE_STATUS = 61

_UUID_SERVICE_KETTLE = "fe95"
_UUID_SERVICE_KETTLE_DATA = "01344736-0000-1000-8000-262837236156"

_SUBSCRIBE_TRUE = bytes([0x01, 0x00])

MI_ACTION = "action"
MI_MODE = "mode"
MI_SET_TEMPERATURE = "set temperature"
MI_CURRENT_TEMPERATURE = "current temperature"
MI_KW_TYPE = "keep warm type"
MI_KW_TIME = "keep warm time"

MI_ACTION_MAP = {
    0: "idle",
    1: "heating",
    2: "cooling",
    3: "keeping warm"
}

MI_MODE_MAP = {
    255: "none",
    1: "boil",
    3: "keep warm"
}

MI_KW_TYPE_MAP = {
    0: "warm up",
    1: "cool down"
}

_LOGGER = logging.getLogger(__name__)


class MiKettle(object):
    """"
    A class to control mi kettle device.
    """

    def __init__(self, mac, product_id, cache_timeout=600, retries=3, iface=None, token=None):
        """
        Initialize a Mi Kettle for the given MAC address.
        """
        _LOGGER.debug('Init Mikettle with mac %s and pid %s', mac, product_id)

        self._mac = mac
        self._reversed_mac = MiKettle.reverseMac(mac)

        self._cache = None
        self._cache_timeout = timedelta(seconds=cache_timeout)
        self._last_read = None
        self.retries = retries
        self.ble_timeout = 10
        self.lock = Lock()

        self._product_id = product_id
        self._iface = iface
        # Generate token if not supplied
        if token is None:
            token = MiKettle.generateRandomToken()
        self._token = token

    def connect(self):
        self._p = Peripheral(deviceAddr=self._mac, iface=self._iface)
        self._p.setDelegate(self)

    def name(self):
        """Return the name of the device."""
        self.connect()
        self.auth()
        name = self._p.readCharacteristic(_HANDLE_READ_NAME)

        if not name:
            raise Exception("Could not read NAME using handle %s"
                            " from Mi Kettle %s" % (_HANDLE_READ_NAME, self._mac))
        return ''.join(chr(n) for n in name)

    def firmware_version(self):
        """Return the firmware version."""
        self.connect()
        self.auth()
        firmware_version = self._p.readCharacteristic(_HANDLE_READ_FIRMWARE_VERSION)

        if not firmware_version:
            raise Exception("Could not read FIRMWARE_VERSION using handle %s"
                            " from Mi Kettle %s" % (_HANDLE_READ_FIRMWARE_VERSION, self._mac))
        return ''.join(chr(n) for n in firmware_version)

    def parameter_value(self, parameter, read_cached=True):
        """Return a value of one of the monitored paramaters.
        This method will try to retrieve the data from cache and only
        request it by bluetooth if no cached value is stored or the cache is
        expired.
        This behaviour can be overwritten by the "read_cached" parameter.
        """
        # Use the lock to make sure the cache isn't updated multiple times
        with self.lock:
            if (read_cached is False) or \
                    (self._last_read is None) or \
                    (datetime.now() - self._cache_timeout > self._last_read):
                self.fill_cache()
            else:
                _LOGGER.debug("Using cache (%s < %s)",
                              datetime.now() - self._last_read,
                              self._cache_timeout)

        if self.cache_available():
            return self._cache[parameter]
        else:
            raise Exception("Could not read data from MiKettle %s" % self._mac)

    def fill_cache(self):
        """Fill the cache with new data from the sensor."""
        _LOGGER.debug('Filling cache with new sensor data.')
        try:
            _LOGGER.debug('Connect')
            self.connect()
            _LOGGER.debug('Auth')
            self.auth()
            _LOGGER.debug('Subscribe')
            self.subscribeToData()
            _LOGGER.debug('Wait for data')
            self._p.waitForNotifications(self.ble_timeout)
            # If a sensor doesn't work, wait 5 minutes before retrying
        except Exception as error:
            _LOGGER.debug('Error %s', error)
            self._last_read = datetime.now() - self._cache_timeout + \
                timedelta(seconds=300)
            return

    def clear_cache(self):
        """Manually force the cache to be cleared."""
        self._cache = None
        self._last_read = None

    def cache_available(self):
        """Check if there is data in the cache."""
        return self._cache is not None

    def _parse_data(self, data):
        """Parses the byte array returned by the sensor."""
        res = dict()
        res[MI_ACTION] = MI_ACTION_MAP[int(data[0])]
        res[MI_MODE] = MI_MODE_MAP[int(data[1])]
        res[MI_SET_TEMPERATURE] = int(data[4])
        res[MI_CURRENT_TEMPERATURE] = int(data[5])
        res[MI_KW_TYPE] = MI_KW_TYPE_MAP[int(data[6])]
        res[MI_KW_TIME] = MiKettle.bytes_to_int(data[7:8])
        return res

    @staticmethod
    def bytes_to_int(bytes):
        result = 0
        for b in bytes:
            result = result * 256 + int(b)

        return result

    def auth(self):
        auth_service = self._p.getServiceByUUID(_UUID_SERVICE_KETTLE)
        auth_descriptors = auth_service.getDescriptors()

        self._p.writeCharacteristic(_HANDLE_AUTH_INIT, _KEY1, "true")

        auth_descriptors[1].write(_SUBSCRIBE_TRUE, "true")

        self._p.writeCharacteristic(_HANDLE_AUTH,
                                    MiKettle.cipher(MiKettle.mixA(self._reversed_mac, self._product_id), self._token),
                                    "true")

        self._p.waitForNotifications(10.0)

        self._p.writeCharacteristic(_HANDLE_AUTH, MiKettle.cipher(self._token, _KEY2), "true")

        self._p.readCharacteristic(_HANDLE_VERSION)

    def subscribeToData(self):
        controlService = self._p.getServiceByUUID(_UUID_SERVICE_KETTLE_DATA)
        controlDescriptors = controlService.getDescriptors()
        controlDescriptors[3].write(_SUBSCRIBE_TRUE, "true")

    # TODO: Actually generate random token instead of static one
    @staticmethod
    def generateRandomToken() -> bytes:
        return bytes([0x01, 0x5C, 0xCB, 0xA8, 0x80, 0x0A, 0xBD, 0xC1, 0x2E, 0xB8, 0xED, 0x82])

    @staticmethod
    def reverseMac(mac) -> bytes:
        parts = mac.split(":")
        reversedMac = bytearray()
        leng = len(parts)
        for i in range(1, leng + 1):
            reversedMac.extend(bytearray.fromhex(parts[leng - i]))
        return reversedMac

    @staticmethod
    def mixA(mac, productID) -> bytes:
        return bytes([mac[0], mac[2], mac[5], (productID & 0xff), (productID & 0xff), mac[4], mac[5], mac[1]])

    @staticmethod
    def mixB(mac, productID) -> bytes:
        return bytes([mac[0], mac[2], mac[5], ((productID >> 8) & 0xff), mac[4], mac[0], mac[5], (productID & 0xff)])

    @staticmethod
    def _cipherInit(key) -> bytes:
        perm = bytearray()
        for i in range(0, 256):
            perm.extend(bytes([i & 0xff]))
        keyLen = len(key)
        j = 0
        for i in range(0, 256):
            j += perm[i] + key[i % keyLen]
            j = j & 0xff
            perm[i], perm[j] = perm[j], perm[i]
        return perm

    @staticmethod
    def _cipherCrypt(input, perm) -> bytes:
        index1 = 0
        index2 = 0
        output = bytearray()
        for i in range(0, len(input)):
            index1 = index1 + 1
            index1 = index1 & 0xff
            index2 += perm[index1]
            index2 = index2 & 0xff
            perm[index1], perm[index2] = perm[index2], perm[index1]
            idx = perm[index1] + perm[index2]
            idx = idx & 0xff
            outputByte = input[i] ^ perm[idx]
            output.extend(bytes([outputByte & 0xff]))

        return output

    @staticmethod
    def cipher(key, input) -> bytes:
        perm = MiKettle._cipherInit(key)
        return MiKettle._cipherCrypt(input, perm)

    def handleNotification(self, cHandle, data):
        if cHandle == _HANDLE_AUTH:
            if(MiKettle.cipher(MiKettle.mixB(self._reversed_mac, self._product_id),
                               MiKettle.cipher(MiKettle.mixA(self._reversed_mac,
                                                             self._product_id),
                                               data)) != self._token):
                raise Exception("Authentication failed.")
        elif cHandle == _HANDLE_STATUS:
            _LOGGER.debug("Status update:")
            if data is None:
              return

            _LOGGER.debug("Parse data: %s", data)
            self._cache = self._parse_data(data)
            _LOGGER.debug("data parsed %s", self._cache)

            if self.cache_available():
                self._last_read = datetime.now()
            else:
                # If a sensor doesn't work, wait 5 minutes before retrying
                self._last_read = datetime.now() - self._cache_timeout + \
                    timedelta(seconds=300)
        else:
            _LOGGER.error("Unknown notification from handle: %s with Data: %s", cHandle, data.hex())