# Copyright 2023-2024 Teemu Ikonen
# SPDX-License-Identifier: GPL-3.0-only

import re

import gi
from pynmea2.nmea import NMEASentence

from satellite.nmeasource import (
    ModemNoNMEAError,
    NmeaSource,
    NmeaSourceNotFoundError,
)

gi.require_version('ModemManager', '1.0')
from gi.repository import Gio, ModemManager  # noqa: E402, I100


class ModemManagerGLibNmeaSource(NmeaSource):

    def __init__(self, update_callback, quirks=['auto'], **kwargs):
        super().__init__(update_callback, **kwargs)
        self.bus = None
        self.manager = None
        self.modem = None
        self.mlocation = None
        self.location_updated = None
        self.quirks = set(quirks)
        self.wanted_sources = None
        self.type = "ModemManager"

    def initialize(self):
        # If reinitializing, disconnect old update cb
        if self.mlocation is not None:
            self.mlocation.disconnect_by_func(self.update_callback)
        self.bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
        self.manager = ModemManager.Manager.new_sync(
            self.bus, Gio.DBusObjectManagerClientFlags.DO_NOT_AUTO_START, None)
        if self.manager.get_name_owner() is None:
            raise NmeaSourceNotFoundError("ModemManager is not running")
        objs = self.manager.get_objects()
        for obj in objs:
            self.modem = obj.get_modem()
            self.mlocation = obj.get_modem_location()
            if self.modem is not None and self.mlocation is not None:
                break
        else:
            raise NmeaSourceNotFoundError("No Modems Found")
        if self.modem is None or self.mlocation is None:
            raise NmeaSourceNotFoundError("Cannot find modem with location support")
        self.path = self.modem.dup_path()
        self.manufacturer = self.modem.get_manufacturer()
        self.model = self.modem.get_model()
        self.revision = self.modem.get_revision()

        if 'auto' in self.quirks:
            self.quirks.remove('auto')
            if (self.model.startswith('QUECTEL')
                    and self.manufacturer == 'QUALCOMM INCORPORATED'):
                self.quirks.add('quectel-talker')
            # Detect various snapdragon GNSS units and disable MSB assistance,
            # which causes stalling at startup due to some bug somewhere
            if (self.manufacturer == 'QUALCOMM INCORPORATED'
                    and self.model == '0'
                    and (self.revision.find('SDM845') >= 0
                         or self.revision.find('SDM670') >= 0)):
                self.quirks.add('no-agps')

        caps = self.mlocation.get_capabilities()
        if not caps & ModemManager.ModemLocationSource.GPS_NMEA:
            raise NmeaSourceNotFoundError(
                "Modem does not support NMEA")
        want = ModemManager.ModemLocationSource.GPS_NMEA
        if (caps & ModemManager.ModemLocationSource.AGPS_MSB
                and self.modem.get_state() >= ModemManager.ModemState.ENABLED
                and 'no-agps' not in self.quirks):
            want |= ModemManager.ModemLocationSource.AGPS_MSB
        self.wanted_sources = want
        self.setup_sources()

        self.mlocation.set_gps_refresh_rate_sync(self.refresh_rate, None)

        self.mlocation.connect('notify::location', self.update_callback)
        self.mlocation.connect('notify::enabled',
                               self.on_enabled_or_signals_location_notify)
        self.mlocation.connect('notify::signals-location',
                               self.on_enabled_or_signals_location_notify)
        self.mlocation.connect('notify::gps-refresh-rate',
                               self.on_gps_refresh_rate_notify)
        self.initialized = True

    def setup_sources(self):
        """Enable sources in self.wanted_sources, leave other sources as they were.

        If setup fails, first disable AGPS from wanted_sources and retry.
        If that also fails, raise an exception.
        """
        enabled = ModemManager.ModemLocationSource(self.mlocation.props.enabled)
        while True:
            try:
                want = enabled | self.wanted_sources
                self.mlocation.setup_sync(want, True, None)
                return
            except gi.repository.GLib.GError as e:
                # Ignore error on AGPS enablement by this hack
                if ('agps-msb' in str(e)
                        and (self.wanted_sources
                             & ModemManager.ModemLocationSource.AGPS_MSB)):
                    print("Source setup failed, disabling AGPS and retrying...")
                    self.wanted_sources = ModemManager.ModemLocationSource.GPS_NMEA
                else:
                    raise e

    def on_enabled_or_signals_location_notify(self, modem_location, _param):
        """Re-enable the ModemLocationSources we need, if they get disabled."""
        enabled = ModemManager.ModemLocationSource(modem_location.props.enabled)
        signals = modem_location.props.signals_location
        if not signals or (enabled & self.wanted_sources) ^ self.wanted_sources:
            want = enabled | self.wanted_sources
            print(f"MM props changed to SignalsLocation: {signals}; Enabled: {enabled}")
            print(f"Resetting to SignalsLocation: True; Enabled: {want}")
            self.setup_sources()

    def on_gps_refresh_rate_notify(self, modem_location, _param):
        """Reset ModemLocation.GpsRefreshRate if something else modifies it.

        In order to avoid value reset ping-pong with another app, only reset the
        value if it's higher (slower repetition rate) than what we need.
        """
        rate = modem_location.props.gps_refresh_rate
        if rate > self.refresh_rate:
            print(f"Resetting GpsRefreshRate from {rate} to {self.refresh_rate}")
            modem_location.set_gps_refresh_rate_sync(self.refresh_rate, None)

    def _really_get(self):
        if not self.initialized:
            self.initialize()
        try:
            loc = self.mlocation.get_signaled_gps_nmea()
        except Exception as e:
            self.initialized = False
            raise e

        if loc is None:
            raise ModemNoNMEAError

        nmeas = loc.get_traces()
        if nmeas is None:
            self.initialized = False
            raise ModemNoNMEAError

        if 'quectel-talker' in self.quirks:
            nmeas = self.quectel_talker_quirk(nmeas)

        return '\r\n'.join(nmeas)

    def close(self):
        if self.mlocation is None:
            return
        try:
            self.mlocation.disconnect_by_func(self.update_callback)
            self.mlocation.disconnect_by_func(self.on_gps_refresh_rate_notify)
            # Disconnect twice, since this function is connected to two signals:
            self.mlocation.disconnect_by_func(
                self.on_enabled_or_signals_location_notify)
            self.mlocation.disconnect_by_func(
                self.on_enabled_or_signals_location_notify)
        except TypeError:
            pass  # Ignore error when nothing is connected
        self.mlocation.setup_sync(ModemManager.ModemLocationSource.NONE, False, None)

    def quectel_talker_quirk(self, nmeas):
        pq_re = re.compile(r"""
            ^\s*\$?
            (?P<talker>PQ)
            (?P<sentence>\w{3})
            (?P<data>[^*]*)
            (?:[*](?P<checksum>[A-F0-9]{2}))$""", re.VERBOSE)
        out = []
        for nmea in (n for n in nmeas if n):
            mo = pq_re.match(nmea)
            if mo:
                # The last extra data field is Signal ID, these are
                # 1 = GPS, 2 = Glonass, 3 = Galileo, 4 = BeiDou, 5 = QZSS
                # Determine talker from Signal ID
                talker = 'QZ' if mo.group('data').endswith('5') else 'BD'
                # Fake talker and checksum
                fake = talker + "".join(mo.group(2, 3))
                out.append('$' + fake + "*%02X" % NMEASentence.checksum(fake))
            else:
                out.append(nmea)

        return out
