"""NMEA 0183 helpers: checksum validation and AIS sentence detection.

Pure functions, no Qt/QGIS dependencies, so they're easy to unit-test on their
own. A valid NMEA sentence looks like:

    !AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C
    ^                                              ^^
    start char ($ or !)                  '*' + 2-hex checksum

The checksum is the XOR of every character between the start char and the '*'.
"""


def strip_tag_block(sentence: str) -> str:
    r"""Remove a leading NMEA 4.0 / IEC 61162-1 TAG block if present.

    Many AIS aggregators and multiplexers (e.g. the Norwegian Coastal
    Administration feed) prefix each sentence with a tag block carrying source /
    timestamp metadata, like::

        \s:SRC,c:1700000000*HH\!AIVDM,1,1,,B,....,0*5C

    The payload only starts at the second backslash. Strip it so the sentence can
    be classified, checksum-validated and decoded normally; sentences without a
    tag block are returned unchanged."""
    s = sentence.lstrip()
    if s.startswith("\\"):
        end = s.find("\\", 1)
        if end != -1:
            return s[end + 1:].lstrip()
    return sentence


def nmea_checksum(sentence: str) -> str:
    """Return the two-character uppercase hex checksum for a raw NMEA sentence."""
    s = sentence.strip()
    if s and s[0] in "$!":
        s = s[1:]
    star = s.find("*")
    if star != -1:
        s = s[:star]
    cs = 0
    for ch in s:
        cs ^= ord(ch)
    return f"{cs:02X}"


def is_valid_nmea(sentence: str) -> bool:
    """True if the sentence is well-formed and its checksum matches."""
    s = sentence.strip()
    if not s or s[0] not in "$!" or "*" not in s:
        return False
    body, _, given = s.partition("*")
    given = given.strip().upper()
    if len(given) < 2:
        return False
    return nmea_checksum(s) == given[:2]


def is_ais_sentence(sentence: str) -> bool:
    """True for AIS VDM/VDO sentences from any talker (e.g. !AIVDM, !ABVDM)."""
    s = sentence.strip().upper()
    if not s.startswith("!"):
        return False
    head = s.split(",", 1)[0]  # e.g. "!AIVDM"
    return head.endswith("VDM") or head.endswith("VDO")


def classify(sentence: str) -> str:
    """Return 'ais', 'nmea', or 'other' for a single line of text."""
    s = sentence.strip()
    if not s or s[0] not in "$!":
        return "other"
    return "ais" if is_ais_sentence(s) else "nmea"


_GPS_TYPES = ("RMC", "GGA", "GLL", "VTG", "GSV", "GSA", "ZDA")


def is_gps_sentence(sentence: str) -> bool:
    """True for common GNSS position/time sentences (e.g. $GPRMC, $GNGGA)."""
    s = sentence.strip().upper()
    if not s.startswith("$"):
        return False
    head = s.split(",", 1)[0]  # e.g. "$GPRMC" -> talker 'GP', type 'RMC'
    return len(head) >= 6 and head[3:6] in _GPS_TYPES


def _dm_to_deg(value: str, hemi: str):
    """Convert an NMEA ddmm.mmmm / dddmm.mmmm coordinate + hemisphere letter to
    signed decimal degrees, or None if it can't be parsed / is out of range."""
    if not value or not hemi:
        return None
    try:
        f = float(value)
    except ValueError:
        return None
    deg = int(f / 100)                       # whole degrees are the leading digits
    minutes = f - deg * 100
    if minutes >= 60.0:                      # malformed (minutes field must be <60)
        return None
    dec = deg + minutes / 60.0
    if hemi.upper() in ("S", "W"):
        dec = -dec
    return dec


def parse_gps_fix(sentence: str):
    """Extract a single position fix from a GNSS RMC/GGA/GLL sentence.

    Returns (lat, lon) in decimal degrees (EPSG:4326) for a *valid, positioned*
    fix, or None otherwise. Void RMC ('V'), a GGA fix-quality of 0, an invalid
    GLL, empty coordinate fields or a bad checksum all yield None — so a caller
    can trust any non-None result as a real position. Pure/testable: no I/O."""
    s = sentence.strip()
    if not is_gps_sentence(s):
        return None
    if "*" in s and not is_valid_nmea(s):    # reject corrupt sentences when a
        return None                          # checksum is present
    body = s.split("*", 1)[0]               # drop checksum before field-splitting
    f = body.split(",")
    typ = f[0][3:6].upper() if len(f[0]) >= 6 else ""
    try:
        if typ == "RMC":                    # ...,status,lat,N,lon,W,...
            if len(f) < 7 or f[2].upper() != "A":
                return None
            lat = _dm_to_deg(f[3], f[4])
            lon = _dm_to_deg(f[5], f[6])
        elif typ == "GGA":                  # ...,lat,N,lon,W,quality,...
            if len(f) < 7 or f[6].strip() in ("", "0"):
                return None
            lat = _dm_to_deg(f[2], f[3])
            lon = _dm_to_deg(f[4], f[5])
        elif typ == "GLL":                  # lat,N,lon,W,time,status,...
            if len(f) >= 7 and f[6].upper() not in ("A", ""):
                return None                 # explicit non-'A' status -> not valid
            if len(f) < 5:
                return None
            lat = _dm_to_deg(f[1], f[2])
            lon = _dm_to_deg(f[3], f[4])
        else:
            return None
    except (IndexError, ValueError):
        return None
    if lat is None or lon is None:
        return None
    if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
        return None
    return (lat, lon)
