미디어위키:DevTools.js

Nxdsxn (토론 | 기여)님의 2026년 7월 11일 (토) 01:57 판 (Install package: clbiwiki-nations-to-era-migration-20260711 / js/DevTools.js)

참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: Ctrl-F5를 입력.
(function (mw, $) {
    'use strict';

    var OWNER = 'Nxdsxn';
    var JSZIP_URL = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
    var LANGUAGE_STATUS_TITLE = 'MediaWiki:LanguageStatus.json';
    var DECORATIONS_TITLE = 'MediaWiki:Decorations.json';
    var DEVTOOLS_BUILD = '20260707-entry-warm-cache-001';
    var LANGUAGE_ORDER = ['ko', 'en', 'zh', 'ja', 'ru', 'es'];
    var LANGUAGE_LABELS = {
        ko: '한국어',
        en: 'English',
        zh: '中文',
        ja: '日本語',
        ru: 'Русский',
        es: 'Español'
    };
    var LANGUAGE_STATUS_VALUES = {
        available: true,
        wip: true,
        unavailable: true
    };

    var decorationEditor = {
        editMode: false,
        registry: null,
        savedRegistry: null,
        workingRegistry: null,
        selectedId: '',
        previewMap: {},
        gridTarget: null,
        drag: null
    };

    var decorationPixelAssetCache = {};

    var DOCUMENT_TOOLS_GROUPS = {
        editor: true,
        'senior-editor': true,
        'devtools-access': true
    };

    var MANAGE_TOOLS_GROUPS = {
        'senior-editor': true,
        'devtools-access': true
    };

    function isOwner() {
        return (mw.config.get('wgUserName') || '') === OWNER;
    }

    function hasAnyGroup(groupMap) {
        var groups = mw.config.get('wgUserGroups') || [];

        return groups.some(function (group) {
            return !!groupMap[String(group || '')];
        });
    }

    function hasDocumentToolsAccess() {
        return isOwner() || hasAnyGroup(DOCUMENT_TOOLS_GROUPS);
    }

    function hasManageToolsAccess() {
        return isOwner() || hasAnyGroup(MANAGE_TOOLS_GROUPS);
    }

    function hasDevToolsAccess() {
        return hasDocumentToolsAccess() || hasManageToolsAccess();
    }

    if (!hasDevToolsAccess()) return;
    if (window.DevToolsPanelInitialized) return;
    window.DevToolsPanelInitialized = true;


    var SOURCE_SEED_TITLES = [
        'MediaWiki:Common.css',
        'MediaWiki:Theme.css',
        'MediaWiki:Layout.css',
        'MediaWiki:MainPage.css',
        'MediaWiki:Components.css',
        'MediaWiki:Template.css',
        'MediaWiki:Template.Infobox.css',
        'MediaWiki:Icons.css',
        'MediaWiki:Editor.css',
        'MediaWiki:Dialogs.css',
        'MediaWiki:DevTools.css',
        'MediaWiki:AnecdoteViewer.css',
        'MediaWiki:Test.css',
        'MediaWiki:Common.js',
        'MediaWiki:Lang.js',
        'MediaWiki:DevTools.js',
        'MediaWiki:CategoryNav.js',
        'MediaWiki:AnecdoteViewer.js',
        'MediaWiki:NationsGlobe.js',
        'MediaWiki:NationsPanel.js',
        'MediaWiki:Shortcuts.js',
        'MediaWiki:Nation_List_Manager.js',
        'MediaWiki:Nation_List_Manager.css',
        'MediaWiki:History_Event_Manager.js',
        'MediaWiki:History_Event_Manager.css',
        'MediaWiki:LanguageStatus.json',
        'Manage:Nation_List',
        'Manage:History_Events'
    ];

    function getRenameSourceSeedTitles() {
        var titles = SOURCE_SEED_TITLES.slice();
        var year;

        for (year = 1950; year <= 2100; year += 10) {
            titles.push('MediaWiki:' + year + '_Nation_List.json');
            titles.push('MediaWiki:' + year + '_Nation_Link_Map.json');
            titles.push('MediaWiki:' + year + '_History_Events.json');
        }

        return uniqueStrings(titles);
    }

    var SOURCE_TEXT_EXTENSIONS = {
        css: 'text/css',
        js: 'text/javascript',
        json: 'application/json',
        geojson: 'application/geo+json',
        topojson: 'application/json',
        gpx: 'application/gpx+xml',
        kml: 'application/vnd.google-earth.kml+xml',
        txt: 'text/plain',
        map: 'application/json',
        lua: 'text/plain',
        less: 'text/css',
        scss: 'text/css',
        xml: 'text/xml',
        html: 'text/html',
        htm: 'text/html',
        md: 'text/plain',
        csv: 'text/csv',
        tsv: 'text/tab-separated-values',
        yml: 'text/plain',
        yaml: 'text/plain'
    };

    var SOURCE_FILE_EXTENSIONS = {
        css: true,
        js: true,
        json: true,
        geojson: true,
        topojson: true,
        gpx: true,
        kml: true,
        kmz: true,
        gpkg: true,
        shp: true,
        shx: true,
        dbf: true,
        prj: true,
        cpg: true,
        txt: true,
        map: true,
        lua: true,
        xml: true,
        html: true,
        htm: true,
        md: true,
        csv: true,
        tsv: true,
        yml: true,
        yaml: true
    };

    var SOURCE_ALWAYS_NAMESPACE_IDS = {
        8: true,
        10: true,
        828: true
    };

    var SOURCE_ALWAYS_NAMESPACE_NAMES = {
        mediawiki: true,
        template: true,
        '틀': true,
        module: true,
        manage: true,
        '관리': true
    };


    function escapeHtml(value) {
        return String(value == null ? '' : value)
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#039;');
    }


    function normalizeDocumentToolPageName(value) {
        return String(value || '')
            .split('?')[0]
            .replace(/^\/index\.php\//, '')
            .replace(/_/g, ' ')
            .trim();
    }

    function getDocumentToolPageName() {
        var pageName = mw.config.get('wgPageName') || '';

        if (pageName) {
            return String(pageName).replace(/ /g, '_');
        }

        pageName = normalizeDocumentToolPageName(window.location.pathname || '');
        return pageName ? pageName.replace(/ /g, '_') : '대문';
    }

    function getDocumentToolDisplayTitle() {
        var pageName = getDocumentToolPageName();
        return normalizeDocumentToolPageName(pageName) || '현재 문서';
    }

    function getDocumentToolUrl(pageName, params) {
        pageName = pageName || getDocumentToolPageName();
        params = params || {};

        if (mw.util && typeof mw.util.getUrl === 'function') {
            return mw.util.getUrl(pageName, params);
        }

        var query = $.param(params);
        return '/index.php/' + encodeURI(String(pageName || '').replace(/ /g, '_')) + (query ? '?' + query : '');
    }

    function getDocumentToolTalkPageName() {
        var namespaceNumber = Number(mw.config.get('wgNamespaceNumber'));
        var formattedNamespaces = mw.config.get('wgFormattedNamespaces') || {};
        var title = String(mw.config.get('wgTitle') || '').trim();
        var talkNamespace;
        var prefix;

        if (!Number.isFinite(namespaceNumber) || namespaceNumber < 0 || namespaceNumber % 2 !== 0) {
            return '';
        }

        talkNamespace = namespaceNumber + 1;
        prefix = formattedNamespaces[String(talkNamespace)] || formattedNamespaces[talkNamespace] || '';

        if (!prefix || !title) {
            return '';
        }

        return prefix + ':' + title.replace(/ /g, '_');
    }

    function renderDocumentToolLink(label, href, disabled, extraClass) {
        var className = 'dev-tools-button dev-tools-link-button' + (extraClass ? ' ' + extraClass : '');

        if (disabled || !href) {
            return '<span class="' + escapeHtml(className + ' dev-tools-button-disabled') + '" aria-disabled="true">' + escapeHtml(label) + '</span>';
        }

        return '<a class="' + escapeHtml(className) + '" href="' + escapeHtml(href) + '">' + escapeHtml(label) + '</a>';
    }


    function renderDocumentToolButton(label, id, disabled, extraClass) {
        var className = 'dev-tools-button' + (extraClass ? ' ' + extraClass : '');

        if (disabled || !id) {
            return '<span class="' + escapeHtml(className + ' dev-tools-button-disabled') + '" aria-disabled="true">' + escapeHtml(label) + '</span>';
        }

        return '<button type="button" class="' + escapeHtml(className) + '" id="' + escapeHtml(id) + '">' + escapeHtml(label) + '</button>';
    }


    function normalizeMoveTitle(value) {
        var text = String(value || '')
            .split('#')[0]
            .split('?')[0]
            .trim();

        text = text.replace(/^https?:\/\/[^/]+\/index\.php\/?/i, '');
        text = text.replace(/^\/index\.php\/?/i, '');

        try {
            text = decodeURIComponent(text);
        } catch (err) {
            // Keep the original text if it is not a valid encoded title.
        }

        return text.replace(/_/g, ' ').trim();
    }

    function getMoveNamespacePrefix(title) {
        var text = normalizeMoveTitle(title);
        var index = text.indexOf(':');

        if (index <= 0) return '';

        return text.slice(0, index);
    }

    function normalizeMoveTargetTitle(fromTitle, targetTitle) {
        var from = normalizeMoveTitle(fromTitle);
        var target = normalizeMoveTitle(targetTitle);
        var prefix;

        if (!target) return '';
        if (target.indexOf(':') !== -1) return target;

        prefix = getMoveNamespacePrefix(from);
        return prefix ? prefix + ':' + target : target;
    }

    function normalizeMoveCompare(value) {
        return normalizeMoveTitle(value).replace(/_/g, ' ').trim().toLowerCase();
    }

    function isCurrentMoveSource(fromTitle) {
        return normalizeMoveCompare(fromTitle) === normalizeMoveCompare(getDocumentToolPageName());
    }

    function getMoveTitleBody(title) {
        var text = normalizeMoveTitle(title);
        var index = text.indexOf(':');
        return index > 0 ? text.slice(index + 1) : text;
    }

    function normalizeApiTitle(title) {
        return normalizeMoveTitle(title).replace(/ /g, '_');
    }

    function escapeRegExp(value) {
        return String(value == null ? '' : value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    function uniqueStrings(values) {
        var seen = {};
        var result = [];

        values.forEach(function (value) {
            value = String(value || '').trim();
            if (!value || seen[value]) return;
            seen[value] = true;
            result.push(value);
        });

        return result;
    }

    function isFileTitle(title) {
        var prefix = getMoveNamespacePrefix(title).toLowerCase();
        return prefix === 'file' || prefix === '파일';
    }

    function getFileTitleAliases(title) {
        var body = getMoveTitleBody(title);
        if (!body) return [];

        return uniqueStrings([
            '파일:' + body,
            'File:' + body,
            'file:' + body,
            'Image:' + body,
            'image:' + body
        ]);
    }

    function getTitleReferenceVariants(title) {
        var normalized = normalizeMoveTitle(title);
        var underscored = normalized.replace(/ /g, '_');
        var variants = [normalized, underscored];
        var body;

        if (isFileTitle(normalized)) {
            body = getMoveTitleBody(normalized);
            variants = variants.concat(getFileTitleAliases(normalized));
            getFileTitleAliases(normalized).forEach(function (alias) {
                variants.push(alias.replace(/ /g, '_'));
            });

            /*
             * Nation_List JSON and several manager registries store a file as the bare
             * filename, not as File:/파일:.  A MediaWiki rename still leaves the old
             * redirect page alive, so failing to rewrite the bare filename makes both
             * the old and new names appear in CLBI lists.
             */
            if (body) {
                variants.push(body);
                variants.push(body.replace(/ /g, '_'));
            }
        }

        return uniqueStrings(variants).sort(function (a, b) {
            return b.length - a.length;
        });
    }

    function isBareFileReferenceVariant(fromVariant, fromTitle) {
        var body;

        if (!isFileTitle(fromTitle)) return false;
        if (getMoveNamespacePrefix(fromVariant)) return false;

        body = getMoveTitleBody(fromTitle);
        return normalizeMoveCompare(fromVariant) === normalizeMoveCompare(body);
    }

    function getTitleReferenceReplacement(fromVariant, fromTitle, toTitle) {
        var fromPrefix = getMoveNamespacePrefix(fromVariant);
        var toBody = getMoveTitleBody(toTitle);
        var normalizedTo = normalizeMoveTitle(toTitle);
        var replacement;

        if (isFileTitle(fromVariant) && toBody) {
            replacement = fromPrefix ? fromPrefix + ':' + toBody : normalizedTo;
        } else if (isBareFileReferenceVariant(fromVariant, fromTitle) && toBody) {
            replacement = toBody;
        } else {
            replacement = normalizedTo;
        }

        if (fromVariant.indexOf('_') !== -1) {
            replacement = replacement.replace(/ /g, '_');
        }

        return replacement;
    }

    function replaceAllText(value, from, to) {
        return String(value).split(from).join(to);
    }

    function replaceRenameReferences(text, fromTitle, toTitle, includePlainText) {
        var next = String(text || '');
        var variants = getTitleReferenceVariants(fromTitle);
        var changed = false;

        variants.forEach(function (variant) {
            var replacement = getTitleReferenceReplacement(variant, fromTitle, toTitle);
            var before = next;
            var escaped = escapeRegExp(variant).replace(/[ _]+/g, '[ _]+');

            next = next.replace(new RegExp('(\\[\\[\\s*)' + escaped + '(?=\\s*(?:[\\]#|]))', 'gi'), '$1' + replacement);
            next = next.replace(new RegExp('(\\[\\[\\s*)' + escaped + '(?=\\s*\\|)', 'gi'), '$1' + replacement);

            if (includePlainText) {
                next = replaceAllText(next, variant, replacement);
            }

            if (next !== before) changed = true;
        });

        return {
            text: next,
            changed: changed || next !== String(text || '')
        };
    }

    function isRenameSourceLikeTitle(title) {
        var ext = getTitleExtension(title);
        var nsName = normalizeNamespaceName(getTitleNamespaceName(title));

        if (SOURCE_TEXT_EXTENSIONS[ext]) return true;
        if (SOURCE_ALWAYS_NAMESPACE_NAMES[nsName]) return true;
        return false;
    }

    function queryRenameReferencePages(api, fromTitle) {
        var normalized = normalizeApiTitle(fromTitle);
        var requests = [
            api.get({
                action: 'query',
                list: 'backlinks',
                bltitle: normalized,
                bllimit: 'max',
                blfilterredir: 'nonredirects',
                format: 'json',
                formatversion: 2
            }).then(function (data) {
                var list = data && data.query ? data.query.backlinks : [];
                return (list || []).map(function (item) { return item.title; });
            }).catch(function () { return []; })
        ];

        if (isFileTitle(fromTitle)) {
            requests.push(api.get({
                action: 'query',
                list: 'imageusage',
                iutitle: normalized,
                iulimit: 'max',
                format: 'json',
                formatversion: 2
            }).then(function (data) {
                var list = data && data.query ? data.query.imageusage : [];
                return (list || []).map(function (item) { return item.title; });
            }).catch(function () { return []; }));
        }

        return Promise.all(requests).then(function (chunks) {
            var titles = [];

            chunks.forEach(function (chunk) {
                titles = titles.concat(chunk || []);
            });

            if (hasManageToolsAccess()) {
                titles = titles.concat(getRenameSourceSeedTitles());
            }
            titles.push(getDocumentToolPageName());

            return uniqueStrings(titles).filter(function (title) {
                return normalizeMoveCompare(title) !== normalizeMoveCompare(fromTitle);
            });
        });
    }

    function fetchRenamePageText(api, title) {
        return api.get({
            action: 'query',
            prop: 'revisions|info',
            titles: normalizeApiTitle(title),
            rvprop: 'content|ids',
            rvslots: 'main',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var pages = data && data.query ? data.query.pages : [];
            var page = pages && pages.length ? pages[0] : null;
            var text = '';

            if (!page || page.missing) return null;
            if (page.ns === 6) return null;

            if (page.revisions && page.revisions[0]) {
                if (page.revisions[0].slots && page.revisions[0].slots.main) {
                    text = page.revisions[0].slots.main.content || '';
                } else {
                    text = page.revisions[0].content || '';
                }
            }

            return {
                title: page.title || title,
                text: text,
                ns: page.ns,
                contentmodel: page.contentmodel || '',
                revid: page.revisions && page.revisions[0] ? page.revisions[0].revid : null
            };
        });
    }

    function editRenameReferencePage(api, page, nextText, fromTitle, toTitle) {
        var payload = {
            action: 'edit',
            title: page.title,
            text: nextText,
            summary: 'Update references after rename: ' + fromTitle + ' → ' + toTitle,
            minor: 1,
            nocreate: 1,
            format: 'json',
            formatversion: 2,
            errorformat: 'plaintext'
        };

        if (page.contentmodel) payload.contentmodel = page.contentmodel;
        if (page.revid) payload.baserevid = page.revid;

        return api.postWithToken('csrf', payload);
    }

    function updateRenameReferences(api, fromTitle, toTitle) {
        var checked = $('#dev-tools-rename-update-references').prop('checked');

        if (!checked) {
            return Promise.resolve({ scanned: 0, changed: 0, failed: 0 });
        }

        return queryRenameReferencePages(api, fromTitle).then(function (titles) {
            var total = titles.length;
            var index = 0;
            var changed = 0;
            var failed = 0;
            var chain = Promise.resolve();

            titles.forEach(function (title) {
                chain = chain.then(function () {
                    index += 1;
                    setStatus('참조 정리 중... ' + index + '/' + total);

                    return fetchRenamePageText(api, title).then(function (page) {
                        var result;
                        var includePlainText;

                        if (!page) return;

                        includePlainText = isRenameSourceLikeTitle(page.title);
                        result = replaceRenameReferences(page.text, fromTitle, toTitle, includePlainText);
                        if (!result.changed) return;

                        return editRenameReferencePage(api, page, result.text, fromTitle, toTitle).then(function () {
                            changed += 1;
                        });
                    }).catch(function () {
                        failed += 1;
                    });
                });
            });

            return chain.then(function () {
                return { scanned: total, changed: changed, failed: failed };
            });
        });
    }


    function isNationsDocumentPage() {
        var pageName = normalizeDocumentToolPageName(getDocumentToolPageName());
        var title = normalizeDocumentToolPageName(mw.config.get('wgTitle') || '');
        var candidates = [pageName, title];

        return candidates.some(function (value) {
            value = String(value || '').replace(/_/g, ' ').trim().toLowerCase();
            return value === '시대' || value === 'era';
        });
    }

    function buildNationsManageToolsHtml() {
        if (!hasManageToolsAccess()) return '';
        if (!isNationsDocumentPage()) return '';

        return '' +
            '<div class="dev-tools-section dev-tools-nations-manage-section">' +
                '<div class="dev-tools-section-title">ERA MANAGE</div>' +
                '<div class="dev-tools-grid dev-tools-doc-grid" id="dev-tools-nations-manage-grid">' +
                    renderDocumentToolLink('국가 목록', getDocumentToolUrl('Manage:Nation_List'), false) +
                    renderDocumentToolLink('사건 기록', getDocumentToolUrl('Manage:History_Events'), false) +
                '</div>' +
                '<div class="dev-tools-note">시대 문서의 국가 목록과 사건 기록 관리 페이지로 이동합니다.</div>' +
            '</div>';
    }

    function buildOwnerToolsHtml() {
        if (!isOwner()) return '';

        return '' +
            '<div class="dev-tools-section dev-tools-owner-section">' +
                '<div class="dev-tools-section-title">OWNER</div>' +
                '<div class="dev-tools-grid dev-tools-doc-grid" id="dev-tools-owner-grid">' +
                    renderDocumentToolLink('사용자 권한', getDocumentToolUrl('Special:UserRights'), false) +
                '</div>' +
                '<div class="dev-tools-note">사용자 그룹과 권한을 관리합니다.</div>' +
            '</div>';
    }

    function buildSourcePackageToolsHtml() {
        if (!isOwner()) return '';

        var svgCollapsed = localStorage.getItem('dev-tools-section-svg-collapsed');
        var svgIsCollapsed = svgCollapsed === null ? true : svgCollapsed === 'true';

        return '' +
            '<div class="dev-tools-section">' +
                '<div class="dev-tools-section-title">SOURCE PACKAGE</div>' +
                '<button type="button" class="dev-tools-button" id="dev-tools-export">위키 소스 ZIP 다운로드</button>' +
                '<div class="dev-tools-note">위키 전체를 스캔해 CSS·JS·JSON·GeoJSON·Template·Manage·Module 원본과 source-like 파일을 ZIP으로 묶습니다. SVG는 제외합니다.</div>' +
            '</div>' +
            '<div class="dev-tools-section dev-tools-package-section">' +
                '<div class="dev-tools-section-title">PACKAGE INSTALLER</div>' +
                '<input id="dev-tools-package-file" class="dev-tools-file-input" type="file" accept=".zip,application/zip,application/x-zip-compressed">' +
                '<div class="dev-tools-grid">' +
                    '<button type="button" class="dev-tools-button" id="dev-tools-package-analyze">분석</button>' +
                    '<button type="button" class="dev-tools-button dev-tools-danger" id="dev-tools-package-install" disabled>적용</button>' +
                '</div>' +
                '<div class="dev-tools-package-summary" id="dev-tools-package-summary">ZIP을 선택하면 적용 대상이 표시됩니다.</div>' +
                '<div class="dev-tools-package-list" id="dev-tools-package-list"></div>' +
                '<div class="dev-tools-note">manifest.json이 있으면 그 매핑을 우선하고, 없으면 js/ css/ mediawiki/ page/ manage/ 경로를 자동 추론합니다. PHP·server 파일·이미지 업로드는 적용하지 않습니다. 적용 전 현재 문서 백업 ZIP을 자동으로 내려받습니다.</div>' +
            '</div>' +
            '<div class="dev-tools-section dev-tools-svg-section dev-tools-section-collapsible' + (svgIsCollapsed ? ' is-collapsed' : '') + '" data-devtools-section="svg">' +
                '<button type="button" class="dev-tools-section-toggle" aria-expanded="' + (svgIsCollapsed ? 'false' : 'true') + '">' +
                    '<span class="dev-tools-section-caret">' + (svgIsCollapsed ? '+' : '−') + '</span>' +
                    '<span class="dev-tools-section-toggle-label">SVG FLAG NORMALIZER</span>' +
                '</button>' +
                '<div class="dev-tools-section-content">' +
                    '<textarea id="dev-tools-svg-normalize-titles" class="dev-tools-textarea dev-tools-svg-textarea" rows="4" placeholder="파일:Flag-Example.svg\nFile:Flag-Example-2.svg"></textarea>' +
                    '<div class="dev-tools-grid">' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-svg-current">현재 파일</button>' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-svg-recent">최근 Flag SVG</button>' +
                        '<button type="button" class="dev-tools-button dev-tools-danger" id="dev-tools-svg-normalize">정규화 업로드</button>' +
                    '</div>' +
                    '<div class="dev-tools-svg-summary" id="dev-tools-svg-summary">SVG 파일명의 목록을 넣고 정규화하면 투명 여백/아트보드 밖 요소 때문에 작아지는 썸네일을 파일 자체에서 정리합니다.</div>' +
                    '<div class="dev-tools-package-list dev-tools-svg-list" id="dev-tools-svg-list"></div>' +
                    '<div class="dev-tools-note">MediaWiki 썸네일은 SVG 원본의 viewBox/viewport를 기준으로 래스터화합니다. CSS로 고치는 문제가 아니므로, 이 도구는 SVG를 다시 읽어 실제 보이는 픽셀 범위로 viewBox를 정리한 뒤 같은 파일명으로 재업로드합니다.</div>' +
                '</div>' +
            '</div>';
    }


    function formatRevisionManifestTime(value) {
        var num = Number(value || 0);
        var d;
        if (!num) return 'NONE';
        d = new Date(num);
        if (!Number.isFinite(d.getTime())) return 'NONE';
        return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
    }

    function buildRevisionManifestToolsHtml() {
        if (!isOwner()) return '';

        var collapsed = localStorage.getItem('dev-tools-section-revision-manifest-collapsed');
        var isCollapsed = collapsed === null ? false : collapsed === 'true';

        return '' +
            '<div class="dev-tools-section dev-tools-revision-manifest-section dev-tools-section-collapsible' + (isCollapsed ? ' is-collapsed' : '') + '" data-devtools-section="revision-manifest">' +
                '<button type="button" class="dev-tools-section-toggle" aria-expanded="' + (isCollapsed ? 'false' : 'true') + '">' +
                    '<span class="dev-tools-section-caret">' + (isCollapsed ? '+' : '−') + '</span>' +
                    '<span class="dev-tools-section-toggle-label">REVISION MANIFEST</span>' +
                '</button>' +
                '<div class="dev-tools-section-content">' +
                    '<div class="dev-tools-progress-readout" id="dev-tools-revision-manifest-readout">SYNC</div>' +
                    '<div class="dev-tools-grid">' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-revision-manifest-refresh">동기화</button>' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-entry-cache-reset">Entry cache reset</button>' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-boot-preview">Loading preview</button>' +
                    '</div>' +
                    '<div class="dev-tools-note">장부는 최신 상태표만 읽습니다. 두 번째 탭은 같은 RevisionManifest이면 EntryCache의 text/image blob/flagurl과 pack certificate를 우선 소비합니다. 이 readout에는 캐시 종류, hit 수, 네트워크 저장 수, 정리까지 남은 일수가 표시됩니다.</div>' +
                '</div>' +
            '</div>';
    }

    function refreshRevisionManifestTools() {
        var status;
        var prune;
        var text;
        var changed;
        if (!$('#dev-tools-revision-manifest-readout').length) return;
        if (!window.RevisionManifest || typeof window.RevisionManifest.status !== 'function') {
            $('#dev-tools-revision-manifest-readout').text('RevisionManifest: NOT LOADED');
            return;
        }
        status = window.RevisionManifest.status();
        prune = status.prune || {};
        changed = Number(status.changedCount || 0);
        var cache = status.cache || {};
        var stats = cache.stats || {};
        var kinds = cache.byKind || {};
        text = 'VERSION: ' + (status.manifestVersion || 'UNKNOWN') +
            ' / CHANGED: ' + changed +
            ' / CACHE: ' + (cache.entries != null ? cache.entries : 0) +
            ' / IMAGE: ' + (kinds.image || 0) +
            ' / TEXT: ' + (kinds.text || 0) +
            ' / FLAGURL: ' + (kinds.flagurl || 0) +
            ' / PACKS: ' + (cache.packs || 0) +
            ' / HITS: T' + (stats.textHits || 0) + ' B' + (stats.blobHits || 0) +
            ' / NETSTORE: ' + (stats.networkStores || 0) +
            ' / CLEANUP: ' + (prune.due ? 'DUE NOW' : (String(prune.remainingDays || 0) + '일 남음')) +
            ' / NEXT: ' + formatRevisionManifestTime(prune.nextClientPruneAt);
        $('#dev-tools-revision-manifest-readout').text(text);
    }

    function buildProgressToolsHtml() {
        if (!isOwner()) return '';

        var collapsed = localStorage.getItem('dev-tools-section-progress-collapsed');
        var isCollapsed = collapsed === null ? true : collapsed === 'true';

        return '' +
            '<div class="dev-tools-section dev-tools-progress-section dev-tools-section-collapsible' + (isCollapsed ? ' is-collapsed' : '') + '" data-devtools-section="progress">' +
                '<button type="button" class="dev-tools-section-toggle" aria-expanded="' + (isCollapsed ? 'false' : 'true') + '">' +
                    '<span class="dev-tools-section-caret">' + (isCollapsed ? '+' : '−') + '</span>' +
                    '<span class="dev-tools-section-toggle-label">PROGRESS TEST</span>' +
                '</button>' +
                '<div class="dev-tools-section-content">' +
                    '<div class="dev-tools-progress-readout" id="dev-tools-progress-readout">SYNC</div>' +
                    '<label class="dev-tools-label" for="dev-tools-xp-amount">XP AMOUNT</label>' +
                    '<input id="dev-tools-xp-amount" class="dev-tools-input" type="number" min="1" step="1" value="10">' +
                    '<label class="dev-tools-label" for="dev-tools-xp-reason">REASON</label>' +
                    '<input id="dev-tools-xp-reason" class="dev-tools-input" type="text" value="관리자 테스트">' +
                    '<div class="dev-tools-grid">' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-xp-add">XP 지급</button>' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-progress-refresh">동기화</button>' +
                    '</div>' +
                    '<button type="button" class="dev-tools-button" id="dev-tools-daily-reset">오늘 XP 보너스 리셋</button>' +
                    '<button type="button" class="dev-tools-button dev-tools-danger" id="dev-tools-level-reset">레벨 리셋</button>' +
                '</div>' +
            '</div>';
    }

    function buildLanguageStatusToolsHtml() {
        if (!isOwner()) return '';

        var collapsed = localStorage.getItem('dev-tools-section-language-collapsed');
        var isCollapsed = collapsed === null ? true : collapsed === 'true';

        return '' +
            '<div class="dev-tools-section dev-tools-language-section dev-tools-section-collapsible' + (isCollapsed ? ' is-collapsed' : '') + '" data-devtools-section="language">' +
                '<button type="button" class="dev-tools-section-toggle" aria-expanded="' + (isCollapsed ? 'false' : 'true') + '">' +
                    '<span class="dev-tools-section-caret">' + (isCollapsed ? '+' : '−') + '</span>' +
                    '<span class="dev-tools-section-toggle-label">LANGUAGE STATUS</span>' +
                '</button>' +
                '<div class="dev-tools-section-content">' +
                    '<div class="dev-tools-language-page" id="dev-tools-language-page">PAGE: SYNC</div>' +
                    '<div class="dev-tools-language-list" id="dev-tools-language-list">SYNC</div>' +
                    '<div class="dev-tools-grid">' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-language-save">저장</button>' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-language-refresh">불러오기</button>' +
                    '</div>' +
                    '<div class="dev-tools-note">현재 문서의 언어 다이얼 상태를 JSON으로 저장합니다.</div>' +
                '</div>' +
            '</div>';
    }

    function buildDecorationToolsHtml() {
        if (!hasDevToolsAccess()) return '';

        var collapsed = localStorage.getItem('dev-tools-section-decoration-collapsed');
        var isCollapsed = collapsed === null ? false : collapsed === 'true';
        var gridOn = localStorage.getItem('dev-tools-decoration-grid-enabled') !== 'false';

        return '' +
            '<div class="dev-tools-section dev-tools-decoration-section dev-tools-section-collapsible' + (isCollapsed ? ' is-collapsed' : '') + '" data-devtools-section="decoration">' +
                '<button type="button" class="dev-tools-section-toggle" aria-expanded="' + (isCollapsed ? 'false' : 'true') + '">' +
                    '<span class="dev-tools-section-caret">' + (isCollapsed ? '+' : '−') + '</span>' +
                    '<span class="dev-tools-section-toggle-label">DECORATION EDITOR</span>' +
                '</button>' +
                '<div class="dev-tools-section-content">' +
                    '<div class="dev-tools-decoration-headline">' +
                        '<div class="dev-tools-decoration-mode" id="dev-tools-decoration-mode">VIEW MODE</div>' +
                        '<div class="dev-tools-decoration-page" id="dev-tools-decoration-page">PAGE: SYNC</div>' +
                    '</div>' +
                    '<div class="dev-tools-decoration-mainbar">' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-decoration-edit-toggle">EDIT MODE</button>' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-decoration-save" disabled>SAVE</button>' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-decoration-cancel" disabled>CANCEL</button>' +
                    '</div>' +
                    '<div class="dev-tools-decoration-layerbar">' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-decoration-add" disabled>+ LAYER</button>' +
                        '<button type="button" class="dev-tools-button dev-tools-danger" id="dev-tools-decoration-delete" disabled>DELETE</button>' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-decoration-grid-toggle" data-grid="' + (gridOn ? '1' : '0') + '">' + (gridOn ? 'GRID ON' : 'GRID OFF') + '</button>' +
                    '</div>' +
                    '<div class="dev-tools-decoration-layer-list" id="dev-tools-decoration-layer-list">SYNC</div>' +
                    '<label class="dev-tools-label" for="dev-tools-decoration-label">LAYER NAME</label>' +
                    '<input id="dev-tools-decoration-label" class="dev-tools-input" type="text" value="" placeholder="layer name">' +
                    '<label class="dev-tools-label" for="dev-tools-decoration-asset-type">ASSET TYPE</label>' +
                    '<select id="dev-tools-decoration-asset-type" class="dev-tools-input">' +
                        '<option value="image">IMAGE PNG/WEBP</option>' +
                        '<option value="pixel-json">CLBI PIXEL JSON 1:1</option>' +
                    '</select>' +
                    '<label class="dev-tools-label" for="dev-tools-decoration-src">IMAGE FILE / PIXEL JSON PAGE</label>' +
                    '<input id="dev-tools-decoration-src" class="dev-tools-input" type="text" placeholder="Example_Deco.webp or MediaWiki:Deco.Example.json">' +
                    '<label class="dev-tools-label" for="dev-tools-decoration-target">TARGET SELECTOR</label>' +
                    '<input id="dev-tools-decoration-target" class="dev-tools-input" type="text" value=".liberty-content-main">' +
                    '<div class="dev-tools-decoration-continent-row">' +
                        '<div class="dev-tools-decoration-continent-readout" id="dev-tools-decoration-continent-readout">ERA: NONE / CONTINENT: NONE</div>' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-decoration-target-continent" disabled>CURRENT</button>' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-decoration-target-boot" disabled>BOOT</button>' +
                    '</div>' +
                    '<div class="dev-tools-decoration-scope-grid">' +
                        '<label><span>ERA</span><input id="dev-tools-decoration-era" class="dev-tools-input" type="text" value="" placeholder="optional"></label>' +
                        '<label><span>CONTINENT</span><input id="dev-tools-decoration-continent" class="dev-tools-input" type="text" value="" placeholder="optional"></label>' +
                    '</div>' +
                    '<div class="dev-tools-decoration-grid">' +
                        '<label><span>X</span><input id="dev-tools-decoration-x" class="dev-tools-input" type="number" step="1" value="0"></label>' +
                        '<label><span>Y</span><input id="dev-tools-decoration-y" class="dev-tools-input" type="number" step="1" value="0"></label>' +
                        '<label><span>W</span><input id="dev-tools-decoration-width" class="dev-tools-input" type="number" step="1" value="1372"></label>' +
                        '<label><span>H</span><input id="dev-tools-decoration-height" class="dev-tools-input" type="text" value="auto"></label>' +
                        '<label><span>OP</span><input id="dev-tools-decoration-opacity" class="dev-tools-input" type="number" min="0" max="1" step="0.05" value="1"></label>' +
                        '<label><span>Z</span><input id="dev-tools-decoration-z" class="dev-tools-input" type="number" step="1" value="0"></label>' +
                    '</div>' +
                    '<div class="dev-tools-decoration-quickbar">' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-decoration-center" disabled>CENTER</button>' +
                        '<button type="button" class="dev-tools-button" id="dev-tools-decoration-fit-width" disabled>FIT W</button>' +
                    '</div>' +
                    '<input id="dev-tools-decoration-id" type="hidden" value="">' +
                    '<input id="dev-tools-decoration-placement" type="hidden" value="">' +
                    '<div class="dev-tools-note">EDIT MODE에서만 조작합니다. 저장 전 변경은 CANCEL로 폐기됩니다. 레이어를 선택한 뒤 드래그/방향키로 이동하고, Shift+드래그는 수평·수직 축을 잠급니다. GRID ON은 중앙 가이드와 중앙 스냅을 켭니다. CLBI PIXEL JSON은 최종 해상도 1:1 canvas로 렌더하며 width/height 스케일을 적용하지 않습니다.</div>' +
                '</div>' +
            '</div>';
    }


    function buildDocumentToolsHtml() {
        var namespaceNumber = Number(mw.config.get('wgNamespaceNumber'));
        var pageName = getDocumentToolPageName();
        var displayTitle = getDocumentToolDisplayTitle();
        var isSpecial = Number.isFinite(namespaceNumber) && namespaceNumber < 0;
        var talkPageName = getDocumentToolTalkPageName();

        return '' +
            '<div class="dev-tools-section dev-tools-document-section">' +
                '<div class="dev-tools-section-title">DOCUMENT TOOLS</div>' +
                '<div class="dev-tools-current-page" id="dev-tools-current-page">PAGE: ' + escapeHtml(displayTitle) + '</div>' +
                '<div class="dev-tools-grid dev-tools-doc-grid" id="dev-tools-doc-grid">' +
                    renderDocumentToolLink('문서', getDocumentToolUrl(pageName), isSpecial) +
                    renderDocumentToolLink('편집', getDocumentToolUrl(pageName, { action: 'edit' }), isSpecial) +
                    renderDocumentToolLink('원본', getDocumentToolUrl(pageName, { action: 'raw' }), isSpecial) +
                    renderDocumentToolLink('역사', getDocumentToolUrl(pageName, { action: 'history' }), isSpecial) +
                    renderDocumentToolLink('토론', talkPageName ? getDocumentToolUrl(talkPageName) : '', !talkPageName) +
                    renderDocumentToolLink('새로고침', getDocumentToolUrl(pageName, { action: 'purge' }), isSpecial) +
                    renderDocumentToolButton('이름변경', 'dev-tools-rename-toggle', isSpecial) +
                    renderDocumentToolLink('삭제', getDocumentToolUrl(pageName, { action: 'delete' }), isSpecial, 'dev-tools-danger') +
                '</div>' +
                '<div class="dev-tools-rename-box" id="dev-tools-rename-box" hidden>' +
                    '<label class="dev-tools-label" for="dev-tools-rename-from">현재 이름</label>' +
                    '<input id="dev-tools-rename-from" class="dev-tools-input" type="text" value="' + escapeHtml(displayTitle) + '">' +
                    '<label class="dev-tools-label" for="dev-tools-rename-to">새 이름</label>' +
                    '<input id="dev-tools-rename-to" class="dev-tools-input" type="text" placeholder="새 문서명 또는 파일명">' +
                    '<label class="dev-tools-label" for="dev-tools-rename-reason">사유</label>' +
                    '<input id="dev-tools-rename-reason" class="dev-tools-input" type="text" value="이름 변경">' +
                    '<label class="dev-tools-label"><input id="dev-tools-rename-legacy" type="checkbox"> 기존 이름 redirect 남김</label>' +
                    '<label class="dev-tools-label"><input id="dev-tools-rename-update-references" type="checkbox" checked> 사용처와 설정 참조 정리</label>' +
                    '<label class="dev-tools-label"><input id="dev-tools-rename-delete-old" type="checkbox" checked> 기존 redirect 문서 제거</label>' +
                    '<label class="dev-tools-label"><input id="dev-tools-rename-talk" type="checkbox" checked> 토론 문서도 같이 이름 변경</label>' +
                    '<label class="dev-tools-label"><input id="dev-tools-rename-subpages" type="checkbox"> 하위 문서도 같이 이름 변경</label>' +
                    '<button type="button" class="dev-tools-button" id="dev-tools-rename-submit">실제 이름 변경 실행</button>' +
                    '<button type="button" class="dev-tools-button" id="dev-tools-rename-cleanup-submit">참조/리스트만 정리</button>' +
                    '<div class="dev-tools-note">문서/파일의 실제 제목을 새 이름으로 바꿉니다. 기본값은 기존 제목 redirect를 남기지 않는 실제 이름 변경입니다. 이미 생긴 redirect는 참조/리스트 정리 후 제거할 수 있습니다.</div>' +
                '</div>' +
                '<div class="dev-tools-note">상단 문서 조작은 일반 화면에서 숨기고 이 패널로 모읍니다.</div>' +
            '</div>';
    }

    function refreshDocumentTools() {
        var $section = $('.dev-tools-document-section');

        if (!$section.length) return;

        $section.replaceWith(buildDocumentToolsHtml());
    }

    function buildPanel() {
        if ($('#dev-tools-panel').length) return;

        var collapsed = localStorage.getItem('dev-tools-collapsed') === 'true';
        var html = '' +
            '<div id="dev-tools-panel" class="' + (collapsed ? 'dev-tools-collapsed' : '') + '">' +
                '<div id="dev-tools-header">' +
                    '<div id="dev-tools-title"><span class="dev-tools-led"></span><span>ADMIN TOOLS</span></div>' +
                    '<button type="button" id="dev-tools-toggle" aria-label="도구 접기">' + (collapsed ? '+' : '−') + '</button>' +
                '</div>' +
                '<div id="dev-tools-body">' +
                    buildOwnerToolsHtml() +
                    buildDocumentToolsHtml() +
                    buildDecorationToolsHtml() +
                    buildNationsManageToolsHtml() +
                    buildSourcePackageToolsHtml() +
                    buildRevisionManifestToolsHtml() +
                    buildProgressToolsHtml() +
                    buildLanguageStatusToolsHtml() +
                    '<div id="dev-tools-status">READY</div>' +
                '</div>' +
            '</div>';

        $('body').append(html);
    }

    function setStatus(text, type) {
        var $status = $('#dev-tools-status');
        $status.removeClass('dev-tools-status-ok dev-tools-status-error');
        if (type === 'ok') $status.addClass('dev-tools-status-ok');
        if (type === 'error') $status.addClass('dev-tools-status-error');
        $status.text(text || '');
    }

    function setButtonsDisabled(disabled) {
        $('#dev-tools-panel button, #dev-tools-panel input, #dev-tools-panel select, #dev-tools-panel textarea').prop('disabled', !!disabled);
        $('#dev-tools-toggle, #dev-tools-panel .dev-tools-section-toggle').prop('disabled', false);
    }

    function loadScript(url) {
        return new Promise(function (resolve, reject) {
            if (window.JSZip) {
                resolve();
                return;
            }

            var script = document.createElement('script');
            script.src = url;
            script.onload = resolve;
            script.onerror = reject;
            document.head.appendChild(script);
        });
    }


    function getTitleExtension(title) {
        var match = String(title || '').split('?')[0].split('#')[0].match(/\.([A-Za-z0-9]+)$/);
        return match ? match[1].toLowerCase() : '';
    }

    function normalizeNamespaceName(name) {
        return String(name || '').replace(/_/g, ' ').trim().toLowerCase();
    }

    function getTitleNamespaceName(title) {
        var text = String(title || '');
        var idx = text.indexOf(':');
        return idx === -1 ? '' : text.slice(0, idx);
    }

    function getTitleBody(title) {
        var text = String(title || '');
        var idx = text.indexOf(':');
        return idx === -1 ? text : text.slice(idx + 1);
    }

    function sanitizePathPart(value) {
        var text = String(value == null ? '' : value).trim();
        if (!text) return '_';
        return text
            .replace(/[\\:*?"<>|]/g, '_')
            .replace(/\s+/g, ' ')
            .replace(/^\.+$/, '_')
            .replace(/^\.+/, '')
            .replace(/\.+$/, '') || '_';
    }

    function sanitizeTitlePath(value) {
        return String(value || '')
            .replace(/_/g, ' ')
            .split('/')
            .map(sanitizePathPart)
            .join('/');
    }

    function namespaceFolder(source) {
        var nsName = source.namespaceName || getTitleNamespaceName(source.title);
        var normalized = normalizeNamespaceName(nsName);

        if (source.ns === 6 || normalized === 'file' || normalized === '파일') return 'file';
        if (source.ns === 8 || normalized === 'mediawiki') return 'mediawiki';
        if (source.ns === 10 || normalized === 'template' || normalized === '틀') return 'template';
        if (source.ns === 828 || normalized === 'module') return 'module';
        if (normalized === 'manage' || normalized === '관리') return 'manage';
        if (!normalized) return 'page';
        return sanitizePathPart(normalized);
    }

    function contentTypeForTitle(title) {
        return SOURCE_TEXT_EXTENSIONS[getTitleExtension(title)] || 'text/plain';
    }

    function outputPathForSource(source) {
        var title = source.title || '';
        var body = getTitleBody(title);
        var ext = getTitleExtension(title);
        var folder;

        if ((source.ns === 8 || normalizeNamespaceName(getTitleNamespaceName(title)) === 'mediawiki') && ext === 'css') {
            return 'css/' + sanitizeTitlePath(body);
        }
        if ((source.ns === 8 || normalizeNamespaceName(getTitleNamespaceName(title)) === 'mediawiki') && ext === 'js') {
            return 'js/' + sanitizeTitlePath(body);
        }

        folder = namespaceFolder(source);
        return folder + '/' + sanitizeTitlePath(body || title);
    }

    function rawUrl(file) {
        return mw.util.getUrl(file.title, {
            action: 'raw',
            ctype: file.ctype || contentTypeForTitle(file.title)
        });
    }

    function fetchRaw(file) {
        return fetch(rawUrl(file), { credentials: 'same-origin', cache: 'no-cache' }).then(function (res) {
            if (!res.ok) throw new Error(file.title + ' HTTP ' + res.status);
            return res.text();
        });
    }

    function fetchFileBlob(api, file) {
        return api.get({
            action: 'query',
            prop: 'imageinfo',
            titles: file.title,
            iiprop: 'url|mime|size',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var pages = data && data.query ? data.query.pages : [];
            var page = pages && pages.length ? pages[0] : null;
            var info = page && page.imageinfo && page.imageinfo.length ? page.imageinfo[0] : null;
            if (!info || !info.url) throw new Error(file.title + ' file url not found');
            return fetch(info.url, { credentials: 'same-origin', cache: 'no-cache' }).then(function (res) {
                if (!res.ok) throw new Error(file.title + ' HTTP ' + res.status);
                return res.blob();
            });
        });
    }

    function isFileSourcePage(page, ext) {
        return (page.ns === 6 || normalizeNamespaceName(getTitleNamespaceName(page.title)) === 'file' || normalizeNamespaceName(getTitleNamespaceName(page.title)) === '파일') && !!SOURCE_FILE_EXTENSIONS[ext];
    }

    function shouldExportSourcePage(page, namespaceInfo) {
        var title = page && page.title ? page.title : '';
        var ext = getTitleExtension(title);
        var nsName = namespaceInfo && namespaceInfo.namesById ? namespaceInfo.namesById[page.ns] : getTitleNamespaceName(title);
        var normalized = normalizeNamespaceName(nsName || getTitleNamespaceName(title));

        if (!title) return false;
        if (ext === 'svg') return false;
        if (isFileSourcePage(page, ext)) return true;
        if (SOURCE_TEXT_EXTENSIONS[ext]) return true;
        if (SOURCE_ALWAYS_NAMESPACE_IDS[page.ns]) return true;
        if (SOURCE_ALWAYS_NAMESPACE_NAMES[normalized]) return true;
        return false;
    }

    function sourceFromTitle(title, namespaceInfo) {
        var nsName = getTitleNamespaceName(title);
        var normalizedNs = normalizeNamespaceName(nsName);
        var ns = null;

        if (namespaceInfo && namespaceInfo.idsByName && normalizedNs && namespaceInfo.idsByName.hasOwnProperty(normalizedNs)) {
            ns = namespaceInfo.idsByName[normalizedNs];
        }

        return {
            title: title,
            ns: ns,
            namespaceName: nsName,
            ctype: contentTypeForTitle(title),
            kind: (ns === 6 || normalizedNs === 'file' || normalizedNs === '파일') ? 'file' : 'page'
        };
    }

    function canonicalSourceKey(source) {
        var title = String(source && source.title ? source.title : '').replace(/_/g, ' ').trim();
        var body = getTitleBody(title).replace(/_/g, ' ').trim();
        var ns = source && source.ns != null ? String(source.ns) : '';
        var nsName = normalizeNamespaceName(source && source.namespaceName ? source.namespaceName : getTitleNamespaceName(title));

        if (!title) return '';

        /*
         * Namespace aliases can expose the same page twice during whole-wiki scan.
         * Example: MediaWiki:Common.css from the seed list and the localized namespace
         * title returned by allpages.  Deduplicate by namespace id + title body, not by
         * the literal namespace prefix, so those aliases do not become Common-2.css.
         */
        if (ns) {
            return 'ns:' + ns + ':' + body.toLowerCase();
        }

        return 'title:' + (nsName ? nsName + ':' : '') + body.toLowerCase();
    }

    function uniqueSources(sources) {
        var seen = {};
        var outPaths = {};
        var result = [];

        sources.forEach(function (source) {
            var key = canonicalSourceKey(source);
            var basePath;
            var path;
            var ext;
            var n;

            if (!key || seen[key]) return;
            seen[key] = true;

            source.ctype = source.ctype || contentTypeForTitle(source.title);
            source.kind = source.kind || (source.ns === 6 ? 'file' : 'page');
            basePath = outputPathForSource(source);
            path = basePath;
            n = 2;

            while (outPaths[path]) {
                ext = path.match(/(\.[^/.]+)$/);
                if (ext) {
                    path = basePath.slice(0, -ext[1].length) + '-' + n + ext[1];
                } else {
                    path = basePath + '-' + n;
                }
                n += 1;
            }

            outPaths[path] = true;
            source.out = path;
            result.push(source);
        });

        return result.sort(function (a, b) {
            return a.out.localeCompare(b.out);
        });
    }

    function getSourceNamespaces(api) {
        return api.get({
            action: 'query',
            meta: 'siteinfo',
            siprop: 'namespaces',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var raw = data && data.query ? data.query.namespaces : {};
            var list = Array.isArray(raw) ? raw : Object.keys(raw || {}).map(function (key) { return raw[key]; });
            var ids = [];
            var namesById = {};
            var idsByName = {};

            list.forEach(function (item) {
                var id = parseInt(item.id != null ? item.id : item.ns, 10);
                var name = item.name || item.canonical || '';
                if (!Number.isFinite(id) || id < 0) return;
                ids.push(id);
                namesById[id] = name;
                idsByName[normalizeNamespaceName(name)] = id;
                if (item.canonical) idsByName[normalizeNamespaceName(item.canonical)] = id;
            });

            ids.sort(function (a, b) { return a - b; });
            return {
                ids: ids,
                namesById: namesById,
                idsByName: idsByName
            };
        });
    }

    function fetchAllPagesInNamespace(api, ns) {
        var pages = [];

        function next(apcontinue) {
            var params = {
                action: 'query',
                list: 'allpages',
                apnamespace: ns,
                apfilterredir: 'nonredirects',
                aplimit: 'max',
                format: 'json',
                formatversion: 2
            };
            if (apcontinue) params.apcontinue = apcontinue;

            return api.get(params).then(function (data) {
                var chunk = data && data.query ? data.query.allpages : [];
                if (chunk && chunk.length) pages = pages.concat(chunk);
                if (data && data.continue && data.continue.apcontinue) {
                    return next(data.continue.apcontinue);
                }
                return pages;
            });
        }

        return next();
    }

    function scanSourcePages(api, onProgress) {
        return getSourceNamespaces(api).then(function (namespaceInfo) {
            var all = [];
            var chain = Promise.resolve();
            var index = 0;

            namespaceInfo.ids.forEach(function (ns) {
                chain = chain.then(function () {
                    index += 1;
                    if (onProgress) onProgress('스캔 중: namespace ' + ns + ' (' + index + '/' + namespaceInfo.ids.length + ')');
                    return fetchAllPagesInNamespace(api, ns).then(function (pages) {
                        pages.forEach(function (page) {
                            var ext = getTitleExtension(page.title);
                            var source;
                            if (!shouldExportSourcePage(page, namespaceInfo)) return;
                            source = {
                                title: page.title,
                                ns: page.ns,
                                namespaceName: namespaceInfo.namesById[page.ns] || getTitleNamespaceName(page.title),
                                ctype: contentTypeForTitle(page.title),
                                kind: isFileSourcePage(page, ext) ? 'file' : 'page'
                            };
                            all.push(source);
                        });
                    });
                });
            });

            return chain.then(function () {
                SOURCE_SEED_TITLES.forEach(function (title) {
                    all.push(sourceFromTitle(title, namespaceInfo));
                });
                return uniqueSources(all);
            });
        });
    }

    function mapLimit(items, limit, iterator) {
        var index = 0;
        var active = 0;
        var done = 0;
        var results = [];

        return new Promise(function (resolve) {
            function launch() {
                var current;
                while (active < limit && index < items.length) {
                    current = index;
                    index += 1;
                    active += 1;
                    Promise.resolve(iterator(items[current], current)).then(function (result) {
                        results[current] = result;
                    }).catch(function (err) {
                        results[current] = { error: err };
                    }).finally(function () {
                        active -= 1;
                        done += 1;
                        if (done >= items.length) {
                            resolve(results);
                        } else {
                            launch();
                        }
                    });
                }
                if (!items.length) resolve(results);
            }
            launch();
        });
    }
    function downloadBlob(blob, filename) {
        var url = URL.createObjectURL(blob);
        var a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        a.remove();
        setTimeout(function () {
            URL.revokeObjectURL(url);
        }, 1000);
    }


    function exportSources() {
        setButtonsDisabled(true);
        setStatus('위키 소스 스캔 준비 중...');

        loadScript(JSZIP_URL)
            .then(function () {
                return new Promise(function (resolve, reject) {
                    withApi(function (api) {
                        resolve(api);
                    }, function () {
                        reject(new Error('mediawiki.api 로드 실패'));
                    });
                });
            })
            .then(function (api) {
                var zip = new JSZip();
                var meta = [];
                var errors = [];

                return scanSourcePages(api, setStatus).then(function (sources) {
                    setStatus('소스 수집 중: 0/' + sources.length);
                    return mapLimit(sources, 6, function (file, index) {
                        var fetcher = file.kind === 'file' ? fetchFileBlob(api, file) : fetchRaw(file);
                        return fetcher.then(function (payload) {
                            zip.file(file.out, payload);
                            meta.push({
                                title: file.title,
                                out: file.out,
                                kind: file.kind,
                                ns: file.ns,
                                ctype: file.ctype
                            });
                            if ((index + 1) % 5 === 0 || index + 1 === sources.length) {
                                setStatus('소스 수집 중: ' + (index + 1) + '/' + sources.length);
                            }
                        }).catch(function (err) {
                            var message = String(err && err.message ? err.message : err);
                            var errorPath = '_errors/' + sanitizeTitlePath(file.title) + '.ERROR.txt';
                            zip.file(errorPath, message);
                            errors.push({
                                title: file.title,
                                out: file.out,
                                error: message
                            });
                        });
                    }).then(function () {
                        var sortedFiles = meta.slice().sort(function (a, b) { return a.out.localeCompare(b.out); });
                        var readme = 'CLBI Wiki source export\n' +
                            'Generated: ' + new Date().toISOString() + '\n' +
                            'Mode: whole-wiki source scan\n' +
                            'Included: ' + sortedFiles.length + '\n' +
                            'Errors: ' + errors.length + '\n\n' +
                            'Scope:\n' +
                            '- CSS / JS pages in any namespace\n' +
                            '- JSON / GeoJSON / TopoJSON and other source files, excluding SVG\n' +
                            '- MediaWiki / Template / Module / Manage namespace source pages\n' +
                            '- Source-like files in File namespace, including GeoJSON/GPKG/shapefile sidecars when present\n' +
                            '- Seeded known CLBI source pages are added even if namespace scan misses them\n\n' +
                            'Files:\n' + sortedFiles.map(function (item) {
                                return item.out + ' <= ' + item.title;
                            }).join('\n') + '\n';

                        zip.file('README.txt', readme);
                        zip.file('manifest.json', JSON.stringify({
                            generated: new Date().toISOString(),
                            mode: 'whole-wiki-source-scan',
                            included: sortedFiles.length,
                            errors: errors,
                            files: sortedFiles
                        }, null, 2) + '\n');

                        setStatus('ZIP 압축 중...');
                        return zip.generateAsync({ type: 'blob' });
                    });
                });
            })
            .then(function (blob) {
                var stamp = new Date().toISOString().replace(/[:.]/g, '-');
                downloadBlob(blob, 'clbiwiki-source-' + stamp + '.zip');
                setStatus('ZIP 다운로드 완료', 'ok');
            })
            .catch(function (err) {
                setStatus('ZIP 실패: ' + (err && err.message ? err.message : err), 'error');
            })
            .finally(function () {
                setButtonsDisabled(false);
            });
    }


    var PACKAGE_INSTALL_PLAN = null;

    function readPackageFileAsArrayBuffer(file) {
        return new Promise(function (resolve, reject) {
            var reader = new FileReader();
            reader.onload = function () { resolve(reader.result); };
            reader.onerror = function () { reject(reader.error || new Error('ZIP 읽기 실패')); };
            reader.readAsArrayBuffer(file);
        });
    }

    function normalizePackagePath(path) {
        return String(path || '')
            .replace(/\\/g, '/')
            .replace(/^\/+/, '')
            .replace(/\/+/g, '/')
            .trim();
    }

    function getPackageBasename(path) {
        var clean = normalizePackagePath(path);
        var parts = clean.split('/');
        return parts[parts.length - 1] || clean;
    }

    function stripTextLikeExtension(name) {
        return String(name || '').replace(/\.(txt|wiki|wikitext)$/i, '');
    }

    function titleFromScriptOrStylePath(path, prefix) {
        var base = getPackageBasename(path);
        return 'MediaWiki:' + base.replace(/\s+/g, '_');
    }

    function titleFromMediaWikiPath(path) {
        var base = getPackageBasename(path);
        return 'MediaWiki:' + base.replace(/\s+/g, '_');
    }

    function titleFromFilePath(path) {
        var base = getPackageBasename(path).replace(/_/g, ' ').trim();
        return base ? 'File:' + base : '';
    }

    function titleFromPagePath(path) {
        var base = stripTextLikeExtension(getPackageBasename(path));
        return base.replace(/_/g, ' ').trim();
    }

    function titleFromManagePath(path) {
        var base = stripTextLikeExtension(getPackageBasename(path));
        base = base.replace(/_/g, ' ').trim();
        return base.indexOf('Manage:') === 0 || base.indexOf('관리:') === 0 ? base : 'Manage:' + base;
    }

    function titleFromModulePath(path) {
        var base = stripTextLikeExtension(getPackageBasename(path));
        base = base.replace(/_/g, ' ').trim();
        return base.indexOf('Module:') === 0 || base.indexOf('모듈:') === 0 ? base : 'Module:' + base;
    }

    function titleFromTemplatePath(path) {
        var base = stripTextLikeExtension(getPackageBasename(path));
        base = base.replace(/_/g, ' ').trim();
        return base.indexOf('Template:') === 0 || base.indexOf('틀:') === 0 ? base : 'Template:' + base;
    }

    function inferPackageTarget(path) {
        var clean = normalizePackagePath(path);
        var lower = clean.toLowerCase();
        var ext = getTitleExtension(clean);

        if (!clean || lower === 'manifest.json' || lower === 'readme.txt' || lower.indexOf('report/') === 0) {
            return { skip: true, reason: '보고서/메타 파일' };
        }
        if (lower.indexOf('server/') === 0 || lower === 'localsettings.php' || ext === 'php') {
            return { skip: true, reason: '서버/PHP 파일은 브라우저 인스톨러에서 제외' };
        }
        if (lower.indexOf('__macosx/') === 0 || /(^|\/)\.ds_store$/i.test(clean)) {
            return { skip: true, reason: '시스템 메타 파일' };
        }
        if (lower.indexOf('js/') === 0 || (clean.indexOf('/') === -1 && ext === 'js')) {
            return { target: titleFromScriptOrStylePath(clean), kind: 'js' };
        }
        if (lower.indexOf('css/') === 0 || (clean.indexOf('/') === -1 && ext === 'css')) {
            return { target: titleFromScriptOrStylePath(clean), kind: 'css' };
        }
        if (lower.indexOf('mediawiki/') === 0) {
            return { target: titleFromMediaWikiPath(clean), kind: ext || 'text' };
        }
        if (lower.indexOf('file/') === 0 || lower.indexOf('files/') === 0) {
            return { target: titleFromFilePath(clean), kind: 'file' };
        }
        if (lower.indexOf('page/') === 0 || lower.indexOf('pages/') === 0) {
            return { target: titleFromPagePath(clean), kind: 'wikitext' };
        }
        if (lower.indexOf('manage/') === 0) {
            return { target: titleFromManagePath(clean), kind: 'wikitext' };
        }
        if (lower.indexOf('module/') === 0 || lower.indexOf('modules/') === 0) {
            return { target: titleFromModulePath(clean), kind: 'module' };
        }
        if (lower.indexOf('template/') === 0 || lower.indexOf('templates/') === 0) {
            return { target: titleFromTemplatePath(clean), kind: 'wikitext' };
        }
        if (SOURCE_TEXT_EXTENSIONS[ext]) {
            return { skip: true, reason: '대상 문서를 추론할 수 없음' };
        }
        return { skip: true, reason: '비텍스트/미지원 파일' };
    }

    function getPackageContentModel(title, kind) {
        var ext = getTitleExtension(title);
        if (kind === 'js' || ext === 'js') return 'javascript';
        if (kind === 'css' || ext === 'css') return 'css';
        if (kind === 'json' || ext === 'json') return 'json';
        if (kind === 'geojson' || ext === 'geojson') return 'json';
        if (kind === 'module') return 'Scribunto';
        return null;
    }

    function isPackageFileUploadItem(item) {
        var target = String(item && item.target ? item.target : '').trim();
        return !!item && (item.kind === 'file' || /^(file|파일):/i.test(target));
    }

    function packageUploadFilename(target) {
        var title = String(target || '').replace(/_/g, ' ').trim();
        return title.replace(/^(file|파일):\s*/i, '').trim();
    }

    function packageUploadMimeType(item) {
        var ext = getTitleExtension((item && item.source) || (item && item.target) || '');
        return SOURCE_TEXT_EXTENSIONS[ext] || 'application/octet-stream';
    }

    function readPackageItemBlob(item) {
        return item.entry.async('blob').then(function (blob) {
            var type = packageUploadMimeType(item);
            if (blob && blob.type) return blob;
            try {
                return blob.slice(0, blob.size, type);
            } catch (err) {
                return blob;
            }
        });
    }

    function packageDelay(ms) {
        return new Promise(function (resolve) {
            window.setTimeout(resolve, Math.max(0, ms || 0));
        });
    }

    function arrayBufferToHex(buffer) {
        var bytes = new Uint8Array(buffer);
        var out = [];
        var i;

        for (i = 0; i < bytes.length; i += 1) {
            out.push(('0' + bytes[i].toString(16)).slice(-2));
        }

        return out.join('');
    }

    function sha1Blob(blob) {
        if (!window.crypto || !window.crypto.subtle || !blob || typeof blob.arrayBuffer !== 'function') {
            return Promise.resolve('');
        }

        return blob.arrayBuffer().then(function (buffer) {
            return window.crypto.subtle.digest('SHA-1', buffer);
        }).then(arrayBufferToHex).catch(function () {
            return '';
        });
    }

    function fetchPackageFileInfo(api, target) {
        var filename = packageUploadFilename(target);

        if (!filename) {
            return Promise.resolve({
                title: target || '',
                missing: true,
                sha1: '',
                size: 0,
                timestamp: '',
                url: '',
                mime: ''
            });
        }

        return api.get({
            action: 'query',
            prop: 'imageinfo|info',
            titles: 'File:' + filename,
            iiprop: 'url|sha1|timestamp|size|mime',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var pages = data && data.query ? data.query.pages : [];
            var page = pages && pages.length ? pages[0] : null;
            var info = page && page.imageinfo && page.imageinfo[0] ? page.imageinfo[0] : null;

            return {
                title: page && page.title ? page.title : ('File:' + filename),
                missing: !page || !!page.missing || !info,
                sha1: info && info.sha1 ? String(info.sha1) : '',
                size: info && info.size ? Number(info.size) : 0,
                timestamp: info && info.timestamp ? String(info.timestamp) : '',
                url: info && info.url ? String(info.url) : '',
                mime: info && info.mime ? String(info.mime) : ''
            };
        });
    }

    function fetchPackageFileInfoUntil(api, target, expectedSha1, previousSha1) {
        var tries = 0;

        function once() {
            tries += 1;
            return fetchPackageFileInfo(api, target).then(function (info) {
                if (!expectedSha1 || info.sha1 === expectedSha1 || tries >= 4) return info;
                if (previousSha1 && info.sha1 && info.sha1 !== previousSha1) return info;
                return packageDelay(450 * tries).then(once);
            });
        }

        return once();
    }

    function collectUploadWarnings(data) {
        var out = [];
        var uploadWarnings = data && data.upload && data.upload.warnings ? data.upload.warnings : null;
        var warnings = data && data.warnings ? data.warnings : null;

        function pushObject(prefix, obj) {
            Object.keys(obj || {}).forEach(function (key) {
                var value = obj[key];
                var text = '';

                if (value == null) {
                    text = key;
                } else if (typeof value === 'string') {
                    text = key + ': ' + value;
                } else if (value.warning) {
                    text = key + ': ' + value.warning;
                } else if (value['*']) {
                    text = key + ': ' + value['*'];
                } else {
                    try {
                        text = key + ': ' + JSON.stringify(value);
                    } catch (err) {
                        text = key;
                    }
                }

                out.push((prefix ? prefix + ' ' : '') + text);
            });
        }

        pushObject('', uploadWarnings);
        pushObject('api', warnings);

        return out;
    }

    function compactSha(value) {
        value = String(value || '');
        return value ? value.slice(0, 8) : '-';
    }

    function uploadPackageFileItem(api, item, summary) {
        var filename = packageUploadFilename(item.target);

        if (!filename) {
            return Promise.reject(new Error('파일 업로드 대상명을 확인할 수 없음: ' + item.target));
        }

        return readPackageItemBlob(item).then(function (blob) {
            var localSha1 = '';
            var beforeInfo = null;

            return Promise.all([
                sha1Blob(blob),
                fetchPackageFileInfo(api, item.target).catch(function () { return null; })
            ]).then(function (pre) {
                var form = new FormData();

                localSha1 = pre[0] || '';
                beforeInfo = pre[1] || null;

                return api.getToken('csrf').then(function (token) {
                    form.append('action', 'upload');
                    form.append('filename', filename);
                    form.append('file', blob, filename);
                    form.append('ignorewarnings', '1');
                    form.append('comment', summary || ('Install package file: ' + item.source));
                    form.append('format', 'json');
                    form.append('formatversion', '2');
                    form.append('errorformat', 'plaintext');
                    form.append('token', token);

                    return $.ajax({
                        url: mw.util.wikiScript('api'),
                        method: 'POST',
                        data: form,
                        processData: false,
                        contentType: false
                    });
                });
            }).then(function (data) {
                var result = data && data.upload ? data.upload.result : '';
                var warnings = collectUploadWarnings(data);

                if (data && data.error) {
                    throw new Error(data.error.info || data.error.code || '파일 업로드 실패');
                }
                if (result && result !== 'Success' && result !== 'Warning') {
                    throw new Error('파일 업로드 실패: ' + result);
                }

                return fetchPackageFileInfoUntil(api, item.target, localSha1, beforeInfo && beforeInfo.sha1 ? beforeInfo.sha1 : '').then(function (afterInfo) {
                    var verified = !!(localSha1 && afterInfo && afterInfo.sha1 === localSha1);
                    var beforeSha = beforeInfo && beforeInfo.sha1 ? beforeInfo.sha1 : '';
                    var afterSha = afterInfo && afterInfo.sha1 ? afterInfo.sha1 : '';
                    var changed = !!(afterSha && beforeSha && afterSha !== beforeSha);
                    var sameContent = !!(localSha1 && beforeSha && beforeSha === localSha1 && afterSha === localSha1);

                    if (localSha1 && afterSha && afterSha !== localSha1) {
                        throw new Error('파일 업로드 검증 실패: 업로드 후 SHA가 ZIP 내용과 다름 (' + compactSha(afterSha) + ' ≠ ' + compactSha(localSha1) + ')');
                    }

                    return {
                        data: data,
                        before: beforeInfo,
                        after: afterInfo,
                        localSha1: localSha1,
                        verified: verified,
                        changed: changed,
                        sameContent: sameContent,
                        warnings: warnings
                    };
                });
            });
        });
    }

    function normalizePackageManifestFileEntry(entry) {
        if (!entry || typeof entry !== 'object') return null;
        var source = normalizePackagePath(entry.source || entry.path || entry.out || '');
        var target = String(entry.target || entry.title || '').trim();
        if (!source || !target) return null;
        return {
            source: source,
            target: target,
            kind: entry.type || entry.kind || (/^(file|파일):/i.test(target) ? 'file' : getTitleExtension(source) || 'text'),
            fromManifest: true
        };
    }

    function parsePackageManifest(zip) {
        var file = zip.file('manifest.json');
        if (!file) return Promise.resolve({ files: [] });
        return file.async('string').then(function (text) {
            var data;
            try {
                data = JSON.parse(text || '{}');
            } catch (err) {
                throw new Error('manifest.json 파싱 실패: ' + err.message);
            }
            var files = Array.isArray(data.files) ? data.files.map(normalizePackageManifestFileEntry).filter(Boolean) : [];
            return {
                name: data.package || data.name || data.mode || '',
                files: files
            };
        });
    }

    function buildPackagePlanFromZip(zip, fileName) {
        return parsePackageManifest(zip).then(function (manifest) {
            var manifestMap = {};
            var items = [];
            var skipped = [];
            var seenTargets = {};

            (manifest.files || []).forEach(function (entry) {
                manifestMap[normalizePackagePath(entry.source)] = entry;
            });

            Object.keys(zip.files).sort().forEach(function (rawPath) {
                var zipEntry = zip.files[rawPath];
                var path = normalizePackagePath(rawPath);
                var mapped;

                if (!path || zipEntry.dir) return;

                if (manifestMap[path]) {
                    mapped = manifestMap[path];
                } else {
                    mapped = inferPackageTarget(path);
                }

                if (!mapped || mapped.skip || !mapped.target) {
                    skipped.push({ source: path, reason: mapped && mapped.reason ? mapped.reason : '적용 제외' });
                    return;
                }

                if (seenTargets[mapped.target]) {
                    skipped.push({ source: path, reason: '대상 중복: ' + mapped.target });
                    return;
                }
                seenTargets[mapped.target] = true;

                items.push({
                    source: path,
                    target: mapped.target,
                    kind: mapped.kind || getTitleExtension(path) || 'text',
                    entry: zipEntry,
                    fromManifest: !!mapped.fromManifest
                });
            });

            return {
                zip: zip,
                fileName: fileName || 'package.zip',
                packageName: manifest.name || String(fileName || 'package.zip').replace(/\.zip$/i, ''),
                items: items,
                skipped: skipped
            };
        });
    }

    function renderPackagePlan(plan) {
        var html;
        var skipped;

        PACKAGE_INSTALL_PLAN = plan || null;
        $('#dev-tools-package-install').prop('disabled', !(plan && plan.items && plan.items.length));

        if (!plan) {
            $('#dev-tools-package-summary').text('ZIP을 선택하면 적용 대상이 표시됩니다.');
            $('#dev-tools-package-list').empty();
            return;
        }

        skipped = plan.skipped || [];
        $('#dev-tools-package-summary').text(
            'PACKAGE: ' + plan.packageName + ' / 적용 ' + plan.items.length + '개 / 제외 ' + skipped.length + '개'
        );

        html = '<div class="dev-tools-package-subtitle">적용 대상</div>' +
            (plan.items.length ? plan.items.map(function (item) {
                var mode = isPackageFileUploadItem(item) ? ' <b>[파일 업로드 · 중복 강행 · SHA 검증]</b>' : '';
                return '<div class="dev-tools-package-row">' +
                    '<span class="dev-tools-package-source">' + escapeHtml(item.source) + '</span>' +
                    '<span class="dev-tools-package-arrow">→</span>' +
                    '<span class="dev-tools-package-target">' + escapeHtml(item.target) + mode + '</span>' +
                '</div>';
            }).join('') : '<div class="dev-tools-package-empty">적용할 파일이 없습니다.</div>');

        if (plan.installResults && plan.installResults.length) {
            html += '<div class="dev-tools-package-subtitle">최근 적용 결과</div>' + plan.installResults.map(function (item) {
                var ok = item.installed || item.skipped;
                var status = item.error || item.detail || item.skipped || 'OK';
                return '<div class="dev-tools-package-row' + (ok ? '' : ' dev-tools-package-skipped') + '">' +
                    '<span class="dev-tools-package-source">' + escapeHtml(item.source || item.target || '') + '</span>' +
                    '<span class="dev-tools-package-arrow">' + (ok ? '✓' : '×') + '</span>' +
                    '<span class="dev-tools-package-target">' + escapeHtml(status) + '</span>' +
                '</div>';
            }).join('');
        }

        if (skipped.length) {
            html += '<div class="dev-tools-package-subtitle">제외</div>' + skipped.slice(0, 30).map(function (item) {
                return '<div class="dev-tools-package-row dev-tools-package-skipped">' +
                    '<span class="dev-tools-package-source">' + escapeHtml(item.source) + '</span>' +
                    '<span class="dev-tools-package-arrow">×</span>' +
                    '<span class="dev-tools-package-target">' + escapeHtml(item.reason) + '</span>' +
                '</div>';
            }).join('');
            if (skipped.length > 30) {
                html += '<div class="dev-tools-package-empty">외 ' + (skipped.length - 30) + '개 제외</div>';
            }
        }

        $('#dev-tools-package-list').html(html);
    }

    function analyzeSelectedPackage() {
        var input = document.getElementById('dev-tools-package-file');
        var file = input && input.files && input.files[0] ? input.files[0] : null;

        if (!file) {
            setStatus('ZIP 파일을 먼저 선택하세요.', 'error');
            return;
        }

        setButtonsDisabled(true);
        setStatus('패키지 분석 중...');
        $('#dev-tools-package-install').prop('disabled', true);

        loadScript(JSZIP_URL)
            .then(function () { return readPackageFileAsArrayBuffer(file); })
            .then(function (buffer) { return JSZip.loadAsync(buffer); })
            .then(function (zip) { return buildPackagePlanFromZip(zip, file.name); })
            .then(function (plan) {
                renderPackagePlan(plan);
                setStatus('패키지 분석 완료: 적용 ' + plan.items.length + '개 / 제외 ' + plan.skipped.length + '개', plan.items.length ? 'ok' : 'error');
            })
            .catch(function (err) {
                PACKAGE_INSTALL_PLAN = null;
                renderPackagePlan(null);
                setStatus('패키지 분석 실패: ' + (err && err.message ? err.message : err), 'error');
            })
            .finally(function () {
                setButtonsDisabled(false);
                $('#dev-tools-package-install').prop('disabled', !(PACKAGE_INSTALL_PLAN && PACKAGE_INSTALL_PLAN.items.length));
            });
    }

    function fetchPackageTargetPage(api, title) {
        return api.get({
            action: 'query',
            prop: 'revisions|info',
            titles: normalizeApiTitle(title),
            rvprop: 'content|ids|timestamp',
            rvslots: 'main',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var pages = data && data.query ? data.query.pages : [];
            var page = pages && pages.length ? pages[0] : null;
            var text = '';

            if (!page || page.missing) {
                return {
                    title: title,
                    missing: true,
                    text: '',
                    ns: page ? page.ns : null,
                    contentmodel: page ? page.contentmodel : '',
                    revid: null
                };
            }

            if (page.revisions && page.revisions[0]) {
                if (page.revisions[0].slots && page.revisions[0].slots.main) {
                    text = page.revisions[0].slots.main.content || '';
                } else {
                    text = page.revisions[0].content || '';
                }
            }

            return {
                title: page.title || title,
                missing: false,
                text: text,
                ns: page.ns,
                contentmodel: page.contentmodel || '',
                revid: page.revisions && page.revisions[0] ? page.revisions[0].revid : null
            };
        });
    }

    function readPackageItemText(item) {
        return item.entry.async('string').then(function (text) {
            return String(text || '').replace(/^\uFEFF/, '');
        });
    }

    function makePackageBackupZip(plan, pages) {
        var zip = new JSZip();
        var files = [];
        var stamp = new Date().toISOString();

        pages.forEach(function (page) {
            var title = page.title || page.requestedTitle || '';
            var path = 'backup/' + sanitizeTitlePath(title) + '.txt';
            zip.file(path, page.missing ? '' : page.text);
            files.push({
                title: title,
                path: path,
                missing: !!page.missing,
                revid: page.revid || null
            });
        });

        zip.file('manifest.json', JSON.stringify({
            generated: stamp,
            mode: 'package-install-backup',
            package: plan.packageName,
            files: files
        }, null, 2) + '\n');

        zip.file('README.txt', 'CLBI package installer backup\nGenerated: ' + stamp + '\nPackage: ' + plan.packageName + '\n\n이 ZIP은 PACKAGE INSTALLER 적용 직전에 대상 문서들의 기존 내용을 저장한 백업입니다.\n');

        return zip.generateAsync({ type: 'blob' });
    }

    function installPackagePlan() {
        var plan = PACKAGE_INSTALL_PLAN;

        if (!plan || !plan.items || !plan.items.length) {
            setStatus('분석된 패키지가 없습니다.', 'error');
            return;
        }

        if (!window.confirm('패키지 적용 전 백업 ZIP을 내려받은 뒤 ' + plan.items.length + '개 문서를 수정합니다. 계속할까요?')) {
            return;
        }

        setButtonsDisabled(true);
        setStatus('패키지 적용 준비 중...');

        loadScript(JSZIP_URL)
            .then(function () {
                return new Promise(function (resolve, reject) {
                    withApi(resolve, function () { reject(new Error('mediawiki.api 로드 실패')); });
                });
            })
            .then(function (api) {
                var pagesByTarget = {};
                var targets = plan.items.map(function (item) { return item.target; });
                var installed = 0;
                var skippedSame = 0;
                var failed = 0;
                var results = [];

                setStatus('현재 문서 백업 수집 중...');

                return mapLimit(targets, 4, function (title) {
                    return fetchPackageTargetPage(api, title).then(function (page) {
                        page.requestedTitle = title;
                        pagesByTarget[title] = page;
                        return page;
                    });
                }).then(function (pages) {
                    setStatus('백업 ZIP 생성 중...');
                    return makePackageBackupZip(plan, pages).then(function (blob) {
                        var stamp = new Date().toISOString().replace(/[:.]/g, '-');
                        downloadBlob(blob, 'clbiwiki-package-backup-' + sanitizePathPart(plan.packageName) + '-' + stamp + '.zip');
                    });
                }).then(function () {
                    setStatus('패키지 적용 중... 0/' + plan.items.length);
                    return plan.items.reduce(function (chain, item, index) {
                        return chain.then(function () {
                            var page = pagesByTarget[item.target] || { title: item.target, missing: true, text: '', revid: null };
                            var contentModel = getPackageContentModel(item.target, item.kind);
                            var summary = 'Install package: ' + plan.packageName + ' / ' + item.source;

                            if (isPackageFileUploadItem(item)) {
                                return uploadPackageFileItem(api, item, summary).then(function (uploadResult) {
                                    var beforeSha = uploadResult.before && uploadResult.before.sha1 ? uploadResult.before.sha1 : '';
                                    var afterSha = uploadResult.after && uploadResult.after.sha1 ? uploadResult.after.sha1 : '';
                                    var detail = '파일 업로드 검증됨: ' + compactSha(beforeSha) + ' → ' + compactSha(afterSha);

                                    if (uploadResult.sameContent) {
                                        detail += ' / ZIP과 기존 파일 동일';
                                    } else if (uploadResult.changed) {
                                        detail += ' / 본체 교체됨';
                                    }
                                    if (uploadResult.warnings && uploadResult.warnings.length) {
                                        detail += ' / 경고 강행: ' + uploadResult.warnings.slice(0, 2).join(' | ');
                                    }

                                    installed += 1;
                                    results.push({ target: item.target, source: item.source, installed: true, uploaded: true, detail: detail });
                                    setStatus('패키지 적용 중... ' + (index + 1) + '/' + plan.items.length + ' / 파일 검증 ' + compactSha(afterSha));
                                }).catch(function (err) {
                                    failed += 1;
                                    results.push({
                                        target: item.target,
                                        source: item.source,
                                        error: err && err.message ? err.message : String(err)
                                    });
                                    setStatus('패키지 적용 중... ' + (index + 1) + '/' + plan.items.length + ' / 실패 ' + failed);
                                });
                            }

                            return readPackageItemText(item).then(function (nextText) {
                                var payload;

                                if (!page.missing && String(page.text || '') === String(nextText || '')) {
                                    skippedSame += 1;
                                    results.push({ target: item.target, source: item.source, skipped: 'same' });
                                    setStatus('패키지 적용 중... ' + (index + 1) + '/' + plan.items.length);
                                    return;
                                }

                                payload = {
                                    action: 'edit',
                                    title: item.target,
                                    text: nextText,
                                    summary: summary,
                                    format: 'json',
                                    formatversion: 2,
                                    errorformat: 'plaintext'
                                };

                                if (!page.missing && page.revid) payload.baserevid = page.revid;
                                if (contentModel) payload.contentmodel = contentModel;

                                return api.postWithToken('csrf', payload).then(function () {
                                    installed += 1;
                                    results.push({ target: item.target, source: item.source, installed: true });
                                    setStatus('패키지 적용 중... ' + (index + 1) + '/' + plan.items.length);
                                });
                            }).catch(function (err) {
                                failed += 1;
                                results.push({
                                    target: item.target,
                                    source: item.source,
                                    error: err && err.message ? err.message : String(err)
                                });
                                setStatus('패키지 적용 중... ' + (index + 1) + '/' + plan.items.length + ' / 실패 ' + failed);
                            });
                        });
                    }, Promise.resolve());
                }).then(function () {
                    var message = '패키지 적용 완료: 수정 ' + installed + ' / 동일 ' + skippedSame + ' / 실패 ' + failed;
                    plan.installResults = results.slice();
                    setStatus(message, failed ? 'error' : 'ok');
                    renderPackagePlan(plan);
                });
            })
            .catch(function (err) {
                setStatus('패키지 적용 실패: ' + (err && err.message ? err.message : err), 'error');
            })
            .finally(function () {
                setButtonsDisabled(false);
                $('#dev-tools-package-install').prop('disabled', !(PACKAGE_INSTALL_PLAN && PACKAGE_INSTALL_PLAN.items.length));
            });
    }


    function withApi(done, fail) {
        mw.loader.using(['mediawiki.api']).then(function () {
            done(new mw.Api());
        }, function () {
            if (typeof fail === 'function') fail();
        });
    }

    function updateReadout(summary) {
        var $box = $('#dev-tools-progress-readout');
        if (!summary) {
            $box.text('NO DATA');
            return;
        }

        $box.html(
            '<span>' + (summary.isMaxLevel ? 'MAX ' : 'LVL ') + escapeHtml(summary.level) + '</span>' +
            '<span>' + escapeHtml(summary.totalXp) + ' XP</span>' +
            '<span>TODAY ' + escapeHtml(summary.dailyXp) + ' XP</span>' +
            '<span>READ ' + escapeHtml(summary.dailyReadCount) + '</span>' +
            '<span>DISC ' + escapeHtml(summary.discoveryCount) + '</span>'
        );
    }

    function refreshProgressSummary() {
        withApi(function (api) {
            api.get({
                action: 'progress_summary',
                format: 'json',
                formatversion: 2
            }).then(function (data) {
                var payload = data && data.progress_summary;
                if (payload && payload.available && payload.summary) {
                    updateReadout(payload.summary);
                    if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.requestSummary === 'function') {
                        window.ProgressSystemWebUi.requestSummary(true);
                    }
                    setStatus('진행도 동기화 완료', 'ok');
                } else {
                    updateReadout(null);
                    setStatus('진행도 없음', 'error');
                }
            }).catch(function () {
                setStatus('동기화 실패', 'error');
            });
        }, function () {
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }

    function runProgressAdmin(operation, extra) {
        extra = extra || {};
        setButtonsDisabled(true);
        setStatus('요청 처리 중...');

        withApi(function (api) {
            var payload = $.extend({
                action: 'progress_admin',
                format: 'json',
                formatversion: 2,
                errorformat: 'plaintext',
                operation: operation
            }, extra);

            api.postWithToken('csrf', payload).then(function (data) {
                var result = data && data.progress_admin;
                if (!result || !result.ok) {
                    setStatus('실패: ' + (result && result.reason ? result.reason : 'unknown'), 'error');
                    return;
                }

                if (result.summary) {
                    updateReadout(result.summary);

                    if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.applySummary === 'function') {
                        window.ProgressSystemWebUi.applySummary(result.summary, {
                            animateGain: operation === 'add_xp'
                        });
                    } else if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.requestSummary === 'function') {
                        window.ProgressSystemWebUi.requestSummary();
                    }
                }

                setStatus('완료: ' + operation, 'ok');
            }).catch(function (code) {
                setStatus('API 실패: ' + code, 'error');
            }).always(function () {
                setButtonsDisabled(false);
            });
        }, function () {
            setButtonsDisabled(false);
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }


    function normalizePageName(value) {
        return String(value || '')
            .split('?')[0]
            .replace(/^\/index\.php\//, '')
            .replace(/_/g, ' ')
            .trim();
    }

    function getLanguageStatusPageKey() {
        var raw = String(mw.config.get('wgPageName') || '').trim();
        var normalized = normalizePageName(raw);
        return normalized || raw || '대문';
    }

    function normalizeLanguageStatusValue(value) {
        value = String(value == null ? '' : value).toLowerCase().trim();
        return LANGUAGE_STATUS_VALUES[value] ? value : '';
    }

    function getLanguageTargetTitle(lang) {
        var data = document.getElementById('clbi-lang-data');
        var keys;
        var i;
        var value;

        if (!data || !lang) return '';

        keys = [
            'data-' + lang,
            'data-page-' + lang,
            'data-title-' + lang,
            'data-target-' + lang,
            'data-lang-' + lang
        ];

        for (i = 0; i < keys.length; i += 1) {
            value = data.getAttribute(keys[i]);
            if (value) return value;
        }

        return '';
    }

    function getLanguageStatusPages(registry) {
        if (!registry || typeof registry !== 'object') return {};
        if (registry.pages && typeof registry.pages === 'object') return registry.pages;
        return registry;
    }

    function readLanguageStatusEntry(registry, pageKey) {
        var pages = getLanguageStatusPages(registry);
        var underscored = String(pageKey || '').replace(/ /g, '_');

        if (pages[pageKey] && typeof pages[pageKey] === 'object') return pages[pageKey];
        if (pages[underscored] && typeof pages[underscored] === 'object') return pages[underscored];

        return {};
    }

    function fetchLanguageStatusRegistry(api) {
        return api.get({
            action: 'query',
            prop: 'revisions',
            titles: LANGUAGE_STATUS_TITLE,
            rvprop: 'content',
            rvslots: 'main',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var pages = data && data.query ? data.query.pages : [];
            var page = pages && pages.length ? pages[0] : null;
            var text = '';

            if (!page || page.missing) return {};

            if (page.revisions && page.revisions[0]) {
                if (page.revisions[0].slots && page.revisions[0].slots.main) {
                    text = page.revisions[0].slots.main.content || '';
                } else {
                    text = page.revisions[0].content || '';
                }
            }

            if (!text) return {};

            try {
                return JSON.parse(text);
            } catch (err) {
                setStatus('LanguageStatus JSON 파싱 실패', 'error');
                return {};
            }
        });
    }

    function renderLanguageStatusEditor(registry) {
        var pageKey = getLanguageStatusPageKey();
        var entry = readLanguageStatusEntry(registry, pageKey);
        var currentLang = (document.getElementById('clbi-lang-data') || {}).getAttribute
            ? (document.getElementById('clbi-lang-data').getAttribute('data-lang') || 'ko')
            : 'ko';
        var html = '';

        $('#dev-tools-language-page').text('PAGE: ' + pageKey);

        LANGUAGE_ORDER.forEach(function (lang) {
            var stored = normalizeLanguageStatusValue(entry[lang]);
            var fallback = getLanguageTargetTitle(lang) ? 'available' : 'unavailable';
            var value = stored || fallback;
            var isCurrent = lang === currentLang;

            html += '' +
                '<div class="dev-tools-language-row" data-lang="' + escapeHtml(lang) + '">' +
                    '<span class="dev-tools-language-name">' + escapeHtml(LANGUAGE_LABELS[lang] || lang.toUpperCase()) + (isCurrent ? ' *' : '') + '</span>' +
                    '<select class="dev-tools-select dev-tools-language-select" data-lang="' + escapeHtml(lang) + '">' +
                        '<option value="available"' + (value === 'available' ? ' selected' : '') + '>AVAILABLE</option>' +
                        '<option value="wip"' + (value === 'wip' ? ' selected' : '') + '>WIP</option>' +
                        '<option value="unavailable"' + (value === 'unavailable' ? ' selected' : '') + '>UNAVAILABLE</option>' +
                    '</select>' +
                '</div>';
        });

        $('#dev-tools-language-list').html(html || 'NO DATA');
    }

    function refreshLanguageStatusEditor() {
        withApi(function (api) {
            setStatus('언어 상태 불러오는 중...');
            fetchLanguageStatusRegistry(api).then(function (registry) {
                renderLanguageStatusEditor(registry || {});
                setStatus('언어 상태 로드 완료', 'ok');
            }).catch(function () {
                renderLanguageStatusEditor({});
                setStatus('언어 상태 로드 실패', 'error');
            });
        }, function () {
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }

    function saveLanguageStatusEditor() {
        withApi(function (api) {
            var pageKey = getLanguageStatusPageKey();

            setButtonsDisabled(true);
            setStatus('언어 상태 저장 중...');

            fetchLanguageStatusRegistry(api).then(function (registry) {
                var pages = getLanguageStatusPages(registry);
                var nextRegistry = registry && typeof registry === 'object' ? registry : {};
                var entry = {};

                if (nextRegistry.pages && typeof nextRegistry.pages === 'object') {
                    pages = nextRegistry.pages;
                } else {
                    pages = nextRegistry;
                }

                $('.dev-tools-language-select').each(function () {
                    var lang = $(this).data('lang');
                    var value = normalizeLanguageStatusValue($(this).val());

                    if (lang && value) {
                        entry[lang] = value;
                    }
                });

                pages[pageKey] = entry;

                return api.postWithToken('csrf', {
                    action: 'edit',
                    title: LANGUAGE_STATUS_TITLE,
                    text: JSON.stringify(nextRegistry, null, 2) + '\n',
                    summary: 'Update language status: ' + pageKey,
                    contentmodel: 'json',
                    format: 'json',
                    formatversion: 2
                });
            }).then(function () {
                setStatus('언어 상태 저장 완료', 'ok');

                if (window.CLBI_LANGUAGE_STATUS && typeof window.CLBI_LANGUAGE_STATUS.reload === 'function') {
                    window.CLBI_LANGUAGE_STATUS.reload();
                }
            }).catch(function (err) {
                setStatus('언어 상태 저장 실패: ' + (err && err.message ? err.message : err), 'error');
            }).always(function () {
                setButtonsDisabled(false);
            });
        }, function () {
            setButtonsDisabled(false);
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }


    function getDefaultDecorationTarget() {
        if (document.querySelector('.clbi-nations-panel-stack')) return '.clbi-nations-panel-stack';
        if (document.querySelector('.clbi-nations-board')) return '.clbi-nations-board';
        if (document.querySelector('.liberty-content-main')) return '.liberty-content-main';
        if (document.querySelector('.container-fluid.liberty-content')) return '.container-fluid.liberty-content';
        return 'body';
    }

    function cssAttrEscape(value) {
        return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
    }

    function getActiveDecorationEra() {
        var content = document.querySelector('.clbi-nations-era-content.is-active[data-era-content]:not([hidden])');
        var title;
        var globe;

        if (content) return content.getAttribute('data-era-content') || '';

        title = document.querySelector('.clbi-nations-era-title-plate.is-active[data-era]');
        if (title) return title.getAttribute('data-era') || '';

        globe = document.querySelector('.clbi-nations-globe-window[data-nations-globe]');
        if (globe) {
            return globe.getAttribute('data-current-era') || globe.getAttribute('data-nations-current-era') || globe.getAttribute('data-era-year') || '';
        }

        return '';
    }

    function getActiveDecorationEraPanel() {
        var era = getActiveDecorationEra();
        var selector;
        if (!era) return document.querySelector('.clbi-nations-era-content.is-active[data-era-content]:not([hidden])');
        selector = '.clbi-nations-era-content[data-era-content="' + cssAttrEscape(era) + '"]';
        return document.querySelector(selector) || document.querySelector('.clbi-nations-era-content.is-active[data-era-content]:not([hidden])');
    }

    function getActiveDecorationContinent() {
        var eraPanel = getActiveDecorationEraPanel();
        var root = eraPanel || document;
        var tab = root.querySelector('.clbi-nations-tabpanel-tab.is-active[data-continent], .clbi-nations-tabpanel-tab[aria-selected="true"][data-continent]');
        var panel;

        if (tab) return tab.getAttribute('data-continent') || '';

        panel = root.querySelector('.clbi-nations-tabpanel-continent.is-active[data-continent-panel]');
        if (panel) return panel.getAttribute('data-continent-panel') || '';

        return '';
    }

    function getActiveDecorationScopeSelector() {
        var era = getActiveDecorationEra();
        var continent = getActiveDecorationContinent();
        var selector = '.clbi-nations-panel-stack';

        if (era) selector += ' .clbi-nations-era-content[data-era-content="' + cssAttrEscape(era) + '"]';
        if (continent) selector += ' .clbi-nations-tabpanel-continent[data-continent-panel="' + cssAttrEscape(continent) + '"]';
        return continent || era ? selector : '';
    }

    function getDecorationNationsBodySelector(era) {
        if (era) {
            return '.clbi-nations-era-content[data-era-content="' + cssAttrEscape(era) + '"] .clbi-nations-tabpanel-body';
        }
        return '.clbi-nations-era-content.is-active[data-era-content]:not([hidden]) .clbi-nations-tabpanel-body, .clbi-nations-tabpanel-body';
    }

    function getDefaultDecorationScopedTarget() {
        return document.querySelector('.clbi-nations-panel-stack') ? '.clbi-nations-panel-stack' : getDefaultDecorationTarget();
    }

    /*
    Decoration semantic placement resolver
    -----------------------------------------
    장식 에디터의 저장 데이터는 사용자가 생각하는 의미 단위와 실제 DOM 부착 위치를
    분리해서 다룬다. 시대처럼 "1950년 / 아메리카"라는 조합을 고르는 문서에서는
    사용자가 보는 기준은 특정 대륙 탭이지만, 좌표를 잡아야 하는 물리적 기준면은 그 대륙
    패널을 담는 .clbi-nations-tabpanel-body일 수 있다.

    새 문서에서 비슷한 조합형 UI가 생기면 아래 resolver에 placement 값을 하나 추가한다.
    저장 항목에는 placement, era, continent 같은 의미값을 남기고, resolver가 attach target과
    visible 조건을 계산한다. 이 규칙은 Common.js 런타임 렌더러에도 같은 형태로 있어야 한다.
    한쪽만 바꾸면 에디터 미리보기와 실제 표시가 다시 어긋난다.
    */
    function shouldUseNationsBodyPlacement(entry) {
        var placement = String(entry && entry.placement || '').trim();
        var target = String(entry && entry.target || '').trim();
        var era = String(entry && entry.era || '').trim();
        var continent = String(entry && entry.continent || '').trim();

        if (!document.querySelector('.clbi-nations-panel-stack')) return false;
        if (placement === 'nations-continent-body') return true;
        if (era || continent) return true;
        if (target.indexOf('clbi-nations-tabpanel-continent') !== -1) return true;
        return false;
    }

    function normalizeDecorationSemanticPlacement(entry) {
        var era;
        var continent;

        if (!entry || typeof entry !== 'object') return entry;
        if (!shouldUseNationsBodyPlacement(entry)) return entry;

        era = String(entry.era || getActiveDecorationEra() || '').trim();
        continent = String(entry.continent || getActiveDecorationContinent() || '').trim();

        entry.placement = 'nations-continent-body';
        entry.target = getDecorationNationsBodySelector(era);
        if (era) entry.era = era;
        else delete entry.era;
        if (continent) entry.continent = continent;
        else delete entry.continent;

        return entry;
    }

    function resolveDecorationPlacement(entry) {
        var normalized = normalizeDecorationSemanticPlacement(decoClone(entry || {}));
        var placement = String(normalized && normalized.placement || '').trim();
        var selector = String(normalized && normalized.target || '').trim() || getDefaultDecorationTarget();
        var base;
        var era = String(normalized && normalized.era || '').trim();
        var continent = String(normalized && normalized.continent || '').trim();
        var activeEra;
        var activeContinent;

        if (placement === 'nations-continent-body') {
            selector = getDecorationNationsBodySelector(era);
            base = document.querySelector(selector) || document.querySelector('.clbi-nations-tabpanel-body') || document.querySelector(String(entry && entry.target || '').trim());
            activeEra = getActiveDecorationEra();
            activeContinent = getActiveDecorationContinent();
            return {
                placement: placement,
                target: base,
                targetSelector: selector,
                visible: (!era || era === activeEra) && (!continent || continent === activeContinent)
            };
        }

        if (placement === 'boot-gate' || placement === 'loading-screen') {
            selector = '#boot-gate-screen .boot-gate-decoration-layer, #boot-gate-screen';
            return {
                placement: placement,
                target: document.querySelector(selector),
                targetSelector: selector,
                visible: !!document.getElementById('boot-gate-screen')
            };
        }

        return {
            placement: placement,
            target: document.querySelector(selector),
            targetSelector: selector,
            visible: true
        };
    }

    function refreshDecorationScopeReadout() {
        var era = getActiveDecorationEra();
        var continent = getActiveDecorationContinent();
        var readout = $('#dev-tools-decoration-continent-readout');

        if (readout.length) {
            readout.text('ERA: ' + (era || 'NONE') + ' / CONTINENT: ' + (continent || 'NONE'));
        }
    }

    function normalizeDecorationSrc(src) {
        src = String(src || '').trim();
        if (!src) return '';
        if (/^(?:https?:)?\/\//i.test(src) || src.charAt(0) === '/' || src.indexOf('data:') === 0 || src.indexOf('blob:') === 0) return src;
        return '/index.php/Special:Redirect/file/' + encodeURIComponent(src);
    }


    function normalizeDecorationAssetType(entry) {
        var type = String(entry && entry.assetType || '').trim().toLowerCase();
        var ref = String(entry && (entry.asset || entry.src) || '').trim();
        if (type === 'clbi-pixel-json' || type === 'pixel-rle' || type === 'pixel-json') return 'pixel-json';
        if (!type && /\.json(?:[?#].*)?$/i.test(ref)) return 'pixel-json';
        return type || 'image';
    }

    function isDecorationPixelJson(entry) {
        return normalizeDecorationAssetType(entry) === 'pixel-json';
    }

    function getDecorationPixelJsonRef(entry) {
        return String(entry && (entry.asset || entry.src) || '').trim();
    }

    function hasDecorationRenderableAsset(entry) {
        /*
        에디터의 실시간 미리보기는 저장된 JSON이 아니라 현재 폼/작업 레이어를 기준으로 그린다.
        이미지 장식은 src가 있어야 하고, CLBI pixel-json 장식은 asset 필드가 실제 자산 참조이므로
        src만 검사하면 저장 전 미리보기와 드래그가 끊긴다.
        */
        if (!entry) return false;
        if (isDecorationPixelJson(entry)) return !!getDecorationPixelJsonRef(entry);
        return !!String(entry.src || '').trim();
    }

    function normalizeDecorationPixelJsonUrl(ref) {
        ref = String(ref || '').trim();
        if (!ref) return '';
        if (/^(?:https?:)?\/\//i.test(ref) || ref.charAt(0) === '/' || ref.indexOf('data:') === 0 || ref.indexOf('blob:') === 0) return ref;
        if (/^(?:file|파일):/i.test(ref)) return '/index.php/Special:Redirect/file/' + encodeURIComponent(ref.replace(/^(?:file|파일):/i, ''));
        if (mw && mw.util && typeof mw.util.getUrl === 'function') {
            if (ref.indexOf(':') !== -1) return mw.util.getUrl(ref, { action: 'raw', ctype: 'application/json' });
            return mw.util.getUrl('MediaWiki:' + ref, { action: 'raw', ctype: 'application/json' });
        }
        return ref;
    }

    function parseDecorationPixelColor(value) {
        var text;
        var m;
        if (Array.isArray(value)) {
            return [
                Math.max(0, Math.min(255, Number(value[0]) || 0)),
                Math.max(0, Math.min(255, Number(value[1]) || 0)),
                Math.max(0, Math.min(255, Number(value[2]) || 0)),
                value.length > 3 ? Math.max(0, Math.min(255, Number(value[3]) || 0)) : 255
            ];
        }
        text = String(value || '').trim();
        m = text.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
        if (!m) return [0, 0, 0, 0];
        text = m[1];
        if (text.length === 3 || text.length === 4) {
            return [
                parseInt(text.charAt(0) + text.charAt(0), 16),
                parseInt(text.charAt(1) + text.charAt(1), 16),
                parseInt(text.charAt(2) + text.charAt(2), 16),
                text.length === 4 ? parseInt(text.charAt(3) + text.charAt(3), 16) : 255
            ];
        }
        return [
            parseInt(text.slice(0, 2), 16),
            parseInt(text.slice(2, 4), 16),
            parseInt(text.slice(4, 6), 16),
            text.length === 8 ? parseInt(text.slice(6, 8), 16) : 255
        ];
    }


    function decodeDecorationPixelRle36(value) {
        var text = String(value || '').trim();
        var parts;
        var runs = [];
        var i;
        var x;
        var y;
        var len;
        var colorIndex;

        if (!text) return runs;
        parts = text.split(',');
        for (i = 0; i + 3 < parts.length; i += 4) {
            x = parseInt(parts[i], 36);
            y = parseInt(parts[i + 1], 36);
            len = parseInt(parts[i + 2], 36);
            colorIndex = parseInt(parts[i + 3], 36);
            if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(len) || !Number.isFinite(colorIndex)) continue;
            runs.push([x, y, len, colorIndex]);
        }
        return runs;
    }

    function normalizeDecorationPixelAsset(doc) {
        var encoding = String(doc && (doc.encoding || doc.e) || '').trim().toLowerCase();
        var width = Math.round(Number(doc && (doc.width || doc.w)) || 0);
        var height = Math.round(Number(doc && (doc.height || doc.h)) || 0);
        var paletteSource = Array.isArray(doc && doc.palette) ? doc.palette : (Array.isArray(doc && doc.p) ? doc.p : []);
        var palette = paletteSource.map(parseDecorationPixelColor);
        var runs;

        /* Runtime Common.js와 같은 compact pixel JSON 해석 규칙이다.
           DevTools 미리보기도 같은 rle36을 복원해야 저장 전/후 표시가 달라지지 않는다. */
        if ((encoding === 'rle36' || encoding === 'clbi-rle36') && typeof (doc && doc.r) === 'string') {
            runs = decodeDecorationPixelRle36(doc.r);
        } else {
            runs = Array.isArray(doc && doc.runs) ? doc.runs : [];
        }

        if (!width || !height || width < 1 || height < 1) throw new Error('invalid pixel decoration size');
        if (width > 8192 || height > 8192) throw new Error('pixel decoration too large');
        return { width: width, height: height, palette: palette, runs: runs, encoding: encoding || 'runs' };
    }

    function fetchDecorationPixelAsset(ref) {
        var url = normalizeDecorationPixelJsonUrl(ref);
        var cached;
        if (!url) return Promise.reject(new Error('pixel json ref is empty'));
        if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.loadPixelAsset === 'function') {
            return window.CLBI_DECORATIONS.loadPixelAsset(ref);
        }
        cached = decorationPixelAssetCache[url];
        if (cached) return cached.promise;
        cached = {
            promise: fetch(url, { credentials: 'same-origin', cache: 'force-cache' })
                .then(function (response) {
                    if (!response.ok) throw new Error('HTTP ' + response.status);
                    return response.json();
                })
                .then(normalizeDecorationPixelAsset)
        };
        decorationPixelAssetCache[url] = cached;
        return cached.promise;
    }

    function drawDecorationPixelAssetToCanvas(canvas, asset) {
        var ctx;
        var image;
        var data;
        var i;
        var run;
        var x;
        var y;
        var len;
        var color;
        var colorIndex;
        var p;
        var k;

        if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.drawPixelAssetToCanvas === 'function') {
            window.CLBI_DECORATIONS.drawPixelAssetToCanvas(canvas, asset);
            return;
        }

        canvas.width = asset.width;
        canvas.height = asset.height;
        ctx = canvas.getContext('2d');
        ctx.imageSmoothingEnabled = false;
        image = ctx.createImageData(asset.width, asset.height);
        data = image.data;
        for (i = 0; i < asset.runs.length; i += 1) {
            run = asset.runs[i];
            if (!Array.isArray(run) || run.length < 4) continue;
            x = Math.round(Number(run[0]) || 0);
            y = Math.round(Number(run[1]) || 0);
            len = Math.round(Number(run[2]) || 0);
            colorIndex = Math.round(Number(run[3]) || 0);
            color = asset.palette[colorIndex];
            if (!color || len <= 0 || y < 0 || y >= asset.height || x >= asset.width) continue;
            if (x < 0) { len += x; x = 0; }
            len = Math.min(len, asset.width - x);
            for (k = 0; k < len; k += 1) {
                p = ((y * asset.width) + x + k) * 4;
                data[p] = color[0];
                data[p + 1] = color[1];
                data[p + 2] = color[2];
                data[p + 3] = color[3];
            }
        }
        ctx.putImageData(image, 0, 0);
    }

    function readDecorationNumber(selector, fallback) {
        var n = parseFloat($(selector).val());
        return Number.isFinite(n) ? n : fallback;
    }

    function readDecorationHeight() {
        var raw = String($('#dev-tools-decoration-height').val() || '').trim();
        var n;
        if (!raw || raw.toLowerCase() === 'auto') return null;
        n = parseFloat(raw);
        return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
    }

    function getDecorationTargetElement(entry) {
        var resolved = resolveDecorationPlacement(entry);
        return resolved && resolved.target ? resolved.target : null;
    }

    function isDecorationPlacementVisible(entry) {
        var resolved = resolveDecorationPlacement(entry);
        return !!(resolved && resolved.visible !== false);
    }

    function fetchDecorationRegistry(api) {
        return api.get({
            action: 'query',
            prop: 'revisions',
            titles: DECORATIONS_TITLE,
            rvprop: 'content',
            rvslots: 'main',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var pages = data && data.query ? data.query.pages : [];
            var page = pages && pages.length ? pages[0] : null;
            var text = '';

            if (!page || page.missing) return { version: 1, decorations: [] };
            if (page.revisions && page.revisions[0]) {
                if (page.revisions[0].slots && page.revisions[0].slots.main) {
                    text = page.revisions[0].slots.main.content || '';
                } else {
                    text = page.revisions[0].content || '';
                }
            }
            if (!text) return { version: 1, decorations: [] };

            try {
                return JSON.parse(text);
            } catch (err) {
                setStatus('Decorations JSON 파싱 실패', 'error');
                return { version: 1, decorations: [] };
            }
        });
    }

    function ensureDecorationHost(target) {
        var style;
        if (!target) return;
        target.setAttribute('data-wiki-decoration-host', '1');
        style = window.getComputedStyle ? window.getComputedStyle(target) : null;
        if (style && style.position === 'static') target.style.position = 'relative';
    }

    function getDecorationTargetScale(target) {
        var rect;
        if (!target) return { x: 1, y: 1 };
        rect = target.getBoundingClientRect();
        return {
            x: rect.width && target.offsetWidth ? rect.width / target.offsetWidth : 1,
            y: rect.height && target.offsetHeight ? rect.height / target.offsetHeight : 1
        };
    }


    /* -----------------------------------------
       Decoration layer editor v2
       ----------------------------------------- */
    function decoClone(value) {
        try {
            return JSON.parse(JSON.stringify(value || {}));
        } catch (err) {
            return {};
        }
    }

    function decoCurrentPageKey() {
        var raw = String(mw.config.get('wgPageName') || '').trim();
        var normalized = normalizePageName(raw);
        return normalized || raw || '대문';
    }

    function decoNormalizePage(value) {
        return normalizePageName(value || '');
    }

    function decoMatchesCurrentPage(entry) {
        var page = decoCurrentPageKey();
        var underscored = page.replace(/ /g, '_');
        var pages = entry && entry.pages;
        var target = entry && entry.page;
        var i;

        if (!entry || typeof entry !== 'object') return false;
        if (String(entry.placement || '').trim() === 'boot-gate' || String(entry.placement || '').trim() === 'loading-screen') return true;
        if (entry.global === true || entry.global === 'true' || entry.global === 1 || entry.global === '1') return true;
        if (!target && !pages) return true;

        if (Array.isArray(pages)) {
            for (i = 0; i < pages.length; i += 1) {
                if (decoNormalizePage(pages[i]) === page || String(pages[i] || '') === underscored) return true;
            }
        }

        target = decoNormalizePage(target);
        return target === page || target === underscored;
    }

    function decoRegistryList(registry) {
        if (!registry || typeof registry !== 'object') return [];
        if (Array.isArray(registry)) return registry;
        if (!Array.isArray(registry.decorations)) registry.decorations = [];
        return registry.decorations;
    }

    function decoMakeRegistry(raw) {
        var registry = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : { version: 1, decorations: [] };
        if (!Array.isArray(registry.decorations)) registry.decorations = [];
        if (!registry.version) registry.version = 1;
        return registry;
    }

    function decoNormalizeRegistryForEditor(registry) {
        decoRegistryList(registry).forEach(function (entry) {
            if (decoMatchesCurrentPage(entry)) normalizeDecorationSemanticPlacement(entry);
        });
        return registry;
    }

    function decoMakeId(label) {
        var base = String(label || decoCurrentPageKey() || 'decoration')
            .replace(/[^a-zA-Z0-9가-힣_-]+/g, '-')
            .replace(/^-+|-+$/g, '') || 'decoration';
        return base + '-' + Date.now();
    }

    function decoIsGridEnabled() {
        return localStorage.getItem('dev-tools-decoration-grid-enabled') !== 'false';
    }

    function decoSetGridEnabled(enabled) {
        localStorage.setItem('dev-tools-decoration-grid-enabled', enabled ? 'true' : 'false');
        $('#dev-tools-decoration-grid-toggle')
            .attr('data-grid', enabled ? '1' : '0')
            .text(enabled ? 'GRID ON' : 'GRID OFF');
        renderDecorationGridOverlay();
    }

    function decorationLayerList() {
        return decoRegistryList(decorationEditor.workingRegistry || decorationEditor.registry || { decorations: [] });
    }

    function decorationVisibleLayerList() {
        return decorationLayerList().filter(function (entry) {
            return decoMatchesCurrentPage(entry);
        }).sort(function (a, b) {
            return (Number(b.zIndex) || 0) - (Number(a.zIndex) || 0);
        });
    }

    function getDecorationSelectedLayer() {
        var list = decorationLayerList();
        var id = decorationEditor.selectedId;
        var i;

        for (i = 0; i < list.length; i += 1) {
            if (list[i] && list[i].id === id) return list[i];
        }

        return null;
    }

    function setDecorationEditorModeUi() {
        var editing = !!decorationEditor.editMode;
        var selectedEntry = getDecorationSelectedLayer();
        var hasSelected = !!selectedEntry;
        var pixelJsonSelected = !!(selectedEntry && isDecorationPixelJson(selectedEntry));

        $('.dev-tools-decoration-section').toggleClass('is-editing', editing);
        $('body').toggleClass('clbi-decoration-edit-mode', editing);
        $('#dev-tools-decoration-mode').text(editing ? 'EDIT MODE' : 'VIEW MODE');
        $('#dev-tools-decoration-edit-toggle').text(editing ? 'EDITING' : 'EDIT MODE').toggleClass('is-active', editing);
        $('#dev-tools-decoration-save, #dev-tools-decoration-cancel, #dev-tools-decoration-add').prop('disabled', !editing);
        $('#dev-tools-decoration-delete, #dev-tools-decoration-target-continent, #dev-tools-decoration-target-boot, #dev-tools-decoration-center, #dev-tools-decoration-fit-width').prop('disabled', !editing || !hasSelected);
        $('#dev-tools-decoration-label, #dev-tools-decoration-asset-type, #dev-tools-decoration-src, #dev-tools-decoration-target, #dev-tools-decoration-era, #dev-tools-decoration-continent, #dev-tools-decoration-x, #dev-tools-decoration-y, #dev-tools-decoration-width, #dev-tools-decoration-height, #dev-tools-decoration-opacity, #dev-tools-decoration-z').prop('disabled', !editing || !hasSelected);
        $('#dev-tools-decoration-width, #dev-tools-decoration-height').prop('disabled', !editing || !hasSelected || pixelJsonSelected);
        $('#dev-tools-decoration-target').prop('readonly', hasSelected && selectedEntry && (selectedEntry.placement === 'nations-continent-body' || selectedEntry.placement === 'boot-gate' || selectedEntry.placement === 'loading-screen'));
    }

    function renderDecorationLayerList() {
        var list = decorationVisibleLayerList();
        var html;
        var selectedStillVisible = false;

        $('#dev-tools-decoration-page').text('PAGE: ' + decoCurrentPageKey());
        refreshDecorationContinentReadout();

        list.forEach(function (entry) {
            if (entry && entry.id === decorationEditor.selectedId) selectedStillVisible = true;
        });

        if (!list.length) {
            decorationEditor.selectedId = '';
            writeDecorationLayerForm({});
            html = '<div class="dev-tools-decoration-empty">이 문서에 표시되는 장식 레이어가 없습니다.</div>';
        } else {
            if (!selectedStillVisible) {
                decorationEditor.selectedId = list[0].id || '';
                writeDecorationLayerForm(list[0]);
            }
            html = list.map(function (entry) {
                var selected = entry.id === decorationEditor.selectedId;
                var title = entry.label || entry.name || entry.id || 'untitled';
                var meta = [];
                if (entry.placement) meta.push(String(entry.placement));
                if (entry.era) meta.push(String(entry.era));
                if (entry.continent) meta.push(String(entry.continent));
                meta.push('z ' + (entry.zIndex == null ? 0 : entry.zIndex));
                return '' +
                    '<button type="button" class="dev-tools-decoration-layer' + (selected ? ' is-selected' : '') + '" data-decoration-id="' + escapeHtml(entry.id || '') + '">' +
                        '<span class="dev-tools-decoration-layer-name">' + escapeHtml(title) + '</span>' +
                        '<span class="dev-tools-decoration-layer-meta">' + escapeHtml(meta.join(' / ')) + '</span>' +
                    '</button>';
            }).join('');
        }

        $('#dev-tools-decoration-layer-list').html(html);
        setDecorationEditorModeUi();
    }

    function writeDecorationLayerForm(entry) {
        entry = entry || {};
        $('#dev-tools-decoration-id').val(entry.id || '');
        $('#dev-tools-decoration-placement').val(entry.placement || '');
        $('#dev-tools-decoration-label').val(entry.label || entry.name || entry.id || '');
        $('#dev-tools-decoration-target').val(entry.placement === 'nations-continent-body' ? getDecorationNationsBodySelector(entry.era || '') : ((entry.placement === 'boot-gate' || entry.placement === 'loading-screen') ? '#boot-gate-screen .boot-gate-decoration-layer' : (entry.target || getDefaultDecorationTarget())));
        $('#dev-tools-decoration-era').val(entry.era || '');
        $('#dev-tools-decoration-continent').val(entry.continent || '');
        $('#dev-tools-decoration-asset-type').val(normalizeDecorationAssetType(entry));
        $('#dev-tools-decoration-src').val(isDecorationPixelJson(entry) ? (entry.asset || entry.src || '') : (entry.src || ''));
        $('#dev-tools-decoration-x').val(entry.x == null ? 0 : Math.round(Number(entry.x) || 0));
        $('#dev-tools-decoration-y').val(entry.y == null ? 0 : Math.round(Number(entry.y) || 0));
        $('#dev-tools-decoration-width').val(entry.width == null ? 1372 : Math.max(1, Math.round(Number(entry.width) || 1372)));
        $('#dev-tools-decoration-height').val(entry.height == null ? 'auto' : Math.max(1, Math.round(Number(entry.height) || 1)));
        $('#dev-tools-decoration-opacity').val(entry.opacity == null ? 1 : Math.max(0, Math.min(1, Number(entry.opacity) || 0)));
        $('#dev-tools-decoration-z').val(entry.zIndex == null ? 0 : Math.round(Number(entry.zIndex) || 0));
        refreshDecorationScopeReadout();
        setDecorationEditorModeUi();
    }

    function readDecorationLayerForm() {
        var height = readDecorationHeight();
        var label = String($('#dev-tools-decoration-label').val() || '').trim();
        var placement = String($('#dev-tools-decoration-placement').val() || '').trim();
        var assetType = String($('#dev-tools-decoration-asset-type').val() || 'image').trim();
        var assetRef = String($('#dev-tools-decoration-src').val() || '').trim();
        var era = String($('#dev-tools-decoration-era').val() || '').trim();
        var continent = String($('#dev-tools-decoration-continent').val() || '').trim();
        var target = String($('#dev-tools-decoration-target').val() || '').trim() || getDefaultDecorationTarget();
        var entry;

        if (!placement && document.querySelector('.clbi-nations-panel-stack') && (era || continent || target.indexOf('clbi-nations-tabpanel-continent') !== -1)) {
            placement = 'nations-continent-body';
        }

        if (placement === 'nations-continent-body') {
            target = getDecorationNationsBodySelector(era);
        }
        if (placement === 'boot-gate' || placement === 'loading-screen') {
            placement = 'boot-gate';
            target = '#boot-gate-screen .boot-gate-decoration-layer';
            era = '';
            continent = '';
        }

        entry = {
            id: String($('#dev-tools-decoration-id').val() || '').trim() || decoMakeId(label),
            label: label,
            page: placement === 'boot-gate' ? '__boot__' : decoCurrentPageKey(),
            placement: placement,
            target: target,
            era: era,
            continent: continent,
            assetType: assetType,
            src: assetType === 'pixel-json' ? '' : assetRef,
            asset: assetType === 'pixel-json' ? assetRef : '',
            x: Math.round(readDecorationNumber('#dev-tools-decoration-x', 0)),
            y: Math.round(readDecorationNumber('#dev-tools-decoration-y', 0)),
            width: Math.max(1, Math.round(readDecorationNumber('#dev-tools-decoration-width', 1372))),
            opacity: Math.max(0, Math.min(1, readDecorationNumber('#dev-tools-decoration-opacity', 1))),
            zIndex: Math.round(readDecorationNumber('#dev-tools-decoration-z', 0)),
            pointerEvents: 'none',
            enabled: true
        };

        if (!entry.label) delete entry.label;
        if (!entry.placement) delete entry.placement;
        if (entry.assetType !== 'pixel-json') { delete entry.assetType; delete entry.asset; }
        if (entry.assetType === 'pixel-json') { delete entry.src; }
        if (height) entry.height = height;
        if (entry.assetType === 'pixel-json') { delete entry.width; delete entry.height; }
        if (!entry.era) delete entry.era;
        if (!entry.continent) delete entry.continent;
        return entry;
    }

    function replaceSelectedDecorationFromForm() {
        var entry;
        var list;
        var i;
        if (!decorationEditor.editMode || !decorationEditor.selectedId) return null;
        entry = normalizeDecorationSemanticPlacement(readDecorationLayerForm());
        list = decorationLayerList();
        for (i = 0; i < list.length; i += 1) {
            if (list[i] && list[i].id === decorationEditor.selectedId) {
                list[i] = entry;
                decorationEditor.selectedId = entry.id;
                return entry;
            }
        }
        return null;
    }

    function selectDecorationLayer(id, silent) {
        var list = decorationLayerList();
        var i;
        var entry = null;

        id = String(id || '');
        for (i = 0; i < list.length; i += 1) {
            if (list[i] && list[i].id === id) {
                entry = list[i];
                break;
            }
        }
        if (!entry) return false;

        decorationEditor.selectedId = entry.id;
        writeDecorationLayerForm(entry);
        renderDecorationLayerList();
        markDecorationSelectedPreview();
        renderDecorationGridOverlay();
        if (!silent) setStatus('레이어 선택: ' + (entry.label || entry.id), 'ok');
        return true;
    }

    function selectFirstVisibleDecorationLayer() {
        var list = decorationVisibleLayerList();
        if (list.length) {
            selectDecorationLayer(list[0].id, true);
        } else {
            decorationEditor.selectedId = '';
            writeDecorationLayerForm({});
            renderDecorationLayerList();
        }
    }

    function loadDecorationLayerRegistry(callback) {
        withApi(function (api) {
            fetchDecorationRegistry(api).then(function (registry) {
                decorationEditor.registry = decoNormalizeRegistryForEditor(decoMakeRegistry(registry));
                if (!decorationEditor.editMode) {
                    decorationEditor.workingRegistry = decoNormalizeRegistryForEditor(decoClone(decorationEditor.registry));
                }
                renderDecorationLayerList();
                if (!getDecorationSelectedLayer()) selectFirstVisibleDecorationLayer();
                if (typeof callback === 'function') callback(decorationEditor.registry);
            }).catch(function (err) {
                setStatus('장식 목록 로드 실패: ' + (err && err.message ? err.message : err), 'error');
            });
        }, function () {
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }

    function writeDecorationDefaults() {
        decoSetGridEnabled(decoIsGridEnabled());
        writeDecorationLayerForm({
            id: decoMakeId('decoration'),
            page: decoCurrentPageKey(),
            target: getDefaultDecorationScopedTarget(),
            assetType: 'image',
            src: localStorage.getItem('dev-tools-decoration-last-src') || '',
            x: 0,
            y: 0,
            width: 1372,
            opacity: 1,
            zIndex: 0
        });
        loadDecorationLayerRegistry();
    }

    function clearDecorationWorkPreviews() {
        Array.prototype.forEach.call(document.querySelectorAll('.dev-tools-decoration-work-preview, .dev-tools-decoration-grid-overlay, .wiki-decoration[data-dev-tools-decoration-preview="1"], .wiki-decoration[data-wiki-decoration-rendered="1"]'), function (node) {
            if (node.parentNode) node.parentNode.removeChild(node);
        });
        decorationEditor.previewMap = {};
        decorationEditor.gridTarget = null;
    }

    function applyDecorationLayerStyle(node, entry) {
        var h = Number(entry.height);
        node.style.left = Math.round(Number(entry.x) || 0) + 'px';
        node.style.top = Math.round(Number(entry.y) || 0) + 'px';
        if (!isDecorationPixelJson(entry)) {
            node.src = normalizeDecorationSrc(entry.src);
            node.style.width = Math.max(1, Math.round(Number(entry.width) || 1)) + 'px';
            node.style.height = Number.isFinite(h) && h > 0 ? Math.round(h) + 'px' : 'auto';
        } else {
            node.style.width = 'auto';
            node.style.height = 'auto';
            node.style.imageRendering = 'pixelated';
        }
        node.style.opacity = String(entry.opacity == null ? 1 : entry.opacity);
        node.style.zIndex = String(Math.round(Number(entry.zIndex) || 0));
        node.style.pointerEvents = 'auto';
        node.style.cursor = 'move';
        node.style.touchAction = 'none';
        node.setAttribute('data-decoration-id', entry.id || '');
    }

    function renderDecorationPixelPreview(canvas, entry) {
        var ref = getDecorationPixelJsonRef(entry);
        if (!ref) return;
        canvas.width = Math.max(1, Math.round(Number(entry.assetWidth) || 1));
        canvas.height = Math.max(1, Math.round(Number(entry.assetHeight) || 1));
        fetchDecorationPixelAsset(ref).then(function (asset) {
            if (!canvas.parentNode) return;
            drawDecorationPixelAssetToCanvas(canvas, asset);
            canvas.style.width = asset.width + 'px';
            canvas.style.height = asset.height + 'px';
            canvas.setAttribute('data-decoration-asset-size', asset.width + 'x' + asset.height);
            if (entry.id === decorationEditor.selectedId) {
                $('#dev-tools-decoration-width').val(asset.width);
                $('#dev-tools-decoration-height').val(asset.height);
            }
        }).catch(function (err) {
            if (entry.id === decorationEditor.selectedId) {
                setStatus('픽셀 JSON 로드 실패: ' + (err && err.message ? err.message : err), 'error');
            }
        });
    }

    function renderDecorationWorkPreviews() {
        var list;
        var map = {};
        var i;
        var entry;
        var target;
        var img;

        if (!decorationEditor.editMode) return;
        clearDecorationWorkPreviews();
        list = decorationLayerList();

        for (i = 0; i < list.length; i += 1) {
            entry = normalizeDecorationSemanticPlacement(list[i]);
            if (!entry || entry.enabled === false || !hasDecorationRenderableAsset(entry) || !decoMatchesCurrentPage(entry) || !isDecorationPlacementVisible(entry)) continue;
            target = getDecorationTargetElement(entry);
            if (!target) continue;
            ensureDecorationHost(target);
            img = document.createElement(isDecorationPixelJson(entry) ? 'canvas' : 'img');
            img.className = 'wiki-decoration dev-tools-decoration-work-preview' + (isDecorationPixelJson(entry) ? ' wiki-decoration-pixel-json' : '');
            img.setAttribute('aria-hidden', 'true');
            if (img.tagName && img.tagName.toLowerCase() === 'img') img.setAttribute('alt', '');
            img.setAttribute('tabindex', '0');
            img.setAttribute('data-dev-tools-decoration-preview', '1');
            if (isDecorationPixelJson(entry)) img.setAttribute('data-decoration-asset-type', 'pixel-json');
            img.draggable = false;
            applyDecorationLayerStyle(img, entry);
            target.appendChild(img);
            if (isDecorationPixelJson(entry)) renderDecorationPixelPreview(img, entry);
            map[entry.id] = img;
        }

        decorationEditor.previewMap = map;
        markDecorationSelectedPreview();
        renderDecorationGridOverlay();
        bindDecorationLayerPreviewDrag();
    }

    function markDecorationSelectedPreview() {
        var map = decorationEditor.previewMap || {};
        Object.keys(map).forEach(function (id) {
            if (map[id]) map[id].classList.toggle('is-selected', id === decorationEditor.selectedId);
        });
    }

    function renderDecorationGridOverlay() {
        var entry;
        var target;
        var overlay;
        clearDecorationGridOverlay();
        if (!decorationEditor.editMode || !decoIsGridEnabled()) return;
        entry = getDecorationSelectedLayer();
        if (!entry) return;
        target = getDecorationTargetElement(entry);
        if (!target) return;
        ensureDecorationHost(target);
        overlay = document.createElement('div');
        overlay.className = 'dev-tools-decoration-grid-overlay';
        overlay.setAttribute('aria-hidden', 'true');
        target.appendChild(overlay);
        decorationEditor.gridTarget = target;
    }

    function clearDecorationGridOverlay() {
        Array.prototype.forEach.call(document.querySelectorAll('.dev-tools-decoration-grid-overlay'), function (node) {
            if (node.parentNode) node.parentNode.removeChild(node);
        });
        decorationEditor.gridTarget = null;
    }

    function getDecorationPreviewForSelected() {
        var map = decorationEditor.previewMap || {};
        return map[decorationEditor.selectedId] || null;
    }

    function snapDecorationPosition(x, y, entry) {
        var target;
        var img;
        var targetW;
        var targetH;
        var imgW;
        var imgH;
        var centerX;
        var centerY;
        var threshold = 7;

        if (!decoIsGridEnabled()) return { x: x, y: y };
        target = getDecorationTargetElement(entry);
        img = getDecorationPreviewForSelected();
        if (!target || !img) return { x: x, y: y };
        targetW = target.clientWidth || target.offsetWidth || 0;
        targetH = target.clientHeight || target.offsetHeight || 0;
        imgW = img.offsetWidth || Number(entry.width) || 0;
        imgH = img.offsetHeight || Number(entry.height) || 0;
        if (targetW > 0 && imgW > 0) {
            centerX = Math.round((targetW - imgW) / 2);
            if (Math.abs(x - centerX) <= threshold) x = centerX;
        }
        if (targetH > 0 && imgH > 0) {
            centerY = Math.round((targetH - imgH) / 2);
            if (Math.abs(y - centerY) <= threshold) y = centerY;
        }
        return { x: Math.round(x), y: Math.round(y) };
    }

    function setDecorationLayerPosition(x, y) {
        var entry;
        var snapped;
        var preview;
        if (!decorationEditor.editMode) return;
        entry = getDecorationSelectedLayer();
        if (!entry) return;
        snapped = snapDecorationPosition(x, y, entry);
        entry.x = snapped.x;
        entry.y = snapped.y;
        $('#dev-tools-decoration-x').val(entry.x);
        $('#dev-tools-decoration-y').val(entry.y);

        /*
        위치 조절은 가장 잦은 조작이다. 여기서 전체 프리뷰를 지웠다 다시 만들면
        pixel-json canvas를 매 프레임 다시 생성하게 되고, pointer capture도 흔들려 드래그가 끊긴다.
        선택 레이어의 left/top만 즉시 바꾸고, target/asset/scale이 바뀌는 경우에만 전체 렌더를 한다.
        */
        preview = getDecorationPreviewForSelected();
        if (preview) {
            preview.style.left = Math.round(entry.x) + 'px';
            preview.style.top = Math.round(entry.y) + 'px';
        } else {
            renderDecorationWorkPreviews();
        }
    }

    function bindDecorationLayerPreviewDrag() {
        var map = decorationEditor.previewMap || {};
        Object.keys(map).forEach(function (id) {
            var img = map[id];
            if (!img || img.getAttribute('data-dev-tools-layer-bound') === '1') return;
            img.setAttribute('data-dev-tools-layer-bound', '1');

            img.addEventListener('pointerdown', function (event) {
                var entry;
                var target;
                if (!decorationEditor.editMode) return;
                selectDecorationLayer(id, true);
                entry = getDecorationSelectedLayer();
                target = getDecorationTargetElement(entry);
                if (!entry || !target) return;
                event.preventDefault();
                event.stopPropagation();
                decorationEditor.drag = {
                    id: id,
                    pointerId: event.pointerId,
                    startClientX: event.clientX,
                    startClientY: event.clientY,
                    startX: Number(entry.x) || 0,
                    startY: Number(entry.y) || 0,
                    axis: ''
                };
                img.setPointerCapture && img.setPointerCapture(event.pointerId);
                img.classList.add('is-dragging');
            });

            img.addEventListener('wheel', function (event) {
                var entry;
                var width;
                if (!decorationEditor.editMode || (!event.ctrlKey && !event.altKey)) return;
                event.preventDefault();
                selectDecorationLayer(id, true);
                entry = getDecorationSelectedLayer();
                if (!entry) return;
                width = Math.max(1, Math.round((Number(entry.width) || 1) + (event.deltaY < 0 ? 8 : -8) * (event.shiftKey ? 4 : 1)));
                entry.width = width;
                $('#dev-tools-decoration-width').val(width);
                renderDecorationWorkPreviews();
            }, { passive: false });
        });
    }

    function updateDecorationDragMove(event) {
        var drag = decorationEditor.drag;
        var entry;
        var target;
        var scale;
        var dx;
        var dy;
        var x;
        var y;
        if (!drag || !decorationEditor.editMode) return;
        entry = getDecorationSelectedLayer();
        target = getDecorationTargetElement(entry);
        if (!entry || !target) return;
        scale = getDecorationTargetScale(target);
        dx = (event.clientX - drag.startClientX) / (scale.x || 1);
        dy = (event.clientY - drag.startClientY) / (scale.y || 1);
        if (event.shiftKey) {
            if (!drag.axis && Math.max(Math.abs(dx), Math.abs(dy)) >= 4) {
                drag.axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
            }
            if (drag.axis === 'x') dy = 0;
            if (drag.axis === 'y') dx = 0;
        } else {
            drag.axis = '';
        }
        x = drag.startX + dx;
        y = drag.startY + dy;
        setDecorationLayerPosition(x, y);
    }

    function stopDecorationDrag() {
        var map = decorationEditor.previewMap || {};
        Object.keys(map).forEach(function (id) {
            if (map[id]) map[id].classList.remove('is-dragging');
        });
        decorationEditor.drag = null;
    }

    function enterDecorationEditMode() {
        if (decorationEditor.editMode) return;
        setButtonsDisabled(true);
        setStatus('장식 에딧 모드 준비 중...');
        clearDecorationWorkPreviews();
        withApi(function (api) {
            fetchDecorationRegistry(api).then(function (registry) {
                decorationEditor.registry = decoNormalizeRegistryForEditor(decoMakeRegistry(registry));
                decorationEditor.savedRegistry = decoClone(decorationEditor.registry);
                decorationEditor.workingRegistry = decoNormalizeRegistryForEditor(decoClone(decorationEditor.registry));
                decorationEditor.editMode = true;
                if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.clear === 'function') {
                    window.CLBI_DECORATIONS.clear(document);
                }
                if (!getDecorationSelectedLayer()) selectFirstVisibleDecorationLayer();
                renderDecorationLayerList();
                renderDecorationWorkPreviews();
                setDecorationEditorModeUi();
                setStatus('DECORATION EDIT MODE', 'ok');
            }).catch(function (err) {
                setStatus('장식 에딧 모드 시작 실패: ' + (err && err.message ? err.message : err), 'error');
            }).always(function () {
                setButtonsDisabled(false);
                setDecorationEditorModeUi();
            });
        }, function () {
            setButtonsDisabled(false);
            setDecorationEditorModeUi();
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }

    function cancelDecorationLayerEditor() {
        if (!decorationEditor.editMode) return;
        decorationEditor.workingRegistry = decoClone(decorationEditor.savedRegistry || decorationEditor.registry || { decorations: [] });
        decorationEditor.editMode = false;
        clearDecorationWorkPreviews();
        setDecorationEditorModeUi();
        renderDecorationLayerList();
        if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.reload === 'function') {
            window.CLBI_DECORATIONS.reload();
        }
        setStatus('장식 편집 취소 / 저장값 복구', 'ok');
    }

    function toggleDecorationEditMode() {
        if (decorationEditor.editMode) {
            cancelDecorationLayerEditor();
        } else {
            enterDecorationEditMode();
        }
    }

    function addDecorationLayer() {
        var list;
        var entry;
        var era;
        var continent;
        var resolved;
        var target;
        if (!decorationEditor.editMode) return;
        list = decorationLayerList();
        era = getActiveDecorationEra() || '';
        continent = getActiveDecorationContinent() || '';

        entry = {
            id: decoMakeId('decoration'),
            label: 'Decoration ' + (list.length + 1),
            page: decoCurrentPageKey(),
            target: getDefaultDecorationScopedTarget(),
            assetType: 'image',
            era: era,
            continent: continent,
            src: localStorage.getItem('dev-tools-decoration-last-src') || '',
            x: 0,
            y: 0,
            width: 1372,
            opacity: 1,
            zIndex: list.length,
            pointerEvents: 'none',
            enabled: true
        };

        entry = normalizeDecorationSemanticPlacement(entry);
        resolved = resolveDecorationPlacement(entry);
        target = resolved && resolved.target ? resolved.target : document.querySelector(entry.target || getDefaultDecorationTarget());
        if (target) entry.width = Math.max(1, target.clientWidth || target.offsetWidth || entry.width || 1372);
        if (!entry.era) delete entry.era;
        if (!entry.continent) delete entry.continent;

        list.push(entry);
        decorationEditor.selectedId = entry.id;
        writeDecorationLayerForm(entry);
        renderDecorationLayerList();
        renderDecorationWorkPreviews();
        setStatus('새 장식 레이어 추가', 'ok');
    }

    function deleteDecorationLayer() {
        var list;
        var id;
        var next;
        if (!decorationEditor.editMode || !decorationEditor.selectedId) return;
        if (!window.confirm('선택한 장식 레이어를 삭제할까요? 저장 전까지는 CANCEL로 되돌릴 수 있습니다.')) return;
        list = decorationLayerList();
        id = decorationEditor.selectedId;
        decorationEditor.workingRegistry.decorations = list.filter(function (entry) {
            return !entry || entry.id !== id;
        });
        clearDecorationWorkPreviews();
        next = decorationVisibleLayerList()[0];
        decorationEditor.selectedId = next ? next.id : '';
        if (next) writeDecorationLayerForm(next);
        else writeDecorationLayerForm({});
        renderDecorationLayerList();
        renderDecorationWorkPreviews();
        setStatus('레이어 삭제 대기 / SAVE 필요', 'ok');
    }

    function updateDecorationLayerFromForm() {
        var entry;
        if (!decorationEditor.editMode) return;
        entry = replaceSelectedDecorationFromForm();
        if (!entry) return;
        if (entry.placement === 'nations-continent-body') {
            $('#dev-tools-decoration-target').val(entry.target || getDecorationNationsBodySelector(entry.era || ''));
        }
        if (entry.placement === 'boot-gate' || entry.placement === 'loading-screen') {
            $('#dev-tools-decoration-target').val('#boot-gate-screen .boot-gate-decoration-layer');
        }
        if (entry.src || entry.asset) localStorage.setItem('dev-tools-decoration-last-src', entry.src || entry.asset);
        renderDecorationLayerList();
        renderDecorationWorkPreviews();
    }

    function centerDecorationLayer() {
        var entry;
        var target;
        var img;
        var x;
        var y;
        if (!decorationEditor.editMode) return;
        updateDecorationLayerFromForm();
        entry = getDecorationSelectedLayer();
        target = getDecorationTargetElement(entry);
        img = getDecorationPreviewForSelected();
        if (!entry || !target) return;
        x = Math.round(((target.clientWidth || target.offsetWidth || 0) - (img ? img.offsetWidth : Number(entry.width) || 0)) / 2);
        y = Math.round(((target.clientHeight || target.offsetHeight || 0) - (img ? img.offsetHeight : Number(entry.height) || 0)) / 2);
        setDecorationLayerPosition(x, y);
    }

    function fitDecorationLayerWidth() {
        var entry;
        var target;
        if (!decorationEditor.editMode) return;
        updateDecorationLayerFromForm();
        entry = getDecorationSelectedLayer();
        target = getDecorationTargetElement(entry);
        if (!entry || !target) return;
        entry.x = 0;
        entry.width = Math.max(1, target.clientWidth || target.offsetWidth || Number(entry.width) || 1);
        $('#dev-tools-decoration-x').val(0);
        $('#dev-tools-decoration-width').val(entry.width);
        renderDecorationWorkPreviews();
    }

    function saveDecorationEditor() {
        var registry;
        if (!decorationEditor.editMode) return;
        updateDecorationLayerFromForm();
        registry = decoNormalizeRegistryForEditor(decoMakeRegistry(decoClone(decorationEditor.workingRegistry || { decorations: [] })));
        registry.updatedAt = new Date().toISOString();
        withApi(function (api) {
            setButtonsDisabled(true);
            setStatus('장식 레이어 저장 중...');
            api.postWithToken('csrf', {
                action: 'edit',
                title: DECORATIONS_TITLE,
                text: JSON.stringify(registry, null, 2) + '\n',
                summary: 'Update decoration layers',
                contentmodel: 'json',
                format: 'json',
                formatversion: 2
            }).then(function () {
                decorationEditor.registry = decoClone(registry);
                decorationEditor.savedRegistry = decoClone(registry);
                decorationEditor.workingRegistry = decoClone(registry);
                decorationEditor.editMode = false;
                clearDecorationWorkPreviews();
                renderDecorationLayerList();
                setDecorationEditorModeUi();
                if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.reload === 'function') {
                    window.CLBI_DECORATIONS.reload();
                }
                setStatus('장식 레이어 저장 완료', 'ok');
            }).catch(function (err) {
                setStatus('장식 레이어 저장 실패: ' + (err && err.message ? err.message : err), 'error');
            }).always(function () {
                setButtonsDisabled(false);
                setDecorationEditorModeUi();
            });
        }, function () {
            setButtonsDisabled(false);
            setDecorationEditorModeUi();
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }

    function targetActiveDecorationContinent() {
        var entry;
        var era = getActiveDecorationEra();
        var continent = getActiveDecorationContinent();
        var selector = era ? getDecorationNationsBodySelector(era) : getDefaultDecorationScopedTarget();

        if (!decorationEditor.editMode) return;
        entry = getDecorationSelectedLayer();
        if (!entry) return;

        $('#dev-tools-decoration-placement').val(era || continent ? 'nations-continent-body' : '');
        $('#dev-tools-decoration-era').val(era || '');
        $('#dev-tools-decoration-continent').val(continent || '');
        $('#dev-tools-decoration-target').val(selector || getDefaultDecorationTarget());
        updateDecorationLayerFromForm();
        setStatus('대상 scope 지정: ' + (era || 'PAGE') + (continent ? (' / ' + continent) : '') + ' / BODY TARGET', 'ok');
    }

    function targetBootGateDecorationSurface() {
        var entry;
        if (!decorationEditor.editMode) return;
        entry = getDecorationSelectedLayer();
        if (!entry) return;

        $('#dev-tools-decoration-placement').val('boot-gate');
        $('#dev-tools-decoration-era').val('');
        $('#dev-tools-decoration-continent').val('');
        $('#dev-tools-decoration-target').val('#boot-gate-screen .boot-gate-decoration-layer');
        if (window.BootGate && typeof window.BootGate.preview === 'function') {
            window.BootGate.preview({
                status: 'Loading screen decoration preview',
                detail: 'editing boot-gate decoration layer',
                progress: 64
            });
        }
        updateDecorationLayerFromForm();
        setStatus('로딩 화면 장식 target 지정: boot-gate', 'ok');
    }

    function refreshDecorationContinentReadout() {
        var era = getActiveDecorationEra();
        var continent = getActiveDecorationContinent();
        $('#dev-tools-decoration-continent-readout').text('ERA: ' + (era || 'NONE') + ' / CONTINENT: ' + (continent || 'NONE'));
    }

    function toggleRenameBox() {
        var box = document.getElementById('dev-tools-rename-box');
        var targetInput;

        if (!box) return;

        box.hidden = !box.hidden;

        if (!box.hidden) {
            targetInput = document.getElementById('dev-tools-rename-to');
            if (targetInput) targetInput.focus();
        }
    }

    function resolveRenameRedirectTarget(api, fromTitle) {
        return api.get({
            action: 'query',
            titles: normalizeApiTitle(fromTitle),
            redirects: 1,
            prop: 'info',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var redirects = data && data.query ? data.query.redirects : [];
            var item = redirects && redirects.length ? redirects[0] : null;

            if (item && item.to) return item.to;
            return '';
        }).catch(function () {
            return '';
        });
    }


    function deleteRenameRedirectPage(api, fromTitle, expectedToTitle, reason) {
        fromTitle = normalizeMoveTitle(fromTitle);
        expectedToTitle = normalizeMoveTitle(expectedToTitle);

        if (!fromTitle) return Promise.resolve({ deleted: false, skipped: 'empty' });

        return resolveRenameRedirectTarget(api, fromTitle).then(function (redirectTo) {
            redirectTo = normalizeMoveTitle(redirectTo);

            if (!redirectTo) {
                return { deleted: false, skipped: 'not_redirect' };
            }

            if (expectedToTitle && normalizeMoveCompare(redirectTo) !== normalizeMoveCompare(expectedToTitle)) {
                throw new Error('기존 이름이 새 이름이 아니라 다른 대상으로 redirect됩니다: ' + redirectTo);
            }

            setStatus('기존 redirect 문서 제거 중... ' + fromTitle);

            return api.postWithToken('csrf', {
                action: 'delete',
                title: fromTitle,
                reason: reason || ('이름 변경 후 남은 redirect 제거: ' + fromTitle + ' → ' + redirectTo),
                format: 'json',
                formatversion: 2,
                errorformat: 'plaintext'
            }).then(function () {
                return { deleted: true, target: redirectTo };
            });
        });
    }

    function runRenameReferenceCleanupOnly() {
        var fromTitle = normalizeMoveTitle($('#dev-tools-rename-from').val());
        var toTitle = normalizeMoveTargetTitle(fromTitle, $('#dev-tools-rename-to').val());
        var shouldDeleteOldRedirect = $('#dev-tools-rename-delete-old').prop('checked');
        var reason = String($('#dev-tools-rename-reason').val() || '이름 변경').trim();

        if (!fromTitle) {
            setStatus('현재 이름이 비어 있습니다.', 'error');
            return;
        }

        setButtonsDisabled(true);
        setStatus('참조/리스트 정리 준비 중...');

        withApi(function (api) {
            var targetPromise = toTitle ? Promise.resolve(toTitle) : resolveRenameRedirectTarget(api, fromTitle);

            targetPromise.then(function (resolvedToTitle) {
                resolvedToTitle = normalizeMoveTitle(resolvedToTitle);

                if (!resolvedToTitle) {
                    throw new Error('새 이름을 입력하거나, 현재 이름이 새 이름으로 넘겨주는 redirect여야 합니다.');
                }

                if (normalizeMoveCompare(fromTitle) === normalizeMoveCompare(resolvedToTitle)) {
                    throw new Error('현재 이름과 새 이름이 같습니다.');
                }

                setStatus('참조/리스트 정리 중...');
                return updateRenameReferences(api, fromTitle, resolvedToTitle).then(function (referenceResult) {
                    if (!shouldDeleteOldRedirect) {
                        return { movedTo: resolvedToTitle, referenceResult: referenceResult, deleteResult: null };
                    }

                    return deleteRenameRedirectPage(api, fromTitle, resolvedToTitle, '이름 변경 후 기존 redirect 제거: ' + fromTitle + ' → ' + resolvedToTitle).then(function (deleteResult) {
                        return { movedTo: resolvedToTitle, referenceResult: referenceResult, deleteResult: deleteResult };
                    });
                });
            }).then(function (result) {
                var referenceResult = result.referenceResult || { changed: 0, failed: 0, scanned: 0 };
                var message = '참조/리스트 정리 완료: ' + fromTitle + ' → ' + result.movedTo +
                    ' / 변경 ' + referenceResult.changed + '개 / 검사 ' + referenceResult.scanned + '개';

                if (referenceResult.failed) message += ' / 실패 ' + referenceResult.failed + '개';
                if (result.deleteResult && result.deleteResult.deleted) message += ' / 기존 redirect 삭제';
                if (result.deleteResult && result.deleteResult.skipped === 'not_redirect') message += ' / 기존 redirect 없음';
                setStatus(message, referenceResult.failed ? 'error' : 'ok');
                setButtonsDisabled(false);
                $('#dev-tools-rename-to').val(result.movedTo);
            }).catch(function (err) {
                setButtonsDisabled(false);
                setStatus('참조/리스트 정리 실패: ' + (err && err.message ? err.message : err), 'error');
            });
        }, function () {
            setButtonsDisabled(false);
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }

    function runDocumentRename() {
        var fromTitle = normalizeMoveTitle($('#dev-tools-rename-from').val());
        var toTitle = normalizeMoveTargetTitle(fromTitle, $('#dev-tools-rename-to').val());
        var reason = String($('#dev-tools-rename-reason').val() || '이름 변경').trim();
        var keepLegacy = $('#dev-tools-rename-legacy').prop('checked');
        var moveTalk = $('#dev-tools-rename-talk').prop('checked');
        var moveSubpages = $('#dev-tools-rename-subpages').prop('checked');
        var shouldUpdateReferences = $('#dev-tools-rename-update-references').prop('checked');
        var shouldDeleteOldRedirect = $('#dev-tools-rename-delete-old').prop('checked');
        var payload;

        if (!fromTitle) {
            setStatus('현재 이름이 비어 있습니다.', 'error');
            return;
        }

        if (!toTitle) {
            setStatus('새 이름이 비어 있습니다.', 'error');
            return;
        }

        if (normalizeMoveCompare(fromTitle) === normalizeMoveCompare(toTitle)) {
            setStatus('현재 이름과 새 이름이 같습니다.', 'error');
            return;
        }

        if (!window.confirm('실제 이름을 변경할까요?\n\n' + fromTitle + '\n→ ' + toTitle + (keepLegacy ? '\n\n기존 이름 redirect를 남깁니다.' : '\n\n기존 이름 redirect를 남기지 않습니다.'))) {
            return;
        }

        payload = {
            action: 'move',
            from: fromTitle,
            to: toTitle,
            reason: reason,
            format: 'json',
            formatversion: 2,
            errorformat: 'plaintext'
        };

        if (moveTalk) payload.movetalk = 1;
        if (moveSubpages) payload.movesubpages = 1;
        if (!keepLegacy) payload.noredirect = 1;

        setButtonsDisabled(true);
        setStatus('실제 이름 변경 중...');

        withApi(function (api) {
            api.postWithToken('csrf', payload).then(function (data) {
                var moved = data && data.move;
                var movedTo = moved && (moved.to || toTitle);

                var cleanupChain = shouldUpdateReferences ? updateRenameReferences(api, fromTitle, movedTo || toTitle) : Promise.resolve(null);

                return cleanupChain.then(function (referenceResult) {
                    if (keepLegacy || !shouldDeleteOldRedirect) {
                        return { movedTo: movedTo, referenceResult: referenceResult, deleteResult: null };
                    }

                    return deleteRenameRedirectPage(api, fromTitle, movedTo || toTitle, '이름 변경 후 기존 redirect 제거: ' + fromTitle + ' → ' + (movedTo || toTitle)).then(function (deleteResult) {
                        return { movedTo: movedTo, referenceResult: referenceResult, deleteResult: deleteResult };
                    });
                });
            }).then(function (result) {
                var movedTo = result && result.movedTo ? result.movedTo : toTitle;
                var referenceResult = result && result.referenceResult;
                var deleteResult = result && result.deleteResult;
                var message = '이름 변경 완료: ' + movedTo;

                if (referenceResult) {
                    message += ' / 참조 ' + referenceResult.changed + '개 정리';
                    if (referenceResult.failed) message += ' / 실패 ' + referenceResult.failed + '개';
                }
                if (deleteResult && deleteResult.deleted) message += ' / 기존 redirect 삭제';
                if (deleteResult && deleteResult.skipped === 'not_redirect') message += ' / 기존 redirect 없음';

                setStatus(message, referenceResult && referenceResult.failed ? 'error' : 'ok');

                if (isCurrentMoveSource(fromTitle)) {
                    window.setTimeout(function () {
                        window.location.href = getDocumentToolUrl(movedTo);
                    }, 700);
                } else {
                    setButtonsDisabled(false);
                    $('#dev-tools-rename-from').val(movedTo);
                    $('#dev-tools-rename-to').val('');
                }
            }).catch(function (code, details) {
                var message = code;

                if (details && details.error && details.error.info) {
                    message = details.error.info;
                } else if (details && details.info) {
                    message = details.info;
                } else if (code && code.message) {
                    message = code.message;
                }

                setButtonsDisabled(false);
                setStatus('이름 변경 실패: ' + message, 'error');
            });
        }, function () {
            setButtonsDisabled(false);
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }


    function normalizeSvgFileTitle(value) {
        var title = String(value || '')
            .replace(/_/g, ' ')
            .replace(/^\s+|\s+$/g, '');

        if (!title) return '';
        if (/^(file|파일):/i.test(title)) return title.replace(/^(file|파일):\s*/i, 'File:');
        return 'File:' + title;
    }

    function svgFileUploadName(title) {
        return normalizeSvgFileTitle(title).replace(/^File:/i, '').trim();
    }

    function parseSvgTitleList(value) {
        var seen = {};
        var list = [];

        String(value || '').split(/[\n,]+/).forEach(function (part) {
            var title = normalizeSvgFileTitle(part);
            var key = title.toLowerCase();
            if (!title || seen[key]) return;
            seen[key] = true;
            list.push(title);
        });

        return list;
    }

    function renderSvgNormalizeLog(items) {
        var html;

        if (!items || !items.length) {
            $('#dev-tools-svg-list').empty();
            return;
        }

        html = items.map(function (item) {
            var cls = item.ok ? 'dev-tools-package-row' : 'dev-tools-package-row dev-tools-package-skipped';
            var status = item.message || '';
            return '<div class="' + cls + '">' +
                '<span class="dev-tools-package-source">' + escapeHtml(item.title || '') + '</span>' +
                '<span class="dev-tools-package-arrow">' + (item.ok ? '✓' : '×') + '</span>' +
                '<span class="dev-tools-package-target">' + escapeHtml(status) + '</span>' +
            '</div>';
        }).join('');

        $('#dev-tools-svg-list').html(html);
    }

    function fillCurrentSvgFileTitle() {
        var ns = Number(mw.config.get('wgNamespaceNumber'));
        var title = mw.config.get('wgPageName') || '';

        if (ns !== 6 || !title) {
            setStatus('현재 문서가 파일 문서가 아닙니다.', 'error');
            return;
        }

        title = title.replace(/_/g, ' ');
        if (!/\.svg$/i.test(title)) {
            setStatus('현재 파일이 SVG가 아닙니다.', 'error');
            return;
        }

        $('#dev-tools-svg-normalize-titles').val(normalizeSvgFileTitle(title));
        setStatus('현재 SVG 파일명을 입력했습니다.', 'ok');
    }

    function fillRecentFlagSvgTitles() {
        setButtonsDisabled(true);
        setStatus('최근 Flag SVG 목록을 가져오는 중...');

        withApi(function (api) {
            api.get({
                action: 'query',
                list: 'allimages',
                aiprefix: 'Flag-',
                aifrom: 'Flag-',
                aiprop: 'timestamp|mime|url',
                ailimit: 50,
                format: 'json',
                formatversion: 2,
                _: Date.now()
            }).then(function (data) {
                var images = data && data.query ? data.query.allimages || [] : [];
                var titles = images.filter(function (image) {
                    var name = image && image.name ? String(image.name) : '';
                    return /\.svg$/i.test(name);
                }).map(function (image) {
                    return 'File:' + image.name;
                });

                $('#dev-tools-svg-normalize-titles').val(titles.join('\n'));
                setStatus('최근 Flag SVG ' + titles.length + '개를 입력했습니다.', titles.length ? 'ok' : 'error');
            }).catch(function (err) {
                setStatus('최근 Flag SVG 목록 로드 실패: ' + (err && err.message ? err.message : err), 'error');
            }).finally(function () {
                setButtonsDisabled(false);
            });
        }, function () {
            setButtonsDisabled(false);
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }

    function parseSvgNumber(value) {
        var match = String(value == null ? '' : value).trim().match(/^-?\d+(?:\.\d+)?/);
        return match ? parseFloat(match[0]) : NaN;
    }

    function parseSvgViewBox(root) {
        var viewBox = root.getAttribute('viewBox') || root.getAttribute('viewbox') || '';
        var parts = viewBox.trim().split(/[\s,]+/).map(function (part) { return parseFloat(part); });
        var width;
        var height;

        if (parts.length === 4 && parts.every(function (value) { return Number.isFinite(value); }) && parts[2] > 0 && parts[3] > 0) {
            return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
        }

        width = parseSvgNumber(root.getAttribute('width'));
        height = parseSvgNumber(root.getAttribute('height'));
        if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
            return { x: 0, y: 0, width: width, height: height };
        }

        return null;
    }

    function formatSvgNumber(value) {
        var rounded = Math.round(value * 1000) / 1000;
        if (Math.abs(rounded) < 0.0005) rounded = 0;
        return String(rounded).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
    }

    function serializeSvgDocument(doc, hadXmlDecl) {
        var text = new XMLSerializer().serializeToString(doc);
        if (hadXmlDecl && text.indexOf('<?xml') !== 0) {
            text = '<?xml version="1.0" encoding="UTF-8"?>\n' + text;
        }
        return text + (text.slice(-1) === '\n' ? '' : '\n');
    }

    function renderSvgTextToCanvas(svgText, viewport) {
        return new Promise(function (resolve, reject) {
            var blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
            var url = URL.createObjectURL(blob);
            var img = new Image();
            var maxSide = 1600;
            var scale = Math.min(maxSide / Math.max(viewport.width, viewport.height), 4);
            var width = Math.max(1, Math.round(viewport.width * scale));
            var height = Math.max(1, Math.round(viewport.height * scale));
            var canvas = document.createElement('canvas');
            var ctx;

            canvas.width = width;
            canvas.height = height;
            ctx = canvas.getContext('2d');

            img.onload = function () {
                try {
                    ctx.clearRect(0, 0, width, height);
                    ctx.drawImage(img, 0, 0, width, height);
                    URL.revokeObjectURL(url);
                    resolve({ canvas: canvas, width: width, height: height });
                } catch (err) {
                    URL.revokeObjectURL(url);
                    reject(err);
                }
            };
            img.onerror = function () {
                URL.revokeObjectURL(url);
                reject(new Error('SVG 렌더링 실패'));
            };
            img.src = url;
        });
    }

    function findCanvasAlphaBounds(canvas) {
        var ctx = canvas.getContext('2d');
        var width = canvas.width;
        var height = canvas.height;
        var data = ctx.getImageData(0, 0, width, height).data;
        var minX = width;
        var minY = height;
        var maxX = -1;
        var maxY = -1;
        var x;
        var y;
        var alpha;
        var index;

        for (y = 0; y < height; y += 1) {
            for (x = 0; x < width; x += 1) {
                index = (y * width + x) * 4 + 3;
                alpha = data[index];
                if (alpha > 3) {
                    if (x < minX) minX = x;
                    if (y < minY) minY = y;
                    if (x > maxX) maxX = x;
                    if (y > maxY) maxY = y;
                }
            }
        }

        if (maxX < minX || maxY < minY) return null;
        return { minX: minX, minY: minY, maxX: maxX, maxY: maxY };
    }

    function cropSvgTextToRenderedPixels(svgText) {
        var parser = new DOMParser();
        var doc = parser.parseFromString(svgText, 'image/svg+xml');
        var root = doc.documentElement;
        var parserError = doc.querySelector('parsererror');
        var viewport;
        var hadXmlDecl = /^\s*<\?xml/i.test(svgText);

        if (parserError || !root || String(root.nodeName || '').toLowerCase() !== 'svg') {
            return Promise.reject(new Error('SVG 파싱 실패'));
        }

        viewport = parseSvgViewBox(root);
        if (!viewport) return Promise.reject(new Error('viewBox/width/height를 읽을 수 없음'));

        return renderSvgTextToCanvas(svgText, viewport).then(function (rendered) {
            var bounds = findCanvasAlphaBounds(rendered.canvas);
            var padX;
            var padY;
            var cropX;
            var cropY;
            var cropW;
            var cropH;
            var oldViewBox;
            var newViewBox;
            var style;

            if (!bounds) throw new Error('보이는 픽셀이 없음');

            padX = Math.max(1, Math.round(rendered.width * 0.002));
            padY = Math.max(1, Math.round(rendered.height * 0.002));
            bounds.minX = Math.max(0, bounds.minX - padX);
            bounds.minY = Math.max(0, bounds.minY - padY);
            bounds.maxX = Math.min(rendered.width - 1, bounds.maxX + padX);
            bounds.maxY = Math.min(rendered.height - 1, bounds.maxY + padY);

            cropX = viewport.x + (bounds.minX / rendered.width) * viewport.width;
            cropY = viewport.y + (bounds.minY / rendered.height) * viewport.height;
            cropW = ((bounds.maxX - bounds.minX + 1) / rendered.width) * viewport.width;
            cropH = ((bounds.maxY - bounds.minY + 1) / rendered.height) * viewport.height;

            if (!(cropW > 0 && cropH > 0)) throw new Error('계산된 크롭 범위가 잘못됨');

            oldViewBox = [viewport.x, viewport.y, viewport.width, viewport.height].map(formatSvgNumber).join(' ');
            newViewBox = [cropX, cropY, cropW, cropH].map(formatSvgNumber).join(' ');

            root.setAttribute('viewBox', newViewBox);
            root.setAttribute('width', formatSvgNumber(cropW));
            root.setAttribute('height', formatSvgNumber(cropH));
            root.setAttribute('overflow', 'hidden');
            root.setAttribute('preserveAspectRatio', 'xMidYMid meet');
            style = root.getAttribute('style') || '';
            if (!/overflow\s*:/i.test(style)) {
                root.setAttribute('style', (style ? style.replace(/;?\s*$/, '; ') : '') + 'overflow:hidden;');
            }

            return {
                text: serializeSvgDocument(doc, hadXmlDecl),
                oldViewBox: oldViewBox,
                newViewBox: newViewBox,
                cropWidth: cropW,
                cropHeight: cropH,
                changed: oldViewBox !== newViewBox
            };
        });
    }

    function fetchSvgFileText(api, title) {
        title = normalizeSvgFileTitle(title);
        return api.get({
            action: 'query',
            prop: 'imageinfo',
            titles: normalizeApiTitle(title),
            iiprop: 'url|mime',
            format: 'json',
            formatversion: 2,
            _: Date.now()
        }).then(function (data) {
            var pages = data && data.query ? data.query.pages || [] : [];
            var page = pages[0];
            var info = page && page.imageinfo ? page.imageinfo[0] : null;
            var url = info && info.url ? info.url : '';

            if (!page || page.missing) throw new Error('파일 문서 없음');
            if (!url) throw new Error('파일 URL 없음');
            if (!/\.svg(?:\?|$)/i.test(url) && info.mime !== 'image/svg+xml') throw new Error('SVG 파일이 아님');

            return fetch(url + (url.indexOf('?') === -1 ? '?' : '&') + '_=' + Date.now(), {
                credentials: 'same-origin',
                cache: 'no-store'
            }).then(function (response) {
                if (!response.ok) throw new Error('SVG 다운로드 실패: HTTP ' + response.status);
                return response.text();
            });
        });
    }

    function uploadNormalizedSvg(api, title, svgText, comment) {
        var filename = svgFileUploadName(title);
        var blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });

        return api.getToken('csrf').then(function (token) {
            var form = new FormData();
            form.append('action', 'upload');
            form.append('filename', filename);
            form.append('file', blob, filename);
            form.append('ignorewarnings', '1');
            form.append('comment', comment || 'Normalize SVG viewBox for CLBI flag thumbnail');
            form.append('token', token);
            form.append('format', 'json');
            form.append('formatversion', '2');

            return fetch(mw.util.wikiScript('api'), {
                method: 'POST',
                credentials: 'same-origin',
                body: form
            }).then(function (response) {
                if (!response.ok) throw new Error('업로드 실패: HTTP ' + response.status);
                return response.json();
            }).then(function (data) {
                if (data && data.error) throw new Error(data.error.info || data.error.code || '업로드 API 오류');
                if (!data || !data.upload || (data.upload.result !== 'Success' && data.upload.result !== 'Warning')) {
                    throw new Error('업로드 결과 확인 실패');
                }
                return data;
            });
        });
    }

    function normalizeOneSvgFile(api, title) {
        return fetchSvgFileText(api, title).then(function (text) {
            return cropSvgTextToRenderedPixels(text).then(function (normalized) {
                return uploadNormalizedSvg(api, title, normalized.text, 'CLBI SVG flag viewBox normalization').then(function () {
                    return {
                        title: normalizeSvgFileTitle(title),
                        ok: true,
                        message: normalized.oldViewBox + ' → ' + normalized.newViewBox
                    };
                });
            });
        });
    }

    function runSvgNormalizeUpload() {
        var titles = parseSvgTitleList($('#dev-tools-svg-normalize-titles').val());
        var results = [];

        if (!titles.length) {
            setStatus('정규화할 SVG 파일명을 입력하세요.', 'error');
            return;
        }

        if (!window.confirm('SVG 파일 ' + titles.length + '개를 실제 보이는 픽셀 범위 기준으로 정규화한 뒤 같은 파일명으로 재업로드합니다. 계속할까요?')) {
            return;
        }

        setButtonsDisabled(true);
        setStatus('SVG 정규화 준비 중...');
        renderSvgNormalizeLog([]);

        withApi(function (api) {
            mapLimit(titles, 1, function (title, index) {
                setStatus('SVG 정규화 중... ' + (index + 1) + '/' + titles.length + ' ' + title);
                return normalizeOneSvgFile(api, title).then(function (result) {
                    results.push(result);
                    renderSvgNormalizeLog(results);
                }).catch(function (err) {
                    results.push({
                        title: normalizeSvgFileTitle(title),
                        ok: false,
                        message: err && err.message ? err.message : String(err)
                    });
                    renderSvgNormalizeLog(results);
                });
            }).then(function () {
                var ok = results.filter(function (item) { return item.ok; }).length;
                var failed = results.length - ok;
                setStatus('SVG 정규화 완료: 성공 ' + ok + '개 / 실패 ' + failed + '개', failed ? 'error' : 'ok');
            }).finally(function () {
                setButtonsDisabled(false);
            });
        }, function () {
            setButtonsDisabled(false);
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }


    function bindEvents() {
        $(document).on('click', '#dev-tools-panel .dev-tools-button-disabled', function (event) {
            event.preventDefault();
            event.stopPropagation();
        });

        $(document).on('click', '#dev-tools-toggle', function () {
            var $panel = $('#dev-tools-panel');
            $panel.toggleClass('dev-tools-collapsed');
            var collapsed = $panel.hasClass('dev-tools-collapsed');
            localStorage.setItem('dev-tools-collapsed', collapsed ? 'true' : 'false');
            $(this).text(collapsed ? '+' : '−');
        });

        $(document).on('click', '.dev-tools-section-collapsible .dev-tools-section-toggle', function () {
            var $section = $(this).closest('.dev-tools-section-collapsible');
            var sectionKey = String($section.data('devtools-section') || '').trim();
            var willCollapse = !$section.hasClass('is-collapsed');
            $section.toggleClass('is-collapsed', willCollapse);
            $(this).attr('aria-expanded', willCollapse ? 'false' : 'true');
            $(this).find('.dev-tools-section-caret').text(willCollapse ? '+' : '−');
            if (sectionKey) {
                localStorage.setItem('dev-tools-section-' + sectionKey + '-collapsed', willCollapse ? 'true' : 'false');
            }
        });

        $(document).on('click', '#dev-tools-export', exportSources);
        $(document).on('click', '#dev-tools-revision-manifest-refresh', function () {
            if (!window.RevisionManifest || typeof window.RevisionManifest.load !== 'function') {
                setStatus('RevisionManifest가 로드되지 않았습니다.', 'error');
                return;
            }
            window.RevisionManifest.load({ force: true }).then(function () {
                refreshRevisionManifestTools();
                setStatus('Revision manifest 동기화 완료', 'ok');
            }).catch(function () {
                refreshRevisionManifestTools();
                setStatus('Revision manifest 동기화 실패', 'error');
            });
        });
        $(document).on('click', '#dev-tools-entry-cache-reset', function () {
            if (!window.EntryCache || typeof window.EntryCache.reset !== 'function') {
                setStatus('EntryCache가 로드되지 않았습니다.', 'error');
                return;
            }
            window.EntryCache.reset().then(function () {
                refreshRevisionManifestTools();
                setStatus('Entry cache 정리 완료', 'ok');
            });
        });
        $(document).on('click', '#dev-tools-boot-preview', function () {
            if (!window.BootGate || typeof window.BootGate.preview !== 'function') {
                setStatus('BootGate preview를 사용할 수 없습니다.', 'error');
                return;
            }
            window.BootGate.preview({
                status: 'Loading screen preview',
                detail: 'DevTools preview · decoration placement: boot-gate',
                progress: 64
            });
            setStatus('로딩 화면 미리보기를 열었습니다. 우측 상단 ×로 닫을 수 있습니다.', 'ok');
        });
        $(document).on('change', '#dev-tools-package-file', analyzeSelectedPackage);
        $(document).on('click', '#dev-tools-package-analyze', analyzeSelectedPackage);
        $(document).on('click', '#dev-tools-package-install', installPackagePlan);
        $(document).on('click', '#dev-tools-svg-current', fillCurrentSvgFileTitle);
        $(document).on('click', '#dev-tools-svg-recent', fillRecentFlagSvgTitles);
        $(document).on('click', '#dev-tools-svg-normalize', runSvgNormalizeUpload);
        $(document).on('click', '#dev-tools-rename-toggle', toggleRenameBox);
        $(document).on('click', '#dev-tools-rename-submit', runDocumentRename);
        $(document).on('click', '#dev-tools-rename-cleanup-submit', runRenameReferenceCleanupOnly);
        $(document).on('keydown', '#dev-tools-rename-to', function (event) {
            if (event.key === 'Enter') runDocumentRename();
        });
        $(document).on('click', '#dev-tools-progress-refresh', refreshProgressSummary);
        $(document).on('click', '#dev-tools-language-refresh', refreshLanguageStatusEditor);
        $(document).on('click', '#dev-tools-language-save', saveLanguageStatusEditor);
        $(document).on('click', '#dev-tools-decoration-edit-toggle', toggleDecorationEditMode);
        $(document).on('click', '#dev-tools-decoration-save', saveDecorationEditor);
        $(document).on('click', '#dev-tools-decoration-cancel', cancelDecorationLayerEditor);
        $(document).on('click', '#dev-tools-decoration-add', addDecorationLayer);
        $(document).on('click', '#dev-tools-decoration-delete', deleteDecorationLayer);
        $(document).on('click', '#dev-tools-decoration-grid-toggle', function () {
            decoSetGridEnabled(!decoIsGridEnabled());
        });
        $(document).on('click', '#dev-tools-decoration-target-continent', targetActiveDecorationContinent);
        $(document).on('click', '#dev-tools-decoration-target-boot', targetBootGateDecorationSurface);
        $(document).on('click', '#dev-tools-decoration-center', centerDecorationLayer);
        $(document).on('click', '#dev-tools-decoration-fit-width', fitDecorationLayerWidth);
        $(document).on('click', '.dev-tools-decoration-layer', function () {
            selectDecorationLayer($(this).attr('data-decoration-id') || '');
        });
        $(document).on('click', '.dev-tools-decoration-work-preview', function () {
            selectDecorationLayer(this.getAttribute('data-decoration-id') || '');
        });
        $(document).on('pointermove.devToolsDecorationLayers', updateDecorationDragMove);
        $(document).on('pointerup.devToolsDecorationLayers pointercancel.devToolsDecorationLayers', stopDecorationDrag);
        $(document).on('keydown.devToolsDecorationLayers', function (event) {
            var tag = event.target && event.target.tagName ? event.target.tagName.toLowerCase() : '';
            var entry;
            var step;
            var x;
            var y;
            if (!decorationEditor.editMode) return;
            if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
            if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
            entry = getDecorationSelectedLayer();
            if (!entry) return;
            event.preventDefault();
            step = event.shiftKey ? 10 : 1;
            x = Number(entry.x) || 0;
            y = Number(entry.y) || 0;
            if (event.key === 'ArrowLeft') x -= step;
            if (event.key === 'ArrowRight') x += step;
            if (event.key === 'ArrowUp') y -= step;
            if (event.key === 'ArrowDown') y += step;
            setDecorationLayerPosition(x, y);
        });
        $(document).on('click', '.clbi-nations-tabpanel-tab[data-continent], [data-nations-era-move]', function () {
            window.setTimeout(function () {
                refreshDecorationContinentReadout();
                if (decorationEditor.editMode) renderDecorationWorkPreviews();
            }, 40);
        });
        $(document).on('input change', '#dev-tools-decoration-target', function () {
            $('#dev-tools-decoration-placement').val('');
            updateDecorationLayerFromForm();
        });
        $(document).on('input change', '#dev-tools-decoration-label, #dev-tools-decoration-asset-type, #dev-tools-decoration-src, #dev-tools-decoration-era, #dev-tools-decoration-continent, #dev-tools-decoration-x, #dev-tools-decoration-y, #dev-tools-decoration-width, #dev-tools-decoration-height, #dev-tools-decoration-opacity, #dev-tools-decoration-z', updateDecorationLayerFromForm);


        $(document).on('click', '#dev-tools-xp-add', function () {
            var amount = parseInt($('#dev-tools-xp-amount').val(), 10);
            var reason = $('#dev-tools-xp-reason').val() || '관리자 테스트';

            if (!Number.isFinite(amount) || amount <= 0) {
                setStatus('XP 값이 잘못되었습니다.', 'error');
                return;
            }

            runProgressAdmin('add_xp', {
                amount: amount,
                reason: reason
            });
        });

        $(document).on('click', '#dev-tools-daily-reset', function () {
            runProgressAdmin('reset_daily');
        });

        $(document).on('click', '#dev-tools-level-reset', function () {
            if (!window.confirm('레벨/총 경험치와 오늘 보너스 카운트를 리셋할까요?')) return;
            runProgressAdmin('reset_progress');
        });
    }

    $(function () {
        buildPanel();
        bindEvents();

        if (isOwner()) {
            refreshProgressSummary();
            refreshLanguageStatusEditor();
            refreshRevisionManifestTools();
            window.setInterval(refreshRevisionManifestTools, 60000);
        }

        writeDecorationDefaults();

        if (mw.hook) {
            mw.hook('wikipage.content').add(refreshDocumentTools);
        }
    });
})(mediaWiki, jQuery);