#!/usr/bin/env python3
#--------------------------------------------------------------------------------------------------------
# Name: Linux Lite - Lite Software
# Architecture: amd64
# Author: Jerry Bezencon
# Website: https://www.linuxliteos.com
# Language: Python/GTK4
# Licence: GPLv2
#--------------------------------------------------------------------------------------------------------

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

import subprocess
import os
import sys
import re
import glob
import gettext
import locale
import threading
from datetime import datetime
from typing import Optional

# ── Translations ──────────────────────────────────────────────────────────────
# Standard gettext wiring (matches the rest of the Lite suite). User-facing
# strings are wrapped in _(); translations ship as
# /usr/share/locale/<lang>/LC_MESSAGES/lite-software.mo and are picked up from
# the user's locale. Set up before SOFTWARE_CATALOG so its strings resolve too.
TEXTDOMAIN = "lite-software"
LOCALE_DIR = "/usr/share/locale"
ENV_FILE = "/tmp/.lite-software-env"

# When re-launched as root via pkexec the user's locale env (LANG/LANGUAGE)
# is scrubbed, so the root process would fall back to English. main() saves
# the locale to ENV_FILE before elevating; restore it HERE — before gettext
# binds and before SOFTWARE_CATALOG's _() calls run — so the user's language
# takes effect. (Display vars are restored later, in main().)
if os.geteuid() == 0 and os.path.exists(ENV_FILE):
    try:
        with open(ENV_FILE) as _ef:
            for _line in _ef:
                _line = _line.strip()
                if "=" in _line:
                    _k, _v = _line.split("=", 1)
                    if _k in ("LANG", "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_NUMERIC"):
                        os.environ[_k] = _v
    except Exception:
        pass

try:
    locale.setlocale(locale.LC_ALL, "")
except locale.Error:
    pass
gettext.bindtextdomain(TEXTDOMAIN, LOCALE_DIR)
gettext.textdomain(TEXTDOMAIN)
_ = gettext.translation(TEXTDOMAIN, LOCALE_DIR, fallback=True).gettext

# Constants
APP_ID = "com.linuxlite.software"
APP_NAME = "Lite Software"
ICON_PATH = "/usr/share/icons/Papirus/24x24/apps/lite-software.png"
APPICON_PATH = "/usr/share/liteappsicons/litesoftware/appicons/"
INSTALL_ICON = "/usr/share/liteappsicons/litesoftware/ll-install-software_32x32.png"
REMOVE_ICON = "/usr/share/liteappsicons/litesoftware/ll-remove-software_32x32.png"
LOG_FILE = "/var/log/lite-software.log"
TMP_LOG = "/tmp/lite-software.log"

# Internal companion packages that must NOT appear in the Install All Software
# browser. They only make sense pulled in by a dedicated install flow and have
# dependencies the apt cache can't satisfy on their own (e.g.
# wine-menu-linuxlite Depends: wine-stable, which lives behind the Wine setup
# flow). Surfacing them as standalone tickable items lets users accidentally
# install one whose deps then snowball into an unwanted stack or a broken
# system. Hidden from both the install and the installed/remove listings.
HIDDEN_FROM_BROWSER = {
    "wine-menu-linuxlite",
}

# WineHQ codenames that ship WoW64 packages (no i386 architecture needed).
# Source: https://gitlab.winehq.org/wine/wine/-/wikis/Debian-Ubuntu — "Ubuntu
# 25.10 and later / Debian Testing" notes. Older codenames (noble, jammy,
# trixie, bookworm) still need `dpkg --add-architecture i386`.
WINEHQ_WOW64_CODENAMES = {"resolute", "questing", "forky"}


class SoftwareItem(GObject.Object):
    """Data model for a software item."""

    __gtype_name__ = 'SoftwareItem'

    def __init__(self, alias: str, icon: str, name: str, category: str,
                 description: str, packages: str, optional_packages: str = ""):
        super().__init__()
        self._alias = alias
        self._icon = icon
        self._name = name
        self._category = category
        self._description = description
        self._packages = packages
        self._optional_packages = optional_packages
        self._status = "Not Installed"
        self._selected = False

    @GObject.Property(type=str)
    def alias(self) -> str:
        return self._alias

    @GObject.Property(type=str)
    def icon(self) -> str:
        return self._icon

    @GObject.Property(type=str)
    def name(self) -> str:
        return self._name

    @GObject.Property(type=str)
    def category(self) -> str:
        return self._category

    @GObject.Property(type=str)
    def description(self) -> str:
        return self._description

    @GObject.Property(type=str)
    def packages(self) -> str:
        return self._packages

    @GObject.Property(type=str)
    def optional_packages(self) -> str:
        return self._optional_packages

    @GObject.Property(type=str)
    def status(self) -> str:
        return self._status

    @status.setter
    def status(self, value: str):
        self._status = value

    @GObject.Property(type=bool, default=False)
    def selected(self) -> bool:
        return self._selected

    @selected.setter
    def selected(self, value: bool):
        self._selected = value


class PkgItem(GObject.Object):
    """Lightweight row model for the full Ubuntu-repository package list.

    Backs the "Install All Software" Gtk.ColumnView. One PkgItem per apt
    package (~105k of them), so it deliberately stores plain strings/bools
    only — no apt.Package handle is kept, which would balloon memory.
    """

    __gtype_name__ = 'PkgItem'

    def __init__(self, name: str, summary: str, version: str,
                 installed: bool, is_lib: bool, is_snap: bool = False):
        super().__init__()
        self._name = name
        self._summary = summary
        self._version = version
        self._installed = installed
        self._is_lib = is_lib
        self._is_snap = is_snap
        self._marked = False

    @GObject.Property(type=str)
    def name(self) -> str:
        return self._name

    @GObject.Property(type=str)
    def summary(self) -> str:
        return self._summary

    @GObject.Property(type=str)
    def version(self) -> str:
        return self._version

    @GObject.Property(type=bool, default=False)
    def installed(self) -> bool:
        return self._installed

    @GObject.Property(type=bool, default=False)
    def is_lib(self) -> bool:
        return self._is_lib

    @GObject.Property(type=bool, default=False)
    def is_snap(self) -> bool:
        return self._is_snap

    @GObject.Property(type=bool, default=False)
    def marked(self) -> bool:
        return self._marked

    @marked.setter
    def marked(self, value: bool):
        self._marked = value


# Software catalog - sorted A-Z by name
SOFTWARE_CATALOG = [
    ("abiword", f"{APPICON_PATH}/abiword.png", "AbiWord", _("Office"),
     _("Lightweight word processor"), "abiword", ""),
    ("audacity", f"{APPICON_PATH}/audacity.png", "Audacity", _("Multimedia"),
     _("Software for recording and editing sounds"), "audacity", ""),
    ("bleachbit", f"{APPICON_PATH}/bleachbit.png", "BleachBit", _("System"),
     _("System cleaner and privacy tool"), "bleachbit", ""),
    ("blender", f"{APPICON_PATH}/blender.png", "Blender", _("Graphics"),
     _("3D modeling and animation software"), "blender", ""),
    ("brasero", f"{APPICON_PATH}/brasero.png", "Brasero", _("Multimedia"),
     _("CD/DVD burning tool"), "brasero", ""),
    ("calibre", f"{APPICON_PATH}/calibre.png", "Calibre", _("Office"),
     _("E-book library management application"), "calibre", ""),
    ("clamtk", f"{APPICON_PATH}/clamtk.png", "ClamTk", _("System"),
     _("Graphical front-end for the ClamAV antivirus engine"), "clamtk", ""),
    ("cpux", f"{APPICON_PATH}/cpu-x.png", "CPU-X", _("System"),
     _("System information tool"), "cpu-x", ""),
    ("darktable", f"{APPICON_PATH}/darktable.png", "Darktable", _("Graphics"),
     _("Photo workflow and RAW editor"), "darktable", ""),
    ("torrent", f"{APPICON_PATH}/deluge.png", "Deluge", _("Internet"),
     _("Deluge Torrent client software"), "deluge deluge-common", ""),
    ("docker", f"{APPICON_PATH}/docker.png", "Docker", _("System"),
     _("Container platform for building and running applications"), "docker.io", ""),
    ("dropbox", f"{APPICON_PATH}/dropbox.png", "Dropbox", _("Internet"),
     _("Popular cloud storage application"), "dropbox thunar-dropbox-plugin python3-gpg", ""),
    ("emacs", f"{APPICON_PATH}/emacs.png", "Emacs", _("Accessories"),
     _("Extensible text editor and computing environment"), "emacs", ""),
    ("evolution", f"{APPICON_PATH}/evolution.png", "Evolution", _("Office"),
     _("Email and calendar application"), "evolution", ""),
    ("filezilla", f"{APPICON_PATH}/filezilla.png", "Filezilla", _("Internet"),
     _("Full-featured FTP/SFTP client"), "filezilla", ""),
    ("firefox", f"{APPICON_PATH}/firefox.png", "Firefox", _("Internet"),
     _("Popular open source web browser"), "firefox", ""),
    ("flameshot", f"{APPICON_PATH}/flameshot.png", "Flameshot", _("Accessories"),
     _("Powerful screenshot tool"), "flameshot", ""),
    ("frozenbubble", f"{APPICON_PATH}/frozen-bubble.png", "Frozen Bubble", _("Games"),
     _("Match-three bubble shooter arcade game"), "frozen-bubble", ""),
    ("gamespack", f"{APPICON_PATH}/gamespack.png", "Games Pack", _("Games"),
     _("Solitaire, Mahjongg, Chess, Mines, Sudoku and Tetris"),
     "aisleriot gnome-chess gnome-mahjongg gnome-mines gnome-sudoku quadrapassel", ""),
    ("geany", f"{APPICON_PATH}/geany.png", "Geany", _("Accessories"),
     _("IDE and text editor for developers"), "geany", "geany-plugins"),
    ("geary", f"{APPICON_PATH}/geary.png", "Geary", _("Internet"),
     _("Lightweight email client"), "geary", ""),
    ("gnome-authenticator", f"{APPICON_PATH}/gnome-authenticator.png", "GNOME Authenticator", _("Accessories"),
     _("Two-factor authentication app"), "gnome-authenticator", ""),
    ("gnomeboxes", f"{APPICON_PATH}/gnome-boxes.png", "GNOME Boxes", _("System"),
     _("Simple virtual machine manager"), "gnome-boxes", ""),
    ("gnomemaps", f"{APPICON_PATH}/gnome-maps.png", "GNOME Maps", _("Internet"),
     _("Maps and navigation application"), "gnome-maps", ""),
    ("gnucash", f"{APPICON_PATH}/gnucash.png", "GnuCash", _("Office"),
     _("Personal and small-business financial accounting"), "gnucash", ""),
    ("gnumeric", f"{APPICON_PATH}/gnumeric.png", "Gnumeric", _("Office"),
     _("Lightweight spreadsheet application"), "gnumeric", ""),
    ("grsync", f"{APPICON_PATH}/grsync.png", "Grsync", _("Accessories"),
     _("Graphical front-end for rsync file synchronisation"), "grsync", ""),
    ("gthumb", f"{APPICON_PATH}/gthumb.png", "gThumb", _("Graphics"),
     _("Image viewer, organiser and editor"), "gthumb", ""),
    ("guvcview", f"{APPICON_PATH}/guvcview.png", "Guvcview", _("Multimedia"),
     _("Webcam software for your computer"), "guvcview", ""),
    ("handbrake", f"{APPICON_PATH}/handbrake.png", "Handbrake", _("Multimedia"),
     _("Convert video to nearly any format"), "handbrake", ""),
    ("hexchat", f"{APPICON_PATH}/hexchat.png", "HexChat", _("Internet"),
     _("IRC chat client"), "hexchat", ""),
    ("homebank", f"{APPICON_PATH}/homebank.png", "HomeBank", _("Office"),
     _("Personal accounting software"), "homebank", ""),
    ("inkscape", f"{APPICON_PATH}/inkscape.png", "Inkscape", _("Graphics"),
     _("Vector graphics editor"), "inkscape", ""),
    ("kazam", f"{APPICON_PATH}/kazam.png", "Kazam", _("Multimedia"),
     _("Simple screen recording tool"), "kazam", ""),
    ("kdeconnect", f"{APPICON_PATH}/kdeconnect.png", "KDE Connect", _("Accessories"),
     _("Connect your phone to your computer"), "kdeconnect", ""),
    ("kdenlive", f"{APPICON_PATH}/kdenlive.png", "Kdenlive", _("Multimedia"),
     _("Professional video editor"), "kdenlive", ""),
    ("passmgr", f"{APPICON_PATH}/keepassxc.png", "KeePassXC", _("Accessories"),
     _("Full featured password manager"), "keepassxc", ""),
    ("kodi", f"{APPICON_PATH}/kodi.png", "Kodi", _("Multimedia"),
     _("The Kodi Media Center"), "kodi", ""),
    ("krita", f"{APPICON_PATH}/krita.png", "Krita", _("Graphics"),
     _("Professional digital painting application"), "krita", ""),
    ("lutris", f"{APPICON_PATH}/lutris.png", "Lutris", _("Games"),
     _("Open gaming platform that supports Wine, Proton and emulators"), "lutris", ""),
    ("meshlab", f"{APPICON_PATH}/meshlab.png", "MeshLab", _("Graphics"),
     _("3D triangular mesh processing and editing"), "meshlab", ""),
    ("msedge", f"{APPICON_PATH}/microsoft-edge.png", "Microsoft Edge", _("Internet"),
     _("Cross-platform web browser"), "microsoft-edge-stable", ""),
    ("mpv", f"{APPICON_PATH}/mpv.png", "MPV", _("Multimedia"),
     _("Lightweight media player"), "mpv", ""),
    ("clementine", f"{APPICON_PATH}/clementine.png", "Music Player", _("Multimedia"),
     _("Clementine music player and library organizer"), "clementine", ""),
    ("mypaint", f"{APPICON_PATH}/mypaint.png", "MyPaint", _("Graphics"),
     _("Digital painter for graphics tablets"), "mypaint", ""),
    ("nmap", f"{APPICON_PATH}/nmap.png", "Nmap", _("System"),
     _("Network discovery and security auditing tool"), "nmap", ""),
    ("notepadqq", f"{APPICON_PATH}/notepadqq.png", "Notepadqq", _("Accessories"),
     _("Notepad++ like text editor"), "notepadqq", ""),
    ("zim", f"{APPICON_PATH}/zim.png", "Note Taking Journal", _("Accessories"),
     _("Zim Note taking/Wiki editing application"), "zim", ""),
    ("obs", f"{APPICON_PATH}/obs.png", "OBS Studio", _("Multimedia"),
     _("Record and stream desktop content"), "obs-studio", ""),
    ("openscad", f"{APPICON_PATH}/openscad.png", "OpenSCAD", _("Graphics"),
     _("Programmer-oriented 3D solid CAD modeller"), "openscad", ""),
    ("peek", f"{APPICON_PATH}/peek.png", "Peek", _("Multimedia"),
     _("Animated GIF screen recorder"), "peek", ""),
    ("imessenger", f"{APPICON_PATH}/pidgin.png", "Pidgin", _("Internet"),
     _("Multi-protocol Instant Messaging client"), "pidgin", ""),
    ("planner", f"{APPICON_PATH}/planner.png", "Planner", _("Office"),
     _("Project management tool"), "planner", ""),
    ("podman", f"{APPICON_PATH}/podman.png", "Podman", _("System"),
     _("Daemonless container engine, drop-in alternative to Docker"), "podman", ""),
    ("prusaslicer", f"{APPICON_PATH}/prusa-slicer.png", "PrusaSlicer", _("Graphics"),
     _("3D-printer slicer for FDM and SLA printers"), "prusa-slicer", ""),
    ("qbittorrent", f"{APPICON_PATH}/qbittorrent.png", "qBittorrent", _("Internet"),
     _("Feature-rich BitTorrent client"), "qbittorrent", ""),
    ("redshift", f"{APPICON_PATH}/redshift.png", "Redshift", _("Accessories"),
     _("Adjusts screen color temperature"), "redshift redshift-gtk", ""),
    ("remote", f"{APPICON_PATH}/remmina.png", "Remmina", _("Internet"),
     _("Remote desktop client"), "remmina", ""),
    ("extras", f"{APPICON_PATH}/extras.png", "Restricted Extras", _("Multimedia"),
     _("Additional codecs and file formats"), "ubuntu-restricted-extras", "libavcodec-extra62"),
    ("rhythmbox", f"{APPICON_PATH}/rhythmbox.png", "Rhythmbox", _("Multimedia"),
     _("Music player and organizer"), "rhythmbox", ""),
    ("scribus", f"{APPICON_PATH}/scribus.png", "Scribus", _("Office"),
     _("Desktop publishing application"), "scribus", ""),
    ("videoedit", f"{APPICON_PATH}/shotcut.png", "Shotcut", _("Multimedia"),
     _("Simple yet powerful video editor"), "shotcut", ""),
    ("shutter", f"{APPICON_PATH}/shutter.png", "Shutter", _("Accessories"),
     _("Feature-rich screenshot tool"), "shutter", ""),
    ("ssr", f"{APPICON_PATH}/ssr.png", "Simple Screen Recorder", _("Multimedia"),
     _("Simple screen and audio recorder"), "simplescreenrecorder", ""),
    ("smplayer", f"{APPICON_PATH}/smplayer.png", "SMPlayer", _("Multimedia"),
     _("Media player with built-in codecs and YouTube support"), "smplayer", ""),
    ("soundjuicer", f"{APPICON_PATH}/sound-juicer.png", "Sound Juicer", _("Multimedia"),
     _("Extract music tracks from CDs"), "sound-juicer", "ubuntu-restricted-extras"),
    ("spotify", f"{APPICON_PATH}/spotify.png", "Spotify", _("Multimedia"),
     _("Digital music service"), "spotify-client", ""),
    ("sqlitebrowser", f"{APPICON_PATH}/sqlitebrowser.png", "SQLite Browser", _("Accessories"),
     _("Visual tool to browse and edit SQLite databases"), "sqlitebrowser", ""),
    ("stacer", f"{APPICON_PATH}/stacer.png", "Stacer", _("System"),
     _("System optimizer and monitor"), "stacer", ""),
    ("steam", f"{APPICON_PATH}/steam.png", "Steam", _("Games"),
     _("Cross platform gaming client"), "steam-installer steam-devices steam:i386", ""),
    ("supertuxkart", f"{APPICON_PATH}/supertuxkart.png", "SuperTuxKart", _("Games"),
     _("Free 3D kart racing game"), "supertuxkart", ""),
    ("teamviewer", f"{APPICON_PATH}/teamviewer.png", "Teamviewer", _("Internet"),
     _("Remote Desktop Support software"), "teamviewer", ""),
    ("tor", f"{APPICON_PATH}/tor.png", "Tor Web Browser", _("Internet"),
     _("Privacy respecting web browser"), "tor-web-browser", ""),
    ("transmission", f"{APPICON_PATH}/transmission.png", "Transmission", _("Internet"),
     _("Lightweight BitTorrent client"), "transmission-gtk", ""),
    ("uget", f"{APPICON_PATH}/uget.png", "uGet", _("Internet"),
     _("Download manager"), "uget", ""),
    ("vbox", f"{APPICON_PATH}/virtualbox.png", "VirtualBox", _("System"),
     _("Run other operating systems within Linux"),
     "virtualbox-qt virtualbox-guest-additions-iso virtualbox-guest-utils virtualbox-guest-x11 virtualbox-dkms", ""),
    ("weather", f"{APPICON_PATH}/weather.png", "Weather Monitor", _("System Tray"),
     _("Weather Monitor Plugin for your tray"), "xfce4-weather-plugin", ""),
    ("wesnoth", f"{APPICON_PATH}/wesnoth.png", "Wesnoth", _("Games"),
     _("Turn-based strategy game with a fantasy theme"), "wesnoth", ""),
    ("wine", f"{APPICON_PATH}/wine.png", "Wine", _("Cross Platform"),
     _("Run Windows programs and games on Linux"),
     "winehq-stable wine-menu-linuxlite", ""),
    ("winff", f"{APPICON_PATH}/winff.png", "WinFF", _("Multimedia"),
     _("Video converter GUI for FFmpeg"), "winff", ""),
    ("wireshark", f"{APPICON_PATH}/wireshark.png", "Wireshark", _("System"),
     _("Network protocol analyser"), "wireshark", ""),
    ("ytdlp", f"{APPICON_PATH}/yt-dlp.png", "yt-dlp", _("Internet"),
     _("Video downloader from YouTube and more"), "yt-dlp", ""),
    ("zoom", f"{APPICON_PATH}/zoom.png", "Zoom", _("Internet"),
     _("Video and audio conferencing application"), "zoom", ""),
]


def log_message(message: str):
    """Write a log entry to the log file."""
    try:
        timestamp = datetime.now().strftime("[%D %H:%M:%S]")
        with open(LOG_FILE, "a") as f:
            f.write(f"{timestamp} {message}\n")
    except PermissionError:
        pass


def check_internet() -> bool:
    """Check if internet connection is available."""
    try:
        result = subprocess.run(
            ["curl", "-sk", "google.com"],
            capture_output=True,
            timeout=10
        )
        return result.returncode == 0
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return False


def get_package_status(packages: str) -> str:
    """Check if the main package is installed using dpkg-query."""
    pkg_list = packages.split()
    if not pkg_list:
        return "Not Installed"

    # Use the first (main) package as the indicator
    pkg_name = pkg_list[0].split(":")[0]  # Remove architecture suffix

    try:
        result = subprocess.run(
            ["dpkg-query", "-W", "-f=${Status}", pkg_name],
            capture_output=True,
            text=True
        )
        # Package is installed if status contains "install ok installed"
        if result.returncode == 0 and "install ok installed" in result.stdout:
            return "Installed"
    except FileNotFoundError:
        pass

    return "Not Installed"


def kill_package_managers():
    """Kill any running package managers."""
    for proc in ["synaptic", "gdebi-gtk"]:
        subprocess.run(["pkill", "-9", proc], capture_output=True)


class SoftwareListRow(Gtk.Box):
    """Custom row widget for the software list."""

    def __init__(self, item: SoftwareItem, show_checkbox: bool = True):
        super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        self.item = item
        self.set_margin_top(8)
        self.set_margin_bottom(8)
        self.set_margin_start(12)
        self.set_margin_end(12)

        # Checkbox
        if show_checkbox:
            self.checkbox = Gtk.CheckButton()
            self.checkbox.set_active(item.selected)
            self.checkbox.connect("toggled", self._on_checkbox_toggled)
            self.append(self.checkbox)

        # Icon
        if os.path.exists(item.icon):
            try:
                pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(item.icon, 32, 32, True)
                icon = Gtk.Image.new_from_pixbuf(pixbuf)
            except GLib.Error:
                icon = Gtk.Image.new_from_icon_name("application-x-executable")
        else:
            icon = Gtk.Image.new_from_icon_name("application-x-executable")
        icon.set_pixel_size(32)
        self.append(icon)

        # Info box
        info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        info_box.set_hexpand(True)

        # Name and category row
        name_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        name_label = Gtk.Label(label=item.name)
        name_label.set_halign(Gtk.Align.START)
        name_label.add_css_class("heading")
        name_row.append(name_label)

        category_label = Gtk.Label(label=item.category)
        category_label.add_css_class("dim-label")
        category_label.add_css_class("caption")
        name_row.append(category_label)

        info_box.append(name_row)

        # Description
        desc_label = Gtk.Label(label=item.description)
        desc_label.set_halign(Gtk.Align.START)
        desc_label.add_css_class("dim-label")
        desc_label.set_wrap(True)
        desc_label.set_xalign(0)
        info_box.append(desc_label)

        self.append(info_box)

        # Status badge — item.status stays an English sentinel for the logic
        # below and for filtering; only the visible text is translated.
        status_label = Gtk.Label(
            label=_("Installed") if item.status == "Installed" else _("Not Installed"))
        if item.status == "Installed":
            status_label.add_css_class("success")
        else:
            status_label.add_css_class("dim-label")
        status_label.set_valign(Gtk.Align.CENTER)
        self.append(status_label)

    def _on_checkbox_toggled(self, checkbox):
        self.item.selected = checkbox.get_active()


class ProgressDialog(Adw.Window):
    """Progress dialog for package operations."""

    def __init__(self, parent, title: str, text: str):
        super().__init__()
        self.set_title(title)
        self.set_modal(True)
        self.set_transient_for(parent)
        self.set_default_size(500, 150)
        self.set_deletable(False)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
        box.set_margin_top(24)
        box.set_margin_bottom(24)
        box.set_margin_start(24)
        box.set_margin_end(24)

        self.status_label = Gtk.Label(label=text)
        self.status_label.set_wrap(True)
        box.append(self.status_label)

        self.progress_bar = Gtk.ProgressBar()
        self.progress_bar.set_show_text(True)
        box.append(self.progress_bar)

        self.detail_label = Gtk.Label()
        self.detail_label.add_css_class("dim-label")
        self.detail_label.add_css_class("caption")
        self.detail_label.set_wrap(True)
        box.append(self.detail_label)

        self.set_content(box)

    def set_progress(self, fraction: float, text: str = ""):
        self.progress_bar.set_fraction(fraction)
        if text:
            self.progress_bar.set_text(text)

    def set_status(self, text: str):
        self.status_label.set_label(text)

    def set_detail(self, text: str):
        self.detail_label.set_label(text)

    def pulse(self):
        self.progress_bar.pulse()


class LiteSoftwareWindow(Adw.ApplicationWindow):
    """Main application window."""

    def __init__(self, app):
        super().__init__(application=app)
        self.set_icon_name("lite-software")
        self.set_title(APP_NAME)
        self.set_default_size(900, 700)

        # Hide system window decorations (use CSD only)
        self.set_decorated(False)

        # Load software items
        self.software_items = []
        for data in SOFTWARE_CATALOG:
            item = SoftwareItem(*data)
            self.software_items.append(item)

        # Navigation view for page switching (contains its own headers)
        self.navigation_view = Adw.NavigationView()
        self.navigation_view.set_vexpand(True)

        # Create main menu page
        self.create_main_menu()

        self.set_content(self.navigation_view)

        # Check for root and prepare
        GLib.idle_add(self._initial_setup)

    def _initial_setup(self):
        """Run initial setup after window is shown."""
        if os.geteuid() != 0:
            dialog = Adw.AlertDialog()
            dialog.set_heading(_("Root Privileges Required"))
            dialog.set_body(_("This application requires root privileges to install and remove software."))
            dialog.add_response("quit", _("Quit"))
            dialog.set_default_response("quit")
            dialog.connect("response", lambda d, r: self.get_application().quit())
            dialog.present(self)
            return False

        kill_package_managers()
        self._check_internet_and_update()
        return False

    def _check_internet_and_update(self):
        """Check internet and update sources."""
        if not check_internet():
            dialog = Adw.AlertDialog()
            dialog.set_heading(_("No Internet Access"))
            dialog.set_body(_("Your computer does not seem to be connected to the Internet.\n\n"
                          "Please connect to the Internet before running Lite Software."))
            dialog.add_response("ok", _("OK"))
            dialog.connect("response", lambda d, r: self.get_application().quit())
            dialog.present(self)
            return

        # Automatically update sources on launch
        self._update_sources()

    def _count_apt_sources(self):
        """Count the number of apt source entries to estimate progress."""
        count = 0
        sources_dir = "/etc/apt/sources.list.d"
        sources_file = "/etc/apt/sources.list"
        try:
            if os.path.exists(sources_file):
                with open(sources_file) as f:
                    for line in f:
                        s = line.strip()
                        if s and not s.startswith("#") and s.startswith("deb"):
                            count += 1
        except Exception:
            pass
        try:
            if os.path.isdir(sources_dir):
                for fname in os.listdir(sources_dir):
                    if fname.endswith((".list", ".sources")):
                        fpath = os.path.join(sources_dir, fname)
                        try:
                            with open(fpath) as f:
                                for line in f:
                                    s = line.strip()
                                    if s and not s.startswith("#") and s.startswith("deb"):
                                        count += 1
                        except Exception:
                            pass
        except Exception:
            pass
        return max(count, 5)

    def _update_sources(self):
        """Update apt sources."""
        progress = ProgressDialog(self, _("Updating Software Sources"), _("Updating package lists..."))
        progress.present()

        # Estimate total lines: each source entry produces ~3 Hit/Get/Ign lines
        estimated_total = self._count_apt_sources() * 3

        def update_thread():
            try:
                process = subprocess.Popen(
                    ["apt-get", "update"],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True
                )

                line_count = 0
                error_lines = []
                for line in process.stdout:
                    stripped = line.strip()
                    if stripped.startswith(("Hit:", "Get:", "Ign:")):
                        line_count += 1
                        fraction = min(line_count / estimated_total, 0.85)
                        pct = int(fraction * 100)
                        GLib.idle_add(progress.set_progress, fraction, f"{pct}%")
                    elif stripped.startswith("Reading package lists"):
                        GLib.idle_add(progress.set_progress, 0.90, "90%")
                    if stripped.startswith(("Err:", "E:", "W:")):
                        error_lines.append(stripped)
                    if stripped:
                        GLib.idle_add(progress.set_detail, stripped[:80])

                process.wait()
                GLib.idle_add(progress.set_progress, 1.0, "100%")
                GLib.idle_add(progress.close)

                if process.returncode != 0:
                    error_detail = "\n".join(error_lines[-5:]) if error_lines else _("Check your internet connection.")
                    log_message(f"ERROR: Updating sources has failed. {error_detail}")
                    GLib.idle_add(self.set_visible, True)
                    GLib.idle_add(self._show_error, _("Update Failed"),
                                 _("Some package sources could not be updated.\n\n{detail}").format(detail=error_detail))
                else:
                    log_message("INFO: Software sources were updated.")
                    GLib.idle_add(self.set_visible, True)
            except Exception as e:
                GLib.idle_add(progress.close)
                GLib.idle_add(self.set_visible, True)
                GLib.idle_add(self._show_error, _("Update Failed"), str(e))

        thread = threading.Thread(target=update_thread, daemon=True)
        thread.start()

    def _show_error(self, title: str, message: str):
        """Show an error dialog."""
        dialog = Adw.AlertDialog()
        dialog.set_heading(title)
        dialog.set_body(message)
        dialog.add_response("ok", _("OK"))
        dialog.present(self)

    def _show_info(self, title: str, message: str):
        """Show an info dialog."""
        dialog = Adw.AlertDialog()
        dialog.set_heading(title)
        dialog.set_body(message)
        dialog.add_response("ok", _("OK"))
        dialog.present(self)

    def create_main_menu(self):
        """Create the main task selector menu."""
        page = Adw.NavigationPage()
        page.set_title(APP_NAME)

        toolbar_view = Adw.ToolbarView()
        header = Adw.HeaderBar()
        toolbar_view.add_top_bar(header)

        # Main content
        content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
        content_box.set_margin_top(48)
        content_box.set_margin_bottom(48)
        content_box.set_margin_start(48)
        content_box.set_margin_end(48)
        content_box.set_valign(Gtk.Align.CENTER)
        content_box.set_halign(Gtk.Align.CENTER)

        # Title
        title_label = Gtk.Label(label=_("Please select a Task below"))
        title_label.add_css_class("title-2")
        content_box.append(title_label)

        # Buttons box
        buttons_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        buttons_box.set_halign(Gtk.Align.CENTER)

        # Install button
        install_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        install_box.set_halign(Gtk.Align.START)

        if os.path.exists(INSTALL_ICON):
            try:
                pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(INSTALL_ICON, 32, 32, True)
                install_icon = Gtk.Image.new_from_pixbuf(pixbuf)
            except GLib.Error:
                install_icon = Gtk.Image.new_from_icon_name("list-add-symbolic")
        else:
            install_icon = Gtk.Image.new_from_icon_name("list-add-symbolic")
        install_icon.set_pixel_size(32)

        install_btn = Gtk.Button()
        install_btn.set_size_request(280, 60)
        install_btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        install_btn_box.set_halign(Gtk.Align.CENTER)
        install_btn_box.append(install_icon)
        install_btn_box.append(Gtk.Label(label=_("Install Popular Software")))
        install_btn.set_child(install_btn_box)
        install_btn.add_css_class("suggested-action")
        install_btn.add_css_class("pill")
        install_btn.connect("clicked", self._on_install_clicked)
        buttons_box.append(install_btn)

        # Remove button
        if os.path.exists(REMOVE_ICON):
            try:
                pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(REMOVE_ICON, 32, 32, True)
                remove_icon = Gtk.Image.new_from_pixbuf(pixbuf)
            except GLib.Error:
                remove_icon = Gtk.Image.new_from_icon_name("list-remove-symbolic")
        else:
            remove_icon = Gtk.Image.new_from_icon_name("list-remove-symbolic")
        remove_icon.set_pixel_size(32)

        remove_btn = Gtk.Button()
        remove_btn.set_size_request(280, 60)
        remove_btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        remove_btn_box.set_halign(Gtk.Align.CENTER)
        remove_btn_box.append(remove_icon)
        remove_btn_box.append(Gtk.Label(label=_("Remove Popular Software")))
        remove_btn.set_child(remove_btn_box)
        remove_btn.add_css_class("destructive-action")
        remove_btn.add_css_class("pill")
        remove_btn.connect("clicked", self._on_remove_clicked)
        buttons_box.append(remove_btn)

        # Install All Software button — opens the full Ubuntu-repository
        # package browser (the Synaptic replacement for LL 8.0).
        if os.path.exists(INSTALL_ICON):
            try:
                pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(INSTALL_ICON, 32, 32, True)
                all_icon = Gtk.Image.new_from_pixbuf(pixbuf)
            except GLib.Error:
                all_icon = Gtk.Image.new_from_icon_name("system-software-install-symbolic")
        else:
            all_icon = Gtk.Image.new_from_icon_name("system-software-install-symbolic")
        all_icon.set_pixel_size(32)

        all_btn = Gtk.Button()
        all_btn.set_size_request(280, 60)
        all_btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        all_btn_box.set_halign(Gtk.Align.CENTER)
        all_btn_box.append(all_icon)
        all_btn_box.append(Gtk.Label(label=_("Install All Software")))
        all_btn.set_child(all_btn_box)
        all_btn.add_css_class("pill")
        all_btn.connect("clicked", self._on_browse_all_clicked)
        buttons_box.append(all_btn)

        content_box.append(buttons_box)

        # Instructions
        instructions = Gtk.Label()
        instructions.set_markup(
            _("<span size='small'>Select <b>Install Popular Software</b> to add curated applications\n"
              "or <b>Remove Popular Software</b> to uninstall existing ones.\n"
              "Choose <b>Install All Software</b> to browse every package in the Ubuntu repositories.</span>")
        )
        instructions.add_css_class("dim-label")
        instructions.set_justify(Gtk.Justification.CENTER)
        content_box.append(instructions)

        toolbar_view.set_content(content_box)
        page.set_child(toolbar_view)

        self.navigation_view.add(page)

    def _refresh_package_status(self):
        """Refresh the installation status of all packages."""
        for item in self.software_items:
            item.status = get_package_status(item.packages)

    def _on_install_clicked(self, button):
        """Show install software page."""
        self._refresh_package_status()
        self._show_software_list("install")

    def _on_remove_clicked(self, button):
        """Show remove software page."""
        self._refresh_package_status()
        self._show_software_list("remove")

    def _show_software_list(self, mode: str):
        """Show the software list page."""
        is_install = mode == "install"
        title = _("Install Popular Software") if is_install else _("Remove Popular Software")
        self.current_filter_status = "Not Installed" if is_install else "Installed"

        page = Adw.NavigationPage()
        page.set_title(title)

        toolbar_view = Adw.ToolbarView()

        # Header bar with action button
        header = Adw.HeaderBar()

        action_btn = Gtk.Button(label=_("Install") if is_install else _("Remove"))
        if is_install:
            action_btn.add_css_class("suggested-action")
        else:
            action_btn.add_css_class("destructive-action")
        action_btn.connect("clicked", lambda b: self._on_action_clicked(is_install))
        header.pack_end(action_btn)

        # Select all button
        select_all_btn = Gtk.Button(label=_("Select All"))
        select_all_btn.connect("clicked", lambda b: self._select_all(self.current_filter_status))
        header.pack_start(select_all_btn)

        toolbar_view.add_top_bar(header)

        # Main content box
        main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

        # Search box
        search_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        search_box.set_margin_top(12)
        search_box.set_margin_start(12)
        search_box.set_margin_end(12)

        self.search_entry = Gtk.SearchEntry()
        self.search_entry.set_placeholder_text(_("Search software..."))
        self.search_entry.set_hexpand(True)
        self.search_entry.connect("search-changed", self._on_search_changed)
        search_box.append(self.search_entry)

        main_box.append(search_box)

        # Content
        scrolled = Gtk.ScrolledWindow()
        scrolled.set_vexpand(True)

        self.list_box = Gtk.ListBox()
        self.list_box.set_selection_mode(Gtk.SelectionMode.NONE)
        self.list_box.add_css_class("boxed-list")
        self.list_box.set_margin_top(12)
        self.list_box.set_margin_bottom(12)
        self.list_box.set_margin_start(12)
        self.list_box.set_margin_end(12)

        # Clear selections and populate list
        for item in self.software_items:
            item.selected = False
            if item.status == self.current_filter_status:
                row = SoftwareListRow(item)
                row.set_name(item.name.lower())  # Set name for filtering
                self.list_box.append(row)

        scrolled.set_child(self.list_box)
        main_box.append(scrolled)

        toolbar_view.set_content(main_box)
        page.set_child(toolbar_view)

        self.navigation_view.push(page)

    def _on_search_changed(self, search_entry):
        """Filter the software list based on search text."""
        search_text = search_entry.get_text().lower()

        # Iterate through all rows in the list box
        row = self.list_box.get_first_child()
        while row:
            if isinstance(row, Gtk.ListBoxRow):
                child = row.get_child()
                if isinstance(child, SoftwareListRow):
                    item = child.item
                    # Check if search text matches name, category, or description
                    visible = (search_text in item.name.lower() or
                              search_text in item.category.lower() or
                              search_text in item.description.lower())
                    row.set_visible(visible)
            row = row.get_next_sibling()

    def _select_all(self, filter_status: str):
        """Select all items with given status."""
        for item in self.software_items:
            if item.status == filter_status:
                item.selected = True
        # Refresh the current page
        current_page = self.navigation_view.get_visible_page()
        if current_page:
            # Find all checkboxes and set them
            self._update_checkboxes_in_widget(current_page.get_child(), True)

    def _update_checkboxes_in_widget(self, widget, state: bool):
        """Recursively find and update all checkboxes."""
        if isinstance(widget, Gtk.CheckButton):
            widget.set_active(state)
        elif hasattr(widget, 'get_first_child'):
            child = widget.get_first_child()
            while child:
                self._update_checkboxes_in_widget(child, state)
                child = child.get_next_sibling()
        elif hasattr(widget, 'get_child'):
            child = widget.get_child()
            if child:
                self._update_checkboxes_in_widget(child, state)

    def _on_action_clicked(self, is_install: bool):
        """Handle install/remove button click."""
        selected_items = [item for item in self.software_items if item.selected]

        if not selected_items:
            self._show_info(
                _("No Selection"),
                _("No application was selected for installation.") if is_install
                else _("No application was selected for removal.")
            )
            return

        # Build confirmation message
        names = "\n".join([f"• {item.name}" for item in selected_items])

        dialog = Adw.AlertDialog()
        dialog.set_heading(_("Confirm Installation") if is_install else _("Confirm Removal"))
        if is_install:
            body = _("The following software has been selected:\n\n{names}\n\nDo you want to proceed with the installation?")
        else:
            body = _("The following software has been selected:\n\n{names}\n\nDo you want to proceed with the removal?")
        dialog.set_body(body.format(names=names))
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("confirm", _("Install") if is_install else _("Remove"))

        if is_install:
            dialog.set_response_appearance("confirm", Adw.ResponseAppearance.SUGGESTED)
        else:
            dialog.set_response_appearance("confirm", Adw.ResponseAppearance.DESTRUCTIVE)

        dialog.connect("response", lambda d, r: self._execute_action(selected_items, is_install) if r == "confirm" else None)
        dialog.present(self)

    def _execute_action(self, items: list, is_install: bool):
        """Execute install or remove action."""
        # Collect all packages
        packages = []
        for item in items:
            packages.extend(item.packages.split())
            if item.optional_packages:
                packages.extend(item.optional_packages.split())

        action = "install" if is_install else "remove"
        log_message(f"INFO: {'Installation' if is_install else 'Removal'} of packages initiated: {' '.join(packages)}")

        progress = ProgressDialog(
            self,
            _("Installing Software") if is_install else _("Removing Software"),
            _("Installing packages...\nThis may take a while.") if is_install
            else _("Removing packages...\nThis may take a while.")
        )
        progress.present()

        def action_thread():
            try:
                if is_install and "winehq-stable" in packages:
                    GLib.idle_add(progress.set_detail, _("Setting up WineHQ repository..."))
                    # Fallback to "resolute" — LL 8.0 ships on Ubuntu 26.04.
                    codename = "resolute"
                    try:
                        r = subprocess.run(["lsb_release", "-cs"], capture_output=True, text=True)
                        if r.returncode == 0 and r.stdout.strip():
                            detected = r.stdout.strip()
                            check = subprocess.run(
                                ["wget", "-q", "--spider",
                                 f"https://dl.winehq.org/wine-builds/ubuntu/dists/{detected}/winehq-{detected}.sources"],
                                capture_output=True)
                            if check.returncode == 0:
                                codename = detected
                            else:
                                log_message(f"WineHQ has no repo for '{detected}', falling back to '{codename}'")
                    except Exception:
                        pass
                    log_message(f"INFO: Wine setup using codename '{codename}'")

                    # Clean up any legacy winehq sources files from previous
                    # attempts. The deprecated `archive_uri-…-noble.list` form
                    # often hits the "Malformed entry" trap and breaks every
                    # subsequent apt-get update.
                    for stale in ("/etc/apt/sources.list.d/winehq.list",
                                  "/etc/apt/sources.list.d/winehq-stable.list"):
                        try:
                            if os.path.exists(stale):
                                os.remove(stale)
                                log_message(f"INFO: Removed stale {stale}")
                        except Exception as e:
                            log_message(f"WARN: Could not remove {stale}: {e}")
                    # Glob for the auto-named legacy file too (any codename).
                    for stale in glob.glob("/etc/apt/sources.list.d/archive_uri-https_dl_winehq_org*"):
                        try:
                            os.remove(stale)
                            log_message(f"INFO: Removed stale {stale}")
                        except Exception as e:
                            log_message(f"WARN: Could not remove {stale}: {e}")

                    # Build the setup command list. Use bash -c for the key
                    # download so we can pipe through `gpg --dearmor` exactly
                    # as the WineHQ wiki / omgubuntu / ubuntuhandbook docs
                    # specify. Modern apt accepts ASCII-armored keys too, but
                    # the dearmored binary keyring matches every published
                    # how-to and is least likely to trip future apt versions.
                    wine_setup_cmds = [
                        ["mkdir", "-pm755", "/etc/apt/keyrings"],
                        ["bash", "-c",
                         "set -o pipefail; "
                         "wget -qO - https://dl.winehq.org/wine-builds/winehq.key | "
                         "gpg --dearmor --yes -o /etc/apt/keyrings/winehq-archive.key"],
                        ["wget", "-NP", "/etc/apt/sources.list.d/",
                         f"https://dl.winehq.org/wine-builds/ubuntu/dists/{codename}/winehq-{codename}.sources"],
                    ]
                    # Resolute and later are WoW64 — skip the i386 enable step.
                    if codename not in WINEHQ_WOW64_CODENAMES:
                        wine_setup_cmds.insert(0, ["dpkg", "--add-architecture", "i386"])
                    for cmd in wine_setup_cmds:
                        result = subprocess.run(cmd, capture_output=True, text=True)
                        if result.returncode != 0:
                            error_detail = result.stderr.strip() or result.stdout.strip() or _("Unknown error")
                            log_message(f"ERROR: Wine setup failed: {' '.join(cmd)}\n{error_detail}")
                            GLib.idle_add(progress.close)
                            GLib.idle_add(self._show_error, _("Installation Failed"),
                                         _("Failed to set up the WineHQ repository.\n\n"
                                           "Command: {cmd}\n"
                                           "Error: {error}").format(
                                               cmd=' '.join(cmd[-2:]), error=error_detail))
                            return

                    # Refresh the apt cache. CHECK the returncode and capture
                    # the output — silently ignoring this is the bug that
                    # produces the misleading "Unable to locate package
                    # winehq-stable" downstream.
                    GLib.idle_add(progress.set_detail, _("Refreshing package cache..."))
                    upd = subprocess.run(["apt-get", "update"], capture_output=True, text=True)
                    if upd.returncode != 0:
                        err = (upd.stderr.strip() or upd.stdout.strip() or _("Unknown error"))[-1500:]
                        log_message(f"ERROR: apt-get update failed (rc={upd.returncode}):\n{err}")
                        GLib.idle_add(progress.close)
                        GLib.idle_add(self._show_error, _("Installation Failed"),
                                     _("Refreshing the package cache failed after adding the WineHQ "
                                       "repository.\n\n"
                                       "This usually means another sources file in /etc/apt/sources.list.d/ "
                                       "is malformed or unreachable.\n\n"
                                       "apt-get update output (tail):\n{output}").format(output=err))
                        return

                    # Verify winehq-stable is now visible. If not, give a
                    # clear, specific error instead of the generic apt one.
                    pol = subprocess.run(["apt-cache", "policy", "winehq-stable"],
                                         capture_output=True, text=True)
                    if "dl.winehq.org" not in pol.stdout:
                        log_message(f"ERROR: winehq-stable not advertised after apt-get update.\n"
                                    f"apt-cache policy output:\n{pol.stdout}")
                        GLib.idle_add(progress.close)
                        GLib.idle_add(self._show_error, _("Installation Failed"),
                                     _("The WineHQ repository was added, but apt cannot see "
                                       "winehq-stable in it.\n\n"
                                       "Check that /etc/apt/sources.list.d/winehq-{codename}.sources "
                                       "exists and that /etc/apt/keyrings/winehq-archive.key "
                                       "is valid.").format(codename=codename))
                        return

                cmd = ["apt-get", action, "-f", "-y"] + packages

                with open(TMP_LOG, "w") as log_file:
                    process = subprocess.Popen(
                        cmd,
                        stdout=subprocess.PIPE,
                        stderr=subprocess.STDOUT,
                        text=True,
                        env={**os.environ, "DEBIAN_FRONTEND": "noninteractive",
                             "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"}
                    )

                    # Two-phase progress like Lite Updates:
                    # 0 → 0.5 = downloading (counts Get: lines)
                    # 0.5 → 1.0 = installing (counts Unpacking lines)
                    # Total comes from apt's "X newly installed" / "X to remove"
                    # summary line; until we see it, pulse instead of advancing.
                    summary_re = re.compile(
                        r'(\d+)\s+upgraded.*?(\d+)\s+newly installed.*?(\d+)\s+to remove',
                        re.IGNORECASE)
                    total_pkgs = 0
                    downloaded = 0
                    installed = 0
                    phase = "starting"

                    for line in process.stdout:
                        log_file.write(line)
                        log_file.flush()
                        s = line.strip()
                        if not s:
                            continue

                        # Pick up the package count from apt's summary line,
                        # e.g. "0 upgraded, 25 newly installed, 0 to remove
                        # and 3 not upgraded."
                        if not total_pkgs:
                            m = summary_re.search(s)
                            if m:
                                total_pkgs = int(m.group(1)) + int(m.group(2)) + int(m.group(3))

                        if s.startswith("Get:"):
                            if phase != "download":
                                phase = "download"
                                GLib.idle_add(progress.set_status, _("Downloading packages..."))
                            downloaded += 1
                            if total_pkgs:
                                frac = min(downloaded / total_pkgs, 1.0) * 0.5
                                GLib.idle_add(progress.set_progress, frac,
                                              f"{downloaded}/{total_pkgs}")
                            else:
                                GLib.idle_add(progress.pulse)
                            GLib.idle_add(progress.set_detail, s[:100])
                        elif s.startswith("Unpacking") or s.startswith("Setting up") \
                                or s.startswith("Processing") or s.startswith("Removing"):
                            if phase != "install":
                                phase = "install"
                                msg = _("Removing packages...") if not is_install else _("Installing packages...")
                                GLib.idle_add(progress.set_status, msg)
                            if s.startswith("Unpacking") or s.startswith("Removing"):
                                installed += 1
                            if total_pkgs:
                                frac = 0.5 + min(installed / total_pkgs, 1.0) * 0.5
                                GLib.idle_add(progress.set_progress, frac,
                                              f"{installed}/{total_pkgs}")
                            else:
                                GLib.idle_add(progress.pulse)
                            GLib.idle_add(progress.set_detail, s[:100])
                        else:
                            # apt's metadata-fetch / dependency-resolve lines:
                            # keep the bar moving so the dialog doesn't look frozen.
                            if not total_pkgs:
                                GLib.idle_add(progress.pulse)

                    process.wait()

                GLib.idle_add(progress.close)

                if process.returncode != 0:
                    # Get error message from log
                    try:
                        with open(TMP_LOG, "r") as f:
                            for line in f:
                                if line.startswith("E:"):
                                    err_msg = line[2:].strip()
                                    break
                            else:
                                err_msg = _("Unknown error occurred")
                    except:
                        err_msg = _("Unknown error occurred")

                    log_message(f"ERROR: {err_msg}")
                    GLib.idle_add(self._show_error,
                                 _("Installation Failed") if is_install else _("Removal Failed"),
                                 _("{app} Error:\n\n{err}\n\nMake sure your computer is connected "
                                   "to the Internet and try again.").format(app=APP_NAME, err=err_msg))
                else:
                    log_message(f"INFO: {'Installation' if is_install else 'Removal'} was a success.")
                    GLib.idle_add(self._show_info,
                                 _("Installation Complete") if is_install else _("Removal Complete"),
                                 _("Installation successfully completed.") if is_install
                                 else _("Removal successfully completed."))
                    GLib.idle_add(self._go_back_to_main)

            except Exception as e:
                GLib.idle_add(progress.close)
                GLib.idle_add(self._show_error, _("Error"), str(e))

        thread = threading.Thread(target=action_thread, daemon=True)
        thread.start()

    def _go_back_to_main(self):
        """Navigate back to main menu."""
        self.navigation_view.pop()

    # ------------------------------------------------------------------
    # Install All Software — full Ubuntu-repository browser (Synaptic
    # replacement). Speed comes from python-apt, which reads libapt-pkg's
    # memory-mapped binary cache (the same trick Synaptic uses), plus a
    # virtualized Gtk.ColumnView that only realizes the visible rows.
    # ------------------------------------------------------------------

    @staticmethod
    def _is_library_pkg(name: str, section: str) -> bool:
        """Heuristic: is this package developer/runtime plumbing rather than
        an end-user application? Used by the optional 'Hide libraries' filter."""
        # A few real apps start with "lib" — don't hide those.
        if name.startswith("lib") and not name.startswith(("libreoffice", "librecad")):
            return True
        if name.endswith(("-dev", "-dbg", "-dbgsym")):
            return True
        sec = (section or "").lower()
        if any(k in sec for k in ("libdevel", "debug", "oldlibs", "introspection")):
            return True
        return sec.endswith("libs")

    def _on_browse_all_clicked(self, button):
        """Push the all-software page and load the apt cache off-thread."""
        page = Adw.NavigationPage()
        page.set_title(_("Install All Software"))

        toolbar_view = Adw.ToolbarView()
        header = Adw.HeaderBar()

        self.all_apply_btn = Gtk.Button(label=_("Apply Changes"))
        self.all_apply_btn.add_css_class("suggested-action")
        self.all_apply_btn.set_sensitive(False)
        self.all_apply_btn.connect("clicked", lambda b: self._on_apply_all_clicked())
        header.pack_end(self.all_apply_btn)
        toolbar_view.add_top_bar(header)

        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.all_outer = outer
        self.all_banner = None

        # Controls row: search + hide-libraries toggle.
        controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        controls.set_margin_top(12)
        controls.set_margin_start(12)
        controls.set_margin_end(12)

        self.all_search_entry = Gtk.SearchEntry()
        self.all_search_entry.set_placeholder_text(_("Search all packages..."))
        self.all_search_entry.set_hexpand(True)
        self.all_search_entry.connect("search-changed", self._on_all_search_changed)
        controls.append(self.all_search_entry)

        self.all_hide_libs_check = Gtk.CheckButton(label=_("Hide libraries / dev / debug"))
        self.all_hide_libs_check.set_tooltip_text(
            _("Hide library, header (-dev) and debug (-dbg) packages so only "
              "end-user applications remain."))
        self.all_hide_libs_check.connect("toggled", self._on_hide_libs_toggled)
        controls.append(self.all_hide_libs_check)

        self.all_hide_snap_check = Gtk.CheckButton(label=_("Hide Snap packages"))
        self.all_hide_snap_check.set_tooltip_text(
            _("Hide Snap packages. Linux Lite does not include Snap; installing "
              "one would also pull in snapd."))
        self.all_hide_snap_check.connect("toggled", self._on_hide_libs_toggled)
        controls.append(self.all_hide_snap_check)
        outer.append(controls)

        # Count / selection summary line.
        self.all_count_label = Gtk.Label(label=_("Loading package list…"))
        self.all_count_label.add_css_class("dim-label")
        self.all_count_label.set_halign(Gtk.Align.START)
        self.all_count_label.set_margin_start(14)
        self.all_count_label.set_margin_top(4)
        self.all_count_label.set_margin_bottom(4)
        outer.append(self.all_count_label)

        # Content area: spinner now, ColumnView once the cache is loaded.
        self.all_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.all_content.set_vexpand(True)
        spinner = Gtk.Spinner()
        spinner.set_size_request(48, 48)
        spinner.set_halign(Gtk.Align.CENTER)
        spinner.set_valign(Gtk.Align.CENTER)
        spinner.set_vexpand(True)
        spinner.start()
        self.all_content.append(spinner)
        outer.append(self.all_content)

        toolbar_view.set_content(outer)
        page.set_child(toolbar_view)
        self.navigation_view.push(page)

        threading.Thread(target=self._load_all_packages_thread, daemon=True).start()

    def _load_all_packages_thread(self):
        """Worker: read the apt binary cache and build the PkgItem list."""
        try:
            import apt
            cache = apt.Cache()
            items = []
            for pkg in cache:
                if pkg.name in HIDDEN_FROM_BROWSER:
                    continue  # internal companion package — never list directly
                cand = pkg.candidate
                if cand is None:
                    continue  # no installable version in any enabled source
                installed = pkg.is_installed
                if installed and pkg.installed is not None:
                    version = pkg.installed.version
                else:
                    version = cand.version
                _vl = (version or "").lower()
                items.append(PkgItem(
                    pkg.name,
                    cand.summary or "",
                    version or "",
                    installed,
                    self._is_library_pkg(pkg.name, cand.section),
                    # Ubuntu deb->snap transitional stubs carry "snap" in their
                    # version (e.g. chromium-browser 2:1snap1-0ubuntu2). LL ships
                    # no snapd, so flag them. Guard against "snapshot" versions.
                    "snap" in _vl and "snapshot" not in _vl,
                ))
            # Detect a pre-existing broken system state so we can warn before
            # the user tries to install (an already-broken cache makes the
            # dependency preview misleading). broken_count = unmet deps;
            # dpkg --audit = half-installed/half-configured packages.
            broken = 0
            try:
                broken = cache.broken_count
            except Exception:
                pass
            if not broken:
                try:
                    a = subprocess.run(["dpkg", "--audit"], capture_output=True,
                                       text=True, timeout=20)
                    if a.returncode == 0 and a.stdout.strip():
                        broken = 1
                except Exception:
                    pass
        except Exception as e:
            log_message(f"ERROR: Could not load apt package list: {e}")
            GLib.idle_add(self._all_packages_load_failed, str(e))
            return
        items.sort(key=lambda it: it.name)
        GLib.idle_add(self._populate_all_packages, items, broken)

    def _all_packages_load_failed(self, message: str):
        self.all_count_label.set_text(_("Failed to load package list."))
        child = self.all_content.get_first_child()
        if child:
            self.all_content.remove(child)
        label = Gtk.Label()
        label.set_markup(
            _("<span size='large'>Could not read the package list.</span>") + "\n\n"
            + GLib.markup_escape_text(message))
        label.set_justify(Gtk.Justification.CENTER)
        label.set_valign(Gtk.Align.CENTER)
        label.set_vexpand(True)
        label.add_css_class("dim-label")
        self.all_content.append(label)
        return False

    def _populate_all_packages(self, items: list, broken: int = 0):
        """Main thread: wire the model, filters and ColumnView once loaded."""
        self.all_pkg_items = items
        self._set_broken_banner(bool(broken))

        self.all_store = Gio.ListStore(item_type=PkgItem)
        self.all_store.splice(0, 0, items)

        # --- Filters -------------------------------------------------------
        # Library hide toggle: a CustomFilter re-evaluated only when toggled.
        self.all_lib_filter = Gtk.CustomFilter.new(self._lib_filter_func)

        # Search: name OR summary contains the query. Two C-side StringFilters
        # in an AnyFilter — fast enough to refilter 105k rows per keystroke.
        self.all_search_name = Gtk.StringFilter.new(
            Gtk.PropertyExpression.new(PkgItem, None, "name"))
        self.all_search_name.set_ignore_case(True)
        self.all_search_name.set_match_mode(Gtk.StringFilterMatchMode.SUBSTRING)
        self.all_search_summary = Gtk.StringFilter.new(
            Gtk.PropertyExpression.new(PkgItem, None, "summary"))
        self.all_search_summary.set_ignore_case(True)
        self.all_search_summary.set_match_mode(Gtk.StringFilterMatchMode.SUBSTRING)
        search_any = Gtk.AnyFilter()
        search_any.append(self.all_search_name)
        search_any.append(self.all_search_summary)

        every = Gtk.EveryFilter()
        every.append(self.all_lib_filter)
        every.append(search_any)

        self.all_filter_model = Gtk.FilterListModel(model=self.all_store, filter=every)

        # --- ColumnView (virtualized: only visible rows are realized) ------
        column_view = Gtk.ColumnView()
        column_view.add_css_class("data-table")
        sort_model = Gtk.SortListModel(model=self.all_filter_model,
                                       sorter=column_view.get_sorter())
        column_view.set_model(Gtk.NoSelection(model=sort_model))
        self.all_column_view = column_view

        # Mark (checkbox) column.
        mark_factory = Gtk.SignalListItemFactory()
        mark_factory.connect("setup", self._mark_setup)
        mark_factory.connect("bind", self._mark_bind)
        mark_col = Gtk.ColumnViewColumn(title="", factory=mark_factory)
        mark_col.set_fixed_width(44)
        column_view.append_column(mark_col)

        # Package name column (installed packages shown in the accent colour).
        name_factory = Gtk.SignalListItemFactory()
        name_factory.connect("setup", self._label_setup)
        name_factory.connect("bind", self._name_bind)
        name_col = Gtk.ColumnViewColumn(title=_("Package"), factory=name_factory)
        name_col.set_fixed_width(240)
        name_col.set_resizable(True)
        name_col.set_sorter(Gtk.StringSorter.new(
            Gtk.PropertyExpression.new(PkgItem, None, "name")))
        column_view.append_column(name_col)

        # Snap indicator column — flags Ubuntu deb->snap transitional stubs.
        snap_factory = Gtk.SignalListItemFactory()
        snap_factory.connect("setup", self._snap_setup)
        snap_factory.connect("bind", self._snap_bind)
        snap_col = Gtk.ColumnViewColumn(title=_("Type"), factory=snap_factory)
        snap_col.set_fixed_width(70)
        column_view.append_column(snap_col)

        # Installed indicator column.
        inst_factory = Gtk.SignalListItemFactory()
        inst_factory.connect("setup", self._installed_setup)
        inst_factory.connect("bind", self._installed_bind)
        inst_col = Gtk.ColumnViewColumn(title=_("Installed"), factory=inst_factory)
        inst_col.set_fixed_width(80)
        column_view.append_column(inst_col)

        # Version column.
        ver_factory = Gtk.SignalListItemFactory()
        ver_factory.connect("setup", self._label_setup)
        ver_factory.connect("bind", self._version_bind)
        ver_col = Gtk.ColumnViewColumn(title=_("Version"), factory=ver_factory)
        ver_col.set_fixed_width(150)
        ver_col.set_resizable(True)
        column_view.append_column(ver_col)

        # Description column (takes the remaining width).
        desc_factory = Gtk.SignalListItemFactory()
        desc_factory.connect("setup", self._label_setup)
        desc_factory.connect("bind", self._desc_bind)
        desc_col = Gtk.ColumnViewColumn(title=_("Description"), factory=desc_factory)
        desc_col.set_expand(True)
        column_view.append_column(desc_col)

        scrolled = Gtk.ScrolledWindow()
        scrolled.set_vexpand(True)
        scrolled.set_child(column_view)

        child = self.all_content.get_first_child()
        if child:
            self.all_content.remove(child)
        self.all_content.append(scrolled)

        self._update_all_count()
        return False

    # --- Broken-package detection / repair --------------------------------

    def _set_broken_banner(self, broken: bool):
        """Show/hide a 'Fix Now' banner at the top of the page when the system
        has broken or half-configured packages."""
        if broken and self.all_banner is None:
            self.all_banner = Adw.Banner()
            self.all_banner.set_title(
                _("Broken packages detected — repair them before installing "
                  "or removing software."))
            self.all_banner.set_button_label(_("Fix Now"))
            self.all_banner.connect("button-clicked", self._on_fix_broken_clicked)
            self.all_outer.prepend(self.all_banner)
        if self.all_banner is not None:
            self.all_banner.set_revealed(broken)

    def _repair_pass(self, progress):
        """The non-destructive repair steps: finish any interrupted dpkg
        transaction, then let apt install whatever missing dependencies it
        CAN. Returns the last error text seen (for reporting)."""
        GLib.idle_add(progress.set_status, _("Configuring pending packages…"))
        _rc, err1 = self._run_apt_phase(["dpkg", "--configure", "-a"], progress, True)
        GLib.idle_add(progress.set_status, _("Fixing broken dependencies…"))
        _rc, err2 = self._run_apt_phase(
            ["apt-get", "install", "-f", "-y"], progress, True)
        return err2 or err1 or ""

    def _assess_breakage(self):
        """Inspect dpkg/apt state AFTER a repair pass. Returns
        (still_broken, removable, protected): `removable` are broken packages
        safe to remove to recover (their deps can't be satisfied), `protected`
        are broken but Essential/Priority:required so we must NOT auto-remove
        them (removing could brick the system)."""
        removable, protected = [], []
        # dpkg half-states: want=install ('i'…) but current state is
        # half-installed (H), unpacked-not-configured (U) or half-configured (F).
        try:
            out = subprocess.run(
                ["dpkg-query", "-W",
                 "-f=${db:Status-Abbrev}|${Essential}|${Priority}|${Package}\n"],
                capture_output=True, text=True, timeout=40).stdout
        except Exception:
            out = ""
        for line in out.splitlines():
            try:
                ab, ess, pri, name = line.split("|", 3)
            except ValueError:
                continue
            if len(ab) >= 2 and ab[0] == "i" and ab[1] in ("U", "F", "H"):
                if ess.strip() == "yes" or pri.strip() == "required":
                    protected.append(name)
                else:
                    removable.append(name)
        # apt-level unmet deps on otherwise fully-installed packages.
        try:
            import apt
            c = apt.Cache()
            for pkg in c:
                try:
                    if not (pkg.is_installed and pkg.is_now_broken):
                        continue
                    if pkg.name in removable or pkg.name in protected:
                        continue
                    inst = pkg.installed
                    if getattr(pkg, "essential", False) or (inst and inst.priority == "required"):
                        protected.append(pkg.name)
                    else:
                        removable.append(pkg.name)
                except Exception:
                    pass
        except Exception:
            pass
        removable = sorted(set(removable))
        protected = sorted(set(protected))
        still = bool(removable or protected)
        if not still:
            try:
                a = subprocess.run(["dpkg", "--audit"], capture_output=True,
                                   text=True, timeout=30)
                still = bool(a.stdout.strip())
            except Exception:
                pass
        return still, removable, protected

    def _on_fix_broken_clicked(self, banner):
        """Repair the system. Escalating strategy so it recovers from ANY
        breakage, not just the fixable-by-apt kind:
          1. dpkg --configure -a  (finish interrupted transactions)
          2. apt-get install -f   (install whatever missing deps it can)
          3. if STILL broken — the offending packages have dependencies that
             aren't installable (e.g. an accidental wine-menu-linuxlite that
             needs wine-stable). The only real recovery is to remove them, so
             we identify them, ask the user, then remove + re-verify.
        Essential / Priority:required packages are never auto-removed."""
        progress = ProgressDialog(self, _("Repairing Packages"),
                                  _("Fixing broken packages…"))
        progress.present()

        def worker():
            try:
                err = self._repair_pass(progress)
                still, removable, protected = self._assess_breakage()
                GLib.idle_add(progress.close)
                if not still:
                    log_message("INFO: Broken-package repair succeeded.")
                    GLib.idle_add(self._show_info, _("Repair Complete"),
                                  _("Broken packages have been repaired."))
                    GLib.idle_add(self._reload_all_packages)
                elif removable:
                    GLib.idle_add(self._prompt_remove_broken, removable, protected)
                else:
                    # Broken, but only Essential/required packages — too
                    # dangerous to auto-remove. Report with the real reason.
                    if protected:
                        msg = _("These broken packages are system-essential and "
                                "were left untouched. Fix them manually in a "
                                "terminal:\n{pkgs}").format(
                                    pkgs="\n".join("• " + p for p in protected))
                    else:
                        msg = err or _("Unknown error occurred")
                    log_message(f"ERROR: Broken-package repair could not complete: "
                                f"protected={protected} detail={err}")
                    GLib.idle_add(self._show_error, _("Repair Failed"), msg)
            except Exception as e:
                GLib.idle_add(progress.close)
                GLib.idle_add(self._show_error, _("Error"), str(e))

        threading.Thread(target=worker, daemon=True).start()

    def _prompt_remove_broken(self, removable, protected):
        """Ask before removing the un-repairable packages — removal is
        destructive, so the user sees exactly what goes."""
        body = _("These packages are broken and can't be repaired because "
                 "their dependencies aren't available. They were most likely "
                 "installed by mistake. Remove them to fix your system?")
        body += "\n\n" + "\n".join("• " + p for p in removable)
        if protected:
            body += "\n\n" + _("(System-essential broken packages were left "
                               "untouched: {pkgs})").format(pkgs=", ".join(protected))
        dialog = Adw.AlertDialog()
        dialog.set_heading(_("Remove broken packages?"))
        dialog.set_body(body)
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("remove", _("Remove & Repair"))
        dialog.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE)
        dialog.set_default_response("cancel")
        dialog.set_close_response("cancel")
        dialog.connect("response",
                       lambda d, r: self._do_remove_broken(removable)
                       if r == "remove" else None)
        dialog.present(self)

    def _do_remove_broken(self, removable):
        """Remove the un-repairable packages, then tidy up and re-verify."""
        progress = ProgressDialog(self, _("Repairing Packages"),
                                  _("Removing broken packages…"))
        progress.present()

        def worker():
            try:
                log_message(f"INFO: Broken-package repair removing: {' '.join(removable)}")
                GLib.idle_add(progress.set_status, _("Removing broken packages…"))
                rc, err = self._run_apt_phase(
                    ["apt-get", "remove", "-y"] + removable, progress, False)
                # dpkg --remove fallback for anything apt wouldn't take.
                if rc != 0:
                    self._run_apt_phase(["dpkg", "--remove"] + removable, progress, False)
                err = self._repair_pass(progress) or err
                still, removable2, protected2 = self._assess_breakage()
                GLib.idle_add(progress.close)
                if not still:
                    log_message("INFO: Broken-package repair succeeded (after removal).")
                    GLib.idle_add(self._show_info, _("Repair Complete"),
                                  _("Broken packages have been repaired."))
                    GLib.idle_add(self._reload_all_packages)
                else:
                    leftover = removable2 + protected2
                    msg = err or _("Some packages are still broken after "
                                   "removal:\n{pkgs}").format(
                                       pkgs="\n".join("• " + p for p in leftover))
                    log_message(f"ERROR: Broken-package repair incomplete after "
                                f"removal: {leftover}")
                    GLib.idle_add(self._show_error, _("Repair Failed"), msg)
            except Exception as e:
                GLib.idle_add(progress.close)
                GLib.idle_add(self._show_error, _("Error"), str(e))

        threading.Thread(target=worker, daemon=True).start()

    def _reload_all_packages(self):
        """Reset the content area to a spinner and reload the apt cache."""
        child = self.all_content.get_first_child()
        if child:
            self.all_content.remove(child)
        spinner = Gtk.Spinner()
        spinner.set_size_request(48, 48)
        spinner.set_halign(Gtk.Align.CENTER)
        spinner.set_valign(Gtk.Align.CENTER)
        spinner.set_vexpand(True)
        spinner.start()
        self.all_content.append(spinner)
        self.all_count_label.set_text(_("Loading package list…"))
        threading.Thread(target=self._load_all_packages_thread, daemon=True).start()
        return False

    # --- ColumnView factory callbacks -------------------------------------

    def _mark_setup(self, factory, list_item):
        check = Gtk.CheckButton()
        check.set_halign(Gtk.Align.CENTER)
        check._pkg_handler = check.connect("toggled", self._on_pkg_check_toggled)
        check._pkg_item = None
        list_item.set_child(check)

    def _mark_bind(self, factory, list_item):
        item = list_item.get_item()
        check = list_item.get_child()
        check._pkg_item = item
        # Block the handler so set_active during recycling doesn't fire it.
        check.handler_block(check._pkg_handler)
        check.set_active(item.marked)
        check.handler_unblock(check._pkg_handler)

    def _on_pkg_check_toggled(self, check):
        item = getattr(check, "_pkg_item", None)
        if item is not None:
            item.marked = check.get_active()
            self._update_all_count()

    def _label_setup(self, factory, list_item):
        label = Gtk.Label(xalign=0)
        label.set_ellipsize(Pango.EllipsizeMode.END)
        list_item.set_child(label)

    def _name_bind(self, factory, list_item):
        item = list_item.get_item()
        label = list_item.get_child()
        label.set_text(item.name)
        if item.installed:
            label.add_css_class("success")
        else:
            label.remove_css_class("success")

    def _version_bind(self, factory, list_item):
        list_item.get_child().set_text(list_item.get_item().version)

    def _desc_bind(self, factory, list_item):
        list_item.get_child().set_text(list_item.get_item().summary)

    def _installed_setup(self, factory, list_item):
        img = Gtk.Image.new_from_icon_name("object-select-symbolic")
        img.set_halign(Gtk.Align.CENTER)
        img.add_css_class("success")
        list_item.set_child(img)

    def _installed_bind(self, factory, list_item):
        list_item.get_child().set_visible(list_item.get_item().installed)

    def _snap_setup(self, factory, list_item):
        # "Snap" is the format's proper name — kept untranslated, like "deb".
        lbl = Gtk.Label(label="Snap")
        lbl.set_halign(Gtk.Align.CENTER)
        lbl.add_css_class("error")
        list_item.set_child(lbl)

    def _snap_bind(self, factory, list_item):
        item = list_item.get_item()
        lbl = list_item.get_child()
        lbl.set_visible(item.is_snap)
        if item.is_snap:
            lbl.set_tooltip_text(
                _("This is a Snap package (its version contains “snap”). "
                  "Linux Lite does not include Snap — installing it would "
                  "also install snapd."))

    # --- Filter callbacks / search ----------------------------------------

    def _lib_filter_func(self, item):
        if self.all_hide_libs_check.get_active() and item.is_lib:
            return False
        if self.all_hide_snap_check.get_active() and item.is_snap:
            return False
        return True

    def _on_hide_libs_toggled(self, check):
        self.all_lib_filter.changed(Gtk.FilterChange.DIFFERENT)
        GLib.idle_add(self._update_all_count)

    def _on_all_search_changed(self, entry):
        text = entry.get_text()
        # Empty search => StringFilter matches everything, so the full list
        # is shown again.
        self.all_search_name.set_search(text)
        self.all_search_summary.set_search(text)
        GLib.idle_add(self._update_all_count)

    def _update_all_count(self):
        try:
            shown = self.all_filter_model.get_n_items()
            total = self.all_store.get_n_items()
            marked = sum(1 for it in self.all_pkg_items if it.marked)
        except Exception:
            return False
        parts = [_("{shown:,} of {total:,} packages").format(shown=shown, total=total)]
        if marked:
            parts.append(_("{n} selected").format(n=marked))
        self.all_count_label.set_text("   •   ".join(parts))
        self.all_apply_btn.set_sensitive(marked > 0)
        return False

    # --- Apply (install + remove) -----------------------------------------

    def _on_apply_all_clicked(self):
        to_install = [it.name for it in self.all_pkg_items
                      if it.marked and not it.installed]
        to_remove = [it.name for it in self.all_pkg_items
                     if it.marked and it.installed]

        if not to_install and not to_remove:
            self._show_info(_("No Selection"), _("No packages have been selected."))
            return

        # Resolve the full plan (pulled-in dependencies + download/disk size)
        # off the UI thread, then show the confirmation with that preview —
        # Synaptic-style, so the user isn't surprised by what apt-get pulls in.
        self.all_apply_btn.set_sensitive(False)
        self.all_count_label.set_text(_("Calculating changes…"))

        def worker():
            plan = self._resolve_changes(to_install, to_remove)
            GLib.idle_add(self._show_confirm_changes, to_install, to_remove, plan)

        threading.Thread(target=worker, daemon=True).start()

    def _resolve_changes(self, to_install, to_remove):
        """Compute the complete change set with python-apt — every dependency
        apt-get would pull in, plus download and disk-space totals — WITHOUT
        committing anything (we never call cache.commit()). The actual apply
        still goes through apt-get, which re-resolves identically."""
        plan = {"ok": False, "error": "", "broken": False}
        try:
            import apt
            cache = apt.Cache()
            # If the system is ALREADY broken, the resolver below would produce
            # a misleading plan — flag it so the user is told to repair first
            # rather than being blamed for a conflicting selection.
            try:
                if cache.broken_count:
                    plan["broken"] = True
                    return plan
            except Exception:
                pass
            explicit = set(to_install) | set(to_remove)
            for name in to_install:
                if name in cache:
                    cache[name].mark_install()
            for name in to_remove:
                if name in cache:
                    cache[name].mark_delete()

            installs, removals = [], []
            for p in cache.get_changes():
                if p.marked_delete:
                    removals.append(p.name)
                elif p.marked_install or p.marked_upgrade or p.marked_reinstall:
                    installs.append(p.name)

            plan.update({
                "ok": True,
                "installs": sorted(installs),
                "removals": sorted(removals),
                "extra": sorted(n for n in installs if n not in explicit),
                "download": cache.required_download,
                "space": cache.required_space,
            })
        except Exception as e:
            plan["error"] = str(e)
        return plan

    @staticmethod
    def _fmt_size(n):
        n = float(abs(n))
        for unit in ("B", "KB", "MB", "GB", "TB"):
            if n < 1024 or unit == "TB":
                return f"{int(n)} {unit}" if unit == "B" else f"{n:.1f} {unit}"
            n /= 1024

    def _show_confirm_changes(self, to_install, to_remove, plan):
        self.all_apply_btn.set_sensitive(True)
        self._update_all_count()

        if not plan["ok"]:
            if plan.get("broken"):
                self._set_broken_banner(True)
                self._show_error(
                    _("Broken Packages Detected"),
                    _("Your system has broken packages that must be repaired "
                      "before installing or removing software.\n\nClose this "
                      "dialog and use the “Fix Now” button at the top of the "
                      "list."))
            else:
                self._show_error(
                    _("Could Not Calculate Changes"),
                    _("Unable to work out the full set of changes:\n\n{error}\n\n"
                      "You may have selected packages with conflicting "
                      "dependencies.").format(error=plan["error"] or _("Unknown error")))
            return False

        installs = plan["installs"]
        removals = plan["removals"]
        extra = plan["extra"]

        if not installs and not removals:
            self._show_info(
                _("No Changes"),
                _("The selected packages are already in the requested state."))
            return False

        def _bullets(names, cap=15):
            text = "\n".join(f"• {n}" for n in names[:cap])
            if len(names) > cap:
                text += "\n" + _("• …and {n} more").format(n=len(names) - cap)
            return text

        body_parts = []
        if installs:
            seg = _("Install {n} package(s):").format(n=len(installs)) + "\n" + _bullets(to_install)
            if extra:
                ex = ", ".join(extra[:12])
                if len(extra) > 12:
                    ex += _(", …(+{n} more)").format(n=len(extra) - 12)
                seg += ("\n\n"
                        + _("This will also install {n} dependencies:").format(n=len(extra))
                        + "\n" + ex)
            body_parts.append(seg)
        if removals:
            body_parts.append(
                _("Remove {n} package(s):").format(n=len(removals)) + "\n" + _bullets(removals))

        size_lines = []
        if plan["download"]:
            size_lines.append(
                _("Download size: {size}").format(size=self._fmt_size(plan["download"])))
        space = plan["space"]
        if space > 0:
            size_lines.append(
                _("Additional disk space needed: {size}").format(size=self._fmt_size(space)))
        elif space < 0:
            size_lines.append(
                _("Disk space freed: {size}").format(size=self._fmt_size(space)))

        body = "\n\n".join(body_parts)
        if size_lines:
            body += "\n\n" + "\n".join(size_lines)
        body += "\n\n" + _("Do you want to proceed?")

        dialog = Adw.AlertDialog()
        dialog.set_heading(_("Confirm Changes"))
        dialog.set_body(body)
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("confirm", _("Apply"))
        dialog.set_response_appearance("confirm", Adw.ResponseAppearance.SUGGESTED)
        dialog.connect(
            "response",
            lambda d, r: self._execute_all_changes(to_install, to_remove)
            if r == "confirm" else None)
        dialog.present(self)
        return False

    def _run_apt_phase(self, cmd, progress, is_install: bool):
        """Stream one apt-get install/remove run. Returns (returncode, err)."""
        summary_re = re.compile(
            r'(\d+)\s+upgraded.*?(\d+)\s+newly installed.*?(\d+)\s+to remove',
            re.IGNORECASE)
        total_pkgs = 0
        downloaded = 0
        processed = 0
        err_lines = []

        with open(TMP_LOG, "w") as log_file:
            process = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                env={**os.environ, "DEBIAN_FRONTEND": "noninteractive",
                     "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"})

            for line in process.stdout:
                log_file.write(line)
                log_file.flush()
                s = line.strip()
                if not s:
                    continue
                # Capture diagnostic lines for the error message. apt's own
                # errors start with "E:", but a failed `dpkg --configure -a`
                # (e.g. an unmet dependency like wine-menu-linuxlite needing
                # wine-stable) emits "dpkg:" lines and indented "depends on …
                # however: … is not installed" lines — none of which start
                # with "E:". Capturing only "E:" left those failures showing
                # the useless generic "Unknown error occurred". Collect the
                # informative lines so the real reason surfaces in the GUI.
                _low = s.lower()
                if s.startswith("E:"):
                    err_lines.append(s[2:].strip())
                elif s.startswith(("dpkg:", "dpkg-deb:", "dpkg-query:", "W:")):
                    err_lines.append(s)
                elif any(k in _low for k in (
                        "depends on", "depends:", "is not installed",
                        "is not configured", "not going to be installed",
                        "unmet dependencies", "errors were encountered",
                        "but it is not", "however")):
                    err_lines.append(s)
                if not total_pkgs:
                    m = summary_re.search(s)
                    if m:
                        total_pkgs = int(m.group(1)) + int(m.group(2)) + int(m.group(3))

                if s.startswith("Get:"):
                    downloaded += 1
                    if total_pkgs:
                        frac = min(downloaded / total_pkgs, 1.0) * 0.5
                        GLib.idle_add(progress.set_progress, frac, f"{downloaded}/{total_pkgs}")
                    else:
                        GLib.idle_add(progress.pulse)
                    GLib.idle_add(progress.set_detail, s[:100])
                elif s.startswith(("Unpacking", "Setting up", "Removing", "Processing")):
                    if s.startswith(("Unpacking", "Removing")):
                        processed += 1
                    if total_pkgs:
                        frac = 0.5 + min(processed / total_pkgs, 1.0) * 0.5
                        GLib.idle_add(progress.set_progress, frac, f"{processed}/{total_pkgs}")
                    else:
                        GLib.idle_add(progress.pulse)
                    GLib.idle_add(progress.set_detail, s[:100])
                else:
                    if not total_pkgs:
                        GLib.idle_add(progress.pulse)

            process.wait()

        # De-duplicate while preserving order, then show the most informative
        # tail (dependency-problem blocks span several lines, so keep more
        # than the old 3). Falls back to the generic message only when apt/
        # dpkg genuinely emitted nothing we could identify as an error.
        _seen = set()
        _uniq = [x for x in err_lines if not (x in _seen or _seen.add(x))]
        err_msg = "\n".join(_uniq[-8:]) if _uniq else _("Unknown error occurred")
        return process.returncode, err_msg

    def _execute_all_changes(self, to_install, to_remove):
        """Apply the marked install/remove changes via apt-get."""
        log_message(f"INFO: Install All Software apply — "
                    f"install={' '.join(to_install)} remove={' '.join(to_remove)}")

        progress = ProgressDialog(self, _("Applying Changes"), _("Preparing..."))
        progress.present()

        def worker():
            try:
                ok = True
                err_msg = ""

                if to_remove:
                    GLib.idle_add(progress.set_status, _("Removing packages..."))
                    GLib.idle_add(progress.set_progress, 0.0, "")
                    rc, err = self._run_apt_phase(
                        ["apt-get", "remove", "-f", "-y"] + to_remove, progress, False)
                    if rc != 0:
                        ok = False
                        err_msg = err

                if ok and to_install:
                    GLib.idle_add(progress.set_status, _("Installing packages..."))
                    GLib.idle_add(progress.set_progress, 0.0, "")
                    rc, err = self._run_apt_phase(
                        ["apt-get", "install", "-f", "-y"] + to_install, progress, True)
                    if rc != 0:
                        ok = False
                        err_msg = err

                GLib.idle_add(progress.close)

                if ok:
                    log_message("INFO: Install All Software changes applied successfully.")
                    GLib.idle_add(self._show_info, _("Changes Complete"),
                                  _("The selected changes were applied successfully."))
                    GLib.idle_add(self._go_back_to_main)
                else:
                    log_message(f"ERROR: Install All Software apply failed: {err_msg}")
                    GLib.idle_add(self._show_error, _("Changes Failed"),
                                  _("{app} Error:\n\n{err}\n\n"
                                    "Make sure your computer is connected to the "
                                    "Internet and try again.").format(app=APP_NAME, err=err_msg))
            except Exception as e:
                GLib.idle_add(progress.close)
                GLib.idle_add(self._show_error, _("Error"), str(e))

        threading.Thread(target=worker, daemon=True).start()


class LiteSoftwareApp(Adw.Application):
    """Main application class."""

    def __init__(self):
        super().__init__(
            application_id=APP_ID,
            flags=Gio.ApplicationFlags.FLAGS_NONE
        )

    def do_activate(self):
        win = self.props.active_window
        if not win:
            win = LiteSoftwareWindow(self)


def main():
    # Check if running as root, if not, use pkexec to elevate
    if os.geteuid() != 0:
        try:
            # Allow root to access X display
            display = os.environ.get('DISPLAY', ':0')
            subprocess.run(['xhost', '+si:localuser:root'], capture_output=True)

            # Set up environment variables file for the elevated process.
            # Locale vars are included so the root process shows the user's
            # language (pkexec scrubs them); they're restored at module top.
            env_file = ENV_FILE
            with open(env_file, 'w') as f:
                for var in ['DISPLAY', 'XAUTHORITY', 'WAYLAND_DISPLAY', 'XDG_RUNTIME_DIR',
                            'LANG', 'LANGUAGE', 'LC_ALL', 'LC_MESSAGES', 'LC_NUMERIC']:
                    if var in os.environ:
                        f.write(f'{var}={os.environ[var]}\n')

            # Use pkexec to run the installed script directly (matches policy file)
            os.execvp("pkexec", ["pkexec", "/usr/bin/lite-software"] + sys.argv[1:])
        except Exception as e:
            print(f"Failed to elevate privileges: {e}")
            sys.exit(1)

    # Running as root - load environment from temp file if available
    env_file = ENV_FILE
    if os.path.exists(env_file):
        try:
            with open(env_file, 'r') as f:
                for line in f:
                    line = line.strip()
                    if '=' in line:
                        key, value = line.split('=', 1)
                        os.environ[key] = value
            os.unlink(env_file)
        except:
            pass

    app = LiteSoftwareApp()
    return app.run(sys.argv)


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