#!/usr/bin/env python3
import sys
import re
from PySide6.QtCore import Qt, QProcess, Slot, SLOT
from PySide6.QtDBus import QDBusConnection, QDBusInterface
from PySide6.QtWidgets import (
    QApplication,
    QMainWindow,
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QPushButton,
    QTableWidget,
    QTableWidgetItem,
    QHeaderView,
    QLabel,
    QMessageBox,
    QCheckBox,
)

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


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

        self.process = None
        self.pending_packages = []
        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...", self)
        self.status_label.setStyleSheet("font-weight: bold; margin: 5px;")
        main_layout.addWidget(self.status_label)

        # Table Widget for 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)
        main_layout.addWidget(self.table)

        # Action Buttons Layout
        btn_layout = QHBoxLayout()

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

        self.force_refresh_box = QCheckBox("Force metadata?", self)
        self.force_refresh_box.setToolTip(
            "Re-download the metadata even if they look up to date."
        )
        btn_layout.addWidget(self.force_refresh_box)

        self.install_btn = QPushButton("Apply All Updates", self)
        self.install_btn.clicked.connect(self.start_install_flow)
        btn_layout.addWidget(self.install_btn)

        main_layout.addLayout(btn_layout)

    def set_ui_busy(self, busy: bool):
        self.refresh_btn.setEnabled(not busy)
        self.install_btn.setEnabled(not busy and self.table.rowCount() > 0)
        self.table.setEnabled(not busy)
        self.force_refresh_box.setEnabled(not busy)

    def get_all_packages(self):
        packages = []
        for row in range(self.table.rowCount()):
            pkg_item = self.table.item(row, 0)
            if pkg_item:
                packages.append(pkg_item.text())
        return packages

    def check_for_updates(self):
        self.set_ui_busy(True)
        self.status_label.setText("Checking for updates...")
        self.table.setRowCount(0)

        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 _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")

        if exit_code == 0:
            lines = stdout.strip().splitlines()
            updates = []
            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)
            self.status_label.setText(f"{len(updates)} update(s) available.")
            self.install_btn.setEnabled(len(updates) > 0)
        elif exit_code == 2:
            self.table.setRowCount(0)
            self.status_label.setText("No updates available")
            self.install_btn.setEnabled(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)

    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.table.setRowCount(len(updates))
        for row, (pkg, old_ver, new_ver) in enumerate(updates):
            pkg_item = QTableWidgetItem(pkg)
            pkg_item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)

            old_ver_item = QTableWidgetItem(old_ver)
            old_ver_item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)

            new_ver_item = QTableWidgetItem(new_ver)
            new_ver_item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)

            self.table.setItem(row, 0, pkg_item)
            self.table.setItem(row, 1, old_ver_item)
            self.table.setItem(row, 2, new_ver_item)

    def start_install_flow(self):
        packages = self.get_all_packages()
        if not packages:
            QMessageBox.warning(self, "No Updates", "There are no updates to install.")
            return

        self.pending_packages = packages
        self.set_ui_busy(True)
        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))

        # False == 'pacman -Sy', True == 'pacman -Syy'
        force = self.force_refresh_box.isChecked()
        self.pamac.asyncCallWithArgumentList("StartTransRefresh", [force])
        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

        # Step 2: On refresh success, launch pamac-installer
        self.status_label.setText(f"Running pamac-installer for {len(self.pending_packages)} 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", self.pending_packages)

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