#!/usr/bin/python3
# -*- coding: utf-8 -*-
#
# Lite Game Center — one-click gaming setup for Linux Lite 8.0
#
# Bundles Steam / Proton-GE / Lutris / Vulkan / GameMode /
# MangoHud / Gamescope / system tweaks / optional PipeWire migration into
# named "Pick a stack" tiles. The Python GUI is a thin shell over the
# pkexec helper at /usr/lib/lite-game-center/lite-game-center-helper,
# which does the actual apt/config work — same split-of-trust pattern as
# Lite Driver Manager and Lite Software Sources.

import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gdk", "4.0")
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import Gtk, Adw, GLib, Gio, Gdk, GdkPixbuf, GObject, Pango

import os
import re
import sys
import shutil
import subprocess
import threading
import time
from pathlib import Path

APP_ID    = "com.linuxlite.lite-game-center"
APP_NAME  = "Lite Game Center"
ICON_NAME = "lite-gamecenter"

HELPER    = "/usr/lib/lite-game-center/lite-game-center-helper"
PKEXEC    = "/usr/bin/pkexec"
LOGO_PATH = "/usr/share/lite-game-center/lite-game-center-logo.svg"

# Persistent install log written by the helper. The "Open Log" buttons
# in the main header bar and each progress dialog point at this path.
LOG_FILE  = os.path.expanduser("~/.local/share/lite-gamecenter/install.log")

# Per-tile verb invoked on the helper. Single source of truth for "what
# does this button actually do" — the GUI only fires the verb, the helper
# owns the apt + config writes.
VERB_STEAM_ESSENTIALS = "install-steam-essentials"
VERB_STEAM_PROTON_GE  = "install-steam-proton-ge"
VERB_LUTRIS_WINE_GE   = "install-lutris-wine-ge"
VERB_NVIDIA_TWEAKS    = "apply-nvidia-tweaks"
VERB_SYSTEM_TWEAKS    = "apply-system-tweaks"
VERB_SWITCH_PIPEWIRE  = "switch-to-pipewire"
VERB_SWITCH_PULSE     = "switch-to-pulseaudio"
VERB_FULL_SETUP       = "install-full-setup"
VERB_REMOVE_PREFIX    = "remove-"

# Sentinel files the helper writes to mark a tweak as applied. The GUI
# reads these to decide each tile's badge state.
SENTINEL_NVIDIA_TWEAKS = Path("/etc/environment.d/99-linuxlite-gaming-nvidia.conf")
SENTINEL_SYSTEM_TWEAKS = Path("/etc/sysctl.d/99-linuxlite-gaming.conf")

# CSS — shared visual tokens with the HTML mockup, translated for GTK4.
# Mockup at /home/jerry/repo/hematite/litegamecenter/litegamecenter-mockup.html.
# Stored as a unicode string + .encode('utf-8') below so comments here can
# safely contain UTF-8 punctuation (em-dashes etc.).
CSS = """
window, .lgc-bg {
    background-color: #0d1117;
    color: #e8eef3;
}

.lgc-hero-title {
    font-size: 30px;
    font-weight: 800;
    letter-spacing: -0.6px;
    color: #5BA8E8;
}
.lgc-hero-tagline {
    font-size: 14px;
    color: #8a9199;
}
.lgc-hero-tagline-strong {
    font-size: 14px;
    color: #c5cdd6;
    font-weight: 500;
}

/* Section headings (Pick your stack / Tweaks & Optimisations) */
.lgc-section-heading {
    font-size: 12px;
    font-weight: 700;
    color: #8a9199;
}
.lgc-section-note {
    font-size: 11px;
    color: #5a6168;
}

/* Status strip — our own dark surface, NOT libadwaita's .card class
   (which renders white in light contexts and washed out the pills). */
.lgc-status-strip {
    background-color: #161b22;
    border: 1px solid #2d3741;
    border-radius: 14px;
    padding: 14px;
}

/* Status pills — subtle glow per state. The glow does double duty:
   reads as a "status indicator" at a glance AND lifts the pills off
   the dark surface so they don't look like flat dead text. */
.lgc-pill {
    padding: 6px 13px;
    border-radius: 18px;
    background-color: #1f262e;
    color: #c5cdd6;
    font-size: 12px;
    font-weight: 500;
    border: 1px solid #3a4452;
    box-shadow: 0 0 12px rgba(91,168,232,0.12);
}
.lgc-pill.ok {
    color: #4ade80;
    background-color: rgba(74,222,128,0.10);
    border-color: rgba(74,222,128,0.40);
    box-shadow: 0 0 14px rgba(74,222,128,0.28);
}
.lgc-pill.miss {
    color: #8a9199;
    box-shadow: 0 0 10px rgba(90,97,105,0.18);
}
.lgc-pill.warn {
    color: #f59e0b;
    background-color: rgba(245,158,11,0.10);
    border-color: rgba(245,158,11,0.40);
    box-shadow: 0 0 14px rgba(245,158,11,0.28);
}
.lgc-pill-label-dim {
    color: #5a6168;
    font-weight: 400;
}

/* Kernel CTA chip */
.lgc-kernel-chip {
    padding: 14px 18px;
    border-radius: 14px;
    background-color: rgba(245,158,11,0.06);
    border: 1px solid rgba(245,158,11,0.32);
}
.lgc-kernel-chip-title {
    color: #f59e0b;
    font-weight: 600;
    font-size: 13px;
}
.lgc-kernel-chip-body {
    color: #c5cdd6;
    font-size: 12px;
}
.lgc-kernel-chip-cta {
    color: #f59e0b;
    border: 1px solid rgba(245,158,11,0.45);
    background-color: transparent;
    padding: 7px 13px;
    border-radius: 8px;
    font-weight: 600;
    font-size: 12px;
}
.lgc-kernel-chip-cta:hover {
    background-color: rgba(245,158,11,0.14);
}

/* Featured (Full Setup) card */
.lgc-featured {
    padding: 22px 24px;
    border-radius: 18px;
    background: linear-gradient(135deg, #161b22 0%, #1f262e 100%);
    border: 2px solid rgba(151,71,255,0.32);
}
.lgc-featured-eyebrow {
    font-size: 10px;
    font-weight: 700;
    color: #fbbf24;
}
.lgc-featured-title {
    font-size: 22px;
    font-weight: 800;
    color: #e8eef3;
    letter-spacing: -0.4px;
}
.lgc-featured-sub {
    font-size: 13px;
    color: #8a9199;
}
.lgc-featured-icon-bg {
    background: linear-gradient(135deg, #fbbf24 0%, #9747ff 100%);
    border-radius: 16px;
    padding: 14px;
    min-width: 56px;
    min-height: 56px;
}
.lgc-featured-btn {
    background: linear-gradient(135deg, #5BA8E8 0%, #9747ff 100%);
    color: white;
    border: none;
    border-radius: 11px;
    padding: 13px 22px;
    font-weight: 700;
    font-size: 14px;
}
.lgc-featured-btn:hover { opacity: 0.92; }

/* Per-tile profile cards */
.lgc-card {
    background-color: #161b22;
    border: 1px solid #2d3741;
    border-radius: 14px;
    padding: 20px;
}
.lgc-card:hover {
    border-color: rgba(91,168,232,0.32);
}
.lgc-card.installed {
    border-color: rgba(74,222,128,0.30);
}
.lgc-card.installing {
    border-color: rgba(91,168,232,0.32);
}

.lgc-card-icon {
    border-radius: 11px;
    padding: 11px;
    min-width: 44px;
    min-height: 44px;
}
.lgc-card-icon.steam   { background-color: rgba(26,159,255,0.16);  color: #1a9fff; }
.lgc-card-icon.proton  { background-color: rgba(233,78,27,0.16);   color: #e94e1b; }
.lgc-card-icon.pplus   { background-color: rgba(151,71,255,0.18);  color: #9747ff; }
.lgc-card-icon.lutris  { background-color: rgba(244,118,41,0.16);  color: #f47629; }
.lgc-card-icon.nvidia  { background-color: rgba(118,185,0,0.18);   color: #76b900; }
.lgc-card-icon.system  { background-color: rgba(245,158,11,0.18);  color: #f59e0b; }
.lgc-card-icon.audio   { background-color: rgba(45,212,191,0.16);  color: #2dd4bf; }

.lgc-card-status {
    font-size: 10px;
    font-weight: 700;
    color: #8a9199;
}
.lgc-card-status.installed { color: #4ade80; }
.lgc-card-status.installing { color: #5BA8E8; }

.lgc-card-title {
    font-size: 16px;
    font-weight: 700;
    color: #e8eef3;
}
.lgc-card-tagline {
    font-size: 12px;
    color: #8a9199;
}
.lgc-card-includes-label {
    font-size: 9px;
    font-weight: 700;
    color: #5a6168;
}
.lgc-card-includes-pill {
    background-color: #2a3038;
    color: #c5cdd6;
    font-size: 10px;
    padding: 3px 8px;
    border-radius: 5px;
    border: 1px solid #2d3741;
}
.lgc-card-changes {
    padding: 10px 12px;
    border-radius: 8px;
    background-color: rgba(45,212,191,0.06);
    border: 1px solid rgba(45,212,191,0.22);
    border-left: 3px solid #2dd4bf;
}
.lgc-card-changes-title {
    font-size: 11px;
    font-weight: 600;
    color: #2dd4bf;
}
.lgc-card-changes-body {
    font-size: 11px;
    color: #c5cdd6;
}
.lgc-card-changes-list {
    font-size: 10px;
    color: #8a9199;
}
.lgc-card-cta {
    background-color: rgba(91,168,232,0.12);
    color: #5BA8E8;
    border: 1px solid rgba(91,168,232,0.32);
    border-radius: 10px;
    padding: 10px 14px;
    font-weight: 700;
    font-size: 12px;
}
.lgc-card-cta:hover {
    background-color: #5BA8E8;
    color: white;
}
.lgc-card-cta.muted {
    background-color: transparent;
    color: #8a9199;
    border-color: #3a4452;
}
.lgc-card-cta.muted:hover {
    background-color: #1f262e;
    color: #c5cdd6;
}
.lgc-card-cta.danger {
    background-color: transparent;
    color: #f87171;
    border-color: rgba(248,113,113,0.32);
}
.lgc-card-cta.danger:hover {
    background-color: rgba(248,113,113,0.14);
}

/* Progress dialog — log view. Bright foreground for legibility on the
   dark surface; per-line tags below add colour for status / ok / error /
   apt lines so the eye can pick out structure inside a wall of dpkg
   output. */
.lgc-log-view text {
    background-color: #0a0e14;
    color: #e8eef3;
    font-family: "JetBrains Mono", "DejaVu Sans Mono", monospace;
    font-size: 12px;
    padding: 14px;
}

/* Progress dialog — chrome. Force brand-blue on every label and a
   matching progress-bar fill, so the dialog reads as part of Lite Game
   Center rather than picking up whatever colour the system Adwaita
   theme happens to be set to. The Adw.Window itself plus its content
   box, header bar, labels, and buttons all get explicit colours. */
.lgc-prog-window,
.lgc-prog-window > * {
    background-color: #0d1117;
    color: #5BA8E8;
}
.lgc-prog-window headerbar {
    background-color: #161b22;
    border-bottom: 1px solid #2d3741;
    min-height: 38px;
}
.lgc-prog-window headerbar label,
.lgc-prog-window headerbar .title,
.lgc-prog-window headerbar windowhandle label {
    color: #5BA8E8;
    font-weight: 600;
}
.lgc-prog-title {
    color: #5BA8E8;
    font-size: 18px;
    font-weight: 700;
    letter-spacing: -0.3px;
}
.lgc-prog-sub {
    color: #5BA8E8;
    font-size: 13px;
    opacity: 0.85;
}
.lgc-prog-window progressbar > trough {
    background-color: #1f262e;
    border: 1px solid #2d3741;
    border-radius: 4px;
    min-height: 6px;
}
.lgc-prog-window progressbar > trough > progress {
    background: linear-gradient(90deg, #5BA8E8 0%, #9747ff 100%);
    border-radius: 4px;
    min-height: 6px;
}
.lgc-prog-window button {
    background-color: rgba(91,168,232,0.12);
    color: #5BA8E8;
    border: 1px solid rgba(91,168,232,0.32);
    border-radius: 8px;
    padding: 6px 16px;
    font-weight: 600;
}
.lgc-prog-window button:hover {
    background-color: #5BA8E8;
    color: white;
}
.lgc-prog-window button:disabled {
    opacity: 0.5;
}
"""


# ════════════════════════════════════════════════════════════════════
# State detection
# ════════════════════════════════════════════════════════════════════
def _run(args, timeout=4):
    """Run a command, capture text output, return (rc, stdout, stderr).
    Never raises — failures return (-1, '', str(e)). Forces C locale so
    callers parsing apt/dpkg output get English strings."""
    env = os.environ.copy()
    env["LANG"] = "C.UTF-8"
    env["LC_ALL"] = "C.UTF-8"
    try:
        p = subprocess.run(
            args, capture_output=True, text=True, timeout=timeout, env=env)
        return p.returncode, p.stdout, p.stderr
    except (subprocess.SubprocessError, FileNotFoundError, OSError) as e:
        return -1, "", str(e)


def _pkg_installed(pkg):
    """True if dpkg-query says the package is installed (ii). False on
    'rc' (removed, configs remain), 'un' (never installed), absent, or
    any error. Cheap — no apt cache load."""
    rc, out, _ = _run(["dpkg-query", "-W", "-f=${Status}", pkg], timeout=3)
    return rc == 0 and "install ok installed" in out


def _which(cmd):
    return shutil.which(cmd) is not None


def detect_state():
    """Returns a dict describing what's currently installed, what gaming
    artefacts exist on disk, which audio server is active, etc. Called
    on startup AND after each install completes so the UI auto-refreshes."""
    state = {}

    # ── Steam ───────────────────────────────────────────────────────
    state["steam_pkg"] = _pkg_installed("steam-installer") or _pkg_installed("steam")
    state["steam_bin"] = _which("steam")

    # ── Vulkan stack ───────────────────────────────────────────────
    state["vulkan"] = _pkg_installed("libvulkan1") and _pkg_installed("mesa-vulkan-drivers")
    if state["vulkan"]:
        rc, out, _ = _run(["dpkg-query", "-W", "-f=${Version}", "mesa-vulkan-drivers"])
        state["mesa_version"] = out.strip() if rc == 0 else ""
    else:
        state["mesa_version"] = ""

    # ── Proton-GE ──────────────────────────────────────────────────
    # Looks for any GE-Proton* dir under either Steam-native or
    # Steam-Snap-fallback compatibility tools path. We never install via
    # snap but we read both to make sure we report accurately if a user
    # had snap-Steam before.
    proton_ge_dirs = []
    home = os.path.expanduser("~")
    for candidate in [
        Path(home) / ".steam/root/compatibilitytools.d",
        Path(home) / ".local/share/Steam/compatibilitytools.d",
    ]:
        if candidate.is_dir():
            for child in candidate.iterdir():
                if child.is_dir() and child.name.startswith("GE-Proton"):
                    proton_ge_dirs.append(child)
    state["proton_ge"] = bool(proton_ge_dirs)
    state["proton_ge_latest"] = (
        max((d.name for d in proton_ge_dirs), default="")
        if proton_ge_dirs else "")

    # ── Lutris ─────────────────────────────────────────────────────
    state["lutris"] = _pkg_installed("lutris")

    # ── NVIDIA driver + classification ─────────────────────────────
    state["nvidia_present"] = False
    state["nvidia_driver_version"] = ""
    rc, out, _ = _run(["lspci"], timeout=3)
    if rc == 0 and re.search(r"NVIDIA Corporation", out):
        state["nvidia_present"] = True
        rc2, out2, _ = _run([
            "nvidia-smi",
            "--query-gpu=driver_version",
            "--format=csv,noheader,nounits"], timeout=3)
        if rc2 == 0:
            state["nvidia_driver_version"] = out2.strip().splitlines()[0] if out2.strip() else ""

    # ── Sentinel files for applied tweaks ──────────────────────────
    state["nvidia_tweaks_applied"] = SENTINEL_NVIDIA_TWEAKS.is_file()
    state["system_tweaks_applied"] = SENTINEL_SYSTEM_TWEAKS.is_file()

    # ── Audio server ───────────────────────────────────────────────
    # PipeWire is "active" if the daemon package is installed and the
    # user-level service has been started. We don't trust the package
    # alone — libpipewire-* often gets pulled in transitively without
    # the actual daemon being enabled.
    state["pipewire_pkg"] = _pkg_installed("pipewire") and _pkg_installed("wireplumber")
    if state["pipewire_pkg"]:
        rc, out, _ = _run(["systemctl", "--user", "is-active", "pipewire.service"], timeout=3)
        state["pipewire_active"] = (rc == 0 and "active" in out)
    else:
        state["pipewire_active"] = False
    state["audio_server"] = "pipewire" if state["pipewire_active"] else "pulseaudio"

    # ── Running kernel ─────────────────────────────────────────────
    rc, out, _ = _run(["uname", "-r"], timeout=2)
    state["kernel_release"] = out.strip() if rc == 0 else ""
    state["kernel_is_gaming"] = "linuxlite-gaming" in state["kernel_release"]

    return state


# ════════════════════════════════════════════════════════════════════
# Helper invocation
# ════════════════════════════════════════════════════════════════════
def open_install_log():
    """Open the persistent install log in the user's default text editor.
    Pre-creates the file with a friendly placeholder header so the button
    always Just Works, even before the first install. Uses xdg-open so
    the user's preferred handler (.txt -> Mousepad / gedit / VSCode etc.)
    is respected."""
    log_path = Path(LOG_FILE)
    try:
        log_path.parent.mkdir(parents=True, exist_ok=True)
        if not log_path.exists():
            log_path.write_text(
                "Lite Game Center — install log\n"
                "Each install/remove session appends to this file. "
                "No sessions yet.\n")
    except OSError as e:
        print(f"LGC: could not create log file: {e}", file=sys.stderr)
    try:
        subprocess.Popen(["xdg-open", str(log_path)])
    except (OSError, FileNotFoundError) as e:
        print(f"LGC: could not open log file: {e}", file=sys.stderr)


def spawn_helper(verb, on_line, on_done, extra_args=None):
    """Spawn pkexec + helper with the given verb. Streams each stdout
    line to `on_line` via GLib.idle_add (safe for UI updates). Calls
    `on_done(rc)` once the process exits. Non-blocking; returns the
    Popen handle so the caller can store it for cancellation."""
    args = [PKEXEC, HELPER, verb]
    if extra_args:
        args.extend(extra_args)

    env = os.environ.copy()
    env["LANG"] = "C.UTF-8"
    env["LC_ALL"] = "C.UTF-8"
    # Pass the calling user so the helper knows whose ~/.steam/
    # ~/.local/share/lutris/ etc. to write to when extracting tarballs.
    env["LITE_GAMECENTER_USER"] = os.environ.get("USER", "")
    env["LITE_GAMECENTER_HOME"] = os.path.expanduser("~")

    try:
        proc = subprocess.Popen(
            args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            text=True, bufsize=1, env=env)
    except (OSError, FileNotFoundError) as e:
        GLib.idle_add(on_line, f"FAILED to spawn helper: {e}")
        GLib.idle_add(on_done, -1)
        return None

    def _reader():
        try:
            for line in iter(proc.stdout.readline, ""):
                if not line:
                    break
                GLib.idle_add(on_line, line.rstrip("\n"))
        finally:
            proc.stdout.close()
            rc = proc.wait()
            GLib.idle_add(on_done, rc)

    threading.Thread(target=_reader, daemon=True).start()
    return proc


# ════════════════════════════════════════════════════════════════════
# Inline SVG icons (mirror the mockup glyphs)
# ════════════════════════════════════════════════════════════════════
# Each tile's icon is the same shape the mockup uses, drawn from a
# stroke SVG that we tint via `color:` on the wrapping CSS class.
TILE_ICON_SVGS = {
    "steam":  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 11h4v6H6zM14 7h4v10h-4z"/><circle cx="8" cy="9" r="3"/><path d="M2 22l9-9"/></svg>',
    "proton": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><ellipse cx="12" cy="12" rx="10" ry="4"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(60 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(120 12 12)"/></svg>',
    "pplus":  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12l-2-2H5L3 12l2 2h14l2-2z"/><line x1="12" y1="6" x2="12" y2="18"/><line x1="9" y1="9" x2="15" y2="9"/><line x1="9" y1="15" x2="15" y2="15"/></svg>',
    "lutris": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2h8l-1 8a4 4 0 0 1-3 4h-0a4 4 0 0 1-3-4z"/><line x1="12" y1="14" x2="12" y2="20"/><line x1="8" y1="22" x2="16" y2="22"/></svg>',
    "nvidia": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z"/></svg>',
    "system": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><circle cx="9" cy="6" r="2"/><line x1="4" y1="12" x2="20" y2="12"/><circle cx="15" cy="12" r="2"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="8" cy="18" r="2"/></svg>',
    "audio":  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h2"/><path d="M6 8v8"/><path d="M10 4v16"/><path d="M14 7v10"/><path d="M18 10v4"/><path d="M22 12h-2"/></svg>',
    "star":   '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.95 6.91L22 9.74l-5.5 4.76L18.18 22 12 18.27 5.82 22l1.68-7.5L2 9.74l7.05-.83L12 2z"/></svg>',
}


def make_tile_icon(kind, size=44):
    """Return a Gtk.Image rendering the named glyph as an SVG texture
    via Gdk.Texture.new_from_bytes — future-proof for GTK 4.20+ since
    we never touch GdkPixbuf.Pixbuf.new_for_pixbuf()."""
    svg = TILE_ICON_SVGS.get(kind, TILE_ICON_SVGS["star"])
    loader = GdkPixbuf.PixbufLoader()
    loader.set_size(size, size)
    loader.write(svg.encode("utf-8"))
    loader.close()
    pix = loader.get_pixbuf()
    success, buf = pix.save_to_bufferv("png", [], [])
    bytes_obj = GLib.Bytes.new(buf)
    texture = Gdk.Texture.new_from_bytes(bytes_obj)
    img = Gtk.Image.new_from_paintable(texture)
    img.set_pixel_size(size)
    return img


# ════════════════════════════════════════════════════════════════════
# UI primitives — build once, used many times
# ════════════════════════════════════════════════════════════════════
def make_pill(label, css_classes=None, dim_label=None):
    """Status-pill widget used in the top strip. `dim_label` is the
    detail half (e.g. 'mesa 25.0.4') in a softer colour."""
    box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
    box.add_css_class("lgc-pill")
    for c in (css_classes or []):
        box.add_css_class(c)
    text = Gtk.Label(label=label)
    text.set_xalign(0)
    box.append(text)
    if dim_label:
        dim = Gtk.Label(label=dim_label)
        dim.set_xalign(0)
        dim.add_css_class("lgc-pill-label-dim")
        box.append(dim)
    return box


def make_includes_pill(label):
    """Small pill in the 'Includes' row of a tile card."""
    lbl = Gtk.Label(label=label)
    lbl.add_css_class("lgc-card-includes-pill")
    lbl.set_xalign(0.5)
    return lbl


def make_section_heading(title, note=None):
    box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
    box.set_margin_top(12)
    box.set_margin_bottom(8)
    title_lbl = Gtk.Label(label=title.upper())
    title_lbl.set_xalign(0)
    title_lbl.add_css_class("lgc-section-heading")
    box.append(title_lbl)
    if note:
        note_lbl = Gtk.Label(label=note)
        note_lbl.set_xalign(1)
        note_lbl.set_hexpand(True)
        note_lbl.add_css_class("lgc-section-note")
        note_lbl.set_halign(Gtk.Align.END)
        box.append(note_lbl)
    return box


# ════════════════════════════════════════════════════════════════════
# Tile card widget
# ════════════════════════════════════════════════════════════════════
class TileCard(Gtk.Frame):
    """One install tile — icon + title + tagline + includes pills +
    action button. Caller wires `on_install` (Install button click) and
    `on_remove` (after a tile is in `installed` state and user wants to
    revert). `apply_state(state_dict)` updates the visible badge/CTA to
    match the latest detection.

    spec keys (passed at construction):
        kind            — icon CSS class ('steam'/'proton'/'pplus' etc.)
        glyph           — glyph key into TILE_ICON_SVGS
        title           — card title
        tagline         — one-sentence pitch
        includes        — list of strings for the pill row
        verb            — install verb passed to the helper
        is_applied      — callable(state) -> bool, decides installed badge
        cta_label       — Install button label
        cta_label_done  — Reinstall/Reapply button label when installed
        changes_html    — optional dict for the 'What changes' callout
                          (keys: title, body, bullets[list[str]])
    """

    __gsignals__ = {
        "install-requested": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "remove-requested":  (GObject.SignalFlags.RUN_FIRST, None, ()),
    }

    def __init__(self, spec):
        super().__init__()
        self.spec = spec
        self.add_css_class("lgc-card")
        self.set_hexpand(True)
        self.set_vexpand(False)

        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        self.set_child(outer)

        # ── Header (icon + title/status) ────────────────────────────
        head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)

        icon_wrap = Gtk.Box()
        icon_wrap.add_css_class("lgc-card-icon")
        icon_wrap.add_css_class(spec["kind"])
        icon_wrap.set_halign(Gtk.Align.CENTER)
        icon_wrap.set_valign(Gtk.Align.CENTER)
        icon_wrap.append(make_tile_icon(spec["glyph"], size=22))
        head.append(icon_wrap)

        title_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        title_col.set_hexpand(True)
        title_col.set_valign(Gtk.Align.CENTER)

        self.status_lbl = Gtk.Label(label="NOT INSTALLED")
        self.status_lbl.set_xalign(0)
        self.status_lbl.add_css_class("lgc-card-status")
        title_col.append(self.status_lbl)

        title_lbl = Gtk.Label(label=spec["title"])
        title_lbl.set_xalign(0)
        title_lbl.set_wrap(True)
        title_lbl.set_wrap_mode(Pango.WrapMode.WORD)
        title_lbl.add_css_class("lgc-card-title")
        title_col.append(title_lbl)

        head.append(title_col)
        outer.append(head)

        # ── Tagline ────────────────────────────────────────────────
        tag_lbl = Gtk.Label(label=spec["tagline"])
        tag_lbl.set_xalign(0)
        tag_lbl.set_wrap(True)
        tag_lbl.set_wrap_mode(Pango.WrapMode.WORD)
        tag_lbl.add_css_class("lgc-card-tagline")
        outer.append(tag_lbl)

        # ── Includes label + pills FlowBox ─────────────────────────
        if spec.get("includes"):
            includes_lbl = Gtk.Label(label=spec.get("includes_label", "INCLUDES"))
            includes_lbl.set_xalign(0)
            includes_lbl.add_css_class("lgc-card-includes-label")
            outer.append(includes_lbl)

            flow = Gtk.FlowBox()
            flow.set_max_children_per_line(6)
            flow.set_column_spacing(4)
            flow.set_row_spacing(4)
            flow.set_selection_mode(Gtk.SelectionMode.NONE)
            flow.set_homogeneous(False)
            for inc in spec["includes"]:
                flow.append(make_includes_pill(inc))
            outer.append(flow)

        # ── 'What changes' disclosure (optional, e.g. PipeWire) ────
        if spec.get("changes"):
            ch = spec["changes"]
            ch_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
            ch_box.add_css_class("lgc-card-changes")

            t_row = Gtk.Box(spacing=6)
            t_lbl = Gtk.Label(label=ch.get("title", "What changes"))
            t_lbl.set_xalign(0)
            t_lbl.add_css_class("lgc-card-changes-title")
            t_row.append(t_lbl)
            ch_box.append(t_row)

            b_lbl = Gtk.Label(label=ch.get("body", ""))
            b_lbl.set_xalign(0)
            b_lbl.set_wrap(True)
            b_lbl.set_wrap_mode(Pango.WrapMode.WORD)
            b_lbl.add_css_class("lgc-card-changes-body")
            ch_box.append(b_lbl)

            for bullet in ch.get("bullets", []):
                bl_lbl = Gtk.Label(label="• " + bullet)
                bl_lbl.set_xalign(0)
                bl_lbl.set_wrap(True)
                bl_lbl.set_wrap_mode(Pango.WrapMode.WORD)
                bl_lbl.add_css_class("lgc-card-changes-list")
                ch_box.append(bl_lbl)

            outer.append(ch_box)

        # ── Action row (Install / Reapply + Remove) ────────────────
        self.actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self.actions.set_margin_top(4)

        self.install_btn = Gtk.Button(label=spec.get("cta_label", "Install"))
        self.install_btn.add_css_class("lgc-card-cta")
        self.install_btn.set_hexpand(True)
        self.install_btn.connect("clicked", lambda b: self.emit("install-requested"))
        self.actions.append(self.install_btn)

        self.remove_btn = Gtk.Button(label="Remove")
        self.remove_btn.add_css_class("lgc-card-cta")
        self.remove_btn.add_css_class("danger")
        self.remove_btn.connect("clicked", lambda b: self.emit("remove-requested"))
        # Hidden until tile is in 'installed' state.
        self.remove_btn.set_visible(False)
        self.actions.append(self.remove_btn)

        outer.append(self.actions)

    def apply_state(self, state):
        """Re-evaluate `is_applied(state)` and flip card appearance + CTA
        labels accordingly. Called on startup and after every helper run.

        If the spec defines a `requires_check(state) -> Optional[str]`
        and it returns a string, that string is treated as the reason
        the tile is unavailable on this hardware/config. The Install
        button gets disabled, its label becomes the reason, and the
        status row reflects the unmet precondition. Used so e.g. NVIDIA
        Gaming Tweaks visibly stays available-but-locked on machines
        without the proprietary NVIDIA driver, rather than appearing
        usable while actually being a silent no-op."""
        self.remove_css_class("installed")
        self.remove_css_class("installing")

        requires = self.spec.get("requires_check")
        reason = requires(state) if requires else None
        if reason:
            # Precondition not met — grey out, explain why, never let
            # the user install or remove this one.
            self.status_lbl.set_label("REQUIREMENT NOT MET")
            self.status_lbl.remove_css_class("installed")
            self.status_lbl.remove_css_class("installing")
            self.install_btn.set_label(reason)
            self.install_btn.add_css_class("muted")
            self.install_btn.set_sensitive(False)
            self.remove_btn.set_visible(False)
            return

        # Precondition (if any) passes — fall back to install/applied logic.
        self.install_btn.set_sensitive(True)
        self.install_btn.remove_css_class("muted")

        is_applied = self.spec["is_applied"](state)
        if is_applied:
            self.add_css_class("installed")
            self.status_lbl.set_label(self.spec.get(
                "status_label_done", "INSTALLED ✓"))
            self.status_lbl.add_css_class("installed")
            self.status_lbl.remove_css_class("installing")
            self.install_btn.set_label(self.spec.get(
                "cta_label_done", "Reinstall / Update"))
            self.install_btn.add_css_class("muted")
            self.remove_btn.set_visible(self.spec.get("removable", True))
        else:
            self.status_lbl.set_label(self.spec.get(
                "status_label_pending", "NOT INSTALLED"))
            self.status_lbl.remove_css_class("installed")
            self.status_lbl.remove_css_class("installing")
            self.install_btn.set_label(self.spec.get("cta_label", "Install"))
            self.install_btn.remove_css_class("muted")
            self.remove_btn.set_visible(False)

    def set_installing(self, label="INSTALLING…"):
        """UI hint while the helper is running for this tile — disables
        buttons and shows the busy badge."""
        self.add_css_class("installing")
        self.status_lbl.set_label(label)
        self.status_lbl.add_css_class("installing")
        self.install_btn.set_sensitive(False)
        self.remove_btn.set_sensitive(False)

    def set_idle(self):
        self.install_btn.set_sensitive(True)
        self.remove_btn.set_sensitive(True)


# ════════════════════════════════════════════════════════════════════
# Progress dialog — runs the pkexec helper and streams its output
# ════════════════════════════════════════════════════════════════════
class ProgressWindow(Adw.Window):
    """Modal-ish window that shows the helper output in a TextView while
    a verb runs. Closes automatically on success after a 1.5 s pause; on
    failure the Close button stays available so the user can read what
    went wrong."""

    def __init__(self, parent, title, subtitle):
        super().__init__()
        self.set_transient_for(parent)
        self.set_modal(True)
        self.set_default_size(780, 480)
        self.set_title(title)
        # All dialog labels + the progress bar + the Close button are
        # styled via this class so they read in the brand blue regardless
        # of the system Adwaita scheme (light/dark). See CSS .lgc-prog-*.
        self.add_css_class("lgc-prog-window")

        toolbar = Adw.ToolbarView()
        header = Adw.HeaderBar()
        header.set_show_end_title_buttons(False)
        toolbar.add_top_bar(header)

        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        content.set_margin_top(16)
        content.set_margin_bottom(16)
        content.set_margin_start(20)
        content.set_margin_end(20)

        self.title_lbl = Gtk.Label(label=title)
        self.title_lbl.set_xalign(0)
        self.title_lbl.add_css_class("lgc-prog-title")
        content.append(self.title_lbl)

        self.sub_lbl = Gtk.Label(label=subtitle)
        self.sub_lbl.set_xalign(0)
        self.sub_lbl.set_wrap(True)
        self.sub_lbl.add_css_class("lgc-prog-sub")
        content.append(self.sub_lbl)

        self.progress = Gtk.ProgressBar()
        self.progress.set_pulse_step(0.08)
        content.append(self.progress)

        # Streaming log
        self.log_scroll = Gtk.ScrolledWindow()
        self.log_scroll.set_vexpand(True)
        self.log_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        self.log_scroll.add_css_class("card")
        log_scroll = self.log_scroll  # local alias for the rest of init
        self.log_view = Gtk.TextView()
        self.log_view.set_editable(False)
        self.log_view.set_cursor_visible(False)
        self.log_view.set_monospace(True)
        self.log_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
        self.log_view.add_css_class("lgc-log-view")
        self.log_buf = self.log_view.get_buffer()
        # Per-line colour tags so the eye can find structure inside what
        # is otherwise a wall of dpkg output. Plain apt lines are kept in
        # the regular bright foreground (CSS) so they read clearly without
        # competing with the status/ok/err highlights.
        self.tag_status   = self.log_buf.create_tag(
            "status",   foreground="#5BA8E8", weight=Pango.Weight.BOLD)
        self.tag_ok       = self.log_buf.create_tag(
            "ok",       foreground="#4ade80", weight=Pango.Weight.BOLD)
        self.tag_err      = self.log_buf.create_tag(
            "err",      foreground="#f87171", weight=Pango.Weight.BOLD)
        self.tag_apt      = self.log_buf.create_tag(
            "apt",      foreground="#c5cdd6")
        self.tag_apt_head = self.log_buf.create_tag(
            "apt_head", foreground="#fbbf24")
        log_scroll.set_child(self.log_view)
        content.append(log_scroll)

        # Sticky-bottom auto-scroll. GTK4's scroll_to_iter on a freshly
        # inserted line races the TextView's layout — by the time we
        # call it, the new line's coordinates aren't computed yet, so
        # scroll silently no-ops. The reliable pattern is to listen for
        # the ScrolledWindow's vadjustment "changed" signal: it fires
        # whenever the inner widget's size grows (i.e. whenever a new
        # line lands and the TextView re-lays out). We respond by
        # pinning the value to upper - page_size, the "fully scrolled"
        # position. Result: log follows new output continuously.
        self._stick_to_bottom = True
        adj = self.log_scroll.get_vadjustment()
        adj.connect("changed", self._on_log_vadj_changed)
        adj.connect("value-changed", self._on_log_vadj_value_changed)

        # Bottom action row — Copy is always available so a user can
        # grab partial output during a hang/long install for support
        # tickets; Close is gated on completion.
        actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        actions.set_halign(Gtk.Align.END)

        self.copy_btn = Gtk.Button(label="Copy to clipboard")
        self.copy_btn.set_tooltip_text(
            "Copy the entire log to the clipboard — paste into a forum "
            "thread or bug report")
        self.copy_btn.connect("clicked", self._on_copy_clicked)
        actions.append(self.copy_btn)

        self.open_log_btn = Gtk.Button(label="Open Log")
        self.open_log_btn.set_tooltip_text(
            f"Open the persistent install log ({LOG_FILE})")
        self.open_log_btn.connect("clicked", lambda b: open_install_log())
        actions.append(self.open_log_btn)

        self.close_btn = Gtk.Button(label="Close")
        self.close_btn.connect("clicked", lambda b: self.close())
        self.close_btn.set_sensitive(False)  # enabled when done
        actions.append(self.close_btn)
        content.append(actions)

        toolbar.set_content(content)
        self.set_content(toolbar)

        # Indeterminate pulse until we receive the first @@PROG@@ event;
        # then we switch to a determinate bar driven by apt's Status-Fd
        # stream and stop pulsing.
        self._pulse_id = GLib.timeout_add(120, self._pulse)
        self.done = False
        self.success = False

        # apt progress accounting. _download_total_mb is parsed from
        # apt's "Need to get X MB" line at install start; combined with
        # the dlstatus percent and a download-start monotonic time, we
        # synthesise a rolling MB/s estimate that's accurate enough.
        self._download_total_mb = None
        self._download_started_at = None
        self._got_determinate = False
        # Regex once, not per-line.
        self._need_to_get_re = re.compile(
            r"Need to get\s+([\d.,]+)\s*(kB|MB|GB)\b", re.IGNORECASE)

    def _pulse(self):
        if not self.done:
            self.progress.pulse()
            return True
        return False

    def _on_copy_clicked(self, _btn):
        """Copy the entire visible log to the system clipboard. Includes
        the dialog's current title + subtitle as a header so a support
        recipient gets context, not just an apt log fragment."""
        start = self.log_buf.get_start_iter()
        end   = self.log_buf.get_end_iter()
        log_text = self.log_buf.get_text(start, end, True)
        header = (f"[Lite Game Center]\n"
                  f"  {self.title_lbl.get_label()}\n"
                  f"  {self.sub_lbl.get_label()}\n"
                  f"{'-' * 60}\n")
        payload = header + log_text
        display = Gdk.Display.get_default()
        if display:
            display.get_clipboard().set(payload)
        # Quick visual confirmation — flash the label for 1.2 s.
        original = self.copy_btn.get_label()
        self.copy_btn.set_label("Copied ✓")
        GLib.timeout_add(1200,
            lambda: (self.copy_btn.set_label(original), False)[1])

    def _on_log_vadj_changed(self, adj):
        """Fires when the TextView grows. Pin to bottom if we're in
        sticky mode (default true; flipped off only when the user
        deliberately scrolls upward)."""
        if self._stick_to_bottom:
            adj.set_value(adj.get_upper() - adj.get_page_size())

    def _on_log_vadj_value_changed(self, adj):
        """Detect when the user scrolls up to read older output, so we
        stop yanking them back to the bottom. We compare current value
        against the maximum-scrolled position with a 32-pixel slack: if
        they're within slack of the bottom, treat them as 'still at
        bottom' and keep sticky on. If they're further up, sticky off."""
        max_value = adj.get_upper() - adj.get_page_size()
        if max_value <= 0:
            return
        at_bottom = (adj.get_value() >= max_value - 32)
        self._stick_to_bottom = at_bottom

    def _stop_pulse(self):
        """Switch the progress bar from indeterminate pulse to driven-by-
        apt-Status-Fd determinate mode. Idempotent."""
        if self._pulse_id:
            GLib.source_remove(self._pulse_id)
            self._pulse_id = None
        self._got_determinate = True

    def _handle_apt_progress(self, payload):
        """Parse one APT::Status-Fd line. Format (colon-separated):
            dlstatus:<id>:<percent>:<message>   (download phase)
            pmstatus:<pkg>:<percent>:<message>  (install/configure phase)
            status:<pkg>:<status>:<message>     (general state changes)
        We surface dlstatus for the bar (overall download) and pmstatus
        for the install phase; other 'status:' lines are ignored to
        avoid noise."""
        parts = payload.split(":", 3)
        if len(parts) < 3:
            return
        kind = parts[0]
        ident = parts[1]
        try:
            percent = float(parts[2])
        except ValueError:
            return
        message = parts[3] if len(parts) > 3 else ""
        fraction = max(0.0, min(1.0, percent / 100.0))

        if kind == "dlstatus":
            self._stop_pulse()
            self.progress.set_fraction(fraction)

            # Mark download start the first time we see a non-zero %.
            now = time.monotonic()
            if self._download_started_at is None and percent > 0.5:
                self._download_started_at = now

            speed_str = ""
            if (self._download_total_mb
                    and self._download_started_at is not None):
                elapsed = now - self._download_started_at
                if elapsed >= 1.0:
                    mb_done = self._download_total_mb * fraction
                    mbps = mb_done / elapsed
                    speed_str = f"  ·  {mbps:.1f} MB/s"

            size_str = (f"  ·  {self._download_total_mb:.0f} MB total"
                        if self._download_total_mb else "")
            msg_str = f"  ·  {message}" if message else ""
            self.title_lbl.set_label(
                f"Downloading  ·  {percent:5.1f}%{speed_str}")
            self.sub_lbl.set_label(f"{size_str.lstrip(' ·')}{msg_str}".strip(" ·"))
            return

        if kind == "pmstatus":
            self._stop_pulse()
            # Download is done by definition once any pmstatus event
            # arrives. Keep the bar at 100% rather than jumping back to
            # 0% per package — pmstatus's percent is per-package and
            # doesn't map cleanly onto overall progress. The title
            # narrates which package is being configured so the user
            # still sees forward motion.
            self.progress.set_fraction(1.0)
            head = f"Installing  ·  {ident}" if ident else "Installing"
            self.title_lbl.set_label(head)
            if message:
                self.sub_lbl.set_label(message)
            return
        # 'status:' lines and unknown kinds — ignore (not useful UX).

    def _tag_for_line(self, line):
        """Pick a TextTag based on the start of the line — helper status
        ('==' headings or '→' steps), success ticks, errors, or apt's own
        dpkg phase markers. None returns the default style from CSS."""
        s = line.lstrip()
        if s.startswith(("ERROR", "FAILED", "E:")):
            return self.tag_err
        if s.startswith(("✓", "✓✓")):
            return self.tag_ok
        if s.startswith(("→", "==")):
            return self.tag_status
        if s.startswith(("Get:", "Hit:", "Ign:", "Fetched")):
            return self.tag_apt_head
        if s.startswith(("Preparing to unpack", "Unpacking",
                         "Selecting previously", "Setting up",
                         "Processing triggers")):
            return self.tag_apt
        return None

    def append_line(self, line):
        if not line:
            return

        # apt's structured progress events come through the helper with a
        # @@PROG@@ prefix. They drive the progress bar + title, not the
        # visible log.
        if line.startswith("@@PROG@@"):
            self._handle_apt_progress(line[len("@@PROG@@"):])
            return

        # Sniff the "Need to get X MB" line from regular apt stdout so we
        # know total download size — used to compute MB/s from dlstatus %.
        if self._download_total_mb is None:
            m = self._need_to_get_re.search(line)
            if m:
                size = float(m.group(1).replace(",", "."))
                unit = m.group(2).lower()
                if   unit == "kb": size /= 1024.0
                elif unit == "gb": size *= 1024.0
                self._download_total_mb = size

        end = self.log_buf.get_end_iter()
        tag = self._tag_for_line(line)
        if tag:
            self.log_buf.insert_with_tags(end, line + "\n", tag)
        else:
            self.log_buf.insert(end, line + "\n")
        # No explicit scroll here. The TextView's size grows on the
        # next layout pass, fires `vadjustment::changed`, and our
        # _on_log_vadj_changed pins the view to the bottom — reliable
        # across GTK 4.10 → 4.22+ where scroll_to_iter races layout.

    def mark_done(self, rc):
        self.done = True
        self.success = (rc == 0)
        if self._pulse_id:
            GLib.source_remove(self._pulse_id)
            self._pulse_id = None
        self.progress.set_fraction(1.0)
        self.close_btn.set_sensitive(True)
        if rc == 0:
            self.sub_lbl.set_label("Done.")
            # Auto-close on success after a beat
            GLib.timeout_add(1500, self.close)
        else:
            self.sub_lbl.set_label(f"Failed (exit code {rc}). Check the log above.")


# ════════════════════════════════════════════════════════════════════
# Main application window
# ════════════════════════════════════════════════════════════════════
class LGCWindow(Adw.ApplicationWindow):

    def __init__(self, app):
        super().__init__(application=app)
        self.set_title(APP_NAME)
        self.set_default_size(1240, 880)
        self.set_icon_name(ICON_NAME)

        self.state = {}
        self.tiles = []
        self._tile_by_verb = {}

        self._load_css()
        self._build_ui()
        self.refresh_state()

    def _load_css(self):
        provider = Gtk.CssProvider()
        provider.load_from_data(CSS.encode("utf-8"))
        Gtk.StyleContext.add_provider_for_display(
            Gdk.Display.get_default(), provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

    def _build_ui(self):
        toolbar = Adw.ToolbarView()

        header = Adw.HeaderBar()
        # Brand mark on the left
        brand = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        brand_icon = Gtk.Image.new_from_icon_name(ICON_NAME)
        brand_icon.set_pixel_size(20)
        brand.append(brand_icon)
        brand_lbl = Gtk.Label(label=APP_NAME)
        brand_lbl.add_css_class("dim-label")
        brand.append(brand_lbl)
        header.set_title_widget(brand)

        open_log_btn = Gtk.Button.new_from_icon_name("text-x-generic-symbolic")
        open_log_btn.set_tooltip_text(
            f"Open the install log ({LOG_FILE})")
        open_log_btn.connect("clicked", lambda b: open_install_log())
        header.pack_end(open_log_btn)

        refresh_btn = Gtk.Button.new_from_icon_name("view-refresh-symbolic")
        refresh_btn.set_tooltip_text("Refresh status")
        refresh_btn.connect("clicked", lambda b: self.refresh_state())
        header.pack_end(refresh_btn)

        toolbar.add_top_bar(header)

        # Scrollable content
        scroller = Gtk.ScrolledWindow()
        scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scroller.set_hexpand(True)
        scroller.set_vexpand(True)

        # Content column, centred, with hard max-width like the mockup
        outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        outer.set_hexpand(True)
        outer.set_halign(Gtk.Align.FILL)

        clamp = Adw.Clamp()
        clamp.set_maximum_size(1180)
        clamp.set_tightening_threshold(900)

        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
        content.set_margin_top(20)
        content.set_margin_bottom(40)
        content.set_margin_start(20)
        content.set_margin_end(20)

        # ── Hero ────────────────────────────────────────────────────
        content.append(self._build_hero())

        # ── Status pills strip ──────────────────────────────────────
        self.status_strip = Gtk.FlowBox()
        self.status_strip.set_max_children_per_line(8)
        self.status_strip.set_column_spacing(8)
        self.status_strip.set_row_spacing(8)
        self.status_strip.set_selection_mode(Gtk.SelectionMode.NONE)
        self.status_strip.set_homogeneous(False)
        self.status_strip.set_halign(Gtk.Align.CENTER)
        strip_frame = Gtk.Frame()
        strip_frame.add_css_class("lgc-status-strip")
        strip_frame.set_child(self.status_strip)
        content.append(strip_frame)

        # ── Kernel chip ─────────────────────────────────────────────
        self.kernel_chip = self._build_kernel_chip()
        content.append(self.kernel_chip)

        # ── Featured Full Setup ────────────────────────────────────
        self.featured = self._build_featured()
        content.append(self.featured)

        # ── Section 1: Pick your stack — 1×3 row ───────────────────
        content.append(make_section_heading(
            "Pick your stack",
            "Mix & match — every tile is safe to install on its own"))

        stack_grid = Gtk.Grid()
        stack_grid.set_row_spacing(16)
        stack_grid.set_column_spacing(16)
        stack_grid.set_column_homogeneous(True)

        for i, spec in enumerate(self._stack_specs()):
            tile = TileCard(spec)
            tile.connect("install-requested", self._on_tile_primary)
            tile.connect("remove-requested",
                         lambda _t, v=spec["verb"]: self._on_remove(v))
            self.tiles.append(tile)
            self._tile_by_verb[spec["verb"]] = tile
            # Toggle tiles (e.g. PipeWire ↔ PulseAudio) need the alt verb
            # registered too, so progress badges work in both directions.
            if "verb_when_applied" in spec:
                self._tile_by_verb[spec["verb_when_applied"]] = tile
            stack_grid.attach(tile, i, 0, 1, 1)
        content.append(stack_grid)

        # ── Section 2: Tweaks & Optimisations — 3x1 row ────────────
        content.append(make_section_heading(
            "Tweaks & Optimisations",
            "Layer on top of any stack — or use without one"))

        tweaks_grid = Gtk.Grid()
        tweaks_grid.set_row_spacing(16)
        tweaks_grid.set_column_spacing(16)
        tweaks_grid.set_column_homogeneous(True)

        for i, spec in enumerate(self._tweaks_specs()):
            tile = TileCard(spec)
            tile.connect("install-requested", self._on_tile_primary)
            tile.connect("remove-requested",
                         lambda _t, v=spec["verb"]: self._on_remove(v))
            self.tiles.append(tile)
            self._tile_by_verb[spec["verb"]] = tile
            # Toggle tiles (e.g. PipeWire ↔ PulseAudio) need the alt verb
            # registered too, so progress badges work in both directions.
            if "verb_when_applied" in spec:
                self._tile_by_verb[spec["verb_when_applied"]] = tile
            tweaks_grid.attach(tile, i, 0, 1, 1)
        content.append(tweaks_grid)

        # Footer
        footer = Gtk.Label(label=(
            "Linux Lite 8.0 · Lite Game Center · "
            "every install is individually reversible from inside the app"))
        footer.add_css_class("dim-label")
        footer.set_margin_top(20)
        content.append(footer)

        clamp.set_child(content)
        outer.append(clamp)
        scroller.set_child(outer)
        toolbar.set_content(scroller)
        self.set_content(toolbar)

    def _build_hero(self):
        hero = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        hero.set_halign(Gtk.Align.CENTER)
        hero.set_margin_top(12)
        hero.set_margin_bottom(12)

        # Logo (lite_game_center_app_logo.svg, wide aspect)
        try:
            pix = GdkPixbuf.Pixbuf.new_from_file_at_scale(
                LOGO_PATH, 360, -1, True)
            success, buf = pix.save_to_bufferv("png", [], [])
            bytes_obj = GLib.Bytes.new(buf)
            texture = Gdk.Texture.new_from_bytes(bytes_obj)
            logo = Gtk.Image.new_from_paintable(texture)
            logo.set_pixel_size(220)
            logo.set_halign(Gtk.Align.CENTER)
            hero.append(logo)
        except Exception as e:
            print(f"LGC: could not load hero logo: {e}", file=sys.stderr)

        title = Gtk.Label(label=APP_NAME)
        title.add_css_class("lgc-hero-title")
        title.set_halign(Gtk.Align.CENTER)
        hero.append(title)

        tagline = Gtk.Label(label=(
            "Gaming on Linux, without the hours of setup. "
            "One click installs Steam, Proton, Vulkan, "
            "the Valve performance stack, and every patch the community "
            "uses — together, and known-good."))
        tagline.add_css_class("lgc-hero-tagline")
        tagline.set_wrap(True)
        tagline.set_wrap_mode(Pango.WrapMode.WORD)
        tagline.set_max_width_chars(72)
        tagline.set_justify(Gtk.Justification.CENTER)
        tagline.set_halign(Gtk.Align.CENTER)
        hero.append(tagline)
        return hero

    def _build_kernel_chip(self):
        chip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        chip.add_css_class("lgc-kernel-chip")

        icon = make_tile_icon("star", size=22)
        chip.append(icon)

        text_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        text_col.set_hexpand(True)
        self.kernel_title_lbl = Gtk.Label()
        self.kernel_title_lbl.set_xalign(0)
        self.kernel_title_lbl.add_css_class("lgc-kernel-chip-title")
        text_col.append(self.kernel_title_lbl)

        self.kernel_body_lbl = Gtk.Label()
        self.kernel_body_lbl.set_xalign(0)
        self.kernel_body_lbl.set_wrap(True)
        self.kernel_body_lbl.set_wrap_mode(Pango.WrapMode.WORD)
        self.kernel_body_lbl.add_css_class("lgc-kernel-chip-body")
        text_col.append(self.kernel_body_lbl)
        chip.append(text_col)

        self.kernel_btn = Gtk.Button(label="Open Lite Kernel Manager →")
        self.kernel_btn.add_css_class("lgc-kernel-chip-cta")
        self.kernel_btn.connect("clicked", self._on_open_kernel_manager)
        chip.append(self.kernel_btn)
        return chip

    def _build_featured(self):
        wrap = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20)
        wrap.add_css_class("lgc-featured")

        icon_bg = Gtk.Box()
        icon_bg.add_css_class("lgc-featured-icon-bg")
        icon_bg.set_halign(Gtk.Align.CENTER)
        icon_bg.set_valign(Gtk.Align.CENTER)
        icon_bg.append(make_tile_icon("star", size=32))
        wrap.append(icon_bg)

        text_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        text_col.set_hexpand(True)
        eyebrow = Gtk.Label(label="RECOMMENDED")
        eyebrow.set_xalign(0)
        eyebrow.add_css_class("lgc-featured-eyebrow")
        text_col.append(eyebrow)

        title = Gtk.Label(label="The Full Setup")
        title.set_xalign(0)
        title.add_css_class("lgc-featured-title")
        text_col.append(title)

        sub = Gtk.Label(label=(
            "Everything below in one click — Steam, the latest GE-Proton, "
            "Lutris with Wine-GE for non-Steam Windows games, the full "
            "Vulkan stack, performance daemons, and the universal system "
            "tweaks. Audio stays on PulseAudio (with a low-latency tune) "
            "— the PipeWire swap is an explicit, separate decision."))
        sub.set_xalign(0)
        sub.set_wrap(True)
        sub.set_wrap_mode(Pango.WrapMode.WORD)
        sub.add_css_class("lgc-featured-sub")
        text_col.append(sub)

        # Includes pill row
        flow = Gtk.FlowBox()
        flow.set_max_children_per_line(8)
        flow.set_column_spacing(4)
        flow.set_row_spacing(4)
        flow.set_selection_mode(Gtk.SelectionMode.NONE)
        flow.set_homogeneous(False)
        for inc in [
            "Steam", "Vulkan", "GE-Proton (latest)",
            "Lutris", "Wine-GE", "GameMode", "MangoHud", "Gamescope",
            "sysctls", "nofile=524288", "PulseAudio low-latency",
            "xpadneo", "xone", "xone-firmware",
            "dualsensectl", "steam-devices", "piper",
        ]:
            flow.append(make_includes_pill(inc))
        text_col.append(flow)

        wrap.append(text_col)

        self.featured_btn = Gtk.Button(label="Install Everything")
        self.featured_btn.add_css_class("lgc-featured-btn")
        self.featured_btn.set_valign(Gtk.Align.CENTER)
        self.featured_btn.connect("clicked",
                                  lambda b: self._on_install(VERB_FULL_SETUP))
        wrap.append(self.featured_btn)
        return wrap

    # ── Tile specs ──────────────────────────────────────────────────
    def _stack_specs(self):
        return [
            {
                "kind": "steam", "glyph": "steam",
                "title": "Steam Essentials",
                "tagline": ("Steam itself plus the Vulkan stack and the "
                            "Valve performance tools every modern game expects."),
                "includes": ["steam-installer", "mesa-vulkan-drivers",
                             "libvulkan1", "vulkan-tools",
                             "gamemode", "mangohud", "gamescope"],
                "verb": VERB_STEAM_ESSENTIALS,
                "is_applied": lambda s: s.get("steam_pkg") and s.get("vulkan"),
            },
            {
                "kind": "proton", "glyph": "proton",
                "title": "Steam + Proton-GE",
                "tagline": ("Adds the latest GE-Proton release on top of "
                            "Essentials — the community fork most non-native "
                            "games run best on."),
                "includes": ["Everything in Essentials", "GE-Proton (latest)",
                             "dxvk-nvapi", "vkd3d-proton"],
                "verb": VERB_STEAM_PROTON_GE,
                "is_applied": lambda s: (
                    s.get("steam_pkg") and s.get("vulkan")
                    and s.get("proton_ge")),
            },
            {
                "kind": "lutris", "glyph": "lutris",
                "title": "Lutris + Wine-GE",
                "tagline": ("For non-Steam Windows games — Epic, GOG, "
                            "Battle.net, classics. Lutris install scripts + "
                            "Wine-GE runners."),
                "includes": ["lutris", "wine-staging", "Wine-GE (latest)",
                             "dxvk", "winetricks"],
                "verb": VERB_LUTRIS_WINE_GE,
                "is_applied": lambda s: s.get("lutris"),
            },
        ]

    def _tweaks_specs(self):
        return [
            {
                "kind": "nvidia", "glyph": "nvidia",
                "title": "NVIDIA Gaming Tweaks",
                "tagline": ("G-Sync/VRR, bigger shader cache, DLSS-friendly "
                            "Proton flags, and the PixelCluster VRAM-overflow "
                            "fix."),
                "includes": ["__GL_GSYNC_ALLOWED=1",
                             "__GL_VRR_ALLOWED=1",
                             "shader cache 12 GB",
                             "DXVK_NVAPI",
                             "VRAM-mgmt fix"],
                "verb": VERB_NVIDIA_TWEAKS,
                "status_label_done": "APPLIED ✓",
                "cta_label": "Apply",
                "cta_label_done": "Reapply / Update",
                "is_applied": lambda s: s.get("nvidia_tweaks_applied"),
                # The __GL_* env vars are read ONLY by the proprietary
                # NVIDIA userspace. On Nouveau/NVK or non-NVIDIA hardware
                # they'd be silent no-ops, which would mislead the user
                # into thinking the tile did something. Gate strictly on
                # nvidia-smi succeeding (proprietary loaded + working).
                "requires_check": lambda s: (
                    None if s.get("nvidia_driver_version")
                    else "Requires NVIDIA proprietary driver"),
            },
            {
                "kind": "system", "glyph": "system",
                "title": "System Gaming Tweaks",
                "tagline": ("Vendor-neutral system tuning + controller / mouse "
                            "drivers. Sysctls, limits and config files that "
                            "fix games stock Linux can't launch, plus Xbox "
                            "(BT + USB dongle), PS5 DualSense polish, "
                            "non-Steam udev rules, and the piper GUI for "
                            "gaming mice."),
                "includes": ["vm.max_map_count", "nofile=524288",
                             "vm.swappiness=10", "MangoHud preset",
                             "gamemode.ini", "PulseAudio low-latency",
                             "xpadneo-dkms", "xone-dkms",
                             "xone-dongle-firmware",
                             "dualsensectl", "steam-devices", "piper"],
                "verb": VERB_SYSTEM_TWEAKS,
                "status_label_done": "APPLIED ✓",
                "cta_label": "Apply",
                "cta_label_done": "Reapply / Update",
                "is_applied": lambda s: s.get("system_tweaks_applied"),
            },
            {
                "kind": "audio", "glyph": "audio",
                "title": "Switch to PipeWire (Audio)",
                "tagline": ("Replace PulseAudio with PipeWire — Valve's "
                            "recommended audio server. Sub-6 ms gaming "
                            "latency, sample-accurate routing, and better "
                            "Bluetooth headset support."),
                "includes_label": "INSTALLS & CONFIGURES",
                "includes": ["pipewire", "wireplumber", "pipewire-pulse",
                             "pipewire-alsa", "low-latency drop-in"],
                "changes": {
                    "title": "What changes",
                    "body": ("Your current audio server (pulseaudio) is "
                             "replaced by pipewire + wireplumber. Existing "
                             "PulseAudio apps keep working via pipewire-pulse."),
                    "bullets": [
                        "Audio cuts out for ~2 seconds during the swap. "
                        "No reboot needed.",
                        "Reversible from this tile — the button becomes "
                        "“Switch back to PulseAudio”.",
                        "Heads-up if you use JACK for music production or "
                        "have unusual Bluetooth audio quirks.",
                    ],
                },
                "verb": VERB_SWITCH_PIPEWIRE,
                # Toggle tile: when already applied, the primary button
                # fires the reverse verb instead of re-running install.
                "verb_when_applied": VERB_SWITCH_PULSE,
                "status_label_done": "PIPEWIRE ACTIVE ✓",
                "status_label_pending": "PULSEAUDIO ACTIVE",
                "cta_label": "Switch to PipeWire",
                "cta_label_done": "Switch back to PulseAudio",
                "is_applied": lambda s: s.get("pipewire_active"),
                # Hide standard Remove button; the Switch button toggles.
                "removable": False,
            },
        ]

    # ── State sync ──────────────────────────────────────────────────
    def refresh_state(self):
        self.state = detect_state()
        self._render_status_strip()
        self._render_kernel_chip()
        for tile in self.tiles:
            tile.apply_state(self.state)

    def _render_status_strip(self):
        # Clear existing children
        child = self.status_strip.get_first_child()
        while child:
            nxt = child.get_next_sibling()
            self.status_strip.remove(child)
            child = nxt

        def add(pill):
            self.status_strip.append(pill)

        s = self.state
        add(make_pill("● Steam",
                      ["ok"] if s.get("steam_pkg") else ["miss"],
                      dim_label="ready" if s.get("steam_pkg") else "not installed"))
        add(make_pill("● Vulkan",
                      ["ok"] if s.get("vulkan") else ["miss"],
                      dim_label=(f"mesa {s['mesa_version']}"
                                 if s.get("mesa_version") else None)))
        add(make_pill("● Proton-GE",
                      ["ok"] if s.get("proton_ge") else ["miss"],
                      dim_label=s.get("proton_ge_latest") or None))
        add(make_pill("● Lutris",
                      ["ok"] if s.get("lutris") else ["miss"]))
        if s.get("nvidia_present"):
            ver = s.get("nvidia_driver_version") or "loaded"
            add(make_pill("● NVIDIA driver", ["ok"], dim_label=ver))
        add(make_pill("● Audio",
                      ["ok"] if s.get("pipewire_active") else ["miss"],
                      dim_label=s.get("audio_server", "")))
        kernel_classes = ["ok"] if s.get("kernel_is_gaming") else ["warn"]
        kernel_label = ("linuxlite-gaming"
                        if s.get("kernel_is_gaming") else "linuxlite (desktop)")
        add(make_pill("● Kernel", kernel_classes, dim_label=kernel_label))

    def _render_kernel_chip(self):
        if self.state.get("kernel_is_gaming"):
            self.kernel_chip.set_visible(False)
            return
        self.kernel_chip.set_visible(True)
        self.kernel_title_lbl.set_label("You're on the desktop kernel.")
        self.kernel_body_lbl.set_label(
            "Switching to linuxlite-gaming gives you a 1000 Hz timer, full "
            "kernel preemption, transparent huge pages, and the BORE "
            "scheduler — typically 5–15% better frame pacing and lower "
            "input lag. No reinstall, switches at the next boot.")

    # ── Actions ─────────────────────────────────────────────────────
    def _on_open_kernel_manager(self, _btn):
        for cmd in ("lite-kernel-manager", "litekernelmanager"):
            if shutil.which(cmd):
                try:
                    subprocess.Popen([cmd])
                    return
                except OSError:
                    pass
        self._toast("Lite Kernel Manager isn't installed on this system.")

    def _on_tile_primary(self, tile):
        # Resolve the verb at click time so toggle tiles (PipeWire ↔
        # PulseAudio) can fire the reverse verb when already applied,
        # instead of re-running the install verb. Non-toggle tiles
        # behave exactly as before.
        spec = tile.spec
        if (spec.get("is_applied", lambda _s: False)(self.state)
                and "verb_when_applied" in spec):
            self._on_install(spec["verb_when_applied"])
        else:
            self._on_install(spec["verb"])

    def _on_install(self, verb):
        tile = self._tile_by_verb.get(verb)
        title, subtitle = self._descriptions_for(verb)
        prog = ProgressWindow(self, title, subtitle)
        prog.present()
        if tile:
            tile.set_installing("INSTALLING…")

        def on_line(line):
            prog.append_line(line)
            return False

        def on_done(rc):
            prog.mark_done(rc)
            if tile:
                tile.set_idle()
            # Re-detect after the helper finished
            self.refresh_state()
            return False

        spawn_helper(verb, on_line, on_done)

    def _on_remove(self, verb):
        # Removal verbs follow the convention "remove-<install-verb-suffix>"
        # so the helper has a single naming scheme.
        suffix = verb
        for prefix in ("install-", "apply-", "switch-to-"):
            if suffix.startswith(prefix):
                suffix = suffix[len(prefix):]
                break
        remove_verb = VERB_REMOVE_PREFIX + suffix

        # Confirm
        dlg = Adw.AlertDialog.new(
            "Remove this setup?",
            f"This will undo the changes made by '{verb}'. "
            "Your installed apps and configs will be removed.")
        dlg.add_response("cancel", "Cancel")
        dlg.add_response("remove", "Remove")
        dlg.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE)
        dlg.set_default_response("cancel")
        dlg.set_close_response("cancel")

        def _on_response(_d, response):
            if response != "remove":
                return
            tile = self._tile_by_verb.get(verb)
            prog = ProgressWindow(self, f"Removing {verb}",
                                  "Reverting changes…")
            prog.present()
            if tile:
                tile.set_installing("REMOVING…")

            def on_line(line):
                prog.append_line(line)
                return False

            def on_done(rc):
                prog.mark_done(rc)
                if tile:
                    tile.set_idle()
                self.refresh_state()
                return False

            spawn_helper(remove_verb, on_line, on_done)

        dlg.connect("response", _on_response)
        dlg.present(self)

    def _descriptions_for(self, verb):
        return {
            VERB_STEAM_ESSENTIALS:
                ("Installing Steam Essentials",
                 "apt-installing Steam, Vulkan, GameMode, MangoHud and Gamescope."),
            VERB_STEAM_PROTON_GE:
                ("Installing Steam + Proton-GE",
                 "Steam Essentials, then downloading the latest GE-Proton "
                 "release into ~/.steam/root/compatibilitytools.d/."),
            VERB_LUTRIS_WINE_GE:
                ("Installing Lutris + Wine-GE",
                 "Lutris from apt, then downloading the latest Wine-GE "
                 "tarball into the Lutris runners directory."),
            VERB_NVIDIA_TWEAKS:
                ("Applying NVIDIA Gaming Tweaks",
                 "Writing /etc/environment.d/ and DXVK config — no apt installs."),
            VERB_SYSTEM_TWEAKS:
                ("Applying System Gaming Tweaks",
                 "Writing sysctl, limits, MangoHud, GameMode and PulseAudio "
                 "low-latency drop-ins."),
            VERB_SWITCH_PIPEWIRE:
                ("Switching to PipeWire",
                 "Installing PipeWire + WirePlumber, masking PulseAudio user "
                 "services, dropping the low-latency config."),
            VERB_SWITCH_PULSE:
                ("Switching back to PulseAudio",
                 "Unmasking PulseAudio user services and stopping PipeWire."),
            VERB_FULL_SETUP:
                ("Installing the Full Setup",
                 "Everything except the PipeWire audio swap — that stays "
                 "an explicit, separate decision."),
        }.get(verb, ("Working…", "Running helper."))

    def _toast(self, message):
        # Adw.ToastOverlay would be the modern way, but for v0010 just print.
        print(f"LGC toast: {message}", file=sys.stderr)


# ════════════════════════════════════════════════════════════════════
# Application entry point
# ════════════════════════════════════════════════════════════════════
class LGCApplication(Adw.Application):
    def __init__(self):
        super().__init__(application_id=APP_ID)
        self.connect("activate", self._on_activate)

    def _on_activate(self, app):
        # Lite Game Center is designed dark-first; force the libadwaita
        # colour scheme so widgets we don't explicitly style (the
        # progress dialog's headerbar title, system Adw.AlertDialog
        # buttons, etc.) don't fall back to dark-text-on-dark-bg when
        # the user's session is in light mode.
        Adw.StyleManager.get_default().set_color_scheme(
            Adw.ColorScheme.FORCE_DARK)
        win = LGCWindow(app)
        win.present()


def main():
    app = LGCApplication()
    return app.run(sys.argv)


if __name__ == "__main__":
    sys.exit(main())
