"""NMEA 0183 serial reader — parses GGA, RMC, VTG, GSV, GSA, GLL.
Runs in a background thread; calls broadcast_fn(msg) directly.
Thread safety is handled by the caller (Qt signal emit or similar)."""
import threading, serial, serial.tools.list_ports

# Known USB-serial VIDs: u-blox, CH340, FTDI, Prolific
KNOWN_VIDS = {0x1546, 0x1A86, 0x0403, 0x067B}

SYSTEM_MAP = {"GP": "GPS", "GL": "GLONASS", "GA": "Galileo",
              "GB": "BeiDou", "GN": "GNSS", "GQ": "QZSS"}


class NMEAReader(threading.Thread):
    def __init__(self, port: str, baud: int, broadcast_fn):
        super().__init__(daemon=True, name="nmea-reader")
        self.port   = port
        self.baud   = baud
        self._bcast = broadcast_fn
        self._stop  = threading.Event()
        self._fix   = {}
        self._sats  = {}        # key="{sys}_{prn}" → dict
        self._active = set()    # PRN strings from GSA

    # ── Auto-detect ───────────────────────────────────────────────────────────
    @staticmethod
    def autodetect() -> str | None:
        """Try to find a GPS serial port.
        1. Match by USB vendor ID (u-blox, CH340, FTDI, Prolific) — fast and reliable.
        2. If no VID match, return the first available COM port from the OS list
           (works for USB-CDC devices that don't expose a VID, e.g. some clone adapters).
        """
        ports = serial.tools.list_ports.comports()
        # Priority: well-known GPS USB chip vendor IDs
        for p in ports:
            if (p.vid or 0) in KNOWN_VIDS:
                return p.device
        # Fallback: first port in the system list (avoid brute-force which can hang on BT ports)
        if ports:
            return ports[0].device
        return None

    @staticmethod
    def list_ports():
        return [{"port": p.device, "desc": p.description,
                 "vid": p.vid, "pid": p.pid}
                for p in serial.tools.list_ports.comports()]

    # ── Thread control ────────────────────────────────────────────────────────
    def stop(self):
        self._stop.set()

    def _emit(self, msg: dict):
        self._bcast(msg)

    # ── Main loop ─────────────────────────────────────────────────────────────
    def run(self):
        try:
            ser = serial.Serial(self.port, self.baud, timeout=0.3)
        except Exception as e:
            self._emit({"type": "error", "msg": str(e)})
            return

        self._emit({"type": "connected", "port": self.port, "baud": self.baud})

        while not self._stop.is_set():
            try:
                raw = ser.readline()
                if not raw:
                    continue
                line = raw.decode("ascii", errors="replace").strip()
                if not line.startswith("$"):
                    continue
                self._emit({"type": "raw", "sentence": line})
                self._parse(line)
            except serial.SerialException as e:
                self._emit({"type": "error", "msg": str(e)})
                break
            except Exception:
                pass

        ser.close()
        self._emit({"type": "disconnected"})

    # ── NMEA dispatch ─────────────────────────────────────────────────────────
    def _parse(self, line: str):
        body = line[1:line.index("*")] if "*" in line else line[1:]
        parts = body.split(",")
        if not parts:
            return
        talker   = parts[0][:2]
        sentence = parts[0][2:]
        dispatch = {
            "GGA": self._gga, "RMC": self._rmc, "VTG": self._vtg,
            "GSV": self._gsv, "GSA": self._gsa, "GLL": self._gll,
        }
        fn = dispatch.get(sentence)
        if fn:
            try:
                fn(parts, talker)
            except Exception:
                pass

    # ── Helpers ───────────────────────────────────────────────────────────────
    @staticmethod
    def _lat(v, h):
        if not v: return None
        d = int(v[:2]); m = float(v[2:])
        return -(d + m/60) if h == "S" else (d + m/60)

    @staticmethod
    def _lon(v, h):
        if not v: return None
        d = int(v[:3]); m = float(v[3:])
        return -(d + m/60) if h == "W" else (d + m/60)

    @staticmethod
    def _f(v):
        s = v.split("*")[0].strip() if v else ""
        return float(s) if s else None

    @staticmethod
    def _i(v):
        s = v.split("*")[0].strip() if v else ""
        return int(s) if s else None

    def _sat_list(self):
        return list(self._sats.values())

    # ── Sentence handlers ─────────────────────────────────────────────────────
    def _gga(self, p, talker):
        if len(p) < 10: return
        lat = self._lat(p[2], p[3])
        lon = self._lon(p[4], p[5])
        fq  = self._i(p[6]) or 0
        sats = self._i(p[7]) or 0
        hdop = self._f(p[8])
        alt  = self._f(p[9])
        self._fix.update({"lat": lat, "lon": lon, "fix_quality": fq,
                          "satellites": sats, "hdop": hdop, "altitude": alt,
                          "utc": p[1]})
        self._emit({"type": "position", **self._fix, "sats": self._sat_list()})

    def _rmc(self, p, talker):
        if len(p) < 9: return
        if p[2] != "A": return   # void
        lat = self._lat(p[3], p[4])
        lon = self._lon(p[5], p[6])
        sog = self._f(p[7])
        cog = self._f(p[8])
        magvar = None
        if len(p) > 11 and p[10]:
            mv = self._f(p[10])
            if mv is not None:
                magvar = -mv if (len(p) > 11 and p[11].startswith("W")) else mv
        self._fix.update({"sog": sog, "cog": cog, "magvar": magvar,
                          "date": p[9]})
        self._emit({"type": "rmc", "lat": lat, "lon": lon,
                    "sog": sog, "cog": cog, "magvar": magvar, "date": p[9]})

    def _vtg(self, p, talker):
        if len(p) < 8: return
        mode = p[9].split("*")[0] if len(p) > 9 else ""
        if mode == "N": return
        cog_t = self._f(p[1])
        cog_m = self._f(p[3])
        sog   = self._f(p[5])
        self._fix.update({"cog": cog_t, "cog_m": cog_m, "sog": sog})

    def _gsv(self, p, talker):
        sys = SYSTEM_MAP.get(talker, talker)
        i = 4
        while i + 2 < len(p):
            prn = p[i].strip()
            el  = self._i(p[i+1]) if i+1 < len(p) else None
            az  = self._i(p[i+2]) if i+2 < len(p) else None
            snr_raw = p[i+3] if i+3 < len(p) else ""
            snr = self._i(snr_raw)
            if prn:
                key = f"{sys}_{prn}"
                self._sats[key] = {
                    "key": key, "prn": prn, "system": sys,
                    "el": el, "az": az, "snr": snr,
                    "used": prn in self._active,
                }
            i += 4
        self._emit({"type": "satellites", "sats": self._sat_list()})

    def _gsa(self, p, talker):
        if len(p) < 15: return
        self._active = {p[i].strip() for i in range(3, 15) if p[i].strip()}
        fix_mode = self._i(p[2]) or 1
        pdop = self._f(p[15]) if len(p) > 15 else None
        hdop = self._f(p[16]) if len(p) > 16 else None
        vdop = self._f(p[17]) if len(p) > 17 else None
        self._fix.update({"fix_mode": fix_mode, "pdop": pdop,
                          "hdop": hdop, "vdop": vdop})
        self._emit({"type": "dop", "fix_mode": fix_mode,
                    "pdop": pdop, "hdop": hdop, "vdop": vdop})
        # refresh used flag
        for k, s in self._sats.items():
            s["used"] = s["prn"] in self._active

    def _gll(self, p, talker):
        if len(p) < 6: return
        status = p[6] if len(p) > 6 else p[5]
        if "A" not in status: return
        lat = self._lat(p[1], p[2])
        lon = self._lon(p[3], p[4])
        if lat and lon:
            self._fix.update({"lat": lat, "lon": lon})
