// Bulk Rename (macOS) — UI logic. Rename engine reused from the Eagle plugin; data layer
// talks to the native Swift bridge (real files, Finder tags/labels/comments).

let allFiles = [];          // FileInfo[] currently loaded
let currentFolder = null;
let metaOverrides = {};      // path -> { tags?:string[], label?:int, comment?:string, name?:string }
let tagsChips = [];
let tagPool = [];
let lastSnapshot = null;     // [{path,newPath,name,tags,label,comment}] for undo
let lastNewPaths = [];       // for "Reveal in Finder"
let previewTimer = null, statsTimer = null, saveCfgTimer = null;
let thumbCache = {};
let lastFocusedInput = null;
let selectedPaths = new Set();   // files ticked for processing (default: all)
let itemMode = 'files';          // 'files' or 'folders' (Rename Files / Rename Folders tabs)
let folderView = false;          // true: list mirrors a folder (Choose / dropped folder / "Show all in folder"); false: a hand-curated drop set
let viewFolders = [];            // folder paths backing a folder view (for Refresh + the Subfolders re-list)

const COLOR_NAMES = ['', 'Gray', 'Green', 'Purple', 'Blue', 'Yellow', 'Red', 'Orange'];
const META_TAB = { 'tab-tags': 'tags', 'tab-label': 'label', 'tab-notes': 'notes' };
const PRESETS_KEY = 'bulkRenameMac.presets';
const PRESET_FIELDS = [
    ['rename-newname', 'text'], ['rename-seq', 'check'], ['rename-seq-pad', 'text'], ['rename-seq-start', 'text'], ['rename-seq-sep', 'text'],
    ['opt-include-ext', 'check'],
    ['text-find', 'text'], ['text-replace', 'text'], ['text-match-case', 'check'], ['text-use-regex', 'check'],
    ['text-prefix', 'text'], ['text-suffix', 'text'],
    ['remove-first-n', 'text'], ['remove-last-n', 'text'],
    ['strip-digits', 'check'], ['strip-symbols', 'check'], ['strip-spaces', 'check'],
    ['num-enable', 'check'], ['num-start', 'text'], ['num-step', 'text'], ['num-padding', 'text'], ['num-separator', 'text']
];

document.addEventListener('DOMContentLoaded', init);

function init() {
    initTabs();
    initFocusTracking();
    initTokenButtons();
    initInputs();
    initPresets();
    initMeta();
    initGridEvents();
    document.getElementById('btn-choose').addEventListener('click', chooseFolder);
    document.getElementById('btn-refresh').addEventListener('click', refreshFolder);
    document.getElementById('recurse-toggle').addEventListener('change', refreshFolder);
    document.getElementById('btn-rename').addEventListener('click', executeApply);
    document.getElementById('btn-undo').addEventListener('click', undoLastApply);
    document.getElementById('btn-reveal').addEventListener('click', () => revealFiles(lastNewPaths));
    document.getElementById('btn-clear').addEventListener('click', clearAll);
    document.getElementById('show-all-toggle').addEventListener('change', onShowAllToggle);
    document.getElementById('select-kind').addEventListener('change', (e) => {
        const v = e.target.value;
        if (v === 'all') selectedPaths = new Set(allFiles.map(f => f.path));
        else if (v === 'none') selectedPaths = new Set();
        else if (v.startsWith('ext:')) {
            const k = v.slice(4);
            selectedPaths = new Set(allFiles.filter(f => (k === '—' ? !f.ext : (f.ext || '').toLowerCase() === k)).map(f => f.path));
        } else return;
        e.target.value = '';   // it's an action menu — snap back to the placeholder
        generatePreview();
    });
    document.getElementById('sort-key').addEventListener('change', () => { sortFiles(); generatePreview(); });
    document.getElementById('sort-dir').addEventListener('click', () => {
        const b = document.getElementById('sort-dir');
        const desc = b.getAttribute('data-dir') !== 'desc';
        b.setAttribute('data-dir', desc ? 'desc' : 'asc');
        b.textContent = desc ? '↓' : '↑';
        sortFiles(); generatePreview();
    });
    window.onNativeDrop = handleNativeDrop;
    generatePreview();
    restoreLastConfig();   // bring back the rename setup from the last session
}

// Populate the "Select by kind" menu with the distinct extensions present (+ counts).
function populateSelectKind() {
    const sel = document.getElementById('select-kind');
    if (!sel) return;
    const counts = {};
    allFiles.forEach(f => { const k = (f.ext || '—').toLowerCase(); counts[k] = (counts[k] || 0) + 1; });
    const kinds = Object.keys(counts).sort();
    let html = '<option value="">by…</option><option value="all">All</option><option value="none">None</option>';
    if (kinds.length) html += '<option value="" disabled>──────────</option>';
    kinds.forEach(k => {
        const label = k === '—' ? 'no extension' : '.' + k;
        html += `<option value="ext:${escapeAttr(k)}">${escapeHtml(label)} (${counts[k]})</option>`;
    });
    sel.innerHTML = html;
}

// Sort the loaded file list (drives both the displayed order and the auto-numbering counter).
function sortFiles() {
    const key = val('sort-key') || 'name';
    const dir = document.getElementById('sort-dir').getAttribute('data-dir') === 'desc' ? -1 : 1;
    const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
    allFiles.sort((a, b) => {
        let r = 0;
        if (key === 'created') r = (a.created || 0) - (b.created || 0);
        else if (key === 'modified') r = (a.modified || 0) - (b.modified || 0);
        else if (key === 'kind') r = (a.ext || '').localeCompare(b.ext || '') || byName(a, b);
        else if (key === 'size') r = (a.size || 0) - (b.size || 0);
        else r = byName(a, b);
        return (r || byName(a, b)) * dir;
    });
}

// ---------- DOM helpers ----------
function val(id) { const el = document.getElementById(id); return el ? el.value : ''; }
function checked(id) { const el = document.getElementById(id); return el ? el.checked : false; }
function intVal(id, fb) { const v = parseInt(val(id), 10); return isNaN(v) ? fb : v; }
function activeField() { const a = document.querySelector('.tab-panel.active'); return META_TAB[a ? a.id : ''] || 'rename'; }

function initTabs() {
    document.querySelectorAll('.tab-btn').forEach(btn => {
        btn.addEventListener('click', () => {
            document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
            document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
            btn.classList.add('active');
            document.getElementById(btn.getAttribute('data-tab')).classList.add('active');
            // The "Rename Files" / "Rename Folders" tabs switch what we operate on.
            const mode = btn.getAttribute('data-mode');
            if (mode && mode !== itemMode) {
                itemMode = mode;
                setNativeMode();
                relistForMode(); return;   // folder view re-lists for the new mode; a curated set just re-renders
            }
            generatePreview();
        });
    });
}
function setNativeMode() { if (window.hasNative) nativeCall('setMode', { kind: itemMode }).catch(() => {}); }
async function relistForMode() {
    if (folderView && viewFolders.length) await reloadCurrent(false);  // re-list folders for the new mode
    else generatePreview();                                            // curated set: just re-render
}

function initFocusTracking() {
    ['rename-newname', 'rename-seq-sep', 'text-find', 'text-replace', 'text-prefix', 'text-suffix', 'num-separator', 'meta-desc'].forEach(id => {
        const el = document.getElementById(id);
        if (el) el.addEventListener('focus', () => { lastFocusedInput = el; });
    });
}

function initTokenButtons() {
    document.querySelectorAll('.token-btn').forEach(btn => btn.addEventListener('click', () => insertToken(btn.getAttribute('data-token'))));
}
function insertToken(token) {
    let target = lastFocusedInput || document.getElementById('rename-newname');
    if (!target) return;
    const hasSel = target.selectionStart != null && target.selectionEnd != null;
    const start = hasSel ? target.selectionStart : target.value.length;
    const end = hasSel ? target.selectionEnd : target.value.length;
    target.value = target.value.substring(0, start) + token + target.value.substring(end);
    const caret = start + token.length;
    target.focus();
    try { target.setSelectionRange(caret, caret); } catch (e) { }
    lastFocusedInput = target;
    generatePreview();
    scheduleSaveConfig();
}

function initInputs() {
    [
        'rename-newname', 'rename-seq', 'rename-seq-pad', 'rename-seq-start', 'rename-seq-sep', 'opt-include-ext',
        'text-find', 'text-replace', 'text-prefix', 'text-suffix', 'text-match-case', 'text-use-regex',
        'remove-first-n', 'remove-last-n', 'strip-digits', 'strip-symbols', 'strip-spaces',
        'num-enable', 'num-start', 'num-step', 'num-padding', 'num-separator', 'meta-desc'
    ].forEach(id => {
        const el = document.getElementById(id);
        if (el) el.addEventListener(el.type === 'checkbox' ? 'change' : 'input', schedulePreview);
    });
    const seqCb = document.getElementById('rename-seq');
    if (seqCb) seqCb.addEventListener('change', updateSeqOptions);
    updateSeqOptions();
    document.querySelectorAll('input[name="case-option"]').forEach(r => r.addEventListener('change', schedulePreview));
    document.querySelectorAll('input[name="num-position"]').forEach(r => r.addEventListener('change', schedulePreview));
}

function updateSeqOptions() {
    const o = document.getElementById('seq-options');
    if (o) o.classList.toggle('show', checked('rename-seq'));
}
function schedulePreview() { clearTimeout(previewTimer); previewTimer = setTimeout(generatePreview, 100); scheduleSaveConfig(); }
function scheduleStats() { clearTimeout(statsTimer); statsTimer = setTimeout(updateStatsOnly, 120); }

// ---------- Presets ----------
function loadPresets() { try { return JSON.parse(localStorage.getItem(PRESETS_KEY)) || {}; } catch (e) { return {}; } }
function savePresets(p) { try { localStorage.setItem(PRESETS_KEY, JSON.stringify(p)); } catch (e) { console.error(e); } }
function presetHas(p, n) { return Object.prototype.hasOwnProperty.call(p, n); }
function presetSet(p, n, v) { Object.defineProperty(p, n, { value: v, enumerable: true, writable: true, configurable: true }); }
function getConfig() {
    const cfg = {};
    PRESET_FIELDS.forEach(([id, type]) => { const el = document.getElementById(id); if (el) cfg[id] = (type === 'check') ? el.checked : el.value; });
    cfg['case-option'] = document.querySelector('input[name="case-option"]:checked').value;
    cfg['num-position'] = document.querySelector('input[name="num-position"]:checked').value;
    return cfg;
}
function applyConfig(cfg) {
    if (!cfg) return;
    PRESET_FIELDS.forEach(([id, type]) => { const el = document.getElementById(id); if (el && (id in cfg)) { if (type === 'check') el.checked = !!cfg[id]; else el.value = cfg[id]; } });
    if (cfg['case-option']) { const r = document.querySelector(`input[name="case-option"][value="${cfg['case-option']}"]`); if (r) r.checked = true; }
    if (cfg['num-position']) { const r = document.querySelector(`input[name="num-position"][value="${cfg['num-position']}"]`); if (r) r.checked = true; }
    updateSeqOptions();
    generatePreview();
    scheduleSaveConfig();   // applying a preset also becomes the remembered "last rename"
}

// ---------- Remember the last rename setup across app restarts ----------
// The whole rename configuration (name pattern, find/replace, numbering, casing, strip,
// prefix/suffix…) is persisted NATIVELY (UserDefaults) on every change and restored on launch,
// so reopening the app brings back exactly the rename you last set up — no preset needed.
const LAST_CFG_KEY = 'bulkRenameMac.lastConfig';
function scheduleSaveConfig() {
    clearTimeout(saveCfgTimer);
    saveCfgTimer = setTimeout(() => {
        let json;
        try { json = JSON.stringify(getConfig()); } catch (e) { return; }
        try { localStorage.setItem(LAST_CFG_KEY, json); } catch (e) { }   // fast path
        if (window.hasNative) nativeCall('savePrefs', { data: json }).catch(() => { });  // durable path
    }, 350);
}
async function restoreLastConfig() {
    let json = null;
    if (window.hasNative) {
        try { const r = await nativeCall('loadPrefs', {}); if (r && r.data) json = r.data; } catch (e) { }
    }
    if (!json) { try { json = localStorage.getItem(LAST_CFG_KEY); } catch (e) { } }   // fallback
    if (!json) return;
    try { applyConfig(JSON.parse(json)); } catch (e) { }
}
function refreshPresetDropdown(selectName) {
    const sel = document.getElementById('preset-select');
    const presets = loadPresets();
    sel.innerHTML = '<option value="">— Presets —</option>';
    Object.keys(presets).sort((a, b) => a.localeCompare(b)).forEach(n => { const o = document.createElement('option'); o.value = n; o.textContent = n; sel.appendChild(o); });
    if (selectName && presetHas(presets, selectName)) sel.value = selectName;
    document.getElementById('btn-preset-delete').disabled = !sel.value;
}
function initPresets() {
    refreshPresetDropdown();
    const sel = document.getElementById('preset-select');
    sel.addEventListener('change', () => { const p = loadPresets(); if (sel.value && presetHas(p, sel.value)) applyConfig(p[sel.value]); document.getElementById('btn-preset-delete').disabled = !sel.value; });
    document.getElementById('btn-preset-save').addEventListener('click', async () => {
        const name = ((await promptDialog('Save Preset', '', 'Preset name…', 'Save')) || '').trim();
        if (!name) return;
        const p = loadPresets();
        if (presetHas(p, name) && !(await confirmDialog('Overwrite Preset', `A preset named "${name}" already exists. Overwrite it?`, 'Overwrite'))) return;
        presetSet(p, name, getConfig()); savePresets(p); refreshPresetDropdown(name); showToast(`Preset "${name}" saved.`);
    });
    document.getElementById('btn-preset-delete').addEventListener('click', async () => {
        const name = sel.value; if (!name) return;
        if (!(await confirmDialog('Delete Preset', `Delete preset "${name}"?`, 'Delete'))) return;
        const p = loadPresets(); delete p[name]; savePresets(p); refreshPresetDropdown(); showToast(`Preset "${name}" deleted.`);
    });
}

// ---------- Data layer (native bridge) ----------
// Two ways the list gets populated:
//   • FOLDER VIEW (folderView=true): the list mirrors a whole folder's contents — entered via
//     "Choose Folder", dropping a folder, or the "Show all in folder" toggle. Refresh + after-apply
//     RE-LIST the folder(s) in `viewFolders`.
//   • CURATED SET (folderView=false, the default for dropped files): the list is exactly the files
//     the user dropped. Dropping more files ADDS to it; nothing auto-imports the surrounding folder.
//     Refresh + after-apply RE-STAT only the loaded files — they never expand to the whole folder.
function normalizeInfo(f) {
    f.parent = (f.path || '').replace(/\/[^/]*$/, '').replace(/.*\//, '');  // folder NAME (for the [parent] token)
    f.tags = f.tags || [];
    return f;
}
function distinctParents(files) {
    const set = new Set();
    (files || []).forEach(f => { if (f && f.path) set.add(f.path.replace(/\/[^/]*$/, '')); });
    return [...set];
}
function setShowAllCheckbox(on) { const c = document.getElementById('show-all-toggle'); if (c) c.checked = on; }

// A drop arrives from native as {folder, files, dropKind}.
function handleNativeDrop(payload) {
    const dropKind = payload.dropKind || 'files';
    if (dropKind === 'folder') {                 // a dropped folder = a deliberate "show this folder"
        enterFolderView([payload.folder], { files: payload.files });
        return;
    }
    if (folderView) {                            // "Show all in folder" is on → list the dropped files' folder(s)
        const folders = distinctParents(payload.files);
        enterFolderView(folders.length ? folders : (payload.folder ? [payload.folder] : []));
    } else {                                     // default → ADD the dropped files to the curated set
        mergeDroppedFiles(payload);
    }
}

async function chooseFolder() {
    try {
        const r = await nativeCall('chooseFolder', { recursive: checked('recurse-toggle'), kind: itemMode });
        if (r && r.folder) enterFolderView([r.folder], { files: r.files });   // the big CTA = deliberate folder view
    } catch (e) { showToast('Could not open folder: ' + e.message); }
}

// Switch to (or refresh) a live folder view of `folders`. If opts.files is supplied it's used
// directly (native already listed it); otherwise the folders are listed now.
async function enterFolderView(folders, opts) {
    if (!folders || !folders.length) { showToast('Drop files or choose a folder first.'); setShowAllCheckbox(folderView); return; }
    folderView = true; setShowAllCheckbox(true);
    viewFolders = folders.slice();
    currentFolder = folders[0];
    if (opts && opts.files) loadFiles({ folder: currentFolder, files: opts.files }, true);
    else await reloadCurrent(false);   // brand-new view → clear undo
}

// Re-read whatever is loaded. preserveUndo=true keeps an Undo created by a just-finished apply.
async function reloadCurrent(preserveUndo) {
    if (folderView) {
        try {
            let all = [];
            for (const f of viewFolders) {
                const r = await nativeCall('listFolder', { folder: f, recursive: checked('recurse-toggle'), kind: itemMode });
                (r.files || []).forEach(x => all.push(x));
            }
            loadFiles({ folder: viewFolders[0] || currentFolder, files: all }, !preserveUndo);
        } catch (e) { showToast('Could not read folder: ' + e.message); }
    } else {
        await refreshFilesetPaths(allFiles.map(f => f.path), preserveUndo);
    }
}

// Re-stat an explicit list of paths (curated set). Paths that no longer exist drop out.
async function refreshFilesetPaths(paths, preserveUndo) {
    if (!paths.length) return;
    try {
        const r = await nativeCall('statPaths', { paths });
        loadFiles({ folder: currentFolder, files: r.files || [] }, !preserveUndo);
    } catch (e) { showToast('Could not refresh files: ' + e.message); }
}

// The "Refresh" button + the "Subfolders" toggle.
async function refreshFolder() {
    if (folderView) {
        if (!viewFolders.length && currentFolder) viewFolders = [currentFolder];
        if (!viewFolders.length) return;
    } else if (!allFiles.length) return;
    await reloadCurrent(true);   // manual refresh preserves an existing Undo
}

// The "Show all in folder" toggle.
async function onShowAllToggle(e) {
    if (e.target.checked) {
        const folders = allFiles.length ? distinctParents(allFiles) : (currentFolder ? [currentFolder] : []);
        await enterFolderView(folders);   // expand to the whole folder(s) the files live in
    } else {
        folderView = false; viewFolders = [];   // stop auto-expanding; keep what's shown as a curated set
        updateFolderLabel();
    }
}

// ADD dropped files to the curated set (never replaces — only "Clear" empties it).
function mergeDroppedFiles(payload) {
    const incoming = (payload.files || []).map(normalizeInfo);
    const have = new Set(allFiles.map(f => f.path));
    let added = 0;
    incoming.forEach(f => { if (!have.has(f.path)) { allFiles.push(f); have.add(f.path); selectedPaths.add(f.path); added++; } });
    if (payload.folder) currentFolder = payload.folder;   // remembered for a later "Show all in folder"
    sortFiles();
    populateSelectKind();
    loadTagPool();
    updateFolderLabel();
    document.getElementById('file-count-val').innerText = allFiles.length;
    generatePreview(true);
    if (added === 0 && incoming.length) showToast('Those files are already in the list.');
}

function loadFiles(payload, isNewFolder) {
    currentFolder = payload.folder || null;
    allFiles = (payload.files || []).map(normalizeInfo);
    metaOverrides = {};
    thumbCache = {};
    sortFiles();
    populateSelectKind();
    selectedPaths = new Set(allFiles.map(f => f.path));   // everything ticked by default
    // Only a NEW view (Choose / drop / show-all) clears the undo + reveal state; a post-apply or
    // manual re-read (isNewFolder === false) must preserve the Undo the apply just created.
    if (isNewFolder !== false) {
        lastSnapshot = null;
        lastNewPaths = [];
        document.getElementById('btn-undo').classList.add('hidden');
        document.getElementById('btn-reveal').classList.add('hidden');
    }
    document.getElementById('file-count-val').innerText = allFiles.length;
    updateFolderLabel();
    loadTagPool();
    generatePreview(true);
}
function shorten(p) { const parts = p.split('/').filter(Boolean); return parts.length > 3 ? '…/' + parts.slice(-2).join('/') : p; }
function updateFolderLabel() {
    const el = document.getElementById('folder-label');
    if (!allFiles.length) { el.innerText = (folderView && currentFolder) ? shorten(currentFolder) : 'No folder chosen'; return; }
    if (folderView && currentFolder) { el.innerText = shorten(currentFolder); return; }
    const parents = distinctParents(allFiles);
    el.innerText = parents.length === 1 ? shorten(parents[0]) : `${allFiles.length} items · ${parents.length} folders`;
}

// Clear the loaded files and reset to the empty state (operation settings are kept).
function clearAll() {
    allFiles = [];
    currentFolder = null;
    folderView = false; viewFolders = [];
    setShowAllCheckbox(false);
    selectedPaths = new Set();
    metaOverrides = {};
    thumbCache = {};
    lastSnapshot = null;
    lastNewPaths = [];
    document.getElementById('btn-undo').classList.add('hidden');
    document.getElementById('btn-reveal').classList.add('hidden');
    document.getElementById('file-count-val').innerText = 0;
    document.getElementById('folder-label').innerText = 'No folder chosen';
    populateSelectKind();
    generatePreview();
}
function loadTagPool() {
    const pool = new Set();
    allFiles.forEach(f => (f.tags || []).forEach(t => pool.add(t)));
    tagPool = [...pool].filter(Boolean).sort();
    const dl = document.getElementById('tags-datalist');
    if (dl) dl.innerHTML = tagPool.map(t => `<option value="${escapeAttr(t)}"></option>`).join('');
}

// ---------- token + name engine ----------
function fmtDate(ms) {
    const d = new Date(ms || 0);
    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function escapeDollars(s) { return String(s).replace(/\$/g, '$$$$'); }
function escapeHtml(t) { const m = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }; return String(t).replace(/[&<>"']/g, c => m[c]); }
function escapeAttr(s) { return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }

function resolveTokens(text, item, forReplacement = false) {
    if (!text) return '';
    const enc = forReplacement ? escapeDollars : (s => String(s));
    const md = new Date(item.modified || item.created || 0);
    const p2 = n => String(n).padStart(2, '0');
    const lookup = {
        name: () => item.basename || '',
        ext: () => item.ext || '',
        parent: () => item.parent || '',
        tags: () => (item.tags || []).join(', '),
        tag: () => (item.tags && item.tags[0]) || '',
        created: () => fmtDate(item.created),
        modified: () => fmtDate(item.modified),
        date: () => fmtDate(item.modified),
        YYYY: () => String(md.getFullYear()),
        MM: () => p2(md.getMonth() + 1),
        DD: () => p2(md.getDate())
    };
    return text.replace(/\[(name|ext|parent|tags|tag|created|modified|date|YYYY|MM|DD)\]/g, (m, k) => enc(lookup[k]()));
}

function convertCase(str, opt) {
    if (!str) return '';
    switch (opt) {
        case 'lower': return str.toLowerCase();
        case 'upper': return str.toUpperCase();
        case 'title': return str.replace(/\b\w/g, c => c.toUpperCase());
        case 'sentence': return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
        default: return str;
    }
}

function buildContext() {
    const findText = val('text-find');
    const matchCase = checked('text-match-case');
    const useRegex = checked('text-use-regex');
    let regex = null, regexValid = true, regexError = '';
    if (findText) {
        const flags = matchCase ? 'g' : 'gi';
        if (useRegex) { try { regex = new RegExp(findText, flags); } catch (e) { regexValid = false; regexError = e.message || String(e); } }
        else { regex = new RegExp(findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags); }
    }
    return {
        includeExt: checked('opt-include-ext'),
        newBase: val('rename-newname'), renameSeq: checked('rename-seq'),
        renameSeqPad: intVal('rename-seq-pad', 2), renameSeqStart: intVal('rename-seq-start', 1), renameSeqSep: val('rename-seq-sep'),
        findText, replaceText: val('text-replace'), useRegex, regex, regexValid, regexError,
        removeFirst: intVal('remove-first-n', 0), removeLast: intVal('remove-last-n', 0),
        stripDigits: checked('strip-digits'), stripSymbols: checked('strip-symbols'), stripSpaces: checked('strip-spaces'),
        caseOption: document.querySelector('input[name="case-option"]:checked').value,
        prefix: val('text-prefix'), suffix: val('text-suffix'),
        numEnable: checked('num-enable'), numStart: intVal('num-start', 1), numStep: intVal('num-step', 1),
        numPadding: intVal('num-padding', 3), numSeparator: val('num-separator'),
        numPosition: document.querySelector('input[name="num-position"]:checked').value
    };
}

// Produce the new FULL filename for a file (basename transformed, extension reattached unless includeExt).
function calculateNewName(item, index, ctx) {
    const ext = item.ext || '';
    const source = ctx.includeExt ? item.name : (item.basename || '');
    const hasBase = ctx.newBase && ctx.newBase.trim() !== '';
    let newName = hasBase ? resolveTokens(ctx.newBase, item).trim() : source;

    if (ctx.findText && ctx.regex && ctx.regexValid) {
        const repl = ctx.useRegex ? resolveTokens(ctx.replaceText, item, true) : resolveTokens(escapeDollars(ctx.replaceText), item, true);
        newName = newName.replace(ctx.regex, repl);
    }
    if (ctx.removeFirst > 0) newName = newName.substring(ctx.removeFirst);
    if (ctx.removeLast > 0 && newName.length >= ctx.removeLast) newName = newName.substring(0, newName.length - ctx.removeLast);
    if (ctx.stripDigits) newName = newName.replace(/\d/g, '');
    if (ctx.stripSymbols) newName = newName.replace(/[^\w\s一-鿿-]/gi, '');
    if (ctx.stripSpaces) newName = newName.replace(/\s/g, '');
    if (ctx.caseOption !== 'none') newName = convertCase(newName, ctx.caseOption);
    if (ctx.prefix) newName = resolveTokens(ctx.prefix, item) + newName;
    if (ctx.suffix) newName = newName + resolveTokens(ctx.suffix, item);
    if (ctx.numEnable) {
        const n = ctx.numStart + (index * ctx.numStep);
        const pad = String(n).padStart(ctx.numPadding, '0');
        newName = ctx.numPosition === 'prefix' ? pad + ctx.numSeparator + newName : newName + ctx.numSeparator + pad;
    }
    if (hasBase) {
        const sep = ctx.renameSeq ? ctx.renameSeqSep : '_';
        const pad = ctx.renameSeq ? ctx.renameSeqPad : 4;
        const start = ctx.renameSeq ? ctx.renameSeqStart : 1;
        newName = newName + sep + String(start + index).padStart(pad, '0');
    }
    // A filename must never contain a path separator (would move the file elsewhere).
    newName = newName.trim().replace(/\//g, '-');
    if (!newName || newName === '.' || newName === '..') newName = 'Untitled';
    if (!ctx.includeExt && ext) newName = newName + '.' + ext;
    return newName;
}
function stripExt(name, ext) { return (ext && name.toLowerCase().endsWith('.' + ext.toLowerCase())) ? name.slice(0, -(ext.length + 1)) : name; }

// ---------- metadata ----------
function sameTags(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; }
function uniqTags(arr) { const s = new Set(), o = []; arr.forEach(t => { if (t && !s.has(t)) { s.add(t); o.push(t); } }); return o; }

function metaContext() {
    const hasOv = f => Object.keys(metaOverrides).some(p => f in (metaOverrides[p] || {}));
    const desc = val('meta-desc');
    const lp = document.getElementById('label-picker');
    const labelBulk = lp ? (parseInt(lp.getAttribute('data-value'), 10) || 0) : 0;
    const labelEnabled = checked('meta-label-enable');
    const tm = document.querySelector('input[name="tags-mode"]:checked');
    const dm = document.querySelector('input[name="desc-mode"]:checked');
    return {
        tagsMode: tm ? tm.value : 'add', tagsBulk: tagsChips.slice(), tagsEngaged: tagsChips.length > 0 || hasOv('tags'),
        labelEnabled, labelBulk, labelEngaged: labelEnabled || hasOv('label'),
        descMode: dm ? dm.value : 'replace', desc, descEngaged: desc.trim() !== '' || hasOv('comment')
    };
}

function computeMeta(item, mctx, newBasename) {
    const ov = metaOverrides[item.path] || {};
    let tags = (item.tags || []).slice();
    if ('tags' in ov) tags = ov.tags.slice();
    else if (mctx.tagsBulk.length) {
        if (mctx.tagsMode === 'replace') tags = mctx.tagsBulk.slice();
        else if (mctx.tagsMode === 'remove') tags = (item.tags || []).filter(t => !mctx.tagsBulk.includes(t));
        else tags = uniqTags([...(item.tags || []), ...mctx.tagsBulk]);
    }
    const tokenItem = Object.assign({}, item, { basename: newBasename != null ? newBasename : item.basename, tags });

    let label = item.label || 0;
    if ('label' in ov) label = ov.label;
    else if (mctx.labelEnabled) label = mctx.labelBulk;

    let comment = item.comment || '';
    if ('comment' in ov) comment = ov.comment;
    else if (mctx.desc.trim() !== '') {
        const v = resolveTokens(mctx.desc, tokenItem);
        if (mctx.descMode === 'replace') comment = v;
        else if (mctx.descMode === 'prepend') comment = v + (item.comment || '');
        else comment = (item.comment || '') + v;
    }
    return { tags, label, comment };
}

function computePlannedAll(ctx, mctx) {
    let renameIdx = 0;   // counter advances only over SELECTED files, so numbering stays 1..N
    return allFiles.map((item) => {
        const selected = selectedPaths.has(item.path);
        if (!selected) {
            // Unticked files are left completely untouched.
            return { item, selected: false, name: item.name, nameChanged: false, nameConflict: false,
                tags: (item.tags || []).slice(), label: item.label || 0, comment: item.comment || '' };
        }
        const name = ctx.regexValid ? calculateNewName(item, renameIdx, ctx) : item.name;
        renameIdx++;
        const m = computeMeta(item, mctx, stripExt(name, item.ext));
        return { item, selected: true, name, nameChanged: ctx.regexValid && name !== item.name, nameConflict: false, ...m };
    });
}

function dirOf(path) { return path.replace(/\/[^/]*$/, ''); }

function computeStats(planned, mctx) {
    // Collisions per-directory, case-insensitive (default APFS). Every file's FINAL name counts
    // (ticked → new name, unticked → current name), so a rename clashing with an untouched file is caught.
    const counts = new Map();
    planned.forEach(p => { const k = dirOf(p.item.path) + '\n' + p.name.toLowerCase(); counts.set(k, (counts.get(k) || 0) + 1); });
    // Only flag SELECTED files — an unticked file keeps its (already-unique) name.
    planned.forEach(p => { p.nameConflict = p.selected && counts.get(dirOf(p.item.path) + '\n' + p.name.toLowerCase()) > 1; });
    const conflictRows = planned.filter(p => p.nameConflict).length;
    const conflictGroups = new Set(planned.filter(p => p.nameConflict).map(p => dirOf(p.item.path) + '\n' + p.name.toLowerCase())).size;

    const sel = planned.filter(p => p.selected);
    const nameChanges = sel.filter(p => p.nameChanged).length;
    const tagChanges = mctx.tagsEngaged ? sel.filter(p => !sameTags(p.tags, p.item.tags || [])).length : 0;
    const labelChanges = mctx.labelEngaged ? sel.filter(p => p.label !== (p.item.label || 0)).length : 0;
    const commentChanges = mctx.descEngaged ? sel.filter(p => p.comment !== (p.item.comment || '')).length : 0;
    const totalChanged = sel.filter(p =>
        p.nameChanged ||
        (mctx.tagsEngaged && !sameTags(p.tags, p.item.tags || [])) ||
        (mctx.labelEngaged && p.label !== (p.item.label || 0)) ||
        (mctx.descEngaged && p.comment !== (p.item.comment || ''))
    ).length;
    const selectedCount = sel.length;
    return { conflictRows, conflictGroups, nameChanges, tagChanges, labelChanges, commentChanges, totalChanged, selectedCount };
}

function generatePreview(animate) {
    const body = document.getElementById('preview-list-body');
    document.getElementById('btn-clear').classList.toggle('hidden', allFiles.length === 0);
    document.getElementById('count-noun').innerText = itemMode === 'folders' ? 'folders' : 'files';
    const extOpt = document.getElementById('opt-include-ext');
    const extRow = extOpt ? extOpt.closest('.checkbox-row') : null;
    if (extRow) extRow.style.display = itemMode === 'folders' ? 'none' : '';   // folders have no extension
    if (allFiles.length === 0) {
        document.getElementById('preview-thead').innerHTML = '<tr><th></th><th>File</th><th>New Name</th></tr>';
        body.innerHTML = `<tr><td colspan="3" class="empty-state">Drag files or a folder here, or click <strong>Choose Folder</strong>.</td></tr>`;
        document.getElementById('sel-summary').innerText = '';
        document.getElementById('conflict-banner').classList.add('hidden');
        const btn = document.getElementById('btn-rename'); btn.disabled = true; btn.classList.remove('btn-pulse');
        document.getElementById('rename-summary').innerText = 'Ready';
        updateRegexUI(null);
        return;
    }
    const ctx = buildContext();
    updateRegexUI(ctx);
    const mctx = metaContext();
    const planned = computePlannedAll(ctx, mctx);
    const st = computeStats(planned, mctx);
    renderGrid(planned, animate);
    applyBannerSummary(st, ctx);
}

function updateStatsOnly() {
    if (allFiles.length === 0) return;
    const ctx = buildContext(); const mctx = metaContext();
    applyBannerSummary(computeStats(computePlannedAll(ctx, mctx), mctx), ctx);
}

function applyBannerSummary(st, ctx) {
    const banner = document.getElementById('conflict-banner');
    if (st.conflictRows > 0) {
        document.getElementById('conflict-banner-text').textContent =
            `${st.conflictRows} files would collide on ${st.conflictGroups} name${st.conflictGroups > 1 ? 's' : ''} (a folder can't hold duplicates). Turn on numbering or adjust to make them unique.`;
        banner.classList.remove('hidden');
    } else banner.classList.add('hidden');

    // Name collisions HARD-block apply (the rename would fail). Invalid regex skips names only.
    const btn = document.getElementById('btn-rename');
    const canApply = st.totalChanged > 0 && st.conflictRows === 0;
    btn.disabled = !canApply;
    btn.classList.toggle('btn-pulse', canApply);

    const sum = document.getElementById('rename-summary');
    const parts = [];
    if (st.nameChanges) parts.push(`${st.nameChanges} name${st.nameChanges > 1 ? 's' : ''}`);
    if (st.tagChanges) parts.push(`${st.tagChanges} tag set${st.tagChanges > 1 ? 's' : ''}`);
    if (st.labelChanges) parts.push(`${st.labelChanges} label${st.labelChanges > 1 ? 's' : ''}`);
    if (st.commentChanges) parts.push(`${st.commentChanges} comment${st.commentChanges > 1 ? 's' : ''}`);
    let summary = parts.length ? `Will change ${parts.join(' · ')}` : 'No changes';
    if (st.conflictRows > 0) summary = `⚠ ${st.conflictRows} name collisions — fix before applying`;
    else if (!ctx.regexValid) summary = parts.length ? `⚠ RegEx invalid (names skipped) · ${summary}` : 'Invalid RegEx — fix the Find pattern';
    sum.innerText = summary;

    // Selection summary + the header "select all" tri-state
    const total = allFiles.length, selN = st.selectedCount;
    document.getElementById('sel-summary').innerText = total ? `${selN} of ${total} selected` : '';
    const selAll = document.getElementById('select-all');
    if (selAll) { selAll.checked = selN === total && total > 0; selAll.indeterminate = selN > 0 && selN < total; }
}

// ---------- grid ----------
function resetBtn() { return `<button type="button" class="meta-reset" title="Reset to the bulk value">↺</button>`; }
function thumbHtml(path) { return `<img class="thumb" data-path="${escapeAttr(path)}" src="${thumbCache[path] || ''}" alt="">`; }
function labelDot(n) { return n > 0 ? `<span class="label-dot" data-c="${COLOR_NAMES[n].toLowerCase()}" title="${COLOR_NAMES[n]}"></span>` : ''; }
function rowSwatches(label, path) {
    let s = `<span class="row-swatches" data-path="${escapeAttr(path)}"><span class="rsw none ${label === 0 ? 'on' : ''}" data-v="0" title="None">∅</span>`;
    for (let v = 1; v <= 7; v++) s += `<span class="rsw ${label === v ? 'on' : ''}" data-v="${v}" data-c="${COLOR_NAMES[v].toLowerCase()}" title="${COLOR_NAMES[v]}"></span>`;
    return s + '</span>';
}

function renderGrid(planned, animate) {
    const thead = document.getElementById('preview-thead');
    const body = document.getElementById('preview-list-body');
    const title = document.getElementById('preview-title');
    const field = activeField();
    let html = '';

    const selHead = '<th class="sel-cell"><input type="checkbox" id="select-all" title="Select / deselect all"></th>';
    const selCell = (p) => `<td class="sel-cell"><input type="checkbox" class="row-sel" data-path="${escapeAttr(p.item.path)}"${p.selected ? ' checked' : ''}></td>`;
    const rowClass = (p, extra) => `class="${extra}${p.selected ? '' : ' row-unselected'}" data-path="${escapeAttr(p.item.path)}"`;
    const fileCell = (path, name) => `<td class="file-cell"><div class="cellrow">${thumbHtml(path)}<span class="file-name">${escapeHtml(name)}</span></div></td>`;

    if (field === 'rename') {
        title.textContent = 'Rename';
        thead.innerHTML = `<tr>${selHead}<th>Original</th><th>New Name</th></tr>`;
        planned.forEach(p => {
            const cls = []; if (p.nameChanged) cls.push('changed'); if (p.nameConflict) cls.push('conflict');
            const badge = p.nameConflict ? '<span class="conflict-badge">⚠ collision</span>' : '';
            html += `<tr ${rowClass(p, p.nameConflict ? 'row-conflict' : '')}>
                ${selCell(p)}${fileCell(p.item.path, p.item.name)}
                <td class="new-name-preview ${cls.join(' ')}">${escapeHtml(p.name)}${badge}</td></tr>`;
        });
    } else if (field === 'label') {
        title.textContent = 'Color label';
        thead.innerHTML = `<tr>${selHead}<th>File</th><th>Color label</th></tr>`;
        planned.forEach(p => {
            const overridden = p.selected && 'label' in (metaOverrides[p.item.path] || {});
            html += `<tr ${rowClass(p, overridden ? 'row-override' : '')}>
                ${selCell(p)}${fileCell(p.item.path, p.name)}
                <td class="meta-cell"><div class="cellrow">${rowSwatches(p.label, p.item.path)}${overridden ? resetBtn() : ''}</div></td></tr>`;
        });
    } else {
        const label = field === 'tags' ? 'Tags' : 'Comment';
        const fkey = field === 'tags' ? 'tags' : 'comment';
        title.textContent = label;
        thead.innerHTML = `<tr>${selHead}<th>File</th><th>${label}</th></tr>`;
        planned.forEach(p => {
            const overridden = p.selected && fkey in (metaOverrides[p.item.path] || {});
            const value = field === 'tags' ? p.tags.join(', ') : p.comment;
            const ph = field === 'tags' ? 'tag1, tag2…' : 'comment…';
            html += `<tr ${rowClass(p, overridden ? 'row-override' : '')}>
                ${selCell(p)}${fileCell(p.item.path, p.name)}
                <td class="meta-cell"><div class="cellrow"><input type="text" class="meta-input" data-field="${fkey}" data-path="${escapeAttr(p.item.path)}" value="${escapeAttr(value)}" placeholder="${ph}">${overridden ? resetBtn() : ''}</div></td></tr>`;
        });
    }
    body.innerHTML = html;
    const selAll = document.getElementById('select-all');
    if (selAll) { const n = selectedPaths.size, t = allFiles.length; selAll.checked = n === t && t > 0; selAll.indeterminate = n > 0 && n < t; }
    if (animate) { body.classList.remove('fade-rows'); void body.offsetWidth; body.classList.add('fade-rows'); }
    requestThumbs();
}

function requestThumbs() {
    if (!window.hasNative) return;
    document.querySelectorAll('#preview-list-body img.thumb').forEach(img => {
        if (img.getAttribute('src')) return;
        const path = img.getAttribute('data-path');
        nativeCall('thumbnail', { path, size: 40 }).then(r => {
            if (r && r.dataUrl) { thumbCache[path] = r.dataUrl; img.src = r.dataUrl; }
        }).catch(() => { });
    });
}

function initGridEvents() {
    const body = document.getElementById('preview-list-body');
    // Selection: row checkboxes (tbody) + the select-all (thead, rebuilt each render) —
    // one delegated listener on the table catches both.
    document.querySelector('.preview-table').addEventListener('change', (e) => {
        const rs = e.target.closest('.row-sel');
        if (rs) {
            const path = rs.getAttribute('data-path');
            if (rs.checked) selectedPaths.add(path); else selectedPaths.delete(path);
            generatePreview();
            return;
        }
        if (e.target.id === 'select-all') {
            selectedPaths = e.target.checked ? new Set(allFiles.map(f => f.path)) : new Set();
            generatePreview();
        }
    });
    body.addEventListener('input', (e) => {
        const inp = e.target.closest('.meta-input'); if (!inp) return;
        const path = inp.getAttribute('data-path'), field = inp.getAttribute('data-field');
        if (!metaOverrides[path]) metaOverrides[path] = {};
        if (field === 'tags') metaOverrides[path].tags = inp.value.split(',').map(s => s.trim()).filter(Boolean);
        else metaOverrides[path][field] = inp.value;
        const row = inp.closest('tr');
        if (row && !row.classList.contains('row-override')) {
            row.classList.add('row-override');
            const cell = inp.closest('.meta-cell'); if (cell && !cell.querySelector('.meta-reset')) cell.insertAdjacentHTML('beforeend', resetBtn());
        }
        scheduleStats();
    });
    body.addEventListener('click', (e) => {
        const reset = e.target.closest('.meta-reset');
        if (reset) {
            const row = reset.closest('tr'), path = row.getAttribute('data-path'), fkey = currentFieldKey();
            if (fkey && metaOverrides[path]) { delete metaOverrides[path][fkey]; if (!Object.keys(metaOverrides[path]).length) delete metaOverrides[path]; }
            generatePreview();
            return;
        }
        const sw = e.target.closest('.row-swatches .rsw');
        if (sw) {
            const path = sw.closest('.row-swatches').getAttribute('data-path');
            if (!metaOverrides[path]) metaOverrides[path] = {};
            metaOverrides[path].label = +sw.getAttribute('data-v');
            generatePreview();
        }
    });
}
function currentFieldKey() { const f = activeField(); return f === 'tags' ? 'tags' : (f === 'notes' ? 'comment' : (f === 'label' ? 'label' : null)); }

// ---------- Tags / Label pickers ----------
function initMeta() {
    const ti = document.getElementById('meta-tags-input');
    if (ti) {
        ti.addEventListener('keydown', e => {
            if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); commitTagInput(); }
            else if (e.key === 'Backspace' && ti.value === '' && tagsChips.length) { tagsChips.pop(); renderTagChips(); generatePreview(); }
        });
        ti.addEventListener('input', () => { if (ti.value.includes(',')) commitTagInput(); });
        ti.addEventListener('blur', commitTagInput);
    }
    const cl = document.getElementById('tags-chip-list');
    if (cl) cl.addEventListener('click', e => { const x = e.target.closest('.chip-x'); if (x) { tagsChips.splice(+x.getAttribute('data-i'), 1); renderTagChips(); generatePreview(); } });
    document.querySelectorAll('input[name="tags-mode"]').forEach(r => r.addEventListener('change', generatePreview));
    document.querySelectorAll('input[name="desc-mode"]').forEach(r => r.addEventListener('change', generatePreview));
    const lp = document.getElementById('label-picker');
    if (lp) lp.addEventListener('click', e => { const sw = e.target.closest('.swatch'); if (sw) setBulkLabel(+sw.getAttribute('data-v')); });
    renderLabelPicker();
}
function commitTagInput() {
    const inp = document.getElementById('meta-tags-input'); if (!inp) return;
    let added = false;
    inp.value.split(',').map(s => s.trim()).filter(Boolean).forEach(t => { if (!tagsChips.includes(t)) { tagsChips.push(t); added = true; } });
    inp.value = '';
    if (added) { renderTagChips(); generatePreview(); }
}
function renderTagChips() {
    const list = document.getElementById('tags-chip-list'); if (!list) return;
    list.innerHTML = tagsChips.map((t, i) => `<span class="chip">${escapeHtml(t)}<span class="chip-x" data-i="${i}" title="Remove">×</span></span>`).join('');
}
function setBulkLabel(v) {
    document.getElementById('label-picker').setAttribute('data-value', String(v));
    document.getElementById('meta-label-enable').checked = true;
    renderLabelPicker(); generatePreview();
}
function renderLabelPicker() {
    const lp = document.getElementById('label-picker'); if (!lp) return;
    const v = parseInt(lp.getAttribute('data-value'), 10) || 0;
    lp.querySelectorAll('.swatch').forEach(s => s.classList.toggle('on', (+s.getAttribute('data-v')) === v));
}

function updateRegexUI(ctx) {
    const errEl = document.getElementById('regex-error'), hintEl = document.getElementById('regex-hint'), findEl = document.getElementById('text-find');
    hintEl.style.display = checked('text-use-regex') ? 'block' : 'none';
    if (ctx && !ctx.regexValid) { errEl.textContent = 'Invalid RegEx: ' + (ctx.regexError || ''); errEl.classList.add('visible'); findEl.classList.add('input-error'); }
    else { errEl.classList.remove('visible'); findEl.classList.remove('input-error'); }
}

// ---------- Apply / Undo / Reveal ----------
async function executeApply() {
    if (allFiles.length === 0) return;
    const ctx = buildContext();
    const mctx = metaContext();
    const planned = computePlannedAll(ctx, mctx);
    const st = computeStats(planned, mctx);
    if (st.conflictRows > 0) { await confirmDialog('Name collisions', 'Some files would end up with the same name in the same folder. Turn on numbering or adjust before applying.', 'OK'); return; }
    if (st.totalChanged === 0) { showToast(!ctx.regexValid ? 'Fix the RegEx, or set tags/label/comment to apply.' : 'No changes to apply.'); return; }

    if (st.totalChanged > 50) {
        const segs = [];
        if (st.nameChanges) segs.push(`${st.nameChanges} renames`);
        if (st.tagChanges) segs.push(`${st.tagChanges} tag sets`);
        if (st.labelChanges) segs.push(`${st.labelChanges} labels`);
        if (st.commentChanges) segs.push(`${st.commentChanges} comments`);
        if (!(await confirmDialog('Confirm', `Apply ${segs.join(', ')} across ${st.totalChanged} files?`, 'Apply'))) return;
    }

    const ops = [], snapshot = [];
    planned.forEach(p => {
        if (!p.selected) return;   // untouched
        const it = p.item;
        const willName = p.name !== it.name;
        const willTags = mctx.tagsEngaged && !sameTags(p.tags, it.tags || []);
        const willLabel = mctx.labelEngaged && p.label !== (it.label || 0);
        const willComment = mctx.descEngaged && p.comment !== (it.comment || '');
        if (!(willName || willTags || willLabel || willComment)) return;
        ops.push({ path: it.path, newName: willName ? p.name : it.name, tags: p.tags, label: p.label, comment: p.comment, setTags: willTags, setLabel: willLabel, setComment: willComment });
        snapshot.push({ path: it.path, name: it.name, tags: (it.tags || []).slice(), label: it.label || 0, comment: it.comment || '' });
    });

    const btn = document.getElementById('btn-rename');
    btn.disabled = true; btn.classList.remove('btn-pulse'); btn.innerText = 'Applying…';
    try {
        const r = await nativeCall('apply', { ops });
        const results = (r && r.results) || [];
        let ok = 0, fail = 0; const newPaths = []; const okSnap = []; const pathMap = {};
        results.forEach((res, i) => {
            if (res.ok) { ok++; const np = res.newPath || ops[i].path; newPaths.push(np); pathMap[ops[i].path] = np; okSnap.push(Object.assign({}, snapshot[i], { newPath: np })); }
            else { fail++; console.error('apply fail', res); }
        });
        lastSnapshot = okSnap.length ? okSnap : null;
        lastNewPaths = newPaths;
        document.getElementById('btn-undo').classList.toggle('hidden', !lastSnapshot);
        document.getElementById('btn-reveal').classList.toggle('hidden', !newPaths.length);
        showToast(fail > 0 ? `Applied ${ok}, ${fail} failed.` : `Applied changes to ${ok} file${ok !== 1 ? 's' : ''}!`);
        // Refresh WITHOUT swallowing the surrounding folder: a folder view re-lists; a curated set
        // re-stats only its own files (mapped to their new post-rename paths).
        if (folderView) await reloadCurrent(true);
        else await refreshFilesetPaths(allFiles.map(f => pathMap[f.path] || f.path), true);
    } catch (e) {
        showToast('Error: ' + e.message);
    } finally {
        btn.innerHTML = `<svg viewBox="0 0 24 24" class="icon"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg> Apply`;
        btn.disabled = false;
    }
}

async function undoLastApply() {
    if (!lastSnapshot) return;
    const n = lastSnapshot.length;
    if (!(await confirmDialog('Undo last apply', `Restore ${n} file${n !== 1 ? 's' : ''} to their previous name, tags, label and comment?`, 'Undo'))) return;
    const ops = lastSnapshot.map(s => ({ path: s.newPath, newName: s.name, tags: s.tags, label: s.label, comment: s.comment, setTags: true, setLabel: true, setComment: true }));
    const btn = document.getElementById('btn-undo'); btn.disabled = true;
    try {
        const r = await nativeCall('apply', { ops });
        const results = (r && r.results) || [];
        const ok = results.filter(x => x.ok).length;
        const fail = results.length - ok;
        const pathMap = {};
        results.forEach((res, i) => { if (res.ok) pathMap[ops[i].path] = res.newPath || ops[i].path; });
        lastSnapshot = null; lastNewPaths = [];
        btn.classList.add('hidden'); document.getElementById('btn-reveal').classList.add('hidden');
        showToast(fail > 0 ? `Restored ${ok}, ${fail} failed.` : `Restored ${ok} file${ok !== 1 ? 's' : ''}.`);
        if (folderView) await reloadCurrent(true);
        else await refreshFilesetPaths(allFiles.map(f => pathMap[f.path] || f.path), true);
    } catch (e) { showToast('Undo error: ' + e.message); }
    finally { btn.disabled = false; }
}

async function revealFiles(paths) {
    if (!paths || !paths.length) return;
    try { await nativeCall('reveal', { paths }); } catch (e) { }
}

// ---------- toast + modal (Electron-safe) ----------
function showToast(msg) {
    const t = document.getElementById('toast'); t.innerText = msg; t.classList.remove('hidden');
    setTimeout(() => t.classList.add('hidden'), 3000);
}
function showModal({ title, message, input, inputValue, placeholder, confirmText, cancelText }) {
    return new Promise(resolve => {
        const overlay = document.getElementById('modal-overlay');
        const titleEl = document.getElementById('modal-title'), msgEl = document.getElementById('modal-message');
        const inputEl = document.getElementById('modal-input'), confirmBtn = document.getElementById('modal-confirm'), cancelBtn = document.getElementById('modal-cancel');
        titleEl.textContent = title || ''; msgEl.textContent = message || ''; msgEl.classList.toggle('hidden', !message);
        inputEl.classList.toggle('hidden', !input); inputEl.value = inputValue || ''; inputEl.placeholder = placeholder || '';
        confirmBtn.textContent = confirmText || 'OK'; cancelBtn.textContent = cancelText || 'Cancel';
        overlay.classList.remove('hidden');
        if (input) setTimeout(() => { inputEl.focus(); inputEl.select(); }, 30); else setTimeout(() => confirmBtn.focus(), 30);
        let settled = false;
        function cleanup(res) {
            if (settled) return; settled = true;
            overlay.classList.add('hidden');
            confirmBtn.removeEventListener('click', onConfirm); cancelBtn.removeEventListener('click', onCancel);
            document.removeEventListener('keydown', onKey, true); overlay.removeEventListener('mousedown', onOverlay);
            resolve(res);
        }
        function onConfirm() { cleanup(input ? inputEl.value : true); }
        function onCancel() { cleanup(input ? null : false); }
        function onKey(e) { if (e.key === 'Enter') { e.preventDefault(); onConfirm(); } else if (e.key === 'Escape') { e.preventDefault(); onCancel(); } }
        function onOverlay(e) { if (e.target === overlay) onCancel(); }
        confirmBtn.addEventListener('click', onConfirm); cancelBtn.addEventListener('click', onCancel);
        document.addEventListener('keydown', onKey, true); overlay.addEventListener('mousedown', onOverlay);
    });
}
function confirmDialog(t, m, c) { return showModal({ title: t, message: m, confirmText: c || 'OK', cancelText: 'Cancel' }); }
function promptDialog(t, v, ph, c) { return showModal({ title: t, input: true, inputValue: v, placeholder: ph, confirmText: c || 'Save', cancelText: 'Cancel' }); }
