#!/usr/bin/env python3
# wallhaven-dl: search wallhaven.cc and download wallpapers, with thumbnail previews.
#
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

import json
import os
from collections import Counter
import re
import subprocess
import sys
import time
from pathlib import Path
from urllib.parse import urlencode

from PyQt6.QtCore import QFile, QSettings, QSize, Qt, QTimer, QUrl
from PyQt6.QtGui import QColor, QIcon, QImageReader, QKeySequence, QPainter, QPixmap, QShortcut
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from PyQt6.QtWidgets import (QAbstractItemView, QApplication, QButtonGroup, QCheckBox,
                             QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QLineEdit,
                             QListView, QListWidget, QListWidgetItem, QMainWindow,
                             QMessageBox, QProgressBar, QPushButton, QScrollArea, QSplitter, QStyle,
                             QTabBar, QToolButton, QVBoxLayout, QWidget)

API = "https://wallhaven.cc/api/v1/"
LIBRARY = Path.home() / "Pictures" / "wallpapers"  # a wallpaper anywhere under here counts as downloaded
DEFAULT_DIR = LIBRARY / "wallhaven"
SORTS = ["date_added", "toplist", "views", "favorites", "random", "relevance"]
ROLE = Qt.ItemDataRole.UserRole
THUMB = ROLE.value + 1  # item data: the bare thumbnail, so the overlay can be added or removed
SAVED = QColor("#6fcf6f")  # overlay color of wallpapers already in the save folder
# the only values the API's colors filter accepts
COLORS = ["660000", "990000", "cc0000", "cc3333", "ea4c88", "993399", "663399", "333399",
          "0066cc", "0099cc", "66cccc", "77cc33", "669900", "336600", "666600", "999900",
          "cccc33", "ffff00", "ffcc33", "ff9900", "ff6600", "cc6633", "996633", "663300",
          "000000", "999999", "cccccc", "ffffff", "424153"]
PALETTE = [tuple(int(c[k:k + 2], 16) for k in (0, 2, 4)) for c in COLORS]
# wallhaven's own file names carry the id, also kept in renamed copies like wallhaven-<id>_2560x1080.png
WALL_ID = re.compile(r"wallhaven-([a-z0-9]+)[._]")
PASS_ENTRY = os.environ.get("WALLHAVEN_PASS_ENTRY", "wallhaven.cc/api-key")
APIKEY = ""  # filled from pass at startup, needed for NSFW results
API_LIMIT = 40  # calls per minute; wallhaven allows 45, keep some headroom


def read_apikey(entry=PASS_ENTRY):
    """API key from pass: the entry's 'apikey:' line if it has one, else its first line."""
    try:
        out = subprocess.run(["pass", "show", entry], capture_output=True,
                             text=True, check=True).stdout
    except (OSError, subprocess.CalledProcessError):
        return ""  # no pass or no entry: run SFW/Sketchy only
    lines = out.splitlines()
    for line in lines:
        key, sep, value = line.partition(":")
        if sep and key.strip().lower() == "apikey":
            return value.strip()
    return lines[0].strip() if lines else ""


def api_delay(sent, now, limit=API_LIMIT, window=60.0):
    """Seconds to wait before the next API call, given the send times of earlier ones."""
    recent = [t for t in sent if now - t < window]
    return 0.0 if len(recent) < limit else window - (now - recent[-limit])


def nearest_color(r, g, b):
    """Index into COLORS of the palette color closest to r, g, b. Uses the "redmean"
    weighted RGB distance, a cheap approximation of how different colors look."""
    def dist(c):
        rm = (r + c[0]) / 2
        return ((2 + rm / 256) * (r - c[0]) ** 2 + 4 * (g - c[1]) ** 2
                + (2 + (255 - rm) / 256) * (b - c[2]) ** 2)
    return min(range(len(PALETTE)), key=lambda i: dist(PALETTE[i]))


def palette_histogram(pixels):
    """Counter of COLORS indexes over an iterable of (r, g, b) pixels."""
    return Counter(nearest_color(*px) for px in pixels)


NEUTRALS = {COLORS.index(c) for c in ("000000", "999999", "cccccc", "ffffff", "424153")}


def search_pick(hist, min_share=0.10):
    """Palette color to search by: the top chromatic one if it covers min_share of the
    image, else the top overall. Greys dominate most images and make a vague search."""
    total = sum(hist.values())
    for i, n in hist.most_common():
        if i not in NEUTRALS and n >= total * min_share:
            return i
    return hist.most_common(1)[0][0]


def target(w, h, monitors):
    """Index of the monitor a w x h image is meant for: the largest one in the same
    orientation, or the largest overall when no monitor shares its orientation."""
    same = [i for i, (mw, mh) in enumerate(monitors) if (mh > mw) == (h > w)]
    return max(same or range(len(monitors)), key=lambda i: monitors[i][0] * monitors[i][1])


def downloaded_overlay(pix):
    """Copy of pix, dimmed, with a big green check mark over the word "downloaded"."""
    out = QPixmap(pix)
    w, h = out.width(), out.height()
    p = QPainter(out)
    p.fillRect(out.rect(), QColor(0, 0, 0, 140))
    p.setPen(SAVED)
    font = p.font()
    font.setBold(True)
    font.setPixelSize(h * 2 // 5)
    p.setFont(font)
    p.drawText(0, 0, w, h * 3 // 5, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignBottom, "✓")
    font.setPixelSize(h // 9)
    p.setFont(font)
    p.drawText(0, h * 3 // 5, w, h * 2 // 5, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop,
               "downloaded")
    p.end()
    return out


def library_files(*roots):
    """Every file under roots, skipping hidden folders and unfinished .part downloads."""
    for root in roots:
        for dirpath, dirs, files in os.walk(root):
            dirs[:] = sorted(d for d in dirs if not d.startswith("."))
            for f in sorted(files):
                if not f.endswith(".part"):
                    yield Path(dirpath, f)


def scan_library(*roots):
    """Map wallhaven id -> path for every wallhaven-named file under roots."""
    found = {}
    for p in library_files(*roots):
        m = WALL_ID.match(p.name)
        if m:
            found.setdefault(m[1], p)
    return found


def make_grid():
    grid = QListWidget()
    grid.setViewMode(QListView.ViewMode.IconMode)
    grid.setResizeMode(QListView.ResizeMode.Adjust)
    grid.setMovement(QListView.Movement.Static)
    grid.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
    grid.setIconSize(QSize(300, 200))
    grid.setGridSize(QSize(310, 232))  # fixed cells: icons load late, so items can't size themselves
    return grid


class Reference(QLabel):
    """Floating window showing the local image a low-res search started from, so it stays
    in view while browsing candidates. Move and resize it freely, close it when done."""

    def __init__(self, parent):
        super().__init__(parent, Qt.WindowType.Tool)  # stays above the main window
        self.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.setMinimumSize(160, 100)  # explicit, so the pixmap's size can't stop shrinking
        self.pix = QPixmap()

    def show_image(self, path):
        self.path = path
        self.pix = QPixmap(path)
        self.setWindowTitle(f"original: {Path(path).name} ({self.pix.width()}x{self.pix.height()})")
        if not self.isVisible():
            self.resize(self.pix.size().scaled(360, 360, Qt.AspectRatioMode.KeepAspectRatio))
            self.show()
        self.fit()

    def fit(self):
        if not self.pix.isNull():
            self.setPixmap(self.pix.scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio,
                                           Qt.TransformationMode.SmoothTransformation))

    def resizeEvent(self, event):
        super().resizeEvent(event)
        self.fit()

    def closeEvent(self, event):
        super().closeEvent(event)
        self.parent().replace.hide()  # nothing left to replace


class Main(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("wallhaven-dl")
        self.resize(1300, 850)
        self.net = QNetworkAccessManager(self)
        self.gen = 0  # bumped on each new search, so stale replies are dropped
        self.page = self.last_page = 1
        self.seed = None
        self.sent = []  # send times of API calls, for the rate limit
        self.later = QTimer(self, singleShot=True, interval=400)  # debounces filter changes
        self.settings = QSettings("wallhaven-dl", "wallhaven-dl")  # ~/.config/wallhaven-dl/
        self.save_dir = Path(self.settings.value("save_dir", str(DEFAULT_DIR)))
        self.have = {}  # wallhaven id -> file on disk, see refresh_library()
        self.reference = Reference(self)

        self.purity = QTabBar()
        for name in ("SFW", "Sketchy", "NSFW"):
            self.purity.addTab(name)
        self.purity.setTabEnabled(2, bool(APIKEY))
        if not APIKEY:
            self.purity.setTabToolTip(2, f"no API key found in pass entry {PASS_ENTRY}")

        self.query = QLineEdit(placeholderText="tags, @user, id:<tag id>")
        self.sort = QComboBox()
        self.sort.addItems(SORTS)
        # one entry per monitor: wallpapers at least as big as it, in its orientation
        self.fit = QComboBox()
        screens = QApplication.screens()
        self.monitors = []
        for s in screens:
            r = s.devicePixelRatio()
            w, h = round(s.size().width() * r), round(s.size().height() * r)
            self.monitors.append((w, h))
            self.fit.addItem(f"fits {s.name()} ({w}x{h})",
                             {"atleast": f"{w}x{h}", "ratios": "portrait" if h > w else "landscape"})
        # explicit 1x1 rather than nothing, so the account's own resolution filter never applies
        self.fit.addItem("any size", {"atleast": "1x1"})
        self.fit.setCurrentIndex(screens.index(QApplication.primaryScreen()))
        # color swatches, one exclusive row: id 0 is "any", id i is COLORS[i - 1]
        self.color = QButtonGroup(self)
        swatches = QHBoxLayout()
        swatches.setContentsMargins(0, 0, 0, 0)
        swatches.setSpacing(4)
        swatches.addStretch()  # with the one after the buttons: centered when it fits
        for i, hexcode in enumerate([None, *COLORS]):
            b = QToolButton(checkable=True, checked=not i)
            b.setFixedSize(30 if i else 46, 30)  # "any" needs room for its text inside the thick border
            b.setText("any" if not i else "")
            b.setToolTip("any color" if not i else f"#{hexcode}")
            b.setAccessibleName(b.toolTip())
            bg = ""
            if hexcode:
                r, g, bl = (int(hexcode[k:k + 2], 16) for k in (0, 2, 4))
                ink = "black" if 0.299 * r + 0.587 * g + 0.114 * bl > 150 else "white"
                bg = f"background: #{hexcode}; color: {ink}; font-weight: bold;"
                # a check mark in a contrasting ink, the border alone vanishes on similar colors
                b.toggled.connect(lambda on, b=b: b.setText("✓" if on else ""))
            b.setStyleSheet(f"QToolButton {{ {bg} border: 1px solid palette(mid); border-radius: 4px; }}"
                            "QToolButton:checked { border: 3px solid palette(highlight); }")
            self.color.addButton(b, i)
            swatches.addWidget(b)
        swatches.addStretch()
        row = QWidget()
        row.setLayout(swatches)
        # fits on wide windows, scrolls sideways on narrow ones
        self.colors_row = QScrollArea(widgetResizable=True, frameShape=QScrollArea.Shape.NoFrame)
        self.colors_row.setWidget(row)
        self.colors_row.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.colors_row.setFixedHeight(
            34 + self.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent))
        # always sent, so the account's category preferences never apply either
        self.cats = [QCheckBox(name, checked=True) for name in ("General", "Anime", "People")]
        search = QPushButton("Search")
        self.more = QPushButton("Load more", enabled=False)
        lowres = QPushButton("Find low-res on disk")
        self.folder = QPushButton()
        self.folder.setToolTip("folder downloads are saved to")
        self.show_folder()
        download = QPushButton("Download selected")

        self.grid = make_grid()
        self.blank = QPixmap(300, 200)  # placeholder until the thumbnail arrives
        self.blank.fill(Qt.GlobalColor.transparent)

        tabs = QHBoxLayout()
        tabs.addStretch()
        tabs.addWidget(self.purity)
        tabs.addStretch()
        bar = QHBoxLayout()
        bar.setSpacing(10)
        bar.addWidget(self.query, stretch=1)
        for w in (self.sort, self.fit):
            bar.addWidget(w)
        bar.addSpacing(10)
        for w in (*self.cats, search):
            bar.addWidget(w)
        bottom = QHBoxLayout()
        bottom.setSpacing(10)
        bottom.addWidget(self.more)
        bottom.addStretch()
        for w in (lowres, self.folder, download):
            bottom.addWidget(w)
        box = QVBoxLayout()
        box.setContentsMargins(14, 12, 14, 8)
        box.setSpacing(12)
        for row in (tabs, bar):
            box.addLayout(row)
        box.addWidget(self.colors_row)
        self.preview = self.make_preview()
        self.split = QSplitter()
        self.split.addWidget(self.grid)
        self.split.addWidget(self.preview)
        self.split.setStretchFactor(0, 1)
        self.split.splitterMoved.connect(self.fit_preview)
        box.addWidget(self.split)
        box.addLayout(bottom)
        central = QWidget()
        central.setLayout(box)
        self.setCentralWidget(central)

        # every filter change searches, after a short pause so a burst of clicks costs one API call
        self.later.timeout.connect(self.search)
        for sig in (self.purity.currentChanged, self.sort.currentIndexChanged,
                    self.fit.currentIndexChanged, self.color.idClicked,
                    *(c.toggled for c in self.cats)):
            sig.connect(lambda *_: self.later.start())
        self.query.returnPressed.connect(self.search)
        search.clicked.connect(self.search)
        self.more.clicked.connect(lambda: self.search(page=self.page + 1))
        lowres.clicked.connect(self.find_lowres)
        self.folder.clicked.connect(self.choose_folder)
        download.clicked.connect(self.download)
        self.grid.itemActivated.connect(self.show_preview)  # double-click/Enter
        QShortcut(QKeySequence("Ctrl+Q"), self, activated=self.close)

        # API calls in the last minute, the same sliding window the rate limiter uses
        self.meter_label = QLabel()
        self.meter = QProgressBar(maximum=API_LIMIT, textVisible=False, maximumWidth=140)
        self.statusBar().addPermanentWidget(self.meter_label)
        self.statusBar().addPermanentWidget(self.meter)
        QTimer(self, interval=1000, timeout=self.update_meter).start()
        self.update_meter()
        self.search()

    def update_meter(self):
        now = time.monotonic()
        self.sent = [t for t in self.sent if now - t < 60]
        self.meter.setValue(len(self.sent))
        self.meter_label.setText(f"{len(self.sent)}/{API_LIMIT} api calls")

    def make_preview(self):
        panel = QWidget(visible=False)
        self.pv_image = QLabel(alignment=Qt.AlignmentFlag.AlignCenter, minimumWidth=320)
        self.pv_info = QLabel(openExternalLinks=True, wordWrap=True,
                              textFormat=Qt.TextFormat.RichText)
        dl, close = QPushButton("Download"), QPushButton("Close")
        # shown only while a low-res original is open in the reference window
        self.replace = QPushButton("Replace original", visible=False)
        self.replace.setToolTip("save this wallpaper next to the original, then move the original to the trash")
        dl.clicked.connect(self.download_preview)
        self.replace.clicked.connect(self.replace_original)
        close.clicked.connect(panel.hide)
        buttons = QHBoxLayout()
        for b in (dl, self.replace, close):
            buttons.addWidget(b)
        box = QVBoxLayout(panel)
        box.setContentsMargins(8, 0, 0, 0)
        box.addWidget(self.pv_image, stretch=1)
        box.addWidget(self.pv_info)
        box.addLayout(buttons)
        return panel

    def show_preview(self, item):
        w = item.data(ROLE)
        self.pv = {"w": w, "item": item, "gen": self.gen, "data": None, "pix": QPixmap()}
        self.replace.setVisible(self.reference.isVisible())
        swatches = "".join(f'<span style="background:{c}">&nbsp;&nbsp;&nbsp;&nbsp;</span> '
                           for c in w["colors"])
        self.pv_info.setText(
            f'<b>{w["resolution"]}</b> · {w["file_size"] / 2**20:.1f} MiB · '
            f'★{w["favorites"]} · {w["views"]} views · {w["category"]} · {w["purity"]}<br>'
            f'{swatches}<br><a href="{w["url"]}">open on wallhaven</a>')
        self.pv_image.setText("loading full image...")
        if not self.preview.isVisible():
            self.preview.show()
            self.split.setSizes([self.width() - 520, 520])
        # the full image doubles as the download, the panel's Download button reuses it
        self.get(w["path"], lambda data, pv=self.pv: self.preview_loaded(pv, data))

    def preview_loaded(self, pv, data):
        if pv is not self.pv:
            return  # another wallpaper was opened meanwhile
        pv["data"] = data
        pv["pix"].loadFromData(data)
        self.fit_preview()

    def fit_preview(self, *_):
        if self.preview.isVisible() and getattr(self, "pv", None) and not self.pv["pix"].isNull():
            self.pv_image.setPixmap(self.pv["pix"].scaled(
                self.pv_image.size(), Qt.AspectRatioMode.KeepAspectRatio,
                Qt.TransformationMode.SmoothTransformation))

    def resizeEvent(self, event):
        super().resizeEvent(event)
        self.fit_preview()

    def download_preview(self):
        pv = self.pv
        if pv["w"]["id"] in self.have:
            self.already(pv["w"])
        elif pv["data"]:
            self.save_dir.mkdir(parents=True, exist_ok=True)
            self.save(self.dest(pv["w"]), pv["data"], pv["item"], pv["gen"], pv["w"]["id"])
        else:
            self.statusBar().showMessage("full image still loading, try again in a moment")

    def get(self, url, done):
        if url.startswith(API):  # thumbnails and full images don't count toward the limit
            now = time.monotonic()
            self.sent = [t for t in self.sent if now - t < 60]
            wait = api_delay(self.sent, now)
            if wait:
                self.statusBar().showMessage(f"API limit reached, waiting {wait:.0f}s...")
                QTimer.singleShot(int(wait * 1000) + 50, lambda: self.get(url, done))
                return
            self.sent.append(now)
            self.update_meter()
        req = QNetworkRequest(QUrl(url))
        req.setHeader(QNetworkRequest.KnownHeaders.UserAgentHeader, "wallhaven-dl")
        if APIKEY and url.startswith(API):  # header, not ?apikey=, so it never lands in URLs or logs
            req.setRawHeader(b"X-API-Key", APIKEY.encode())
        reply = self.net.get(req)

        def finished():
            reply.deleteLater()
            if reply.error() != QNetworkReply.NetworkError.NoError:
                self.statusBar().showMessage(f"{url}: {reply.errorString()}")
            else:
                done(bytes(reply.readAll()))
        reply.finished.connect(finished)

    def search(self, *_, page=1):
        cats = "".join("1" if c.isChecked() else "0" for c in self.cats)
        if cats == "000":
            self.statusBar().showMessage("pick at least one category")
            return
        if page == 1:
            self.gen += 1
            self.seed = None
            self.refresh_library()  # pick up files moved or deleted since the last search
        params = {"q": self.query.text().strip(), "sorting": self.sort.currentText(),
                  "page": page, "seed": self.seed, "categories": cats,
                  "purity": "".join("1" if i == self.purity.currentIndex() else "0" for i in range(3)),
                  "colors": COLORS[self.color.checkedId() - 1] if self.color.checkedId() else None,
                  **self.fit.currentData()}
        params = {k: v for k, v in params.items() if v}
        self.more.setEnabled(False)
        self.statusBar().showMessage("searching...")
        gen = self.gen
        self.get(f"{API}search?{urlencode(params)}", lambda data: self.show_results(data, gen))

    def show_results(self, data, gen):
        if gen != self.gen:
            return
        res = json.loads(data)
        meta = res["meta"]
        self.page, self.last_page = meta["current_page"], meta["last_page"]
        self.seed = meta.get("seed")
        if self.page == 1:
            self.grid.clear()
        for w in res["data"]:
            item = QListWidgetItem()
            item.setData(ROLE, w)
            item.setToolTip(f'{w["id"]}  {w["resolution"]}  {w["file_size"] // 1024} KiB')
            self.mark(item)
            self.grid.addItem(item)
            self.get(w["thumbs"]["small"], lambda d, item=item: self.thumb(item, d, gen))
        self.more.setEnabled(self.page < self.last_page)
        self.statusBar().showMessage(f'page {self.page}/{self.last_page}, {meta["total"]} results')

    def thumb(self, item, data, gen):
        if gen == self.gen:  # grid not cleared since, so item still alive
            pix = QPixmap()
            pix.loadFromData(data)
            item.setData(THUMB, pix)
            self.mark(item)

    def dest(self, w):
        return self.save_dir / w["path"].rsplit("/", 1)[1]

    def roots(self):
        """The library, plus the save folder when it lives outside it."""
        return (LIBRARY,) if self.save_dir.is_relative_to(LIBRARY) else (LIBRARY, self.save_dir)

    def refresh_library(self):
        self.have = scan_library(*self.roots())

    def already(self, w):
        where = str(self.have[w["id"]]).replace(str(Path.home()), "~", 1)
        self.statusBar().showMessage(f"already have {where}")

    def mark(self, item):
        """Label a result, with the downloaded overlay if it is anywhere in the library."""
        w = item.data(ROLE)
        item.setText(f'{w["resolution"]}  ★{w["favorites"]}')
        pix = item.data(THUMB)
        pix = self.blank if pix is None else pix
        item.setIcon(QIcon(downloaded_overlay(pix) if w["id"] in self.have else pix))

    def show_folder(self):
        self.folder.setText("Folder: " + str(self.save_dir).replace(str(Path.home()), "~", 1))

    def choose_folder(self):
        d = QFileDialog.getExistingDirectory(self, "Save wallpapers to", str(self.save_dir))
        if not d:
            return
        self.save_dir = Path(d)
        self.settings.setValue("save_dir", d)
        self.show_folder()
        self.refresh_library()
        for i in range(self.grid.count()):
            self.mark(self.grid.item(i))

    def download(self):
        self.save_dir.mkdir(parents=True, exist_ok=True)
        gen = self.gen
        for item in self.grid.selectedItems():
            w = item.data(ROLE)
            if w["id"] in self.have:
                self.already(w)
                continue
            self.get(w["path"], lambda d, w=w, item=item: self.save(self.dest(w), d, item, gen, w["id"]))

    def save(self, dest, data, item, gen, wid):
        if wid in self.have:
            return  # selected twice, or preview and grid download raced
        # write to a temp name first, so a crash never leaves a truncated wallpaper behind
        tmp = dest.with_suffix(".part")
        tmp.write_bytes(data)
        tmp.rename(dest)
        self.have[wid] = dest
        self.statusBar().showMessage(f"saved {dest.name}")
        if gen == self.gen:  # grid not cleared since, so item still alive
            self.mark(item)

    def find_lowres(self):
        """List images in the library smaller than the monitor they are meant for."""
        lst = make_grid()
        # ponytail: scans and decodes thumbnails synchronously, the UI stalls on folders of
        # thousands of images; move to a QThread if that ever hurts
        for p in library_files(*self.roots()):
            reader = QImageReader(str(p))
            size = reader.size()
            if not size.isValid():
                continue  # not an image
            w, h = size.width(), size.height()
            i = target(w, h, self.monitors)
            mw, mh = self.monitors[i]
            if w >= mw and h >= mh:
                continue
            reader.setScaledSize(size.scaled(300, 200, Qt.AspectRatioMode.KeepAspectRatio))
            item = QListWidgetItem(QIcon(QPixmap.fromImage(reader.read())), f"{w}x{h}  <  {mw}x{mh}")
            item.setToolTip(str(p).replace(str(Path.home()), "~", 1))
            item.setData(ROLE, (str(p), i))
            lst.addItem(item)
        if not lst.count():
            self.statusBar().showMessage("every image in the library covers its monitor")
            return

        dlg = QDialog(self)
        dlg.setWindowTitle(f"{lst.count()} low-res wallpapers in the library")
        dlg.resize(1000, 700)
        box = QVBoxLayout(dlg)
        box.addWidget(QLabel("Smaller than the monitor they match. Double-click one to search wallhaven "
                             "at that monitor's size: by its main tag for wallhaven files, by its "
                             "dominant color for anything else."))
        box.addWidget(lst)

        def similar(item):
            path, i = item.data(ROLE)
            m = WALL_ID.match(Path(path).name)
            dlg.accept()
            self.reference.show_image(path)
            self.replace.setVisible(self.preview.isVisible())
            self.fit.blockSignals(True)  # set quietly, the search below runs once
            self.fit.setCurrentIndex(i)
            self.fit.blockSignals(False)
            if m:
                self.get(f"{API}w/{m[1]}", lambda data: self.search_tag(m[1], data))
            else:
                self.search_color(path)
        lst.itemActivated.connect(similar)
        dlg.exec()

    def replace_original(self):
        """Swap the low-res original for the previewed wallpaper: save the new file into the
        original's folder first, and only then move the original to the trash."""
        pv, orig = self.pv, Path(self.reference.path)
        if not self.reference.isVisible() or not orig.exists():
            self.statusBar().showMessage("no original to replace, open one from \"Find low-res on disk\"")
            return
        if not pv["data"]:
            self.statusBar().showMessage("full image still loading, try again in a moment")
            return
        if pv["w"]["id"] in self.have:
            self.already(pv["w"])
            return
        dest = orig.parent / self.dest(pv["w"]).name
        old = f"{orig.name} ({self.reference.pix.width()}x{self.reference.pix.height()})"
        folder = str(orig.parent).replace(str(Path.home()), "~", 1)
        if QMessageBox.question(self, "Replace original",
                                f"Save {dest.name} ({pv['w']['resolution']}) in {folder}\n"
                                f"and move {old} to the trash?") != QMessageBox.StandardButton.Yes:
            return
        self.save(dest, pv["data"], pv["item"], pv["gen"], pv["w"]["id"])
        ok, _ = QFile.moveToTrash(str(orig))
        if not ok:  # the new file is saved either way, nothing is lost
            self.statusBar().showMessage(f"saved {dest.name}, but could not move {orig.name} to the trash")
            return
        self.reference.close()
        self.replace.hide()
        self.refresh_library()  # the original may have been a wallhaven file too
        self.statusBar().showMessage(f"replaced {orig.name} with {dest.name}, original is in the trash")

    def search_color(self, path):
        """Search by the palette color covering most of a local image, wallhaven's own
        colors filter only takes palette colors. Purity and categories stay as set."""
        reader = QImageReader(path)
        reader.setScaledSize(QSize(64, 64))  # plenty for a color histogram
        img = reader.read()
        hist = palette_histogram((c.red(), c.green(), c.blue())
                                 for c in (img.pixelColor(x, y)
                                           for y in range(img.height()) for x in range(img.width())))
        top = hist.most_common(3)
        pick = search_pick(hist)
        self.color.button(pick + 1).setChecked(True)  # button ids are offset by "any"
        self.query.clear()
        self.search()
        total = sum(hist.values())
        self.statusBar().showMessage(f"{Path(path).name}: searching #{COLORS[pick]}, top colors "
                                     + ", ".join(f"#{COLORS[i]} {n * 100 // total}%" for i, n in top)
                                     + ", click another swatch to change")

    def search_tag(self, wid, data):
        # ponytail: wallhaven's like:<id> similarity search sits behind a Cloudflare bot
        # challenge, so search by the wallpaper's first tag instead (usually its most specific)
        info = json.loads(data)["data"]
        tags = info["tags"]
        if not tags:
            self.statusBar().showMessage(f"wallhaven-{wid} has no tags to search by")
            return
        # search the wallpaper's own purity: an NSFW tag finds next to nothing under SFW
        self.purity.blockSignals(True)
        self.purity.setCurrentIndex(["sfw", "sketchy", "nsfw"].index(info["purity"]))
        self.purity.blockSignals(False)
        self.query.setText(f'id:{tags[0]["id"]}')
        self.search()
        self.statusBar().showMessage(f'searching tag "{tags[0]["name"]}" of wallhaven-{wid}...')


if __name__ == "__main__":
    APIKEY = read_apikey()
    app = QApplication(sys.argv)
    app.setDesktopFileName("wallhaven-dl")  # ties windows to wallhaven-dl.desktop (Wayland app id)
    # installed theme icon first, else the svg beside the real script (resolve() follows a symlink)
    app.setWindowIcon(QIcon.fromTheme("wallhaven-dl",
                                      QIcon(str(Path(__file__).resolve().with_name("wallhaven-dl.svg")))))
    win = Main()
    win.show()
    sys.exit(app.exec())
