import os
import fcntl
import array
import termios
import select


class SerialError(IOError):
    """Base class for Serial errors."""
    pass


class Serial(object):
    _DATABITS_TO_CFLAG = {
        5: termios.CS5, 6: termios.CS6, 7: termios.CS7, 8: termios.CS8
    }
    _CFLAG_TO_DATABITS = {v: k for k, v in _DATABITS_TO_CFLAG.items()}

    _BAUDRATE_TO_OSPEED = {
        50: termios.B50, 75: termios.B75, 110: termios.B110, 134: termios.B134,
        150: termios.B150, 200: termios.B200, 300: termios.B300,
        600: termios.B600, 1200: termios.B1200, 1800: termios.B1800,
        2400: termios.B2400, 4800: termios.B4800, 9600: termios.B9600,
        19200: termios.B19200, 38400: termios.B38400, 57600: termios.B57600,
        115200: termios.B115200, 230400: termios.B230400,
        # Linux baudrates bits missing in termios module included below
        460800: 0x1004, 500000: 0x1005, 576000: 0x1006,
        921600: 0x1007, 1000000: 0x1008, 1152000: 0x1009,
        1500000: 0x100A, 2000000: 0x100B, 2500000: 0x100C,
        3000000: 0x100D, 3500000: 0x100E, 4000000: 0x100F,
    }
    _OSPEED_TO_BAUDRATE = {v: k for k, v in _BAUDRATE_TO_OSPEED.items()}

    def __init__(self, devpath, baudrate, databits=8, parity="none", stopbits=1, xonxoff=False, rtscts=False):
        """Instantiate a Serial object and open the tty device at the specified
        path with the specified baudrate, and the defaults of 8 data bits, no
        parity, 1 stop bit, no software flow control (xonxoff), and no hardware
        flow control (rtscts).

        Args:
            devpath (str): tty device path.
            baudrate (int): baudrate.
            databits (int): data bits, can be 5, 6, 7, 8.
            parity (str): parity, can be "none", "even", "odd".
            stopbits (int): stop bits, can be 1 or 2.
            xonxoff (bool): software flow control.
            rtscts (bool): hardware flow control.

        Returns:
            Serial: Serial object.

        Raises:
            SerialError: if an I/O or OS error occurs.
            TypeError: if `devpath`, `baudrate`, `databits`, `parity`, `stopbits`, `xonxoff`, or `rtscts` types are invalid.
            ValueError: if `baudrate`, `databits`, `parity`, or `stopbits` values are invalid.

        """
        self._fd = None
        self._devpath = None
        self._open(devpath, baudrate, databits, parity, stopbits, xonxoff, rtscts)

    def __del__(self):
        self.close()

    def __enter__(self):
        return self

    def __exit__(self, t, value, traceback):
        self.close()

    def _open(self, devpath, baudrate, databits, parity, stopbits, xonxoff, rtscts):
        if not isinstance(devpath, str):
            raise TypeError("Invalid devpath type, should be string.")
        elif not isinstance(baudrate, int):
            raise TypeError("Invalid baud rate type, should be integer.")
        elif not isinstance(databits, int):
            raise TypeError("Invalid data bits type, should be integer.")
        elif not isinstance(parity, str):
            raise TypeError("Invalid parity type, should be string.")
        elif not isinstance(stopbits, int):
            raise TypeError("Invalid stop bits type, should be integer.")
        elif not isinstance(xonxoff, bool):
            raise TypeError("Invalid xonxoff type, should be boolean.")
        elif not isinstance(rtscts, bool):
            raise TypeError("Invalid rtscts type, should be boolean.")

        if baudrate not in Serial._BAUDRATE_TO_OSPEED:
            raise ValueError("Unknown baud rate: {:d}".format(baudrate))
        elif databits not in [5, 6, 7, 8]:
            raise ValueError("Invalid data bits, can be 5, 6, 7, 8.")
        elif parity.lower() not in ["none", "even", "odd"]:
            raise ValueError("Invalid parity, can be: \"none\", \"even\", \"odd\".")
        elif stopbits not in [1, 2]:
            raise ValueError("Invalid stop bits, can be 1, 2.")

        # Open tty
        try:
            self._fd = os.open(devpath, os.O_RDWR | os.O_NOCTTY)
        except OSError as e:
            raise SerialError(e.errno, "Opening serial port: " + e.strerror)

        self._devpath = devpath

        parity = parity.lower()

        (iflag, oflag, cflag, lflag, ispeed, ospeed, cc) = (0, 0, 0, 0, 0, 0, [0] * 32)

        ###
        # iflag

        # Ignore break characters
        iflag = termios.IGNBRK

        # Setup parity
        if parity != "none":
            iflag |= (termios.INPCK | termios.ISTRIP)

        # Setup xonxoff
        if xonxoff:
            iflag |= (termios.IXON | termios.IXOFF)

        #######
        # oflag
        oflag = 0

        #######
        # lflag
        lflag = 0

        #######
        # cflag

        # Enable receiver, ignore modem control lines
        cflag = (termios.CREAD | termios.CLOCAL)

        # Setup data bits
        cflag |= Serial._DATABITS_TO_CFLAG[databits]

        # Setup parity
        if parity == "even":
            cflag |= termios.PARENB
        elif parity == "odd":
            cflag |= (termios.PARENB | termios.PARODD)

        # Setup stop bits
        if stopbits == 2:
            cflag |= termios.CSTOPB

        # Setup rtscts
        if rtscts:
            cflag |= termios.CRTSCTS

        # Setup baud rate
        cflag |= Serial._BAUDRATE_TO_OSPEED[baudrate]

        ########
        # ispeed
        ispeed = Serial._BAUDRATE_TO_OSPEED[baudrate]

        ########
        # ospeed
        ospeed = Serial._BAUDRATE_TO_OSPEED[baudrate]

        # Set tty attributes
        try:
            termios.tcsetattr(self._fd, termios.TCSANOW, [iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
        except termios.error as e:
            raise SerialError(e.errno, "Setting serial port attributes: " + e.strerror)

    # Methods

    def read(self, length, timeout=None):
        """Read up to `length` number of bytes from the serial port with an
        optional timeout.

        `timeout` can be positive for a timeout in seconds, 0 for a
        non-blocking read, or negative or None for a blocking read that will
        block until `length` number of bytes are read. Default is a blocking
        read.

        For a non-blocking or timeout-bound read, read() may return data whose
        length is less than or equal to the requested length.

        Args:
            length (int): length in bytes.
            timeout (int, float, None): timeout duration in seconds.

        Returns:
            bytes: data read.

        Raises:
            SerialError: if an I/O or OS error occurs.

        """
        data = b""

        # Read length bytes if timeout is None

        # Read up to length bytes if timeout is not None
        while True:
            if timeout is not None:
                # Select
                (rlist, _, _) = select.select([self._fd], [], [], timeout)
                # If timeout
                if self._fd not in rlist:
                    break

            try:
                data += os.read(self._fd, length - len(data))
            except OSError as e:
                raise SerialError(e.errno, "Reading serial port: " + e.strerror)

            if len(data) == length:
                break

        return data

    def write(self, data):
        """Write `data` to the serial port and return the number of bytes
        written.

        Args:
            data (bytes, bytearray, list): a byte array or list of 8-bit integers to write.

        Returns:
            int: number of bytes written.

        Raises:
            SerialError: if an I/O or OS error occurs.
            TypeError: if `data` type is invalid.
            ValueError: if data is not valid bytes.

        """
        if not isinstance(data, (bytes, bytearray, list)):
            raise TypeError("Invalid data type, should be bytes, bytearray, or list.")

        if isinstance(data, list):
            data = bytearray(data)

        try:
            return os.write(self._fd, data)
        except OSError as e:
            raise SerialError(e.errno, "Writing serial port: " + e.strerror)

    def poll(self, timeout=None):
        """Poll for data available for reading from the serial port.

        `timeout` can be positive for a timeout in seconds, 0 for a
        non-blocking poll, or negative or None for a blocking poll. Default is
        a blocking poll.

        Args:
            timeout (int, float, None): timeout duration in seconds.

        Returns:
            bool: ``True`` if data is available for reading from the serial port, ``False`` if not.

        """
        p = select.poll()
        p.register(self._fd, select.POLLIN | select.POLLPRI)
        events = p.poll(int(timeout * 1000))

        if len(events) > 0:
            return True

        return False

    def flush(self):
        """Flush the write buffer of the serial port, blocking until all bytes
        are written.

        Raises:
            SerialError: if an I/O or OS error occurs.

        """
        try:
            termios.tcdrain(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Flushing serial port: " + e.strerror)

    def input_waiting(self):
        """Query the number of bytes waiting to be read from the serial port.

        Returns:
            int: number of bytes waiting to be read.

        Raises:
            SerialError: if an I/O or OS error occurs.

        """
        # Get input waiting
        buf = array.array('I', [0])
        try:
            fcntl.ioctl(self._fd, termios.TIOCINQ, buf, True)
        except (OSError, IOError) as e:
            raise SerialError(e.errno, "Querying input waiting: " + e.strerror)

        return buf[0]

    def output_waiting(self):
        """Query the number of bytes waiting to be written to the serial port.

        Returns:
            int: number of bytes waiting to be written.

        Raises:
            SerialError: if an I/O or OS error occurs.

        """
        # Get input waiting
        buf = array.array('I', [0])
        try:
            fcntl.ioctl(self._fd, termios.TIOCOUTQ, buf, True)
        except (OSError, IOError) as e:
            raise SerialError(e.errno, "Querying output waiting: " + e.strerror)

        return buf[0]

    def close(self):
        """Close the tty device.

        Raises:
            SerialError: if an I/O or OS error occurs.

        """
        if self._fd is None:
            return

        try:
            os.close(self._fd)
        except OSError as e:
            raise SerialError(e.errno, "Closing serial port: " + e.strerror)

        self._fd = None

    # Immutable properties

    @property
    def fd(self):
        """Get the file descriptor of the underlying tty device.

        :type: int
        """
        return self._fd

    @property
    def devpath(self):
        """Get the device path of the underlying tty device.

        :type: str
        """
        return self._devpath

    # Mutable properties

    def _get_baudrate(self):
        # Get tty attributes
        try:
            (_, _, _, _, _, ospeed, _) = termios.tcgetattr(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Getting serial port attributes: " + e.strerror)

        if ospeed not in Serial._OSPEED_TO_BAUDRATE:
            raise SerialError(None, "Unknown baud rate: ospeed 0x{:x}".format(ospeed))

        return Serial._OSPEED_TO_BAUDRATE[ospeed]

    def _set_baudrate(self, baudrate):
        if not isinstance(baudrate, int):
            raise TypeError("Invalid baud rate type, should be integer.")

        if baudrate not in Serial._BAUDRATE_TO_OSPEED:
            raise ValueError("Unknown baud rate: {:d}".format(baudrate))

        # Get tty attributes
        try:
            (iflag, oflag, cflag, lflag, ispeed, ospeed, cc) = termios.tcgetattr(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Getting serial port attributes: " + e.strerror)

        # Modify tty attributes
        cflag &= ~(termios.CBAUD | termios.CBAUDEX)
        cflag |= Serial._BAUDRATE_TO_OSPEED[baudrate]
        ispeed = Serial._BAUDRATE_TO_OSPEED[baudrate]
        ospeed = Serial._BAUDRATE_TO_OSPEED[baudrate]

        # Set tty attributes
        try:
            termios.tcsetattr(self._fd, termios.TCSANOW, [iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
        except termios.error as e:
            raise SerialError(e.errno, "Setting serial port attributes: " + e.strerror)

    baudrate = property(_get_baudrate, _set_baudrate)
    """Get or set the baudrate.

    Raises:
        SerialError: if an I/O or OS error occurs.
        TypeError: if `baudrate` type is not int.
        ValueError: if `baudrate` value is not supported.

    :type: int
    """

    def _get_databits(self):
        # Get tty attributes
        try:
            (_, _, cflag, _, _, _, _) = termios.tcgetattr(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Getting serial port attributes: " + e.strerror)

        cs = cflag & termios.CSIZE

        if cs not in Serial._CFLAG_TO_DATABITS:
            raise SerialError(None, "Unknown data bits setting: csize 0x{:x}".format(cs))

        return Serial._CFLAG_TO_DATABITS[cs]

    def _set_databits(self, databits):
        if not isinstance(databits, int):
            raise TypeError("Invalid data bits type, should be integer.")
        elif databits not in [5, 6, 7, 8]:
            raise ValueError("Invalid data bits, can be 5, 6, 7, 8.")

        # Get tty attributes
        try:
            (iflag, oflag, cflag, lflag, ispeed, ospeed, cc) = termios.tcgetattr(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Getting serial port attributes: " + e.strerror)

        # Modify tty attributes
        cflag &= ~termios.CSIZE
        cflag |= Serial._DATABITS_TO_CFLAG[databits]

        # Set tty attributes
        try:
            termios.tcsetattr(self._fd, termios.TCSANOW, [iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
        except termios.error as e:
            raise SerialError(e.errno, "Setting serial port attributes: " + e.strerror)

    databits = property(_get_databits, _set_databits)
    """Get or set the data bits. Can be 5, 6, 7, 8.

    Raises:
        SerialError: if an I/O or OS error occurs.
        TypeError: if `databits` type is not int.
        ValueError: if `databits` value is invalid.

    :type: int
    """

    def _get_parity(self):
        # Get tty attributes
        try:
            (_, _, cflag, _, _, _, _) = termios.tcgetattr(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Getting serial port attributes: " + e.strerror)

        if (cflag & termios.PARENB) == 0:
            return "none"
        elif (cflag & termios.PARODD) == 0:
            return "even"
        else:
            return "odd"

    def _set_parity(self, parity):
        if not isinstance(parity, str):
            raise TypeError("Invalid parity type, should be string.")
        elif parity.lower() not in ["none", "even", "odd"]:
            raise ValueError("Invalid parity, can be: \"none\", \"even\", \"odd\".")

        parity = parity.lower()

        # Get tty attributes
        try:
            (iflag, oflag, cflag, lflag, ispeed, ospeed, cc) = termios.tcgetattr(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Getting serial port attributes: " + e.strerror)

        # Modify tty attributes
        iflag &= ~(termios.INPCK | termios.ISTRIP)
        cflag &= ~(termios.PARENB | termios.PARODD)
        if parity != "none":
            iflag |= (termios.INPCK | termios.ISTRIP)
            cflag |= termios.PARENB
        if parity == "odd":
            cflag |= termios.PARODD

        # Set tty attributes
        try:
            termios.tcsetattr(self._fd, termios.TCSANOW, [iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
        except termios.error as e:
            raise SerialError(e.errno, "Setting serial port attributes: " + e.strerror)

    parity = property(_get_parity, _set_parity)
    """Get or set the parity. Can be "none", "even", "odd".

    Raises:
        SerialError: if an I/O or OS error occurs.
        TypeError: if `parity` type is not str.
        ValueError: if `parity` value is invalid.

    :type: str
    """

    def _get_stopbits(self):
        # Get tty attributes
        try:
            (_, _, cflag, _, _, _, _) = termios.tcgetattr(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Getting serial port attributes: " + e.strerror)

        if (cflag & termios.CSTOPB) != 0:
            return 2
        else:
            return 1

    def _set_stopbits(self, stopbits):
        if not isinstance(stopbits, int):
            raise TypeError("Invalid stop bits type, should be integer.")
        elif stopbits not in [1, 2]:
            raise ValueError("Invalid stop bits, can be 1, 2.")

        # Get tty attributes
        try:
            (iflag, oflag, cflag, lflag, ispeed, ospeed, cc) = termios.tcgetattr(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Getting serial port attributes: " + e.strerror)

        # Modify tty attributes
        cflag &= ~termios.CSTOPB
        if stopbits == 2:
            cflag |= termios.CSTOPB

        # Set tty attributes
        try:
            termios.tcsetattr(self._fd, termios.TCSANOW, [iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
        except termios.error as e:
            raise SerialError(e.errno, "Setting serial port attributes: " + e.strerror)

    stopbits = property(_get_stopbits, _set_stopbits)
    """Get or set the stop bits. Can be 1 or 2.

    Raises:
        SerialError: if an I/O or OS error occurs.
        TypeError: if `stopbits` type is not int.
        ValueError: if `stopbits` value is invalid.

    :type: int
    """

    def _get_xonxoff(self):
        # Get tty attributes
        try:
            (iflag, _, _, _, _, _, _) = termios.tcgetattr(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Getting serial port attributes: " + e.strerror)

        if (iflag & (termios.IXON | termios.IXOFF)) != 0:
            return True
        else:
            return False

    def _set_xonxoff(self, enabled):
        if not isinstance(enabled, bool):
            raise TypeError("Invalid enabled type, should be boolean.")

        # Get tty attributes
        try:
            (iflag, oflag, cflag, lflag, ispeed, ospeed, cc) = termios.tcgetattr(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Getting serial port attributes: " + e.strerror)

        # Modify tty attributes
        iflag &= ~(termios.IXON | termios.IXOFF | termios.IXANY)
        if enabled:
            iflag |= (termios.IXON | termios.IXOFF)

        # Set tty attributes
        try:
            termios.tcsetattr(self._fd, termios.TCSANOW, [iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
        except termios.error as e:
            raise SerialError(e.errno, "Setting serial port attributes: " + e.strerror)

    xonxoff = property(_get_xonxoff, _set_xonxoff)
    """Get or set software flow control.

    Raises:
        SerialError: if an I/O or OS error occurs.
        TypeError: if `xonxoff` type is not bool.

    :type: bool
    """

    def _get_rtscts(self):
        # Get tty attributes
        try:
            (_, _, cflag, _, _, _, _) = termios.tcgetattr(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Getting serial port attributes: " + e.strerror)

        if (cflag & termios.CRTSCTS) != 0:
            return True
        else:
            return False

    def _set_rtscts(self, enabled):
        if not isinstance(enabled, bool):
            raise TypeError("Invalid enabled type, should be boolean.")

        # Get tty attributes
        try:
            (iflag, oflag, cflag, lflag, ispeed, ospeed, cc) = termios.tcgetattr(self._fd)
        except termios.error as e:
            raise SerialError(e.errno, "Getting serial port attributes: " + e.strerror)

        # Modify tty attributes
        cflag = ~termios.CRTSCTS
        if enabled:
            cflag |= termios.CRTSCTS

        # Set tty attributes
        try:
            termios.tcsetattr(self._fd, termios.TCSANOW, [iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
        except termios.error as e:
            raise SerialError(e.errno, "Setting serial port attributes: " + e.strerror)

    rtscts = property(_get_rtscts, _set_rtscts)
    """Get or set hardware flow control.

    Raises:
        SerialError: if an I/O or OS error occurs.
        TypeError: if `rtscts` type is not bool.

    :type: bool
    """

    # String representation

    def __str__(self):
        return "Serial (device={:s}, fd={:d}, baudrate={:d}, databits={:d}, parity={:s}, stopbits={:d}, xonxoff={:s}, rtscts={:s})" \
            .format(self.devpath, self.fd, self.baudrate, self.databits, self.parity, self.stopbits, str(self.xonxoff), str(self.rtscts))
