Pornolab — Form autocomplete

Вставляет раздел «Автозаполнение формы» и заполняет поля через VNDB/Steam API

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Pornolab — Form autocomplete
// @namespace    https://github.com/yourname
// @version      1.1.1
// @description  Вставляет раздел «Автозаполнение формы» и заполняет поля через VNDB/Steam API
// @author       claude.ai
// @match        https://pornolab.net/forum/posting.php?mode=new_rel&f=1756
// @match        https://pornolab.net/forum/posting.php?mode=new_rel&f=1869
// @match        https://pornolab.net/forum/posting.php?mode=new_rel&f=1750
// @match        https://pornolab.net/forum/posting.php?mode=new_rel&f=1785
// @match        https://pornolab.net/forum/posting.php?mode=new_rel&f=1790
// @match        https://pornolab.net/forum/posting.php?mode=new_rel&f=1827
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      api.vndb.org
// @connect      store.steampowered.com
// @connect      steamspy.com
// @connect      translate.googleapis.com
// @connect      new.fastpic.org
// @connect      static.pornolab.net
// @connect      api.igdb.com
// @connect      id.twitch.tv
// @connect      images.igdb.com
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    // ─── Конфигурация ──────────────────────────────────────────────────────────

    const FORUM_ID = new URL(location.href).searchParams.get('f') || '';
    const DEFAULT_STEAM_FORUMS = ['1750', '1785', '1790', '1827'];
    const API_BASE = 'https://api.vndb.org/kana';

    const FIELDS = {
        poster:        'poster',
        hieroglyphs:   '7cd5187f9e6f07f61f4ba4b07c559644',
        titleEng:      'title_eng',
        titleRu:       '92bc57d69021863813152bd3afe11942',
        year:          'g_year',
        date:          'g_data',
        tags:          'genre_g',
        cens:          'cens_game',
        developer:     '6952199379228e5408178e7c18b27360',
        platform:      'platform',
        releaseType:   'release_type',
        medicine:      'tablet',
        version:       '88311f8a842b3a42b3f4d079846ef9ab',
        langStory:     'lang_g',
        langInterface: 'menulang_g',
        langVoice:     'language_g',
        sysReq:        '8baf6af65599d806043e079219e49003',
        description:   'description',
        moreInfo:      'moreinfo',
        stepInstall:   'stepinstall',
        screenshots:   'screenshots_g',
    };

    const FIELD_LABELS = {
        hieroglyphs: 'иероглифы',
        titleEng:    'Оригинальное название',
        titleRu:     'Название на русском',
        developer:   'Разработчик',
        version:     'Версия',
        sysReq:      'Системные требования',
    };

    const EXTRA_TAGS_GROUPS = [
        { title: 'Ориентация', tags: [
            { key: 'xt_Straight',      label: 'Straight'        },
            { key: 'xt_Gay_Yaoi',      label: 'Gay/Yaoi'        },
            { key: 'xt_Trans_Trap',    label: 'Trans/Trap'      },
            { key: 'xt_Lesbian_Yuri',  label: 'Lesbian/Yuri'    },
            { key: 'xt_Bisexual',      label: 'Bisexual'        },
        ]},
        { title: 'OS', tags: [
            { key: 'xt_Win',           label: 'Win'             },
            { key: 'xt_Lin',           label: 'Lin'             },
            { key: 'xt_Mac',           label: 'Mac'             },
            { key: 'xt_Apk',           label: 'Apk'             },
        ]},
        { title: 'Разработчик', tags: [
            { key: 'xt_Indie',         label: 'Indie'           },
            { key: 'xt_Pro',           label: 'Pro'             },
        ]},
        { title: 'Движок', tags: [
            { key: 'xt_HTML',          label: 'HTML'            },
            { key: 'xt_Flash',         label: 'Flash'           },
            { key: 'xt_GameMaker',     label: 'GameMaker'       },
            { key: 'xt_Godot',         label: 'Godot'           },
            { key: 'xt_NW_js',         label: 'NW.js'           },
            { key: 'xt_Unity',         label: 'Unity'           },
            { key: 'xt_Unreal',        label: 'Unreal'          },
            { key: 'xt_RenPy',         label: "Ren'Py"          },
            { key: 'xt_Kirikiri',      label: 'Kirikiri'        },
            { key: 'xt_Tyranobuilder', label: 'Tyranobuilder'   },
            { key: 'xt_QSP',           label: 'QSP'             },
            { key: 'xt_RPGMaker',      label: 'RPG Maker'       },
            { key: 'xt_WolfRPG',       label: 'Wolf RPG Editor' },
        ]},
    ];

    const LANG_G_ABR = [
        '',                            // » Выбрать
        'rus',                         // Русский
        'rus(auto)',                   // Русский(авто)
        'eng',                         // Английский
        'eng(auto)',                   // Английский(авто)
        'rus+eng',                     // Русский+Английский
        'rus+eng+multi',               // Русский+Английский и др.
        'rus+eng(auto)+multi',         // Русский+Английский(авто) и др.
        'jap',                         // Японский
        'chi',                         // Китайский
        'jap+eng',                     // Японский+Английский
        'jap+eng(auto)',               // Японский+Английский(авто)
        'jap+rus',                     // Японский+Русский
        'jap+rus(auto)',               // Японский+Русский(авто)
        'chi+eng',                     // Китайский+Английский
        'chi+eng(auto)',               // Китайский+Английский(авто)
        'chi+rus',                     // Китайский+Русский
        'chi+rus(auto)',               // Китайский+Русский(авто)
        'jap+eng+rus',                 // Яп.+Англ.+Рус.
        'jap+eng+rus(auto)',           // Яп.+Англ.+Рус.(авто)
        'chi+eng+rus',                 // Кит.+Англ.+Рус.
        'chi+eng+rus(auto)',           // Кит.+Англ.+Рус.(авто)
        'jap+chi+eng+rus',             // Яп.+Кит.+Англ.+Рус.
        'jap+chi+eng+rus(auto)',       // Яп.+Кит.+Англ.+Рус.(авто)
        'jap+chi+eng+rus+multi',       // Яп.+Кит.+Англ.+Рус.и др.
        'jap+chi+eng+rus(auto)+multi', // Яп.+Кит.+Англ.+Рус.(авто)и др.
        '',                            // Неизвестен
    ];

    const VOICE_ABR_TO_LABEL = {
        'jap':                         'Японский',
        'eng':                         'Английский',
        'rus':                         'Русский',
        'chi':                         'Китайский',
        'jap+eng':                     'Японский+Английский',
        'jap+eng(auto)':               'Японский+Английский(авто)',
        'jap+rus':                     'Японский+Русский',
        'jap+rus(auto)':               'Японский+Русский(авто)',
        'chi+eng':                     'Китайский+Английский',
        'chi+eng(auto)':               'Китайский+Английский(авто)',
        'chi+rus':                     'Китайский+Русский',
        'chi+rus(auto)':               'Китайский+Русский(авто)',
        'jap+eng+rus':                 'Яп.+Англ.+Рус.',
        'jap+eng+rus(auto)':           'Яп.+Англ.+Рус.(авто)',
        'chi+eng+rus':                 'Кит.+Англ.+Рус.',
        'chi+eng+rus(auto)':           'Кит.+Англ.+Рус.(авто)',
        'jap+chi+eng+rus':             'Яп.+Кит.+Англ.+Рус.',
        'jap+chi+eng+rus(auto)':       'Яп.+Кит.+Англ.+Рус.(авто)',
        'jap+chi+eng+rus+multi':       'Яп.+Кит.+Англ.+Рус.и др.',
        'jap+chi+eng+rus(auto)+multi': 'Яп.+Кит.+Англ.+Рус.(авто)и др.',
    };

    const STORE_ICON_MAP = new Map([
        ['ci-en.dlsite.com',       'ci-en.png'],
        ['dl.getchu.com',          'getchu.png'],
        ['dmm.co.jp',              'fanza.png'],
        ['store.steampowered.com', 'steam.png'],
        ['gog.com',                'gogcom.png'],
        ['itch.io',                'itch_io.png'],
        ['nutaku.net',             'nutaku.png'],
        ['jastusa.com',            'jast-usa.png'],
        ['jaststore.com',          'jast-usa.png'],
        ['saikeystudios.com',      'saikey.png'],
        ['dlsite.com',             'dlsite.png'],
        ['getchu.com',             'getchu.png'],
        ['dmm.com',                'dmm.png'],
        ['fantia.jp',              'fantia.png'],
        ['boosty.to',              'boosty.png'],
        ['patreon.com',            'patreon.png'],
        ['subscribestar.com',      'subscribestar.png'],
        ['x.com',                  'x.png'],
    ]);
    const ICON_BASE = 'https://static.pornolab.net/icons_new/r/';

    // ─── Настройки ─────────────────────────────────────────────────────────────

    const SETTINGS_KEY = 'af_settings_v1';
    const SOURCE_KEY   = 'af_source_v1';

    // Поля, общие для обоих источников (дублируются с префиксом stm_)
    const _STM_MIRROR = {
        fieldPoster: true,
        fieldHieroglyphs: true, fieldTitleEng: true, fieldTitleRu: true,
        fieldYear: true, fieldDate: true, fieldTags: true,
        fieldDeveloper: true, fieldPlatform: true,
        fieldLanguage: true, fieldVoice: true,
        fieldSysReq: true, formatSysReqTemplate: true,
        fieldLinks: true, fieldScreenshots: true,
    };
    const DEFAULT_SETTINGS = {
        ..._STM_MIRROR,
        adaptTagEditor: true, addIconLinks: true,
        ...Object.fromEntries(EXTRA_TAGS_GROUPS.flatMap(g => g.tags.map(t => [t.key, false]))),
        fieldCensorship: true, fieldReleaseType: true, fieldDescription: true,
        enableSteamPoster: false, enableSteamTags: false, enableSteamScreenshots: false,
        enableSteamSpy: false, enableGoogleTranslate: false, enableFastpicUpload: true,
        ...Object.fromEntries(Object.entries(_STM_MIRROR).map(([k, v]) => [`stm_${k}`, v])),
        stm_fieldDescShort: true, stm_fieldDescLong: true, stm_enableGoogleTranslate: false,
    };

    function loadSettings() {
        try {
            const raw = GM_getValue(SETTINGS_KEY, null);
            if (!raw) return { ...DEFAULT_SETTINGS };
            return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
        } catch { return { ...DEFAULT_SETTINGS }; }
    }

    function saveSettings(s) {
        GM_setValue(SETTINGS_KEY, JSON.stringify(s));
    }

    let settings = loadSettings();

    // ─── Toast-уведомления ─────────────────────────────────────────────────────

    let toastContainer = null;

    function ensureToastContainer() {
        if (toastContainer) return;
        toastContainer = document.createElement('div');
        toastContainer.id = 'af-toast-container';
        document.body.appendChild(toastContainer);
    }

    function toast(msg, type = 'info', duration = 3500) {
        ensureToastContainer();
        const el = document.createElement('div');
        el.className = `af-toast af-toast-${type}`;
        el.textContent = msg;
        el.addEventListener('click', () => el.remove());
        toastContainer.appendChild(el);
        if (duration > 0) setTimeout(() => el.remove(), duration);
        return el;
    }

    async function withToast(loadMsg, fn, { successMsg = '', failMsg = '', failDuration = 5000 } = {}) {
        const t = toast(loadMsg, 'info', 0);
        try {
            const result = await fn();
            t.remove();
            if (successMsg) toast(successMsg, 'success');
            return result;
        } catch (err) {
            t.remove();
            if (failMsg) toast(`${failMsg}: ${err.message}`, 'warning', failDuration);
            return null;
        }
    }

    // ─── Вспомогательные функции ───────────────────────────────────────────────

    function getHostname(url) {
        try { return new URL(url).hostname; } catch { return null; }
    }

    function _matchStore(hostname) {
        if (!hostname) return null;
        for (const [d, icon] of STORE_ICON_MAP)
            if (hostname === d || hostname.endsWith('.' + d)) return { domain: d, icon };
        return null;
    }

    function getCanonicalDomain(url) {
        return _matchStore(getHostname(url))?.domain ?? null;
    }

    function getStoreIcon(url) {
        return _matchStore(getHostname(url))?.icon ?? null;
    }

    function urlToBBIcon(url, iconFile) {
        if (iconFile) return `[url=${url}][img]${ICON_BASE}${iconFile}[/img][/url]`;
        const domain = getCanonicalDomain(url) || getHostname(url) || url;
        return `[url=${url}]${domain}[/url]`;
    }

    function setVal(el, value) {
        if (!el) return;
        el.value = value;
        el.dispatchEvent(new Event('input',  { bubbles: true }));
        el.dispatchEvent(new Event('change', { bubbles: true }));
    }

    function setLangSelect(id, abrValue) {
        const sel = getField(id);
        if (!sel) return;
        const idx = LANG_G_ABR.indexOf(abrValue);
        if (idx >= 0) {
            sel.selectedIndex = idx;
            sel.dispatchEvent(new Event('change', { bubbles: true }));
        }
    }

    function getField(id, label = null) {
        let el = document.getElementById(id);
        if (!el && label) {
            for (const td of document.querySelectorAll('td.rel-title')) {
                if (td.textContent.includes(label)) {
                    el = td.nextElementSibling?.querySelector('input.rel-input, textarea.rel-input, select.rel-input');
                    if (el) break;
                }
            }
        }
        return el || null;
    }

    function getIconsField() {
        const tagsField = getField(FIELDS.tags);
        if (!tagsField) return null;
        const container = tagsField.closest('td') || tagsField.parentElement;
        if (!container) return null;
        const hasEditor = Array.from(container.querySelectorAll('input[type="button"]'))
            .some(b => b.value?.includes('Редактор'));
        if (!hasEditor) return null;
        const inputs = Array.from(container.querySelectorAll('input[type="text"], textarea'));
        const tagIdx = inputs.indexOf(tagsField);
        if (tagIdx >= 0 && tagIdx + 1 < inputs.length) return inputs[tagIdx + 1];
        return null;
    }

    function setAnnotation(targetEl, id, html, before = false) {
        if (!targetEl) return;
        let ann = document.getElementById(id);
        if (!ann) {
            ann = document.createElement(before ? 'div' : 'span');
            ann.id = id;
            ann.style.cssText = before
                ? 'color:#cc0000;margin-bottom:4px;font-size:12px;'
                : 'color:#cc0000;margin-left:8px;font-size:12px;';
            targetEl.parentNode.insertBefore(ann, before ? targetEl : targetEl.nextSibling);
        }
        ann.innerHTML = html;
    }

    function fillIf(settingKey, fieldId, value, label = null) {
        if (!settings[settingKey] || !value) return;
        const f = getField(fieldId, label);
        if (f) setVal(f, value);
    }

    // ─── FastPic — аннотации ──────────────────────────────────────────────────

    const _FP_HREF = '<a href="https://new.fastpic.org/viaurl/" target="_blank" style="color:#345da4;font-weight:bold;">fastpic</a>';
    const ANN_POSTER_MANUAL = `Перезалейте ссылку на ${_FP_HREF} вручную`;
    const ANN_POSTER_COVER  = `Перезалейте ссылку на ${_FP_HREF}, выбрав режим Постер (Cover)`;
    const ANN_SCREENS       = `Перезалейте все ссылки на ${_FP_HREF} и получите ссылки для вставки миниатюр 350px`;
    const TAG_EDITOR_ANN    = 'Скопируйте эти теги в <a href="https://static.pornolab.net/tag_editor/?conf=game" ' +
        'target="_blank" style="color:#345da4;font-weight:bold;">редактор тегов</a> для проверки';

    // ─── Парсинг системных требований ─────────────────────────────────────────

    function parseSteamMinReqs(html) {
    if (!html) return null;
    return html
        .replace(/<br\s*\/?>/gi, '\n')
        .replace(/<strong>Minimum:<\/strong>/gi, '')
        .replace(/<[^>]+>/g, '')
        .replace(/OS \*:/g, 'OS:')
        .replace(/\s*\b(or higher|or better|or equivalent)\b/gi, '')
        .split('\n').map(l => l.trim()).filter(Boolean).join('\n');
    }

    function formatSysReqToTemplate(text) {
        if (!text) return text;
        const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
        const getVal = key => {
            for (const line of lines)
                if (line.toLowerCase().startsWith(key.toLowerCase() + ':'))
                    return line.slice(line.indexOf(':') + 1).trim();
            return null;
        };
        const squishUnits = s => s.replace(/\s+(GHz|MHz|GB|MB)/gi, '$1');

        const os        = getVal('OS');
        const processor = getVal('Processor');
        const memory    = getVal('Memory');
        const graphics  = getVal('Graphics');
        const storage   = getVal('Storage');
        const parts     = [];

        if (os) parts.push(`OS: ${os}`);
        if (processor && /intel|amd|ghz|mhz/i.test(processor)) {
            const cpu = processor
                .replace(/\b(single-core|quad[\s-]?core|dual-core)\b/gi, '')
                .replace(/\s{2,}/g, ' ').trim();
            parts.push(`CPU: ${squishUnits(cpu)}`);
        }
        if (graphics && /intel|nvidia|geforce|amd|radeon/i.test(graphics))
            parts.push(`GPU: ${graphics}`);
        if (memory && /gb|mb/i.test(memory)) {
            const ram = memory.replace(/\bRAM\b/gi, '').replace(/\s{2,}/g, ' ').trim();
            parts.push(`RAM: ${squishUnits(ram)}`);
        }
        if (graphics && /gb|mb/i.test(graphics)) {
            const vram = graphics.replace(/\bVRAM\b/gi, '').replace(/\s{2,}/g, ' ').trim();
            parts.push(`VRAM: ${squishUnits(vram)}`);
        }
        if (storage && /gb|mb/i.test(storage)) {
            const hdd = storage.replace(/\bavailable\s+space\b/gi, '').replace(/\s{2,}/g, ' ').trim();
            parts.push(`HDD: ${squishUnits(hdd)}`);
        }
        return parts.length > 0 ? parts.join(' | ') : text;
    }

    /** Параметрическая замена getSteamSysReq + дублирующего кода из fillFormSteam */
    function parseSysReq(steamData, enabled, format) {
        if (!enabled) return null;
        const raw = parseSteamMinReqs(steamData?.pc_requirements?.minimum);
        if (!raw) return null;
        return format ? formatSysReqToTemplate(raw) : raw;
    }

    // ─── API-функции ───────────────────────────────────────────────────────────

    function apiPost(endpoint, payload) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: `${API_BASE}/${endpoint}`,
                headers: { 'Content-Type': 'application/json' },
                data: JSON.stringify(payload),
                timeout: 15000,
                onload: r => {
                    if (r.status === 200) resolve(JSON.parse(r.responseText));
                    else reject(new Error(`VNDB HTTP ${r.status}: ${r.responseText.slice(0, 200)}`));
                },
                onerror:   () => reject(new Error('VNDB: ошибка сети')),
                ontimeout: () => reject(new Error('VNDB: таймаут запроса')),
            });
        });
    }

    function fetchVNData(id) {
        return apiPost('vn', {
            filters: ['id', '=', `v${id}`],
            fields: 'title, released, description, olang, image { url }, titles { lang, main, title }, tags { name, lie }, languages, developers { name }, platforms, screenshots { url }',
            results: 1,
        }).then(r => r.results?.[0]);
    }

    function fetchReleases(id) {
        return apiPost('release', {
            filters: ['vn', '=', ['id', '=', `v${id}`]],
            fields: 'uncensored, official, patch, voiced, platforms, languages { lang }, vns { id, rtype }, producers { developer, publisher, name }, extlinks { url }, released',
            results: 100,
        }).then(r => r.results || []).catch(() => []);
    }

    function fetchReleaseById(releaseId) {
        return apiPost('release', {
            filters: ['id', '=', `r${releaseId}`],
            fields: 'title, uncensored, official, patch, voiced, platforms, vns { id, rtype }, producers { developer, publisher, name }, extlinks { url }, released, languages { lang, title }',
            results: 1,
        }).then(r => r.results?.[0] || null).catch(() => null);
    }

    function gmFetch(url, { throwOnError = false, timeout = 12000 } = {}) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET', url, timeout,
                onload: r => {
                    if (throwOnError && r.status >= 400) { reject(new Error(`HTTP ${r.status}`)); return; }
                    try { resolve(JSON.parse(r.responseText)); }
                    catch { throwOnError ? reject(new Error('Ошибка парсинга JSON')) : resolve(null); }
                },
                onerror:   () => throwOnError ? reject(new Error('Ошибка сети'))     : resolve(null),
                ontimeout: () => throwOnError ? reject(new Error('Таймаут запроса')) : resolve(null),
            });
        });
    }

    function fetchSteamDetails(appId, locale = 'english') {
        return gmFetch(`https://store.steampowered.com/api/appdetails?appids=${appId}&l=${locale}`)
            .then(json => { const e = json?.[String(appId)]; return e?.success ? e.data : null; });
    }

    function fetchSteamSpy(appId) {
        return gmFetch(`https://steamspy.com/api.php?request=appdetails&appid=${appId}`)
            .then(json => json?.tags ?? null);
    }

    async function translateToRussian(text) {
        if (!text || /[а-яА-ЯёЁ]/.test(text)) return text;
        const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=ru&dt=t&q=${encodeURIComponent(text)}`;
        const data = await gmFetch(url, { timeout: 15000 });
        if (!data?.[0]) throw new Error('Неверный формат ответа');
        return data[0].map(i => i[0]).join('');
    }

    // ─── Steam-специфичные вспомогательные функции ────────────────────────────

    function parseSteamSupportedLanguages(html) {
        if (!html) return { langs: [], audioLangs: [] };
        const noFootnote = html.replace(/<br>[\s\S]*/i, '');
        const langs = [], audioLangs = [];
        noFootnote.split(',').forEach(entry => {
            const hasAudio = /<strong>\*<\/strong>/i.test(entry);
            const clean = entry.replace(/<[^>]+>/g, '').replace(/\*/g, '').trim().toLowerCase();
            if (clean) {
                langs.push(clean);
                if (hasAudio) audioLangs.push(clean);
            }
        });
        return { langs, audioLangs };
    }

    function cleanSteamHtml(html) {
        if (!html) return '';
        return html
            .replace(/<span[^>]*class=["'][^"']*bb_img_ctn[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, '')
            .replace(/<\/h2>/gi, '\n').replace(/<h2[^>]*>/gi, '\n')
            .replace(/<\/p>/gi, '\n').replace(/<p[^>]*>/gi, '')
            .replace(/<br\s*\/?>/gi, '\n')
            .replace(/<[^>]+>/g, '')
            .replace(/&quot;/g, '"').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
            .replace(/&gt;/g, '>').replace(/&#39;/g, "'").replace(/&apos;/g, "'")
            .replace(/&nbsp;/g, ' ')
            .replace(/\n{3,}/g, '\n\n').trim();
    }

    // ─── Маппинг языков ────────────────────────────────────────────────────────

    /** Общая логика маппинга булевых флагов → строку lang_g_abr */
    function _langsAbrFromFlags(rus, eng, jap, chi, multi) {
        if (jap && chi && eng && rus) return multi ? 'jap+chi+eng+rus+multi' : 'jap+chi+eng+rus';
        if (jap && eng && rus) return 'jap+eng+rus';
        if (chi && eng && rus) return 'chi+eng+rus';
        if (jap && rus)        return 'jap+rus';
        if (jap && eng)        return 'jap+eng';
        if (chi && rus)        return 'chi+rus';
        if (chi && eng)        return 'chi+eng';
        if (rus && eng)        return multi ? 'rus+eng+multi' : 'rus+eng';
        if (jap)  return 'jap';
        if (chi)  return 'chi';
        if (rus)  return 'rus';
        if (eng)  return 'eng';
        return '';
    }

    /** VNDB-коды языков (['ru', 'en', 'ja', 'zh-Hans', ...]) → lang_g_abr */
    function langsToAbr(langArray) {
        if (!langArray?.length) return '';
        const lc = langArray.map(l => l.toLowerCase());
        return _langsAbrFromFlags(
            lc.some(l => l === 'ru'),
            lc.some(l => l === 'en'),
            lc.some(l => l === 'ja'),
            lc.some(l => l === 'zh' || l.startsWith('zh-')),
            lc.some(l => l !== 'ru' && l !== 'en' && l !== 'ja' && !l.startsWith('zh'))
        );
    }

    /** Steam-имена языков (['russian', 'english', ...]) → lang_g_abr */
    function steamLangsToAbr(langs) {
        const hasChi = langs.includes('simplified chinese') || langs.includes('traditional chinese');
        return _langsAbrFromFlags(
            langs.includes('russian'),
            langs.includes('english'),
            langs.includes('japanese'),
            hasChi,
            langs.some(l => l !== 'russian' && l !== 'english' && l !== 'japanese' &&
                            l !== 'simplified chinese' && l !== 'traditional chinese')
        );
    }

    /**
     * Универсальный маппинг языка озвучки → lang_g_abr.
     * Принимает строку VNDB-кода ('ja') ИЛИ массив Steam-имён (['japanese']).
     */
    function audioLangToAbr(input) {
        let jap = false, eng = false, rus = false, chi = false;
        if (typeof input === 'string') {
            const l = input.toLowerCase();
            jap = l === 'ja'; eng = l === 'en'; rus = l === 'ru';
            chi = l === 'zh' || l.startsWith('zh-');
        } else if (Array.isArray(input)) {
            jap = input.includes('japanese');
            eng = input.includes('english');
            rus = input.includes('russian');
            chi = input.includes('simplified chinese') || input.includes('traditional chinese');
        }
        return _langsAbrFromFlags(rus, eng, jap, chi, false);
    }

    // ─── Общие хелперы заполнения полей ───────────────────────────────────────

    /** Заполняет поля языка игры и озвучки */
    function applyLanguages(langEnabled, langAbr, voiceEnabled, voiceAbr) {
        if (langEnabled && langAbr) {
            setLangSelect(FIELDS.langStory,     langAbr);
            setLangSelect(FIELDS.langInterface, langAbr);
        }
        if (voiceEnabled && voiceAbr) {
            const sel = getField(FIELDS.langVoice);
            if (sel) {
                const label = VOICE_ABR_TO_LABEL[voiceAbr] || '';
                if (label) { sel.value = label; sel.dispatchEvent(new Event('change', { bubbles: true })); }
            }
        }
    }

    /** Заполняет поле системных требований (или ставит аннотацию «Заполнить самим») */
    function applySysReq(sysReq, enabled) {
        if (!enabled) return;
        const f = getField(FIELDS.sysReq, FIELD_LABELS.sysReq);
        if (!f) return;
        if (sysReq) {
            document.getElementById('af-ann-sysreq')?.remove();
            setVal(f, sysReq);
        } else {
            setAnnotation(f, 'af-ann-sysreq', '<b>Заполнить самим</b>', true);
        }
    }

    /** Заполняет поле описания (+ ссылки под ним) */
    function applyDescription(hasDesc, description, linksEnabled, linksText) {
        const f = getField(FIELDS.description);
        if (!f) return;
        const suffix = linksEnabled && linksText ? '\n\n' + linksText : '';
        if (hasDesc) setVal(f, '\n' + description + suffix);
        else if (linksEnabled && linksText) setVal(f, '\n\n' + linksText);
    }

    // ─── FastPic: загрузка картинок по URL ────────────────────────────────────

    async function uploadToFastpic(urls, onProgress = null) {
        const BATCH_SIZE = 30;
        const batches = [];
        for (let i = 0; i < urls.length; i += BATCH_SIZE)
            batches.push(urls.slice(i, i + BATCH_SIZE));

        function uploadBatch(batchUrls) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method:  'POST',
                    url:     'https://new.fastpic.org/upload_from_url',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded',
                        'Referer':      'https://new.fastpic.org/viaurl/',
                    },
                    data:    'files=' + encodeURIComponent(batchUrls.join('\n')) + '&uploading=1',
                    timeout: 60000,
                    onload(r) {
                        if (r.status >= 400) { reject(new Error(`FastPic HTTP ${r.status}`)); return; }
                        const doc = new DOMParser().parseFromString(r.responseText, 'text/html');
                        const results = [];
                        doc.querySelectorAll('img[data-links]').forEach(img => {
                            try {
                                const links = JSON.parse(img.getAttribute('data-links').replace(/&quot;/g, '"'));
                                if (links.big) results.push(links);
                            } catch (e) {
                                console.warn('[AutoFill] FastPic: ошибка парсинга data-links:', e);
                            }
                        });
                        resolve(results);
                    },
                    onerror()  { reject(new Error('Сетевая ошибка при обращении к FastPic')); },
                    ontimeout(){ reject(new Error('Таймаут FastPic (60 с)')); },
                });
            });
        }

        const results = [];
        for (const batch of batches) {
            const batchResults = await uploadBatch(batch);
            results.push(...batchResults);
            onProgress?.(results.length, urls.length);
        }
        return results;
    }

    // ─── Маппинг платформы ─────────────────────────────────────────────────────

    function mapPlatform(platformsArr) {
        const s = new Set(platformsArr);
        const w = s.has('win'), l = s.has('lin'), m = s.has('mac');
        if (w && l && m) return 'Windows+Linux+MacOS';
        if (w && l)      return 'Windows+Linux';
        if (w && m)      return 'Windows+MacOS';
        if (l && m)      return 'Linux+MacOS';
        if (w)           return 'Windows';
        if (l)           return 'Linux';
        if (m)           return 'MacOS';
        return '';
    }

    // ─── Подготовка данных ─────────────────────────────────────────────────────

    function buildUniqueUrls(releases, priorityRelease) {
        const domainBestLink = new Map();
        if (priorityRelease) {
            (priorityRelease.extlinks || []).forEach(({ url }) => {
                if (!url) return;
                const domain = getCanonicalDomain(url);
                if (domain) domainBestLink.set(domain, { url, date: '9999-99-99' });
            });
        }
        releases.filter(r => r.official && !r.patch).forEach(r => {
            const date = (r.released && r.released !== 'TBA') ? r.released : '';
            (r.extlinks || []).forEach(({ url }) => {
                if (!url) return;
                const domain = getCanonicalDomain(url);
                if (!domain) return;
                const cur = domainBestLink.get(domain);
                if (!cur || date > cur.date) domainBestLink.set(domain, { url, date });
            });
        });
        return [...domainBestLink.values()].map(v => v.url);
    }

    function resolveFormData(vnData, releases, priorityRelease, vnId) {
        const vid      = `v${vnId}`;
        const vnTitles = vnData.titles || [];
        const jaMain   = vnTitles.find(t => t.lang === 'ja' && t.main);
        const enTitle  = vnTitles.find(t => t.lang === 'en');
        const ruTitle  = vnTitles.find(t => t.lang === 'ru');
        const relLangs = priorityRelease?.languages || [];

        const titleJa = priorityRelease
            ? (relLangs.find(l => l.lang === 'ja')?.title ?? jaMain?.title ?? null)
            : (jaMain?.title ?? null);
        const titleEn = priorityRelease
            ? (relLangs.find(l => l.lang === 'en')?.title ?? priorityRelease.title ?? enTitle?.title ?? vnData.title)
            : (enTitle?.title ?? vnData.title);
        const titleRu = priorityRelease
            ? (relLangs.find(l => l.lang === 'ru')?.title ?? ruTitle?.title ?? null)
            : (ruTitle?.title ?? null);

        const hasUncensored = priorityRelease
            ? priorityRelease.uncensored !== false
            : releases.some(r => r.uncensored !== false);

        const hasNonTrial = priorityRelease
            ? (priorityRelease.vns || []).some(v => v.id === vid && v.rtype !== 'trial')
            : releases.some(r => (r.vns || []).some(v => v.id === vid && v.rtype !== 'trial'));

        const platformsArr = priorityRelease
            ? [...new Set(priorityRelease.platforms || [])]
            : [...new Set(releases.filter(r => r.official).flatMap(r => r.platforms || []))];

        let creators;
        if (priorityRelease) {
            const prPublishers  = (priorityRelease.producers || []).filter(p => p.publisher).map(p => p.name);
            const allDevelopers = [...new Set(
                releases.filter(r => r.official).flatMap(r => (r.producers || []).filter(p => p.developer).map(p => p.name))
            )];
            creators = (allDevelopers.length > 0 || prPublishers.length > 0)
                ? [...new Set([...allDevelopers, ...prPublishers])]
                : [...new Set([
                    ...(vnData.developers || []).map(d => d.name),
                    ...releases.filter(r => r.official).flatMap(r => (r.producers || []).map(p => p.name)),
                ])];
        } else {
            creators = [...new Set([
                ...(vnData.developers || []).map(d => d.name),
                ...releases.filter(r => r.official).flatMap(r => (r.producers || []).map(p => p.name)),
            ])];
        }

        const langs = priorityRelease
            ? (priorityRelease.languages || []).map(l => l.lang)
            : (vnData.languages?.length
                ? vnData.languages
                : [...new Set(
                    releases
                        .filter(r => r.official)
                        .flatMap(r => (r.languages || []).map(l => l.lang))
                  )]);
        const hasVoiced = priorityRelease
            ? priorityRelease.voiced >= 3
            : releases.some(r => r.official && (r.vns || []).some(v => v.id === vid) && r.voiced >= 3);
        const voiceLang = hasVoiced ? (vnData.olang || '') : '';

        const released = (priorityRelease?.released && priorityRelease.released !== 'TBA')
            ? priorityRelease.released
            : (vnData.released || '');

        return { titleJa, titleEn, titleRu, hasUncensored, hasNonTrial, platformsArr, creators, langs, voiceLang, released };
    }

    /**
     * Строит массив тегов из VNDB тегов, платформ и опциональных данных Steam/SteamSpy.
     */
    function buildAllTags({ vnTags = [], platforms = [], steamData = null, steamSpyTags = null } = {}) {
        const seen = new Set(), allTags = [];
        const addTag = t => {
            t = t.trim(); if (!t) return;
            const k = t.toLowerCase();
            if (!seen.has(k)) { seen.add(k); allTags.push(t); }
        };
        vnTags.filter(t => !t.lie).forEach(t => addTag(t.name));
        const PLAT_WL = new Set(['win', 'lin', 'mac']);
        platforms.filter(p => PLAT_WL.has(p)).forEach(addTag);
        if (steamData) {
            const STEAM_PLAT = { windows: 'win', mac: 'mac', linux: 'lin' };
            Object.entries(steamData.platforms || {}).filter(([, v]) => v)
                .forEach(([k]) => { const m = STEAM_PLAT[k]; if (m) addTag(m); });
            (steamData.genres || []).forEach(g => g.description && addTag(g.description));
        }
        if (steamSpyTags) Object.keys(steamSpyTags).forEach(addTag);
        return allTags;
    }

    function buildLinksText(releases, priorityRelease, uniqueUrls, vid) {
        if (!settings.fieldLinks) return '';
        let sourceUrls = uniqueUrls;
        if (priorityRelease) {
            const releaseUrls = (priorityRelease.extlinks || [])
                .map(e => e.url)
                .filter(url => url && getCanonicalDomain(url));
            if (releaseUrls.length > 0) sourceUrls = releaseUrls;
        }
        return [...sourceUrls, `https://vndb.org/${vid}`].map(url => {
            const h    = getHostname(url);
            const icon = h === 'vndb.org' ? 'vndb.png' : getStoreIcon(url);
            return urlToBBIcon(url, icon);
        }).join(' ');
    }

    // ─── Заполнение отдельных полей ────────────────────────────────────────────

    async function fillPosterField(posterUrl) {
        const fPoster = getField(FIELDS.poster);
        if (!fPoster || !posterUrl) return;
        setVal(fPoster, posterUrl);
        if (!settings.enableFastpicUpload) {
            setAnnotation(fPoster, 'af-ann-poster', ANN_POSTER_COVER, true);
            return;
        }
        const tPoster = toast('⏳ Загрузка постера на FastPic...', 'info', 0);
        try {
            const result = await uploadToFastpic([posterUrl]);
            tPoster.remove();
            if (result.length > 0) {
                setVal(fPoster, result[0].big);
                toast('✅ Постер загружен на FastPic', 'success');
            } else {
                toast('⚠️ FastPic не вернул ссылку для постера. Проверьте авторизацию.', 'warning', 6000);
                setAnnotation(fPoster, 'af-ann-poster', ANN_POSTER_MANUAL, true);
            }
        } catch (err) {
            tPoster.remove();
            toast(`⚠️ Ошибка FastPic (постер): ${err.message}`, 'warning', 6000);
            setAnnotation(fPoster, 'af-ann-poster', ANN_POSTER_COVER, true);
        }
    }

    async function fillTagsField(allTags) {
        const f = getField(FIELDS.tags);
        if (!f || allTags.length === 0) return;

        if (!settings.adaptTagEditor) {
            setVal(f, allTags.join(', '));
            setAnnotation(f, 'af-ann-tags', TAG_EDITOR_ANN, true);
            return;
        }

        const tAdapt = toast('⏳ Адаптация тегов под редактор pornolab...', 'info', 0);
        try {
            const mergedTags  = [...allTags];
            const mergedLower = new Set(mergedTags.map(t => t.toLowerCase()));
            EXTRA_TAGS_GROUPS.forEach(group => {
                group.tags.forEach(t => {
                    if (settings[t.key] && !mergedLower.has(t.label.toLowerCase())) {
                        mergedTags.push(t.label);
                        mergedLower.add(t.label.toLowerCase());
                    }
                });
            });

            const gameJson   = await gmFetch('https://static.pornolab.net/tag_editor/game.json', { throwOnError: true, timeout: 10000 });
            const tagIndex   = new Map();
            const allCatTags = (gameJson.categories || []).flatMap(c => c.tags || []);
            allCatTags.forEach(entry => {
                tagIndex.set(entry.name.toLowerCase(), entry);
                (entry.knownAs || []).forEach(alias => tagIndex.set(alias.toLowerCase(), entry));
            });

            const validTags = [], validIcons = [], seenValid = new Set();
            mergedTags.forEach(tag => {
                const entry = tagIndex.get(tag.toLowerCase());
                if (entry && !seenValid.has(entry.name.toLowerCase())) {
                    seenValid.add(entry.name.toLowerCase());
                    validTags.push(entry.name);
                    if (settings.addIconLinks && entry.alternative)
                        validIcons.push(entry.alternative);
                }
            });

            setVal(f, validTags.join(', '));
            setAnnotation(f, 'af-ann-tags', TAG_EDITOR_ANN, true);
            if (settings.addIconLinks && validIcons.length > 0) {
                const fIcons = getIconsField();
                if (fIcons) setVal(fIcons, validIcons.join(''));
            }

            tAdapt.remove();
            toast(
                `✅ Теги адаптированы: ${validTags.length} из ${mergedTags.length}` +
                (mergedTags.length - validTags.length > 0
                    ? ` (${mergedTags.length - validTags.length} не найдено в game.json)` : ''),
                'success'
            );
        } catch (err) {
            tAdapt.remove();
            toast(`❌ Ошибка адаптации тегов: ${err.message}`, 'error', 6000);
            console.error('[AutoFill] Ошибка адаптации тегов:', err);
            setVal(f, allTags.join(', '));
            setAnnotation(f, 'af-ann-tags', TAG_EDITOR_ANN, true);
        }
    }

    async function fillScreenshotsField(allScreens) {
        const f = getField(FIELDS.screenshots);
        if (!f || allScreens.length === 0) return;
        setVal(f, allScreens.join('\n'));
        if (!settings.enableFastpicUpload) {
            setAnnotation(f, 'af-ann-screenshots', ANN_SCREENS, true);
            return;
        }
        const tScreens = toast(`⏳ Загрузка ${allScreens.length} скриншотов на FastPic...`, 'info', 0);
        try {
            const result = await uploadToFastpic(allScreens, (done, total) => {
                tScreens.textContent = `⏳ FastPic: обработано ${done} из ${total} скриншотов...`;
            });
            tScreens.remove();
            if (result.length > 0) {
                setVal(f, result.map(links => `[URL=${links.view}][IMG]${links.thumb}[/IMG][/URL]`).join(' '));
                toast(`✅ Скриншоты загружены на FastPic: ${result.length} из ${allScreens.length}`, 'success');
            } else {
                toast('⚠️ FastPic не вернул ссылки для скриншотов. Проверьте авторизацию.', 'warning', 6000);
                setAnnotation(f, 'af-ann-screenshots', ANN_SCREENS, true);
            }
        } catch (err) {
            tScreens.remove();
            toast(`⚠️ Ошибка FastPic (скриншоты): ${err.message}`, 'warning', 6000);
            setAnnotation(f, 'af-ann-screenshots', ANN_SCREENS, true);
        }
    }

    // ─── IGDB / Обложки ────────────────────────────────────────────────────────

    // Учётные данные Twitch для IGDB API
    // При необходимости замените на собственные: https://dev.twitch.tv/console
    const TWITCH_CLIENT_ID     = 'f4nguxprv5p5p94uouph5m8u3qi3a9';
    const TWITCH_CLIENT_SECRET = 'c5yg7qvwmmn5y5qadmmrrpihievuu7';
    const TWITCH_TOKEN_URL     = 'https://id.twitch.tv/oauth2/token';
    const IGDB_BASE            = 'https://api.igdb.com/v4';

    function igdbGmRequest({ method = 'GET', url, headers = {}, data = null, responseType = 'json' }) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method, url, headers, data, responseType,
                timeout: 30000,
                onload(res) {
                    const body = responseType === 'json' ? res.response : res.responseText;
                    if (res.status >= 200 && res.status < 300) resolve(body);
                    else reject(new Error(`HTTP ${res.status} для ${url}`));
                },
                onerror:   () => reject(new Error(`Ошибка запроса: ${url}`)),
                ontimeout: () => reject(new Error(`Таймаут: ${url}`)),
            });
        });
    }

    async function getTwitchToken() {
        const params = new URLSearchParams({
            client_id:     TWITCH_CLIENT_ID,
            client_secret: TWITCH_CLIENT_SECRET,
            grant_type:    'client_credentials',
        });
        const data = await igdbGmRequest({
            method:       'POST',
            url:          TWITCH_TOKEN_URL,
            headers:      { 'Content-Type': 'application/x-www-form-urlencoded' },
            data:         params.toString(),
            responseType: 'json',
        });
        if (!data?.access_token) throw new Error('Twitch: нет access_token');
        return data.access_token;
    }

    function igdbPost(endpoint, token, body) {
        return igdbGmRequest({
            method: 'POST',
            url: `${IGDB_BASE}/${endpoint}`,
            headers: {
                'Client-ID':     TWITCH_CLIENT_ID,
                'Authorization': `Bearer ${token}`,
                'Content-Type':  'text/plain',
            },
            data:         body,
            responseType: 'json',
        });
    }

    function buildIgdbImageUrl(imageId, size = 't_1080p') {
        return `https://images.igdb.com/igdb/image/upload/${size}/${imageId}.jpg`;
    }

    async function fetchIgdbGameByAppId(token, appId) {
        const rows = await igdbPost(
            'external_games', token,
            `fields id,game,uid,external_game_source; where external_game_source = 1 & uid = "${appId}"; limit 1;`
        );
        if (!rows?.length) return null;
        const gameId = rows[0].game;
        if (!gameId) return null;
        const games = await igdbPost(
            'games', token,
            `fields id,name,cover,artworks; where id = ${gameId}; limit 1;`
        );
        return games?.[0] || null;
    }

    async function fetchIgdbCovers(token, gameId) {
        return igdbPost(
            'covers', token,
            `fields id,game,image_id,width,height; where game = ${gameId}; limit 500;`
        ).catch(() => []);
    }

    async function fetchIgdbArtworks(token, gameId) {
        return igdbPost(
            'artworks', token,
            `fields id,game,image_id,width,height; where game = ${gameId}; limit 500;`
        ).catch(() => []);
    }

    /**
     * Собирает список обложек для выбора.
     * @param {string} appId - Steam App ID
     * @param {object|null} steamData - уже полученные данные Steam API (или null)
     * @returns {Promise<Array<{label:string, url:string, kind:string}>>}
     */
    async function buildCoverUrls(appId, steamData) {
        const urls = [], seen = new Set();

        // Steam header image из уже полученных данных
        if (steamData?.header_image) {
            const url = steamData.header_image;
            seen.add(url);
            urls.push({ label: 'Steam: header', url, kind: 'steam' });
        }

        // IGDB обложки и артворки
        try {
            const token = await getTwitchToken();
            const game  = await fetchIgdbGameByAppId(token, appId);
            if (game) {
                const [covers, artworks] = await Promise.all([
                    fetchIgdbCovers(token, game.id),
                    fetchIgdbArtworks(token, game.id),
                ]);
                for (const row of [...(covers || []), ...(artworks || [])]) {
                    if (!row?.image_id) continue;
                    const url = buildIgdbImageUrl(row.image_id, 't_1080p');
                    if (seen.has(url)) continue;
                    seen.add(url);
                    const isArtwork = 'artwork_type' in row;
                    urls.push({
                        label: isArtwork ? `IGDB artwork #${row.id}` : `IGDB cover #${row.id}`,
                        url,
                        kind: isArtwork ? 'artwork' : 'cover',
                    });
                }
            }
        } catch (err) {
            console.warn('[AutoFill] IGDB недоступен:', err.message);
        }

        return urls;
    }

    // ─── Выбор постера ─────────────────────────────────────────────────────────

    /**
     * Показывает панель выбора постера.
     * @param {Array<{label:string, url:string}>} coverUrls
     * @returns {Promise<string|null>} выбранная ссылка или null
     */
    function showCoverSelector(coverUrls) {
        return new Promise(resolve => {
            document.getElementById('af-cover-panel')?.remove();

            let selectedUrl = coverUrls[0]?.url || null;

            const panel = document.createElement('div');
            panel.id = 'af-cover-panel';

            // Заголовок
            const header = document.createElement('div');
            header.id = 'af-cover-header';
            header.innerHTML =
                '<span>Выберите постер (показаны миниатюры, но будет взят исходник)</span>' +
                '<button id="af-cover-close" title="Закрыть">×</button>';

            // Тело со скроллом
            const body = document.createElement('div');
            body.id = 'af-cover-body';

            if (coverUrls.length === 0) {
                body.innerHTML = '<div style="color:#888;padding:8px;">Обложки не найдены.</div>';
            } else {
                coverUrls.forEach((item, i) => {
                    const div = document.createElement('div');
                    div.className = 'af-cover-item';
                    const safeLabel = item.label.replace(/</g, '&lt;').replace(/>/g, '&gt;');
                    const safeUrl   = item.url.replace(/"/g, '&quot;');
                    div.innerHTML = `
                        <label class="af-cover-label">
                            <input type="radio" name="af-cover-pick" value="${safeUrl}" ${i === 0 ? 'checked' : ''}>
                            <span>${safeLabel}</span>
                        </label>
                        <img class="af-cover-thumb" src="${safeUrl}" alt="${safeLabel}" loading="lazy">`;
                    body.appendChild(div);
                });

                body.querySelectorAll('input[type="radio"]').forEach(r => {
                    r.addEventListener('change', () => { selectedUrl = r.value; });
                });
            }

            // Кнопки (прилеплены к низу)
            const footer = document.createElement('div');
            footer.id = 'af-cover-footer';
            footer.innerHTML =
                '<button id="af-cover-select" class="af-cover-btn-primary">Выбрал</button>' +
                '<button id="af-cover-cancel" class="af-cover-btn-secondary">Закрыть</button>';

            panel.appendChild(header);
            panel.appendChild(body);
            panel.appendChild(footer);
            document.body.appendChild(panel);

            const done = url => { panel.remove(); resolve(url); };

            panel.querySelector('#af-cover-close').addEventListener('click', () => done(null));
            panel.querySelector('#af-cover-cancel').addEventListener('click', () => done(null));
            panel.querySelector('#af-cover-select').addEventListener('click', () => done(selectedUrl));

            // Перетаскивание
            let drag = null;
            header.addEventListener('mousedown', e => {
                if (e.target.tagName === 'BUTTON') return;
                e.preventDefault();
                drag = { x: e.clientX, y: e.clientY };
                const onMove = e => {
                    panel.style.left = (panel.offsetLeft - (drag.x - e.clientX)) + 'px';
                    panel.style.top  = (panel.offsetTop  - (drag.y - e.clientY)) + 'px';
                    drag = { x: e.clientX, y: e.clientY };
                };
                const onUp = () => {
                    document.removeEventListener('mousemove', onMove);
                    document.removeEventListener('mouseup', onUp);
                };
                document.addEventListener('mousemove', onMove);
                document.addEventListener('mouseup', onUp);
            });
        });
    }

    // ─── Заполнение формы по VNDB-ссылке ──────────────────────────────────────

    async function fillForm(vndbUrl) {
        const matchVN  = vndbUrl.match(/\/v(\d+)/);
        const matchRel = vndbUrl.match(/\/r(\d+)/);

        if (!matchVN && !matchRel) {
            toast('Неверный формат ссылки VNDB. Пример: https://vndb.org/v97 или https://vndb.org/r64727', 'error');
            return;
        }

        const tVndb = toast('⏳ Запрос к VNDB API...', 'info', 0);
        let vnData, releases, priorityRelease = null, vnId;
        try {
            if (matchRel) {
                priorityRelease = await fetchReleaseById(matchRel[1]);
                if (!priorityRelease) { tVndb.remove(); toast('❌ Релиз не найден в VNDB', 'error'); return; }
                const vnIdFull = priorityRelease.vns?.[0]?.id;
                if (!vnIdFull) { tVndb.remove(); toast('❌ Не удалось определить VN по данному релизу', 'error'); return; }
                vnId = vnIdFull.replace(/^v/, '');
            } else {
                vnId = matchVN[1];
            }
            [vnData, releases] = await Promise.all([fetchVNData(vnId), fetchReleases(vnId)]);
            tVndb.remove();
            toast('✅ Данные VNDB получены', 'success');
        } catch (err) {
            tVndb.remove();
            toast(`❌ Ошибка VNDB: ${err.message}`, 'error', 6000);
            console.error('[AutoFill] Ошибка VNDB:', err);
            return;
        }

        if (!vnData) { toast('❌ VNDB не вернул данные для этого ID', 'error'); return; }

        const vid = `v${vnId}`;

        const uniqueUrls = buildUniqueUrls(releases, priorityRelease);
        const steamUrl   = uniqueUrls.find(u => getHostname(u) === 'store.steampowered.com');
        const steamAppId = steamUrl ? (steamUrl.match(/\/app\/(\d+)/)?.[1] ?? null) : null;

        let steamData = null, steamSpyTags = null;
        const needSteam = steamAppId && (
            settings.enableSteamPoster || settings.enableSteamTags ||
            settings.enableSteamScreenshots || settings.fieldSysReq
        );
        if (needSteam)
            steamData = await withToast('⏳ Запрос к Steam API...', () => fetchSteamDetails(steamAppId),
                { successMsg: '✅ Данные Steam получены', failMsg: '⚠️ Steam API недоступен', failDuration: 5000 });
        if (steamAppId && settings.enableSteamTags)
            steamSpyTags = await withToast('⏳ Запрос к SteamSpy API...', () => fetchSteamSpy(steamAppId),
                { successMsg: '✅ Данные SteamSpy получены', failMsg: '⚠️ SteamSpy недоступен', failDuration: 5000 });

        const { titleJa, titleEn, titleRu, hasUncensored, hasNonTrial,
                platformsArr, creators, langs, voiceLang, released } =
            resolveFormData(vnData, releases, priorityRelease, vnId);

        // Если VNDB не дал разработчиков/издателей — пробуем Steam как запасной источник
        if (creators.length === 0 && steamAppId && !steamData) {
            steamData = await withToast(
                '⏳ Запрос к Steam API (разработчик)...',
                () => fetchSteamDetails(steamAppId),
                { successMsg: '✅ Данные Steam получены', failMsg: '⚠️ Steam API недоступен', failDuration: 5000 }
            );
        }
        const developerStr = creators.length > 0
            ? creators.join(' / ')
            : [...new Set([...(steamData?.developers || []), ...(steamData?.publishers || [])])].join(' / ');

        const platformVal = mapPlatform(platformsArr);
        const allTags     = buildAllTags({
            vnTags:      vnData.tags || [],
            platforms:   platformsArr,
            steamData:   settings.enableSteamTags ? steamData : null,
            steamSpyTags: settings.enableSteamTags ? steamSpyTags : null,
        });
        const sysReq    = parseSysReq(steamData, settings.fieldSysReq, settings.formatSysReqTemplate);
        const linksText = buildLinksText(releases, priorityRelease, uniqueUrls, vid);

        const allScreens = [
            ...(vnData.screenshots || []).map(s => s.url),
            ...(settings.enableSteamScreenshots && steamData
                ? (steamData.screenshots || []).map(s => s.path_full).filter(Boolean) : []),
        ];

        const vnPosterUrl = vnData.image?.url || '';

        let description = '';
        if (settings.fieldDescription) {
            const rawDesc = (vnData.description || '')
                .replace(/\[(?:[^\[\]]|\[[^\[\]]*\])*\]\s*$/, '').trimEnd();
            if (settings.enableGoogleTranslate && rawDesc) {
                description = await withToast(
                    '⏳ Перевод описания через Google Translate...',
                    () => translateToRussian(rawDesc),
                    { successMsg: '✅ Перевод получен', failMsg: '⚠️ Ошибка перевода', failDuration: 4000 }
                ) ?? rawDesc;
            } else {
                description = rawDesc;
            }
        }

        console.log('[AutoFill] Поля для заполнения:', {
            приоритетРелиз: priorityRelease?.id || null,
            постер: settings.enableSteamPoster && steamAppId ? '(выбор из IGDB/Steam)' : vnPosterUrl,
            иероглифы: titleJa || '',
            titleEng: titleEn || '', titleRu: titleRu || '',
            год: released.split('-')[0] || '', дата: released.replace(/-/g, '/') || '',
            теги: allTags, цензура: hasUncensored ? 'Нет' : 'Есть',
            разработчик: creators.join(' / '), платформа: platformVal,
            типИздания: hasNonTrial ? 'Релиз' : (releases.length ? 'Демо-версия' : ''),
            языки: langs, озвучка: voiceLang, sysReq,
            описание: description, ссылки: linksText, скриншоты: allScreens,
        });

        // Постер
        if (settings.fieldPoster) {
            if (settings.enableSteamPoster && steamAppId) {
                const tCovers = toast('⏳ Загрузка обложек из Steam/IGDB...', 'info', 0);
                const coverUrls = await buildCoverUrls(steamAppId, steamData).catch(() => []);
                tCovers.remove();
                if (coverUrls.length > 0) {
                    const chosen = await showCoverSelector(coverUrls);
                    await fillPosterField(chosen || vnPosterUrl);
                } else {
                    toast('⚠️ Обложки не найдены, используется постер VNDB', 'warning', 4000);
                    await fillPosterField(vnPosterUrl);
                }
            } else {
                await fillPosterField(vnPosterUrl);
            }
        }

        fillIf('fieldHieroglyphs', FIELDS.hieroglyphs, titleJa,           FIELD_LABELS.hieroglyphs);
        fillIf('fieldTitleEng',    FIELDS.titleEng,    titleEn,            FIELD_LABELS.titleEng);
        fillIf('fieldTitleRu',     FIELDS.titleRu,     titleRu,            FIELD_LABELS.titleRu);
        fillIf('fieldYear',        FIELDS.year,        released.split('-')[0]);
        fillIf('fieldDate',        FIELDS.date,        released.replace(/-/g, '/'));
        fillIf('fieldCensorship',  FIELDS.cens,        hasUncensored ? 'Нет' : 'Есть');
        fillIf('fieldDeveloper',   FIELDS.developer,   developerStr,         FIELD_LABELS.developer);
        fillIf('fieldPlatform',    FIELDS.platform,    platformVal);

        if (settings.fieldTags) await fillTagsField(allTags);

        if (settings.fieldReleaseType) {
            const f = getField(FIELDS.releaseType);
            if (f) setVal(f, hasNonTrial ? '' : (releases.length ? 'Демо-версия' : ''));
        }

        const fMed = getField(FIELDS.medicine);
        if (fMed) setAnnotation(fMed, 'af-ann-medicine', 'Заполнить самим');
        const fVer = getField(FIELDS.version, FIELD_LABELS.version);
        if (fVer) setAnnotation(fVer, 'af-ann-version', 'Заполнить самим');

        applyLanguages(settings.fieldLanguage, langsToAbr(langs), settings.fieldVoice, audioLangToAbr(voiceLang));
        applySysReq(sysReq, settings.fieldSysReq);
        applyDescription(settings.fieldDescription, description, settings.fieldLinks, linksText);

        if (settings.fieldScreenshots) await fillScreenshotsField(allScreens);

        toast('✅ Форма заполнена!', 'success', 4000);
    }

    // ─── Заполнение формы по Steam-ссылке ─────────────────────────────────────

    async function fillFormSteam(steamUrl) {
        const matchApp = steamUrl.match(/\/app\/(\d+)/);
        if (!matchApp) {
            toast('Неверный формат ссылки Steam. Пример: https://store.steampowered.com/app/3014080', 'error');
            return;
        }
        const appId = matchApp[1];

        const tSteam = toast('⏳ Запрос к Steam API...', 'info', 0);
        let steamData;
        try {
            steamData = await fetchSteamDetails(appId, 'english');
            tSteam.remove();
            if (!steamData) { toast('❌ Steam не вернул данные для этого App ID', 'error'); return; }
            toast('✅ Данные Steam получены', 'success');
        } catch (err) {
            tSteam.remove();
            toast(`❌ Ошибка Steam API: ${err.message}`, 'error', 6000);
            return;
        }

        let titleEng = null, titleRu = null, titleHieroglyphs = null;

        if (settings.stm_fieldTitleEng) {
            const name = steamData.name || '';
            if (!/[\u3040-\u30ff\u4e00-\u9fff\uac00-\ud7af\u0400-\u04ff]/.test(name))
                titleEng = name;
        }

        if (settings.stm_fieldTitleRu) {
            const tRu = toast('⏳ Steam API (Russian)...', 'info', 0);
            const ruData = await fetchSteamDetails(appId, 'russian');
            tRu.remove();
            const name = ruData?.name || '';
            if (/[а-яА-ЯёЁ]/.test(name)) titleRu = name;
        }

        if (settings.stm_fieldHieroglyphs) {
            const locales = [
                { locale: 'japanese', test: /[\u3040-\u30ff\u4e00-\u9fff]/ },
                { locale: 'schinese', test: /[\u4e00-\u9fff]/ },
                { locale: 'koreana',  test: /[\uac00-\ud7af]/ },
            ];
            for (const { locale, test } of locales) {
                const tLoc = toast(`⏳ Steam API (${locale})...`, 'info', 0);
                const locData = await fetchSteamDetails(appId, locale);
                tLoc.remove();
                const name = locData?.name || '';
                if (test.test(name)) { titleHieroglyphs = name; break; }
            }
        }

        let steamSpyTags = null;
        if (settings.stm_fieldTags)
            steamSpyTags = await withToast(
                '⏳ Запрос к SteamSpy API...',
                () => fetchSteamSpy(appId),
                { successMsg: '✅ Данные SteamSpy получены', failMsg: '⚠️ SteamSpy недоступен', failDuration: 5000 }
            );

        let releaseYear = '', releaseDate = '';
        const relDateStr = steamData.release_date?.date || '';
        if (relDateStr && !steamData.release_date?.coming_soon) {
            try {
                const d = new Date(relDateStr);
                if (!isNaN(d.getTime())) {
                    releaseYear = String(d.getFullYear());
                    const mm = String(d.getMonth() + 1).padStart(2, '0');
                    const dd = String(d.getDate()).padStart(2, '0');
                    releaseDate = `${releaseYear}/${mm}/${dd}`;
                }
            } catch {}
        }

        const STEAM_PLAT_MAP = { windows: 'win', mac: 'mac', linux: 'lin' };
        const platformsArr = Object.entries(steamData.platforms || {})
            .filter(([, v]) => v).map(([k]) => STEAM_PLAT_MAP[k] || k);
        const platformVal  = mapPlatform(platformsArr);

        const developers   = steamData.developers || [];
        const publishers   = steamData.publishers || [];
        const developerStr = [...new Set([...developers, ...publishers])].join(' / ');

        const { langs: steamLangs, audioLangs: steamAudioLangs } =
            parseSteamSupportedLanguages(steamData.supported_languages || '');

        const sysReq = parseSysReq(steamData, settings.stm_fieldSysReq, settings.stm_formatSysReqTemplate);

        let description = '';
        if (settings.stm_fieldDescShort || settings.stm_fieldDescLong) {
            const parts = [];
            if (settings.stm_fieldDescShort && steamData.short_description)
                parts.push(steamData.short_description);
            if (settings.stm_fieldDescLong && steamData.about_the_game)
                parts.push(cleanSteamHtml(steamData.about_the_game));
            description = parts.join('\n\n');

            if (settings.stm_enableGoogleTranslate && description)
                description = await withToast(
                    '⏳ Перевод описания через Google Translate...',
                    () => translateToRussian(description),
                    { successMsg: '✅ Перевод получен', failMsg: '⚠️ Ошибка перевода', failDuration: 4000 }
                ) ?? description;
        }

        const allTags = settings.stm_fieldTags
            ? buildAllTags({ platforms: platformsArr, steamData, steamSpyTags })
            : [];

        const linksText  = settings.stm_fieldLinks
            ? urlToBBIcon(`https://store.steampowered.com/app/${appId}`, 'steam.png')
            : '';
        const allScreens = settings.stm_fieldScreenshots
            ? (steamData.screenshots || []).map(s => s.path_full).filter(Boolean)
            : [];

        console.log('[AutoFill Steam] Поля для заполнения:', {
            appId,
            постер: settings.stm_fieldPoster ? '(выбор из IGDB/Steam)' : '',
            titleHieroglyphs, titleEng, titleRu,
            год: releaseYear, дата: releaseDate, платформа: platformVal,
            разработчик: developerStr, теги: allTags,
            langAbr: steamLangsToAbr(steamLangs), voiceAbr: audioLangToAbr(steamAudioLangs),
            sysReq, описание: description, ссылки: linksText, скриншоты: allScreens,
        });

        // Постер — показываем выбор обложки
        if (settings.stm_fieldPoster) {
            const tCovers = toast('⏳ Загрузка обложек из Steam/IGDB...', 'info', 0);
            const coverUrls = await buildCoverUrls(appId, steamData).catch(() => []);
            tCovers.remove();
            if (coverUrls.length > 0) {
                const chosen = await showCoverSelector(coverUrls);
                if (chosen) await fillPosterField(chosen);
            } else {
                toast('⚠️ Обложки не найдены', 'warning', 4000);
            }
        }

        fillIf('stm_fieldHieroglyphs', FIELDS.hieroglyphs, titleHieroglyphs, FIELD_LABELS.hieroglyphs);
        fillIf('stm_fieldTitleEng',    FIELDS.titleEng,    titleEng,         FIELD_LABELS.titleEng);
        fillIf('stm_fieldTitleRu',     FIELDS.titleRu,     titleRu,          FIELD_LABELS.titleRu);
        fillIf('stm_fieldYear',        FIELDS.year,        releaseYear);
        fillIf('stm_fieldDate',        FIELDS.date,        releaseDate);
        fillIf('stm_fieldDeveloper',   FIELDS.developer,   developerStr,     FIELD_LABELS.developer);
        fillIf('stm_fieldPlatform',    FIELDS.platform,    platformVal);

        if (settings.stm_fieldTags && allTags.length > 0) {
            const f = getField(FIELDS.tags);
            if (f) { setVal(f, allTags.join(', ')); setAnnotation(f, 'af-ann-tags', TAG_EDITOR_ANN, true); }
        }

        applyLanguages(settings.stm_fieldLanguage, steamLangsToAbr(steamLangs),
                       settings.stm_fieldVoice,    audioLangToAbr(steamAudioLangs));
        applySysReq(sysReq, settings.stm_fieldSysReq);
        applyDescription(settings.stm_fieldDescShort || settings.stm_fieldDescLong,
                         description, settings.stm_fieldLinks, linksText);

        if (settings.stm_fieldScreenshots) await fillScreenshotsField(allScreens);

        // Аннотации «Заполнить самим»
        const _fCens = getField(FIELDS.cens);
        setAnnotation(_fCens?.nextElementSibling ?? _fCens,           'af-ann-cens',     'Заполнить самим');
        setAnnotation(getField(FIELDS.releaseType),                   'af-ann-reltype',  'Заполнить самим');
        setAnnotation(getField(FIELDS.medicine),                      'af-ann-medicine', 'Заполнить самим');
        setAnnotation(getField(FIELDS.version, FIELD_LABELS.version), 'af-ann-version',  'Заполнить самим');
        const _fLang  = getField(FIELDS.langStory);
        const _tdLang = _fLang?.closest('td') ?? _fLang?.parentElement;
        setAnnotation(_tdLang?.firstChild ?? _fLang,                  'af-ann-lang-warn','Проверьте эту информацию', true);

        toast('✅ Форма заполнена!', 'success', 4000);
    }

    // ─── Панель настроек ───────────────────────────────────────────────────────

    function buildExtraTagsHTML() {
        return EXTRA_TAGS_GROUPS.map(group => {
            const half = Math.ceil(group.tags.length / 2);
            const renderCol = col => col.map(t => {
                const checked = settings[t.key] ? 'checked' : '';
                return `<label class="af-sp-label" style="display:block;">
                    <input type="checkbox" data-key="${t.key}" ${checked}> ${t.label}
                </label>`;
            }).join('');
            return `
                <div style="margin-top:5px;font-size:11px;color:#777;font-style:italic;">${group.title}</div>
                <div style="display:grid;grid-template-columns:1fr 1fr;gap:0 8px;margin-left:4px;">
                    <div>${renderCol(group.tags.slice(0, half))}</div>
                    <div>${renderCol(group.tags.slice(half))}</div>
                </div>`;
        }).join('');
    }

    function buildSettingsHTML(source) {
        const isSteam = source === 'steam';
        const P = isSteam ? 'stm_' : '';

        const cb = (suffix, label, extraStyle = '') => {
            const key     = P + suffix;
            const checked = settings[key] ? 'checked' : '';
            return `<label class="af-sp-label"${extraStyle ? ` style="${extraStyle}"` : ''}>
                <input type="checkbox" data-key="${key}" ${checked}> ${label}
            </label>`;
        };

        const sysReqFmtId = `af-sp-${isSteam ? 'stm-' : ''}sysreq-fmt`;
        const showSysReq  = settings[P + 'fieldSysReq'];
        const showDesc    = isSteam
            ? (settings.stm_fieldDescShort || settings.stm_fieldDescLong)
            : settings.fieldDescription;

        return `
            <div class="af-sp-section">
                <div class="af-sp-section-title">Поля ${isSteam ? 'Steam' : 'VNDB'}</div>
                ${cb('fieldPoster', 'Постер')}
                ${cb('fieldHieroglyphs', 'Оригинальное название (иероглифы)')}
                ${cb('fieldTitleEng',    'Оригинальное название (английское)')}
                ${cb('fieldTitleRu',     'Название на русском')}
                ${cb('fieldYear',        'Год выпуска')}
                ${cb('fieldDate',        'Дата выпуска')}
                ${cb('fieldTags',        'Теги')}
                ${!isSteam ? `
                <div id="af-sp-tags-options" style="display:${settings.fieldTags ? 'block' : 'none'};margin-left:18px;">
                    ${cb('adaptTagEditor', 'Адаптировать под редактор тегов pornolab')}
                    <div id="af-sp-adapt-options" style="display:${settings.fieldTags && settings.adaptTagEditor ? 'block' : 'none'};margin-left:18px;">
                        ${cb('addIconLinks', 'Добавлять ссылки на иконки')}
                        <div style="margin-top:6px;font-size:12px;font-weight:bold;">Дописать теги:</div>
                        ${buildExtraTagsHTML()}
                    </div>
                </div>
                ${cb('fieldCensorship',  'Цензура')}` : ''}
                ${cb('fieldDeveloper',   'Разработчик/Издатель')}
                ${cb('fieldPlatform',    'Платформа')}
                ${!isSteam ? cb('fieldReleaseType', 'Тип издания') : ''}
                ${cb('fieldLanguage',    'Язык игры (сюжет/интерфейс)')}
                ${cb('fieldVoice',       'Озвучка')}
                ${cb('fieldSysReq',      'Системные требования' + (!isSteam ? ' (из Steam)' : ''))}
                <div id="${sysReqFmtId}" style="display:${showSysReq ? 'block' : 'none'}">
                    ${cb('formatSysReqTemplate', 'Форматировать под шаблон pornolab', 'margin-left:20px;')}
                </div>
                ${isSteam ? `
                ${cb('fieldDescShort',  'Описание (короткое)')}
                ${cb('fieldDescLong',   'Описание (длинное)')}
                <div id="af-sp-stm-desc-options" style="display:${showDesc ? 'block' : 'none'};margin-left:18px;">
                    ${cb('enableGoogleTranslate', 'Перевести через Google Translate')}
                </div>` : `
                ${cb('fieldDescription', 'Описание')}
                <div id="af-sp-desc-options" style="display:${showDesc ? 'block' : 'none'};margin-left:18px;">
                    ${cb('enableGoogleTranslate', 'Перевести через Google Translate')}
                </div>`}
                ${cb('fieldLinks',       'Ссылки на источник (под описанием игры)')}
                ${cb('fieldScreenshots', 'Скриншоты/Примеры')}
            </div>
            <div class="af-sp-section">
                <div class="af-sp-section-title">FastPic</div>
                <label class="af-sp-label">
                    <input type="checkbox" data-key="enableFastpicUpload" ${settings.enableFastpicUpload ? 'checked' : ''}>
                    Перезалить картинки на FastPic
                </label>
            </div>
            ${!isSteam ? `
            <div class="af-sp-section">
                <div class="af-sp-section-title">Steam</div>
                <label class="af-sp-label"><input type="checkbox" data-key="enableSteamPoster" ${settings.enableSteamPoster ? 'checked' : ''}> Использовать постер из Steam (с выбором обложки)</label>
                <label class="af-sp-label"><input type="checkbox" data-key="enableSteamTags" ${settings.enableSteamTags ? 'checked' : ''}> Добавлять Steam теги к текущим</label>
                <label class="af-sp-label"><input type="checkbox" data-key="enableSteamScreenshots" ${settings.enableSteamScreenshots ? 'checked' : ''}> Добавлять скриншоты из Steam к текущим</label>
            </div>` : ''}`;
    }

    function buildSettingsPanel(source) {
        const panel = document.createElement('div');
        panel.id = 'af-settings-panel';
        panel.innerHTML = `
            <div id="af-sp-header">
                <span>Настройки ${source}</span>
                <button id="af-sp-close" title="Закрыть">×</button>
            </div>
            <div id="af-sp-body">${buildSettingsHTML(source)}</div>`;

        document.body.appendChild(panel);
        makeSettingsPanelDraggable(panel);
        panel.style.top  = '120px';
        panel.style.left = '50px';

        try {
            const pos = JSON.parse(GM_getValue('af_settings_pos', '{}'));
            if (pos.top)  { panel.style.top  = pos.top;  panel.style.transform = 'none'; }
            if (pos.left) { panel.style.left = pos.left; }
        } catch {}

        panel.querySelector('#af-sp-close').onclick = () => panel.remove();
        bindSettingsEvents(panel, source);
        return panel;
    }

    function bindSettingsEvents(panel, source) {
        const isSteam = source === 'steam';
        const P = isSteam ? 'stm_' : '';

        const TOGGLE_MAP = isSteam ? {
            [`${P}fieldSysReq`]: '#af-sp-stm-sysreq-fmt',
        } : {
            fieldSysReq:       '#af-sp-sysreq-fmt',
            fieldTags:         '#af-sp-tags-options',
            adaptTagEditor:    '#af-sp-adapt-options',
            fieldDescription:  '#af-sp-desc-options',
        };

        panel.querySelectorAll('input[type="checkbox"][data-key]').forEach(cb => {
            cb.addEventListener('change', () => {
                settings[cb.dataset.key] = cb.checked;
                saveSettings(settings);
                const sel = TOGGLE_MAP[cb.dataset.key];
                if (sel) { const el = panel.querySelector(sel); if (el) el.style.display = cb.checked ? 'block' : 'none'; }
                if (isSteam && (cb.dataset.key === 'stm_fieldDescShort' || cb.dataset.key === 'stm_fieldDescLong')) {
                    const el = panel.querySelector('#af-sp-stm-desc-options');
                    if (el) el.style.display = (settings.stm_fieldDescShort || settings.stm_fieldDescLong) ? 'block' : 'none';
                }
            });
        });
    }

    function makeSettingsPanelDraggable(panel) {
        const header = panel.querySelector('#af-sp-header');
        let drag = null;
        const savePos = () => GM_setValue('af_settings_pos', JSON.stringify({
            top: panel.style.top, left: panel.style.left
        }));
        header.onmousedown = e => {
            if (e.target.tagName === 'BUTTON') return;
            e.preventDefault();
            drag = { x: e.clientX, y: e.clientY };
            document.onmousemove = e => {
                panel.style.top  = (panel.offsetTop  - (drag.y - e.clientY)) + 'px';
                panel.style.left = (panel.offsetLeft - (drag.x - e.clientX)) + 'px';
                panel.style.transform = 'none';
                drag = { x: e.clientX, y: e.clientY };
            };
            document.onmouseup = () => {
                document.onmousemove = document.onmouseup = null;
                savePos();
            };
        };
    }

    // ─── Вставка секции «Автозаполнение» в форму ──────────────────────────────

    function injectAutofillSection() {
        const relForm = document.getElementById('rel-form');
        if (!relForm) {
            console.warn('[AutoFill] #rel-form не найден, повтор через 500мс...');
            setTimeout(injectAutofillSection, 500);
            return;
        }

        const forumDefault  = DEFAULT_STEAM_FORUMS.includes(FORUM_ID) ? 'steam' : 'vndb';
        const defaultSource = GM_getValue(SOURCE_KEY, forumDefault);
        const defaultPlaceholder = defaultSource === 'steam'
            ? 'https://store.steampowered.com/app/702050/'
            : 'https://vndb.org/v97 или https://vndb.org/r64727';

        const afSection = document.createElement('div');
        afSection.id = 'af-section';
        afSection.innerHTML = `
            <table class="forumline">
                <colgroup>
                    <col class="row1" width="20%">
                    <col class="row2" width="80%">
                </colgroup>
                <tbody>
                    <tr><th colspan="2">Автозаполнение формы</th></tr>
                    <tr>
                        <td class="rel-title">Источник:</td>
                        <td class="rel-inputs" id="af-controls-td">
                            <div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
                                <select id="af-source-select" class="rel-el rel-input">
                                    <option value="vndb"  ${defaultSource === 'vndb'  ? 'selected' : ''}>vndb</option>
                                    <option value="steam" ${defaultSource === 'steam' ? 'selected' : ''}>steam</option>
                                </select>
                                <input type="button" id="af-settings-btn" value="Настройки ${defaultSource}">
                                <span id="af-vndb-hint"
                                      style="display:${defaultSource === 'vndb' ? 'inline' : 'none'};cursor:default;font-size:13px;line-height:1;vertical-align:middle;user-select:none;">❓</span>
                            </div>
                            <div style="display:flex;align-items:center;gap:6px;margin-top:4px;flex-wrap:wrap;">
                                <input id="af-url-input" type="text" class="rel-el rel-input"
                                       style="width:410px;"
                                       placeholder="${defaultPlaceholder}"
                                       value="">
                                <input type="button" id="af-fill-btn" value="Заполнить форму">
                            </div>
                        </td>
                    </tr>
                </tbody>
            </table>`;

        const spacer = document.createElement('div');
        spacer.className = 'spacer_12';
        relForm.parentNode.insertBefore(afSection, relForm);
        relForm.parentNode.insertBefore(spacer, relForm);

        const sourceSelect = document.getElementById('af-source-select');
        const urlInput     = document.getElementById('af-url-input');
        const settingsBtn  = document.getElementById('af-settings-btn');
        const fillBtn      = document.getElementById('af-fill-btn');

        function applySource(src, clearUrl = true) {
            sourceSelect.value   = src;
            settingsBtn.value    = `Настройки ${src}`;
            urlInput.placeholder = src === 'steam'
                ? 'https://store.steampowered.com/app/702050/'
                : 'https://vndb.org/v97 или https://vndb.org/r64727';
            if (clearUrl) urlInput.value = '';
            document.getElementById('af-settings-panel')?.remove();
            const hintEl = document.getElementById('af-vndb-hint');
            if (hintEl) hintEl.style.display = src === 'vndb' ? 'inline' : 'none';
            GM_setValue(SOURCE_KEY, src);
        }

        sourceSelect.addEventListener('change', () => applySource(sourceSelect.value));

        urlInput.addEventListener('paste', e => {
            const pasted = (e.clipboardData || window.clipboardData).getData('text').trim();
            try {
                const hostname = new URL(pasted).hostname;
                if (hostname === 'vndb.org' || hostname.endsWith('.vndb.org'))
                    applySource('vndb', false);
                else if (hostname === 'store.steampowered.com')
                    applySource('steam', false);
            } catch {}
        });

        settingsBtn.addEventListener('click', () => {
            const existing = document.getElementById('af-settings-panel');
            if (existing) { existing.remove(); return; }
            buildSettingsPanel(sourceSelect.value);
        });

        fillBtn.addEventListener('click', async () => {
            const src = sourceSelect.value;
            const url = urlInput.value.trim();
            if (!url) { toast('Введите ссылку', 'warning'); return; }
            fillBtn.disabled = true;
            fillBtn.value = '⏳ Заполняю...';
            try {
                if (src === 'steam') await fillFormSteam(url);
                else                 await fillForm(url);
            } finally {
                fillBtn.disabled = false;
                fillBtn.value = 'Заполнить форму';
            }
        });

        const hintEl  = document.getElementById('af-vndb-hint');
        let hintTimer = null;

        hintEl.addEventListener('mouseenter', () => {
            hintTimer = setTimeout(() => {
                let tip = document.getElementById('af-vndb-tooltip');
                if (!tip) {
                    tip = document.createElement('div');
                    tip.id = 'af-vndb-tooltip';
                    tip.innerHTML =
                        'Если вставите ссылку на vn страницу, то дата релиза будет взята из первого официального релиза. ' +
                        'Это нормально для новых игр.<br><br>' +
                        'Если vn с большой историей переизданий, то вставляйте ссылку на конкретный релиз, ' +
                        'его данные будут в приоритете на заполнение формы.<br><br>' +
                        'В настройках можно подключить добавление данных из Steam. ' +
                        'Это будет работать, если на vn странице есть хоть один релиз с ссылкой на Steam.<br><br>' +
                        'Если на один магазин будет несколько разных ссылок, то берётся последняя по дате релиза.';
                    document.body.appendChild(tip);
                }
                const r = hintEl.getBoundingClientRect();
                tip.style.left    = r.left + 'px';
                tip.style.top     = (r.bottom + 6) + 'px';
                tip.style.display = 'block';
            }, 50);
        });

        hintEl.addEventListener('mouseleave', () => {
            clearTimeout(hintTimer);
            const tip = document.getElementById('af-vndb-tooltip');
            if (tip) tip.style.display = 'none';
        });
    }

    // ─── Стили ─────────────────────────────────────────────────────────────────

    GM_addStyle(`
        #af-toast-container { position:fixed; top:16px; left:50%; transform:translateX(-50%); z-index:999999; display:flex; flex-direction:column; gap:8px; max-width:340px; pointer-events:none; }
        .af-toast { padding:10px 14px; border-radius:6px; font:13px/1.4 Verdana,Arial,sans-serif; box-shadow:0 3px 12px rgba(0,0,0,.35); pointer-events:auto; cursor:pointer; animation:af-fadein .25s ease; word-break:break-word; }
        @keyframes af-fadein { from { opacity:0; transform:translateX(20px); } to { opacity:1; } }
        .af-toast-info    { background:#1a6ebf; color:#fff; }
        .af-toast-success { background:#2a8a3e; color:#fff; }
        .af-toast-warning { background:#b07c00; color:#fff; }
        .af-toast-error   { background:#b02020; color:#fff; }

        /* ─── Настройки ─── */
        #af-settings-panel { width:max-content; min-width:340px; max-width:min(560px,90vw); position:fixed; max-height:80vh; overflow:hidden; display:flex; flex-direction:column; background:#fafafa; border:1px solid #bbb; border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.25); z-index:99998; font:12px/1.5 Verdana,Arial,sans-serif; }
        #af-sp-header { background:#2a6ea0; color:#fff; padding:8px 12px; border-radius:8px 8px 0 0; display:flex; justify-content:space-between; align-items:center; cursor:move; user-select:none; font-weight:bold; flex-shrink:0; }
        #af-sp-body { padding:10px; overflow-y:auto; flex:1; }
        #af-sp-close { background:none; border:none; color:#fff; font-size:20px; cursor:pointer; line-height:1; padding:0 2px; }
        #af-sp-close:hover { color:#ffc; }
        .af-sp-section { margin-bottom:12px; border:1px solid #ddd; border-radius:5px; padding:8px 10px; }
        .af-sp-section-title { font-weight:bold; color:#2a6ea0; margin-bottom:6px; font-size:12px; text-transform:uppercase; letter-spacing:.5px; }
        .af-sp-label { display:block; padding:2px 0; cursor:pointer; color:#333; }
        .af-sp-label:hover { color:#000; }
        .af-sp-label input[type="checkbox"] { margin-right:6px; cursor:pointer; }
        .af-sp-radio { cursor:pointer; color:#333; }
        .af-sp-radio:hover { color:#000; }
        .af-sp-radio input { margin-right:5px; cursor:pointer; }
        #af-vndb-tooltip { display:none; position:fixed; z-index:999999; max-width:340px; background:#2c2c2c; color:#f0f0f0; font:12px/1.6 Verdana,Arial,sans-serif; padding:10px 13px; border-radius:6px; box-shadow:0 4px 16px rgba(0,0,0,.45); pointer-events:none; word-break:break-word; }

        /* ─── Выбор постера ─── */
        #af-cover-panel { position:fixed; top:80px; left:80px; width:max-content; min-width:340px; max-width:90vw; max-height:82vh; display:flex; flex-direction:column; background:#fafafa; border:1px solid #bbb; border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.3); z-index:99999; font:12px/1.5 Verdana,Arial,sans-serif; }
        #af-cover-header { background:#2a6ea0; color:#fff; padding:8px 12px; border-radius:8px 8px 0 0; display:flex; justify-content:space-between; align-items:center; cursor:move; user-select:none; font-weight:bold; flex-shrink:0; }
        #af-cover-header button { background:none; border:none; color:#fff; font-size:20px; cursor:pointer; line-height:1; padding:0 2px; }
        #af-cover-header button:hover { color:#ffc; }
        #af-cover-body { overflow-y:auto; flex:1; padding:10px 12px; }
        #af-cover-footer { flex-shrink:0; border-top:1px solid #ddd; background:#fafafa; padding:10px 12px; display:flex; gap:8px; border-radius:0 0 8px 8px; }
        .af-cover-item { padding:8px 0; border-top:1px solid #eee; }
        .af-cover-item:first-child { border-top:none; }
        .af-cover-label { display:flex; align-items:center; gap:6px; cursor:pointer; font-weight:bold; color:#333; }
        .af-cover-label:hover { color:#000; }
        .af-cover-label input[type="radio"] { cursor:pointer; flex-shrink:0; }
        .af-cover-thumb { display:block; max-height:320px; width:auto; height:auto; margin-top:6px; border-radius:4px; border:1px solid #ddd; }
        .af-cover-btn-primary { background:#2a6ea0; color:#fff; border:none; border-radius:5px; padding:7px 18px; font:12px Verdana,Arial,sans-serif; cursor:pointer; }
        .af-cover-btn-primary:hover { background:#1a5e90; }
        .af-cover-btn-secondary { background:#fff; color:#333; border:1px solid #bbb; border-radius:5px; padding:7px 14px; font:12px Verdana,Arial,sans-serif; cursor:pointer; }
        .af-cover-btn-secondary:hover { background:#f0f0f0; }
    `);

    // ─── Инициализация ─────────────────────────────────────────────────────────

    function init() {
        if (!document.getElementById('rel-form')) return;
        injectAutofillSection();
    }

    if (document.readyState !== 'loading') {
        setTimeout(init, 300);
    } else {
        document.addEventListener('DOMContentLoaded', () => setTimeout(init, 300));
    }

})();