#!/usr/bin/env python3
# Govibe Package Management and Uninstaller
# Version 1.0.2

import os
import sys
import subprocess
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 apt install python3-gi gir1.2-gtk-3.0", 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.2"
SELF_PACKAGES = {"govibe-package-management-uninstaller"}

PROTECTED_NAMES = {
    "apt", "apt-utils", "base-files", "base-passwd", "bash", "coreutils",
    "dash", "debianutils", "dpkg", "e2fsprogs", "findutils", "grep", "gzip",
    "init", "libc6", "login", "mount", "passwd", "perl-base", "python3",
    "python3-gi", "sed", "sudo", "systemd", "systemd-sysv", "tar", "ubuntu-minimal",
    "ubuntu-standard", "util-linux", "polkitd", "policykit-1", "gir1.2-gtk-3.0"
}


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_deb_path(path: str) -> str:
    path = os.path.abspath(safe_text(path))
    if not path.lower().endswith(".deb"):
        raise ValueError("Only .deb files can be installed")
    if not os.path.exists(path) or not os.path.isfile(path):
        raise ValueError("The selected .deb file no longer exists")
    return path


def root_action_to_args(request: Dict[str, str]) -> List[str]:
    action = safe_text(request.get("action"))
    if action == "update":
        return ["apt-get", "update"]
    if action == "upgrade":
        return ["env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "upgrade", "-y"]
    if action == "autoremove":
        return ["env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "autoremove", "-y"]
    if action == "clean":
        return ["apt-get", "clean"]
    if action == "install_deb":
        return ["env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", validate_deb_path(request.get("path", ""))]
    if action == "reinstall":
        return ["env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "--reinstall", "-y", validate_package_name(request.get("package", ""))]
    if action == "remove":
        return ["env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "remove", "-y", validate_package_name(request.get("package", ""))]
    if action == "purge":
        return ["env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "purge", "-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 apt/dpkg 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 build_install_dates() -> Dict[str, str]:
    """Best-effort install dates from dpkg and apt logs.

    dpkg does not store a permanent install date in /var/lib/dpkg/status.
    This reads current and rotated logs, then falls back to dpkg info file mtimes.
    Older packages can still show Unknown if logs were deleted and no info file exists.
    """
    dates: Dict[str, str] = {}

    dpkg_logs = sorted(
        glob.glob("/var/log/dpkg.log") + glob.glob("/var/log/dpkg.log.*"),
        key=lambda item: os.path.getmtime(item) if os.path.exists(item) else 0,
    )
    for path in dpkg_logs:
        for line in read_log_lines(path):
            # Example:
            # 2026-05-24 10:10:22 install package:amd64 <none> 1.0
            parts = line.split()
            if len(parts) >= 5 and parts[2] == "install":
                name = strip_arch_suffix(parts[3])
                dates[name] = format_log_datetime(parts[0] + " " + parts[1])

    # apt history has useful dates for some apt-managed installs. Keep dpkg as primary,
    # but use apt history as a secondary source when dpkg install entries are missing.
    apt_logs = sorted(
        glob.glob("/var/log/apt/history.log") + glob.glob("/var/log/apt/history.log.*"),
        key=lambda item: os.path.getmtime(item) if os.path.exists(item) else 0,
    )
    current_date = ""
    for path in apt_logs:
        for line in read_log_lines(path):
            if line.startswith("Start-Date:"):
                value = line.split(":", 1)[1].strip()
                current_date = format_log_datetime(value)
            elif line.startswith("Install:") and current_date:
                payload = line.split(":", 1)[1]
                for item in payload.split(","):
                    name = item.strip().split()[0] if item.strip() else ""
                    name = strip_arch_suffix(name)
                    if name and name not in dates:
                        dates[name] = current_date

    return dates


def fallback_install_date_from_dpkg_info(package_name: str) -> str:
    """Fallback date using the dpkg info list file timestamp."""
    safe_name = package_name.replace("/", "_")
    candidates = glob.glob(f"/var/lib/dpkg/info/{safe_name}.list") + glob.glob(f"/var/lib/dpkg/info/{safe_name}:*.list")
    candidates = [item for item in candidates if os.path.exists(item)]
    if not candidates:
        return "Unknown"
    try:
        newest = max(candidates, key=os.path.getmtime)
        return datetime.fromtimestamp(os.path.getmtime(newest)).strftime("%Y-%m-%d %H:%M")
    except Exception:
        return "Unknown"


def parse_dpkg_status(path: str = "/var/lib/dpkg/status") -> List[PackageInfo]:
    packages: List[PackageInfo] = []
    install_dates = build_install_dates()
    if not os.path.exists(path):
        return packages

    current: Dict[str, str] = {}
    last_key: Optional[str] = None

    def finish(record: Dict[str, str]) -> None:
        if not record:
            return
        status = safe_text(record.get("Status"))
        if status != "install ok installed":
            return
        description = safe_text(record.get("Description"))
        synopsis = description.splitlines()[0].strip() if description else ""
        size = safe_text(record.get("Installed-Size"))
        try:
            if size:
                kib = int(size)
                if kib >= 1024 * 1024:
                    size = f"{kib / 1024 / 1024:.2f} GB"
                elif kib >= 1024:
                    size = f"{kib / 1024:.1f} MB"
                else:
                    size = f"{kib} KB"
        except Exception:
            pass
        package_name = safe_text(record.get("Package"))
        installed_date = install_dates.get(package_name)
        if not installed_date:
            installed_date = fallback_install_date_from_dpkg_info(package_name)
        packages.append(PackageInfo(
            name=package_name,
            version=safe_text(record.get("Version")),
            description=synopsis,
            installed_size=size,
            essential=safe_text(record.get("Essential")),
            priority=safe_text(record.get("Priority")),
            section=safe_text(record.get("Section")),
            depends=safe_text(record.get("Depends")),
            status=status,
            installed_date=installed_date,
        ))

    try:
        with open(path, "r", encoding="utf-8", errors="replace") as handle:
            for raw_line in handle:
                line = raw_line.rstrip("\n")
                if not line:
                    finish(current)
                    current = {}
                    last_key = None
                    continue
                if line.startswith(" ") and last_key:
                    current[last_key] = current.get(last_key, "") + "\n" + line.strip()
                    continue
                if ":" in line:
                    key, value = line.split(":", 1)
                    current[key] = value.strip()
                    last_key = key
        finish(current)
    except Exception:
        traceback.print_exc()

    packages = [pkg for pkg in packages if pkg.name]
    packages.sort(key=lambda item: item.name.lower())
    return packages


def is_protected_package(pkg: PackageInfo) -> bool:
    if pkg.essential.lower() == "yes":
        return True
    if pkg.name in SELF_PACKAGES:
        return True
    if pkg.name in PROTECTED_NAMES:
        return True
    if pkg.priority in {"required"}:
        return True
    return False


class GovibePackageManager(Gtk.Window):
    def __init__(self):
        super().__init__(title=APP_NAME)
        self.set_default_size(1160, 720)
        self.set_position(Gtk.WindowPosition.CENTER)
        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.get_style_context().add_class("govibe-title")
        subtitle = Gtk.Label(label="Install local .deb files, search installed packages, view install dates, upgrade, autoremove, and uninstall software from one desktop GUI.")
        subtitle.set_xalign(0)
        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_deb_btn = Gtk.Button(label="Install .deb")
        self.install_deb_btn.get_style_context().add_class("govibe-primary")
        self.install_deb_btn.connect("clicked", self.on_install_deb)
        header.pack_start(self.install_deb_btn, False, False, 0)

        self.update_btn = Gtk.Button(label="Update apt lists")
        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 apt 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)
        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="Purge")
        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(720)

        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_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)
        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_deb_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_dpkg_status()
        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_deb(self, _widget) -> None:
        chooser = Gtk.FileChooserDialog(
            title="Select a .deb package to install",
            transient_for=self,
            action=Gtk.FileChooserAction.OPEN,
        )
        chooser.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, "Install selected .deb", Gtk.ResponseType.OK)
        file_filter = Gtk.FileFilter()
        file_filter.set_name("Debian packages (*.deb)")
        file_filter.add_pattern("*.deb")
        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(".deb"):
            self.alert("Invalid file", "Please select a Debian package ending in .deb.", error=True)
            return
        if not self.confirm("Install local .deb 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 .deb",
            "install_deb",
            {"path": path},
            refresh_after=True,
        )

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

    def on_upgrade_packages(self, _widget) -> None:
        if not self.confirm("Upgrade installed packages?", "This runs apt-get upgrade -y 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 apt-get autoremove -y. It may remove packages that apt 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 apt package cache?", "This runs apt-get clean to remove downloaded package cache files."):
            return
        self.run_root_action_threaded(
            "Clean apt 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(["dpkg", "-s", pkg.name], text=True, stderr=subprocess.STDOUT)
            self.append_log(output + "\n")
        except Exception as exc:
            self.append_log(f"dpkg info failed: {exc}\n")
        try:
            output = subprocess.check_output(["apt-cache", "policy", pkg.name], text=True, stderr=subprocess.STDOUT)
            self.append_log(output + "\n")
        except Exception as exc:
            self.append_log(f"apt-cache policy 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 run apt-get install --reinstall."):
            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 package but normally keeps configuration files.", 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 purge for protected package:\n{pkg.name}\n\nThis helps prevent breaking the operating system.", error=True)
            return
        if not self.confirm("Purge selected package?", f"Package:\n{pkg.name}\n\nPurge removes the package and configuration files. Use this only when you really want a clean uninstall.", danger=True):
            return
        self.run_root_action_threaded(
            f"Purge {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, and uninstaller for Govibe Linux tools and normal Debian packages.")
        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()
        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())
