bygone-yt

youtube time-machine. pick a date, see videos from that era. filters out all videos made after that date. V3 VORAPIS REQUIRED!

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         bygone-yt
// @namespace    http://tampermonkey.net/
// @license      MIT
// @version      377
// @description  youtube time-machine. pick a date, see videos from that era. filters out all videos made after that date. V3 VORAPIS REQUIRED!
// @author       relicofatime
// @match        https://www.youtube.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      youtube.com
// @connect      worldtimeapi.org
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';

    // ============================================================
    //  bygone-yt v2 — V3-only rewrite
    //  Modules (in declaration order):
    //    Interceptor (IIFE, document-start)   — patches Response.json/.text;
    //                                            owns videoPool, mapVideo, sweep,
    //                                            sidebar sweep, click hijack.
    //    Config + VERSION
    //    Store                                — GM_* persistence + profiles + clock.
    //    DateHelper                           — relative-text ↔ Date.
    //    InterestModel                        — watch-history scoring.
    //    YouTubeAPI                           — InnerTube client (auth-trick).
    //    FeedEngine                           — merge 5 sources, dedup, weight.
    //    UI                                   — panel + FAB + styles.
    //    App                                  — wire everything.
    // ============================================================

    // ============================================================
    //  INTERCEPTOR (runs at document-start; before V3)
    //  Owns: videoPool, mapVideo, sweep, sidebar sweep, click hijack.
    //  Exposes setVideos / appendVideos / setLazyFetcher / mapVideo / sweep.
    //  References to Store, DateHelper, etc. resolve at CALL time, not
    //  install time — those classes are declared later in this file but
    //  exist before any YouTube response arrives.
    // ============================================================

    let _v3Detected = false;
    function _checkV3() {
        if (_v3Detected) return true;
        if (document.documentElement.hasAttribute('v3')) { _v3Detected = true; return true; }
        try { if (localStorage.getItem('VLTURBO_DATA')) { _v3Detected = true; return true; } } catch (_) {}
        if (window.PatcherJSC_TURBOPIPE) { _v3Detected = true; return true; }
        if (document.querySelector('.lohp-large-shelf-container, .lohp-medium-shelf')) { _v3Detected = true; return true; }
        return false;
    }

    function _checkStarTube() {
        try {
            if (window.globalDataPoints || document.globalDataPoints) return true;
            if (localStorage.getItem('ST_STABLE_SETTINGS')) return true;
            if (localStorage.getItem('starTubeConfigCreated')) return true;
        } catch (_) {}
        return !!document.querySelector(
            '#startube-settings-window-entity, #startube-settings-window, ' +
            '#st-watch-below, #st-actions-info-row, [id^="st-"], [class^="st-"], [class*=" st-"]'
        );
    }

    const Interceptor = (() => {
        const POOL_LS_KEY = 'bygone_v3_pool';
        // Migrate old localStorage key
        try {
            const old = localStorage.getItem('wbt_v3_pool');
            if (old && !localStorage.getItem(POOL_LS_KEY)) localStorage.setItem(POOL_LS_KEY, old);
        } catch (_) {}
        let videoPool = [];
        let active = false;
        let _poolReadyCbs = [];
        function _onPoolReady(fn) { if (active && videoPool.length) fn(); else _poolReadyCbs.push(fn); }
        function _firePoolReady() { for (const fn of _poolReadyCbs) try { fn(); } catch (_) {} _poolReadyCbs = []; }

        // _idMap (origId → video) gives STABLE mapping across responses.
        // _responseSeen (video.id set) gives DEDUP WITHIN ONE response.
        // _usedReplacements tracks all-time usage (for fresh-pick preference).
        // _poolIdsSet is the set of pool video IDs (fast membership test).
        const _idMap = new Map();
        const _usedReplacements = new Set();
        let _sweepStats = {};   // per-run branch counters, surfaced by __bygoneDiag
        let _lastHomeChannelPromoPruned = 0;
        let _lastHomeChannelPromoLeft = 0;
        let _responseSeen = new Set();
        let _displayedIdsCache = null;
        let _poolIdsSet = new Set();
        const _keptNaturalIds = new Set();
        let _poolCursor = 0;

        function startResponseScope() {
            _responseSeen = new Set();
            _displayedIdsCache = null;
        }

        // Build a set of every videoId currently visible in the page DOM.
        // Cached for the lifetime of one response/sweep scope.
        function _getDisplayedIds() {
            if (_displayedIdsCache) return _displayedIdsCache;
            const ids = new Set();
            document.querySelectorAll('a[href*="/watch"]').forEach(a => {
                const h = a.getAttribute('href') || '';
                const m = h.match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (m) ids.add(m[1]);
            });
            _displayedIdsCache = ids;
            return ids;
        }

        // ---- mapVideo: the heart of the system ---------------------
        //   opts.dedupInResponse:
        //     true  → enforce per-response uniqueness via _responseSeen.
        //   opts.avoidDisplayedOnFreshPick:
        //     true  → fresh picks avoid videos already visible.
        //     (Stable mappings are NEVER rejected for being on-screen.
        //      Returning the same answer for the same origId is what keeps
        //      cards stable across sweep ticks — rejecting it on screen
        //      grounds reintroduces the v189-era once-per-second rotation.)
        function mapVideo(origId, opts) {
            if (!videoPool.length) return null;
            const dedupInResponse = !!(opts && opts.dedupInResponse);
            const avoidDisplayed = !!(opts && opts.avoidDisplayedOnFreshPick);
            const displayed = (dedupInResponse || avoidDisplayed) ? _getDisplayedIds() : null;
            const stableClash = (id) => dedupInResponse && _responseSeen.has(id);
            const freshClash = (id) =>
                (dedupInResponse && _responseSeen.has(id)) ||
                (displayed && displayed.has(id));

            // origId is already a pool video → return as-is.
            if (origId && _poolIdsSet.has(origId)) {
                const found = videoPool.find(p => p.id === origId);
                if (found) {
                    _usedReplacements.add(found.id);
                    if (dedupInResponse) _responseSeen.add(found.id);
                    return found;
                }
            }

            // Stable mapping for this origId — never rejected for on-screen.
            if (origId && _idMap.has(origId)) {
                const stable = _idMap.get(origId);
                if (!stableClash(stable.id)) {
                    _usedReplacements.add(stable.id);
                    if (dedupInResponse) _responseSeen.add(stable.id);
                    return stable;
                }
            }

            // Fresh pick. Avoid videos already mapped to some other origId,
            // otherwise two origIds end up with the same replacement and we
            // see the same video twice in the grid (v203 fix).
            const alreadyMapped = new Set();
            for (const v of _idMap.values()) if (v && v.id) alreadyMapped.add(v.id);

            //   1) not-mapped AND not-clashing
            //   2) not-mapped (may clash)
            //   3) not-clashing (pool saturated — allow remap, avoid on-screen/response dup)
            //   4) anything (fully saturated — round-robin repeat)
            // Tiers 3 & 4 are what stop a modern video leaking through: once
            // every pool video is already mapped to some other slot (pool
            // smaller than the number of card slots — e.g. 109 pool vs 360
            // cards), tiers 1-2 fail. Returning null there left the ORIGINAL
            // present-day video on the card. A repeated era video is always
            // preferable to a modern hole, so never return null while the pool
            // is non-empty.
            let v = _findVideo(c => !alreadyMapped.has(c.id) && !freshClash(c.id));
            if (!v) v = _findVideo(c => !alreadyMapped.has(c.id));
            if (!v) v = _findVideo(c => !freshClash(c.id));
            if (!v) v = _findVideo(() => true);
            if (!v) return null;

            if (origId) _idMap.set(origId, v);
            _usedReplacements.add(v.id);
            if (dedupInResponse) _responseSeen.add(v.id);
            _maybeFetchMore();
            return v;
        }

        function _findVideo(pred) {
            for (let i = 0; i < videoPool.length; i++) {
                const cand = videoPool[(_poolCursor + i) % videoPool.length];
                if (pred(cand)) {
                    _poolCursor = (_poolCursor + i + 1) % videoPool.length;
                    return cand;
                }
            }
            return null;
        }

        // ---- Pool management --------------------------------------

        function hydrateFromLocalStorage() {
            try {
                const raw = localStorage.getItem(POOL_LS_KEY);
                if (!raw) return false;
                const parsed = JSON.parse(raw);
                if (parsed && Array.isArray(parsed.videos) && parsed.videos.length) {
                    videoPool = parsed.videos;
                    _poolIdsSet = new Set(videoPool.map(v => v.id));
                    active = true;
                    _firePoolReady();
                    return true;
                }
            } catch (_) {}
            return false;
        }

        function _persistPool() {
            try {
                localStorage.setItem(POOL_LS_KEY, JSON.stringify({
                    videos: videoPool,
                    savedAt: Date.now(),
                }));
            } catch (_) {}
        }

        const _VALID_VID = /^[A-Za-z0-9_-]{11}$/;

        function _pruneMappingStateForPool() {
            for (const [origId, mapped] of _idMap) {
                if (!mapped || !mapped.id || !_poolIdsSet.has(mapped.id)) _idMap.delete(origId);
            }
            for (const id of Array.from(_usedReplacements)) {
                if (!_poolIdsSet.has(id)) _usedReplacements.delete(id);
            }
            _displayedIdsCache = null;
        }

        function setVideos(videos) {
            if (!Array.isArray(videos) || !videos.length) return;
            const _seen = new Set();
            videoPool = videos.filter(v => {
                if (!v || !v.id || !_VALID_VID.test(v.id) || _seen.has(v.id)) return false;
                _seen.add(v.id);
                return true;
            });
            if (!videoPool.length) return;
            _poolIdsSet = new Set(videoPool.map(v => v.id));
            _poolCursor = 0;
            _pruneMappingStateForPool();
            active = true;
            _persistPool();
            _firePoolReady();
        }

        function appendVideos(videos) {
            if (!Array.isArray(videos) || !videos.length) return 0;
            let added = 0;
            for (const v of videos) {
                if (!v || !v.id || !_VALID_VID.test(v.id)) continue;
                if (_poolIdsSet.has(v.id)) continue;
                videoPool.push(v);
                _poolIdsSet.add(v.id);
                added++;
            }
            if (added > 0) _persistPool();
            return added;
        }

        // Lazy infinite-scroll wiring. App.init calls setLazyFetcher with a
        // function that returns the next page of videos. When the pool hits
        // 70% used, _maybeFetchMore fires the fetcher in the background.
        let _lazyFetcher = null;
        let _fetchingMore = false;
        let _morePage = 2;
        function setLazyFetcher(fn) { _lazyFetcher = fn; }

        function _maybeFetchMore() {
            if (_fetchingMore || !_lazyFetcher || !videoPool.length) return;
            if (_usedReplacements.size / videoPool.length < 0.7) return;
            _fetchingMore = true;
            const page = _morePage++;
            Promise.resolve()
                .then(() => _lazyFetcher(page))
                .then(more => { if (more && more.length) appendVideos(more); })
                .catch(() => { _morePage--; })
                .then(() => { _fetchingMore = false; });
        }

        function isInnerTubeUrl(url) {
            return !!url && url.indexOf('/youtubei/v1/') !== -1;
        }

        // ---- Renderer mutation -------------------------------------

        const RENDERER_KEYS = [
            'videoRenderer',
            'gridVideoRenderer',
            'compactVideoRenderer',
            'lockupViewModel',
        ];

        // ---- "Keep naturally-old videos" --------------------------
        // If YouTube's own algorithm surfaces a video that was uploaded
        // at or before the set date, leave it ALONE — YouTube's recs for
        // genuinely old content are good and worth keeping. Only videos
        // newer than the set date get replaced.
        const _REL_RE = /(\d+)\s*(year|month|week|day|hour|minute|second)s?\s+ago/i;

        function _relTextAtOrBeforeSet(relText) {
            if (!relText) return false;
            let setDateStr;
            try { setDateStr = Store.getCurrentDate(); } catch { return false; }
            if (!setDateStr) return false;
            const setDate = new Date(setDateStr);
            if (isNaN(setDate.getTime())) return false;
            const approx = DateHelper.approxPublishDate(relText);
            if (!approx) return false;
            return approx.getTime() <= setDate.getTime();
        }

        function _rendererRelText(r) {
            const p = r.publishedTimeText;
            if (!p) return '';
            return p.simpleText || (p.runs && p.runs[0] && p.runs[0].text) || '';
        }

        function _lockupRelText(r) {
            try {
                const rows = r.metadata?.lockupMetadataViewModel?.metadata
                    ?.contentMetadataViewModel?.metadataRows || [];
                for (const row of rows) {
                    for (const part of (row.metadataParts || [])) {
                        const txt = (part && part.text && part.text.content) || '';
                        if (_REL_RE.test(txt)) return txt;
                    }
                }
            } catch {}
            return '';
        }

        // Overwrite the date metadataPart of a lockup in place.
        function _rewriteLockupDate(r, newText) {
            try {
                const rows = r.metadata?.lockupMetadataViewModel?.metadata
                    ?.contentMetadataViewModel?.metadataRows || [];
                for (const row of rows) {
                    for (const part of (row.metadataParts || [])) {
                        const txt = (part && part.text && part.text.content) || '';
                        if (_REL_RE.test(txt)) { part.text.content = newText; return; }
                    }
                }
            } catch {}
        }

        let _replaceCount = 0;
        function replaceRenderer(r) {
            if (!r || typeof r !== 'object') return;
            const origId = r.videoId || '';
            if (!origId) return;
            if (_poolIdsSet.has(origId)) return;
            const _keepRel = _rendererRelText(r);
            console.log('[bygone] renderer', origId, 'date="' + _keepRel + '"', _keepRel ? ('old=' + _relTextAtOrBeforeSet(_keepRel)) : 'NO-DATE');
            if (_relTextAtOrBeforeSet(_keepRel)) {
                _keptNaturalIds.add(origId);
                try {
                    const sd = Store.getCurrentDate();
                    if (sd && r.publishedTimeText) {
                        const nd = DateHelper.recalcForFeed(_keepRel, sd, origId);
                        if (nd) {
                            if (r.publishedTimeText.simpleText !== undefined) r.publishedTimeText.simpleText = nd;
                            else if (r.publishedTimeText.runs) r.publishedTimeText.runs = [{ text: nd }];
                        }
                    }
                } catch (_) {}
                return;
            }
            const v = mapVideo(origId, { dedupInResponse: true, avoidDisplayedOnFreshPick: true });
            if (!v) return;
            const vid = v.id;
            _replaceCount++;

            r.videoId = vid;
            r.title = { simpleText: v.title || '', runs: [{ text: v.title || '' }] };
            r.thumbnail = { thumbnails: [
                { url: 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg', width: 480, height: 360 },
                { url: 'https://i.ytimg.com/vi/' + vid + '/mqdefault.jpg', width: 320, height: 180 },
            ] };
            if (v.viewCountFormatted || v.viewCount) {
                const viewsText = v.viewCountFormatted || (v.viewCount + ' views');
                r.viewCountText = { simpleText: viewsText };
                r.shortViewCountText = { simpleText: viewsText };
            }
            // Re-relativize date so "11 years ago" doesn't appear for a video
            // that's RECENT relative to the simulated time-machine date.
            let dateStr = v.relativeDate || '';
            try {
                const setDate = Store.getCurrentDate();
                if (setDate && dateStr) {
                    dateStr = DateHelper.recalcForFeed(dateStr, setDate, vid) || dateStr;
                }
            } catch (_) {}
            if (dateStr) r.publishedTimeText = { simpleText: dateStr };

            const dur = v.duration || '';
            r.lengthText = { simpleText: dur, accessibility: { accessibilityData: { label: dur } } };
            r.lengthSeconds = v.lengthSeconds || 0;

            const chan = v.channel || '';
            const cid = v.channelId || '';
            const byline = { runs: [{
                text: chan,
                navigationEndpoint: cid ? {
                    browseEndpoint: { browseId: cid, canonicalBaseUrl: '/channel/' + cid },
                    commandMetadata: { webCommandMetadata: { url: '/channel/' + cid, webPageType: 'WEB_PAGE_TYPE_CHANNEL' } },
                } : undefined,
            }] };
            if (chan) {
                r.shortBylineText = byline;
                r.longBylineText = byline;
                r.ownerText = byline;
            }

            r.navigationEndpoint = {
                watchEndpoint: { videoId: vid },
                commandMetadata: { webCommandMetadata: { url: '/watch?v=' + vid, webPageType: 'WEB_PAGE_TYPE_WATCH' } },
            };

            r.thumbnailOverlays = dur ? [{
                thumbnailOverlayTimeStatusRenderer: {
                    text: { simpleText: dur, accessibility: { accessibilityData: { label: dur } } },
                    style: 'DEFAULT',
                },
            }] : [];

            // Strip fields V3 might use to re-derive originals.
            delete r.descriptionSnippet;
            delete r.detailedMetadataSnippets;
            delete r.richThumbnail;
            delete r.menu;
            delete r.badges;
            delete r.ownerBadges;
        }

        // Modern InnerTube viewModel format (V3 home feed uses this).
        function replaceLockupViewModel(r) {
            if (!r || typeof r !== 'object') return;
            const origId = r.contentId || '';
            if (!origId) return;
            if (_poolIdsSet.has(origId)) return;
            const _keepRel = _lockupRelText(r);
            console.log('[bygone] lockup', origId, 'date="' + _keepRel + '"', _keepRel ? ('old=' + _relTextAtOrBeforeSet(_keepRel)) : 'NO-DATE');
            if (_relTextAtOrBeforeSet(_keepRel)) {
                _keptNaturalIds.add(origId);
                try {
                    const sd = Store.getCurrentDate();
                    if (sd) {
                        const nd = DateHelper.recalcForFeed(_keepRel, sd, origId);
                        if (nd) _rewriteLockupDate(r, nd);
                    }
                } catch (_) {}
                return;
            }
            const v = mapVideo(origId, { dedupInResponse: true, avoidDisplayedOnFreshPick: true });
            if (!v) return;
            const vid = v.id;
            _replaceCount++;

            const thumbUrl = 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg';
            r.contentId = vid;
            r.contentType = 'LOCKUP_CONTENT_TYPE_VIDEO';

            if (!r.metadata) r.metadata = {};
            if (!r.metadata.lockupMetadataViewModel) r.metadata.lockupMetadataViewModel = {};
            const meta = r.metadata.lockupMetadataViewModel;
            meta.title = { content: v.title || '', styleRuns: [] };

            let dateStr = v.relativeDate || '';
            try {
                const setDate = Store.getCurrentDate();
                if (setDate && dateStr) dateStr = DateHelper.recalcForFeed(dateStr, setDate, vid) || dateStr;
            } catch (_) {}

            const viewsText = v.viewCountFormatted || (v.viewCount ? v.viewCount + ' views' : '');
            meta.metadata = { contentMetadataViewModel: { metadataRows: [
                { metadataParts: [{
                    text: {
                        content: v.channel || '',
                        commandRuns: v.channelId ? [{
                            startIndex: 0,
                            length: (v.channel || '').length,
                            onTap: { innertubeCommand: {
                                browseEndpoint: { browseId: v.channelId, canonicalBaseUrl: '/channel/' + v.channelId },
                                commandMetadata: { webCommandMetadata: { url: '/channel/' + v.channelId, webPageType: 'WEB_PAGE_TYPE_CHANNEL' } },
                            } },
                        }] : [],
                    },
                }] },
                { metadataParts: [
                    viewsText ? { text: { content: viewsText } } : null,
                    dateStr ? { text: { content: dateStr } } : null,
                ].filter(Boolean) },
            ] } };

            r.contentImage = { thumbnailViewModel: {
                image: { sources: [{ url: thumbUrl, width: 480, height: 360 }] },
                overlays: v.duration ? [{
                    thumbnailOverlayBadgeViewModel: { thumbnailBadges: [{
                        thumbnailBadgeViewModel: { text: v.duration, badgeStyle: 'THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT' },
                    }] },
                }] : [],
            } };

            r.rendererContext = { commandContext: { onTap: { innertubeCommand: {
                watchEndpoint: { videoId: vid },
                commandMetadata: { webCommandMetadata: { url: '/watch?v=' + vid, webPageType: 'WEB_PAGE_TYPE_WATCH' } },
            } } } };
        }

        // Walk a parsed JSON response and replace every renderer in place.
        function walkAndReplace(node, depth) {
            if (!node || depth > 40 || typeof node !== 'object') return;
            if (Array.isArray(node)) {
                for (let i = 0; i < node.length; i++) walkAndReplace(node[i], depth + 1);
                return;
            }
            for (const k in node) {
                if (RENDERER_KEYS.indexOf(k) !== -1) {
                    const r = node[k];
                    if (r) {
                        if (k === 'lockupViewModel') { if (r.contentId) replaceLockupViewModel(r); }
                        else if (r.videoId) replaceRenderer(r);
                    }
                }
                walkAndReplace(node[k], depth + 1);
            }
        }

        // ---- Watch-page video date re-relativization --------------
        // Data-level: rewrites the big "X ago" date under the player so
        // it reads relative to the sim date. COMMENT filtering is NOT
        // done here — comments lazy-load in continuation responses V3
        // re-renders from caches the interceptor never sees, so the
        // comment filter is a DOM sweep instead (see _commentSweep).

        function _relTextOf(obj) {
            if (!obj) return '';
            if (typeof obj === 'string') return obj;
            if (obj.simpleText) return obj.simpleText;
            if (obj.content) return obj.content;
            if (Array.isArray(obj.runs)) return obj.runs.map(r => (r && r.text) || '').join('');
            return '';
        }

        function _rewriteWatchDate(vpir, setDateStr) {
            const rd = vpir.relativeDateText;
            const raw = _relTextOf(rd);
            if (!raw) return;
            const approx = DateHelper.approxPublishDate(raw);
            if (!approx) return;
            const newText = DateHelper.recalcRelative(raw, setDateStr);
            if (rd.simpleText !== undefined) rd.simpleText = newText;
            else if (Array.isArray(rd.runs)) rd.runs = [{ text: newText }];
        }

        function _walkWatchDate(node, depth, setDateStr) {
            if (!node || depth > 45 || typeof node !== 'object') return;
            if (Array.isArray(node)) {
                for (let i = 0; i < node.length; i++) _walkWatchDate(node[i], depth + 1, setDateStr);
                return;
            }
            if (node.videoPrimaryInfoRenderer) {
                try { _rewriteWatchDate(node.videoPrimaryInfoRenderer, setDateStr); } catch (_) {}
            }
            for (const k in node) _walkWatchDate(node[k], depth + 1, setDateStr);
        }

        function _processCommentsAndDates(json) {
            if (!json || typeof json !== 'object') return;
            // Browse responses carry no watch date and are the biggest payloads.
            if (json.contents && json.contents.twoColumnBrowseResultsRenderer) return;
            let setDateStr = null;
            try { setDateStr = Store.getCurrentDate(); } catch (_) {}
            if (!setDateStr) return;
            if (isNaN(new Date(setDateStr).getTime())) return;
            _walkWatchDate(json, 0, setDateStr);
        }

        // ---- Response patching -------------------------------------
        // Patch Response.prototype.json + .text. Patches that fail to
        // produce a usable response MUST NOT reject — that crashes V3's
        // render. Always return either the modified or original body.

        function waitForPool(timeoutMs) {
            if (active && videoPool.length) return Promise.resolve(true);
            return new Promise(resolve => {
                const start = Date.now();
                const tick = () => {
                    if (active && videoPool.length) return resolve(true);
                    if (Date.now() - start > timeoutMs) return resolve(false);
                    setTimeout(tick, 30);
                };
                tick();
            });
        }
        // (moved to top — see _poolReadyCbs near pool declarations)

        function rewriteJsonText(text) {
            if (!active || !videoPool.length || !text || text.length < 20) return text;
            try {
                const json = JSON.parse(text);
                _replaceCount = 0;
                startResponseScope();
                walkAndReplace(json, 0);
                try { _processCommentsAndDates(json); } catch (_) {}
                return JSON.stringify(json);
            } catch (_) { return text; }
        }

        // Search responses must NOT be touched. The search hijack puts
        // `before:YYYY-MM-DD` in the search URL — YouTube itself does the
        // date filter and returns genuine, query-relevant videos from
        // before the set date. If we walkAndReplace those, the user sees
        // OUR pool videos (random replacements) instead of actual search
        // results matching their query. Skip /search; keep /browse and
        // /next (those are the home feed + watch-page sidebar — both
        // need replacement to keep modern videos off the page).
        function _isChannelPage() {
            return /^\/(channel\/|@|c\/|user\/)/.test(location.pathname);
        }

        function _shouldReplace(url) {
            if (!isInnerTubeUrl(url)) return false;
            if (url.indexOf('/youtubei/v1/search') !== -1) return false;
            if (url.indexOf('/youtubei/v1/player') !== -1) return false;
            if (url.indexOf('/youtubei/v1/notification') !== -1) return false;
            if (url.indexOf('/youtubei/v1/reel') !== -1) return false;
            if (_isChannelPage() && url.indexOf('/youtubei/v1/browse') !== -1) return false;
            return true;
        }

        function install() {
            try {
                const origRespJson = Response.prototype.json;
                const origRespText = Response.prototype.text;

                Response.prototype.json = async function () {
                    const url = this.url || '';
                    if (!_shouldReplace(url)) return origRespJson.call(this);
                    const ready = await waitForPool(8000);
                    if (!ready || !active || !videoPool.length) return origRespJson.call(this);
                    // Read body ONCE. If we throw here, V3's render dies — wrap
                    // the mutation in try/catch and always return the parsed body.
                    const json = await origRespJson.call(this);
                    try {
                        _replaceCount = 0;
                        startResponseScope();
                        walkAndReplace(json, 0);
                        try { _processCommentsAndDates(json); } catch (_) {}
                        // /next responses populate the watch-page sidebar; the
                        // initial paint can race ahead of our walk landing in
                        // the DOM, so kick the sidebar sweep a few times.
                        if (url.indexOf('/youtubei/v1/next') !== -1) {
                            setTimeout(() => { try { _sidebarSweep(); } catch (_) {} }, 100);
                            setTimeout(() => { try { _sidebarSweep(); } catch (_) {} }, 500);
                            setTimeout(() => { try { _sidebarSweep(); } catch (_) {} }, 1500);
                        }
                    } catch (_) {}
                    return json;
                };

                Response.prototype.text = async function () {
                    const url = this.url || '';
                    if (!_shouldReplace(url)) return origRespText.call(this);
                    const ready = await waitForPool(8000);
                    if (!ready || !active || !videoPool.length) return origRespText.call(this);
                    const text = await origRespText.call(this);
                    return rewriteJsonText(text);
                };
            } catch (e) { console.warn('[bygone] Response patch failed', e); }

            // Belt-and-suspenders fetch patch: if anything reads the body via
            // a Response we hand back (rather than .json()/.text() on the
            // network Response), this still rewrites it.
            const origFetch = window.fetch ? window.fetch.bind(window) : null;
            if (origFetch) {
                window.fetch = function (input, init) {
                    const url = typeof input === 'string' ? input : (input && input.url) || '';
                    const p = origFetch(input, init);
                    if (!_shouldReplace(url)) return p;
                    return p.then(response => {
                        if (!response || !response.ok) return response;
                        return waitForPool(8000).then(ready => {
                            if (!ready) return response;
                            return response.clone().text().then(text => {
                                const out = rewriteJsonText(text);
                                if (out === text) return response;
                                return new Response(out, {
                                    status: response.status,
                                    statusText: response.statusText,
                                    headers: new Headers(response.headers),
                                });
                            }).catch(() => response);
                        });
                    });
                };
            }
        }

        // Store the original fetch so YouTubeAPI can use it without our patch
        // re-entering (would create a feedback loop where our own InnerTube
        // calls get their videos populated back into our pool).
        let _origFetch = null;
        try {
            _origFetch = window.fetch ? window.fetch.bind(window) : null;
            install();
        } catch (e) { console.warn('[bygone] install failed', e); }
        hydrateFromLocalStorage();

        // ----------------------------------------------------------
        //  HIDE UN-REPLACED CARDS
        // ----------------------------------------------------------
        // Cards default to `visibility: hidden`. The sweep marks each card
        // with `data-bygone-ok="1"` once it confirms the card shows a pool
        // video, and `_rewriteCard` sets `data-bygone-swept="<id>"` on cards
        // it freshly rewrites. CSS shows only those two attribute states.
        //
        // Effect: until our sweep verifies a card OR our interceptor's
        // _rewriteCard touches it, you see empty space rather than a
        // modern (un-replaced) YouTube video.
        function _injectHideCss() {
            try {
                if (document.getElementById('bygone-hide-css')) return;
                const s = document.createElement('style');
                s.id = 'bygone-hide-css';
                s.textContent = `
                    ytd-rich-item-renderer,
                    ytd-grid-video-renderer,
                    ytd-video-renderer,
                    ytd-compact-video-renderer,
                    yt-lockup-view-model,
                    .yt-lockup-view-model,
                    .lohp-large-shelf-container,
                    .lohp-medium-shelf {
                        visibility: hidden !important;
                    }
                    [data-bygone-ok="1"],
                    [data-bygone-swept] {
                        visibility: visible !important;
                    }
                    [data-bygone-ok="1"] .yt-lockup,
                    [data-bygone-swept] .yt-lockup,
                    [data-bygone-ok="1"] .context-data-item.yt-lockup,
                    [data-bygone-swept] .context-data-item.yt-lockup,
                    [data-bygone-ok="1"] .yt-lockup-content,
                    [data-bygone-swept] .yt-lockup-content,
                    [data-bygone-ok="1"] .yt-lockup-title,
                    [data-bygone-swept] .yt-lockup-title,
                    [data-bygone-ok="1"] .yt-lockup-title a,
                    [data-bygone-swept] .yt-lockup-title a,
                    [data-bygone-ok="1"] .lohp-media-object-content,
                    [data-bygone-swept] .lohp-media-object-content,
                    [data-bygone-ok="1"] .lohp-video-link,
                    [data-bygone-swept] .lohp-video-link {
                        visibility: visible !important;
                    }
                    /* Kill the load-time flash of YouTube's logged-in
                       "What to Watch" feed (multirow shelves + shelf-grid).
                       Gated with :has() on the genuine 2013 LOHP grid so it
                       only fires when the LOHP is present — the same condition
                       as the JS removal in _cleanupHomeSpaArtifacts. display:none
                       (not visibility) so no blank space is reserved while the
                       sweep removes them from the DOM. The :has() wrapper rules
                       collapse the whole feed row; the bare-shelf rules are the
                       fallback if the row wrapper class differs. */
                    html:has(.lohp-newspaper-shelf, .lohp-large-shelf-container) .feed-item-container:has(.multirow-shelf),
                    html:has(.lohp-newspaper-shelf, .lohp-large-shelf-container) .feed-item-container:has(.yt-shelf-grid),
                    html:has(.lohp-newspaper-shelf, .lohp-large-shelf-container) .multirow-shelf,
                    html:has(.lohp-newspaper-shelf, .lohp-large-shelf-container) .yt-shelf-grid {
                        display: none !important;
                    }
                    ytd-watch-flexy #primary,
                    ytd-watch-flexy #primary-inner,
                    ytd-watch-flexy #above-the-fold,
                    ytd-watch-flexy #owner,
                    ytd-watch-flexy #meta,
                    ytd-watch-flexy #info,
                    ytd-watch-flexy #info-contents,
                    ytd-watch-flexy #upload-info,
                    ytd-watch-flexy #menu,
                    ytd-watch-flexy #menu-container,
                    ytd-watch-flexy #top-level-buttons-computed,
                    ytd-watch-flexy ytd-watch-metadata,
                    ytd-watch-flexy ytd-video-owner-renderer,
                    #watch7-main-container,
                    #watch7-content,
                    #watch-header,
                    #watch-description,
                    .watch-main-col,
                    .watch-info,
                    .watch-actions,
                    .watch-action-buttons,
                    .watch-extras-section {
                        visibility: visible !important;
                    }
                    [data-bygone-swept] img.bygone-thumb,
                    [data-bygone-ok] img.bygone-thumb {
                        display: inline-block !important;
                        visibility: visible !important;
                        width: 196px;
                        height: 110px;
                        object-fit: cover;
                        vertical-align: top;
                        margin-right: 8px;
                        margin-bottom: 4px;
                    }
                    [data-bygone-comment-hidden] {
                        display: none !important;
                        height: 0 !important;
                        overflow: hidden !important;
                        margin: 0 !important;
                        padding: 0 !important;
                        border: 0 !important;
                        min-height: 0 !important;
                    }
                    .bygone-meta {
                        display: block !important;
                        visibility: visible !important;
                        color: #aaa;
                        font-size: 11px;
                        margin-top: 2px;
                    }
                    .bygone-meta .yt-user-name {
                        color: #4e7ab5;
                    }
                    .bygone-meta .view-count,
                    .bygone-meta .content-item-time-created {
                        color: #999;
                    }
                `;
                (document.head || document.documentElement).appendChild(s);
            } catch (_) {}
        }
        _injectHideCss();
        // Re-inject if V3 strips our <style> element.
        setInterval(() => { if (!document.getElementById('bygone-hide-css')) _injectHideCss(); }, 3000);

        // ============================================================
        //  SWEEP — for cards V3 paints from cached renderer data
        //  (LOHP featured block, show-more continuations, sidebar fragments
        //   V3 reads from its own in-memory cache rather than the response).
        // ============================================================

        const _poolIdSet = () => _poolIdsSet;

        const _HOME_ROOT_SEL = [
            '.lohp-newspaper-shelf',
            '#c3-content-items',
            '#browse-items-primary',
            '#feed',
            '#feed-list',
            '.feed-list',
            '.channels-browse-content-grid',
            '.expanded-shelf-content-list',
            '.yt-shelf-grid',
            '.yt-rich-grid',
            'ytd-browse[page-subtype="home"] #contents',
            'ytd-rich-grid-renderer #contents'
        ].join(',');

        const _WATCH_CHROME_SEL = [
            'ytd-watch-flexy',
            '#watch7-container',
            '#watch7-main-container',
            '#watch7-content',
            '#watch7-sidebar',
            '#watch7-sidebar-contents',
            '#watch7-sidebar-modules',
            '#watch-header',
            '#watch-description',
            '#watch-discussion',
            '.watch-main-col',
            '.watch-sidebar',
            '.watch-card',
            '#above-the-fold',
            '#owner',
            '#meta'
        ].join(',');

        const _STALE_HOME_CARD_SEL = [
            'ytd-rich-item-renderer',
            'ytd-video-renderer',
            'ytd-compact-video-renderer',
            'yt-lockup-view-model',
            '.yt-lockup-view-model',
            '.video-list-item',
            '.feed-item-container .yt-lockup',
            '.context-data-item.yt-lockup',
            '.yt-shelf-grid-item',
            '.yt-uix-shelfslider-item',
            '.expanded-shelf-content-item',
            '.channels-content-item'
        ].join(',');

        const _HOME_CHANNEL_PROMO_SEL = [
            '.channels-content-item',
            '.yt-lockup-channel',
            '.yt-lockup.yt-lockup-channel',
            '.context-data-item.yt-lockup',
            'yt-lockup-view-model',
            'ytd-channel-renderer'
        ].join(',');

        const _CHANNEL_LINK_SEL = [
            'a[href^="/channel/"]',
            'a[href^="/user/"]',
            'a[href^="/c/"]',
            'a[href^="/@"]'
        ].join(',');

        function _isHomeLikePath() {
            const p = location.pathname;
            return p === '/' || p === '' || p === '/feed/trending';
        }

        function _homeRoots(root) {
            const scope = root || document;
            const out = [];
            const add = el => { if (el && out.indexOf(el) === -1) out.push(el); };
            if (scope.matches && scope.matches(_HOME_ROOT_SEL)) add(scope);
            if (scope.querySelectorAll) scope.querySelectorAll(_HOME_ROOT_SEL).forEach(add);
            return out.filter(el => !(el.closest && el.closest(_WATCH_CHROME_SEL)));
        }

        function _insideAnyRoot(el, roots) {
            for (const root of roots || []) {
                if (root === el || (root.contains && root.contains(el))) return true;
            }
            return false;
        }

        function _insideWatchChrome(el) {
            return !!(el && el.closest && el.closest(_WATCH_CHROME_SEL));
        }

        function _cleanupHomeSpaArtifacts() {
            if (!_isHomeLikePath()) return;

            // LOGGED-IN "WHAT TO WATCH" FEED REMOVAL.
            // For a signed-in user, YouTube/V3 render the modern logged-in home
            // feed (`.multirow-shelf` recommendation shelves + `.yt-shelf-grid`
            // + shelfslider lockups) ON TOP of the genuine 2013 LOHP newspaper
            // grid. Those shelves are not part of the time-machine homepage:
            // `.yt-shelf-grid` is a home root, so the sweep fills each lockup
            // with a pool video and reveals it, and they multiply as YouTube
            // lazy-loads more shelves (1 -> 30 -> ...). When the real LOHP grid
            // is present, strip the competing feed shelves wholesale (header and
            // all) BEFORE the sweep runs, so they are never swept/revealed.
            // Guarded so a container holding any LOHP markup is never removed,
            // and runs every sweep tick to catch lazy-loaded shelves.
            const LOHP_SEL = '.lohp-newspaper-shelf, .lohp-large-shelf-container, ' +
                '.lohp-medium-shelf, .lohp-media-object';
            if (document.querySelector(LOHP_SEL)) {
                const kill = new Set();
                // Climb to the OUTERMOST feed-row wrapper so removal takes the
                // whole row — leaving the empty `li.feed-item-container` (which
                // keeps its margin) behind is what produced blank gaps between
                // the LOHP shelves. Stop at the shared feed list and never climb
                // into a wrapper that holds LOHP markup.
                const WRAP_RE = /feed-item|shelf-wrapper|multirow-shelf|yt-shelf-grid|expander/i;
                document.querySelectorAll('.multirow-shelf, .yt-shelf-grid').forEach(shelf => {
                    if (shelf.querySelector(LOHP_SEL)) return;
                    if (shelf.closest('#masthead, #guide, #guide-container, #wbt-panel')) return;
                    let target = shelf;
                    let p = shelf.parentElement;
                    while (p && p !== document.body) {
                        const cls = (p.className && p.className.toString) ? p.className.toString() : '';
                        if (!WRAP_RE.test(cls)) break;
                        if (p.querySelector(LOHP_SEL)) break;
                        target = p;
                        p = p.parentElement;
                    }
                    kill.add(target);
                });
                kill.forEach(el => {
                    try { el.remove(); } catch (_) {
                        try { el.style.setProperty('display', 'none', 'important'); } catch (__) {}
                    }
                });
            }

            const roots = _homeRoots(document);
            let channelPromoPruned = 0;
            let channelPromoLeft = 0;
            document.querySelectorAll(_WATCH_CHROME_SEL).forEach(el => {
                if (_insideAnyRoot(el, roots)) return;
                try { el.remove(); } catch (_) {
                    try { el.style.setProperty('display', 'none', 'important'); } catch (__) {}
                }
            });
            if (roots.length) {
                document.querySelectorAll(_STALE_HOME_CARD_SEL).forEach(el => {
                    if (_insideAnyRoot(el, roots)) return;
                    if (!el.querySelector || !el.querySelector('a[href*="/watch"]')) return;
                    if (el.closest && el.closest('#masthead, #guide, #guide-container, #wbt-panel')) return;
                    try { el.remove(); } catch (_) {
                        try { el.style.setProperty('display', 'none', 'important'); } catch (__) {}
                    }
                });
                document.querySelectorAll(_HOME_CHANNEL_PROMO_SEL).forEach(el => {
                    if (!_insideAnyRoot(el, roots)) return;
                    if (_insideWatchChrome(el)) return;
                    if (el.closest && el.closest('#masthead, #guide, #guide-container, #wbt-panel')) return;
                    if (el.closest && el.closest('[data-bygone-ok], [data-bygone-swept], [data-bygone-keep]')) return;
                    if (!el.querySelector || el.querySelector('a[href*="/watch"]')) return;
                    if (!el.querySelector(_CHANNEL_LINK_SEL)) return;

                    // Home-page channel recommendation/promo modules (for example
                    // brand intro cards) are not video cards, so they cannot be
                    // rewritten into pool videos. Remove them instead of revealing
                    // one native modern recommendation.
                    try { el.remove(); channelPromoPruned++; } catch (_) {
                        try { el.style.setProperty('display', 'none', 'important'); channelPromoPruned++; } catch (__) {}
                    }
                });
                document.querySelectorAll(_HOME_CHANNEL_PROMO_SEL).forEach(el => {
                    if (!_insideAnyRoot(el, roots)) return;
                    if (_insideWatchChrome(el)) return;
                    if (el.closest && el.closest('#masthead, #guide, #guide-container, #wbt-panel')) return;
                    if (el.closest && el.closest('[data-bygone-ok], [data-bygone-swept], [data-bygone-keep]')) return;
                    if (!el.querySelector || el.querySelector('a[href*="/watch"]')) return;
                    if (!el.querySelector(_CHANNEL_LINK_SEL)) return;
                    const cs = getComputedStyle(el);
                    if (cs.display !== 'none' && cs.visibility !== 'hidden') channelPromoLeft++;
                });
            }
            _lastHomeChannelPromoPruned = channelPromoPruned;
            _lastHomeChannelPromoLeft = channelPromoLeft;
        }

        let _homeSpaFixTimer = null;
        function _burstHomeSpaFix() {
            if (_homeSpaFixTimer) { clearInterval(_homeSpaFixTimer); _homeSpaFixTimer = null; }
            let elapsed = 0;
            const run = () => {
                try { _cleanupHomeSpaArtifacts(); } catch (_) {}
                try { _sweep(); } catch (_) {}
            };
            run();
            _homeSpaFixTimer = setInterval(() => {
                run();
                elapsed += 150;
                if (elapsed >= 4500) {
                    clearInterval(_homeSpaFixTimer);
                    _homeSpaFixTimer = null;
                }
            }, 150);
        }

        function _nextDomVideoFor(origId) {
            // dedupInResponse: false — the sweep uses a LOCAL `seenOnPage`
            // Set (built fresh from the live DOM each sweep) for within-
            // pass dedup. avoidDisplayedOnFreshPick uses our displayed
            // cache so the FIRST mapping for any origId picks something
            // not already on screen. Future ticks for the same origId hit
            // the stable path and never touch displayed state.
            return mapVideo(origId, {
                dedupInResponse: false,
                avoidDisplayedOnFreshPick: true,
            });
        }

        function _freshDomVideoAvoiding(avoidIds) {
            return _findVideo(v => v && v.id && !avoidIds.has(v.id));
        }

        function _findCards(root) {
            // Outer shelf containers AND inner cards are BOTH kept (no
            // innermost-only filter). V3's featured-top shelf has the
            // BIG promoted card's img/title/link directly inside
            // `.lohp-large-shelf-container` with no per-card wrapper —
            // dropping the outer would leave the big card unmatched and
            // its assets get partially rewritten by stray sub-element
            // matches (title from one video, thumb from another, click
            // from a third). The sweep handles the nesting by sorting
            // INNERMOST FIRST and scoping each card's rewrite to its
            // OWN subtree (excluding descendants that are themselves
            // matched cards) — so the big card's own assets get one
            // video and the 3 sidekick cards each get their own.
            const sels = [
                'ytd-rich-item-renderer',
                'ytd-video-renderer',
                'ytd-grid-video-renderer',
                'ytd-compact-video-renderer',
                'yt-lockup-view-model',
                '.lohp-large-shelf-container',
                '.lohp-medium-shelf',
                '.lohp-media-object',
                '.video-list-item',
                '.feed-item-container .yt-lockup',
                '.yt-shelf-grid-item',
                '.yt-uix-shelfslider-item',
                '.expanded-shelf-content-item',
                '.channels-content-item',
            ];
            const set = new Set();
            for (const s of sels) root.querySelectorAll(s).forEach(el => set.add(el));
            // Card must have at least one /watch link to be a video card.
            let arr = Array.from(set).filter(c => c.querySelector('a[href*="/watch"]'));
            if (_isHomeLikePath()) {
                const roots = _homeRoots(root);
                if (!roots.length) return [];
                arr = arr.filter(c => _insideAnyRoot(c, roots) && !_insideWatchChrome(c));
            }

            // THUMB-ONLY INNER MERGE. V3's big featured card has its
            // thumbnail wrapped in its own `.lohp-media-object` (re-used
            // per-card class) separate from the title/click area. That
            // inner wrapper has NO body text — just the thumb img.
            // Normal sidekick cards have title + channel + views + date
            // text (20+ chars). Dropping ONLY thumb-only inners lets the
            // outer's scoped rewrite reach the big card's thumb without
            // disturbing normal shelves where every inner is a real
            // self-contained card.
            arr = arr.filter(c => {
                const cText = (c.textContent || '').trim();
                if (cText.length >= 20) return true;
                for (const outer of arr) {
                    if (outer === c || !outer.contains(c)) continue;
                    return false;
                }
                return true;
            });

            // INNERMOST FIRST: descendant cards sort before their ancestors.
            // The sweep marks each card it processes with `data-bygone-swept`,
            // and uses scoped-link/scoped-rewrite helpers so outer cards
            // only touch elements not inside any inner card.
            arr.sort((a, b) => a.contains(b) ? 1 : b.contains(a) ? -1 : 0);
            return arr;
        }

        // Inner cards (matched cards that this card contains). Used by the
        // sweep to scope an outer card's operations to its OWN subtree.
        function _innerCardsOf(card, allCards) {
            if (!allCards) return [];
            const out = [];
            for (const c of allCards) if (c !== card && card.contains(c)) out.push(c);
            return out;
        }

        // Owned: not inside any inner card.
        function _ownedBy(el, innerCards) {
            for (const inner of innerCards) if (inner.contains(el)) return false;
            return true;
        }

        function _primaryWatchLink(card, innerCards) {
            const inner = innerCards || [];
            const links = Array.from(card.querySelectorAll('a[href*="/watch"]'))
                .filter(a => _ownedBy(a, inner));
            for (const a of links) if (a.querySelector('img')) return a;
            return links[0] || null;
        }

        // Debug logger. AUTO-logs a BYGONE-DIAG line every 3s for ~90s after
        // load (so the transient "blank for ~30s then fills in" is captured
        // without any console call — avoids the Tampermonkey sandbox boundary).
        // Also exposes __bygoneDiag() / __bygoneDiag('watch') on the page window
        // for manual checks. Reports matched cards HIDDEN (blank holes) vs
        // visible, how many hidden ones still carry a valid /watch id, and the
        // live pool/usage — distinguishing pool/mapping exhaustion (HIDDEN high,
        // POOL < cards) from a thumbnail-only problem (visNoImg high).
        const _diagT0 = Date.now();
        function _bygoneDiagRun() {
            try {
                const cards = _findCards(document);
                const idOf = function (el) {
                    const inner = _innerCardsOf(el, cards);
                    const a = _primaryWatchLink(el, inner) || el.querySelector('a[href*="/watch?v="]');
                    const m = a && (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]{11})/);
                    return m ? m[1] : null;
                };
                let vis = 0, hid = 0, hidId = 0, noImg = 0;
                const allIds = new Set(), visIds = new Set(), hiddenIds = [];
                const visCounts = {};
                const samp = [];
                for (const c of cards) {
                    const id = idOf(c);
                    if (id) allIds.add(id);
                    if (getComputedStyle(c).visibility === 'hidden') {
                        hid++;
                        if (id) { hidId++; hiddenIds.push(id); }
                        if (samp.length < 6) samp.push((c.className || '').toString().trim().slice(0, 36));
                    } else {
                        vis++;
                        if (id) visIds.add(id);
                        if (id) visCounts[id] = (visCounts[id] || 0) + 1;
                        const hasImg = Array.from(c.querySelectorAll('img')).some(function (im) {
                            return (im.getAttribute('src') || '').length > 10 && im.getBoundingClientRect().width > 10;
                        });
                        if (!hasImg) noImg++;
                    }
                }
                let dupHidden = 0;
                let visDupes = 0, visDupeGroups = 0;
                for (const count of Object.values(visCounts)) {
                    if (count > 1) {
                        visDupeGroups++;
                        visDupes += count - 1;
                    }
                }
                const distinctHidden = new Set();
                for (const id of hiddenIds) { distinctHidden.add(id); if (visIds.has(id)) dupHidden++; }
                const msg = 'BYGONE-DIAG t=' + Math.round((Date.now() - _diagT0) / 1000) + 's' +
                    ' cards=' + cards.length + ' vis=' + vis + ' HIDDEN=' + hid +
                    ' hiddenWithId=' + hidId + ' visNoImg=' + noImg +
                    ' visDupes=' + visDupes + ' visDupeGroups=' + visDupeGroups +
                    ' chanPromoPruned=' + _lastHomeChannelPromoPruned +
                    ' chanPromoLeft=' + _lastHomeChannelPromoLeft +
                    ' POOL=' + videoPool.length + ' used=' + _usedReplacements.size +
                    ' idMap=' + _idMap.size + ' active=' + active;
                const msg2 = '  distinctAll=' + allIds.size + ' distinctVisible=' + visIds.size +
                    ' distinctHidden=' + distinctHidden.size + ' dupHidden=' + dupHidden +
                    ' (hidden cards whose id is ALSO on a visible card)';
                console.log(msg);
                console.log(msg2);
                console.log('  sweepBranches: ' + JSON.stringify(_sweepStats));
                if (samp.length) console.log('  hidden: ' + samp.join(' / '));
                return msg;
            } catch (e) { console.log('BYGONE-DIAG err ' + e.message); return 'err: ' + e.message; }
        }
        function _bygoneDiag(mode) {
            if (mode === 'watch') {
                let n = 0;
                const id = setInterval(function () { _bygoneDiagRun(); if (++n >= 20) clearInterval(id); }, 3000);
                return 'BYGONE-DIAG watching for 60s…';
            }
            return _bygoneDiagRun();
        }
        try { window.__bygoneDiag = _bygoneDiag; } catch (_) {}
        try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) unsafeWindow.__bygoneDiag = _bygoneDiag; } catch (_) {}
        try {
            let _dn = 0;
            const _dt = setInterval(function () { _bygoneDiagRun(); if (++_dn >= 30) clearInterval(_dt); }, 3000);
        } catch (_) {}

        // ---- Flight recorder (debug) ---------------------------------
        // Read-only timeline of how the page settles, so a specific bad refresh
        // can be replayed from its log. Captures V3 render bursts (a logging-
        // ONLY MutationObserver — never writes, so no feedback loop), per-second
        // state (visible / reverted-by-V3 / overlapping-thumbnail artifacts /
        // pool), and the last sweep's branch counts. Dump with __bygoneLog();
        // auto-prints once at ~25s.
        const _rec = [];
        const _recT0b = Date.now();
        function _recAt() { return String(Date.now() - _recT0b).padStart(5, '0') + 'ms'; }
        function _recPush(ev) { if (_rec.length < 600) _rec.push(_recAt() + ' ' + ev); }
        function _recReverts() {
            let reverted = 0, held = 0;
            const all = document.querySelectorAll('[data-bygone-swept]');
            for (const c of all) {
                const want = c.getAttribute('data-bygone-swept');
                const a = c.querySelector('a[href*="/watch?v="]');
                const m = a && (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]{11})/);
                if (m && m[1] === want) held++; else reverted++;
            }
            return { reverted: reverted, held: held };
        }
        function _recOverlaps() {
            let bad = 0, scanned = 0;
            const all = document.querySelectorAll('[data-bygone-ok],[data-bygone-swept]');
            for (const c of all) {
                if (scanned++ > 200) break;
                const imgs = Array.from(c.querySelectorAll('img')).filter(function (im) {
                    const r = im.getBoundingClientRect();
                    return r.width > 40 && r.height > 30 && getComputedStyle(im).visibility !== 'hidden';
                });
                let hit = false;
                for (let i = 0; i < imgs.length && !hit; i++) {
                    for (let j = i + 1; j < imgs.length; j++) {
                        const a = imgs[i].getBoundingClientRect(), b = imgs[j].getBoundingClientRect();
                        const ox = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
                        const oy = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
                        if (ox * oy > 0.5 * Math.min(a.width * a.height, b.width * b.height)) { hit = true; break; }
                    }
                }
                if (hit) bad++;
            }
            return bad;
        }
        try {
            let _recN = 0;
            const _recTick = setInterval(function () {
                try {
                    const rv = _recReverts();
                    const ov = _recOverlaps();
                    const s = _sweepStats || {};
                    _recPush('vis=' + document.querySelectorAll('[data-bygone-ok],[data-bygone-swept]').length +
                        ' held=' + rv.held + ' revertedByV3=' + rv.reverted + ' overlapCards=' + ov +
                        ' chanPromoLeft=' + _lastHomeChannelPromoLeft +
                        ' pool=' + videoPool.length +
                        ' sweep{t:' + (s.total || 0) + ',rw:' + (s.rewritten || 0) + ',dup:' + (s.dupSkip || 0) +
                        ',reuse:' + (s.dupReuse || 0) + ',fix:' + (s.dupFixed || 0) + ',wrap:' + (s.wrapperReveal || 0) +
                        ',nul:' + (s.mapNull || 0) + ',noL:' + (s.noOwnedLink || 0) + ',thr:' + (s.rewriteThrew || 0) + '}');
                } catch (e) { _recPush('tick-err ' + e.message); }
                if (++_recN >= 30) clearInterval(_recTick);
            }, 1000);
        } catch (_) {}
        try {
            let _mut = 0, _mutCards = 0, _mutFlush = null;
            const _mo = new MutationObserver(function (muts) {
                for (const mu of muts) {
                    _mut += mu.addedNodes.length;
                    mu.addedNodes.forEach(function (n) {
                        if (n.nodeType === 1 && n.matches && n.matches('.context-data-item,.yt-lockup,.lohp-media-object,.lohp-medium-shelf,.yt-shelf-grid-item,ytd-rich-item-renderer')) _mutCards++;
                    });
                }
                if (!_mutFlush) _mutFlush = setTimeout(function () {
                    _recPush('V3-render burst +' + _mut + ' nodes (~' + _mutCards + ' card-ish)');
                    _mut = 0; _mutCards = 0; _mutFlush = null;
                }, 250);
            });
            _mo.observe(document.documentElement, { childList: true, subtree: true });
            setTimeout(function () { try { _mo.disconnect(); } catch (_) {} }, 30000);
        } catch (_) {}
        function _bygoneLogDump() {
            const out = 'BYGONE-LOG (' + _rec.length + ' events)\n' + _rec.join('\n');
            console.log(out);
            return out;
        }
        try { window.__bygoneLog = _bygoneLogDump; } catch (_) {}
        try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) unsafeWindow.__bygoneLog = _bygoneLogDump; } catch (_) {}
        try { setTimeout(function () { _bygoneLogDump(); }, 25000); } catch (_) {}

        // Read the "X ago" relative-date text shown on a DOM card.
        function _cardRelText(card) {
            const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, null);
            let node;
            while ((node = walker.nextNode())) {
                const m = (node.nodeValue || '').match(_REL_RE);
                if (m) return m[0];
            }
            return '';
        }

        // True when a DOM card shows a video uploaded at/before the set
        // date — i.e. a naturally-old YouTube recommendation worth keeping.
        function _cardIsNaturallyOld(card) {
            return _relTextAtOrBeforeSet(_cardRelText(card));
        }

        function _cardVideoId(card, innerCards) {
            const a = _primaryWatchLink(card, innerCards);
            if (!a) return '';
            const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
            return m ? m[1] : '';
        }

        // Re-relativize a kept card's "X ago" date to the set date so the
        // feed stays date-consistent. Called ONCE when the card is first
        // marked `data-bygone-keep` — after that the displayed date reads
        // as "modern", so the sweep must skip-guard kept cards (otherwise
        // they'd be re-evaluated as new and erroneously replaced).
        // The actual one-shot guard is `data-bygone-redated`; if a kept
        // card had no date text yet, later sweeps may try again.
        function _redateKeptCard(card, vid) {
            let setDate;
            try { setDate = Store.getCurrentDate(); } catch { return false; }
            if (!setDate || !card || !card.getAttribute) return false;
            if (card.getAttribute('data-bygone-redated') === '1') return true;
            const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, null);
            let node;
            while ((node = walker.nextNode())) {
                const v = node.nodeValue || '';
                const m = v.match(_REL_RE);
                if (m) {
                    if (!_relTextAtOrBeforeSet(m[0])) {
                        card.setAttribute('data-bygone-redated', '1');
                        return true;
                    }
                    const nd = DateHelper.recalcForFeed(m[0], setDate, vid);
                    if (nd && nd !== m[0]) node.nodeValue = v.replace(m[0], nd);
                    card.setAttribute('data-bygone-redated', '1');
                    return true;
                }
            }
            return false;
        }

        // Idempotent channel-name update. Hard guard against writing into
        // title elements (the v189 mistake where titles became channel
        // names). Writes only when value differs — calling on a correct
        // card produces zero mutations, so it cannot cause a sweep loop.
        function _setCardChannel(card, video, innerCards) {
            if (!card || !video || !video.channel) return;
            const want = video.channel;
            const href = video.channelId ? '/channel/' + video.channelId : null;
            const inner = innerCards || [];
            const owns = (el) => _ownedBy(el, inner);
            const ownedFirst = (sel) => {
                for (const e of card.querySelectorAll(sel)) if (owns(e)) return e;
                return null;
            };

            // 1. Real channel anchors. Skip those wrapping an <img> (avatar).
            let hitAnchor = false;
            card.querySelectorAll(
                'a[href^="/channel/"], a[href^="/user/"], a[href^="/c/"], a[href^="/@"]'
            ).forEach(link => {
                if (!owns(link)) return;
                if (link.querySelector('img')) return;
                if ((link.textContent || '').trim() !== want) link.textContent = want;
                if (href && link.getAttribute('href') !== href) link.setAttribute('href', href);
                hitAnchor = true;
            });
            if (hitAnchor) return;

            // 2. V3's 2013 sidebar/card markup: `.attribution > .g-hovercard`.
            //    The video title is a SEPARATE `.title` element.
            let el = ownedFirst('.attribution .g-hovercard')
                || ownedFirst('.attribution .yt-user-name')
                || ownedFirst('.attribution');
            if (!el) {
                el = ownedFirst(
                    '.chan-name, .yt-user-name, .video-user-name, ' +
                    '#channel-name, .ytd-channel-name'
                );
            }
            if (!el) return;
            // Hard guard: never write into a title element.
            if (el.matches && el.matches('[class*="title"], #video-title, h3, h3 *')) return;

            const cur = el.textContent || '';
            const byMatch = cur.match(/^(\s*by\s+)/i);
            const desired = (byMatch ? byMatch[1] : '') + want;
            if (cur.trim() !== desired.trim()) el.textContent = desired;
        }

        function _rewriteCard(card, video, innerCards) {
            const vid = video.id;
            const watchUrl = '/watch?v=' + vid;
            const thumbUrl = video.thumbnail || ('https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg');
            const inner = innerCards || [];
            const owns = (el) => _ownedBy(el, inner);
            // querySelectorAll filtered to this card's OWN subtree.
            const ownedQS = (sel) =>
                Array.from(card.querySelectorAll(sel)).filter(owns);

            // Rewrite all /watch hrefs in this card's own subtree.
            ownedQS('a').forEach(a => {
                const href = a.getAttribute('href') || '';
                if (href.includes('/watch')) a.setAttribute('href', watchUrl);
            });

            // Primary thumbnail. V3 sidebar cards have multiple imgs (avatar,
            // badges) — target the one INSIDE the primary watch link first.
            const primaryLink = _primaryWatchLink(card, inner);
            const targetImgs = new Set();
            if (primaryLink) primaryLink.querySelectorAll('img').forEach(im => {
                if (owns(im)) targetImgs.add(im);
            });
            ownedQS('img').forEach(im => {
                const cls = (im.className || '') + ' ' + (im.parentElement && im.parentElement.className || '');
                if (/thumb|preview|video/i.test(cls)) targetImgs.add(im);
            });
            if (!targetImgs.size) {
                const fi = ownedQS('img')[0];
                if (fi) targetImgs.add(fi);
            }
            if (!targetImgs.size) {
                const thumbImg = document.createElement('img');
                thumbImg.src = thumbUrl;
                thumbImg.alt = video.title || '';
                thumbImg.className = 'bygone-thumb';
                const link = ownedQS('a[href*="/watch"]')[0];
                if (link) link.insertBefore(thumbImg, link.firstChild);
                else card.insertBefore(thumbImg, card.firstChild);
            } else {
                targetImgs.forEach(img => {
                    img.setAttribute('src', thumbUrl);
                    img.removeAttribute('srcset');
                    if (img.hasAttribute('data-src')) img.setAttribute('data-src', thumbUrl);
                    if (img.hasAttribute('data-thumb')) img.setAttribute('data-thumb', thumbUrl);
                    img.alt = video.title || '';
                    img.style.visibility = 'visible';
                    if (img.style.display === 'none') img.style.display = '';
                });
            }
            ownedQS('source').forEach(s => {
                if (s.hasAttribute('srcset')) s.setAttribute('srcset', thumbUrl);
                if (s.hasAttribute('src')) s.setAttribute('src', thumbUrl);
            });
            ownedQS('[style*="ytimg"], [style*="background-image"]').forEach(el => {
                try { el.style.backgroundImage = 'url(' + thumbUrl + ')'; } catch (_) {}
            });
            ownedQS('[data-thumb], [data-thumbnail], [data-thumb-url]').forEach(el => {
                if (el.hasAttribute('data-thumb')) el.setAttribute('data-thumb', thumbUrl);
                if (el.hasAttribute('data-thumbnail')) el.setAttribute('data-thumbnail', thumbUrl);
                if (el.hasAttribute('data-thumb-url')) el.setAttribute('data-thumb-url', thumbUrl);
            });

            // Title: only use existing StarTube/YouTube title nodes. Do not
            // manufacture title markup; that breaks V3's layouts.
            const titleSels = [
                '.yt-lockup-title a[href*="/watch"]',
                'h3.yt-lockup-title a[href*="/watch"]',
                'a.yt-uix-tile-link[href*="/watch"]',
                'a.yt-uix-sessionlink[href*="/watch"]',
                '.lohp-video-link[href*="/watch"]',
                '#video-title-link',
                'a#video-title',
                'span#video-title',
                '#video-title',
                'a[href*="/watch"] .yt-ui-ellipsis-wrapper',
                'a[href*="/watch"] .title',
                'a.related-video span.title',
                '.related-video .title',
                'span.title',
                '.title',
            ];
            let titleEl = null;
            for (const sel of titleSels) {
                const candidates = ownedQS(sel).filter(e => {
                    if (!e || (e.querySelector && e.querySelector('img'))) return false;
                    if (e.closest && e.closest('.yt-lockup-meta, .lohp-video-metadata, .attribution')) return false;
                    const a = e.matches && e.matches('a') ? e : (e.closest && e.closest('a'));
                    return !a || ((a.getAttribute('href') || '').indexOf('/watch') !== -1);
                });
                titleEl = candidates.find(e => (e.textContent || '').trim()) || candidates[0] || null;
                if (titleEl) break;
            }
            if (titleEl && video.title) {
                titleEl.textContent = video.title;
                if (titleEl.hasAttribute('title')) titleEl.setAttribute('title', video.title);
                const titleAnchor = titleEl.matches && titleEl.matches('a') ? titleEl : (titleEl.closest && titleEl.closest('a'));
                if (titleAnchor) {
                    titleAnchor.setAttribute('href', watchUrl);
                    titleAnchor.setAttribute('title', video.title);
                }
            }

            _setCardChannel(card, video, inner);

            // Views + date via text-node regex (works across V3/modern markup).
            // Skip text nodes that live inside an inner matched card — those
            // belong to that card, not this one.
            const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, {
                acceptNode: (node) => owns(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
            });
            let node, viewsNode = null, dateNode = null;
            while ((node = walker.nextNode())) {
                const v = node.nodeValue || '';
                if (!viewsNode && /\b\d[\d,.]*\s*[KkMmBb]?\s*views?\b/.test(v)) viewsNode = node;
                if (!dateNode && /\b\d+\s*(year|month|week|day|hour|minute|second)s?\s+ago\b/i.test(v)) dateNode = node;
            }
            if (viewsNode && (video.viewCountFormatted || video.viewCount)) {
                viewsNode.nodeValue = video.viewCountFormatted || (video.viewCount + ' views');
            }
            if (dateNode) {
                let dateStr = video.relativeDate || '';
                try {
                    const setDate = Store.getCurrentDate();
                    if (setDate && dateStr) dateStr = DateHelper.recalcForFeed(dateStr, setDate, vid) || dateStr;
                } catch (_) {}
                if (dateStr) dateNode.nodeValue = dateStr;
            }

            const durEl = ownedQS('.video-time, .ytd-thumbnail-overlay-time-status-renderer, .badge-shape-wiz__text')[0];
            if (durEl) durEl.textContent = video.duration || '';

            card.setAttribute('data-bygone-swept', vid);
        }

        // Re-relativize the "X ago" date text node on a card that already
        // shows a pool video. Used after exact dates arrive so the displayed
        // date corrects itself in place (no rebuild / reshuffle). Idempotent:
        // recalcForFeed is deterministic, so it only writes when the value
        // actually changes.
        function _refreshCardDate(card, video, inner) {
            if (!card || !video || !video.id) return;
            const setDate = Store.getCurrentDate();
            if (!setDate) return;
            const owns = (el) => _ownedBy(el, inner || []);
            const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, {
                acceptNode: (node) => owns(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
            });
            let node;
            while ((node = walker.nextNode())) {
                const v = node.nodeValue || '';
                if (/\b\d+\s*(year|month|week|day|hour|minute|second)s?\s+ago\b/i.test(v)) {
                    const base = video.relativeDate || v;
                    let nd;
                    try { nd = DateHelper.recalcForFeed(base, setDate, video.id); } catch (_) { return; }
                    if (nd && nd !== v) node.nodeValue = nd;
                    return;
                }
            }
        }

        // Walk every on-screen card and re-relativize its date. Called when a
        // batch of exact dates has just been cached.
        function refreshAllDates() {
            try {
                if (!Store.getCurrentDate()) return;
                const cards = _findCards(document);
                for (const card of cards) {
                    const inner = _innerCardsOf(card, cards);
                    const vid = _cardVideoId(card, inner);
                    if (!vid) continue;
                    const video = videoPool.find(v => v.id === vid) || { id: vid };
                    _refreshCardDate(card, video, inner);
                }
            } catch (_) {}
        }

        function _imgHasAnySource(img) {
            if (!img) return false;
            if ((img.currentSrc || '').length > 10) return true;
            const attrs = ['src', 'srcset', 'data-src', 'data-thumb', 'data-thumbnail', 'data-thumb-url'];
            for (const attr of attrs) {
                if ((img.getAttribute(attr) || '').length > 10) return true;
            }
            return false;
        }

        function _cleanupBygoneThumbs(card, inner) {
            const owns = (el) => _ownedBy(el, inner || []);
            const thumbs = Array.from(card.querySelectorAll('img.bygone-thumb')).filter(owns);
            if (!thumbs.length) return false;
            const nativeImgs = Array.from(card.querySelectorAll('img'))
                .filter(img => owns(img) && !img.classList.contains('bygone-thumb'));
            if (!nativeImgs.some(_imgHasAnySource)) return false;
            for (const thumb of thumbs) {
                try { thumb.remove(); } catch (_) {}
            }
            return true;
        }

        function _hasNativeThumbScaffold(card, inner) {
            const owns = (el) => _ownedBy(el, inner || []);
            const primary = _primaryWatchLink(card, inner || []);
            const imgRoot = primary || card;
            const imgs = Array.from(imgRoot.querySelectorAll('img'))
                .filter(img => owns(img) && !img.classList.contains('bygone-thumb'));
            if (imgs.length) return true;
            const scaffolds = Array.from(card.querySelectorAll(
                'picture, source, [data-thumb], [data-thumbnail], [data-thumb-url], ' +
                '[style*="ytimg"], [style*="background-image"], .yt-thumb, .video-thumb, ' +
                '.thumb, .thumbnail, ytd-thumbnail'
            )).filter(owns);
            return scaffolds.length > 0;
        }

        function _fillExistingThumbImg(card, videoId, inner) {
            const owns = (el) => _ownedBy(el, inner || []);
            const primary = _primaryWatchLink(card, inner || []);
            const imgRoot = primary || card;
            const imgs = Array.from(imgRoot.querySelectorAll('img'))
                .filter(img => owns(img) && !img.classList.contains('bygone-thumb'));
            if (!imgs.length) return false;
            if (imgs.some(_imgHasAnySource)) return true;
            const target = imgs.find(img => {
                const cls = ((img.className || '') + ' ' +
                    ((img.parentElement && img.parentElement.className) || '')).toString();
                return /thumb|preview|video/i.test(cls);
            }) || imgs[0];
            const thumbUrl = 'https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg';
            try {
                target.setAttribute('src', thumbUrl);
                target.removeAttribute('srcset');
                if (target.hasAttribute('data-src')) target.setAttribute('data-src', thumbUrl);
                if (target.hasAttribute('data-thumb')) target.setAttribute('data-thumb', thumbUrl);
                if (target.hasAttribute('data-thumbnail')) target.setAttribute('data-thumbnail', thumbUrl);
                if (target.hasAttribute('data-thumb-url')) target.setAttribute('data-thumb-url', thumbUrl);
                target.style.visibility = 'visible';
                if (target.style.display === 'none') target.style.display = '';
                return true;
            } catch (_) {
                return false;
            }
        }

        function _ensureThumbOnCard(card, videoId, inner) {
            if (_cleanupBygoneThumbs(card, inner)) return;
            const owns = (el) => _ownedBy(el, inner || []);
            if (Array.from(card.querySelectorAll('img.bygone-thumb')).some(owns)) return;
            if (_fillExistingThumbImg(card, videoId, inner)) return;
            if (_hasNativeThumbScaffold(card, inner)) return;
            const thumbUrl = 'https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg';
            const thumbImg = document.createElement('img');
            thumbImg.src = thumbUrl;
            thumbImg.className = 'bygone-thumb';
            const link = Array.from(card.querySelectorAll('a[href*="/watch"]')).filter(owns)[0];
            if (link) link.insertBefore(thumbImg, link.firstChild);
            else card.insertBefore(thumbImg, card.firstChild);
        }

        function _ensureMetadata(card, videoId, inner) {
            if (card.getAttribute('data-bygone-meta')) return;
            const owns = (el) => _ownedBy(el, inner || []);
            const contentArea = Array.from(card.querySelectorAll(
                '.lohp-media-object-content, .yt-lockup-content'
            )).filter(owns)[0];
            if (!contentArea) return;
            const hasChannel = contentArea.querySelector('.yt-user-name, a[href*="/channel/"]');
            let hasViews = false;
            for (const s of contentArea.querySelectorAll('span, li')) {
                if (/\d[\d,.]*\s*(views?|[KkMmBb]\s*views?)/i.test(s.textContent || '')) { hasViews = true; break; }
            }
            if (hasChannel && hasViews) { card.setAttribute('data-bygone-meta', '1'); return; }
            const video = videoPool.find(v => v.id === videoId);
            if (!video) return;
            if (!hasChannel && video.channel) {
                const d = document.createElement('div');
                d.className = 'lohp-video-metadata bygone-meta';
                const chanHref = video.channelId ? '/channel/' + video.channelId : '#';
                d.innerHTML = '<span class="run run-text ">by </span>' +
                    '<a class="yt-user-name" href="' + chanHref + '">' +
                    video.channel.replace(/</g, '&lt;') + '</a>';
                contentArea.appendChild(d);
            }
            const viewsText = video.viewCountFormatted || ((video.viewCount || '0') + ' views');
            if (!hasViews) {
                const d = document.createElement('div');
                d.className = 'lohp-video-metadata bygone-meta';
                let dateStr = video.relativeDate || '';
                try {
                    const setDate = Store.getCurrentDate();
                    if (setDate && dateStr) dateStr = DateHelper.recalcForFeed(dateStr, setDate, videoId) || dateStr;
                } catch (_) {}
                d.innerHTML = '<span><span class="view-count">' + viewsText.replace(/</g, '&lt;') +
                    '</span>' + (dateStr ? ' <span class="content-item-time-created">' +
                    dateStr.replace(/</g, '&lt;') + '</span>' : '') + '</span>';
                contentArea.appendChild(d);
            }
            card.setAttribute('data-bygone-meta', '1');
        }

        // ---- Grid sweep --------------------------------------------
        // RULE: only rewrite a card that currently shows a non-pool video.
        // A card already showing one of our videos is revealed and never
        // re-rolled. If V3 creates more slots than the pool can uniquely
        // populate in this render wave, repeated era videos are acceptable;
        // blank slots and rotation are not.

        function _hasRevealedInnerCard(innerCards) {
            for (const inner of innerCards || []) {
                if (!inner || !inner.getAttribute) continue;
                if (inner.getAttribute('data-bygone-ok') === '1') return true;
                if (inner.getAttribute('data-bygone-swept')) return true;
                if (inner.getAttribute('data-bygone-keep')) return true;
            }
            return false;
        }

        function _tryRewriteDuplicatePoolCard(card, currentVid, inner, seenOnPage, keptPoolThisSweep, st) {
            if (!currentVid || !keptPoolThisSweep.has(currentVid)) return false;
            const alt = _freshDomVideoAvoiding(seenOnPage);
            if (!alt) {
                if (st) st.dupSkip++;
                return false;
            }
            try {
                _rewriteCard(card, alt, inner);
                seenOnPage.add(alt.id);
                keptPoolThisSweep.add(alt.id);
                if (st) {
                    st.dupReuse++;
                    st.dupFixed++;
                    st.rewritten++;
                }
                return true;
            } catch (_) {
                if (st) st.rewriteThrew++;
                return false;
            }
        }

        function _sweep() {
            if (!active || !videoPool.length) return;
            // Search-results page: real query results from YouTube (already
            // date-bounded via `before:` in the URL). Sweeping would replace
            // those genuine matches with pool videos. Leave them alone.
            const _p = location.pathname;
            if (_p === '/results' || _p === '/results/' || _isChannelPage()) return;
            if (_isHomeLikePath()) _cleanupHomeSpaArtifacts();
            startResponseScope();                         // refresh _displayedIdsCache
            const poolIds = _poolIdSet();
            const cards = _findCards(document);
            const st = {
                total: cards.length, alreadySwept: 0, keep: 0, noOwnedLink: 0,
                badHref: 0, isPool: 0, natural: 0, mapNull: 0, dupSkip: 0,
                dupReuse: 0, dupFixed: 0, wrapperReveal: 0, rewritten: 0, rewriteThrew: 0
            };
            _sweepStats = st;
            if (!cards.length) return;
            let swept = 0;
            const poolById = new Map();
            for (const v of videoPool) if (v && v.id) poolById.set(v.id, v);

            // First pass: every pool video already visible on the page.
            // The next-pass fresh picks will avoid duplicating these.
            const seenOnPage = new Set();
            for (const card of cards) {
                const inner = _innerCardsOf(card, cards);
                const a = _primaryWatchLink(card, inner);
                if (!a) continue;
                const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (m && poolIds.has(m[1])) seenOnPage.add(m[1]);
            }
            const keptPoolThisSweep = new Set();

            for (const card of cards) {
                const inner = _innerCardsOf(card, cards);
                if (card.getAttribute && card.getAttribute('data-bygone-swept')) {
                    const sweptVid = card.getAttribute('data-bygone-swept');
                    const visibleVid = _cardVideoId(card, inner) || sweptVid;
                    if (poolIds.has(visibleVid) && _tryRewriteDuplicatePoolCard(card, visibleVid, inner, seenOnPage, keptPoolThisSweep, st)) {
                        continue;
                    }
                    if (poolIds.has(visibleVid)) {
                        seenOnPage.add(visibleVid);
                        keptPoolThisSweep.add(visibleVid);
                    }
                    const sweptVideo = poolById.get(sweptVid) || poolById.get(visibleVid);
                    if (sweptVideo) {
                        try { _setCardChannel(card, sweptVideo, inner); } catch (_) {}
                        try { _ensureMetadata(card, sweptVideo.id, inner); } catch (_) {}
                    }
                    _cleanupBygoneThumbs(card, inner);
                    st.alreadySwept++;
                    continue;
                }
                // Kept card from an earlier sweep — its date was already
                // redated, so re-evaluating it would read as "modern".
                // Skip it; just keep it revealed.
                if (card.getAttribute && card.getAttribute('data-bygone-keep')) {
                    const keepVid = _cardVideoId(card, inner);
                    if (keepVid) _redateKeptCard(card, keepVid);
                    card.setAttribute('data-bygone-ok', '1');
                    st.keep++;
                    continue;
                }
                const a = _primaryWatchLink(card, inner);
                if (!a) {
                    st.noOwnedLink++;
                    if (_hasRevealedInnerCard(inner)) {
                        card.setAttribute('data-bygone-ok', '1');
                        st.wrapperReveal++;
                    }
                    continue;
                }
                const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (!m) { st.badHref++; continue; }
                const currentVid = m[1];

                // ANTI-ROTATION (v206 rule, do not change): NEVER touch a
                // card already showing one of our videos — not even to
                // dedup. Rewriting an already-replaced card means fighting
                // V3's re-render, and that tug-of-war is the once-per-
                // second rotation. Visible duplicates are accepted; the
                // rotation is not.
                if (poolIds.has(currentVid)) {
                    if (_tryRewriteDuplicatePoolCard(card, currentVid, inner, seenOnPage, keptPoolThisSweep, st)) continue;
                    seenOnPage.add(currentVid);
                    keptPoolThisSweep.add(currentVid);
                    card.setAttribute('data-bygone-ok', '1');
                    const currentVideo = poolById.get(currentVid);
                    if (currentVideo) {
                        try { _setCardChannel(card, currentVideo, inner); } catch (_) {}
                    }
                    _ensureThumbOnCard(card, currentVid, inner);
                    _ensureMetadata(card, currentVid, inner);
                    st.isPool++;
                    continue;
                }

                // Naturally-old recommendation — kept by the interceptor at
                // the JSON level OR detected here from the DOM date text.
                // Mark `keep` so the click hijack navigates to the REAL video.
                if (_keptNaturalIds.has(currentVid) || _cardIsNaturallyOld(card)) {
                    _keptNaturalIds.add(currentVid);
                    _redateKeptCard(card, currentVid);
                    card.setAttribute('data-bygone-ok', '1');
                    card.setAttribute('data-bygone-keep', '1');
                    _ensureThumbOnCard(card, currentVid, inner);
                    _ensureMetadata(card, currentVid, inner);
                    st.natural++;
                    continue;
                }

                let next = _nextDomVideoFor(currentVid);
                if (!next) { st.mapNull++; continue; }
                if (seenOnPage.has(next.id)) {
                    const alt = _freshDomVideoAvoiding(seenOnPage);
                    if (alt) next = alt;
                    st.dupReuse++;
                }
                seenOnPage.add(next.id);
                keptPoolThisSweep.add(next.id);
                try { _rewriteCard(card, next, inner); swept++; st.rewritten++; } catch (_) { st.rewriteThrew++; }
            }
        }

        // ---- Sidebar sweep -----------------------------------------
        // CRITICAL: disconnect the MutationObserver during our own DOM
        // writes. Otherwise our writes refire the observer → schedule
        // another sweep → write again → tight feedback loop.

        let _sidebarObs = null;
        let _sidebarObsTarget = null;

        function _sidebarSweep() {
            let resumeTarget = null;
            if (_sidebarObs && _sidebarObsTarget) {
                try { _sidebarObs.disconnect(); resumeTarget = _sidebarObsTarget; } catch (_) {}
            }
            try { _sidebarSweepCore(); }
            finally {
                if (_sidebarObs && resumeTarget) {
                    try { _sidebarObs.observe(resumeTarget, { childList: true, subtree: true }); } catch (_) {}
                }
            }
        }

        function _sidebarSweepCore() {
            if (!active || !videoPool.length) return;
            if (!location.pathname.startsWith('/watch')) return;
            startResponseScope();
            const poolIds = _poolIdSet();
            const poolById = new Map();
            for (const v of videoPool) poolById.set(v.id, v);

            const rewriteIfNeeded = (card) => {
                if (!card || (card.getAttribute && card.getAttribute('data-bygone-swept'))) return;
                // Kept card from an earlier sweep — already redated; skip.
                if (card.getAttribute && card.getAttribute('data-bygone-keep')) {
                    const keepVid = _cardVideoId(card);
                    if (keepVid) _redateKeptCard(card, keepVid);
                    card.setAttribute('data-bygone-ok', '1');
                    return;
                }
                const a = _primaryWatchLink(card);
                if (!a) return;
                const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (!m) return;
                const cur = m[1];
                // ANTI-ROTATION (v206 rule): card already shows one of our
                // videos → only fix a stale channel name (idempotent) and
                // skip. Re-mapping "duplicates" here is what produces the
                // every-tick rotation, because V3 immediately re-renders
                // the card back to its cached state and we re-replace it.
                if (poolIds.has(cur)) {
                    card.setAttribute('data-bygone-ok', '1');
                    const vobj = poolById.get(cur);
                    if (vobj) { try { _setCardChannel(card, vobj); } catch (_) {} }
                    return;
                }
                if (_keptNaturalIds.has(cur) || _cardIsNaturallyOld(card)) {
                    _keptNaturalIds.add(cur);
                    _redateKeptCard(card, cur);
                    card.setAttribute('data-bygone-ok', '1');
                    card.setAttribute('data-bygone-keep', '1');
                    return;
                }
                const next = _nextDomVideoFor(cur);
                if (!next) return;
                try { _rewriteCard(card, next); } catch (_) {}
            };

            // V3 exact path: every <li> in the sidebar <ol> is a card.
            document.querySelectorAll(
                '#watch7-sidebar-contents ol > li, #watch7-sidebar ol > li, ' +
                '#watch7-sidebar-modules li.video-list-item, .watch-sidebar-body li'
            ).forEach(rewriteIfNeeded);

            // Named containers only (the old [id*="sidebar"] catch-all scanned
            // the whole document on every sweep — huge CPU sink).
            const containers = [
                document.getElementById('watch7-sidebar-contents'),
                document.getElementById('watch7-sidebar'),
                document.getElementById('watch7-sidebar-modules'),
                document.getElementById('related'),
                document.getElementById('secondary'),
                document.getElementById('secondary-inner'),
            ].filter(Boolean);
            const seenCards = new Set();
            for (const c of containers) {
                const classicMatches = c.querySelectorAll(
                    '.video-list-item, .related-list-item, ytd-compact-video-renderer, ' +
                    'yt-lockup-view-model, yt-collection-thumbnail-view-model, ' +
                    '.ytd-watch-next-secondary-results-renderer'
                );
                const cards = new Set(Array.from(classicMatches));
                c.querySelectorAll('a[href*="/watch?v="]').forEach(a => {
                    let p = a;
                    for (let i = 0; i < 8 && p && p !== c; i++) {
                        if (p.querySelector && p.querySelector('img')) { cards.add(p); break; }
                        p = p.parentElement;
                    }
                });
                cards.forEach(card => {
                    if (seenCards.has(card)) return;
                    seenCards.add(card);
                    rewriteIfNeeded(card);
                });
            }
        }

        // ---- Sidebar infinite scroll -------------------------------
        // V3's Up Next sidebar is a fixed list from the initial /next
        // response — it doesn't natively load more. We extend it by
        // cloning an existing <li> (inherits V3's exact markup + CSS)
        // and rewriting it with a fresh pool video whenever the user
        // scrolls near the bottom.

        let _lastSidebarExtend = 0;
        function _maybeExtendSidebar() {
            if (!active || !videoPool.length) return;
            if (!location.pathname.startsWith('/watch')) return;
            // Find the sidebar <ol> (V3) or any container with sidebar list items.
            const ol = document.querySelector(
                '#watch7-sidebar-contents ol, #watch7-sidebar ol, ' +
                '#watch7-sidebar-modules ol, .watch-sidebar-body ol'
            );
            if (!ol) return;
            const items = ol.querySelectorAll('li');
            if (!items.length) return;
            // Are we near the bottom of the sidebar list?
            const olBottom = ol.getBoundingClientRect().bottom;
            if (olBottom - window.innerHeight > 800) return;
            // Throttle: at most one extend every 500ms.
            if (Date.now() - _lastSidebarExtend < 500) return;
            _lastSidebarExtend = Date.now();

            // Collect videoIds already shown so we don't duplicate.
            const shown = new Set();
            items.forEach(li => {
                const a = _primaryWatchLink(li);
                if (!a) return;
                const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (m) shown.add(m[1]);
            });

            // Pick N unused pool videos.
            const N = 10;
            const picks = [];
            for (const v of videoPool) {
                if (shown.has(v.id)) continue;
                picks.push(v);
                if (picks.length >= N) break;
            }
            if (!picks.length) {
                // Pool dry — trigger lazy fetch and try again next scroll.
                _maybeFetchMore();
                return;
            }

            // Clone the FIRST existing <li> as a template (inherits V3's
            // exact markup + CSS) and rewrite each clone with a fresh video.
            const template = items[0];
            for (const v of picks) {
                try {
                    const clone = template.cloneNode(true);
                    clone.removeAttribute('data-bygone-swept');
                    clone.removeAttribute('data-bygone-ok');
                    _rewriteCard(clone, v);
                    clone.setAttribute('data-bygone-ok', '1');
                    ol.appendChild(clone);
                } catch (_) {}
            }
        }

        // Hook scroll on multiple potential containers — V3's scroll
        // container varies by layout. window scroll is the catch-all.
        window.addEventListener('scroll', _maybeExtendSidebar, { passive: true });
        document.addEventListener('scroll', _maybeExtendSidebar, { passive: true, capture: true });

        // ---- Home page infinite scroll --------------------------------
        // V3's LOHP continuation fires once then dies. We inject more
        // pool videos into the grid when the user scrolls near bottom.

        let _lastHomeExtend = 0;
        let _homeExtendCount = 0;
        let _homeExtendPausedUntil = 0;
        const _MAX_HOME_EXTENDS = 20;
        const _HOME_FEATURE_SEL = '.lohp-large-shelf-container, .lohp-medium-shelf';
        const _HOME_EXTEND_CONTAINER_SEL = [
            '#c3-content-items',
            '#browse-items-primary',
            '#feed',
            '#feed-list',
            '.feed-list',
            '.channels-browse-content-grid',
            '.expanded-shelf-content-list',
            '.yt-shelf-grid',
            '.yt-rich-grid',
            'ytd-rich-grid-renderer #contents'
        ].join(',');

        function _isHomeFeature(el) {
            return !!(el && el.matches && el.matches(_HOME_FEATURE_SEL));
        }

        function _isInsideHomeFeature(el) {
            return !!(el && el.closest && el.closest(_HOME_FEATURE_SEL));
        }

        function _containsHomeFeature(el) {
            return !!(el && el.querySelector && el.querySelector(_HOME_FEATURE_SEL));
        }

        function _countDirectVideoChildren(container) {
            if (!container || !container.children) return 0;
            let n = 0;
            for (const child of container.children) {
                if (_isHomeFeature(child) || _isInsideHomeFeature(child) || _containsHomeFeature(child)) continue;
                if (child.querySelector && child.querySelector('a[href*="/watch"]')) n++;
            }
            return n;
        }

        function _watchLinkCount(el) {
            if (!el || !el.querySelectorAll) return 0;
            const ids = new Set();
            el.querySelectorAll('a[href*="/watch"]').forEach(a => {
                const h = a.getAttribute('href') || '';
                const m = h.match(/[?&]v=([A-Za-z0-9_-]+)/);
                ids.add(m ? m[1] : h);
            });
            return ids.size;
        }

        function _watchAnchorCount(el) {
            return el && el.querySelectorAll ? el.querySelectorAll('a[href*="/watch"]').length : 0;
        }

        function _isSingleVideoTemplate(el) {
            if (!el || _isHomeFeature(el) || _isInsideHomeFeature(el) || _containsHomeFeature(el)) return false;
            return _watchLinkCount(el) === 1 && _watchAnchorCount(el) <= 3;
        }

        function _templateFromHomeContainer(container, cards) {
            if (!container || _isHomeFeature(container) || _isInsideHomeFeature(container)) return null;
            const direct = [];
            for (const child of Array.from(container.children || [])) {
                if (_isSingleVideoTemplate(child)) direct.push(child);
            }
            if (direct.length) return direct[direct.length - 1];

            for (let i = cards.length - 1; i >= 0; i--) {
                let node = cards[i];
                if (!_isSingleVideoTemplate(node)) continue;
                while (node && node.parentElement && node.parentElement !== container) node = node.parentElement;
                if (node && node.parentElement === container && _isSingleVideoTemplate(node)) {
                    return node;
                }
            }
            return null;
        }

        function _findHomeExtendTarget(cards) {
            const sidebarAncestorSel = [
                '#watch7-sidebar-contents',
                '#watch7-sidebar',
                '#watch7-sidebar-modules',
                '#related',
                '#secondary',
                '#secondary-inner'
            ].join(',');
            const badAncestorSel = [
                _HOME_FEATURE_SEL,
                sidebarAncestorSel
            ].join(',');

            const knownContainers = [
                '#c3-content-items',
                '#browse-items-primary',
                '#feed',
                '#feed-list',
                '.feed-list',
                '.channels-browse-content-grid',
                '.expanded-shelf-content-list',
                '.yt-shelf-grid',
                '.yt-rich-grid',
                'ytd-rich-grid-renderer #contents'
            ];
            for (const sel of knownContainers) {
                for (const container of document.querySelectorAll(sel)) {
                    if (!container || (container.closest && container.closest(sidebarAncestorSel))) continue;
                    const template = _templateFromHomeContainer(container, cards);
                    if (template) return { container, template };
                }
            }

            for (let i = cards.length - 1; i >= 0; i--) {
                const template = cards[i];
                if (!template || !template.parentElement) continue;
                if (_isHomeFeature(template) || _isInsideHomeFeature(template) || _containsHomeFeature(template)) continue;
                if (template.closest && template.closest(badAncestorSel)) continue;

                let container = template.parentElement;
                for (let depth = 0; depth < 5 && container; depth++, container = container.parentElement) {
                    if (_isHomeFeature(container) || _isInsideHomeFeature(container) || _containsHomeFeature(container)) break;
                    if (container.closest && container.closest('#watch7-sidebar-contents,#watch7-sidebar,#watch7-sidebar-modules,#related,#secondary,#secondary-inner')) break;

                    const tag = (container.tagName || '').toLowerCase();
                    const cls = (container.className || '').toString();
                    const directVideoChildren = _countDirectVideoChildren(container);
                    const listLike = tag === 'ol' || tag === 'ul' || /items|grid|list|feed|shelf|browse|content/i.test(cls);

                    // The top LOHP feature area has a big card + side cards but
                    // is not a repeatable feed list. Requiring repeated direct
                    // card children keeps appended batches out of that gap.
                    if (listLike && directVideoChildren >= 2) {
                        return { container, template };
                    }
                }
            }
            return null;
        }

        function _maybeExtendHome() {
            if (!active || !videoPool.length) return;
            const path = location.pathname;
            if (path !== '/' && path !== '' && path !== '/feed/trending') return;
            if (_homeExtendCount >= _MAX_HOME_EXTENDS) return;
            if (Date.now() - _lastHomeExtend < 600) return;

            const docBottom = document.documentElement.scrollHeight;
            if (docBottom - window.scrollY - window.innerHeight > 1200) return;
            _lastHomeExtend = Date.now();

            const cards = _findCards(document);
            if (!cards.length) return;

            const shown = new Set();
            for (const c of cards) {
                const a = _primaryWatchLink(c);
                if (!a) continue;
                const m = (a.getAttribute('href') || '').match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (m) shown.add(m[1]);
            }

            const N = 12;
            const picks = [];
            for (const v of videoPool) {
                if (shown.has(v.id)) continue;
                picks.push(v);
                if (picks.length >= N) break;
            }
            if (!picks.length) { _maybeFetchMore(); return; }

            const target = _findHomeExtendTarget(cards);
            if (!target) return;
            const { container, template } = target;

            for (const v of picks) {
                try {
                    const clone = template.cloneNode(true);
                    clone.removeAttribute('data-bygone-swept');
                    clone.removeAttribute('data-bygone-ok');
                    clone.removeAttribute('data-bygone-keep');
                    clone.removeAttribute('data-bygone-redated');
                    _rewriteCard(clone, v);
                    clone.setAttribute('data-bygone-ok', '1');
                    clone.setAttribute('data-bygone-home-extend', '1');
                    container.appendChild(clone);
                    shown.add(v.id);
                } catch (_) {}
            }
            _homeExtendCount++;
            _maybeFetchMore();
        }

        // Reset extend count on navigation (new page = fresh feed).
        function _resetHomeExtend() { _homeExtendCount = 0; }

        window.addEventListener('scroll', _maybeExtendHome, { passive: true });
        document.addEventListener('scroll', _maybeExtendHome, { passive: true, capture: true });
        window.addEventListener('yt-navigate-finish', _resetHomeExtend);
        window.addEventListener('popstate', _resetHomeExtend);

        // ---- Comment filter (DOM sweep) ----------------------------
        // Comments lazy-load in batches and V3 re-renders them from its
        // own caches, so a data-level filter can't reliably catch them.
        // Instead we sweep the rendered comment DOM on a heartbeat:
        //   - Hide any comment thread dated AFTER the cutoff
        //     (set date + 2 years).
        //   - Re-relativize surviving comment timestamps to the set date.
        //   - Auto-drain the V3 "show/load more comments" continuation,
        //     sweeping each loaded batch before it has a chance to linger.
        const _C_DATE_RE = /(?:Streamed\s+)?(\d+)\s+(year|month|week|day|hour|minute|second)s?\s+ago/i;
        // V3's 2013 layout can render an ABSOLUTE date ("May 15, 2013")
        // instead of a relative "X years ago" string. Detect both.
        const _C_ABS_DATE_RE = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}/i;
        let _cDiag = { found: 0, hidden: 0, kept: 0, notime: 0 };

        function _collectCommentThreads() {
            const threads = [];
            const seen = new Set();
            const add = (node) => {
                if (!node || seen.has(node)) return;
                seen.add(node);
                threads.push(node);
            };
            document.querySelectorAll('ytd-comment-thread-renderer').forEach(add);
            document.querySelectorAll('ytd-comment-view-model').forEach(t => {
                if (!t.closest('ytd-comment-thread-renderer')) add(t);
            });
            document.querySelectorAll('.comment-thread-renderer, .comment-item').forEach(add);
            // V3 2013 layout: comments are div.comment[data-id].
            document.querySelectorAll('div.comment[data-id]').forEach(add);
            return threads;
        }

        function _commentRemovalTarget(thread) {
            if (!thread || !thread.parentElement) return thread;
            const root = _commentRoot();
            let best = thread;
            for (let el = thread; el && el !== document.body && el !== root; el = el.parentElement) {
                const cls = (el.className || '').toString();
                const id = el.id || '';
                const tag = (el.tagName || '').toLowerCase();
                const sig = cls + ' ' + id;
                const isSlot = tag === 'li' ||
                    /^(ytd-comment-thread-renderer|ytd-comment-view-model)$/i.test(tag) ||
                    /(^|\s)(post|comment|comment-renderer|comment-thread|comment-item|comment-entry|comment-container|comment-renderer-root)(\s|$)/i.test(cls);

                if (isSlot) {
                    best = el;
                }
                if (el.parentElement === root) {
                    if (isSlot) best = el;
                    break;
                }
                if (el.parentElement && /comments?|discussion|responses?|threads?|items?|list/i.test((el.parentElement.className || '') + ' ' + (el.parentElement.id || ''))) {
                    best = el;
                    break;
                }
                if (/comments?|discussion|responses?|threads?/i.test(sig) && !/(^|\s)(post|comment|comment-renderer|comment-thread|comment-item|comment-entry|comment-container)(\s|$)/i.test(cls)) break;
            }
            return best || thread;
        }

        function _collapseEmptyCommentAncestors(start, root) {
            let el = start;
            let guard = 0;
            while (el && el !== document.body && el !== root && guard++ < 8) {
                const next = el.parentElement;
                const sig = ((el.className || '') + ' ' + (el.id || '')).toString();
                const tag = (el.tagName || '').toLowerCase();
                const isSlot = /^(ytd-comment-thread-renderer|ytd-comment-view-model)$/i.test(tag) ||
                    /(^|\s)(post|comment|comment-renderer|comment-thread|comment-item|comment-entry|comment-container|comment-renderer-root)(\s|$)/i.test(sig);
                const isListItem = tag === 'li';
                if (!isSlot && !isListItem) break;
                const hasRemainingComment = !!(el.querySelector && el.querySelector(
                    'div.comment[data-id], ytd-comment-thread-renderer, ytd-comment-view-model, ' +
                    '.comment-thread-renderer, .comment-item'
                ));
                const visibleText = (el.textContent || '').replace(/\s+/g, '').trim();
                if (!hasRemainingComment && visibleText.length < 8) {
                    try { el.remove(); } catch (_) {
                        try {
                            el.setAttribute('data-bygone-comment-hidden', '1');
                            el.style.setProperty('display', 'none', 'important');
                            el.style.setProperty('height', '0', 'important');
                            el.style.setProperty('min-height', '0', 'important');
                            el.style.setProperty('margin', '0', 'important');
                            el.style.setProperty('padding', '0', 'important');
                        } catch (__) {}
                    }
                } else {
                    break;
                }
                el = next;
            }
        }

        function _removeCommentSlot(thread) {
            const target = _commentRemovalTarget(thread);
            const root = _commentRoot();
            const parentAfterRemove = target && target.parentElement;
            const sibsToRemove = [];
            let sib = target && target.nextElementSibling, guard = 0;
            while (sib && guard++ < 8) {
                if (sib.matches && sib.matches('.post, div.comment[data-id], ytd-comment-thread-renderer, .comment-thread-renderer, .comment-thread, .comment-item')) break;
                const cls = (sib.className || '').toString();
                const txt = (sib.textContent || '').toLowerCase();
                if (/comment-repl|reply|replies|repl/i.test(cls) || /reply|replies|repl/i.test(txt)) sibsToRemove.push(sib);
                sib = sib.nextElementSibling;
            }
            for (const s of sibsToRemove) {
                try { s.remove(); } catch (_) {}
            }
            if (target) {
                try {
                    target.remove();
                    _collapseEmptyCommentAncestors(parentAfterRemove, root);
                    return;
                } catch (_) {}
                try {
                    target.setAttribute('data-bygone-comment-hidden', '1');
                    target.style.setProperty('display', 'none', 'important');
                    target.style.setProperty('height', '0', 'important');
                    target.style.setProperty('min-height', '0', 'important');
                    target.style.setProperty('margin', '0', 'important');
                    target.style.setProperty('padding', '0', 'important');
                    _collapseEmptyCommentAncestors(parentAfterRemove, root);
                } catch (_) {}
            }
        }

        function _commentSweep() {
            if (!active) return;
            if (!location.pathname.startsWith('/watch')) return;
            let setDateStr;
            try { setDateStr = Store.getCurrentDate(); } catch { return; }
            if (!setDateStr) return;
            const setDate = new Date(setDateStr);
            if (isNaN(setDate.getTime())) return;
            const cutoff = new Date(setDate);
            cutoff.setFullYear(cutoff.getFullYear() + 2);

            // Collect comment threads — prefer the OUTER thread renderer
            // so we don't double-process the inner view-model.
            const threads = _collectCommentThreads();

            for (const thread of threads) {
                if (thread.getAttribute('data-bygone-cchecked')) continue;
                _cDiag.found++;
                // Find the timestamp element. Try explicit selectors first,
                // then fall back to scanning for any element whose text is a
                // date (relative "X ago" OR absolute "May 15, 2013").
                let timeEl = thread.querySelector(
                    '#published-time-text a, #published-time-text yt-formatted-string, ' +
                    '#published-time-text yt-core-attributed-string, ' +
                    '.published-time-text a, .published-time-text yt-core-attributed-string, ' +
                    '.metadata a.detail_link:not(.detail_link_full), ' +
                    '.metadata .time, .comment .time, .time a, .time, ' +
                    '.comment-time, .comment-date, a.comment-author-time, time'
                );
                if (!timeEl) {
                    for (const el of thread.querySelectorAll(
                        'a, span, div, time, yt-core-attributed-string, yt-formatted-string'
                    )) {
                        const t = (el.textContent || '').trim();
                        if (!t || t.length > 40) continue;     // skip prose
                        if (_C_DATE_RE.test(t) || _C_ABS_DATE_RE.test(t)) { timeEl = el; break; }
                    }
                }
                if (!timeEl) { _cDiag.notime++; continue; }   // not rendered yet — retry next tick
                const raw = (timeEl.textContent || '').trim();
                // Resolve an approximate publish date from either format.
                let approx = null;
                const m = raw.match(_C_DATE_RE);
                if (m) {
                    approx = DateHelper.approxPublishDate(m[0]);
                } else {
                    const am = raw.match(_C_ABS_DATE_RE);
                    if (am) { const d = new Date(am[0]); if (!isNaN(d.getTime())) approx = d; }
                }
                if (!approx || isNaN(approx.getTime())) continue;
                // Mark processed only once we actually have a date.
                thread.setAttribute('data-bygone-cchecked', '1');
                if (approx.getTime() > cutoff.getTime()) {
                    _cDiag.hidden++;
                    _removeCommentSlot(thread);
                } else {
                    _cDiag.kept++;
                    // Survivor — re-relativize its timestamp to the set date.
                    const edited = /\(edited\)/i.test(raw) ? ' (edited)' : '';
                    const newText = DateHelper.relativeToDate(approx, setDate) + edited;
                    try { timeEl.textContent = newText; } catch (_) {}
                }
            }
            // One-shot diagnostic per render burst: report what the sweep did.
            if (_cDiag.found && (_cDiag.hidden || _cDiag.kept || _cDiag.notime) &&
                !_commentSweep._lastReport) {
                _commentSweep._lastReport = true;
                console.log('[bygone] comments swept:',
                    'threads=' + _cDiag.found, 'hidden=' + _cDiag.hidden,
                    'kept=' + _cDiag.kept, 'no-timestamp=' + _cDiag.notime,
                    'cutoff=' + cutoff.toISOString().slice(0, 10));
                if (_cDiag.notime && !_cDiag.hidden && !_cDiag.kept && threads[0]) {
                    console.log('[bygone] NO timestamps matched. Sample thread:',
                        threads[0].outerHTML.slice(0, 400));
                }
                setTimeout(() => { _commentSweep._lastReport = false; }, 4000);
            }
        }

        const _COMMENT_DRAIN_MAX_CLICKS = 400;
        const _COMMENT_DRAIN_MIN_CLICK_MS = 70;
        const _COMMENT_DRAIN_WAIT_SAME_COUNT_MS = 180;
        const _COMMENT_DRAIN_STALL_TICKS = 70;
        let _commentDrain = {
            key: '',
            clicks: 0,
            done: false,
            capped: false,
            stall: 0,
            lastCount: 0,
            lastClickAt: 0,
            lastClickCount: -1,
            lastLogAt: 0,
        };

        function _commentPageKey() {
            const m = location.search.match(/[?&]v=([A-Za-z0-9_-]+)/);
            return location.pathname + '|' + (m ? m[1] : location.search);
        }

        function _resetCommentDrain() {
            _cDiag = { found: 0, hidden: 0, kept: 0, notime: 0 };
            _commentSweep._lastReport = false;
            _commentDrain = {
                key: _commentPageKey(),
                clicks: 0,
                done: false,
                capped: false,
                stall: 0,
                lastCount: 0,
                lastClickAt: 0,
                lastClickCount: -1,
                lastLogAt: 0,
            };
        }

        function _commentRoot() {
            return document.querySelector(
                'ytd-comments, #comments, #watch-discussion, #watch-discussion-section, ' +
                '#watch7-discussion, .comment-section, .comments-section, .comment-list, .comments'
            );
        }

        function _commentButtonText(el) {
            if (!el) return '';
            return [
                el.getAttribute && el.getAttribute('aria-label'),
                el.getAttribute && el.getAttribute('title'),
                el.value,
                el.textContent,
            ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
        }

        function _isUsableCommentButton(el) {
            if (!el || !el.getAttribute) return false;
            if (el.disabled || el.getAttribute('aria-disabled') === 'true') return false;
            const text = _commentButtonText(el).toLowerCase();
            const cls = (el.className || '').toString().toLowerCase();
            if (!text && !/(load-more|continuation|paginator)/i.test(cls)) return false;
            if (/reply|replies|repl|transcript|description|playlist|share|sort|newest|top comments/.test(text + ' ' + cls)) return false;
            if (el.closest && el.closest(
                'div.comment[data-id], ytd-comment-thread-renderer, ytd-comment-view-model, ' +
                '.comment-thread-renderer, .comment-item'
            )) return false;
            if (!/(more comments|load more|show more|more|continuation|paginator|load-more)/.test(text + ' ' + cls)) return false;
            try {
                const cs = getComputedStyle(el);
                if (cs.display === 'none' || cs.visibility === 'hidden' || cs.pointerEvents === 'none') return false;
            } catch (_) {}
            if (/loading|working|please wait/.test(text + ' ' + cls)) return false;
            return true;
        }

        function _findCommentLoadButton() {
            const root = _commentRoot();
            if (!root) return null;
            const selectors = [
                'button',
                'a',
                '[role="button"]',
                'input[type="button"]',
                'input[type="submit"]',
                '.yt-uix-load-more',
                '.load-more-button',
                '.load-more',
                '.comment-section-renderer-paginator',
                '.comments-pagination',
                '.comment-pager',
                '[class*="continuation"]',
                '[class*="paginator"]',
            ].join(',');
            const candidates = root.querySelectorAll(selectors);
            for (const el of candidates) {
                if (_isUsableCommentButton(el)) return el;
            }
            return null;
        }

        function _clickCommentLoadButton(btn) {
            try {
                btn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
                btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
                btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
                btn.click();
                return true;
            } catch (_) {
                try { btn.click(); return true; } catch (_) {}
            }
            return false;
        }

        function _commentDrainTick() {
            if (!active || !location.pathname.startsWith('/watch')) return;
            const key = _commentPageKey();
            if (_commentDrain.key !== key) _resetCommentDrain();
            if (_commentDrain.capped) return;
            if (_commentDrain.done) {
                const now = Date.now();
                if (now - _commentDrain.lastLogAt < 3000) return;
                _commentDrain.lastLogAt = now;
                if (!_findCommentLoadButton()) return;
                _commentDrain.done = false;
                _commentDrain.stall = 0;
            }

            try { _commentSweep(); } catch (_) {}

            const count = _collectCommentThreads().length;
            const btn = _findCommentLoadButton();
            if (!btn) {
                if (count !== _commentDrain.lastCount) {
                    _commentDrain.lastCount = count;
                    _commentDrain.stall = 0;
                } else if (_commentDrain.clicks) {
                    _commentDrain.stall++;
                }
                if (_commentDrain.clicks && _commentDrain.stall >= _COMMENT_DRAIN_STALL_TICKS) {
                    _commentDrain.done = true;
                    console.log('[bygone] comment drain done:',
                        'clicks=' + _commentDrain.clicks,
                        'threads=' + count,
                        'hidden=' + _cDiag.hidden,
                        'kept=' + _cDiag.kept,
                        'no-timestamp=' + _cDiag.notime);
                }
                return;
            }

            if (_commentDrain.clicks >= _COMMENT_DRAIN_MAX_CLICKS) {
                _commentDrain.done = true;
                _commentDrain.capped = true;
                console.log('[bygone] comment drain stopped at cap:',
                    'clicks=' + _commentDrain.clicks,
                    'threads=' + count);
                return;
            }

            const now = Date.now();
            if (now - _commentDrain.lastClickAt < _COMMENT_DRAIN_MIN_CLICK_MS) return;
            if (count === _commentDrain.lastClickCount &&
                now - _commentDrain.lastClickAt < _COMMENT_DRAIN_WAIT_SAME_COUNT_MS) return;

            if (_clickCommentLoadButton(btn)) {
                _commentDrain.clicks++;
                _commentDrain.lastClickAt = now;
                _commentDrain.lastClickCount = count;
                _commentDrain.lastCount = count;
                _commentDrain.stall = 0;
                [25, 80, 180].forEach(ms => setTimeout(() => { try { _commentSweep(); } catch (_) {} }, ms));
                if (_commentDrain.clicks === 1 || now - _commentDrain.lastLogAt > 3000) {
                    _commentDrain.lastLogAt = now;
                    console.log('[bygone] comment drain:',
                        'clicks=' + _commentDrain.clicks,
                        'threads=' + count,
                        'button="' + _commentButtonText(btn).slice(0, 60) + '"');
                }
            }
        }

        let _commentObs = null;
        let _commentObsRoot = null;
        let _commentSweepQueued = false;
        function _queueCommentSweep() {
            if (_commentSweepQueued) return;
            _commentSweepQueued = true;
            setTimeout(() => {
                _commentSweepQueued = false;
                try { _commentSweep(); _commentDrainTick(); } catch (_) {}
            }, 25);
        }

        function _installCommentObserver() {
            if (!location.pathname.startsWith('/watch')) return;
            const root = _commentRoot();
            if (!root) { setTimeout(_installCommentObserver, 500); return; }
            if (_commentObs && _commentObsRoot === root) return;
            if (_commentObs) { try { _commentObs.disconnect(); } catch (_) {} }
            _commentObsRoot = root;
            _commentObs = new MutationObserver(_queueCommentSweep);
            try { _commentObs.observe(root, { childList: true, subtree: true }); } catch (_) {}
            _queueCommentSweep();
        }

        // ---- Heartbeats + nav burst --------------------------------

        // Steady heartbeat. A persistent MutationObserver on the home grid
        // was tried (v310) but it fought V3's own re-render of the feed —
        // every fix we made triggered a V3 re-render which the observer
        // caught and re-fixed, an unbounded loop. Sweeping is therefore
        // POLL-based only: a steady heartbeat plus a time-BOUNDED burst
        // after load / nav (see _burstGridSweep). Bounded = cannot loop.
        setInterval(_sweep, 800);
        setInterval(_sidebarSweep, 1000);
        // Comment sweep — frequent so newly lazy-loaded / V3-re-rendered
        // comment batches get filtered before the user reads them.
        setInterval(_commentSweep, 300);
        setInterval(_commentDrainTick, 80);
        if (document.readyState !== 'loading') _installCommentObserver();
        else document.addEventListener('DOMContentLoaded', _installCommentObserver);
        window.addEventListener('yt-navigate-finish', (e) => {
            if (e.detail && e.detail._bygonePoke) return;
            _resetCommentDrain();
            if (location.pathname.startsWith('/watch')) {
                setTimeout(_installCommentObserver, 200);
            } else if (_commentObs) {
                try { _commentObs.disconnect(); } catch (_) {}
                _commentObs = null;
                _commentObsRoot = null;
            }
            [300, 800, 1600, 3000].forEach(ms => setTimeout(() => {
                try { _commentSweep(); _commentDrainTick(); } catch (_) {}
            }, ms));
        });
        // Heartbeat extender as a safety net when scroll events miss.
        setInterval(() => { try { _maybeExtendSidebar(); } catch (_) {} }, 1500);
        // Time-bounded grid burst: sweep every 120 ms for the first ~4 s
        // after load / nav, then stop. This catches V3's load-time feed
        // re-paints fast (so the "3-4 cycles" settle quickly and barely
        // register) WITHOUT a persistent observer that could loop forever.
        // Self-terminating + single-flight: it always ends.
        let _gridBurstTimer = null;
        function _burstGridSweep() {
            if (_gridBurstTimer) { clearInterval(_gridBurstTimer); _gridBurstTimer = null; }
            let elapsed = 0;
            try { _sweep(); } catch (_) {}
            _gridBurstTimer = setInterval(() => {
                try { _sweep(); } catch (_) {}
                elapsed += 120;
                if (elapsed >= 4000) { clearInterval(_gridBurstTimer); _gridBurstTimer = null; }
            }, 120);
        }
        if (document.readyState !== 'loading') _burstGridSweep();
        else document.addEventListener('DOMContentLoaded', _burstGridSweep);
        _onPoolReady(() => { _burstGridSweep(); _sidebarSweep(); });

        // SPA navigation re-renders without a reload — restart the bounded
        // burst so replacements land fast after the new page paints.
        let _lastV3Poke = 0;
        function _navSweepBurst() {
            if (_isHomeLikePath()) _burstHomeSpaFix();
            else _burstGridSweep();
            _pokeRetries = 0;
            setTimeout(_maybePokeV3, 1200);
            setTimeout(_maybePokeV3, 3000);
        }

        // Watch → home: V3 sometimes fails to render the top featured block;
        // the card SLOTS are missing so our sweep can't do anything (nothing
        // to sweep). Poke V3 by re-dispatching its own nav events. NOT a
        // page reload, the URL doesn't change. Guarded so it can't loop:
        // only on home, once per 8 s, retries up to 3 times.
        let _pokeRetries = 0;
        function _maybePokeV3() {
            try {
                if (!_checkV3()) return;
                const path = location.pathname;
                if (path !== '/' && path !== '') return;
                if (!active || !videoPool.length) return;
                if (Date.now() - _lastV3Poke < 8000) return;

                const hasFeatured = !!document.querySelector('.lohp-large-shelf-container, .lohp-medium-shelf');
                const cardCount = _findCards(document).length;
                if (hasFeatured && cardCount >= 5) { _pokeRetries = 0; return; }

                _lastV3Poke = Date.now();
                try { window.dispatchEvent(new CustomEvent('yt-navigate-start',  { detail: { pageType: 'home', url: location.href, _bygonePoke: true } })); } catch (_) {}
                try { window.dispatchEvent(new CustomEvent('yt-navigate-finish', { detail: { pageType: 'home', url: location.href, response: {}, _bygonePoke: true } })); } catch (_) {}
                try { window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } catch (_) {}
                [400, 900, 1600, 2600].forEach(ms => setTimeout(() => { try { _sweep(); } catch (_) {} }, ms));

                _pokeRetries++;
                if (_pokeRetries < 3) {
                    setTimeout(_maybePokeV3, 3000);
                } else {
                    _pokeRetries = 0;
                }
            } catch (_) {}
        }
        window.addEventListener('yt-navigate-finish', (e) => {
            if (e.detail && e.detail._bygonePoke) return;
            startResponseScope();
            _navSweepBurst();
        });
        window.addEventListener('popstate', (e) => {
            if (e.detail && e.detail._bygonePoke) return;
            startResponseScope();
            _resetCommentDrain();
            if (location.pathname.startsWith('/watch')) {
                setTimeout(_installCommentObserver, 200);
            } else if (_commentObs) {
                try { _commentObs.disconnect(); } catch (_) {}
                _commentObs = null;
                _commentObsRoot = null;
            }
            _navSweepBurst();
        });

        // Watch page poke — V3's SPA nav often leaves the watch page
        // metadata (channel, likes, description) empty. Detect this and
        // re-dispatch nav events to kick V3 into rendering. Guarded:
        // only on /watch, max 4 retries spaced 2s apart.
        let _watchPokeRetries = 0;
        let _lastWatchPoke = 0;
        function _visibleEnough(el) {
            if (!el) return false;
            try {
                const cs = getComputedStyle(el);
                if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
                const r = el.getBoundingClientRect();
                if (r.width <= 1 || r.height <= 1) return false;
            } catch (_) {}
            return true;
        }
        function _watchMetaState() {
            const ownerSelectors = [
                '#owner ytd-channel-name',
                '#owner #channel-name',
                '#upload-info ytd-channel-name',
                '#upload-info #channel-name',
                '#watch7-content .yt-user-name',
                '#watch-header .yt-user-name',
                '.watch-main-col .yt-user-name',
                '.watch-user-name',
                '.yt-user-info',
                'a[href^="/channel/UC"]'
            ];
            const actionSelectors = [
                '#top-level-buttons-computed',
                '#menu-container',
                '#menu ytd-toggle-button-renderer',
                'ytd-menu-renderer',
                '.watch-actions',
                '.watch-action-buttons',
                '#watch8-sentiment-actions',
                '.like-button-renderer',
                '.yt-uix-button-content'
            ];
            const firstVisible = (selectors) => {
                for (const sel of selectors) {
                    const els = document.querySelectorAll(sel);
                    for (const el of els) {
                        if (!_visibleEnough(el)) continue;
                        const text = (el.textContent || '').trim();
                        if (text || el.querySelector('button, a, img, yt-icon')) return el;
                    }
                }
                return null;
            };
            const owner = firstVisible(ownerSelectors);
            const actions = firstVisible(actionSelectors);
            return {
                owner: !!owner,
                actions: !!actions,
                ownerSel: owner ? (owner.tagName.toLowerCase() + (owner.id ? '#' + owner.id : '') + (owner.className ? '.' + String(owner.className).split(/\s+/)[0] : '')) : '',
                actionsSel: actions ? (actions.tagName.toLowerCase() + (actions.id ? '#' + actions.id : '') + (actions.className ? '.' + String(actions.className).split(/\s+/)[0] : '')) : ''
            };
        }
        try { window.__bygoneWatchDiag = _watchMetaState; } catch (_) {}
        try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) unsafeWindow.__bygoneWatchDiag = _watchMetaState; } catch (_) {}
        function _maybePokeWatch() {
            try {
                if (!_checkV3()) return;
                if (!location.pathname.startsWith('/watch')) return;
                if (!active || !videoPool.length) return;
                if (Date.now() - _lastWatchPoke < 4000) return;

                const metaState = _watchMetaState();
                const hasMeta = metaState.owner && metaState.actions;
                if (hasMeta) { _watchPokeRetries = 0; return; }

                _lastWatchPoke = Date.now();
                try { console.log('[bygone] watch metadata missing; poking V3', metaState); } catch (_) {}
                try { window.dispatchEvent(new CustomEvent('yt-navigate-start',  { detail: { pageType: 'watch', url: location.href, _bygonePoke: true } })); } catch (_) {}
                try { window.dispatchEvent(new CustomEvent('yt-navigate-finish', { detail: { pageType: 'watch', url: location.href, response: {}, _bygonePoke: true } })); } catch (_) {}
                try { window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } catch (_) {}

                _watchPokeRetries++;
                if (_watchPokeRetries < 4) {
                    setTimeout(_maybePokeWatch, 2000);
                } else {
                    _watchPokeRetries = 0;
                }
            } catch (_) {}
        }
        window.addEventListener('yt-navigate-finish', (e) => {
            if (e.detail && e.detail._bygonePoke) return;
            if (location.pathname.startsWith('/watch')) {
                _watchPokeRetries = 0;
                setTimeout(_maybePokeWatch, 1500);
                setTimeout(_maybePokeWatch, 3500);
            }
        });
        window.addEventListener('popstate', (e) => {
            if (e.detail && e.detail._bygonePoke) return;
            if (location.pathname.startsWith('/watch')) {
                _watchPokeRetries = 0;
                setTimeout(_maybePokeWatch, 1500);
            }
        });

        // Sidebar MutationObserver — coalesced via rAF so V3's hundred-per-
        // second sidebar mutations collapse to one sweep per animation frame.
        let _sidebarSweepScheduled = false;
        function _scheduleSidebarSweep() {
            if (_sidebarSweepScheduled) return;
            _sidebarSweepScheduled = true;
            requestAnimationFrame(() => {
                _sidebarSweepScheduled = false;
                try { _sidebarSweep(); } catch (_) {}
            });
        }
        function _installSidebarObserver() {
            const target = document.getElementById('watch7-sidebar-contents')
                || document.getElementById('watch7-sidebar')
                || document.querySelector('#secondary');
            if (!target) { setTimeout(_installSidebarObserver, 1000); return; }
            if (_sidebarObs) { try { _sidebarObs.disconnect(); } catch (_) {} }
            _sidebarObsTarget = target;
            _sidebarObs = new MutationObserver(_scheduleSidebarSweep);
            _sidebarObs.observe(target, { childList: true, subtree: true });
        }
        if (document.readyState !== 'loading') _installSidebarObserver();
        else document.addEventListener('DOMContentLoaded', _installSidebarObserver);
        window.addEventListener('yt-navigate-finish', () => setTimeout(_installSidebarObserver, 500));
        window.addEventListener('popstate', () => setTimeout(_installSidebarObserver, 500));

        // Aggressive post-nav polling for the watch sidebar (V3 may re-render
        // it multiple times in the first few seconds). Single-flight.
        let _burstTimer = null;
        function _burstSidebarSweep() {
            if (_burstTimer) { clearInterval(_burstTimer); _burstTimer = null; }
            let elapsed = 0;
            _burstTimer = setInterval(() => {
                try { _sidebarSweep(); } catch (_) {}
                elapsed += 200;
                if (elapsed >= 10000) { clearInterval(_burstTimer); _burstTimer = null; }
            }, 200);
        }
        window.addEventListener('yt-navigate-finish', _burstSidebarSweep);
        window.addEventListener('popstate', _burstSidebarSweep);
        if (location.pathname.startsWith('/watch')) {
            if (document.readyState !== 'loading') _burstSidebarSweep();
            else document.addEventListener('DOMContentLoaded', _burstSidebarSweep);
        }

        // ---- Click hijack ------------------------------------------
        // V3 binds click handlers in closures over the ORIGINAL videoId at
        // render time. Even if we rewrite href + the renderer's videoId
        // afterwards, V3's bubble-phase handler navigates to the original.
        // We capture BEFORE V3 sees the click and force navigation.

        function _findClickTarget(target) {
            let el = target, link = null, sweptCard = null, keepCard = null;
            for (let i = 0; i < 14 && el && el !== document.body; i++) {
                if (!link && el.tagName === 'A') {
                    const h = el.getAttribute('href') || '';
                    if (h.indexOf('/watch') !== -1 && h.indexOf('v=') !== -1) link = el;
                }
                if (!sweptCard && el.hasAttribute && el.hasAttribute('data-bygone-swept')) sweptCard = el;
                if (!keepCard && el.hasAttribute && el.hasAttribute('data-bygone-keep')) keepCard = el;
                el = el.parentElement;
            }
            return { link, sweptCard, keepCard };
        }

        function _resolveTarget(link, sweptCard, keepCard) {
            if (sweptCard) {
                const id = sweptCard.getAttribute('data-bygone-swept');
                if (id) return id;
            }
            if (!link) return null;
            const href = link.getAttribute('href') || '';
            const m = href.match(/[?&]v=([A-Za-z0-9_-]+)/);
            if (!m) return null;
            const origId = m[1];
            if (_poolIdsSet.has(origId)) return origId;
            // Kept naturally-old recommendation — navigate to the REAL
            // video, don't map it to a pool replacement.
            if (keepCard) return origId;
            const v = mapVideo(origId);
            return v ? v.id : null;
        }

        // True when the user is on the search-results page. Click hijack
        // must NOT rewrite link targets there — the results are genuine
        // YouTube search hits already date-bounded via `before:` in the
        // URL, so clicks should go to those real videos, not be remapped
        // into our pool.
        const _onResultsPage = () => {
            const p = location.pathname;
            return p === '/results' || p === '/results/';
        };

        function _isHomeNavClick(target) {
            if (!target || !target.closest) return false;
            const a = target.closest('a');
            const href = a && (a.getAttribute('href') || '');
            let homeHref = false;
            if (href) {
                try {
                    const u = new URL(href, location.origin);
                    homeHref = u.origin === location.origin && u.pathname === '/';
                } catch (_) {
                    homeHref = href === '/' || href.indexOf('/?') === 0;
                }
            }
            const logoHit = target.closest(
                '#logo, #logo-container, #masthead-logo-link, #logo-icon, ' +
                '.v3-logo, .yt-masthead-logo, .appbar-logo, ytd-topbar-logo-renderer, ' +
                'a[title="YouTube"], a[aria-label="YouTube"], a[aria-label="Home"]'
            );
            return homeHref || !!logoHit;
        }

        function _scheduleHomeSpaFixFromClick() {
            [80, 220, 500, 1000, 1800].forEach(ms => setTimeout(() => {
                if (_isHomeLikePath()) _burstHomeSpaFix();
            }, ms));
        }

        document.addEventListener('click', function (e) {
            if (e.defaultPrevented) return;
            // Only act on REAL user clicks. YouTube/V3 dispatch SYNTHETIC clicks
            // for autoplay advance, end-screen cards and SPA prefetch; turning
            // those into a full `location.href` load can cause a reload loop on
            // the watch page. Untrusted clicks fall through to YouTube's own
            // handling — and since the sweep already rewrites link hrefs to the
            // pool video, navigation still lands on the right video. No feature
            // is lost; only the synthetic-click → forced-reload path is cut.
            if (!e.isTrusted) return;
            if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
            if (_isHomeNavClick(e.target)) {
                _scheduleHomeSpaFixFromClick();
                return;
            }
            if (!active || !videoPool.length) return;

            // On search results: don't remap video IDs, but DO force a
            // full page load so V3's broken SPA nav doesn't eat the watch
            // page metadata (channel, likes, description).
            if (_onResultsPage()) {
                const { link } = _findClickTarget(e.target);
                if (!link) return;
                const href = link.getAttribute('href') || '';
                if (href.indexOf('/watch') === -1) return;
                e.preventDefault();
                e.stopImmediatePropagation();
                location.href = location.origin + href;
                return;
            }

            const { link, sweptCard, keepCard } = _findClickTarget(e.target);
            if (!sweptCard && !link) return;
            const targetId = _resolveTarget(link, sweptCard, keepCard);
            if (!targetId) return;
            e.preventDefault();
            e.stopImmediatePropagation();
            try {
                const pv = videoPool.find(v => v.id === targetId);
                if (pv) {
                    Store.addClickEvent({
                        videoId: targetId, channelId: pv.channelId || null,
                        channel: pv.channel || '', title: pv.title || '',
                        source: pv.source || 'unknown', ts: Date.now(),
                    });
                    Store.markFeedClicked(targetId);
                    Store.recordSourceClick(pv.source || 'unknown');
                }
            } catch (_) {}
            location.href = location.origin + '/watch?v=' + targetId;
        }, true);

        // Middle-click / ctrl-click: rewrite href so the browser-native
        // new-tab open lands on the correct video.
        document.addEventListener('auxclick', function (e) {
            if (!active || !videoPool.length) return;
            if (_onResultsPage()) return;
            if (e.button !== 1) return;
            const { link, sweptCard, keepCard } = _findClickTarget(e.target);
            const targetId = _resolveTarget(link, sweptCard, keepCard);
            if (!targetId || !link) return;
            const href = link.getAttribute('href') || '';
            link.setAttribute('href', href.replace(/([?&]v=)[A-Za-z0-9_-]+/, '$1' + targetId));
        }, true);

        return {
            setVideos,
            appendVideos,
            setLazyFetcher,
            isActive: () => active,
            poolSize: () => videoPool.length,
            usedCount: () => _usedReplacements.size,
            origFetch: _origFetch,
            sweep: _sweep,
            mapVideo,
            getPoolVideo: (id) => videoPool.find(v => v.id === id) || null,
            getPoolIds: () => new Set(_poolIdsSet),
            getPool: () => videoPool,
            isKeptNatural: (id) => _keptNaturalIds.has(id),
            rewriteCard: _rewriteCard,
            findCards: _findCards,
            refreshAllDates,
            isChannelPage: _isChannelPage,
        };
    })();

    // ============================================================
    //  CONFIG + VERSION
    // ============================================================

    const VERSION = 377;

    const CONFIG = {
        // maxSearchPages: how many continuation pages to walk per search query.
        // The date filter drops a large share of each ~20-result page, so
        // paging is what lets one era query yield well beyond a single page.
        api: { maxResults: 25, cooldownMs: 250, maxSearchPages: 5 },
        feed: {
            dateWindowDays: 7,
            // No forward grace on the video feed — only past-or-on-set-date
            // uploads. (Comments have a separate 2-year cutoff applied in
            // _commentCutoff; that one is intentional and stays.)
            futureGraceDays: 0,
            maxHomepageVideos: 300,
            weights: {
                subscriptions: 0.28,
                searchTerms:   0.13,
                categories:    0.18,
                topics:        0.09,
                similar:       0.14,
                trending:      0.18,
            },
        },
        cache: {
            subscriptions: 14400000,    // 4h
            searchTerms:    7200000,    // 2h
            categories:     7200000,
            topics:         7200000,
            similar:        3600000,
            trending:       1800000,
        },
        defaultGlobalNegatives: ['for kids', 'nursery rhymes', 'cocomelon', 'baby shark'],
        installUrls: {
            v3: 'https://vorapis.pages.dev/#/home/download',
            starTube: 'https://greasyfork.org/scripts/485622-startube',
        },
        categories: {
            1: 'Film & Animation', 2: 'Autos & Vehicles', 10: 'Music',
            15: 'Pets & Animals', 17: 'Sports', 19: 'Travel & Events',
            20: 'Gaming', 22: 'People & Blogs', 23: 'Comedy',
            24: 'Entertainment', 25: 'News & Politics', 26: 'How-to & Style',
            27: 'Education', 28: 'Science & Technology',
        },
        discoveryQueries: [
            '', 'music video', 'trailer', 'funny', 'review', 'highlights',
            'how to', 'compilation', 'reaction', 'vlog', 'tutorial', 'news',
            'challenge', 'unboxing', 'animation', 'top 10', 'best of', 'cover',
            'remix', 'documentary', 'interview', 'gameplay', 'montage',
        ],
    };

    // ============================================================
    //  STORE — GM_* persistence + profiles + rolling clock
    // ============================================================

    class Store {
        static _migrated = false;
        static _migrateFromWbt() {
            if (this._migrated) return;
            this._migrated = true;
            try {
                if (GM_getValue('bygone_migrated', false)) return;
                const allKeys = GM_listValues();
                let found = 0;
                for (const k of allKeys) {
                    if (!k.startsWith('wbt_')) continue;
                    const newKey = 'bygone_' + k.slice(4);
                    if (GM_getValue(newKey, undefined) !== undefined) continue;
                    const val = GM_getValue(k, undefined);
                    if (val !== undefined) { GM_setValue(newKey, val); found++; }
                }
                GM_setValue('bygone_migrated', true);
                if (found) console.log('[bygone] migrated', found, 'keys from wbt_ → bygone_');
            } catch (e) { console.warn('[bygone] migration error', e); }
        }

        static _get(k, d) {
            this._migrateFromWbt();
            try { const r = GM_getValue(k, undefined); if (r === undefined) return d; return typeof r === 'string' ? JSON.parse(r) : r; }
            catch { return d; }
        }
        static _set(k, v) { GM_setValue(k, JSON.stringify(v)); }
        static _del(k) { GM_deleteValue(k); }

        // Selected date
        static getDate()           { return this._get('bygone_date', null); }
        static setDate(d)          { this._set('bygone_date', d); }

        // Sources
        static getSubscriptions()  { return this._get('bygone_subscriptions', []); }
        static setSubscriptions(s) { this._set('bygone_subscriptions', s); }
        static getSearchTerms()    { return this._get('bygone_search_terms', []); }
        static setSearchTerms(t)   { this._set('bygone_search_terms', t); }
        static getCategories()     { return this._get('bygone_categories', [20, 10, 24]); }
        static setCategories(c)    { this._set('bygone_categories', c); }
        static getTopics()         { return this._get('bygone_topics', []); }
        static setTopics(t)        { this._set('bygone_topics', t); }
        static getBlockedChannels(){ return this._get('bygone_blocked_channels', []); }
        static setBlockedChannels(b){ this._set('bygone_blocked_channels', b); }

        // Exact publish dates (ISO YYYY-MM-DD) keyed by video id, fetched from
        // /next so relative dates can be computed precisely against the set
        // date instead of guessed from year-granular strings. Persistent +
        // in-memory cached because recalcForFeed reads it per card per sweep.
        static _exactCache = null;
        static getExactDates()      { return this._get('bygone_exact_dates', {}); }
        static getExactDate(id) {
            if (!id) return null;
            if (!this._exactCache) this._exactCache = this.getExactDates();
            return this._exactCache[id] || null;
        }
        static addExactDates(map) {
            if (!map) return;
            if (!this._exactCache) this._exactCache = this.getExactDates();
            Object.assign(this._exactCache, map);
            this._set('bygone_exact_dates', this._exactCache);
        }

        // State
        static isActive()          { return this._get('bygone_active', true); }
        static setActive(v)        { this._set('bygone_active', v); }
        static hasSeenDependencyPrompt(){ return this._get('bygone_dependency_prompt_seen', false); }
        static markDependencyPromptSeen(){ this._set('bygone_dependency_prompt_seen', true); }
        static isDiscoveryEnabled(){ return this._get('bygone_discovery', true); }
        static setDiscoveryEnabled(v){ this._set('bygone_discovery', v); }
        static isSimilarEnabled()  { return this._get('bygone_similar_enabled', true); }
        static setSimilarEnabled(v){ this._set('bygone_similar_enabled', v); }
        static isLearningEnabled() { return this._get('bygone_learning', true); }
        static setLearningEnabled(v){ this._set('bygone_learning', v); }

        // Auto-sync bygone subscriptions to YouTube account
        static isAutoSyncSubs()    { return this._get('bygone_auto_sync_subs', true); }
        static setAutoSyncSubs(v)  { this._set('bygone_auto_sync_subs', v); }
        // Track which channel IDs we've already synced to YouTube so we
        // don't re-call subscribe on every panel render / page load.
        static getSyncedSubIds()   { return this._get('bygone_synced_sub_ids', []); }
        static setSyncedSubIds(ids){ this._set('bygone_synced_sub_ids', ids); }
        static markSubSynced(id) {
            if (!id) return;
            const ids = this.getSyncedSubIds();
            if (!ids.includes(id)) { ids.push(id); this.setSyncedSubIds(ids); }
        }

        // Global negatives
        static getGlobalNegatives()  { return this._get('bygone_global_negatives', CONFIG.defaultGlobalNegatives.slice()); }
        static setGlobalNegatives(v) { this._set('bygone_global_negatives', v); }

        // Hidden videos
        static getHiddenIds()      { return this._get('bygone_hidden_ids', []); }
        static setHiddenIds(ids)   { this._set('bygone_hidden_ids', ids); }
        static hideVideoId(id) {
            const ids = this.getHiddenIds();
            if (!ids.includes(id)) {
                ids.push(id);
                if (ids.length > 2000) ids.splice(0, ids.length - 2000);
                this.setHiddenIds(ids);
            }
        }

        // Profiles
        static getProfiles()       { return this._get('bygone_profiles', {}); }
        static setProfiles(p)      { this._set('bygone_profiles', p); }
        static saveProfile(name) {
            const profiles = this.getProfiles();
            profiles[name] = {
                date: this.getDate(),
                subscriptions: this.getSubscriptions(),
                searchTerms: this.getSearchTerms(),
                categories: this.getCategories(),
                topics: this.getTopics(),
                blockedChannels: this.getBlockedChannels(),
                customLogo: this.getCustomLogo(),
                discovery: this.isDiscoveryEnabled(),
                similar: this.isSimilarEnabled(),
                learning: this.isLearningEnabled(),
                globalNegatives: this.getGlobalNegatives(),
                savedAt: Date.now(),
            };
            this.setProfiles(profiles);
        }
        // Create a fresh, empty profile (clean-install defaults) rather than
        // snapshotting the current state. Keeps the current era date so the
        // blank profile is immediately usable; everything else starts empty.
        static createBlankProfile(name) {
            const profiles = this.getProfiles();
            profiles[name] = {
                date: this.getDate() || '',
                subscriptions: [],
                searchTerms: [],
                categories: [20, 10, 24],
                topics: [],
                blockedChannels: [],
                customLogo: '',
                discovery: true,
                similar: true,
                learning: true,
                globalNegatives: CONFIG.defaultGlobalNegatives.slice(),
                savedAt: Date.now(),
            };
            this.setProfiles(profiles);
        }
        static loadProfile(name) {
            const p = this.getProfiles()[name];
            if (!p) return false;
            if (p.date)             this.setDate(p.date);
            if (p.subscriptions)    this.setSubscriptions(p.subscriptions);
            if (p.searchTerms)      this.setSearchTerms(p.searchTerms);
            if (p.categories)       this.setCategories(p.categories);
            if (p.topics)           this.setTopics(p.topics);
            if (p.blockedChannels)  this.setBlockedChannels(p.blockedChannels);
            if (p.discovery !== undefined) this.setDiscoveryEnabled(p.discovery);
            if (p.similar   !== undefined) this.setSimilarEnabled(p.similar);
            if (p.learning  !== undefined) this.setLearningEnabled(p.learning);
            if (p.globalNegatives)  this.setGlobalNegatives(p.globalNegatives);
            if (p.customLogo)       this.setCustomLogo(p.customLogo);
            else                    this.clearCustomLogo();
            this.stopClock();
            return true;
        }
        static deleteProfile(name) {
            const profiles = this.getProfiles();
            delete profiles[name];
            this.setProfiles(profiles);
        }
        static exportProfile(name) {
            const p = this.getProfiles()[name];
            return p ? JSON.stringify({ name, ...p }, null, 2) : null;
        }
        static importProfile(json) {
            const data = JSON.parse(json);
            const name = data.name;
            if (!name) throw new Error('Profile has no name');
            delete data.name;
            const profiles = this.getProfiles();
            profiles[name] = data;
            this.setProfiles(profiles);
            return name;
        }

        static exportAll() {
            return JSON.stringify({
                _bygone_export: true,
                _version: VERSION,
                _exportedAt: Date.now(),
                date: this.getDate(),
                active: this.isActive(),
                subscriptions: this.getSubscriptions(),
                searchTerms: this.getSearchTerms(),
                categories: this.getCategories(),
                topics: this.getTopics(),
                blockedChannels: this.getBlockedChannels(),
                globalNegatives: this.getGlobalNegatives(),
                hiddenIds: this.getHiddenIds(),
                customLogo: this.getCustomLogo(),
                discovery: this.isDiscoveryEnabled(),
                similar: this.isSimilarEnabled(),
                learning: this.isLearningEnabled(),
                autoSyncSubs: this.isAutoSyncSubs(),
                syncedSubIds: this.getSyncedSubIds(),
                profiles: this.getProfiles(),
                clockActive: this.isClockActive(),
                clockRealStart: this.getClockRealStart(),
                clockSimStart: this.getClockSimStart(),
                timeOffset: this.getTimeOffset(),
                watchHistory: this.getWatchHistory(),
                cachedInterests: this._get('bygone_cached_interests', null),
                loadCount: this.getLoadCount(),
                dislikes: this.getDislikes(),
                impressions: this.getImpressions(),
                seenIds: this.getSeenIds(),
                clickEvents: this.getClickEvents(),
                feedImpressions: this.getFeedImpressions(),
                searchHistory: this.getSearchHistory(),
                sourceStats: this._get('bygone_source_stats', {}),
                sourceOrder: this.getSourceOrder(),
            }, null, 2);
        }
        static importAll(json) {
            const d = JSON.parse(json);
            if (!d._bygone_export) throw new Error('Not a bygone-yt full export');
            if (d.date !== undefined)            this.setDate(d.date);
            if (d.active !== undefined)          this.setActive(d.active);
            if (d.subscriptions)                 this.setSubscriptions(d.subscriptions);
            if (d.searchTerms)                   this.setSearchTerms(d.searchTerms);
            if (d.categories)                    this.setCategories(d.categories);
            if (d.topics)                        this.setTopics(d.topics);
            if (d.blockedChannels)               this.setBlockedChannels(d.blockedChannels);
            if (d.globalNegatives)               this.setGlobalNegatives(d.globalNegatives);
            if (d.hiddenIds)                     this.setHiddenIds(d.hiddenIds);
            if (d.customLogo !== undefined)       d.customLogo ? this.setCustomLogo(d.customLogo) : this.clearCustomLogo();
            if (d.discovery !== undefined)       this.setDiscoveryEnabled(d.discovery);
            if (d.similar !== undefined)         this.setSimilarEnabled(d.similar);
            if (d.learning !== undefined)        this.setLearningEnabled(d.learning);
            if (d.autoSyncSubs !== undefined)    this.setAutoSyncSubs(d.autoSyncSubs);
            if (d.syncedSubIds)                  this.setSyncedSubIds(d.syncedSubIds);
            if (d.profiles)                      this.setProfiles(d.profiles);
            if (d.clockActive !== undefined)     this.setClockActive(d.clockActive);
            if (d.clockRealStart !== undefined)  this.setClockRealStart(d.clockRealStart);
            if (d.clockSimStart !== undefined)   this.setClockSimStart(d.clockSimStart);
            if (d.timeOffset !== undefined)      this.setTimeOffset(d.timeOffset);
            if (d.watchHistory)                  this.setWatchHistory(d.watchHistory);
            if (d.cachedInterests)               this._set('bygone_cached_interests', d.cachedInterests);
            if (d.loadCount !== undefined)        this._set('bygone_load_count', d.loadCount);
            if (d.dislikes)                      this.setDislikes(d.dislikes);
            if (d.impressions)                   this.setImpressions(d.impressions);
            if (d.seenIds)                       this.setSeenIds(d.seenIds);
            if (d.clickEvents)                   this.setClickEvents(d.clickEvents);
            if (d.feedImpressions)               this.setFeedImpressions(d.feedImpressions);
            if (d.searchHistory)                 this.setSearchHistory(d.searchHistory);
            if (d.sourceStats)                   this.setSourceStats(d.sourceStats);
            if (d.sourceOrder)                   this._set('bygone_source_order', d.sourceOrder);
        }

        // Custom logo
        static getCustomLogo()     { return this._get('bygone_custom_logo', null); }
        static setCustomLogo(d)    { this._set('bygone_custom_logo', d); }
        static clearCustomLogo()   { this._del('bygone_custom_logo'); }

        // Rolling clock — sim time = simStart + (Date.now() - realStart), i.e.
        // it advances exactly 1:1 with real elapsed wall-time from a single
        // saved anchor (arm at 2014-05-02, reopen a week later → 2014-05-09).
        // Date.now() is RAW (no offset) so the world-time sync can't skew the
        // progression. Default ON and still toggleable; the anchor persists
        // across reloads and is NEVER silently re-set, so the rate can't drift.
        static isClockActive()     { return this._get('bygone_clock_active', true); }
        static setClockActive(v)   { this._set('bygone_clock_active', v); }
        static getClockRealStart() { return this._get('bygone_clock_real_start', 0); }
        static setClockRealStart(t){ this._set('bygone_clock_real_start', t); }
        static getClockSimStart()  { return this._get('bygone_clock_sim_start', 0); }
        static setClockSimStart(t) { this._set('bygone_clock_sim_start', t); }
        static getTimeOffset()     { return this._get('bygone_time_offset', 0); }
        static setTimeOffset(v)    { this._set('bygone_time_offset', v); }

        // Parse YYYY-MM-DD as LOCAL midnight (not UTC — `new Date('2010-01-15')`
        // is UTC midnight, the wrong day for non-UTC users).
        static _parseLocalDate(s) {
            if (!s) return new Date();
            const m = String(s).match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
            return m ? new Date(+m[1], +m[2] - 1, +m[3]) : new Date(s);
        }
        static _formatLocalDate(d) {
            const y = d.getFullYear();
            const m = String(d.getMonth() + 1).padStart(2, '0');
            const dy = String(d.getDate()).padStart(2, '0');
            return `${y}-${m}-${dy}`;
        }

        // Arm the rolling clock if it's active but has no anchor yet — fresh
        // install, default-on, or after a stop cleared the anchors. Anchors to
        // the current base date at the real wall-clock moment, so from here it
        // tracks real elapsed time 1:1 and never silently re-anchors.
        static _armClockIfNeeded() {
            if (!this.isClockActive()) return;
            if (this.getClockRealStart() && this.getClockSimStart()) return;
            const base = this.getDate();
            if (!base) return;
            this.setClockSimStart(this._parseLocalDate(base).getTime());
            this.setClockRealStart(Date.now());
        }
        static getCurrentDate() {
            if (this.isClockActive()) {
                this._armClockIfNeeded();
                const rs = this.getClockRealStart();
                const ss = this.getClockSimStart();
                if (rs && ss) return this._formatLocalDate(new Date(ss + (Date.now() - rs)));
            }
            return this.getDate();
        }
        static getCurrentDateTime() {
            if (this.isClockActive()) {
                this._armClockIfNeeded();
                const rs = this.getClockRealStart();
                const ss = this.getClockSimStart();
                if (rs && ss) return new Date(ss + (Date.now() - rs));
            }
            const d = this.getDate();
            return d ? this._parseLocalDate(d) : new Date();
        }
        // Arm/re-arm at an explicit date: sim time = that date as of right now,
        // then it rolls forward 1:1 with real elapsed wall-time.
        static startClock(dateStr) {
            const base = dateStr || this.getDate();
            if (base) this.setDate(base);
            this.setClockActive(true);
            this.setClockSimStart(this._parseLocalDate(base).getTime());
            this.setClockRealStart(Date.now());
        }
        // Freeze at the current advanced date and clear the anchors, so a later
        // re-arm starts cleanly from the frozen date with no stale offset.
        static stopClock() {
            const cur = this.getCurrentDate();
            this.setClockActive(false);
            this.setDate(cur);
            this.setClockRealStart(0);
            this.setClockSimStart(0);
        }

        // Watch-history learning
        static getWatchHistory()   { return this._get('bygone_watch_history', []); }
        static setWatchHistory(h)  { this._set('bygone_watch_history', h); }
        static addWatchEvent(ev) {
            const h = this.getWatchHistory();
            if (h.some(e => e.videoId === ev.videoId && (ev.ts - e.ts) < 300000)) return;
            h.push(ev);
            const cutoff = Date.now() - (60 * 86400000);
            const pruned = h.filter(e => e.ts > cutoff);
            if (pruned.length > 200) pruned.splice(0, pruned.length - 200);
            this.setWatchHistory(pruned);
            this._del('bygone_cached_interests');
        }
        static getCachedInterests() {
            const cached = this._get('bygone_cached_interests', null);
            if (cached) return cached;
            const i = InterestModel.compute();
            this._set('bygone_cached_interests', i);
            return i;
        }
        static clearLearningData() {
            this._del('bygone_watch_history');
            this._del('bygone_cached_interests');
            this._del('bygone_load_count');
        }
        static getLoadCount()      { return this._get('bygone_load_count', 0); }
        static incrementLoadCount(){ const c = this.getLoadCount() + 1; this._set('bygone_load_count', c); return c; }

        // Dislike signal — blocks a channel and pushes its keywords down.
        static getDislikes()       { return this._get('bygone_dislikes', { channels: {}, keywords: {} }); }
        static setDislikes(d)      { this._set('bygone_dislikes', d); }
        static recordDislike({ channelId, title }) {
            const d = this.getDislikes();
            if (channelId) d.channels[channelId] = (d.channels[channelId] || 0) + 2;
            if (title) {
                const stop = new Set(['the','a','an','in','on','at','to','for','of','and','or','is','it','my','we','i','you','this','that','with','from','by']);
                const words = title.replace(/[^\w\s]/g, '').split(/\s+/)
                    .filter(w => w.length > 2 && !stop.has(w.toLowerCase()));
                for (const w of words.slice(0, 5)) {
                    const k = w.toLowerCase();
                    d.keywords[k] = (d.keywords[k] || 0) + 1;
                }
            }
            this.setDislikes(d);
            this._del('bygone_cached_interests');
        }

        static getImpressions()    { return this._get('bygone_impressions', {}); }
        static setImpressions(i)   { this._set('bygone_impressions', i); }
        static recordImpressions(videoIds) {
            const store = this.getImpressions();
            const fi = this.getFeedImpressions();
            const clicks = this.getClickEvents();
            const now = Date.now();
            const PARK_MS_COLD      = 2 * 86400000;
            const PARK_MS_CLICKED   = 5 * 86400000;
            const THRESHOLD_COLD    = 5;
            const THRESHOLD_CLICKED = 15;
            const clickCounts = {};
            for (const ev of clicks) {
                clickCounts[ev.videoId] = (clickCounts[ev.videoId] || 0) + 1;
            }
            for (const id of videoIds) {
                if (!store[id]) store[id] = { count: 0, hiddenUntil: 0, clicks: 0 };
                const row = store[id];
                if (row.hiddenUntil && row.hiddenUntil <= now) { row.count = 0; row.hiddenUntil = 0; }
                row.count++;
                const userClicks = clickCounts[id] || 0;
                const fiClicked = fi[id] && fi[id].clicked;
                row.clicks = userClicks;
                if (userClicks >= 3 || fiClicked) {
                    if (row.count >= THRESHOLD_CLICKED) {
                        row.hiddenUntil = now + PARK_MS_CLICKED;
                        row.count = 0;
                    }
                } else {
                    if (row.count >= THRESHOLD_COLD) {
                        row.hiddenUntil = now + PARK_MS_COLD;
                        row.count = 0;
                    }
                }
            }
            const keys = Object.keys(store);
            if (keys.length > 5000) {
                const dormant = keys.filter(k => !store[k].hiddenUntil && store[k].count === 0);
                for (const k of dormant.slice(0, keys.length - 4000)) delete store[k];
            }
            this.setImpressions(store);
        }
        static isImpressionHidden(id) {
            const row = this.getImpressions()[id];
            if (!row || !row.hiddenUntil) return false;
            return row.hiddenUntil > Date.now();
        }

        // Seen videos (push to back of feed across refreshes)
        static getSeenIds()        { return this._get('bygone_seen_ids', []); }
        static setSeenIds(ids)     { this._set('bygone_seen_ids', ids); }
        static addSeenIds(newIds) {
            const ids = this.getSeenIds();
            for (const id of newIds) if (!ids.includes(id)) ids.push(id);
            if (ids.length > 300) ids.splice(0, ids.length - 300);
            this.setSeenIds(ids);
        }

        // Click events (source-attributed engagement signal)
        static getClickEvents()    { return this._get('bygone_click_events', []); }
        static setClickEvents(e)   { this._set('bygone_click_events', e); }
        static addClickEvent(ev) {
            const events = this.getClickEvents();
            if (events.some(e => e.videoId === ev.videoId && (ev.ts - e.ts) < 300000)) return;
            events.push(ev);
            const cutoff = Date.now() - (90 * 86400000);
            const pruned = events.filter(e => e.ts > cutoff);
            if (pruned.length > 500) pruned.splice(0, pruned.length - 500);
            this.setClickEvents(pruned);
        }

        // Feed impressions (tracks shown-but-not-clicked for negative signals)
        static getFeedImpressions()    { return this._get('bygone_feed_impressions', {}); }
        static setFeedImpressions(fi)  { this._set('bygone_feed_impressions', fi); }
        static recordFeedImpressions(videos) {
            const store = this.getFeedImpressions();
            const now = Date.now();
            for (const v of videos) {
                if (!v || !v.id) continue;
                if (!store[v.id]) {
                    store[v.id] = {
                        impressions: 0, clicked: false,
                        channelId: v.channelId || null, channel: v.channel || '',
                        title: v.title || '', source: v.source || '',
                        firstSeen: now, lastSeen: now,
                    };
                }
                store[v.id].impressions++;
                store[v.id].lastSeen = now;
            }
            const keys = Object.keys(store);
            if (keys.length > 3000) {
                const sorted = keys.filter(k => !store[k].clicked)
                    .sort((a, b) => store[a].lastSeen - store[b].lastSeen);
                for (const k of sorted.slice(0, keys.length - 2500)) delete store[k];
            }
            this.setFeedImpressions(store);
        }
        static markFeedClicked(videoId) {
            const store = this.getFeedImpressions();
            if (store[videoId]) { store[videoId].clicked = true; this.setFeedImpressions(store); }
        }

        // Search history (auto-learned search queries)
        static getSearchHistory()    { return this._get('bygone_search_history', []); }
        static setSearchHistory(h)   { this._set('bygone_search_history', h); }
        static addSearchQuery(query) {
            if (!query || query.length < 3) return;
            const clean = query.replace(/\s*before:\d{4}-\d{2}-\d{2}/g, '').trim();
            if (!clean) return;
            const h = this.getSearchHistory();
            if (h.some(e => e.query.toLowerCase() === clean.toLowerCase() && (Date.now() - e.ts) < 3600000)) return;
            h.push({ query: clean, ts: Date.now() });
            const cutoff = Date.now() - (180 * 86400000);
            const pruned = h.filter(e => e.ts > cutoff);
            if (pruned.length > 200) pruned.splice(0, pruned.length - 200);
            this.setSearchHistory(pruned);
        }

        // Source CTR stats (per-source impression/click counts)
        static getSourceStats() {
            const stats = this._get('bygone_source_stats', {});
            const now = Date.now();
            const DECAY_INTERVAL = 7 * 86400000;
            let needsWrite = false;
            for (const key of Object.keys(stats)) {
                const s = stats[key];
                if (s.lastUpdated && (now - s.lastUpdated) > DECAY_INTERVAL) {
                    s.impressions = Math.floor(s.impressions * 0.7);
                    s.clicks = Math.floor(s.clicks * 0.7);
                    s.lastUpdated = now;
                    needsWrite = true;
                    if (s.impressions < 5) { delete stats[key]; continue; }
                }
            }
            if (needsWrite) this._set('bygone_source_stats', stats);
            return stats;
        }
        static setSourceStats(s)   { this._set('bygone_source_stats', s); }
        static recordSourceImpression(source) {
            if (!source) return;
            const stats = this.getSourceStats();
            if (!stats[source]) stats[source] = { impressions: 0, clicks: 0, lastUpdated: Date.now() };
            stats[source].impressions++;
            stats[source].lastUpdated = Date.now();
            this.setSourceStats(stats);
        }
        static recordSourceClick(source) {
            if (!source) return;
            const stats = this.getSourceStats();
            if (!stats[source]) stats[source] = { impressions: 0, clicks: 0, lastUpdated: Date.now() };
            stats[source].clicks++;
            stats[source].lastUpdated = Date.now();
            this.setSourceStats(stats);
        }

        // Cache helpers
        static getCacheEntry(key, ttlMs) {
            const e = this._get(`bygone_cache_${key}`, null);
            if (!e) return null;
            if (Date.now() - e.ts > ttlMs) { this._del(`bygone_cache_${key}`); return null; }
            return e.data;
        }
        static setCacheEntry(key, data) { this._set(`bygone_cache_${key}`, { ts: Date.now(), data }); }

        // Source-source order (for drag-reorder support in UI)
        static getSourceOrder() {
            return this._get('bygone_source_order', ['subscriptions', 'searchTerms', 'topics', 'categories']);
        }
        static setSourceOrder(o) { this._set('bygone_source_order', o); }
    }

    // ============================================================
    //  DATE HELPER — relative-text ↔ Date
    // ============================================================

    class DateHelper {
        static _msMap = {
            year:   365.25 * 86400000,
            month:  30.44  * 86400000,
            week:   7      * 86400000,
            day:             86400000,
            hour:            3600000,
            minute:             60000,
            second:              1000,
        };

        static approxPublishDate(relativeText) {
            if (!relativeText) return null;
            const clean = relativeText.replace(/^Streamed\s+/i, '');
            const m = clean.match(/(\d+)\s*(year|month|week|day|hour|minute|second)/i);
            if (!m) return null;
            return new Date(Date.now() - parseInt(m[1], 10) * (this._msMap[m[2].toLowerCase()] || 0));
        }

        static relativeToDate(publishDate, referenceDate, videoId) {
            const diffMs = new Date(referenceDate).getTime() - new Date(publishDate).getTime();
            if (diffMs < 0) {
                const h = videoId ? this._hash(videoId) : this._hash(String(publishDate));
                const d = (h % 13) + 1;
                return d === 1 ? '1 day ago' : `${d} days ago`;
            }
            const days = Math.floor(diffMs / 86400000);
            const years = Math.floor(days / 365.25);
            const months = Math.floor(days / 30.44);
            const weeks = Math.floor(days / 7);
            const hours = Math.floor(diffMs / 3600000);
            if (years  >= 1) return years  === 1 ? '1 year ago'  : `${years} years ago`;
            if (months >= 1) return months === 1 ? '1 month ago' : `${months} months ago`;
            if (weeks  >= 1) return weeks  === 1 ? '1 week ago'  : `${weeks} weeks ago`;
            if (days   >= 1) return days   === 1 ? '1 day ago'   : `${days} days ago`;
            if (hours  >= 1) return hours  === 1 ? '1 hour ago'  : `${hours} hours ago`;
            return '1 day ago';
        }

        // Parse YouTube's exact watch-page date text ("Mar 18, 2008",
        // "Premiered Mar 18, 2008", "Streamed live on Apr 5, 2007") to ISO.
        static parseExactDateText(text) {
            if (!text) return null;
            const t = text.replace(/^(Premiered|Streamed live on|Started streaming on|Uploaded on|Published on)\s+/i, '').trim();
            const d = new Date(t);
            if (isNaN(d.getTime())) return null;
            return d.toISOString().slice(0, 10);
        }

        static recalcRelative(innertubeText, setDateStr, videoId) {
            if (!setDateStr) return innertubeText || '';
            const prefix = /^Streamed\s+/i.test(innertubeText || '') ? 'Streamed ' : '';
            const exact = videoId ? Store.getExactDate(videoId) : null;
            if (exact) return prefix + this.relativeToDate(new Date(exact), setDateStr, videoId);
            if (!innertubeText) return '';
            const pub = this.approxPublishDate(innertubeText);
            if (!pub) return innertubeText;
            return prefix + this.relativeToDate(pub, setDateStr, videoId);
        }

        static _hash(str) {
            let h = 0;
            for (let i = 0; i < str.length; i++) { h = ((h << 5) - h) + str.charCodeAt(i); h |= 0; }
            return Math.abs(h);
        }

        // Real recalc when meaningful (months/years), hash-spread otherwise.
        // Variety prevents the feed from looking like "every video is 1 day ago".
        static recalcForFeed(innertubeText, setDateStr, videoId) {
            if (!setDateStr) return innertubeText || '';
            const prefix = /^Streamed\s+/i.test(innertubeText || '') ? 'Streamed ' : '';
            // Exact date known → compute the precise relative-to-set-date.
            const exact = videoId ? Store.getExactDate(videoId) : null;
            if (exact) return prefix + this.relativeToDate(new Date(exact), setDateStr, videoId);
            if (!innertubeText) return '';
            const pub = this.approxPublishDate(innertubeText);
            if (!pub) return innertubeText;
            const real = this.relativeToDate(pub, setDateStr, videoId);
            if (real.includes('year') || real.includes('month')) return prefix + real;
            // The relative string is year-granular, so a video within ~a year
            // of the set date can't be relativized precisely and otherwise
            // collapses to a fake "1 day ago" (negative diff from rounding).
            // The pool is date-bounded to roughly the ~6 months before the set
            // date, so spread these across a realistic recency window
            // (days → weeks → months) keyed off the id for stable variety —
            // instead of making every near-set-date video look brand new.
            const h = this._hash(videoId || innertubeText);
            const days = (h % 168) + 1;                 // 1..168 days (~5.5 months)
            if (days <= 1)  return prefix + '1 day ago';
            if (days <= 6)  return prefix + `${days} days ago`;
            if (days === 7) return prefix + '1 week ago';
            if (days < 30)  return prefix + `${Math.floor(days / 7)} weeks ago`;
            const months = Math.floor(days / 30.44);
            return prefix + (months <= 1 ? '1 month ago' : `${months} months ago`);
        }
    }

    // ============================================================
    //  INTEREST MODEL — channel/keyword scoring from watch history
    // ============================================================

    class InterestModel {
        static _YT_STOP = new Set([
            'official','video','full','new','part','episode','ep',
            'hd','4k','live','stream','clip','trailer','season',
            'ft','feat','vs','vol','remix','edit','reupload',
            'deleted','original','extended','version','subtitles',
        ]);
        static _STOP = new Set([
            'the','a','an','in','on','at','to','for','of','and','or','is','it',
            'my','we','i','you','this','that','with','from','by','be','as','are',
            'was','were','been','has','have','had','do','does','did','but','not',
            'so','if','no','yes',
        ]);

        static compute() {
            const watches = Store.getWatchHistory();
            const dislikes = Store.getDislikes();
            const now = Date.now();
            const channels = {};
            const keywords = {};
            for (const w of watches) {
                const ageDays = (now - w.ts) / 86400000;
                const decay = Math.pow(0.5, ageDays / 7);
                if (w.channelId) {
                    if (!channels[w.channelId]) channels[w.channelId] = { name: w.channel, score: 0 };
                    channels[w.channelId].score += decay;
                }
                if (w.title) {
                    const kws = w.title.replace(/[^\w\s]/g, '').split(/\s+/)
                        .filter(x => x.length > 2 && !this._STOP.has(x.toLowerCase()) && !this._YT_STOP.has(x.toLowerCase()));
                    for (const kw of kws.slice(0, 5)) {
                        const lower = kw.toLowerCase();
                        if (!keywords[lower]) keywords[lower] = { score: 0 };
                        keywords[lower].score += decay;
                    }
                }
            }
            for (const [id, p] of Object.entries(dislikes.channels || {})) if (channels[id]) channels[id].score -= p;
            for (const [kw, p] of Object.entries(dislikes.keywords || {})) if (keywords[kw]) keywords[kw].score -= p;
            try {
                const neg = InterestModel.computeNegativeSignals();
                for (const cc of neg.coldChannels) {
                    if (channels[cc.channelId]) channels[cc.channelId].score -= Math.min(cc.skipScore * 0.5, 3);
                }
                for (const kw of neg.autoNegKeywords) {
                    if (keywords[kw]) keywords[kw].score -= 1;
                }
            } catch (_) {}
            return { channels, keywords };
        }

        static getLearnedChannels(i) {
            return Object.entries(i.channels)
                .filter(([_, c]) => c.score >= 2)
                .sort((a, b) => b[1].score - a[1].score)
                .slice(0, 25)
                .map(([id, c]) => ({ channelId: id, name: c.name, score: c.score }));
        }
        static getLearnedKeywords(i) {
            return Object.entries(i.keywords)
                .filter(([_, k]) => k.score >= 3)
                .sort((a, b) => b[1].score - a[1].score)
                .slice(0, 15)
                .map(([kw, k]) => ({ keyword: kw, score: k.score }));
        }

        static computeNegativeSignals() {
            const fi = Store.getFeedImpressions();
            const channelSkips = {};
            const keywordSkips = {};
            for (const [_, data] of Object.entries(fi)) {
                if (data.impressions < 4) continue;
                const isSkip = !data.clicked;
                if (data.channelId) {
                    if (!channelSkips[data.channelId]) channelSkips[data.channelId] = { name: data.channel, shown: 0, clicked: 0 };
                    channelSkips[data.channelId].shown++;
                    if (!isSkip) channelSkips[data.channelId].clicked++;
                }
                if (isSkip && data.title) {
                    const words = data.title.replace(/[^\w\s]/g, '').split(/\s+/)
                        .filter(w => w.length > 2 && !this._STOP.has(w.toLowerCase()) && !this._YT_STOP.has(w.toLowerCase()));
                    for (const w of words.slice(0, 5)) {
                        const k = w.toLowerCase();
                        if (!keywordSkips[k]) keywordSkips[k] = { shown: 0 };
                        keywordSkips[k].shown++;
                    }
                }
            }
            const coldChannels = Object.entries(channelSkips)
                .filter(([_, c]) => c.shown >= 3 && c.clicked === 0)
                .map(([id, c]) => ({ channelId: id, name: c.name, skipScore: c.shown }));
            const autoNegKeywords = Object.entries(keywordSkips)
                .filter(([_, k]) => k.shown >= 3)
                .sort((a, b) => b[1].shown - a[1].shown)
                .slice(0, 20)
                .map(([kw]) => kw);
            return { coldChannels, autoNegKeywords };
        }

        static inferLanguageHints() {
            const watches = Store.getWatchHistory();
            const clicks = Store.getClickEvents();
            const allTitles = [...watches, ...clicks].map(e => e.title || '').filter(Boolean);
            let latin = 0, total = 0;
            for (const title of allTitles) {
                for (const ch of title) {
                    const cp = ch.codePointAt(0);
                    total++;
                    if (cp >= 0x0041 && cp <= 0x024F) latin++;
                }
            }
            const englishMarkers = /\b(the|and|for|with|this|that|from|have|will|your|about)\b/i;
            let englishScore = 0;
            for (const t of allTitles) if (englishMarkers.test(t)) englishScore++;
            const likelyEnglish = allTitles.length > 5 && (englishScore / allTitles.length) > 0.5;
            const latinDominant = total > 100 && (latin / total) > 0.8;
            return { latinDominant, likelyEnglish };
        }
    }

    // ============================================================
    //  YOUTUBE API — InnerTube (no API keys, uses page's auth cookies
    //  via the auth-trick: SAPISIDHASH header on GM_xmlhttpRequest).
    //  V3-only: always use GM_xmlhttpRequest. The page-fetch path is
    //  dropped because V3's Response patch would otherwise re-enter
    //  our interceptor on our own outbound calls.
    // ============================================================

    class YouTubeAPI {
        constructor() {
            this._lastRequest = 0;
            this._configCache = null;
            this._configCacheTs = 0;
        }

        _getCookie(name) {
            try {
                const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
                const m = win.document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
                return m ? decodeURIComponent(m[1]) : null;
            } catch { return null; }
        }

        async _getSapisidHash(origin) {
            const sapisid = this._getCookie('SAPISID') || this._getCookie('__Secure-3PAPISID');
            if (!sapisid) return null;
            const ts = Math.floor(Date.now() / 1000);
            try {
                const data = new TextEncoder().encode(`${ts} ${sapisid} ${origin}`);
                const buf = await crypto.subtle.digest('SHA-1', data);
                const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
                return `SAPISIDHASH ${ts}_${hex}`;
            } catch { return null; }
        }

        _getConfig() {
            if (this._configCache && Date.now() - this._configCacheTs < 30000) return this._configCache;
            let cfg = null;
            try {
                const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
                cfg = win.ytcfg && win.ytcfg.data_;
            } catch {}
            let fullContext = null;
            if (cfg && cfg.INNERTUBE_CONTEXT) {
                try { fullContext = JSON.parse(JSON.stringify(cfg.INNERTUBE_CONTEXT)); } catch {}
            }
            this._configCache = {
                apiKey: (cfg && cfg.INNERTUBE_API_KEY) || 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
                clientVersion: (cfg && cfg.INNERTUBE_CLIENT_VERSION) || '2.20260301.00.00',
                fullContext,
            };
            this._configCacheTs = Date.now();
            return this._configCache;
        }

        _buildContext(cfg) {
            return cfg.fullContext || { client: { clientName: 'WEB', clientVersion: cfg.clientVersion, hl: 'en', gl: 'US' } };
        }

        _postViaGM(url, body, headers) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'POST', url, headers, data: JSON.stringify(body), timeout: 15000,
                    onload(res) {
                        if (res.status >= 200 && res.status < 300) {
                            try { resolve(JSON.parse(res.responseText)); }
                            catch { reject(new Error('Invalid JSON')); }
                        } else {
                            const err = new Error(`InnerTube HTTP ${res.status}`);
                            err.status = res.status;
                            reject(err);
                        }
                    },
                    onerror() { reject(new Error('Network error')); },
                    ontimeout() { reject(new Error('Timed out')); },
                });
            });
        }

        async _rateLimit() {
            const wait = CONFIG.api.cooldownMs - (Date.now() - this._lastRequest);
            if (wait > 0) await new Promise(r => setTimeout(r, wait));
            this._lastRequest = Date.now();
        }

        async _post(endpoint, body) {
            await this._rateLimit();
            const cfg = this._getConfig();
            const url = `https://www.youtube.com/youtubei/v1/${endpoint}?key=${cfg.apiKey}&prettyPrint=false`;
            const fullBody = { context: this._buildContext(cfg), ...body };
            const headers = {
                'Content-Type': 'application/json',
                'X-YouTube-Client-Name': '1',
                'X-YouTube-Client-Version': cfg.clientVersion,
                'X-Origin': 'https://www.youtube.com',
                'Origin':   'https://www.youtube.com',
                'Referer':  'https://www.youtube.com/',
            };
            const auth = await this._getSapisidHash('https://www.youtube.com');
            if (auth) { headers['Authorization'] = auth; headers['X-Goog-AuthUser'] = '0'; }
            try { return await this._postViaGM(url, fullBody, headers); }
            catch (err) {
                if (err.status === 403 || (err.status >= 500 && err.status < 600)) {
                    this._configCache = null;
                    await new Promise(r => setTimeout(r, 1000));
                    const cfg2 = this._getConfig();
                    headers['X-YouTube-Client-Version'] = cfg2.clientVersion;
                    const auth2 = await this._getSapisidHash('https://www.youtube.com');
                    if (auth2) headers['Authorization'] = auth2;
                    fullBody.context = this._buildContext(cfg2);
                    return await this._postViaGM(`https://www.youtube.com/youtubei/v1/${endpoint}?key=${cfg2.apiKey}&prettyPrint=false`, fullBody, headers);
                }
                throw err;
            }
        }

        // ---- Parsers -----------------------------------------------

        _parseViewCount(t) {
            if (!t) return 0;
            const m = t.replace(/,/g, '').toLowerCase().match(/([\d.]+)\s*([kmb])?/);
            if (!m) return 0;
            const n = parseFloat(m[1]);
            return m[2] === 'b' ? n * 1e9 : m[2] === 'm' ? n * 1e6 : m[2] === 'k' ? n * 1e3 : n;
        }

        _parseSearchResults(data) {
            const out = [];
            const pushVideo = (v) => {
                if (!v || !v.videoId || !/^[A-Za-z0-9_-]{11}$/.test(v.videoId)) return;
                const viewText = v.viewCountText?.simpleText || v.viewCountText?.runs?.[0]?.text || '';
                out.push({
                    id: v.videoId,
                    title: v.title?.runs?.[0]?.text || '',
                    channel: v.ownerText?.runs?.[0]?.text || v.longBylineText?.runs?.[0]?.text || '',
                    channelId: v.ownerText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || '',
                    thumbnail: v.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
                    viewCount: this._parseViewCount(viewText),
                    viewCountFormatted: viewText || '0 views',
                    relativeDate: v.publishedTimeText?.simpleText || '',
                    duration: v.lengthText?.simpleText
                        || v.lengthText?.accessibility?.accessibilityData?.label
                        || (v.thumbnailOverlays || []).map(o => o?.thumbnailOverlayTimeStatusRenderer?.text?.simpleText).filter(Boolean)[0]
                        || '',
                });
            };
            const scanItems = (items) => {
                for (const item of items || []) pushVideo(item.videoRenderer);
            };
            try {
                // Initial search response.
                const sections = data?.contents?.twoColumnSearchResultsRenderer
                    ?.primaryContents?.sectionListRenderer?.contents || [];
                for (const section of sections) scanItems(section?.itemSectionRenderer?.contents);
                // Continuation response (page 2+).
                for (const c of data?.onResponseReceivedCommands || []) {
                    for (const cont of c?.appendContinuationItemsAction?.continuationItems || []) {
                        scanItems(cont?.itemSectionRenderer?.contents);
                    }
                }
            } catch (e) { console.warn('[bygone] parse error', e.message); }
            return out;
        }

        _parsePlaylistResults(data) {
            const out = [];
            try {
                const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
                let items = [];
                for (const tab of tabs) {
                    const contents = tab?.tabRenderer?.content?.sectionListRenderer?.contents
                        || tab?.tabRenderer?.content?.richGridRenderer?.contents || [];
                    for (const section of contents) {
                        const sectionItems = section?.itemSectionRenderer?.contents?.[0]
                            ?.playlistVideoListRenderer?.contents || [];
                        items.push(...sectionItems);
                    }
                }
                for (const item of items) {
                    const v = item.playlistVideoRenderer;
                    if (!v || !v.videoId || !/^[A-Za-z0-9_-]{11}$/.test(v.videoId)) continue;
                    const viewText = v.videoInfo?.runs?.[0]?.text || '';
                    out.push({
                        id: v.videoId,
                        title: v.title?.runs?.[0]?.text || v.title?.simpleText || '',
                        channel: v.shortBylineText?.runs?.[0]?.text || '',
                        channelId: v.shortBylineText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || '',
                        thumbnail: v.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
                        viewCount: this._parseViewCount(viewText),
                        viewCountFormatted: viewText || '0 views',
                        relativeDate: v.videoInfo?.runs?.[2]?.text || '',
                        duration: v.lengthText?.simpleText || '',
                    });
                }
            } catch (e) { console.warn('[bygone] playlist parse error', e.message); }
            return out;
        }

        _parseRelatedResults(data) {
            const out = [];
            try {
                const items = data?.contents?.twoColumnWatchNextResults?.secondaryResults?.secondaryResults?.results || [];
                for (const item of items) {
                    const v = item?.compactVideoRenderer;
                    if (!v || !v.videoId) continue;
                    const viewText = v.viewCountText?.simpleText || v.viewCountText?.runs?.[0]?.text || v.shortViewCountText?.simpleText || '';
                    out.push({
                        id: v.videoId,
                        title: v.title?.simpleText || v.title?.runs?.[0]?.text || '',
                        channel: v.longBylineText?.runs?.[0]?.text || v.shortBylineText?.runs?.[0]?.text || '',
                        channelId: v.longBylineText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId
                            || v.shortBylineText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || '',
                        thumbnail: v.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
                        viewCount: this._parseViewCount(viewText),
                        viewCountFormatted: viewText || '0 views',
                        relativeDate: v.publishedTimeText?.simpleText || '',
                        duration: v.lengthText?.simpleText || '',
                    });
                }
            } catch (e) { console.warn('[bygone] related parse error', e.message); }
            return out;
        }

        _parseChannelResults(data) {
            try {
                const sections = data?.contents?.twoColumnSearchResultsRenderer
                    ?.primaryContents?.sectionListRenderer?.contents || [];
                for (const section of sections) {
                    for (const item of section?.itemSectionRenderer?.contents || []) {
                        if (item.channelRenderer) {
                            return {
                                id: item.channelRenderer.channelId,
                                name: item.channelRenderer.title?.simpleText
                                    || item.channelRenderer.title?.runs?.[0]?.text || '',
                            };
                        }
                    }
                }
            } catch {}
            return null;
        }

        // ---- Search query builder ----------------------------------

        _buildDateQuery(query, after, before, negatives) {
            let q = query || '';
            if (after)  q += ` after:${(after instanceof Date ? after : new Date(after)).toISOString().split('T')[0]}`;
            if (before) q += ` before:${(before instanceof Date ? before : new Date(before)).toISOString().split('T')[0]}`;
            if (Array.isArray(negatives)) {
                for (const n of negatives) {
                    const t = String(n || '').trim();
                    if (!t) continue;
                    q += /\s/.test(t) ? ` -"${t}"` : ` -${t}`;
                }
            }
            return q.trim();
        }

        // ---- Public methods ----------------------------------------

        _allNegatives(extra) {
            const neg = [...(Array.isArray(extra) ? extra : []), ...Store.getGlobalNegatives()];
            try {
                const lang = InterestModel.inferLanguageHints();
                if (lang.likelyEnglish) neg.push('ITA', 'dublado', 'doblado', 'español', 'en español', 'hindi', 'tamil', 'telugu', 'bhojpuri', 'bollywood', 'русский', 'arabic', 'legendado', 'sottotitoli');
            } catch (_) {}
            return neg;
        }

        async searchVideos(query, { publishedAfter, publishedBefore, maxResults, order = 'relevance', categoryId, negatives, strictMatch, maxPages } = {}) {
            const allNeg = this._allNegatives(negatives);
            let q = this._buildDateQuery(query, publishedAfter, publishedBefore, allNeg);
            if (categoryId && CONFIG.categories[categoryId]) q = `${CONFIG.categories[categoryId]} ${q}`.trim();
            const params = order === 'viewCount' ? 'CAMSAhAB' : 'EgIQAQ==';
            const want = maxResults || CONFIG.api.maxResults;
            const pageCap = Math.max(1, maxPages || CONFIG.api.maxSearchPages || 1);

            const strictRe = strictMatch
                ? new RegExp(`\\b${strictMatch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i')
                : null;
            // CLIENT-SIDE DATE BOUNDING. YouTube applies the `before:`/`after:`
            // search operators loosely on broad/popular queries and returns
            // modern videos anyway, so each page is filtered here. Undated
            // results are kept (rare; matches the channel path) — modern
            // pollution carries a recent relative date and is dropped.
            const aMin = publishedAfter ? new Date(publishedAfter) : null;
            const aMax = publishedBefore ? new Date(publishedBefore) : null;
            const inWindow = (v) => {
                if (!aMin && !aMax) return true;
                const d = DateHelper.approxPublishDate(v.relativeDate);
                if (!d) return true;
                if (aMin && d < aMin) return false;
                if (aMax && d > aMax) return false;
                return true;
            };

            // PAGINATE via continuation tokens. The first page of a search is
            // only ~20 results; the response ends with a continuation token
            // that fetches the next ~20 (still inside the date window), and so
            // on. The date filter drops a lot per page, so we keep paging until
            // we have `want` valid results or run out of pages/token. This is
            // what lets a single era query surface far more than one page.
            const collected = [];
            const seen = new Set();
            let token = null;
            for (let page = 0; page < pageCap; page++) {
                let data;
                try { data = await this._post('search', token ? { continuation: token } : { query: q, params }); }
                catch (_) { break; }
                const results = this._parseSearchResults(data);
                for (const v of results) {
                    if (!v || !v.id || seen.has(v.id)) continue;
                    if (strictRe && !strictRe.test(v.title || '')) continue;
                    if (!inWindow(v)) continue;
                    seen.add(v.id);
                    collected.push(v);
                }
                if (collected.length >= want) break;
                token = this._extractSearchContinuation(data);
                if (!token) break;
            }
            return collected.slice(0, want);
        }

        // Pull the search continuation token from either an initial search
        // response (continuationItemRenderer inside the sectionList) or a
        // continuation response (appendContinuationItemsAction).
        _extractSearchContinuation(data) {
            const fromItems = (items) => {
                for (const it of items || []) {
                    const t = it?.continuationItemRenderer?.continuationEndpoint
                        ?.continuationCommand?.token;
                    if (t) return t;
                }
                return null;
            };
            try {
                const sects = data?.contents?.twoColumnSearchResultsRenderer
                    ?.primaryContents?.sectionListRenderer?.contents || [];
                const t1 = fromItems(sects);
                if (t1) return t1;
                for (const s of sects) {
                    const t = fromItems(s?.itemSectionRenderer?.contents);
                    if (t) return t;
                }
                for (const c of data?.onResponseReceivedCommands || []) {
                    const t = fromItems(c?.appendContinuationItemsAction?.continuationItems);
                    if (t) return t;
                }
            } catch (_) {}
            return null;
        }

        async getChannelVideos(channelName, { publishedAfter, publishedBefore, maxResults, channelId } = {}) {
            if (channelId && channelId.startsWith('UC')) {
                try {
                    const data = await this._post('browse', { browseId: `VL${'UU' + channelId.slice(2)}` });
                    const results = this._parsePlaylistResults(data);
                    const filtered = results.filter(v => {
                        const a = DateHelper.approxPublishDate(v.relativeDate);
                        if (!a) return true;
                        if (publishedAfter  && a < new Date(publishedAfter))  return false;
                        if (publishedBefore && a > new Date(publishedBefore)) return false;
                        return true;
                    });
                    if (filtered.length) return filtered.slice(0, maxResults || CONFIG.api.maxResults);
                } catch (e) { /* fall through to search */ }
            }
            const q = this._buildDateQuery(channelName, publishedAfter, publishedBefore, this._allNegatives());
            const data = await this._post('search', { query: q, params: 'EgIQAQ==' });
            let results = this._parseSearchResults(data);
            if (channelId) {
                results = results.filter(v => v.channelId === channelId);
            } else {
                const lc = channelName.toLowerCase();
                results = results.filter(v => (v.channel || '').toLowerCase() === lc);
            }
            return results.slice(0, maxResults || CONFIG.api.maxResults);
        }

        async getPopularByCategory(categoryId, opts) {
            return this.searchVideos('', { ...opts, categoryId, order: 'relevance' });
        }

        async getRelatedVideos(videoId) {
            try { return this._parseRelatedResults(await this._post('next', { videoId })); }
            catch { return []; }
        }

        // Exact publish date (ISO) for a video from its watch page's primary
        // info date text. /next is already used for related videos and is less
        // bot-gated than /player.
        async fetchExactDate(videoId) {
            if (!videoId) return null;
            try {
                const data = await this._post('next', { videoId });
                const conts = data?.contents?.twoColumnWatchNextResults
                    ?.results?.results?.contents || [];
                for (const c of conts) {
                    const dt = c?.videoPrimaryInfoRenderer?.dateText;
                    const text = dt?.simpleText || (dt?.runs || []).map(r => r.text).join('');
                    const iso = DateHelper.parseExactDateText(text);
                    if (iso) return iso;
                }
            } catch (_) {}
            return null;
        }

        async resolveChannel(input) {
            const q = input.startsWith('@') ? input : `"${input}"`;
            const data = await this._post('search', { query: q, params: 'EgIQAg==' });
            return this._parseChannelResults(data);
        }

        // Subscribe (or unsubscribe) the logged-in user to one or more
        // channels via InnerTube. Uses the same auth-trick as everything
        // else, so the user must already be logged in to YouTube in this
        // browser session.
        async subscribeToChannel(channelId) {
            if (!channelId) return false;
            try {
                await this._post('subscription/subscribe', { channelIds: [channelId] });
                return true;
            } catch (e) {
                console.warn('[bygone] subscribe failed for', channelId, '—', e.message);
                return false;
            }
        }
        async unsubscribeFromChannel(channelId) {
            if (!channelId) return false;
            try {
                await this._post('subscription/unsubscribe', { channelIds: [channelId] });
                return true;
            } catch (e) {
                console.warn('[bygone] unsubscribe failed for', channelId, '—', e.message);
                return false;
            }
        }
    }

    // ============================================================
    //  FEED ENGINE — merge 5 sources (subs / search / categories /
    //  topics / similar) + trending; dedup; weight; date-window
    //  ending at the set date (no forward grace).
    // ============================================================

    class FeedEngine {
        constructor(api) { this.api = api; }

        static _tag(videos, source, detail) {
            for (const v of videos) if (v) { v.source = source; v.sourceDetail = detail; }
            return videos;
        }

        _dateWindow(selectedDate) {
            const d = new Date(selectedDate);
            const days = CONFIG.feed.dateWindowDays;
            const after = new Date(d);  after.setDate(after.getDate() - days);
            const before = new Date(d); before.setDate(before.getDate() + days);
            return { after, before, center: d };
        }

        _interleave(batches) {
            const out = [];
            const longest = Math.max(0, ...batches.map(b => b.length));
            for (let i = 0; i < longest; i++) for (const b of batches) if (i < b.length) out.push(b[i]);
            return out;
        }

        _dedupe(videos) {
            const seen = new Set();
            const blockedNames = new Set(Store.getBlockedChannels().map(b => b.name.toLowerCase()));
            const blockedIds = new Set(Store.getBlockedChannels().map(b => b.id).filter(Boolean));
            const hidden = new Set(Store.getHiddenIds());
            let coldIds = new Set();
            try {
                const neg = InterestModel.computeNegativeSignals();
                coldIds = new Set(neg.coldChannels.filter(c => c.skipScore >= 5).map(c => c.channelId));
            } catch (_) {}
            return videos.filter(v => {
                if (!v || seen.has(v.id)) return false;
                if (v.channel && blockedNames.has(v.channel.toLowerCase())) return false;
                if (v.channelId && blockedIds.has(v.channelId)) return false;
                if (v.channelId && coldIds.has(v.channelId)) return false;
                if (hidden.has(v.id)) return false;
                seen.add(v.id);
                return true;
            });
        }

        // Soft bias toward videos near the chosen date. Flatter falloff
        // (^0.3) so older material still surfaces.
        _weightedShuffle(videos, centerDate) {
            const center = new Date(centerDate).getTime();
            const weighted = videos.map(v => {
                let pub = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
                if (!pub || isNaN(pub)) {
                    const d = DateHelper.approxPublishDate(v.relativeDate);
                    pub = d ? d.getTime() : center;
                }
                const daysDiff = Math.max(1, Math.abs(center - pub) / 86400000);
                return { v, sort: Math.random() / Math.pow(daysDiff, 0.3), source: v.source || '' };
            });
            weighted.sort((a, b) => b.sort - a.sort);
            const result = [];
            const deferred = [];
            let lastSrc = '', consecutive = 0;
            for (const w of weighted) {
                if (w.source === lastSrc) { consecutive++; if (consecutive > 2) { deferred.push(w); continue; } }
                else { lastSrc = w.source; consecutive = 1; }
                result.push(w);
            }
            let insertIdx = 1;
            for (const d of deferred) { result.splice(Math.min(insertIdx, result.length), 0, d); insertIdx += 3; }
            return result.map(w => w.v);
        }

        // ---- Source collection ------------------------------------

        _collectSubscriptions() {
            const subs = Store.getSubscriptions();
            const explicitIds = new Set(subs.map(s => s.id).filter(Boolean));
            const explicitNames = new Set(subs.map(s => s.name.toLowerCase()));
            const result = [...subs];
            if (Store.isLearningEnabled()) {
                const interests = Store.getCachedInterests();
                if (interests) for (const lc of InterestModel.getLearnedChannels(interests)) {
                    if (!explicitIds.has(lc.channelId)) {
                        result.push({ id: lc.channelId, name: lc.name, weight: Math.min(3, Math.round(lc.score)), _learned: true });
                        explicitIds.add(lc.channelId);
                        explicitNames.add(lc.name.toLowerCase());
                    }
                }
            }
            return result;
        }

        _normalizeTerm(raw) {
            if (typeof raw === 'string') return { term: raw, weight: 3 };
            return {
                term: raw.term || '',
                weight: raw.weight || 3,
                negatives: Array.isArray(raw.negatives) ? raw.negatives : [],
                strict: !!raw.strict,
                categoryBias: raw.categoryBias || null,
            };
        }

        _normalizeTopic(raw) {
            if (typeof raw === 'string') return { name: raw, weight: 3 };
            return {
                name: raw.name || '',
                weight: raw.weight || 3,
                negatives: Array.isArray(raw.negatives) ? raw.negatives : [],
                strict: !!raw.strict,
                categoryBias: raw.categoryBias || null,
            };
        }

        _collectSearchTerms() {
            const terms = Store.getSearchTerms().map(t => this._normalizeTerm(t));
            if (Store.isLearningEnabled()) {
                const interests = Store.getCachedInterests();
                if (interests) {
                    const existing = new Set(terms.map(t => t.term.toLowerCase()));
                    for (const lk of InterestModel.getLearnedKeywords(interests)) {
                        if (!existing.has(lk.keyword)) {
                            terms.push({ term: lk.keyword, weight: 2, _learned: true, negatives: [], strict: false, categoryBias: null });
                        }
                    }
                }
            }
            return terms;
        }

        // Unified source fetcher: caches optional. Each fetcher returns an array.
        async _fetchSubs(dw, count, useCache) {
            const subs = this._collectSubscriptions();
            if (!subs.length) return [];
            const key = `subs_${dw.center.toDateString()}`;
            if (useCache) {
                const c = Store.getCacheEntry(key, CONFIG.cache.subscriptions);
                if (c) return c;
            }
            const totalW = subs.reduce((s, x) => s + (x.weight || 3), 0);
            const batches = await Promise.allSettled(subs.map(async sub => {
                if (!sub.id && !sub._learned) {
                    try {
                        const ch = await this.api.resolveChannel(sub.name);
                        if (ch && ch.id) {
                            sub.id = ch.id;
                            sub.name = ch.name || sub.name;
                            const stored = Store.getSubscriptions();
                            const match = stored.find(s => s.name.toLowerCase() === sub.name.toLowerCase() || (s.id && s.id === ch.id));
                            if (match && !match.id) { match.id = ch.id; match.name = ch.name || match.name; Store.setSubscriptions(stored); }
                        }
                    } catch (_) {}
                }
                const w = sub.weight || 3;
                const per = Math.max(3, Math.ceil(count * w / totalW));
                const videos = await this.api.getChannelVideos(sub.name, {
                    publishedAfter: dw.after, publishedBefore: dw.before,
                    maxResults: per, channelId: sub.id,
                });
                const detail = sub._learned ? `Learned: ${sub.name}` : `Subscription: ${sub.name}`;
                return FeedEngine._tag(videos, 'subscriptions', detail);
            }));
            const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
            if (useCache && out.length) Store.setCacheEntry(key, out);
            return out;
        }

        async _fetchSearch(dw, count, useCache) {
            const terms = this._collectSearchTerms();
            if (!terms.length) return [];
            const key = `search_${dw.center.toDateString()}`;
            if (useCache) {
                const c = Store.getCacheEntry(key, CONFIG.cache.searchTerms);
                if (c) return c;
            }
            const totalW = terms.reduce((s, x) => s + (x.weight || 3), 0);
            const batches = await Promise.allSettled(terms.map(async t => {
                const w = t.weight || 3;
                const per = Math.max(3, Math.ceil(count * w / totalW));
                const videos = await this.api.searchVideos(t.term, {
                    publishedAfter: dw.after, publishedBefore: dw.before, maxResults: per,
                    negatives: t.negatives, strictMatch: t.strict ? t.term : null, categoryId: t.categoryBias,
                });
                return FeedEngine._tag(videos, 'searchTerms', `Search: "${t.term}"`);
            }));
            const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
            if (useCache && out.length) Store.setCacheEntry(key, out);
            return out;
        }

        async _fetchCategories(dw, count, useCache) {
            const cats = Store.getCategories();
            if (!cats.length) return [];
            const key = `cats_${cats.join('_')}_${dw.center.toDateString()}`;
            if (useCache) {
                const c = Store.getCacheEntry(key, CONFIG.cache.categories);
                if (c) return c;
            }
            const per = Math.max(5, Math.ceil(count / cats.length));
            const batches = await Promise.allSettled(cats.map(async id => {
                const videos = await this.api.getPopularByCategory(id, {
                    publishedAfter: dw.after, publishedBefore: dw.before, maxResults: per,
                });
                return FeedEngine._tag(videos, 'categories', `Category: ${CONFIG.categories[id] || id}`);
            }));
            const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
            if (useCache && out.length) Store.setCacheEntry(key, out);
            return out;
        }

        async _fetchTopics(dw, count, useCache) {
            const topics = Store.getTopics().map(t => this._normalizeTopic(t));
            if (!topics.length) return [];
            const key = `topics_${dw.center.toDateString()}`;
            if (useCache) {
                const c = Store.getCacheEntry(key, CONFIG.cache.topics);
                if (c) return c;
            }
            const totalW = topics.reduce((s, x) => s + (x.weight || 3), 0);
            const batches = await Promise.allSettled(topics.map(async topic => {
                const w = topic.weight || 3;
                const per = Math.max(3, Math.ceil(count * w / totalW));
                const videos = await this.api.searchVideos(topic.name, {
                    publishedAfter: dw.after, publishedBefore: dw.before, maxResults: per,
                    negatives: topic.negatives, strictMatch: topic.strict ? topic.name : null, categoryId: topic.categoryBias,
                });
                return FeedEngine._tag(videos, 'topics', `Topic: "${topic.name}"`);
            }));
            const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
            if (useCache && out.length) Store.setCacheEntry(key, out);
            return out;
        }

        // Trending: random discovery queries sorted by view count.
        _buildDiscoveryQueries() {
            const queries = new Set();
            const interests = Store.getCachedInterests();
            if (interests) {
                const keywords = Object.entries(interests.keywords)
                    .filter(([_, k]) => k.score >= 1)
                    .sort((a, b) => b[1].score - a[1].score)
                    .slice(0, 15)
                    .map(([kw]) => kw);
                for (let i = 0; i < keywords.length; i++) {
                    queries.add(keywords[i]);
                    if (i + 1 < keywords.length) queries.add(keywords[i] + ' ' + keywords[i + 1]);
                }
            }
            const searchTerms = Store.getSearchTerms();
            for (const raw of searchTerms) {
                const term = typeof raw === 'string' ? raw : raw.term;
                if (term) { queries.add(term); queries.add(term + ' review'); }
            }
            const subs = Store.getSubscriptions();
            for (const sub of subs.slice(0, 5)) { if (sub.name) queries.add(sub.name); }
            const searchHistory = Store.getSearchHistory();
            for (const entry of searchHistory.slice(-10)) { if (entry.query) queries.add(entry.query); }
            if (queries.size < 4) {
                for (const f of ['review', 'tutorial', 'documentary', 'explained', 'analysis']) queries.add(f);
            }
            return Array.from(queries);
        }

        async _fetchTrending(dw, count, useCache) {
            if (!Store.isDiscoveryEnabled()) return [];
            const key = `trending_${dw.center.toDateString()}`;
            if (useCache) {
                const c = Store.getCacheEntry(key, CONFIG.cache.trending);
                if (c) return c;
            }
            const pool = this._buildDiscoveryQueries();
            const picked = [];
            for (let i = 0; i < 6 && pool.length; i++) {
                picked.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
            }
            if (!picked.length) picked.push('');
            let autoNeg = [];
            try { autoNeg = InterestModel.computeNegativeSignals().autoNegKeywords.slice(0, 10); } catch (_) {}
            const per = Math.max(5, Math.ceil(count / picked.length));
            const batches = await Promise.allSettled(picked.map(async q => {
                const videos = await this.api.searchVideos(q, {
                    negatives: autoNeg,
                    publishedAfter: dw.after, publishedBefore: dw.before, maxResults: per, order: 'relevance',
                });
                return FeedEngine._tag(videos, 'trending', q ? `Discover: "${q}"` : 'Discover');
            }));
            const out = batches.filter(r => r.status === 'fulfilled').flatMap(r => r.value);
            if (useCache && out.length) Store.setCacheEntry(key, out);
            return out;
        }

        // CF "Similar": harvest /next sidebar for recent watch seeds.
        async _fetchSimilar(dw, count, useCache) {
            if (!Store.isSimilarEnabled()) return [];
            const key = `similar_${dw.center.toDateString()}`;
            if (useCache) {
                const c = Store.getCacheEntry(key, CONFIG.cache.similar);
                if (c) return c;
            }
            const seeds = await this._pickSimilarSeeds();
            if (!seeds.length) return [];
            const batches = await Promise.allSettled(seeds.map(async seed => {
                const related = await this.api.getRelatedVideos(seed.videoId);
                const filtered = related.filter(v => {
                    const a = DateHelper.approxPublishDate(v.relativeDate);
                    if (!a) return true;
                    return a >= dw.after && a <= dw.before;
                });
                return FeedEngine._tag(filtered, 'similar', `Similar to: ${seed.label}`);
            }));
            const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value)).slice(0, count);
            if (useCache && out.length) Store.setCacheEntry(key, out);
            return out;
        }

        async _pickSimilarSeeds() {
            const recent = Store.getWatchHistory().slice().reverse().filter(w => w.videoId).slice(0, 8);
            const seeds = [];
            const usedChannels = new Set();
            for (const w of recent) {
                if (w.channelId && usedChannels.has(w.channelId)) continue;
                seeds.push({ videoId: w.videoId, label: w.title || w.channel || 'recent watch' });
                if (w.channelId) usedChannels.add(w.channelId);
                if (seeds.length >= 3) break;
            }
            if (seeds.length >= 3) return seeds;
            const interests = Store.getCachedInterests();
            if (interests) for (const lc of InterestModel.getLearnedChannels(interests)) {
                if (seeds.length >= 3) break;
                if (usedChannels.has(lc.channelId)) continue;
                try {
                    const v = await this.api.getChannelVideos(lc.name, { maxResults: 1, channelId: lc.channelId });
                    if (v.length) { seeds.push({ videoId: v[0].id, label: lc.name }); usedChannels.add(lc.channelId); }
                } catch {}
            }
            return seeds;
        }

        // RELATED FAN-OUT. Harvest the related-videos sidebar (/next) of the
        // era videos we already pooled. Neighbours of an era video are mostly
        // era videos, so this deepens the pool from billions of candidates
        // without inventing search terms. The /next endpoint takes no date
        // operator, so results are STRICTLY date-filtered and undated ones are
        // dropped (related lists are full of present-day recommendations).
        async _fetchRelatedExpansion(seedVideos, dw, count) {
            const seeds = [];
            const usedCh = new Set();
            for (const v of seedVideos) {
                if (!v || !v.id) continue;
                if (v.channelId && usedCh.has(v.channelId)) continue;  // spread seeds across channels
                seeds.push(v);
                if (v.channelId) usedCh.add(v.channelId);
                if (seeds.length >= 8) break;
            }
            if (!seeds.length) return [];
            const batches = await Promise.allSettled(seeds.map(async s => {
                const related = await this.api.getRelatedVideos(s.id);
                const filtered = related.filter(r => {
                    const a = DateHelper.approxPublishDate(r.relativeDate);
                    if (!a) return false;                       // drop undated (likely modern)
                    return a >= dw.after && a <= dw.before;     // strict era window
                });
                return FeedEngine._tag(filtered, 'related', `Related to: ${s.title || s.id}`);
            }));
            const out = this._interleave(batches.filter(r => r.status === 'fulfilled').map(r => r.value));
            return out.slice(0, count);
        }

        // Boost subs+search when learning has signal; drain from trending+similar.
        _effectiveWeights() {
            if (!Store.isLearningEnabled()) return CONFIG.feed.weights;
            const i = Store.getCachedInterests();
            if (!i) return CONFIG.feed.weights;
            const lc = InterestModel.getLearnedChannels(i).length;
            const lk = InterestModel.getLearnedKeywords(i).length;
            const w = { ...CONFIG.feed.weights };
            const subBoost = Math.min(0.10, lc * 0.02);
            const termBoost = Math.min(0.05, lk * 0.01);
            w.subscriptions += subBoost;
            w.searchTerms += termBoost;
            const drain = (subBoost + termBoost) / 2;
            w.trending = Math.max(0.05, w.trending - drain);
            w.similar  = Math.max(0.05, (w.similar || 0) - drain);

            const stats = Store.getSourceStats();
            const sourceKeys = Object.keys(w);
            const ctrs = {};
            let hasEnoughData = false;
            for (const key of sourceKeys) {
                const s = stats[key];
                if (s && s.impressions >= 20) { ctrs[key] = s.clicks / s.impressions; hasEnoughData = true; }
                else ctrs[key] = null;
            }
            if (hasEnoughData) {
                const validCtrs = Object.values(ctrs).filter(v => v !== null);
                const avgCtr = validCtrs.reduce((s, v) => s + v, 0) / validCtrs.length;
                for (const key of sourceKeys) {
                    if (ctrs[key] === null) continue;
                    const adj = Math.max(-0.08, Math.min(0.08, (ctrs[key] - avgCtr) * 2));
                    w[key] = Math.max(0.03, w[key] + adj);
                }
                const total = Object.values(w).reduce((s, v) => s + v, 0);
                for (const key of sourceKeys) w[key] /= total;
            }

            return w;
        }

        _mixSources(sources, weights) {
            const w = weights || CONFIG.feed.weights;
            const total = CONFIG.feed.maxHomepageVideos;
            const take = (arr, n) => [...arr].sort(() => Math.random() - 0.5).slice(0, n);
            const counts = {
                subscriptions: Math.round(total * (w.subscriptions || 0)),
                searchTerms:   Math.round(total * (w.searchTerms   || 0)),
                categories:    Math.round(total * (w.categories    || 0)),
                topics:        Math.round(total * (w.topics        || 0)),
                similar:       Math.round(total * (w.similar       || 0)),
                trending:      Math.round(total * (w.trending      || 0)),
            };
            const mixed = [];
            for (const [k, n] of Object.entries(counts)) mixed.push(...take(sources[k] || [], n));
            if (mixed.length < total) {
                const ids = new Set(mixed.map(v => v.id));
                const extras = Object.values(sources).flat().filter(v => v && !ids.has(v.id));
                mixed.push(...take(extras, total - mixed.length));
            }
            return mixed;
        }

        // Internal: race the build against a 30s timeout so loading can't hang.
        async _buildWithTimeout(promise) {
            return Promise.race([promise, new Promise((_, rej) => setTimeout(() => rej(new Error('Feed build timed out')), 30000))]);
        }

        // useCache:false — InnerTube fetches are free, so pull a fresh
        // feed from the live API on every page load instead of serving
        // a stale cached batch. The random shuffle in _mixSources/
        // _weightedShuffle plus the impression park (recordImpressions)
        // mean every refresh surfaces a different set of pool videos.
        async buildHomeFeed(selectedDate) { return this._buildWithTimeout(this._build(selectedDate, false)); }

        async buildHomeFeedMore(selectedDate, page, excludeIds) {
            const d = new Date(selectedDate);
            d.setDate(d.getDate() - CONFIG.feed.dateWindowDays * 2 * (page - 1));
            const out = await this._build(d.toISOString().split('T')[0], false);
            const excl = excludeIds instanceof Set ? excludeIds : new Set(excludeIds || []);
            return out.filter(v => !excl.has(v.id));
        }

        _enforceDiversity(videos) {
            const MAX_PER_CHANNEL = 3;
            const MAX_PER_SOURCE = Math.ceil(videos.length * 0.35);
            const channelCounts = {};
            const sourceCounts = {};
            const kept = [];
            const deferred = [];
            for (const v of videos) {
                const chKey = v.channelId || v.channel || 'unknown';
                const srcKey = v.source || 'unknown';
                const chCount = channelCounts[chKey] || 0;
                const srcCount = sourceCounts[srcKey] || 0;
                if (chCount >= MAX_PER_CHANNEL || srcCount >= MAX_PER_SOURCE) {
                    deferred.push(v);
                    continue;
                }
                channelCounts[chKey] = chCount + 1;
                sourceCounts[srcKey] = srcCount + 1;
                kept.push(v);
            }
            if (kept.length < 100) for (const v of deferred) { kept.push(v); if (kept.length >= videos.length) break; }
            return kept;
        }

        async _build(selectedDate, useCache) {
            const total = CONFIG.feed.maxHomepageVideos;
            const anchor = new Date(selectedDate);
            // No future-grace on videos. The small +7d edge on each window
            // is just smoothing for YouTube's approximate relative-date
            // strings ("1 day ago" can fall a couple days either side of
            // the actual upload), not a deliberate grace period.
            const smoothMs = 7 * 86400000;
            const subsWindow = this._dateWindow(selectedDate);
            const queryWindow    = { after: new Date(anchor - 90  * 86400000), before: new Date(anchor.getTime() + smoothMs), center: anchor };
            const catWindow      = { after: new Date(anchor - 180 * 86400000), before: new Date(anchor.getTime() + smoothMs), center: anchor };
            const trendingWindow = { after: new Date(anchor - 365 * 86400000), before: new Date(anchor.getTime() + smoothMs), center: anchor };

            const loadNum = Store.incrementLoadCount();
            const explore = loadNum % 10 === 0;
            const weights = explore ? CONFIG.feed.weights : this._effectiveWeights();

            const results = await Promise.allSettled([
                this._fetchSubs      (subsWindow,     Math.round(total * weights.subscriptions * 2), useCache),
                this._fetchSearch    (queryWindow,    Math.round(total * weights.searchTerms   * 2), useCache),
                this._fetchCategories(catWindow,      Math.round(total * weights.categories    * 2), useCache),
                this._fetchTopics    (queryWindow,    Math.round(total * weights.topics        * 2), useCache),
                this._fetchSimilar   (subsWindow,     Math.round(total * weights.similar       * 2), useCache),
                this._fetchTrending  (trendingWindow, Math.round(total * weights.trending      * 2), useCache),
            ]);
            const val = (i) => results[i].status === 'fulfilled' ? results[i].value : [];
            const err = (i) => results[i].status === 'rejected' ? (results[i].reason && results[i].reason.message) || 'rejected' : '';
            // Per-source diagnostic: tells us at a glance which fetchers
            // are returning videos and which are silently empty/failing.
            console.log('[bygone] sources:',
                'subs=' + val(0).length + (err(0) ? '(err:'+err(0)+')' : ''),
                'search=' + val(1).length + (err(1) ? '(err:'+err(1)+')' : ''),
                'cats=' + val(2).length + (err(2) ? '(err:'+err(2)+')' : ''),
                'topics=' + val(3).length + (err(3) ? '(err:'+err(3)+')' : ''),
                'similar=' + val(4).length + (err(4) ? '(err:'+err(4)+')' : ''),
                'trending=' + val(5).length + (err(5) ? '(err:'+err(5)+')' : ''));
            const mixed = this._mixSources({
                subscriptions: val(0), searchTerms: val(1), categories: val(2),
                topics:        val(3), similar:     val(4), trending:    val(5),
            }, weights);

            let deduped = this._dedupe(mixed);
            // RELATED FAN-OUT. If the keyword/category/trending pool came back
            // below target (deep/sparse eras, or a near-sourceless profile),
            // expand it by harvesting the related sidebar of the era videos we
            // already have. Seeds come from the pool itself, so it works on any
            // date and needs no watch history (unlike _fetchSimilar).
            if (deduped.length < total) {
                try {
                    const related = await this._fetchRelatedExpansion(deduped, trendingWindow, total - deduped.length);
                    if (related.length) {
                        deduped = this._dedupe([...deduped, ...related]);
                        console.log('[bygone] related fan-out added', related.length, '→ pool', deduped.length);
                    }
                } catch (_) {}
            }
            const diverse = this._enforceDiversity(deduped);
            let visible = diverse.filter(v => !Store.isImpressionHidden(v.id));
            console.log('[bygone] feed build: mixed=' + mixed.length + ' deduped=' + deduped.length + ' visible=' + visible.length);
            // Safety valve: if the impression-park filter has whittled
            // the visible pool below a usable threshold, fall back to
            // the unfiltered pool. Better to show seen videos than to
            // show the same 3-4 cards everywhere.
            if (visible.length < 30 && deduped.length > visible.length) visible = deduped;

            // Push recently seen videos to the back so refreshes feel fresh.
            const seen = new Set(Store.getSeenIds());
            const unseen = visible.filter(v => !seen.has(v.id));
            const seenVids = visible.filter(v => seen.has(v.id));
            const ordered = [
                ...this._weightedShuffle(unseen,   anchor),
                ...this._weightedShuffle(seenVids, anchor),
            ];
            // Always record impressions (not gated on useCache) so the
            // park logic keeps running — that's what stops the same
            // videos reappearing across refreshes. Slice is small (20)
            // so that only videos actually likely to be SHOWN this load
            // get counted; recording the whole 150-video pool would mean
            // a couple of reloads parks everything the API returns.
            const top20 = ordered.slice(0, 20);
            Store.recordImpressions(top20.map(v => v.id));
            try {
                Store.recordFeedImpressions(top20);
                for (const v of top20) Store.recordSourceImpression(v.source || 'unknown');
            } catch (_) {}
            return ordered;
        }
    }

    // ============================================================
    //  UI — floating panel + FAB. Panel-only CSS (no YouTube-layout
    //  overrides; V3 already provides the 2013 look).
    // ============================================================

    // CSS uses !important throughout because V3 / YouTube CSS targets
    // a lot of selectors aggressively (e.g. `body button { display: none }`
    // in some V3 layouts) and would otherwise hide our FAB.
    const CSS = `
        #wbt-fab {
            position: fixed !important;
            bottom: 18px !important; right: 18px !important;
            z-index: 2147483646 !important;
            width: 48px !important; height: 48px !important;
            border-radius: 50% !important;
            background: #c00 !important; color: #fff !important;
            border: 1px solid #800 !important;
            font: bold 18px sans-serif !important;
            cursor: pointer !important;
            box-shadow: 0 2px 8px rgba(0,0,0,.4) !important;
            display: block !important; visibility: visible !important; opacity: 1 !important;
            padding: 0 !important; margin: 0 !important;
        }
        #wbt-fab:hover { background: #e00 !important; }
        #wbt-panel {
            position: fixed !important;
            bottom: 78px !important; right: 18px !important;
            z-index: 2147483647 !important;
            width: 340px !important; max-height: 78vh !important; overflow-y: auto !important;
            background: #f5f5f5 !important; color: #222 !important;
            border: 1px solid #888 !important; border-radius: 4px !important;
            box-shadow: 0 2px 12px rgba(0,0,0,.4) !important;
            font: 12px sans-serif !important;
            visibility: visible !important; opacity: 1 !important;
        }
        #wbt-panel.wbt-hidden { display: none !important; }
        .wbt-h { background: linear-gradient(#e8e8e8, #d4d4d4); padding: 8px 12px;
            font-weight: bold; border-bottom: 1px solid #aaa; cursor: move; }
        .wbt-close { float: right; cursor: pointer; color: #666; font-weight: normal; }
        .wbt-close:hover { color: #c00; }
        .wbt-body { padding: 10px 12px; }
        .wbt-sec { margin-bottom: 14px; }
        .wbt-sec h4 { margin: 0 0 6px; font-size: 12px; color: #333;
            border-bottom: 1px dotted #aaa; padding-bottom: 3px; }
        .wbt-row { display: flex; gap: 6px; align-items: center; margin: 4px 0; }
        .wbt-row input[type="text"], .wbt-row input[type="date"], .wbt-row input[type="number"],
        .wbt-row select { flex: 1; padding: 3px 5px; border: 1px solid #aaa;
            border-radius: 2px; font: 12px sans-serif; min-width: 0; }
        .wbt-btn { padding: 3px 9px; border: 1px solid #888; background: #ddd;
            border-radius: 2px; cursor: pointer; font: 12px sans-serif; white-space: nowrap; }
        .wbt-btn:hover { background: #e8e8e8; }
        .wbt-btn-primary { background: #c00; color: #fff; border-color: #800; }
        .wbt-btn-primary:hover { background: #e00; }
        .wbt-btn-x { padding: 0 6px; font-weight: bold; color: #c00; }
        .wbt-list { background: #fff; border: 1px solid #aaa; border-radius: 2px;
            min-height: 28px; max-height: 110px; overflow-y: auto; }
        .wbt-item { padding: 3px 6px; border-bottom: 1px dotted #ddd;
            display: flex; align-items: center; gap: 6px; cursor: grab; }
        .wbt-item:last-child { border-bottom: 0; }
        .wbt-item.dragging { opacity: .4; }
        .wbt-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
        .wbt-item-weight { width: 32px; }
        .wbt-toggle { display: flex; align-items: center; gap: 6px; margin: 3px 0; cursor: pointer; }
        .wbt-tabs { display: flex; gap: 0; border-bottom: 1px solid #aaa; }
        .wbt-tab { padding: 5px 10px; cursor: pointer; border: 1px solid transparent;
            border-bottom: 0; font-size: 11px; }
        .wbt-tab.active { background: #f5f5f5; border-color: #aaa; }
        .wbt-tabbody { display: none; }
        .wbt-tabbody.active { display: block; }
        .wbt-mute { color: #666; font-size: 11px; }
        .wbt-pill { display: inline-block; background: #ddd; padding: 1px 5px; border-radius: 8px;
            font-size: 10px; margin-right: 3px; }
        .wbt-stats td { padding: 1px 6px 1px 0; }
        #wbt-dep-modal {
            position: fixed !important;
            inset: 0 !important;
            z-index: 2147483647 !important;
            background: rgba(0,0,0,.55) !important;
            display: flex !important;
            align-items: center !important;
            justify-content: center !important;
            visibility: visible !important;
            opacity: 1 !important;
            font: 12px sans-serif !important;
            color: #222 !important;
        }
        #wbt-dep-modal-box {
            width: min(420px, calc(100vw - 28px)) !important;
            background: #f5f5f5 !important;
            border: 1px solid #777 !important;
            box-shadow: 0 4px 22px rgba(0,0,0,.45) !important;
            padding: 12px !important;
            border-radius: 3px !important;
        }
        .wbt-dep-actions {
            display: flex !important;
            flex-wrap: wrap !important;
            gap: 6px !important;
            margin-top: 10px !important;
        }
    `;

    class UI {
        constructor(api, feedEngine) {
            this.api = api;
            this.feedEngine = feedEngine;
            this._dragSrc = null;
            this._activeTab = _checkV3() ? 'feed' : 'setup';
        }

        init() {
            // Try GM_addStyle (polish). ALSO inject a manual <style> element
            // (so the styles survive even if GM_addStyle is blocked). The
            // layout-critical styles are duplicated inline on each container
            // below so the panel works even if the <style> is stripped by V3.
            try { GM_addStyle(CSS); } catch {}
            try {
                if (!document.getElementById('wbt-style')) {
                    const s = document.createElement('style');
                    s.id = 'wbt-style';
                    s.textContent = CSS;
                    (document.head || document.documentElement).appendChild(s);
                }
            } catch {}
            if (!document.body) {
                document.addEventListener('DOMContentLoaded', () => this.init(), { once: true });
                return;
            }
            this._mountFab();
            this._mountPanel();
        }

        // Apply a style object to an element using setProperty + 'important'
        // so no other stylesheet can override us.
        _style(el, props) {
            for (const k in props) {
                try { el.style.setProperty(k, props[k], 'important'); } catch {}
            }
        }

        _mountFab() {
            if (document.getElementById('wbt-fab')) return;
            const fab = this._el('button', 'wbt-fab', '⏲');
            fab.id = 'wbt-fab';
            fab.title = 'bygone-yt — open panel';
            // Inline critical styles so the FAB shows even if CSS was stripped.
            this._style(fab, {
                position: 'fixed', bottom: '18px', right: '18px',
                'z-index': '2147483646',
                width: '48px', height: '48px',
                'border-radius': '50%',
                background: '#c00', color: '#fff',
                border: '1px solid #800',
                font: 'bold 18px sans-serif',
                cursor: 'pointer',
                'box-shadow': '0 2px 8px rgba(0,0,0,.4)',
                display: 'block', visibility: 'visible', opacity: '1',
                padding: '0', margin: '0',
            });
            fab.onclick = (e) => {
                e.preventDefault();
                e.stopPropagation();
                const panel = document.getElementById('wbt-panel');
                if (panel) {
                    const wasHidden = panel.classList.contains('wbt-hidden');
                    panel.classList.toggle('wbt-hidden');
                    // Belt-and-suspenders: toggle inline display too.
                    panel.style.setProperty('display', wasHidden ? 'block' : 'none', 'important');
                }
            };
            (document.body || document.documentElement).appendChild(fab);
        }

        _mountPanel() {
            const old = document.getElementById('wbt-panel');
            if (old) old.remove();
            const p = this._el('div');
            p.id = 'wbt-panel';
            p.classList.add('wbt-hidden');
            // Inline critical layout styles. CSS classes provide polish on
            // top; if CSS is stripped, the panel still renders correctly.
            this._style(p, {
                position: 'fixed', bottom: '78px', right: '18px',
                'z-index': '2147483647',
                width: '340px', 'max-height': '78vh',
                'overflow-y': 'auto',
                background: '#f5f5f5', color: '#222',
                border: '1px solid #888', 'border-radius': '4px',
                'box-shadow': '0 2px 12px rgba(0,0,0,.4)',
                font: '12px sans-serif',
                display: 'none',                      // start hidden
                'pointer-events': 'auto',             // defeat any global pe:none
                padding: '0', margin: '0',
            });
            this._renderPanel(p);
            (document.body || document.documentElement).appendChild(p);
        }

        _renderPanel(p) {
            p.innerHTML = '';

            const header = this._el('div', 'wbt-h', 'bygone-yt v' + VERSION);
            this._style(header, {
                background: 'linear-gradient(#e8e8e8, #d4d4d4)',
                padding: '8px 12px', 'font-weight': 'bold',
                'border-bottom': '1px solid #aaa', cursor: 'move',
                color: '#222', font: 'bold 12px sans-serif',
            });
            const close = this._el('span', 'wbt-close', '✕');
            this._style(close, { float: 'right', cursor: 'pointer', color: '#666', 'font-weight': 'normal' });
            close.onclick = () => {
                p.classList.add('wbt-hidden');
                p.style.setProperty('display', 'none', 'important');
            };
            header.appendChild(close);
            this._enableDrag(p, header);
            p.appendChild(header);

            const tabs = this._el('div', 'wbt-tabs');
            this._style(tabs, { display: 'flex', gap: '0', 'border-bottom': '1px solid #aaa', background: '#eee' });
            const body = this._el('div', 'wbt-body');
            this._style(body, { padding: '10px 12px' });

            const sections = [
                ['feed',     'Feed',     () => this._renderFeed()],
                ['sources',  'Sources',  () => this._renderSources()],
                ['profiles', 'Profiles', () => this._renderProfiles()],
                ['look',     'Look',     () => this._renderLook()],
                ['stats',    'Stats',    () => this._renderStats()],
                ['setup',    'Setup',    () => this._renderSetup()],
            ];
            for (const [id, label, render] of sections) {
                const tab = this._el('div', 'wbt-tab', label);
                this._style(tab, {
                    padding: '5px 10px', cursor: 'pointer',
                    border: '1px solid transparent', 'border-bottom': '0',
                    'font-size': '11px',
                    background: id === this._activeTab ? '#f5f5f5' : 'transparent',
                    'border-top-color': id === this._activeTab ? '#aaa' : 'transparent',
                    'border-left-color': id === this._activeTab ? '#aaa' : 'transparent',
                    'border-right-color': id === this._activeTab ? '#aaa' : 'transparent',
                });
                if (id === this._activeTab) tab.classList.add('active');
                tab.onclick = () => {
                    this._activeTab = id;
                    this._renderPanel(p);
                };
                tabs.appendChild(tab);
            }
            const section = sections.find(s => s[0] === this._activeTab);
            const content = section ? section[2]() : this._renderFeed();
            body.appendChild(content);
            p.appendChild(tabs);
            p.appendChild(body);
        }

        // Style helpers used by section renderers below.
        _styleBtn(b, primary) {
            this._style(b, {
                padding: '3px 9px',
                border: '1px solid ' + (primary ? '#800' : '#888'),
                background: primary ? '#c00' : '#ddd',
                color: primary ? '#fff' : '#222',
                'border-radius': '2px', cursor: 'pointer',
                font: '12px sans-serif', 'white-space': 'nowrap',
            });
        }
        _styleInput(i) {
            this._style(i, {
                flex: '1', padding: '3px 5px',
                border: '1px solid #aaa', 'border-radius': '2px',
                font: '12px sans-serif', 'min-width': '0',
                background: '#fff', color: '#222',
            });
        }
        _styleRow(r) {
            this._style(r, { display: 'flex', gap: '6px', 'align-items': 'center', margin: '4px 0' });
        }
        _styleSec(s) {
            this._style(s, { 'margin-bottom': '14px' });
        }
        _styleH4(h) {
            this._style(h, {
                margin: '0 0 6px', 'font-size': '12px', color: '#333',
                'border-bottom': '1px dotted #aaa', 'padding-bottom': '3px',
                font: 'bold 12px sans-serif',
            });
        }
        _styleList(l) {
            this._style(l, {
                background: '#fff', border: '1px solid #aaa',
                'border-radius': '2px', 'min-height': '28px',
                'max-height': '110px', 'overflow-y': 'auto',
            });
        }
        _styleItem(it) {
            this._style(it, {
                padding: '3px 6px', 'border-bottom': '1px dotted #ddd',
                display: 'flex', 'align-items': 'center', gap: '6px', cursor: 'grab',
            });
        }
        _styleMute(m) {
            this._style(m, { color: '#666', 'font-size': '11px' });
        }
        _styleToggle(l) {
            this._style(l, { display: 'flex', 'align-items': 'center', gap: '6px', margin: '3px 0', cursor: 'pointer' });
        }

        // ---- Tabs --------------------------------------------------

        _renderFeed() {
            const wrap = this._el('div');
            // Active toggle
            wrap.appendChild(this._toggle('Active', Store.isActive(), v => { Store.setActive(v); }));

            // Date picker
            const dateSec = this._el('div', 'wbt-sec');
            dateSec.appendChild(this._el('h4', null, 'Date'));
            const dateRow = this._el('div', 'wbt-row');
            const date = Store.getDate() || '';
            const dateInput = this._el('input');
            dateInput.type = 'date';
            dateInput.value = date;
            dateInput.onchange = () => {
                // Re-anchor the rolling clock at the newly chosen date instead
                // of stopping it, so picking an era keeps it rolling from there.
                if (Store.isClockActive()) Store.startClock(dateInput.value);
                else Store.setDate(dateInput.value);
                this._reloadFeed();
            };
            dateRow.appendChild(dateInput);
            dateSec.appendChild(dateRow);

            // Clock
            const clockRow = this._el('div', 'wbt-row');
            const clockBtn = this._el('button', 'wbt-btn', Store.isClockActive() ? 'Stop clock' : 'Start rolling clock');
            clockBtn.onclick = () => {
                if (Store.isClockActive()) Store.stopClock();
                else if (dateInput.value) Store.startClock(dateInput.value);
                this._renderPanel(document.getElementById('wbt-panel'));
                this._reloadFeed();
            };
            clockRow.appendChild(clockBtn);
            dateSec.appendChild(clockRow);
            if (Store.isClockActive()) {
                const now = Store.getCurrentDate();
                dateSec.appendChild(this._el('div', 'wbt-mute', 'Sim time: ' + now));
            }
            wrap.appendChild(dateSec);

            // Source toggles
            const togSec = this._el('div', 'wbt-sec');
            togSec.appendChild(this._el('h4', null, 'Sources'));
            togSec.appendChild(this._toggle('"Similar" (CF) source', Store.isSimilarEnabled(), v => Store.setSimilarEnabled(v)));
            togSec.appendChild(this._toggle('Trending discovery', Store.isDiscoveryEnabled(), v => Store.setDiscoveryEnabled(v)));
            togSec.appendChild(this._toggle('Watch-history learning', Store.isLearningEnabled(), v => Store.setLearningEnabled(v)));
            togSec.appendChild(this._toggle('Auto-subscribe on YouTube to bygone subs', Store.isAutoSyncSubs(), v => {
                Store.setAutoSyncSubs(v);
                if (v) App._scheduleSubSync(500);
            }));
            wrap.appendChild(togSec);

            // Reload + clear caches
            const actions = this._el('div', 'wbt-sec');
            const row = this._el('div', 'wbt-row');
            const reload = this._el('button', 'wbt-btn wbt-btn-primary', 'Reload feed');
            reload.onclick = () => this._reloadFeed();
            const clear = this._el('button', 'wbt-btn', 'Clear cache');
            clear.onclick = () => {
                try { for (const k of GM_listValues()) if (k.startsWith('bygone_cache_')) GM_deleteValue(k); } catch {}
                this._reloadFeed();
            };
            row.appendChild(reload); row.appendChild(clear);
            actions.appendChild(row);
            wrap.appendChild(actions);

            return wrap;
        }

        _renderSources() {
            const wrap = this._el('div');

            // Subscriptions
            const subSec = this._listSection('Subscriptions',
                Store.getSubscriptions(),
                (s) => s.name + (s.weight ? ` (w${s.weight})` : ''),
                'Channel name…',
                async (name) => {
                    if (!name) return;
                    const ch = await this.api.resolveChannel(name).catch(() => null);
                    const subs = Store.getSubscriptions();
                    subs.push({ id: ch ? ch.id : null, name: ch ? ch.name : name, weight: 3 });
                    Store.setSubscriptions(subs);
                    // Newly added → push to YouTube account.
                    App._scheduleSubSync(800);
                },
                (newOrder) => Store.setSubscriptions(newOrder));
            wrap.appendChild(subSec);

            // Search terms
            wrap.appendChild(this._listSection('Search terms',
                Store.getSearchTerms(),
                (t) => (typeof t === 'string' ? t : t.term) + (t.weight ? ` (w${t.weight})` : ''),
                'Search query…',
                (q) => { if (!q) return; const t = Store.getSearchTerms(); t.push({ term: q, weight: 3 }); Store.setSearchTerms(t); },
                (newOrder) => Store.setSearchTerms(newOrder)));

            // Topics
            wrap.appendChild(this._listSection('Custom topics',
                Store.getTopics(),
                (t) => (typeof t === 'string' ? t : t.name) + (t.weight ? ` (w${t.weight})` : ''),
                'Topic name…',
                (n) => { if (!n) return; const t = Store.getTopics(); t.push({ name: n, weight: 3 }); Store.setTopics(t); },
                (newOrder) => Store.setTopics(newOrder)));

            // Categories
            const catSec = this._el('div', 'wbt-sec');
            catSec.appendChild(this._el('h4', null, 'Categories'));
            const selected = new Set(Store.getCategories());
            for (const [id, name] of Object.entries(CONFIG.categories)) {
                const row = this._el('label', 'wbt-toggle');
                const cb = this._el('input');
                cb.type = 'checkbox';
                cb.checked = selected.has(Number(id));
                cb.onchange = () => {
                    if (cb.checked) selected.add(Number(id));
                    else selected.delete(Number(id));
                    Store.setCategories(Array.from(selected));
                };
                row.appendChild(cb);
                row.appendChild(document.createTextNode(' ' + name));
                catSec.appendChild(row);
            }
            wrap.appendChild(catSec);

            // Blocked channels
            wrap.appendChild(this._listSection('Blocked channels',
                Store.getBlockedChannels(),
                (b) => b.name,
                'Channel name to block…',
                (n) => { if (!n) return; const b = Store.getBlockedChannels(); b.push({ name: n, id: null }); Store.setBlockedChannels(b); },
                (newOrder) => Store.setBlockedChannels(newOrder)));

            // Global negatives
            wrap.appendChild(this._listSection('Global negative keywords',
                Store.getGlobalNegatives().map(n => ({ name: n })),
                (n) => n.name,
                'Negative keyword…',
                (n) => { if (!n) return; const list = Store.getGlobalNegatives(); list.push(n); Store.setGlobalNegatives(list); },
                (newOrder) => Store.setGlobalNegatives(newOrder.map(n => n.name))));

            return wrap;
        }

        // Generic list section with add + remove + drag-reorder.
        _listSection(title, items, formatItem, placeholder, onAdd, onReorder) {
            const sec = this._el('div', 'wbt-sec');
            sec.appendChild(this._el('h4', null, title));
            const list = this._el('div', 'wbt-list');
            items.forEach((item, idx) => {
                const row = this._el('div', 'wbt-item');
                row.draggable = true;
                row.dataset.idx = String(idx);
                row.ondragstart = (e) => { this._dragSrc = idx; row.classList.add('dragging'); try { e.dataTransfer.effectAllowed = 'move'; } catch {} };
                row.ondragend = () => row.classList.remove('dragging');
                row.ondragover = (e) => { e.preventDefault(); };
                row.ondrop = (e) => {
                    e.preventDefault();
                    const src = this._dragSrc; this._dragSrc = null;
                    if (src === null || src === idx) return;
                    const reordered = items.slice();
                    const [moved] = reordered.splice(src, 1);
                    reordered.splice(idx, 0, moved);
                    onReorder(reordered);
                    this._renderPanel(document.getElementById('wbt-panel'));
                };
                row.appendChild(this._el('span', 'wbt-item-name', formatItem(item)));
                const rm = this._el('button', 'wbt-btn wbt-btn-x', '×');
                rm.onclick = () => {
                    const next = items.slice();
                    next.splice(idx, 1);
                    onReorder(next);
                    this._renderPanel(document.getElementById('wbt-panel'));
                };
                row.appendChild(rm);
                list.appendChild(row);
            });
            sec.appendChild(list);

            const addRow = this._el('div', 'wbt-row');
            const input = this._el('input');
            input.type = 'text';
            input.placeholder = placeholder;
            const addBtn = this._el('button', 'wbt-btn', '+');
            const doAdd = async () => {
                const val = input.value.trim();
                input.value = '';
                await onAdd(val);
                this._renderPanel(document.getElementById('wbt-panel'));
            };
            addBtn.onclick = doAdd;
            input.onkeydown = (e) => { if (e.key === 'Enter') doAdd(); };
            addRow.appendChild(input);
            addRow.appendChild(addBtn);
            sec.appendChild(addRow);

            return sec;
        }

        _renderProfiles() {
            const wrap = this._el('div');
            wrap.appendChild(this._el('h4', null, 'Profiles'));
            const profiles = Store.getProfiles();
            const names = Object.keys(profiles);

            if (!names.length) wrap.appendChild(this._el('div', 'wbt-mute', 'No saved profiles yet.'));
            for (const name of names) {
                const row = this._el('div', 'wbt-item');
                row.appendChild(this._el('span', 'wbt-item-name', name));
                const load = this._el('button', 'wbt-btn', 'Load');
                load.onclick = () => { Store.loadProfile(name); this._renderPanel(document.getElementById('wbt-panel')); this._reloadFeed(); App._scheduleSubSync(800); };
                const exp  = this._el('button', 'wbt-btn', 'Export');
                exp.onclick = () => {
                    const blob = new Blob([Store.exportProfile(name)], { type: 'application/json' });
                    const url = URL.createObjectURL(blob);
                    const a = this._el('a');
                    a.href = url; a.download = `bygone-profile-${name}.json`;
                    a.click();
                    setTimeout(() => URL.revokeObjectURL(url), 1000);
                };
                const del  = this._el('button', 'wbt-btn wbt-btn-x', '×');
                del.onclick = () => { if (confirm(`Delete profile "${name}"?`)) { Store.deleteProfile(name); this._renderPanel(document.getElementById('wbt-panel')); } };
                row.appendChild(load); row.appendChild(exp); row.appendChild(del);
                wrap.appendChild(row);
            }

            const addRow = this._el('div', 'wbt-row');
            const input = this._el('input');
            input.type = 'text';
            input.placeholder = 'New profile name…';
            const saveBtn = this._el('button', 'wbt-btn', 'Save current as…');
            saveBtn.onclick = () => {
                const n = input.value.trim();
                if (!n) return;
                Store.saveProfile(n);
                input.value = '';
                this._renderPanel(document.getElementById('wbt-panel'));
            };
            const blankBtn = this._el('button', 'wbt-btn', 'New blank');
            blankBtn.onclick = () => {
                const n = input.value.trim();
                if (!n) { input.placeholder = 'enter a name first…'; input.focus(); return; }
                if (Store.getProfiles()[n] && !confirm(`Overwrite "${n}" with a blank profile?`)) return;
                Store.createBlankProfile(n);
                input.value = '';
                this._renderPanel(document.getElementById('wbt-panel'));
            };
            addRow.appendChild(input); addRow.appendChild(saveBtn); addRow.appendChild(blankBtn);
            wrap.appendChild(addRow);

            // Import
            const impRow = this._el('div', 'wbt-row');
            const imp = this._el('input');
            imp.type = 'file';
            imp.accept = 'application/json';
            imp.onchange = () => {
                const f = imp.files && imp.files[0];
                if (!f) return;
                const r = new FileReader();
                r.onload = () => {
                    try { Store.importProfile(r.result); alert('Profile imported.'); this._renderPanel(document.getElementById('wbt-panel')); }
                    catch (e) { alert('Import failed: ' + e.message); }
                };
                r.readAsText(f);
            };
            impRow.appendChild(imp);
            wrap.appendChild(impRow);

            // Full export/import (ALL data)
            wrap.appendChild(this._el('h4', null, 'Full Backup'));
            const expAllRow = this._el('div', 'wbt-row');
            const expAllBtn = this._el('button', 'wbt-btn wbt-btn-primary', 'Export ALL data');
            expAllBtn.onclick = () => {
                const blob = new Blob([Store.exportAll()], { type: 'application/json' });
                const url = URL.createObjectURL(blob);
                const a = this._el('a');
                a.href = url; a.download = `bygone-yt-full-backup.json`;
                a.click();
                setTimeout(() => URL.revokeObjectURL(url), 1000);
            };
            expAllRow.appendChild(expAllBtn);
            wrap.appendChild(expAllRow);

            const impAllRow = this._el('div', 'wbt-row');
            const impAll = this._el('input');
            impAll.type = 'file';
            impAll.accept = 'application/json';
            impAll.onchange = () => {
                const f = impAll.files && impAll.files[0];
                if (!f) return;
                const r = new FileReader();
                r.onload = () => {
                    try { Store.importAll(r.result); alert('Full backup restored.'); this._renderPanel(document.getElementById('wbt-panel')); }
                    catch (e) { alert('Import failed: ' + e.message); }
                };
                r.readAsText(f);
            };
            impAllRow.appendChild(impAll);
            wrap.appendChild(impAllRow);

            return wrap;
        }

        _renderLook() {
            const wrap = this._el('div');
            wrap.appendChild(this._el('h4', null, 'Custom logo'));

            const cur = Store.getCustomLogo();
            if (cur) {
                const img = this._el('img');
                img.src = cur;
                img.style.maxWidth = '100%';
                img.style.maxHeight = '60px';
                wrap.appendChild(img);
            }
            const row = this._el('div', 'wbt-row');
            const file = this._el('input');
            file.type = 'file';
            file.accept = 'image/*';
            file.onchange = () => {
                const f = file.files && file.files[0];
                if (!f) return;
                const r = new FileReader();
                r.onload = () => {
                    Store.setCustomLogo(r.result);
                    this._renderPanel(document.getElementById('wbt-panel'));
                    this._applyCustomLogo();
                };
                r.readAsDataURL(f);
            };
            const clear = this._el('button', 'wbt-btn', 'Clear');
            clear.onclick = () => { Store.clearCustomLogo(); this._renderPanel(document.getElementById('wbt-panel')); };
            row.appendChild(file); row.appendChild(clear);
            wrap.appendChild(row);

            return wrap;
        }

        _renderStats() {
            const wrap = this._el('div');
            wrap.appendChild(this._el('h4', null, 'Pool'));
            const pool = this._el('table', 'wbt-stats');
            const row = (k, v) => { const tr = this._el('tr'); tr.appendChild(this._el('td', null, k)); tr.appendChild(this._el('td', null, String(v))); pool.appendChild(tr); };
            row('Pool size',   Interceptor.poolSize());
            row('Used',        Interceptor.usedCount());
            row('Active',      Interceptor.isActive());
            wrap.appendChild(pool);

            wrap.appendChild(this._el('h4', null, 'Learning'));
            const interests = Store.getCachedInterests();
            const lc = interests ? InterestModel.getLearnedChannels(interests) : [];
            const lk = interests ? InterestModel.getLearnedKeywords(interests) : [];
            if (!lc.length && !lk.length) {
                wrap.appendChild(this._el('div', 'wbt-mute', 'No learning data yet — watch some videos to build a profile.'));
            } else {
                wrap.appendChild(this._el('div', 'wbt-mute', 'Top channels:'));
                for (const c of lc.slice(0, 6)) wrap.appendChild(this._el('div', null, `• ${c.name} (${c.score.toFixed(1)})`));
                wrap.appendChild(this._el('div', 'wbt-mute', 'Top keywords:'));
                for (const k of lk.slice(0, 6)) wrap.appendChild(this._el('div', null, `• ${k.keyword}`));
            }

            const clearBtn = this._el('button', 'wbt-btn', 'Clear learning data');
            clearBtn.onclick = () => { if (confirm('Clear all watch history + learning?')) { Store.clearLearningData(); this._renderPanel(document.getElementById('wbt-panel')); } };
            wrap.appendChild(clearBtn);

            wrap.appendChild(this._el('h4', null, 'Diagnostics'));
            const probeBtn = this._el('button', 'wbt-btn', 'Copy LOHP probe to clipboard');
            probeBtn.onclick = () => {
                const cards = Interceptor.findCards(document);
                const lines = [`bygone-yt v${VERSION} LOHP probe`, `URL: ${location.href}`, `Cards found: ${cards.length}`, ''];
                for (let i = 0; i < Math.min(cards.length, 20); i++) {
                    const c = cards[i];
                    const tag = c.tagName.toLowerCase();
                    const cls = c.className || '';
                    const swept = c.getAttribute('data-bygone-swept') || '';
                    const ok = c.getAttribute('data-bygone-ok') || '';
                    const keep = c.getAttribute('data-bygone-keep') || '';
                    const redated = c.getAttribute('data-bygone-redated') || '';
                    const link = c.querySelector('a[href*="/watch"]');
                    const href = link ? link.getAttribute('href') : 'NO-LINK';
                    const hasImg = !!c.querySelector('img[src*="ytimg"], img.bygone-thumb');
                    const vis = getComputedStyle(c).visibility;
                    const disp = getComputedStyle(c).display;
                    lines.push(`[${i}] <${tag} class="${cls.slice(0,80)}">`);
                    lines.push(`    href=${href} img=${hasImg} swept="${swept}" ok="${ok}" keep="${keep}" redated="${redated}" vis=${vis} disp=${disp}`);
                    let parent = c.parentElement;
                    const ancestry = [];
                    for (let j = 0; j < 4 && parent; j++) {
                        ancestry.push(`${parent.tagName.toLowerCase()}${parent.className ? '.' + parent.className.split(/\s+/)[0] : ''}`);
                        parent = parent.parentElement;
                    }
                    lines.push(`    parents: ${ancestry.join(' > ')}`);
                }
                const unmatched = document.querySelectorAll('a.lohp-video-link');
                if (unmatched.length) {
                    lines.push('', `--- Unmatched a.lohp-video-link: ${unmatched.length} ---`);
                    for (let i = 0; i < Math.min(unmatched.length, 10); i++) {
                        const a = unmatched[i];
                        let parent = a.parentElement;
                        const ancestry = [];
                        for (let j = 0; j < 6 && parent; j++) {
                            ancestry.push(`${parent.tagName.toLowerCase()}${parent.id ? '#' + parent.id : ''}${parent.className ? '.' + parent.className.split(/\s+/)[0] : ''}`);
                            parent = parent.parentElement;
                        }
                        lines.push(`[${i}] href=${a.getAttribute('href')} text="${(a.textContent||'').trim().slice(0,50)}"`);
                        lines.push(`    parents: ${ancestry.join(' > ')}`);
                    }
                }
                const allVideoLinks = document.querySelectorAll('a[href*="/watch?v="]');
                const notSwept = [];
                for (const a of allVideoLinks) {
                    const card = a.closest('[data-bygone-swept], [data-bygone-ok]');
                    if (!card) notSwept.push(a);
                }
                if (notSwept.length) {
                    lines.push('', `--- Unswept /watch links: ${notSwept.length} ---`);
                    for (let i = 0; i < Math.min(notSwept.length, 10); i++) {
                        const a = notSwept[i];
                        let parent = a.parentElement;
                        const ancestry = [];
                        for (let j = 0; j < 6 && parent; j++) {
                            ancestry.push(`${parent.tagName.toLowerCase()}${parent.id ? '#' + parent.id : ''}${parent.className ? '.' + parent.className.split(/\s+/)[0] : ''}`);
                            parent = parent.parentElement;
                        }
                        lines.push(`[${i}] href=${a.getAttribute('href')} text="${(a.textContent||'').trim().slice(0,50)}"`);
                        lines.push(`    parents: ${ancestry.join(' > ')}`);
                    }
                }
                const txt = lines.join('\n');
                navigator.clipboard.writeText(txt).then(() => {
                    probeBtn.textContent = 'Copied!';
                    setTimeout(() => { probeBtn.textContent = 'Copy LOHP probe to clipboard'; }, 2000);
                });
            };
            wrap.appendChild(probeBtn);

            const fullProbeBtn = this._el('button', 'wbt-btn', 'Copy FULL homepage DOM probe');
            fullProbeBtn.onclick = () => {
                const lines = [`bygone-yt v${VERSION} FULL DOM probe`, `URL: ${location.href}`, `Date: ${new Date().toISOString()}`, ''];
                const content = document.querySelector('#content, #page-container, #page, body');
                if (!content) { lines.push('NO content root found'); }
                else {
                    // All watch links on page with full ancestry + surrounding HTML
                    const allLinks = document.querySelectorAll('a[href*="/watch?v="]');
                    lines.push(`=== ALL /watch links: ${allLinks.length} ===`, '');
                    const seen = new Set();
                    for (let i = 0; i < Math.min(allLinks.length, 30); i++) {
                        const a = allLinks[i];
                        const href = a.getAttribute('href') || '';
                        const text = (a.textContent || '').trim().slice(0, 60);
                        const hasImg = !!a.querySelector('img');
                        const imgSrc = a.querySelector('img') ? (a.querySelector('img').getAttribute('src') || '').slice(0, 80) : 'NONE';
                        const aVis = getComputedStyle(a).visibility;
                        const aDisp = getComputedStyle(a).display;
                        const aClass = (a.className || '').slice(0, 80);
                        lines.push(`[${i}] <a class="${aClass}" href="${href}">`);
                        lines.push(`    text="${text}" hasImg=${hasImg} imgSrc=${imgSrc}`);
                        lines.push(`    vis=${aVis} disp=${aDisp}`);
                        // Walk up 8 ancestors
                        const ancestry = [];
                        let p = a.parentElement;
                        for (let j = 0; j < 8 && p && p !== document.body; j++) {
                            const pTag = p.tagName.toLowerCase();
                            const pId = p.id ? '#' + p.id : '';
                            const pCls = p.className ? '.' + (p.className + '').split(/\s+/).slice(0, 3).join('.') : '';
                            const pVis = getComputedStyle(p).visibility;
                            const pDisp = getComputedStyle(p).display;
                            const swept = p.getAttribute('data-bygone-swept') || '';
                            const ok = p.getAttribute('data-bygone-ok') || '';
                            ancestry.push(`${pTag}${pId}${pCls}[vis=${pVis},disp=${pDisp},swept="${swept}",ok="${ok}"]`);
                            p = p.parentElement;
                        }
                        lines.push(`    ancestry: ${ancestry.join(' > ')}`);
                        // Sibling content (what's next to this link)
                        const par = a.parentElement;
                        if (par && !seen.has(par)) {
                            seen.add(par);
                            const parHTML = (par.innerHTML || '').replace(/\s+/g, ' ').slice(0, 400);
                            lines.push(`    parent innerHTML: ${parHTML}`);
                        }
                        lines.push('');
                    }
                    // All img elements on page
                    const allImgs = document.querySelectorAll('img');
                    let visibleImgs = 0, hiddenImgs = 0, bygoneImgs = 0;
                    for (const img of allImgs) {
                        const v = getComputedStyle(img).visibility;
                        const d = getComputedStyle(img).display;
                        if (img.classList.contains('bygone-thumb')) bygoneImgs++;
                        else if (v === 'visible' && d !== 'none' && img.offsetWidth > 5) visibleImgs++;
                        else hiddenImgs++;
                    }
                    lines.push(`=== IMAGES: total=${allImgs.length} visible=${visibleImgs} hidden=${hiddenImgs} bygone-thumb=${bygoneImgs} ===`, '');
                    // Sample first 10 visible imgs
                    let imgIdx = 0;
                    for (const img of allImgs) {
                        if (imgIdx >= 10) break;
                        const v = getComputedStyle(img).visibility;
                        const d = getComputedStyle(img).display;
                        if (v !== 'visible' || d === 'none' || img.offsetWidth < 5) continue;
                        const src = (img.getAttribute('src') || '').slice(0, 100);
                        const cls = (img.className || '').slice(0, 60);
                        const w = img.offsetWidth;
                        const h = img.offsetHeight;
                        lines.push(`  img[${imgIdx}] class="${cls}" ${w}x${h} src=${src}`);
                        imgIdx++;
                    }
                    lines.push('');
                    // _findCards result
                    const cards = Interceptor.findCards(document);
                    lines.push(`=== _findCards: ${cards.length} cards ===`);
                    // Unique container classes
                    const clsCounts = {};
                    for (const c of cards) {
                        const k = c.tagName.toLowerCase() + '.' + (c.className || '').split(/\s+/).sort().join('.');
                        clsCounts[k] = (clsCounts[k] || 0) + 1;
                    }
                    for (const [k, v] of Object.entries(clsCounts)) lines.push(`  ${v}x ${k}`);
                    lines.push('');
                    // Elements with hide CSS that are still visible
                    const hideSelectors = [
                        'ytd-rich-item-renderer', 'ytd-grid-video-renderer', 'ytd-video-renderer',
                        'ytd-compact-video-renderer', 'yt-lockup-view-model', '.yt-lockup-view-model',
                        '.lohp-large-shelf-container', '.lohp-medium-shelf', '.lohp-media-object',
                        '.video-list-item', '.channels-content-item', '.feed-item-container .yt-lockup',
                        '.yt-shelf-grid-item', '.yt-uix-shelfslider-item', '.expanded-shelf-content-item',
                        '.context-data-item.yt-lockup'
                    ];
                    lines.push('=== Hide CSS selector hits ===');
                    for (const sel of hideSelectors) {
                        const els = document.querySelectorAll(sel);
                        if (els.length) lines.push(`  "${sel}": ${els.length} elements`);
                    }
                    lines.push('');
                    // Top-level structure of content area
                    lines.push('=== Content area children (first 2 levels) ===');
                    const root = document.querySelector('#page-container, #page, #content, body');
                    if (root) {
                        for (const child of Array.from(root.children).slice(0, 20)) {
                            const cTag = child.tagName.toLowerCase();
                            const cId = child.id ? '#' + child.id : '';
                            const cCls = child.className ? '.' + (child.className + '').split(/\s+/).slice(0, 3).join('.') : '';
                            const cKids = child.children.length;
                            lines.push(`  <${cTag}${cId}${cCls}> (${cKids} children)`);
                            for (const gc of Array.from(child.children).slice(0, 10)) {
                                const gTag = gc.tagName.toLowerCase();
                                const gId = gc.id ? '#' + gc.id : '';
                                const gCls = gc.className ? '.' + (gc.className + '').split(/\s+/).slice(0, 3).join('.') : '';
                                const gKids = gc.children.length;
                                lines.push(`    <${gTag}${gId}${gCls}> (${gKids} children)`);
                            }
                        }
                    }
                }
                const txt = lines.join('\n');
                navigator.clipboard.writeText(txt).then(() => {
                    fullProbeBtn.textContent = 'Copied!';
                    setTimeout(() => { fullProbeBtn.textContent = 'Copy FULL homepage DOM probe'; }, 2000);
                });
            };
            wrap.appendChild(fullProbeBtn);

            // GAP probe: find visible, tall, nearly-empty containers (the blank
            // gaps left after pruning the logged-in feed), plus the LOHP's feed
            // list children with their heights so the reserved space is obvious.
            const gapProbeBtn = this._el('button', 'wbt-btn', 'Copy GAP probe');
            gapProbeBtn.onclick = () => {
                const L = [`bygone-yt v${VERSION} GAP probe`, `URL: ${location.href}`, ''];
                const LOHP = '.lohp-media-object,.lohp-large-shelf-container,.lohp-medium-shelf,.lohp-newspaper-shelf';
                // 1) Tall empty visible containers.
                const out = [];
                document.querySelectorAll('div,li,ul,section,ol').forEach(e => {
                    const cs = getComputedStyle(e);
                    if (cs.display === 'none' || cs.visibility === 'hidden') return;
                    if (e.offsetHeight < 60) return;
                    if (e.querySelector('a[href*="/watch"]')) return;
                    if (e.querySelector(LOHP)) return;
                    if ((e.textContent || '').trim().length > 15) return;
                    if (e.children.length > 4) return;
                    out.push({
                        tag: e.tagName.toLowerCase(),
                        cls: (e.className || '').toString().slice(0, 55),
                        id: e.id || '',
                        h: e.offsetHeight, kids: e.children.length,
                        mt: cs.marginTop, mb: cs.marginBottom, minH: cs.minHeight,
                    });
                });
                out.sort((a, b) => b.h - a.h);
                L.push('=== TALL EMPTY CONTAINERS (gap suspects) ===');
                L.push(JSON.stringify(out.slice(0, 18), null, 2), '');
                // 2) LOHP feed-list ancestry + each child's height.
                const lohp = document.querySelector('.lohp-newspaper-shelf') || document.querySelector(LOHP);
                if (lohp) {
                    L.push('=== LOHP ancestry (tag.class [h]) ===');
                    let p = lohp, depth = 0;
                    while (p && p !== document.body && depth < 12) {
                        const cs = getComputedStyle(p);
                        L.push(`  ${p.tagName.toLowerCase()}${p.id ? '#' + p.id : ''}.${(p.className || '').toString().split(/\s+/).slice(0, 3).join('.')} [h=${p.offsetHeight} minH=${cs.minHeight} display=${cs.display}]`);
                        p = p.parentElement; depth++;
                    }
                    L.push('');
                    // The feed list = nearest ancestor that has multiple element children.
                    let feed = lohp.parentElement;
                    while (feed && feed !== document.body && feed.children.length < 2) feed = feed.parentElement;
                    if (feed) {
                        L.push(`=== FEED LIST children of <${feed.tagName.toLowerCase()}${feed.id ? '#' + feed.id : ''}.${(feed.className || '').toString().split(/\s+/).slice(0, 2).join('.')}> ===`);
                        Array.from(feed.children).forEach((c, i) => {
                            const cs = getComputedStyle(c);
                            L.push(`  [${i}] ${c.tagName.toLowerCase()}.${(c.className || '').toString().split(/\s+/).slice(0, 3).join('.')} h=${c.offsetHeight} disp=${cs.display} lohp=${!!c.querySelector(LOHP)} watch=${!!c.querySelector('a[href*="/watch"]')} txt=${(c.textContent || '').trim().length}`);
                        });
                    }
                }
                navigator.clipboard.writeText(L.join('\n')).then(() => {
                    gapProbeBtn.textContent = 'Copied!';
                    setTimeout(() => { gapProbeBtn.textContent = 'Copy GAP probe'; }, 2000);
                });
            };
            wrap.appendChild(gapProbeBtn);

            return wrap;
        }

        _renderSetup() {
            const wrap = this._el('div');
            wrap.appendChild(this._el('h4', null, 'Required Extensions'));

            const v3Ok = _checkV3();

            const v3Row = this._el('div', 'wbt-row');
            const v3Status = this._el('span', null, v3Ok ? '✅ V3 detected' : '❌ V3 not detected');
            this._style(v3Status, { color: v3Ok ? '#080' : '#c00', 'font-weight': 'bold', 'font-size': '12px' });
            v3Row.appendChild(v3Status);
            wrap.appendChild(v3Row);

            if (!v3Ok) {
                const warn = this._el('div');
                this._style(warn, {
                    background: '#fff3cd', border: '1px solid #e0c36a',
                    'border-radius': '4px', padding: '8px 10px', margin: '8px 0',
                    color: '#664d03', 'font-size': '12px', 'line-height': '1.5',
                });
                warn.innerHTML = '<b>bygone-yt requires V3 and StarTube to work.</b><br>' +
                    'V3 ("Get Old YouTube Layout") provides the 2013 YouTube layout.<br>' +
                    'StarTube is the companion userscript that V3 depends on.<br><br>' +
                    'Without these, bygone-yt cannot function — the page will not render correctly ' +
                    'and you may experience refresh loops or broken layouts.<br><br>' +
                    '<b>Install both V3 and StarTube first, then reload the page.</b>';
                wrap.appendChild(warn);
            } else {
                const ok = this._el('div', 'wbt-mute', 'V3 is installed and active. bygone-yt is ready to use.');
                wrap.appendChild(ok);
            }

            wrap.appendChild(this._el('h4', null, 'About'));
            const about = this._el('div', 'wbt-mute');
            about.textContent = 'bygone-yt v' + VERSION + ' — YouTube time machine for V3/StarTube. ' +
                'Set a date and browse YouTube as it was back then.';
            wrap.appendChild(about);

            return wrap;
        }

        // ---- Helpers -----------------------------------------------

        // Style map keyed off class name. _el applies these inline whenever
        // an element is created with a known class. This is what makes the
        // UI work even when GM_addStyle is blocked or V3 strips our <style>.
        static _STYLE_MAP = {
            'wbt-sec':   { 'margin-bottom': '14px' },
            'wbt-row':   { display: 'flex', gap: '6px', 'align-items': 'center', margin: '4px 0' },
            'wbt-btn':   {
                padding: '3px 9px', border: '1px solid #888', background: '#ddd',
                color: '#222', 'border-radius': '2px', cursor: 'pointer',
                font: '12px sans-serif', 'white-space': 'nowrap',
            },
            'wbt-btn-primary': {
                padding: '3px 9px', border: '1px solid #800', background: '#c00',
                color: '#fff', 'border-radius': '2px', cursor: 'pointer',
                font: '12px sans-serif', 'white-space': 'nowrap',
            },
            'wbt-btn-x': { padding: '0 6px', 'font-weight': 'bold', color: '#c00', border: '1px solid #888', background: '#ddd', cursor: 'pointer', 'border-radius': '2px', font: '12px sans-serif' },
            'wbt-list':  {
                background: '#fff', border: '1px solid #aaa',
                'border-radius': '2px', 'min-height': '28px',
                'max-height': '110px', 'overflow-y': 'auto',
            },
            'wbt-item':  {
                padding: '3px 6px', 'border-bottom': '1px dotted #ddd',
                display: 'flex', 'align-items': 'center', gap: '6px', cursor: 'grab',
            },
            'wbt-item-name': { flex: '1', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
            'wbt-toggle': { display: 'flex', 'align-items': 'center', gap: '6px', margin: '3px 0', cursor: 'pointer' },
            'wbt-mute':  { color: '#666', 'font-size': '11px' },
            'wbt-stats': { 'border-collapse': 'collapse' },
        };

        _el(tag, cls, text) {
            const el = document.createElement(tag);
            if (cls) {
                el.className = cls;
                // Apply inline styles for every class on the element.
                for (const c of cls.split(/\s+/)) {
                    const sty = UI._STYLE_MAP[c];
                    if (sty) for (const k in sty) {
                        try { el.style.setProperty(k, sty[k], 'important'); } catch {}
                    }
                }
            }
            if (text !== undefined && text !== null) el.textContent = text;
            // <h4> headings — pure tag-based default style (used inside panels).
            if (tag === 'h4') {
                try {
                    el.style.setProperty('margin', '0 0 6px', 'important');
                    el.style.setProperty('font', 'bold 12px sans-serif', 'important');
                    el.style.setProperty('color', '#333', 'important');
                    el.style.setProperty('border-bottom', '1px dotted #aaa', 'important');
                    el.style.setProperty('padding-bottom', '3px', 'important');
                } catch {}
            }
            // <input type="text"|"date"|"number">, <select> — style at use site
            // since type isn't known until later. Done with a microtask defer.
            if (tag === 'input' || tag === 'select') {
                queueMicrotask(() => {
                    if (el.type === 'checkbox' || el.type === 'radio' || el.type === 'file') return;
                    try {
                        el.style.setProperty('flex', '1', 'important');
                        el.style.setProperty('padding', '3px 5px', 'important');
                        el.style.setProperty('border', '1px solid #aaa', 'important');
                        el.style.setProperty('border-radius', '2px', 'important');
                        el.style.setProperty('font', '12px sans-serif', 'important');
                        el.style.setProperty('min-width', '0', 'important');
                        el.style.setProperty('background', '#fff', 'important');
                        el.style.setProperty('color', '#222', 'important');
                    } catch {}
                });
            }
            // Plain buttons (without wbt-btn class) — style anyway.
            if (tag === 'button' && (!cls || !/wbt-btn/.test(cls))) {
                try {
                    el.style.setProperty('padding', '3px 9px', 'important');
                    el.style.setProperty('border', '1px solid #888', 'important');
                    el.style.setProperty('background', '#ddd', 'important');
                    el.style.setProperty('color', '#222', 'important');
                    el.style.setProperty('border-radius', '2px', 'important');
                    el.style.setProperty('cursor', 'pointer', 'important');
                    el.style.setProperty('font', '12px sans-serif', 'important');
                } catch {}
            }
            return el;
        }

        _toggle(label, value, onChange) {
            const lab = this._el('label', 'wbt-toggle');
            const cb = this._el('input');
            cb.type = 'checkbox';
            cb.checked = !!value;
            cb.onchange = () => onChange(cb.checked);
            lab.appendChild(cb);
            lab.appendChild(document.createTextNode(' ' + label));
            return lab;
        }

        _enableDrag(panel, handle) {
            let dragging = false, ox = 0, oy = 0;
            handle.addEventListener('mousedown', (e) => {
                if (e.target.tagName === 'SPAN' || e.target.tagName === 'BUTTON') return;
                dragging = true;
                const r = panel.getBoundingClientRect();
                ox = e.clientX - r.left; oy = e.clientY - r.top;
                e.preventDefault();
            });
            document.addEventListener('mousemove', (e) => {
                if (!dragging) return;
                panel.style.left = (e.clientX - ox) + 'px';
                panel.style.top  = (e.clientY - oy) + 'px';
                panel.style.right = 'auto';
                panel.style.bottom = 'auto';
            });
            document.addEventListener('mouseup', () => { dragging = false; });
        }

        _reloadFeed() {
            App.primeInterceptor().catch(e => console.warn('[bygone] reload failed:', e));
        }

        _applyCustomLogo() {
            const url = Store.getCustomLogo();
            if (!url) return;
            const apply = () => {
                document.querySelectorAll('#logo img, #logo-icon img, .logo-icon img, a#logo img, .v3-logo img, #masthead-logo-link img')
                    .forEach(img => { img.src = url; });
            };
            apply();
            setTimeout(apply, 800);
            setTimeout(apply, 2500);
        }
    }

    // ============================================================
    //  APP — wire everything
    // ============================================================

    class App {
        static async init() {
            // Validate stored time offset (max 24h drift; reset garbage values).
            const offset = Store.getTimeOffset();
            if (Math.abs(offset) > 86400000) Store.setTimeOffset(0);

            // On version bump, clear cached source results (they may be stale or
            // use shapes the new code doesn't understand). ALSO wipe the
            // impression park + seen-id list: with `recordImpressions`
            // logging 60 ids per load and parking after 3 impressions for
            // 7 days, repeated reloads (testing, dev cycles, or even
            // ordinary use) shrink the visible pool to a handful of
            // videos. A version bump is the safe time to reset.
            const lastVersion = Store._get('bygone_last_version', 0);
            if (lastVersion < VERSION) {
                try { for (const k of GM_listValues()) if (k.startsWith('bygone_cache_')) GM_deleteValue(k); } catch {}
                try { Store.setImpressions({}); } catch {}
                try { Store.setSeenIds([]); } catch {}
                Store._set('bygone_last_version', VERSION);
            }

            // Sync with external time (non-blocking, silent on failure).
            App._syncTime();

            // Wait for body (V3 strips the YT shell so we don't wait for ytd-app).
            await App._waitForBody();

            const api = new YouTubeAPI();
            const feedEngine = new FeedEngine(api);
            App._api = api;
            App._feedEngine = feedEngine;
            App._ui = new UI(api, feedEngine);

            // Default date = 5 years ago if unset.
            if (!Store.getDate()) {
                const d = new Date();
                d.setFullYear(d.getFullYear() - 5);
                Store.setDate(Store._formatLocalDate ? Store._formatLocalDate(d) : d.toISOString().split('T')[0]);
            }

            // MOUNT UI FIRST — before priming the pool. The feed build can
            // take up to 30 s (or fail if no sources are configured), and
            // awaiting it before showing the panel meant a fresh install
            // saw no UI at all. The user needs the panel to even add
            // sources, so it has to come up immediately.
            try { App._ui.init(); } catch (e) { console.error('[bygone] UI mount failed:', e); }
            setTimeout(() => App._maybeShowDependencyPrompt(), 800);
            setInterval(() => {
                if (!document.getElementById('wbt-panel') && !document.getElementById('wbt-fab')) {
                    try { App._ui.init(); } catch {}
                }
            }, 5000);

            // Prime the interceptor pool IN THE BACKGROUND so V3 gets its
            // videos as soon as they're ready, but the UI stays responsive.
            App.primeInterceptor().catch(e => console.error('[bygone] prime failed:', e));

            // Apply custom logo on every nav.
            const applyLogo = () => { try { App._ui._applyCustomLogo(); } catch {} };
            applyLogo();
            window.addEventListener('yt-navigate-finish', applyLogo);
            window.addEventListener('popstate', applyLogo);

            // Watch-history tracking: on watch page, after ≥ 15 s of watching,
            // record the watch event.
            App._wireWatchTracking();

            // Subscribe hijack + auto-sync bygone subs to YouTube account.
            App._installSubscribeHijack();
            App._scheduleSubSync(2000); // initial sync after 2 s

            // Search-date-filter: append before:YYYY-MM-DD to search_query
            // (in the URL) but keep the visible search input clean.
            App._installSearchHijack();

            // Channel page handler — fetch that channel's videos by ID.
            window.addEventListener('yt-navigate-finish', () => {
                if (Interceptor.isChannelPage()) {
                    setTimeout(() => App._handleChannelPage().catch(e => console.warn('[bygone] channel page error:', e)), 800);
                } else {
                    App._channelPageActive = null;
                }
            });
            if (Interceptor.isChannelPage()) {
                setTimeout(() => App._handleChannelPage().catch(e => console.warn('[bygone] channel page error:', e)), 1500);
            }

            console.log(`[bygone] v2 (${VERSION}) ready. Date: ${Store.getCurrentDate()}`);
        }

        static _depsReady() {
            return { v3: _checkV3(), starTube: _checkStarTube() };
        }

        static _maybeShowDependencyPrompt() {
            try {
                if (Store.hasSeenDependencyPrompt()) return;
                const deps = App._depsReady();
                if (deps.v3 && deps.starTube) return;
                Store.markDependencyPromptSeen();
                App._renderDependencyPrompt(deps);
            } catch (e) {
                console.warn('[bygone] dependency prompt failed:', e);
            }
        }

        static _renderDependencyPrompt(deps) {
            if (!document.body || document.getElementById('wbt-dep-modal')) return;
            const modal = document.createElement('div');
            modal.id = 'wbt-dep-modal';

            const box = document.createElement('div');
            box.id = 'wbt-dep-modal-box';

            const title = document.createElement('div');
            title.textContent = 'bygone-yt needs V3 and StarTube';
            title.style.cssText = 'font:bold 14px sans-serif;margin-bottom:8px;color:#222;';
            box.appendChild(title);

            const missing = [];
            if (!deps.v3) missing.push('V3 / VORAPIS');
            if (!deps.starTube) missing.push('StarTube');
            const body = document.createElement('div');
            body.style.cssText = 'line-height:1.45;color:#333;margin-bottom:8px;';
            body.textContent = 'Missing: ' + missing.join(', ') + '. Install the missing scripts, then reload YouTube.';
            box.appendChild(body);

            const note = document.createElement('div');
            note.textContent = 'This message only appears once. The Setup tab will still show dependency status later.';
            note.style.cssText = 'font-size:11px;color:#666;margin-bottom:10px;';
            box.appendChild(note);

            const actions = document.createElement('div');
            actions.className = 'wbt-dep-actions';

            const mkBtn = (label, primary, onClick) => {
                const b = document.createElement('button');
                b.textContent = label;
                b.className = primary ? 'wbt-btn wbt-btn-primary' : 'wbt-btn';
                b.style.cssText = 'padding:4px 9px;border:1px solid ' + (primary ? '#800' : '#888') +
                    ';background:' + (primary ? '#c00' : '#ddd') +
                    ';color:' + (primary ? '#fff' : '#222') +
                    ';border-radius:2px;cursor:pointer;font:12px sans-serif;';
                b.addEventListener('click', onClick);
                return b;
            };
            const close = () => { try { modal.remove(); } catch (_) {} };

            if (!deps.v3) {
                actions.appendChild(mkBtn('Install V3', true, () => {
                    window.open(CONFIG.installUrls.v3, '_blank', 'noopener,noreferrer');
                    close();
                }));
            }
            if (!deps.starTube) {
                actions.appendChild(mkBtn('Install StarTube', true, () => {
                    window.open(CONFIG.installUrls.starTube, '_blank', 'noopener,noreferrer');
                    close();
                }));
            }
            actions.appendChild(mkBtn('Open setup', false, () => {
                close();
                try {
                    if (App._ui) App._ui._activeTab = 'setup';
                    App._ui.init();
                    const panel = document.getElementById('wbt-panel');
                    if (panel) {
                        panel.classList.remove('wbt-hidden');
                        panel.style.setProperty('display', 'block', 'important');
                    }
                } catch (_) {}
            }));
            actions.appendChild(mkBtn('Close', false, close));

            box.appendChild(actions);
            modal.appendChild(box);
            document.body.appendChild(modal);
        }

        // ---- Search-date-filter hijack ---------------------------
        // Goal: every search the user runs should be limited to videos
        // published before the configured time-machine date, but the
        // search bar itself should NEVER show the `before:YYYY-MM-DD`
        // tag. The tag lives only in the URL / link target.
        //
        // Two paths cover this:
        //   1. Submit-time:   intercept the search form's submit and
        //      temporarily mutate the input.value to append the tag.
        //      YT reads input.value to build the navigation URL, so
        //      the resulting /results URL has `before:` in it. We
        //      restore the visible value on the next tick so the user
        //      never sees the tag.
        //   2. URL-fixup:     if a /results page is reached without
        //      the tag (back-button, deep link, programmatic nav),
        //      redirect to the same URL with the tag appended.
        // After landing on /results, a low-frequency interval keeps
        // the visible input scrubbed (YT re-renders it on nav).
        static _installSearchHijack() {
            const TAG_RE = /\s*before:\d{4}-\d{2}-\d{2}/g;
            const cleanOf = (s) => (s || '').replace(TAG_RE, '').trim();
            const hasTag = (s) => /before:\d{4}-\d{2}-\d{2}/.test(s || '');

            const findSearchInput = () => {
                return document.querySelector(
                    'input#search, input[name="search_query"], input#masthead-search-term'
                );
            };

            const scrubVisibleInput = () => {
                const inp = findSearchInput();
                if (!inp) return;
                if (hasTag(inp.value)) inp.value = cleanOf(inp.value);
            };

            // Path 2: URL-level fixup on /results.
            const applyUrlFixup = () => {
                if (!Store.isActive()) return;
                const p = location.pathname;
                if (p !== '/results' && p !== '/results/') return;
                const dateStr = Store.getCurrentDate();
                if (!dateStr) return;
                const params = new URLSearchParams(location.search);
                const query = params.get('search_query') || '';
                if (!query) return;
                if (hasTag(query)) { scrubVisibleInput(); return; }
                params.set('search_query', `${query} before:${dateStr}`.trim());
                window.location.replace(`/results?${params.toString()}`);
            };

            // Path 1: capture-phase submit hook. Runs before YT's own
            // handlers, so by the time YT reads input.value to build
            // the URL, the tag is already there.
            document.addEventListener('submit', (e) => {
                if (!Store.isActive()) return;
                const form = e.target;
                if (!form || form.tagName !== 'FORM') return;
                const input = form.querySelector(
                    'input[name="search_query"], input#search, input#masthead-search-term'
                );
                if (!input || !input.value.trim()) return;
                if (hasTag(input.value)) return;
                const dateStr = Store.getCurrentDate();
                if (!dateStr) return;
                const original = input.value;
                try { Store.addSearchQuery(original); } catch (_) {}
                input.value = `${original.trim()} before:${dateStr}`;
                // Restore the visible value after YT has read it to
                // build the navigation URL. Microtask is too early
                // (sometimes runs before YT's submit handler); a 0ms
                // timeout is safe.
                setTimeout(() => {
                    const live = findSearchInput();
                    if (live) live.value = original;
                }, 0);
            }, true);

            // Same idea for Enter keydown — some YT layouts fire nav
            // directly off the keypress without a form submit event.
            document.addEventListener('keydown', (e) => {
                if (e.key !== 'Enter' || e.isComposing) return;
                if (!Store.isActive()) return;
                const t = e.target;
                if (!t || !t.matches) return;
                if (!t.matches('input[name="search_query"], input#search, input#masthead-search-term')) return;
                if (!t.value.trim() || hasTag(t.value)) return;
                const dateStr = Store.getCurrentDate();
                if (!dateStr) return;
                const original = t.value;
                try { Store.addSearchQuery(original); } catch (_) {}
                t.value = `${original.trim()} before:${dateStr}`;
                setTimeout(() => {
                    const live = findSearchInput();
                    if (live) live.value = original;
                }, 0);
            }, true);

            // Run URL fixup on initial load + every nav.
            applyUrlFixup();
            window.addEventListener('yt-navigate-finish', () => {
                applyUrlFixup();
                setTimeout(scrubVisibleInput, 100);
                setTimeout(scrubVisibleInput, 500);
            });
            window.addEventListener('popstate', () => {
                applyUrlFixup();
                setTimeout(scrubVisibleInput, 100);
            });
            // Low-frequency scrubber for YT re-renders of the input.
            setInterval(() => {
                const p = location.pathname;
                if (p === '/results' || p === '/results/') scrubVisibleInput();
            }, 1000);
        }

        // ---- Subscribe button click hijack -----------------------
        // When the user clicks any "Subscribe" button anywhere on
        // YouTube (V3 2013 markup OR modern), add the channel to the
        // bygone subscriptions list. Doesn't BLOCK YouTube's own
        // subscribe flow — just records the channel for us.
        static _installSubscribeHijack() {
            const isSubButton = (el) => {
                for (let i = 0; i < 8 && el && el !== document.body; i++) {
                    const cls = (el.className && el.className.toString && el.className.toString()) || '';
                    if (/yt-uix-button-subscribe-branded|yt-uix-subscription-button|subscribe-button-renderer|ytd-subscribe-button/i.test(cls)) return el;
                    const aria = el.getAttribute && (el.getAttribute('aria-label') || '');
                    if (/^subscribe/i.test(aria) || /^subscribed/i.test(aria)) return el;
                    const txt = (el.textContent || '').trim().toLowerCase();
                    if (txt === 'subscribe' || txt === 'subscribed') return el;
                    el = el.parentElement;
                }
                return null;
            };

            document.addEventListener('click', async (e) => {
                if (!isSubButton(e.target)) return;
                // Detect whether the action was a SUBSCRIBE or an
                // UNSUBSCRIBE by reading state RIGHT before the click
                // takes effect. If the button currently reads
                // "Subscribed", the click will unsubscribe.
                let wasSubscribed = false;
                try {
                    let el = e.target;
                    for (let i = 0; i < 8 && el; i++) {
                        const txt = (el.textContent || '').trim().toLowerCase();
                        if (txt === 'subscribed') { wasSubscribed = true; break; }
                        if (txt === 'subscribe') { wasSubscribed = false; break; }
                        el = el.parentElement;
                    }
                } catch {}

                // Give YouTube's own handler a moment to fire so the page
                // state settles, then extract channel info from the DOM.
                setTimeout(async () => {
                    try {
                        const info = await App._extractChannelInfo();
                        if (!info || !info.id) return;
                        const subs = Store.getSubscriptions();
                        const i = subs.findIndex(s => s.id === info.id);
                        if (wasSubscribed) {
                            // User just unsubscribed → remove from bygone.
                            if (i >= 0) {
                                subs.splice(i, 1);
                                Store.setSubscriptions(subs);
                                console.log('[bygone] removed subscription:', info.name);
                            }
                        } else {
                            // User just subscribed → add to bygone.
                            if (i < 0) {
                                subs.push({ id: info.id, name: info.name, weight: 3 });
                                Store.setSubscriptions(subs);
                                Store.markSubSynced(info.id); // already on YT
                                console.log('[bygone] added subscription:', info.name);
                            }
                        }
                    } catch (err) {
                        console.warn('[bygone] sub hijack error:', err);
                    }
                }, 200);
            }, true);
        }

        // Pull channel ID + name from the current page (works on
        // channel pages, watch pages, and anywhere a channel link is
        // visible near the top of the page).
        static async _extractChannelInfo() {
            const path = location.pathname;
            // Direct /channel/UC...
            let m = path.match(/^\/channel\/(UC[A-Za-z0-9_-]+)/);
            if (m) {
                const id = m[1];
                const name = App._scrapeChannelName() || id;
                return { id, name };
            }
            // /@handle, /c/, /user/ — find a channel link on the page
            // whose href is /channel/UC...
            const link = document.querySelector('a[href^="/channel/UC"]');
            if (link) {
                const href = link.getAttribute('href') || '';
                const m2 = href.match(/^\/channel\/(UC[A-Za-z0-9_-]+)/);
                if (m2) {
                    const id = m2[1];
                    const name = (link.textContent || '').trim() || App._scrapeChannelName() || id;
                    return { id, name };
                }
            }
            // Fall back: try to resolve via the name shown on the page
            // (slower; one API hit).
            const name = App._scrapeChannelName();
            if (!name) return null;
            try {
                const ch = await App._api.resolveChannel(name);
                if (ch && ch.id) return { id: ch.id, name: ch.name || name };
            } catch {}
            return null;
        }

        static _scrapeChannelName() {
            const sels = [
                '.qualified-channel-title-text',
                '.channel-header-profile-image-container .channel-title',
                '#channel-header-container .channel-name',
                '#channel-name',
                'ytd-channel-name',
                '.ytd-channel-name',
                '.attribution .g-hovercard',
                '.attribution .yt-user-name',
                '.attribution',
            ];
            for (const sel of sels) {
                const el = document.querySelector(sel);
                if (el && el.textContent && el.textContent.trim()) {
                    return el.textContent.trim().replace(/^by\s+/i, '');
                }
            }
            return null;
        }

        // Auto-sync bygone subscriptions to the user's YouTube account.
        // For every bygone sub with a channel ID that hasn't been synced
        // yet, fire a subscribe API call. Rate-limited via the YouTubeAPI
        // internal cooldown.
        static async _syncSubsToYouTube() {
            if (!Store.isAutoSyncSubs()) return;
            const subs = Store.getSubscriptions();
            const synced = new Set(Store.getSyncedSubIds());
            const pending = subs.filter(s => s && s.id && !synced.has(s.id));
            if (!pending.length) return;
            console.log(`[bygone] syncing ${pending.length} subscription(s) to YouTube…`);
            for (const sub of pending) {
                try {
                    const ok = await App._api.subscribeToChannel(sub.id);
                    if (ok) {
                        Store.markSubSynced(sub.id);
                        console.log('[bygone] subscribed on YouTube:', sub.name);
                    }
                } catch (e) {
                    console.warn('[bygone] sync error for', sub.name, e.message);
                }
            }
        }

        // Debounced trigger — coalesces multiple changes into one batch
        // (e.g. when the user adds three subs in a row in the panel).
        static _scheduleSubSync(delay) {
            if (App._syncTimer) clearTimeout(App._syncTimer);
            App._syncTimer = setTimeout(() => {
                App._syncTimer = null;
                App._syncSubsToYouTube().catch(e => console.warn('[bygone] sync failed:', e));
            }, delay || 1500);
        }

        // Build the feed and feed it into the interceptor pool. Also wires
        // the lazy fetcher for infinite scroll.
        static async primeInterceptor() {
            try {
                const date = Store.getCurrentDate();
                if (!date) return;
                const videos = await App._feedEngine.buildHomeFeed(date);
                if (videos && videos.length) {
                    Interceptor.setVideos(videos);
                    console.log('[bygone] primed with', videos.length, 'videos');
                    App._enrichExactDates(videos, date);   // background, non-blocking
                }
                Interceptor.setLazyFetcher(async (page) => {
                    const cur = Store.getCurrentDate();
                    if (!cur) return [];
                    const more = await App._feedEngine.buildHomeFeedMore(cur, page, Interceptor.getPoolIds());
                    App._enrichExactDates(more, cur);
                    return more;
                });
            } catch (e) {
                console.error('[bygone] prime failed', e);
            }
        }

        // Fetch EXACT publish dates for the videos most likely to be shown, so
        // their "X ago" reads precisely against the set date instead of being
        // guessed from year-granular strings. Only the ambiguous ones (coarse
        // approx within ~400 days of the set date, where year-granularity
        // fails) are fetched; clearly-older videos relativize fine already.
        // Results cache persistently per id, so this cost is paid once across
        // loads. Runs in the background and refreshes displayed dates per
        // chunk as they arrive. Guarded so only one run happens at a time.
        static _enrichRunning = false;
        static async _enrichExactDates(videos, setDateStr) {
            if (App._enrichRunning || !videos || !videos.length) return;
            // Home-feed only. The watch page doesn't show the home feed, and
            // firing dozens of /next calls there floods the session with API
            // traffic (on top of the page's own player/next/comment requests),
            // which can get the session rate-limited and the player reloading.
            const _p = location.pathname;
            if (_p !== '/' && _p !== '' && _p !== '/feed/trending') return;
            const anchor = new Date(setDateStr).getTime();
            if (isNaN(anchor)) return;
            const AMBIG_MS = 400 * 86400000;
            const need = [];
            for (const v of videos) {
                if (!v || !v.id || Store.getExactDate(v.id)) continue;
                const approx = DateHelper.approxPublishDate(v.relativeDate);
                if (approx && Math.abs(anchor - approx.getTime()) > AMBIG_MS) continue;
                need.push(v.id);
                if (need.length >= 80) break;   // prioritise the soonest-shown
            }
            if (!need.length) return;
            App._enrichRunning = true;
            try {
                const CHUNK = 10;
                for (let i = 0; i < need.length; i += CHUNK) {
                    const slice = need.slice(i, i + CHUNK);
                    const map = {};
                    await Promise.all(slice.map(async id => {
                        const iso = await App._api.fetchExactDate(id);
                        if (iso) map[id] = iso;
                    }));
                    if (Object.keys(map).length) {
                        Store.addExactDates(map);
                        try { Interceptor.refreshAllDates(); } catch (_) {}
                    }
                    // Bail if the user changed the era mid-fetch.
                    if (Store.getCurrentDate() !== setDateStr) break;
                }
                console.log('[bygone] exact-date enrichment done for', need.length, 'videos');
            } catch (_) {
            } finally {
                App._enrichRunning = false;
            }
        }

        static _channelPageActive = null;

        static async _handleChannelPage() {
            if (!Interceptor.isChannelPage()) return;
            const date = Store.getCurrentDate();
            if (!date) return;

            const info = await App._extractChannelInfo();
            if (!info || !info.id) {
                console.warn('[bygone] channel page: could not extract channelId');
                return;
            }

            if (App._channelPageActive === info.id) return;
            App._channelPageActive = info.id;

            console.log('[bygone] channel page: fetching videos for', info.id, info.name);

            try {
                const videos = await App._api.getChannelVideos(info.name, {
                    channelId: info.id,
                    publishedBefore: date,
                    maxResults: 50,
                });

                if (!videos || !videos.length) {
                    console.log('[bygone] channel page: no videos found before', date);
                    return;
                }

                videos.sort((a, b) => {
                    const da = DateHelper.approxPublishDate(a.relativeDate);
                    const db = DateHelper.approxPublishDate(b.relativeDate);
                    if (!da && !db) return 0;
                    if (!da) return 1;
                    if (!db) return -1;
                    return db.getTime() - da.getTime();
                });

                for (const v of videos) {
                    if (v.relativeDate) {
                        try {
                            v.relativeDate = DateHelper.recalcForFeed(v.relativeDate, date, v.id) || v.relativeDate;
                        } catch (_) {}
                    }
                }

                console.log('[bygone] channel page: fetched', videos.length, 'videos for', info.id);

                const tryRewrite = () => {
                    const cards = Interceptor.findCards(document);
                    if (!cards.length) return false;
                    const channelCards = cards.filter(c => {
                        const a = c.querySelector('a[href*="/watch"]');
                        return !!a;
                    });
                    const inner = [];
                    let wrote = 0;
                    for (let i = 0; i < channelCards.length; i++) {
                        if (i < videos.length) {
                            try {
                                Interceptor.rewriteCard(channelCards[i], videos[i], inner);
                                channelCards[i].setAttribute('data-bygone-ok', '1');
                                wrote++;
                            } catch (_) {}
                        } else {
                            try { channelCards[i].style.setProperty('display', 'none', 'important'); } catch (_) {}
                        }
                    }
                    return wrote > 0;
                };

                if (!tryRewrite()) {
                    setTimeout(tryRewrite, 500);
                    setTimeout(tryRewrite, 1500);
                    setTimeout(tryRewrite, 3000);
                }
            } catch (e) {
                console.error('[bygone] channel page fetch failed:', e);
            }
        }

        static _syncTime() {
            try {
                GM_xmlhttpRequest({
                    method: 'GET', url: 'https://worldtimeapi.org/api/ip', timeout: 5000,
                    onload(res) {
                        try {
                            const data = JSON.parse(res.responseText);
                            if (!data || !data.unixtime) return;
                            const drift = data.unixtime * 1000 - Date.now();
                            // Cap drift at 24h — anything bigger is garbage.
                            if (Math.abs(drift) > 86400000) return;
                            Store.setTimeOffset(Math.abs(drift) > 30000 ? drift : 0);
                        } catch {}
                    },
                    onerror() {}, ontimeout() {},
                });
            } catch {}
        }

        static _waitForBody() {
            return new Promise(resolve => {
                let waited = 0;
                const check = () => {
                    if (document.body) return resolve();
                    waited += 200;
                    if (waited >= 10000) return resolve();
                    setTimeout(check, 200);
                };
                if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', check, { once: true });
                else check();
            });
        }

        // On watch page, count a "watch" after 15s of being on it. Cheap,
        // doesn't try to read the video element (which V3's player wraps).
        static _wireWatchTracking() {
            let timer = null;
            const tick = () => {
                if (timer) { clearTimeout(timer); timer = null; }
                if (!location.pathname.startsWith('/watch')) return;
                const m = location.search.match(/[?&]v=([A-Za-z0-9_-]+)/);
                if (!m) return;
                const videoId = m[1];
                timer = setTimeout(() => {
                    if (!location.pathname.startsWith('/watch')) return;
                    if (location.search.indexOf(videoId) === -1) return;
                    const titleEl = document.querySelector('.watch-title, #eow-title, h1.title, .watch-page-title') || document.querySelector('title');
                    const chanEl = document.querySelector('.yt-user-name, .watch-user-name, .attribution .g-hovercard, .attribution');
                    let channelId = null;
                    const chanLink = document.querySelector(
                        'a[href*="/channel/UC"], .yt-user-name[href*="/channel/"], .attribution a[href*="/channel/"]'
                    );
                    if (chanLink) {
                        const cm = (chanLink.getAttribute('href') || '').match(/\/channel\/(UC[A-Za-z0-9_-]+)/);
                        if (cm) channelId = cm[1];
                    }
                    if (!channelId) {
                        const pv = Interceptor.getPoolVideo(videoId);
                        if (pv && pv.channelId) channelId = pv.channelId;
                    }
                    Store.addWatchEvent({
                        videoId,
                        title: titleEl ? titleEl.textContent.trim().slice(0, 200) : '',
                        channel: chanEl ? chanEl.textContent.trim().slice(0, 80) : '',
                        channelId,
                        ts: Date.now(),
                    });
                }, 15000);
            };
            window.addEventListener('yt-navigate-finish', tick);
            window.addEventListener('popstate', tick);
            tick();
        }
    }

    // ============================================================
    //  ENTRY
    // ============================================================

    App.init();

})();