#!/usr/bin/env python3
"""Lite Software Sources — GTK4/Adw editor for APT DEB822 source files.

Manages Ubuntu repositories (components / pockets), the Linux Lite mirror
(country flag picker), third-party PPAs and .sources entries, and APT
signing keys in /etc/apt/keyrings/ and /usr/share/keyrings/. DEB822 only.

Privileged writes go through:

    /usr/lib/lite-software-sources/lite-software-sources-helper

via pkexec under the com.linuxlite.lite-software-sources.run polkit action.
"""

import os
import socket
import subprocess
import threading
import urllib.error
import urllib.request
from pathlib import Path

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

APP_ID = "com.linuxlite.lite-software-sources"
ICON_NAME = "lite-softwaresources"
HELPER = "/usr/lib/lite-software-sources/lite-software-sources-helper"
PKEXEC = "/usr/bin/pkexec"
SOURCES_DIR = Path("/etc/apt/sources.list.d")
# Keys: read from both, write only to the admin location.
KEYRINGS_READ_DIRS = [Path("/etc/apt/keyrings"), Path("/usr/share/keyrings")]
KEYRINGS_WRITE_DIR = Path("/etc/apt/keyrings")

# Distro keyrings shipped by the ubuntu-keyring base package that are required
# on disk (dist-upgrade chain-of-trust, cloud-init / MAAS) but are noise in the
# Keys tab for a desktop user. Filter applies only to /usr/share/keyrings/.
HIDDEN_DISTRO_KEYRINGS = {
    "ubuntu-archive-removed-keys",
    "ubuntu-cloudimage-keyring",
    "ubuntu-cloudimage-removed-keys",
}
FLAGS_DIR = Path("/usr/share/lite-software-sources/flags")
UBUNTU_SOURCES = SOURCES_DIR / "ubuntu.sources"
LINUXLITE_SOURCES = SOURCES_DIR / "linuxlite.sources"

COMPONENTS = ["main", "restricted", "universe", "multiverse"]
SUITE_SUFFIXES = ["", "-updates", "-security", "-backports"]

# Linux Lite mirrors — ported from litesources 8.0-0020 (May 2026).
# Tuple: (code, flag_basename, country_display, region, url)
# Ubuntu mirrors. (label, url). First entry is the geo-routed magic URI.
# Country mirrors use the official <cc>.archive.ubuntu.com pattern — Canonical
# redirects each request to a country-local mirror, so this list stays stable
# without us maintaining individual hostnames.
UBUNTU_MIRRORS = [
    ("Auto (geo-routed)",                  "mirror://mirrors.ubuntu.com/mirrors.txt"),
    ("Default archive",                    "http://archive.ubuntu.com/ubuntu/"),
    ("Australia",                          "http://au.archive.ubuntu.com/ubuntu/"),
    ("Brazil",                             "http://br.archive.ubuntu.com/ubuntu/"),
    ("Canada",                             "http://ca.archive.ubuntu.com/ubuntu/"),
    ("China",                              "https://mirrors.cloud.tencent.com/ubuntu/"),
    ("France",                             "http://fr.archive.ubuntu.com/ubuntu/"),
    ("Germany",                            "http://de.archive.ubuntu.com/ubuntu/"),
    ("India",                              "http://in.archive.ubuntu.com/ubuntu/"),
    ("Italy",                              "http://it.archive.ubuntu.com/ubuntu/"),
    ("Japan",                              "http://jp.archive.ubuntu.com/ubuntu/"),
    ("Netherlands",                        "http://nl.archive.ubuntu.com/ubuntu/"),
    ("New Zealand",                        "http://nz.archive.ubuntu.com/ubuntu/"),
    ("Russia",                             "http://ru.archive.ubuntu.com/ubuntu/"),
    ("South Korea",                        "http://kr.archive.ubuntu.com/ubuntu/"),
    ("Spain",                              "http://es.archive.ubuntu.com/ubuntu/"),
    ("Sweden",                             "http://se.archive.ubuntu.com/ubuntu/"),
    ("Switzerland",                        "http://ch.archive.ubuntu.com/ubuntu/"),
    ("United Kingdom",                     "http://gb.archive.ubuntu.com/ubuntu/"),
    ("USA",                                "http://us.archive.ubuntu.com/ubuntu/"),
]

# Sentinel value used in the Ubuntu mirror dropdown for the "Custom URL…" row.
CUSTOM_SENTINEL = "__custom__"

# Country-code → flag-PNG basename for <cc>.archive.ubuntu.com mirrors.
# Values must match files in /usr/share/lite-software-sources/flags/ — names
# audited 2026-05-14 against the existing flag set (mix of 2-letter codes and
# full country names from the ported litesources asset pack).
UBUNTU_FLAG_MAP = {
    "au": "Australia",
    "br": "BR",
    "ca": "CA",
    "cn": "CN",
    "fr": "FR",
    "de": "DE",
    "in": "India",
    "it": "IT",
    "jp": "Japan",
    "nl": "NL",
    "nz": "NZ",
    "ru": "Russian Federation",
    "kr": "South Korea",
    "es": "SP",
    "se": "SE",
    "ch": "CH",
    "gb": "United Kingdom",
    "us": "US",
}

import re as _re
_UBUNTU_CC_RE = _re.compile(r"https?://([a-z]{2})\.archive\.ubuntu\.com/")
_UBUNTU_HOST_RE = _re.compile(r"^https?://([^/]+)/")

# Override map: hostname → flag basename for mirrors that don't follow the
# <cc>.archive.ubuntu.com convention. Extended whenever a curated entry
# points at a specific country mirror (e.g., Tencent Cloud for China).
UBUNTU_HOST_FLAG_OVERRIDES = {
    "mirrors.cloud.tencent.com": "CN",
}


def flag_for_ubuntu_url(url):
    """Return the flag basename (without .png) for a Ubuntu mirror URL, or None."""
    if not url or url == CUSTOM_SENTINEL:
        return None
    # Hostname override (specific mirror hostnames mapped to country flags).
    hm = _UBUNTU_HOST_RE.match(url)
    if hm and hm.group(1) in UBUNTU_HOST_FLAG_OVERRIDES:
        return UBUNTU_HOST_FLAG_OVERRIDES[hm.group(1)]
    # Standard <cc>.archive.ubuntu.com pattern.
    m = _UBUNTU_CC_RE.match(url)
    if m:
        return UBUNTU_FLAG_MAP.get(m.group(1))
    return None


def probe_ubuntu_mirror(url, codename, timeout=5):
    """Probe a candidate Ubuntu mirror URL.

    Returns (status, message) where status is one of:
      "ok"           — confirmed Ubuntu mirror (Origin: Ubuntu)
      "wrong_origin" — reachable but not a Ubuntu archive
      "unreachable"  — couldn't fetch (DNS, refused, timeout, TLS, 4xx/5xx)
      "bad_url"      — URL didn't pass basic syntax checks
    """
    if not url:
        return "bad_url", "Enter a URL first."
    if not (url.startswith("http://") or url.startswith("https://")):
        return "bad_url", "URL must start with http:// or https://"

    normalized = url.rstrip("/") + "/"
    probe = f"{normalized}dists/{codename}/Release"

    try:
        req = urllib.request.Request(
            probe, headers={"User-Agent": "lite-software-sources"})
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            if resp.status != 200:
                return "unreachable", f"HTTP {resp.status} for {probe}"
            body = resp.read(4096).decode("utf-8", errors="replace")
    except urllib.error.HTTPError as e:
        return "unreachable", f"HTTP {e.code} {e.reason} for {probe}"
    except urllib.error.URLError as e:
        return "unreachable", f"Couldn't reach {probe}: {e.reason}"
    except socket.timeout:
        return "unreachable", f"Timed out after {timeout}s reaching {probe}"
    except Exception as e:
        return "unreachable", f"{type(e).__name__}: {e}"

    origin = None
    for line in body.splitlines():
        if line.startswith("Origin:"):
            origin = line.split(":", 1)[1].strip()
            break

    if origin == "Ubuntu":
        return "ok", f"Valid Ubuntu mirror (probed {probe})."
    if origin:
        return "wrong_origin", (f"URL is reachable but Origin is '{origin}', "
                                f"not Ubuntu.")
    return "wrong_origin", "URL is reachable but no Origin field in Release file."


LL_MIRRORS = [
    ("chn", "CN",  "China",               "Asia",           "https://mirrors.sjtug.sjtu.edu.cn/linuxliteos/"),
    ("dea", "DE",  "Germany — DEFAULT",   "Europe",         "http://repo.linuxliteos.com/linuxlite/"),
    ("deb", "DE",  "Germany",             "Europe",         "http://mirror.alpix.eu/linuxliteos/"),
    ("dec", "DE",  "Germany (SSL)",       "Europe",         "https://mirror.alpix.eu/linuxliteos/"),
    ("eca", "EC",  "Ecuador (Cuenca)",    "South America",  "http://mirror.cedia.org.ec/linuxliteos/"),
    ("ecb", "EC",  "Ecuador (Guaranda)",  "South America",  "http://mirror.ueb.edu.ec/linuxliteos/"),
    ("ena", "ENG", "England",             "United Kingdom", "http://www.mirrorservice.org/sites/repo.linuxliteos.com/linuxlite/"),
    ("enb", "ENG", "England",             "United Kingdom", "https://mirror.vinehost.net/linuxlite/"),
    ("gra", "GR",  "Greece",              "Europe",         "http://ftp.cc.uoc.gr/mirrors/linux/linuxlite/"),
    ("grb", "GR",  "Greece",              "Europe",         "https://fosszone.csd.auth.gr/linuxlite/"),
    ("hka", "HK",  "Hong Kong",           "Asia",           "http://mirror-hk.koddos.net/linuxlite/"),
    ("hkb", "HK",  "Hong Kong (SSL)",     "Asia",           "https://mirror-hk.koddos.net/linuxlite/"),
    ("ino", "ID",  "Indonesia (SSL)",     "Asia",           "https://pinguin.dinus.ac.id/iso/lite/"),
    ("nca", "NC",  "New Caledonia",       "Oceania",        "http://mirror.lagoon.nc/linuxlite/linuxlite/"),
    ("nla", "NL",  "Netherlands",         "Europe",         "http://mirror.koddos.net/linuxlite/"),
    ("nlb", "NL",  "Netherlands (SSL)",   "Europe",         "https://mirror.koddos.net/linuxlite/"),
    ("sia", "SG",  "Singapore",           "Asia",           "https://mirror.freedif.org/LinuxLiteOS/"),
    ("sea", "SE",  "Sweden",              "North Europe",   "http://ftpmirror1.infania.net/linuxlite/"),
    ("seb", "SE",  "Sweden",              "North Europe",   "https://mirror.accum.se/mirror/linuxliteos.com/"),
    ("swa", "CH",  "Switzerland (SSL)",   "Europe",         "https://mirror.gofoss.xyz/linuxlite/"),
    ("usa", "US",  "USA",                 "North America",  "http://mirror.clarkson.edu/linux-lite/"),
]


def os_codename():
    try:
        for line in Path("/etc/os-release").read_text().splitlines():
            if line.startswith("VERSION_CODENAME="):
                return line.split("=", 1)[1].strip().strip('"')
    except OSError:
        pass
    return "resolute"


# --- DEB822 parser / serializer -------------------------------------------------

def parse_deb822(text):
    stanzas, current, last_key = [], {}, None
    for line in text.splitlines():
        if not line.strip():
            if current:
                stanzas.append(current)
                current, last_key = {}, None
            continue
        if line[0] in (" ", "\t") and last_key:
            current[last_key] = current[last_key] + "\n" + line.strip()
            continue
        if ":" in line:
            key, _, val = line.partition(":")
            key = key.strip()
            current[key] = val.strip()
            last_key = key
    if current:
        stanzas.append(current)
    return stanzas


def stanza_to_text(stanza):
    lines = []
    for key, val in stanza.items():
        parts = val.split("\n")
        lines.append(f"{key}: {parts[0]}")
        for extra in parts[1:]:
            lines.append(f" {extra}")
    return "\n".join(lines) + "\n"


def stanzas_to_text(stanzas):
    return "\n".join(stanza_to_text(s) for s in stanzas)


def read_sources_file(path):
    try:
        return parse_deb822(Path(path).read_text(encoding="utf-8"))
    except OSError:
        return []


def list_source_files():
    if not SOURCES_DIR.is_dir():
        return []
    return sorted(SOURCES_DIR.glob("*.sources"))


# --- Helper invocation ----------------------------------------------------------

def call_helper(*args, stdin_bytes=None):
    """Run the privileged helper via pkexec. Returns (rc, stdout, stderr)."""
    cmd = [PKEXEC, HELPER, *args]
    env = {**os.environ, "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"}
    proc = subprocess.Popen(
        cmd,
        stdin=subprocess.PIPE if stdin_bytes is not None else subprocess.DEVNULL,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        env=env,
    )
    if stdin_bytes is not None:
        try:
            proc.stdin.write(stdin_bytes)
        finally:
            proc.stdin.close()
    stdout, err_bytes = proc.communicate()
    return (
        proc.returncode,
        stdout.decode("utf-8", errors="replace"),
        err_bytes.decode("utf-8", errors="replace"),
    )


def spawn_streaming(args, on_line, on_done):
    """Non-blocking + cancellable streaming helper invocation. Returns Popen proc."""
    cmd = [PKEXEC, HELPER, *args]
    env = {**os.environ, "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"}
    proc = subprocess.Popen(
        cmd,
        stdin=subprocess.DEVNULL,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        env=env,
    )

    def reader():
        for line in proc.stdout:
            decoded = line.decode("utf-8", errors="replace").rstrip("\n")
            GLib.idle_add(on_line, decoded)
        proc.wait()
        GLib.idle_add(on_done, proc.returncode)

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


# --- Dialog helpers -------------------------------------------------------------

def show_info(parent, heading, body):
    d = Adw.AlertDialog.new(heading, body)
    d.add_response("ok", "OK")
    d.set_default_response("ok")
    d.set_close_response("ok")
    d.present(parent)


def show_error(parent, heading, body):
    d = Adw.AlertDialog.new(heading, body)
    d.add_response("ok", "OK")
    d.set_default_response("ok")
    d.set_close_response("ok")
    d.present(parent)


def confirm(parent, heading, body, on_confirm, ok_label="OK", destructive=False):
    d = Adw.AlertDialog.new(heading, body)
    d.add_response("cancel", "Cancel")
    d.add_response("ok", ok_label)
    appearance = (Adw.ResponseAppearance.DESTRUCTIVE if destructive
                  else Adw.ResponseAppearance.SUGGESTED)
    d.set_response_appearance("ok", appearance)
    d.set_default_response("ok")
    d.set_close_response("cancel")
    d.connect("response", lambda _d, r: on_confirm() if r == "ok" else None)
    d.present(parent)


# --- Page: Ubuntu Repos ---------------------------------------------------------

class UbuntuReposPage(Gtk.Box):
    def __init__(self, win):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)
        self.win = win

        scrolled = Gtk.ScrolledWindow(vexpand=True, hexpand=True,
                                       hscrollbar_policy=Gtk.PolicyType.NEVER)
        self.append(scrolled)

        clamp = Adw.Clamp(maximum_size=680)
        scrolled.set_child(clamp)

        inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18,
                        margin_top=24, margin_bottom=24, margin_start=24, margin_end=24)
        clamp.set_child(inner)

        intro = Gtk.Label(
            label="Choose which Ubuntu archive components and pockets to use.",
            halign=Gtk.Align.START, wrap=True,
        )
        intro.add_css_class("dim-label")
        inner.append(intro)

        # Components
        comp_group = Adw.PreferencesGroup(
            title="Components",
            description="Categories of packages from the Ubuntu archive.",
        )
        inner.append(comp_group)

        self.component_switches = {}
        comp_desc = {
            "main":       "Canonical-supported free and open-source software",
            "restricted": "Proprietary drivers for devices",
            "universe":   "Community-maintained free and open-source software",
            "multiverse": "Software restricted by copyright or legal issues",
        }
        for comp in COMPONENTS:
            row = Adw.SwitchRow(title=comp.capitalize(),
                                subtitle=comp_desc.get(comp, ""))
            row.connect("notify::active", self._on_changed)
            self.component_switches[comp] = row
            comp_group.add(row)

        # Suites / pockets
        codename = os_codename()
        suite_group = Adw.PreferencesGroup(
            title="Pockets",
            description=f"Which update pockets of {codename} to include.",
        )
        inner.append(suite_group)

        self.suite_switches = {}
        suite_desc = {
            "":           "Original release packages",
            "-updates":   "Major bug-fix updates after release",
            "-security":  "Important security updates (recommended)",
            "-backports": "Newer versions backported from later releases",
        }
        for suffix in SUITE_SUFFIXES:
            suite = f"{codename}{suffix}"
            row = Adw.SwitchRow(title=suite,
                                subtitle=suite_desc.get(suffix, ""))
            row.connect("notify::active", self._on_changed)
            self.suite_switches[suite] = row
            suite_group.add(row)

        action_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12,
                              halign=Gtk.Align.END)
        inner.append(action_bar)

        self.apply_btn = Gtk.Button()
        self.apply_btn.set_child(Adw.ButtonContent(
            label="Apply Changes",
            icon_name="emblem-ok-symbolic",
        ))
        self.apply_btn.add_css_class("suggested-action")
        self.apply_btn.set_sensitive(False)
        self.apply_btn.connect("clicked", self._on_apply)
        action_bar.append(self.apply_btn)

        self._loading = False
        self.reload()

    def _on_changed(self, *_):
        if self._loading:
            return
        self.apply_btn.set_sensitive(True)

    def reload(self):
        self._loading = True
        try:
            stanzas = read_sources_file(UBUNTU_SOURCES)
            active_components, active_suites = set(), set()
            for s in stanzas:
                for c in s.get("Components", "").split():
                    active_components.add(c)
                for sui in s.get("Suites", "").split():
                    active_suites.add(sui)
            for comp, row in self.component_switches.items():
                row.set_active(comp in active_components)
            for suite, row in self.suite_switches.items():
                row.set_active(suite in active_suites)
        finally:
            self._loading = False
        self.apply_btn.set_sensitive(False)

    def _on_apply(self, _btn):
        enabled_components = [c for c, r in self.component_switches.items() if r.get_active()]
        enabled_suites = [s for s, r in self.suite_switches.items() if r.get_active()]

        if not enabled_components:
            show_error(self.win, "No Components Selected",
                       "Select at least one component — 'main' is recommended.")
            return
        if not enabled_suites:
            show_error(self.win, "No Pockets Selected",
                       f"Select at least one pocket (e.g., {os_codename()}).")
            return

        stanzas = read_sources_file(UBUNTU_SOURCES)
        if not stanzas:
            show_error(self.win, "Cannot Read",
                       f"{UBUNTU_SOURCES} not found or empty.")
            return

        # Check for pockets the user wants but that aren't in any existing stanza —
        # we can't infer their URI, so reject with a clear message.
        all_original_suites = set()
        for s in stanzas:
            all_original_suites.update(s.get("Suites", "").split())
        missing = [s for s in enabled_suites if s not in all_original_suites]
        if missing:
            show_error(self.win, "Cannot Add New Pocket",
                       "These pockets aren't in the original file and need a "
                       "manual edit to add (we can't infer their archive URI):\n\n  "
                       + ", ".join(missing))
            return

        def go():
            new_stanzas = []
            for s in stanzas:
                orig = s.get("Suites", "").split()
                kept = [su for su in orig if su in enabled_suites]
                if not kept:
                    continue  # drop the stanza entirely
                s["Suites"] = " ".join(kept)
                s["Components"] = " ".join(enabled_components)
                new_stanzas.append(s)

            blob = stanzas_to_text(new_stanzas).encode("utf-8")

            def worker():
                rc, out, err = call_helper("write-source", str(UBUNTU_SOURCES),
                                            stdin_bytes=blob)
                def done():
                    if rc == 0:
                        self.reload()
                        show_info(self.win, "Saved",
                                  "Click Update in the header to refresh package lists.")
                    else:
                        show_error(self.win, "Save Failed",
                                   (err or out).strip() or "Unknown error.")
                GLib.idle_add(done)
            threading.Thread(target=worker, daemon=True).start()

        confirm(self.win, "Apply Component / Pocket Changes?",
                f"Update {UBUNTU_SOURCES}.\nRemember to click Update afterward.",
                go, ok_label="Apply")


# --- Page: Mirrors (Linux Lite mirror picker with flags) ------------------------

class MirrorRow(Gtk.ListBoxRow):
    def __init__(self, code, flag, country, region, url):
        super().__init__()
        self.code, self.url = code, url
        self.country, self.region = country, region
        self.set_activatable(True)

        hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14,
                       margin_top=10, margin_bottom=10, margin_start=14, margin_end=14)
        self.set_child(hbox)

        hbox.append(self._build_flag(flag))

        labels = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
                         hexpand=True, valign=Gtk.Align.CENTER)
        hbox.append(labels)

        title = Gtk.Label(label=country, halign=Gtk.Align.START)
        title.add_css_class("heading")
        labels.append(title)

        subtitle = Gtk.Label(label=f"{region}  —  {url}", halign=Gtk.Align.START,
                              ellipsize=3)  # PANGO_ELLIPSIZE_END = 3
        subtitle.add_css_class("dim-label")
        subtitle.add_css_class("caption")
        labels.append(subtitle)

    def _build_flag(self, flag):
        flag_path = FLAGS_DIR / f"{flag}.png"
        if flag_path.is_file():
            try:
                pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
                    str(flag_path), 36, 24, True)
                ok, buf = pixbuf.save_to_bufferv("png", [], [])
                if ok:
                    texture = Gdk.Texture.new_from_bytes(GLib.Bytes.new(buf))
                    img = Gtk.Image.new_from_paintable(texture)
                    img.set_pixel_size(36)
                    return img
            except Exception:
                pass
        return Gtk.Image.new_from_icon_name("preferences-system-network-symbolic")

    def set_current(self, on):
        if on:
            self.add_css_class("current-mirror")
        else:
            self.remove_css_class("current-mirror")


class MirrorsPage(Gtk.Box):
    def __init__(self, win):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)
        self.win = win
        self.selected_row = None
        self.mirror_rows = []
        # Runtime copy of UBUNTU_MIRRORS, possibly with a "Current (custom):"
        # entry prepended if the live URI doesn't match any curated mirror.
        self.ubuntu_combo_items = []
        self._ubuntu_loading = False
        # The last URL string the Test button has successfully validated.
        # Apply only enables for Custom URL when entry text == this.
        self._custom_tested_url = None

        scope_banner = Adw.Banner(
            title="This Mirrors tab is for Linux Lite and Ubuntu archive mirrors only."
        )
        scope_banner.set_revealed(True)
        self.append(scope_banner)

        scrolled = Gtk.ScrolledWindow(vexpand=True, hexpand=True,
                                       hscrollbar_policy=Gtk.PolicyType.NEVER)
        self.append(scrolled)

        clamp = Adw.Clamp(maximum_size=780)
        scrolled.set_child(clamp)

        inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18,
                        margin_top=18, margin_bottom=18, margin_start=18, margin_end=18)
        clamp.set_child(inner)

        # === Ubuntu mirror ============================================
        ubuntu_header = Gtk.Label(label="Ubuntu Mirror", halign=Gtk.Align.CENTER)
        ubuntu_header.add_css_class("title-2")
        inner.append(ubuntu_header)

        ubuntu_desc = Gtk.Label(label="Where apt fetches Ubuntu packages from.",
                                 halign=Gtk.Align.CENTER, wrap=True)
        ubuntu_desc.add_css_class("dim-label")
        inner.append(ubuntu_desc)

        ubuntu_group = Adw.PreferencesGroup()
        inner.append(ubuntu_group)

        self.ubuntu_combo = Adw.ComboRow(title="Mirror")
        self.ubuntu_model = Gtk.StringList()
        self.ubuntu_combo.set_model(self.ubuntu_model)
        # Custom factory renders each row as flag + label.
        factory = Gtk.SignalListItemFactory()
        factory.connect("setup", self._on_combo_factory_setup)
        factory.connect("bind", self._on_combo_factory_bind)
        self.ubuntu_combo.set_factory(factory)
        self.ubuntu_combo.connect("notify::selected", self._on_ubuntu_combo_changed)
        ubuntu_group.add(self.ubuntu_combo)

        # Custom URL entry — enabled only when "Custom URL…" is the active combo
        # row. Suffix "Test" button runs probe_ubuntu_mirror() in a thread.
        self.custom_entry = Adw.EntryRow(title="Custom URL")
        self.custom_entry.set_sensitive(False)
        self.custom_entry.connect("changed", self._on_custom_url_changed)
        ubuntu_group.add(self.custom_entry)

        self.test_btn = Gtk.Button(label="Test", valign=Gtk.Align.CENTER)
        self.test_btn.add_css_class("flat")
        self.test_btn.set_sensitive(False)
        self.test_btn.connect("clicked", self._on_test_clicked)
        self.custom_entry.add_suffix(self.test_btn)

        # Probe result line — sits below the group, colored by status class.
        self.custom_status = Gtk.Label(label="", halign=Gtk.Align.START,
                                        wrap=True, margin_start=4, margin_end=4)
        self.custom_status.add_css_class("caption")
        self.custom_status.set_visible(False)
        inner.append(self.custom_status)

        ubuntu_apply_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL,
                                    halign=Gtk.Align.CENTER, margin_top=6)
        inner.append(ubuntu_apply_row)

        self.ubuntu_apply_btn = Gtk.Button()
        self.ubuntu_apply_btn.set_child(Adw.ButtonContent(
            label="Apply Ubuntu Mirror",
            icon_name="emblem-ok-symbolic",
        ))
        self.ubuntu_apply_btn.add_css_class("suggested-action")
        self.ubuntu_apply_btn.add_css_class("pill")
        self.ubuntu_apply_btn.set_sensitive(False)
        self.ubuntu_apply_btn.connect("clicked", self._on_ubuntu_apply)
        ubuntu_apply_row.append(self.ubuntu_apply_btn)

        # === Linux Lite mirror ========================================
        ll_header = Gtk.Label(label="Linux Lite Mirror", halign=Gtk.Align.CENTER,
                               margin_top=12)
        ll_header.add_css_class("title-2")
        inner.append(ll_header)

        ll_desc = Gtk.Label(
            label="Choose the Linux Lite mirror nearest you for faster downloads.",
            halign=Gtk.Align.CENTER, wrap=True)
        ll_desc.add_css_class("dim-label")
        inner.append(ll_desc)

        ll_apply_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL,
                                halign=Gtk.Align.CENTER, margin_top=4)
        inner.append(ll_apply_row)

        self.apply_btn = Gtk.Button()
        self.apply_btn.set_child(Adw.ButtonContent(
            label="Apply Linux Lite Mirror",
            icon_name="emblem-ok-symbolic",
        ))
        self.apply_btn.add_css_class("suggested-action")
        self.apply_btn.add_css_class("pill")
        self.apply_btn.set_sensitive(False)
        self.apply_btn.connect("clicked", self._on_apply)
        ll_apply_row.append(self.apply_btn)

        frame = Gtk.Frame()
        inner.append(frame)

        self.listbox = Gtk.ListBox()
        self.listbox.set_selection_mode(Gtk.SelectionMode.SINGLE)
        self.listbox.add_css_class("boxed-list")
        self.listbox.connect("row-activated", self._on_row_activated)
        frame.set_child(self.listbox)

        self._build_rows()
        self.reload()

    # ----- Linux Lite mirror ------------------------------------------

    def _build_rows(self):
        for code, flag, country, region, url in sorted(LL_MIRRORS, key=lambda m: m[2]):
            row = MirrorRow(code, flag, country, region, url)
            self.listbox.append(row)
            self.mirror_rows.append(row)

    def _on_row_activated(self, _lb, row):
        self.selected_row = row
        self.apply_btn.set_sensitive(True)

    def _current_uri(self):
        for s in read_sources_file(LINUXLITE_SOURCES):
            uris = s.get("URIs", "").split()
            if uris:
                return uris[0]
        return None

    def _on_apply(self, _btn):
        if not self.selected_row:
            return
        new_uri = self.selected_row.url
        country = self.selected_row.country

        stanzas = read_sources_file(LINUXLITE_SOURCES)
        if not stanzas:
            show_error(self.win, "Cannot Read",
                       f"{LINUXLITE_SOURCES} not found.")
            return

        def go():
            for s in stanzas:
                s["URIs"] = new_uri
            blob = stanzas_to_text(stanzas).encode("utf-8")

            def worker():
                rc, out, err = call_helper("write-source", str(LINUXLITE_SOURCES),
                                            stdin_bytes=blob)
                def done():
                    if rc == 0:
                        self.reload()
                        # Auto-run apt-get update with the new mirror.
                        upd = UpdateWindow(self.win)
                        upd.present()
                        upd.start()
                    else:
                        show_error(self.win, "Save Failed",
                                   (err or out).strip() or "Unknown error.")
                GLib.idle_add(done)
            threading.Thread(target=worker, daemon=True).start()

        confirm(self.win, "Switch Linux Lite Mirror?",
                f"Set the Linux Lite mirror to:\n\n{country}\n{new_uri}",
                go, ok_label="Apply")

    # ----- Ubuntu mirror ----------------------------------------------

    def _ubuntu_current_uri(self):
        for s in read_sources_file(UBUNTU_SOURCES):
            uris = s.get("URIs", "").split()
            if uris:
                return uris[0]
        return None

    def _populate_ubuntu_combo(self):
        current = self._ubuntu_current_uri()
        items = list(UBUNTU_MIRRORS)
        # "Custom URL…" second from top, right after Auto.
        items.insert(1, ("Custom URL…", CUSTOM_SENTINEL))

        match_idx = None
        if current:
            norm_current = current.rstrip("/")
            for idx, (_label, url) in enumerate(items):
                if url == CUSTOM_SENTINEL:
                    continue
                if norm_current == url.rstrip("/"):
                    match_idx = idx
                    break
            if match_idx is None:
                # Installer-written or manually set host that isn't in our
                # curated list — prepend it as the current selection so the
                # user can see (and preserve) their actual mirror.
                items.insert(0, (f"Current (custom): {current}", current))
                match_idx = 0

        self.ubuntu_combo_items = items

        self._ubuntu_loading = True
        while self.ubuntu_model.get_n_items() > 0:
            self.ubuntu_model.remove(0)
        for label, _url in items:
            self.ubuntu_model.append(label)
        if match_idx is not None:
            self.ubuntu_combo.set_selected(match_idx)
        self._ubuntu_loading = False
        # Clear test state on (re)load — entry text comes back blank.
        self._custom_tested_url = None
        self.custom_entry.set_text("")
        self.custom_status.set_visible(False)
        self._refresh_custom_sensitivity()
        self.ubuntu_apply_btn.set_sensitive(False)

    # ----- Adw.ComboRow factory (flag + label) ------------------------

    def _on_combo_factory_setup(self, _factory, list_item):
        box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10,
                      margin_start=2, margin_end=2)
        flag = Gtk.Image()
        flag.set_pixel_size(22)
        box.append(flag)
        label = Gtk.Label(halign=Gtk.Align.START,
                           ellipsize=Pango.EllipsizeMode.END)
        box.append(label)
        list_item.set_child(box)

    def _on_combo_factory_bind(self, _factory, list_item):
        idx = list_item.get_position()
        if idx < 0 or idx >= len(self.ubuntu_combo_items):
            return
        item_label, item_url = self.ubuntu_combo_items[idx]

        box = list_item.get_child()
        flag = box.get_first_child()
        label = box.get_last_child()
        label.set_label(item_label)

        flag_basename = flag_for_ubuntu_url(item_url)
        if flag_basename:
            flag_path = FLAGS_DIR / f"{flag_basename}.png"
            if flag_path.is_file():
                try:
                    pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
                        str(flag_path), 22, 22, True)
                    ok, buf = pixbuf.save_to_bufferv("png", [], [])
                    if ok:
                        texture = Gdk.Texture.new_from_bytes(
                            GLib.Bytes.new(buf))
                        flag.set_from_paintable(texture)
                        return
                except Exception:
                    pass
        # Fallback for Auto / Default / Custom URL / Current(custom) /
        # any country without a flag PNG.
        flag.set_from_icon_name("network-server-symbolic")

    def _is_custom_selected(self):
        idx = self.ubuntu_combo.get_selected()
        if idx < 0 or idx >= len(self.ubuntu_combo_items):
            return False
        return self.ubuntu_combo_items[idx][1] == CUSTOM_SENTINEL

    def _refresh_custom_sensitivity(self):
        is_custom = self._is_custom_selected()
        self.custom_entry.set_sensitive(is_custom)
        self.test_btn.set_sensitive(is_custom)
        if not is_custom:
            self.custom_status.set_visible(False)

    def _show_test_result(self, status, message):
        self.custom_status.set_label(message)
        for cls in ("success", "warning", "error"):
            self.custom_status.remove_css_class(cls)
        if status == "ok":
            self.custom_status.add_css_class("success")
        elif status == "wrong_origin":
            self.custom_status.add_css_class("warning")
        else:
            self.custom_status.add_css_class("error")
        self.custom_status.set_visible(True)

    def _on_custom_url_changed(self, _entry):
        # Editing invalidates any prior test result.
        self._custom_tested_url = None
        self.custom_status.set_visible(False)
        if self._is_custom_selected():
            self.ubuntu_apply_btn.set_sensitive(False)

    def _on_test_clicked(self, _btn):
        url = self.custom_entry.get_text().strip()
        codename = os_codename()
        if not url:
            self._show_test_result("bad_url", "Enter a URL first.")
            return

        self._show_test_result("warning",
                                f"Probing {url.rstrip('/')}/dists/{codename}/Release …")
        self.test_btn.set_sensitive(False)
        self.custom_entry.set_sensitive(False)

        def worker():
            status, message = probe_ubuntu_mirror(url, codename)
            def done():
                self._show_test_result(status, message)
                self.test_btn.set_sensitive(True)
                self.custom_entry.set_sensitive(True)
                if status == "ok":
                    self._custom_tested_url = url
                    if self._is_custom_selected():
                        self.ubuntu_apply_btn.set_sensitive(True)
                else:
                    self._custom_tested_url = None
                    self.ubuntu_apply_btn.set_sensitive(False)
            GLib.idle_add(done)
        threading.Thread(target=worker, daemon=True).start()

    def _on_ubuntu_combo_changed(self, *_):
        if self._ubuntu_loading:
            return
        self._refresh_custom_sensitivity()
        if self._is_custom_selected():
            text = self.custom_entry.get_text().strip()
            self.ubuntu_apply_btn.set_sensitive(
                bool(text) and text == self._custom_tested_url)
        else:
            self.ubuntu_apply_btn.set_sensitive(True)

    def _on_ubuntu_apply(self, _btn):
        idx = self.ubuntu_combo.get_selected()
        if idx < 0 or idx >= len(self.ubuntu_combo_items):
            return
        label, slot = self.ubuntu_combo_items[idx]

        if slot == CUSTOM_SENTINEL:
            entered = self.custom_entry.get_text().strip()
            if entered != self._custom_tested_url:
                show_error(self.win, "Test Required",
                           "Click Test to validate the URL before applying.")
                return
            new_uri = entered.rstrip("/") + "/"
            label = f"Custom: {new_uri}"
        else:
            new_uri = slot

        # Decide whether to pre-probe this mirror. Skip for:
        #  - mirror:// (Auto, Canonical-managed, not directly HTTP-probable)
        #  - Custom URL (already validated by the Test button)
        #  - Same URI as currently configured (no functional change)
        skip_probe = (
            slot == CUSTOM_SENTINEL
            or new_uri.startswith("mirror://")
            or new_uri.rstrip("/") == (self._ubuntu_current_uri() or "").rstrip("/")
        )

        def proceed():
            if skip_probe:
                self._do_ubuntu_write(new_uri, label)
            else:
                self._probe_then_write_ubuntu(new_uri, label)

        confirm(self.win, "Switch Ubuntu Mirror?",
                f"Set the Ubuntu mirror to:\n\n{label}\n{new_uri}",
                proceed, ok_label="Apply")

    def _probe_then_write_ubuntu(self, new_uri, label):
        """Probe the curated mirror; only write to disk if it responds correctly."""
        original = self.ubuntu_apply_btn.get_child()
        self.ubuntu_apply_btn.set_sensitive(False)
        self.ubuntu_apply_btn.set_child(Adw.ButtonContent(
            label=f"Testing {label}…",
            icon_name="content-loading-symbolic",
        ))
        codename = os_codename()

        def worker():
            status, message = probe_ubuntu_mirror(new_uri, codename)
            def done():
                self.ubuntu_apply_btn.set_child(original)
                self.ubuntu_apply_btn.set_sensitive(True)
                if status == "ok":
                    self._do_ubuntu_write(new_uri, label)
                else:
                    show_error(
                        self.win, "Mirror Unreachable",
                        f"Couldn't reach the {label} mirror.\n\n{message}\n\n"
                        "ubuntu.sources was not changed.")
            GLib.idle_add(done)
        threading.Thread(target=worker, daemon=True).start()

    def _do_ubuntu_write(self, new_uri, label):
        """Atomic write of ubuntu.sources + auto-run apt-get update."""
        stanzas = read_sources_file(UBUNTU_SOURCES)
        if not stanzas:
            show_error(self.win, "Cannot Read",
                       f"{UBUNTU_SOURCES} not found.")
            return
        for s in stanzas:
            s["URIs"] = new_uri
        blob = stanzas_to_text(stanzas).encode("utf-8")

        def worker():
            rc, out, err = call_helper("write-source", str(UBUNTU_SOURCES),
                                        stdin_bytes=blob)
            def done():
                if rc == 0:
                    self._populate_ubuntu_combo()
                    upd = UpdateWindow(self.win)
                    upd.present()
                    upd.start()
                else:
                    show_error(self.win, "Save Failed",
                               (err or out).strip() or "Unknown error.")
            GLib.idle_add(done)
        threading.Thread(target=worker, daemon=True).start()

    # ----- Shared -----------------------------------------------------

    def reload(self):
        current_ll = self._current_uri()
        for row in self.mirror_rows:
            row.set_current(bool(current_ll) and
                            current_ll.rstrip("/") == row.url.rstrip("/"))
        self._populate_ubuntu_combo()


# --- Page: Other Software (PPAs + 3rd-party .sources) --------------------------

class OtherSoftwarePage(Gtk.Box):
    EXCLUDED = {"ubuntu.sources", "linuxlite.sources"}

    def __init__(self, win):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)
        self.win = win

        scrolled = Gtk.ScrolledWindow(vexpand=True, hexpand=True,
                                       hscrollbar_policy=Gtk.PolicyType.NEVER)
        self.append(scrolled)

        clamp = Adw.Clamp(maximum_size=760)
        scrolled.set_child(clamp)

        self.inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18,
                              margin_top=24, margin_bottom=24, margin_start=24, margin_end=24)
        clamp.set_child(self.inner)

        top = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12,
                       halign=Gtk.Align.END)
        self.inner.append(top)

        add_btn = Gtk.Button()
        add_btn.set_child(Adw.ButtonContent(
            label="Add PPA…",
            icon_name="list-add-symbolic",
        ))
        add_btn.add_css_class("suggested-action")
        add_btn.connect("clicked", self._on_add_ppa)
        top.append(add_btn)

        self.group = Adw.PreferencesGroup(title="Third-Party Sources")
        self.inner.append(self.group)

        self.empty = Gtk.Label(
            label="No third-party sources configured.\nUse “Add PPA…” to add one.",
            halign=Gtk.Align.CENTER, justify=Gtk.Justification.CENTER)
        self.empty.add_css_class("dim-label")
        self.inner.append(self.empty)

        self.reload()

    def reload(self):
        self.inner.remove(self.group)
        self.group = Adw.PreferencesGroup(title="Third-Party Sources")
        # Insert right after the top action row (first child of self.inner).
        self.inner.insert_child_after(self.group, self.inner.get_first_child())

        files = [p for p in list_source_files() if p.name not in self.EXCLUDED]
        if not files:
            self.empty.set_visible(True)
            return
        self.empty.set_visible(False)

        for path in files:
            stanzas = read_sources_file(path)
            if not stanzas:
                continue
            s = stanzas[0]
            uri = s.get("URIs", "").split()
            display_uri = uri[0] if uri else "(no URI)"
            enabled = s.get("Enabled", "yes").lower() != "no"

            row = Adw.ActionRow(title=path.stem, subtitle=display_uri)

            switch = Gtk.Switch(active=enabled, valign=Gtk.Align.CENTER)
            switch.connect("notify::active", self._on_enabled_toggled, path)
            row.add_suffix(switch)

            remove_btn = Gtk.Button.new_from_icon_name("user-trash-symbolic")
            remove_btn.set_tooltip_text("Remove this source")
            remove_btn.set_valign(Gtk.Align.CENTER)
            remove_btn.add_css_class("flat")
            remove_btn.add_css_class("destructive-action")
            remove_btn.connect("clicked", self._on_remove_clicked, path)
            row.add_suffix(remove_btn)

            self.group.add(row)

    def _on_enabled_toggled(self, switch, _pspec, path):
        new_state = switch.get_active()
        stanzas = read_sources_file(path)
        if not stanzas:
            return
        for s in stanzas:
            s["Enabled"] = "yes" if new_state else "no"
        blob = stanzas_to_text(stanzas).encode("utf-8")

        def worker():
            rc, out, err = call_helper("write-source", str(path), stdin_bytes=blob)
            def done():
                if rc != 0:
                    # Revert the switch silently
                    switch.set_active(not new_state)
                    show_error(self.win, "Toggle Failed",
                               (err or out).strip() or "Unknown error.")
            GLib.idle_add(done)
        threading.Thread(target=worker, daemon=True).start()

    def _on_remove_clicked(self, _btn, path):
        def go():
            def worker():
                rc, out, err = call_helper("remove-source", str(path))
                def done():
                    if rc == 0:
                        self.reload()
                    else:
                        show_error(self.win, "Remove Failed",
                                   (err or out).strip() or "Unknown error.")
                GLib.idle_add(done)
            threading.Thread(target=worker, daemon=True).start()

        confirm(self.win, "Remove Source?",
                f"Permanently delete:\n\n{path.name}\n\n"
                "The source and its companion keyring (if any) will be removed. "
                "This cannot be undone.",
                go, ok_label="Remove", destructive=True)

    def _on_add_ppa(self, _btn):
        # Adw.AlertDialog.set_extra_child silently fails — use Adw.Window for input.
        win = Adw.Window(transient_for=self.win, modal=True,
                          default_width=440, title="Add PPA")

        toolbar = Adw.ToolbarView()
        win.set_content(toolbar)
        toolbar.add_top_bar(Adw.HeaderBar())

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14,
                      margin_top=18, margin_bottom=18, margin_start=18, margin_end=18)
        toolbar.set_content(box)

        box.append(Gtk.Label(
            label="Enter a PPA in the form ppa:owner/name",
            halign=Gtk.Align.START, wrap=True))

        entry = Gtk.Entry(placeholder_text="ppa:owner/name", hexpand=True)
        box.append(entry)

        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10,
                       halign=Gtk.Align.END)
        box.append(row)

        cancel = Gtk.Button.new_with_label("Cancel")
        cancel.connect("clicked", lambda _b: win.close())
        row.append(cancel)

        add = Gtk.Button()
        add.set_child(Adw.ButtonContent(label="Add", icon_name="list-add-symbolic"))
        add.add_css_class("suggested-action")
        row.append(add)

        def do_add(*_):
            spec = entry.get_text().strip()
            if not spec.startswith("ppa:") or "/" not in spec[4:]:
                show_error(win, "Invalid PPA",
                           "PPA must be in the form ppa:owner/name")
                return
            add.set_sensitive(False)
            cancel.set_sensitive(False)

            def worker():
                rc, out, err = call_helper("add-ppa", spec)
                def done():
                    win.close()
                    if rc == 0:
                        self.reload()
                        show_info(self.win, "PPA Added",
                                  f"{spec} added.\nClick Update in the header to refresh.")
                    else:
                        show_error(self.win, "Add Failed",
                                   (err or out).strip() or "Unknown error.")
                GLib.idle_add(done)
            threading.Thread(target=worker, daemon=True).start()

        add.connect("clicked", do_add)
        entry.connect("activate", do_add)
        win.present()


# --- Page: Keys ----------------------------------------------------------------

class KeysPage(Gtk.Box):
    def __init__(self, win):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)
        self.win = win

        scrolled = Gtk.ScrolledWindow(vexpand=True, hexpand=True,
                                       hscrollbar_policy=Gtk.PolicyType.NEVER)
        self.append(scrolled)

        clamp = Adw.Clamp(maximum_size=760)
        scrolled.set_child(clamp)

        self.inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18,
                              margin_top=24, margin_bottom=24, margin_start=24, margin_end=24)
        clamp.set_child(self.inner)

        top = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12,
                       halign=Gtk.Align.END)
        self.inner.append(top)

        import_btn = Gtk.Button()
        import_btn.set_child(Adw.ButtonContent(
            label="Import Key…",
            icon_name="document-open-symbolic",
        ))
        import_btn.add_css_class("suggested-action")
        import_btn.connect("clicked", self._on_import)
        top.append(import_btn)

        self.group = Adw.PreferencesGroup(
            title="Signing Keys",
            description=("Distro keys live in /usr/share/keyrings/; admin-added "
                          "keys live in /etc/apt/keyrings/. Imports go to the "
                          "admin location."),
        )
        self.inner.append(self.group)

        self.empty = Gtk.Label(
            label="No signing keys found.",
            halign=Gtk.Align.CENTER)
        self.empty.add_css_class("dim-label")
        self.inner.append(self.empty)

        self.reload()

    def _key_summary(self, path):
        try:
            r = subprocess.run(
                ["gpg", "--show-keys", "--with-colons", str(path)],
                capture_output=True, text=True, timeout=5,
                env={**os.environ, "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"},
            )
        except Exception:
            return "(unreadable)"
        if r.returncode != 0:
            return "(parse error)"
        uid = None
        fpr = None
        for line in r.stdout.splitlines():
            parts = line.split(":")
            if parts[0] == "uid" and len(parts) > 9 and not uid:
                uid = parts[9]
            elif parts[0] == "fpr" and len(parts) > 9 and not fpr:
                fpr = parts[9][-16:]
        bits = [b for b in (uid, fpr) if b]
        return "  —  ".join(bits) if bits else "(no UID)"

    def reload(self):
        self.inner.remove(self.group)
        self.group = Adw.PreferencesGroup(
            title="Signing Keys",
            description=("Distro keys live in /usr/share/keyrings/; admin-added "
                          "keys live in /etc/apt/keyrings/. Imports go to the "
                          "admin location."),
        )
        self.inner.insert_child_after(self.group, self.inner.get_first_child())

        # Gather (path, parent_label) tuples from both read directories.
        # /etc/apt/keyrings/ comes first so admin-added keys sort to the top.
        # Hide noise transition/cloud keyrings from /usr/share/keyrings/.
        distro_dir = Path("/usr/share/keyrings")
        entries = []
        for d in KEYRINGS_READ_DIRS:
            if not d.is_dir():
                continue
            for k in sorted(d.glob("*.gpg")):
                if d == distro_dir and k.stem in HIDDEN_DISTRO_KEYRINGS:
                    continue
                entries.append((k, str(d) + "/"))

        if not entries:
            self.empty.set_visible(True)
            return
        self.empty.set_visible(False)

        for path, parent_label in entries:
            subtitle = f"{parent_label}  —  {self._key_summary(path)}"
            row = Adw.ActionRow(title=path.stem, subtitle=subtitle)
            icon = Gtk.Image.new_from_icon_name("application-certificate-symbolic")
            icon.set_pixel_size(28)
            row.add_prefix(icon)

            if path.parent == KEYRINGS_WRITE_DIR:
                del_btn = Gtk.Button.new_from_icon_name("user-trash-symbolic")
                del_btn.set_tooltip_text("Delete this key")
                del_btn.set_valign(Gtk.Align.CENTER)
                del_btn.add_css_class("flat")
                del_btn.add_css_class("destructive-action")
                del_btn.connect("clicked", self._on_delete_clicked, path)
                row.add_suffix(del_btn)
            else:
                lock = Gtk.Image.new_from_icon_name("changes-prevent-symbolic")
                lock.set_tooltip_text("Distro key — read-only")
                lock.set_valign(Gtk.Align.CENTER)
                lock.add_css_class("dim-label")
                row.add_suffix(lock)

            self.group.add(row)

    def _references_key(self, key_path):
        """Return list of .sources filenames whose Signed-By references key_path."""
        needle = str(key_path)
        refs = []
        for src in list_source_files():
            for s in read_sources_file(src):
                sb = s.get("Signed-By", "")
                sb_paths = [p.strip() for p in sb.split("\n") if p.strip()]
                if needle in sb_paths:
                    refs.append(src.name)
                    break
        return refs

    def _on_delete_clicked(self, _btn, path):
        refs = self._references_key(path)
        if refs:
            ref_list = "\n  • ".join(refs)
            body = (f"Permanently delete:\n\n{path.name}\n\n"
                    f"Warning: this key is referenced by:\n  • {ref_list}\n\n"
                    "apt-get update will warn about unverified packages from "
                    "those sources until you re-import the key or remove the "
                    "sources. This cannot be undone.")
        else:
            body = (f"Permanently delete:\n\n{path.name}\n\n"
                    "No active sources reference this key. "
                    "This cannot be undone.")

        def go():
            def worker():
                rc, out, err = call_helper("remove-key", str(path))
                def done():
                    if rc == 0:
                        self.reload()
                    else:
                        show_error(self.win, "Delete Failed",
                                   (err or out).strip() or "Unknown error.")
                GLib.idle_add(done)
            threading.Thread(target=worker, daemon=True).start()

        confirm(self.win, "Delete Key?", body, go, ok_label="Delete", destructive=True)

    def _on_import(self, _btn):
        dialog = Gtk.FileDialog()
        dialog.set_title("Choose a GPG key file")
        f = Gtk.FileFilter()
        f.set_name("GPG keys (.gpg, .asc, .key)")
        f.add_pattern("*.gpg")
        f.add_pattern("*.asc")
        f.add_pattern("*.key")
        filters = Gio.ListStore.new(Gtk.FileFilter)
        filters.append(f)
        dialog.set_filters(filters)
        dialog.open(self.win, None, self._on_file_chosen)

    def _on_file_chosen(self, dialog, result):
        try:
            f = dialog.open_finish(result)
        except GLib.Error:
            return
        path = Path(f.get_path())
        try:
            data = path.read_bytes()
        except OSError as e:
            show_error(self.win, "Read Failed", str(e))
            return

        dest = KEYRINGS_WRITE_DIR / (path.stem + ".gpg")

        def go():
            def worker():
                rc, out, err = call_helper("import-key", str(dest), stdin_bytes=data)
                def done():
                    if rc == 0:
                        self.reload()
                        show_info(self.win, "Key Imported", f"Imported to {dest}.")
                    else:
                        show_error(self.win, "Import Failed",
                                   (err or out).strip() or "Unknown error.")
                GLib.idle_add(done)
            threading.Thread(target=worker, daemon=True).start()

        confirm(self.win, "Import Key?",
                f"Import {path.name} as:\n\n{dest}\n\nOnly import keys you trust.",
                go, ok_label="Import")


# --- Streaming update window ---------------------------------------------------

class UpdateWindow(Adw.Window):
    # Lines apt prints that aren't useful to a user staring at this window.
    NOISE_PREFIXES = ("Reading package lists",)

    def __init__(self, parent):
        super().__init__(transient_for=parent, modal=True,
                         default_width=1024, default_height=520,
                         title="Updating Package Lists")
        self.proc = None
        self.canceled = False

        toolbar = Adw.ToolbarView()
        self.set_content(toolbar)

        header = Adw.HeaderBar()
        header.set_show_end_title_buttons(False)
        header.set_show_start_title_buttons(False)

        # Live title — spinner + label that mutate on completion.
        title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10,
                             halign=Gtk.Align.CENTER)
        self.spinner = Gtk.Spinner(spinning=True)
        title_box.append(self.spinner)
        self.title_label = Gtk.Label(label="Updating package lists…")
        self.title_label.add_css_class("heading")
        title_box.append(self.title_label)
        header.set_title_widget(title_box)

        toolbar.add_top_bar(header)

        scrolled = Gtk.ScrolledWindow(vexpand=True, hexpand=True)
        toolbar.set_content(scrolled)

        self.textview = Gtk.TextView(editable=False, monospace=True,
                                      wrap_mode=Gtk.WrapMode.WORD_CHAR,
                                      top_margin=12, bottom_margin=12,
                                      left_margin=14, right_margin=14)
        scrolled.set_child(self.textview)

        # Line-level tags for streaming output.
        buf = self.textview.get_buffer()
        buf.create_tag("hit",  foreground="#888888")
        buf.create_tag("ign",  foreground="#c08020")
        buf.create_tag("err",  foreground="#c01818", weight=Pango.Weight.BOLD)
        buf.create_tag("info", foreground="#888888", style=Pango.Style.ITALIC)

        # Bottom: status banner + button row stacked vertically.
        bottom_stack = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        toolbar.add_bottom_bar(bottom_stack)

        self.status_label = Gtk.Label(label="", halign=Gtk.Align.FILL,
                                       wrap=True, hexpand=True)
        self.status_label.set_visible(False)
        bottom_stack.append(self.status_label)

        button_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10,
                              margin_top=10, margin_bottom=10,
                              margin_start=12, margin_end=12)
        bottom_stack.append(button_row)

        # Copy to clipboard — left-aligned utility action.
        self.copy_btn = Gtk.Button()
        self.copy_btn.set_child(Adw.ButtonContent(
            label="Copy to Clipboard",
            icon_name="edit-copy-symbolic",
        ))
        self.copy_btn.set_tooltip_text("Copy the entire output to the clipboard")
        self.copy_btn.connect("clicked", self._on_copy)
        button_row.append(self.copy_btn)

        # Spacer pushes Cancel + Close to the right.
        button_row.append(Gtk.Box(hexpand=True))

        self.cancel_btn = Gtk.Button.new_with_label("Cancel")
        self.cancel_btn.add_css_class("destructive-action")
        self.cancel_btn.connect("clicked", self._on_cancel)
        button_row.append(self.cancel_btn)

        self.close_btn = Gtk.Button.new_with_label("Close")
        self.close_btn.set_sensitive(False)
        self.close_btn.connect("clicked", lambda _b: self.close())
        button_row.append(self.close_btn)

    def start(self):
        self.proc = spawn_streaming(["update"], self._append_line, self._on_done)

    def _classify(self, stripped):
        """Return the tag name for a given line, or None for default styling."""
        if stripped.startswith("Hit:"):
            return "hit"
        if stripped.startswith("Ign:"):
            return "ign"
        if stripped.startswith(("Err:", "E:", "W:")):
            return "err"
        if stripped.startswith(("Fetched ", "Building dependency",
                                "All packages are up to date",
                                "packages can be upgraded")):
            return "info"
        return None

    def _append_line(self, line):
        # Filter out apt chatter Jerry doesn't want surfaced.
        stripped = line.lstrip()
        if any(stripped.startswith(p) for p in self.NOISE_PREFIXES):
            return

        tag = self._classify(stripped)

        buf = self.textview.get_buffer()
        end = buf.get_end_iter()
        if tag:
            buf.insert_with_tags_by_name(end, line + "\n", tag)
        else:
            buf.insert(end, line + "\n")

        mark = buf.create_mark(None, buf.get_end_iter(), False)
        self.textview.scroll_mark_onscreen(mark)
        buf.delete_mark(mark)

    def _set_status(self, text, css_class):
        for cls in ("status-success", "status-warning", "status-error"):
            self.status_label.remove_css_class(cls)
        self.status_label.add_css_class(css_class)
        self.status_label.set_label(text)
        self.status_label.set_visible(True)

    def _on_done(self, rc):
        self.spinner.stop()
        self.spinner.set_visible(False)
        if self.canceled:
            self.title_label.set_label("Update cancelled")
            self._set_status("Update was cancelled.", "status-warning")
            self.cancel_btn.set_label("Cancelled")
        elif rc == 0:
            self.title_label.set_label("Update complete")
            self._set_status("Update complete.", "status-success")
        else:
            self.title_label.set_label("Update failed")
            self._set_status(f"Update failed (exit code {rc}).", "status-error")
        self.cancel_btn.set_sensitive(False)
        self.close_btn.set_sensitive(True)

    def _on_copy(self, _btn):
        buf = self.textview.get_buffer()
        text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False)
        clipboard = self.get_clipboard()
        clipboard.set(text)
        # Brief "Copied!" feedback, then restore.
        self.copy_btn.set_child(Adw.ButtonContent(
            label="Copied!",
            icon_name="emblem-ok-symbolic",
        ))
        def reset():
            self.copy_btn.set_child(Adw.ButtonContent(
                label="Copy to Clipboard",
                icon_name="edit-copy-symbolic",
            ))
            return False  # one-shot
        GLib.timeout_add_seconds(2, reset)

    def _on_cancel(self, _btn):
        # The user-owned Popen wraps pkexec; SIGTERMing it doesn't reach the
        # root-owned apt-get child. Route the kill through a second helper
        # call (auth_admin_keep cached, no re-prompt) that runs as root and
        # signals the apt-get PID it recorded in UPDATE_PIDFILE.
        if not self.proc or self.proc.poll() is not None:
            return
        self.canceled = True
        self.cancel_btn.set_sensitive(False)
        self.cancel_btn.set_label("Cancelling…")

        def worker():
            call_helper("cancel-update")
            # When apt-get dies, spawn_streaming's reader sees EOF on stdout
            # and triggers _on_done() naturally — no extra signaling needed.
        threading.Thread(target=worker, daemon=True).start()


# --- Main window ---------------------------------------------------------------

class MainWindow(Adw.ApplicationWindow):
    def __init__(self, app):
        super().__init__(application=app, title="Lite Software Sources",
                         default_width=920, default_height=660)
        self.set_icon_name(ICON_NAME)

        toolbar = Adw.ToolbarView()
        self.set_content(toolbar)

        header = Adw.HeaderBar()
        toolbar.add_top_bar(header)

        switcher = Adw.ViewSwitcher()
        switcher.set_policy(Adw.ViewSwitcherPolicy.WIDE)
        header.set_title_widget(switcher)

        reload_btn = Gtk.Button.new_from_icon_name("view-refresh-symbolic")
        reload_btn.set_tooltip_text("Reload from disk")
        reload_btn.connect("clicked", self.on_reload)
        header.pack_start(reload_btn)

        update_btn = Gtk.Button()
        update_btn.set_child(Adw.ButtonContent(
            label="Update",
            icon_name="emblem-synchronizing-symbolic",
        ))
        update_btn.add_css_class("suggested-action")
        update_btn.set_tooltip_text("Run apt-get update")
        update_btn.connect("clicked", self.on_update)
        header.pack_end(update_btn)

        stack = Adw.ViewStack()
        self.pages = [
            (UbuntuReposPage(self),   "ubuntu",  "Ubuntu Repos",   "system-software-install-symbolic"),
            (MirrorsPage(self),       "mirrors", "Mirrors",        "network-server-symbolic"),
            (OtherSoftwarePage(self), "other",   "Other Software", "application-x-addon-symbolic"),
            (KeysPage(self),          "keys",    "Keys",           "application-certificate-symbolic"),
        ]
        for page, key, title, icon in self.pages:
            stack.add_titled_with_icon(page, key, title, icon)
        switcher.set_stack(stack)
        toolbar.set_content(stack)

    def on_reload(self, _btn):
        for page, key, _title, _icon in self.pages:
            try:
                page.reload()
            except Exception as e:
                print(f"reload({key}) failed: {e}", flush=True)

    def on_update(self, _btn):
        win = UpdateWindow(self)
        win.present()
        win.start()


CSS = b"""
/* Pleasant pastel green for the currently-selected tab in the header switcher. */
viewswitcher button:checked {
    background-color: #c8eddb;
}
viewswitcher button:checked label,
viewswitcher button:checked image {
    color: #1e1e1e;
}

/* Same green for the currently-active LL mirror row. The matched-pair
   selector covers both GTK's internal CSS node name ('row') and the
   class-style selector ('listboxrow'); both forms occur depending on
   libadwaita version. Left border makes the highlight unambiguous even
   if the user's theme tints listbox row backgrounds. */
row.current-mirror,
listboxrow.current-mirror {
    background-color: #c8eddb;
    border-left: 4px solid #2e7d32;
}
row.current-mirror:hover,
listboxrow.current-mirror:hover {
    background-color: #b5e0c5;
}
row.current-mirror label,
listboxrow.current-mirror label {
    color: #1e1e1e;
}

/* Status banner inside the Update Package Lists window. */
.status-success {
    background-color: #c8eddb;
    color: #1e1e1e;
    padding: 10px;
    font-weight: bold;
}
.status-warning {
    background-color: #ffe8b3;
    color: #1e1e1e;
    padding: 10px;
    font-weight: bold;
}
.status-error {
    background-color: #f8c4c4;
    color: #1e1e1e;
    padding: 10px;
    font-weight: bold;
}
"""


class App(Adw.Application):
    def __init__(self):
        super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.DEFAULT_FLAGS)

    def do_activate(self):
        self._apply_css()
        win = self.props.active_window or MainWindow(self)
        win.present()

    def _apply_css(self):
        provider = Gtk.CssProvider()
        provider.load_from_data(CSS)
        Gtk.StyleContext.add_provider_for_display(
            Gdk.Display.get_default(),
            provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
        )


if __name__ == "__main__":
    raise SystemExit(App().run(None))
