#!/usr/bin/env python3
"""A virtual RF network useful for testing."""

# NOTE: does not rely on ramses_rf library

import asyncio
import logging
import os
import pty
import re
import tty
from collections import deque
from io import FileIO
from types import TracebackType
from typing import Final, Self, TypeAlias, TypedDict

from serial import Serial, serial_for_url  # type: ignore[import-untyped]

from .const import MAX_NUM_PORTS, HgiFwTypes

# Constants
HGI_DEVICE_ID: Final = "18:000730"  # Default HGI ID for emulation
DEFAULT_GWY_ID: Final = bytes(HGI_DEVICE_ID, "ascii")

DEVICE_ID: Final = "device_id"
DEVICE_ID_BYTES: Final = "device_id_bytes"
FW_TYPE: Final = "fw_type"

_LOGGER: Final = logging.getLogger(__name__)
_LOGGER.setLevel(logging.DEBUG)

# Types
_FD: TypeAlias = int  # file descriptor
_PN: TypeAlias = str  # port name


class _GatewaysT(TypedDict):
    """
    Internal mapping for gateway device identification.
    """

    device_id: str
    fw_type: HgiFwTypes
    device_id_bytes: bytes


class VirtualComPortInfo:
    """
    A container for emulating pyserial's PortInfo (SysFS) objects.
    """

    def __init__(self, port_name: _PN, dev_type: HgiFwTypes | None) -> None:
        """
        Initialize the VirtualComPortInfo.

        Supplies a useful subset of PortInfo attrs according to gateway type.

        :param port_name: The system port name (e.g., /dev/pts/2).
        :param dev_type: The firmware type to emulate.
        """
        self.device: _PN = port_name  # e.g. /dev/pts/2 (a la /dev/ttyUSB0)
        self.name: str = port_name[5:]  # e.g.      pts/2 (a la      ttyUSB0)

        # Access attributes directly from the Enum member's value (NamedTuple)
        profile = (dev_type or HgiFwTypes.EVOFW3).value

        self.description: str = profile.description
        self.interface: str | None = profile.interface
        self.manufacturer: str = profile.manufacturer
        self.pid: int = profile.pid
        self.product: str = profile.product
        self.serial_number: str | None = profile.serial_number
        self.subsystem: str = profile.subsystem
        self.vid: int = profile.vid


class VirtualRfBase:
    """A virtual many-to-many network of serial port (a la RF network).

    Creates a collection of serial ports. When data frames are received from any one
    port, they are sent to all the other ports.

    The data frames are in the RAMSES_II format, terminated by `\\r\\n`.
    """

    def __init__(self, num_ports: int, log_size: int = 100) -> None:
        """Initialize the VirtualRfBase.

        :param num_ports: Number of ports to create.
        :param log_size: Size of the internal log deque.
        """
        if os.name != "posix":
            raise RuntimeError(f"Unsupported OS: {os.name} (requires termios)")

        if not (1 <= num_ports <= MAX_NUM_PORTS):
            raise ValueError(f"Port limit exceeded: {num_ports}")

        self._port_info_list: dict[_PN, VirtualComPortInfo] = {}
        self._loop = asyncio.get_running_loop()

        self._master_to_port: dict[_FD, _PN] = {}  # for polling port
        self._port_to_master: dict[_PN, _FD] = {}  # for logging
        self._port_to_object: dict[_PN, FileIO] = {}  # for I/O (read/write)
        self._port_to_slave_: dict[_PN, _FD] = {}  # for cleanup only

        # Buffer for incoming data to handle fragmentation
        self._rx_buffer: dict[_PN, bytes] = {}

        for idx in range(num_ports):
            self._create_port(idx)

        self._log: deque[tuple[_PN, str, bytes]] = deque([], log_size)
        self._replies: dict[str, bytes] = {}

    def _create_port(self, port_idx: int, dev_type: HgiFwTypes | None = None) -> None:
        """Create a port without a HGI80 attached."""
        master_fd, slave_fd = pty.openpty()  # pty, tty

        tty.setraw(master_fd)  # requires termios module, so: works only on *nix
        os.set_blocking(master_fd, False)  # make non-blocking

        port_name = os.ttyname(slave_fd)

        self._master_to_port[master_fd] = port_name
        self._port_to_master[port_name] = master_fd
        self._port_to_object[port_name] = open(master_fd, "rb+", buffering=0)  # noqa: SIM115
        self._port_to_slave_[port_name] = slave_fd
        self._rx_buffer[port_name] = b""  # Initialize buffer

        self._set_comport_info(port_name, dev_type=dev_type)

    def comports(
        self, include_links: bool = False
    ) -> list[VirtualComPortInfo]:  # unsorted
        """Use this method to monkey patch serial.tools.list_ports.comports().

        :param include_links: Ignored, present for signature compatibility.
        """
        return list(self._port_info_list.values())

    def _set_comport_info(
        self, port_name: _PN, dev_type: HgiFwTypes | None = None
    ) -> VirtualComPortInfo:
        """Add comport info to the list (won't fail if the entry already exists)."""
        self._port_info_list.pop(port_name, None)
        self._port_info_list[port_name] = VirtualComPortInfo(port_name, dev_type)
        return self._port_info_list[port_name]

    @property
    def ports(self) -> list[_PN]:
        """Return a list of the names of the serial ports."""
        return list(self._port_to_master)  # [p.name for p in self.comports]

    async def start(self) -> None:
        """
        Start distributing data between ports.

        Registers asyncio readers for all master file descriptors.
        """
        for master_fd in self._master_to_port:
            self._loop.add_reader(master_fd, self._handle_data_ready, master_fd)

    async def stop(self) -> None:
        """Stop distributing data and cleanup resources.

        Unregisters readers and closes file descriptors deterministically.
        """
        # 1. Remove readers first to stop new events from being queued
        for master_fd in list(self._master_to_port.keys()):
            self._loop.remove_reader(master_fd)

        # 2. Yield to the event loop.
        await asyncio.sleep(0)

        # 3. Perform the actual destruction of resources (FDs)
        self._cleanup()

    async def __aenter__(self) -> Self:
        """
        Enter the asynchronous context and start the network listeners.

        :return: The instance of the virtual RF network.
        """
        await self.start()
        return self

    async def __aexit__(
        self,
        err_type: type[BaseException] | None,
        err_val: BaseException | None,
        err_tb: TracebackType | None,
    ) -> None:
        """
        Exit the asynchronous context and ensure all ports are closed.

        This ensures cleanup is called even if an exception occurs within
        the 'async with' block.

        :param err_type: The type of the exception raised, if any.
        :param err_val: The instance of the exception raised, if any.
        :param err_tb: The traceback object, if any.
        """
        await self.stop()

    def _cleanup(self) -> None:
        """Destroy file objects and file descriptors."""
        # 1. Close master FileIO objects
        for port_name, fp in self._port_to_object.items():
            if fp.closed:
                continue
            try:
                fp.flush()
                fp.close()
            except (OSError, ValueError) as err:
                # Log at DEBUG because this is often a side-effect of PTY closure
                _LOGGER.debug(f"Note: Master FP for {port_name} closure: {err}")

        # 2. Close slave FDs
        for port_name, fd in self._port_to_slave_.items():
            try:
                os.close(fd)
            except OSError as err:
                # EBADF (Error 9) is common if the OS already reclaimed it
                if err.errno != 9:
                    _LOGGER.warning(
                        f"Unexpected OSError closing slave FD for {port_name}: {err}"
                    )
            except ValueError as err:
                _LOGGER.error(f"ValueError closing slave FD for {port_name}: {err}")

        # 3. Clear maps so _handle_data_ready safely exits if called late
        self._master_to_port.clear()
        self._port_to_master.clear()
        self._port_to_object.clear()
        self._port_to_slave_.clear()
        self._rx_buffer.clear()

    def _handle_data_ready(self, master_fd: _FD) -> None:
        """
        Callback for asyncio reader when data is available.

        :param master_fd: The file descriptor ready for reading.
        """
        if master_fd not in self._master_to_port:
            return  # FD might have been closed/removed
        src_port = self._master_to_port[master_fd]
        self._pull_data_from_src_port(src_port)

    def _pull_data_from_src_port(self, src_port: _PN) -> None:
        """Pull the data from the sending port and process any frames."""
        try:
            data = self._port_to_object[src_port].read(1024)  # read the Tx'd data
        except OSError as err:
            _LOGGER.warning(f"Read error on {src_port}: {err}")
            return

        if not data:
            return  # EOF or empty

        self._log.append((src_port, "SENT", data))

        # Append new data to buffer
        self._rx_buffer[src_port] += data

        # Process complete lines from the buffer
        while b"\r\n" in self._rx_buffer[src_port]:
            line, remainder = self._rx_buffer[src_port].split(b"\r\n", 1)
            self._rx_buffer[src_port] = remainder

            # Reconstruct frame with delimiter
            frame = line + b"\r\n"

            if fr := self._proc_before_tx(src_port, frame):
                self._cast_frame_to_all_ports(src_port, fr)  # is not echo only

    def _cast_frame_to_all_ports(self, src_port: _PN, frame: bytes) -> None:
        """Pull the frame from the source port and cast it to the RF."""
        _LOGGER.info(f"{src_port:<11} cast:  {frame!r}")
        for dst_port in self._port_to_master:
            self._push_frame_to_dst_port(dst_port, frame)

        # see if there is a faked response (RP/I) for a given command (RQ/W)
        if not (reply := self._find_reply_for_cmd(frame)):
            return

        _LOGGER.info(f"{src_port:<11} rply:  {reply!r}")
        for dst_port in self._port_to_master:
            self._push_frame_to_dst_port(dst_port, reply)  # is not echo only

    def add_reply_for_cmd(self, cmd: str, reply: str) -> None:
        """Add a reply packet for a given command frame (for a mocked device).

        For example (note no RSSI, \\r\\n in reply pkt):
          cmd regex: r"RQ.* 18:.* 01:.* 0006 001 00"
          reply pkt: "RP --- 01:145038 18:013393 --:------ 0006 004 00050135",
        :param cmd: Regex pattern for the command.
        :param reply: The reply string to send.
        """
        self._replies[cmd] = reply.encode() + b"\r\n"

    def _find_reply_for_cmd(self, cmd: bytes) -> bytes | None:
        """Return a reply packet for a given command frame (for a mocked device)."""
        for pattern, reply in self._replies.items():
            if re.match(pattern, cmd.decode()):
                return reply
        return None

    def _push_frame_to_dst_port(self, dst_port: _PN, frame: bytes) -> None:
        """Push the frame to a single destination port."""
        if data := self._proc_after_rx(dst_port, frame):
            self._log.append((dst_port, "RCVD", data))
            try:
                # Handle BlockingIOError (buffer full)
                self._port_to_object[dst_port].write(data)
            except BlockingIOError:
                _LOGGER.warning(f"Buffer full writing to {dst_port}, dropping packet")
            except OSError as err:
                _LOGGER.error(f"Write error to {dst_port}: {err}")

    def _proc_after_rx(self, rcv_port: _PN, frame: bytes) -> bytes | None:
        """Allow the device to modify the frame after receiving (e.g. adding RSSI)."""
        return frame

    def _proc_before_tx(self, src_port: _PN, frame: bytes) -> bytes | None:
        """Allow the device to modify the frame before sending (e.g. changing addr0)."""
        return frame


class VirtualRf(VirtualRfBase):
    """A virtual network of serial ports, each with an optional HGI80s or compatible.

    Frames are modified/dropped according to the expected behaviours of the gateway that
    is transmitting (addr0) / receiving (RSSI) it.
    """

    def __init__(self, num_ports: int, log_size: int = 100, start: bool = True) -> None:
        """Create a number of virtual serial ports.

        Each port has the option of a HGI80 or evofw3-based gateway device.
        :param num_ports: Number of ports.
        :param log_size: Log size.
        :param start: Whether to start the loop immediately.
        """
        self._gateways: dict[_PN, _GatewaysT] = {}
        super().__init__(num_ports, log_size)

        if start:
            asyncio.create_task(self.start())

    @property
    def gateways(self) -> dict[str, _PN]:
        """Return the gateway configuration."""
        return {v[DEVICE_ID]: k for k, v in self._gateways.items()}

    @property
    def gateway_device_id(self) -> str:
        """Return the Device ID of the primary gateway.

        If no gateway is configured, returns the default HGI ID.
        """
        return HGI_DEVICE_ID

    def set_gateway(
        self,
        port_name: _PN,
        device_id: str,
        fw_type: HgiFwTypes = HgiFwTypes.EVOFW3,
    ) -> None:
        """Attach a gateway with a given device_id and FW type to a port.

        Raise an exception if the device_id is already attached to another port.
        :param port_name: The port name.
        :param device_id: The fake device ID.
        :param fw_type: Firmware type.
        """

        if port_name not in self.ports:
            raise LookupError(f"Port does not exist: {port_name}")

        if [v for k, v in self.gateways.items() if k != port_name and v == device_id]:
            raise LookupError(f"Gateway exists on another port: {device_id}")

        if fw_type not in HgiFwTypes:
            raise LookupError(f"Unknown FW specified for gateway: {fw_type}")

        self._gateways[port_name] = {
            DEVICE_ID: device_id,
            FW_TYPE: fw_type,
            DEVICE_ID_BYTES: bytes(device_id, "ascii"),
        }

        self._set_comport_info(port_name, dev_type=fw_type)

    async def dump_frames_to_rf(
        self, pkts: list[bytes], /, timeout: float | None = None
    ) -> None:  # TODO: WIP - improved to be robust (but still simple) pattern
        """Dump frames as if from a sending port (for mocking).

        :param pkts: List of raw byte packets.
        :param timeout: Optional timeout to wait for processing.
        """

        for data in pkts:
            self._log.append(("/dev/mock", "SENT", data))
            self._cast_frame_to_all_ports("/dev/mock", data)  # is not echo only

        # Deterministic-ish Yield:
        # Yield control repeatedly to ensure all micro-tasks generated by the write
        # have a chance to run.
        # A generic 'sleep(0)' yields once. Doing it a few times is a 'poor man's'
        # way of flushing the loop without a hardcoded timer.
        for _ in range(5):
            await asyncio.sleep(0)

    def _proc_after_rx(self, rcv_port: _PN, frame: bytes) -> bytes | None:
        """Return the frame as it would have been modified by a gateway after Rx.

        Return None if the bytes are not to be Rx by this device.

        Both FW types will prepend an RSSI to the frame.
        """

        if frame[:1] != b"!":
            return b"000 " + frame

        # The type of Gateway will inform next steps (NOTE: is not a ramses_rf.Gateway)
        gwy: _GatewaysT | None = self._gateways.get(rcv_port)

        if gwy is None or gwy.get(FW_TYPE) not in (
            HgiFwTypes.EVOFW3,
            HgiFwTypes.EVOFW3_FTDI,
        ):
            return None

        if frame == b"!V":
            return b"# evofw3 0.7.1\r\n"  # self._fxle_objs[port_name].write(data)
        return None  # TODO: return the ! response

    def _proc_before_tx(self, src_port: _PN, frame: bytes) -> bytes | None:
        """Return the frame as it would have been modified by a gateway before Tx.

        Return None if the bytes are not to be Tx to the RF ether (e.g. to echo only).

        Both FW types will convert addr0 (only) from 18:000730 to its actual device_id.
        HGI80-based gateways will silently drop frames with addr0 other than 18:000730.
        """

        # The type of Gateway will inform next steps (NOTE: is not a ramses_rf.Gateway)
        gwy: _GatewaysT | None = self._gateways.get(src_port)

        # Handle trace flags (evofw3 only)
        if frame[:1] == b"!":  # never to be cast, but may be echo'd, or other response
            if gwy is None or gwy.get(FW_TYPE) not in (
                HgiFwTypes.EVOFW3,
                HgiFwTypes.EVOFW3_FTDI,
            ):
                return None  # do not Tx the frame
            self._push_frame_to_dst_port(src_port, frame)

        if gwy is None:  # TODO: ?should raise: but is probably from test suite
            return frame

        # Real HGI80s will silently drop cmds if addr0 is not the 18:000730 sentinel
        if gwy[FW_TYPE] == HgiFwTypes.HGI_80 and frame[7:16] != DEFAULT_GWY_ID:
            return None

        # Both (HGI80 & evofw3) will swap out addr0 (and only addr0)
        if frame[7:16] == DEFAULT_GWY_ID:
            frame = frame[:7] + gwy[DEVICE_ID_BYTES] + frame[16:]

        return frame


async def main() -> None:
    """Demonstrate the class functionality using an async context manager."""
    num_ports = 3

    async with VirtualRf(num_ports) as rf:
        print(f"Ports are: {rf.ports}")

        sers: list[Serial] = [serial_for_url(rf.ports[i]) for i in range(num_ports)]  # type: ignore[no-any-unimported]

        for i in range(num_ports):
            sers[i].write(bytes(f"Hello World {i}! ", "utf-8"))

            # CI-Safe Wait: Poll for data instead of fixed sleep
            # We yield to the loop (sleep) to let VirtualRf process the write.
            # We wait up to 100ms (10 * 0.01s), which is plenty for CI but fast locally.
            for _ in range(10):
                await asyncio.sleep(0.01)
                if sers[i].in_waiting > 0:
                    break

            print(f"{sers[i].name}: {sers[i].read(sers[i].in_waiting)}")
            sers[i].close()


if __name__ == "__main__":
    asyncio.run(main())
