Image-Search-Direct-View

Adds a "View Image" button to Image Search results. [Supported sites: Bing / DuckDuckGo / Google]

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

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

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

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

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

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Image-Search-Direct-View
// @namespace    https://github.com/p65536
// @version      1.0.1
// @license      MIT
// @description  Adds a "View Image" button to Image Search results. [Supported sites: Bing / DuckDuckGo / Google]
// @icon         https://raw.githubusercontent.com/p65536/p65536/main/images/isdv.svg
// @author       p65536
// @match        https://*.bing.com/images/search*
// @match        https://duckduckgo.com/*
// @match        https://*.google.com/search*
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.registerMenuCommand
// @grant        GM.xmlHttpRequest
// @grant        GM.openInTab
// @connect      *
// @run-at       document-idle
// @noframes
// ==/UserScript==

(function () {
    'use strict';

    // =================================================================================
    // SECTION: Platform-Specific Definitions
    // =================================================================================

    const OWNERID = 'p65536';
    const APPID = 'isdv';
    const APPNAME = 'Image Search Direct View';
    const LOG_PREFIX = `[${APPID.toUpperCase()}]`;

    // =================================================================================
    // SECTION: Configuration Definitions
    // =================================================================================

    const CONSTANTS = {
        CONFIG_KEY: `${APPID}_config`,
        TOAST_DURATION: 3000,
        TOAST_FADE_OUT_DURATION: 300,
        NETWORK_TIMEOUT: 20000, // 20 seconds
        WAIT_FOR_VALID_URL_TIMEOUT: 500,
        MODAL: {
            WIDTH: 400,
            Z_INDEX: 100,
        },
        REFERRER_POLICY: {
            NO_REFERRER: 'no-referrer',
            ORIGIN: 'origin',
            UNSAFE_URL: 'unsafe-url',
        },
        FETCH_STRATEGY: {
            AUTO: 'auto',
            BLOB: 'blob',
            DIRECT: 'direct',
        },
        TIMEOUTS: {
            FETCH_ORIGINAL: 3000,
            DOM_POLLING: 100,
            SCROLL_CLAMP: 500,
            UI_DELAY: 100,
            POST_NAVIGATION_DOM_SETTLE: 500,
        },
        ICONS: {
            IMAGE: {
                tag: 'svg',
                props: { viewBox: '0 0 24 24', width: '18px', height: '18px', fill: 'currentColor' },
                children: [{ tag: 'path', props: { d: 'M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z' } }],
            },
            GLOBE: {
                tag: 'svg',
                props: { viewBox: '0 0 24 24', width: '18px', height: '18px', fill: 'currentColor' },
                children: [
                    {
                        tag: 'path',
                        props: {
                            d: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z',
                        },
                    },
                ],
            },
        },
        LOG_TAGS: {
            ORIGINAL: 'ORIGINAL',
            THUMBNAIL: 'THUMBNAIL',
        },
    };

    const SITE_STYLES = {
        google: {
            // For Modal & Common Settings (Dark mode support via CSS vars)
            bg: 'var(--background-color, Canvas)',
            text: 'var(--primary-text-color, CanvasText)',
            border: 'var(--border-color, ButtonBorder)',
            header_bg: 'var(--header-bg-color, var(--background-color, Canvas))',
            btn_bg: 'var(--background-color, Canvas)',
            btn_text: 'var(--primary-text-color, CanvasText)',
            btn_border: 'var(--border-color, ButtonBorder)',
            // Adjusted fallback to be visible in both Light (darken) and Dark (lighten via var) modes
            btn_hover_bg: 'var(--hover-bg-color, rgb(0 0 0 / 0.08))',
            input_bg: 'var(--textfield-surface, Field)',
            input_text: 'var(--textfield-primary, FieldText)',
            input_border: 'var(--border-color, FieldBorder)',
            accent: '#4285F4', // Google Blue
            text_secondary: 'var(--secondary-text-color, GrayText)',

            // Variable mapping specific to Button UI
            vars: {
                '--isdv-bg': 'var(--background-color, Canvas)',
                '--isdv-text': 'var(--primary-text-color, CanvasText)',
                '--isdv-border': 'var(--border-color, ButtonBorder)',
                // Hover bg: Solid color (Google Light Gray) instead of transparent
                '--isdv-hover-bg': 'var(--hover-bg-color, rgb(241 243 244))',
                '--isdv-accent': '#4285F4',
                // Alert Colors (Light Mode Default)
                '--isdv-unsafe': '#d93025',
                '--isdv-noref': '#1a0dab', // Google Link Blue
            },
            // Override settings for Dark Mode (Supplementing incomplete CSS vars)
            css_overrides: `
                @media (prefers-color-scheme: dark) {
                    :root {
                        /* Hover bg: Solid color (Google Dark Gray) */
                        --isdv-hover-bg: rgb(48 49 52);
                        --isdv-unsafe: #e06055; /* Bright red for dark mode */
                        --isdv-noref: #8ab4f8;  /* Bright blue for dark mode */
                    }
                }
            `,
            overrides: '', // No layout override needed for Google
        },
        bing: {
            // Bing Colors (Use as is, since CSS vars are complete)
            bg: 'var(--c-w-1, Canvas)',
            text: 'var(--c-t-1, CanvasText)',
            border: 'var(--c-s-1, ButtonBorder)',
            header_bg: 'var(--c-w-1, Canvas)',
            btn_bg: 'var(--c-w-1, ButtonFace)',
            btn_text: 'var(--c-t-1, ButtonText)',
            btn_border: 'var(--c-s-1, ButtonBorder)',
            btn_hover_bg: 'var(--c-s-2, Highlight)',
            input_bg: 'var(--c-w-1, Field)',
            input_text: 'var(--c-t-1, FieldText)',
            input_border: 'var(--c-s-1, FieldBorder)',
            accent: '#0078d4', // Bing Blue
            text_secondary: 'var(--c-t-2, GrayText)',

            // Variable mapping specific to Button UI
            vars: {
                '--isdv-bg': 'var(--c-w-1)',
                '--isdv-text': 'var(--c-t-1)',
                '--isdv-border': 'var(--c-s-1)',
                '--isdv-hover-bg': 'var(--c-s-2)',
                '--isdv-accent': '#0078d4',
                '--isdv-unsafe': '#d93025',
                '--isdv-noref': 'var(--c-h-1)', // Bing link color
            },
            // No override needed as Bing variables switch automatically
            css_overrides: '',
            // Ensure buttons are visible (z-index: 1)
            overrides: `
                .${APPID}-icon-btn {
                    left: 8px;
                    right: auto;
                    z-index: 1 !important;
                }
                .${APPID}-icon-btn:hover {
                    z-index: 2 !important;
                }
            `,
        },
        duckduckgo: {
            // DuckDuckGo Colors (Using their native CSS variables)
            bg: 'var(--color-bg-main, #fff)',
            text: 'var(--color-text-primary, #333)',
            border: 'var(--color-border-main, #ccc)',
            header_bg: 'var(--color-bg-main, #fff)',
            btn_bg: 'var(--color-bg-main, #fff)',
            btn_text: 'var(--color-text-primary, #333)',
            btn_border: 'var(--color-border-main, #ccc)',
            btn_hover_bg: 'var(--color-bg-dim, #f0f0f0)',
            input_bg: 'var(--color-bg-input, #fff)',
            input_text: 'var(--color-text-primary, #333)',
            input_border: 'var(--color-border-main, #ccc)',
            accent: '#de5833', // DDG Orange/Red
            text_secondary: 'var(--color-text-secondary, #666)',

            // Variable mapping specific to Button UI
            vars: {
                '--isdv-bg': 'var(--color-bg-main, #fff)',
                '--isdv-text': 'var(--color-text-primary, #333)',
                '--isdv-border': 'var(--color-border-main, rgb(0 0 0 / 0.1))',
                '--isdv-hover-bg': 'var(--color-bg-dim, #f0f0f0)',
                '--isdv-accent': '#de5833',
                '--isdv-unsafe': '#d93025',
                '--isdv-noref': '#4096ff',
            },
            css_overrides: '',
            // Position: Default (Top-Right)
            // 1. Set ISDV button z-index to 1 to ensure it sits above the image.
            // 2. Force DDG's menu button to z-index 100 to ensure it sits above ISDV button.
            overrides: `
                .${APPID}-icon-btn {
                    z-index: 1 !important;
                }
                .${APPID}-icon-btn:hover {
                    z-index: 2 !important;
                }
                /* Force DDG menu button above ISDV buttons */
                figure button[aria-label="menu"] {
                    z-index: 100 !important;
                }
            `,
        },
    };

    const DEFAULT_CONFIG = {
        common: {
            showOnlyOnHover: false, // Default to false so users see buttons immediately
            showVisitPageButton: true, // Show the "Visit Page" button by default
            referrerPolicy: CONSTANTS.REFERRER_POLICY.ORIGIN, // Default: Send origin only
            retryOnFailure: false, // Default: Do not retry automatically
            fetchStrategy: CONSTANTS.FETCH_STRATEGY.AUTO, // Default: Auto Detect
            blobRevokeTimeout: 600000, // Default: 10 minutes (600,000 ms)
        },
        developer: {
            logger_level: 'log',
        },
    };

    const EVENTS = {
        CONFIG_UPDATED: `${APPID}:configUpdated`,
        NAVIGATION: `${APPID}:navigation`,
    };

    const UI_STYLES = {
        BASE: `
            /* Button Style (Icon Button) - CSS Variable Support */
            .${APPID}-icon-btn {
                position: absolute;
                right: 8px;
                width: 32px;
                height: 32px;
                border-radius: 50%;
                background-color: var(--isdv-bg, rgb(255 255 255));
                border: 1px solid var(--isdv-border, rgb(0 0 0 / 0.1));
                /* Increased shadow opacity for better visibility */
                box-shadow: 0 2px 5px rgb(0 0 0 / 0.3);
                cursor: pointer;
                display: flex;
                align-items: center;
                justify-content: center;
                color: var(--isdv-text, rgb(95 99 104));
                transition: transform 0.1s, background-color 0.1s, opacity 0.2s ease-in-out;
                z-index: 10;
            }
            .${APPID}-icon-btn:hover {
                background-color: var(--isdv-hover-bg, rgb(241 243 244));
                color: var(--isdv-text, rgb(32 33 36));
                transform: scale(1.1);
                box-shadow: 0 4px 10px rgb(0 0 0 / 0.4);
                z-index: 11;
            }
            .${APPID}-icon-btn:active {
                transform: scale(0.95);
            }
            
            /* Policy Colors - Controlled via CSS Variables */
            
            /* Origin (Default) - Use standard text color (Native look) */
            body[data-${APPID}-referrer-policy="${CONSTANTS.REFERRER_POLICY.ORIGIN}"] .${APPID}-icon-btn { color: var(--isdv-text) !important; }
            body[data-${APPID}-referrer-policy="${CONSTANTS.REFERRER_POLICY.ORIGIN}"] .${APPID}-icon-btn:hover { color: var(--isdv-text) !important; }

            /* Unsafe URL - Red (Alert) */
            body[data-${APPID}-referrer-policy="${CONSTANTS.REFERRER_POLICY.UNSAFE_URL}"] .${APPID}-icon-btn { color: var(--isdv-unsafe) !important; }
            body[data-${APPID}-referrer-policy="${CONSTANTS.REFERRER_POLICY.UNSAFE_URL}"] .${APPID}-icon-btn:hover { color: var(--isdv-unsafe) !important; }

            /* No Referrer - Blue (Custom/Link Color) */
            body[data-${APPID}-referrer-policy="${CONSTANTS.REFERRER_POLICY.NO_REFERRER}"] .${APPID}-icon-btn { color: var(--isdv-noref) !important; }
            body[data-${APPID}-referrer-policy="${CONSTANTS.REFERRER_POLICY.NO_REFERRER}"] .${APPID}-icon-btn:hover { color: var(--isdv-noref) !important; }
            
            /* Container Style */
            .${APPID}-container {
                position: relative;
            }

            /* Default Positions */
            .${APPID}-btn-view-image {
                top: 8px;
            }
            .${APPID}-btn-visit-page {
                top: 48px; /* 8px (top) + 32px (btn) + 8px (gap) */
            }

            /* Toast */
            .${APPID}-toast-container {
                position: fixed; top: 80px; left: 50%; transform: translateX(-50%);
                z-index: 2147483647; display: flex; flex-direction: column; gap: 8px;
            }
            .${APPID}-toast {
                padding: 10px 20px; border-radius: 24px; color: white;
                font-family: Roboto, Arial, sans-serif; font-size: 14px;
                box-shadow: 0 4px 12px rgb(0 0 0 / 0.3);
                animation: ${APPID}-fade-in 0.3s ease-out;
            }
            .${APPID}-toast-info { background-color: #333; }
            .${APPID}-toast-warn { background-color: #FBBC04; color: #202124; }
            .${APPID}-toast-error { background-color: #d93025; }
            @keyframes ${APPID}-fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
        `,
        HOVER_ENABLE: `
            .${APPID}-icon-btn {
                opacity: 0;
                pointer-events: none;
            }
            .${APPID}-container:hover .${APPID}-icon-btn {
                opacity: 1;
                pointer-events: auto;
            }
        `,
        HOVER_DISABLE: `
            .${APPID}-icon-btn {
                opacity: 1;
                pointer-events: auto;
            }
        `,
    };

    // =================================================================================
    // SECTION: Logging Utility
    // Description: Centralized logging interface for consistent log output across modules.
    //              Handles log level control, message formatting, and console API wrapping.
    // =================================================================================

    class Logger {
        /** @property {object} levels - Defines the numerical hierarchy of log levels. */
        static levels = {
            error: 0,
            warn: 1,
            info: 2,
            log: 3,
            debug: 4,
        };
        /** @property {string} level - The current active log level. */
        static level = 'log'; // Default level

        /**
         * Defines the available badge styles.
         * @property {object} styles
         */
        static styles = {
            BASE: 'color: white; padding: 2px 6px; border-radius: 4px; font-weight: bold;',
            RED: 'background: #dc3545;',
            YELLOW: 'background: #ffc107; color: black;',
            GREEN: 'background: #28a745;',
            BLUE: 'background: #007bff;',
            GRAY: 'background: #6c757d;',
            ORANGE: 'background: #fd7e14;',
            PINK: 'background: #e83e8c;',
            PURPLE: 'background: #6f42c1;',
            CYAN: 'background: #17a2b8; color: black;',
            TEAL: 'background: #20c997; color: black;',
        };

        /**
         * Maps log levels to default badge styles.
         * @private
         */
        static _defaultStyles = {
            error: this.styles.RED,
            warn: this.styles.YELLOW,
            info: this.styles.BLUE,
            log: this.styles.GREEN,
            debug: this.styles.GRAY,
        };

        /**
         * Sets the current log level.
         * @param {string} level The new log level. Must be one of 'error', 'warn', 'info', 'log', 'debug'.
         */
        static setLevel(level) {
            if (Object.prototype.hasOwnProperty.call(this.levels, level)) {
                this.level = level;
            } else {
                // Use default style (empty string) for the badge
                this._out('warn', 'INVALID LEVEL', '', `Invalid log level "${level}". Valid levels are: ${Object.keys(this.levels).join(', ')}. Level not changed.`);
            }
        }

        /**
         * Internal method to output logs if the level permits.
         * @private
         * @param {string} level - The log level ('error', 'warn', 'info', 'log', 'debug').
         * @param {string} badgeText - The text inside the badge. If empty, no badge is shown.
         * @param {string} badgeStyle - The background-color style (from Logger.styles). If empty, uses default.
         * @param {...any} args - The messages to log.
         */
        static _out(level, badgeText, badgeStyle, ...args) {
            if (this.levels[this.level] >= this.levels[level]) {
                const consoleMethod = console[level] || console.log;

                if (badgeText !== '') {
                    // Badge mode: Use %c formatting
                    let style = badgeStyle;
                    if (style === '') {
                        style = this._defaultStyles[level] || this.styles.GRAY;
                    }
                    const combinedStyle = `${this.styles.BASE} ${style}`;

                    consoleMethod(
                        `%c${LOG_PREFIX}%c %c${badgeText}%c`,
                        'font-weight: bold;', // Style for the prefix
                        'color: inherit;', // Reset for space
                        combinedStyle, // Style for the badge
                        'color: inherit;', // Reset for the rest of the message
                        ...args
                    );
                } else {
                    // No badge mode: Direct output for better object inspection
                    consoleMethod(LOG_PREFIX, ...args);
                }
            }
        }

        /**
         * Internal method to start a log group if the level permits (debug or higher).
         * @private
         * @param {'group'|'groupCollapsed'} method - The console method to use.
         * @param {string} badgeText
         * @param {string} badgeStyle
         * @param {...any} args
         */
        static _groupOut(method, badgeText, badgeStyle, ...args) {
            if (this.levels[this.level] >= this.levels.debug) {
                const consoleMethod = console[method];

                if (badgeText !== '') {
                    let style = badgeStyle;
                    if (style === '') {
                        style = this.styles.GRAY;
                    }
                    const combinedStyle = `${this.styles.BASE} ${style}`;

                    consoleMethod(`%c${LOG_PREFIX}%c %c${badgeText}%c`, 'font-weight: bold;', 'color: inherit;', combinedStyle, 'color: inherit;', ...args);
                } else {
                    consoleMethod(LOG_PREFIX, ...args);
                }
            }
        }

        /**
         * @param {string} badgeText
         * @param {string} badgeStyle
         * @param {...any} args
         */
        static error(badgeText, badgeStyle, ...args) {
            this._out('error', badgeText, badgeStyle, ...args);
        }

        /**
         * @param {string} badgeText
         * @param {string} badgeStyle
         * @param {...any} args
         */
        static warn(badgeText, badgeStyle, ...args) {
            this._out('warn', badgeText, badgeStyle, ...args);
        }

        /**
         * @param {string} badgeText
         * @param {string} badgeStyle
         * @param {...any} args
         */
        static info(badgeText, badgeStyle, ...args) {
            this._out('info', badgeText, badgeStyle, ...args);
        }

        /**
         * @param {string} badgeText
         * @param {string} badgeStyle
         * @param {...any} args
         */
        static log(badgeText, badgeStyle, ...args) {
            this._out('log', badgeText, badgeStyle, ...args);
        }

        /**
         * Logs messages for debugging. Only active in 'debug' level.
         * @param {string} badgeText
         * @param {string} badgeStyle
         * @param {...any} args
         */
        static debug(badgeText, badgeStyle, ...args) {
            this._out('debug', badgeText, badgeStyle, ...args);
        }

        /**
         * Starts a timer for performance measurement. Only active in 'debug' level.
         * @param {string} label The label for the timer.
         */
        static time(label) {
            if (this.levels[this.level] >= this.levels.debug) {
                console.time(`${LOG_PREFIX} ${label}`);
            }
        }

        /**
         * Ends a timer and logs the elapsed time. Only active in 'debug' level.
         * @param {string} label The label for the timer, must match the one used in time().
         */
        static timeEnd(label) {
            if (this.levels[this.level] >= this.levels.debug) {
                console.timeEnd(`${LOG_PREFIX} ${label}`);
            }
        }

        /**
         * Starts a log group. Only active in 'debug' level.
         * @param {string} badgeText
         * @param {string} badgeStyle
         * @param {...any} args The title for the log group.
         */
        static group(badgeText, badgeStyle, ...args) {
            this._groupOut('group', badgeText, badgeStyle, ...args);
        }

        /**
         * Starts a collapsed log group. Only active in 'debug' level.
         * @param {string} badgeText
         * @param {string} badgeStyle
         * @param {...any} args The title for the log group.
         */
        static groupCollapsed(badgeText, badgeStyle, ...args) {
            this._groupOut('groupCollapsed', badgeText, badgeStyle, ...args);
        }

        /**
         * Closes the current log group. Only active in 'debug' level.
         * @returns {void}
         */
        static groupEnd() {
            if (this.levels[this.level] >= this.levels.debug) {
                console.groupEnd();
            }
        }
    }

    // Alias for ease of use
    const LOG_STYLES = Logger.styles;

    // =================================================================================
    // SECTION: Execution Guard
    // Description: Prevents the script from being executed multiple times per page.
    // =================================================================================

    class ExecutionGuard {
        // A shared key for all scripts from the same author to avoid polluting the window object.
        static #GUARD_KEY = `__${OWNERID}_guard__`;
        // A specific key for this particular script.
        static #APP_KEY = `${APPID}_executed`;

        /**
         * Checks if the script has already been executed on the page.
         * @returns {boolean} True if the script has run, otherwise false.
         */
        static hasExecuted() {
            return window[this.#GUARD_KEY]?.[this.#APP_KEY] || false;
        }

        /**
         * Sets the flag indicating the script has now been executed.
         */
        static setExecuted() {
            window[this.#GUARD_KEY] = window[this.#GUARD_KEY] || {};
            window[this.#GUARD_KEY][this.#APP_KEY] = true;
        }
    }

    // =================================================================================
    // SECTION: General Utilities
    // =================================================================================

    /**
     * Schedules a function to run when the browser is idle.
     * Returns a cancel function to abort the scheduled task.
     * In environments without `requestIdleCallback`, this runs asynchronously immediately (1ms delay) to prevent blocking,
     * effectively ignoring the `timeout` constraint by satisfying it instantly.
     * @param {(deadline: IdleDeadline) => void} callback The function to execute.
     * @param {number} timeout The maximum time to wait for idle before forcing execution.
     * @returns {() => void} A function to cancel the scheduled task.
     */
    function runWhenIdle(callback, timeout) {
        if ('requestIdleCallback' in window) {
            const id = window.requestIdleCallback(callback, { timeout });
            return () => window.cancelIdleCallback(id);
        } else {
            // Fallback: Execute almost immediately (1ms) to avoid blocking.
            // This satisfies the "run by timeout" contract trivially since 1ms < timeout.
            const id = setTimeout(() => {
                // Provide a minimal IdleDeadline-like object.
                // timeRemaining() returns 50ms to simulate a fresh frame.
                callback({
                    didTimeout: false,
                    timeRemaining: () => 50,
                });
            }, 1);

            return () => clearTimeout(id);
        }
    }

    /**
     * @param {Function} func
     * @param {number} delay
     * @param {boolean} useIdle
     * @returns {((...args: any[]) => void) & { cancel: () => void }}
     */
    function debounce(func, delay, useIdle) {
        let timerId = null;
        let cancelIdle = null;

        const cancel = () => {
            if (timerId !== null) {
                clearTimeout(timerId);
                timerId = null;
            }
            if (cancelIdle) {
                cancelIdle();
                cancelIdle = null;
            }
        };

        const debounced = function (...args) {
            cancel();
            timerId = setTimeout(() => {
                timerId = null; // Timer finished
                if (useIdle) {
                    // Calculate idle timeout based on delay: clamp(delay * 4, 200, 2000)
                    // This ensures short delays don't wait too long, while long delays are capped.
                    const idleTimeout = Math.min(Math.max(delay * 4, 200), 2000);

                    // Schedule idle callback and store the cancel function
                    // Explicitly receive 'deadline' to match runWhenIdle signature
                    cancelIdle = runWhenIdle((deadline) => {
                        cancelIdle = null; // Idle callback finished
                        func.apply(this, args);
                    }, idleTimeout);
                } else {
                    func.apply(this, args);
                }
            }, delay);
        };

        debounced.cancel = cancel;
        return debounced;
    }

    /**
     * Helper function to check if an item is a non-array object.
     * @param {unknown} item The item to check.
     * @returns {item is Record<string, any>}
     */
    function isObject(item) {
        return !!(item && typeof item === 'object' && !Array.isArray(item));
    }

    /**
     * Creates a deep copy of a JSON-serializable object.
     * @template T
     * @param {T} obj The object to clone.
     * @returns {T} The deep copy of the object.
     */
    function deepClone(obj) {
        try {
            return structuredClone(obj);
        } catch (e) {
            Logger.error('CLONE FAILED', '', 'deepClone failed. Data contains non-clonable items.', e);
            throw e;
        }
    }

    /**
     * Recursively resolves the configuration by overlaying source properties onto the target object.
     * The target object is mutated. This handles recursive updates for nested objects but overwrites arrays/primitives.
     *
     * [MERGE BEHAVIOR]
     * Keys present in 'source' but missing in 'target' are ignored.
     * The 'target' object acts as a schema; it must contain all valid keys.
     *
     * @param {object} target The target object (e.g., a deep copy of default config).
     * @param {object} source The source object (e.g., user config).
     * @returns {object} The mutated target object.
     */
    function resolveConfig(target, source) {
        for (const key in source) {
            // Security: Prevent prototype pollution
            if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
                continue;
            }

            if (Object.prototype.hasOwnProperty.call(source, key)) {
                // Strict check: Ignore keys that do not exist in the target (default config).
                if (!Object.prototype.hasOwnProperty.call(target, key)) {
                    continue;
                }

                const sourceVal = source[key];
                const targetVal = target[key];

                if (isObject(sourceVal) && isObject(targetVal)) {
                    // If both are objects, recurse
                    resolveConfig(targetVal, sourceVal);
                } else if (typeof sourceVal !== 'undefined') {
                    // Otherwise, overwrite or set the value from the source
                    target[key] = sourceVal;
                }
            }
        }
        return target;
    }

    /**
     * @typedef {Node|string|number|boolean|null|undefined} HChild
     */
    /**
     * Creates a DOM element using a hyperscript-style syntax.
     * @param {string} tag - Tag name with optional ID/class (e.g., "div#app.container", "my-element").
     * @param {object | HChild | HChild[]} [propsOrChildren] - Attributes object or children.
     * @param {HChild | HChild[]} [children] - Children (if props are specified).
     * @returns {HTMLElement | SVGElement} The created DOM element.
     */
    function h(tag, propsOrChildren, children) {
        const SVG_NS = 'http://www.w3.org/2000/svg';
        const match = tag.match(/^([a-z0-9-]+)(#[\w-]+)?((\.[\w-]+)*)$/i);
        if (!match) throw new Error(`Invalid tag syntax: ${tag}`);

        const [, tagName, id, classList] = match;
        const isSVG = ['svg', 'circle', 'rect', 'path', 'g', 'line', 'text', 'use', 'defs', 'clipPath'].includes(tagName);
        const el = isSVG ? document.createElementNS(SVG_NS, tagName) : document.createElement(tagName);

        if (id) el.id = id.slice(1);
        if (classList) {
            const classes = classList.replace(/\./g, ' ').trim();
            if (classes) {
                el.classList.add(...classes.split(/\s+/));
            }
        }

        let props = {};
        let childrenArray;
        if (propsOrChildren && Object.prototype.toString.call(propsOrChildren) === '[object Object]') {
            props = propsOrChildren;
            childrenArray = children;
        } else {
            childrenArray = propsOrChildren;
        }

        // --- Start of Attribute/Property Handling ---
        const directProperties = new Set(['value', 'checked', 'selected', 'readOnly', 'disabled', 'multiple', 'textContent']);
        const urlAttributes = new Set(['href', 'src', 'action', 'formaction']);
        const safeProtocols = new Set(['https:', 'http:', 'mailto:', 'tel:', 'blob:', 'data:']);

        for (const [key, value] of Object.entries(props)) {
            // 0. Handle `ref` callback (highest priority after props parsing).
            if (key === 'ref' && typeof value === 'function') {
                value(el);
            }
            // 1. Security check for URL attributes.
            else if (urlAttributes.has(key)) {
                const url = String(value);
                try {
                    const parsedUrl = new URL(url); // Throws if not an absolute URL.
                    if (safeProtocols.has(parsedUrl.protocol)) {
                        el.setAttribute(key, url);
                    } else {
                        el.setAttribute(key, '#');
                        Logger.warn('UNSAFE URL', LOG_STYLES.YELLOW, `Blocked potentially unsafe protocol "${parsedUrl.protocol}" in attribute "${key}":`, url);
                    }
                } catch {
                    el.setAttribute(key, '#');
                    Logger.warn('INVALID URL', LOG_STYLES.YELLOW, `Blocked invalid or relative URL in attribute "${key}":`, url);
                }
            }
            // 2. Direct property assignments.
            else if (directProperties.has(key)) {
                el[key] = value;
            }
            // 3. Other specialized handlers.
            else if (key === 'style' && typeof value === 'object') {
                Object.assign(el.style, value);
            } else if (key === 'dataset' && typeof value === 'object') {
                for (const [dataKey, dataVal] of Object.entries(value)) {
                    el.dataset[dataKey] = dataVal;
                }
            } else if (key.startsWith('on')) {
                if (typeof value === 'function') {
                    el.addEventListener(key.slice(2).toLowerCase(), value);
                }
            } else if (key === 'className') {
                const classes = String(value).trim();
                if (classes) {
                    el.classList.add(...classes.split(/\s+/));
                }
            } else if (key.startsWith('aria-')) {
                el.setAttribute(key, String(value));
            }
            // 4. Default attribute handling.
            else if (value !== false && value !== null && typeof value !== 'undefined') {
                el.setAttribute(key, value === true ? '' : String(value));
            }
        }
        // --- End of Attribute/Property Handling ---

        const fragment = document.createDocumentFragment();
        /**
         * Appends a child node or text to the document fragment.
         * @param {HChild} child - The child to append.
         */
        function append(child) {
            if (child === null || child === false || typeof child === 'undefined') return;
            if (typeof child === 'string' || typeof child === 'number') {
                fragment.appendChild(document.createTextNode(String(child)));
            } else if (Array.isArray(child)) {
                child.forEach(append);
            } else if (child instanceof Node) {
                fragment.appendChild(child);
            } else {
                throw new Error('Unsupported child type');
            }
        }
        append(childrenArray);

        el.appendChild(fragment);

        if (el instanceof HTMLElement || el instanceof SVGElement) {
            return el;
        }
        throw new Error('Created element is not a valid HTMLElement or SVGElement');
    }

    /**
     * Recursively builds a DOM element from a definition object using the h() function.
     * @param {object} def The definition object for the element.
     * @returns {HTMLElement | SVGElement | null} The created DOM element.
     */
    function createIconFromDef(def) {
        if (!def) return null;
        const children = def.children ? def.children.map((child) => createIconFromDef(child)) : [];
        return h(def.tag, def.props, children);
    }

    // =================================================================================
    // SECTION: Event-Driven Architecture (Pub/Sub)
    // Description: A event bus for decoupled communication between classes.
    // =================================================================================

    const EventBus = {
        events: {},
        uiWorkQueue: [],
        isUiWorkScheduled: false,
        _logAggregation: {},
        // prettier-ignore
        _aggregatedEvents: new Set([
        ]),
        _aggregationDelay: 500, // ms

        /**
         * Subscribes a listener to an event using a unique key.
         * If a subscription with the same event and key already exists, it will be overwritten.
         * @param {string} event The event name.
         * @param {Function} listener The callback function.
         * @param {string} key A unique key for this subscription (e.g., 'ClassName.methodName').
         */
        subscribe(event, listener, key) {
            if (!key) {
                Logger.error('', '', 'EventBus.subscribe requires a unique key.');
                return;
            }
            if (!this.events[event]) {
                this.events[event] = new Map();
            }
            this.events[event].set(key, listener);
        },
        /**
         * Subscribes a listener that will be automatically unsubscribed after one execution.
         * @param {string} event The event name.
         * @param {Function} listener The callback function.
         * @param {string} key A unique key for this subscription.
         */
        once(event, listener, key) {
            if (!key) {
                Logger.error('', '', 'EventBus.once requires a unique key.');
                return;
            }
            const onceListener = (...args) => {
                this.unsubscribe(event, key);
                listener(...args);
            };
            this.subscribe(event, onceListener, key);
        },
        /**
         * Unsubscribes a listener from an event using its unique key.
         * @param {string} event The event name.
         * @param {string} key The unique key used during subscription.
         */
        unsubscribe(event, key) {
            if (!this.events[event] || !key) {
                return;
            }
            this.events[event].delete(key);
            if (this.events[event].size === 0) {
                delete this.events[event];
            }
        },
        /**
         * Publishes an event, calling all subscribed listeners with the provided data.
         * @param {string} event The event name.
         * @param {...any} args The data to pass to the listeners.
         */
        publish(event, ...args) {
            if (!this.events[event]) {
                return;
            }

            if (Logger.levels[Logger.level] >= Logger.levels.debug) {
                // --- Aggregation logic START ---
                if (this._aggregatedEvents.has(event)) {
                    if (!this._logAggregation[event]) {
                        this._logAggregation[event] = { timer: null, count: 0 };
                    }
                    const aggregation = this._logAggregation[event];
                    aggregation.count++;

                    clearTimeout(aggregation.timer);
                    aggregation.timer = setTimeout(() => {
                        const finalCount = this._logAggregation[event]?.count || 0;
                        if (finalCount > 0) {
                            Logger.debug('EventBus', LOG_STYLES.PURPLE, `Event Published: ${event} (x${finalCount})`);
                        }
                        delete this._logAggregation[event];
                    }, this._aggregationDelay);

                    // Execute subscribers for the aggregated event, but without the verbose individual logs.
                    [...this.events[event].values()].forEach((listener) => {
                        try {
                            listener(...args);
                        } catch (e) {
                            Logger.error('', '', `EventBus error in listener for event "${event}":`, e);
                        }
                    });
                    return; // End execution here for aggregated events in debug mode.
                }
                // --- Aggregation logic END ---

                // In debug mode, provide detailed logging for NON-aggregated events.
                const subscriberKeys = [...this.events[event].keys()];

                Logger.groupCollapsed('EventBus', LOG_STYLES.PURPLE, `Event Published: ${event}`);

                if (args.length > 0) {
                    console.log('  - Payload:', ...args);
                } else {
                    console.log('  - Payload: (No data)');
                }

                // Displaying subscribers helps in understanding the event's impact.
                if (subscriberKeys.length > 0) {
                    console.log('  - Subscribers:\n' + subscriberKeys.map((key) => `    > ${key}`).join('\n'));
                } else {
                    console.log('  - Subscribers: (None)');
                }

                // Iterate with keys for better logging
                this.events[event].forEach((listener, key) => {
                    try {
                        // Log which specific subscriber is being executed
                        Logger.debug('', LOG_STYLES.PURPLE, `-> Executing: ${key}`);
                        listener(...args);
                    } catch (e) {
                        // Enhance error logging with the specific subscriber key
                        Logger.error('LISTENER ERROR', LOG_STYLES.RED, `Listener "${key}" failed for event "${event}":`, e);
                    }
                });

                Logger.groupEnd();
            } else {
                // Iterate over a copy of the values in case a listener unsubscribes itself.
                [...this.events[event].values()].forEach((listener) => {
                    try {
                        listener(...args);
                    } catch (e) {
                        Logger.error('LISTENER ERROR', LOG_STYLES.RED, `Listener failed for event "${event}":`, e);
                    }
                });
            }
        },

        /**
         * Queues a function to be executed on the next animation frame.
         * Batches multiple UI updates into a single repaint cycle.
         * @param {Function} workFunction The function to execute.
         */
        queueUIWork(workFunction) {
            this.uiWorkQueue.push(workFunction);
            if (!this.isUiWorkScheduled) {
                this.isUiWorkScheduled = true;
                requestAnimationFrame(this._processUIWorkQueue.bind(this));
            }
        },

        /**
         * @private
         * Processes all functions in the UI work queue.
         */
        _processUIWorkQueue() {
            // Prevent modifications to the queue while processing.
            const queueToProcess = [...this.uiWorkQueue];
            this.uiWorkQueue.length = 0;

            for (const work of queueToProcess) {
                try {
                    work();
                } catch (e) {
                    Logger.error('UI QUEUE ERROR', LOG_STYLES.RED, 'Error in queued UI work:', e);
                }
            }
            this.isUiWorkScheduled = false;
        },
    };

    /**
     * Creates a unique, consistent event subscription key for EventBus.
     * @param {object} context The `this` context of the subscribing class instance.
     * @param {string} eventName The full event name from the EVENTS constant.
     * @returns {string} A key in the format 'ClassName.purpose'.
     */
    function createEventKey(context, eventName) {
        // Extract a meaningful 'purpose' from the event name
        const parts = eventName.split(':');
        const purpose = parts.length > 1 ? parts.slice(1).join('_') : parts[0];

        let contextName = 'UnknownContext';
        if (context && context.constructor && context.constructor.name) {
            contextName = context.constructor.name;
        }
        return `${contextName}.${purpose}`;
    }

    // =================================================================================
    // SECTION: Configuration Management
    // =================================================================================

    class ConfigManager {
        constructor() {
            this.config = null;
        }
        async load() {
            const raw = await GM.getValue(CONSTANTS.CONFIG_KEY, null);
            this.config = resolveConfig(deepClone(DEFAULT_CONFIG), raw ? JSON.parse(raw) : {});
            Logger.setLevel(this.config.developer.logger_level);
        }
        async save(newConfig) {
            this.config = resolveConfig(deepClone(DEFAULT_CONFIG), newConfig);
            await GM.setValue(CONSTANTS.CONFIG_KEY, JSON.stringify(this.config));
            EventBus.publish(EVENTS.CONFIG_UPDATED, this.config);
        }
        get() {
            return this.config || deepClone(DEFAULT_CONFIG);
        }
    }

    // =================================================================================
    // SECTION: Settings Modal
    // =================================================================================

    class SettingsModal {
        /**
         * @param {ConfigManager} configManager
         * @param {object} siteStyles - The style definition object.
         */
        constructor(configManager, siteStyles) {
            this.configManager = configManager;
            this.siteStyles = siteStyles;
            this.overlay = null;
            // Bind the keydown handler once to ensure consistent reference for add/removeEventListener
            this._boundHandleKeyDown = this._handleKeyDown.bind(this);
        }

        /**
         * Opens the settings modal.
         */
        open() {
            if (this.overlay) return;

            // Inject styles dynamically using the provided siteStyles
            this._injectStyles();

            const config = this.configManager.get();

            // --- Helper: Dynamic Description Maps ---

            // Referrer Policy Descriptions
            const referrerDescMap = {
                [CONSTANTS.REFERRER_POLICY.NO_REFERRER]: {
                    text: 'No referrer information is sent. Maximum privacy, but many images or pages may fail to load due to anti-hotlink protection.',
                    color: 'inherit',
                },
                [CONSTANTS.REFERRER_POLICY.ORIGIN]: {
                    text: 'Only the domain name is sent. Balances privacy and functionality, though some strict sites may still block access.',
                    color: '#81c995',
                },
                [CONSTANTS.REFERRER_POLICY.UNSAFE_URL]: {
                    text: 'The full URL including search queries is sent. Highest compatibility, but exposes your search data to the site.',
                    color: '#ff8a80',
                },
            };

            const createReferrerDesc = (val) => {
                const info = referrerDescMap[val] || referrerDescMap[CONSTANTS.REFERRER_POLICY.ORIGIN];
                return h(`div#${APPID}-referrer-desc`, { style: { marginTop: '4px', color: info.color } }, info.text);
            };

            // Fetch Strategy Descriptions
            const strategyDescMap = {
                [CONSTANTS.FETCH_STRATEGY.AUTO]: 'Checks headers first. Uses Blob mode (hidden URL) if forced download is detected. Adds slight delay.',
                [CONSTANTS.FETCH_STRATEGY.BLOB]: "Loads images as 'blob:' URLs. Fast, but the original URL is hidden (visible only in Console). Consumes memory.",
                [CONSTANTS.FETCH_STRATEGY.DIRECT]: 'Opens the URL directly. Zero overhead, but may trigger forced downloads.',
            };

            const createStrategyDesc = (val) => {
                const text = strategyDescMap[val] || strategyDescMap[CONSTANTS.FETCH_STRATEGY.AUTO];
                return h(`div#${APPID}-strategy-desc`, { style: { marginTop: '4px', color: '#9aa0a6' } }, text);
            };

            // --- Helper: Interaction Logic ---

            const updateRetryState = (strategy) => {
                const retryWrapper = document.getElementById(`${APPID}-retry-wrapper`);
                const retryInput = document.getElementById(`${APPID}-input-retry`);
                const retryDesc = document.getElementById(`${APPID}-retry-desc`);

                if (!retryWrapper || !retryInput) return;

                const isDisabled = strategy === CONSTANTS.FETCH_STRATEGY.DIRECT;
                const opacity = isDisabled ? '0.5' : '1';

                retryInput.disabled = isDisabled;
                retryWrapper.style.opacity = opacity;
                retryWrapper.style.pointerEvents = isDisabled ? 'none' : 'auto';

                if (retryDesc) {
                    retryDesc.style.opacity = opacity;
                }
            };

            // --- Modal Construction ---

            this.overlay = h(
                `div.${APPID}-modal-overlay`,
                {
                    onclick: (e) => {
                        if (e.target === this.overlay) this.close();
                    },
                },
                [
                    h(`div.${APPID}-modal-box`, [
                        // Header
                        h(`div.${APPID}-modal-header`, [h('span', `${APPNAME} Settings`)]),

                        // Content
                        h(`div.${APPID}-modal-content`, [
                            // Group 1: Appearance
                            this._createFormGroup('Appearance', '', [
                                h(
                                    `label.${APPID}-checkbox-wrapper`,
                                    {
                                        title: 'Reduces visual clutter by hiding buttons until you hover over an image result.',
                                    },
                                    [
                                        h(`input#${APPID}-input-hover`, {
                                            type: 'checkbox',
                                            checked: config.common.showOnlyOnHover,
                                        }),
                                        h('span', 'Show buttons only on hover'),
                                    ]
                                ),
                                h(
                                    `label.${APPID}-checkbox-wrapper`,
                                    {
                                        title: 'Displays the globe button that takes you directly to the webpage hosting the image.',
                                    },
                                    [
                                        h(`input#${APPID}-input-page-btn`, {
                                            type: 'checkbox',
                                            checked: config.common.showVisitPageButton,
                                        }),
                                        h('span', 'Show "Visit Page" button'),
                                    ]
                                ),
                            ]),

                            h('hr', { style: { border: '0', borderTop: '1px solid #5f6368', margin: '8px 0 16px 0' } }),

                            // Group 2: Network & Privacy
                            this._createFormGroup('Network & Privacy', '', [
                                // 1. Fetch Strategy
                                h(
                                    `label.${APPID}-form-label`,
                                    {
                                        style: { fontWeight: 'normal', marginBottom: '4px' },
                                        title: 'Controls how images are fetched and opened.',
                                    },
                                    'Fetch Strategy'
                                ),
                                h(
                                    `select#${APPID}-input-strategy.${APPID}-form-select`,
                                    {
                                        onchange: (e) => {
                                            const val = e.target.value;
                                            const descEl = document.getElementById(`${APPID}-strategy-desc`);
                                            if (descEl) {
                                                descEl.textContent = strategyDescMap[val] || strategyDescMap[CONSTANTS.FETCH_STRATEGY.AUTO];
                                            }
                                            updateRetryState(val);
                                        },
                                        title: 'Select the strategy for fetching and opening images.',
                                    },
                                    [
                                        h('option', { value: CONSTANTS.FETCH_STRATEGY.AUTO, selected: config.common.fetchStrategy === CONSTANTS.FETCH_STRATEGY.AUTO }, 'Auto Detect (Default)'),
                                        h('option', { value: CONSTANTS.FETCH_STRATEGY.BLOB, selected: config.common.fetchStrategy === CONSTANTS.FETCH_STRATEGY.BLOB }, 'Always Blob (Fast)'),
                                        h('option', { value: CONSTANTS.FETCH_STRATEGY.DIRECT, selected: config.common.fetchStrategy === CONSTANTS.FETCH_STRATEGY.DIRECT }, 'Always Direct'),
                                    ]
                                ),
                                createStrategyDesc(config.common.fetchStrategy || CONSTANTS.FETCH_STRATEGY.AUTO),

                                // 2. Referrer Policy
                                h(
                                    `label.${APPID}-form-label`,
                                    {
                                        style: { fontWeight: 'normal', marginBottom: '4px', marginTop: '16px' },
                                        title: 'Controls referrer data sent to the destination. Balances privacy with image loading success.',
                                    },
                                    'Referrer Policy'
                                ),
                                h(
                                    `select#${APPID}-input-referrer.${APPID}-form-select`,
                                    {
                                        onchange: (e) => {
                                            const descEl = document.getElementById(`${APPID}-referrer-desc`);
                                            if (descEl) {
                                                const info = referrerDescMap[e.target.value] || referrerDescMap[CONSTANTS.REFERRER_POLICY.ORIGIN];
                                                descEl.textContent = info.text;
                                                descEl.style.color = info.color;
                                            }
                                        },
                                        title: 'Controls what information is sent to the destination site.',
                                    },
                                    [
                                        h('option', { value: CONSTANTS.REFERRER_POLICY.NO_REFERRER, selected: config.common.referrerPolicy === CONSTANTS.REFERRER_POLICY.NO_REFERRER }, 'No Referrer'),
                                        h('option', { value: CONSTANTS.REFERRER_POLICY.ORIGIN, selected: config.common.referrerPolicy === CONSTANTS.REFERRER_POLICY.ORIGIN || !config.common.referrerPolicy }, 'Origin Only (Default)'),
                                        h('option', { value: CONSTANTS.REFERRER_POLICY.UNSAFE_URL, selected: config.common.referrerPolicy === CONSTANTS.REFERRER_POLICY.UNSAFE_URL }, 'Full URL'),
                                    ]
                                ),
                                createReferrerDesc(config.common.referrerPolicy || CONSTANTS.REFERRER_POLICY.ORIGIN),

                                // 3. Retry on failure
                                h('div', { style: { marginTop: '16px' } }, [
                                    h(
                                        `label#${APPID}-retry-wrapper.${APPID}-checkbox-wrapper`,
                                        {
                                            title: 'Automatically retries with an alternative referrer policy if the initial attempt fails.',
                                            style: { transition: 'opacity 0.2s' },
                                        },
                                        [
                                            h(`input#${APPID}-input-retry`, {
                                                type: 'checkbox',
                                                checked: config.common.retryOnFailure,
                                            }),
                                            h('span', 'Retry on failure'),
                                        ]
                                    ),
                                    h(`div#${APPID}-retry-desc.${APPID}-form-desc`, { style: { marginLeft: '24px', transition: 'opacity 0.2s' } }, 'Automatically retries with an alternative referrer policy if the initial attempt fails.'),
                                ]),
                            ]),

                            h('hr', { style: { border: '0', borderTop: '1px solid #5f6368', margin: '8px 0 16px 0' } }),

                            // Group 3: Advanced Settings
                            this._createFormGroup('Advanced Settings', '', [
                                h(
                                    `label.${APPID}-form-label`,
                                    {
                                        style: { fontWeight: 'normal', marginBottom: '4px' },
                                        title: 'Determines how long the image data is kept in memory.',
                                    },
                                    'Blob URL Revoke Time'
                                ),
                                h(
                                    `select#${APPID}-input-revoke-time.${APPID}-form-select`,
                                    {
                                        title: 'Determines how long the image data is kept in memory.',
                                    },
                                    [
                                        h('option', { value: 60000, selected: Number(config.common.blobRevokeTimeout) === 60000 }, '1 Minute (Low Memory)'),
                                        h('option', { value: 300000, selected: Number(config.common.blobRevokeTimeout) === 300000 }, '5 Minutes'),
                                        h('option', { value: 600000, selected: Number(config.common.blobRevokeTimeout) === 600000 || !config.common.blobRevokeTimeout }, '10 Minutes (Default)'),
                                        h('option', { value: 1800000, selected: Number(config.common.blobRevokeTimeout) === 1800000 }, '30 Minutes'),
                                        h('option', { value: 3600000, selected: Number(config.common.blobRevokeTimeout) === 3600000 }, '1 Hour (High Memory)'),
                                    ]
                                ),
                                h(`div.${APPID}-form-desc`, { style: { marginTop: '4px' } }, 'Time to hold image data in memory. Increase this if images fail to load when viewing background tabs after a delay.'),
                            ]),
                        ]),

                        // Footer
                        h(`div.${APPID}-modal-footer`, [
                            // Left: Restore Defaults
                            h(`button.${APPID}-ui-btn.${APPID}-btn-secondary`, { onclick: () => this._restoreDefaults() }, 'Restore Defaults'),
                            // Right: Actions
                            h(`div.${APPID}-footer-actions`, [
                                h(`button.${APPID}-ui-btn.${APPID}-btn-secondary`, { onclick: () => this.close() }, 'Cancel'),
                                h(`button.${APPID}-ui-btn.${APPID}-btn-primary`, { onclick: () => this.save() }, 'Save'),
                            ]),
                        ]),
                    ]),
                ]
            );

            document.body.appendChild(this.overlay);

            // Initialize Retry state based on current strategy
            updateRetryState(config.common.fetchStrategy || CONSTANTS.FETCH_STRATEGY.AUTO);

            // Add global key listener for ESC
            document.addEventListener('keydown', this._boundHandleKeyDown);
        }

        /**
         * Closes the settings modal.
         */
        close() {
            if (this.overlay) {
                // Remove global key listener
                document.removeEventListener('keydown', this._boundHandleKeyDown);

                this.overlay.remove();
                this.overlay = null;
            }
        }

        /**
         * Saves the current settings from the form.
         */
        async save() {
            const newConfig = this.configManager.get();

            // Collect values from DOM
            newConfig.common.showOnlyOnHover = document.getElementById(`${APPID}-input-hover`).checked;
            newConfig.common.showVisitPageButton = document.getElementById(`${APPID}-input-page-btn`).checked;
            newConfig.common.fetchStrategy = document.getElementById(`${APPID}-input-strategy`).value;
            newConfig.common.referrerPolicy = document.getElementById(`${APPID}-input-referrer`).value;
            newConfig.common.retryOnFailure = document.getElementById(`${APPID}-input-retry`).checked;
            newConfig.common.blobRevokeTimeout = Number(document.getElementById(`${APPID}-input-revoke-time`).value);

            await this.configManager.save(newConfig);
            this.close();
        }

        /**
         * Restores default settings to the form inputs.
         * @private
         */
        _restoreDefaults() {
            // Restore Checkboxes
            document.getElementById(`${APPID}-input-hover`).checked = DEFAULT_CONFIG.common.showOnlyOnHover;
            document.getElementById(`${APPID}-input-page-btn`).checked = DEFAULT_CONFIG.common.showVisitPageButton;

            // Strategy (Triggers Retry state update via change event)
            const strategyInput = document.getElementById(`${APPID}-input-strategy`);
            strategyInput.value = DEFAULT_CONFIG.common.fetchStrategy;
            strategyInput.dispatchEvent(new Event('change'));

            // Referrer (Triggers description update)
            const referrerInput = document.getElementById(`${APPID}-input-referrer`);
            referrerInput.value = DEFAULT_CONFIG.common.referrerPolicy;
            referrerInput.dispatchEvent(new Event('change'));

            // Retry
            document.getElementById(`${APPID}-input-retry`).checked = DEFAULT_CONFIG.common.retryOnFailure;

            // Advanced
            document.getElementById(`${APPID}-input-revoke-time`).value = DEFAULT_CONFIG.common.blobRevokeTimeout;
        }

        /**
         * Handles global keydown events.
         * @private
         */
        _handleKeyDown(e) {
            if (e.key === 'Escape') {
                this.close();
            }
        }

        /**
         * Helper to create a labeled form group with indented content.
         * @private
         */
        _createFormGroup(label, desc, control) {
            return h(`div.${APPID}-form-group`, [h(`label.${APPID}-form-label`, label), h(`div.${APPID}-indent-content`, [control, desc ? h(`div.${APPID}-form-desc`, desc) : null])]);
        }

        /**
         * Injects the modal styles dynamically.
         * Enforces a fixed Dark Mode theme as requested.
         * @private
         */
        _injectStyles() {
            const id = `${APPID}-modal-dynamic-styles`;
            if (document.getElementById(id)) return;

            // Fixed Dark Mode Palette (Google-like Dark Theme)
            const css = `
                .${APPID}-modal-overlay {
                    position: fixed; top: 0; left: 0; width: 100%; height: 100%;
                    background: rgb(0 0 0 / 0.6);
                    z-index: ${CONSTANTS.MODAL.Z_INDEX};
                    display: flex; align-items: center; justify-content: center;
                    backdrop-filter: blur(2px);
                }
                .${APPID}-modal-box {
                    background: #202124;
                    color: #e8eaed;
                    width: ${CONSTANTS.MODAL.WIDTH}px;
                    max-width: 90vw;
                    max-height: 90vh; /* Limit height to viewport */
                    border: 1px solid #5f6368;
                    border-radius: 8px;
                    box-shadow: 0 4px 16px rgb(0 0 0 / 0.5);
                    display: flex; flex-direction: column;
                    font-family: Roboto, Arial, sans-serif; font-size: 14px;
                    color-scheme: dark;
                }
                .${APPID}-modal-header {
                    padding: 12px 16px;
                    font-size: 1.1em; font-weight: bold;
                    border-bottom: 1px solid #5f6368;
                    display: flex; justify-content: space-between; align-items: center;
                    background: #202124;
                    border-radius: 8px 8px 0 0;
                    flex-shrink: 0; /* Prevent shrinking */
                }
                .${APPID}-modal-content {
                    padding: 16px;
                    overflow-y: auto;
                    flex: 1; /* Fill remaining space */
                    min-height: 0; /* Enable scrolling inside flex item */
                }
                .${APPID}-modal-footer {
                    padding: 12px 16px;
                    border-top: 1px solid #5f6368;
                    display: flex; justify-content: space-between;
                    align-items: center;
                    background: #202124;
                    border-radius: 0 0 8px 8px;
                    flex-shrink: 0; /* Prevent shrinking */
                }
                .${APPID}-footer-actions {
                    display: flex; gap: 8px;
                }
                .${APPID}-form-group {
                    margin-bottom: 16px;
                }
                .${APPID}-form-label {
                    display: block; margin-bottom: 8px; font-weight: 600; color: #e8eaed;
                }
                .${APPID}-indent-content {
                    margin-left: 12px;
                    display: flex; flex-direction: column;
                    gap: 8px;
                }
                .${APPID}-form-desc {
                    color: #9aa0a6; margin-bottom: 6px; line-height: 1.4;
                }
                .${APPID}-form-input {
                    width: 100%; padding: 6px 8px;
                    background: #303134; border: 1px solid #5f6368; border-radius: 4px;
                    color: #e8eaed; box-sizing: border-box;
                }
                .${APPID}-form-input:focus {
                    border-color: #8ab4f8; outline: 1px solid #8ab4f8;
                }
                .${APPID}-form-select {
                    width: 100%; padding: 6px 8px;
                    background: #303134; border: 1px solid #5f6368; border-radius: 4px;
                    color: #e8eaed; box-sizing: border-box;
                    cursor: pointer;
                }
                .${APPID}-form-select:focus {
                    border-color: #8ab4f8; outline: 1px solid #8ab4f8;
                }
                .${APPID}-checkbox-wrapper {
                    display: flex; align-items: center; gap: 8px; color: #e8eaed;
                }
                .${APPID}-ui-btn {
                    padding: 6px 16px; border-radius: 4px; border: 1px solid #5f6368;
                    cursor: pointer; font-size: 13px; font-weight: 500;
                    transition: background 0.1s;
                    white-space: nowrap;
                }
                .${APPID}-btn-primary {
                    background: #8ab4f8; color: #202124; border: 1px solid #8ab4f8;
                }
                .${APPID}-btn-primary:hover {
                    opacity: 0.9;
                }
                .${APPID}-btn-secondary {
                    background: #303134; color: #e8eaed;
                }
                .${APPID}-btn-secondary:hover {
                    background: #3c4043;
                }
                
                /* Mobile Responsive */
                @media (max-width: 480px) {
                    .${APPID}-modal-box {
                        width: 95vw;
                        max-height: 95vh;
                    }
                }
            `;

            const style = h('style', { id }, css);
            // Add nonce if available
            const nonce = document.querySelector('script[nonce]')?.nonce;
            if (nonce) style.setAttribute('nonce', nonce);

            document.head.appendChild(style);
        }
    }

    // =================================================================================
    // SECTION: UI Manager
    // =================================================================================

    class UIManager {
        /**
         * @param {ConfigManager} configManager
         */
        constructor(configManager) {
            this.configManager = configManager;
            this.toastContainer = null;
            this.subscriptions = [];
            this.imageBtnTemplate = null;
            this.pageBtnTemplate = null;
            this.urlFetcher = null;

            /**
             * Stores state associated with button elements without polluting the DOM.
             * Key: Button Element
             * Value: { sentinel: HTMLElement, isFetching: boolean, activeBlobUrl: string|null, activeRevokeTimer: number|null, hostSource: string|null }
             * @type {WeakMap<HTMLElement, object>}
             */
            this.btnState = new WeakMap();

            // Pre-bind event handlers to avoid closure creation per button
            this.handleImageClick = this._handleImageClick.bind(this);
            this.handlePageClick = this._handlePageClick.bind(this);
            this.handleHoverOrFocus = this._handleHoverOrFocus.bind(this);
            this.handleMouseDown = this._handleMouseDown.bind(this);
            this.stopProp = this._stopProp.bind(this);

            this._subscribe(EVENTS.CONFIG_UPDATED, () => this.updateStyles());
        }

        /**
         * Helper to subscribe to EventBus events with automatic key management.
         * @param {string} event - The event name to subscribe to.
         * @param {Function} listener - The callback function.
         * @private
         */
        _subscribe(event, listener) {
            const key = createEventKey(this, event);
            EventBus.subscribe(event, listener.bind(this), key);
            this.subscriptions.push({ event, key });
        }

        /**
         * Initializes the UI Manager with platform-specific styles.
         * @param {object} platformStyles - The style configuration for the current platform.
         */
        init(platformStyles) {
            this._injectStyles(platformStyles);
            this.updateStyles();
            this._createToastContainer();

            // Prepare the button templates once for performance (cloneNode usage)
            const commonProps = {
                target: '_blank',
                rel: 'noopener noreferrer',
                referrerpolicy: 'no-referrer',
                // Reset text decoration and force color to avoid "visited" link style issues
                style: { textDecoration: 'none' }, // Color assignment delegated to CSS class
                draggable: 'false',
            };

            // 1. Image Button Template
            this.imageBtnTemplate = h(`a.${APPID}-icon-btn.${APPID}-btn-view-image`, { ...commonProps, title: 'View Image' }, [createIconFromDef(CONSTANTS.ICONS.IMAGE)]);
            this.imageBtnTemplate.setAttribute('href', '#');

            // 2. Page Button Template
            this.pageBtnTemplate = h(`a.${APPID}-icon-btn.${APPID}-btn-visit-page`, { ...commonProps, title: 'Visit Page' }, [createIconFromDef(CONSTANTS.ICONS.GLOBE)]);
            this.pageBtnTemplate.setAttribute('href', '#');
        }

        /**
         * Registers the function used to extract URLs from a container.
         * @param {Function} fetcherFn - Function that takes a container element and returns { imageUrl, hostUrl, hostUrlSource }.
         */
        setUrlFetcher(fetcherFn) {
            this.urlFetcher = fetcherFn;
        }

        /**
         * Registers the function used to asynchronously fetch original image URLs.
         * @param {Function} fetcherFn - Async function that takes a sentinel element and returns a Promise resolving to the URL.
         */
        setOriginalImageFetcher(fetcherFn) {
            this.originalImageFetcher = fetcherFn;
        }

        /**
         * Injects base styles and platform-specific CSS variables.
         * @param {object} platformStyles
         */
        _injectStyles(platformStyles) {
            const id = `${APPID}-styles`;
            if (document.getElementById(id)) return;

            // 1. Generate CSS Variables from platformStyles.vars
            let varDef = ':root {\n';
            if (platformStyles && platformStyles.vars) {
                for (const [key, val] of Object.entries(platformStyles.vars)) {
                    varDef += `  ${key}: ${val};\n`;
                }
            }
            varDef += '}\n';

            // 2. Add Dark Mode Overrides if present
            if (platformStyles && platformStyles.css_overrides) {
                varDef += platformStyles.css_overrides;
            }

            const cssContent = varDef + UI_STYLES.BASE;

            const style = h('style', { id }, cssContent);
            // Add nonce if available (CSP fix)
            const nonce = document.querySelector('script[nonce]')?.nonce;
            if (nonce) style.setAttribute('nonce', nonce);

            document.head.appendChild(style);
        }

        updateStyles() {
            const config = this.configManager.get();
            const id = `${APPID}-dynamic-styles`;

            // Update global state on body for CSS-based color control
            // dataset uses camelCase: data-gidv-referrer-policy -> gidvReferrerPolicy
            // We namespace it to avoid conflicts
            const datasetKey = `${APPID}ReferrerPolicy`;
            document.body.dataset[datasetKey] = config.common.referrerPolicy;

            // Remove existing dynamic styles to re-apply
            let styleEl = document.getElementById(id);
            if (!styleEl) {
                styleEl = h('style', { id: id, type: 'text/css' });
                // Add nonce if available
                const nonce = document.querySelector('script[nonce]')?.nonce;
                if (nonce) styleEl.setAttribute('nonce', nonce);
                document.head.appendChild(styleEl);
            }

            let css = '';

            // 1. Hover Logic
            css += config.common.showOnlyOnHover ? UI_STYLES.HOVER_ENABLE : UI_STYLES.HOVER_DISABLE;

            // 2. Visit Page Button Visibility
            if (!config.common.showVisitPageButton) {
                css += `.${APPID}-btn-visit-page { display: none !important; }`;
            }

            styleEl.textContent = css;
        }

        _createToastContainer() {
            this.toastContainer = h(`div.${APPID}-toast-container`);
            document.body.appendChild(this.toastContainer);
        }

        showToast(message, type = 'info') {
            if (!this.toastContainer) return;
            const toast = h(`div.${APPID}-toast.${APPID}-toast-${type}`, message);
            this.toastContainer.appendChild(toast);
            setTimeout(() => {
                toast.style.opacity = '0';
                setTimeout(() => toast.remove(), CONSTANTS.TOAST_FADE_OUT_DURATION);
            }, CONSTANTS.TOAST_DURATION);
        }

        /**
         * Attaches the buttons to the target container.
         * @param {HTMLElement} sentinelElement - The element detected by Sentinel (used for duplicate check and data source).
         * @param {HTMLElement} targetContainer - The element where buttons will be inserted.
         */
        attachButtons(sentinelElement, targetContainer) {
            // Prevent duplicates using a specific processed class on the sentinel
            const processedClass = `${APPID}-processed`;
            // NOTE: Even if the class exists, we might need to re-attach buttons if they were removed by the host.
            // But if the class exists AND buttons exist, we typically skip.
            // However, with the :not() selector strategy, this method is called only when the class is MISSING.
            // So we can proceed to add the class and manage buttons.

            if (sentinelElement.classList.contains(processedClass)) {
                // If the class is present, checks if buttons are actually there (Paranoid check)
                const hasImgBtn = targetContainer.querySelector(`.${APPID}-btn-view-image`);
                const hasPageBtn = targetContainer.querySelector(`.${APPID}-btn-visit-page`);
                if (hasImgBtn && hasPageBtn) return;
            }

            // Mark the specific element as processed
            sentinelElement.classList.add(processedClass);

            // Add the class used for hover effects to the container that holds the button.
            targetContainer.classList.add(`${APPID}-container`);

            // Create buttons from template
            const imgBtn = this.imageBtnTemplate.cloneNode(true);
            const pageBtn = this.pageBtnTemplate.cloneNode(true);

            // Initialize state in WeakMap
            const initialState = {
                sentinel: sentinelElement,
                isFetching: false,
                activeBlobUrl: null,
                activeRevokeTimer: null,
                activeThumbnailUrl: null,
                hostSource: null,
            };

            this.btnState.set(imgBtn, { ...initialState });
            this.btnState.set(pageBtn, { ...initialState });

            // Bind events for Image Button
            imgBtn.addEventListener('mouseenter', this.handleHoverOrFocus);
            imgBtn.addEventListener('focus', this.handleHoverOrFocus);
            imgBtn.addEventListener('mousedown', this.handleMouseDown);
            imgBtn.addEventListener('mouseup', this.stopProp);
            imgBtn.addEventListener('click', this.handleImageClick);
            imgBtn.addEventListener('auxclick', this.handleImageClick);

            // Bind events for Page Button
            pageBtn.addEventListener('mouseenter', this.handleHoverOrFocus);
            pageBtn.addEventListener('focus', this.handleHoverOrFocus);
            pageBtn.addEventListener('mousedown', this.handleMouseDown);
            pageBtn.addEventListener('mouseup', this.stopProp);
            pageBtn.addEventListener('click', this.handlePageClick);
            pageBtn.addEventListener('auxclick', this.handlePageClick);

            // [Resilience] Smart Attach: Replace existing buttons if they exist to prevent flickering, otherwise append.
            const mountButton = (newBtn, btnClass) => {
                const existingBtn = targetContainer.querySelector(`.${btnClass}`);
                if (existingBtn) {
                    existingBtn.replaceWith(newBtn);
                } else {
                    targetContainer.appendChild(newBtn);
                }
            };

            mountButton(imgBtn, `${APPID}-btn-view-image`);
            mountButton(pageBtn, `${APPID}-btn-visit-page`);
        }

        /**
         * Handles mouseenter and focus events.
         * Updates the URL but DOES NOT stop propagation, allowing the host site's scripts to detect the user.
         * @param {Event} e
         */
        _handleHoverOrFocus(e) {
            const btn = e.currentTarget;
            if (btn instanceof HTMLAnchorElement) {
                this._updateButtonHref(btn);
            }
        }

        /**
         * Handles mousedown event.
         * Updates the URL without stopping propagation to allow host site scripts to execute.
         * @param {Event} e
         */
        _handleMouseDown(e) {
            const btn = e.currentTarget;
            if (btn instanceof HTMLAnchorElement) {
                this._updateButtonHref(btn);
            }
        }

        /**
         * Waits for the URL to be injected by the host site's scripts.
         * Uses MutationObserver to detect changes in the anchor tag's href attribute.
         * @param {HTMLElement} btn
         * @returns {Promise<void>}
         */
        _waitForValidUrl(btn) {
            return new Promise((resolve) => {
                const state = this.btnState.get(btn);
                if (!state || !state.sentinel) {
                    resolve();
                    return;
                }

                // Identify the anchor tag that receives the URL (sentinel is usually inside it or related)
                const link = state.sentinel.closest('a');
                if (!link) {
                    resolve();
                    return;
                }

                const observer = new MutationObserver(() => {
                    this._updateButtonHref(btn);
                    if (btn.getAttribute('href')) {
                        observer.disconnect();
                        resolve();
                    }
                });

                observer.observe(link, { attributes: true, attributeFilter: ['href', 'data-href', 'jsaction'] });

                // Timeout safety
                setTimeout(() => {
                    observer.disconnect();
                    resolve();
                }, CONSTANTS.WAIT_FOR_VALID_URL_TIMEOUT); // 500ms max wait
            });
        }

        /**
         * Stops event propagation.
         * @param {Event} e
         */
        _stopProp(e) {
            e.stopPropagation();
        }

        /**
         * Updates the href and title of the button based on current DOM state.
         * @param {HTMLAnchorElement} btn
         */
        _updateButtonHref(btn) {
            const state = this.btnState.get(btn);
            if (!this.urlFetcher || !state || !state.sentinel) return;

            // Fetch URLs on-demand from the current state of the DOM
            const { imageUrl, hostUrl, hostUrlSource, thumbnailUrl } = this.urlFetcher(state.sentinel);
            const config = this.configManager.get();
            const currentPolicy = config.common.referrerPolicy;
            let policyChanged = false;

            // Update attributes only if policy has changed (Performance Optimization)
            if (btn.getAttribute('referrerpolicy') !== currentPolicy) {
                btn.setAttribute('referrerpolicy', currentPolicy);
                const relValue = currentPolicy === CONSTANTS.REFERRER_POLICY.NO_REFERRER ? 'noopener noreferrer' : 'noopener';
                btn.setAttribute('rel', relValue);
                policyChanged = true;
            }

            const isImageBtn = btn.classList.contains(`${APPID}-btn-view-image`);

            if (isImageBtn) {
                if (imageUrl) {
                    if (btn.getAttribute('href') !== imageUrl || policyChanged) {
                        btn.href = imageUrl;
                        btn.title = 'View Image';
                    }
                    // Store thumbnail URL in state for fallback
                    state.activeThumbnailUrl = thumbnailUrl;
                } else {
                    // Handling for Async Fetchers (Google/DDG)
                    // If an async fetcher is registered, we assume the URL might be found later via interaction.
                    // We keep the thumbnail URL for potential fallback usage.
                    if (this.originalImageFetcher) {
                        btn.removeAttribute('href');
                        btn.title = 'View Image'; // Suppress "(Not found)" for async contexts
                        state.activeThumbnailUrl = thumbnailUrl;
                    } else {
                        btn.removeAttribute('href');
                        btn.title = 'View Image (Not found)';
                        state.activeThumbnailUrl = null;
                    }
                }
            } else {
                // Page Button
                if (hostUrl) {
                    if (btn.getAttribute('href') !== hostUrl || policyChanged) {
                        btn.href = hostUrl;
                        btn.title = 'Visit Page';
                        state.hostSource = hostUrlSource; // Store for logging
                    }
                } else {
                    btn.removeAttribute('href');
                    btn.title = 'Visit Page (Not found)';
                }
            }
        }

        /**
         * Handles clicking the Image button.
         * Supports both Left Click and Middle Click (auxclick).
         * @param {MouseEvent} e
         */
        async _handleImageClick(e) {
            // Only handle Left Click (0) or Middle Click (1)
            if (e.button !== 0 && e.button !== 1) return;

            // Determine activation behavior based on click type
            // Left Click (without modifiers) -> Active (Foreground)
            // Middle Click OR Ctrl/Meta + Click -> Inactive (Background)
            const isActive = e.button === 0 && !e.ctrlKey && !e.metaKey;

            this._stopProp(e);
            e.preventDefault(); // Always prevent default to control opening behavior

            const btn = e.currentTarget;
            if (!(btn instanceof HTMLAnchorElement)) return;

            const state = this.btnState.get(btn);
            if (!state) return;

            if (state.isFetching) return;
            state.isFetching = true;

            // Visual feedback
            btn.style.opacity = '0.6';
            btn.style.cursor = 'wait';

            const config = this.configManager.get();
            const fetchStrategy = config.common.fetchStrategy || CONSTANTS.FETCH_STRATEGY.AUTO;

            // Notify user that fetch is in progress (since we don't open a tab immediately)
            this.showToast('Fetching image info...', 'info');

            let targetUrl = null;

            try {
                this._updateButtonHref(btn);

                // 1. Fetch URL (Async for DDG/Google)
                if (this.originalImageFetcher) {
                    Logger.info('ASYNC FETCH', LOG_STYLES.PURPLE, 'Fetching original URL via adapter...');
                    targetUrl = await this.originalImageFetcher(state.sentinel);

                    if (targetUrl) {
                        Logger.info('ASYNC SUCCESS', LOG_STYLES.GREEN, `URL: ${targetUrl}`);
                    } else {
                        Logger.warn('ASYNC FAIL', '', 'Fetcher returned null.');
                    }
                }

                // Fallback / Default URL
                if (!targetUrl) {
                    if (!btn.getAttribute('href')) {
                        await this._waitForValidUrl(btn);
                    }
                    targetUrl = btn.href;
                }

                if (!targetUrl || targetUrl === window.location.href) {
                    throw new Error('Image URL not found');
                }

                // Cleanup previous resources
                if (state.activeRevokeTimer) {
                    clearTimeout(state.activeRevokeTimer);
                    state.activeRevokeTimer = null;
                }
                if (state.activeBlobUrl) {
                    URL.revokeObjectURL(state.activeBlobUrl);
                    state.activeBlobUrl = null;
                }

                Logger.info('CHECK START', LOG_STYLES.GRAY, targetUrl);

                // 2. Determine Mode (Blob vs Direct)
                let useBlob = false;

                switch (fetchStrategy) {
                    case CONSTANTS.FETCH_STRATEGY.BLOB:
                        Logger.info('DECISION', LOG_STYLES.PURPLE, 'Strategy="Always Blob"');
                        useBlob = true;
                        break;
                    case CONSTANTS.FETCH_STRATEGY.DIRECT:
                        Logger.info('DECISION', LOG_STYLES.PURPLE, 'Strategy="Always Direct"');
                        useBlob = false;
                        break;
                    case CONSTANTS.FETCH_STRATEGY.AUTO:
                    default:
                        // Default behavior: Check headers
                        useBlob = await NetworkHelper.shouldFetchAsBlob(targetUrl, config.common.referrerPolicy);
                        break;
                }

                // 3. Execute Opening
                if (useBlob) {
                    Logger.info('DECISION', LOG_STYLES.BLUE, `Mode="Blob", Policy="${config.common.referrerPolicy}"`);

                    const blob = await NetworkHelper.fetchImageAsBlob(targetUrl, config.common.referrerPolicy, config.common.retryOnFailure, CONSTANTS.LOG_TAGS.ORIGINAL);
                    const blobUrl = URL.createObjectURL(blob);
                    state.activeBlobUrl = blobUrl;

                    Logger.info('OPENING', LOG_STYLES.GREEN, `Opening Blob URL: "${blobUrl}"`);

                    // Use Anchor Tag Click for Blob URLs
                    // GM.openInTab often fails with blob: URLs due to security restrictions.
                    // We use a standard anchor click which browsers handle correctly for local resources.
                    const a = document.createElement('a');
                    a.href = blobUrl;
                    a.target = '_blank';
                    a.rel = 'noopener noreferrer';
                    a.style.display = 'none';
                    document.body.appendChild(a);
                    a.click();
                    setTimeout(() => a.remove(), CONSTANTS.TIMEOUTS.UI_DELAY);

                    // Schedule cleanup
                    const revokeTime = config.common.blobRevokeTimeout || 600000;
                    Logger.info('BLOB', LOG_STYLES.GRAY, `Revoke scheduled in ${revokeTime / 60000} min`);
                    state.activeRevokeTimer = setTimeout(() => {
                        URL.revokeObjectURL(blobUrl);
                        if (state.activeBlobUrl === blobUrl) {
                            state.activeBlobUrl = null;
                            state.activeRevokeTimer = null;
                        }
                    }, revokeTime);
                } else {
                    Logger.info('DECISION', LOG_STYLES.GREEN, `Mode="Direct"`);
                    Logger.info('OPENING', LOG_STYLES.GREEN, `Opening Direct URL: "${targetUrl}" (Active: ${isActive})`);

                    // Use GM.openInTab for Direct URLs to support background opening preference
                    GM.openInTab(targetUrl, { active: isActive, insert: true });
                }
            } catch (err) {
                // Error handling (Thumbnail fallback etc.)
                if (state.activeThumbnailUrl) {
                    this.showToast('Original image failed. Opening preview...', 'warn');
                    Logger.warn('FALLBACK', '', `Original failed: ${err.message}`);

                    try {
                        const blob = await NetworkHelper.fetchImageAsBlob(state.activeThumbnailUrl, config.common.referrerPolicy, false, CONSTANTS.LOG_TAGS.THUMBNAIL);
                        const blobUrl = URL.createObjectURL(blob);
                        state.activeBlobUrl = blobUrl;

                        // Use anchor for fallback blob as well
                        const a = document.createElement('a');
                        a.href = blobUrl;
                        a.target = '_blank';
                        a.rel = 'noopener noreferrer';
                        a.style.display = 'none';
                        document.body.appendChild(a);
                        a.click();
                        setTimeout(() => a.remove(), CONSTANTS.TIMEOUTS.UI_DELAY);

                        this.showToast('Preview image loaded (Original unavailable).', 'info');
                        // Cleanup logic...
                        const revokeTime = config.common.blobRevokeTimeout || 600000;
                        state.activeRevokeTimer = setTimeout(() => {
                            URL.revokeObjectURL(blobUrl);
                            if (state.activeBlobUrl === blobUrl) {
                                state.activeBlobUrl = null;
                                state.activeRevokeTimer = null;
                            }
                        }, revokeTime);
                        return;
                    } catch (e) {
                        /* ignore thumbnail fail */
                    }
                }

                // Final Fallback: Direct Link
                const fallbackUrl = targetUrl || (btn.href && btn.href !== window.location.href ? btn.href : null);
                if (fallbackUrl) {
                    Logger.warn('FALLBACK', '', `Reverting to direct navigation.`);
                    GM.openInTab(fallbackUrl, { active: isActive, insert: true });
                    this.showToast('Fetch failed. Opening direct link...', 'warn');
                } else {
                    this.showToast('Image URL not found', 'error');
                }
            } finally {
                state.isFetching = false;
                btn.style.opacity = '';
                btn.style.cursor = '';
            }
        }

        /**
         * Handles clicking the Page button.
         * Supports both Left Click and Middle Click (auxclick).
         * @param {MouseEvent} e
         */
        _handlePageClick(e) {
            // Only handle Left Click (0) or Middle Click (1)
            if (e.button !== 0 && e.button !== 1) return;

            this._stopProp(e);
            const btn = e.currentTarget;

            if (!(btn instanceof HTMLAnchorElement)) return;

            // Ensure URL is up-to-date.
            this._updateButtonHref(btn);

            if (btn.href && btn.href !== window.location.href) {
                const state = this.btnState.get(btn);
                const source = state ? state.hostSource : 'UNKNOWN';
                Logger.info('NAVIGATING', LOG_STYLES.GREEN, `Host URL: "${btn.href}" (Source: ${source})`);

                // For Page Button, we just let the browser handle the link navigation normally.
                // Middle click will open in new tab (default behavior).
                // Left click will open in new tab (target="_blank").
            } else {
                e.preventDefault();
                this.showToast('Host page URL not found.', 'warn');
            }
        }
    }

    // =================================================================================
    // SECTION: Data & Logic Adapters
    // =================================================================================

    /**
     * @class NetworkHelper
     * @description Handles network requests and binary data processing.
     */
    class NetworkHelper {
        /**
         * Determines if the URL should be fetched as a Blob or opened directly.
         * Uses a HEAD request to check for forced download headers.
         * @param {string} url
         * @param {string} referrerPolicy - The referrer policy to use for the request.
         * @returns {Promise<boolean>} True if Blob fetch is recommended, False if direct navigation is safe.
         */
        static async shouldFetchAsBlob(url, referrerPolicy) {
            return new Promise((resolve) => {
                GM.xmlHttpRequest({
                    method: 'HEAD',
                    url: url,
                    timeout: CONSTANTS.NETWORK_TIMEOUT,
                    headers: this._getHeaders(referrerPolicy),
                    onload: (response) => {
                        // If HEAD fails (e.g. 405 Method Not Allowed), default to Blob strategy to be safe.
                        if (response.status < 200 || response.status >= 300) {
                            resolve(true);
                            return;
                        }

                        const headers = (response.responseHeaders || '').toLowerCase();

                        // Parse Content-Disposition to check for 'attachment'
                        // regex matches: content-disposition: ... attachment ...
                        if (/content-disposition:.*attachment/.test(headers)) {
                            resolve(true);
                            return;
                        }

                        // Parse Content-Type
                        const typeMatch = headers.match(/content-type:\s*([^;\r\n]+)/);
                        const contentType = typeMatch ? typeMatch[1].trim() : '';

                        // If it's explicitly an image, safe to open directly.
                        // Otherwise (octet-stream, unknown, etc.), use Blob.
                        if (contentType.startsWith('image/')) {
                            resolve(false);
                        } else {
                            resolve(true);
                        }
                    },
                    onerror: () => {
                        // Network error on HEAD. Try Blob flow (GET) as it might have better error handling or succeed.
                        resolve(true);
                    },
                    ontimeout: () => resolve(true),
                });
            });
        }

        /**
         * Fetches an image URL and returns it as a Blob.
         * Detects the correct MIME type from binary headers to prevent forced downloads.
         * Implements automatic retry logic if shouldRetry is true.
         * Handles Data URIs directly without network requests.
         * @param {string} url - The image URL to fetch.
         * @param {string} referrerPolicy - The referrer policy to use for the request.
         * @param {boolean} shouldRetry - Whether to attempt one retry with a different policy on failure.
         * @param {string} logTag - Tag for logging purposes (e.g., 'ORIGINAL', 'THUMBNAIL').
         * @returns {Promise<Blob>} The image data as a Blob.
         */
        static async fetchImageAsBlob(url, referrerPolicy, shouldRetry, logTag) {
            // 1. Handle Data URI Scheme directly
            if (url.startsWith('data:')) {
                Logger.info(`FETCH (${logTag})`, LOG_STYLES.BLUE, `Processing Data URI...`);
                try {
                    const blob = this._base64ToBlob(url);
                    Logger.info(`FETCH OK (${logTag})`, LOG_STYLES.GREEN, `Type="${blob.type}", Size=${(blob.size / 1024).toFixed(2)}KB`);
                    return blob;
                } catch (error) {
                    Logger.error(`FETCH ERROR (${logTag})`, '', `Data URI parsing failed: ${error.message}`);
                    throw error;
                }
            }

            // 2. Handle HTTP/HTTPS URLs
            Logger.info(`FETCH (${logTag})`, LOG_STYLES.BLUE, `Executing... Policy="${referrerPolicy}"`);

            const attemptFetch = (policy) => {
                return new Promise((resolve, reject) => {
                    GM.xmlHttpRequest({
                        method: 'GET',
                        url: url,
                        timeout: CONSTANTS.NETWORK_TIMEOUT,
                        responseType: 'arraybuffer',
                        headers: this._getHeaders(policy),
                        onload: (response) => {
                            if (response.status >= 200 && response.status < 300) {
                                const buffer = response.response;
                                // Detect MIME type from binary signature
                                let mimeType = this._detectMimeType(buffer);

                                // Fallback: Check Content-Type header if magic bytes detection failed
                                // Only accept specific types (e.g., SVG) to avoid processing HTML as image
                                if (!mimeType) {
                                    const headers = (response.responseHeaders || '').toLowerCase();
                                    const typeMatch = headers.match(/content-type:\s*([^;\r\n]+)/);
                                    const contentType = typeMatch ? typeMatch[1].trim() : '';

                                    if (contentType === 'image/svg+xml') {
                                        mimeType = contentType;
                                    }
                                }

                                if (mimeType) {
                                    Logger.info(`FETCH OK (${logTag})`, LOG_STYLES.GREEN, `Type="${mimeType}", Size=${(buffer.byteLength / 1024).toFixed(2)}KB`);
                                    const blob = new Blob([buffer], { type: mimeType });
                                    resolve(blob);
                                } else {
                                    // Reject non-image data (HTML, Video, etc.) to trigger fallback
                                    reject(new Error('Unsupported file type'));
                                }
                            } else {
                                reject(new Error(`HTTP error ${response.status}`));
                            }
                        },
                        onerror: (err) => {
                            reject(new Error('Network request failed'));
                        },
                        ontimeout: () => {
                            reject(new Error('Request timed out'));
                        },
                    });
                });
            };

            try {
                return await attemptFetch(referrerPolicy);
            } catch (error) {
                Logger.error(`FETCH ERROR (${logTag})`, '', `Policy="${referrerPolicy}", Reason="${error.message}"`);

                if (shouldRetry) {
                    // Determine alternative policy based on system-defined strategy
                    let nextPolicy = CONSTANTS.REFERRER_POLICY.ORIGIN; // Default fallback

                    if (referrerPolicy === CONSTANTS.REFERRER_POLICY.NO_REFERRER) {
                        nextPolicy = CONSTANTS.REFERRER_POLICY.ORIGIN; // If hidden failed, try showing origin
                    } else if (referrerPolicy === CONSTANTS.REFERRER_POLICY.ORIGIN || referrerPolicy === CONSTANTS.REFERRER_POLICY.UNSAFE_URL) {
                        nextPolicy = CONSTANTS.REFERRER_POLICY.NO_REFERRER; // If origin/unsafe failed, try hiding
                    }

                    // Prevent redundant retry if nextPolicy is same as current (edge case)
                    if (nextPolicy !== referrerPolicy) {
                        Logger.warn('RETRY', '', `Switching "${referrerPolicy}" -> "${nextPolicy}"`);
                        // Explicitly pass false to ensure max 1 retry
                        return await this.fetchImageAsBlob(url, nextPolicy, false, logTag);
                    }
                }
                throw error;
            }
        }

        /**
         * Converts a Base64 Data URI string to a Blob object.
         * @private
         * @param {string} dataUrl - The Data URI string (e.g., "data:image/jpeg;base64,...").
         * @returns {Blob} The created Blob object.
         */
        static _base64ToBlob(dataUrl) {
            if (!dataUrl.startsWith('data:')) {
                throw new Error('Invalid Data URI: Missing "data:" prefix');
            }

            const commaIndex = dataUrl.indexOf(',');
            if (commaIndex === -1) {
                throw new Error('Invalid Data URI: Missing comma separator');
            }

            const metadata = dataUrl.slice(0, commaIndex);
            const data = dataUrl.slice(commaIndex + 1);

            // Parse metadata: data:[<mediatype>][;base64]
            const mimeMatch = metadata.match(/:(.*?)(;|$)/);
            const mime = mimeMatch ? mimeMatch[1] : 'text/plain';

            const isBase64 = metadata.includes(';base64');

            if (isBase64) {
                try {
                    const bstr = atob(data);
                    let n = bstr.length;
                    const u8arr = new Uint8Array(n);
                    while (n--) {
                        u8arr[n] = bstr.charCodeAt(n);
                    }
                    return new Blob([u8arr], { type: mime });
                } catch (e) {
                    throw new Error(`Base64 decoding failed: ${e.message}`);
                }
            } else {
                // Non-base64 (URL-encoded) data is not fully implemented yet
                // Throw error to trigger fallback mechanism
                throw new Error('Unsupported Data URI encoding: Non-base64');
            }
        }

        /**
         * Generates headers for GM.xmlHttpRequest based on the referrer policy.
         * @private
         * @param {string} policy
         * @returns {object} Headers object
         */
        static _getHeaders(policy) {
            const headers = {};
            switch (policy) {
                case CONSTANTS.REFERRER_POLICY.NO_REFERRER:
                    // Explicitly set empty string to suppress referrer
                    headers['Referer'] = '';
                    break;
                case CONSTANTS.REFERRER_POLICY.UNSAFE_URL:
                    headers['Referer'] = window.location.href;
                    break;
                case CONSTANTS.REFERRER_POLICY.ORIGIN:
                default:
                    headers['Referer'] = window.location.origin;
                    break;
            }
            return headers;
        }

        /**
         * Detects MIME type from the first few bytes (Magic Numbers).
         * @private
         * @param {ArrayBuffer} buffer
         * @returns {string|null} Detected MIME type or null.
         */
        static _detectMimeType(buffer) {
            if (!buffer || buffer.byteLength < 4) return null;

            const arr = new Uint8Array(buffer).subarray(0, 12);
            // Convert bytes to hex string for easy comparison
            const header = Array.from(arr)
                .map((b) => b.toString(16).padStart(2, '0'))
                .join('')
                .toUpperCase();

            // JPEG: FF D8 FF
            if (header.startsWith('FFD8FF')) return 'image/jpeg';
            // PNG: 89 50 4E 47
            if (header.startsWith('89504E47')) return 'image/png';
            // GIF: 47 49 46 38
            if (header.startsWith('47494638')) return 'image/gif';
            // WebP: RIFF....WEBP (RIFF at 0, WEBP at 8)
            // 'RIFF' in hex is 52 49 46 46, 'WEBP' is 57 45 42 50
            if (header.startsWith('52494646') && header.slice(16, 24) === '57454250') return 'image/webp';
            // BMP: 42 4D
            if (header.startsWith('424D')) return 'image/bmp';
            // ICO: 00 00 01 00
            if (header.startsWith('00000100')) return 'image/x-icon';
            // AVIF: ....ftypavif (ftyp at offset 4, avif at offset 8)
            // Offset 4-7 (ftyp): 66 74 79 70 -> Index 8-16
            // Offset 8-11 (avif): 61 76 69 66 -> Index 16-24
            if (header.slice(8, 16) === '66747970' && header.slice(16, 24) === '61766966') return 'image/avif';

            return null;
        }
    }

    /**
     * @class BaseAdapter
     * @abstract
     * @description Base class for platform-specific adapters.
     */
    class BaseAdapter {
        /**
         * @param {UIManager} uiManager
         */
        constructor(uiManager) {
            this.uiManager = uiManager;
            /** @type {boolean} */
            this.hasSmokeTested = false;
        }

        /**
         * Unique identifier for the platform.
         * @returns {string}
         */
        static get id() {
            return 'base';
        }

        /**
         * Checks if this adapter should run on the current page.
         * @returns {boolean}
         */
        static isApplicable() {
            return false;
        }

        /**
         * Returns the CSS selector for the sentinel element.
         * @returns {string|null}
         */
        getSentinelSelector() {
            return null;
        }

        /**
         * Called when a new result element is detected.
         * @param {HTMLElement} element
         */
        onResultFound(element) {
            // To be implemented by subclasses
        }

        /**
         * Extracts the high-resolution image URL and the host page URL from Bing's metadata.
         *
         * Extraction Strategy:
         * Bing stores metadata as a JSON string in the `m` attribute of the `a.iusc` element.
         *
         * JSON Keys:
         * - `murl`: Media URL (The direct link to the high-res image).
         * - `purl`: Page URL (The link to the website hosting the image).
         * - `turl`: Thumbnail URL (Not used here, but available).
         *
         * @param {HTMLElement} element - The `a.iusc` element containing the `m` attribute.
         * @returns {{imageUrl: string|null, hostUrl: string|null, hostUrlSource: string|null, thumbnailUrl: string|null}}
         */
        extractUrls(element) {
            let imageUrl = null;
            let hostUrl = null;
            let hostUrlSource = null;
            let thumbnailUrl = null;

            try {
                // Bing stores metadata in the 'm' attribute as a JSON string.
                // Example: m='{"murl":"...","purl":"...","turl":"..."}'
                const mAttr = element.getAttribute('m');
                if (mAttr) {
                    const data = JSON.parse(mAttr);

                    // 'murl': Media URL (Direct link to the high-res image)
                    if (data.murl) {
                        imageUrl = data.murl;
                    } else {
                        Logger.error('EXTRACTION_FAIL', '', 'Bing: "murl" missing in metadata.', data);
                    }

                    // 'purl': Page URL (Link to the hosting webpage)
                    if (data.purl) {
                        hostUrl = data.purl;
                        hostUrlSource = 'M-ATTR';
                    }

                    // 'turl': Thumbnail URL (Fallback)
                    if (data.turl) {
                        thumbnailUrl = data.turl;
                    }
                } else {
                    Logger.error('EXTRACTION_FAIL', '', 'Bing: "m" attribute missing on sentinel.');
                }
            } catch (e) {
                Logger.error('EXTRACTION_FAIL', '', 'Bing: JSON parse error or structure change.', e);
                // JSON parse error or structure change.
                // We silently fail here as other elements might not have valid JSON.
            }

            return { imageUrl, hostUrl, hostUrlSource, thumbnailUrl };
        }

        /**
         * Asynchronously fetches the original image URL by interacting with the DOM.
         * Used when the high-res URL is not present in the initial DOM (e.g., DuckDuckGo).
         * @param {HTMLElement} sentinel - The sentinel element.
         * @returns {Promise<string|null>} The original image URL or null if not found.
         */
        async fetchOriginalImageUrl(sentinel) {
            return null;
        }
    }

    /**
     * @class BingAdapter
     * @extends BaseAdapter
     * @description Adapter for Bing Image Search.
     * Handles DOM interactions specific to Bing's search results page.
     */
    class BingAdapter extends BaseAdapter {
        constructor(uiManager) {
            super(uiManager);
            this.uiManager.setUrlFetcher((element) => this.extractUrls(element));
        }

        static get id() {
            return 'bing';
        }

        /**
         * Checks if the current page is a supported Bing Image Search page.
         * @returns {boolean}
         */
        static isApplicable() {
            // prettier-ignore
            return (
                // Match *.bing.com (e.g., www, cn, global) to align with @match
                /(^|\.)bing\.com$/.test(window.location.hostname) &&
                window.location.pathname.startsWith('/images/search')
            );
        }

        /**
         * Returns the CSS selector for the sentinel element.
         * In Bing, `a.iusc` (Image URL Source Container?) is the interactive element holding metadata.
         * @returns {string}
         */
        getSentinelSelector() {
            return 'a.iusc';
        }

        /**
         * Called when a new result element is detected.
         * Attaches buttons to the parent container (usually `div.img_cont`) to overlay correctly on the image.
         * @param {HTMLElement} element - The detected `a.iusc` element.
         */
        onResultFound(element) {
            const targetContainer = element.parentElement;
            if (targetContainer) {
                this.uiManager.attachButtons(element, targetContainer);
            }
        }

        /**
         * Extracts the high-resolution image URL and the host page URL from Bing's metadata.
         *
         * Extraction Strategy:
         * Bing stores metadata as a JSON string in the `m` attribute of the `a.iusc` element.
         *
         * JSON Keys:
         * - `murl`: Media URL (The direct link to the high-res image).
         * - `purl`: Page URL (The link to the website hosting the image).
         * - `turl`: Thumbnail URL (Not used here, but available).
         *
         * @param {HTMLElement} element - The `a.iusc` element containing the `m` attribute.
         * @returns {{imageUrl: string|null, hostUrl: string|null, hostUrlSource: string|null, thumbnailUrl: string|null}}
         */
        extractUrls(element) {
            let imageUrl = null;
            let hostUrl = null;
            let hostUrlSource = null;
            let thumbnailUrl = null;

            try {
                // Bing stores metadata in the 'm' attribute as a JSON string.
                // Example: m='{"murl":"...","purl":"...","turl":"..."}'
                const mAttr = element.getAttribute('m');
                if (mAttr) {
                    const data = JSON.parse(mAttr);

                    // 'murl': Media URL (Direct link to the high-res image)
                    if (data.murl) {
                        imageUrl = data.murl;
                    }

                    // 'purl': Page URL (Link to the hosting webpage)
                    if (data.purl) {
                        hostUrl = data.purl;
                        hostUrlSource = 'M-ATTR';
                    }

                    // 'turl': Thumbnail URL (Fallback)
                    if (data.turl) {
                        thumbnailUrl = data.turl;
                    }
                }
            } catch (e) {
                // JSON parse error or structure change.
                // We silently fail here as other elements might not have valid JSON.
            }

            return { imageUrl, hostUrl, hostUrlSource, thumbnailUrl };
        }
    }

    /**
     * @class DuckDuckGoAdapter
     * @extends BaseAdapter
     * @description Adapter for DuckDuckGo Image Search.
     */
    class DuckDuckGoAdapter extends BaseAdapter {
        constructor(uiManager) {
            super(uiManager);
            this.uiManager.setUrlFetcher((element) => this.extractUrls(element));
        }

        static get id() {
            return 'duckduckgo';
        }

        static isApplicable() {
            if (!/(^|\.)duckduckgo\.com$/.test(window.location.hostname)) return false;

            const params = new URLSearchParams(window.location.search);
            // Check for any parameter starting with 'ia' (e.g., ia, iax, iar) with value 'images'
            for (const [key, value] of params.entries()) {
                if (key.startsWith('ia') && value === 'images') {
                    return true;
                }
            }
            return false;
        }

        /**
         * Returns the CSS selector for the sentinel element.
         * Updated to use tag name 'figure' as DDG now uses obfuscated class names.
         * @returns {string}
         */
        getSentinelSelector() {
            // Target 'figure' elements which are the containers for image cards in the new React layout
            // Use :not() selector to re-trigger Sentinel if the processed class is removed by the host
            return 'figure:not(.isdv-processed)';
        }

        /**
         * Called when a new result element is detected.
         * @param {HTMLElement} element - The detected sentinel element.
         */
        onResultFound(element) {
            // Verify if this figure contains the expected image search structure
            // It should have an anchor tag and an image
            if (element.querySelector('a') && element.querySelector('img')) {
                this.uiManager.attachButtons(element, element);
            }
        }

        /**
         * Extracts URLs from DuckDuckGo result element.
         *
         * [LIMITATION & DESIGN DECISION]
         * 1. Cached URL Only: The URL obtained here is typically a cached version (via Bing/DDG proxy), NOT the direct original source URL.
         * 2. DOM Limitation: The true high-resolution original URL is NOT present in the card's DOM. It is only injected after clicking the card to open the detail panel.
         * 3. Strategy: We intentionally use this cached URL (extracted from the 'u' param) to enable immediate access from the grid view.
         * This accepts a trade-off: slightly lower resolution in exchange for significantly better UX (0-click access).
         *
         * @param {HTMLElement} element - The sentinel element (figure).
         * @returns {{imageUrl: string|null, hostUrl: string|null, hostUrlSource: string|null, thumbnailUrl: string|null}}
         */
        extractUrls(element) {
            let imageUrl = null;
            let hostUrl = null;
            let hostUrlSource = null;
            let thumbnailUrl = null;

            // 1. Get Host URL from the anchor tag
            const link = element.querySelector('a');
            if (link && link.href) {
                // STRICTLY use the href as is.
                // Do NOT decode or strip any redirect parameters (e.g. duckduckgo.com/l/?uddg=...).
                // We must respect DDG's privacy protections (redirects/referrer hiding) if present.
                hostUrl = link.href;
                hostUrlSource = 'HREF';
            } else {
                // Validation: If it looks like an image card (has img) but no link, it's a structure error.
                if (element.querySelector('img')) {
                    Logger.error('EXTRACTION_FAIL', '', 'DDG: Host URL (anchor) missing in image card.');
                }
            }

            // 2. Get Image URL & Thumbnail URL
            const img = element.querySelector('img');
            if (img && img.src) {
                // Use the proxy URL as the thumbnail
                thumbnailUrl = img.src;

                // Extract the cached image URL from the 'u' parameter of the proxy URL.
                // NOTE: This is NOT the original source URL but a cached version used by DDG/Bing.
                // We use this because the original URL is not available in the card view DOM.
                try {
                    const urlObj = new URL(img.src);
                    const originalParam = urlObj.searchParams.get('u');
                    if (originalParam) {
                        imageUrl = decodeURIComponent(originalParam);
                    } else {
                        // Fallback: Use proxy URL if 'u' parameter is missing
                        imageUrl = img.src;
                    }
                } catch (e) {
                    // Fallback: Use proxy URL on parse error
                    imageUrl = img.src;
                }
            }

            return { imageUrl, hostUrl, hostUrlSource, thumbnailUrl };
        }

        /**
         * Asynchronously fetches the original image URL by opening the detail panel.
         * Verifies the panel content matches the clicked item using the Page URL.
         * Scopes extraction to the visible container to avoid grabbing preloaded/hidden links.
         * @param {HTMLElement} sentinel - The sentinel element.
         * @returns {Promise<string|null>} The original image URL or null.
         */
        async fetchOriginalImageUrl(sentinel) {
            return new Promise((resolve) => {
                // 1. Get the expected Page URL to verify the panel content later
                const anchor = sentinel.querySelector('a');
                if (!anchor || !anchor.href) {
                    Logger.warn('ASYNC_FAIL', '', 'DDG: Trigger anchor missing.');
                    resolve(null);
                    return;
                }
                const expectedPageUrl = anchor.href;

                // Helper to normalize URL for loose comparison (remove trailing slash)
                const normalizeUrl = (u) => (u ? u.replace(/\/$/, '') : '');

                // 2. Trigger click to open detail panel
                // Click the image (img) to open panel without navigation.
                const trigger = sentinel.querySelector('img');
                if (!trigger) {
                    Logger.warn('ASYNC_FAIL', '', 'DDG: Trigger image missing.');
                    resolve(null);
                    return;
                }

                // Smart Scroll Clamping & Stealth Mode
                // 1. Clamp: Locks scroll position to prevent jumping.
                // 2. Stealth: Hides the detail panel (aside) via direct style injection to the specific element.

                const savedScrollY = window.scrollY;
                let isClamping = true;
                const userEvents = ['wheel', 'touchmove', 'keydown', 'mousedown'];

                // Handler to break the clamp on user interaction
                const stopClamping = () => {
                    isClamping = false;
                };

                // Attach listeners to detect user intent (capture phase)
                // Use explicit boolean 'true' for capture to ensure removeEventListener works reliably across browsers.
                userEvents.forEach((evt) => window.addEventListener(evt, stopClamping, true));

                // Stealth Logic: Use MutationObserver to hide the specific panel as soon as it appears.
                let targetAside = null;
                const originalStyles = { opacity: '', pointerEvents: '' };

                const hideElement = (el) => {
                    if (el && el.style && !targetAside) {
                        // Backup original inline styles to allow safe restoration
                        originalStyles.opacity = el.style.opacity;
                        originalStyles.pointerEvents = el.style.pointerEvents;

                        el.style.setProperty('opacity', '0', 'important');
                        el.style.setProperty('pointer-events', 'none', 'important');
                        targetAside = el; // Keep reference for cleanup

                        // Optimization: Disconnect observer once the target is found and hidden
                        observer.disconnect();
                    }
                };

                const observer = new MutationObserver((mutations) => {
                    for (const mutation of mutations) {
                        if (mutation.type === 'childList') {
                            mutation.addedNodes.forEach((node) => {
                                if (node.nodeName === 'ASIDE') {
                                    hideElement(node);
                                }
                            });
                        }
                    }
                });
                observer.observe(document.body, { childList: true, subtree: true });

                // Start clamping loop
                const start = Date.now();
                const clampDuration = CONSTANTS.TIMEOUTS.SCROLL_CLAMP;
                const maintainScroll = () => {
                    if (isClamping) {
                        window.scrollTo(0, savedScrollY);
                        if (Date.now() - start < clampDuration) {
                            requestAnimationFrame(maintainScroll);
                        }
                    }
                };
                requestAnimationFrame(maintainScroll);

                // Helper to clean up listeners, styles, and ensure final state
                const finalize = () => {
                    // Stop observing (if not already stopped)
                    observer.disconnect();

                    // Cleanup styles if the panel still exists (restore visibility safely)
                    if (targetAside) {
                        if (originalStyles.opacity) {
                            targetAside.style.opacity = originalStyles.opacity;
                        } else {
                            targetAside.style.removeProperty('opacity');
                        }

                        if (originalStyles.pointerEvents) {
                            targetAside.style.pointerEvents = originalStyles.pointerEvents;
                        } else {
                            targetAside.style.removeProperty('pointer-events');
                        }
                    }

                    // Stop clamping
                    isClamping = false;
                    userEvents.forEach((evt) => window.removeEventListener(evt, stopClamping, true));

                    // Final position restoration after layout settles
                    window.scrollTo(0, savedScrollY);
                    setTimeout(() => window.scrollTo(0, savedScrollY), CONSTANTS.TIMEOUTS.UI_DELAY);
                };

                // Use standard click() to prevent 'MouseEvent constructor' error
                trigger.click();

                // 3. Wait for the detail panel (<aside>) to appear AND match the expected URL
                const MAX_ATTEMPTS = Math.ceil(CONSTANTS.TIMEOUTS.FETCH_ORIGINAL / CONSTANTS.TIMEOUTS.DOM_POLLING);
                let attempts = 0;

                const checkPanel = () => {
                    attempts++;
                    const aside = document.querySelector('aside');

                    if (aside) {
                        // Fail-safe: Ensure style is applied if Observer missed it (e.g. reused DOM)
                        if (!targetAside) {
                            hideElement(aside);
                        }

                        // Target ONLY the visible container within aside to avoid hidden/preloaded slides.
                        // DDG uses aria-hidden="false" for the active slide.
                        // Fallback to aside itself if structure changes/not found (though unlikely).
                        const activeContainer = aside.querySelector('[aria-hidden="false"]') || aside;

                        // VERIFICATION: Check if this visible container belongs to the clicked image.
                        const linksInPanel = activeContainer.querySelectorAll('a');
                        let isMatch = false;
                        const targetUrlNorm = normalizeUrl(expectedPageUrl);

                        for (const link of linksInPanel) {
                            if (normalizeUrl(link.href) === targetUrlNorm) {
                                isMatch = true;
                                break;
                            }
                        }

                        if (isMatch) {
                            // Found the correct panel content. Proceed to extract from THIS container.
                            processPanel(aside, activeContainer);
                            return;
                        }
                    }

                    if (attempts >= MAX_ATTEMPTS) {
                        Logger.warn('ASYNC_FAIL', '', 'DDG: Panel detection timed out.');
                        finalize();
                        resolve(null);
                    } else {
                        setTimeout(checkPanel, CONSTANTS.TIMEOUTS.DOM_POLLING);
                    }
                };

                const processPanel = (aside, container) => {
                    let foundUrl = null;

                    // Strategy: Find 'a' tags with target="_blank" inside the ACTIVE container
                    const links = container.querySelectorAll('a[target="_blank"]');

                    // Priority 1: Link ending with common image extensions
                    for (const link of links) {
                        const href = link.href;
                        if (/\.(jpg|jpeg|png|webp|gif|bmp|svg)(\?.*)?$/i.test(href)) {
                            foundUrl = href;
                            break;
                        }
                    }

                    // Priority 2: Fallback to the last external link found in the active container
                    if (!foundUrl && links.length > 0) {
                        foundUrl = links[links.length - 1].href;
                    }

                    if (!foundUrl) {
                        Logger.warn('ASYNC_FAIL', '', 'DDG: Target image URL not found in panel.');
                    }

                    // 4. Close the panel immediately
                    const closeBtn = aside.querySelector('.ddgsi-close');
                    if (closeBtn) {
                        const btn = closeBtn.closest('button') || closeBtn;
                        btn.click();
                    } else {
                        // Fallback: Send Escape key
                        document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
                    }

                    finalize();
                    resolve(foundUrl);
                };

                // Start polling
                checkPanel();
            });
        }
    }

    /**
     * @class GoogleAdapter
     * @extends BaseAdapter
     * @description Adapter for Google Image Search.
     * Handles DOM interactions specific to Google's search results page.
     */
    class GoogleAdapter extends BaseAdapter {
        constructor(uiManager) {
            super(uiManager);
            this.uiManager.setUrlFetcher((element) => this.extractUrls(element));
        }

        static get id() {
            return 'google';
        }

        /**
         * Checks if the current page is a supported Google Image Search page.
         * Supported contexts:
         * 1. Standard Web Search with "Images" tab selected (`udm=2`).
         * 2. Dedicated Image Search page (`tbm=isch`).
         * @returns {boolean}
         */
        static isApplicable() {
            // prettier-ignore
            return (
                // Match *.google.com to align with @match
                /(^|\.)google\.com$/.test(window.location.hostname) &&
                (
                    // 'udm=2': Indicates the "Images" tab in standard Web Search (new interface)
                    new URL(window.location.href).searchParams.get('udm') === '2' ||
                    // 'tbm=isch': Indicates the dedicated Image Search mode (classic interface)
                    new URL(window.location.href).searchParams.get('tbm') === 'isch'
                )
            );
        }

        /**
         * Returns the CSS selector for the sentinel element.
         * Targets the container with 'data-lpage' and 'data-docid' which represents a single result card.
         * @returns {string}
         */
        getSentinelSelector() {
            return 'div[data-lpage][data-docid]';
        }

        /**
         * Called when a new result element is detected.
         * Finds the anchor tag and the appropriate container to inject buttons.
         * @param {HTMLElement} element - The detected sentinel element.
         */
        onResultFound(element) {
            // Check if the element is inside the detailed viewer panel.
            // Google displays similar images inside the side panel, which causes duplication.
            // We use 'data-lhcontainer' or 'data-viewer-type' which are characteristic of the viewer structure.
            const isInsidePanel = element.closest('[data-lhcontainer], [data-viewer-type]');
            if (isInsidePanel) {
                return;
            }

            const link = element.querySelector('a');
            if (!link) return;

            // Attach buttons to the parent of the link to ensure they overlay the image correctly.
            const targetContainer = link.parentElement;
            if (targetContainer) {
                this.uiManager.attachButtons(element, targetContainer);
            }
        }

        /**
         * Extracts URLs from a Google result element.
         *
         * Strategy:
         * 1. **Image URL**: Returns null to force the Async Fetcher (`fetchOriginalImageUrl`) to run.
         * 2. **Host URL**: Extracted directly from the `data-lpage` attribute of the sentinel.
         * 3. **Thumbnail URL**: Extracted from the `img` tag inside the card.
         *
         * @param {HTMLElement} element - The sentinel element.
         * @returns {{imageUrl: string|null, hostUrl: string|null, hostUrlSource: string|null, thumbnailUrl: string|null}}
         */
        extractUrls(element) {
            let imageUrl = null;
            let hostUrl = null;
            let hostUrlSource = null;
            let thumbnailUrl = null;

            // 1. Extract Host Page URL from 'data-lpage' attribute
            if (element.dataset.lpage) {
                hostUrl = element.dataset.lpage;
                hostUrlSource = 'DATA-ATTR';
            } else {
                Logger.error('EXTRACTION_FAIL', '', 'Google: "data-lpage" attribute missing.');
            }

            // 2. Extract Thumbnail URL
            const imgEl = element.querySelector('img');
            if (imgEl) {
                thumbnailUrl = imgEl.getAttribute('data-src') || imgEl.getAttribute('src');
            }

            // Image URL is intentionally null to trigger async fetch
            return { imageUrl, hostUrl, hostUrlSource, thumbnailUrl };
        }

        /**
         * Asynchronously fetches the original image URL by interacting with the detailed panel.
         *
         * Flow:
         * 1. Triggers a click on the card to open the detailed panel (side panel).
         * 2. Uses CSS injection to keep the panel invisible (Stealth Mode).
         * 3. Observes the DOM for the specific panel creation and image src update using MutationObserver.
         * 4. Extracts the high-res URL from the panel's image element once loaded.
         * 5. Closes the panel to restore state.
         *
         * @param {HTMLElement} sentinel - The sentinel element.
         * @returns {Promise<string|null>} The original image URL or null.
         */
        async fetchOriginalImageUrl(sentinel) {
            return new Promise((resolve) => {
                const docId = sentinel.dataset.docid;
                const lpage = sentinel.dataset.lpage;

                if (!docId) {
                    Logger.warn('ASYNC_FAIL', '', 'Google: "data-docid" missing on sentinel.');
                    resolve(null);
                    return;
                }

                // Identify the trigger button (div[role="button"]) inside the sentinel
                const trigger = sentinel.querySelector('div[role="button"]');
                if (!trigger) {
                    Logger.warn('ASYNC_FAIL', '', 'Google: Trigger button missing.');
                    resolve(null);
                    return;
                }

                // Inject Stealth Styles (Hide the detailed panel container by data-id)
                const stealthStyle = document.createElement('style');
                stealthStyle.textContent = `
                    div[data-id="${docId}"] {
                        opacity: 0 !important;
                        pointer-events: none !important;
                    }
                `;
                document.head.appendChild(stealthStyle);

                let observer = null;
                let timeoutId = null;
                let lastKnownSrc = null; // Keep track of the best available URL (including Base64/Thumbnail)

                const cleanup = () => {
                    if (observer) observer.disconnect();
                    if (timeoutId) clearTimeout(timeoutId);
                    stealthStyle.remove();
                };

                const closePanel = (panel) => {
                    // Strategy: Find the close button by its specific SVG path content
                    // This is robust against class name/jsname changes.
                    const closeIconPath = 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z';

                    // Search for path elements within the panel
                    const paths = panel.querySelectorAll('svg path');
                    let closeBtn = null;

                    for (const path of paths) {
                        if (path.getAttribute('d') === closeIconPath) {
                            // Found the path, find the nearest button ancestor
                            closeBtn = path.closest('button');
                            break;
                        }
                    }

                    if (closeBtn) {
                        closeBtn.click();
                    } else {
                        // Fallback: Escape key
                        Logger.warn('CLOSE FALLBACK', '', 'Close button (SVG) not found. Sending Escape key.');
                        document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
                    }
                };

                // Setup Observer to watch for panel appearance and image src change
                observer = new MutationObserver(() => {
                    // 1. Find the panel that matches the clicked docId
                    const panel = document.querySelector(`div[data-id="${docId}"]`);
                    if (panel) {
                        // Ensure the panel is inside an active container (aria-hidden="false")
                        const activeContainer = panel.closest('div[data-sci][aria-hidden="false"]');
                        if (!activeContainer) return;

                        // Find the image linked to the Landing Page (lpage)
                        let targetImg = null;

                        if (lpage) {
                            // Try exact match first, specifically targeting the main image link
                            targetImg = panel.querySelector(`a[href="${lpage}"][role="link"] img`);
                        }

                        // Fallback: Look for any link with role="link" which typically denotes the main image
                        if (!targetImg) {
                            targetImg = panel.querySelector('a[role="link"] > img');
                        }

                        if (targetImg && targetImg.src) {
                            lastKnownSrc = targetImg.src; // Update candidate

                            // 3. Check if src is a high-quality URL.
                            // We filter out known thumbnail/preview domains to wait for the original image.
                            const isLowRes = /encrypted-tbn|gstatic\.com|favicon/.test(targetImg.src);

                            if (targetImg.src.startsWith('http') && !isLowRes) {
                                cleanup();
                                closePanel(panel);
                                resolve(targetImg.src);
                            }
                        }
                    }
                });

                // Start observing the body for subtree changes (panel insertion & attribute changes)
                observer.observe(document.body, {
                    childList: true,
                    subtree: true,
                    attributes: true,
                    attributeFilter: ['src', 'aria-hidden'],
                });

                // Trigger Click with a slight delay to ensure observer is ready and browser has painted
                requestAnimationFrame(() => {
                    trigger.click();
                });

                // Timeout Safety
                // If the image remains a thumbnail (e.g. Instagram logic), we return the thumbnail as fallback.
                timeoutId = setTimeout(() => {
                    // Try to find the panel to close it properly even on timeout
                    const panel = document.querySelector(`div[data-id="${docId}"]`);

                    cleanup();

                    if (panel) {
                        closePanel(panel);
                    } else {
                        // Fallback if panel ref is lost or not found
                        Logger.warn('ASYNC_FAIL', '', 'Google: Panel not found or timed out.');
                        document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
                    }

                    resolve(lastKnownSrc);
                }, CONSTANTS.TIMEOUTS.FETCH_ORIGINAL);
            });
        }
    }

    // =================================================================================
    // SECTION: Sentinel (DOM Observer)
    // =================================================================================

    /**
     * @class Sentinel
     * @description Detects DOM node insertion using a shared, prefixed CSS animation trick.
     * @property {Map<string, Array<(element: Element) => void>>} listeners
     * @property {Set<string>} rules
     * @property {HTMLElement | null} styleElement
     * @property {CSSStyleSheet | null} sheet
     * @property {string[]} pendingRules
     * @property {WeakMap<CSSRule, string>} ruleSelectors
     */
    class Sentinel {
        /**
         * @param {string} prefix - A unique identifier for this Sentinel instance to avoid CSS conflicts. Required.
         */
        constructor(prefix) {
            if (!prefix) {
                throw new Error('[Sentinel] "prefix" argument is required to avoid CSS conflicts.');
            }

            /** @type {any} */
            const globalScope = window;
            globalScope.__global_sentinel_instances__ = globalScope.__global_sentinel_instances__ || {};
            if (globalScope.__global_sentinel_instances__[prefix]) {
                return globalScope.__global_sentinel_instances__[prefix];
            }

            // Use a unique, prefixed animation name shared by all scripts in a project.
            this.animationName = `${prefix}-global-sentinel-animation`;
            this.styleId = `${prefix}-sentinel-global-rules`; // A single, unified style element
            this.listeners = new Map();
            this.rules = new Set(); // Tracks all active selectors
            this.styleElement = null; // Holds the reference to the single style element
            this.sheet = null; // Cache the CSSStyleSheet reference
            this.pendingRules = []; // Queue for rules requested before sheet is ready
            /** @type {WeakMap<CSSRule, string>} */
            this.ruleSelectors = new WeakMap(); // Tracks selector strings associated with CSSRule objects

            this._injectStyleElement();
            document.addEventListener('animationstart', this._handleAnimationStart.bind(this), true);

            globalScope.__global_sentinel_instances__[prefix] = this;
        }

        _injectStyleElement() {
            // Ensure the style element is injected only once per project prefix.
            this.styleElement = document.getElementById(this.styleId);

            if (this.styleElement instanceof HTMLStyleElement) {
                this.sheet = this.styleElement.sheet;
                return;
            }

            // Create empty style element
            this.styleElement = h('style', {
                id: this.styleId,
            });

            // CSP Fix: Try to fetch a valid nonce from existing scripts/styles
            // "nonce" property exists on HTMLScriptElement/HTMLStyleElement, not basic Element.
            let nonce;
            const script = document.querySelector('script[nonce]');
            const style = document.querySelector('style[nonce]');

            if (script instanceof HTMLScriptElement) {
                nonce = script.nonce;
            } else if (style instanceof HTMLStyleElement) {
                nonce = style.nonce;
            }

            if (nonce) {
                this.styleElement.setAttribute('nonce', nonce);
            }

            // Try to inject immediately. If the document is not yet ready (e.g. extremely early document-start), wait for the root element.
            const target = document.head || document.documentElement;

            const initSheet = () => {
                if (this.styleElement instanceof HTMLStyleElement) {
                    this.sheet = this.styleElement.sheet;
                    // Insert the shared keyframes rule at index 0.
                    try {
                        const keyframes = `@keyframes ${this.animationName} { from { transform: none; } to { transform: none; } }`;
                        this.sheet.insertRule(keyframes, 0);
                    } catch (e) {
                        Logger.error('SENTINEL', LOG_STYLES.RED, 'Failed to insert keyframes rule:', e);
                    }
                    this._flushPendingRules();
                }
            };

            if (target) {
                target.appendChild(this.styleElement);
                initSheet();
            } else {
                const observer = new MutationObserver(() => {
                    const retryTarget = document.head || document.documentElement;
                    if (retryTarget) {
                        observer.disconnect();
                        retryTarget.appendChild(this.styleElement);
                        initSheet();
                    }
                });
                observer.observe(document, { childList: true });
            }
        }

        _flushPendingRules() {
            if (!this.sheet || this.pendingRules.length === 0) return;

            const rulesToInsert = [...this.pendingRules];
            this.pendingRules = [];

            rulesToInsert.forEach((selector) => {
                this._insertRule(selector);
            });
        }

        /**
         * Helper to insert a single rule into the stylesheet
         * @param {string} selector
         */
        _insertRule(selector) {
            try {
                const index = this.sheet.cssRules.length;
                const ruleText = `${selector} { animation-duration: 0.001s; animation-name: ${this.animationName}; }`;
                this.sheet.insertRule(ruleText, index);

                // Associate the inserted rule with the selector via WeakMap for safer removal later.
                // This mimics sentinel.js behavior to handle index shifts and selector normalization.
                const insertedRule = this.sheet.cssRules[index];
                if (insertedRule) {
                    this.ruleSelectors.set(insertedRule, selector);
                }
            } catch (e) {
                Logger.error('SENTINEL', LOG_STYLES.RED, `Failed to insert rule for selector "${selector}":`, e);
            }
        }

        _handleAnimationStart(event) {
            // Check if the animation is the one we're listening for.
            if (event.animationName !== this.animationName) return;

            const target = event.target;
            if (!(target instanceof Element)) {
                return;
            }

            // Check if the target element matches any of this instance's selectors.
            for (const [selector, callbacks] of this.listeners.entries()) {
                if (target.matches(selector)) {
                    // Use a copy of the callbacks array in case a callback removes itself.
                    [...callbacks].forEach((cb) => cb(target));
                }
            }
        }

        /**
         * @param {string} selector
         * @param {(element: Element) => void} callback
         */
        on(selector, callback) {
            // Add callback to listeners
            if (!this.listeners.has(selector)) {
                this.listeners.set(selector, []);
            }
            this.listeners.get(selector).push(callback);

            // If selector is already registered in rules, do nothing
            if (this.rules.has(selector)) return;

            this.rules.add(selector);

            // Apply rule
            if (this.sheet) {
                this._insertRule(selector);
            } else {
                this.pendingRules.push(selector);
            }
        }

        /**
         * @param {string} selector
         * @param {(element: Element) => void} callback
         */
        off(selector, callback) {
            const callbacks = this.listeners.get(selector);
            if (!callbacks) return;

            const newCallbacks = callbacks.filter((cb) => cb !== callback);

            if (newCallbacks.length === callbacks.length) {
                return; // Callback not found, do nothing.
            }

            if (newCallbacks.length === 0) {
                // Remove listener and rule
                this.listeners.delete(selector);
                this.rules.delete(selector);

                if (this.sheet) {
                    // Iterate backwards to avoid index shifting issues during deletion
                    for (let i = this.sheet.cssRules.length - 1; i >= 0; i--) {
                        const rule = this.sheet.cssRules[i];
                        // Check for recorded selector via WeakMap or fallback to selectorText match
                        const recordedSelector = this.ruleSelectors.get(rule);

                        if (recordedSelector === selector || (rule instanceof CSSStyleRule && rule.selectorText === selector)) {
                            this.sheet.deleteRule(i);
                            // We assume one rule per selector, so we can break after deletion
                            break;
                        }
                    }
                }
            } else {
                this.listeners.set(selector, newCallbacks);
            }
        }

        suspend() {
            if (this.styleElement instanceof HTMLStyleElement) {
                this.styleElement.disabled = true;
            }
            Logger.debug('SENTINEL', LOG_STYLES.CYAN, 'Suspended.');
        }

        resume() {
            if (this.styleElement instanceof HTMLStyleElement) {
                this.styleElement.disabled = false;
            }
            Logger.debug('SENTINEL', LOG_STYLES.CYAN, 'Resumed.');
        }
    }

    // =================================================================================
    // SECTION: Navigation Monitor
    // Description: Centralizes URL change detection via history API hooks and popstate events.
    // =================================================================================

    class NavigationMonitor {
        constructor() {
            this.originalHistoryMethods = { pushState: null, replaceState: null };
            this._historyWrappers = {};
            this.isInitialized = false;
            this.lastPath = null;
            this._handlePopState = this._handlePopState.bind(this);
            // Debounce the navigation event to allow the DOM to settle and prevent duplicate events
            this.debouncedNavigation = debounce(
                () => {
                    EventBus.publish(EVENTS.NAVIGATION);
                },
                CONSTANTS.TIMEOUTS.POST_NAVIGATION_DOM_SETTLE,
                false
            );
        }

        init() {
            if (this.isInitialized) return;
            this.isInitialized = true;
            // Capture initial path
            this.lastPath = location.pathname + location.search;
            this._hookHistory();
            window.addEventListener('popstate', this._handlePopState);
        }

        destroy() {
            if (!this.isInitialized) return;
            this._restoreHistory();
            window.removeEventListener('popstate', this._handlePopState);
            if (this.debouncedNavigation.cancel) {
                this.debouncedNavigation.cancel();
            }
            this.isInitialized = false;
        }

        _hookHistory() {
            // Capture the instance for use in the wrapper
            const instance = this;
            for (const m of ['pushState', 'replaceState']) {
                const orig = history[m];
                this.originalHistoryMethods[m] = orig;

                const wrapper = function (...args) {
                    const result = orig.apply(this, args);
                    instance._onUrlChange();
                    return result;
                };

                this._historyWrappers[m] = wrapper;
                history[m] = wrapper;
            }
        }

        _restoreHistory() {
            for (const m of ['pushState', 'replaceState']) {
                if (this.originalHistoryMethods[m]) {
                    if (history[m] === this._historyWrappers[m]) {
                        history[m] = this.originalHistoryMethods[m];
                    } else {
                        Logger.warn('HISTORY HOOK', '', `history.${m} has been wrapped by another script. Skipping restoration to prevent breaking the chain.`);
                    }
                    this.originalHistoryMethods[m] = null;
                }
            }
            this._historyWrappers = {};
        }

        _handlePopState() {
            this._onUrlChange();
        }

        _onUrlChange() {
            const currentPath = location.pathname + location.search;
            // Prevent re-triggers if the path hasn't actually changed
            if (currentPath === this.lastPath) {
                return;
            }
            this.lastPath = currentPath;
            this.debouncedNavigation();
        }
    }

    // =================================================================================
    // SECTION: Main Application Controller
    // =================================================================================

    class AppController {
        constructor() {
            /** @type {ConfigManager} */
            this.configManager = new ConfigManager();
            /** @type {UIManager} */
            this.uiManager = new UIManager(this.configManager);
            /** @type {Sentinel} */
            this.sentinel = new Sentinel(OWNERID);
            /** @type {SettingsModal} */
            this.settingsModal = null;
            /** @type {NavigationMonitor} */
            this.navMonitor = new NavigationMonitor();

            // State
            /** @type {BaseAdapter} */
            this.adapter = null;
            /** @type {string|null} */
            this.activeSentinelSelector = null;
            /** @type {Function|null} */
            this.boundResultHandler = null;
        }

        /**
         * Initializes the script.
         */
        async init() {
            // 1. Load configuration asynchronously
            await this.configManager.load();

            // 2. Initialize Navigation Monitor (SPA Support)
            this.navMonitor.init();
            EventBus.subscribe(EVENTS.NAVIGATION, this._reconcileAdapter.bind(this), createEventKey(this, EVENTS.NAVIGATION));

            // 3. Initial Adapter Reconciliation
            await this._reconcileAdapter();

            // 4. Register Menu Command (Global)
            // Note: Settings modal is lazy-loaded with platform styles when needed, or default styles if no adapter is active.
            GM.registerMenuCommand('Open Settings', () => {
                this._openSettings();
            });
        }

        /**
         * Checks the current URL and mounts/unmounts the appropriate adapter.
         */
        async _reconcileAdapter() {
            const adapters = [GoogleAdapter, BingAdapter, DuckDuckGoAdapter];
            const currentUrl = window.location.href;

            // Check if current adapter is still applicable
            if (this.adapter) {
                if (this.adapter.constructor.isApplicable()) {
                    Logger.debug('DEBUG', '', 'Current adapter is still applicable. Forcing rescan for content updates.');
                    this._forceRescan();
                    return;
                }
                // Current adapter is no longer valid
                Logger.info('NAV', '', 'Current adapter no longer applicable. Unmounting...');
                this._unmountAdapter();
            }

            // Find new applicable adapter
            const AdapterClass = adapters.find((a) => a.isApplicable());

            if (AdapterClass) {
                Logger.info('NAV', LOG_STYLES.GREEN, `Switching to adapter: ${AdapterClass.id.toUpperCase()}`);
                await this._mountAdapter(AdapterClass);
            } else {
                Logger.info('NAV', LOG_STYLES.GRAY, `No applicable adapter found for: ${currentUrl}`);
            }
        }

        /**
         * Forces a rescan of the DOM to handle dynamic content updates (SPA navigation).
         * Removes processed flags and manually triggers handlers for existing elements.
         */
        _forceRescan() {
            if (!this.adapter || !this.activeSentinelSelector) return;

            Logger.info('RESCAN', LOG_STYLES.BLUE, 'Rescanning DOM for new content...');

            // 1. Reset processed flags to allow re-detection
            const processedElements = document.querySelectorAll(`.${APPID}-processed`);
            processedElements.forEach((el) => el.classList.remove(`${APPID}-processed`));

            // 2. Manually query matches using the sentinel selector
            // Since we removed the exclusion class, the selector will match all valid items again.
            const targets = document.querySelectorAll(this.activeSentinelSelector);

            Logger.debug('DEBUG', '', `Manual rescan found ${targets.length} candidates.`);

            targets.forEach((el) => {
                this.adapter.onResultFound(el);
            });
        }

        /**
         * Mounts the specified adapter and starts observation.
         * @param {typeof BaseAdapter} AdapterClass
         */
        async _mountAdapter(AdapterClass) {
            // Get Platform Styles
            const platformStyles = SITE_STYLES[AdapterClass.id];

            // Inject Platform-Specific Overrides (Layout)
            if (platformStyles.overrides) {
                const style = document.createElement('style');
                style.id = `${APPID}-platform-overrides`;
                style.textContent = platformStyles.overrides;
                const nonce = document.querySelector('script[nonce]')?.nonce;
                if (nonce) style.setAttribute('nonce', nonce);
                document.head.appendChild(style);
            }

            // Initialize UI Manager with platform styles
            this.uiManager.init(platformStyles);

            // Instantiate Adapter
            this.adapter = new AdapterClass(this.uiManager);

            // Register async fetcher if supported
            if (typeof this.adapter.fetchOriginalImageUrl === 'function' && this.adapter.fetchOriginalImageUrl !== BaseAdapter.prototype.fetchOriginalImageUrl) {
                this.uiManager.setOriginalImageFetcher(this.adapter.fetchOriginalImageUrl.bind(this.adapter));
            } else {
                // Reset fetcher if previous adapter had one but this one doesn't
                this.uiManager.setOriginalImageFetcher(null);
            }

            Logger.log('INIT', LOG_STYLES.GREEN, `Mounted ${AdapterClass.id.toUpperCase()}`);

            // Start Sentinel Observation
            const selector = this.adapter.getSentinelSelector();
            if (selector) {
                this.activeSentinelSelector = selector;
                // Bind handler to maintain "this" context and allow removal
                this.boundResultHandler = (el) => {
                    if (this.adapter) {
                        this.adapter.onResultFound(el);

                        // Smoke Test: Validate extraction on the first result found.
                        // Triggers validation logic inside extractUrls.
                        if (!this.adapter.hasSmokeTested) {
                            this.adapter.hasSmokeTested = true;
                            this.adapter.extractUrls(el);
                        }
                    }
                };
                Logger.info('SENTINEL', LOG_STYLES.BLUE, `Observing: "${selector}"`);
                this.sentinel.on(selector, this.boundResultHandler);
            } else {
                Logger.warn('INIT WARN', '', 'No sentinel selector defined for this adapter.');
            }
        }

        /**
         * Unmounts the current adapter and stops observation.
         */
        _unmountAdapter() {
            if (!this.adapter) return;

            // Stop Sentinel Observation
            if (this.activeSentinelSelector && this.boundResultHandler) {
                this.sentinel.off(this.activeSentinelSelector, this.boundResultHandler);
                this.activeSentinelSelector = null;
                this.boundResultHandler = null;
            }

            // Remove Platform Overrides
            const style = document.getElementById(`${APPID}-platform-overrides`);
            if (style) style.remove();

            // Clear reference
            this.adapter = null;
            Logger.info('TERM', LOG_STYLES.GRAY, 'Adapter unmounted.');
        }

        /**
         * Opens settings modal with appropriate styles.
         */
        _openSettings() {
            // Determine styles: use active adapter's styles or fallback to Google's (as generic dark theme)
            let styles = SITE_STYLES.google;
            if (this.adapter) {
                styles = SITE_STYLES[this.adapter.constructor.id];
            }

            if (!this.settingsModal) {
                this.settingsModal = new SettingsModal(this.configManager, styles);
            } else {
                // Update styles if reused (though we typically recreate logic if needed, simple prop update is tricky here)
                // For simplicity, we just create a new instance if styles differ, or let the modal handle it.
                // Given the modal implementation, it's safer to recreate if we want to switch themes dynamically.
                // But typically users don't switch sites while settings is open.
                // We'll pass the current styles to a new instance to be safe.
                this.settingsModal = new SettingsModal(this.configManager, styles);
            }
            this.settingsModal.open();
        }
    }

    // =================================================================================
    // SECTION: Entry Point
    // =================================================================================

    if (ExecutionGuard.hasExecuted()) return;
    ExecutionGuard.setExecuted();

    // 1. Instantiate controller immediately.
    const app = new AppController();

    // 2. Initialize the app immediately.
    // The init method checks isApplicable() internally.
    app.init().catch((e) => {
        Logger.error('INIT ERROR', '', 'Failed to initialize app:', e);
    });
})();