/**
 * Dashboard customization panel — slide-in panel that lets the user toggle
 * section / perf-cell visibility, reorder them by drag, change density,
 * pick presets, and import/export the layout as JSON.
 *
 * The panel writes through `dashboard-layout.ts` which debounces a server
 * PUT and notifies subscribers — `dashboard.ts` listens and re-renders
 * live, so every change shows immediately on the page behind the panel.
 *
 * Drag/drop is hand-rolled HTML5 drag-and-drop (no external dep). It only
 * works on pointer devices; for keyboard / TV remote we expose ↑/↓ buttons
 * on each row so the panel is fully reachable without a mouse.
 */
import { t } from '../core/i18n.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import {
    getDashboardLayout,
    saveDashboardLayout,
    applyDashboardPreset,
    resetDashboardLayout,
    exportDashboardLayoutJson,
    importDashboardLayoutJson,
    setSectionVisible,
    setSectionOrder,
    setSectionDensity,
    setSectionCollapsedDefault,
    setPerfCellVisible,
    setPerfCellOrder,
    setPerfCellMode,
    setPerfCellWindow,
    setPerfCellYScale,
    setGlobalPerfMode,
    setGlobalPerfWindow,
    setGlobalConfig,
    PRESETS,
    subscribeDashboardLayout,
    type DashboardLayoutV1,
    type Density,
    type PerfMode,
    type SampleWindow,
    type YScale,
    type Width,
    type AnimationsLevel,
} from './dashboard-layout.ts';
import {
    ICON_X, ICON_EYE, ICON_EYE_OFF, ICON_DOWNLOAD, ICON_REFRESH,
} from '../core/icons.ts';
import { enhanceMiniSelects } from '../core/mini-select.ts';

const ICON_DRAG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/></svg>';
const ICON_LOCK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';

const PANEL_ID = 'dashboard-customize-panel';
const BACKDROP_ID = 'dashboard-customize-backdrop';

/** Sections that the user can reorder. The perf section is special-cased
 *  (always at top in v1; only its visibility / cells are configurable),
 *  so it's not part of this list. */
const REORDERABLE_SECTIONS: readonly string[] = [
    'integrations',
    'automations',
    'scenes',
    'sync-clocks',
    'targets',
] as const;

const SECTION_LABEL_KEYS: Record<string, string> = {
    perf: 'dashboard.section.performance',
    integrations: 'dashboard.section.integrations',
    automations: 'dashboard.section.automations',
    scenes: 'dashboard.section.scenes',
    'sync-clocks': 'dashboard.section.sync_clocks',
    targets: 'dashboard.section.targets',
};

const PERF_CELL_LABEL_KEYS: Record<string, string> = {
    patches: 'dashboard.perf.active_patches',
    fps: 'dashboard.perf.total_fps',
    capture_fps: 'dashboard.perf.total_capture_fps',
    capture_fps_actual: 'dashboard.perf.total_capture_fps_actual',
    errors: 'dashboard.perf.errors',
    devices: 'dashboard.perf.devices',
    cpu: 'dashboard.perf.cpu',
    ram: 'dashboard.perf.ram',
    gpu: 'dashboard.perf.gpu',
    temp: 'dashboard.perf.temp',
    network: 'dashboard.perf.network',
    device_latency: 'dashboard.perf.device_latency',
    send_timing: 'dashboard.perf.send_timing',
};

let _unsubscribe: (() => void) | null = null;
// True while the user is mid-drag in the panel. While set, layout-change
// notifications are deferred — re-rendering would replace the dragged DOM
// node and abort the drag. The flag is cleared on `dragend`.
let _activeDrag = false;
let _renderDeferred = false;
// After an arrow-button move, restore focus on the same arrow once the
// panel re-renders. Without this, every press destroys the focus and the
// user has to re-aim for each click.
let _focusAfterRender: string | null = null;

export function openDashboardCustomize(): void {
    let panel = document.getElementById(PANEL_ID);
    if (!panel) {
        _mountPanel();
        panel = document.getElementById(PANEL_ID)!;
    }
    panel.classList.add('is-open');
    const backdrop = document.getElementById(BACKDROP_ID);
    if (backdrop) backdrop.classList.add('is-open');
    _renderPanelBody();
    if (!_unsubscribe) {
        _unsubscribe = subscribeDashboardLayout(() => {
            if (_activeDrag) { _renderDeferred = true; return; }
            _renderPanelBody();
        });
    }
}

export function closeDashboardCustomize(): void {
    const panel = document.getElementById(PANEL_ID);
    const backdrop = document.getElementById(BACKDROP_ID);
    if (panel) panel.classList.remove('is-open');
    if (backdrop) backdrop.classList.remove('is-open');
    if (_unsubscribe) { _unsubscribe(); _unsubscribe = null; }
}

function _mountPanel(): void {
    const backdrop = document.createElement('div');
    backdrop.id = BACKDROP_ID;
    backdrop.className = 'dash-cust-backdrop';
    backdrop.addEventListener('click', closeDashboardCustomize);
    document.body.appendChild(backdrop);

    const panel = document.createElement('aside');
    panel.id = PANEL_ID;
    panel.className = 'dash-cust-panel';
    panel.setAttribute('role', 'dialog');
    panel.setAttribute('aria-modal', 'false');
    panel.setAttribute('aria-labelledby', 'dash-cust-title');
    panel.innerHTML = `
        <header class="dash-cust-header">
            <h2 id="dash-cust-title">${t('dashboard.customize.title')}</h2>
            <button class="dash-cust-close" type="button" aria-label="${t('aria.close')}" onclick="closeDashboardCustomize()">${ICON_X}</button>
        </header>
        <div class="dash-cust-body" id="dash-cust-body"></div>
    `;
    document.body.appendChild(panel);

    // Force a layout flush so the initial off-screen transform commits before
    // the caller adds `.is-open`. Without this, on the first open after a page
    // reload the browser collapses both styles into one paint and the slide-in
    // transition is skipped.
    void panel.offsetWidth;
    void backdrop.offsetWidth;

    document.addEventListener('keydown', (e) => {
        if (e.key === 'Escape' && panel.classList.contains('is-open')) {
            closeDashboardCustomize();
        }
    });
}

function _renderPanelBody(): void {
    const body = document.getElementById('dash-cust-body');
    if (!body) return;
    const layout = getDashboardLayout();
    body.innerHTML = `
        ${_renderPresets(layout)}
        ${_renderGlobal(layout)}
        ${_renderSections(layout)}
        ${_renderPerfCells(layout)}
        ${_renderActions()}
    `;
    _bindHandlers(body);
    // Enhance the compact <select> elements rendered above into MiniSelect
    // popups. The native elements stay in the DOM hidden, so the change
    // handlers attached in _bindHandlers continue to fire on selection.
    enhanceMiniSelects(body, 'select.dash-cust-mini-select');
    if (_focusAfterRender) {
        const el = body.querySelector<HTMLElement>(_focusAfterRender);
        el?.focus();
        _focusAfterRender = null;
    }
}

// ── Sub-renderers ────────────────────────────────────────────────────────

function _renderPresets(layout: DashboardLayoutV1): string {
    const chips = Object.keys(PRESETS).map(name => {
        const active = layout.presetActive === name;
        return `<button type="button" class="dash-cust-chip${active ? ' is-active' : ''}" data-preset="${name}">
            ${t('dashboard.customize.preset.' + name)}
        </button>`;
    }).join('');
    const modifiedHint = layout.userModified
        ? `<span class="dash-cust-modified">${t('dashboard.customize.modified')}</span>`
        : '';
    return `<section class="dash-cust-section">
        <h3 class="dash-cust-h3">${t('dashboard.customize.presets')}${modifiedHint}</h3>
        <div class="dash-cust-chips">${chips}</div>
    </section>`;
}

function _renderGlobal(layout: DashboardLayoutV1): string {
    const widthOpts: { v: Width; k: string }[] = [
        { v: 'full',     k: 'dashboard.customize.width.full' },
        { v: 'centered', k: 'dashboard.customize.width.centered' },
        { v: 'narrow',   k: 'dashboard.customize.width.narrow' },
    ];
    const animOpts: { v: AnimationsLevel; k: string }[] = [
        { v: 'full',    k: 'dashboard.customize.anim.full' },
        { v: 'reduced', k: 'dashboard.customize.anim.reduced' },
        { v: 'off',     k: 'dashboard.customize.anim.off' },
    ];
    const modeOpts: { v: 'system' | 'app' | 'both'; k: string }[] = [
        { v: 'system', k: 'dashboard.perf.mode.system' },
        { v: 'app',    k: 'dashboard.perf.mode.app' },
        { v: 'both',   k: 'dashboard.perf.mode.both' },
    ];
    const windowOpts: SampleWindow[] = [30, 60, 120, 300];
    const widthBtns = widthOpts.map(o => `<button type="button" class="dash-cust-seg-btn${layout.global.width === o.v ? ' is-active' : ''}" data-global-width="${o.v}">${t(o.k)}</button>`).join('');
    const animBtns = animOpts.map(o => `<button type="button" class="dash-cust-seg-btn${layout.global.animations === o.v ? ' is-active' : ''}" data-global-anim="${o.v}">${t(o.k)}</button>`).join('');
    const modeBtns = modeOpts.map(o => `<button type="button" class="dash-cust-seg-btn${layout.global.perfMode === o.v ? ' is-active' : ''}" data-global-perfmode="${o.v}">${t(o.k)}</button>`).join('');
    const windowBtns = windowOpts.map(w => `<button type="button" class="dash-cust-seg-btn${layout.global.perfWindow === w ? ' is-active' : ''}" data-global-perfwindow="${w}">${w >= 60 ? `${w / 60}m` : `${w}s`}</button>`).join('');
    return `<section class="dash-cust-section">
        <h3 class="dash-cust-h3">${t('dashboard.customize.global')}</h3>
        <div class="dash-cust-row">
            <label class="dash-cust-label">${t('dashboard.customize.width')}</label>
            <div class="dash-cust-seg">${widthBtns}</div>
        </div>
        <div class="dash-cust-row">
            <label class="dash-cust-label">${t('dashboard.customize.anim')}</label>
            <div class="dash-cust-seg">${animBtns}</div>
        </div>
        <div class="dash-cust-row">
            <label class="dash-cust-label">${t('dashboard.customize.perf_mode')}</label>
            <div class="dash-cust-seg">${modeBtns}</div>
        </div>
        <div class="dash-cust-row">
            <label class="dash-cust-label">${t('dashboard.customize.window')}</label>
            <div class="dash-cust-seg">${windowBtns}</div>
        </div>
    </section>`;
}

function _renderSections(layout: DashboardLayoutV1): string {
    const perfRow = (() => {
        const perf = layout.sections.find(s => s.key === 'perf');
        if (!perf) return '';
        return `<div class="dash-cust-row dash-cust-row-fixed" data-section-key="perf">
            <span class="dash-cust-row-label">
                <span class="dash-cust-pin" title="${t('dashboard.customize.fixed_top')}">${ICON_LOCK}</span>
                ${t(SECTION_LABEL_KEYS.perf)}
            </span>
            ${_eyeBtn(perf.visible, 'section', 'perf')}
        </div>`;
    })();

    const orderedSlugs = REORDERABLE_SECTIONS.filter(k =>
        layout.sections.some(s => s.key === k));
    const orderedFromLayout = layout.sections.map(s => s.key).filter(k => orderedSlugs.includes(k));

    const rows = orderedFromLayout.map(key => {
        const s = layout.sections.find(s => s.key === key);
        if (!s) return '';
        const densityBtns: { v: Density; lbl: string }[] = [
            { v: 'comfortable', lbl: 'C' },
            { v: 'compact',     lbl: 'M' },
            { v: 'dense',       lbl: 'D' },
        ];
        const densityHtml = densityBtns.map(b =>
            `<button type="button" class="dash-cust-density${s.density === b.v ? ' is-active' : ''}" data-section-density="${key}" data-density="${b.v}" title="${t('dashboard.customize.density.' + b.v)}">${b.lbl}</button>`
        ).join('');
        return `<div class="dash-cust-row dash-cust-row-drag" draggable="true" data-section-key="${key}">
            <span class="dash-cust-grip" aria-hidden="true">${ICON_DRAG}</span>
            <span class="dash-cust-row-label">${t(SECTION_LABEL_KEYS[key] || key)}</span>
            <span class="dash-cust-density-group">${densityHtml}</span>
            <button type="button" class="dash-cust-arrow" data-move="up" data-section-key="${key}" aria-label="↑">↑</button>
            <button type="button" class="dash-cust-arrow" data-move="down" data-section-key="${key}" aria-label="↓">↓</button>
            ${_collapseBtn(s.collapsedDefault, 'section', key)}
            ${_eyeBtn(s.visible, 'section', key)}
        </div>`;
    }).join('');

    return `<section class="dash-cust-section" data-section-list>
        <h3 class="dash-cust-h3">${t('dashboard.customize.sections')}</h3>
        ${perfRow}
        <div class="dash-cust-list" id="dash-cust-section-list">${rows}</div>
        <p class="dash-cust-help">${t('dashboard.customize.drag_help')}</p>
    </section>`;
}

function _renderPerfCells(layout: DashboardLayoutV1): string {
    const modeOpts: PerfMode[] = ['inherit', 'system', 'app', 'both'];
    const windowOpts: (SampleWindow | 'inherit')[] = ['inherit', 30, 60, 120, 300];
    const yScaleOpts: YScale[] = ['auto', 'fixed', 'log'];

    const rows = layout.perfCells.map(c => {
        const modeSel = `<select class="dash-cust-mini-select" data-cell-mode="${c.key}" title="${t('dashboard.customize.perf_mode')}">${
            modeOpts.map(m => `<option value="${m}"${c.mode === m ? ' selected' : ''}>${t('dashboard.customize.mode.' + m)}</option>`).join('')
        }</select>`;
        const windowSel = `<select class="dash-cust-mini-select" data-cell-window="${c.key}" title="${t('dashboard.customize.window')}">${
            windowOpts.map(w => {
                const lbl = w === 'inherit' ? t('dashboard.customize.mode.inherit') : (w >= 60 ? `${w / 60}m` : `${w}s`);
                return `<option value="${w}"${c.window === w ? ' selected' : ''}>${lbl}</option>`;
            }).join('')
        }</select>`;
        const yScaleSel = `<select class="dash-cust-mini-select" data-cell-yscale="${c.key}" title="${t('dashboard.customize.scale')}">${
            yScaleOpts.map(y => `<option value="${y}"${c.yScale === y ? ' selected' : ''}>${t('dashboard.customize.yscale.' + y)}</option>`).join('')
        }</select>`;
        return `<div class="dash-cust-row dash-cust-row-drag dash-cust-cell-row" draggable="true" data-cell-key="${c.key}">
            <div class="dash-cust-cell-top">
                <span class="dash-cust-grip" aria-hidden="true">${ICON_DRAG}</span>
                <span class="dash-cust-row-label">${t(PERF_CELL_LABEL_KEYS[c.key] || c.key)}</span>
                <button type="button" class="dash-cust-arrow" data-cell-move="up" data-cell-key="${c.key}" aria-label="↑">↑</button>
                <button type="button" class="dash-cust-arrow" data-cell-move="down" data-cell-key="${c.key}" aria-label="↓">↓</button>
                ${_eyeBtn(c.visible, 'cell', c.key)}
            </div>
            <div class="dash-cust-cell-opts">
                <span class="dash-cust-cell-opt">
                    <span class="dash-cust-cell-opt-k">${t('dashboard.customize.mode_short')}</span>
                    ${modeSel}
                </span>
                <span class="dash-cust-cell-opt">
                    <span class="dash-cust-cell-opt-k">${t('dashboard.customize.window_short')}</span>
                    ${windowSel}
                </span>
                <span class="dash-cust-cell-opt">
                    <span class="dash-cust-cell-opt-k">${t('dashboard.customize.scale_short')}</span>
                    ${yScaleSel}
                </span>
            </div>
        </div>`;
    }).join('');

    return `<section class="dash-cust-section" data-cell-list>
        <h3 class="dash-cust-h3">${t('dashboard.customize.perf_cells')}</h3>
        <div class="dash-cust-list" id="dash-cust-cell-list">${rows}</div>
        <p class="dash-cust-help">${t('dashboard.customize.cell_drag_help')}</p>
    </section>`;
}

function _renderActions(): string {
    return `<section class="dash-cust-section dash-cust-actions">
        <button type="button" class="btn btn-secondary" data-action="export">${ICON_DOWNLOAD} ${t('dashboard.customize.export')}</button>
        <button type="button" class="btn btn-secondary" data-action="import">${t('dashboard.customize.import')}</button>
        <button type="button" class="btn btn-secondary" data-action="reset">${ICON_REFRESH} ${t('dashboard.customize.reset')}</button>
    </section>`;
}

function _eyeBtn(visible: boolean, kind: 'section' | 'cell', key: string): string {
    const dataAttr = kind === 'section' ? 'data-section-toggle' : 'data-cell-toggle';
    const label = visible ? t('dashboard.customize.hide') : t('dashboard.customize.show');
    return `<button type="button" class="dash-cust-eye${visible ? ' is-on' : ''}" ${dataAttr}="${key}" aria-pressed="${visible}" title="${label}" aria-label="${label}">${visible ? ICON_EYE : ICON_EYE_OFF}</button>`;
}

function _collapseBtn(collapsed: boolean, kind: 'section', key: string): string {
    const label = collapsed ? t('dashboard.customize.collapse_default.on') : t('dashboard.customize.collapse_default.off');
    return `<button type="button" class="dash-cust-arrow${collapsed ? ' is-active' : ''}" data-section-collapse-default="${key}" aria-pressed="${collapsed}" title="${label}" aria-label="${label}">▾</button>`;
}

// ── Handlers ─────────────────────────────────────────────────────────────

function _bindHandlers(root: HTMLElement): void {
    // Presets
    root.querySelectorAll<HTMLElement>('[data-preset]').forEach(btn => {
        btn.addEventListener('click', () => {
            const name = btn.dataset.preset!;
            applyDashboardPreset(name);
        });
    });

    // Global toggles
    root.querySelectorAll<HTMLElement>('[data-global-width]').forEach(btn => {
        btn.addEventListener('click', () => {
            saveDashboardLayout(setGlobalConfig(getDashboardLayout(), { width: btn.dataset.globalWidth as Width }));
        });
    });
    root.querySelectorAll<HTMLElement>('[data-global-anim]').forEach(btn => {
        btn.addEventListener('click', () => {
            saveDashboardLayout(setGlobalConfig(getDashboardLayout(), { animations: btn.dataset.globalAnim as AnimationsLevel }));
        });
    });
    root.querySelectorAll<HTMLElement>('[data-global-perfmode]').forEach(btn => {
        btn.addEventListener('click', () => {
            const mode = btn.dataset.globalPerfmode as 'system' | 'app' | 'both';
            saveDashboardLayout(setGlobalPerfMode(getDashboardLayout(), mode));
        });
    });

    // Section visibility / density / order / collapse-default
    root.querySelectorAll<HTMLElement>('[data-section-toggle]').forEach(btn => {
        btn.addEventListener('click', () => {
            const key = btn.dataset.sectionToggle!;
            const layout = getDashboardLayout();
            const cur = layout.sections.find(s => s.key === key);
            if (!cur) return;
            saveDashboardLayout(setSectionVisible(layout, key, !cur.visible));
        });
    });
    root.querySelectorAll<HTMLElement>('[data-section-density]').forEach(btn => {
        btn.addEventListener('click', () => {
            const key = btn.dataset.sectionDensity!;
            const density = btn.dataset.density as Density;
            saveDashboardLayout(setSectionDensity(getDashboardLayout(), key, density));
        });
    });
    root.querySelectorAll<HTMLElement>('[data-section-collapse-default]').forEach(btn => {
        btn.addEventListener('click', () => {
            const key = btn.dataset.sectionCollapseDefault!;
            const layout = getDashboardLayout();
            const cur = layout.sections.find(s => s.key === key);
            if (!cur) return;
            saveDashboardLayout(setSectionCollapsedDefault(layout, key, !cur.collapsedDefault));
        });
    });
    root.querySelectorAll<HTMLElement>('[data-move]').forEach(btn => {
        btn.addEventListener('click', () => {
            const key = btn.dataset.sectionKey!;
            const dir = btn.dataset.move as 'up' | 'down';
            _focusAfterRender = `[data-section-key="${key}"][data-move="${dir}"]`;
            _moveSection(key, dir);
        });
    });

    // Perf cells
    root.querySelectorAll<HTMLElement>('[data-cell-toggle]').forEach(btn => {
        btn.addEventListener('click', () => {
            const key = btn.dataset.cellToggle!;
            const layout = getDashboardLayout();
            const cur = layout.perfCells.find(c => c.key === key);
            if (!cur) return;
            saveDashboardLayout(setPerfCellVisible(layout, key, !cur.visible));
        });
    });
    root.querySelectorAll<HTMLSelectElement>('[data-cell-mode]').forEach(sel => {
        sel.addEventListener('change', () => {
            const key = sel.dataset.cellMode!;
            saveDashboardLayout(setPerfCellMode(getDashboardLayout(), key, sel.value as PerfMode));
        });
    });
    root.querySelectorAll<HTMLSelectElement>('[data-cell-window]').forEach(sel => {
        sel.addEventListener('change', () => {
            const key = sel.dataset.cellWindow!;
            const raw = sel.value;
            const win: SampleWindow | 'inherit' = raw === 'inherit'
                ? 'inherit'
                : (parseInt(raw, 10) as SampleWindow);
            saveDashboardLayout(setPerfCellWindow(getDashboardLayout(), key, win));
        });
    });
    root.querySelectorAll<HTMLElement>('[data-global-perfwindow]').forEach(btn => {
        btn.addEventListener('click', () => {
            const w = parseInt(btn.dataset.globalPerfwindow || '120', 10) as SampleWindow;
            saveDashboardLayout(setGlobalPerfWindow(getDashboardLayout(), w));
        });
    });
    root.querySelectorAll<HTMLSelectElement>('[data-cell-yscale]').forEach(sel => {
        sel.addEventListener('change', () => {
            const key = sel.dataset.cellYscale!;
            saveDashboardLayout(setPerfCellYScale(getDashboardLayout(), key, sel.value as YScale));
        });
    });
    root.querySelectorAll<HTMLElement>('[data-cell-move]').forEach(btn => {
        btn.addEventListener('click', () => {
            const key = btn.dataset.cellKey!;
            const dir = btn.dataset.cellMove as 'up' | 'down';
            _focusAfterRender = `[data-cell-key="${key}"][data-cell-move="${dir}"]`;
            _movePerfCell(key, dir);
        });
    });

    // Drag-and-drop reorder
    _bindDragSort(root, '#dash-cust-section-list', 'data-section-key', (orderedKeys) => {
        const layout = getDashboardLayout();
        // Preserve relative position of fixed/non-reorderable keys (perf).
        const nonReorderable = layout.sections.map(s => s.key).filter(k => !REORDERABLE_SECTIONS.includes(k));
        const merged = [...nonReorderable, ...orderedKeys];
        saveDashboardLayout(setSectionOrder(layout, merged));
    });
    _bindDragSort(root, '#dash-cust-cell-list', 'data-cell-key', (orderedKeys) => {
        saveDashboardLayout(setPerfCellOrder(getDashboardLayout(), orderedKeys));
    });

    // Actions
    const exportBtn = root.querySelector<HTMLButtonElement>('[data-action="export"]');
    if (exportBtn) exportBtn.addEventListener('click', _doExport);
    const importBtn = root.querySelector<HTMLButtonElement>('[data-action="import"]');
    if (importBtn) importBtn.addEventListener('click', _doImport);
    const resetBtn = root.querySelector<HTMLButtonElement>('[data-action="reset"]');
    if (resetBtn) resetBtn.addEventListener('click', async () => {
        const confirmed = await showConfirm(
            t('dashboard.customize.reset_confirm'),
            t('dashboard.customize.reset'),
        );
        if (confirmed) resetDashboardLayout();
    });
}

function _moveSection(key: string, dir: 'up' | 'down'): void {
    const layout = getDashboardLayout();
    const orderable = layout.sections
        .map(s => s.key)
        .filter(k => REORDERABLE_SECTIONS.includes(k));
    const idx = orderable.indexOf(key);
    if (idx < 0) return;
    const swap = dir === 'up' ? idx - 1 : idx + 1;
    if (swap < 0 || swap >= orderable.length) return;
    const next = [...orderable];
    [next[idx], next[swap]] = [next[swap], next[idx]];
    const nonReorderable = layout.sections.map(s => s.key).filter(k => !REORDERABLE_SECTIONS.includes(k));
    saveDashboardLayout(setSectionOrder(layout, [...nonReorderable, ...next]));
}

function _movePerfCell(key: string, dir: 'up' | 'down'): void {
    const layout = getDashboardLayout();
    const order = layout.perfCells.map(c => c.key);
    const idx = order.indexOf(key);
    if (idx < 0) return;
    const swap = dir === 'up' ? idx - 1 : idx + 1;
    if (swap < 0 || swap >= order.length) return;
    const next = [...order];
    [next[idx], next[swap]] = [next[swap], next[idx]];
    saveDashboardLayout(setPerfCellOrder(layout, next));
}

// ── Hand-rolled drag-and-drop sort ──────────────────────────────────────
//
// Uses event delegation (one listener per list, not per row) so re-renders
// don't accumulate listeners and the cost is constant in row count.
//
// Insertion semantics: the dragged item lands BEFORE or AFTER the row
// under the cursor based on whether the cursor is in the upper or lower
// half of that row. A coloured insertion line shows where it will land.
//
// Drag is suppressed when the press starts on an interactive child
// (button/select/input) so embedded controls — eye toggle, density
// buttons, ↑/↓ arrows — keep working.
//
// While a drag is active, `_activeDrag` is set so the layout subscriber
// defers re-rendering. Without that, a debounced PUT echo mid-drag would
// replace the dragged DOM node and abort the gesture.

function _bindDragSort(
    root: HTMLElement,
    listSelector: string,
    keyAttr: string,
    onReorder: (orderedKeys: string[]) => void,
): void {
    const list = root.querySelector<HTMLElement>(listSelector);
    if (!list) return;

    let dragKey: string | null = null;
    let dragRow: HTMLElement | null = null;
    let lastIndicatorRow: HTMLElement | null = null;
    let lastIndicatorPos: 'before' | 'after' | null = null;
    let autoScrollRaf: number | null = null;
    let autoScrollDir: -1 | 0 | 1 = 0;

    const scroller = list.closest<HTMLElement>('.dash-cust-body');

    const clearIndicator = (): void => {
        if (lastIndicatorRow) {
            lastIndicatorRow.classList.remove('is-drop-target-before', 'is-drop-target-after');
        }
        lastIndicatorRow = null;
        lastIndicatorPos = null;
    };

    const stopAutoScroll = (): void => {
        if (autoScrollRaf !== null) {
            cancelAnimationFrame(autoScrollRaf);
            autoScrollRaf = null;
        }
        autoScrollDir = 0;
    };

    const tickAutoScroll = (): void => {
        autoScrollRaf = null;
        if (!scroller || autoScrollDir === 0) return;
        scroller.scrollTop += autoScrollDir * 12;
        autoScrollRaf = requestAnimationFrame(tickAutoScroll);
    };

    const updateAutoScroll = (clientY: number): void => {
        if (!scroller) return;
        const rect = scroller.getBoundingClientRect();
        const edge = 40;
        let dir: -1 | 0 | 1 = 0;
        if (clientY < rect.top + edge) dir = -1;
        else if (clientY > rect.bottom - edge) dir = 1;
        if (dir !== autoScrollDir) {
            autoScrollDir = dir;
            if (dir !== 0 && autoScrollRaf === null) {
                autoScrollRaf = requestAnimationFrame(tickAutoScroll);
            }
        }
    };

    const getRowFrom = (target: EventTarget | null): HTMLElement | null => {
        const el = target as HTMLElement | null;
        if (!el) return null;
        const row = el.closest<HTMLElement>('.dash-cust-row-drag');
        return row && list.contains(row) ? row : null;
    };

    list.addEventListener('dragstart', (e) => {
        // Don't start drag from interactive controls inside the row.
        const interactive = (e.target as HTMLElement | null)
            ?.closest('button, select, input, textarea, a');
        if (interactive) { e.preventDefault(); return; }

        const row = getRowFrom(e.target);
        if (!row) return;

        dragKey = row.getAttribute(keyAttr);
        dragRow = row;
        row.classList.add('is-dragging');
        _activeDrag = true;
        if (e.dataTransfer) {
            e.dataTransfer.effectAllowed = 'move';
            // Required by Firefox to enable drag.
            e.dataTransfer.setData('text/plain', dragKey || '');
        }
    });

    list.addEventListener('dragend', () => {
        if (dragRow) dragRow.classList.remove('is-dragging');
        dragRow = null;
        dragKey = null;
        clearIndicator();
        stopAutoScroll();
        _activeDrag = false;
        // Run any layout update that was deferred during the drag.
        if (_renderDeferred) {
            _renderDeferred = false;
            _renderPanelBody();
        }
    });

    list.addEventListener('dragover', (e) => {
        if (!dragKey) return;
        const row = getRowFrom(e.target);
        if (!row) return;
        e.preventDefault();
        if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';

        const rect = row.getBoundingClientRect();
        const pos: 'before' | 'after' = (e.clientY < rect.top + rect.height / 2)
            ? 'before' : 'after';

        if (row !== lastIndicatorRow || pos !== lastIndicatorPos) {
            clearIndicator();
            row.classList.add(pos === 'before' ? 'is-drop-target-before' : 'is-drop-target-after');
            lastIndicatorRow = row;
            lastIndicatorPos = pos;
        }
        updateAutoScroll(e.clientY);
    });

    list.addEventListener('dragleave', (e) => {
        // Clear only when leaving the list entirely, not when crossing
        // between child rows.
        const related = e.relatedTarget as Node | null;
        if (!related || !list.contains(related)) {
            clearIndicator();
            stopAutoScroll();
        }
    });

    list.addEventListener('drop', (e) => {
        e.preventDefault();
        const row = getRowFrom(e.target);
        if (!dragKey || !row) {
            clearIndicator();
            stopAutoScroll();
            return;
        }
        const targetKey = row.getAttribute(keyAttr);
        if (!targetKey || dragKey === targetKey) {
            clearIndicator();
            stopAutoScroll();
            return;
        }
        const rect = row.getBoundingClientRect();
        const pos: 'before' | 'after' = (e.clientY < rect.top + rect.height / 2)
            ? 'before' : 'after';

        const allRows = Array.from(list.querySelectorAll<HTMLElement>('.dash-cust-row-drag'));
        const orderedKeys = allRows.map(r => r.getAttribute(keyAttr) || '').filter(Boolean);
        const fromIdx = orderedKeys.indexOf(dragKey);
        if (fromIdx < 0) { clearIndicator(); stopAutoScroll(); return; }

        // Remove the dragged key first, then recompute the target's index
        // because the splice may have shifted it.
        orderedKeys.splice(fromIdx, 1);
        const targetIdx = orderedKeys.indexOf(targetKey);
        if (targetIdx < 0) { clearIndicator(); stopAutoScroll(); return; }
        const insertAt = pos === 'before' ? targetIdx : targetIdx + 1;
        orderedKeys.splice(insertAt, 0, dragKey);

        clearIndicator();
        stopAutoScroll();
        onReorder(orderedKeys);
    });
}

// ── Export / import ─────────────────────────────────────────────────────

function _doExport(): void {
    const json = exportDashboardLayoutJson();
    const blob = new Blob([json], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `ledgrab-dashboard-layout-${new Date().toISOString().slice(0, 10)}.json`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
    showToast(t('dashboard.customize.exported'), 'success');
}

function _doImport(): void {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'application/json,.json';
    input.onchange = async () => {
        const file = input.files?.[0];
        if (!file) return;
        try {
            const text = await file.text();
            if (importDashboardLayoutJson(text)) {
                showToast(t('dashboard.customize.imported'), 'success');
            } else {
                showToast(t('dashboard.customize.import_failed'), 'error');
            }
        } catch {
            showToast(t('dashboard.customize.import_failed'), 'error');
        }
    };
    input.click();
}
