# Copyright 2021-2025 Teemu Ikonen
# SPDX-License-Identifier: GPL-3.0-only

import argparse
import importlib.resources as resources
import os
import signal
import sys
import time

import gi

import satellite.nmea as nmea
import satellite.quectel as quectel
from satellite import __version__

from .gpx import GpxSaver
from .mm_glib_source import ModemManagerGLibNmeaSource
from .nmeasource import (
    GnssShareNmeaSource,
    GpsShareNmeaSource,
    GpsdNmeaSource,
    ModemError,
    ModemLockedError,
    ModemNoNMEAError,
    NmeaSourceNotFoundError,
    ReplayNmeaSource,
    UnixSocketNmeaSource,
)
from .util import bearing_to_arrow, now, show_alertdialog, unique_filename
from .window import Window

gi.require_version('Gtk', '4.0')
gi.require_version('Gdk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Adw, GLib, Gdk, Gio, Gtk  # noqa: E402, I100

appname = 'Satellite'
app_id = 'page.codeberg.tpikonen.satellite'
respath = __name__.split(sep='.')[0]


class Application(Adw.Application):

    def __init__(self, *args, **kwargs):
        super().__init__(
            application_id=app_id,
            flags=Gio.ApplicationFlags.FLAGS_NONE)

        GLib.set_application_name(appname)
        GLib.set_prgname(app_id)

        self.window = None
        self.source = None

        desc = "Display navigation satellite data and save GPX tracks"
        parser = argparse.ArgumentParser(
            description=desc, formatter_class=argparse.RawTextHelpFormatter)
        parser.add_argument(
            '-c', '--console-output', dest='console_output',
            action='store_true', default=False,
            help='Output satellite data to console')
        parser.add_argument(
            '-s', '--source', dest='source',
            default='auto',
            help="Select NMEA source. Options are:\n"
                 "'auto' (default) Automatic source detection\n"
                 "'gnss-share' Read from gnss-share socket\n"
                 "'gps-share' Read from gps-share socket\n"
                 "'gpsd' Read from local gpsd (experimental)\n"
                 "'mm' ModemManager GPS\n"
                 "'socket=<socketfile>' Read NMEAs from a unix socket\n"
                 "'replay=<nmeafile>' Replay NMEAs from a text file (for testing)\n")
        parser.add_argument(
            '-q', '--quirks', dest='quirks', nargs='*',
            choices=['auto', 'quectel-talker', 'no-agps'],
            default=['auto'],
            help="Select one or more quirks which modify the selected source.\n"
                 "Options are:\n"
                 "'auto' (default) Detect quirks based on source type, model etc.\n"
                 "'quectel-talker' Fix talker field in NMEA sentences\n"
                 "'no-agps' Do not enable AGPS MSB in a ModemManager source\n")
        self.args = parser.parse_args()

        GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT,
                             self.sigint_handler)

        self.connect('startup', self.on_startup)
        self.connect('activate', self.on_activate)
        self.connect('shutdown', self.on_shutdown)

        # Internal state
        self.last_mode = 1
        self.last_data = None
        self.last_speed = None
        self.last_update = None
        self.had_error = False
        self.sigint_received = False
        self.timeout_source_id = None

        # Constants
        self.refresh_rate = 1  # Really delay between updates in seconds
        self.source_timeout = 5  # Try to revive the source after this time

        # GPX
        docdir = (GLib.get_user_special_dir(
            GLib.UserDirectory.DIRECTORY_DOCUMENTS)
            or os.path.join(GLib.get_home_dir(), 'Documents'))
        self.gpx_save_dir = os.path.join(docdir, 'satellite-tracks')
        self.gpx = None

        self.inhibit_cookie = 0

    def log_msg(self, text):
        if self.window is None:
            print(text)
        else:
            self.window.log_msg(text)

    def sigint_handler(self):
        if not self.sigint_received:
            print("Interrupt signal (Ctrl-C) received")
            self.sigint_received = True
            self.quit()
        else:
            print("Interrupt signal (Ctrl-C) received again, force exit")
            sys.exit(0)

    def create_actions(self):
        app_actions = [
            ("about", self.on_about, None),
            ("quit", self.on_quit, ("app.quit", ["<Ctrl>Q"])),
            ("record", self.on_record, ("app.record", ["<Ctrl>R"])),
            ("stop_recording", self.on_stop_recording,
             ("app.stop_recording", ["<Ctrl>S"])),
            ("copy_coordinates", self.on_copy_coordinates,
             ("app.copy_coordinates", ["<Ctrl>C"])),
        ]
        for action, callback, accel in app_actions:
            simple_action = Gio.SimpleAction.new(action, None)
            simple_action.connect('activate', callback)
            self.add_action(simple_action)
            if accel is not None:
                self.set_accels_for_action(*accel)

    def setup_styles(self):
        provider = Gtk.CssProvider()
        provider.load_from_data(
            resources.read_binary(respath, "main.css"))
        Gtk.StyleContext.add_provider_for_display(
            Gdk.Display.get_default(), provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

    def on_startup(self, app):
        Adw.Application.do_startup(self)
        Adw.StyleManager.get_default().set_color_scheme(
            Adw.ColorScheme.PREFER_LIGHT)
        self.create_actions()
        # Initialize modem after GUI startup
        GLib.timeout_add(1000, self.init_source, None)

    def on_activate(self, app):
        if not self.window:
            self.setup_styles()
            self.window = Window(self)
            self.window.set_default_icon_name(app_id)
            self.log_msg(f"{appname} version {__version__} started")
            # Initialize dataframe
            self.set_values({}, console_output=self.args.console_output)

        self.window.present()

    def on_shutdown(self, app):
        print("Cleaning up... ", end='', flush=True)
        if self.gpx is not None:
            self.gpx.write()
        if self.timeout_source_id is not None:
            GLib.Source.remove(self.timeout_source_id)
        if self.source is not None:
            self.source.close()
        print("done.")

    def on_quit(self, *args):
        self.quit()

    def on_about(self, *args):
        adlg = Adw.AboutDialog(
            application_name=appname,
            application_icon=app_id,
            developer_name="Teemu Ikonen",
            version=__version__,
            website="https://codeberg.org/tpikonen/satellite/",
            comments="A program for showing navigation satellite data",
            license_type=Gtk.License.GPL_3_0_ONLY,
            copyright="Copyright 2021-2025 Teemu Ikonen",
        )
        adlg.present(self.window)

    def on_record(self, *args):
        assert self.gpx is None

        namestem = self.gpx_save_dir + '/track'
        gpxfile = unique_filename(namestem, '.gpx', timestamp=True)
        if gpxfile is None:
            raise FileExistsError(namestem)

        self.gpx = GpxSaver(gpxfile)

        self.window.start_recording()
        self.lookup_action('record').set_enabled(False)
        self.log_msg("Started saving track to '%s'" % self.gpx.filename)

        self.inhibit_cookie = self.inhibit(
            self.window, Gtk.ApplicationInhibitFlags.SUSPEND,
            "Recording GPX track")
        if self.inhibit_cookie == 0:
            self.log_msg("Failed to inhibit system suspend")
        else:
            self.log_msg("Inhibiting system suspend")

    def on_stop_recording(self, *args):
        dialog = Adw.AlertDialog.new(
            "Stop recording?",
            "Do you want to stop recording a track?"
        )
        dialog.add_response("no", "No")
        dialog.add_response("yes", "Yes")
        dialog.set_default_response("no")
        dialog.set_close_response("no")
        dialog.set_response_appearance("yes", Adw.ResponseAppearance.DESTRUCTIVE)
        dialog.choose(self.window, None, self.on_stop_recording_done, None)

    def on_stop_recording_done(self, source_object, res, data):
        dialog = source_object
        response = dialog.choose_finish(res)
        if response == "yes":
            self.gpx.write()
            self.log_msg("Track closed and saved to '%s'" % self.gpx.filename)
            self.gpx = None
            self.uninhibit(self.inhibit_cookie)
            self.window.stop_recording()
            self.lookup_action('record').set_enabled(True)
        else:
            pass

    def toast(self, text):
        label = Gtk.Label.new(text)
        label.set_use_markup(True)
        toast = Adw.Toast.new(text)
        toast.set_custom_title(label)
        self.window.toast_overlay.add_toast(toast)

    def on_copy_coordinates(self, *args):
        latlon = self.last_data.get('latlon') if self.last_data else None
        if latlon is None:
            text = "<b>Coordinates are not known,\ncannot copy</b>"
        else:
            cb = Gdk.Display().get_default().get_clipboard()
            ctext = f"{latlon[0]:0.6f}, {latlon[1]:0.6f}"
            cb.set(ctext)
            text = f"<b>'{ctext}'\ncopied to clipboard</b>"

        self.toast(text)

    def try_to_init_source(self, source, autodetect=False):
        source_name = source[0]
        source_class = source[1]
        try:
            self.source = source_class(self.location_update_cb,
                                       refresh_rate=self.refresh_rate,
                                       quirks=self.args.quirks)
            self.source.initialize()
            return (True, "")
        except Exception as e:
            if autodetect:
                return (False, "")

            if isinstance(e, ModemLockedError):
                self.log_msg("Modem is locked")
                dtext = "Modem is locked. Please unlock the Modem and restart"
            else:
                etext = str(e)
                self.log_msg(f"Error initializing {source_name} source: {etext}")
                dtext = (f"Could not initialize {source_name} source"
                         + (f": {etext}" if etext else ""))

            return (False, dtext)

    def init_source(self, unused):
        sources = {
            # argname: (fullname, class)
            'gnss-share': ('gnss-share', GnssShareNmeaSource),
            'gps-share': ('gps-share', GpsShareNmeaSource),
            'gpsd': ('gpsd', GpsdNmeaSource),
            'mm': ('ModemManager', ModemManagerGLibNmeaSource),
        }

        if self.args.source == 'auto':
            self.log_msg("Detecting NMEA sources...")
            ok = False
            for source in sources.values():
                ok, _dtext = self.try_to_init_source(source, autodetect=True)
                if ok:
                    break

            if not ok:
                self.log_msg('NMEA source not found')
                show_alertdialog(
                    self.window, "Error initializing NMEA source",
                    "Could not find an NMEA source", "OK")
                return GLib.SOURCE_REMOVE
        else:
            self.log_msg(f'NMEA source "{self.args.source}" selected')
            if self.args.source.startswith('replay='):
                nmeafile = self.args.source.split('=')[1].strip('\'" ')
                try:
                    self.source = ReplayNmeaSource(
                        self.location_update_cb,
                        refresh_rate=self.refresh_rate,
                        replay_filename=nmeafile,
                        quirks=self.args.quirks)
                    self.source.initialize()
                    ok, dtext = True, ""
                except NmeaSourceNotFoundError as e:
                    ok, dtext = False, str(e)
            elif self.args.source.startswith('socket='):
                socketfile = self.args.source.split('=')[1].strip('\'" ')
                try:
                    self.source = UnixSocketNmeaSource(
                        self.location_update_cb,
                        refresh_rate=self.refresh_rate,
                        socket_file_path=socketfile,
                        quirks=self.args.quirks)
                    self.source.initialize()
                    ok, dtext = True, ""
                except FileNotFoundError as e:
                    ok, dtext = False, str(e)
            else:
                if self.args.source not in sources:
                    self.log_msg(f"Unknown NMEA source '{self.args.source}'")
                    show_alertdialog(
                        self.window, "Error initializing NMEA source",
                        f"Unknown NMEA source '{self.args.source}'", "OK")
                    return GLib.SOURCE_REMOVE
                ok, dtext = self.try_to_init_source(sources[self.args.source])

            if not ok:
                self.log_msg(f"Error initializing NMEA source: {dtext}")
                show_alertdialog(
                    self.window, "Error initializing NMEA source", dtext, "OK")
                return GLib.SOURCE_REMOVE

        self.log_msg(
            f"Source is {self.source.type}"
            + (f" at '{self.source.path}'" if self.source.path else "")
            + (f", manufacturer {self.source.manufacturer}" if self.source.manufacturer
               else "")
            + (f", model {self.source.model}" if self.source.model else "")
            + (f", revision {self.source.revision}" if self.source.revision else ""))
        self.log_msg(
            f"Quirks enabled: {', '.join(self.source.quirks)}"
            if hasattr(self.source, "quirks") and self.source.quirks
            else "No quirks enabled")

        if (self.source.model and self.source.model.startswith("QUECTEL")):
            constellations = quectel.get_constellations(self.source)
            if constellations is not None:
                self.log_msg("Supported constellations: "
                             + ", ".join(constellations))
            xtradates = quectel.get_xtradata_dates(self.source, fix_week=False)
            if xtradates is not None:
                dt1, dt2 = xtradates
                self.log_msg("XTRA data is valid from %s to %s" % (
                    dt1.isoformat(' ', 'minutes'),
                    dt2.isoformat(' ', 'minutes')))
                self.log_msg("XTRA data is "
                             + ("VALID" if now < dt2 else "NOT valid"))

        self.timeout_source_id = GLib.timeout_add(
            1000 * self.source_timeout / 2, self.timeout_cb, None)

        return GLib.SOURCE_REMOVE

    def set_barchart(self, data):
        return self.window.set_barchart(data)

    def set_values(self, data, console_output=True):
        utcfmt = "%H:%M:%S UTC"

        def to_str(x, fmt="%s"):
            return fmt % x if x is not None else "-"

        def get_actives(xkey):
            actives = str(len(data.get("actives", [])))
            inuse = str(data.get("num_sats", "n/a"))
            return "%s / %s" % (actives, inuse)

        def get_ages(xkey):
            up_age = to_str(data.get("updateage"), "%0.0f s")
            fixage = to_str(data.get("fixage"), "%0.0f s")
            return "%s / %s" % (up_age, fixage)

        def get_dops(xkey):
            pdop = to_str(data.get("pdop"), "%1.1f")
            hdop = to_str(data.get("hdop"), "%1.1f")
            vdop = to_str(data.get("vdop"), "%1.1f")
            return f"{pdop} / {hdop} / {vdop}"

        mode2fix = {
            "2": "2 D",
            "3": "3 D",
        }

        # Mapping: Data key, description, converter func
        order = [
            ("mode", "Fix type", lambda x: mode2fix.get(x, "No Fix")),
            ("mode_indicator", "Modes (GP,GL,GA)",
                lambda x: str(x) if x is not None else "n/a"),
            ("actives", "Active / in use sats", get_actives),
            ("visibles", "Receiving sats", lambda x: str(len(
                [r for r in x if r['snr'] > 0.0]))),
            ("visibles", "Visible sats", lambda x: str(len(x))),
            # ("fixage", "Age of fix", lambda x: to_str(x, "%0.0f s")),
            ("fixage", "Age of update / fix", get_ages),
            ("systime", "Sys. Time", lambda x: x.strftime(utcfmt)),
            ("latlon", "Latitude", lambda x: "%0.6f" % x[0] if x else "-"),
            ("latlon", "Longitude", lambda x: "%0.6f" % x[1] if x else "-"),
            ("altitude", "Altitude", lambda x: to_str(x, "%0.1f m")),
            ("geoid_sep", "Geoidal separation", lambda x: to_str(x, "%0.1f m")),
            # ("fixtime", "Time of fix",
            #     lambda x: x.strftime(utcfmt) if x else "-"),
            # ("date",    "Date of fix",
            #      lambda x: x.strftime("%Y-%m-%d") if x else "-"),
            ("speed", "Speed", lambda x: to_str(x, "%0.1f m/s")),
            ("true_course", "True Course",
                lambda x: to_str(x, "%0.1f deg ")
                + (bearing_to_arrow(x) if x is not None else "")),
            ("pdop", "PDOP/HDOP/VDOP", get_dops),
        ]
        descs = []
        vals = []
        for key, desc, fun in order:
            if key not in data.keys():
                value = "n/a"
            else:
                value = fun(data[key])
            descs.append(desc)
            vals.append(value)

        if self.window is not None:
            if self.window.dataframe.rows != len(descs):
                self.window.dataframe.set_rowtitles(descs)
            self.window.dataframe.set_values(vals)

        if console_output:
            for i, val in enumerate(vals):
                print(f"{descs[i]}: {val}")

    def recording_label_update(self):
        tdelta = self.gpx.get_duration()
        elapsed = round(tdelta.total_seconds())
        hours, rem = divmod(elapsed, 3600)
        mins, secs = divmod(rem, 60)

        meters = self.gpx.get_length()
        dist_str = (f"{str(round(meters))} m" if meters < 1000.0 else
                    f"{(meters / 1000.0):.1f} km")

        self.window.recording_label.set_markup(
            f"<span font-features='tnum'>"
            f"Track: <b>{dist_str}, {hours:02}:{mins:02}:{secs:02}</b>"
            f"</span>")

    def timeout_cb(self, x):
        dt = (time.time() - self.last_update
              if self.last_update else self.source_timeout + 1)
        if dt > self.source_timeout:
            self.window.sensitive(False)
            self.update()
        return GLib.SOURCE_CONTINUE

    def location_update_cb(self, *args):
        self.last_update = time.time()
        self.window.sensitive(True)
        self.update()

    def update(self):
        try:
            nmeas = self.source.get()
            if self.had_error:
                self.log_msg("Getting updates")
                self.window.sensitive(True)

            self.had_error = False
            data = nmea.parse(nmeas)
        except Exception as e:
            nmeas = None
            show_dialog = False
            etext = str(e)
            dtext = None

            if isinstance(e, ModemLockedError):
                dtext = "Please unlock the Modem"
                show_dialog = True
            elif isinstance(e, ModemNoNMEAError):
                dtext = "NMEA info not received with location"
            elif isinstance(e, ModemError):
                dtext = "Modem error: " + str(e)
            elif isinstance(e, NmeaSourceNotFoundError):
                if not self.had_error:
                    dtext = etext if etext else "Modem disappeared"
                    self.had_error = True
                    self.window.sensitive(False)
            else:
                dtext = etext if etext else "Unknown error"

            if not self.had_error:
                if show_dialog:
                    show_alertdialog(self.window, "Error", dtext, "OK")
                elif dtext is not None:
                    self.log_msg(dtext)

            self.had_error = True
            if self.last_data is None:
                return
            else:
                data = self.last_data

        data["updateage"] = ((time.time() - self.last_update)
                             if self.last_update else None)

        barchart = self.set_barchart(data)
        if self.args.console_output:
            print(barchart)

        self.set_values(data, self.args.console_output)

        speed = data['speed']
        bearing = data['true_course']
        self.window.set_speedlabel(speed, bearing)
        if speed and not self.last_speed:
            self.window.infocarousel.scroll_to(self.window.speedlabel, True)
        elif not speed and self.last_speed:
            self.window.infocarousel.scroll_to(self.window.chartlabel, True)
        self.last_speed = speed

        # log
        mode = data["mode"]
        mode = int(mode) if mode else self.last_mode
        if mode != self.last_mode:
            if mode > 1:
                self.log_msg(f"Got lock, mode: {mode}")
            elif mode <= 1:
                self.log_msg("Lock lost")
        self.last_mode = mode

        if (self.gpx is not None
                and data.get("valid", False)
                and data.get("fixage", 1e6) < (2 * self.refresh_rate)):
            self.gpx.update(data)
            self.recording_label_update()

        self.last_data = data
