#!/usr/bin/env python3
#--------------------------------------------------------------------------------------------------------
# Name: Linux Lite - Lite Update Tray
# Architecture: amd64
# Author: Jerry Bezencon
# Website: https://www.linuxliteos.com
# Language: Python/GTK3 (AyatanaAppIndicator3)
# Licence: GPLv2
#
# Tiny system-tray daemon that surfaces apt update state in the XFCE panel
# via StatusNotifier (Ayatana AppIndicator). Two icon states:
#   - White outline = no updates pending
#   - Green outline = updates available
# Polls `apt-get -s dist-upgrade` every <interval> (configurable; default
# 1 h 0 min, minimum 15 min, minutes in 15-min steps) against the cached
# package state — no root, no apt lock, no network traffic. Cache itself
# is refreshed by apt-daily.timer or by the user running apt-get update.
#
# Click the tray icon (left or right) → only menu item is "Configure…",
# which opens a window with:
#   - update status (count, security count split, last-checked, cache age)
#   - reboot-required banner when /var/run/reboot-required exists
#   - expandable list of pending packages
#   - live 1 Hz Down/Up area chart for the default-route interface
#   - hours + minutes (15-min steps) spinners to change polling interval
#   - "Notify me when new updates appear" toggle (notify-send)
#   - Pause checks for 1 h / 4 h / Until tomorrow morning
#--------------------------------------------------------------------------------------------------------

import gi
gi.require_version('Gtk', '3.0')
gi.require_version('AyatanaAppIndicator3', '0.1')

from gi.repository import Gtk, AyatanaAppIndicator3, GLib, Gio, Gdk

import os
import re
import json
import time
import signal
import subprocess
import datetime
from pathlib import Path

APP_ID = "lite-update-tray"
ICON_DIR = "/usr/share/lite-update-tray/icons"
ICON_IDLE = os.path.join(ICON_DIR, "lite-update-tray-idle.svg")
ICON_UPDATES = os.path.join(ICON_DIR, "lite-update-tray-updates.svg")

CONFIG_DIR = Path.home() / ".config" / "lite-update-tray"
CONFIG_FILE = CONFIG_DIR / "config.json"

DEFAULTS = {
    "interval_hours": 1,
    "interval_minutes": 0,
    "notify_on_new": True,
    "snooze_until": 0,
}
MIN_INTERVAL_SECONDS = 15 * 60   # 15 minutes is the smallest allowed gap
MINUTE_STEP = 15                 # only 0, 15, 30, 45 are valid minute values

REBOOT_REQUIRED_FILE = "/var/run/reboot-required"
APT_UPDATE_STAMP = "/var/lib/apt/periodic/update-success-stamp"
APT_LISTS_DIR = "/var/lib/apt/lists"
DPKG_LOG = "/var/log/dpkg.log"
DPKG_DEBOUNCE_SECONDS = 5

GRAPH_WINDOW_SECONDS = 60

INST_RE = re.compile(r'^Inst (\S+) (?:\[([^\]]+)\] )?\((\S+) ([^)]+)\)')


# ---------- helpers ----------

def snap_minutes(m):
    """Round a minute value to the nearest valid step (0, 15, 30, 45)."""
    m = max(0, min(45, int(m)))
    return int(round(m / MINUTE_STEP) * MINUTE_STEP)


def load_config():
    try:
        with open(CONFIG_FILE) as f:
            cfg = json.load(f)
        merged = dict(DEFAULTS)
        for k, v in cfg.items():
            if k in DEFAULTS:
                merged[k] = v
        # normalise minutes
        merged['interval_minutes'] = snap_minutes(merged.get('interval_minutes', 0))
        return merged
    except Exception:
        return dict(DEFAULTS)


def save_config(cfg):
    try:
        CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        with open(CONFIG_FILE, 'w') as f:
            json.dump(cfg, f, indent=2)
    except Exception as e:
        print(f"[lite-update-tray] save_config failed: {e}", flush=True)


def interval_seconds(cfg):
    s = int(cfg['interval_hours']) * 3600 + int(cfg['interval_minutes']) * 60
    return max(MIN_INTERVAL_SECONDS, s)


def default_iface():
    try:
        r = subprocess.run(['ip', '-o', 'route', 'show', 'default'],
                           capture_output=True, text=True, timeout=3)
        for line in r.stdout.splitlines():
            m = re.search(r' dev (\S+)', line)
            if m:
                return m.group(1)
    except Exception:
        pass
    try:
        for name in sorted(os.listdir('/sys/class/net')):
            if name == 'lo':
                continue
            try:
                with open(f'/sys/class/net/{name}/operstate') as f:
                    if f.read().strip() == 'up':
                        return name
            except Exception:
                continue
    except Exception:
        pass
    return None


def iface_kind(name):
    if not name:
        return ""
    if os.path.isdir(f'/sys/class/net/{name}/wireless'):
        return "Wi-Fi"
    if name.startswith(('docker', 'br-', 'virbr')):
        return "Bridge"
    if name.startswith(('tun', 'tap', 'wg')):
        return "VPN"
    return "Ethernet"


def iface_counters(name):
    try:
        with open(f'/sys/class/net/{name}/statistics/rx_bytes') as f:
            rx = int(f.read().strip())
        with open(f'/sys/class/net/{name}/statistics/tx_bytes') as f:
            tx = int(f.read().strip())
        return rx, tx
    except Exception:
        return None, None


def fmt_rate(bps):
    units = ['B/s', 'KB/s', 'MB/s', 'GB/s']
    i = 0
    v = float(bps)
    while v >= 1024 and i < len(units) - 1:
        v /= 1024
        i += 1
    return f"{v:.0f} {units[i]}" if v >= 100 else f"{v:.2f} {units[i]}"


def fmt_bytes(b):
    units = ['B', 'KB', 'MB', 'GB', 'TB']
    i = 0
    v = float(b)
    while v >= 1024 and i < len(units) - 1:
        v /= 1024
        i += 1
    return f"{v:.0f} {units[i]}" if v >= 10 else f"{v:.1f} {units[i]}"


def cache_age_seconds():
    for p in (APT_UPDATE_STAMP, APT_LISTS_DIR):
        try:
            return time.time() - os.path.getmtime(p)
        except Exception:
            continue
    return None


def fmt_age(seconds):
    if seconds is None:
        return "never"
    s = int(seconds)
    if s < 60:
        return f"{s} s ago"
    m = s // 60
    if m < 60:
        return f"{m} min ago"
    h = m // 60
    if h < 48:
        return f"{h} h ago"
    return f"{h // 24} days ago"


def reboot_required_reason():
    try:
        if os.path.exists(REBOOT_REQUIRED_FILE):
            with open(REBOOT_REQUIRED_FILE) as f:
                txt = f.read().strip()
            return txt or "A previous update needs a reboot to complete."
    except Exception:
        pass
    return None


# ---------- network graph widget ----------

class GraphWidget(Gtk.DrawingArea):
    def __init__(self):
        super().__init__()
        self.set_size_request(-1, 120)
        self.connect("draw", self._on_draw)
        self.samples_down = [0.0] * GRAPH_WINDOW_SECONDS
        self.samples_up = [0.0] * GRAPH_WINDOW_SECONDS

    def push(self, down_bps, up_bps):
        self.samples_down.append(float(down_bps))
        self.samples_up.append(float(up_bps))
        self.samples_down = self.samples_down[-GRAPH_WINDOW_SECONDS:]
        self.samples_up = self.samples_up[-GRAPH_WINDOW_SECONDS:]
        self.queue_draw()

    def _on_draw(self, _w, cr):
        alloc = self.get_allocation()
        w, h = alloc.width, alloc.height
        cr.set_source_rgb(0.17, 0.17, 0.17)
        cr.rectangle(0, 0, w, h)
        cr.fill()

        cr.set_source_rgb(0.23, 0.23, 0.23)
        cr.set_line_width(1)
        for y in (h * 0.25, h * 0.5, h * 0.75):
            cr.move_to(0, y)
            cr.line_to(w, y)
            cr.stroke()

        peak = max(max(self.samples_down + self.samples_up + [1.0]), 1.0)

        def scale(v):
            return h - (v / peak) * (h - 10)

        n = max(len(self.samples_down), 2)
        step = w / (n - 1)

        def draw_series(samples, fill_rgba, stroke_rgb):
            cr.set_source_rgba(*fill_rgba)
            cr.move_to(0, h)
            for i, v in enumerate(samples):
                cr.line_to(i * step, scale(v))
            cr.line_to(w, h)
            cr.close_path()
            cr.fill()
            cr.set_source_rgb(*stroke_rgb)
            cr.set_line_width(1.6)
            cr.move_to(0, scale(samples[0]))
            for i, v in enumerate(samples):
                cr.line_to(i * step, scale(v))
            cr.stroke()

        # Down: Material green-500. Up: Material purple-400 — keeps the
        # two lines visually distinct without colliding with the orange
        # "updates available" pill or the red reboot banner.
        draw_series(self.samples_down, (0.30, 0.69, 0.31, 0.22), (0.30, 0.69, 0.31))
        draw_series(self.samples_up,   (0.67, 0.36, 0.86, 0.20), (0.67, 0.36, 0.86))


# ---------- configure window ----------

class ConfigWindow(Gtk.Window):
    def __init__(self, app):
        super().__init__(title="Lite Update Tray")
        self.app = app
        self.set_default_size(540, -1)
        self.set_icon_name("lite-update-tray")
        self.set_resizable(False)
        self.connect("delete-event", self._on_delete)
        self._suppress_freq_signal = False

        self.iface = default_iface()
        self.last_rx = None
        self.last_tx = None
        self.last_sample_time = None
        self.session_rx = 0
        self.session_tx = 0
        self.graph_timer_id = None

        self._install_css()
        self._build()

    def _install_css(self):
        css = b"""
        .card { background: shade(@theme_bg_color, 1.08);
                border: 1px solid alpha(@theme_fg_color, 0.10);
                border-radius: 8px; }
        .section-title { font-size: 10px; letter-spacing: 0.06em;
                         opacity: 0.65; margin-top: 6px; }
        .h { font-weight: 600; font-size: 14px; }
        .sub, .muted { opacity: 0.65; font-size: 12px; }
        .devname { font-weight: 600; }
        .pill { min-width: 36px; min-height: 36px;
                border-radius: 18px; font-weight: 700; font-size: 14px; }
        .pill-good { background: alpha(#43a047, 0.18); color: #43a047; }
        .pill-warn { background: alpha(#fb8c00, 0.18); color: #fb8c00; }
        .reboot-bar { background: alpha(#e53935, 0.14);
                      border-radius: 6px; padding: 8px; }
        .reboot-bar label { color: #e53935; font-weight: 600; }
        .security-tag { color: #e53935; font-size: 11px;
                        font-weight: 600; padding: 0 6px; }
        """
        prov = Gtk.CssProvider()
        prov.load_from_data(css)
        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(), prov,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

    def _build(self):
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        outer.set_margin_start(18)
        outer.set_margin_end(18)
        outer.set_margin_top(14)
        outer.set_margin_bottom(14)

        # ---- reboot banner (hidden until needed) ----
        self.reboot_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self.reboot_bar.get_style_context().add_class("reboot-bar")
        self.reboot_bar.set_no_show_all(True)
        rb_icon = Gtk.Image.new_from_icon_name("system-reboot-symbolic", Gtk.IconSize.MENU)
        self.reboot_bar.pack_start(rb_icon, False, False, 4)
        self.reboot_label = Gtk.Label(label="")
        self.reboot_label.set_xalign(0)
        self.reboot_label.set_line_wrap(True)
        self.reboot_bar.pack_start(self.reboot_label, True, True, 0)
        outer.pack_start(self.reboot_bar, False, False, 0)

        # ---- status card ----
        status = Gtk.Frame()
        status.get_style_context().add_class("card")
        srow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        srow.set_margin_start(12)
        srow.set_margin_end(12)
        srow.set_margin_top(12)
        srow.set_margin_bottom(12)
        self.status_pill = Gtk.Label(label="✓")
        self.status_pill.get_style_context().add_class("pill")
        self.status_pill.get_style_context().add_class("pill-good")
        srow.pack_start(self.status_pill, False, False, 0)
        vb = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        self.status_h = Gtk.Label(label="Up to date")
        self.status_h.set_xalign(0)
        self.status_h.get_style_context().add_class("h")
        self.status_s = Gtk.Label(label="—")
        self.status_s.set_xalign(0)
        self.status_s.set_line_wrap(True)
        self.status_s.get_style_context().add_class("sub")
        vb.pack_start(self.status_h, False, False, 0)
        vb.pack_start(self.status_s, False, False, 0)
        srow.pack_start(vb, True, True, 0)
        self.open_btn = Gtk.Button(label="Open Lite Updates")
        self.open_btn.connect("clicked", self._on_open_lite_updates)
        self.open_btn.set_no_show_all(True)
        srow.pack_end(self.open_btn, False, False, 0)
        status.add(srow)
        outer.pack_start(status, False, False, 0)

        # ---- pending packages disclosure ----
        self.pending_exp = Gtk.Expander(label="Show pending packages")
        self.pending_exp.set_no_show_all(True)
        self.pending_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.pending_box.set_margin_top(6)
        self.pending_box.set_margin_start(6)
        self.pending_box.set_margin_end(6)
        scroller = Gtk.ScrolledWindow()
        scroller.set_min_content_height(80)
        scroller.set_max_content_height(180)
        scroller.set_propagate_natural_height(True)
        scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scroller.add(self.pending_box)
        self.pending_exp.add(scroller)
        outer.pack_start(self.pending_exp, False, False, 0)

        # ---- network section ----
        outer.pack_start(self._section_title("NETWORK"), False, False, 0)
        net = Gtk.Frame()
        net.get_style_context().add_class("card")
        nb = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        nb.set_margin_start(12)
        nb.set_margin_end(12)
        nb.set_margin_top(12)
        nb.set_margin_bottom(12)
        nh = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        # Icon swaps in populate() based on iface_kind: wired vs Wi-Fi vs VPN.
        self.dev_icon = Gtk.Image.new_from_icon_name("network-wired-symbolic",
                                                    Gtk.IconSize.MENU)
        nh.pack_start(self.dev_icon, False, False, 0)
        self.dev_name_lbl = Gtk.Label(label="—")
        self.dev_name_lbl.get_style_context().add_class("devname")
        nh.pack_start(self.dev_name_lbl, False, False, 0)
        self.dev_kind_lbl = Gtk.Label(label="")
        self.dev_kind_lbl.get_style_context().add_class("muted")
        nh.pack_start(self.dev_kind_lbl, False, False, 0)
        rng = Gtk.Label(label="Last 60 seconds")
        rng.get_style_context().add_class("muted")
        rng.set_xalign(1)
        nh.pack_end(rng, False, False, 0)
        nb.pack_start(nh, False, False, 0)
        self.graph = GraphWidget()
        nb.pack_start(self.graph, False, False, 0)
        lg = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14)
        self.down_lbl = Gtk.Label()
        self.down_lbl.set_xalign(0)
        self.down_lbl.set_markup(self._down_markup("0 B/s"))
        self.up_lbl = Gtk.Label()
        self.up_lbl.set_xalign(0)
        self.up_lbl.set_markup(self._up_markup("0 B/s"))
        self.session_lbl = Gtk.Label(label="Session ↓ 0 B · ↑ 0 B")
        self.session_lbl.get_style_context().add_class("muted")
        self.session_lbl.set_xalign(1)
        lg.pack_start(self.down_lbl, False, False, 0)
        lg.pack_start(self.up_lbl, False, False, 0)
        lg.pack_end(self.session_lbl, True, True, 0)
        nb.pack_start(lg, False, False, 0)
        net.add(nb)
        outer.pack_start(net, False, False, 0)

        # ---- frequency ----
        outer.pack_start(self._section_title("CHECK FOR UPDATES EVERY"), False, False, 0)
        fcard = Gtk.Frame()
        fcard.get_style_context().add_class("card")
        fb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14)
        fb.set_margin_start(12)
        fb.set_margin_end(12)
        fb.set_margin_top(12)
        fb.set_margin_bottom(12)

        h_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        h_box.pack_start(self._field_label("Hours"), False, False, 0)
        self.hours_spin = Gtk.SpinButton.new_with_range(0, 23, 1)
        self.hours_spin.set_value(self.app.config['interval_hours'])
        self.hours_spin.connect("value-changed", self._on_freq_changed)
        h_box.pack_start(self.hours_spin, False, False, 0)
        fb.pack_start(h_box, False, False, 0)

        m_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        m_box.pack_start(self._field_label("Minutes (15-min steps)"), False, False, 0)
        # range 0–45, step 15 → spinner arrows produce 0, 15, 30, 45
        self.minutes_spin = Gtk.SpinButton.new_with_range(0, 45, MINUTE_STEP)
        self.minutes_spin.set_snap_to_ticks(True)
        self.minutes_spin.set_numeric(True)
        self.minutes_spin.set_increments(MINUTE_STEP, MINUTE_STEP)
        self.minutes_spin.set_value(snap_minutes(self.app.config['interval_minutes']))
        self.minutes_spin.connect("value-changed", self._on_freq_changed)
        m_box.pack_start(self.minutes_spin, False, False, 0)
        fb.pack_start(m_box, False, False, 0)

        self.freq_help = Gtk.Label(label="")
        self.freq_help.set_xalign(1)
        self.freq_help.set_line_wrap(True)
        self.freq_help.get_style_context().add_class("muted")
        fb.pack_end(self.freq_help, True, True, 0)
        fcard.add(fb)
        outer.pack_start(fcard, False, False, 0)

        # ---- notify toggle ----
        nt = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
        nt.set_margin_top(4)
        nt_lbl = Gtk.Label(label="Notify me when new updates appear")
        nt_lbl.set_xalign(0)
        nt.pack_start(nt_lbl, True, True, 0)
        self.notify_switch = Gtk.Switch()
        self.notify_switch.set_active(self.app.config['notify_on_new'])
        self.notify_switch.connect("notify::active", self._on_notify_toggled)
        nt.pack_end(self.notify_switch, False, False, 0)
        outer.pack_start(nt, False, False, 0)

        # ---- footer ----
        footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        footer.set_margin_top(8)

        self.pause_btn = Gtk.MenuButton(label="Pause checks")
        pause_menu = Gtk.Menu()
        for label, secs in (
            ("1 hour", 3600),
            ("4 hours", 4 * 3600),
            ("Until tomorrow morning", None),
        ):
            mi = Gtk.MenuItem(label=label)
            mi.connect("activate", self._on_pause_activate, secs)
            pause_menu.append(mi)
        pause_menu.append(Gtk.SeparatorMenuItem())
        self.resume_item = Gtk.MenuItem(label="Resume now")
        self.resume_item.connect("activate", self._on_resume)
        pause_menu.append(self.resume_item)
        pause_menu.show_all()
        self.pause_btn.set_popup(pause_menu)
        footer.pack_start(self.pause_btn, False, False, 0)

        close_btn = Gtk.Button(label="Close")
        close_btn.connect("clicked", lambda *_: self._on_delete())
        footer.pack_end(close_btn, False, False, 0)
        outer.pack_start(footer, False, False, 0)

        self.add(outer)

    def _field_label(self, text):
        l = Gtk.Label(label=text)
        l.set_xalign(0)
        l.get_style_context().add_class("muted")
        return l

    def _section_title(self, text):
        l = Gtk.Label(label=text)
        l.set_xalign(0)
        l.get_style_context().add_class("section-title")
        return l

    # Colour swatches must match the graph line colours in _on_draw.
    @staticmethod
    def _down_markup(rate_text):
        return (f"<span foreground='#4caf50' size='large'>■</span>"
                f"  Down {GLib.markup_escape_text(rate_text)}")

    @staticmethod
    def _up_markup(rate_text):
        return (f"<span foreground='#aa5ddc' size='large'>■</span>"
                f"  Up {GLib.markup_escape_text(rate_text)}")

    # ---- refresh from app state ----
    def populate(self):
        snoozed = self.app.snoozed_until > time.time()
        last_age = (time.time() - self.app.last_poll_ts) if self.app.last_poll_ts else None

        if snoozed:
            until = datetime.datetime.fromtimestamp(self.app.snoozed_until)
            self.status_pill.set_label("⏸")
            self.status_pill.get_style_context().remove_class("pill-warn")
            self.status_pill.get_style_context().add_class("pill-good")
            self.status_h.set_text("Checks paused")
            self.status_s.set_text(f"Will resume at {until.strftime('%H:%M on %a %d %b')}")
            self.open_btn.hide()
        elif self.app.last_count > 0:
            total = self.app.last_count
            sec = self.app.last_security_count
            self.status_pill.set_label(str(total))
            self.status_pill.get_style_context().remove_class("pill-good")
            self.status_pill.get_style_context().add_class("pill-warn")
            head = f"{total} update{'s' if total != 1 else ''} available"
            if sec:
                head += f"  ·  {sec} security"
            self.status_h.set_text(head)
            self.status_s.set_text(
                f"Last checked {fmt_age(last_age)}  ·  apt cache {fmt_age(cache_age_seconds())}"
            )
            self.open_btn.show()
        else:
            self.status_pill.set_label("✓")
            self.status_pill.get_style_context().remove_class("pill-warn")
            self.status_pill.get_style_context().add_class("pill-good")
            self.status_h.set_text("Up to date")
            self.status_s.set_text(
                f"Last checked {fmt_age(last_age)}  ·  apt cache {fmt_age(cache_age_seconds())}"
            )
            self.open_btn.hide()

        # reboot banner
        reason = reboot_required_reason()
        if reason:
            self.reboot_label.set_text(reason)
            self.reboot_bar.show_all()
        else:
            self.reboot_bar.hide()

        # pending packages
        for child in self.pending_box.get_children():
            self.pending_box.remove(child)
        pkgs = self.app.last_pending
        if pkgs and not snoozed:
            for name, ver, is_sec in pkgs:
                row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
                row.set_margin_top(2)
                row.set_margin_bottom(2)
                nl = Gtk.Label(label=name)
                nl.set_xalign(0)
                row.pack_start(nl, True, True, 0)
                if is_sec:
                    tag = Gtk.Label(label="security")
                    tag.get_style_context().add_class("security-tag")
                    row.pack_start(tag, False, False, 0)
                vl = Gtk.Label(label=ver)
                vl.set_xalign(1)
                vl.get_style_context().add_class("muted")
                row.pack_end(vl, False, False, 0)
                self.pending_box.pack_start(row, False, False, 0)
            self.pending_box.show_all()
            self.pending_exp.set_label(
                f"Show {len(pkgs)} pending package{'s' if len(pkgs) != 1 else ''}"
            )
            self.pending_exp.show_all()
        else:
            self.pending_exp.hide()

        # network header
        if self.iface:
            kind = iface_kind(self.iface)
            self.dev_name_lbl.set_text(self.iface)
            self.dev_kind_lbl.set_text(f"·  {kind}  ·  active")
            icon_for_kind = {
                "Wi-Fi": "network-wireless-symbolic",
                "Ethernet": "network-wired-symbolic",
                "VPN": "network-vpn-symbolic",
                "Bridge": "network-workgroup-symbolic",
            }.get(kind, "network-wired-symbolic")
            self.dev_icon.set_from_icon_name(icon_for_kind, Gtk.IconSize.MENU)
        else:
            self.dev_name_lbl.set_text("(no active interface)")
            self.dev_kind_lbl.set_text("")
            self.dev_icon.set_from_icon_name("network-offline-symbolic",
                                             Gtk.IconSize.MENU)

        self._refresh_freq_help()

    def _refresh_freq_help(self):
        cfg = self.app.config
        s = interval_seconds(cfg)
        snoozed = self.app.snoozed_until > time.time()
        if snoozed:
            r_txt = "paused"
        else:
            next_at = (self.app.last_poll_ts or time.time()) + s
            remaining = max(0, int(next_at - time.time()))
            if remaining >= 3600:
                r_txt = f"{remaining // 3600} h {(remaining % 3600) // 60} min"
            else:
                r_txt = f"{remaining // 60} min"
        h = int(cfg['interval_hours'])
        m = int(cfg['interval_minutes'])
        cur = (f"{h} h {m} min" if h else f"{m} min")
        self.freq_help.set_markup(
            f"Next check in <b>{GLib.markup_escape_text(r_txt)}</b>\n"
            f"Currently every <b>{GLib.markup_escape_text(cur)}</b>"
        )

    # ---- callbacks ----
    def _on_open_lite_updates(self, *_):
        try:
            subprocess.Popen(["lite-updates"], start_new_session=True)
        except Exception as e:
            print(f"[lite-update-tray] launch lite-updates failed: {e}", flush=True)

    def _on_freq_changed(self, *_):
        if self._suppress_freq_signal:
            return
        h = int(self.hours_spin.get_value())
        m_raw = int(self.minutes_spin.get_value())
        m = snap_minutes(m_raw)
        # 15-minute floor when hours == 0
        if h == 0 and m == 0:
            m = MINUTE_STEP
        if m != m_raw:
            self._suppress_freq_signal = True
            self.minutes_spin.set_value(m)
            self._suppress_freq_signal = False
        self.app.config['interval_hours'] = h
        self.app.config['interval_minutes'] = m
        save_config(self.app.config)
        self.app.reschedule_timer()
        self._refresh_freq_help()

    def _on_notify_toggled(self, sw, _):
        self.app.config['notify_on_new'] = sw.get_active()
        save_config(self.app.config)

    def _on_pause_activate(self, _item, secs):
        if secs is None:
            now = datetime.datetime.now()
            target = now.replace(hour=9, minute=0, second=0, microsecond=0)
            if target <= now:
                target += datetime.timedelta(days=1)
            ts = target.timestamp()
        else:
            ts = time.time() + secs
        self.app.set_snooze(ts)
        self.populate()

    def _on_resume(self, *_):
        self.app.set_snooze(0)
        self.app.poll_now()
        self.populate()

    # ---- graph timer ----
    def start_graph(self):
        if self.graph_timer_id is None:
            self.iface = default_iface()
            self.last_rx = self.last_tx = None
            self.last_sample_time = None
            self.session_rx = self.session_tx = 0
            self.graph.samples_down = [0.0] * GRAPH_WINDOW_SECONDS
            self.graph.samples_up = [0.0] * GRAPH_WINDOW_SECONDS
            self.graph_timer_id = GLib.timeout_add_seconds(1, self._tick_graph)
            self._tick_graph()

    def stop_graph(self):
        if self.graph_timer_id is not None:
            GLib.source_remove(self.graph_timer_id)
            self.graph_timer_id = None

    def _tick_graph(self):
        if not self.iface:
            return True
        rx, tx = iface_counters(self.iface)
        if rx is None:
            return True
        now = time.time()
        if (self.last_rx is not None and self.last_tx is not None
                and self.last_sample_time is not None):
            dt = max(0.001, now - self.last_sample_time)
            d_rx = max(0, rx - self.last_rx)
            d_tx = max(0, tx - self.last_tx)
            down_bps = d_rx / dt
            up_bps = d_tx / dt
            self.session_rx += d_rx
            self.session_tx += d_tx
            self.graph.push(down_bps, up_bps)
            self.down_lbl.set_markup(self._down_markup(fmt_rate(down_bps)))
            self.up_lbl.set_markup(self._up_markup(fmt_rate(up_bps)))
            self.session_lbl.set_text(
                f"Session ↓ {fmt_bytes(self.session_rx)}  ·  ↑ {fmt_bytes(self.session_tx)}"
            )
        self.last_rx, self.last_tx, self.last_sample_time = rx, tx, now
        return True

    def _on_delete(self, *_):
        self.hide()
        self.stop_graph()
        return True


# ---------- tray daemon ----------

class LiteUpdateTray:
    def __init__(self):
        self.config = load_config()
        self.indicator = AyatanaAppIndicator3.Indicator.new(
            APP_ID, ICON_IDLE,
            AyatanaAppIndicator3.IndicatorCategory.SYSTEM_SERVICES,
        )
        self.indicator.set_status(AyatanaAppIndicator3.IndicatorStatus.ACTIVE)
        self.indicator.set_title("Linux Lite Updates")
        self.indicator.set_icon_full(ICON_IDLE, "No updates available")

        menu = Gtk.Menu()
        configure_item = Gtk.MenuItem(label="Configure…")
        configure_item.connect("activate", self._on_configure)
        menu.append(configure_item)
        menu.show_all()
        self.indicator.set_menu(menu)
        # Some hosts honour this for left/middle-click — opens Configure
        # without forcing the user through the menu.
        self.indicator.set_secondary_activate_target(configure_item)

        self.config_window = None
        self.last_count = 0
        self.last_security_count = 0
        self.last_pending = []
        self.last_poll_ts = 0

        self.poll_timer_id = None
        self.reschedule_timer()
        GLib.timeout_add_seconds(5, self._first_check)

        self._dpkg_debounce_id = None
        try:
            log_file = Gio.File.new_for_path(DPKG_LOG)
            self._dpkg_monitor = log_file.monitor_file(Gio.FileMonitorFlags.NONE, None)
            self._dpkg_monitor.connect("changed", self._on_dpkg_log_changed)
        except Exception as e:
            print(f"[lite-update-tray] could not watch {DPKG_LOG}: {e}", flush=True)

        if self.snoozed_until > time.time():
            self.indicator.set_title("Linux Lite Updates — Paused")

    @property
    def snoozed_until(self):
        return self.config.get('snooze_until', 0) or 0

    def set_snooze(self, ts):
        self.config['snooze_until'] = ts
        save_config(self.config)
        if ts > time.time():
            self.indicator.set_title("Linux Lite Updates — Paused")
        else:
            if self.last_count > 0:
                self._set_updates(self.last_count, self.last_security_count)
            else:
                self._set_idle()

    def reschedule_timer(self):
        if self.poll_timer_id is not None:
            GLib.source_remove(self.poll_timer_id)
        secs = interval_seconds(self.config)
        self.poll_timer_id = GLib.timeout_add_seconds(secs, self._poll_updates)

    def poll_now(self):
        self._poll_updates()

    def _first_check(self):
        self._poll_updates()
        return False

    def _on_configure(self, *_):
        if self.config_window is None:
            self.config_window = ConfigWindow(self)
        self.config_window.show_all()
        self.config_window.populate()
        self.config_window.start_graph()
        self.config_window.present()

    def _poll_updates(self):
        if self.snoozed_until > time.time():
            if self.config_window and self.config_window.get_visible():
                self.config_window.populate()
            return True

        try:
            env = dict(os.environ)
            env["LANG"] = "C.UTF-8"
            env["LC_ALL"] = "C.UTF-8"
            result = subprocess.run(
                ["apt-get", "-s", "dist-upgrade"],
                capture_output=True, text=True, timeout=60, env=env,
            )
            pending = []
            sec_count = 0
            for line in result.stdout.splitlines():
                if not line.startswith("Inst "):
                    continue
                m = INST_RE.match(line)
                if not m:
                    pending.append(("(unparsed)", "", False))
                    continue
                name, _oldver, newver, suite_arch = m.groups()
                is_sec = "-security" in suite_arch
                pending.append((name, newver, is_sec))
                if is_sec:
                    sec_count += 1
            count = len(pending)
            prev = self.last_count
            self.last_count = count
            self.last_security_count = sec_count
            self.last_pending = pending
            self.last_poll_ts = time.time()

            if count > 0:
                self._set_updates(count, sec_count)
                if self.config.get('notify_on_new', True) and count > prev:
                    self._notify_new(count, sec_count)
            else:
                self._set_idle()
        except Exception as e:
            print(f"[lite-update-tray] poll failed: {e}", flush=True)

        if self.config_window and self.config_window.get_visible():
            self.config_window.populate()

        return True

    def _on_dpkg_log_changed(self, *_):
        if self._dpkg_debounce_id is not None:
            GLib.source_remove(self._dpkg_debounce_id)
        self._dpkg_debounce_id = GLib.timeout_add_seconds(
            DPKG_DEBOUNCE_SECONDS, self._debounced_repoll
        )

    def _debounced_repoll(self):
        self._dpkg_debounce_id = None
        self._poll_updates()
        return False

    def _set_idle(self):
        self.indicator.set_icon_full(ICON_IDLE, "No updates available")
        if self.snoozed_until > time.time():
            self.indicator.set_title("Linux Lite Updates — Paused")
        else:
            self.indicator.set_title("Linux Lite Updates — Up to date")

    def _set_updates(self, count, sec):
        sfx = "" if count == 1 else "s"
        tip = f"{count} update{sfx} available"
        if sec:
            tip += f" ({sec} security)"
        title = f"Linux Lite Updates — {tip}"
        if self.snoozed_until > time.time():
            title += " — Paused"
        self.indicator.set_icon_full(ICON_UPDATES, tip)
        self.indicator.set_title(title)

    def _notify_new(self, count, sec):
        sfx = "" if count == 1 else "s"
        body = f"{count} update{sfx} available"
        if sec:
            body += f", including {sec} security"
        try:
            subprocess.Popen(
                ["notify-send", "-a", "Lite Update Tray",
                 "-i", ICON_UPDATES,
                 "Linux Lite Updates", body],
                start_new_session=True,
            )
        except FileNotFoundError:
            pass
        except Exception as e:
            print(f"[lite-update-tray] notify-send failed: {e}", flush=True)


def main():
    signal.signal(signal.SIGINT, signal.SIG_DFL)
    signal.signal(signal.SIGTERM, signal.SIG_DFL)
    LiteUpdateTray()
    Gtk.main()


if __name__ == "__main__":
    main()
