#!/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

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


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

        content_box.append(buttons_box)

        # Instructions
        instructions = Gtk.Label()
        instructions.set_markup(
            "<span size='small'>Select <b>Install Software</b> to add new applications\n"
            "or <b>Remove Software</b> to uninstall existing ones.</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 Software" if is_install else "Remove 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()


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