#!/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("GdkPixbuf", "2.0")
from gi.repository import Gtk, GLib, Gio, GdkPixbuf, Gdk, Pango  # noqa: E402

import gettext as _gt, locale as _loc
TEXTDOMAIN = "lite-software-sources"
try:
    _loc.setlocale(_loc.LC_ALL, "")
except _loc.Error:
    pass
_gt.bindtextdomain(TEXTDOMAIN, "/usr/share/locale")
_gt.textdomain(TEXTDOMAIN)
_ = _gt.translation(TEXTDOMAIN, "/usr/share/locale", fallback=True).gettext


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 ubuntu-keyring / ubuntu-pro-client that are required
# on disk (dist-upgrade chain-of-trust, cloud-init / MAAS, Ubuntu Pro services)
# but are noise in the Keys tab for a desktop user. Filter applies only to
# /usr/share/keyrings/. Names listed here are matched exactly; prefixes in
# HIDDEN_DISTRO_KEY_PREFIXES are matched as `startswith(...)` so the whole
# ubuntu-pro-* family (anbox-cloud, cc-eal, cis, esm-apps, esm-apps-legacy,
# esm-infra, esm-infra-legacy, fips, fips-preview, realtime-kernel, ros, plus
# any future additions) is hidden without us having to list each one.
HIDDEN_DISTRO_KEYRINGS = {
    "ubuntu-archive-removed-keys",
    "ubuntu-cloudimage-keyring",
    "ubuntu-cloudimage-removed-keys",
    "ubuntu-master-keyring",
}
HIDDEN_DISTRO_KEY_PREFIXES = (
    "ubuntu-pro-",
)


def _is_hidden_distro_key(stem: str) -> bool:
    if stem in HIDDEN_DISTRO_KEYRINGS:
        return True
    return any(stem.startswith(p) for p in HIDDEN_DISTRO_KEY_PREFIXES)
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", _("HTTP {status} for {probe}").format(status=resp.status, probe=probe)
            body = resp.read(4096).decode("utf-8", errors="replace")
    except urllib.error.HTTPError as e:
        return "unreachable", _("HTTP {code} {reason} for {probe}").format(code=e.code, reason=e.reason, probe=probe)
    except urllib.error.URLError as e:
        return "unreachable", _("Couldn't reach {probe}: {reason}").format(probe=probe, reason=e.reason)
    except socket.timeout:
        return "unreachable", _("Timed out after {timeout}s reaching {probe}").format(timeout=timeout, probe=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", _("Valid Ubuntu mirror (probed {probe}).").format(probe=probe)
    if origin:
        return "wrong_origin", _("URL is reachable but Origin is '{origin}', not Ubuntu.").format(origin=origin)
    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 = Gtk.AlertDialog()
    d.set_modal(True)
    d.set_message(heading)
    d.set_detail(body)
    d.set_buttons([_("OK")])
    d.set_default_button(0)
    d.set_cancel_button(0)
    d.show(parent)


def show_error(parent, heading, body):
    show_info(parent, heading, body)


def confirm(parent, heading, body, on_confirm, ok_label=_("OK"), destructive=False):
    d = Gtk.AlertDialog()
    d.set_modal(True)
    d.set_message(heading)
    d.set_detail(body)
    d.set_buttons([_("Cancel"), ok_label])
    d.set_cancel_button(0)
    d.set_default_button(1)

    def _cb(dlg, res):
        try:
            idx = dlg.choose_finish(res)
        except GLib.Error:
            return
        if idx == 1:
            on_confirm()
    d.choose(parent, None, _cb)


# --- libadwaita-free shims (so the app uses the Linux-Lite GTK theme and
#     follows Light/Dark instead of the Adwaita stylesheet) ----------------

def button_content(label="", icon_name=None):
    """Replacement for Adw.ButtonContent — an icon + label box for a button."""
    box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6,
                  halign=Gtk.Align.CENTER)
    if icon_name:
        box.append(Gtk.Image.new_from_icon_name(icon_name))
    box.append(Gtk.Label(label=label))
    return box


class Clamp(Gtk.Box):
    def __init__(self, maximum_size=600):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)
        self._max = maximum_size

    def set_child(self, widget):
        c = self.get_first_child()
        while c is not None:
            self.remove(c)
            c = self.get_first_child()
        widget.set_hexpand(True)
        self.append(widget)


class Banner(Gtk.Revealer):
    def __init__(self, title=""):
        super().__init__()
        bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        bar.add_css_class("toolbar")
        bar.set_margin_top(6)
        bar.set_margin_bottom(6)
        bar.set_margin_start(12)
        bar.set_margin_end(12)
        lbl = Gtk.Label(label=title)
        lbl.set_wrap(True)
        lbl.set_xalign(0)
        lbl.set_hexpand(True)
        bar.append(lbl)
        self.set_child(bar)

    def set_revealed(self, on):
        self.set_reveal_child(on)


class PreferencesGroup(Gtk.Box):
    def __init__(self, title="", description=""):
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        self._title_lbl = None
        if title:
            self.set_title(title)
        if description:
            d = Gtk.Label(label=description, xalign=0)
            d.set_wrap(True)
            d.add_css_class("caption")
            self.append(d)
        self._listbox = Gtk.ListBox()
        self._listbox.set_selection_mode(Gtk.SelectionMode.NONE)
        self._listbox.add_css_class("boxed-list")
        self._listbox.connect("row-activated", self._row_activated)
        self.append(self._listbox)

    def set_title(self, text):
        if self._title_lbl is None:
            self._title_lbl = Gtk.Label(label=text, xalign=0)
            self._title_lbl.add_css_class("heading")
            self.prepend(self._title_lbl)
        else:
            self._title_lbl.set_label(text)

    def add(self, row):
        self._listbox.append(row)

    def _row_activated(self, _lb, row):
        toggle = getattr(row, "_toggle", None)
        if toggle is not None and toggle.get_sensitive():
            toggle.set_active(not toggle.get_active())


class ActionRow(Gtk.ListBoxRow):
    def __init__(self, title="", subtitle=""):
        super().__init__()
        self.set_activatable(False)
        self._box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12,
                            margin_top=10, margin_bottom=10, margin_start=12, margin_end=12)
        self._textbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        self._textbox.set_hexpand(True)
        self._textbox.set_valign(Gtk.Align.CENTER)
        self._title = Gtk.Label(xalign=0)
        self._title.set_wrap(True)
        self._textbox.append(self._title)
        self._subtitle = None
        self._box.append(self._textbox)
        self.set_child(self._box)
        if title:
            self.set_title(title)
        if subtitle:
            self.set_subtitle(subtitle)

    def set_title(self, text):
        self._title.set_label(text)

    def set_subtitle(self, text):
        if self._subtitle is None:
            self._subtitle = Gtk.Label(xalign=0)
            self._subtitle.set_wrap(True)
            self._subtitle.add_css_class("caption")
            self._textbox.append(self._subtitle)
        self._subtitle.set_label(text)

    def add_prefix(self, widget):
        self._box.prepend(widget)

    def add_suffix(self, widget):
        self._box.append(widget)


class EntryRow(Gtk.ListBoxRow):
    def __init__(self, title=""):
        super().__init__()
        self.set_activatable(False)
        self._box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12,
                            margin_top=6, margin_bottom=6, margin_start=12, margin_end=12)
        self._title = Gtk.Label(label=title, xalign=0)
        self._box.append(self._title)
        self.entry = Gtk.Entry()
        self.entry.set_hexpand(True)
        self._box.append(self.entry)
        self.set_child(self._box)

    def set_title(self, text):
        self._title.set_label(text)

    def get_text(self):
        return self.entry.get_text()

    def set_text(self, text):
        self.entry.set_text(text)

    def add_suffix(self, widget):
        self._box.append(widget)

    def connect(self, signal, cb, *args):
        if signal in ("changed", "notify::text"):
            return self.entry.connect("changed", lambda *_: cb(self))
        return super().connect(signal, cb, *args)


class SwitchRow(Gtk.ListBoxRow):
    def __init__(self, title="", subtitle=""):
        super().__init__()
        self.set_activatable(True)
        self._box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12,
                            margin_top=10, margin_bottom=10, margin_start=12, margin_end=12)
        self._textbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        self._textbox.set_hexpand(True)
        self._textbox.set_valign(Gtk.Align.CENTER)
        self._title = Gtk.Label(label=title, xalign=0)
        self._textbox.append(self._title)
        self._subtitle = None
        self._box.append(self._textbox)
        self.switch = Gtk.Switch()
        self.switch.set_valign(Gtk.Align.CENTER)
        self._box.append(self.switch)
        self._toggle = self.switch
        self.set_child(self._box)
        if subtitle:
            self.set_subtitle(subtitle)

    def set_title(self, text):
        self._title.set_label(text)

    def set_subtitle(self, text):
        if self._subtitle is None:
            self._subtitle = Gtk.Label(xalign=0)
            self._subtitle.set_wrap(True)
            self._subtitle.add_css_class("caption")
            self._textbox.append(self._subtitle)
        self._subtitle.set_label(text)

    def get_active(self):
        return self.switch.get_active()

    def set_active(self, v):
        self.switch.set_active(v)

    def connect(self, signal, cb, *args):
        if signal == "notify::active":
            return self.switch.connect("notify::active", lambda *_: cb(self, None))
        return super().connect(signal, cb, *args)


class ComboRow(Gtk.ListBoxRow):
    def __init__(self, title=""):
        super().__init__()
        self.set_activatable(False)
        box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12,
                      margin_top=8, margin_bottom=8, margin_start=12, margin_end=12)
        self._title = Gtk.Label(label=title, xalign=0)
        self._title.set_hexpand(True)
        self.dropdown = Gtk.DropDown()
        self.dropdown.set_valign(Gtk.Align.CENTER)
        box.append(self._title)
        box.append(self.dropdown)
        self.set_child(box)

    def set_model(self, m):
        self.dropdown.set_model(m)

    def set_factory(self, f):
        self.dropdown.set_factory(f)

    def get_selected(self):
        return self.dropdown.get_selected()

    def set_selected(self, i):
        self.dropdown.set_selected(i)

    def connect(self, signal, cb, *args):
        if signal == "notify::selected":
            return self.dropdown.connect("notify::selected", lambda *_: cb(self))
        return super().connect(signal, cb, *args)


# --- 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 = 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,
        )
        inner.append(intro)

        # Components
        comp_group = 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 = 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 = PreferencesGroup(
            title=_("Pockets"),
            description=_("Which update pockets of {codename} to include.").format(codename=codename),
        )
        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 = 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(button_content(
            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"),
                       _("Select at least one pocket (e.g., {codename}).").format(codename=os_codename()))
            return

        stanzas = read_sources_file(UBUNTU_SOURCES)
        if not stanzas:
            show_error(self.win, _("Cannot Read"),
                       _("{path} not found or empty.").format(path=UBUNTU_SOURCES))
            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?"),
                _("Update {path}.\nRemember to click Update afterward.").format(path=UBUNTU_SOURCES),
                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("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 = 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 = 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)
        inner.append(ubuntu_desc)

        ubuntu_group = PreferencesGroup()
        inner.append(ubuntu_group)

        self.ubuntu_combo = 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 = 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(button_content(
            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)
        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(button_content(
            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?"),
                _("Set the Linux Lite mirror to:\n\n{country}\n{uri}").format(country=country, uri=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, (_("Current (custom): {value}").format(value=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 = _("Custom: {uri}").format(uri=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?"),
                _("Set the Ubuntu mirror to:\n\n{label}\n{uri}").format(label=label, uri=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(button_content(
            label=_("Testing {label}…").format(label=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"),
                        _("Couldn't reach the {label} mirror.\n\n{message}\n\nubuntu.sources was not changed.").format(label=label, message=message))
            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 = 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(button_content(
            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 = 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.inner.append(self.empty)

        self.reload()

    def reload(self):
        self.inner.remove(self.group)
        self.group = 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 = 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?"),
                _("Permanently delete:\n\n{name}\n\nThe source and its companion keyring (if any) will be removed. This cannot be undone.").format(name=path.name),
                go, ok_label=_("Remove"), destructive=True)

    def _on_add_ppa(self, _btn):
        # Small modal Gtk.Window for the PPA input (theme-following).
        win = Gtk.Window(transient_for=self.win, modal=True,
                         default_width=440, title=_("Add PPA"))
        win.set_titlebar(Gtk.HeaderBar())

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14,
                      margin_top=18, margin_bottom=18, margin_start=18, margin_end=18)
        win.set_child(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(button_content(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"),
                                  _("{spec} added.\nClick Update in the header to refresh.").format(spec=spec))
                    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 = 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(button_content(
            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 = 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.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 = 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 _is_hidden_distro_key(k.stem):
                    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 = 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"), _("Imported to {dest}.").format(dest=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?"),
                _("Import {name} as:\n\n{dest}\n\nOnly import keys you trust.").format(name=path.name, dest=dest),
                go, ok_label=_("Import"))


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

class UpdateWindow(Gtk.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

        header = Gtk.HeaderBar()
        header.set_show_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)

        self.set_titlebar(header)

        # Vertical layout: streaming output (expands) + bottom bar.
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.set_child(outer)

        scrolled = Gtk.ScrolledWindow(vexpand=True, hexpand=True)
        outer.append(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)
        outer.append(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(button_content(
            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(_("Update failed (exit code {rc}).").format(rc=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(button_content(
            label=_("Copied!"),
            icon_name="emblem-ok-symbolic",
        ))
        def reset():
            self.copy_btn.set_child(button_content(
                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(Gtk.ApplicationWindow):
    def __init__(self, app):
        super().__init__(application=app, title="Lite Software Sources",
                         default_width=920, default_height=760)
        self.set_icon_name(ICON_NAME)

        header = Gtk.HeaderBar()
        self.set_titlebar(header)

        stack = Gtk.Stack()
        stack.set_vexpand(True)
        self.stack = stack

        # Icon + label tab switcher. Gtk.StackSwitcher renders icon-ONLY when
        # the pages carry an icon-name (which hid the tab labels in the
        # titlebar), so build our own linked radio toggle buttons that show
        # both the icon and the label.
        switcher = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        switcher.add_css_class("linked")
        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(button_content(
            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)

        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"),
        ]
        group_btn = None
        for page, key, title, icon in self.pages:
            stack.add_titled(page, key, title)
            tab = Gtk.ToggleButton()
            tab_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6,
                              halign=Gtk.Align.CENTER)
            tab_box.append(Gtk.Image.new_from_icon_name(icon))
            tab_box.append(Gtk.Label(label=title))
            tab.set_child(tab_box)
            if group_btn is None:
                group_btn = tab
                tab.set_active(True)
            else:
                tab.set_group(group_btn)
            tab.connect("toggled", self._on_tab_toggled, key)
            switcher.append(tab)
        self.set_child(stack)

    def _on_tab_toggled(self, btn, key):
        if btn.get_active():
            self.stack.set_visible_child_name(key)

    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. */
stackswitcher button:checked {
    background-color: #c8eddb;
}
stackswitcher button:checked label,
stackswitcher 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(Gtk.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))
