WallFetch

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

2026-07-20 기준 버전입니다. 최신 버전을 확인하세요.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 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();
  }
})();