#!/usr/bin/env python3
# Govibe Package Management and Uninstaller
# Version 1.0.6 Fedora/RHEL

import os
import sys
import subprocess
import shutil
import threading
import traceback
import gzip
import glob
import json
import re
import select
import atexit
from datetime import datetime
from dataclasses import dataclass
from typing import Dict, List, Optional

try:
    import gi
    gi.require_version("Gtk", "3.0")
    from gi.repository import Gtk, Gdk, GLib, Pango
except Exception as exc:
    print("Govibe Package Management and Uninstaller requires GTK 3 Python bindings.", file=sys.stderr)
    print("Install dependencies with: sudo dnf install python3-gobject gtk3", file=sys.stderr)
    print(str(exc), file=sys.stderr)
    sys.exit(1)

APP_ID = "org.govibe.PackageManagementUninstaller"
APP_NAME = "Govibe Package Management and Uninstaller"
APP_VERSION = "1.0.6"
SELF_PACKAGES = {"govibe-package-management-uninstaller"}
WINDOW_WIDTH = 1160
WINDOW_HEIGHT = 720
LEFT_PANE_WIDTH = 720

PROTECTED_NAMES = {
    "basesystem", "filesystem", "setup", "bash", "coreutils", "glibc", "glibc-common",
    "systemd", "systemd-libs", "systemd-pam", "systemd-udev", "dbus", "polkit",
    "rpm", "rpm-libs", "rpm-build-libs", "dnf", "dnf-data", "dnf5", "libdnf", "libdnf5",
    "python3", "python3-libs", "python3-gobject", "gtk3", "sudo", "shadow-utils",
    "util-linux", "grep", "sed", "gawk", "gzip", "xz", "tar", "findutils",
    "grub2-common", "grub2-tools", "shim", "kernel", "kernel-core", "kernel-modules",
    "selinux-policy", "selinux-policy-targeted", "NetworkManager"
}


ROOT_HELPER_ARG = "--govibe-root-helper"
PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+_.-]*$")


def validate_package_name(package_name: str) -> str:
    package_name = safe_text(package_name)
    if not package_name or not PACKAGE_NAME_RE.match(package_name):
        raise ValueError("Invalid package name")
    return package_name


def validate_rpm_path(path: str) -> str:
    path = os.path.abspath(safe_text(path))
    if not path.lower().endswith(".rpm"):
        raise ValueError("Only .rpm files can be installed")
    if not os.path.exists(path) or not os.path.isfile(path):
        raise ValueError("The selected .rpm file no longer exists")
    return path


def get_package_manager_command() -> str:
    for command in ("dnf5", "dnf", "yum"):
        if shutil.which(command):
            return command
    return "dnf"


def root_action_to_args(request: Dict[str, str]) -> List[str]:
    action = safe_text(request.get("action"))
    pkg_mgr = get_package_manager_command()
    if action == "update":
        return [pkg_mgr, "makecache"]
    if action == "upgrade":
        return [pkg_mgr, "upgrade", "-y"]
    if action == "autoremove":
        return [pkg_mgr, "autoremove", "-y"]
    if action == "clean":
        return [pkg_mgr, "clean", "all"]
    if action == "install_rpm":
        return [pkg_mgr, "install", "-y", validate_rpm_path(request.get("path", ""))]
    if action == "reinstall":
        return [pkg_mgr, "reinstall", "-y", validate_package_name(request.get("package", ""))]
    if action == "remove":
        return [pkg_mgr, "remove", "-y", validate_package_name(request.get("package", ""))]
    if action == "purge":
        return [pkg_mgr, "remove", "-y", validate_package_name(request.get("package", ""))]
    raise ValueError("Unsupported root action")


def root_helper_send(payload: Dict[str, object]) -> None:
    print(json.dumps(payload, ensure_ascii=False), flush=True)


def root_helper_main() -> int:
    """Small privileged helper kept alive only while the GUI is open.

    The GUI authenticates once with pkexec at startup. After that, it sends
    whitelisted package-manager actions to this root process over stdin.
    The user's password is never stored by Govibe.
    """
    if os.geteuid() != 0:
        print("This helper must run as root.", file=sys.stderr)
        return 1

    root_helper_send({"type": "ready", "version": APP_VERSION})
    for raw in sys.stdin:
        try:
            request = json.loads(raw)
            action = safe_text(request.get("action"))
            if action == "quit":
                root_helper_send({"type": "done", "code": 0})
                return 0

            args = root_action_to_args(request)
            root_helper_send({"type": "command", "line": " ".join(args)})
            process = subprocess.Popen(
                args,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
                universal_newlines=True,
            )
            if process.stdout:
                for line in process.stdout:
                    root_helper_send({"type": "line", "line": line})
            code = process.wait()
            root_helper_send({"type": "done", "code": code})
        except Exception as exc:
            root_helper_send({"type": "line", "line": f"ERROR: {exc}\n"})
            root_helper_send({"type": "done", "code": 1})
    return 0


class RootHelperClient:
    def __init__(self):
        self.process: Optional[subprocess.Popen] = None
        self.lock = threading.Lock()

    def is_running(self) -> bool:
        return self.process is not None and self.process.poll() is None

    def start(self) -> None:
        if self.is_running():
            return
        script_path = os.path.abspath(sys.argv[0])
        args = ["pkexec", sys.executable, script_path, ROOT_HELPER_ARG]
        self.process = subprocess.Popen(
            args,
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            bufsize=1,
            universal_newlines=True,
        )
        if not self.process.stdout:
            raise RuntimeError("Unable to open root helper output stream")

        ready_line = ""
        fd = self.process.stdout.fileno()
        ready, _, _ = select.select([fd], [], [], 120)
        if ready:
            ready_line = self.process.stdout.readline().strip()
        if not ready_line:
            process = self.process
            self.stop(force=True)
            error = ""
            try:
                if process and process.stderr:
                    error = process.stderr.read().strip()
            except Exception:
                error = ""
            raise RuntimeError(error or "Administrator authentication was cancelled or failed")

        try:
            message = json.loads(ready_line)
        except Exception:
            self.stop(force=True)
            raise RuntimeError(ready_line or "Invalid root helper response")
        if message.get("type") != "ready":
            self.stop(force=True)
            raise RuntimeError("Root helper did not start correctly")
        atexit.register(self.stop)

    def run_action(self, request: Dict[str, str], line_callback) -> int:
        if not self.is_running():
            raise RuntimeError("Root helper is not running")
        if not self.process or not self.process.stdin or not self.process.stdout:
            raise RuntimeError("Root helper streams are unavailable")

        with self.lock:
            self.process.stdin.write(json.dumps(request, ensure_ascii=False) + "\n")
            self.process.stdin.flush()
            final_code = 1
            while True:
                raw = self.process.stdout.readline()
                if raw == "":
                    raise RuntimeError("Root helper closed unexpectedly")
                try:
                    message = json.loads(raw)
                except Exception:
                    line_callback(raw)
                    continue
                msg_type = message.get("type")
                if msg_type == "command":
                    line_callback("Command: " + safe_text(message.get("line")) + "\n\n")
                elif msg_type == "line":
                    line_callback(str(message.get("line", "")))
                elif msg_type == "done":
                    try:
                        final_code = int(message.get("code", 1))
                    except Exception:
                        final_code = 1
                    return final_code

    def stop(self, force: bool = False) -> None:
        process = self.process
        self.process = None
        if not process:
            return
        try:
            if process.poll() is None and process.stdin and not force:
                process.stdin.write(json.dumps({"action": "quit"}) + "\n")
                process.stdin.flush()
                process.wait(timeout=2)
        except Exception:
            pass
        try:
            if process.poll() is None:
                process.terminate()
                process.wait(timeout=2)
        except Exception:
            try:
                process.kill()
            except Exception:
                pass

@dataclass
class PackageInfo:
    name: str
    version: str
    description: str
    installed_size: str
    essential: str
    priority: str
    section: str
    depends: str
    status: str
    installed_date: str


def safe_text(value: Optional[str]) -> str:
    return (value or "").strip()


def strip_arch_suffix(package_name: str) -> str:
    """Return the base package name from values like bash:amd64."""
    if ":" in package_name:
        return package_name.split(":", 1)[0]
    return package_name



def read_log_lines(path: str):
    """Yield lines from normal or gzipped logs."""
    try:
        if path.endswith(".gz"):
            with gzip.open(path, "rt", encoding="utf-8", errors="replace") as handle:
                for line in handle:
                    yield line.rstrip("\n")
        else:
            with open(path, "r", encoding="utf-8", errors="replace") as handle:
                for line in handle:
                    yield line.rstrip("\n")
    except Exception:
        return


def format_log_datetime(value: str) -> str:
    value = " ".join((value or "").split())
    try:
        return datetime.strptime(value, "%Y-%m-%d %H:%M:%S").strftime("%Y-%m-%d %H:%M")
    except Exception:
        return value[:16] if value else "Unknown"


def format_timestamp(value: str) -> str:
    try:
        timestamp = int(str(value).strip())
        if timestamp <= 0:
            return "Unknown"
        return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M")
    except Exception:
        return "Unknown"


def format_size_bytes(value: str) -> str:
    try:
        size = int(str(value).strip())
        if size >= 1024 ** 3:
            return f"{size / 1024 ** 3:.2f} GB"
        if size >= 1024 ** 2:
            return f"{size / 1024 ** 2:.1f} MB"
        if size >= 1024:
            return f"{size / 1024:.0f} KB"
        return f"{size} B"
    except Exception:
        return safe_text(value) or "Unknown"


def parse_rpm_packages() -> List[PackageInfo]:
    packages: List[PackageInfo] = []
    rpm_command = shutil.which("rpm") or "rpm"
    query_format = "%{NAME}\t%{VERSION}-%{RELEASE}\t%{INSTALLTIME}\t%{SIZE}\t%{SUMMARY}\t%{GROUP}\n"
    try:
        output = subprocess.check_output(
            [rpm_command, "-qa", "--qf", query_format],
            text=True,
            stderr=subprocess.STDOUT,
            errors="replace",
        )
    except Exception as exc:
        print(f"Unable to read installed RPM packages: {exc}", file=sys.stderr)
        return packages

    for line in output.splitlines():
        parts = line.split("\t")
        if len(parts) < 6:
            continue
        name, version, install_time, size, summary, group = parts[:6]
        name = safe_text(name)
        if not name:
            continue
        group = safe_text(group)
        if group in {"(none)", "unspecified"}:
            group = "rpm"
        packages.append(PackageInfo(
            name=name,
            version=safe_text(version),
            description=safe_text(summary),
            installed_size=format_size_bytes(size),
            essential="no",
            priority="system" if name in PROTECTED_NAMES else "normal",
            section=group or "rpm",
            depends="",
            status="installed",
            installed_date=format_timestamp(install_time),
        ))

    packages.sort(key=lambda item: item.name.lower())
    return packages

def is_protected_package(pkg: PackageInfo) -> bool:
    name = pkg.name
    if name in SELF_PACKAGES:
        return True
    if name in PROTECTED_NAMES:
        return True
    critical_prefixes = ("kernel", "glibc", "systemd", "dnf", "rpm", "selinux-policy", "grub2", "shim")
    if any(name == prefix or name.startswith(prefix + "-") for prefix in critical_prefixes):
        return True
    if pkg.priority in {"required", "system"} and name in PROTECTED_NAMES:
        return True
    return False


class GovibePackageManager(Gtk.Window):
    def __init__(self):
        super().__init__(title=APP_NAME)
        self.set_default_size(WINDOW_WIDTH, WINDOW_HEIGHT)
        self.set_position(Gtk.WindowPosition.CENTER)
        self.set_resizable(True)
        self.connect("destroy", self.on_destroy)

        try:
            self.set_icon_name("govibe-package-manager")
        except Exception:
            pass

        self.packages: List[PackageInfo] = []
        self.filtered: List[PackageInfo] = []
        self.busy = False
        self.root_ready = False
        self.root_helper = RootHelperClient()

        self._build_css()
        self._build_ui()
        self.refresh_packages()
        self.set_busy(False, "Waiting for administrator authentication")
        GLib.idle_add(self.authenticate_at_startup)

    def _build_css(self) -> None:
        css = b"""
        .govibe-title { font-size: 20px; font-weight: 700; }
        .govibe-subtitle { color: #6b7280; }
        .govibe-danger { background: #b91c1c; color: white; }
        .govibe-primary { background: #111827; color: white; }
        .govibe-card { border-radius: 12px; padding: 10px; background: #f8fafc; border: 1px solid #d1d5db; }
        treeview { font-size: 10.5pt; }
        textview { font-family: Monospace; font-size: 10pt; }
        """
        provider = Gtk.CssProvider()
        try:
            provider.load_from_data(css)
            Gtk.StyleContext.add_provider_for_screen(
                Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
            )
        except Exception:
            pass

    def _build_ui(self) -> None:
        root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        root.set_border_width(10)
        self.add(root)

        header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
        root.pack_start(header, False, False, 0)

        title_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
        title = Gtk.Label(label=APP_NAME)
        title.set_xalign(0)
        title.set_hexpand(True)
        title.set_ellipsize(Pango.EllipsizeMode.END)
        title.get_style_context().add_class("govibe-title")
        subtitle = Gtk.Label(label="Install local .rpm files, search installed packages, view install dates, upgrade, autoremove, clean package cache, and uninstall software from one desktop GUI.")
        subtitle.set_xalign(0)
        subtitle.set_hexpand(True)
        subtitle.set_single_line_mode(True)
        subtitle.set_ellipsize(Pango.EllipsizeMode.END)
        subtitle.get_style_context().add_class("govibe-subtitle")
        title_box.pack_start(title, False, False, 0)
        title_box.pack_start(subtitle, False, False, 0)
        header.pack_start(title_box, True, True, 0)

        self.refresh_btn = Gtk.Button(label="Refresh")
        self.refresh_btn.connect("clicked", lambda _w: self.refresh_packages())
        header.pack_start(self.refresh_btn, False, False, 0)

        self.install_rpm_btn = Gtk.Button(label="Install .rpm")
        self.install_rpm_btn.get_style_context().add_class("govibe-primary")
        self.install_rpm_btn.connect("clicked", self.on_install_rpm)
        header.pack_start(self.install_rpm_btn, False, False, 0)

        self.update_btn = Gtk.Button(label="Update metadata")
        self.update_btn.connect("clicked", self.on_update_lists)
        header.pack_start(self.update_btn, False, False, 0)

        self.upgrade_btn = Gtk.Button(label="Upgrade packages")
        self.upgrade_btn.connect("clicked", self.on_upgrade_packages)
        header.pack_start(self.upgrade_btn, False, False, 0)

        self.autoremove_btn = Gtk.Button(label="Autoremove")
        self.autoremove_btn.connect("clicked", self.on_autoremove)
        header.pack_start(self.autoremove_btn, False, False, 0)

        self.clean_btn = Gtk.Button(label="Clean cache")
        self.clean_btn.connect("clicked", self.on_clean_cache)
        header.pack_start(self.clean_btn, False, False, 0)

        search_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        root.pack_start(search_box, False, False, 0)
        search_label = Gtk.Label(label="Search")
        search_box.pack_start(search_label, False, False, 0)
        self.search_entry = Gtk.SearchEntry()
        self.search_entry.set_placeholder_text("Type package name, section, or description...")
        self.search_entry.connect("search-changed", lambda _w: self.apply_filter())
        search_box.pack_start(self.search_entry, True, True, 0)
        self.count_label = Gtk.Label(label="")
        search_box.pack_start(self.count_label, False, False, 0)

        paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
        root.pack_start(paned, True, True, 0)

        left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        paned.add1(left_box)

        self.store = Gtk.ListStore(str, str, str, str, str, str, str)
        self.tree = Gtk.TreeView(model=self.store)
        self.tree.set_headers_visible(True)
        self.tree.get_selection().connect("changed", self.on_selection_changed)

        columns = [
            ("Package", 0, 240),
            ("Version", 1, 175),
            ("Installed Date", 2, 140),
            ("Size", 3, 85),
            ("Safe", 4, 90),
            ("Section", 5, 115),
            ("Description", 6, 420),
        ]
        for title, index, width in columns:
            renderer = Gtk.CellRendererText()
            renderer.set_property("ellipsize", Pango.EllipsizeMode.END)
            col = Gtk.TreeViewColumn(title, renderer, text=index)
            col.set_resizable(True)
            col.set_sort_column_id(index)
            col.set_min_width(70)
            col.set_fixed_width(width)
            self.tree.append_column(col)

        scroller = Gtk.ScrolledWindow()
        scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        try:
            scroller.set_propagate_natural_width(False)
            scroller.set_min_content_width(680)
        except Exception:
            pass
        scroller.add(self.tree)
        left_box.pack_start(scroller, True, True, 0)

        action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        left_box.pack_start(action_box, False, False, 0)

        self.info_btn = Gtk.Button(label="Package info")
        self.info_btn.connect("clicked", self.on_package_info)
        action_box.pack_start(self.info_btn, False, False, 0)

        self.reinstall_btn = Gtk.Button(label="Reinstall")
        self.reinstall_btn.connect("clicked", self.on_reinstall)
        action_box.pack_start(self.reinstall_btn, False, False, 0)

        self.remove_btn = Gtk.Button(label="Uninstall")
        self.remove_btn.connect("clicked", self.on_remove)
        action_box.pack_start(self.remove_btn, False, False, 0)

        self.purge_btn = Gtk.Button(label="Remove")
        self.purge_btn.get_style_context().add_class("govibe-danger")
        self.purge_btn.connect("clicked", self.on_purge)
        action_box.pack_start(self.purge_btn, False, False, 0)

        right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        paned.add2(right_box)
        paned.set_position(LEFT_PANE_WIDTH)

        details_frame = Gtk.Frame(label="Selected package")
        right_box.pack_start(details_frame, False, False, 0)
        self.details_label = Gtk.Label(label="Select a package to see details.")
        self.details_label.set_xalign(0)
        self.details_label.set_yalign(0)
        self.details_label.set_line_wrap(True)
        self.details_label.set_max_width_chars(48)
        self.details_label.set_selectable(True)
        self.details_label.set_margin_top(8)
        self.details_label.set_margin_bottom(8)
        self.details_label.set_margin_start(8)
        self.details_label.set_margin_end(8)
        details_frame.add(self.details_label)

        log_frame = Gtk.Frame(label="Action log")
        right_box.pack_start(log_frame, True, True, 0)
        self.log_view = Gtk.TextView()
        self.log_view.set_editable(False)
        self.log_view.set_cursor_visible(False)
        self.log_buffer = self.log_view.get_buffer()
        log_scroll = Gtk.ScrolledWindow()
        log_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        try:
            log_scroll.set_propagate_natural_width(False)
            log_scroll.set_min_content_width(360)
        except Exception:
            pass
        log_scroll.add(self.log_view)
        log_frame.add(log_scroll)

        footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        root.pack_start(footer, False, False, 0)
        self.status_label = Gtk.Label(label="Ready")
        self.status_label.set_xalign(0)
        footer.pack_start(self.status_label, True, True, 0)
        about_btn = Gtk.Button(label="About")
        about_btn.connect("clicked", self.on_about)
        footer.pack_start(about_btn, False, False, 0)

        self.privileged_widgets = [
            self.install_rpm_btn, self.update_btn, self.upgrade_btn, self.autoremove_btn, self.clean_btn,
            self.reinstall_btn, self.remove_btn, self.purge_btn,
        ]
        self.local_widgets = [self.refresh_btn, self.info_btn]

    def authenticate_at_startup(self) -> bool:
        try:
            self.root_helper.start()
            self.root_ready = True
            self.set_busy(False, "Ready")
        except Exception as exc:
            self.root_ready = False
            self.set_busy(False, "Administrator authentication failed")
            self.alert(
                "Administrator authentication failed",
                f"The software needs administrator authentication when it opens.\n\n{exc}",
                error=True,
            )
            Gtk.main_quit()
        return False

    def on_destroy(self, _widget=None) -> None:
        try:
            self.root_helper.stop()
        except Exception:
            pass
        Gtk.main_quit()

    def append_log(self, text: str) -> None:
        end = self.log_buffer.get_end_iter()
        self.log_buffer.insert(end, text)
        mark = self.log_buffer.create_mark(None, self.log_buffer.get_end_iter(), False)
        self.log_view.scroll_mark_onscreen(mark)

    def set_status(self, text: str) -> None:
        self.status_label.set_text(text)

    def set_busy(self, busy: bool, status: Optional[str] = None) -> None:
        self.busy = busy
        for widget in getattr(self, "local_widgets", []):
            widget.set_sensitive(not busy)
        for widget in getattr(self, "privileged_widgets", []):
            widget.set_sensitive((not busy) and self.root_ready)
        if status:
            self.set_status(status)

    def refresh_packages(self) -> None:
        self.packages = parse_rpm_packages()
        self.apply_filter()
        self.set_status(f"Loaded {len(self.packages)} installed packages")

    def apply_filter(self) -> None:
        query = self.search_entry.get_text().strip().lower()
        tokens = [token for token in query.split() if token]
        self.filtered = []
        self.store.clear()
        for pkg in self.packages:
            blob = " ".join([pkg.name, pkg.version, pkg.description, pkg.section, pkg.priority, pkg.installed_date]).lower()
            if tokens and not all(token in blob for token in tokens):
                continue
            protected = is_protected_package(pkg)
            safe = "Protected" if protected else "OK"
            self.filtered.append(pkg)
            self.store.append([pkg.name, pkg.version, pkg.installed_date, pkg.installed_size, safe, pkg.section, pkg.description])
        self.count_label.set_text(f"{len(self.filtered)} shown / {len(self.packages)} installed")

    def get_selected_package(self) -> Optional[PackageInfo]:
        selection = self.tree.get_selection()
        model, tree_iter = selection.get_selected()
        if not tree_iter:
            return None
        name = model.get_value(tree_iter, 0)
        for pkg in self.packages:
            if pkg.name == name:
                return pkg
        return None

    def on_selection_changed(self, _selection) -> None:
        pkg = self.get_selected_package()
        if not pkg:
            self.details_label.set_text("Select a package to see details.")
            return
        protected = "Yes" if is_protected_package(pkg) else "No"
        details = (
            f"Package: {pkg.name}\n"
            f"Version: {pkg.version}\n"
            f"Section: {pkg.section}\n"
            f"Installed date: {pkg.installed_date}\n"
            f"Priority: {pkg.priority}\n"
            f"Essential: {pkg.essential or 'no'}\n"
            f"Protected by Govibe: {protected}\n"
            f"Installed size: {pkg.installed_size}\n"
            f"Description: {pkg.description}\n"
        )
        if pkg.depends:
            details += f"Depends: {pkg.depends}\n"
        self.details_label.set_text(details)

    def confirm(self, title: str, message: str, danger: bool = False) -> bool:
        dialog = Gtk.MessageDialog(
            transient_for=self,
            flags=Gtk.DialogFlags.MODAL,
            message_type=Gtk.MessageType.WARNING if danger else Gtk.MessageType.QUESTION,
            buttons=Gtk.ButtonsType.OK_CANCEL,
            text=title,
        )
        dialog.format_secondary_text(message)
        response = dialog.run()
        dialog.destroy()
        return response == Gtk.ResponseType.OK

    def alert(self, title: str, message: str, error: bool = False) -> None:
        dialog = Gtk.MessageDialog(
            transient_for=self,
            flags=Gtk.DialogFlags.MODAL,
            message_type=Gtk.MessageType.ERROR if error else Gtk.MessageType.INFO,
            buttons=Gtk.ButtonsType.OK,
            text=title,
        )
        dialog.format_secondary_text(message)
        dialog.run()
        dialog.destroy()

    def run_root_action_threaded(self, title: str, action: str, payload: Optional[Dict[str, str]] = None, refresh_after: bool = True) -> None:
        if self.busy:
            return
        if not self.root_ready or not self.root_helper.is_running():
            self.alert(
                "Administrator session unavailable",
                "Close and reopen the software to authenticate again.",
                error=True,
            )
            return
        self.set_busy(True, f"Running: {title}")
        self.append_log(f"\n===== {title} =====\n")

        request: Dict[str, str] = {"action": action}
        if payload:
            request.update(payload)

        def worker() -> None:
            code = 1
            try:
                code = self.root_helper.run_action(
                    request,
                    lambda line: GLib.idle_add(self.append_log, line),
                )
            except FileNotFoundError as exc:
                GLib.idle_add(self.append_log, f"ERROR: {exc}\n")
            except Exception as exc:
                GLib.idle_add(self.append_log, f"ERROR: {exc}\n")
                GLib.idle_add(self.append_log, traceback.format_exc())

            def done() -> bool:
                if refresh_after:
                    self.refresh_packages()
                if code == 0:
                    self.append_log(f"\nFinished successfully: {title}\n")
                    self.set_status(f"Finished: {title}")
                else:
                    self.append_log(f"\nFinished with exit code {code}: {title}\n")
                    self.set_status(f"Action failed or was cancelled: {title}")
                self.set_busy(False)
                return False

            GLib.idle_add(done)

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

    def selected_or_alert(self) -> Optional[PackageInfo]:
        pkg = self.get_selected_package()
        if not pkg:
            self.alert("No package selected", "Select a package from the list first.")
            return None
        return pkg

    def on_install_rpm(self, _widget) -> None:
        chooser = Gtk.FileChooserDialog(
            title="Select a .rpm package to install",
            transient_for=self,
            action=Gtk.FileChooserAction.OPEN,
        )
        chooser.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, "Install selected .rpm", Gtk.ResponseType.OK)
        file_filter = Gtk.FileFilter()
        file_filter.set_name("RPM packages (*.rpm)")
        file_filter.add_pattern("*.rpm")
        chooser.add_filter(file_filter)
        response = chooser.run()
        path = chooser.get_filename() if response == Gtk.ResponseType.OK else None
        chooser.destroy()
        if not path:
            return
        if not path.lower().endswith(".rpm"):
            self.alert("Invalid file", "Please select an RPM package ending in .rpm.", error=True)
            return
        if not self.confirm("Install local .rpm package?", f"This will install:\n{path}\n\nThis uses the administrator session created when the software opened."):
            return
        self.run_root_action_threaded(
            "Install local .rpm",
            "install_rpm",
            {"path": path},
            refresh_after=True,
        )

    def on_update_lists(self, _widget) -> None:
        if not self.confirm("Update package metadata?", "This refreshes package manager metadata using the administrator session created when the software opened."):
            return
        self.run_root_action_threaded(
            "Update metadata",
            "update",
            refresh_after=False,
        )

    def on_upgrade_packages(self, _widget) -> None:
        if not self.confirm("Upgrade installed packages?", "This runs a package upgrade using the administrator session created when the software opened. Review the action log after it finishes.", danger=True):
            return
        self.run_root_action_threaded(
            "Upgrade installed packages",
            "upgrade",
            refresh_after=True,
        )

    def on_autoremove(self, _widget) -> None:
        if not self.confirm("Autoremove unused packages?", "This runs autoremove. It may remove packages that the system marks as no longer needed.", danger=True):
            return
        self.run_root_action_threaded(
            "Autoremove unused packages",
            "autoremove",
            refresh_after=True,
        )

    def on_clean_cache(self, _widget) -> None:
        if not self.confirm("Clean package cache?", "This cleans downloaded package manager cache files."):
            return
        self.run_root_action_threaded(
            "Clean cache",
            "clean",
            refresh_after=False,
        )

    def on_package_info(self, _widget) -> None:
        pkg = self.selected_or_alert()
        if not pkg:
            return
        self.append_log(f"\n===== Package info: {pkg.name} =====\n")
        try:
            output = subprocess.check_output(["rpm", "-qi", pkg.name], text=True, stderr=subprocess.STDOUT)
            self.append_log(output + "\n")
        except Exception as exc:
            self.append_log(f"rpm info failed: {exc}\n")
        try:
            output = subprocess.check_output([get_package_manager_command(), "info", "installed", pkg.name], text=True, stderr=subprocess.STDOUT)
            self.append_log(output + "\n")
        except Exception as exc:
            self.append_log(f"package manager info failed: {exc}\n")

    def on_reinstall(self, _widget) -> None:
        pkg = self.selected_or_alert()
        if not pkg:
            return
        if is_protected_package(pkg):
            self.alert("Protected package", f"Govibe blocked reinstall for protected package:\n{pkg.name}", error=True)
            return
        if not self.confirm("Reinstall selected package?", f"Package:\n{pkg.name}\n\nThis will reinstall the selected RPM package through the system package manager."):
            return
        self.run_root_action_threaded(
            f"Reinstall {pkg.name}",
            "reinstall",
            {"package": pkg.name},
            refresh_after=True,
        )

    def on_remove(self, _widget) -> None:
        pkg = self.selected_or_alert()
        if not pkg:
            return
        if is_protected_package(pkg):
            self.alert("Protected package", f"Govibe blocked uninstall for protected package:\n{pkg.name}\n\nThis helps prevent breaking the operating system.", error=True)
            return
        if not self.confirm("Uninstall selected package?", f"Package:\n{pkg.name}\n\nThis removes the selected package through the system package manager.", danger=True):
            return
        self.run_root_action_threaded(
            f"Uninstall {pkg.name}",
            "remove",
            {"package": pkg.name},
            refresh_after=True,
        )

    def on_purge(self, _widget) -> None:
        pkg = self.selected_or_alert()
        if not pkg:
            return
        if is_protected_package(pkg):
            self.alert("Protected package", f"Govibe blocked remove for protected package:\n{pkg.name}\n\nThis helps prevent breaking the operating system.", error=True)
            return
        if not self.confirm("Remove selected package?", f"Package:\n{pkg.name}\n\nRPM systems do not use Debian-style purge. This will remove the selected package through the system package manager.", danger=True):
            return
        self.run_root_action_threaded(
            f"Remove {pkg.name}",
            "purge",
            {"package": pkg.name},
            refresh_after=True,
        )

    def on_about(self, _widget) -> None:
        dialog = Gtk.AboutDialog(transient_for=self, modal=True)
        dialog.set_program_name(APP_NAME)
        dialog.set_version(APP_VERSION)
        dialog.set_comments("A simple all-GUI package installer, upgrader, autoremove tool, cache cleaner, and uninstaller for Govibe Linux tools and normal RPM packages on Fedora, CentOS, and Red Hat systems.")
        dialog.set_website("https://govibe.org")
        dialog.set_authors(["Govibe"])
        dialog.set_license_type(Gtk.License.MIT_X11)
        dialog.run()
        dialog.destroy()


def main() -> int:
    if len(sys.argv) > 1 and sys.argv[1] == ROOT_HELPER_ARG:
        return root_helper_main()
    try:
        app = GovibePackageManager()
        app.show_all()
        app.resize(WINDOW_WIDTH, WINDOW_HEIGHT)
        GLib.idle_add(lambda: (app.resize(WINDOW_WIDTH, WINDOW_HEIGHT), False)[1])
        Gtk.main()
        return 0
    except Exception as exc:
        print(str(exc), file=sys.stderr)
        traceback.print_exc()
        return 1


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