import time
from typing import Any, Union, List, Optional, Callable
from ctypes import c_void_p
import serial as pyserial
from serial.tools.list_ports import comports
from serial.rs485 import RS485Settings
import threading
import asyncio
import janus
import queue
import atexit
from ftd2xx import FTD2XX
import ftd2xx


Device2 = ftd2xx.FTD2XX
SerialDeviceType = Union[str, int, 'Device']
NumberType = Union[float, int]
DeviceWriteHandler = Callable[[int, 'Device'], None]


class SerialException(Exception):
    pass


class SerialInvalidDeviceException(SerialException):
    pass


class SerialTimeoutException(SerialException):
    pass


class SerialReadTimeoutException(SerialTimeoutException):
    pass


class SerialWriteTimeoutException(SerialTimeoutException):
    pass


class SerialDeviceInfo:
    def __init__(self,
                 index: Optional[int]=None,
                 flags: Optional[int]=None,
                 type: Optional[int]=None,
                 id: Optional[int]=None,
                 location: Optional[int]=None,
                 serial: Union[bytes, str]='',
                 description: Optional[Union[bytes, str]]=None,
                 handle: Optional[c_void_p]=None,
                 virtual: bool = False,) -> None:
        self.index = index
        self.flags = flags
        self.type = type
        self.id = id
        self.location = location
        self.handle = handle
        self.serial = serial.decode() if isinstance(serial, bytes) else serial
        self.description = description.decode() if isinstance(description, bytes) else description
        self.virtual = virtual


class Device:
    def __init__(self):
        self.write_handlers: List[DeviceWriteHandler] = []
        self.read_timeout = 1.0
        self.write_timeout = 1.0

    def add_write_handler(self, handler: DeviceWriteHandler):
        self.write_handlers.append(handler)

    def close(self):
        pass

    def clear(self):
        pass

    def purge(self):
        self.clear()

    def reset(self):
        self.clear()

    def set_baud_rate(self, baudrate: int):
        pass

    def set_parameters(self, data_bits: int, stop_bits: int, parity_bits: int):
        pass

    def set_timeouts(self, read_timeout: float, write_timeout: float):
        self.read_timeout = read_timeout / 1000
        self.write_timeout = write_timeout / 1000

    def get_input_size(self) -> int:
        return 0

    def get_output_size(self) -> int:
        return 0

    def write(self, data: bytes):
        for handler in self.write_handlers:
            handler(data, self)

    def read(self, num_bytes: int, raw: bool=True) -> bytes:
        return bytes()

    def read_all(self) -> bytes:
        return self.read(self.get_input_size())

    def enable_write_log(self, enable: bool=True):
        self.write_log_enable = enable

    def get_write_log(self):
        return self.write_log

    def clear_write_log(self):
        self.write_log = bytes()


class FtdiDevice(Device):
    def __init__(self, ftdi: FTD2XX):
        Device.__init__(self)
        self.ftdi = ftdi

    def close(self):
        self.ftdi.close()

    def clear(self):
        self.ftdi.purge()

    def reset(self):
        self.ftdi.resetDevice()
        self.ftdi.resetPort()

    def set_baud_rate(self, baudrate: int):
        self.ftdi.setBaudRate(baudrate)

    def set_timeouts(self, read_timeout: float, write_timeout: float):
        self.ftdi.setTimeouts(read_timeout, write_timeout)

    def set_parameters(self, data_bits: int, stop_bits: int, parity_bits: int):
        self.ftdi.setDataCharacteristics(data_bits, stop_bits, parity_bits)

    def get_input_size(self) -> int:
        return self.ftdi.getStatus()[0]

    def get_output_size(self) -> int:
        return self.ftdi.getStatus()[1]

    def write(self, data: bytes):
        super().write(data)
        self.ftdi.write(data)

    def read(self, num_bytes: int, raw: bool = True) -> bytes:
        return self.ftdi.read(num_bytes, raw)


class PySerialDevice(Device):
    PARITIES = {
        0: pyserial.PARITY_NONE,
        1: pyserial.PARITY_ODD,
        2: pyserial.PARITY_EVEN
    }

    STOP_BITS = {
        0: pyserial.STOPBITS_ONE,
        2: pyserial.STOPBITS_TWO
    }

    def __init__(self, serial: pyserial.Serial):
        Device.__init__(self)
        self.serial = serial
        self.serial.rs485_mode = RS485Settings()

    def close(self):
        self.serial.close()

    def clear(self):
        self.serial.flush()

    def reset(self):
        pass

    def set_baud_rate(self, baudrate: int):
        self.serial.baudrate = baudrate

    def set_timeouts(self, read_timeout: float, write_timeout: float):
        self.serial.timeout = read_timeout / 1000
        self.serial.write_timeout = write_timeout / 1000

    def set_parameters(self, data_bits: int, stop_bits: int, parity_bits: int):
        self.serial.bytesize = data_bits
        self.serial.stopbits = self.STOP_BITS[stop_bits]
        self.serial.parity = self.PARITIES[parity_bits]

    def get_input_size(self) -> int:
        return self.serial.in_waiting

    def get_output_size(self) -> int:
        return self.serial.out_waiting

    def write(self, data: bytes):
        super().write(data)
        self.serial.write(data)

    def read(self, num_bytes: int, raw: bool = True) -> bytes:
        return self.serial.read(num_bytes)


class Serial:
    # FT_STATUS
    FT_OK = 0

    # FT_Purge
    FT_PURGE_RX = 1
    FT_PURGE_TX = 2

    PARITY_NONE = 0
    PARITY_ODD = 1
    PARITY_EVEN = 2

    STOP_BITS_1 = 0
    STOP_BITS_2 = 2

    DATA_BITS_7 = 7
    DATA_BITS_8 = 8

    @staticmethod
    def list_devices() -> List[SerialDeviceInfo]:
        devices = ftd2xx.listDevices()

        if devices is None:
            return []

        num_devices = len(devices)
        return [SerialDeviceInfo(**ftd2xx.getDeviceInfoDetail(i)) for i in range(0, num_devices)]

    @staticmethod
    def list_device_ports() -> List[str]:
        return [info.device for info in comports()]

    @classmethod
    def list_device_serials(cls) -> List[str]:
        return [device.serial for device in cls.list_devices()]

    def __init__(self,
                 device: Optional[SerialDeviceType]=None,
                 device_serial: Optional[str]=None,
                 device_number: Optional[int]=None,
                 device_port: Optional[str]=None,
                 baudrate: int=115200,
                 parity: int=PARITY_NONE,
                 stop_bits: int=STOP_BITS_1,
                 data_bits: int=DATA_BITS_8,
                 read_timeout: NumberType=5,
                 write_timeout: NumberType=5,
                 connect_timeout: Optional[NumberType]=30,
                 connect_retry: bool=True,
                 connect_settle_time: NumberType=3,
                 connect: bool=True) -> None:

        self.device = device
        self.device_serial = device_serial
        self.device_number = device_number
        self.device_port = device_port

        # check to see if we were passed something other then a Device as the first parameter
        if self.device is not None and not isinstance(self.device, Device):
            if isinstance(self.device, int) or isinstance(self.device, float):
                self.device_serial = self.device
            elif isinstance(self.device, str):
                # if the "device" string starts with 'COM' or a '/' assume it is a pyserial device
                if self.device.lower().startswith(('com', '/')):
                    self.device_port = self.device
                else:
                    self.device_serial = self.device
            elif isinstance(self.device, pyserial.Serial):
                self.device = PySerialDevice(self.device)
            elif isinstance(self.device, Serial):
                self.device = self.device.device
            else:
                raise SerialInvalidDeviceException(f'Invalid serial device: {self.device}')

            self.device = None

        self.baudrate = baudrate
        self.data_bits = data_bits
        self.parity = parity
        self.stop_bits = stop_bits
        self.read_timeout_value = read_timeout
        self.write_timeout_value = write_timeout
        self.connect_timeout = connect_timeout
        self.connect_retry = connect_retry
        self.connect_settle_time = connect_settle_time
        self.input_buffer = b''
        self.output_buffer = b''
        self.connected = False

        if connect:
            self.connect()

        atexit.register(self.disconnect)

    def open_device(self):
        if self.device is not None:
            return
        elif self.device_serial is not None:
            self.device = FtdiDevice(ftd2xx.openEx(self.device_serial.encode()))
        elif self.device_number is not None:
            self.device = FtdiDevice(ftd2xx.open(self.device_number))
        elif self.device_port is not None:
            self.device = PySerialDevice(pyserial.Serial(self.device_port))
        else:
            self.device = FtdiDevice(ftd2xx.open())

    def connect(self):
        if self.connected:
            return

        first = True
        start_time = time.time()
        while first or (self.connect_retry and self.device is None):
            try:
                self.open_device()
                # if we weren't able to connect right way, we'll have to wait for the device to settle
                if not first:
                    # wait for the device to settle down
                    time.sleep(self.connect_settle_time)
                    # purge the read buffer to get rid of any bad data accumulated during connection
                    self.device.clear()
                    self.init_device()
            except ftd2xx.DeviceError:
                time.sleep(0.5)

            connect_time = time.time() - start_time

            if self.device is None and connect_time >= self.connect_timeout:
                raise SerialTimeoutException('Timeout while connecting to device')

            first = False

        #time.sleep(0.5)
        self.init_device()
        self.connected = True

    def disconnect(self):
        if not self.connected:
            return

        self.device.close()
        self.connected = False

    def init_device(self):
        self.device.reset()
        self.device.set_baud_rate(self.baudrate)
        self.device.set_parameters(self.data_bits, self.stop_bits, self.parity)
        self.update_timeouts()

    def set_parameters(self, baudrate: Optional[int]=None, parity: Optional[int]=None, stop_bits: Optional[int]=None, data_bits: Optional[int]=None):
        self.baudrate = baudrate if baudrate is not None else self.baudrate
        self.parity = parity if parity is not None else self.parity
        self.stop_bits = stop_bits if stop_bits is not None else self.stop_bits
        self.data_bits = data_bits if data_bits is not None else self.data_bits
        self.init_device()

    def update_timeouts(self):
        self.device.set_timeouts(int(self.read_timeout_value * 1000), int(self.write_timeout_value * 1000))

    @property
    def info(self) -> SerialDeviceInfo:
        if self.device is None or not self.connected:
            raise SerialException('Cannot get device info, device is not connected')

        if isinstance(self.device, FtdiDevice):
            return SerialDeviceInfo(**self.device.ftdi.getDeviceInfo())

        return SerialDeviceInfo()

    @property
    def serial_number(self)-> str:
        if self.device is None or not self.connected:
            raise SerialException('Cannot get device serial, device is not connected')

        return self.info.serial

    @property
    def in_waiting(self) -> int:
        if self.device is None or not self.connected:
            return 0

        return self.device.get_input_size() + len(self.input_buffer)

    @property
    def out_waiting(self) -> int:
        if self.device is None or not self.connected:
            return 0

        return self.device.get_output_size()

    @property
    def read_timeout(self):
        return self.read_timeout_value

    @read_timeout.setter
    def read_timeout(self, value):
        self.read_timeout_value = value
        self.update_timeouts()

    @property
    def write_timeout(self):
        return self.write_timeout_value

    @write_timeout.setter
    def write_timeout(self, value):
        self.write_timeout_value = value
        self.update_timeouts()

    def read(self, num_bytes: int=None, timeout: Optional[NumberType]=None) -> bytes:
        if self.device is None or not self.connected:
            raise SerialException('Cannot read, device is not connected')

        if num_bytes is None:
            num_bytes = self.in_waiting

        if num_bytes == 0:
            return b''

        if timeout is not None:
            old_timeout = self.read_timeout
            self.read_timeout = timeout

        serial_data = self.device.read(num_bytes - len(self.input_buffer))

        if timeout is not None:
            self.read_timeout = old_timeout

        if serial_data == b'' and num_bytes != len(self.input_buffer):
            raise SerialReadTimeoutException('Read timeout')

        data = self.input_buffer + serial_data
        self.input_buffer = b''

        return data

    def read_line(self, line_ending: bytes=b'\r', timeout: Optional[NumberType]=None) -> bytes:
        if self.device is None or not self.connected:
            raise SerialException('Cannot read, device is not connected')

        line = b''
        ending_length = len(line_ending)
        while True:
            num_bytes = self.in_waiting
            # always read at least len(line_ending) bytes
            bytes_to_read = num_bytes if num_bytes != 0 else 1
            data = self.read(bytes_to_read, timeout=timeout)
            line += data
            try:
                ending_index = line.index(line_ending)
                [line_data, remaining] = [line[:ending_index], line[ending_index + ending_length:]]
                self.input_buffer += remaining
                return line_data
            except ValueError:
                pass

    def write(self, data: Union[bytes, str], timeout: Optional[NumberType]=None) -> int:
        if self.device is None or not self.connected:
            raise SerialException('Cannot write, device is not connected')

        if isinstance(data, str):
            data = data.encode()

        if timeout is not None:
            old_timeout = self.write_timeout
            self.write_timeout = timeout

        num_bytes = self.device.write(data)

        if timeout is not None:
            self.write_timeout = old_timeout

        return num_bytes

    def request(self, data: bytes, timeout: Optional[NumberType]=None, line_ending: bytes=b'\r'):
        self.write(data, timeout=timeout)
        return self.read_line(timeout=timeout, line_ending=line_ending)

    def flush(self):
        self.device.purge()

    def reset_input_buffer(self):
        self.device.purge(self.FT_PURGE_RX)

    def reset_output_buffer(self):
        self.device.purge(self.FT_PURGE_TX)

