#!/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")
OS_RELEASE_PATHS = [Path("/etc/os-release"), Path("/usr/lib/os-release")]


def parse_os_release(path: Path | str | None = None) -> dict[str, str]:
    target_paths = [Path(path)] if path else OS_RELEASE_PATHS
    data = {}
    for p in target_paths:
        if p.is_file():
            try:
                with open(p, "r", encoding="utf-8") as f:
                    for line in f:
                        line = line.strip()
                        if not line or line.startswith("#"):
                            continue
                        if "=" in line:
                            k, v = line.split("=", 1)
                            k = k.strip()
                            v = v.strip().strip("\"'")
                            data[k] = v
                return data
            except Exception:
                pass
    return data


def is_manjaro_system(path: Path | str | None = None) -> bool:
    info = parse_os_release(path)
    if not info:
        return False
    os_id = info.get("ID", "").lower()
    if os_id == "manjaro":
        return True
    name = info.get("NAME", "").lower()
    if "manjaro" in name:
        return True
    return False

# 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 parse_available_kernels(output: str, rt_mode: bool = False) -> list[str]:
    clean_text = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", output)
    kernels = []
    for line in clean_text.splitlines():
        match = re.search(r"\*\s*(linux\S+)", line)
        if match:
            k = match.group(1).strip()
            is_rt = "-rt" in k or k.endswith("-rt")
            if rt_mode and is_rt:
                kernels.append(k)
            elif not rt_mode and not is_rt:
                kernels.append(k)
    return kernels


def kernel_version_key(kernel_name: str) -> tuple[int, ...]:
    clean = kernel_name.removesuffix("-rt")
    m = re.match(r"^linux(\d)(\d+)$", clean)
    if m:
        return (int(m.group(1)), int(m.group(2)))
    digits = [int(s) for s in re.findall(r"\d+", clean)]
    return tuple(digits) if digits else (0,)


def get_pamac_info_versions(pkg_names: list[str]) -> dict[str, str]:
    if not pkg_names:
        return {}
    versions = {}
    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
            for line in res.stdout.splitlines():
                name_match = re.match(r"^\s*Name\s*:\s*(\S+)", line)
                if name_match:
                    current_pkg = name_match.group(1).strip()
                    continue
                version_match = re.match(r"^\s*Version\s*:\s*(\S+)", line)
                if version_match and current_pkg:
                    versions[current_pkg] = version_match.group(1).strip()
    except Exception:
        pass
    return versions


def is_rc_version(version: str) -> bool:
    if not version:
        return False
    return bool(re.search(r"rc", version, re.IGNORECASE))


def filter_non_rc_kernels(kernels: list[str]) -> list[str]:
    if not kernels:
        return []
    versions = get_pamac_info_versions(kernels)
    if not versions:
        return list(kernels)
    non_rc = []
    for k in kernels:
        ver = versions.get(k, "")
        if not is_rc_version(ver):
            non_rc.append(k)
    return non_rc


def get_latest_supported_kernel(kernels: list[str]) -> str | None:
    if not kernels:
        return None
    return max(kernels, key=kernel_version_key)


def query_available_kernels(rt_mode: bool = False) -> list[str]:
    try:
        res = subprocess.run(
            ["mhwd-kernel", "-l"],
            capture_output=True,
            text=True,
            check=False,
            timeout=5,
        )
        if res.returncode == 0 and res.stdout.strip():
            kernels = parse_available_kernels(res.stdout, rt_mode=rt_mode)
            return filter_non_rc_kernels(kernels)
    except Exception:
        pass
    return []


def reboot_system():
    try:
        subprocess.run(["systemctl", "reboot"], check=False)
    except Exception:
        try:
            subprocess.run(["reboot"], check=False)
        except Exception:
            pass


def prompt_reboot_dialog(parent=None) -> bool:
    msg_box = QMessageBox(parent)
    msg_box.setWindowTitle("System Restart Required")
    msg_box.setText(
        "The system update updated or replaced the Linux kernel.\n\n"
        "A system restart is required to apply the kernel changes.\n\n"
        "Would you like to restart now?"
    )
    msg_box.setIcon(QMessageBox.Icon.Information)
    reboot_btn = msg_box.addButton("Reboot Now", QMessageBox.ButtonRole.AcceptRole)
    later_btn = msg_box.addButton("Later", QMessageBox.ButtonRole.RejectRole)
    msg_box.setDefaultButton(reboot_btn)
    msg_box.exec()
    if msg_box.clickedButton() == reboot_btn:
        reboot_system()
        return True
    return False


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
    is_rt = "-rt" in running_kernel or running_kernel.endswith("-rt")
    if is_rt:
        pattern = re.compile(rf"^{re.escape(running_kernel)}(-.*)?$")
    else:
        pattern = re.compile(rf"^{re.escape(running_kernel)}(-(?!rt($|-)).*)?$")
    filtered = []
    found = False
    for item in packages:
        pkg_name = item[0] if isinstance(item, (tuple, list)) else item
        if pattern.match(pkg_name):
            found = True
        else:
            filtered.append(item)
    return filtered, found


def query_installed_kernel_packages(kernel_name: str) -> list[str]:
    if not kernel_name:
        return []
    try:
        res = subprocess.run(
            ["pamac", "list", "-i"],
            capture_output=True,
            text=True,
            check=False,
            timeout=10,
        )
        if res.returncode == 0 and res.stdout.strip():
            parsed = parse_pamac_list_output(res.stdout)
            is_rt = "-rt" in kernel_name or kernel_name.endswith("-rt")
            if is_rt:
                pattern = re.compile(rf"^{re.escape(kernel_name)}(-.*)?$")
            else:
                pattern = re.compile(rf"^{re.escape(kernel_name)}(-(?!rt($|-)).*)?$")
            pkgs = []
            for pkg, _ in parsed:
                if pattern.match(pkg):
                    pkgs.append(pkg)
            if kernel_name not in pkgs:
                pkgs.insert(0, kernel_name)
            else:
                pkgs.remove(kernel_name)
                pkgs.insert(0, kernel_name)
            return pkgs
    except Exception:
        pass
    return [kernel_name]


def map_replacement_kernel_packages(
    installed_kernel_pkgs: list[str],
    old_kernel: str,
    new_kernel: str,
) -> list[str]:
    if not new_kernel:
        return []
    if not old_kernel or not installed_kernel_pkgs:
        return [new_kernel]

    replacements = []
    for pkg in installed_kernel_pkgs:
        if pkg == old_kernel:
            replacements.append(new_kernel)
        elif pkg.startswith(f"{old_kernel}-"):
            suffix = pkg[len(old_kernel):]
            replacements.append(f"{new_kernel}{suffix}")
        else:
            replacements.append(pkg)

    seen = set()
    result = []
    for r in replacements:
        if r not in seen:
            seen.add(r)
            result.append(r)
    return result


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._reboot_required = False
        self.replacement_kernel = None
        self.replacement_packages = []
        self.replaced_kernel_packages = []
        self._last_removed_orphans = []
        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("Apply Maintenance", 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):
        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 = list(self.get_selected_updates())
        orphans = self.get_selected_orphans()
        foreign = self.get_selected_foreign()
        drivers = self.get_selected_drivers()

        if self.replacement_packages:
            for pkg in reversed(self.replacement_packages):
                if pkg not in updates:
                    updates.insert(0, pkg)
        elif self.replacement_kernel and self.replacement_kernel not in updates:
            updates.insert(0, self.replacement_kernel)

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

        to_remove = []
        if self.replaced_kernel_packages:
            for pkg in self.replaced_kernel_packages:
                if pkg not in to_remove:
                    to_remove.append(pkg)
        elif self.replacement_kernel and self.running_kernel_name:
            if self.running_kernel_name not in to_remove:
                to_remove.append(self.running_kernel_name)

        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.replacement_kernel = None
        self.replacement_packages = []
        self.replaced_kernel_packages = []
        self.running_kernel_name = None
        self.installed_kernels = []
        self._last_removed_orphans = []
        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)

        if (orphan_kernel_hit or foreign_kernel_hit) and running_kernel:
            is_rt = "-rt" in running_kernel or running_kernel.endswith("-rt")
            available = query_available_kernels(rt_mode=is_rt)
            replacement = get_latest_supported_kernel(available)
            if replacement:
                self.replacement_kernel = replacement
                installed_pkgs = query_installed_kernel_packages(running_kernel)
                self.replaced_kernel_packages = installed_pkgs
                self.replacement_packages = map_replacement_kernel_packages(
                    installed_pkgs, running_kernel, replacement
                )
                modules_msg = ""
                if len(self.replacement_packages) > 1:
                    mapped_str = ", ".join(self.replacement_packages)
                    modules_msg = f"\n\nInstalled kernel packages and modules to replace:\n{mapped_str}"
                QMessageBox.information(
                    self,
                    "Kernel Replacement Notice",
                    f"The currently running kernel '{running_kernel}' has been removed from official repositories.\n\n"
                    f"It will be automatically replaced with supported kernel '{replacement}'.{modules_msg}",
                )

        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):
        try:
            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
                self._update_tab_visibility()
            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()

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

            if self.replacement_kernel:
                modules_count = len(self.replacement_packages) if self.replacement_packages else 1
                if modules_count > 1:
                    self.status_label.setText(
                        f"Unsupported running kernel '{self.running_kernel_name}' and {modules_count - 1} extra module(s) will be replaced with '{self.replacement_kernel}'."
                    )
                else:
                    self.status_label.setText(
                        f"Unsupported running kernel '{self.running_kernel_name}' will be replaced with '{self.replacement_kernel}'."
                    )
            elif 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()
        finally:
            self.set_ui_busy(False)

    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):
        args = self.get_installer_args()
        if not args:
            QMessageBox.warning(self, "No Actions", "There are no packages selected to update, remove, or rebuild.")
            return

        selected_updates = self.get_selected_updates()
        if (
            (self.running_kernel_name and self.running_kernel_name in selected_updates)
            or self.replacement_kernel
            or self.replacement_packages
        ):
            self._reboot_required = True

        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):
        if exit_code == 0:
            remaining_orphans = query_orphans()
            orphan_pkgs = [pkg for pkg, _ in remaining_orphans]
            if orphan_pkgs and set(orphan_pkgs) != set(self._last_removed_orphans):
                self._last_removed_orphans = list(orphan_pkgs)
                self.status_label.setText(f"Removing {len(orphan_pkgs)} newly orphaned package(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", ["--remove", *orphan_pkgs])
                return

        self._last_removed_orphans = []
        if self._reboot_required:
            self._reboot_required = False
            prompt_reboot_dialog(self)

        self.status_label.setText("Installation completed. Refreshing updates...")
        self.check_for_updates()

    def _on_pamac_installer_error(self, error):
        self.set_ui_busy(False)
        self._last_removed_orphans = []
        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.instance() or QApplication(sys.argv)
    if not is_manjaro_system():
        err_msg = "kiss-up is designed exclusively for Manjaro Linux."
        sys.stderr.write(f"Error: {err_msg}\n")
        QMessageBox.critical(None, "Unsupported System", err_msg)
        sys.exit(1)

    window = UpdateApp()
    window.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
