"""NMEA sentence parser for LC29H GNSS modules.

Parses standard NMEA sentences (GGA, RMC, GSV, etc.) into
typed Position and SatelliteInfo objects. Also handles
proprietary PAIR and PQTM response sentences.
"""

import contextlib
import logging
from datetime import UTC, datetime

from .models import Constellation, FixType, ModuleInfo, Position, SatelliteInfo, SurveyInStatus
from .protocol import parse_response, validate_checksum

logger = logging.getLogger(__name__)

# Talker ID → constellation mapping
# From QNMEA.dll decompilation: 24 talker IDs at 0x18001f8a0
TALKER_CONSTELLATION = {
    "GP": Constellation.GPS,
    "GL": Constellation.GLONASS,
    "GA": Constellation.GALILEO,
    "GB": Constellation.BEIDOU,
    "BD": Constellation.BEIDOU,
    "QZ": Constellation.QZSS,
    "GN": Constellation.GPS,  # combined, default to GPS
    "GQ": Constellation.QZSS,
}

# NMEA 4.11 signal ID -> human-readable band name
# Mapping: talker prefix -> {signal_id: band_name}
SIGNAL_BAND_NAMES: dict[str, dict[int, str]] = {
    "GP": {1: "L1", 6: "L2", 8: "L5"},
    "GL": {1: "L1"},
    "GA": {1: "E1", 7: "E5a"},
    "GB": {1: "B1I", 2: "B1C", 5: "B2a"},
    "GQ": {1: "L1", 8: "L5"},
    "BD": {1: "B1I", 2: "B1C", 5: "B2a"},
}


def parse_gga(fields: list[str]) -> dict:
    """Parse GGA (fix data) fields into a dict of set fields.

    Returns only fields that were actually present in the sentence,
    avoiding the zero-value merge bug (0.0 is valid for lat/lon/alt).
    """
    result: dict = {}
    if len(fields) < 14:
        return result

    # UTC time (HHMMSS.sss)
    if fields[0]:
        try:
            t = fields[0]
            h, m, s = int(t[:2]), int(t[2:4]), float(t[4:])
            now = datetime.now(UTC)
            result["timestamp"] = now.replace(
                hour=h, minute=m, second=int(s), microsecond=int((s % 1) * 1e6)
            )
        except (ValueError, IndexError):
            pass

    # Latitude (DDMM.MMMMM, N/S)
    if fields[1] and fields[2]:
        result["latitude"] = _nmea_to_decimal(fields[1], fields[2])

    # Longitude (DDDMM.MMMMM, E/W)
    if fields[3] and fields[4]:
        result["longitude"] = _nmea_to_decimal(fields[3], fields[4])

    # Fix quality
    if fields[5]:
        with contextlib.suppress(ValueError):
            result["fix_type"] = FixType(int(fields[5]))

    # Satellites in use
    if fields[6]:
        with contextlib.suppress(ValueError):
            result["satellites"] = int(fields[6])

    # HDOP
    if fields[7]:
        with contextlib.suppress(ValueError):
            result["hdop"] = float(fields[7])

    # Altitude
    if fields[8]:
        with contextlib.suppress(ValueError):
            result["altitude"] = float(fields[8])

    return result


def parse_rmc(fields: list[str]) -> dict:
    """Parse RMC (recommended minimum) fields into a dict of set fields."""
    result: dict = {}
    if len(fields) < 11:
        return result

    # UTC time + date
    if fields[0]:
        try:
            t = fields[0]
            h, m, s = int(t[:2]), int(t[2:4]), float(t[4:])
            if fields[8]:
                d = fields[8]
                day, mon, yr = int(d[:2]), int(d[2:4]), 2000 + int(d[4:6])
                result["timestamp"] = datetime(
                    yr, mon, day, h, m, int(s), int((s % 1) * 1e6),
                    tzinfo=UTC,
                )
        except (ValueError, IndexError):
            pass

    # Status A=active, V=void
    if fields[1] != "A":
        result["fix_type"] = FixType.NO_FIX
        return result

    # Latitude
    if fields[2] and fields[3]:
        result["latitude"] = _nmea_to_decimal(fields[2], fields[3])

    # Longitude
    if fields[4] and fields[5]:
        result["longitude"] = _nmea_to_decimal(fields[4], fields[5])

    # Speed over ground (knots)
    if fields[6]:
        with contextlib.suppress(ValueError):
            result["speed_knots"] = float(fields[6])

    # Course over ground
    if fields[7]:
        with contextlib.suppress(ValueError):
            result["course"] = float(fields[7])

    # Fix type from mode indicator (field 11 in NMEA 4.10)
    if len(fields) > 11 and fields[11]:
        mode = fields[11]
        mode_map = {
            "A": FixType.GPS_FIX,
            "D": FixType.DGPS_FIX,
            "R": FixType.RTK_FIXED,
            "F": FixType.RTK_FLOAT,
            "E": FixType.ESTIMATED,
        }
        result["fix_type"] = mode_map.get(mode, FixType.GPS_FIX)

    return result


def parse_gsv(fields: list[str], talker: str = "GP") -> list[SatelliteInfo]:
    """Parse GSV (satellites in view) fields.

    GSV can contain 1-4 satellite entries per sentence.
    The last field may be a signal ID (NMEA 4.11 dual-band).
    """
    sats = []
    if len(fields) < 3:
        return sats

    constellation = TALKER_CONSTELLATION.get(talker, Constellation.GPS)

    # Detect signal ID: present when (field_count - 3) % 4 == 1
    # and the last field is a non-empty numeric value
    signal_id = 0
    band = ""
    n_after_header = len(fields) - 3
    has_signal_id = n_after_header > 0 and n_after_header % 4 == 1 and fields[-1]
    if has_signal_id:
        try:
            signal_id = int(fields[-1])
        except ValueError:
            has_signal_id = False

    if signal_id:
        band_map = SIGNAL_BAND_NAMES.get(talker, {})
        band = band_map.get(signal_id, "")

    end_idx = len(fields) - 1 if has_signal_id else len(fields)

    # Fields 3+ are groups of 4: PRN, elev, azim, SNR
    idx = 3
    while idx + 3 < end_idx:
        try:
            prn = int(fields[idx]) if fields[idx] else 0
            elev = int(fields[idx + 1]) if fields[idx + 1] else 0
            azim = int(fields[idx + 2]) if fields[idx + 2] else 0
            snr = int(fields[idx + 3]) if fields[idx + 3] else None
            sats.append(
                SatelliteInfo(
                    prn=prn,
                    elevation=elev,
                    azimuth=azim,
                    snr=snr,
                    constellation=constellation,
                    signal_id=signal_id,
                    band=band,
                )
            )
        except (ValueError, IndexError):
            pass
        idx += 4

    return sats


def parse_gsa(fields: list[str]) -> set[int]:
    """Parse GSA (active satellites) fields.

    Returns set of PRN numbers used in the fix.
    Fields[2] through fields[13] contain PRN numbers (empty if not used).
    """
    prns = set()
    # PRN fields are at indices 2..13
    for i in range(2, min(14, len(fields))):
        if fields[i]:
            with contextlib.suppress(ValueError):
                prns.add(int(fields[i]))
    return prns


def _extract_model_from_firmware(fw: str) -> str:
    """Extract the module model name from the firmware version string.

    Examples:
        "LC29HDANR11A03S_RSA" -> "LC29HDA"
        "LC29HBSNR11A01S"     -> "LC29HBS"
        "LC29HAANR10A02S"     -> "LC29HAA"
    """
    # Firmware strings follow: LC29H{variant}{revision}
    # Variant codes are 2 letters (DA, BS, CA, AA, BA)
    if fw.startswith("LC29H") and len(fw) >= 7:
        return fw[:7]
    return ""


def parse_pqtm_verno(fields: list[str]) -> ModuleInfo:
    """Parse PQTMVERNO response.

    Real format from LC29H(DA): $PQTMVERNO,<FW>,<Date>,<Time>*CS
    Example: $PQTMVERNO,LC29HDANR11A03S_RSA,2024/03/19,13:55:31*20
    """
    info = ModuleInfo()
    if len(fields) >= 1:
        info.firmware_version = fields[0]
        info.model = _extract_model_from_firmware(fields[0])
    if len(fields) >= 2:
        info.build_date = fields[1]
    if len(fields) >= 3:
        # Strip checksum suffix if still present (e.g. "13:55:31*20")
        info.build_time = fields[2].split("*")[0]
    return info


def parse_pair021(fields: list[str]) -> ModuleInfo:
    """Parse PAIR021 firmware version response.

    Example: AG3335A_V2.6.0.AG3335_20240319,D,N,7b9bf67,...
    The first field is the chip-level firmware string.
    """
    info = ModuleInfo()
    if fields:
        chip_fw = fields[0]
        info.chip_version = chip_fw
        # Extract chip model: everything before the first underscore
        # "AG3335A_V2.6.0..." -> "AG3335A"
        if "_" in chip_fw:
            info.chip_model = chip_fw.split("_")[0]
    return info


def parse_pqtm_cfgsvin(fields: list[str]) -> SurveyInStatus:
    """Parse PQTMCFGSVIN response into SurveyInStatus.

    Response format: $PQTMCFGSVIN,OK,<mode>,<dur>,<acc>,<x>,<y>,<z>*CS
    The first field is the R/W indicator or OK status.

    Also handles PQTMSVINSTATUS periodic output:
    $PQTMSVINSTATUS,<active>,<valid>,<dur>,<x>,<y>,<z>,<acc>*CS
    """
    status = SurveyInStatus()

    if not fields:
        return status

    # PQTMCFGSVIN,OK response: fields = [OK, mode, dur, acc, x, y, z]
    # or PQTMCFGSVIN read: fields = [mode, dur, acc, x, y, z] (after R stripped)
    # Detect OK prefix and skip it
    offset = 0
    if fields[0] == "OK":
        offset = 1

    try:
        remaining = fields[offset:]
        if len(remaining) >= 6:
            mode = int(remaining[0])
            status.active = mode == 1
            status.valid = mode == 2
            status.duration = int(remaining[1])
            status.mean_accuracy = float(remaining[2])
            status.mean_x = float(remaining[3])
            status.mean_y = float(remaining[4])
            status.mean_z = float(remaining[5])
    except (ValueError, IndexError):
        pass

    return status


def parse_pqtm_svinstatus(fields: list[str]) -> SurveyInStatus:
    """Parse PQTMSVINSTATUS periodic output.

    Format: $PQTMSVINSTATUS,<active>,<valid>,<dur>,<x>,<y>,<z>,<acc>*CS
    """
    status = SurveyInStatus()

    if len(fields) < 7:
        return status

    try:
        status.active = int(fields[0]) == 1
        status.valid = int(fields[1]) == 1
        status.duration = int(fields[2])
        status.mean_x = float(fields[3])
        status.mean_y = float(fields[4])
        status.mean_z = float(fields[5])
        status.mean_accuracy = float(fields[6])
    except (ValueError, IndexError):
        pass

    return status


class NMEAStream:
    """Streaming NMEA parser that maintains current position and satellite state.

    Processes standard NMEA sentences (GGA, RMC, GSV) and
    proprietary PAIR/PQTM responses from the LC29H module.
    """

    def __init__(self):
        self._position = Position()
        self._satellites: list[SatelliteInfo] = []
        self._gsv_pending: list[SatelliteInfo] = []
        self._gsa_used_prns: set[int] = set()
        self._gsv_epoch_seen: bool = False
        self._module_info = ModuleInfo()

    @property
    def position(self) -> Position:
        return self._position

    @property
    def satellites(self) -> list[SatelliteInfo]:
        return self._satellites

    @property
    def module_info(self) -> ModuleInfo:
        return self._module_info

    def parse_line(self, line: str) -> str | None:
        """Parse a single NMEA line and update internal state.

        Returns:
            The sentence type if recognized (e.g. "GGA", "RMC", "PQTMVERNO"),
            or None if not parseable.
        """
        line = line.strip()
        if not line or not line.startswith("$"):
            return None

        try:
            if not validate_checksum(line):
                logger.debug("Bad checksum: %s", line)
                return None
        except (ValueError, IndexError):
            return None

        try:
            sentence_id, fields = parse_response(line)
        except ValueError:
            return None

        # Extract talker ID (first 2 chars) and sentence type
        if sentence_id.startswith("PAIR"):
            return self._handle_pair(sentence_id, fields)
        elif sentence_id.startswith("PQTM"):
            return self._handle_pqtm(sentence_id, fields)
        else:
            talker = sentence_id[:2]
            msg_type = sentence_id[2:]
            return self._handle_nmea(talker, msg_type, fields)

    def _handle_nmea(self, talker: str, msg_type: str, fields: list[str]) -> str | None:
        if msg_type == "GGA":
            # GGA marks the epoch boundary — swap accumulated GSV data
            if self._gsv_pending:
                self._satellites = list(self._gsv_pending)
                self._gsv_pending.clear()
            # Cross-reference GSA data to mark used satellites
            if self._gsa_used_prns:
                for sat in self._satellites:
                    if sat.prn in self._gsa_used_prns:
                        sat.used_in_fix = True
                self._gsa_used_prns.clear()
            updates = parse_gga(fields)
            self._apply_position_updates(updates)
            return "GGA"
        elif msg_type == "GSA":
            prns = parse_gsa(fields)
            self._gsa_used_prns.update(prns)
            return "GSA"
        elif msg_type == "RMC":
            updates = parse_rmc(fields)
            self._apply_position_updates(updates)
            return "RMC"
        elif msg_type == "GSV":
            self._handle_gsv(fields, talker)
            return "GSV"
        return msg_type

    def _merge_module_info(self, parsed: ModuleInfo) -> None:
        """Merge non-empty fields from parsed info into existing module_info."""
        for field_name in ModuleInfo.model_fields:
            value = getattr(parsed, field_name)
            if value:  # Only merge non-empty strings
                setattr(self._module_info, field_name, value)

    def _handle_pair(self, sentence_id: str, fields: list[str]) -> str:
        if sentence_id == "PAIR021":
            self._merge_module_info(parse_pair021(fields))
        return sentence_id

    def _handle_pqtm(self, sentence_id: str, fields: list[str]) -> str:
        if sentence_id == "PQTMVERNO":
            self._merge_module_info(parse_pqtm_verno(fields))
        return sentence_id

    def _apply_position_updates(self, updates: dict) -> None:
        """Apply parsed fields to current position state.

        Only fields actually present in the sentence are updated,
        avoiding the zero-value bug where 0.0 is a valid coordinate.
        """
        for key, value in updates.items():
            setattr(self._position, key, value)

    def _handle_gsv(self, fields: list[str], talker: str) -> None:
        """Handle GSV sentence by accumulating into epoch buffer.

        Dual-band GNSS modules output separate GSV sequences per
        constellation per signal band.  Rather than tracking individual
        cycles, we accumulate all satellites and let the next GGA
        (epoch boundary) swap the buffer into the live list.

        If the first GSV of a new epoch resets the buffer via GGA,
        we detect a "new epoch" when the first GPGSV msg 1 arrives
        after the buffer was swapped.
        """
        if len(fields) < 3:
            return

        try:
            msg_num = int(fields[1])
        except (ValueError, IndexError):
            return

        # First GPS GSV msg 1 signals a new epoch of GSV data
        if talker == "GP" and msg_num == 1 and self._gsv_epoch_seen:
            self._gsv_pending.clear()
            self._gsv_epoch_seen = False

        sats = parse_gsv(fields, talker)
        self._gsv_pending.extend(sats)

        if sats:
            self._gsv_epoch_seen = True

    def clear_satellites(self) -> None:
        """Clear satellite list."""
        self._satellites.clear()
        self._gsv_pending.clear()
        self._gsa_used_prns.clear()


def _nmea_to_decimal(coord: str, direction: str) -> float:
    """Convert NMEA coordinate (DDMM.MMMM or DDDMM.MMMM) to decimal degrees."""
    if not coord:
        return 0.0
    # Find the decimal point to determine DD vs DDD
    dot = coord.index(".")
    deg_len = dot - 2
    degrees = float(coord[:deg_len])
    minutes = float(coord[deg_len:])
    decimal = degrees + minutes / 60.0
    if direction in ("S", "W"):
        decimal = -decimal
    return decimal
