8chan YouTube Link Enhancer

Cleans up YouTube links, adds titles, optional thumbnail previews, and settings via STM.

// ==UserScript==
// @name         8chan YouTube Link Enhancer
// @namespace    nipah-scripts-8chan
// @version      3.3.1
// @description  Cleans up YouTube links, adds titles, optional thumbnail previews, and settings via STM.
// @author       nipah, Gemini
// @license      MIT
// @match        https://8chan.moe/*
// @match        https://8chan.se/*
// @grant        GM.xmlHttpRequest
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM_addStyle
// @connect      youtube.com
// @connect      i.ytimg.com
// @run-at       document-idle
// ==/UserScript==

(async function() {
    'use strict';

    // --- Constants ---
    const SCRIPT_NAME = 'YTLE';
    const SCRIPT_ID = 'YTLE'; // Unique ID for Settings Tab Manager
    const CACHE_KEY_SETTINGS = 'ytleSettings';
    const CACHE_KEY_TITLES = 'ytleTitleCache';

    const DEFAULTS = Object.freeze({
        CACHE_EXPIRY_DAYS: 7,
        SHOW_THUMBNAILS: false,
        API_DELAY_MS: 200,
        CACHE_CLEANUP_PROBABILITY: 0.1, // 10% chance per run
        THUMBNAIL_POPUP_ID: 'ytle-thumbnail-popup',
        THUMBNAIL_HIDE_DELAY_MS: 150,
    });

    const REGEX = Object.freeze({
        YOUTUBE: /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/|live\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})(?:[?&#]|$)/i, // Simplified slightly, captures ID
        YOUTUBE_TRACKING_PARAMS: /[?&](si|feature|ref|fsi|source|utm_source|utm_medium|utm_campaign|gclid|gclsrc|fbclid)=[^&]+/gi,
    });

    const URL_TEMPLATES = Object.freeze({
        OEMBED: "https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=VIDEO_ID&format=json",
        THUMBNAIL_WEBP: "https://i.ytimg.com/vi_webp/VIDEO_ID/maxresdefault.webp",
        // Fallback might be needed if maxresdefault webp fails often, e.g., mqdefault.jpg
        // THUMBNAIL_JPG_HQ: "https://i.ytimg.com/vi/VIDEO_ID/hqdefault.jpg",
    });

    const PLACEHOLDER_IMG_SRC = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; // Transparent pixel
    const YOUTUBE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="#FF0000" d="M549.7 124.1c-6.3-23.7-24.9-42.4-48.6-48.6C456.5 64 288 64 288 64s-168.5 0-213.1 11.5c-23.7 6.3-42.4 24.9-48.6 48.6C16 168.5 16 256 16 256s0 87.5 10.3 131.9c6.3 23.7 24.9 42.4 48.6 48.6C119.5 448 288 448 288 448s168.5 0 213.1-11.5c23.7-6.3 42.4-24.9 48.6-48.6 10.3-44.4 10.3-131.9 10.3-131.9s0-87.5-10.3-131.9zM232 334.1V177.9L361 256 232 334.1z"/></svg>`;

    // --- Utilities ---
    const Logger = {
        prefix: `[${SCRIPT_NAME}]`,
        log: (...args) => console.log(Logger.prefix, ...args),
        warn: (...args) => console.warn(Logger.prefix, ...args),
        error: (...args) => console.error(Logger.prefix, ...args),
    };

    function delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function getVideoId(href) {
        if (!href) return null;
        const match = href.match(REGEX.YOUTUBE);
        return match ? match[1] : null;
    }

    // --- Settings Manager ---
    class SettingsManager {
        constructor() {
            this.settings = {
                cacheExpiryDays: DEFAULTS.CACHE_EXPIRY_DAYS,
                showThumbnails: DEFAULTS.SHOW_THUMBNAILS
            };
        }

        async load() {
            try {
                const loadedSettings = await GM.getValue(CACHE_KEY_SETTINGS, this.settings);

                // Validate and merge loaded settings
                this.settings.cacheExpiryDays = (typeof loadedSettings.cacheExpiryDays === 'number' && Number.isInteger(loadedSettings.cacheExpiryDays) && loadedSettings.cacheExpiryDays > 0)
                    ? loadedSettings.cacheExpiryDays
                    : DEFAULTS.CACHE_EXPIRY_DAYS;

                this.settings.showThumbnails = (typeof loadedSettings.showThumbnails === 'boolean')
                    ? loadedSettings.showThumbnails
                    : DEFAULTS.SHOW_THUMBNAILS;

                Logger.log('Settings loaded:', this.settings);
            } catch (e) {
                Logger.warn('Failed to load settings, using defaults.', e);
                // Reset to defaults on error
                this.settings = {
                    cacheExpiryDays: DEFAULTS.CACHE_EXPIRY_DAYS,
                    showThumbnails: DEFAULTS.SHOW_THUMBNAILS
                };
            }
        }

        async save() {
            try {
                // Ensure types before saving
                this.settings.cacheExpiryDays = Math.max(1, Math.floor(this.settings.cacheExpiryDays || DEFAULTS.CACHE_EXPIRY_DAYS));
                this.settings.showThumbnails = !!this.settings.showThumbnails;

                await GM.setValue(CACHE_KEY_SETTINGS, this.settings);
                Logger.log('Settings saved:', this.settings);
            } catch (e) {
                Logger.error('Failed to save settings.', e);
            }
        }

        get cacheExpiryDays() {
            return this.settings.cacheExpiryDays;
        }

        set cacheExpiryDays(days) {
            const val = parseInt(days, 10);
            if (!isNaN(val) && val > 0) {
                this.settings.cacheExpiryDays = val;
            } else {
                 Logger.warn(`Attempted to set invalid cacheExpiryDays: ${days}`);
            }
        }

        get showThumbnails() {
            return this.settings.showThumbnails;
        }

        set showThumbnails(value) {
            this.settings.showThumbnails = !!value;
        }
    }

    // --- Title Cache ---
    class TitleCache {
        constructor(settingsManager) {
            this.settings = settingsManager; // Reference to settings
            this.cache = null; // Lazy loaded
        }

        async _loadCache() {
            if (this.cache === null) {
                try {
                    this.cache = await GM.getValue(CACHE_KEY_TITLES, {});
                } catch (e) {
                    Logger.warn('Failed to load title cache:', e);
                    this.cache = {}; // Use empty cache on error
                }
            }
            return this.cache;
        }

        async _saveCache() {
            if (this.cache === null) return; // Don't save if never loaded
            try {
                await GM.setValue(CACHE_KEY_TITLES, this.cache);
            } catch (e) {
                Logger.warn('Failed to save title cache:', e);
            }
        }

        async getTitle(videoId) {
            const cache = await this._loadCache();
            const item = cache[videoId];
            if (item && typeof item.expiry === 'number' && item.expiry > Date.now()) {
                return item.title;
            }
            // If expired or not found, remove old entry (if exists) and return null
            if (item) {
                delete cache[videoId];
                await this._saveCache(); // Save removal
            }
            return null;
        }

        async setTitle(videoId, title) {
            if (!videoId || typeof title !== 'string') return;
            const cache = await this._loadCache();
            const expiryDays = this.settings.cacheExpiryDays;
            const expiryTime = Date.now() + (expiryDays * 24 * 60 * 60 * 1000);

            cache[videoId] = { title: title, expiry: expiryTime };
            await this._saveCache();
        }

        async clearExpired() {
            // Only run cleanup occasionally
            if (Math.random() >= DEFAULTS.CACHE_CLEANUP_PROBABILITY) return 0;

            const cache = await this._loadCache();
            const now = Date.now();
            let changed = false;
            let malformedCount = 0;
            let expiredCount = 0;

            for (const videoId in cache) {
                if (Object.hasOwnProperty.call(cache, videoId)) {
                    const item = cache[videoId];
                    // Check for invalid format or expiry
                    if (!item || typeof item.title !== 'string' || typeof item.expiry !== 'number' || item.expiry <= now) {
                         delete cache[videoId];
                         changed = true;
                         if (item && item.expiry <= now) expiredCount++;
                         else malformedCount++;
                         if (!item || typeof item.title !== 'string' || typeof item.expiry !== 'number') {
                             Logger.warn(`Removed malformed cache entry: ${videoId}`);
                         }
                    }
                }
            }

            if (changed) {
                await this._saveCache();
                const totalCleared = malformedCount + expiredCount;
                if (totalCleared > 0) {
                    Logger.log(`Cleared ${totalCleared} cache entries (${expiredCount} expired, ${malformedCount} malformed).`);
                }
            }
            return expiredCount + malformedCount;
        }

        async purgeAll() {
            try {
                this.cache = {}; // Clear in-memory cache
                await GM.setValue(CACHE_KEY_TITLES, {}); // Clear storage
                Logger.log('Title cache purged successfully.');
                return true;
            } catch (e) {
                Logger.error('Failed to purge title cache:', e);
                return false;
            }
        }
    }

    // --- API Fetcher ---
    class ApiFetcher {
        async fetchVideoData(videoId) {
            const url = URL_TEMPLATES.OEMBED.replace('VIDEO_ID', videoId);
            return new Promise((resolve, reject) => {
                GM.xmlHttpRequest({
                    method: "GET",
                    url: url,
                    responseType: "json",
                    timeout: 10000,
                    onload: (response) => {
                        if (response.status === 200 && response.response?.title) {
                            resolve(response.response);
                        } else if ([401, 403, 404].includes(response.status)) {
                            reject(new Error(`Video unavailable (Status: ${response.status})`));
                        } else {
                            reject(new Error(`oEmbed request failed (${response.statusText || `Status ${response.status}`})`));
                        }
                    },
                    onerror: (err) => reject(new Error(`GM.xmlHttpRequest error: ${err.error || 'Network error'}`)),
                    ontimeout: () => reject(new Error('oEmbed request timed out')),
                });
            });
        }

        async fetchThumbnailAsDataURL(videoId) {
            const thumbnailUrl = URL_TEMPLATES.THUMBNAIL_WEBP.replace('VIDEO_ID', videoId);
            return new Promise((resolve) => {
                GM.xmlHttpRequest({
                    method: "GET",
                    url: thumbnailUrl,
                    responseType: 'blob',
                    timeout: 8000, // Slightly shorter timeout for images
                    onload: (response) => {
                        if (response.status === 200 && response.response) {
                            const reader = new FileReader();
                            reader.onloadend = () => resolve(reader.result); // result is the Data URL
                            reader.onerror = (err) => {
                                Logger.warn(`FileReader error for thumbnail ${videoId}:`, err);
                                resolve(null); // Resolve with null on reader error
                            };
                            reader.readAsDataURL(response.response);
                        } else {
                            // Log non-200 status for debugging, but still resolve null
                             if(response.status !== 404) Logger.warn(`Thumbnail fetch failed for ${videoId} (Status: ${response.status})`);
                            resolve(null);
                        }
                    },
                    onerror: (err) => {
                        Logger.error(`GM.xmlHttpRequest error fetching thumbnail for ${videoId}:`, err);
                        resolve(null);
                    },
                    ontimeout: () => {
                        Logger.warn(`Timeout fetching thumbnail for ${videoId}`);
                        resolve(null);
                    }
                });
            });
        }
    }

    // --- Link Enhancer (DOM Manipulation) ---
    class LinkEnhancer {
        constructor(titleCache, apiFetcher, settingsManager) {
            this.cache = titleCache;
            this.api = apiFetcher;
            this.settings = settingsManager;
            this.styleAdded = false;
            this.processingLinks = new Set(); // Track links currently being fetched
        }

        addStyles() {
            if (this.styleAdded) return;
            const encodedSvg = `data:image/svg+xml;base64,${btoa(YOUTUBE_ICON_SVG)}`;
            const styles = `
                .youtubelink {
                    position: relative;
                    padding-left: 20px; /* Space for icon */
                    display: inline-block; /* Prevent line breaks inside link */
                    white-space: nowrap;
                    text-decoration: none !important;
                    /* Optional: slightly adjust vertical alignment if needed */
                    /* vertical-align: middle; */
                }
                .youtubelink:hover {
                    text-decoration: underline !important;
                }
                .youtubelink::before {
                    content: '';
                    position: absolute;
                    left: 0px;
                    top: 50%;
                    transform: translateY(-50%);
                    width: 16px; /* Icon size */
                    height: 16px;
                    background-image: url("${encodedSvg}");
                    background-repeat: no-repeat;
                    background-size: contain;
                    background-position: center;
                    /* vertical-align: middle; /* Align icon with text */
                }
                /* Thumbnail Popup Styles */
                #${DEFAULTS.THUMBNAIL_POPUP_ID} {
                    position: fixed; display: none; z-index: 10000;
                    border: 1px solid #555; background-color: #282828;
                    padding: 2px; border-radius: 2px;
                    box-shadow: 3px 3px 8px rgba(0,0,0,0.4);
                    pointer-events: none; /* Don't interfere with mouse events */
                    max-width: 320px; max-height: 180px; overflow: hidden;
                }
                #${DEFAULTS.THUMBNAIL_POPUP_ID} img {
                    display: block; width: 100%; height: auto;
                    max-height: 176px; /* Max height inside padding */
                    object-fit: contain; background-color: #111;
                }
                /* Settings Panel Content (Scoped to parent div) */
                #${SCRIPT_ID}-panel-content > div { margin-bottom: 10px; }
                #${SCRIPT_ID}-panel-content input[type="number"] {
                    width: 60px; padding: 3px; margin-left: 5px;
                    border: 1px solid var(--settings-input-border, #ccc);
                    background-color: var(--settings-input-bg, #fff);
                    color: var(--settings-text, #000); box-sizing: border-box;
                }
                #${SCRIPT_ID}-panel-content input[type="checkbox"] { margin-right: 5px; vertical-align: middle; }
                #${SCRIPT_ID}-panel-content label.small { vertical-align: middle; font-size: 0.95em; }
                #${SCRIPT_ID}-panel-content button { margin-top: 5px; margin-right: 10px; padding: 4px 8px; }
                #${SCRIPT_ID}-save-status, #${SCRIPT_ID}-purge-status {
                    margin-left: 10px; font-size: 0.9em;
                    color: var(--settings-text, #ccc); font-style: italic;
                }
            `;
            GM_addStyle(styles);
            this.styleAdded = true;
            Logger.log('Styles added.');
        }

        cleanLinkUrl(linkElement) {
            if (!linkElement?.href) return;
            const originalHref = linkElement.href;
            let cleanHref = originalHref;

            // Normalize youtu.be, /live/, /shorts/ to standard watch?v= format
             if (cleanHref.includes('youtu.be/')) {
                const videoId = getVideoId(cleanHref);
                 if (videoId) {
                     const url = new URL(cleanHref);
                     const timestamp = url.searchParams.get('t');
                     cleanHref = `https://www.youtube.com/watch?v=${videoId}${timestamp ? `&t=${timestamp}` : ''}`;
                 }
            } else {
                cleanHref = cleanHref.replace('/live/', '/watch?v=')
                                      .replace('/shorts/', '/watch?v=')
                                      .replace('/embed/', '/watch?v=')
                                      .replace('/v/', '/watch?v=');
             }

            // Remove tracking parameters more reliably using URL API
            try {
                const url = new URL(cleanHref);
                const paramsToRemove = ['si', 'feature', 'ref', 'fsi', 'source', 'utm_source', 'utm_medium', 'utm_campaign', 'gclid', 'gclsrc', 'fbclid'];
                let changedParams = false;
                paramsToRemove.forEach(param => {
                    if (url.searchParams.has(param)) {
                        url.searchParams.delete(param);
                        changedParams = true;
                    }
                });
                if (changedParams) {
                    cleanHref = url.toString();
                }
            } catch (e) {
                // Fallback to regex if URL parsing fails (e.g., malformed URL initially)
                cleanHref = cleanHref.replace(REGEX.YOUTUBE_TRACKING_PARAMS, '');
                cleanHref = cleanHref.replace(/(\?|&)$/, ''); // Remove trailing ? or &
                cleanHref = cleanHref.replace('?&', '?');     // Fix "?&" case
            }


            if (cleanHref !== originalHref) {
                try {
                    linkElement.href = cleanHref;
                    // Only update text if it exactly matched the old URL
                    if (linkElement.textContent.trim() === originalHref.trim()) {
                        linkElement.textContent = cleanHref;
                    }
                } catch (e) {
                    // This can happen if the element is removed from DOM during processing
                    Logger.warn("Failed to update link href/text (element might be gone):", linkElement.textContent, e);
                }
            }
        }

        findLinksInNode(node) {
            if (!node || node.nodeType !== Node.ELEMENT_NODE) return [];

            const links = [];
            // Check if the node itself is a link in the target area
            if (node.matches && node.matches('.divMessage a')) {
                links.push(node);
            }
            // Find links within the node (or descendants) that are inside a .divMessage
            if (node.querySelectorAll) {
                 const potentialLinks = node.querySelectorAll('.divMessage a');
                 potentialLinks.forEach(link => {
                     // Ensure the link is actually *within* a .divMessage that is a descendant of (or is) the input node
                     if (node.contains(link) && link.closest('.divMessage')) {
                         links.push(link);
                     }
                 });
            }
            // Return unique links only
             return [...new Set(links)];
        }


        async processLinks(links) {
            if (!links || links.length === 0) return;

            // Perform opportunistic cache cleanup *before* heavy processing
            await this.cache.clearExpired();

            const linksToFetch = [];

            for (const link of links) {
                // Skip if already enhanced, marked as failed for a different reason, or currently being fetched
                // Note: We specifically allow reprocessing if ytFailed is 'no-id' from a previous incorrect run
                if (link.dataset.ytEnhanced ||
                    (link.dataset.ytFailed && link.dataset.ytFailed !== 'no-id') ||
                    this.processingLinks.has(link)) {
                    continue;
                }

                // --- Skip quotelinks ---
                if (link.classList.contains('quoteLink')) {
                    // Mark as skipped so we don't check again
                    link.dataset.ytFailed = 'skipped-type';
                    continue; // Skip this link entirely, don't process further
                }

                // --- PRIMARY FIX: Check for Video ID FIRST ---
                const videoId = getVideoId(link.href);

                if (!videoId) {
                    // It's NOT a YouTube link, or not one we can parse.
                    // Mark as failed so we don't re-check it constantly.
                    // Crucially, DO NOT call cleanLinkUrl or _applyTitle.
                    link.dataset.ytFailed = 'no-id';
                    // Optional: Remove old enhancement classes/data if they exist from a bad run
                    // link.classList.remove("youtubelink");
                    // delete link.dataset.videoId;
                    continue; // Move to the next link in the list
                }

                // --- If we reach here, it IS a potential YouTube link ---

                // Now it's safe to clean the URL (only affects confirmed YT links)
                this.cleanLinkUrl(link);

                // Add video ID attribute now that we know it's a YT link
                link.dataset.videoId = videoId;
                // Clear any previous 'no-id' failure flag if it existed
                delete link.dataset.ytFailed;

                // Check cache for the title
                const cachedTitle = await this.cache.getTitle(videoId);

                if (cachedTitle !== null) {
                    // Title found in cache, apply it directly
                    this._applyTitle(link, videoId, cachedTitle);
                } else {
                    // Title not cached, mark for fetching
                    this.processingLinks.add(link);
                    linksToFetch.push({ link, videoId });
                }
            } // End of loop through links

            // --- Process the batch of links needing API fetches ---
            if (linksToFetch.length === 0) {
                 // Log only if there were links initially, but none needed fetching
                 if (links.length > 0) Logger.log('No new links require title fetching.');
                 return;
             }

            Logger.log(`Fetching titles for ${linksToFetch.length} links...`);

            // Fetch titles sequentially with delay
            for (let i = 0; i < linksToFetch.length; i++) {
                const { link, videoId } = linksToFetch[i];

                // Double check if link still exists in DOM before fetching
                if (!document.body.contains(link)) {
                    this.processingLinks.delete(link);
                    Logger.warn(`Link removed from DOM before title fetch: ${videoId}`);
                    continue;
                }

                // Also check if it somehow got enhanced while waiting (e.g., duplicate link processed faster)
                if (link.dataset.ytEnhanced) {
                    this.processingLinks.delete(link);
                    continue;
                }

                try {
                    const videoData = await this.api.fetchVideoData(videoId);
                    const title = videoData.title.trim() || '[Untitled Video]'; // Handle empty titles
                    this._applyTitle(link, videoId, title);
                    await this.cache.setTitle(videoId, title);
                } catch (e) {
                    Logger.warn(`Failed to enhance link ${videoId}: ${e.message}`);
                    // Apply error state visually AND cache it
                    this._applyTitle(link, videoId, "[YT Fetch Error]"); // Show error to user
                    await this.cache.setTitle(videoId, "[YT Fetch Error]"); // Cache error state
                    link.dataset.ytFailed = 'fetch-error'; // Mark specific failure type
                } finally {
                    this.processingLinks.delete(link); // Remove from processing set regardless of outcome
                }

                // Apply delay between API calls
                if (i < linksToFetch.length - 1) {
                    await delay(DEFAULTS.API_DELAY_MS);
                }
            }
            Logger.log(`Finished fetching batch.`);
        }

        _applyTitle(link, videoId, title) {
             // Check if link still exists before modifying
             if (!document.body.contains(link)) {
                  Logger.warn(`Link removed from DOM before applying title: ${videoId}`);
                 return;
             }
            const displayTitle = (title === "[YT Fetch Error]") ? '[YT Error]' : title;
            // Use textContent for security, avoid potential HTML injection from titles
            link.textContent = `${displayTitle} [${videoId}]`;
            link.classList.add("youtubelink");
            link.dataset.ytEnhanced = "true"; // Mark as successfully enhanced
            delete link.dataset.ytFailed; // Remove failed flag if it was set previously
        }

        // Force re-enhancement of all currently enhanced/failed links
        async reEnhanceAll() {
             Logger.log('Triggering re-enhancement of all detected YouTube links...');
             const links = document.querySelectorAll('a[data-video-id]');
             links.forEach(link => {
                 delete link.dataset.ytEnhanced;
                 delete link.dataset.ytFailed;
                 // Reset text content only if it looks like our format, otherwise leave user-edited text
                 if (link.classList.contains('youtubelink')) {
                      const videoId = link.dataset.videoId;
                      // Basic reset, might need refinement based on how cleanLinkUrl behaves
                      link.textContent = link.href;
                     this.cleanLinkUrl(link); // Re-clean the URL just in case
                 }
                 link.classList.remove('youtubelink');
             });
             await this.processLinks(Array.from(links)); // Process them again
             Logger.log('Re-enhancement process finished.');
         }
    }

    // --- Thumbnail Preview ---
    class ThumbnailPreview {
        constructor(settingsManager, apiFetcher) {
            this.settings = settingsManager;
            this.api = apiFetcher;
            this.popupElement = null;
            this.imageElement = null;
            this.currentVideoId = null;
            this.isHovering = false;
            this.hideTimeout = null;
            this.fetchController = null; // AbortController for fetch
        }

        createPopupElement() {
            if (document.getElementById(DEFAULTS.THUMBNAIL_POPUP_ID)) {
                this.popupElement = document.getElementById(DEFAULTS.THUMBNAIL_POPUP_ID);
                this.imageElement = this.popupElement.querySelector('img');
                if (!this.imageElement) { // Fix if img somehow got removed
                    this.imageElement = document.createElement('img');
                    this.imageElement.alt = "YouTube Thumbnail Preview";
                    this.popupElement.appendChild(this.imageElement);
                 }
                 Logger.log('Re-using existing thumbnail popup element.');
                return;
            }
            this.popupElement = document.createElement('div');
            this.popupElement.id = DEFAULTS.THUMBNAIL_POPUP_ID;

            this.imageElement = document.createElement('img');
            this.imageElement.alt = "YouTube Thumbnail Preview";
            this.imageElement.src = PLACEHOLDER_IMG_SRC;
            this.imageElement.onerror = () => {
                // Don't log error if we aborted the load or hid the popup
                if (this.isHovering && this.imageElement.src !== PLACEHOLDER_IMG_SRC) {
                   Logger.warn(`Thumbnail image failed to load data for video ${this.currentVideoId || '(unknown)'}.`);
                }
                this.hide(); // Hide on error
            };

            this.popupElement.appendChild(this.imageElement);
            document.body.appendChild(this.popupElement);
            Logger.log('Thumbnail popup created.');
        }

        handleMouseOver(event) {
            if (!this.settings.showThumbnails || !this.popupElement) return;

            const link = event.target.closest('.youtubelink[data-video-id]');
            if (!link) return;

            const videoId = link.dataset.videoId;
            if (!videoId) return;

            // Clear any pending hide action
            if (this.hideTimeout) {
                clearTimeout(this.hideTimeout);
                this.hideTimeout = null;
            }

            this.isHovering = true;

            // If it's a different video or the popup is hidden, show it
            if (videoId !== this.currentVideoId || this.popupElement.style.display === 'none') {
                this.currentVideoId = videoId;
                // Abort previous fetch if any
                this.fetchController?.abort();
                this.fetchController = new AbortController();
                this.show(event, videoId, this.fetchController.signal);
            }
        }

        handleMouseOut(event) {
            if (!this.settings.showThumbnails || !this.isHovering) return;

             const link = event.target.closest('.youtubelink[data-video-id]');
             if (!link) return; // Mouse out event not from a target link or its children

             // Check if the mouse moved to the popup itself (though pointer-events: none should prevent this)
             // or to another element still within the original link
             if (event.relatedTarget && (link.contains(event.relatedTarget) || this.popupElement?.contains(event.relatedTarget))) {
                 return;
             }

            // Use a short delay before hiding
            this.hideTimeout = setTimeout(() => {
                 this.isHovering = false;
                 this.currentVideoId = null;
                 this.fetchController?.abort(); // Abort fetch if mouse moves away quickly
                 this.fetchController = null;
                 this.hide();
                 this.hideTimeout = null;
             }, DEFAULTS.THUMBNAIL_HIDE_DELAY_MS);
        }

        async show(event, videoId, signal) {
             if (!this.isHovering || !this.popupElement || !this.imageElement) return;

             // Reset image while loading
             this.imageElement.src = PLACEHOLDER_IMG_SRC;
             this.popupElement.style.display = 'block'; // Show popup frame immediately
             this.positionPopup(event); // Position based on initial event

             try {
                const dataUrl = await this.api.fetchThumbnailAsDataURL(videoId);

                // Check if fetch was aborted or if state changed during await
                if (signal?.aborted || !this.isHovering || videoId !== this.currentVideoId) {
                    if (this.popupElement.style.display !== 'none') this.hide();
                    return;
                }

                if (dataUrl) {
                    this.imageElement.src = dataUrl;
                     // Reposition after image loads, as dimensions might change slightly
                     // Use requestAnimationFrame for smoother updates if needed, but direct might be fine
                     this.positionPopup(event);
                    this.popupElement.style.display = 'block'; // Ensure it's visible
                } else {
                    Logger.warn(`No thumbnail data URL received for ${videoId}. Hiding popup.`);
                    this.hide();
                }

             } catch (error) {
                 if (error.name === 'AbortError') {
                     Logger.log(`Thumbnail fetch aborted for ${videoId}.`);
                 } else {
                      Logger.error(`Error fetching thumbnail for ${videoId}:`, error);
                 }
                 this.hide(); // Hide on error
             }
        }

         positionPopup(event) {
             if (!this.popupElement) return;

             const offsetX = 15;
             const offsetY = 15;
             const buffer = 5; // Buffer from window edge

             // Get potential dimensions (use max dimensions as fallback)
             const popupWidth = this.popupElement.offsetWidth || 320;
             const popupHeight = this.popupElement.offsetHeight || 180;
             const winWidth = window.innerWidth;
             const winHeight = window.innerHeight;
             const mouseX = event.clientX;
             const mouseY = event.clientY;

             let x = mouseX + offsetX;
             let y = mouseY + offsetY;

             // Adjust horizontal position
             if (x + popupWidth + buffer > winWidth) {
                 x = mouseX - popupWidth - offsetX; // Flip to left
             }
             x = Math.max(buffer, x); // Ensure it's not off-screen left

             // Adjust vertical position
             if (y + popupHeight + buffer > winHeight) {
                 y = mouseY - popupHeight - offsetY; // Flip to top
             }
             y = Math.max(buffer, y); // Ensure it's not off-screen top

             this.popupElement.style.left = `${x}px`;
             this.popupElement.style.top = `${y}px`;
         }


        hide() {
             if (this.popupElement) {
                 this.popupElement.style.display = 'none';
             }
             if (this.imageElement) {
                 this.imageElement.src = PLACEHOLDER_IMG_SRC; // Reset image
             }
             // Don't reset currentVideoId here, mouseover might happen again quickly
         }

         attachListeners() {
            document.body.addEventListener('mouseover', this.handleMouseOver.bind(this));
            document.body.addEventListener('mouseout', this.handleMouseOut.bind(this));
            Logger.log('Thumbnail hover listeners attached.');
        }
    }

    // --- Settings UI (STM Integration) ---
    class SettingsUI {
        constructor(settingsManager, titleCache, linkEnhancer) {
            this.settings = settingsManager;
            this.cache = titleCache;
            this.enhancer = linkEnhancer;
            this.stmRegistrationAttempted = false; // Prevent multiple attempts if somehow called again
        }

        // Called by STM when the panel needs to be initialized
        initializePanel(panelElement) {
            Logger.log(`STM Initializing panel for ${SCRIPT_ID}`);
            // Use a specific ID for the content wrapper for easier targeting
            panelElement.innerHTML = `
                <div id="${SCRIPT_ID}-panel-content">
                    <div>
                        <strong>Title Cache:</strong><br>
                        <label for="${SCRIPT_ID}-cache-expiry" class="small">Title Cache Expiry (Days):</label>
                        <input type="number" id="${SCRIPT_ID}-cache-expiry" min="1" step="1" value="${this.settings.cacheExpiryDays}" title="Number of days to cache YouTube video titles">
                    </div>
                    <div>
                        <button id="${SCRIPT_ID}-purge-cache">Purge Title Cache</button>
                        <span id="${SCRIPT_ID}-purge-status"></span>
                    </div>
                    <hr style="border-color: #444; margin: 10px 0;">
                    <div>
                        <strong>Thumbnail Preview:</strong><br>
                        <input type="checkbox" id="${SCRIPT_ID}-show-thumbnails" ${this.settings.showThumbnails ? 'checked' : ''}>
                        <label for="${SCRIPT_ID}-show-thumbnails" class="small">Show Thumbnails on Hover</label>
                    </div>
                    <hr style="border-color: #444; margin: 15px 0 10px;">
                    <div>
                         <button id="${SCRIPT_ID}-save-settings">Save Settings</button>
                         <span id="${SCRIPT_ID}-save-status"></span>
                    </div>
                </div>`;

            // Attach listeners using the specific IDs
            panelElement.querySelector(`#${SCRIPT_ID}-save-settings`)?.addEventListener('click', () => this.handleSaveClick(panelElement));
            panelElement.querySelector(`#${SCRIPT_ID}-purge-cache`)?.addEventListener('click', () => this.handlePurgeClick(panelElement));
        }

        // Called by STM when the tab is activated
        activatePanel(panelElement) {
            Logger.log(`STM Activating panel for ${SCRIPT_ID}`);
             const contentWrapper = panelElement.querySelector(`#${SCRIPT_ID}-panel-content`);
             if (!contentWrapper) return;

             // Update input values from current settings
             const expiryInput = contentWrapper.querySelector(`#${SCRIPT_ID}-cache-expiry`);
             const thumbCheckbox = contentWrapper.querySelector(`#${SCRIPT_ID}-show-thumbnails`);
             const saveStatusSpan = contentWrapper.querySelector(`#${SCRIPT_ID}-save-status`);
             const purgeStatusSpan = contentWrapper.querySelector(`#${SCRIPT_ID}-purge-status`);

             if (expiryInput) expiryInput.value = this.settings.cacheExpiryDays;
             if (thumbCheckbox) thumbCheckbox.checked = this.settings.showThumbnails;

             // Clear status messages on activation
             if (saveStatusSpan) saveStatusSpan.textContent = '';
             if (purgeStatusSpan) purgeStatusSpan.textContent = '';
        }

        async handleSaveClick(panelElement) {
            const contentWrapper = panelElement.querySelector(`#${SCRIPT_ID}-panel-content`);
            if (!contentWrapper) { Logger.error("Cannot find panel content for saving."); return; }

            const expiryInput = contentWrapper.querySelector(`#${SCRIPT_ID}-cache-expiry`);
            const thumbCheckbox = contentWrapper.querySelector(`#${SCRIPT_ID}-show-thumbnails`);
            const statusSpan = contentWrapper.querySelector(`#${SCRIPT_ID}-save-status`);

            if (!expiryInput || !thumbCheckbox || !statusSpan) { Logger.error("Missing settings elements in panel."); return; }

            const days = parseInt(expiryInput.value, 10);

            if (isNaN(days) || days <= 0 || !Number.isInteger(days)) {
                this.showStatus(statusSpan, 'Invalid number of days!', 'red');
                Logger.warn('Attempted to save invalid cache expiry days:', expiryInput.value);
                return;
            }

            // Update settings via the SettingsManager instance
            this.settings.cacheExpiryDays = days;
            this.settings.showThumbnails = thumbCheckbox.checked;
            await this.settings.save();

            this.showStatus(statusSpan, 'Settings saved!', 'lime');
            Logger.log(`Settings saved via UI: Cache expiry ${days} days, Show Thumbnails ${thumbCheckbox.checked}.`);

             // Apply thumbnail setting change immediately
             if (!this.settings.showThumbnails) {
                 // Hide any currently visible thumbnail popup if setting is disabled
                 const thumbnailPreview = window.ytle?.thumbnailPreview; // Access instance if exposed
                 thumbnailPreview?.hide();
             }
        }

        async handlePurgeClick(panelElement) {
            const contentWrapper = panelElement.querySelector(`#${SCRIPT_ID}-panel-content`);
             if (!contentWrapper) { Logger.error("Cannot find panel content for purging."); return; }
             const statusSpan = contentWrapper.querySelector(`#${SCRIPT_ID}-purge-status`);
             if (!statusSpan) { Logger.error("Missing purge status element."); return; }


            if (!confirm('Are you sure you want to purge the entire YouTube title cache?\nThis cannot be undone and will trigger re-fetching of all titles.')) {
                this.showStatus(statusSpan, 'Purge cancelled.', 'grey');
                return;
            }

            this.showStatus(statusSpan, 'Purging cache...', 'orange');
            const success = await this.cache.purgeAll();

            if (success) {
                this.showStatus(statusSpan, 'Cache purged! Re-enhancing links...', 'lime');
                // Trigger a re-enhancement of all known links
                await this.enhancer.reEnhanceAll();
                this.showStatus(statusSpan, 'Cache purged! Re-enhancement complete.', 'lime', 3000); // Update message after re-enhancement
            } else {
                this.showStatus(statusSpan, 'Purge failed!', 'red');
            }
        }

        showStatus(spanElement, message, color, duration = 3000) {
            if (!spanElement) return;
            spanElement.textContent = message;
            spanElement.style.color = color;
            // Clear message after duration, only if the message hasn't changed
            setTimeout(() => {
                if (spanElement.textContent === message) {
                    spanElement.textContent = '';
                    spanElement.style.color = 'var(--settings-text, #ccc)'; // Reset color
                }
            }, duration);
        }

        // --- Updated STM Registration with Timeout ---
        async registerWithSTM() {
            if (this.stmRegistrationAttempted) {
                Logger.log('STM registration already attempted, skipping.');
                return;
            }
            this.stmRegistrationAttempted = true;

            let stmAttempts = 0;
            const MAX_STM_ATTEMPTS = 20; // 20 attempts
            const STM_RETRY_DELAY_MS = 250; // 250ms delay
            const MAX_WAIT_TIME_MS = MAX_STM_ATTEMPTS * STM_RETRY_DELAY_MS; // ~5 seconds total

            const checkAndRegister = () => {
                stmAttempts++;
                // Use Logger.log for debugging attempts if needed
                // Logger.log(`STM check attempt ${stmAttempts}/${MAX_STM_ATTEMPTS}...`);

                // *** Check unsafeWindow directly ***
                if (typeof unsafeWindow !== 'undefined'
                    && typeof unsafeWindow.SettingsTabManager !== 'undefined'
                    && typeof unsafeWindow.SettingsTabManager.ready !== 'undefined')
                {
                    Logger.log('Found SettingsTabManager on unsafeWindow. Proceeding with registration...');
                    // Found it, call the async registration function, but don't wait for it here.
                    // Let the rest of the script initialization continue.
                    performStmRegistration().catch(err => {
                        Logger.error("Async registration with STM failed after finding it:", err);
                        // Even if registration fails *after* finding STM, we proceed without the panel.
                    });
                    // STM found (or at least its .ready property), stop polling.
                    return; // Exit the polling function
                }

                // STM not found/ready yet, check if we should give up
                if (stmAttempts >= MAX_STM_ATTEMPTS) {
                    Logger.warn(`SettingsTabManager not found or not ready after ${MAX_STM_ATTEMPTS} attempts (${(MAX_WAIT_TIME_MS / 1000).toFixed(1)} seconds). Proceeding without settings panel.`);
                    // Give up polling, DO NOT call setTimeout again.
                    return; // Exit the polling function
                }

                // STM not found, limit not reached, schedule next attempt
                // Optional: Log if STM exists but .ready is missing
                // if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.SettingsTabManager !== 'undefined') {
                //      Logger.log('Found SettingsTabManager on unsafeWindow, but .ready property is missing. Waiting...');
                // } else {
                //      Logger.log('SettingsTabManager not found on unsafeWindow or not ready yet. Waiting...');
                // }
                setTimeout(checkAndRegister, STM_RETRY_DELAY_MS); // Retry after a delay
            };

            const performStmRegistration = async () => {
                 // This function now only runs if STM.ready was detected
                 try {
                     // *** Access via unsafeWindow ***
                     // Ensure SettingsTabManager and .ready still exist before awaiting
                     if (typeof unsafeWindow?.SettingsTabManager?.ready === 'undefined') {
                         // Should not happen if called correctly, but check defensively
                         Logger.error('SettingsTabManager.ready disappeared before registration could complete.');
                         return; // Cannot register
                     }
                     const STM = await unsafeWindow.SettingsTabManager.ready;
                     // *** End Access via unsafeWindow ***

                     Logger.log('SettingsTabManager ready, registering tab...');
                     const registrationSuccess = STM.registerTab({
                         scriptId: SCRIPT_ID,
                         tabTitle: SCRIPT_NAME,
                         order: 110, // Keep your desired order
                         onInit: this.initializePanel.bind(this),
                         onActivate: this.activatePanel.bind(this)
                     });

                     if (registrationSuccess) {
                          Logger.log(`Tab registration request sent successfully for ${SCRIPT_ID}.`);
                     } else {
                          Logger.warn(`STM registration for ${SCRIPT_ID} returned false (tab might already exist or other issue).`);
                     }

                 } catch (err) {
                     Logger.error('Failed during SettingsTabManager.ready await or registerTab call:', err);
                     // No need to retry here, just log the failure.
                 }
            };

            // Start the check/wait process *asynchronously*.
            // This allows the main script initialization to continue immediately.
            checkAndRegister();
        }
    }

    // --- Main Application Class ---
    class YouTubeLinkEnhancerApp {
        constructor() {
            this.settingsManager = new SettingsManager();
            this.titleCache = new TitleCache(this.settingsManager);
            this.apiFetcher = new ApiFetcher();
            this.linkEnhancer = new LinkEnhancer(this.titleCache, this.apiFetcher, this.settingsManager);
            this.thumbnailPreview = new ThumbnailPreview(this.settingsManager, this.apiFetcher);
            this.settingsUI = new SettingsUI(this.settingsManager, this.titleCache, this.linkEnhancer);
            this.observer = null;

             // Expose instances for debugging/potential external interaction (optional)
             // Be cautious with exposing internal state/methods
             window.ytle = {
                 settings: this.settingsManager,
                 cache: this.titleCache,
                 enhancer: this.linkEnhancer,
                 thumbnailPreview: this.thumbnailPreview,
                 ui: this.settingsUI
             };
        }

        async initialize() {
            Logger.log('Initializing...');

            // 1. Load settings
            await this.settingsManager.load();

            // 2. Add styles & create UI elements
            this.linkEnhancer.addStyles();
            this.thumbnailPreview.createPopupElement();

            // 3. Attach global listeners
            this.thumbnailPreview.attachListeners();

            // 4. Register settings UI
            await this.settingsUI.registerWithSTM();

            // 5. Initial scan & process existing links
            Logger.log('Running initial link processing...');
            const initialLinks = this.linkEnhancer.findLinksInNode(document.body);
            await this.linkEnhancer.processLinks(initialLinks);
            Logger.log('Initial processing complete.');

            // 6. Setup MutationObserver
            this.setupObserver();

            Logger.log('Initialization complete.');
        }

        setupObserver() {
            this.observer = new MutationObserver(async (mutationsList) => {
                let linksToProcess = new Set();

                for (const mutation of mutationsList) {
                    if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                        for (const addedNode of mutation.addedNodes) {
                           // Only process element nodes
                            if (addedNode.nodeType === Node.ELEMENT_NODE) {
                                const foundLinks = this.linkEnhancer.findLinksInNode(addedNode);
                                foundLinks.forEach(link => {
                                    // Add link if it's potentially enhanceable (no videoId yet, or failed/not enhanced)
                                     if (!link.dataset.videoId || !link.dataset.ytEnhanced || link.dataset.ytFailed) {
                                         linksToProcess.add(link);
                                     }
                                });
                            }
                        }
                    }
                     // Optional: Handle attribute changes if needed (e.g., href changes on existing links)
                     // else if (mutation.type === 'attributes' && mutation.attributeName === 'href') {
                     //    const targetLink = mutation.target;
                     //    if (targetLink.matches && targetLink.matches('.divMessage a') && targetLink.closest('.divMessage')) {
                     //        // Handle potential re-enhancement if href changed
                     //        delete targetLink.dataset.ytEnhanced;
                     //        delete targetLink.dataset.ytFailed;
                     //        delete targetLink.dataset.videoId;
                     //        targetLink.classList.remove('youtubelink');
                     //        linksToProcess.add(targetLink);
                     //    }
                     //}
                }

                if (linksToProcess.size > 0) {
                     // Debounce slightly? Or process immediately? Immediate is simpler.
                     Logger.log(`Observer detected ${linksToProcess.size} new/updated potential links.`);
                     await this.linkEnhancer.processLinks([...linksToProcess]);
                }
            });

            this.observer.observe(document.body, {
                childList: true,
                subtree: true,
                // attributes: true, // Uncomment if you want to observe href changes
                // attributeFilter: ['href'] // Only observe href attribute changes
            });
            Logger.log('MutationObserver started.');
        }
    }

    // --- Script Entry Point ---
    const app = new YouTubeLinkEnhancerApp();
    app.initialize().catch(err => {
        Logger.error("Initialization failed:", err);
    });

})();