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

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/litekernel.png"
ICON_PATH_DEV  = os.path.join(TOOL_DIR, "litekernel.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) -> glxinfo
    -> lspci basic. Skips nouveau internal codenames like 'NV134'.
    """
    # 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:
                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. glxinfo -B — good for Intel/AMD; reject nouveau codenames (NV<digits>)
    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

    # 4. lspci basic (vendor + chip code, no marketing name available)
    if lspci_basic:
        return lspci_basic
    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)
        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 = f"\u2728 Recommended: Gaming  |  Score: {fps}  |  Confidence: {confidence}"
                    rec_class = "rec-badge-gaming"
                else:
                    rec_text = f"\u2705 Recommended: Desktop  |  Score: {fps}  |  Confidence: {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(
                f"Upload disabled — running in a VM ({self._vm_type}). "
                "VM benchmark results aren't comparable to bare metal "
                "and can't be submitted to the public database.")
        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", f"Clear Cache ({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 = f"Update available: {latest} (installed: {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=f"Virtual machine detected ({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\n"
                "You 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("\nBenchmark complete.\n\n")
            self._bench_done_this_session = True
            report = self._find_bench_report()
            if report:
                self._append_log(f"Report saved to: {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("\nBenchmark 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(
                f"This system is a virtual machine ({self._vm_type}). "
                "Benchmark results from VMs aren't accepted by the public database.")
            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(
            f"CPU:     {payload['cpu']}\n"
            f"Memory:  {payload['memory']}\n"
            f"GPU:     {payload['gpu']}\n"
            f"Kernel:  {payload['kernel']}\n"
            f"Distro:  {payload['distro']}\n"
            f"Score:   {payload['score']}\n\n"
            f"Results are public and viewable at:\n{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\n"
                    f"View all results at:\n{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\n"
                    f"View all results at:\n{link}")
            else:
                text = "Benchmark uploaded"
                secondary = (
                    "Your results have been added to the public benchmark database.\n\n"
                    f"View all results at:\n{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(f"Desktop kernel: installed ({desktop_ver})")
        else:
            lines.append("Desktop kernel: not installed")
        if gaming_installed:
            lines.append(f"Gaming kernel: installed ({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=f"{selected_label} is not installed")
            dlg.format_secondary_text(
                "\n".join(lines) +
                "\n\nUse 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=f"Could not find GRUB entry for {selected_label}")
            dlg.format_secondary_text(
                "No matching kernel was found in /boot/grub/grub.cfg.\n"
                "Try 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=f"Default boot set to {selected_label}")
        dlg.format_secondary_text(
            "\n".join(lines) +
            f"\n\nGRUB entry: {grub_entry}"
            "\n\nThe 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.\n"
                  "The 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=f"Remove {len(selected)} kernel package(s)?")
        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("\nKernel packages removed successfully.\n\n")
        else:
            self._progress.set_fraction(0.0)
            self._append_log("\nRemoval 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(f"Installing {label} Kernel\u2026")
        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, f"Using cached {name}\n")
                    shutil.copy2(cached, dest)
                else:
                    GLib.idle_add(self._append_log, f"Downloading {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, "\nDownload 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=f"{label} profile activated")
                dlg.format_secondary_text(
                    f"The {profile} performance profile is now active.\n"
                    "This takes effect immediately — no reboot required.")
                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=f"Clear download cache ({cache_size})?")
        dlg.format_secondary_text(
            "Cached kernel packages will be removed.\n"
            "They 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(f"Freed {cache_size} of disk space.")
            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.\n"
            "Desktop and Gaming flavours with community patches.\n\n"
            "Created by Jerry Bezencon\n"
            "License: 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("\nInstallation 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("\nInstallation 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()
