"""Actions.

Currently, only usb actions are implemented. All actions return a dict that contains any error that might have happened
or the actual data specific to that action. Since all of the actions are reduced by the same reducer (`USBReducer`),
they all return a dict with the keys `data` (for any results from a successful execution e.g. the usb handle, a success
message), `error` (any error that might have happened), and `args` (used by `data` or `error` for any extra data
that doesn't fit into a single variable). If no errors happened, `error` is set to `None`. Otherwise, `data` is set to
`None`.

Todo:
    * Implement an action to stop EIS execution.

.. _src-actions:
    https://github.com/hivebattery/gui/blob/master/driver/src/actions/actions.py

"""
from __future__ import absolute_import

import usb

import numpy as np
from sys import platform

from react.index import NamedTuple

from src.common.log.console_message import *
from src.common.bytes import bytes as byte_utils
from src.config.config import *


bmVENDOR_REQUEST = usb.util.build_request_type(usb.util.CTRL_RECIPIENT_DEVICE, usb.util.CTRL_TYPE_VENDOR,
                                               usb.util.CTRL_OUT)
"""int: Vendor request hex number to use for the `bmRequest` parameter. IN.
"""
bmCONTROL_IN = usb.util.build_request_type(usb.util.CTRL_RECIPIENT_DEVICE, usb.util.CTRL_TYPE_VENDOR,
                                           usb.util.CTRL_IN)
"""int: Control IN request hex number to use for the `bmRequest` parameter.
"""


bINITIATE_EIS = 0x00
"""int: `bRequest` value to start EIS.
"""
bINITIATE_ABORT_EIS = 0x01
"""int: `bRequest` value to stop EIS. NOT IMPLEMENTED.
"""
bUPDATE_EIS = 0x02
"""int: `bRequest` value to fetch the status code generated by the device to describe its own state.
"""
bCLEAR_EIS_ERR = 0x03
"""int: `bRequest` value to clear all errors and set the device's status code to `sREADY`.
"""
bINITIATE_EIS_DATA_TRANSFER = 0x04
"""int: `bRequest` value to start the data transfer once the device has data available.
"""

sREADY = 0x00
"""int: status code to indicate the device is ready for requests.
"""
sBUSY = 0xB0
"""int: status code to indicate the device is busy doing some work for some previous request.
"""
sSIGN = 0xC0
"""int: status code to indicate the device has finished a data transfer and is now ready to send the current ranging
value.
"""
sDAV = 0xD0
"""int: status code to indicate the device has data available for transfer.
"""
sTRANS = 0xA0
"""int: status code to indicate the device is currently transferring data.
"""
sERROR = 0xE0
"""int: status code to indicate the device halted due to an error.
"""

VENDOR_ID = 0x0b6a
"""int: The device's vendor ID.
"""
PRODUCT_ID = 0x5346
"""int: The device's product ID.
"""


ACTION_TYPES = NamedTuple(dict(CONNECT='CONNECT', START_EIS='START_EIS', STOP_EIS='STOP_EIS', POLL_EIS='POLL_EIS',
                               CHECK_CONNECTION='CHECK_CONNECTION', START_EIS_DATA_TRANSFER='START_EIS_DATA_TRANSFER',
                               CLEAR_USB_ERRORS='CLEAR_USB_ERRORS', UPDATE_USB_STATUS='UPDATE_USB_STATUS'),
                          'ActionTypes')
"""react.data_structures.named_tuple.NamedTuple of str: Used by the reducer to identify actions.
"""


class USBHandle(object):
    """USB Handle.

    Stores the information of the device provided by `pyUSB` to provide easy access for reading the BULK in endpoint
    and accessing the device at any point.

    """
    def __init__(self, dev):
        """USB Handle constructor.

        Args:
            dev (usb.core.Device): The device returned by pyUSB's `find` method.

        """
        self.__dev = dev
        self.__ep_read = None

    @property
    def dev(self):
        """usb.core.Device: The device found by pyUSB matching the vendor and product IDs."""
        return self.__dev

    @property
    def ep_read(self):
        """usb.core.Endpoint: The device found by pyUSB matching the vendor and product IDs."""
        return self.__ep_read

    @ep_read.setter
    def ep_read(self, value):
        self.__ep_read = value


def prepare_kernel(usb_handle):
    """Detach kernel.

    If the platform is 'Darwin' i.e. a computer running MacOS, this step is necessary to claim the device's interface.

    Args:
         usb_handle (USBHandle): The device returned by pyUSB's `find` method.

    """
    for config in usb_handle.dev:
        for i in range(config.bNumInterfaces):
            if usb_handle.dev.is_kernel_driver_active(i):
                usb_handle.dev.detach_kernel_driver(i)


def read(usb_handle, length):
    """Read the register linked to `usb_handle.ep_read`.

    Args:
        usb_handle (USBHandle): The usb handle for the device.
        length (int): The max number of bytes to read (the exact number of bytes is preferred).

    Returns:
        bytearray: An `length`-sized array with the bytes read at the register.

    """
    return usb_handle.ep_read.read(length, 0)


def build_response(usb_handle, status_queue, pos, data, final_pos, timeout, action_type, bmRequest, bRequest, freq_id):
    """Concatenate the data transferred from the device.

    These operations happen in the following order:
        1) Return the `res` dict with the data transferred if `pos` equals `final_pos` i.e. all bytes have been
            transferred.
        2) Check the device's status code to ensure it's ready for a data transfer. If there's an error, interrupt the
            transfer and return the error.
        3) Send the current frequency id as part of the request and include all other necessary parameters.
        4) Check the device's status code to ensure it's ready for a data transfer. If there's an error, interrupt the
        transfer and return the error.
        5) Read the register and fetch the actual data.
        6) Start another recursion of the process until all the data has been transferred.

    Args:
        usb_handle (USBHandle): The usb handle for the device.
        status_queue (src.common.data_structures.queue.Queue): Used to log errors and any updates during the data
            transfer.
        pos (int): Let :math:`D` be the data array being transferred from the device. Then the device will return the
            slice :math:`D[pos:pos + 60]` plus the next `pos` parameter in the first byte (assuming the size of `D` is
            less than :math:`pos + 60`, otherwise the device returns as much data as possible).
        data (list of float): The concatenated data list.
        final_pos (int): The special value for `pos` that means all data has been transferred.
        timeout (int): The max number of milliseconds to wait for USB requests.
        action_type (str): The type of this action i.e. 'START_EIS_DATA_TRANSFER'
        bmRequest (int): `bmVENDOR_REQUEST`.
        bRequest (int): `bINITIATE_EIS_DATA_TRANSFER`
        freq_id (int): The id of the frequency whose data is being transferred.

    Returns:
        dict: Contains the full array of data if the transfer was successful, the error and error args otherwise.

    """
    res = dict(type=action_type, error=None, args=None)

    try:
        if pos == final_pos or DEV:  #: Step 1
            res['data'] = data
            return res

        has_content, status = get_status(usb_handle)  #: Step 2

        if has_content == sERROR or status == sERROR:
            error = status if has_content else ERR_USB_OTHER
            res['error'] = error

            if error == ERR_USB_OTHER:
                res['args'] = ("transfer data from the device",)
            return res

        if has_content:
            status_queue.push((has_content, freq_id if has_content == sTRANS else status))

        b = bytearray()  #: Step 3
        b.append(freq_id)
        real_val = bytes(b[::-1])

        if pos > 255:
            n_bytes = usb_handle.dev.ctrl_transfer(bmRequest, bRequest, 255, pos - 255, real_val, timeout)
        else:
            n_bytes = usb_handle.dev.ctrl_transfer(bmRequest, bRequest, pos, 0, real_val, timeout)

        if n_bytes != len(real_val):
            res['error'] = ERR_USB_WRITE
            return res

        has_content, status = get_status(usb_handle)  #: Step 4

        if has_content == sERROR or status == sERROR:
            error = status if has_content else ERR_USB_OTHER
            res['error'] = error

            if error == ERR_USB_OTHER:
                res['args'] = ("transfer data from the device",)

            return res

        read_bytes = read(usb_handle, 64)  #: Step 5
        res = byte_utils.bytes_to_double(read_bytes)

        if has_content:
            status_queue.push((has_content, freq_id if has_content == sTRANS else status))

        return build_response(usb_handle, status_queue, int(res[0]), data + res[1:],  #: Step 6
                              final_pos, timeout, action_type, bmRequest, bRequest, freq_id)

    except usb.USBError:
        res['error'] = ERR_USB_OTHER
        res['args'] = ("transfer data from the device",)
        return res


def get_status(usb_handle):
    """Check the device's status code.

    Args:
        usb_handle (USBHandle): The usb handle for the device.

    Returns:
        (int, int): (None, STATUS) if the status code has no inner status, (STATUS, INNER_STATUS) otherwise.

    """
    try:
        status_arr = usb_handle.dev.ctrl_transfer(bmCONTROL_IN, bUPDATE_EIS, 0, 0, 6, 0)
        main_code = status_arr[0]

        if main_code == sERROR or main_code == sSIGN or main_code == sTRANS:
            has_content = main_code

            if main_code == sSIGN:
                data = status_arr[1:]
            else:
                data = status_arr[1]
        else:
            has_content = None

            if main_code == sDAV or main_code == sREADY or main_code == sBUSY:
                data = main_code
            else:
                data = sERROR

        return has_content, data

    except (usb.USBError, AttributeError) as e:
        return None, ERR_USB_OTHER


def check_connection():
    """Make sure device is still connected.

    Returns:
        dict: Contains the error and error args if anything went wrong, a dict with all keys set to None otherwise.

    """
    res = dict(type=ACTION_TYPES.CHECK_CONNECTION, args=None)

    try:
        dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
        err = None

        if dev is None:
            dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)

        if dev is None:
            err = ERR_USB_DEVICE_NOT_FOUND

        res['error'] = err

    except usb.USBError:
        res['args'] = ("assert device's connection with the computer",)
        res['error'] = ERR_USB_OTHER

    return res


def connect():
    """Attempt connection.

    This action is called when the device has been disconnected. It attempts to connect and generates the usb handle
    used by all the other actions if successful.

    Returns:
        dict: Contains an initialized `USBHandle` instance if the connection was successful, the error and error args
            otherwise.

    """
    res = dict(type=ACTION_TYPES.CONNECT, error=None, args=None)

    try:
        dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)  #: Try to find device twice
        err = None
        usb_handle = None

        if dev is None:
            dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)

        if dev is None:
            err = ERR_USB_DEVICE_NOT_FOUND
        else:
            try:
                usb_handle = USBHandle(dev)  #: Create USBHandle and init instance with the correct interface
                try:
                    prepare_kernel(usb_handle)
                except NotImplementedError:
                    print "%s: Not using OS X, but %s" % (datetime.datetime.now(), platform)
                dev.set_configuration()
                cfg = dev.get_active_configuration()
                interface_number = cfg[(0, 0)].bInterfaceNumber

                alternate_setting = usb.control.get_interface(dev, interface_number)

                intf = usb.util.find_descriptor(
                    cfg, bInterfaceNumber=interface_number,
                    bAlternateSetting=alternate_setting
                )

                #: Store a reference to the BULK IN endpoint
                ep_read = usb.util.find_descriptor(intf, custom_match=lambda e:
                                                   usb.util.endpoint_direction(e.bEndpointAddress) ==
                                                   usb.util.ENDPOINT_IN)

                if ep_read:
                    usb_handle.ep_read = ep_read
                else:
                    err = ERR_USB_ENDPOINTS

            except TypeError:
                err = ERR_USB_DEVICE_NOT_FOUND

        if err is not None:
            res['error'] = err
        else:
            status = clear_errors(usb_handle)

            if status['error'] is not None:
                res['error'] = status['error']
                res['args'] = status['args']
            else:
                res['data'] = usb_handle

    except usb.USBError:
        res['error'] = ERR_USB_OTHER
        res['args'] = ("connect to the device",)

    return res


def start_eis(usb_handle, freq_bytes, amp, amplitude_type, smps, n_pers):
    """Send an EIS request to the device.

    Builds a request with all the specified parameters e.g. start frequency, end frequency, amplitude, and starts
    EIS with that configuration.

    Args:
        usb_handle (USBHandle): The usb handle for the device.
        freq_bytes (str): The 24 bits that represent the target frequencies.
        amp (int): Amplitude
        amplitude_type (int): 0 for voltage, 1 for current
        smps (int): Number of samples
        n_pers (int): Number of periods

    Returns:
        dict: Contains a success message if the start EIS request was successful, the error and error args otherwise.

    """
    res = dict(type=ACTION_TYPES.START_EIS, error=None, args=None)

    try:
        factor = 30.0 - amplitude_type * 10
        amplitude = str(amplitude_type) + bin(int(amp / factor * (2 ** 15 - 1)))[2:].zfill(15)

        #: HARDCODED CURRENT RANGING, has no effect but removing it requires changing the microcontroller code
        current_ranging = "10"
        samples = bin(int(np.floor(np.log2(smps))))[2:].zfill(4)
        n_periods = bin(n_pers)[2:].zfill(4)

        wVal = freq_bytes + amplitude + samples + n_periods

        print "%s: Frequencies: %s %s" % (datetime.datetime.now(), freq_bytes, hex(int(freq_bytes, 2)).capitalize())
        print "%s: Amplitude: %s %s" % (datetime.datetime.now(), amplitude, hex(int(amplitude, 2)).capitalize())
        print "%s: Samples per period: %s %s" % (datetime.datetime.now(), samples, hex(int(samples, 2)).capitalize())
        print "%s: Number of periods: %s %s" % (datetime.datetime.now(), n_periods, hex(int(n_periods, 2)).capitalize())
        print "%s: Current ranging: %s %s" % (datetime.datetime.now(), current_ranging,
                                              hex(int(current_ranging, 2)).capitalize())
        print "%s: Request: %s (%i bits)" % (datetime.datetime.now(), wVal, len(wVal))

        b = bytearray()  #: Convert request body into a bytearray to be sent to the device.

        b.append(int(current_ranging, 2))

        v = int(wVal, 2)

        for i in range(6):
            b.append(v & 0xff)
            v >>= 8

        real_val = bytes(b[::-1])

        if usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID) is None:  #: Check connection
            res['error'] = ERR_USB_DEVICE_NOT_FOUND
        else:
            has_content, status = get_status(usb_handle)  #: Check status code

            if has_content or status == sERROR:
                error = status if has_content else ERR_USB_OTHER
                print "req error: %02X" % error
                res['error'] = error

                if not has_content:
                    res['args'] = ("check the device's status prior to starting EIS",)
            else:
                n_bytes = usb_handle.dev.ctrl_transfer(bmVENDOR_REQUEST, bINITIATE_EIS, 0, 0, real_val, 0)

                if n_bytes != len(real_val):
                    res['error'] = ERR_USB_WRITE
                else:
                    res['data'] = [("Successfully sent start request to device.", 0)]

    except usb.USBError:
        res['args'] = ("start EIS",)
        res['error'] = ERR_USB_OTHER

    return res


def poll_eis(usb_handle):
    """Status code check action.

    Calls `get_status` to get the current status code of the device and returns a `res` dict according to the results
    to be reduced.

    Args:
        usb_handle (USBHandle): The usb handle for the device.

    Returns:
        dict: Contains the status code and its args if the device sent no error; the error and error args otherwise.

    """
    res = dict(type=ACTION_TYPES.POLL_EIS, data=None, error=None, args=None)

    try:
        has_content, status_code = get_status(usb_handle)

        if has_content is not None:
            if has_content == sERROR:
                res['error'] = status_code
            elif has_content == sSIGN:
                res['data'] = has_content
                res['args'] = status_code
            else:
                res['args'] = ("assert device's status code",)
                res['error'] = ERR_USB_OTHER
        else:
            if status_code == sDAV or status_code == sBUSY or status_code == sREADY or status_code == sTRANS:
                res['data'] = status_code
            else:
                res['args'] = ("assert device's status code",)
                res['error'] = ERR_USB_OTHER

    except usb.USBError:
        res['args'] = ("determine the device's state",)
        res['error'] = ERR_USB_OTHER

    return res


def clear_errors(usb_handle):
    """Clear USB errors action.

    Clears any errors that might have remained from previous sessions and set the device's status code to `sREADY`.

    Args:
        usb_handle (USBHandle): The usb handle for the device.

    Returns:
        dict: A success message if no errors happened, the error and error args otherwise.

    """
    res = dict(type=ACTION_TYPES.CLEAR_USB_ERRORS, data=None, error=None, args=None)
    try:
        if usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID) is None:
            res['error'] = ERR_USB_DEVICE_NOT_FOUND
        else:
            n_bytes = usb_handle.dev.ctrl_transfer(bmVENDOR_REQUEST, bCLEAR_EIS_ERR, 0, 0, 'hello', 0)

            if n_bytes != 5:
                res['error'] = ERR_USB_WRITE
            else:
                res['data'] = 'Cleared USB errors!'

    except usb.USBError:
        res['args'] = ("clear the device's error register",)
        res['error'] = ERR_USB_OTHER

    return res


def start_eis_data_transfer(usb_handle, status_queue, freq_id):
    """Begin a data transfer.

    Begins the recursion of `build_response`, which handles the actual transfer.

    Args:
        usb_handle (USBHandle): The usb handle for the device.
        status_queue (src.common.data_structures.queue.Queue): Used to log errors and any updates during the data
            transfer.
        freq_id (int): The id of the frequency whose data is being transferred.

    Returns:
        dict: Contains the full array of data if the transfer was successful, the error and error args otherwise.

    """
    return build_response(usb_handle, status_queue, 0, [], -1, 0, ACTION_TYPES.START_EIS_DATA_TRANSFER,
                          bmVENDOR_REQUEST, bINITIATE_EIS_DATA_TRANSFER, freq_id)


def update_usb_status(status):
    """Simulate status code from device.

    Regardless of whatever's happening in the communication between the computer and the device, simulates the status
    code specified being sent from the device to the computer.

    Args:
        status (int): The status code to simulate

    Returns:
        dict: Contains the hardcoded status code plus all the other keys set to `None`.

    """
    return dict(type=ACTION_TYPES.UPDATE_USB_STATUS, error=None, args=None, data=status)
