#!/usr/bin/env python3
import sys
import re
import subprocess
import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
from PySide6.QtCore import Qt, QProcess, Slot, SLOT, QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtDBus import QDBusConnection, QDBusInterface
from PySide6.QtWidgets import (
    QApplication,
    QMainWindow,
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QPushButton,
    QTabWidget,
    QTableWidget,
    QTableWidgetItem,
    QHeaderView,
    QLabel,
    QMessageBox,
)

BRANCH_RSS_FEEDS = {
    "stable": "https://forum.manjaro.org/c/12.rss",
    "testing": "https://forum.manjaro.org/c/13.rss",
    "unstable": "https://forum.manjaro.org/c/15.rss",
}
DEFAULT_FORUM_URL = "https://forum.manjaro.org/c/announcements/11"
AUR_META_FILE = Path("/var/lib/pacman/sync/packages-meta-ext-v1.json.gz")

# The pamac system daemon. StartTransRefresh is deliberately not polkit
# protected (see "do not check authorization" in libpamac daemon.vala), so the
# databases can be synced without a password. pamac-installer then asks for
# authentication once, for the transaction itself.
PAMAC_SERVICE = "org.manjaro.pamac.daemon"
PAMAC_PATH = "/org/manjaro/pamac/daemon"
PAMAC_IFACE = "org.manjaro.pamac.daemon"

# Daemon signal -> slot of UpdateApp it is routed to. All of them carry the
# client bus name as first argument.
PAMAC_SIGNALS = (
    ("EmitAction", "_on_pamac_action(QString,QString)"),
    ("EmitActionProgress", "_on_pamac_progress(QString,QString,QString,double)"),
    ("EmitDownloadProgress", "_on_pamac_progress(QString,QString,QString,double)"),
    ("StartWaiting", "_on_pamac_start_waiting(QString)"),
    ("StopWaiting", "_on_pamac_stop_waiting(QString)"),
    ("EmitWarning", "_on_pamac_warning(QString,QString)"),
    ("EmitError", "_on_pamac_error(QString,QString,QStringList)"),
    ("TransRefreshFinished", "_on_db_refresh_finished(QString,bool)"),
)


def get_system_branch() -> str:
    try:
        res = subprocess.run(
            ["pacman-mirrors", "-G"],
            capture_output=True,
            text=True,
            check=False,
            timeout=5,
        )
        if res.returncode == 0 and res.stdout.strip():
            return res.stdout.strip().lower()
    except Exception:
        pass
    return "stable"


def get_latest_announcement_url(branch: str | None = None) -> str:
    if not branch:
        branch = get_system_branch()
    feed_url = BRANCH_RSS_FEEDS.get(branch.lower(), BRANCH_RSS_FEEDS["stable"])
    try:
        req = urllib.request.Request(
            feed_url,
            headers={"User-Agent": "Mozilla/5.0 (kiss-up)"},
        )
        with urllib.request.urlopen(req, timeout=10) as response:
            tree = ET.fromstring(response.read())
        items = tree.findall(".//item")
        if items:
            link = items[0].findtext("link")
            if link and link.strip():
                return link.strip()
    except Exception:
        pass
    # Fallback to category base URL or default forum announcement page
    return feed_url.removesuffix(".rss") if feed_url else DEFAULT_FORUM_URL


def is_dkms_package(pkg_name: str) -> bool:
    name = pkg_name.lower()
    return name.endswith("-dkms") or "dkms" in name


def is_driver_package(pkg_name: str, description: str = "") -> bool:
    text = f"{pkg_name} {description}".lower()
    keywords = ["cups", "printer", "scanner", "driver"]
    return any(kw in text for kw in keywords)


def parse_mhwd_kernel_output(output: str) -> tuple[str | None, list[str]]:
    clean_text = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", output)
    running_kernel = None
    running_match = re.search(r"Currently running:.*?\((linux\S+?)\)", clean_text, re.IGNORECASE)
    if running_match:
        running_kernel = running_match.group(1).strip()

    installed_kernels = []
    for line in clean_text.splitlines():
        match = re.match(r"^\s*\*\s*(linux\S+)", line)
        if match:
            installed_kernels.append(match.group(1).strip())

    return running_kernel, installed_kernels


def query_kernel_status() -> tuple[str | None, list[str]]:
    try:
        res = subprocess.run(
            ["mhwd-kernel", "-li"],
            capture_output=True,
            text=True,
            check=False,
            timeout=5,
        )
        if res.returncode == 0 and res.stdout.strip():
            return parse_mhwd_kernel_output(res.stdout)
    except Exception:
        pass
    return None, []


def filter_running_kernel(
    packages: list[tuple[str, str]],
    running_kernel: str | None,
) -> tuple[list[tuple[str, str]], bool]:
    if not running_kernel:
        return list(packages), False
    filtered = []
    found = False
    for item in packages:
        pkg_name = item[0] if isinstance(item, (tuple, list)) else item
        if pkg_name == running_kernel:
            found = True
        else:
            filtered.append(item)
    return filtered, found


def check_aur_metadata(path: Path | str = AUR_META_FILE) -> bool:
    try:
        p = Path(path)
        return p.is_file() and p.stat().st_size > 0
    except Exception:
        return False


def filter_orphan_duplicates(
    orphans: list[tuple[str, str]],
    foreign: list[tuple[str, str]] | set[str] | list[str],
) -> list[tuple[str, str]]:
    foreign_names = {item[0] if isinstance(item, (tuple, list)) else item for item in foreign}
    return [
        item for item in orphans
        if (item[0] if isinstance(item, (tuple, list)) else item) not in foreign_names
    ]


def parse_pamac_list_output(output: str) -> list[tuple[str, str]]:
    results = []
    for line in output.strip().splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.split()
        if len(parts) >= 2:
            results.append((parts[0], parts[1]))
        elif len(parts) == 1:
            results.append((parts[0], ""))
    return results


def query_orphans() -> list[tuple[str, str]]:
    try:
        res = subprocess.run(
            ["pamac", "list", "--orphans"],
            capture_output=True,
            text=True,
            check=False,
            timeout=10,
        )
        if res.returncode == 0 and res.stdout.strip():
            return parse_pamac_list_output(res.stdout)
    except Exception:
        pass
    return []


def query_foreign() -> list[tuple[str, str]]:
    try:
        res = subprocess.run(
            ["pamac", "list", "--foreign"],
            capture_output=True,
            text=True,
            check=False,
            timeout=10,
        )
        if res.returncode == 0 and res.stdout.strip():
            return parse_pamac_list_output(res.stdout)
    except Exception:
        pass
    return []


def get_pamac_info_descriptions(pkg_names: list[str]) -> dict[str, str]:
    if not pkg_names:
        return {}
    descriptions = {}
    try:
        res = subprocess.run(
            ["pamac", "info", *pkg_names],
            capture_output=True,
            text=True,
            check=False,
            timeout=5,
        )
        if res.returncode == 0 and res.stdout.strip():
            current_pkg = None
            current_desc = []
            in_desc = False
            for line in res.stdout.splitlines():
                name_match = re.match(r"^\s*Name\s*:\s*(\S+)", line)
                if name_match:
                    if current_pkg and current_desc:
                        descriptions[current_pkg] = " ".join(" ".join(current_desc).split())
                    current_pkg = name_match.group(1).strip()
                    current_desc = []
                    in_desc = False
                    continue

                desc_match = re.match(r"^\s*Description\s*:\s*(.*)", line)
                if desc_match:
                    in_desc = True
                    current_desc.append(desc_match.group(1).strip())
                    continue

                if in_desc:
                    if re.match(r"^\s*\w[\w\s]*\s*:", line):
                        in_desc = False
                    else:
                        current_desc.append(line.strip())

            if current_pkg and current_desc:
                descriptions[current_pkg] = " ".join(" ".join(current_desc).split())
    except Exception:
        pass
    return descriptions


def get_pamac_info_description(pkg_name: str) -> str:
    return get_pamac_info_descriptions([pkg_name]).get(pkg_name, "")


def _create_table_item(text: str, checkable: bool = False, checked: bool = True) -> QTableWidgetItem:
    item = QTableWidgetItem(text)
    flags = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
    if checkable:
        flags |= Qt.ItemFlag.ItemIsUserCheckable
        item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
    item.setFlags(flags)
    return item


class UpdateApp(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Manjaro System Updater")
        self.resize(750, 500)

        self._populating = False
        self._metadata_refreshed = False
        self.running_kernel_blocked = False
        self.running_kernel_name = None
        self.installed_kernels = []
        self.process = None
        self.pending_packages = []
        self.pending_installer_args = []
        self.pamac = None
        self.pamac_sender = None
        self.pamac_signals = []
        self.refresh_action = ""
        self.refresh_error = ""

        self._init_ui()
        self.check_for_updates()

    def _init_ui(self):
        central_widget = QWidget(self)
        self.setCentralWidget(central_widget)
        main_layout = QVBoxLayout(central_widget)

        # Status / Inline Message Label
        self.status_label = QLabel("Initializing... please wait", self)
        self.status_label.setStyleSheet("font-weight: bold; margin: 5px;")
        main_layout.addWidget(self.status_label)

        # Tab Widget
        self.tabs = QTabWidget(self)

        # Tab 1: Updates
        self.table = QTableWidget(self)
        self.table.setColumnCount(3)
        self.table.setHorizontalHeaderLabels(["Package", "Current Version", "New Version"])
        self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
        self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        self.table.itemChanged.connect(self._on_table_item_changed)
        self.tabs.addTab(self.table, "Packages to Update")

        # Tab 2: Remove Orphans
        self.orphans_table = QTableWidget(self)
        self.orphans_table.setColumnCount(2)
        self.orphans_table.setHorizontalHeaderLabels(["Package", "Version"])
        self.orphans_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        self.orphans_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        self.orphans_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        self.orphans_table.itemChanged.connect(self._on_table_item_changed)
        self.tabs.addTab(self.orphans_table, "Orphans to Remove")

        # Tab 3: Remove Foreign
        self.foreign_table = QTableWidget(self)
        self.foreign_table.setColumnCount(3)
        self.foreign_table.setHorizontalHeaderLabels(["Package", "Version", "Description"])
        self.foreign_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive)
        self.foreign_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        self.foreign_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
        self.foreign_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        self.foreign_table.itemChanged.connect(self._on_table_item_changed)
        self.tabs.addTab(self.foreign_table, "Foreign to Remove")

        # Tab 4: Rebuild Foreign Drivers
        self.drivers_table = QTableWidget(self)
        self.drivers_table.setColumnCount(3)
        self.drivers_table.setHorizontalHeaderLabels(["Package", "Version", "Description"])
        self.drivers_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive)
        self.drivers_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        self.drivers_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
        self.drivers_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        self.drivers_table.itemChanged.connect(self._on_table_item_changed)
        self.tabs.addTab(self.drivers_table, "Rebuild Drivers")

        self._update_tab_visibility()

        main_layout.addWidget(self.tabs)

        # Action Buttons Layout
        btn_layout = QHBoxLayout()

        self.help_btn = QPushButton("Forum Announce", self)
        self.help_btn.setToolTip("Open latest announcement for this branch on Manjaro Forum")
        self.help_btn.clicked.connect(self.open_help_url)
        btn_layout.addWidget(self.help_btn)

        self.refresh_btn = QPushButton("Check for Updates", self)
        self.refresh_btn.clicked.connect(self.check_for_updates)
        btn_layout.addWidget(self.refresh_btn)

        self.install_btn = QPushButton("Update System", self)
        self.install_btn.clicked.connect(self.start_install_flow)
        btn_layout.addWidget(self.install_btn)

        main_layout.addLayout(btn_layout)

    def _update_tab_visibility(self):
        self.tabs.setTabVisible(0, True)
        self.tabs.setTabVisible(1, self.orphans_table.rowCount() > 0)
        self.tabs.setTabVisible(2, self.foreign_table.rowCount() > 0)
        self.tabs.setTabVisible(3, self.drivers_table.rowCount() > 0)

    def _on_table_item_changed(self, item):
        if not self._populating:
            self.update_action_button_state()

    def update_action_button_state(self):
        if self.running_kernel_blocked:
            self.install_btn.setEnabled(False)
            return
        args = self.get_installer_args()
        self.install_btn.setEnabled(len(args) > 0)

    @Slot()
    def open_help_url(self):
        QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
        try:
            url = get_latest_announcement_url()
            QDesktopServices.openUrl(QUrl(url))
        finally:
            while QApplication.overrideCursor() is not None:
                QApplication.restoreOverrideCursor()

    def set_ui_busy(self, busy: bool):
        if busy:
            QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
        else:
            while QApplication.overrideCursor() is not None:
                QApplication.restoreOverrideCursor()
        self.refresh_btn.setEnabled(not busy)
        if busy:
            self.install_btn.setEnabled(False)
        else:
            self.update_action_button_state()
        self.help_btn.setEnabled(not busy)
        self.tabs.setEnabled(not busy)

    def _get_checked_packages(self, table: QTableWidget) -> list[str]:
        packages = []
        for row in range(table.rowCount()):
            pkg_item = table.item(row, 0)
            if pkg_item:
                if pkg_item.flags() & Qt.ItemFlag.ItemIsUserCheckable:
                    if pkg_item.checkState() == Qt.CheckState.Checked:
                        packages.append(pkg_item.text())
                else:
                    packages.append(pkg_item.text())
        return packages

    def get_all_packages(self) -> list[str]:
        return self._get_checked_packages(self.table)

    def get_selected_updates(self) -> list[str]:
        return self._get_checked_packages(self.table)

    def get_selected_orphans(self) -> list[str]:
        return self._get_checked_packages(self.orphans_table)

    def get_selected_foreign(self) -> list[str]:
        return self._get_checked_packages(self.foreign_table)

    def get_selected_drivers(self) -> list[str]:
        return self._get_checked_packages(self.drivers_table)

    def get_installer_args(self) -> list[str]:
        updates = self.get_selected_updates()
        orphans = self.get_selected_orphans()
        foreign = self.get_selected_foreign()
        drivers = self.get_selected_drivers()

        args = []
        if updates:
            args.extend(updates)

        to_remove = []
        for pkg in orphans + foreign:
            if pkg not in to_remove:
                to_remove.append(pkg)

        if to_remove:
            args.append("--remove")
            args.extend(to_remove)

        if drivers:
            args.append("--build")
            args.extend(drivers)

        return args

    def check_for_updates(self):
        self.set_ui_busy(True)
        self.status_label.setText("Checking for updates...")
        self.running_kernel_blocked = False
        self.running_kernel_name = None
        self.installed_kernels = []
        self._populating = True
        self.table.setRowCount(0)
        self.orphans_table.setRowCount(0)
        self.foreign_table.setRowCount(0)
        self.drivers_table.setRowCount(0)
        self._populating = False
        self._update_tab_visibility()

        self.process = QProcess(self)
        self.process.finished.connect(self._on_checkupdates_finished)
        self.process.errorOccurred.connect(self._on_checkupdates_error)
        self.process.start("checkupdates", [])

    def _refresh_extra_packages(self):
        orphans = query_orphans()
        foreign = query_foreign()
        running_kernel, installed_kernels = query_kernel_status()
        self.running_kernel_name = running_kernel
        self.installed_kernels = installed_kernels

        orphans, orphan_kernel_hit = filter_running_kernel(orphans, running_kernel)
        foreign, foreign_kernel_hit = filter_running_kernel(foreign, running_kernel)
        self.running_kernel_blocked = orphan_kernel_hit or foreign_kernel_hit

        orphans = filter_orphan_duplicates(orphans, foreign)
        self._populate_orphans_table(orphans)
        self._populate_foreign_and_drivers_tables(foreign)

    def _on_checkupdates_finished(self, exit_code, exit_status):
        self.set_ui_busy(False)
        stdout = bytes(self.process.readAllStandardOutput()).decode("utf-8", errors="replace")
        stderr = bytes(self.process.readAllStandardError()).decode("utf-8", errors="replace")

        updates = []
        if exit_code == 0:
            lines = stdout.strip().splitlines()
            pattern = re.compile(r"^(\S+)\s+(\S+)\s+->\s+(\S+)$")
            for line in lines:
                match = pattern.match(line.strip())
                if match:
                    updates.append(match.groups())
            self._populate_table(updates)
        elif exit_code == 2:
            self._populating = True
            self.table.setRowCount(0)
            self._populating = False
        else:
            err_msg = stderr.strip() or f"Process exited with code {exit_code}"
            self.status_label.setText(f"Error checking updates: {err_msg}")
            self.install_btn.setEnabled(False)
            return

        self._refresh_extra_packages()

        if self.running_kernel_blocked:
            self.status_label.setText(
                f"Critical Error: Running kernel '{self.running_kernel_name}' has been removed from repositories. Updates blocked."
            )
            self.install_btn.setEnabled(False)
            other_kernels = [k for k in self.installed_kernels if k != self.running_kernel_name]
            other_msg = (
                f"Other installed kernel(s): {', '.join(other_kernels)}."
                if other_kernels
                else "No alternative installed kernels were detected."
            )
            QMessageBox.critical(
                self,
                "Critical Kernel Warning",
                f"The currently running kernel '{self.running_kernel_name}' has been removed from official repositories.\n\n"
                "System updates have been blocked to prevent system failure.\n\n"
                f"{other_msg}\n\n"
                "Manual intervention is required: please switch to or install a supported kernel before updating.",
            )
            return

        # Update status message
        orphans_count = self.orphans_table.rowCount()
        foreign_count = self.foreign_table.rowCount()
        drivers_count = self.drivers_table.rowCount()

        if not updates and not orphans_count and not foreign_count and not drivers_count:
            self.status_label.setText("No updates available")
        else:
            status_parts = []
            if updates:
                status_parts.append(f"{len(updates)} update(s)")
            if orphans_count:
                status_parts.append(f"{orphans_count} orphan(s)")
            if foreign_count:
                status_parts.append(f"{foreign_count} foreign package(s)")
            if drivers_count:
                status_parts.append(f"{drivers_count} driver(s) to rebuild")
            self.status_label.setText(", ".join(status_parts) + " available.")

        self.update_action_button_state()

    def _on_checkupdates_error(self, error):
        self.set_ui_busy(False)
        err_msg = self.process.errorString() if self.process else "Unknown error"
        self.status_label.setText(f"Failed to run checkupdates: {err_msg}")
        self.install_btn.setEnabled(False)

    def _populate_table(self, updates):
        self._populating = True
        self.table.setRowCount(len(updates))
        for row, (pkg, old_ver, new_ver) in enumerate(updates):
            pkg_item = _create_table_item(pkg, checkable=False)
            old_ver_item = _create_table_item(old_ver)
            new_ver_item = _create_table_item(new_ver)

            self.table.setItem(row, 0, pkg_item)
            self.table.setItem(row, 1, old_ver_item)
            self.table.setItem(row, 2, new_ver_item)
        self._populating = False
        self._update_tab_visibility()
        self.update_action_button_state()

    def _populate_orphans_table(self, orphans):
        self._populating = True
        self.orphans_table.setRowCount(len(orphans))
        for row, (pkg, ver) in enumerate(orphans):
            pkg_item = _create_table_item(pkg, checkable=True, checked=True)
            ver_item = _create_table_item(ver)

            self.orphans_table.setItem(row, 0, pkg_item)
            self.orphans_table.setItem(row, 1, ver_item)
        self._populating = False
        self._update_tab_visibility()
        self.update_action_button_state()

    def _populate_foreign_and_drivers_tables(self, foreign_pkgs):
        non_dkms_foreign = [(pkg, ver) for (pkg, ver) in foreign_pkgs if not is_dkms_package(pkg)]
        pkg_names = [pkg for pkg, _ in non_dkms_foreign]
        descriptions = get_pamac_info_descriptions(pkg_names)

        foreign_list = []
        drivers_list = []
        for pkg, ver in non_dkms_foreign:
            desc = descriptions.get(pkg, "")
            if is_driver_package(pkg, desc):
                drivers_list.append((pkg, ver, desc))
            else:
                foreign_list.append((pkg, ver, desc))

        self._populating = True
        self.foreign_table.setRowCount(len(foreign_list))
        for row, (pkg, ver, desc) in enumerate(foreign_list):
            pkg_item = _create_table_item(pkg, checkable=False)
            ver_item = _create_table_item(ver)
            desc_item = _create_table_item(desc)

            self.foreign_table.setItem(row, 0, pkg_item)
            self.foreign_table.setItem(row, 1, ver_item)
            self.foreign_table.setItem(row, 2, desc_item)

        self.drivers_table.setRowCount(len(drivers_list))
        for row, (pkg, ver, desc) in enumerate(drivers_list):
            pkg_item = _create_table_item(pkg, checkable=False)
            ver_item = _create_table_item(ver)
            desc_item = _create_table_item(desc)

            self.drivers_table.setItem(row, 0, pkg_item)
            self.drivers_table.setItem(row, 1, ver_item)
            self.drivers_table.setItem(row, 2, desc_item)
        self._populating = False
        self._update_tab_visibility()
        self.update_action_button_state()

    def _filter_installer_args(self, args: list[str]) -> list[str]:
        if "--build" in args and not check_aur_metadata():
            QMessageBox.warning(
                self,
                "AUR Metadata Warning",
                "AUR metadata (/var/lib/pacman/sync/packages-meta-ext-v1.json.gz) is missing or unavailable.\n\n"
                "Printer/scanner driver rebuilds will be skipped.",
            )
            filtered = []
            skip = False
            for token in args:
                if token == "--build":
                    skip = True
                    continue
                if skip:
                    if token.startswith("--"):
                        skip = False
                        filtered.append(token)
                    continue
                filtered.append(token)
            return filtered
        return list(args)

    def _launch_pamac_installer(self):
        target_args = self._filter_installer_args(self.pending_installer_args or self.pending_packages)
        if not target_args:
            self.set_ui_busy(False)
            self.status_label.setText("No remaining actions to perform.")
            return

        self.status_label.setText(f"Running pamac-installer for {len(target_args)} argument(s)...")
        self.process = QProcess(self)
        self.process.finished.connect(self._on_pamac_installer_finished)
        self.process.errorOccurred.connect(self._on_pamac_installer_error)
        self.process.start("pamac-installer", target_args)

    def start_install_flow(self):
        if self.running_kernel_blocked:
            QMessageBox.critical(
                self,
                "Critical Kernel Warning",
                f"The currently running kernel '{self.running_kernel_name}' has been removed from official repositories.\n\n"
                "System updates cannot proceed until you switch to a supported kernel.",
            )
            return

        args = self.get_installer_args()
        if not args:
            QMessageBox.warning(self, "No Actions", "There are no packages selected to update, remove, or rebuild.")
            return

        self.pending_installer_args = args
        self.pending_packages = self.get_selected_updates()
        self.set_ui_busy(True)

        if self._metadata_refreshed:
            self._launch_pamac_installer()
            return

        self.status_label.setText("Refreshing package databases...")

        # Step 1: Let the pamac daemon sync the databases (no authentication)
        if not self._start_db_refresh():
            self.set_ui_busy(False)
            self.status_label.setText("Metadata refresh failed.")
            QMessageBox.critical(
                self,
                "Critical Error",
                "Failed to refresh package metadata:\n\n"
                "Could not reach the pamac daemon on the system bus.",
            )

    def _start_db_refresh(self):
        bus = QDBusConnection.systemBus()
        if not bus.isConnected():
            return False

        # Kept on self: the async call is tied to the interface's lifetime
        self.pamac = QDBusInterface(PAMAC_SERVICE, PAMAC_PATH, PAMAC_IFACE, bus)
        if not self.pamac.isValid():
            self.pamac = None
            return False

        # The daemon tags every signal with the bus name of the client it
        # belongs to, so we can ignore transactions started by other clients.
        reply = self.pamac.call("GetSender")
        if not reply.arguments():
            self.pamac = None
            return False
        self.pamac_sender = reply.arguments()[0]
        self.refresh_action = ""
        self.refresh_error = ""

        for name, slot in PAMAC_SIGNALS:
            if not bus.connect(PAMAC_SERVICE, PAMAC_PATH, PAMAC_IFACE, name, self, SLOT(slot)):
                self._disconnect_pamac_signals()
                self.pamac = None
                return False
            self.pamac_signals.append((name, slot))

        # True == 'pacman -Syy' (force refresh)
        self.pamac.asyncCallWithArgumentList("StartTransRefresh", [True])
        return True

    def _disconnect_pamac_signals(self):
        bus = QDBusConnection.systemBus()
        for name, slot in self.pamac_signals:
            bus.disconnect(PAMAC_SERVICE, PAMAC_PATH, PAMAC_IFACE, name, self, SLOT(slot))
        self.pamac_signals = []

    def _is_own_transaction(self, sender):
        return self.pamac_sender is not None and sender == self.pamac_sender

    @Slot(str, str)
    def _on_pamac_action(self, sender, action):
        if self._is_own_transaction(sender) and action.strip():
            self.refresh_action = action.strip()
            self.status_label.setText(self.refresh_action)

    @Slot(str, str, str, float)
    def _on_pamac_progress(self, sender, action, status, progress):
        if not self._is_own_transaction(sender):
            return

        # The daemon only repeats the action when it changes, so keep showing
        # the last one instead of leaving a bare download counter behind.
        if action.strip():
            self.refresh_action = action.strip()

        parts = [part for part in (self.refresh_action, status.strip()) if part]
        if not parts:
            return
        text = " ".join(parts)
        if 0.0 < progress <= 1.0:
            text = f"{text} ({progress * 100:.0f}%)"
        self.status_label.setText(text)

    @Slot(str)
    def _on_pamac_start_waiting(self, sender):
        if self._is_own_transaction(sender):
            self.status_label.setText("Waiting for another package manager to quit...")

    @Slot(str)
    def _on_pamac_stop_waiting(self, sender):
        if self._is_own_transaction(sender):
            self.status_label.setText("Refreshing package databases...")

    @Slot(str, str)
    def _on_pamac_warning(self, sender, message):
        if self._is_own_transaction(sender) and message.strip():
            self.refresh_error = message.strip()
            self.status_label.setText(message.strip())

    @Slot(str, str, "QStringList")
    def _on_pamac_error(self, sender, message, details):
        if not self._is_own_transaction(sender):
            return

        lines = [line.strip() for line in [message, *details] if line.strip()]
        if lines:
            self.refresh_error = "\n".join(lines)
            self.status_label.setText(lines[0])

    @Slot(str, bool)
    def _on_db_refresh_finished(self, sender, success):
        if not self._is_own_transaction(sender):
            return

        self._disconnect_pamac_signals()
        self.pamac = None
        self.pamac_sender = None

        if not success:
            self.set_ui_busy(False)
            self.status_label.setText("Metadata refresh failed.")
            err_msg = self.refresh_error or "The pamac daemon failed to synchronize the package databases."
            QMessageBox.critical(
                self,
                "Critical Error",
                f"Failed to refresh package metadata:\n\n{err_msg}",
            )
            return

        self._metadata_refreshed = True

        # Step 2: On refresh success, launch pamac-installer
        self._launch_pamac_installer()

    def _on_pamac_installer_finished(self, exit_code, exit_status):
        self.status_label.setText("Installation completed. Refreshing updates...")
        # Step 3: Monitor pamac-installer and rerun checkupdates script after update
        self.check_for_updates()

    def _on_pamac_installer_error(self, error):
        self.set_ui_busy(False)
        err_msg = self.process.errorString() if self.process else "Unknown error"
        QMessageBox.critical(
            self,
            "Installer Error",
            f"Failed to run pamac-installer:\n\n{err_msg}",
        )
        self.check_for_updates()


def main():
    app = QApplication(sys.argv)
    window = UpdateApp()
    window.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
