#!/usr/bin/env python3

"""
APT Update Checker - StatusNotifierItem

Dependencies:
- python3-dbus
- python3-gi

References:
Dbus python API: https://dbus.freedesktop.org/doc/dbus-python/dbus.html
Dbus Spec: https://dbus.freedesktop.org/doc/dbus-specification.html
Dbus status notifier: https://specifications.freedesktop.org/status-notifier-item/latest/

Monitor dbus for testing:
dbus-monitor "interface=org.freedesktop.DBus.Properties,member=PropertiesChanged"


Author: Arjen Balfoort
Date: 2026-06-02
"""

import os
import sys
import subprocess
import threading
import time
import json
import gettext
import re
import signal

from enum import StrEnum
from logging_config import logger
from flatpak_handler import FlatPakInfo
from apt_handler import AptInfo
from update_indicator_monitor import AptCacheMonitor

import dbus
import dbus.service
from dbus.mainloop.glib import DBusGMainLoop

from gi.repository import GLib

_ = gettext.translation('deb-software', fallback=True).gettext

# Auto-reap zombie processes
signal.signal(signal.SIGCHLD, signal.SIG_IGN)


class Urgency(StrEnum):
    """ Package urgency levels. """
    LOW = "low"
    NORMAL = "normal"
    HIGH = "high"

class AptUpdateChecker:
    """ Checks for updates using apt and flatpak. """
    def __init__(self, check_interval: int = 3600):
        self.check_interval: int = check_interval
        self.update_count: int = 0
        self.lock: threading.Lock = threading.Lock()
        self.urgency: Urgency = Urgency.LOW
        self.flatpak_info: FlatPakInfo = FlatPakInfo()
        self.apt_info: AptInfo = AptInfo()

    def start(self, on_update_finished):
        """ Starts the update checker. """
        def worker_flatpak():
            while True:
                flat = self.check_flatpak_updates()
                with self.lock:
                    self.update_count += flat
                on_update_finished()
                time.sleep(self.check_interval)

        threading.Thread(target=worker_flatpak, daemon=True).start()

    def check_apt_updates(self) -> int:
        """ Checks for Apt updates. """
        logger.info("Checking for Apt updates")
        self.urgency = Urgency.LOW

        installed_flatpak_packages = self.flatpak_info.get_installed_flatpak_packages()
        deb_upgrades = self.apt_info.get_apt_updates(installed_flatpak_packages)

        for pkg_name in deb_upgrades:
            if self.urgency == Urgency.LOW:
                if self._get_package_urgency(pkg_name) == Urgency.HIGH:
                    self.urgency = Urgency.HIGH

        return len(deb_upgrades)

    def check_flatpak_updates(self) -> int:
        """ Checks for Flatpak updates. """
        fp_updates = self.flatpak_info.get_flatpak_updates()
        return len(fp_updates)

    def update_combined_count(self, apt_count: int, flatpak_count: int):
        """ Updates the combined update count. """
        with self.lock:
            self.update_count = apt_count + flatpak_count

    @staticmethod
    def _get_package_urgency(pkg_name: str) -> Urgency:
        """
        Returns Urgency.HIGH if the package has urgency=high in its changelog.
        Otherwise returns Urgency.LOW.
        """
        try:
            result = subprocess.run(
                ["apt", "changelog", pkg_name],
                capture_output=True,
                text=True,
                check=True,
            )
        except subprocess.CalledProcessError:
            return Urgency.LOW

        # Only inspect the first few lines — urgency is always in the first stanza
        for line in result.stdout.splitlines()[:10]:
            match = re.search(r"urgency=([a-zA-Z]+)", line)
            if match:
                level = match.group(1).lower()
                if level == "high":
                    return Urgency.HIGH
                return Urgency.LOW

        return Urgency.LOW


class StatusNotifierItem(dbus.service.Object):
    """ org.kde.StatusNotifierItem implementation. """
    PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'
    SNI_IFACE = 'org.kde.StatusNotifierItem'
    WATCHER_BUS_NAME = 'org.kde.StatusNotifierWatcher'
    WATCHER_OBJECT = '/StatusNotifierWatcher'
    WATCHER_IFACE = 'org.kde.StatusNotifierWatcher'

    def __init__(self, bus, object_path, checker, well_known_name):
        logger.info("SNI object created")

        self._registered = False

        self.bus = bus
        self.path = object_path
        self.checker = checker
        self.well_known_name = well_known_name

        basename = self.well_known_name.split('.')[-1]

        # Base SNI properties
        self._properties = {
            'Id': basename,
            'Category': 'SystemServices',
            'Status': 'Active',
            'Title': 'Debian Software Manager',
            'IconName': 'update-none',
            'ToolTip': {
                'icon_name': 'update-none',
                'title': 'Debian Software Manager',
                'description': _("Your system is up to date"),
            },
        }

        dbus.service.Object.__init__(self, bus, object_path)
        GLib.idle_add(self._register_with_watcher)

    # --- org.freedesktop.DBus.Properties ---

    @dbus.service.method(PROPERTIES_IFACE, in_signature='ss', out_signature='v')
    def Get(self, interface, property_name):    # pylint: disable=invalid-name
        """ Returns a property value. """
        if interface != self.SNI_IFACE or property_name not in self._properties:
            raise dbus.exceptions.DBusException(
                'org.freedesktop.DBus.Error.InvalidArgs',
                'Unknown interface or property'
            )
        return self._to_dbus_value(property_name, self._properties[property_name])

    @dbus.service.method(PROPERTIES_IFACE, in_signature='s', out_signature='a{sv}')
    def GetAll(self, interface):    # pylint: disable=invalid-name
        """ Returns all properties. """
        if interface != self.SNI_IFACE:
            raise dbus.exceptions.DBusException(
                'org.freedesktop.DBus.Error.InvalidArgs',
                'Unknown interface'
            )
        result = {}
        for k, v in self._properties.items():
            result[k] = self._to_dbus_value(k, v)
        return dbus.Dictionary(result, signature='sv')

    # --- SNI signals ---

    @dbus.service.signal(PROPERTIES_IFACE, signature='sa{sv}as')
    def PropertiesChanged(self, interface, changed_properties, invalidated_properties):    # pylint: disable=invalid-name
        """ Emitted when a property changes. """
        logger.debug("PropertiesChanged: %s %s %s",
            interface,
            changed_properties,
            invalidated_properties
        )

    @dbus.service.signal(SNI_IFACE, signature='')
    def NewIcon(self):    # pylint: disable=invalid-name
        """Legacy SNI signal: icon changed."""
        pass    # pylint: disable=unnecessary-pass

    @dbus.service.signal(SNI_IFACE, signature='')
    def NewToolTip(self):    # pylint: disable=invalid-name
        """Legacy SNI signal: tooltip changed."""
        pass    # pylint: disable=unnecessary-pass

    # --- SNI methods ---

    @dbus.service.method(SNI_IFACE, in_signature='ii', out_signature='')
    def Activate(self, _x, _y):    # pylint: disable=invalid-name
        """ Activates the SNI. """
        self._launch_software_manager()

    @dbus.service.method(SNI_IFACE, in_signature='ii', out_signature='')
    def SecondaryActivate(self, _x, _y):    # pylint: disable=invalid-name
        """ Activates the SNI. """
        self._launch_software_manager()

    @dbus.service.method(SNI_IFACE, in_signature='ii', out_signature='')
    def ContextMenu(self, _x, _y):    # pylint: disable=invalid-name
        """ Activates the menu. """
        logger.debug("ContextMenu not implemented")  # No menu support anywhere

    # --- Actions ---

    def _launch_software_manager(self):
        try:
            env = os.environ.copy()
            uid = os.getuid()
            env.update({
                'DISPLAY': ':0',  # For X11
                'XAUTHORITY': os.path.expanduser('~/.Xauthority'),
                'XDG_RUNTIME_DIR': f'/run/user/{uid}',
                'WAYLAND_DISPLAY': 'wayland-0',  # For Wayland (Plasma 6 default)
                'DBUS_SESSION_BUS_ADDRESS': os.environ.get(
                    'DBUS_SESSION_BUS_ADDRESS',
                    f'unix:path=/run/user/{uid}/bus'
                ),
            })
            # Detach the child to avoid zombies even if SIGCHLD is ignored
            subprocess.Popen(
                ["/usr/bin/deb-software", "-u"],
                env=env,
                start_new_session=True  # Critical: detaches from parent
            )
        except Exception as e:  # pylint: disable=broad-except
            logger.error("Failed to launch deb-software: %s", e)

    # --- Icon update logic (called from checker thread via GLib.idle_add) ---

    def update_icon(self):
        """ Updates the SNI icon. """
        with self.checker.lock:
            count = self.checker.update_count

        new_icon = (
            'update-none' if count == 0
            else 'update-high' if self.checker.urgency != Urgency.LOW
            else 'update-low'
        )

        desc = (
            _("Your system is up to date") if count == 0
            else _("You have 1 update") if count == 1
            else _("You have %d updates") % count
        )

        old_icon = self._properties['IconName']
        old_desc = self._properties['ToolTip']['description']

        if old_icon == new_icon and old_desc == desc:
            return

        logger.info("Update status changed")
        self._properties['IconName'] = new_icon
        self._properties['ToolTip']['icon_name'] = new_icon
        self._properties['ToolTip']['description'] = desc

        # Always try PropertiesChanged first
        try:
            self._emit_properties_changed(['IconName', 'ToolTip'])
            logger.info("PropertiesChanged emitted successfully")

            # Legacy SNI signals – important for some panels (e.g. Xfce)
            self.NewIcon()
            self.NewToolTip()
        except Exception as e:  # pylint: disable=broad-except
            logger.warning("PropertiesChanged failed, falling back to re-registration: %s", e)
            self._register_with_watcher()

    def _build_tooltip_struct(self):
        return dbus.Struct((
            dbus.String(self._properties['ToolTip']['icon_name']),
            dbus.Array([], signature='(iiay)'),
            dbus.String(self._properties['ToolTip']['title']),
            dbus.String(self._properties['ToolTip']['description']),
        ), signature='s(iiay)ss')  # Add explicit signature

    def _emit_properties_changed(self, keys):
        changed = {}
        for k in keys:
            if k == 'ToolTip':
                changed['ToolTip'] = self._build_tooltip_struct()
            else:
                val = self._properties.get(k)
                if isinstance(val, bool):
                    changed[k] = dbus.Boolean(val)
                elif isinstance(val, int):
                    changed[k] = dbus.Int32(val)
                else:
                    changed[k] = dbus.String(str(val))

        changed_dict = dbus.Dictionary(changed, signature='sv')
        invalidated = dbus.Array([], signature='s')
        try:
            self.PropertiesChanged(self.SNI_IFACE, changed_dict, invalidated)
        except Exception as e:  # pylint: disable=broad-except
            logger.error("Failed to emit PropertiesChanged: %s", e)

    # --- Registration ---

    def _register_with_watcher(self):
        if self._registered:
            return

        logger.info("Attempting to register with StatusNotifierWatcher…")

        retries = 0
        max_retries = 30

        def try_register():
            nonlocal retries

            # Stop retrying after max_retries
            if retries >= max_retries:
                logger.error(
                    "Failed to register with StatusNotifierWatcher after %d retries",
                    retries
                )
                return False

            try:
                # Try to get the watcher
                watcher = self.bus.get_object(
                    self.WATCHER_BUS_NAME,
                    self.WATCHER_OBJECT
                )
                watcher_iface = dbus.Interface(
                    watcher,
                    dbus_interface=self.WATCHER_IFACE
                )

                # Attempt registration
                watcher_iface.RegisterStatusNotifierItem(self.path)

                logger.info(
                    "Successfully registered SNI %s after %d retries",
                    self.path,
                    retries
                )
                self._registered = True
                return False  # Stop retrying

            except dbus.DBusException:
                # Watcher not available yet
                retries += 1
                return True  # Retry again

        # First attempt immediately
        if try_register():
            # Retry every 2 seconds until success or timeout
            GLib.timeout_add_seconds(2, try_register)

    # --- DBus value helpers ---

    def _to_dbus_value(self, name, value):
        if name == 'IconPixmap':
            return dbus.Array([], signature='(iiay)')
        if name == 'ToolTip':
            return dbus.Struct((
                dbus.String(value['icon_name']),
                dbus.Array([], signature='(iiay)'),
                dbus.String(value['title']),
                dbus.String(value['description']),
            ), signature='s(iiay)ss')  # Add explicit signature
        if isinstance(value, bool):
            return dbus.Boolean(value)
        if isinstance(value, int):
            return dbus.Int32(value)
        return dbus.String(str(value))


def main():
    """ Main function. """
    logger.info("SNI starting up…")

    # Get check interval from config
    check_interval = 0
    script_dir: str = os.path.abspath(os.path.dirname(__file__))
    save_dir = os.path.expanduser("~/.config/deb-software")
    try:
        # If config.json exists in the user directory, use that
        user_config = os.path.join(save_dir, "config.json")
        if os.path.exists(user_config):
            with open(file=user_config,
                      mode="r",
                      encoding="utf-8") as f:
                config = json.load(f)
                check_interval = config["check_interval"]
        else:
            # Load default configuration from json
            with open(
                file=os.path.join(script_dir, "data", "config.json"),
                mode="r",
                encoding="utf-8"
            ) as f:
                config = json.load(f)
                check_interval = config["check_interval"]
    except Exception as e:  # pylint: disable=broad-except
        logger.error("Failed to load config: %s", e)

    if check_interval == 0:
        logger.info("Check interval is 0, exiting.")
        sys.exit(0)

    DBusGMainLoop(set_as_default=True)
    bus = dbus.SessionBus()
    well_known_name = 'org.debsoftware.Indicator'
    try:
        # request a well-known name so plasmashell can associate the SNI with an application name
        bus.request_name(well_known_name)
    except Exception: # pylint: disable=broad-except
        # ignore if name cannot be requested
        pass
    object_path = '/org/debsoftware/Indicator'

    checker = AptUpdateChecker(check_interval)
    sni = StatusNotifierItem(bus, object_path, checker, well_known_name)

    # Initial Apt + Flatpak check (synchronous)
    apt = checker.check_apt_updates()
    flat = checker.check_flatpak_updates()
    checker.update_combined_count(apt, flat)
    GLib.idle_add(sni.update_icon)

    # Start Flatpak interval thread
    checker.start(lambda: GLib.idle_add(sni.update_icon))

    # callback invoked when apt cache update is detected
    def on_apt_cache_changed():
        def worker():
            apt = checker.check_apt_updates()
            flat = checker.check_flatpak_updates()  # optional: keep Flatpak fresh
            checker.update_combined_count(apt, flat)
            GLib.idle_add(sni.update_icon)
        threading.Thread(target=worker, daemon=True).start()

    # Start Apt monitor
    monitor = AptCacheMonitor(on_change=on_apt_cache_changed)
    monitor.start()

    loop = GLib.MainLoop()
    try:
        loop.run()
    except KeyboardInterrupt:
        loop.quit()


if __name__ == "__main__":
    main()
