"""Serial port discovery and a background reader thread for NMEA/AIS data.

Port enumeration and reading both use pyserial. The read loop runs inside a
QThread so the blocking serial I/O never freezes the QGIS main (GUI) thread.
Communication back to the GUI is done purely through Qt signals, which is the
thread-safe way to update widgets from a worker thread.
"""
from qgis.PyQt.QtCore import QThread, pyqtSignal

from . import vendor
vendor.setup()  # ensure bundled pyserial is importable

try:
    import serial
    from serial.tools import list_ports
    PYSERIAL_AVAILABLE = True
except ImportError:
    serial = None
    list_ports = None
    PYSERIAL_AVAILABLE = False


# USB-serial / common AIS-receiver chipset vendor IDs, for friendlier labels.
KNOWN_VENDORS = {
    0x0403: "FTDI",
    0x067B: "Prolific",
    0x10C4: "Silicon Labs (CP210x)",
    0x1A86: "QinHeng (CH34x)",
    0x2341: "Arduino",
}


def list_serial_ports():
    """Return a list of dicts describing the available serial ports.

    Keys: device, description, hwid, vid, pid, manufacturer, is_usb,
    vendor_label. A port is treated as USB when it reports a USB vendor ID.
    """
    if not PYSERIAL_AVAILABLE:
        return []
    ports = []
    for p in list_ports.comports():
        is_usb = p.vid is not None
        ports.append({
            "device": p.device,
            "description": p.description or "",
            "hwid": p.hwid or "",
            "vid": p.vid,
            "pid": p.pid,
            "manufacturer": p.manufacturer or "",
            "is_usb": is_usb,
            "vendor_label": KNOWN_VENDORS.get(p.vid, "") if is_usb else "",
        })
    return ports


class SerialReader(QThread):
    """Reads newline-delimited NMEA sentences from a serial port off-thread.

    Signals:
        lineReceived(str)  — one decoded, stripped text line
        errorOccurred(str) — open/read error message
        connected(str)     — port opened successfully (device name)
        disconnected()     — port closed / loop ended
    """
    lineReceived = pyqtSignal(str)
    errorOccurred = pyqtSignal(str)
    connected = pyqtSignal(str)
    disconnected = pyqtSignal()

    def __init__(self, device, baudrate=38400, parent=None):
        """Configure a reader for one serial device at the given baud rate."""
        super().__init__(parent)
        self._device = device
        self._baudrate = baudrate
        self._running = False
        self._serial = None

    def run(self):
        """Thread body: open the port, then emit each decoded line until stopped;
        all open/read errors are reported via the errorOccurred signal."""
        if not PYSERIAL_AVAILABLE:
            self.errorOccurred.emit("pyserial is not installed.")
            return
        try:
            # timeout=1 lets readline() return periodically so we can re-check
            # the _running flag and shut down promptly.
            self._serial = serial.Serial(self._device, self._baudrate, timeout=1)
        except Exception as exc:
            self.errorOccurred.emit(f"Could not open {self._device}: {exc}")
            return

        self._running = True
        self.connected.emit(self._device)
        try:
            while self._running:
                try:
                    raw = self._serial.readline()
                except Exception as exc:
                    # A read that fails while we are still meant to be running is
                    # a real fault (most often the device was unplugged); report
                    # it. If _running is already False the read was aborted by
                    # stop() (cancel_read) — that's a clean shutdown, stay quiet.
                    if self._running:
                        self.errorOccurred.emit(
                            f"Read error (device disconnected?): {exc}")
                    break
                if not raw:
                    continue  # timeout / aborted read — loop and re-check _running
                line = raw.decode("ascii", errors="replace").strip()
                if line:
                    self.lineReceived.emit(line)
        finally:
            try:
                if self._serial and self._serial.is_open:
                    self._serial.close()
            except Exception:  # nosec B110
                pass
            self._serial = None      # don't let a late stop()/finaliser touch it
            self.disconnected.emit()

    def stop(self):
        """Ask the loop to finish and unblock a pending read AT ONCE.

        cancel_read() (CancelIoEx) aborts the in-flight overlapped read from this
        thread safely — far better than waiting out the read timeout, and it
        avoids the Windows crash class where the port handle is closed/finalised
        while a read is still in flight (an access violation deep in pyserial's
        GetOverlappedResult). We then wait for the thread to actually finish, so
        the reader is never garbage-collected (and its port closed) mid-read.
        """
        self._running = False
        try:
            s = self._serial
            if s is not None and hasattr(s, "cancel_read"):
                s.cancel_read()
        except Exception:  # nosec B110
            pass
        if not self.wait(5000):
            # Still stuck (e.g. a genuinely wedged driver): try once more to
            # cancel, then wait again rather than return with the thread running.
            try:
                s = self._serial
                if s is not None and hasattr(s, "cancel_read"):
                    s.cancel_read()
            except Exception:  # nosec B110
                pass
            self.wait(3000)
