#!/usr/bin/python3

# This script is a GUI for slakfinder <https://slackware.nl/slakfinder/index.php>
# Requires BeautifulSoup4 + (PyQt6 or PyQt5)
# License MIT Anagnostakis Ioannis 2025 GR
#
# --- PyQt6 added in Slackware-current (21/6/2026) ---
# --- PyQt5/PyQt6 dual support ---

import sys
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

# ---------------------------------------------------------------- Qt bindings
try:
    from PyQt6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout,
                                 QLineEdit, QPushButton, QLabel, QTableWidget,
                                 QTableWidgetItem, QHeaderView, QMessageBox,
                                 QAbstractItemView)
    from PyQt6.QtGui import (QDesktopServices, QColor, QFont, QPalette,
                             QKeySequence, QShortcut)
    from PyQt6.QtCore import QUrl, Qt
    QT_API = "PyQt6"
except ImportError:
    from PyQt5.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout,
                                 QLineEdit, QPushButton, QLabel, QTableWidget,
                                 QTableWidgetItem, QHeaderView, QMessageBox,
                                 QAbstractItemView, QShortcut)
    from PyQt5.QtGui import QDesktopServices, QColor, QFont, QPalette, QKeySequence
    from PyQt5.QtCore import QUrl, Qt
    QT_API = "PyQt5"


def qt_enum(scope, group, name):
    """Return scope.group.name (Qt6 scoped) falling back to scope.name (Qt5 flat)."""
    grp = getattr(scope, group, None)
    if grp is not None:
        val = getattr(grp, name, None)
        if val is not None:
            return val
    return getattr(scope, name)


ALIGN_CENTER = qt_enum(Qt, "AlignmentFlag", "AlignCenter")
ALIGN_VCENTER = qt_enum(Qt, "AlignmentFlag", "AlignVCenter")
ALIGN_RIGHT = qt_enum(Qt, "AlignmentFlag", "AlignRight")
ITEM_EDITABLE = qt_enum(Qt, "ItemFlag", "ItemIsEditable")
ITEM_SELECTABLE = qt_enum(Qt, "ItemFlag", "ItemIsSelectable")
ITEM_ENABLED = qt_enum(Qt, "ItemFlag", "ItemIsEnabled")
KEY_RETURN = qt_enum(Qt, "Key", "Key_Return")
KEY_ENTER = qt_enum(Qt, "Key", "Key_Enter")
KEY_ESCAPE = qt_enum(Qt, "Key", "Key_Escape")
CURSOR_WAIT = qt_enum(Qt, "CursorShape", "WaitCursor")

HDR_STRETCH = qt_enum(QHeaderView, "ResizeMode", "Stretch")
HDR_FIT = qt_enum(QHeaderView, "ResizeMode", "ResizeToContents")
SELECT_ROWS = qt_enum(QAbstractItemView, "SelectionBehavior", "SelectRows")
SELECT_SINGLE = qt_enum(QAbstractItemView, "SelectionMode", "SingleSelection")
NO_EDIT = qt_enum(QAbstractItemView, "EditTrigger", "NoEditTriggers")
PAL_WINDOW = qt_enum(QPalette, "ColorRole", "Window")

BASE_URL = "https://slackware.nl/slakfinder/"

COL_RANK, COL_PKG, COL_VER, COL_ARCH, COL_DISTRO, COL_REPO, COL_LOC, COL_DL = range(8)


# ------------------------------------------------------------- sorting helpers
class NumItem(QTableWidgetItem):
    """Sorts by float, not by string: 9.1 must come before 17.5."""

    def __init__(self, text):
        super().__init__(text)
        try:
            self._v = float(text)
        except (TypeError, ValueError):
            self._v = -1.0

    def __lt__(self, other):
        if isinstance(other, NumItem):
            return self._v < other._v
        return super().__lt__(other)


def _verkey(text):
    """0.5.10 must sort after 0.5.6, which a plain string compare gets wrong."""
    out = []
    for chunk in str(text).replace("-", ".").replace("_", ".").split("."):
        if chunk.isdigit():
            out.append((0, int(chunk), ""))
        else:
            head = ""
            while chunk and chunk[0].isdigit():
                head += chunk[0]
                chunk = chunk[1:]
            out.append((0, int(head), chunk) if head else (1, 0, chunk))
    return out


class VerItem(QTableWidgetItem):
    def __init__(self, text):
        super().__init__(text)
        self._k = _verkey(text)

    def __lt__(self, other):
        if isinstance(other, VerItem):
            return self._k < other._k
        return super().__lt__(other)


class SlakfinderApp(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Slackware Package Finder")
        self.setGeometry(200, 200, 1080, 640)
        self.setMinimumSize(760, 400)

        self._dark = self._is_dark()
        self._link = QColor("#5aa9ff") if self._dark else QColor("#0b5fbf")
        self._muted = QColor("#8b93a1") if self._dark else QColor("#6b7280")
        self._accent = QColor("#7fc4a0") if self._dark else QColor("#2f7d5a")

        root = QVBoxLayout()
        root.setContentsMargins(16, 14, 16, 12)
        root.setSpacing(10)

        # ---------------------------------------------------------- header row
        title = QLabel("Slackware Package Finder")
        title.setObjectName("title")
        subtitle = QLabel("powered by slakfinder \u00b7 %s" % QT_API)
        subtitle.setObjectName("subtitle")

        head = QVBoxLayout()
        head.setSpacing(0)
        head.addWidget(title)
        head.addWidget(subtitle)

        self.home_button = QPushButton("Open in browser")
        self.home_button.setObjectName("ghost")
        self.home_button.setCursor(qt_enum(Qt, "CursorShape", "PointingHandCursor"))
        self.home_button.clicked.connect(self.open_slakfinder_home)

        topbar = QHBoxLayout()
        topbar.addLayout(head)
        topbar.addStretch(1)
        topbar.addWidget(self.home_button)
        root.addLayout(topbar)

        # ------------------------------------------------------------ searchbar
        self.search_bar = QLineEdit(self)
        self.search_bar.setPlaceholderText("Package name\u2026    (Ctrl+L to focus, Esc to clear)")
        self.search_bar.setClearButtonEnabled(True)
        self.search_bar.setMinimumHeight(38)
        self.search_bar.returnPressed.connect(self.search_packages)

        self.search_button = QPushButton("Search", self)
        self.search_button.setObjectName("primary")
        self.search_button.setMinimumHeight(38)
        self.search_button.setMinimumWidth(110)
        self.search_button.setCursor(qt_enum(Qt, "CursorShape", "PointingHandCursor"))
        self.search_button.clicked.connect(self.search_packages)

        bar = QHBoxLayout()
        bar.setSpacing(8)
        bar.addWidget(self.search_bar, 1)
        bar.addWidget(self.search_button)
        root.addLayout(bar)

        # ---------------------------------------------------------------- table
        self.table = QTableWidget()
        self.table.setColumnCount(8)
        self.table.setHorizontalHeaderLabels(
            ["Rank", "Package", "Version", "Arch", "Distro",
             "Repository", "Location", "Download"])
        self.table.verticalHeader().setVisible(False)
        self.table.setShowGrid(False)
        self.table.setAlternatingRowColors(True)
        self.table.setSelectionBehavior(SELECT_ROWS)
        self.table.setSelectionMode(SELECT_SINGLE)
        self.table.setEditTriggers(NO_EDIT)
        self.table.setWordWrap(False)
        self.table.setSortingEnabled(True)
        self.table.verticalHeader().setDefaultSectionSize(30)
        self.table.cellDoubleClicked.connect(self.open_link)

        hdr = self.table.horizontalHeader()
        hdr.setHighlightSections(False)
        for c in (COL_RANK, COL_VER, COL_ARCH, COL_DISTRO, COL_REPO):
            hdr.setSectionResizeMode(c, HDR_FIT)
        hdr.setSectionResizeMode(COL_PKG, HDR_STRETCH)
        hdr.setSectionResizeMode(COL_LOC, HDR_STRETCH)
        hdr.setSectionResizeMode(COL_DL, HDR_STRETCH)
        root.addWidget(self.table, 1)

        # --------------------------------------------------------- status strip
        self.status = QLabel("Type a package name and press Enter.")
        self.status.setObjectName("status")
        self.hint = QLabel("Double-click a row to open its directory")
        self.hint.setObjectName("status")
        self.hint.setAlignment(ALIGN_RIGHT | ALIGN_VCENTER)

        foot = QHBoxLayout()
        foot.addWidget(self.status, 1)
        foot.addWidget(self.hint)
        root.addLayout(foot)

        self.setLayout(root)
        self.setStyleSheet(self._qss())

        QShortcut(QKeySequence("Ctrl+L"), self, activated=self.search_bar.setFocus)
        QShortcut(QKeySequence("Ctrl+F"), self, activated=self.search_bar.setFocus)
        self.search_bar.setFocus()

    # ------------------------------------------------------------------ styling
    def _is_dark(self):
        c = self.palette().color(PAL_WINDOW)
        return (c.red() * 299 + c.green() * 587 + c.blue() * 114) / 1000 < 128

    def _qss(self):
        if self._dark:
            field_bg, field_br, field_fo = "#1e222a", "#333a45", "#e6e9ef"
            prim_bg, prim_hv, prim_fg = "#3f8cff", "#529aff", "#ffffff"
            ghost_fg, ghost_br = "#9aa3b2", "#333a45"
            head_bg, head_fg, sub_fg = "#171a20", "#aeb6c4", "#7d8595"
        else:
            field_bg, field_br, field_fo = "#ffffff", "#d3d8e0", "#1b1f27"
            prim_bg, prim_hv, prim_fg = "#2f6fd0", "#3a7ee0", "#ffffff"
            ghost_fg, ghost_br = "#5a6474", "#d3d8e0"
            head_bg, head_fg, sub_fg = "#f2f4f8", "#4a5261", "#8b93a1"

        return """
        QLabel#title    { font-size: 17px; font-weight: 600; }
        QLabel#subtitle { color: %(sub)s; font-size: 11px; }
        QLabel#status   { color: %(sub)s; font-size: 11px; }

        QLineEdit {
            background: %(fbg)s; color: %(ffo)s;
            border: 1px solid %(fbr)s; border-radius: 8px;
            padding: 6px 12px; font-size: 13px;
        }
        QLineEdit:focus { border: 1px solid %(pbg)s; }

        QPushButton#primary {
            background: %(pbg)s; color: %(pfg)s;
            border: none; border-radius: 8px;
            font-size: 13px; font-weight: 600;
        }
        QPushButton#primary:hover    { background: %(phv)s; }
        QPushButton#primary:disabled { background: %(fbr)s; color: %(sub)s; }

        QPushButton#ghost {
            background: transparent; color: %(gfg)s;
            border: 1px solid %(gbr)s; border-radius: 7px;
            padding: 5px 12px; font-size: 12px;
        }
        QPushButton#ghost:hover { border-color: %(pbg)s; color: %(pbg)s; }

        QTableWidget {
            border: 1px solid %(fbr)s; border-radius: 8px;
            background: %(fbg)s; gridline-color: %(fbr)s;
        }
        QTableWidget::item { padding: 4px 8px; }
        QHeaderView::section {
            background: %(hbg)s; color: %(hfg)s;
            padding: 7px 8px; border: none;
            border-bottom: 1px solid %(fbr)s;
            font-size: 11px; font-weight: 600;
        }
        QHeaderView::section:hover { color: %(pbg)s; }
        """ % dict(fbg=field_bg, fbr=field_br, ffo=field_fo, pbg=prim_bg,
                   phv=prim_hv, pfg=prim_fg, gfg=ghost_fg, gbr=ghost_br,
                   hbg=head_bg, hfg=head_fg, sub=sub_fg)

    # ------------------------------------------------------------------- search
    def search_packages(self):
        query = self.search_bar.text().strip()
        if not query:
            return

        self.table.setRowCount(0)
        self._busy(True, "Searching for \u201c%s\u201d\u2026" % query)

        params = {'act': 'search', 'name': query, 'repo': 0}
        try:
            response = requests.get(BASE_URL, params=params, timeout=20)
            response.raise_for_status()
        except requests.RequestException as exc:
            self._busy(False, "Network error.")
            QMessageBox.warning(self, "Network error", str(exc))
            return

        soup = BeautifulSoup(response.text, 'html.parser')

        results = []
        result_table = soup.find('table', {'class': 'results'})

        if result_table:
            rows = result_table.find_all('tr')[1:]  # Skip the header row
            for row in rows:
                cols = row.find_all('td')
                if len(cols) > 6:
                    rank = cols[0].text.strip()
                    package = cols[1].text.strip()
                    version = cols[2].text.strip()
                    arch = cols[3].text.strip()
                    distro = cols[4].text.strip()
                    repository = cols[5].text.strip()
                    location = cols[6].text.strip()

                    download_link = None
                    download_tag = cols[6].find('a', href=True)
                    if download_tag:
                        download_link = urljoin(BASE_URL, download_tag['href'])

                    # slakfinder puts the whole package description in the
                    # title= attribute of the package link; free tooltip.
                    tip = ""
                    tag = cols[1].find('a')
                    if tag is not None and tag.has_attr('title'):
                        tip = tag['title'].strip()

                    results.append((rank, package, version, arch, distro,
                                    repository, location, download_link, tip))

        self._busy(False)

        if not results:
            self.status.setText("No results for \u201c%s\u201d." % query)
            return

        self._fill(results, query)

    def _busy(self, on, message=None):
        self.search_button.setEnabled(not on)
        self.search_button.setText("Searching\u2026" if on else "Search")
        if message:
            self.status.setText(message)
        if on:
            QApplication.setOverrideCursor(CURSOR_WAIT)
        else:
            QApplication.restoreOverrideCursor()
        QApplication.processEvents()

    def _fill(self, results, query):
        # Sorting MUST be off while inserting, or Qt reorders rows mid-populate
        # and cells end up on the wrong lines.
        self.table.setSortingEnabled(False)
        self.table.setRowCount(len(results))

        mono = QFont("monospace")
        mono.setStyleHint(qt_enum(QFont, "StyleHint", "Monospace"))
        mono.setPointSize(max(8, self.font().pointSize() - 1))

        repos = set()
        for row, (rank, package, version, arch, distro,
                  repository, location, download_url, tip) in enumerate(results):
            repos.add(repository)

            r = NumItem(rank)
            r.setTextAlignment(ALIGN_RIGHT | ALIGN_VCENTER)
            r.setForeground(self._muted)
            self.table.setItem(row, COL_RANK, r)

            p = QTableWidgetItem(package)
            f = p.font()
            f.setBold(True)
            p.setFont(f)
            if tip:
                p.setToolTip(tip)
            self.table.setItem(row, COL_PKG, p)

            v = VerItem(version)
            v.setFont(mono)
            self.table.setItem(row, COL_VER, v)

            for col, value in ((COL_ARCH, arch), (COL_DISTRO, distro)):
                it = QTableWidgetItem(value)
                it.setFont(mono)
                it.setForeground(self._muted)
                self.table.setItem(row, col, it)

            rp = QTableWidgetItem(repository)
            rp.setForeground(self._accent)
            self.table.setItem(row, COL_REPO, rp)

            lo = QTableWidgetItem(location)
            lo.setFont(mono)
            lo.setForeground(self._muted)
            self.table.setItem(row, COL_LOC, lo)

            if download_url:
                self.table.setItem(row, COL_DL, self.create_clickable_item(download_url))
            else:
                self.table.setItem(row, COL_DL, QTableWidgetItem("No download"))

        self.table.setSortingEnabled(True)
        self.status.setText(
            "%d result%s for \u201c%s\u201d  \u00b7  %d repositor%s"
            % (len(results), "" if len(results) == 1 else "s", query,
               len(repos), "y" if len(repos) == 1 else "ies"))

    # ------------------------------------------------------------------ helpers
    def create_clickable_item(self, text):
        item = QTableWidgetItem(text)
        item.setForeground(self._link)
        item.setTextAlignment(ALIGN_CENTER)
        item.setToolTip("Double-click or press Enter to open this directory")
        item.setFlags((item.flags() & ~ITEM_EDITABLE) | ITEM_SELECTABLE | ITEM_ENABLED)
        return item

    def _open(self, url):
        if url and url != "No download":
            QDesktopServices.openUrl(QUrl(url))

    def _open_row(self, row):
        """Open the row's directory whichever cell happens to be current."""
        if row < 0:
            return
        item = self.table.item(row, COL_DL)
        if item:
            self._open(item.text())

    def open_link(self, row, column):
        self._open_row(row)

    def open_slakfinder_home(self):
        QDesktopServices.openUrl(QUrl("https://slackware.nl/slakfinder/index.php"))

    def keyPressEvent(self, event):
        if event.key() == KEY_ESCAPE:
            self.search_bar.clear()
            self.search_bar.setFocus()
            event.accept()
            return
        if event.key() in (KEY_RETURN, KEY_ENTER):
            if self.table.hasFocus():
                self._open_row(self.table.currentRow())
                event.accept()
                return
        super().keyPressEvent(event)


if __name__ == "__main__":
    # Qt6 scales for HiDPI on its own; Qt5 needs to be told, or the window
    # comes out tiny on a high-resolution panel. Both attributes are gone in
    # Qt6, hence the guards.
    if QT_API == "PyQt5":
        for _attr in ("AA_EnableHighDpiScaling", "AA_UseHighDpiPixmaps"):
            _v = getattr(Qt, _attr, None)
            if _v is not None:
                QApplication.setAttribute(_v, True)

    app = QApplication(sys.argv)
    app.setApplicationName("Slackware Package Finder")
    window = SlakfinderApp()
    window.show()
    run = getattr(app, "exec", None) or app.exec_
    sys.exit(run())
