#!/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 threading
from datetime import datetime
from typing import Optional

# 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"

# 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):
        super().__init__()
        self._name = name
        self._summary = summary
        self._version = version
        self._installed = installed
        self._is_lib = is_lib
        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 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
        status_label = Gtk.Label(label=item.status)
        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",
                                 f"Some package sources could not be updated.\n\n{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",
                f"No application was selected for {'installation' if is_install else 'removal'}."
            )
            return

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

        dialog = Adw.AlertDialog()
        dialog.set_heading(f"Confirm {'Installation' if is_install else 'Removal'}")
        dialog.set_body(f"The following software has been selected:\n\n{names}\n\nDo you want to proceed with the {action}?")
        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,
            f"{'Installing' if is_install else 'Removing'} Software",
            f"{'Installing' 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",
                                         f"Failed to set up the WineHQ repository.\n\n"
                                         f"Command: {' '.join(cmd[-2:])}\n"
                                         f"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"
                                     f"apt-get update output (tail):\n{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-"
                                     f"{codename}.sources exists and that "
                                     "/etc/apt/keyrings/winehq-archive.key is valid.")
                        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, f"{'Installation' if is_install else 'Removal'} Failed",
                                 f"{APP_NAME} Error:\n\n{err_msg}\n\nMake sure your computer is connected to the Internet and try again.")
                else:
                    log_message(f"INFO: {'Installation' if is_install else 'Removal'} was a success.")
                    GLib.idle_add(self._show_info, f"{'Installation' if is_install else 'Removal'} Complete",
                                 f"{'Installation' 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)

        # 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)
        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:
                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
                items.append(PkgItem(
                    pkg.name,
                    cand.summary or "",
                    version or "",
                    installed,
                    self._is_library_pkg(pkg.name, cand.section),
                ))
        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)

    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):
        """Main thread: wire the model, filters and ColumnView once loaded."""
        self.all_pkg_items = items

        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)

        # 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

    # --- 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)

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

    def _lib_filter_func(self, item):
        if self.all_hide_libs_check.get_active() and item.is_lib:
            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 = [f"{shown:,} of {total:,} packages"]
        if marked:
            parts.append(f"{marked} selected")
        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": ""}
        try:
            import apt
            cache = apt.Cache()
            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"]:
            self._show_error(
                "Could Not Calculate Changes",
                "Unable to work out the full set of changes:\n\n"
                + (plan["error"] or "Unknown error")
                + "\n\nYou may have selected packages with conflicting "
                "dependencies.")
            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 += f"\n• …and {len(names) - cap} more"
            return text

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

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

        body = "\n\n".join(body_parts)
        if size_lines:
            body += "\n\n" + "\n".join(size_lines)
        body += "\n\nDo 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
                if s.startswith("E:"):
                    err_lines.append(s[2:].strip())
                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()

        err_msg = "\n".join(err_lines[-3:]) if err_lines 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", "-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",
                                  f"{APP_NAME} Error:\n\n{err_msg}\n\n"
                                  "Make sure your computer is connected to the "
                                  "Internet and try again.")
            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
            env_file = '/tmp/.lite-software-env'
            with open(env_file, 'w') as f:
                for var in ['DISPLAY', 'XAUTHORITY', 'WAYLAND_DISPLAY', 'XDG_RUNTIME_DIR']:
                    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 = '/tmp/.lite-software-env'
    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())
