"""POSIX serial port implementation."""

from __future__ import annotations

import asyncio
import errno
import fcntl
import logging
import os
import select
import sys
import termios
import time
from typing import Any, NamedTuple, cast

if sys.version_info >= (3, 11):
    from asyncio import timeout as asyncio_timeout
else:
    from async_timeout import timeout as asyncio_timeout

from typing_extensions import Buffer

from ..common import (
    BaseSerial,
    ModemPins,
    Parity,
    PinState,
    StopBits,
    UnsupportedSetting,
    register_uri_handler,
)
from ..descriptor_transport import DescriptorTransport

LOGGER = logging.getLogger(__name__)

FLUSH_TIMEOUT = 10.0

# Reportedly, some drivers benefit from delaying between the `open` syscall and using
# `TCIOFLUSH`. Otherwise, the flush operation does not work reliably and stale data may
# be read.
AFTER_OPEN_DELAY = 0.01

MODEM_BIT_MAPPING = {
    "le": termios.TIOCM_LE,
    "dtr": termios.TIOCM_DTR,
    "rts": termios.TIOCM_RTS,
    "st": termios.TIOCM_ST,
    "sr": termios.TIOCM_SR,
    "cts": termios.TIOCM_CTS,
    "car": termios.TIOCM_CAR,
    "rng": termios.TIOCM_RNG,
    "dsr": termios.TIOCM_DSR,
}
assert MODEM_BIT_MAPPING.keys() == ModemPins.__annotations__.keys()


class TcsetattrFlags(NamedTuple):
    """Flags for `termios.tcsetattr`."""

    iflag: int
    oflag: int
    cflag: int
    lflag: int
    ispeed: int
    ospeed: int
    cc_vmin: int
    cc_vtime: int


def modem_pins_mask_of_value(modem_pins: ModemPins, mask: PinState) -> int:
    """Get modem bit mask for bits matching the specified value."""
    result = 0x00000000

    for name, bit in MODEM_BIT_MAPPING.items():
        value = getattr(modem_pins, name)

        if value is mask:
            result |= bit

    return result


def modem_pins_as_int(modem_pins: ModemPins) -> int:
    """Convert modem pins to integer."""
    result = 0x00000000

    for name, bit in MODEM_BIT_MAPPING.items():
        result |= bit if getattr(modem_pins, name) else 0x00000000

    return result


class PosixSerial(BaseSerial):
    """POSIX serial port implementation."""

    def __init__(
        self,
        *args: Any,
        fileno: int | None = None,
        inter_byte_timeout: float = 0.01,
        min_read_size: int = 1,
        **kwargs: Any,
    ) -> None:
        """Initialize POSIX serial port."""
        super().__init__(*args, **kwargs)
        self._fileno: int | None = fileno
        self._inter_byte_timeout = inter_byte_timeout
        self._min_read_size = min_read_size

    def _open(self) -> None:
        """Open the serial port."""
        LOGGER.debug("Opening serial port %r", self._path)

        if self._fileno is not None:
            raise ValueError("Serial port is already open")

        assert self._path is not None
        self._fileno = os.open(self._path, os.O_RDWR | os.O_NOCTTY)
        self._auto_close = True

        if self._exclusive:
            self._lock()

        time.sleep(AFTER_OPEN_DELAY)

    @property
    def is_open(self) -> bool:
        """Check if the serial port is open."""
        return self._fileno is not None

    def _lock(self) -> None:
        """Lock the serial port for exclusive access."""
        LOGGER.debug("Locking serial port %r", self._path)

        assert self._fileno is not None

        try:
            fcntl.flock(self._fileno, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError as exc:
            raise OSError(
                errno.EBUSY,
                f"Serial port {self._path!r} is already locked by another process",
            ) from exc

    def _build_parity_flags(self) -> int:
        if self._parity == Parity.NONE:
            return 0
        elif self._parity == Parity.EVEN:
            return termios.PARENB
        elif self._parity == Parity.ODD:
            return termios.PARENB | termios.PARODD
        else:
            raise UnsupportedSetting(f"Unsupported parity {self._parity}")

    def _build_character_size_flags(self) -> int:
        if self._byte_size == 5:
            return termios.CS5
        elif self._byte_size == 6:
            return termios.CS6
        elif self._byte_size == 7:
            return termios.CS7
        elif self._byte_size == 8:
            return termios.CS8
        else:
            raise UnsupportedSetting(
                f"Unsupported byte size {self._byte_size}, must be 5, 6, 7, or 8"
            )

    def _build_stopbits_flags(self) -> int:
        if self._stopbits == StopBits.ONE:
            return 0
        elif self._stopbits == StopBits.TWO:
            return termios.CSTOPB
        elif self._stopbits == StopBits.ONE_POINT_FIVE:
            raise UnsupportedSetting("1.5 stop bits not supported on POSIX")
        else:
            raise UnsupportedSetting(f"Unsupported stop bits {self._stopbits}")

    def _build_flow_control_flags(self) -> tuple[int, int]:
        iflag = 0x00000000
        cflag = 0x00000000

        if self._xonxoff:
            iflag |= termios.IXON | termios.IXOFF | termios.IXANY

        if self._rtscts:
            raise UnsupportedSetting(
                "RTS/CTS hardware flow control is not supported on this POSIX platform"
            )

        if self._dsrdtr:
            raise UnsupportedSetting(
                "DSR/DTR hardware flow control is not supported on this POSIX platform"
            )

        return (iflag, cflag)

    def _build_ispeed(self) -> int:
        try:
            return cast(int, getattr(termios, f"B{self._baudrate}"))
        except AttributeError as exc:
            raise UnsupportedSetting(f"Unsupported baudrate {self._baudrate}") from exc

    def _build_ospeed(self) -> int:
        try:
            return cast(int, getattr(termios, f"B{self._baudrate}"))
        except AttributeError as exc:
            raise UnsupportedSetting(f"Unsupported baudrate {self._baudrate}") from exc

    def _build_tcsetattr_flags(self) -> TcsetattrFlags:
        iflag = 0x00000000
        oflag = 0x00000000
        cflag = 0x00000000
        lflag = 0x00000000

        # Enable receiver
        cflag |= termios.CREAD

        # Ignore modem control lines
        cflag |= termios.CLOCAL

        # Lower modem control lines after last process closes the device (hang up)
        if self._rtsdtr_on_close is PinState.UNDEFINED:
            pass
        elif self._rtsdtr_on_close is PinState.HIGH:
            raise UnsupportedSetting(
                "POSIX only supports setting RTS/DTR to LOW on close"
            )
        else:
            cflag |= termios.HUPCL

        cflag |= self._build_character_size_flags()
        cflag |= self._build_parity_flags()
        cflag |= self._build_stopbits_flags()

        fc_iflag, fc_cflag = self._build_flow_control_flags()
        cflag |= fc_cflag
        iflag |= fc_iflag

        # Only emit reads if VMIN characters have been read, after no more data comes in
        # for VTIME seconds
        vmin = self._min_read_size
        vtime = int(self._inter_byte_timeout * 10)

        if not 0 <= vmin <= 255:
            raise ValueError(
                f"VMIN must be in range 0-255 (min_read_size={self._min_read_size})"
            )

        if not 0 <= vtime <= 255:
            raise ValueError(
                f"VTIME must be in range 0-255 (inter_byte_timeout={self._inter_byte_timeout})"
            )

        ispeed = self._build_ispeed()
        ospeed = self._build_ospeed()

        return TcsetattrFlags(
            iflag=iflag,
            oflag=oflag,
            cflag=cflag,
            lflag=lflag,
            ispeed=ispeed,
            ospeed=ospeed,
            cc_vmin=vmin,
            cc_vtime=vtime,
        )

    def _after_configure_port(self) -> None:
        pass

    def _configure_port(self) -> None:
        """Configure the serial port settings."""
        LOGGER.debug("Configuring serial port %r", self._path)

        if self._fileno is None:
            raise ValueError("Cannot configure, serial port is not open")

        tcsetattr = self._build_tcsetattr_flags()

        # We need to overwrite VMIN and VTIME in the CC array
        (
            _iflag,
            _oflag,
            _cflag,
            _lflag,
            _ispeed,
            _ospeed,
            cc,
        ) = termios.tcgetattr(self._fileno)

        cc[termios.VMIN] = tcsetattr.cc_vmin
        cc[termios.VTIME] = tcsetattr.cc_vtime

        LOGGER.debug("Configuring serial port: %r + cc=%r", tcsetattr, cc)

        # Finally, set up the serial port
        termios.tcsetattr(
            self._fileno,
            termios.TCSANOW,  # TODO: should we use TCSADRAIN or TCSAFLUSH instead?
            [
                tcsetattr.iflag,
                tcsetattr.oflag,
                tcsetattr.cflag,
                tcsetattr.lflag,
                tcsetattr.ispeed,
                tcsetattr.ospeed,
                cc,
            ],
        )

        self._after_configure_port()

        self.set_modem_pins(dtr=self._rtsdtr_on_open, rts=self._rtsdtr_on_open)

        # Flush input and output buffers to discard stale data
        termios.tcflush(self._fileno, termios.TCIOFLUSH)

    def _get_modem_pins(self) -> ModemPins:
        """Get current modem control bits."""
        assert self._fileno is not None

        # A `bytearray` is critical here: `bytes` will not be mutated
        buffer = bytearray((0x00000000).to_bytes(4, "little"))

        try:
            fcntl.ioctl(self._fileno, termios.TIOCMGET, buffer)
        except OSError as exc:
            if exc.errno == errno.ENOTTY:
                LOGGER.debug("Device is not a serial port, cannot get modem pins")
                return ModemPins()

        n = int.from_bytes(buffer, "little")
        return ModemPins(
            **{
                name: PinState.HIGH if n & bit else PinState.LOW
                for name, bit in MODEM_BIT_MAPPING.items()
            }
        )

    def _set_modem_pins(self, modem_pins: ModemPins) -> None:
        """Set modem control bits."""
        assert self._fileno is not None

        LOGGER.debug("Setting modem pins: %r", modem_pins)

        all_pins_set = all(
            getattr(modem_pins, name) is not PinState.UNDEFINED
            for name in MODEM_BIT_MAPPING
        )

        try:
            if all_pins_set:
                value = modem_pins_as_int(modem_pins)
                LOGGER.debug("Setting all with TIOCMSET: 0x%08X", value)
                fcntl.ioctl(self._fileno, termios.TIOCMSET, value.to_bytes(4, "little"))
            else:
                to_set = modem_pins_mask_of_value(modem_pins, PinState.HIGH)
                to_clear = modem_pins_mask_of_value(modem_pins, PinState.LOW)

                if to_set:
                    LOGGER.debug("Setting TIOCMBIS: 0x%08X", to_set)
                    fcntl.ioctl(
                        self._fileno, termios.TIOCMBIS, to_set.to_bytes(4, "little")
                    )

                if to_clear:
                    LOGGER.debug("TIOCMBIC: 0x%08X", to_clear)
                    fcntl.ioctl(
                        self._fileno, termios.TIOCMBIC, to_clear.to_bytes(4, "little")
                    )
        except OSError as exc:
            if exc.errno == errno.ENOTTY:
                LOGGER.debug("Device is not a serial port, cannot set modem pins")

    def _flush(self) -> None:
        """Flush write buffers, waiting until all data is written."""
        assert self._fileno is not None
        LOGGER.debug("Flushing file descriptor %r", self._fileno)
        termios.tcdrain(self._fileno)

    def _close(self) -> None:
        """Close the serial port."""
        if self._fileno is not None:
            os.close(self._fileno)
            self._fileno = None

    def fileno(self) -> int:
        """Get the file descriptor number."""
        assert self._fileno is not None
        return self._fileno

    # `io.IOBase` implements `read`, `readline`, using `readinto`
    if sys.version_info >= (3, 14):

        def _readinto(self, b: Buffer, *, timeout: float | None) -> int:
            """Read bytes from serial port into buffer."""
            assert self._fileno is not None

            if timeout is not None:
                ready, _, _ = select.select([self._fileno], [], [], timeout)
                if not ready:
                    return 0

            n = os.readinto(self._fileno, b)
            LOGGER.debug("Read %d bytes", n)

            return n

    else:

        def _readinto(self, b: Buffer, *, timeout: float | None) -> int:
            """Read bytes from serial port into buffer."""
            assert self._fileno is not None

            if timeout is not None:
                ready, _, _ = select.select([self._fileno], [], [], timeout)
                if not ready:
                    return 0

            m = memoryview(b).cast("B")
            size = len(m)
            LOGGER.debug("Reading up to %d bytes", size)

            chunk = os.read(self._fileno, size)

            n = len(chunk)
            m[:n] = chunk
            LOGGER.debug("Read %d bytes: %r", n, chunk)

            return n

    def _write(self, data: Buffer, *, timeout: float | None) -> int:
        """Write bytes to serial port."""
        LOGGER.debug("Writing %d bytes: %r", len(data), data)  # type: ignore[arg-type]
        assert self._fileno is not None

        if timeout is not None:
            _, ready, _ = select.select([], [self._fileno], [], timeout)
            if not ready:
                raise TimeoutError("Write timeout")

        return os.write(self._fileno, data)

    def num_unread_bytes(self) -> int:
        """Return the number of bytes waiting to be read."""
        assert self._fileno is not None
        buffer = bytearray((0x00000000).to_bytes(4, "little"))

        fcntl.ioctl(self._fileno, termios.FIONREAD, buffer)

        return int.from_bytes(buffer, "little")

    def num_unwritten_bytes(self) -> int:
        """Return the number of bytes waiting to be written."""
        assert self._fileno is not None
        buffer = bytearray((0x00000000).to_bytes(4, "little"))

        fcntl.ioctl(self._fileno, termios.TIOCOUTQ, buffer)

        return int.from_bytes(buffer, "little")

    def _reset_read_buffer(self) -> None:
        """Reset the read buffer."""
        assert self._fileno is not None
        termios.tcflush(self._fileno, termios.TCIFLUSH)

    def _reset_write_buffer(self) -> None:
        """Reset the write buffer."""
        assert self._fileno is not None
        termios.tcflush(self._fileno, termios.TCOFLUSH)


class PosixSerialTransport(DescriptorTransport):
    """POSIX serial port transport using asyncio."""

    _serial_cls: type[PosixSerial] = PosixSerial

    async def _connect(  # type: ignore[override]
        self, *, path: str | os.PathLike[str], **kwargs: Any
    ) -> None:
        """Connect to serial port."""
        normalized_path = str(path)
        await super()._open(normalized_path)

        self._serial = self._serial_cls(
            **kwargs,
            path=normalized_path,
            # `DescriptorTransport` opened the port
            fileno=self._fileno,
            # Nonblocking mode
            min_read_size=0,
            inter_byte_timeout=0,
        )
        self._extra["serial"] = self._serial

        if self._serial._exclusive:
            await self._loop.run_in_executor(None, self._serial._lock)

        await asyncio.sleep(AFTER_OPEN_DELAY)

        await self._loop.run_in_executor(None, self._serial.configure_port)

        if self.is_closing():
            # If we are closing, we should not call `connection_made`
            return

        await super()._connect()

        self._protocol.connection_made(self)

    async def flush(self) -> None:
        """Flush write buffers, waiting until all data is written."""
        assert self._serial is not None

        try:
            # Wait for internal buffer to drain
            await self._make_empty_waiter()

            # Wait for hardware buffer to flush (with timeout)
            async with asyncio_timeout(FLUSH_TIMEOUT):
                await self._loop.run_in_executor(None, self._serial.flush)
        finally:
            self._reset_empty_waiter()


register_uri_handler(
    scheme="device://",
    unique_scheme="posix://",
    sync_cls=PosixSerial,
    async_transport_cls=PosixSerialTransport,
    strip_uri_scheme=True,
)
