#!/usr/bin/env python3
import sys
import re
import subprocess
import urllib.request
import xml.etree.ElementTree as ET
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,
    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"

# 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


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.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 All Updates", self)
        self.install_btn.clicked.connect(self.start_install_flow)
        btn_layout.addWidget(self.install_btn)

        main_layout.addLayout(btn_layout)

    @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)
        self.install_btn.setEnabled(not busy and self.table.rowCount() > 0)
        self.help_btn.setEnabled(not busy)
        self.table.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))

        # 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

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