""" Orbivo S20. """

import binascii
import struct
import logging
import socket
import threading
import time

_LOGGER = logging.getLogger(__name__)

# S20 UDP port
PORT = 10000

# UDP best-effort.
RETRIES = 3
TIMEOUT = 1.0
DISCOVERY_TIMEOUT = 1.0

# Timeout after which to renew device subscriptions
SUBSCRIPTION_TIMEOUT = 60

# Packet constants.
MAGIC = b'\x68\x64'
DISCOVERY = b'\x00\x06\x71\x61'
DISCOVERY_RESP = b'\x00\x2a\x71\x61'
SUBSCRIBE = b'\x00\x1e\x63\x6c'
SUBSCRIBE_RESP = b'\x00\x18\x63\x6c'
CONTROL = b'\x00\x17\x64\x63'
CONTROL_RESP = b'\x00\x17\x73\x66'
PADDING_1 = b'\x20\x20\x20\x20\x20\x20'
PADDING_2 = b'\x00\x00\x00\x00'
ON = b'\x01'
OFF = b'\x00'

# Socket
_SOCKET = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

# Buffer
_BUFFER = {}


def _listen():
    """ Listen on socket. """
    while True:
        data, addr = _SOCKET.recvfrom(1024)
        _BUFFER[addr[0]] = data


def _setup():
    """ Set up module.

    Open a UDP socket, and listen in a thread.
    """
    _SOCKET.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    _SOCKET.bind(('', PORT))
    udp = threading.Thread(target=_listen, daemon=True)
    udp.start()


def _device_time(tab):
    ts = struct.unpack('<L', tab)[0] - 2208988800
    return ts


def discover(timeout=DISCOVERY_TIMEOUT):
    """ Discover devices on the local network.

    :param timeout: Optional timeout in seconds.
    :returns: Set of discovered host addresses.
    """
    hosts = {}
    payload = MAGIC + DISCOVERY
    for _ in range(RETRIES):
        _SOCKET.sendto(bytearray(payload), ('255.255.255.255', PORT))
        start = time.time()
        while time.time() < start + timeout:
            for host, data in _BUFFER.copy().items():
                if not _is_discovery_response(data):
                    continue
                if host not in hosts:
                    _LOGGER.debug("Discovered device at %s", host)
                    entry = {}
                    entry['mac'] = data[7:13]
                    entry['imac'] = data[19:25]
                    entry['next'] = 0
                    entry['st'] = int(data[-1])
                    entry['time'] = _device_time(data[37:41])
                    entry['serverTime'] = int(time.time())
                    hosts[host] = entry
    return hosts


def _is_discovery_response(data):
    """ Is this a discovery response?

    :param data: Payload.
    """
    return data[0:6] == (MAGIC + DISCOVERY_RESP)


def _is_subscribe_response(data):
    """ Is this a subscribe response?

    :param data: Payload.
    """
    return data[0:6] == (MAGIC + SUBSCRIBE_RESP)


def _is_control_response(data):
    """ Is this a control response?

    :param data: Payload.
    """
    return data[0:6] == (MAGIC + CONTROL_RESP)


class S20Exception(Exception):
    """ S20 exception. """
    pass


class S20(object):
    """ Controls an Orbivo S20 WiFi Smart Socket.

    http://www.orvibo.com/en_products_view.asp?mid=15&pid=4&id=234

    Protocol documentation: http://pastebin.com/LfUhsbcS
    """
    def __init__(self, host, mac = None):
        """ Initialize S20 object.

        :param host: IP or hostname of device.
        """
        self.host = host
        if not mac:
            (self._mac, self._mac_reversed) = self._discover_mac()
        else:
            if type(mac) is str:
                self._mac = binascii.a2b_hex(''.join(mac.split(':')))
            else:
                self._mac = mac
            ba = bytearray(self._mac)
            ba.reverse()
            self._mac_reversed = bytes(ba)
        self._subscribe()

    @property
    def on(self):
        """ State property.

        :returns: State of device (on/off).
        """
        return self._subscribe()

    @on.setter
    def on(self, state):
        """ Change device state.

        :param state: True (on) or False (off).
        """
        if state:
            self._turn_on()
        else:
            self._turn_off()

    def _discover_mac(self):
        """ Discovers MAC address of device.

        Discovery is done by sending a UDP broadcast.
        All configured devices reply. The response contains
        the MAC address in both needed formats.

        Discovery of multiple switches must be done synchronously.

        :returns: Tuple of MAC address and reversed MAC address.
        """
        mac = None
        mac_reversed = None
        cmd = MAGIC + DISCOVERY
        resp = self._udp_transact(cmd, self._discovery_resp,
                                  broadcast=True,
                                  timeout=DISCOVERY_TIMEOUT)
        if resp:
            (mac, mac_reversed) = resp
        if mac is None:
            raise S20Exception("Couldn't discover {}".format(self.host))
        return (mac, mac_reversed)

    def _subscribe(self):
        """ Subscribe to the device.

        A subscription serves two purposes:
        - Returns state (on/off).
        - Enables state changes on the device
          for a short period of time.
        """
        cmd = MAGIC + SUBSCRIBE + self._mac \
            + PADDING_1 + self._mac_reversed + PADDING_1
        status = self._udp_transact(cmd, self._subscribe_resp)
        if status is not None:
            self.last_subscribed = time.time()
            return status == ON
        else:
            raise S20Exception(
                "No status could be found for {}".format(self.host))

    def _subscription_is_recent(self):
        """ Check if subscription occurred recently.

        :returns: Yes (True) or no (False)
        """
        return self.last_subscribed > time.time() - SUBSCRIPTION_TIMEOUT

    def _control(self, state):
        """ Control device state.

        Possible states are ON or OFF.

        :param state: Switch to this state.
        """

        # Renew subscription if necessary
        if not self._subscription_is_recent():
            self._subscribe()

        cmd = MAGIC + CONTROL + self._mac + PADDING_1 + PADDING_2 + state
        _LOGGER.debug("Sending new state to %s: %s", self.host, ord(state))
        ack_state = self._udp_transact(cmd, self._control_resp, state)
        if ack_state is None:
            raise S20Exception(
                "Device didn't acknowledge control request: {}".format(
                    self.host))

    def _discovery_resp(self, data):
        """ Handle a discovery response.

        :param data: Payload.
        :param addr: Address tuple.
        :returns: MAC and reversed MAC.
        """
        if _is_discovery_response(data):
            _LOGGER.debug("Discovered MAC of %s: %s", self.host,
                          binascii.hexlify(data[7:13]).decode())
            return (data[7:13], data[19:25])

    def _subscribe_resp(self, data):
        """ Handle a subscribe response.

        :param data: Payload.
        :returns: State (ON/OFF)
        """
        if _is_subscribe_response(data):
            status = bytes([data[23]])
            _LOGGER.debug("Successfully subscribed to %s, state: %s",
                          self.host, ord(status))
            return status

    def _control_resp(self, data, state):
        """ Handle a control response.

        :param data: Payload.
        :param state: Requested state.
        :returns: Acknowledged state.
        """
        if _is_control_response(data):
            ack_state = bytes([data[22]])
            if state == ack_state:
                _LOGGER.debug("Received state ack from %s, state: %s",
                              self.host, ord(ack_state))
                return ack_state

    def _udp_transact(self, payload, handler, *args,
                      broadcast=False, timeout=TIMEOUT):
        """ Complete a UDP transaction.

        UDP is stateless and not guaranteed, so we have to
        take some mitigation steps:
        - Send payload multiple times.
        - Wait for awhile to receive response.

        :param payload: Payload to send.
        :param handler: Response handler.
        :param args: Arguments to pass to response handler.
        :param broadcast: Send a broadcast instead.
        :param timeout: Timeout in seconds.
        """
        if self.host in _BUFFER:
            del _BUFFER[self.host]
        host = self.host
        if broadcast:
            host = '255.255.255.255'
        retval = None
        for _ in range(RETRIES):
            _SOCKET.sendto(bytearray(payload), (host, PORT))
            start = time.time()
            while time.time() < start + timeout:
                data = _BUFFER.get(self.host, None)
                if data:
                    retval = handler(data, *args)
                # Return as soon as a response is received
                if retval:
                    return retval

    def _turn_on(self):
        """ Turn on the device. """
        self._control(ON)

    def _turn_off(self):
        """ Turn off the device. """
        self._control(OFF)


_setup()
