WallFetch

Download original-resolution wallpapers from Wallhaven with metadata, persistent settings, download queue, and advanced customization.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Вам потребуется установить расширение, например Tampermonkey или Violentmonkey, чтобы установить этот скрипт.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         WallFetch
// @namespace    https://github.com/itachi-re/wallfetch
// @version      1.5.1
// @description  Download original-resolution wallpapers from Wallhaven with metadata, persistent settings, download queue, and advanced customization.
// @author       itachi-re
// @license      MIT

// @homepageURL https://github.com/itachi-re/wallfetch
// @supportURL  https://github.com/itachi-re/wallfetch/issues

// @match        https://wallhaven.cc/*
// @match        https://*.wallhaven.cc/*

// @icon         https://wallhaven.cc/favicon.ico

// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_openInTab
// @grant        GM_getValue
// @grant        GM_setValue

// @connect      wallhaven.cc
// @connect      w.wallhaven.cc

// @run-at       document-idle
// @noframes
// @homepage      https://github.com/itachi-re/wallfetch
// @source        https://github.com/itachi-re/wallfetch
// @compatible    chrome
// @compatible    firefox
// @compatible    edge
// ==/UserScript==

/*
 * MIT License
 *
 * Copyright (c) 2026 itachi-re
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 *
 * NOTE: Update @downloadURL / @updateURL above to point at wherever you
 * actually host this file (GitHub raw, GreasyFork, etc.) before publishing.
 */

(() => {
  'use strict';

  const LOG_PREFIX = '[Wallhaven Enhanced Downloader+]';

  /**
   * Minimal internal logger so debug output can be toggled from one place.
   */
  const Logger = {
    debug(...args) {
      if (Config.STATIC.debug) console.debug(LOG_PREFIX, ...args);
    },
    info(...args) {
      console.info(LOG_PREFIX, ...args);
    },
    warn(...args) {
      console.warn(LOG_PREFIX, ...args);
    },
    error(...args) {
      console.error(LOG_PREFIX, ...args);
    },
  };

  /* ==========================================================================
   * CONFIG
   * Two tiers: STATIC (constants that rarely change and aren't exposed in the
   * settings UI) and PERSISTED_DEFAULTS (user-configurable values that are
   * loaded from / saved to GM storage by the Settings module below).
   * ======================================================================== */
  const Config = Object.freeze({
    STATIC: Object.freeze({
      darkTheme: true,
      showProgress: true,
      toastDurationMs: 4200,
      elementWaitTimeout: 8000,
      domObserverDebounce: 250,
      retryDelay: 1000, // ms, multiplied by attempt number (linear backoff)
      enableFloatingFallback: true,
      sanitizeFilenames: true,
      debug: false,
      shortcuts: Object.freeze({
        download: { key: 'd', ctrl: false, shift: false, alt: false }, // D
        downloadNoDialog: { key: 'd', ctrl: true, shift: false, alt: false }, // Ctrl+D
        copyUrl: { key: 'd', ctrl: false, shift: true, alt: false }, // Shift+D
        copyFilename: { key: 'd', ctrl: false, shift: false, alt: true }, // Alt+D
      }),
    }),

    /** User-configurable values, persisted via GM_getValue/GM_setValue. */
    PERSISTED_DEFAULTS: Object.freeze({
      saveAs: false,
      filenameTemplate: '{id}',
      showNotifications: true,
      preventDuplicates: true,
      keyboardShortcuts: true,
      enableContextMenu: true,
      retryCount: 3,
      removeWallhavenPrefix: true,
      apiKey: '', // optional Wallhaven API key, needed to read NSFW metadata
    }),

    STORAGE_PREFIX: 'wod_',
    HISTORY_KEY: 'wod_download_history',
    MAX_HISTORY_ENTRIES: 300,
  });

  /** User-friendly text for classified download/API errors. */
  const ERROR_MESSAGES = Object.freeze({
    FORBIDDEN: 'Access denied (403) — the file may be protected or you may be rate-limited.',
    NOT_FOUND: 'Original image not found (404) — it may have been removed.',
    RATE_LIMITED: 'Rate limited by Wallhaven (429) — please wait a moment and retry.',
    CHALLENGE_PAGE: 'Received a verification page instead of the image (possible Cloudflare challenge).',
    INVALID_CONTENT_TYPE: 'The server did not return an image file; refusing to save it.',
    EMPTY_RESPONSE: 'The downloaded file was empty; refusing to save it.',
    NETWORK_ERROR: 'A network error occurred while downloading.',
    TIMEOUT: 'The download timed out.',
    HTTP_ERROR: 'The server returned an unexpected response.',
    GM_DOWNLOAD_FAILED: 'Your userscript manager could not save the file.',
    ALREADY_QUEUED: 'This wallpaper is already queued for download.',
    PERMISSION_DENIED: 'Download permission was denied by the browser or extension.',
  });

  /**
   * Build an Error carrying a machine-readable `code` for classification.
   * @param {string} code
   * @param {string} message
   */
  function classifiedError(code, message) {
    const err = new Error(message);
    err.code = code;
    return err;
  }

  /* ==========================================================================
   * GMC (GreaseMonkey Compatibility shim)
   * Every GM_* API is called through here so the script degrades gracefully
   * across Tampermonkey, Violentmonkey, FireMonkey, and Greasemonkey's
   * promise-based GM.* namespace, without scattering feature-detection
   * throughout the rest of the code.
   * ======================================================================== */
  const GMC = {
    hasDownload() {
      return typeof GM_download === 'function';
    },
    download(details) {
      if (typeof GM_download === 'function') return GM_download(details);
      throw classifiedError('GM_DOWNLOAD_FAILED', 'GM_download is not available in this userscript manager.');
    },
    xmlHttpRequest(details) {
      if (typeof GM_xmlhttpRequest === 'function') return GM_xmlhttpRequest(details);
      if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function') return GM.xmlHttpRequest(details);
      throw classifiedError('NETWORK_ERROR', 'GM_xmlhttpRequest is not available in this userscript manager.');
    },
    setClipboard(text, type) {
      if (typeof GM_setClipboard === 'function') return GM_setClipboard(text, type);
      if (typeof GM !== 'undefined' && typeof GM.setClipboard === 'function') return GM.setClipboard(text, type);
      return null;
    },
    addStyle(css) {
      if (typeof GM_addStyle === 'function') return GM_addStyle(css);
      if (typeof GM !== 'undefined' && typeof GM.addStyle === 'function') return GM.addStyle(css);
      const style = document.createElement('style');
      style.textContent = css;
      document.head.appendChild(style);
      return style;
    },
    registerMenuCommand(label, fn) {
      if (typeof GM_registerMenuCommand === 'function') return GM_registerMenuCommand(label, fn);
      if (typeof GM !== 'undefined' && typeof GM.registerMenuCommand === 'function') return GM.registerMenuCommand(label, fn);
      return null;
    },
    openInTab(url, options) {
      if (typeof GM_openInTab === 'function') return GM_openInTab(url, options);
      if (typeof GM !== 'undefined' && typeof GM.openInTab === 'function') return GM.openInTab(url, options);
      window.open(url, '_blank', 'noopener');
      return null;
    },
    async getValue(key, defaultValue) {
      try {
        if (typeof GM_getValue === 'function') return GM_getValue(key, defaultValue);
        if (typeof GM !== 'undefined' && typeof GM.getValue === 'function') return await GM.getValue(key, defaultValue);
      } catch (err) {
        Logger.warn(`GM getValue failed for "${key}"`, err);
      }
      return defaultValue;
    },
    async setValue(key, value) {
      try {
        if (typeof GM_setValue === 'function') return GM_setValue(key, value);
        if (typeof GM !== 'undefined' && typeof GM.setValue === 'function') return await GM.setValue(key, value);
      } catch (err) {
        Logger.warn(`GM setValue failed for "${key}"`, err);
      }
      return undefined;
    },
  };

  /* ==========================================================================
   * SETTINGS
   * Loads user-configurable values from GM storage at startup, exposes a
   * synchronous `get()` for the rest of the app, and persists changes made
   * through the Settings UI. Falls back to Config.PERSISTED_DEFAULTS for any
   * key that has never been saved.
   * ======================================================================== */
  const Settings = {
    values: { ...Config.PERSISTED_DEFAULTS },
    _loaded: false,

    async init() {
      const keys = Object.keys(Config.PERSISTED_DEFAULTS);
      const loaded = await Promise.all(
        keys.map((key) => GMC.getValue(Config.STORAGE_PREFIX + key, Config.PERSISTED_DEFAULTS[key]))
      );
      keys.forEach((key, index) => {
        this.values[key] = loaded[index];
      });
      this._loaded = true;
      Logger.debug('Settings loaded', this.values);
    },

    /** @param {string} key */
    get(key) {
      return Object.prototype.hasOwnProperty.call(this.values, key)
        ? this.values[key]
        : Config.PERSISTED_DEFAULTS[key];
    },

    /**
     * @param {string} key
     * @param {*} value
     */
    async set(key, value) {
      this.values[key] = value;
      await GMC.setValue(Config.STORAGE_PREFIX + key, value);
    },

    async resetAll() {
      for (const key of Object.keys(Config.PERSISTED_DEFAULTS)) {
        this.values[key] = Config.PERSISTED_DEFAULTS[key];
        // eslint-disable-next-line no-await-in-loop
        await GMC.setValue(Config.STORAGE_PREFIX + key, Config.PERSISTED_DEFAULTS[key]);
      }
    },
  };

  /* ==========================================================================
   * UTILITIES
   * Small, pure, dependency-free helpers reused across modules.
   * ======================================================================== */
  const Utils = {
    /**
     * Wait for an element matching `selector` to exist under `root`.
     * Resolves with the element, or `null` if the timeout elapses.
     * Uses a single MutationObserver (no polling) and always disconnects.
     *
     * @param {string} selector
     * @param {{ root?: ParentNode, timeout?: number }} [options]
     * @returns {Promise<Element|null>}
     */
    waitForElement(selector, options = {}) {
      const root = options.root ?? document;
      const timeout = options.timeout ?? Config.STATIC.elementWaitTimeout;

      const existing = root.querySelector(selector);
      if (existing) return Promise.resolve(existing);

      return new Promise((resolve) => {
        let settled = false;
        const finish = (value) => {
          if (settled) return;
          settled = true;
          observer.disconnect();
          clearTimeout(timer);
          resolve(value);
        };

        const observer = new MutationObserver(() => {
          const found = root.querySelector(selector);
          if (found) finish(found);
        });

        observer.observe(root === document ? document.documentElement : root, {
          childList: true,
          subtree: true,
        });

        const timer = setTimeout(() => finish(null), timeout);
      });
    },

    /**
     * Debounce a function so it only runs `wait` ms after the last call.
     * @param {Function} fn
     * @param {number} wait
     */
    debounce(fn, wait) {
      let handle = null;
      return (...args) => {
        clearTimeout(handle);
        handle = setTimeout(() => fn(...args), wait);
      };
    },

    /**
     * Sleep helper for retry back-off.
     * @param {number} ms
     */
    sleep(ms) {
      return new Promise((resolve) => setTimeout(resolve, ms));
    },

    /**
     * Format a byte count into a human-readable string (e.g. "1.16 MB").
     * @param {number|null|undefined} bytes
     * @returns {string}
     */
    formatBytes(bytes) {
      if (!bytes || bytes <= 0) return 'unknown';
      const units = ['B', 'KB', 'MB', 'GB'];
      let value = bytes;
      let unitIndex = 0;
      while (value >= 1024 && unitIndex < units.length - 1) {
        value /= 1024;
        unitIndex += 1;
      }
      return `${value.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`;
    },

    /**
     * Find a sidebar "stat" value (e.g. Resolution, Size, Views) by its label
     * text. Used only as a fallback when the Wallhaven API is unreachable.
     * Wallhaven renders stats as label/value pairs; searching generically by
     * visible label text (instead of relying on class names) keeps this
     * working even if markup/classes change.
     *
     * @param {string} label - e.g. "Resolution" or "Size"
     * @returns {string|null}
     */
    findStatByLabel(label) {
      const needle = label.trim().toLowerCase();
      const candidateLabels = document.querySelectorAll(
        'dt, dl > *:nth-child(odd), .stat-label, [class*="label"]'
      );

      for (const el of candidateLabels) {
        const text = (el.textContent || '').trim().toLowerCase();
        if (text !== needle) continue;

        const value = el.nextElementSibling;
        if (value && value.textContent) {
          return value.textContent.trim();
        }
      }
      return null;
    },

    /**
     * Best-effort detection of the wallpaper's category (general/anime/people)
     * from the HTML. Only used when the API is unreachable.
     * @returns {string|null}
     */
    detectCategory() {
      const activeSelectors = [
        '.category-icons .active',
        '.categories .active',
        '[data-category].active',
        '.showcase-stats .active',
      ];

      for (const selector of activeSelectors) {
        const el = document.querySelector(selector);
        if (el) {
          const text = (el.textContent || el.getAttribute('title') || el.getAttribute('aria-label') || '')
            .trim()
            .toLowerCase();
          if (text) return text.replace(/\s+/g, '-');
        }
      }

      const tagLinks = document.querySelectorAll('#tags a, .tag-list a, a.tag');
      for (const link of tagLinks) {
        const href = link.getAttribute('href') || '';
        const match = href.match(/[?&]categories=([^&]+)/i);
        if (match) return match[1];
      }

      return null;
    },

    /**
     * Best-effort detection of purity (sfw/sketchy/nsfw) from the HTML.
     * Only used when the API is unreachable.
     * @returns {string|null}
     */
    detectPurity() {
      const fromStat = Utils.findStatByLabel('Purity');
      if (fromStat) return fromStat.toLowerCase();

      const activeSelectors = ['.purity-icons .active', '[data-purity].active'];
      for (const selector of activeSelectors) {
        const el = document.querySelector(selector);
        if (el) {
          const text = (el.textContent || el.getAttribute('title') || '').trim().toLowerCase();
          if (text) return text;
        }
      }
      return null;
    },

    /**
     * Best-effort detection of the uploader's username from the HTML.
     * Only used when the API is unreachable.
     * @returns {string|null}
     */
    detectUploader() {
      const link = document.querySelector('a[href^="/user/"]');
      return link ? link.textContent.trim() : null;
    },

    /**
     * Extract the wallpaper ID from a Wallhaven URL, filename, or the
     * current page location. Handles both "/w/9mx8kd" style page URLs and
     * "wallhaven-9mx8kd.png" style CDN filenames.
     * @param {string} input
     * @returns {string|null}
     */
    extractId(input) {
      if (!input) return null;
      const fileMatch = input.match(/wallhaven-([a-z0-9]+)\.(jpg|jpeg|png|webp)(?:\?.*)?$/i);
      if (fileMatch) return fileMatch[1];

      const pathMatch = input.match(/\/w\/([a-z0-9]+)/i);
      if (pathMatch) return pathMatch[1];

      return null;
    },

    /**
     * Extract the file extension (without the dot) from a URL, normalized
     * to lowercase. Supports jpg/jpeg/png/webp.
     * @param {string} url
     * @returns {string|null}
     */
    extractExtension(url) {
      const match = url.match(/\.(jpg|jpeg|png|webp)(?:\?.*)?$/i);
      return match ? match[1].toLowerCase() : null;
    },

    /**
     * Normalize a resolution string like "1920 x 1080" into "1920x1080".
     * @param {string|null} resolution
     * @returns {string}
     */
    normalizeResolution(resolution) {
      if (!resolution) return 'unknown';
      return resolution.replace(/\s*x\s*/i, 'x').trim();
    },

    /**
     * Remove characters that are illegal (or awkward) in filenames on
     * Windows/macOS/Linux, and collapse whitespace.
     * @param {string} name
     * @returns {string}
     */
    sanitizeFilename(name) {
      if (!Config.STATIC.sanitizeFilenames) return name;
      return name
        .replace(/[<>:"/\\|?*\x00-\x1F]/g, '')
        .replace(/\s+/g, '_')
        .replace(/_{2,}/g, '_')
        .trim();
    },

    /**
     * Render a filename from the configured template, substituting tokens.
     * Supported tokens: {id} {resolution} {width} {height} {ratio}
     * {category} {ext} {extension} {size} {filesize} {views} {favorites}
     * {purity} {uploader} {colors} {date}
     *
     * @param {import('./WallpaperData').WallpaperMeta} data
     * @returns {string}
     */
    renderFilename(data) {
      const ext = data.ext ?? 'jpg';
      const tokens = {
        '{id}': data.id ?? 'unknown',
        '{resolution}': Utils.normalizeResolution(data.resolution),
        '{width}': data.width ?? 'unknown',
        '{height}': data.height ?? 'unknown',
        '{ratio}': data.ratio ?? 'unknown',
        '{category}': data.category ?? 'wallpaper',
        '{ext}': ext,
        '{extension}': ext,
        '{size}': data.size ?? 'unknown',
        '{filesize}': data.filesize != null ? Utils.formatBytes(data.filesize) : data.size ?? 'unknown',
        '{views}': data.views ?? 'unknown',
        '{favorites}': data.favorites ?? 'unknown',
        '{purity}': data.purity ?? 'unknown',
        '{uploader}': data.uploader ?? 'unknown',
        '{colors}': data.colors ?? 'unknown',
        '{date}': data.date ?? 'unknown',
      };

      const tokenPattern = new RegExp(Object.keys(tokens).map((t) => t.replace(/[{}]/g, '\\$&')).join('|'), 'g');

      let base = Settings.get('filenameTemplate').replace(tokenPattern, (match) => `${tokens[match]}` ?? match);

      if (Settings.get('removeWallhavenPrefix')) {
        base = base.replace(/^wallhaven-/i, '');
      }

      return Utils.sanitizeFilename(`${base}.${ext}`);
    },

    /**
     * Copy text to the clipboard, preferring GM_setClipboard (works without
     * document focus / in more contexts) and falling back to the async
     * Clipboard API.
     * @param {string} text
     * @returns {Promise<boolean>}
     */
    async copyToClipboard(text) {
      try {
        const result = GMC.setClipboard(text, 'text');
        if (result !== null) return true;
      } catch (err) {
        Logger.warn('GM_setClipboard failed, falling back to Clipboard API', err);
      }

      try {
        await navigator.clipboard.writeText(text);
        return true;
      } catch (err) {
        Logger.error('Clipboard copy failed', err);
        return false;
      }
    },
  };

  /* ==========================================================================
   * API CLIENT
   * Talks to the official Wallhaven API (https://wallhaven.cc/api/v1/w/{id})
   * to fetch rich, reliable metadata in a single request. HTML scraping is
   * only used as a fallback if this fails (auth-gated NSFW walls without an
   * API key, network issues, API downtime, etc.).
   * ======================================================================== */
  const ApiClient = {
    BASE_URL: 'https://wallhaven.cc/api/v1/w/',

    /**
     * @param {string} id
     * @returns {Promise<object>} the API's `data` object
     */
    fetchWallpaper(id) {
      const apiKey = Settings.get('apiKey');
      const url = this.BASE_URL + encodeURIComponent(id) + (apiKey ? `?apikey=${encodeURIComponent(apiKey)}` : '');

      return new Promise((resolve, reject) => {
        let request;
        try {
          request = GMC.xmlHttpRequest({
            method: 'GET',
            url,
            timeout: 10000,
            responseType: 'text',
            headers: { Accept: 'application/json' },
            onload: (resp) => {
              if (resp.status === 401 || resp.status === 403) {
                return reject(classifiedError('FORBIDDEN', 'Wallhaven API access denied (NSFW walls require an API key).'));
              }
              if (resp.status === 404) {
                return reject(classifiedError('NOT_FOUND', 'Wallhaven API reported this wallpaper does not exist.'));
              }
              if (resp.status === 429) {
                return reject(classifiedError('RATE_LIMITED', 'Wallhaven API rate limit reached.'));
              }
              if (resp.status !== 200) {
                return reject(classifiedError('HTTP_ERROR', `Wallhaven API returned HTTP ${resp.status}.`));
              }
              try {
                const json = JSON.parse(resp.responseText);
                if (!json || !json.data) {
                  return reject(new Error('Malformed Wallhaven API response.'));
                }
                resolve(json.data);
              } catch (err) {
                reject(err);
              }
            },
            onerror: () => reject(classifiedError('NETWORK_ERROR', 'Network error contacting the Wallhaven API.')),
            ontimeout: () => reject(classifiedError('TIMEOUT', 'Wallhaven API request timed out.')),
          });
        } catch (err) {
          reject(err);
        }
        return request;
      });
    },
  };

  /* ==========================================================================
   * WALLPAPER DATA
   * Resolves everything we know about the wallpaper currently on screen,
   * preferring the Wallhaven API and falling back to HTML parsing. Results
   * are cached per wallpaper ID until navigation invalidates the cache.
   * ======================================================================== */
  const WallpaperData = {
    /** @type {HTMLImageElement|null} */
    _imageEl: null,
    /** @type {object|null} */
    _cache: null,

    /**
     * Locate the full-resolution <img> element for the current page.
     * Wallhaven serves the ORIGINAL image directly as the src of the main
     * showcase image (the CDN "full" path) - there is no separate preview
     * swapped in for the detail page, so no extra network round-trip is
     * required to reach the original file.
     * @returns {Promise<HTMLImageElement|null>}
     */
    async findImageElement() {
      if (this._imageEl && document.contains(this._imageEl)) {
        return this._imageEl;
      }

      const selectors = [
        '#wallpaper',
        'img#wallpaper',
        'figure#showcase img',
        'main img[src*="w.wallhaven.cc/full/"]',
        'img[src*="w.wallhaven.cc/full/"]',
      ];

      for (const selector of selectors) {
        const el = document.querySelector(selector);
        if (el && el.tagName === 'IMG' && /w\.wallhaven\.cc\/full\//i.test(el.src)) {
          this._imageEl = el;
          return el;
        }
      }

      const found = await Utils.waitForElement(selectors.join(', '), {
        timeout: Config.STATIC.elementWaitTimeout,
      });
      if (found && found.tagName === 'IMG') {
        this._imageEl = found;
        return found;
      }
      return null;
    },

    /**
     * Gather all metadata needed to build a filename and perform a download.
     * Uses the Wallhaven API when possible, and caches the result per ID so
     * repeated actions (download, copy URL, copy filename, copy tags, open
     * original) don't repeat the same lookup.
     *
     * @param {{ forceRefresh?: boolean }} [options]
     * @returns {Promise<null | object>}
     */
    async resolve(options = {}) {
      const currentPageId = Utils.extractId(location.href);

      if (!options.forceRefresh && this._cache && this._cache.id === currentPageId) {
        return this._cache;
      }

      const img = await this.findImageElement();
      if (!img || !img.src) {
        Logger.warn('Could not locate the original wallpaper image on this page.');
        return null;
      }

      const url = img.src;
      const id = Utils.extractId(url) || currentPageId;
      const ext = Utils.extractExtension(url) || 'jpg';

      if (!id) {
        Logger.warn('Could not determine wallpaper ID.');
        return null;
      }

      let apiData = null;
      try {
        apiData = await ApiClient.fetchWallpaper(id);
      } catch (err) {
        Logger.warn('Wallhaven API lookup failed, falling back to HTML parsing.', err);
      }

      const data = apiData ? this._fromApi(apiData, { url, ext }) : this._fromHtml({ url, ext, id, img });
      this._cache = data;
      return data;
    },

    /**
     * @param {object} api - the Wallhaven API's `data` payload
     * @param {{url:string, ext:string}} fallback
     */
    _fromApi(api, fallback) {
      const width = api.dimension_x ?? null;
      const height = api.dimension_y ?? null;
      const imageUrl = api.path || fallback.url;

      return {
        id: api.id ?? Utils.extractId(imageUrl),
        url: imageUrl,
        ext: Utils.extractExtension(imageUrl) || fallback.ext,
        resolution: api.resolution || (width && height ? `${width}x${height}` : 'unknown'),
        width: width ?? 'unknown',
        height: height ?? 'unknown',
        ratio: api.ratio ? String(api.ratio) : width && height ? (width / height).toFixed(2) : 'unknown',
        category: api.category || 'wallpaper',
        purity: api.purity || 'unknown',
        uploader: api.uploader && api.uploader.username ? api.uploader.username : 'unknown',
        views: api.views ?? 'unknown',
        favorites: api.favorites ?? 'unknown',
        size: api.file_size ? Utils.formatBytes(api.file_size) : 'unknown',
        filesize: typeof api.file_size === 'number' ? api.file_size : null,
        colors: Array.isArray(api.colors) ? api.colors.join(',') : 'unknown',
        date: api.created_at ? String(api.created_at).split(' ')[0] : 'unknown',
        tags: Array.isArray(api.tags) ? api.tags.map((t) => t.name) : null,
        source: 'api',
      };
    },

    /**
     * @param {{url:string, ext:string, id:string, img:HTMLImageElement}} params
     */
    _fromHtml({ url, ext, id, img }) {
      const resolution = Utils.findStatByLabel('Resolution') || `${img.naturalWidth || 0}x${img.naturalHeight || 0}`;
      const [width, height] = Utils.normalizeResolution(resolution)
        .split('x')
        .map((n) => parseInt(n, 10) || 0);
      const sizeLabel = Utils.findStatByLabel('Size');

      return {
        id,
        url,
        ext,
        resolution,
        width: width || 'unknown',
        height: height || 'unknown',
        ratio: width && height ? (width / height).toFixed(2) : 'unknown',
        category: Utils.detectCategory() || 'wallpaper',
        purity: Utils.detectPurity() || 'unknown',
        uploader: Utils.detectUploader() || 'unknown',
        views: Utils.findStatByLabel('Views') || 'unknown',
        favorites: Utils.findStatByLabel('Favorites') || 'unknown',
        size: sizeLabel || 'unknown',
        filesize: null,
        colors: 'unknown',
        date: Utils.findStatByLabel('Uploaded') || 'unknown',
        tags: null,
        source: 'html',
      };
    },

    /** Clear cached references, e.g. after SPA-style navigation. */
    reset() {
      this._imageEl = null;
      this._cache = null;
    },
  };

  /* ==========================================================================
   * NOTIFICATIONS
   * Lightweight in-page toast system matching Wallhaven's dark theme.
   * ======================================================================== */
  const Notifications = {
    _container: null,

    _ensureContainer() {
      if (this._container && document.body.contains(this._container)) {
        return this._container;
      }
      const container = document.createElement('div');
      container.id = 'wod-toast-container';
      container.setAttribute('role', 'status');
      container.setAttribute('aria-live', 'polite');
      document.body.appendChild(container);
      this._container = container;
      return container;
    },

    /**
     * @param {string} message
     * @param {'info'|'success'|'error'|'warning'} type
     * @param {{ actionLabel?: string, onAction?: Function, duration?: number }} [options]
     * @returns {{dismiss: Function, setText: Function}|undefined}
     */
    show(message, type = 'info', options = {}) {
      if (!Settings.get('showNotifications')) return undefined;

      const container = this._ensureContainer();
      const toast = document.createElement('div');
      toast.className = `wod-toast wod-toast--${type}`;

      const icon = document.createElement('span');
      icon.className = 'wod-toast__icon';
      icon.innerHTML = Icons[type] ?? Icons.info;

      const text = document.createElement('span');
      text.className = 'wod-toast__text';
      text.textContent = message;

      toast.append(icon, text);

      if (options.actionLabel && typeof options.onAction === 'function') {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'wod-toast__action';
        btn.textContent = options.actionLabel;
        btn.addEventListener('click', () => {
          options.onAction();
          dismiss();
        });
        toast.appendChild(btn);
      }

      const closeBtn = document.createElement('button');
      closeBtn.type = 'button';
      closeBtn.className = 'wod-toast__close';
      closeBtn.setAttribute('aria-label', 'Dismiss');
      closeBtn.textContent = '\u00d7';
      closeBtn.addEventListener('click', () => dismiss());
      toast.appendChild(closeBtn);

      container.appendChild(toast);
      requestAnimationFrame(() => toast.classList.add('wod-toast--visible'));

      let dismissed = false;
      const dismiss = () => {
        if (dismissed) return;
        dismissed = true;
        toast.classList.remove('wod-toast--visible');
        toast.classList.add('wod-toast--leaving');
        toast.addEventListener('transitionend', () => toast.remove(), { once: true });
      };

      const duration = options.duration ?? Config.STATIC.toastDurationMs;
      if (duration > 0) setTimeout(dismiss, duration);

      return {
        dismiss,
        setText: (newMessage) => {
          text.textContent = newMessage;
        },
      };
    },

    success(message, options) {
      return this.show(message, 'success', options);
    },
    error(message, options) {
      return this.show(message, 'error', options);
    },
    info(message, options) {
      return this.show(message, 'info', options);
    },
    warning(message, options) {
      return this.show(message, 'warning', options);
    },
  };

  /* ==========================================================================
   * ICONS (inline SVG, no external assets)
   * ======================================================================== */
  const Icons = {
    download:
      '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" ' +
      'stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/>' +
      '<path d="M5 21h14"/></svg>',
    success:
      '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" ' +
      'stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
    error:
      '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" ' +
      'stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v5"/>' +
      '<path d="M12 16h.01"/></svg>',
    warning:
      '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" ' +
      'stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 2 20h20L12 3z"/><path d="M12 10v4"/>' +
      '<path d="M12 17h.01"/></svg>',
    info:
      '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" ' +
      'stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8h.01"/>' +
      '<path d="M11 12h1v5h1"/></svg>',
    spinner:
      '<svg class="wod-spin" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" ' +
      'stroke-width="2" stroke-linecap="round"><path d="M12 3a9 9 0 1 0 9 9"/></svg>',
    gear:
      '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" ' +
      'stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/>' +
      '<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 ' +
      '1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83' +
      'l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82' +
      'l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51' +
      ' 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4' +
      'h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
  };

  /* ==========================================================================
   * STYLES
   * Injected once. Uses CSS variables so it can be re-themed easily and
   * matches Wallhaven's existing dark palette.
   * ======================================================================== */
  function injectStyles() {
    const css = `
      :root {
        --wod-bg: #24242a;
        --wod-bg-hover: #2f2f37;
        --wod-border: #3a3a42;
        --wod-text: #e3e3e6;
        --wod-muted: #9a9aa2;
        --wod-accent: #4fc3f7;
        --wod-success: #4caf82;
        --wod-error: #e0555f;
        --wod-warning: #e0a24f;
        --wod-radius: 6px;
      }

      .wod-btn {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        gap: 6px;
        height: 32px;
        min-width: 32px;
        padding: 0 10px;
        background: var(--wod-bg);
        color: var(--wod-text);
        border: 1px solid var(--wod-border);
        border-radius: var(--wod-radius);
        cursor: pointer;
        font-size: 12px;
        line-height: 1;
        transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
        position: relative;
        user-select: none;
      }
      .wod-btn:hover:not(.wod-btn--disabled) {
        background: var(--wod-bg-hover);
        box-shadow: 0 0 0 1px var(--wod-accent) inset;
      }
      .wod-btn:active:not(.wod-btn--disabled) {
        transform: scale(0.96);
      }
      .wod-btn--disabled {
        opacity: 0.55;
        cursor: not-allowed;
      }
      .wod-btn--success {
        border-color: var(--wod-success);
        color: var(--wod-success);
      }
      .wod-btn--error {
        border-color: var(--wod-error);
        color: var(--wod-error);
      }
      .wod-btn__icon {
        display: inline-flex;
        line-height: 0;
      }
      .wod-btn__ring {
        position: absolute;
        inset: -3px;
        border-radius: 9px;
        pointer-events: none;
        opacity: 0;
        transition: opacity 0.15s ease;
        padding: 2px;
        background: conic-gradient(var(--wod-accent) calc(var(--wod-progress, 0) * 360deg), transparent 0);
        -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
        mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
        -webkit-mask-composite: xor;
        mask-composite: exclude;
      }
      .wod-btn--loading .wod-btn__ring {
        opacity: 1;
      }

      .wod-spin {
        animation: wod-spin-rotate 0.8s linear infinite;
      }
      @keyframes wod-spin-rotate {
        to { transform: rotate(360deg); }
      }

      .wod-btn--success .wod-btn__icon {
        animation: wod-pop 0.35s ease;
      }
      .wod-btn--error .wod-btn__icon {
        animation: wod-shake 0.4s ease;
      }
      @keyframes wod-pop {
        0% { transform: scale(0.5); opacity: 0; }
        60% { transform: scale(1.15); opacity: 1; }
        100% { transform: scale(1); }
      }
      @keyframes wod-shake {
        0%, 100% { transform: translateX(0); }
        25% { transform: translateX(-3px); }
        75% { transform: translateX(3px); }
      }

      .wod-floating-group {
        position: fixed;
        right: 20px;
        bottom: 20px;
        z-index: 9999;
        display: flex;
        gap: 8px;
      }
      .wod-floating-group .wod-btn {
        border-radius: 999px;
        box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
      }

      #wod-toast-container {
        position: fixed;
        top: 16px;
        right: 16px;
        z-index: 10000;
        display: flex;
        flex-direction: column;
        gap: 8px;
        max-width: 340px;
      }

      .wod-toast {
        display: flex;
        align-items: center;
        gap: 8px;
        background: var(--wod-bg);
        color: var(--wod-text);
        border: 1px solid var(--wod-border);
        border-radius: var(--wod-radius);
        padding: 10px 12px;
        font-size: 13px;
        box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
        opacity: 0;
        transform: translateX(16px);
        transition: opacity 0.2s ease, transform 0.2s ease;
      }
      .wod-toast--visible {
        opacity: 1;
        transform: translateX(0);
      }
      .wod-toast--leaving {
        opacity: 0;
        transform: translateX(16px);
      }
      .wod-toast--success { border-color: var(--wod-success); color: var(--wod-success); }
      .wod-toast--error { border-color: var(--wod-error); color: var(--wod-error); }
      .wod-toast--warning { border-color: var(--wod-warning); color: var(--wod-warning); }
      .wod-toast--info { border-color: var(--wod-accent); color: var(--wod-text); }

      .wod-toast__icon { flex: 0 0 auto; display: inline-flex; }
      .wod-toast__text { flex: 1 1 auto; }
      .wod-toast__action, .wod-toast__close {
        background: transparent;
        border: none;
        color: inherit;
        cursor: pointer;
        font-size: 12px;
        padding: 2px 4px;
      }
      .wod-toast__action {
        text-decoration: underline;
        white-space: nowrap;
      }
      .wod-toast__close {
        font-size: 15px;
        opacity: 0.7;
      }
      .wod-toast__close:hover { opacity: 1; }

      .wod-context-menu {
        position: fixed;
        z-index: 10001;
        min-width: 190px;
        background: var(--wod-bg);
        border: 1px solid var(--wod-border);
        border-radius: var(--wod-radius);
        box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
        padding: 4px;
        font-size: 13px;
        color: var(--wod-text);
      }
      .wod-context-menu__item {
        display: flex;
        align-items: center;
        gap: 8px;
        padding: 7px 10px;
        border-radius: 4px;
        cursor: pointer;
        white-space: nowrap;
      }
      .wod-context-menu__item:hover {
        background: var(--wod-bg-hover);
        color: var(--wod-accent);
      }

      .wod-modal-overlay {
        position: fixed;
        inset: 0;
        background: rgba(0, 0, 0, 0.55);
        z-index: 10002;
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 20px;
      }
      .wod-modal {
        width: 100%;
        max-width: 440px;
        max-height: 86vh;
        overflow-y: auto;
        background: var(--wod-bg);
        border: 1px solid var(--wod-border);
        border-radius: 10px;
        color: var(--wod-text);
        box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55);
      }
      .wod-modal__header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 14px 16px;
        border-bottom: 1px solid var(--wod-border);
      }
      .wod-modal__header h2 {
        font-size: 15px;
        margin: 0;
      }
      .wod-modal__close {
        background: transparent;
        border: none;
        color: var(--wod-muted);
        font-size: 20px;
        cursor: pointer;
        line-height: 1;
      }
      .wod-modal__close:hover { color: var(--wod-text); }
      .wod-modal__body {
        padding: 14px 16px;
        display: flex;
        flex-direction: column;
        gap: 12px;
      }
      .wod-field {
        display: flex;
        flex-direction: column;
        gap: 6px;
        font-size: 13px;
      }
      .wod-field--checkbox {
        flex-direction: row;
        align-items: center;
        gap: 8px;
      }
      .wod-field input[type="text"],
      .wod-field input[type="number"],
      .wod-field input[type="password"] {
        background: #1b1b20;
        border: 1px solid var(--wod-border);
        border-radius: 4px;
        color: var(--wod-text);
        padding: 6px 8px;
        font-size: 13px;
      }
      .wod-field input:focus {
        outline: none;
        border-color: var(--wod-accent);
      }
      .wod-field__hint {
        font-size: 11px;
        color: var(--wod-muted);
        margin: -6px 0 0;
        line-height: 1.5;
      }
      .wod-modal__footer {
        display: flex;
        justify-content: flex-end;
        gap: 8px;
        padding: 14px 16px;
        border-top: 1px solid var(--wod-border);
      }
      .wod-btn--primary {
        background: var(--wod-accent);
        color: #0b1a1f;
        border-color: var(--wod-accent);
        font-weight: 600;
      }
      .wod-btn--primary:hover {
        filter: brightness(1.08);
      }
    `;

    GMC.addStyle(css);
  }

  /* ==========================================================================
   * DOWNLOADER
   * Fetches the original image via GM_xmlhttpRequest as a validated Blob
   * (HTTP status, content-type, content-length, and non-empty checks) before
   * ever touching disk, then saves the Blob via GM_download - falling back
   * to a universal anchor-click Blob download when GM_download is
   * unavailable. Retries transient failures with linear back-off and skips
   * retrying permanent failures (404, 403, invalid content, etc).
   * ======================================================================== */
  const PERMANENT_ERROR_CODES = new Set([
    'NOT_FOUND',
    'FORBIDDEN',
    'CHALLENGE_PAGE',
    'INVALID_CONTENT_TYPE',
    'EMPTY_RESPONSE',
  ]);

  const Downloader = {
    /** In-memory mirror of persisted download history (session + past runs). */
    _history: [],
    _downloadedIds: new Set(),

    /** Load persisted download history from GM storage. Call once at startup. */
    async loadHistory() {
      const stored = await GMC.getValue(Config.HISTORY_KEY, []);
      this._history = Array.isArray(stored) ? stored : [];
      this._downloadedIds = new Set(this._history.map((entry) => entry.id));
    },

    async _persistHistory() {
      const trimmed = this._history.slice(-Config.MAX_HISTORY_ENTRIES);
      this._history = trimmed;
      await GMC.setValue(Config.HISTORY_KEY, trimmed);
    },

    async _recordDownload(id, filename) {
      this._history.push({ id, filename, timestamp: Date.now() });
      this._downloadedIds.add(id);
      await this._persistHistory();
    },

    async clearHistory() {
      this._history = [];
      this._downloadedIds.clear();
      await GMC.setValue(Config.HISTORY_KEY, []);
    },

    /**
     * @param {{id:string,url:string,ext:string}} data
     * @param {{saveAs?: boolean, force?: boolean, onProgress?: Function}} [options]
     * @returns {Promise<{filename:string}>}
     */
    async download(data, options = {}) {
      if (Settings.get('preventDuplicates') && this._downloadedIds.has(data.id) && !options.force) {
        throw classifiedError('DUPLICATE', 'Already downloaded this wallpaper this session.');
      }

      const filename = Utils.renderFilename(data);
      await this._downloadWithRetry(data.url, filename, options);
      await this._recordDownload(data.id, filename);
      return { filename };
    },

    /**
     * @param {string} url
     * @param {string} filename
     * @param {{saveAs?: boolean, onProgress?: Function}} options
     */
    async _downloadWithRetry(url, filename, options) {
      const maxAttempts = Math.max(1, Settings.get('retryCount'));
      let lastError = null;

      for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
        try {
          // eslint-disable-next-line no-await-in-loop
          await this._downloadOnce(url, filename, options);
          return;
        } catch (err) {
          lastError = err;
          Logger.warn(`Download attempt ${attempt}/${maxAttempts} failed [${err.code || 'UNKNOWN'}]`, err);
          if (PERMANENT_ERROR_CODES.has(err.code) || attempt >= maxAttempts) break;
          // eslint-disable-next-line no-await-in-loop
          await Utils.sleep(Config.STATIC.retryDelay * attempt);
        }
      }

      throw lastError ?? new Error('Unknown download failure.');
    },

    /**
     * @param {string} url
     * @param {string} filename
     * @param {{saveAs?: boolean, onProgress?: Function}} options
     */
    async _downloadOnce(url, filename, options) {
      const saveAs = options.saveAs ?? Settings.get('saveAs');
      const blob = await this._fetchValidatedBlob(url, options);
      await this._saveBlob(blob, filename, saveAs);
    },

    /**
     * GM_xmlhttpRequest-based fetch of the original image with full
     * validation before the bytes are ever handed off to be saved:
     *  - HTTP 200 required (403/404/429 classified explicitly)
     *  - Content-Type must be image/* (rejects HTML error/challenge pages)
     *  - Non-zero byte count required
     *  - Content-Length (if present) cross-checked against the actual Blob
     *
     * @param {string} url
     * @param {{onProgress?: Function}} options
     * @returns {Promise<Blob>}
     */
    _fetchValidatedBlob(url, options) {
      return new Promise((resolve, reject) => {
        try {
          GMC.xmlHttpRequest({
            method: 'GET',
            url,
            responseType: 'blob',
            timeout: 30000,
            headers: { Accept: 'image/*' },
            onprogress: (evt) => {
              if (typeof options.onProgress === 'function' && evt && evt.lengthComputable) {
                options.onProgress(evt.loaded / evt.total);
              }
            },
            onload: (resp) => {
              const status = resp.status;

              if (status === 403) {
                return reject(classifiedError('FORBIDDEN', ERROR_MESSAGES.FORBIDDEN));
              }
              if (status === 404) {
                return reject(classifiedError('NOT_FOUND', ERROR_MESSAGES.NOT_FOUND));
              }
              if (status === 429) {
                return reject(classifiedError('RATE_LIMITED', ERROR_MESSAGES.RATE_LIMITED));
              }
              if (status !== 200) {
                return reject(classifiedError('HTTP_ERROR', `Unexpected server response (HTTP ${status}).`));
              }

              const headers = resp.responseHeaders || '';
              const contentTypeMatch = headers.match(/^content-type:\s*([^\r\n]+)/im);
              const contentType = contentTypeMatch ? contentTypeMatch[1].split(';')[0].trim().toLowerCase() : '';
              const contentLengthMatch = headers.match(/^content-length:\s*(\d+)/im);
              const declaredLength = contentLengthMatch ? parseInt(contentLengthMatch[1], 10) : null;

              const blob = resp.response;

              if (declaredLength === 0 || !blob || blob.size === 0) {
                return reject(classifiedError('EMPTY_RESPONSE', ERROR_MESSAGES.EMPTY_RESPONSE));
              }
              if (contentType.startsWith('text/html')) {
                return reject(classifiedError('CHALLENGE_PAGE', ERROR_MESSAGES.CHALLENGE_PAGE));
              }
              if (contentType && !contentType.startsWith('image/')) {
                return reject(
                  classifiedError('INVALID_CONTENT_TYPE', `Unexpected content type "${contentType}"; refusing to save.`)
                );
              }
              if (declaredLength && blob.size !== declaredLength) {
                Logger.warn(`Content-Length mismatch for ${url}: expected ${declaredLength}, got ${blob.size}.`);
              }

              resolve(blob);
            },
            onerror: () => reject(classifiedError('NETWORK_ERROR', ERROR_MESSAGES.NETWORK_ERROR)),
            ontimeout: () => reject(classifiedError('TIMEOUT', ERROR_MESSAGES.TIMEOUT)),
          });
        } catch (err) {
          reject(err);
        }
      });
    },

    /**
     * Save an already-validated Blob to disk. Prefers GM_download (which can
     * honour the "Save As" dialog setting); falls back to a plain
     * anchor-click Blob download when GM_download isn't available at all
     * (e.g. some Greasemonkey configurations), which requires no special
     * permission and works purely in page context.
     *
     * @param {Blob} blob
     * @param {string} filename
     * @param {boolean} saveAs
     */
    _saveBlob(blob, filename, saveAs) {
      const objectUrl = URL.createObjectURL(blob);
      const revoke = () => setTimeout(() => URL.revokeObjectURL(objectUrl), 4000);

      if (!GMC.hasDownload()) {
        Logger.warn('GM_download is unavailable; using anchor-based Blob download fallback.');
        const anchor = document.createElement('a');
        anchor.href = objectUrl;
        anchor.download = filename;
        document.body.appendChild(anchor);
        anchor.click();
        anchor.remove();
        revoke();
        return Promise.resolve();
      }

      return new Promise((resolve, reject) => {
        let settled = false;
        const timeoutHandle = setTimeout(() => {
          if (settled) return;
          settled = true;
          revoke();
          reject(classifiedError('TIMEOUT', 'Saving the file timed out.'));
        }, 30000);

        try {
          GMC.download({
            url: objectUrl,
            name: filename,
            saveAs,
            onload: () => {
              if (settled) return;
              settled = true;
              clearTimeout(timeoutHandle);
              revoke();
              resolve();
            },
            onerror: (err) => {
              if (settled) return;
              settled = true;
              clearTimeout(timeoutHandle);
              revoke();
              const reason = err && err.error ? String(err.error) : 'unknown_error';
              const code = reason === 'permission_denied' ? 'PERMISSION_DENIED' : 'GM_DOWNLOAD_FAILED';
              reject(classifiedError(code, `GM_download failed: ${reason}`));
            },
            ontimeout: () => {
              if (settled) return;
              settled = true;
              clearTimeout(timeoutHandle);
              revoke();
              reject(classifiedError('TIMEOUT', 'GM_download timed out.'));
            },
          });
        } catch (err) {
          clearTimeout(timeoutHandle);
          settled = true;
          revoke();
          reject(err);
        }
      });
    },
  };

  /* ==========================================================================
   * DOWNLOAD QUEUE
   * Serializes downloads so only one runs at a time (rather than racing
   * multiple concurrent GM_xmlhttpRequest/GM_download calls), and rejects
   * attempts to queue the same wallpaper twice while it's already pending.
   * ======================================================================== */
  const DownloadQueue = {
    _items: [],
    _active: false,
    _queuedIds: new Set(),

    get size() {
      return this._items.length;
    },

    /**
     * @param {object} data
     * @param {object} options
     * @returns {Promise<{filename:string}>}
     */
    enqueue(data, options) {
      if (this._queuedIds.has(data.id)) {
        return Promise.reject(classifiedError('ALREADY_QUEUED', ERROR_MESSAGES.ALREADY_QUEUED));
      }

      this._queuedIds.add(data.id);
      return new Promise((resolve, reject) => {
        this._items.push({ data, options, resolve, reject });
        void this._pump();
      });
    },

    async _pump() {
      if (this._active) return;
      this._active = true;

      while (this._items.length > 0) {
        const { data, options, resolve, reject } = this._items.shift();
        try {
          // eslint-disable-next-line no-await-in-loop
          const result = await Downloader.download(data, options);
          resolve(result);
        } catch (err) {
          reject(err);
        } finally {
          this._queuedIds.delete(data.id);
        }
      }

      this._active = false;
    },
  };

  /* ==========================================================================
   * UI
   * Injects the download control (and a settings gear) into Wallhaven's
   * existing action bar, falling back to a floating button group if the
   * action bar cannot be located.
   * ======================================================================== */
  const UI = {
    _downloadButton: null,
    _settingsButton: null,
    _hostBar: null,

    /**
     * Candidate selectors for Wallhaven's native action bar, ordered from
     * most to least specific. Kept as a list (rather than one fragile
     * selector) so markup tweaks on Wallhaven's end don't break injection.
     */
    ACTION_BAR_SELECTORS: [
      '#wallpaper-actions .actions',
      '#wallpaper-actions',
      '.showcase-sidebar .actions',
      '.sidebar-content .actions',
      '.showcase-stats + .actions',
    ],

    async mount() {
      this.unmount();

      let hostBar = null;
      for (const selector of this.ACTION_BAR_SELECTORS) {
        hostBar = document.querySelector(selector);
        if (hostBar) break;
      }
      if (!hostBar) {
        hostBar = await Utils.waitForElement(this.ACTION_BAR_SELECTORS.join(', '), { timeout: 3000 });
      }
      this._hostBar = hostBar;

      const downloadButton = this._buildDownloadButton();
      const settingsButton = this._buildSettingsButton();
      this._downloadButton = downloadButton;
      this._settingsButton = settingsButton;

      if (hostBar) {
        hostBar.append(downloadButton, settingsButton);
        Logger.debug('Mounted controls into native action bar.');
      } else if (Config.STATIC.enableFloatingFallback) {
        const group = document.createElement('div');
        group.className = 'wod-floating-group';
        group.append(downloadButton, settingsButton);
        document.body.appendChild(group);
        this._floatingGroup = group;
        Logger.debug('Native action bar not found; using floating fallback controls.');
      } else {
        Logger.warn('Native action bar not found and floating fallback is disabled; UI not mounted.');
        this._downloadButton = null;
        this._settingsButton = null;
      }
    },

    unmount() {
      if (this._floatingGroup && this._floatingGroup.isConnected) {
        this._floatingGroup.remove();
      }
      this._floatingGroup = null;
      if (this._downloadButton && this._downloadButton.isConnected) this._downloadButton.remove();
      if (this._settingsButton && this._settingsButton.isConnected) this._settingsButton.remove();
      this._downloadButton = null;
      this._settingsButton = null;
    },

    _buildDownloadButton() {
      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'wod-btn';
      button.title = 'Download original wallpaper (D)';
      button.setAttribute('aria-label', 'Download original wallpaper');

      const ring = document.createElement('span');
      ring.className = 'wod-btn__ring';
      button.appendChild(ring);

      const iconSpan = document.createElement('span');
      iconSpan.className = 'wod-btn__icon';
      iconSpan.innerHTML = Icons.download;
      button.appendChild(iconSpan);

      const label = document.createElement('span');
      label.className = 'wod-btn__label';
      label.textContent = 'Original';
      button.appendChild(label);

      button.addEventListener('click', () => {
        void App.handleDownloadRequest({ saveAs: Settings.get('saveAs') });
      });

      return button;
    },

    _buildSettingsButton() {
      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'wod-btn';
      button.title = 'Downloader settings';
      button.setAttribute('aria-label', 'Open downloader settings');

      const iconSpan = document.createElement('span');
      iconSpan.className = 'wod-btn__icon';
      iconSpan.innerHTML = Icons.gear;
      button.appendChild(iconSpan);

      button.addEventListener('click', () => SettingsUI.open());
      return button;
    },

    setState(state) {
      const button = this._downloadButton;
      if (!button) return;

      button.classList.remove('wod-btn--disabled', 'wod-btn--success', 'wod-btn--error', 'wod-btn--loading');
      const icon = button.querySelector('.wod-btn__icon');

      switch (state) {
        case 'loading':
          button.classList.add('wod-btn--disabled', 'wod-btn--loading');
          button.disabled = true;
          if (icon) icon.innerHTML = Icons.spinner;
          break;
        case 'success':
          button.disabled = false;
          button.classList.add('wod-btn--success');
          if (icon) icon.innerHTML = Icons.success;
          setTimeout(() => this.setState('idle'), 1600);
          break;
        case 'error':
          button.disabled = false;
          button.classList.add('wod-btn--error');
          if (icon) icon.innerHTML = Icons.error;
          setTimeout(() => this.setState('idle'), 2000);
          break;
        case 'idle':
        default:
          button.disabled = false;
          if (icon) icon.innerHTML = Icons.download;
          break;
      }
    },

    /** @param {number} fraction 0..1 */
    setProgress(fraction) {
      const button = this._downloadButton;
      if (!button) return;
      const clamped = Math.max(0, Math.min(1, fraction));
      button.style.setProperty('--wod-progress', String(clamped));
    },
  };

  /* ==========================================================================
   * SETTINGS UI
   * A small in-page modal (no page reload) for editing every persisted
   * setting, plus a "clear history" action.
   * ======================================================================== */
  const SettingsUI = {
    _overlay: null,
    _escHandler: null,

    open() {
      if (this._overlay) return;

      const overlay = document.createElement('div');
      overlay.className = 'wod-modal-overlay';

      const modal = document.createElement('div');
      modal.className = 'wod-modal';
      modal.setAttribute('role', 'dialog');
      modal.setAttribute('aria-modal', 'true');
      modal.setAttribute('aria-label', 'Wallhaven downloader settings');

      modal.innerHTML = `
        <div class="wod-modal__header">
          <h2>Downloader Settings</h2>
          <button type="button" class="wod-modal__close" aria-label="Close">&times;</button>
        </div>
        <div class="wod-modal__body">
          <label class="wod-field">
            <span>Filename template</span>
            <input type="text" name="filenameTemplate" />
          </label>
          <p class="wod-field__hint">
            Tokens: {id} {resolution} {width} {height} {ratio} {category} {ext} {extension}
            {size} {filesize} {views} {favorites} {purity} {uploader} {colors} {date}
          </p>
          <label class="wod-field wod-field--checkbox">
            <input type="checkbox" name="saveAs" />
            <span>Show "Save As" dialog on download</span>
          </label>
          <label class="wod-field wod-field--checkbox">
            <input type="checkbox" name="removeWallhavenPrefix" />
            <span>Remove "wallhaven-" prefix</span>
          </label>
          <label class="wod-field wod-field--checkbox">
            <input type="checkbox" name="showNotifications" />
            <span>Show toast notifications</span>
          </label>
          <label class="wod-field wod-field--checkbox">
            <input type="checkbox" name="keyboardShortcuts" />
            <span>Enable keyboard shortcuts (D / Ctrl+D / Shift+D / Alt+D)</span>
          </label>
          <label class="wod-field wod-field--checkbox">
            <input type="checkbox" name="enableContextMenu" />
            <span>Enable right-click context menu</span>
          </label>
          <label class="wod-field wod-field--checkbox">
            <input type="checkbox" name="preventDuplicates" />
            <span>Warn before re-downloading the same wallpaper</span>
          </label>
          <label class="wod-field">
            <span>Retry attempts on failure</span>
            <input type="number" name="retryCount" min="1" max="10" />
          </label>
          <label class="wod-field">
            <span>Wallhaven API key (optional, needed for NSFW metadata)</span>
            <input type="password" name="apiKey" autocomplete="off" />
          </label>
        </div>
        <div class="wod-modal__footer">
          <button type="button" class="wod-btn" data-action="clear-history">Clear history</button>
          <button type="button" class="wod-btn" data-action="reset">Reset defaults</button>
          <button type="button" class="wod-btn wod-btn--primary" data-action="save">Save</button>
        </div>
      `;

      overlay.appendChild(modal);
      document.body.appendChild(overlay);
      this._overlay = overlay;

      this._populate(modal);
      this._bind(modal, overlay);
    },

    _populate(modal) {
      modal.querySelector('[name="filenameTemplate"]').value = Settings.get('filenameTemplate');
      modal.querySelector('[name="saveAs"]').checked = Settings.get('saveAs');
      modal.querySelector('[name="removeWallhavenPrefix"]').checked = Settings.get('removeWallhavenPrefix');
      modal.querySelector('[name="showNotifications"]').checked = Settings.get('showNotifications');
      modal.querySelector('[name="keyboardShortcuts"]').checked = Settings.get('keyboardShortcuts');
      modal.querySelector('[name="enableContextMenu"]').checked = Settings.get('enableContextMenu');
      modal.querySelector('[name="preventDuplicates"]').checked = Settings.get('preventDuplicates');
      modal.querySelector('[name="retryCount"]').value = Settings.get('retryCount');
      modal.querySelector('[name="apiKey"]').value = Settings.get('apiKey');
    },

    _bind(modal, overlay) {
      const close = () => this.close();

      overlay.addEventListener('click', (event) => {
        if (event.target === overlay) close();
      });
      modal.querySelector('.wod-modal__close').addEventListener('click', close);

      this._escHandler = (event) => {
        if (event.key === 'Escape') close();
      };
      document.addEventListener('keydown', this._escHandler);

      modal.querySelector('[data-action="save"]').addEventListener('click', async () => {
        await Settings.set('filenameTemplate', modal.querySelector('[name="filenameTemplate"]').value.trim() || '{id}');
        await Settings.set('saveAs', modal.querySelector('[name="saveAs"]').checked);
        await Settings.set('removeWallhavenPrefix', modal.querySelector('[name="removeWallhavenPrefix"]').checked);
        await Settings.set('showNotifications', modal.querySelector('[name="showNotifications"]').checked);
        await Settings.set('keyboardShortcuts', modal.querySelector('[name="keyboardShortcuts"]').checked);
        await Settings.set('enableContextMenu', modal.querySelector('[name="enableContextMenu"]').checked);
        await Settings.set('preventDuplicates', modal.querySelector('[name="preventDuplicates"]').checked);

        const retryValue = parseInt(modal.querySelector('[name="retryCount"]').value, 10);
        await Settings.set('retryCount', Number.isFinite(retryValue) && retryValue > 0 ? retryValue : 3);
        await Settings.set('apiKey', modal.querySelector('[name="apiKey"]').value.trim());

        Notifications.success('Settings saved.');
        close();
      });

      modal.querySelector('[data-action="reset"]').addEventListener('click', async () => {
        await Settings.resetAll();
        this._populate(modal);
        Notifications.info('Settings reset to defaults.');
      });

      modal.querySelector('[data-action="clear-history"]').addEventListener('click', async () => {
        await Downloader.clearHistory();
        Notifications.info('Download history cleared.');
      });
    },

    close() {
      if (this._overlay) {
        this._overlay.remove();
        this._overlay = null;
      }
      if (this._escHandler) {
        document.removeEventListener('keydown', this._escHandler);
        this._escHandler = null;
      }
    },
  };

  /* ==========================================================================
   * CONTEXT MENU
   * A small custom right-click menu on the wallpaper image.
   * ======================================================================== */
  const ContextMenu = {
    _menuEl: null,

    attach(imageEl) {
      if (!imageEl) return;
      imageEl.addEventListener('contextmenu', (event) => this._onContextMenu(event));
    },

    _onContextMenu(event) {
      if (!Settings.get('enableContextMenu')) return;
      event.preventDefault();
      this._close();

      const items = [
        { label: 'Download Original', action: () => App.handleDownloadRequest({ saveAs: Settings.get('saveAs') }) },
        { label: 'Open Original', action: () => App.openOriginal() },
        { label: 'Copy Image URL', action: () => App.copyImageUrl() },
        { label: 'Copy Filename', action: () => App.copyFilename() },
      ];

      const menu = document.createElement('div');
      menu.className = 'wod-context-menu';
      for (const item of items) {
        const el = document.createElement('div');
        el.className = 'wod-context-menu__item';
        el.textContent = item.label;
        el.addEventListener('click', () => {
          this._close();
          void item.action();
        });
        menu.appendChild(el);
      }

      document.body.appendChild(menu);
      this._menuEl = menu;

      const { innerWidth, innerHeight } = window;
      const rect = menu.getBoundingClientRect();
      const x = Math.min(event.clientX, innerWidth - rect.width - 8);
      const y = Math.min(event.clientY, innerHeight - rect.height - 8);
      menu.style.left = `${Math.max(8, x)}px`;
      menu.style.top = `${Math.max(8, y)}px`;

      const closeOnOutsideClick = (e) => {
        if (!menu.contains(e.target)) {
          this._close();
          document.removeEventListener('click', closeOnOutsideClick, true);
          document.removeEventListener('keydown', closeOnEscape, true);
        }
      };
      const closeOnEscape = (e) => {
        if (e.key === 'Escape') {
          this._close();
          document.removeEventListener('click', closeOnOutsideClick, true);
          document.removeEventListener('keydown', closeOnEscape, true);
        }
      };
      setTimeout(() => {
        document.addEventListener('click', closeOnOutsideClick, true);
        document.addEventListener('keydown', closeOnEscape, true);
      }, 0);
    },

    _close() {
      if (this._menuEl && this._menuEl.isConnected) {
        this._menuEl.remove();
      }
      this._menuEl = null;
    },
  };

  /* ==========================================================================
   * KEYBOARD
   * Handles the D / Ctrl+D / Shift+D / Alt+D shortcuts, ignoring keystrokes
   * while the user is typing in a form field. The enabled/disabled state is
   * re-checked on every keystroke against live settings, so toggling the
   * "keyboard shortcuts" setting takes effect immediately without needing to
   * re-attach the listener.
   * ======================================================================== */
  const Keyboard = {
    _handler: null,

    attach() {
      if (this._handler) return;
      this._handler = (event) => this._onKeyDown(event);
      document.addEventListener('keydown', this._handler, true);
    },

    detach() {
      if (this._handler) {
        document.removeEventListener('keydown', this._handler, true);
        this._handler = null;
      }
    },

    _isTypingContext(event) {
      const target = event.target;
      if (!target) return false;
      const tag = target.tagName;
      return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true;
    },

    _matches(event, shortcut) {
      return (
        event.key.toLowerCase() === shortcut.key &&
        event.ctrlKey === shortcut.ctrl &&
        event.shiftKey === shortcut.shift &&
        event.altKey === shortcut.alt &&
        !event.metaKey
      );
    },

    _onKeyDown(event) {
      if (!Settings.get('keyboardShortcuts')) return;
      if (this._isTypingContext(event)) return;

      const { shortcuts } = Config.STATIC;

      if (this._matches(event, shortcuts.downloadNoDialog)) {
        event.preventDefault();
        void App.handleDownloadRequest({ saveAs: false });
      } else if (this._matches(event, shortcuts.copyUrl)) {
        event.preventDefault();
        void App.copyImageUrl();
      } else if (this._matches(event, shortcuts.copyFilename)) {
        event.preventDefault();
        void App.copyFilename();
      } else if (this._matches(event, shortcuts.download)) {
        event.preventDefault();
        void App.handleDownloadRequest({ saveAs: Settings.get('saveAs') });
      }
    },
  };

  /* ==========================================================================
   * PAGE OBSERVER
   * Wallhaven wallpaper pages are mostly server-rendered, but this observer
   * makes the script resilient to client-side navigation, lazy-loaded
   * images, and future SPA-style changes without polling.
   * ======================================================================== */
  const PageObserver = {
    _mutationObserver: null,
    _lastId: null,

    start() {
      this.stop();
      this._lastId = Utils.extractId(location.href);

      this._patchHistoryApi();
      window.addEventListener('popstate', this._onNavigate);
      window.addEventListener('wod:navigate', this._onNavigate);

      const debouncedCheck = Utils.debounce(() => this._checkForContentChange(), Config.STATIC.domObserverDebounce);
      this._mutationObserver = new MutationObserver(debouncedCheck);
      this._mutationObserver.observe(document.body, { childList: true, subtree: true });
    },

    stop() {
      if (this._mutationObserver) {
        this._mutationObserver.disconnect();
        this._mutationObserver = null;
      }
      window.removeEventListener('popstate', this._onNavigate);
      window.removeEventListener('wod:navigate', this._onNavigate);
    },

    _patchHistoryApi() {
      if (history.__wodPatched) return;
      const fire = () => window.dispatchEvent(new Event('wod:navigate'));

      for (const method of ['pushState', 'replaceState']) {
        const original = history[method];
        history[method] = function patched(...args) {
          const result = original.apply(this, args);
          fire();
          return result;
        };
      }
      history.__wodPatched = true;
    },

    _onNavigate: () => {
      PageObserver._checkForContentChange();
    },

    _checkForContentChange() {
      const currentId =
        Utils.extractId(location.href) || Utils.extractId(document.querySelector('img#wallpaper')?.src || '');
      if (currentId !== this._lastId) {
        this._lastId = currentId;
        Logger.debug('Detected navigation to a new wallpaper, reinitializing.', currentId);
        WallpaperData.reset();
        void App.initPage();
      }
    },
  };

  /* ==========================================================================
   * MAIN APPLICATION
   * Coordinates the modules above and exposes the actions used by the UI,
   * keyboard shortcuts, and context menu.
   * ======================================================================== */
  const App = {
    async init() {
      await Settings.init();
      await Downloader.loadHistory();
      injectStyles();
      Keyboard.attach();
      PageObserver.start();
      this._registerMenuCommands();
      await this.initPage();
    },

    /** (Re-)initializes everything that depends on the current page's wallpaper. */
    async initPage() {
      UI.unmount();

      if (!/\/w\/[a-z0-9]+/i.test(location.pathname)) {
        Logger.debug('Not a wallpaper detail page; skipping UI mount.');
        return;
      }

      await UI.mount();

      const img = await WallpaperData.findImageElement();
      if (img) {
        ContextMenu.attach(img);
      }
    },

    _registerMenuCommands() {
      GMC.registerMenuCommand('Download original wallpaper', () => {
        void this.handleDownloadRequest({ saveAs: Settings.get('saveAs') });
      });
      GMC.registerMenuCommand('Copy original image URL', () => void this.copyImageUrl());
      GMC.registerMenuCommand('Copy filename', () => void this.copyFilename());
      GMC.registerMenuCommand('Downloader settings…', () => SettingsUI.open());
      GMC.registerMenuCommand('Clear download history', () => {
        void Downloader.clearHistory().then(() => Notifications.info('Download history cleared.'));
      });
    },

    /**
     * @param {{saveAs?: boolean, force?: boolean}} [options]
     */
    async handleDownloadRequest(options = {}) {
      UI.setState('loading');
      UI.setProgress(0);

      const progressToast = Config.STATIC.showProgress
        ? Notifications.show('Downloading… 0%', 'info', { duration: 0 })
        : undefined;

      const onProgress = (fraction) => {
        UI.setProgress(fraction);
        if (progressToast) progressToast.setText(`Downloading… ${Math.round(fraction * 100)}%`);
      };

      let data;
      try {
        data = await WallpaperData.resolve();
      } catch (err) {
        Logger.error('Failed to resolve wallpaper metadata', err);
        data = null;
      }

      if (!data) {
        if (progressToast) progressToast.dismiss();
        UI.setState('error');
        Notifications.error('Could not find the original wallpaper image on this page.');
        return;
      }

      try {
        const result = await DownloadQueue.enqueue(data, { ...options, onProgress });
        if (progressToast) progressToast.dismiss();
        UI.setState('success');
        Notifications.success(`Saved as ${result.filename}`);
      } catch (err) {
        if (progressToast) progressToast.dismiss();

        if (err && err.code === 'DUPLICATE') {
          UI.setState('idle');
          Notifications.warning('Already downloaded this wallpaper this session.', {
            actionLabel: 'Download again',
            onAction: () => void this.handleDownloadRequest({ ...options, force: true }),
          });
          return;
        }

        if (err && err.code === 'ALREADY_QUEUED') {
          UI.setState('idle');
          Notifications.info(ERROR_MESSAGES.ALREADY_QUEUED);
          return;
        }

        Logger.error('Download failed', err);
        UI.setState('error');
        const message = (err && ERROR_MESSAGES[err.code]) || (err && err.message) || 'Download failed.';
        Notifications.error(message, {
          actionLabel: 'Retry',
          onAction: () => void this.handleDownloadRequest(options),
          duration: 6000,
        });
      }
    },

    async openOriginal() {
      const data = await WallpaperData.resolve();
      if (!data) {
        Notifications.error('Could not find the original wallpaper image on this page.');
        return;
      }
      GMC.openInTab(data.url, { active: true, insert: true, setParent: true });
    },

    async copyImageUrl() {
      const data = await WallpaperData.resolve();
      if (!data) {
        Notifications.error('Could not find the original wallpaper image on this page.');
        return;
      }
      const ok = await Utils.copyToClipboard(data.url);
      if (ok) Notifications.success('Image URL copied to clipboard.');
      else Notifications.error('Could not copy the image URL.');
    },

    async copyFilename() {
      const data = await WallpaperData.resolve();
      if (!data) {
        Notifications.error('Could not find the original wallpaper image on this page.');
        return;
      }
      const filename = Utils.renderFilename(data);
      const ok = await Utils.copyToClipboard(filename);
      if (ok) Notifications.success(`Filename copied: ${filename}`);
      else Notifications.error('Could not copy the filename.');
    },

    async copyTags() {
      const data = await WallpaperData.resolve();
      let tags = data && Array.isArray(data.tags) ? data.tags : null;

      if (!tags) {
        const tagEls = document.querySelectorAll('#tags a, .tag-list a, a.tag');
        tags = Array.from(tagEls)
          .map((el) => el.textContent.trim())
          .filter(Boolean);
      }

      if (!tags || tags.length === 0) {
        Notifications.error('No tags found on this page.');
        return;
      }

      const ok = await Utils.copyToClipboard(tags.join(', '));
      if (ok) Notifications.success('Tags copied to clipboard.');
      else Notifications.error('Could not copy tags.');
    },
  };

  /* ==========================================================================
   * BOOTSTRAP
   * ======================================================================== */
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => void App.init());
  } else {
    void App.init();
  }
})();