#!/usr/bin/env python3
import gi, subprocess, json, os, threading, urllib.request, urllib.parse, re, shutil, glob, webbrowser, time
import platform, datetime
import gettext, locale
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Pango, GdkPixbuf, Gdk

# ── i18n (gettext) ────────────────────────────────────────────────────────────
TEXTDOMAIN = "lite-kernel-manager"
LOCALE_DIR = "/usr/share/locale"
try:
    locale.setlocale(locale.LC_ALL, "")
except locale.Error:
    pass
gettext.bindtextdomain(TEXTDOMAIN, LOCALE_DIR)
gettext.textdomain(TEXTDOMAIN)
_ = gettext.translation(TEXTDOMAIN, LOCALE_DIR, fallback=True).gettext

LOGDIR         = os.path.expanduser("~/.local/share/linuxlite")
TOOL_DIR       = os.path.dirname(os.path.abspath(__file__))
REPO_URL       = "https://repo.linuxliteos.com/linuxlite/pool/main/l/linux-upstream/"
INSTALL_HELPER = "/usr/lib/linuxlite/install-kernel-pkgs.sh"
REMOVE_HELPER  = "/usr/lib/linuxlite/remove-kernel-pkgs.sh"
PROFILE_HELPER = "/usr/lib/linuxlite/auto-profile.sh"
INSTALL_TMPDIR = "/tmp/linuxlite-kernel-install"
CACHE_DIR      = os.path.join(os.path.expanduser("~"), ".cache", "lite-kernel-manager")
ICON_PATH      = "/usr/share/pixmaps/lite-kernelmanager.png"
ICON_PATH_DEV  = os.path.join(TOOL_DIR, "lite-kernelmanager.png")
APP_VERSION    = "8.0"
BENCH_UPLOAD_URL = "https://www.linuxliteos.com/benchmark-upload.php"
BENCH_RESULTS_URL = "https://www.linuxliteos.com/benchmark.php"


def _detect_vm():
    """Return the VM type (e.g. 'kvm', 'vmware', 'oracle') if running inside
    a virtual machine, else None. Uses systemd-detect-virt which is part of
    systemd and present on every modern Ubuntu install."""
    try:
        result = subprocess.run(
            ["systemd-detect-virt"],
            capture_output=True, text=True, timeout=2)
        # Exit 0 = virtualised, exit 1 = bare metal (or 'none' on stdout).
        virt = result.stdout.strip()
        if result.returncode == 0 and virt and virt != "none":
            return virt
    except Exception:
        pass
    return None

CSS = b"""
window, messagedialog, dialog {
    background-color: #2d2d2d;
}
label, messagedialog label, dialog label {
    color: #e0e0e0;
}
messagedialog .dialog-action-area button {
    color: #e0e0e0;
    background-color: #3a3a3a;
    border: 1px solid #4a4a4a;
}
messagedialog .dialog-action-area button:hover {
    background-color: #4a4a4a;
}
.title-label {
    font-size: 18px;
    font-weight: bold;
    color: #ffffff;
}
.section-label {
    font-size: 11px;
    font-weight: bold;
    color: #9e9e9e;
    letter-spacing: 1px;
}
.status-label {
    font-size: 12px;
    color: #b0b0b0;
}
.info-label {
    font-size: 12px;
    color: #9e9e9e;
}
.kernel-badge {
    background-color: #3a3a3a;
    border-radius: 6px;
    padding: 8px 14px;
    color: #82b1ff;
    font-family: monospace;
    font-size: 13px;
}
.rec-badge-desktop {
    background-color: #1b3a4b;
    border-radius: 6px;
    padding: 6px 12px;
    color: #4fc3f7;
}
.rec-badge-gaming {
    background-color: #3a1b4b;
    border-radius: 6px;
    padding: 6px 12px;
    color: #ce93d8;
}
.update-banner {
    background-color: #1a3a1a;
    border-radius: 6px;
    padding: 6px 12px;
    color: #a5d6a7;
}
.main-button {
    background-color: #3a3a3a;
    border: 1px solid #4a4a4a;
    border-radius: 6px;
    padding: 10px 16px;
    color: #e0e0e0;
    font-size: 13px;
}
.main-button:hover {
    background-color: #4a4a4a;
    border-color: #5a5a5a;
}
.bench-button {
    background-color: #1a472a;
    border: 1px solid #2e7d32;
    color: #a5d6a7;
}
.bench-button:hover {
    background-color: #2e7d32;
    color: #ffffff;
}
.upload-button {
    background-color: #14304a;
    border: 1px solid #1565c0;
    color: #90caf9;
}
.upload-button:hover {
    background-color: #1565c0;
    color: #ffffff;
}
.upload-button:disabled,
.upload-button:disabled:hover {
    background-color: #2a2a2a;
    border-color: #3a3a3a;
    color: #5a5a5a;
}
.install-button {
    background-color: #1a3a5c;
    border: 1px solid #1976d2;
    color: #90caf9;
}
.install-button:hover {
    background-color: #1976d2;
    color: #ffffff;
}
.boot-button {
    background-color: #4a3a1a;
    border: 1px solid #f9a825;
    color: #fff59d;
}
.boot-button:hover {
    background-color: #f9a825;
    color: #000000;
}
.remove-button {
    background-color: #4a1a1a;
    border: 1px solid #c62828;
    color: #ef9a9a;
}
.remove-button:hover {
    background-color: #c62828;
    color: #ffffff;
}
.profile-button {
    background-color: #2a1a4a;
    border: 1px solid #7b1fa2;
    color: #ce93d8;
}
.profile-button:hover {
    background-color: #7b1fa2;
    color: #ffffff;
}
.profile-active {
    background-color: #7b1fa2;
    border: 1px solid #ab47bc;
    color: #ffffff;
}
.maint-button {
    background-color: #3a3a3a;
    border: 1px solid #616161;
    color: #bdbdbd;
}
.maint-button:hover {
    background-color: #616161;
    color: #ffffff;
}
.running-label {
    color: #66bb6a;
    font-style: italic;
}
.progress-view, .progress-view text {
    background-color: #1e1e1e;
    color: #d4d4d4;
}
textview {
    color: #d4d4d4;
}
textview text {
    background-color: #1e1e1e;
    color: #d4d4d4;
}
.action-button {
    background-color: #1976d2;
    border: none;
    border-radius: 6px;
    padding: 8px 20px;
    color: #ffffff;
    font-weight: bold;
}
.action-button:hover {
    background-color: #2196f3;
}
.close-button {
    background-color: #3a3a3a;
    border: 1px solid #4a4a4a;
    border-radius: 6px;
    padding: 8px 20px;
    color: #e0e0e0;
}
.close-button:hover {
    background-color: #4a4a4a;
}
separator {
    background-color: #3a3a3a;
    min-height: 1px;
}
"""


def _get_icon_path():
    if os.path.exists(ICON_PATH):
        return ICON_PATH
    if os.path.exists(ICON_PATH_DEV):
        return ICON_PATH_DEV
    return None


def _kernel_installed(flavour):
    """Check if a linuxlite kernel flavour is installed. Returns (bool, version_str)."""
    try:
        out = subprocess.run(
            ["dpkg-query", "-W", "-f", "${Status} ${Version}\n",
             f"linux-image-*-{flavour}"],
            capture_output=True, text=True)
        for line in out.stdout.strip().splitlines():
            if line.startswith("install ok installed"):
                ver = line.split()[-1]
                return True, ver
    except Exception:
        pass
    # Fallback: check /lib/modules for matching dirs
    matches = glob.glob(f"/lib/modules/*-{flavour}")
    if matches:
        ver = os.path.basename(matches[-1])
        return True, ver
    return False, ""


def _parse_kernel_version(filename):
    """Extract a sortable version tuple from a kernel .deb filename.
    Returns (major, minor, patch, is_final, rc_num) where is_final=1 for
    release builds and 0 for RCs, so final sorts higher than any RC."""
    m = re.match(r'^linux-(?:image|headers)-([\d.]+)(?:-rc(\d+))?-', filename)
    if not m:
        return (0, 0, 0, 0, 0)
    parts = [int(x) for x in m.group(1).split('.')]
    while len(parts) < 3:
        parts.append(0)
    rc = int(m.group(2)) if m.group(2) else 0
    is_final = 1 if rc == 0 else 0
    return (parts[0], parts[1], parts[2], is_final, rc)


def _fetch_repo_packages(flavour):
    """Return (headers_url, image_url) for the latest matching flavour in the repo."""
    with urllib.request.urlopen(REPO_URL, timeout=15) as resp:
        html = resp.read().decode("utf-8", errors="replace")
    all_debs = re.findall(r'href="([^"]+\.deb)"', html)
    if flavour == "linuxlite-gaming":
        img_pat = re.compile(r'^linux-image-[\d.]+(-rc\d+)?-linuxlite-gaming_.*_amd64\.deb$')
        hdr_pat = re.compile(r'^linux-headers-[\d.]+(-rc\d+)?-linuxlite-gaming_.*_amd64\.deb$')
    else:
        img_pat = re.compile(r'^linux-image-[\d.]+(-rc\d+)?-linuxlite_.*_amd64\.deb$')
        hdr_pat = re.compile(r'^linux-headers-[\d.]+(-rc\d+)?-linuxlite_.*_amd64\.deb$')
    images  = sorted((d for d in all_debs if img_pat.match(d)), key=_parse_kernel_version)
    headers = sorted((d for d in all_debs if hdr_pat.match(d)), key=_parse_kernel_version)
    if not images or not headers:
        raise RuntimeError(f"No packages found for '{flavour}' in repo")
    return REPO_URL + headers[-1], REPO_URL + images[-1]


def _get_repo_latest_version(flavour):
    """Return the latest version string available in the repo for a flavour, or None."""
    try:
        with urllib.request.urlopen(REPO_URL, timeout=10) as resp:
            html = resp.read().decode("utf-8", errors="replace")
        all_debs = re.findall(r'href="([^"]+\.deb)"', html)
        if flavour == "linuxlite-gaming":
            pat = re.compile(r'^linux-image-([\d.]+(?:-rc\d+)?)-linuxlite-gaming_.*_amd64\.deb$')
        else:
            pat = re.compile(r'^linux-image-([\d.]+(?:-rc\d+)?)-linuxlite_.*_amd64\.deb$')
        versions = []
        for d in all_debs:
            m = pat.match(d)
            if m:
                versions.append((m.group(1), _parse_kernel_version(d)))
        if versions:
            versions.sort(key=lambda x: x[1])
            return versions[-1][0]
    except Exception:
        pass
    return None


def _get_uptime():
    """Return human-readable uptime string."""
    try:
        with open("/proc/uptime") as f:
            secs = int(float(f.read().split()[0]))
        days, rem = divmod(secs, 86400)
        hours, rem = divmod(rem, 3600)
        mins, _ = divmod(rem, 60)
        parts = []
        if days:
            parts.append(f"{days}d")
        if hours:
            parts.append(f"{hours}h")
        parts.append(f"{mins}m")
        return " ".join(parts)
    except Exception:
        return "unknown"


def _get_running_flavour():
    """Detect if the running kernel is desktop or gaming."""
    release = os.uname().release
    if release.endswith("-linuxlite-gaming"):
        return "Gaming"
    elif release.endswith("-linuxlite"):
        return "Desktop"
    return "Standard"


def _get_active_profile():
    """Detect the currently active sysctl profile.

    Prefer the marker file written by auto-profile.sh / postinst. If it's
    missing (running from source, or pre-postinst), derive from the running
    kernel flavour. The previous approach (probing kernel.sched_latency_ns)
    stopped working on EEVDF kernels (6.6+) where the CFS sched_* sysctls
    no longer exist."""
    try:
        with open("/var/lib/lite-kernel-manager/profile") as f:
            val = f.read().strip()
        if val in ("desktop", "gaming"):
            return val
    except Exception:
        pass
    return "gaming" if os.uname().release.endswith("-linuxlite-gaming") else "desktop"


def _get_cpu_model():
    """Return CPU model name from /proc/cpuinfo."""
    try:
        with open("/proc/cpuinfo") as f:
            for line in f:
                if line.startswith("model name"):
                    name = line.split(":", 1)[1]
                    # Strip (R), (TM), "CPU" filler, and "@ <freq>" trailers
                    name = re.sub(r"\(R\)|\(TM\)|\(tm\)|\bCPU\b", "", name)
                    name = re.sub(r"\s*@\s*\S+", "", name)
                    return re.sub(r"\s+", " ", name).strip()
    except Exception:
        pass
    return "Unknown CPU"


def _get_memory_total():
    """Return total system memory rounded to the nearest standard RAM size
    (e.g. '16 GB' rather than '15.6 GB' — kernel/GPU reservations always
    eat ~0.4 GB, so MemTotal is never the marketed size)."""
    try:
        with open("/proc/meminfo") as f:
            for line in f:
                if line.startswith("MemTotal:"):
                    kb = int(line.split()[1])
                    gb = kb / (1024 * 1024)
                    standards = [1, 2, 3, 4, 6, 8, 12, 16, 20, 24, 32, 48,
                                 64, 96, 128, 192, 256, 384, 512, 768, 1024]
                    nearest = min(standards, key=lambda s: abs(s - gb))
                    return f"{nearest} GB"
    except Exception:
        pass
    return "Unknown"


def _normalize_gpu_name(name):
    """Clean common vendor decoration to keep names short and friendly."""
    if not name:
        return name
    # Vendor name simplification
    name = re.sub(r"\bNVIDIA Corporation\b", "Nvidia", name)
    name = re.sub(r"\bNVIDIA\b", "Nvidia", name)
    name = re.sub(r"\bAdvanced Micro Devices,?\s*Inc\.?\s*\[AMD/ATI\]", "AMD", name)
    name = re.sub(r"\bAdvanced Micro Devices,?\s*Inc\.?", "AMD", name)
    name = re.sub(r"\[AMD/ATI\]", "AMD", name)
    name = re.sub(r"\bIntel Corporation\b", "Intel", name)
    name = re.sub(r"\bATI Technologies Inc\b", "ATI", name)
    # Strip (R) / (TM) / "Inc." / "Ltd." / trailing PCI IDs in parens
    name = re.sub(r"\(R\)|\(TM\)|\(tm\)|,?\s*Inc\.?|,?\s*Ltd\.?", "", name)
    name = re.sub(r"\s*\([^)]*0x[0-9a-fA-F]+[^)]*\)", "", name)
    name = re.sub(r"\s*\(rev\s+[^)]+\)", "", name, flags=re.IGNORECASE)
    return re.sub(r"\s+", " ", name).strip(" -")


def _get_gpu_model():
    """Return a user-friendly GPU model.
    Tries nvidia-smi -> lspci (with marketing-name extraction) -> lspci
    basic -> glxinfo. Skips nouveau internal codenames like 'NV134'.
    lspci is preferred over glxinfo because Mesa's renderer string is
    a generic family marketing string (e.g. 'Mesa Intel Graphics (RPL-S)')
    whereas lspci returns the actual chip name from pci.ids.
    """
    # 1. nvidia-smi — definitive marketing name (proprietary NVIDIA driver only)
    try:
        out = subprocess.run(
            ["nvidia-smi", "--query-gpu=name", "--format=csv,noheader,nounits"],
            capture_output=True, text=True, timeout=3)
        if out.returncode == 0 and out.stdout.strip():
            name = out.stdout.strip().splitlines()[0].strip()
            if name:
                return _normalize_gpu_name(name)
    except Exception:
        pass

    # 2. lspci — try to extract a bracketed marketing name first
    #    e.g. "GP104 [GeForce GTX 1070]" -> "GeForce GTX 1070"
    lspci_basic = None
    try:
        out = subprocess.run(["lspci", "-mm"], capture_output=True, text=True)
        for line in out.stdout.splitlines():
            if ('"VGA compatible controller"' not in line
                    and '"3D controller"' not in line
                    and '"Display controller"' not in line):
                continue
            parts = re.findall(r'"([^"]*)"', line)
            if len(parts) < 4:
                continue
            vendor = parts[1].strip()
            model_full = parts[2].strip()
            m = re.search(r'\[([^\]]+)\]', model_full)
            if m:
                marketing = m.group(1).strip()
                # Bracket sometimes holds just a vendor alias like [AMD/ATI]
                if marketing.upper() not in ("AMD/ATI", "AMD", "ATI", "INTEL"):
                    return _normalize_gpu_name(f"{vendor} {marketing}")
            # Hold on to the basic vendor+model as a last-resort fallback
            lspci_basic = _normalize_gpu_name(
                f"{vendor} {model_full.split('[')[0].strip()}")
            break
    except Exception:
        pass

    # 3. lspci basic (vendor + chip code, no marketing name available).
    #    Preferred over glxinfo for Intel/AMD because Mesa returns generic
    #    strings like "Mesa Intel Graphics (RPL-S)" — the lspci device name
    #    "Intel Raptor Lake-S UHD Graphics" is more informative.
    if lspci_basic:
        return lspci_basic

    # 4. glxinfo -B — fallback when lspci had nothing usable; reject nouveau
    #    codenames (NV<digits>) and software renderers.
    try:
        out = subprocess.run(["glxinfo", "-B"],
                             capture_output=True, text=True, timeout=5)
        if out.returncode == 0:
            for prefix in ("Device:", "OpenGL renderer string:"):
                for line in out.stdout.splitlines():
                    line = line.strip()
                    if not line.startswith(prefix):
                        continue
                    name = line.split(":", 1)[1].strip()
                    if not name:
                        continue
                    if re.match(r"^NV\d+\b", name, flags=re.IGNORECASE):
                        continue  # nouveau codename — skip
                    if name.lower() in ("llvmpipe", "software", "swrast"):
                        continue
                    return _normalize_gpu_name(name)
    except Exception:
        pass

    return "Unknown GPU"


def _get_distro():
    """Return PRETTY_NAME from /etc/os-release."""
    try:
        with open("/etc/os-release") as f:
            for line in f:
                if line.startswith("PRETTY_NAME="):
                    return line.split("=", 1)[1].strip().strip('"').strip("'")
    except Exception:
        pass
    return "Unknown"


def _get_cache_size():
    """Return total size of cached .deb files in human-readable format."""
    total = 0
    if os.path.isdir(CACHE_DIR):
        for f in os.listdir(CACHE_DIR):
            fp = os.path.join(CACHE_DIR, f)
            if os.path.isfile(fp):
                total += os.path.getsize(fp)
    if total > 1024 * 1024:
        return f"{total / (1024 * 1024):.1f} MB"
    elif total > 1024:
        return f"{total / 1024:.0f} KB"
    elif total > 0:
        return f"{total} B"
    return "empty"


def _icon_button(icon_name, label_text, css_class):
    """Create a button with an icon and label. Icon is rendered at 22px so
    Papirus's colorful glyphs are visible — the GTK default (BUTTON = 16px)
    washes them out."""
    btn = Gtk.Button()
    box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
    box.set_halign(Gtk.Align.CENTER)
    img = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.LARGE_TOOLBAR)
    img.set_pixel_size(22)
    lbl = Gtk.Label(label=label_text)
    box.pack_start(img, False, False, 0)
    box.pack_start(lbl, False, False, 0)
    btn.add(box)
    btn.get_style_context().add_class("main-button")
    btn.get_style_context().add_class(css_class)
    return btn


_MSG_DIALOG_ICON = {
    Gtk.MessageType.INFO:     "dialog-information",
    Gtk.MessageType.WARNING:  "dialog-warning",
    Gtk.MessageType.ERROR:    "dialog-error",
    Gtk.MessageType.QUESTION: "dialog-question",
}


def _msg_dialog(transient_for=None, modal=True,
                message_type=Gtk.MessageType.INFO,
                buttons=Gtk.ButtonsType.OK, text=""):
    """Drop-in replacement for Gtk.MessageDialog that force-attaches a
    colorful 48px Papirus icon. Modern GTK themes hide or mute the default
    dialog image; this keeps it clearly visible against any background."""
    dlg = Gtk.MessageDialog(
        transient_for=transient_for, modal=modal,
        message_type=message_type, buttons=buttons, text=text)
    icon_name = _MSG_DIALOG_ICON.get(message_type, "dialog-information")
    img = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
    img.set_pixel_size(48)
    img.show()
    dlg.set_image(img)
    return dlg


class LiteKernelManager(Gtk.Window):

    def __init__(self):
        super().__init__(title="Lite Kernel Manager")
        self.set_default_size(560, 620)
        self._pulse_id = None
        self._update_text = None
        # Upload Benchmark Results stays disabled until a benchmark
        # completes in THIS session. A leftover recommendation.json from a
        # previous run must not pre-arm the button.
        self._bench_done_this_session = False
        # VM detection — when running inside a VM the upload button stays
        # disabled (results aren't comparable to bare metal).
        self._vm_type = _detect_vm()
        self.connect("destroy", Gtk.main_quit)
        self.set_icon_name("lite-kernelmanager")
        icon = _get_icon_path()
        if icon:
            self.set_icon_from_file(icon)

        # Load CSS
        css_provider = Gtk.CssProvider()
        css_provider.load_from_data(CSS)
        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(), css_provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

        self._build_main()

        # Check for updates in background
        threading.Thread(target=self._check_updates, daemon=True).start()

    # ── Main screen ───────────────────────────────────────────────────────────

    def _build_main(self):
        self._clear()
        self.resize(560, 680)
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        self.add(box)

        # Header with icon and title
        hdr = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        hdr.set_margin_top(14); hdr.set_margin_bottom(8)
        hdr.set_margin_start(20); hdr.set_margin_end(20)

        icon = _get_icon_path()
        if icon and os.path.exists(icon):
            try:
                pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(icon, 48, 48, True)
                img = Gtk.Image.new_from_pixbuf(pb)
                img.set_margin_bottom(2)
                hdr.pack_start(img, False, False, 0)
            except Exception:
                pass

        title = Gtk.Label()
        title.set_markup("Lite Kernel Manager")
        title.get_style_context().add_class("title-label")
        title.set_halign(Gtk.Align.CENTER)
        hdr.pack_start(title, False, False, 0)

        # Kernel badge
        kernel_box = Gtk.Box(spacing=0)
        kernel_box.set_halign(Gtk.Align.CENTER)
        kernel_lbl = Gtk.Label(label=os.uname().release)
        kernel_lbl.get_style_context().add_class("kernel-badge")
        kernel_box.pack_start(kernel_lbl, False, False, 0)
        hdr.pack_start(kernel_box, False, False, 2)

        # Kernel info line
        flavour = _get_running_flavour()
        arch = platform.machine()
        uptime = _get_uptime()
        info_lbl = Gtk.Label(label=f"{flavour}  |  {arch}  |  {_('Uptime:')} {uptime}")
        info_lbl.get_style_context().add_class("info-label")
        info_lbl.set_halign(Gtk.Align.CENTER)
        hdr.pack_start(info_lbl, False, False, 0)

        # Recommendation badge
        rec_file = os.path.join(LOGDIR, "recommendation.json")
        if os.path.exists(rec_file):
            try:
                with open(rec_file) as f:
                    rec = json.load(f)
                rec_name = rec.get("recommended", "?")
                confidence = rec.get("confidence", "?")
                fps = rec.get("fps", "?")
                if rec_name == "linuxlite-gaming":
                    rec_text = "\u2728 " + _("Recommended: Gaming  |  Score: {fps}  |  Confidence: {conf}").format(fps=fps, conf=confidence)
                    rec_class = "rec-badge-gaming"
                else:
                    rec_text = "\u2705 " + _("Recommended: Desktop  |  Score: {fps}  |  Confidence: {conf}").format(fps=fps, conf=confidence)
                    rec_class = "rec-badge-desktop"
                rec_box = Gtk.Box(spacing=0)
                rec_box.set_halign(Gtk.Align.CENTER)
                rec_lbl = Gtk.Label(label=rec_text)
                rec_lbl.get_style_context().add_class(rec_class)
                rec_box.pack_start(rec_lbl, False, False, 0)
                hdr.pack_start(rec_box, False, False, 2)
            except Exception:
                pass

        # Update available banner (filled by background check)
        self._update_box = Gtk.Box(spacing=0)
        self._update_box.set_halign(Gtk.Align.CENTER)
        self._update_box.set_no_show_all(True)
        hdr.pack_start(self._update_box, False, False, 2)

        if self._update_text:
            self._show_update_banner(self._update_text)

        box.pack_start(hdr, False, False, 0)
        box.pack_start(Gtk.Separator(), False, False, 0)

        # Button area — compact grid layout
        btn_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        btn_area.set_margin_top(10); btn_area.set_margin_bottom(8)
        btn_area.set_margin_start(20); btn_area.set_margin_end(20)

        # ── BENCHMARK ──
        sec = Gtk.Label(); sec.set_markup(_("BENCHMARK"))
        sec.get_style_context().add_class("section-label"); sec.set_halign(Gtk.Align.START)
        btn_area.pack_start(sec, False, False, 0)

        btn_bench = _icon_button("applications-graphics", _("Run Benchmark"), "bench-button")
        btn_bench.connect("clicked", self._run_bench)
        btn_area.pack_start(btn_bench, False, True, 0)

        # Upload Benchmark Results — centered under Run Benchmark, disabled
        # until a benchmark completes IN THIS SESSION. We deliberately don't
        # check recommendation.json on disk: a leftover from a previous run
        # must not pre-arm the button at startup.
        upload_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        upload_box.set_halign(Gtk.Align.CENTER)
        btn_upload = _icon_button("internet-mail", _("Upload Benchmark Results"), "upload-button")
        btn_upload.connect("clicked", self._upload_bench)
        if self._vm_type:
            btn_upload.set_sensitive(False)
            btn_upload.set_tooltip_text(
                _("Upload disabled — running in a VM ({vm}). VM benchmark results aren't comparable to bare metal and can't be submitted to the public database.").format(vm=self._vm_type))
        else:
            btn_upload.set_sensitive(self._bench_done_this_session)
            if not self._bench_done_this_session:
                btn_upload.set_tooltip_text(_("Run a benchmark first to enable upload."))
            else:
                btn_upload.set_tooltip_text(
                    _("Submit your CPU, Memory, GPU, kernel and benchmark score to the public Linux Lite benchmark database."))
        upload_box.pack_start(btn_upload, False, False, 0)
        btn_area.pack_start(upload_box, False, False, 4)

        btn_area.pack_start(Gtk.Separator(), False, False, 4)

        # ── INSTALL FROM REPOSITORY (two-column) ──
        sec = Gtk.Label(); sec.set_markup(_("INSTALL FROM REPOSITORY"))
        sec.get_style_context().add_class("section-label"); sec.set_halign(Gtk.Align.START)
        btn_area.pack_start(sec, False, False, 0)

        row_install = Gtk.Box(spacing=8)
        btn_inst_desktop = _icon_button("cpu", _("Desktop Kernel"), "install-button")
        btn_inst_desktop.connect("clicked", lambda _: self._start_install("linuxlite"))
        row_install.pack_start(btn_inst_desktop, True, True, 0)
        btn_inst_gaming = _icon_button("applications-games", _("Gaming Kernel"), "install-button")
        btn_inst_gaming.connect("clicked", lambda _: self._start_install("linuxlite-gaming"))
        row_install.pack_start(btn_inst_gaming, True, True, 0)
        btn_area.pack_start(row_install, False, True, 0)

        btn_area.pack_start(Gtk.Separator(), False, False, 4)

        # ── SET DEFAULT BOOT KERNEL (two-column) ──
        sec = Gtk.Label(); sec.set_markup(_("SET DEFAULT BOOT KERNEL"))
        sec.get_style_context().add_class("section-label"); sec.set_halign(Gtk.Align.START)
        btn_area.pack_start(sec, False, False, 0)

        row_boot = Gtk.Box(spacing=8)
        btn_boot_desktop = _icon_button("cpu", _("Desktop Kernel"), "boot-button")
        btn_boot_desktop.connect("clicked", lambda _: self._set_boot("linuxlite"))
        row_boot.pack_start(btn_boot_desktop, True, True, 0)
        btn_boot_gaming = _icon_button("applications-games", _("Gaming Kernel"), "boot-button")
        btn_boot_gaming.connect("clicked", lambda _: self._set_boot("linuxlite-gaming"))
        row_boot.pack_start(btn_boot_gaming, True, True, 0)
        btn_area.pack_start(row_boot, False, True, 0)

        btn_area.pack_start(Gtk.Separator(), False, False, 4)

        # ── PERFORMANCE PROFILE (two-column) ──
        sec = Gtk.Label(); sec.set_markup(_("PERFORMANCE PROFILE"))
        sec.get_style_context().add_class("section-label"); sec.set_halign(Gtk.Align.START)
        btn_area.pack_start(sec, False, False, 0)

        active = _get_active_profile()
        row_profile = Gtk.Box(spacing=8)
        desktop_css = "profile-active" if active == "desktop" else "profile-button"
        gaming_css = "profile-active" if active == "gaming" else "profile-button"
        btn_profile_desktop = _icon_button("preferences-desktop", _("Desktop Profile"), desktop_css)
        btn_profile_desktop.connect("clicked", lambda _: self._switch_profile("desktop"))
        row_profile.pack_start(btn_profile_desktop, True, True, 0)
        btn_profile_gaming = _icon_button("applications-games", _("Gaming Profile"), gaming_css)
        btn_profile_gaming.connect("clicked", lambda _: self._switch_profile("gaming"))
        row_profile.pack_start(btn_profile_gaming, True, True, 0)
        btn_area.pack_start(row_profile, False, True, 0)

        btn_area.pack_start(Gtk.Separator(), False, False, 4)

        # ── MAINTENANCE (two-column) ──
        sec = Gtk.Label(); sec.set_markup(_("MAINTENANCE"))
        sec.get_style_context().add_class("section-label"); sec.set_halign(Gtk.Align.START)
        btn_area.pack_start(sec, False, False, 0)

        row_maint = Gtk.Box(spacing=8)
        btn_remove = _icon_button("emblem-important", _("Remove Kernels"), "remove-button")
        btn_remove.connect("clicked", self._show_remove_kernels)
        row_maint.pack_start(btn_remove, True, True, 0)

        cache_size = _get_cache_size()
        btn_cache = _icon_button("applications-utilities", _("Clear Cache ({size})").format(size=cache_size), "maint-button")
        btn_cache.connect("clicked", self._clear_cache)
        row_maint.pack_start(btn_cache, True, True, 0)
        btn_area.pack_start(row_maint, False, True, 0)

        box.pack_start(btn_area, True, True, 0)

        # Footer
        footer_box = Gtk.Box(spacing=0)
        footer_box.set_halign(Gtk.Align.CENTER)
        footer_box.set_margin_top(4); footer_box.set_margin_bottom(8)

        about_btn = Gtk.LinkButton.new_with_label("", _("About"))
        about_btn.connect("activate-link", self._show_about)
        about_btn.get_style_context().add_class("status-label")
        footer_box.pack_start(about_btn, False, False, 0)

        box.pack_start(footer_box, False, False, 0)

        self.show_all()

    # ── Check for updates (background) ────────────────────────────────────────

    def _check_updates(self):
        """Check repo for newer kernel versions in background."""
        running = os.uname().release
        # Extract version from running kernel (e.g. "6.19.5-linuxlite" → "6.19.5")
        m = re.match(r'([\d.]+(?:-rc\d+)?)-linuxlite', running)
        if not m:
            return
        current_ver = m.group(1)

        latest = _get_repo_latest_version("linuxlite")
        if not latest:
            return

        # Compare using version tuples
        cur_tuple = _parse_kernel_version(f"linux-image-{current_ver}-linuxlite_x_amd64.deb")
        lat_tuple = _parse_kernel_version(f"linux-image-{latest}-linuxlite_x_amd64.deb")

        if lat_tuple > cur_tuple:
            text = _("Update available: {latest} (installed: {current})").format(latest=latest, current=current_ver)
            self._update_text = text
            GLib.idle_add(self._show_update_banner, text)

    def _show_update_banner(self, text):
        if not hasattr(self, '_update_box') or not self._update_box:
            return False
        # Clear existing children
        for child in self._update_box.get_children():
            self._update_box.remove(child)
        lbl = Gtk.Label(label=text)
        lbl.get_style_context().add_class("update-banner")
        self._update_box.pack_start(lbl, False, False, 0)
        self._update_box.set_no_show_all(False)
        self._update_box.show_all()
        return False

    # ── Benchmark ─────────────────────────────────────────────────────────────

    def _run_bench(self, _):
        if self._vm_type:
            dlg = _msg_dialog(
                transient_for=self, modal=True,
                message_type=Gtk.MessageType.WARNING,
                buttons=Gtk.ButtonsType.NONE,
                text=_("Virtual machine detected ({vm})").format(vm=self._vm_type))
            dlg.format_secondary_text(
                _("Benchmark results from a VM aren't comparable to bare-metal hardware, so they cannot be uploaded to the public Linux Lite benchmark database.\n\nYou can still run the benchmark locally and view the report, but the Upload button will stay disabled."))
            dlg.add_button(_("Cancel"), Gtk.ResponseType.CANCEL)
            dlg.add_button(_("Run Anyway"), Gtk.ResponseType.OK)
            response = dlg.run()
            dlg.destroy()
            if response != Gtk.ResponseType.OK:
                return
        self._show_progress(_("Running Benchmark\u2026"))
        threading.Thread(target=self._do_bench, daemon=True).start()

    def _do_bench(self):
        try:
            bench = os.path.join(TOOL_DIR, "linuxlite-bench")
            proc = subprocess.Popen(
                [bench],
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                text=True, bufsize=1)
            for line in proc.stdout:
                GLib.idle_add(self._append_log, line)
            proc.wait()
            GLib.idle_add(self._bench_finish, proc.returncode)
        except Exception as e:
            GLib.idle_add(self._append_log, f"\nError: {e}\n")
            GLib.idle_add(self._bench_finish, 1)

    def _bench_finish(self, rc):
        if self._pulse_id:
            GLib.source_remove(self._pulse_id)
            self._pulse_id = None
        if rc == 0:
            self._progress.set_fraction(1.0)
            self._append_log("\n" + _("Benchmark complete.") + "\n\n")
            self._bench_done_this_session = True
            report = self._find_bench_report()
            if report:
                self._append_log(_("Report saved to:") + f" {report}\n")
                btn_open = _icon_button("document-open", _("Open Report"), "action-button")
                btn_open.connect("clicked", lambda _, r=report: webbrowser.open(f"file://{r}"))
                self._btn_row.pack_start(btn_open, False, False, 8)
        else:
            self._progress.set_fraction(0.0)
            self._append_log("\n" + _("Benchmark failed.") + "\n\n")
        btn_close = _icon_button("window-close", _("Close"), "close-button")
        btn_close.connect("clicked", lambda _: self._build_main())
        self._btn_row.pack_start(btn_close, False, False, 0)
        self._btn_row.show_all()
        GLib.idle_add(self._scroll_to_end)
        return False

    def _find_bench_report(self):
        home = os.path.expanduser("~")
        reports = sorted(glob.glob(os.path.join(home, "lite-benchmark *.html")))
        return reports[-1] if reports else None

    # ── Upload benchmark results ──────────────────────────────────────────────

    def _upload_bench(self, _btn):
        if self._vm_type:
            dlg = _msg_dialog(
                transient_for=self, modal=True,
                message_type=Gtk.MessageType.INFO,
                buttons=Gtk.ButtonsType.OK,
                text=_("Upload not available in a VM"))
            dlg.format_secondary_text(
                _("This system is a virtual machine ({vm}). Benchmark results from VMs aren't accepted by the public database.").format(vm=self._vm_type))
            dlg.run(); dlg.destroy()
            return
        rec_file = os.path.join(LOGDIR, "recommendation.json")
        if not os.path.exists(rec_file):
            dlg = _msg_dialog(
                transient_for=self, modal=True,
                message_type=Gtk.MessageType.INFO,
                buttons=Gtk.ButtonsType.OK,
                text=_("No benchmark to upload"))
            dlg.format_secondary_text(_("Run a benchmark first, then try again."))
            dlg.run(); dlg.destroy()
            return

        try:
            with open(rec_file) as f:
                rec = json.load(f)
        except Exception as e:
            dlg = _msg_dialog(
                transient_for=self, modal=True,
                message_type=Gtk.MessageType.ERROR,
                buttons=Gtk.ButtonsType.OK,
                text=_("Could not read benchmark result"))
            dlg.format_secondary_text(str(e))
            dlg.run(); dlg.destroy()
            return

        score = rec.get("fps", 0)
        try:
            score_int = int(score)
        except (TypeError, ValueError):
            score_int = 0

        latency = rec.get("latency_us", 0)
        try:
            latency_int = int(latency)
        except (TypeError, ValueError):
            latency_int = 0

        # `profile` reports the kernel flavour the user is ACTUALLY running
        # (linuxlite = Desktop, linuxlite-gaming = Gaming) — NOT the
        # recommendation from the benchmark logic, which can suggest the
        # other flavour. The website maps this string to a Desktop/Gaming
        # pill; sending the recommendation here mislabels the row.
        running = os.uname().release
        if running.endswith("-linuxlite-gaming"):
            actual_profile = "linuxlite-gaming"
        elif running.endswith("-linuxlite"):
            actual_profile = "linuxlite"
        else:
            actual_profile = ""

        payload = {
            "cpu":        _get_cpu_model(),
            "memory":     _get_memory_total(),
            "gpu":        _get_gpu_model(),
            "kernel":     rec.get("kernel", running),
            "distro":     _get_distro(),
            "score":      str(score_int),
            "latency_us": str(latency_int),
            "profile":    actual_profile,
            "confidence": rec.get("confidence", ""),
        }

        # Confirmation dialog with what's about to be sent
        dlg = _msg_dialog(
            transient_for=self, modal=True,
            message_type=Gtk.MessageType.QUESTION,
            buttons=Gtk.ButtonsType.NONE,
            text=_("Upload these benchmark results?"))
        dlg.format_secondary_text(
            _("CPU:     {cpu}\nMemory:  {memory}\nGPU:     {gpu}\nKernel:  {kernel}\nDistro:  {distro}\nScore:   {score}\n\nResults are public and viewable at:\n{url}").format(
                cpu=payload['cpu'], memory=payload['memory'], gpu=payload['gpu'],
                kernel=payload['kernel'], distro=payload['distro'], score=payload['score'], url=BENCH_RESULTS_URL))
        dlg.add_button(_("Cancel"), Gtk.ResponseType.CANCEL)
        dlg.add_button(_("Upload"), Gtk.ResponseType.OK)
        response = dlg.run()
        dlg.destroy()
        if response != Gtk.ResponseType.OK:
            return

        threading.Thread(target=self._do_upload_bench,
                         args=(payload,), daemon=True).start()

    def _do_upload_bench(self, payload):
        try:
            data = urllib.parse.urlencode(payload).encode("utf-8")
            req = urllib.request.Request(
                BENCH_UPLOAD_URL,
                data=data,
                headers={"User-Agent": f"LiteKernelManager/{APP_VERSION}"})
            with urllib.request.urlopen(req, timeout=30) as resp:
                body = resp.read().decode("utf-8", errors="replace").strip()
                ok = (resp.status == 200)
            GLib.idle_add(self._show_upload_result, ok, body)
        except urllib.error.HTTPError as e:
            try:
                body = e.read().decode("utf-8", errors="replace").strip()
            except Exception:
                body = ""
            msg = body if body else f"HTTP {e.code} {e.reason}"
            GLib.idle_add(self._show_upload_result, False, msg)
        except Exception as e:
            GLib.idle_add(self._show_upload_result, False, str(e))

    def _show_upload_result(self, ok, msg):
        url_esc = GLib.markup_escape_text(BENCH_RESULTS_URL)
        link = f'<a href="{url_esc}">{url_esc}</a>'
        if ok:
            tag = (msg or "").strip().upper()
            if tag == "UPDATED":
                text = _("New personal best")
                secondary = (
                    _("Your previous score for this hardware and kernel has been replaced with this higher one.\n\nView all results at:\n{link}").format(link=link))
            elif tag == "NO_IMPROVEMENT":
                text = _("No improvement")
                secondary = (
                    _("Your previous score for this hardware and kernel is higher, so the leaderboard kept the better one.\n\nView all results at:\n{link}").format(link=link))
            else:
                text = _("Benchmark uploaded")
                secondary = (
                    _("Your results have been added to the public benchmark database.\n\nView all results at:\n{link}").format(link=link))
            msg_type = Gtk.MessageType.INFO
        else:
            text = _("Upload failed")
            secondary = GLib.markup_escape_text(
                msg or _("Could not reach the benchmark server."))
            msg_type = Gtk.MessageType.ERROR

        dlg = _msg_dialog(
            transient_for=self, modal=True,
            message_type=msg_type,
            buttons=Gtk.ButtonsType.OK,
            text=text)
        dlg.format_secondary_markup(secondary)

        # Center every label inside the message area (primary + secondary)
        for child in dlg.get_message_area().get_children():
            if isinstance(child, Gtk.Label):
                child.set_justify(Gtk.Justification.CENTER)
                child.set_halign(Gtk.Align.CENTER)
                child.set_xalign(0.5)

        dlg.run(); dlg.destroy()
        return False

    # ── Set boot kernel ───────────────────────────────────────────────────────

    def _set_boot(self, flavour):
        desktop_installed, desktop_ver = _kernel_installed("linuxlite")
        gaming_installed, gaming_ver = _kernel_installed("linuxlite-gaming")

        selected_installed = desktop_installed if flavour == "linuxlite" else gaming_installed
        selected_label = _("Desktop (linuxlite)") if flavour == "linuxlite" else _("Gaming (linuxlite-gaming)")

        lines = []
        if desktop_installed:
            lines.append(_("Desktop kernel: installed ({ver})").format(ver=desktop_ver))
        else:
            lines.append(_("Desktop kernel: not installed"))
        if gaming_installed:
            lines.append(_("Gaming kernel: installed ({ver})").format(ver=gaming_ver))
        else:
            lines.append(_("Gaming kernel: not installed"))

        if not selected_installed:
            dlg = _msg_dialog(
                transient_for=self,
                modal=True,
                message_type=Gtk.MessageType.ERROR,
                buttons=Gtk.ButtonsType.OK,
                text=_("{label} is not installed").format(label=selected_label))
            dlg.format_secondary_text(
                "\n".join(lines) +
                "\n\n" + _("Use the Install buttons above to install it first."))
            dlg.run()
            dlg.destroy()
            return

        try:
            result = subprocess.run(
                ["pkexec", "/usr/lib/linuxlite/set-boot-kernel.sh", flavour],
                capture_output=True, text=True)
            grub_entry = result.stdout.strip().split("\n")[-1]
        except Exception:
            grub_entry = ""

        if not grub_entry or grub_entry.startswith("ERROR"):
            dlg = _msg_dialog(
                transient_for=self,
                modal=True,
                message_type=Gtk.MessageType.ERROR,
                buttons=Gtk.ButtonsType.OK,
                text=_("Could not find GRUB entry for {label}").format(label=selected_label))
            dlg.format_secondary_text(
                _("No matching kernel was found in /boot/grub/grub.cfg.\nTry running 'sudo update-grub' first."))
            dlg.run()
            dlg.destroy()
            return

        dlg = _msg_dialog(
            transient_for=self,
            modal=True,
            message_type=Gtk.MessageType.INFO,
            buttons=Gtk.ButtonsType.OK,
            text=_("Default boot set to {label}").format(label=selected_label))
        dlg.format_secondary_text(
            "\n".join(lines) +
            "\n\n" + _("GRUB entry: {entry}").format(entry=grub_entry) +
            "\n\n" + _("The selected kernel will be used on next reboot."))
        dlg.run()
        dlg.destroy()

    # ── Remove kernels ────────────────────────────────────────────────────────

    def _show_remove_kernels(self, _):
        """Show the kernel removal screen with checkboxes."""
        self._clear()
        self.resize(560, 500)
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.add(outer)

        # Header
        hdr = Gtk.Box(spacing=8)
        hdr.set_margin_top(16); hdr.set_margin_bottom(8)
        hdr.set_margin_start(16); hdr.set_margin_end(16)
        icon_path = _get_icon_path()
        if icon_path:
            try:
                pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(icon_path, 24, 24, True)
                hdr_icon = Gtk.Image.new_from_pixbuf(pb)
            except Exception:
                hdr_icon = Gtk.Image.new_from_icon_name("edit-delete", Gtk.IconSize.LARGE_TOOLBAR)
        else:
            hdr_icon = Gtk.Image.new_from_icon_name("edit-delete", Gtk.IconSize.LARGE_TOOLBAR)
        hdr.pack_start(hdr_icon, False, False, 0)
        lbl = Gtk.Label()
        lbl.set_markup(_("<b>Remove Kernels</b>"))
        lbl.set_halign(Gtk.Align.START)
        hdr.pack_start(lbl, True, True, 0)
        outer.pack_start(hdr, False, False, 0)

        outer.pack_start(Gtk.Separator(), False, False, 0)

        info = Gtk.Label(
            label=_("Select kernel packages to remove.\nThe currently running kernel cannot be removed."))
        info.set_line_wrap(True)
        info.set_margin_top(10); info.set_margin_bottom(6)
        info.set_margin_start(16); info.set_margin_end(16)
        info.set_halign(Gtk.Align.START)
        outer.pack_start(info, False, False, 0)

        # Scrollable package list
        sw = Gtk.ScrolledWindow()
        sw.set_vexpand(True)
        sw.set_margin_start(10); sw.set_margin_end(10)

        listbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        listbox.set_margin_top(8); listbox.set_margin_bottom(8)
        listbox.set_margin_start(8); listbox.set_margin_end(8)

        current_kernel = os.uname().release
        self._remove_checks = {}

        # Find installed linuxlite kernel packages
        result = subprocess.run(
            ['dpkg-query', '-W', '-f', '${Status}\t${Package}\n'],
            capture_output=True, text=True)

        packages = []
        for line in result.stdout.strip().splitlines():
            status, _, name = line.partition('\t')
            # Include fully-installed AND broken/half-installed packages so the
            # user can clean up failed installs (which would otherwise leave
            # /boot/vmlinuz-* and stale GRUB entries behind). Skip 'config-files'
            # (already purged) and 'not-installed' rows.
            if not status.startswith('install '):
                continue
            if 'not-installed' in status or 'config-files' in status:
                continue
            pkg = name.strip()
            if re.match(r'linux-(image|headers)-.*linuxlite', pkg):
                packages.append(pkg)

        packages.sort()

        if not packages:
            lbl = Gtk.Label(label=_("No Linux Lite kernel packages found."))
            lbl.set_margin_top(20)
            listbox.pack_start(lbl, False, False, 0)
        else:
            for pkg in packages:
                row = Gtk.Box(spacing=8)
                row.set_margin_top(2); row.set_margin_bottom(2)
                check = Gtk.CheckButton()

                # Check if this package belongs to the running kernel
                m = re.match(r'linux-(?:image|headers)-(.*)', pkg)
                is_running = m and m.group(1) == current_kernel

                if is_running:
                    check.set_sensitive(False)
                    lbl = Gtk.Label()
                    lbl.set_markup(f"{pkg}  <i>{_('(running)')}</i>")
                    lbl.set_halign(Gtk.Align.START)
                    lbl.get_style_context().add_class("running-label")
                else:
                    lbl = Gtk.Label(label=pkg)
                    lbl.set_halign(Gtk.Align.START)
                    self._remove_checks[pkg] = check

                row.pack_start(check, False, False, 0)
                row.pack_start(lbl, True, True, 0)
                listbox.pack_start(row, False, False, 0)

        sw.add(listbox)
        outer.pack_start(sw, True, True, 0)

        outer.pack_start(Gtk.Separator(), False, False, 0)

        # Button row
        btn_row = Gtk.Box(spacing=8)
        btn_row.set_margin_top(8); btn_row.set_margin_bottom(8)
        btn_row.set_margin_start(10); btn_row.set_margin_end(10)
        btn_row.pack_start(Gtk.Label(), True, True, 0)  # spacer

        btn_back = _icon_button("go-previous", _("Back"), "close-button")
        btn_back.connect("clicked", lambda _: self._build_main())
        btn_row.pack_start(btn_back, False, False, 0)

        btn_remove = _icon_button("edit-delete", _("Remove Selected"), "remove-button")
        btn_remove.connect("clicked", self._on_remove_clicked)
        btn_row.pack_start(btn_remove, False, False, 0)

        outer.pack_start(btn_row, False, False, 0)
        self.show_all()

    def _on_remove_clicked(self, _):
        selected = [pkg for pkg, chk in self._remove_checks.items() if chk.get_active()]
        if not selected:
            dlg = _msg_dialog(
                transient_for=self, modal=True,
                message_type=Gtk.MessageType.INFO,
                buttons=Gtk.ButtonsType.OK,
                text=_("No packages selected"))
            dlg.format_secondary_text(_("Select one or more kernel packages to remove."))
            dlg.run(); dlg.destroy()
            return

        dlg = _msg_dialog(
            transient_for=self, modal=True,
            message_type=Gtk.MessageType.WARNING,
            buttons=Gtk.ButtonsType.NONE,
            text=_("Remove {n} kernel package(s)?").format(n=len(selected)))
        dlg.format_secondary_text("\n".join(selected))
        dlg.add_button(_("Cancel"), Gtk.ResponseType.CANCEL)
        dlg.add_button(_("Remove"), Gtk.ResponseType.OK)
        response = dlg.run()
        dlg.destroy()

        if response == Gtk.ResponseType.OK:
            self._show_progress(_("Removing Kernels\u2026"))
            threading.Thread(target=self._do_remove, args=(selected,), daemon=True).start()

    def _do_remove(self, packages):
        try:
            GLib.idle_add(self._append_log,
                          _("Packages to remove:") + "\n  " + "\n  ".join(packages) + "\n\n")
            GLib.idle_add(self._append_log, _("Requesting root access\u2026") + "\n\n")

            proc = subprocess.Popen(
                ["pkexec", REMOVE_HELPER] + packages,
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                text=True, bufsize=1)
            for line in proc.stdout:
                GLib.idle_add(self._append_log, line)
            proc.wait()
            GLib.idle_add(self._remove_finish, proc.returncode)
        except Exception as e:
            GLib.idle_add(self._append_log, f"\nError: {e}\n")
            GLib.idle_add(self._remove_finish, 1)

    def _remove_finish(self, rc):
        if self._pulse_id:
            GLib.source_remove(self._pulse_id)
            self._pulse_id = None
        if rc == 0:
            self._progress.set_fraction(1.0)
            self._append_log("\n" + _("Kernel packages removed successfully.") + "\n\n")
        else:
            self._progress.set_fraction(0.0)
            self._append_log("\n" + _("Removal failed.") + "\n\n")
        btn_close = _icon_button("window-close", _("Close"), "close-button")
        btn_close.connect("clicked", lambda _: self._build_main())
        self._btn_row.pack_start(btn_close, False, False, 0)
        self._btn_row.show_all()
        GLib.idle_add(self._scroll_to_end)
        return False

    # ── Install from repo ─────────────────────────────────────────────────────

    def _start_install(self, flavour):
        label = _("Desktop") if flavour == "linuxlite" else _("Gaming")
        self._show_progress(_("Installing {flavour} Kernel\u2026").format(flavour=label))
        threading.Thread(target=self._do_install, args=(flavour,), daemon=True).start()

    def _do_install(self, flavour):
        try:
            GLib.idle_add(self._append_log, _("Fetching package list from repo\u2026") + "\n")
            headers_url, image_url = _fetch_repo_packages(flavour)
            GLib.idle_add(self._append_log, f"  headers : {os.path.basename(headers_url)}\n")
            GLib.idle_add(self._append_log, f"  image   : {os.path.basename(image_url)}\n\n")

            shutil.rmtree(INSTALL_TMPDIR, ignore_errors=True)
            os.makedirs(INSTALL_TMPDIR, exist_ok=True)
            os.makedirs(CACHE_DIR, exist_ok=True)

            # Stop pulsing so the progress bar shows real download progress
            if self._pulse_id:
                GLib.idle_add(self._stop_pulse)

            for url in (headers_url, image_url):
                name = os.path.basename(url)
                cached = os.path.join(CACHE_DIR, name)
                dest = os.path.join(INSTALL_TMPDIR, name)

                if os.path.exists(cached):
                    GLib.idle_add(self._append_log, _("Using cached {name}").format(name=name) + "\n")
                    shutil.copy2(cached, dest)
                else:
                    GLib.idle_add(self._append_log, _("Downloading {name}").format(name=name) + "\n")
                    GLib.idle_add(self._set_progress_fraction, 0.0)
                    self._download_with_progress(url, cached)
                    shutil.copy2(cached, dest)

            GLib.idle_add(self._append_log, "\n" + _("Download complete. Requesting root access\u2026") + "\n\n")

            proc = subprocess.Popen(
                ["pkexec", INSTALL_HELPER],
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                text=True, bufsize=1)
            for line in proc.stdout:
                GLib.idle_add(self._append_log, line)
            proc.wait()
            GLib.idle_add(self._finish, proc.returncode)

        except Exception as e:
            GLib.idle_add(self._append_log, f"\nError: {e}\n")
            GLib.idle_add(self._finish, 1)

    def _download_with_progress(self, url, dest):
        """Download a file with speed, progress, and ETA in status bar."""
        req = urllib.request.Request(url)
        resp = urllib.request.urlopen(req, timeout=30)
        total = int(resp.headers.get("Content-Length", 0))
        downloaded = 0
        start_time = time.time()
        chunk_size = 64 * 1024
        last_update = 0
        name = os.path.basename(dest)

        with open(dest, "wb") as f:
            while True:
                chunk = resp.read(chunk_size)
                if not chunk:
                    break
                f.write(chunk)
                downloaded += len(chunk)

                now = time.time()
                if now - last_update < 0.3:
                    continue
                last_update = now

                elapsed = now - start_time
                speed = downloaded / elapsed if elapsed > 0 else 0

                if speed > 1024 * 1024:
                    speed_str = f"{speed / (1024 * 1024):.1f} MB/s"
                elif speed > 1024:
                    speed_str = f"{speed / 1024:.0f} KB/s"
                else:
                    speed_str = f"{speed:.0f} B/s"

                if total > 0:
                    pct = downloaded * 100 / total
                    dl_mb = downloaded / (1024 * 1024)
                    total_mb = total / (1024 * 1024)
                    remaining = (total - downloaded) / speed if speed > 0 else 0
                    if remaining > 60:
                        eta_str = f"{remaining / 60:.0f}m {remaining % 60:.0f}s"
                    else:
                        eta_str = f"{remaining:.0f}s"
                    status = f"{dl_mb:.1f} / {total_mb:.1f} MB  |  {speed_str}  |  ETA: {eta_str}"
                    GLib.idle_add(self._set_status, status)
                    GLib.idle_add(self._set_progress_fraction, downloaded / total)
                else:
                    dl_mb = downloaded / (1024 * 1024)
                    GLib.idle_add(self._set_status, f"{dl_mb:.1f} MB  |  {speed_str}")

        # Final status
        dl_mb = downloaded / (1024 * 1024)
        elapsed = time.time() - start_time
        avg_speed = downloaded / elapsed if elapsed > 0 else 0
        if avg_speed > 1024 * 1024:
            avg_str = f"{avg_speed / (1024 * 1024):.1f} MB/s"
        else:
            avg_str = f"{avg_speed / 1024:.0f} KB/s"
        GLib.idle_add(self._set_status, f"{dl_mb:.1f} MB  |  {avg_str}  |  Complete")
        GLib.idle_add(self._append_log, f"  Downloaded {dl_mb:.1f} MB ({avg_str} avg)\n")

    def _stop_pulse(self):
        if self._pulse_id:
            GLib.source_remove(self._pulse_id)
            self._pulse_id = None
        self._progress.set_fraction(0.0)
        return False

    def _set_status(self, text):
        """Update the status label at the bottom."""
        self._status_label.set_text(text)
        return False

    def _set_progress_fraction(self, frac):
        self._progress.set_fraction(frac)
        return False

    # ── Performance profile ───────────────────────────────────────────────────

    def _switch_profile(self, profile):
        """Switch sysctl performance profile via pkexec."""
        try:
            result = subprocess.run(
                ["pkexec", PROFILE_HELPER, profile],
                capture_output=True, text=True)
            if result.returncode == 0:
                label = "Desktop" if profile == "desktop" else "Gaming"
                dlg = _msg_dialog(
                    transient_for=self, modal=True,
                    message_type=Gtk.MessageType.INFO,
                    buttons=Gtk.ButtonsType.OK,
                    text=_("{label} profile activated").format(label=label))
                dlg.format_secondary_text(
                    _("The {profile} performance profile is now active.\nThis takes effect immediately — no reboot required.").format(profile=profile))
                dlg.run(); dlg.destroy()
                self._build_main()  # refresh to update active indicator
            else:
                dlg = _msg_dialog(
                    transient_for=self, modal=True,
                    message_type=Gtk.MessageType.ERROR,
                    buttons=Gtk.ButtonsType.OK,
                    text=_("Profile switch failed"))
                dlg.format_secondary_text(result.stderr or _("Unknown error"))
                dlg.run(); dlg.destroy()
        except Exception as e:
            dlg = _msg_dialog(
                transient_for=self, modal=True,
                message_type=Gtk.MessageType.ERROR,
                buttons=Gtk.ButtonsType.OK,
                text=_("Profile switch failed"))
            dlg.format_secondary_text(str(e))
            dlg.run(); dlg.destroy()

    # ── Clear cache ───────────────────────────────────────────────────────────

    def _clear_cache(self, _):
        """Clear the download cache directory."""
        cache_size = _get_cache_size()
        if cache_size == "empty":
            dlg = _msg_dialog(
                transient_for=self, modal=True,
                message_type=Gtk.MessageType.INFO,
                buttons=Gtk.ButtonsType.OK,
                text=_("Cache is empty"))
            dlg.format_secondary_text(_("No cached downloads to remove."))
            dlg.run(); dlg.destroy()
            return

        dlg = _msg_dialog(
            transient_for=self, modal=True,
            message_type=Gtk.MessageType.WARNING,
            buttons=Gtk.ButtonsType.NONE,
            text=_("Clear download cache ({size})?").format(size=cache_size))
        dlg.format_secondary_text(
            _("Cached kernel packages will be removed.\nThey will be re-downloaded if needed."))
        dlg.add_button(_("Cancel"), Gtk.ResponseType.CANCEL)
        dlg.add_button(_("Clear"), Gtk.ResponseType.OK)
        response = dlg.run()
        dlg.destroy()

        if response == Gtk.ResponseType.OK:
            shutil.rmtree(CACHE_DIR, ignore_errors=True)
            os.makedirs(CACHE_DIR, exist_ok=True)
            dlg = _msg_dialog(
                transient_for=self, modal=True,
                message_type=Gtk.MessageType.INFO,
                buttons=Gtk.ButtonsType.OK,
                text=_("Cache cleared"))
            dlg.format_secondary_text(_("Freed {size} of disk space.").format(size=cache_size))
            dlg.run(); dlg.destroy()
            self._build_main()  # refresh to update cache size

    # ── About dialog ──────────────────────────────────────────────────────────

    def _show_about(self, _):
        """Show the About dialog."""
        dlg = _msg_dialog(
            transient_for=self, modal=True,
            message_type=Gtk.MessageType.INFO,
            buttons=Gtk.ButtonsType.OK,
            text="Lite Kernel Manager")

        icon_path = _get_icon_path()
        if icon_path:
            try:
                pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(icon_path, 64, 64, True)
                img = dlg.get_message_area().get_children()[0]
                dlg.set_image(Gtk.Image.new_from_pixbuf(pb))
            except Exception:
                pass

        dlg.format_secondary_markup(
            _("Install, manage, and benchmark Linux Lite kernels.\nDesktop and Gaming flavours with community patches.") + "\n\n" +
            _("Created by {author}").format(author="Jerry Bezencon") + "\n" +
            _("License: {lic}").format(lic="GPL-2.0") + "\n\n" +
            '<a href="https://www.linuxliteos.com">linuxliteos.com</a>')

        dlg.run()
        dlg.destroy()
        return True  # prevent LinkButton from opening URL

    # ── Progress screen ───────────────────────────────────────────────────────

    def _show_progress(self, title):
        self._clear()
        self.resize(800, 420)
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.add(outer)

        hdr = Gtk.Box(spacing=8)
        hdr.set_margin_top(16); hdr.set_margin_bottom(8)
        hdr.set_margin_start(16); hdr.set_margin_end(16)
        icon_path = _get_icon_path()
        if icon_path:
            try:
                pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(icon_path, 24, 24, True)
                hdr_icon = Gtk.Image.new_from_pixbuf(pb)
            except Exception:
                hdr_icon = Gtk.Image.new_from_icon_name("system-run", Gtk.IconSize.LARGE_TOOLBAR)
        else:
            hdr_icon = Gtk.Image.new_from_icon_name("system-run", Gtk.IconSize.LARGE_TOOLBAR)
        hdr.pack_start(hdr_icon, False, False, 0)
        lbl = Gtk.Label()
        lbl.set_markup(f"<b>{title}</b>")
        lbl.set_halign(Gtk.Align.START)
        lbl.set_hexpand(True)
        hdr.pack_start(lbl, True, True, 0)
        outer.pack_start(hdr, False, False, 0)

        outer.pack_start(Gtk.Separator(), False, False, 0)

        sw = Gtk.ScrolledWindow()
        sw.set_vexpand(True)
        sw.set_margin_top(6); sw.set_margin_bottom(4)
        sw.set_margin_start(10); sw.set_margin_end(10)
        self._log_buf = Gtk.TextBuffer()
        tv = Gtk.TextView(buffer=self._log_buf)
        tv.set_editable(False)
        tv.set_cursor_visible(False)
        tv.override_font(Pango.FontDescription("Hack 10"))
        tv.get_style_context().add_class("progress-view")
        self._log_tv = tv
        sw.add(tv)
        outer.pack_start(sw, True, True, 0)

        # Bottom status area
        bottom = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        bottom.set_margin_start(10); bottom.set_margin_end(10)
        bottom.set_margin_top(4); bottom.set_margin_bottom(4)

        self._status_label = Gtk.Label(label="")
        self._status_label.set_halign(Gtk.Align.START)
        self._status_label.set_ellipsize(Pango.EllipsizeMode.NONE)
        self._status_label.set_line_wrap(False)
        self._status_label.set_selectable(False)
        self._status_label.get_style_context().add_class("status-label")
        self._status_label.override_font(Pango.FontDescription("Hack 10"))
        bottom.pack_start(self._status_label, False, False, 0)

        self._progress = Gtk.ProgressBar()
        self._progress.set_pulse_step(0.04)
        bottom.pack_start(self._progress, False, False, 0)

        outer.pack_start(bottom, False, False, 0)

        outer.pack_start(Gtk.Separator(), False, False, 0)

        self._btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self._btn_row.set_margin_top(8); self._btn_row.set_margin_bottom(8)
        self._btn_row.set_margin_start(10); self._btn_row.set_margin_end(10)
        self._btn_row.pack_start(Gtk.Label(), True, True, 0)
        outer.pack_start(self._btn_row, False, False, 0)

        self._pulse_id = GLib.timeout_add(60, self._pulse)
        self.show_all()

    def _pulse(self):
        self._progress.pulse()
        return True

    def _append_log(self, text):
        it = self._log_buf.get_end_iter()
        self._log_buf.insert(it, text)
        mark = self._log_buf.create_mark(None, self._log_buf.get_end_iter(), False)
        self._log_tv.scroll_to_mark(mark, 0.0, True, 0.0, 1.0)
        return False

    def _scroll_to_end(self):
        """Scroll log to bottom after a short delay so GTK layout is settled."""
        GLib.timeout_add(150, self._do_scroll)
        return False

    def _do_scroll(self):
        mark = self._log_buf.create_mark(None, self._log_buf.get_end_iter(), False)
        self._log_tv.scroll_to_mark(mark, 0.0, True, 0.0, 1.0)
        return False

    def _finish(self, rc):
        if self._pulse_id:
            GLib.source_remove(self._pulse_id)
            self._pulse_id = None
        if rc == 0:
            self._progress.set_fraction(1.0)
            self._append_log("\n" + _("Installation complete.") + "\n\n")
            btn_reboot = _icon_button("system-reboot", _("Reboot Now"), "action-button")
            btn_reboot.connect("clicked", lambda _: subprocess.call(["systemctl", "reboot"]))
            self._btn_row.pack_start(btn_reboot, False, False, 8)
        else:
            self._progress.set_fraction(0.0)
            self._append_log("\n" + _("Installation failed. Your current kernel is unaffected.") + "\n\n")
        btn_close = _icon_button("window-close", _("Close"), "close-button")
        btn_close.connect("clicked", lambda _: self._build_main())
        self._btn_row.pack_start(btn_close, False, False, 0)
        self._btn_row.show_all()
        GLib.idle_add(self._scroll_to_end)
        return False

    def _clear(self):
        for child in self.get_children():
            self.remove(child)


win = LiteKernelManager()
win.show_all()
Gtk.main()
