"""Tests for NMEA sentence parser."""

from lc29h.models import Constellation, FixType
from lc29h.parser import (
    SIGNAL_BAND_NAMES,
    NMEAStream,
    _extract_model_from_firmware,
    _nmea_to_decimal,
    parse_gga,
    parse_gsa,
    parse_gsv,
    parse_pair021,
    parse_pqtm_verno,
    parse_rmc,
)
from lc29h.protocol import compute_checksum


def _make_sentence(body: str) -> str:
    """Build a valid NMEA sentence from a body string."""
    cs = compute_checksum(body)
    return f"${body}*{cs}"


class TestNMEAToDecimal:
    def test_north_latitude(self):
        # 48 degrees 07.038 minutes N
        result = _nmea_to_decimal("4807.0380000", "N")
        assert abs(result - 48.1173) < 0.001

    def test_south_latitude(self):
        result = _nmea_to_decimal("4807.0380000", "S")
        assert result < 0
        assert abs(result + 48.1173) < 0.001

    def test_east_longitude(self):
        # 011 degrees 31.000 minutes E
        result = _nmea_to_decimal("01131.0000000", "E")
        assert abs(result - 11.5167) < 0.001

    def test_west_longitude(self):
        result = _nmea_to_decimal("01131.0000000", "W")
        assert result < 0

    def test_empty_string(self):
        assert _nmea_to_decimal("", "N") == 0.0


class TestParseGGA:
    """parse_gga now returns a dict of only the fields it parsed."""

    def test_basic_gga(self):
        fields = [
            "123519.00", "4807.0381200", "N", "01131.0004100", "E",
            "1", "08", "0.9", "545.4", "M", "47.0", "M", "", "",
        ]
        result = parse_gga(fields)
        assert abs(result["latitude"] - 48.1173) < 0.01
        assert abs(result["longitude"] - 11.5167) < 0.01
        assert result["fix_type"] == FixType.GPS_FIX
        assert result["satellites"] == 8
        assert result["hdop"] == 0.9
        assert abs(result["altitude"] - 545.4) < 0.1

    def test_no_fix(self):
        fields = ["123519.00", "", "", "", "", "0", "00", "99.99", "", "M", "", "M", "", ""]
        result = parse_gga(fields)
        assert result["fix_type"] == FixType.NO_FIX
        assert "latitude" not in result  # empty fields not set

    def test_rtk_fixed(self):
        fields = [
            "123519.00", "4807.038", "N", "01131.000", "E",
            "4", "12", "0.5", "100.0", "M", "47.0", "M", "", "",
        ]
        result = parse_gga(fields)
        assert result["fix_type"] == FixType.RTK_FIXED

    def test_zero_altitude_is_valid(self):
        """Altitude 0.0 (sea level) should be included in result."""
        fields = [
            "123519.00", "4807.038", "N", "01131.000", "E",
            "1", "08", "0.9", "0.0", "M", "47.0", "M", "", "",
        ]
        result = parse_gga(fields)
        assert "altitude" in result
        assert result["altitude"] == 0.0

    def test_equator_prime_meridian(self):
        """Latitude 0.0 and longitude 0.0 should be valid."""
        fields = [
            "123519.00", "0000.0000000", "N", "00000.0000000", "E",
            "1", "04", "2.0", "10.0", "M", "0.0", "M", "", "",
        ]
        result = parse_gga(fields)
        assert "latitude" in result
        assert result["latitude"] == 0.0
        assert "longitude" in result
        assert result["longitude"] == 0.0

    def test_empty_fields_returns_empty_dict(self):
        result = parse_gga([""] * 14)
        assert result == {}

    def test_short_fields_returns_empty_dict(self):
        result = parse_gga(["123519.00"])
        assert result == {}


class TestParseRMC:
    def test_active_rmc(self):
        fields = [
            "123519.00", "A", "4807.038", "N", "01131.000", "E",
            "022.4", "084.4", "230326", "003.1", "W", "A",
        ]
        result = parse_rmc(fields)
        assert abs(result["latitude"] - 48.1173) < 0.01
        assert result["speed_knots"] == 22.4
        assert result["course"] == 84.4

    def test_void_rmc(self):
        fields = [
            "123519.00", "V", "", "", "", "",
            "", "", "230326", "", "", "",
        ]
        result = parse_rmc(fields)
        assert result["fix_type"] == FixType.NO_FIX

    def test_zero_speed_is_valid(self):
        """Speed 0.0 should be included."""
        fields = [
            "123519.00", "A", "4807.038", "N", "01131.000", "E",
            "0.0", "0.0", "230326", "003.1", "W", "A",
        ]
        result = parse_rmc(fields)
        assert "speed_knots" in result
        assert result["speed_knots"] == 0.0


class TestParseGSV:
    def test_basic_gsv(self):
        fields = ["3", "1", "12", "01", "40", "083", "46", "02", "17", "308", "41"]
        sats = parse_gsv(fields, "GP")
        assert len(sats) == 2
        assert sats[0].prn == 1
        assert sats[0].elevation == 40
        assert sats[0].azimuth == 83
        assert sats[0].snr == 46
        assert sats[0].constellation == Constellation.GPS

    def test_glonass_gsv(self):
        fields = ["2", "1", "8", "65", "30", "120", "35"]
        sats = parse_gsv(fields, "GL")
        assert len(sats) == 1
        assert sats[0].constellation == Constellation.GLONASS


class TestNMEAStream:
    def test_parse_gga_line(self):
        body = "GPGGA,123519.00,4807.0381200,N,01131.0004100,E,1,08,0.9,545.4,M,47.0,M,,"
        line = _make_sentence(body)
        stream = NMEAStream()
        result = stream.parse_line(line)
        assert result == "GGA"
        assert stream.position.satellites == 8

    def test_parse_invalid_checksum(self):
        stream = NMEAStream()
        result = stream.parse_line("$GPGGA,bad*FF")
        assert result is None

    def test_parse_pqtm_verno(self):
        """Real PQTMVERNO response: firmware, date, time."""
        body = "PQTMVERNO,LC29HDANR11A03S_RSA,2024/03/19,13:55:31"
        line = _make_sentence(body)
        stream = NMEAStream()
        result = stream.parse_line(line)
        assert result == "PQTMVERNO"
        assert stream.module_info.firmware_version == "LC29HDANR11A03S_RSA"
        assert stream.module_info.model == "LC29HDA"
        assert stream.module_info.build_date == "2024/03/19"
        assert stream.module_info.build_time == "13:55:31"

    def test_parse_pair021(self):
        """Real PAIR021 response: chip firmware info."""
        body = "PAIR021,AG3335A_V2.6.0.AG3335_20240319,D,N,7b9bf67"
        line = _make_sentence(body)
        stream = NMEAStream()
        result = stream.parse_line(line)
        assert result == "PAIR021"
        assert stream.module_info.chip_model == "AG3335A"
        assert "AG3335A_V2.6.0" in stream.module_info.chip_version

    def test_module_info_merge(self):
        """PAIR021 and PQTMVERNO should merge into the same module_info."""
        stream = NMEAStream()
        # First: chip-level info from PAIR021
        stream.parse_line(_make_sentence(
            "PAIR021,AG3335A_V2.6.0.AG3335_20240319,D,N"
        ))
        assert stream.module_info.chip_model == "AG3335A"

        # Second: module firmware from PQTMVERNO
        stream.parse_line(_make_sentence(
            "PQTMVERNO,LC29HDANR11A03S_RSA,2024/03/19,13:55:31"
        ))
        # Both should be present
        assert stream.module_info.chip_model == "AG3335A"
        assert stream.module_info.model == "LC29HDA"
        assert stream.module_info.firmware_version == "LC29HDANR11A03S_RSA"

    def test_clear_satellites(self):
        stream = NMEAStream()
        # GSV data
        body = "GPGSV,1,1,04,01,40,083,46,02,17,308,41,03,25,286,32,04,05,219,"
        stream.parse_line(_make_sentence(body))
        # GGA triggers swap from pending to live
        gga = "GNGGA,120000.00,4807.038,N,01131.000,E,1,04,1.0,100.0,M,0.0,M,,"
        stream.parse_line(_make_sentence(gga))
        assert len(stream.satellites) > 0
        stream.clear_satellites()
        assert len(stream.satellites) == 0

    def test_zero_position_preserved(self):
        """Position at 0,0 (equator/prime meridian) should be stored correctly."""
        stream = NMEAStream()
        body = "GPGGA,120000.00,0000.0000000,N,00000.0000000,E,1,04,2.0,0.0,M,0.0,M,,"
        line = _make_sentence(body)
        stream.parse_line(line)
        assert stream.position.latitude == 0.0
        assert stream.position.longitude == 0.0
        assert stream.position.altitude == 0.0
        assert stream.position.fix_type == FixType.GPS_FIX

    def test_zero_position_not_overwritten(self):
        """A subsequent GGA with empty coords shouldn't erase a valid 0,0 position."""
        stream = NMEAStream()
        # First: valid position at equator
        body1 = "GPGGA,120000.00,0000.0000000,N,00000.0000000,E,1,04,2.0,10.0,M,0.0,M,,"
        stream.parse_line(_make_sentence(body1))
        assert stream.position.latitude == 0.0
        assert stream.position.altitude == 10.0

        # Second: GGA with no coordinates (empty fields) — should not clobber
        body2 = "GPGGA,120001.00,,,,,,00,99.99,,M,,M,,"
        stream.parse_line(_make_sentence(body2))
        assert stream.position.latitude == 0.0  # preserved
        assert stream.position.altitude == 10.0  # preserved

    def test_gsv_epoch_swap_on_gga(self):
        """Satellites swap to live list when GGA (epoch boundary) arrives."""
        stream = NMEAStream()
        # GSV accumulates into pending buffer
        body1 = "GPGSV,2,1,08,01,40,083,46,02,17,308,41,03,25,286,32,04,05,219,"
        stream.parse_line(_make_sentence(body1))
        assert len(stream.satellites) == 0  # still pending

        body2 = "GPGSV,2,2,08,05,40,083,46,06,17,308,41,07,25,286,32,08,05,219,"
        stream.parse_line(_make_sentence(body2))
        assert len(stream.satellites) == 0  # still pending

        # GLONASS GSV in the same epoch
        body3 = "GLGSV,1,1,02,75,54,155,27,85,49,032,27"
        stream.parse_line(_make_sentence(body3))
        assert len(stream.satellites) == 0  # still pending

        # GGA arrives — epoch boundary triggers swap
        gga = "GNGGA,120000.00,4807.038,N,01131.000,E,1,10,0.9,545.4,M,47.0,M,,"
        stream.parse_line(_make_sentence(gga))
        # All GSV data from the epoch is now live
        assert len(stream.satellites) == 10  # 8 GPS + 2 GLONASS

    def test_gsv_multi_constellation_accumulation(self):
        """Multiple constellations accumulate before epoch swap."""
        stream = NMEAStream()
        # GPS
        stream.parse_line(_make_sentence(
            "GPGSV,1,1,02,01,40,083,46,02,17,308,41"
        ))
        # GLONASS
        stream.parse_line(_make_sentence(
            "GLGSV,1,1,01,75,54,155,27"
        ))
        # BeiDou
        stream.parse_line(_make_sentence(
            "GBGSV,1,1,01,27,50,053,33"
        ))
        assert len(stream.satellites) == 0  # all pending

        # GGA triggers swap
        stream.parse_line(_make_sentence(
            "GNGGA,120000.00,4807.038,N,01131.000,E,1,04,1.0,100.0,M,0.0,M,,"
        ))
        assert len(stream.satellites) == 4  # 2 GPS + 1 GLONASS + 1 BeiDou

    def test_gsv_new_epoch_replaces_old(self):
        """A new epoch's GSV data replaces the previous satellite list."""
        stream = NMEAStream()
        # First epoch: 2 GPS sats
        stream.parse_line(_make_sentence(
            "GPGSV,1,1,02,01,40,083,46,02,17,308,41"
        ))
        stream.parse_line(_make_sentence(
            "GNGGA,120000.00,4807.038,N,01131.000,E,1,02,1.0,100.0,M,0.0,M,,"
        ))
        assert len(stream.satellites) == 2

        # Second epoch: 1 GPS sat — new GPGSV msg 1 clears pending
        stream.parse_line(_make_sentence(
            "GPGSV,1,1,01,03,50,200,42"
        ))
        stream.parse_line(_make_sentence(
            "GNGGA,120001.00,4807.038,N,01131.000,E,1,01,1.0,100.0,M,0.0,M,,"
        ))
        assert len(stream.satellites) == 1
        assert stream.satellites[0].prn == 3


class TestExtractModel:
    def test_da_variant(self):
        assert _extract_model_from_firmware("LC29HDANR11A03S_RSA") == "LC29HDA"

    def test_bs_variant(self):
        assert _extract_model_from_firmware("LC29HBSNR11A01S") == "LC29HBS"

    def test_aa_variant(self):
        assert _extract_model_from_firmware("LC29HAANR10A02S") == "LC29HAA"

    def test_short_string(self):
        assert _extract_model_from_firmware("LC29") == ""

    def test_non_lc29h(self):
        assert _extract_model_from_firmware("AG3335A_V2.6.0") == ""


class TestParsePqtmVerno:
    def test_real_response(self):
        info = parse_pqtm_verno(["LC29HDANR11A03S_RSA", "2024/03/19", "13:55:31"])
        assert info.firmware_version == "LC29HDANR11A03S_RSA"
        assert info.model == "LC29HDA"
        assert info.build_date == "2024/03/19"
        assert info.build_time == "13:55:31"

    def test_time_with_checksum_suffix(self):
        """Build time may still have *CS suffix if splitting was imperfect."""
        info = parse_pqtm_verno(["FW1.0", "2024/01/01", "09:30:00*2A"])
        assert info.build_time == "09:30:00"

    def test_empty_fields(self):
        info = parse_pqtm_verno([])
        assert info.firmware_version == ""
        assert info.model == ""


class TestParsePair021:
    def test_real_response(self):
        info = parse_pair021(["AG3335A_V2.6.0.AG3335_20240319", "D", "N"])
        assert info.chip_model == "AG3335A"
        assert info.chip_version == "AG3335A_V2.6.0.AG3335_20240319"

    def test_empty_fields(self):
        info = parse_pair021([])
        assert info.chip_model == ""
        assert info.chip_version == ""


class TestParseGSA:
    def test_gps_gsa(self):
        """Parse GSA with GPS satellites."""
        fields = [
            "A", "3", "06", "11", "12", "19", "21", "25",
            "", "", "", "", "", "", "3.56", "1.49", "3.24", "1",
        ]
        prns = parse_gsa(fields)
        assert prns == {6, 11, 12, 19, 21, 25}

    def test_glonass_gsa(self):
        """Parse GSA with GLONASS satellites."""
        fields = [
            "A", "3", "75", "76", "85", "86", "", "",
            "", "", "", "", "", "", "3.56", "1.49", "3.24", "2",
        ]
        prns = parse_gsa(fields)
        assert prns == {75, 76, 85, 86}

    def test_empty_gsa(self):
        """GSA with no active satellites."""
        fields = [
            "A", "3", "", "", "", "", "", "",
            "", "", "", "", "", "", "3.56", "1.49", "3.24", "3",
        ]
        prns = parse_gsa(fields)
        assert prns == set()

    def test_single_satellite(self):
        """GSA with one active satellite (BeiDou)."""
        fields = [
            "A", "3", "27", "", "", "", "", "",
            "", "", "", "", "", "", "3.56", "1.49", "3.24", "4",
        ]
        prns = parse_gsa(fields)
        assert prns == {27}

    def test_short_fields(self):
        """GSA with too few fields."""
        prns = parse_gsa(["A", "3"])
        assert prns == set()


class TestGSAUsedInFix:
    def test_gsa_marks_used_satellites(self):
        """GSA PRNs should mark matching satellites as used_in_fix after GGA epoch."""
        stream = NMEAStream()

        # GSV: satellites in view
        stream.parse_line(_make_sentence(
            "GPGSV,1,1,04,06,53,055,22,11,69,170,34,19,15,297,18,25,28,307,26"
        ))

        # GSA: satellites used in fix (only 06 and 11)
        stream.parse_line(_make_sentence(
            "GNGSA,A,3,06,11,,,,,,,,,,,,1.49,3.24,1"
        ))

        # GGA triggers epoch swap + GSA cross-reference
        stream.parse_line(_make_sentence(
            "GNGGA,120000.00,4807.038,N,01131.000,E,1,02,1.0,100.0,M,0.0,M,,"
        ))

        sats = stream.satellites
        assert len(sats) == 4
        used = {s.prn for s in sats if s.used_in_fix}
        not_used = {s.prn for s in sats if not s.used_in_fix}
        assert used == {6, 11}
        assert not_used == {19, 25}

    def test_multi_constellation_gsa(self):
        """Multiple GSA sentences accumulate used PRNs across constellations."""
        stream = NMEAStream()

        # GPS GSV
        stream.parse_line(_make_sentence(
            "GPGSV,1,1,02,06,53,055,22,11,69,170,34"
        ))
        # GLONASS GSV
        stream.parse_line(_make_sentence(
            "GLGSV,1,1,02,75,54,155,27,76,30,120,20"
        ))
        # BeiDou GSV
        stream.parse_line(_make_sentence(
            "GBGSV,1,1,01,27,50,053,33"
        ))

        # GPS GSA
        stream.parse_line(_make_sentence(
            "GNGSA,A,3,06,11,,,,,,,,,,,,1.49,3.24,1"
        ))
        # GLONASS GSA
        stream.parse_line(_make_sentence(
            "GNGSA,A,3,75,76,,,,,,,,,,,,1.49,3.24,2"
        ))
        # BeiDou GSA
        stream.parse_line(_make_sentence(
            "GNGSA,A,3,27,,,,,,,,,,,,,1.49,3.24,4"
        ))

        # GGA epoch boundary
        stream.parse_line(_make_sentence(
            "GNGGA,120000.00,4807.038,N,01131.000,E,1,05,1.0,100.0,M,0.0,M,,"
        ))

        sats = stream.satellites
        assert len(sats) == 5
        used = {s.prn for s in sats if s.used_in_fix}
        assert used == {6, 11, 75, 76, 27}

    def test_gsa_clears_between_epochs(self):
        """GSA used PRNs should reset between epochs."""
        stream = NMEAStream()

        # First epoch: 2 sats used
        stream.parse_line(_make_sentence(
            "GPGSV,1,1,02,06,53,055,22,11,69,170,34"
        ))
        stream.parse_line(_make_sentence(
            "GNGSA,A,3,06,11,,,,,,,,,,,,1.49,3.24,1"
        ))
        stream.parse_line(_make_sentence(
            "GNGGA,120000.00,4807.038,N,01131.000,E,1,02,1.0,100.0,M,0.0,M,,"
        ))
        used1 = {s.prn for s in stream.satellites if s.used_in_fix}
        assert used1 == {6, 11}

        # Second epoch: only 1 sat used (different set)
        stream.parse_line(_make_sentence(
            "GPGSV,1,1,02,06,53,055,22,11,69,170,34"
        ))
        stream.parse_line(_make_sentence(
            "GNGSA,A,3,06,,,,,,,,,,,,,1.49,3.24,1"
        ))
        stream.parse_line(_make_sentence(
            "GNGGA,120001.00,4807.038,N,01131.000,E,1,01,1.0,100.0,M,0.0,M,,"
        ))
        used2 = {s.prn for s in stream.satellites if s.used_in_fix}
        assert used2 == {6}  # only 06 now, 11 should NOT be used


class TestParseGSVSignalId:
    def test_gsv_with_signal_id(self):
        """GSV sentence with trailing signal ID field."""
        # $GPGSV,2,1,08,06,53,055,22,11,69,170,34,19,15,297,18,25,28,307,26,1*XX
        fields = [
            "2", "1", "08", "06", "53", "055", "22", "11", "69", "170", "34",
            "19", "15", "297", "18", "25", "28", "307", "26", "1",
        ]
        sats = parse_gsv(fields, "GP")
        assert len(sats) == 4
        assert all(s.signal_id == 1 for s in sats)
        assert all(s.band == "L1" for s in sats)

    def test_gsv_l5_signal(self):
        """GPS L5 signal (signal_id=8)."""
        fields = [
            "1", "1", "04", "06", "53", "055", "23", "11", "69", "170", "29",
            "19", "15", "297", "", "25", "28", "307", "27", "8",
        ]
        sats = parse_gsv(fields, "GP")
        assert len(sats) == 4
        assert all(s.signal_id == 8 for s in sats)
        assert all(s.band == "L5" for s in sats)

    def test_gsv_beidou_b2a(self):
        """BeiDou B2a signal (signal_id=5)."""
        fields = ["1", "1", "01", "27", "50", "053", "29", "5"]
        sats = parse_gsv(fields, "GB")
        assert len(sats) == 1
        assert sats[0].signal_id == 5
        assert sats[0].band == "B2a"

    def test_gsv_without_signal_id(self):
        """GSV sentence without signal ID (trailing empty field from comma)."""
        fields = [
            "3", "1", "12", "01", "40", "083", "46", "02", "17", "308", "41",
            "03", "25", "286", "32", "04", "05", "219", "",
        ]
        sats = parse_gsv(fields, "GP")
        assert len(sats) == 4
        assert all(s.signal_id == 0 for s in sats)
        assert all(s.band == "" for s in sats)

    def test_gsv_backward_compatible(self):
        """Existing tests should still pass -- GSV without signal ID."""
        fields = ["3", "1", "12", "01", "40", "083", "46", "02", "17", "308", "41"]
        sats = parse_gsv(fields, "GP")
        assert len(sats) == 2
        assert sats[0].signal_id == 0

    def test_signal_band_names_mapping(self):
        """Verify signal band name mapping is complete."""
        assert SIGNAL_BAND_NAMES["GP"][1] == "L1"
        assert SIGNAL_BAND_NAMES["GP"][8] == "L5"
        assert SIGNAL_BAND_NAMES["GB"][1] == "B1I"
        assert SIGNAL_BAND_NAMES["GB"][5] == "B2a"
        assert SIGNAL_BAND_NAMES["GA"][7] == "E5a"
