/**
 * Auto-update — check for new releases, show banner, manage settings.
 */

import { fetchWithAuth } from '../core/api.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { t } from '../core/i18n.ts';
import { IconSelect } from '../core/icon-select.ts';
import { ICON_SPARKLES, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts';

// ─── State ──────────────────────────────────────────────────

interface UpdateAsset {
    name: string;
    size: number;
    download_url: string;
}

interface UpdateRelease {
    version: string;
    tag: string;
    name: string;
    body: string;
    prerelease: boolean;
    published_at: string;
    assets?: UpdateAsset[];
}

interface UpdateStatus {
    current_version: string;
    has_update: boolean;
    checking: boolean;
    last_check: number | null;
    last_error: string | null;
    releases_url: string;
    install_type: string;
    can_auto_update: boolean;
    downloading: boolean;
    download_progress: number;
    applying: boolean;
    release: UpdateRelease | null;
    dismissed_version: string;
}

let _lastStatus: UpdateStatus | null = null;
let _releaseNotesBody = '';

// ─── Version badge highlight ────────────────────────────────

function _setVersionBadgeUpdate(hasUpdate: boolean): void {
    const badge = document.getElementById('server-version');
    if (!badge) return;
    badge.classList.toggle('has-update', hasUpdate);

    if (hasUpdate) {
        badge.style.cursor = 'pointer';
        badge.title = t('update.badge_tooltip');
        badge.onclick = () => switchSettingsTabToUpdate();
    } else {
        badge.style.cursor = '';
        badge.title = '';
        badge.onclick = null;
    }
}

export function switchSettingsTabToUpdate(): void {
    if (typeof window.openSettingsModal === 'function') {
        window.openSettingsModal();
    }
    setTimeout(() => {
        if (typeof window.switchSettingsTab === 'function') {
            window.switchSettingsTab('updates');
        }
    }, 50);
}

// ─── Update banner ──────────────────────────────────────────

function _showBanner(status: UpdateStatus): void {
    const release = status.release;
    if (!release) return;

    const dismissed = localStorage.getItem('update-dismissed-version');
    if (dismissed === release.version) return;

    const banner = document.getElementById('update-banner');
    if (!banner) return;

    const versionLabel = release.prerelease
        ? `${release.version} (${t('update.prerelease')})`
        : release.version;
    const versionChip = `<span class="update-banner-version">${versionLabel}</span>`;

    let actions = '';

    // "Update Now" button if auto-update is supported
    if (status.can_auto_update) {
        actions += `<button class="btn btn-icon update-banner-action update-banner-apply" onclick="applyUpdate()" title="${t('update.apply_now')}">
            ${ICON_DOWNLOAD}
        </button>`;
    }

    actions += `<button class="btn btn-icon update-banner-action" onclick="switchSettingsTabToUpdate()" title="${t('update.view_release')}">
        ${ICON_SPARKLES}
    </button>`;

    actions += `<button class="btn btn-icon update-banner-action" onclick="dismissUpdate()" title="${t('update.dismiss')}">
        ${ICON_X}
    </button>`;

    banner.innerHTML = `
        <span class="update-banner-text">
            ${t('update.available').replace('{version}', versionChip)}
        </span>
        <span class="update-banner-actions">${actions}</span>
    `;
    banner.style.display = 'flex';
}

function _hideBanner(): void {
    const banner = document.getElementById('update-banner');
    if (banner) banner.style.display = 'none';
}

export function dismissUpdate(): void {
    if (!_lastStatus?.release) return;
    const version = _lastStatus.release.version;
    localStorage.setItem('update-dismissed-version', version);
    _hideBanner();
    _setVersionBadgeUpdate(false);

    fetchWithAuth('/system/update/dismiss', {
        method: 'POST',
        body: JSON.stringify({ version }),
    }).catch(() => {});
}

// ─── Apply update ───────────────────────────────────────────

export async function applyUpdate(): Promise<void> {
    if (!_lastStatus?.release) return;

    const version = _lastStatus.release.version;
    const confirmed = await showConfirm(
        t('update.apply_confirm').replace('{version}', version)
    );
    if (!confirmed) return;

    // Disable the apply button
    const btns = document.querySelectorAll('.update-banner-apply, #update-apply-btn');
    btns.forEach(b => (b as HTMLButtonElement).disabled = true);

    try {
        const resp = await fetchWithAuth('/system/update/apply', {
            method: 'POST',
            timeout: 600000, // 10 min for download + apply
        });
        if (!resp.ok) {
            const err = await resp.json().catch(() => ({}));
            throw new Error(err.detail || `HTTP ${resp.status}`);
        }
        // Server will shut down — the frontend reconnect overlay handles the rest
        showToast(t('update.applying'), 'info');
    } catch (err) {
        showToast(t('update.apply_error') + ': ' + (err as Error).message, 'error');
        btns.forEach(b => (b as HTMLButtonElement).disabled = false);
    }
}

// ─── Status fetch (called on page load + WS event) ─────────

export async function loadUpdateStatus(): Promise<void> {
    try {
        const resp = await fetchWithAuth('/system/update/status');
        if (!resp.ok) return;
        const status: UpdateStatus = await resp.json();
        _lastStatus = status;
        _applyStatus(status);
    } catch {
        // silent — non-critical
    }
}

function _applyStatus(status: UpdateStatus): void {
    const dismissed = localStorage.getItem('update-dismissed-version');
    const hasVisibleUpdate = status.has_update
        && status.release != null
        && status.release.version !== dismissed;

    // The header badge + floating banner respect the user's dismissal so they
    // don't keep nagging on every page reload. The rail badge inside the
    // settings modal is the *destination* the user is told to visit — it
    // should keep flagging the update even after dismissal so they can find
    // the Updates tab again. Hide it only when there's genuinely no update.
    const hasAnyUpdate = status.has_update && status.release != null;

    _setVersionBadgeUpdate(hasVisibleUpdate);
    _setRailUpdateBadge(hasAnyUpdate, status.release?.version ?? '');

    if (hasVisibleUpdate) {
        _showBanner(status);
    } else {
        _hideBanner();
    }

    _renderUpdatePanel(status);
}

/** Toggle the small badge on the Updates rail-button so the sidebar
 *  reflects "update available" the same way the in-app version badge does.
 *  Runs on every status fetch / WS event so the indicator stays in sync. */
function _setRailUpdateBadge(visible: boolean, version: string): void {
    const badge = document.getElementById('settings-rail-update-badge');
    if (!badge) return;
    badge.hidden = !visible;
    if (visible) {
        // Numeric "1" reads cleaner than the version string in the small pill.
        badge.textContent = '1';
        if (version) {
            badge.setAttribute('title', t('update.available').replace('{version}', version));
        }
    } else {
        badge.removeAttribute('title');
    }
}

// ─── WS event handlers ─────────────────────────────────────

export function initUpdateListener(): void {
    document.addEventListener('server:update_available', ((e: CustomEvent) => {
        const data = e.detail;
        if (data && data.version && !data.dismissed) {
            loadUpdateStatus();
        }
    }) as EventListener);

    // Download progress
    document.addEventListener('server:update_download_progress', ((e: CustomEvent) => {
        const progress = e.detail?.progress;
        if (typeof progress === 'number') {
            _updateProgressBar(progress);
        }
    }) as EventListener);
}

function _updateProgressBar(progress: number): void {
    const bar = document.getElementById('update-progress-bar');
    if (bar) {
        bar.style.width = `${Math.round(progress * 100)}%`;
        bar.parentElement!.style.display = progress > 0 && progress < 1 ? '' : 'none';
    }
}

// ─── Manual check ──────────────────────────────────────────

export async function checkForUpdates(): Promise<void> {
    const btn = document.getElementById('update-check-btn') as HTMLButtonElement | null;
    const spinner = document.getElementById('update-check-spinner');
    if (btn) btn.disabled = true;
    if (spinner) spinner.style.display = '';

    try {
        const resp = await fetchWithAuth('/system/update/check', { method: 'POST' });
        if (!resp.ok) {
            const err = await resp.json().catch(() => ({}));
            throw new Error(err.detail || `HTTP ${resp.status}`);
        }
        const status: UpdateStatus = await resp.json();
        _lastStatus = status;
        _applyStatus(status);

        if (status.has_update && status.release) {
            showToast(t('update.available').replace('{version}', status.release.version), 'info');
        } else {
            showToast(t('update.up_to_date'), 'success');
        }
    } catch (err) {
        showToast(t('update.check_error') + ': ' + (err as Error).message, 'error');
    } finally {
        if (btn) btn.disabled = false;
        if (spinner) spinner.style.display = 'none';
    }
}

// ─── Settings panel ────────────────────────────────────────

let _channelIconSelect: IconSelect | null = null;
let _updateIntervalIconSelect: IconSelect | null = null;

function _getIntervalItems(): { value: string; icon: string; label: string }[] {
    return [
        { value: '1',   icon: '<span style="font-weight:700">1h</span>',  label: '1h' },
        { value: '6',   icon: '<span style="font-weight:700">6h</span>',  label: '6h' },
        { value: '12',  icon: '<span style="font-weight:700">12h</span>', label: '12h' },
        { value: '24',  icon: '<span style="font-weight:700">24h</span>', label: '24h' },
        { value: '48',  icon: '<span style="font-weight:700">48h</span>', label: '48h' },
        { value: '168', icon: '<span style="font-weight:700">7d</span>',  label: '7d' },
    ];
}

function _getChannelItems(): { value: string; icon: string; label: string; desc: string }[] {
    return [
        {
            value: 'false',
            icon: '<span style="color:#4CAF50;font-weight:700">S</span>',
            label: t('update.channel.stable'),
            desc: t('update.channel.stable_desc'),
        },
        {
            value: 'true',
            icon: '<span style="color:#ff9800;font-weight:700">P</span>',
            label: t('update.channel.prerelease'),
            desc: t('update.channel.prerelease_desc'),
        },
    ];
}

export function initUpdateSettingsPanel(): void {
    // The IconSelects auto-persist on change — there is no longer a manual
    // Save button in the Auto-Check section.
    if (!_channelIconSelect) {
        const sel = document.getElementById('update-channel') as HTMLSelectElement | null;
        if (sel) {
            _channelIconSelect = new IconSelect({
                target: sel,
                items: _getChannelItems(),
                columns: 2,
                onChange: () => saveUpdateSettings(),
            });
        }
    }
    if (!_updateIntervalIconSelect) {
        const sel = document.getElementById('update-interval') as HTMLSelectElement | null;
        if (sel) {
            _updateIntervalIconSelect = new IconSelect({
                target: sel,
                items: _getIntervalItems(),
                columns: 3,
                onChange: () => saveUpdateSettings(),
            });
        }
    }
    // Toggle auto-saves on every flip.
    const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
    if (enabledEl && !enabledEl.dataset.autosaveBound) {
        enabledEl.addEventListener('change', () => { saveUpdateSettings(); });
        enabledEl.dataset.autosaveBound = '1';
    }
}

export async function loadUpdateSettings(): Promise<void> {
    try {
        const resp = await fetchWithAuth('/system/update/settings');
        if (!resp.ok) return;
        const data = await resp.json();

        const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
        const intervalEl = document.getElementById('update-interval') as HTMLSelectElement | null;
        const channelEl = document.getElementById('update-channel') as HTMLSelectElement | null;

        if (enabledEl) enabledEl.checked = data.enabled;
        if (intervalEl) intervalEl.value = String(data.check_interval_hours);
        if (_updateIntervalIconSelect) {
            _updateIntervalIconSelect.setValue(String(data.check_interval_hours));
        }
        if (_channelIconSelect) {
            _channelIconSelect.setValue(String(data.include_prerelease));
        } else if (channelEl) {
            channelEl.value = String(data.include_prerelease);
        }
    } catch (err) {
        console.error('Failed to load update settings:', err);
    }

    await loadUpdateStatus();
}

/** Persist auto-check settings. The Save button has been removed —
 *  this is invoked silently from change-listeners on the three fields
 *  (enabled toggle, interval IconSelect, channel IconSelect).
 *  Errors still surface as toasts; success is silent. */
export async function saveUpdateSettings(): Promise<void> {
    const enabled = (document.getElementById('update-enabled') as HTMLInputElement)?.checked ?? true;
    const intervalStr = (document.getElementById('update-interval') as HTMLSelectElement)?.value ?? '24';
    const check_interval_hours = parseFloat(intervalStr);
    const channelVal = (document.getElementById('update-channel') as HTMLSelectElement)?.value ?? 'false';
    const include_prerelease = channelVal === 'true';
    if (Number.isNaN(check_interval_hours)) return;

    try {
        const resp = await fetchWithAuth('/system/update/settings', {
            method: 'PUT',
            body: JSON.stringify({ enabled, check_interval_hours, include_prerelease }),
        });
        if (!resp.ok) {
            const err = await resp.json().catch(() => ({}));
            throw new Error(err.detail || `HTTP ${resp.status}`);
        }
    } catch (err) {
        showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error');
    }
}

function _renderUpdatePanel(status: UpdateStatus): void {
    const currentEl = document.getElementById('update-current-version');
    if (currentEl) currentEl.textContent = `v${status.current_version}`;

    const statusEl = document.getElementById('update-status-text');
    const card = document.getElementById('update-status-card');
    const meta = document.getElementById('update-status-meta');

    let state: 'available' | 'error' | 'updated' | 'checking' = 'updated';
    let metaText = '';
    if (statusEl) {
        if (status.has_update && status.release) {
            statusEl.textContent = t('update.available').replace('{version}', status.release.version);
            statusEl.style.color = '';
            state = 'available';
            metaText = t('update.pill.available');
        } else if (status.last_error) {
            statusEl.textContent = t('update.check_error') + ': ' + status.last_error;
            statusEl.style.color = '';
            state = 'error';
            metaText = t('update.pill.error');
        } else {
            statusEl.textContent = t('update.up_to_date');
            statusEl.style.color = '';
            state = 'updated';
            metaText = t('update.pill.updated');
        }
    }
    if (card) card.setAttribute('data-state', state);
    if (meta) {
        if (metaText) {
            meta.textContent = metaText;
            meta.hidden = false;
        } else {
            meta.hidden = true;
        }
    }

    const lastCheckEl = document.getElementById('update-last-check');
    if (lastCheckEl) {
        if (status.last_check) {
            lastCheckEl.textContent = t('update.last_check') + ': ' + new Date(status.last_check * 1000).toLocaleString();
        } else {
            lastCheckEl.textContent = t('update.last_check') + ': ' + t('update.never');
        }
    }

    // Install type info
    const installEl = document.getElementById('update-install-type');
    if (installEl) {
        installEl.textContent = t(`update.install_type.${status.install_type}`);
    }

    // "Update Now" button in settings panel
    const applyBtn = document.getElementById('update-apply-btn') as HTMLButtonElement | null;
    if (applyBtn) {
        const show = status.has_update && status.can_auto_update;
        applyBtn.style.display = show ? '' : 'none';
        applyBtn.disabled = status.downloading || status.applying;
        if (status.downloading) {
            applyBtn.textContent = `${t('update.downloading')} ${Math.round(status.download_progress * 100)}%`;
        } else if (status.applying) {
            applyBtn.textContent = t('update.applying');
        } else {
            applyBtn.textContent = t('update.apply_now');
        }
    }

    // Progress bar
    const progressBar = document.getElementById('update-progress-bar');
    if (progressBar) {
        const show = status.downloading && status.download_progress > 0 && status.download_progress < 1;
        progressBar.style.width = `${Math.round(status.download_progress * 100)}%`;
        progressBar.parentElement!.style.display = show ? '' : 'none';
    }

    // Release notes button visibility
    const notesGroup = document.getElementById('update-release-notes-group');
    if (notesGroup) {
        if (status.has_update && status.release && status.release.body) {
            _releaseNotesBody = status.release.body;
            notesGroup.style.display = '';
        } else {
            _releaseNotesBody = '';
            notesGroup.style.display = 'none';
        }
    }
}

// ─── Release Notes Overlay ─────────────────────────────────

function _setRnText(id: string, text: string | null): void {
    const el = document.getElementById(id);
    if (!el) return;
    if (text) {
        el.textContent = text;
        el.hidden = false;
    } else {
        el.textContent = '';
        el.hidden = true;
    }
}

function _setRnChipShown(id: string, shown: boolean): void {
    const el = document.getElementById(id);
    if (el) el.hidden = !shown;
}

function _formatPublished(iso: string | null | undefined): string | null {
    if (!iso) return null;
    const d = new Date(iso);
    if (Number.isNaN(d.getTime())) return null;
    // Compact ISO-like form: YYYY-MM-DD (matches the mono "instrument" tone).
    const yyyy = d.getFullYear();
    const mm = String(d.getMonth() + 1).padStart(2, '0');
    const dd = String(d.getDate()).padStart(2, '0');
    return `${yyyy}-${mm}-${dd}`;
}

/** Populate the redesigned overlay header from the cached release status. */
function _renderReleaseNotesHeader(): void {
    const release = _lastStatus?.release ?? null;

    const tag = release?.tag?.trim() || null;
    _setRnText('release-notes-tag', tag);
    _setRnChipShown('release-notes-tag-chip', !!tag);

    const dateText = _formatPublished(release?.published_at);
    _setRnText('release-notes-date', dateText);
    _setRnChipShown('release-notes-date-chip', !!dateText);

    _setRnChipShown('release-notes-pre-chip', !!release?.prerelease);

    const metaWrap = document.getElementById('release-notes-meta');
    if (metaWrap) {
        const anyChipShown = !!tag || !!dateText || !!release?.prerelease;
        metaWrap.hidden = !anyChipShown;
    }

    // External link to the release on the upstream tracker (if available).
    const ext = document.getElementById('release-notes-external') as HTMLAnchorElement | null;
    if (ext) {
        const url = _lastStatus?.releases_url;
        if (url) {
            ext.href = url;
            ext.hidden = false;
        } else {
            ext.removeAttribute('href');
            ext.hidden = true;
        }
    }
}

/** File extensions we recognise as direct downloads in markdown links. */
const _RN_ASSET_EXTS = new Set([
    'exe', 'msi', 'dmg', 'pkg', 'zip', 'tgz', 'tbz', '7z', 'rar',
    'apk', 'aab', 'ipa', 'deb', 'rpm', 'appimage', 'iso', 'img',
    'sig', 'asc', 'jar', 'whl', 'gz', 'bz2', 'xz',
]);

interface AssetClass { type: 'asset'; ext: string }
interface ExternalClass { type: 'external' }
type LinkClass = AssetClass | ExternalClass | null;

/** Classify a markdown link as a download asset, an external link, or
 *  neither (in-page anchor / same-origin page link). */
function _classifyReleaseLink(href: string): LinkClass {
    if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('javascript:')) {
        return null;
    }
    let url: URL;
    try {
        url = new URL(href, location.origin);
    } catch {
        return null;
    }
    const path = url.pathname;
    const lower = path.toLowerCase();

    // GitHub / Gitea release-asset and attachment paths are always downloads,
    // even if the extension isn't one we recognise (e.g. unsigned binaries).
    if (lower.includes('/releases/download/') || lower.includes('/attachments/')) {
        const tarMatch = lower.match(/\.tar\.(gz|bz2|xz)$/);
        if (tarMatch) return { type: 'asset', ext: 'tar.' + tarMatch[1] };
        const m = lower.match(/\.([a-z0-9]{1,8})$/);
        return { type: 'asset', ext: m ? m[1] : 'bin' };
    }

    // Compound tar archives — must be checked before single-extension match.
    const tarMatch = lower.match(/\.tar\.(gz|bz2|xz)$/);
    if (tarMatch) return { type: 'asset', ext: 'tar.' + tarMatch[1] };

    // Single-extension downloadable file.
    const m = lower.match(/\.([a-z0-9]+)$/);
    if (m && _RN_ASSET_EXTS.has(m[1])) {
        return { type: 'asset', ext: m[1] };
    }

    // Anything off-origin we treat as external (gets a small ↗ glyph).
    if (url.origin && url.origin !== location.origin) {
        return { type: 'external' };
    }

    return null;
}

/** Walk `<a>` tags inside the rendered markdown and decorate each one
 *  according to its classification: download assets become rack-style
 *  chips with a download icon and an extension badge; external links
 *  get a small ↗ marker. Idempotent — safe to call multiple times. */
function _decorateReleaseNotesLinks(root: HTMLElement): void {
    const anchors = root.querySelectorAll<HTMLAnchorElement>('a[href]');
    anchors.forEach((a) => {
        if (a.classList.contains('rn-dl') || a.classList.contains('rn-ext')) {
            return; // already decorated
        }
        const cls = _classifyReleaseLink(a.getAttribute('href') || '');
        if (!cls) return;

        if (cls.type === 'asset') {
            a.classList.add('rn-dl');
            // Hint to the browser to download rather than navigate.
            if (!a.hasAttribute('download')) a.setAttribute('download', '');
            a.setAttribute('target', '_blank');
            a.setAttribute('rel', 'noopener noreferrer');

            const icon = document.createElement('span');
            icon.className = 'rn-dl__icon';
            icon.setAttribute('aria-hidden', 'true');
            icon.innerHTML = ICON_DOWNLOAD;
            a.prepend(icon);

            const badge = document.createElement('span');
            badge.className = 'rn-dl__ext';
            badge.textContent = cls.ext;
            a.appendChild(badge);
        } else {
            a.classList.add('rn-ext');
            a.setAttribute('target', '_blank');
            a.setAttribute('rel', 'noopener noreferrer');
        }
    });
}

/** Pretty-print a byte count as a compact human-readable string
 *  (`24.3 MB`, `512 KB`, `7 B`). Tabular-friendly — single decimal. */
function _formatBytes(bytes: number): string {
    if (!Number.isFinite(bytes) || bytes <= 0) return '';
    const units = ['B', 'KB', 'MB', 'GB', 'TB'];
    let i = 0;
    let n = bytes;
    while (n >= 1024 && i < units.length - 1) {
        n /= 1024;
        i += 1;
    }
    return (i === 0 ? n.toFixed(0) : n.toFixed(n >= 100 ? 0 : 1)) + ' ' + units[i];
}

/** Extract the file extension (incl. compound `tar.gz/bz2/xz`) from a name. */
function _assetExt(name: string): string {
    const lower = name.toLowerCase();
    const tar = lower.match(/\.tar\.(gz|bz2|xz)$/);
    if (tar) return 'tar.' + tar[1];
    const m = lower.match(/\.([a-z0-9]{1,8})$/);
    return m ? m[1] : 'bin';
}

/** Build a `<span>` with a class and text content. */
function _span(cls: string, text?: string): HTMLSpanElement {
    const el = document.createElement('span');
    el.className = cls;
    if (text !== undefined) el.textContent = text;
    return el;
}

/** Validate a download URL — reject anything that isn't http(s) so a
 *  malicious upstream release can't smuggle a `javascript:` URL. */
function _safeDownloadHref(raw: string): string | null {
    try {
        const u = new URL(raw, location.origin);
        if (u.protocol === 'http:' || u.protocol === 'https:') {
            return u.toString();
        }
    } catch {
        return null;
    }
    return null;
}

/** Compute the trailing file extension (incl. compound `.tar.gz/.bz2/.xz`)
 *  on a filename string, or '' if there isn't one. Lower-cased. */
function _extOf(name: string): string {
    const lower = name.toLowerCase();
    const tar = lower.match(/\.tar\.(gz|bz2|xz)$/);
    if (tar) return '.tar.' + tar[1];
    const m = lower.match(/\.[a-z0-9]+$/);
    return m ? m[0] : '';
}

/** Tokenise a filename for fuzzy matching: split on `-_.\s` and keep
 *  tokens of length ≥ 2 (drops the version segment to noise tokens). */
function _fileTokens(name: string): Set<string> {
    return new Set(
        name.toLowerCase()
            .split(/[-_.\s]+/)
            .filter((t) => t.length >= 2),
    );
}

/** Find the asset whose name best matches a filename string from the
 *  release-notes markdown. Returns the asset on exact match, or on a
 *  same-extension fuzzy match where the user-supplied tokens cover ≥ 60%
 *  of the asset's tokens (handles "LedGrab-v0.5.0-setup.exe" → the
 *  full "LedGrab-v0.5.0-win-x64-setup.exe" asset). */
function _findMatchingAsset(text: string, assets: UpdateAsset[]): UpdateAsset | null {
    const lower = text.trim().toLowerCase();
    if (!lower || !lower.includes('.')) return null;

    // 1. Exact name match (basename only — strip any path component).
    const basename = lower.replace(/^.*[\\/]/, '');
    for (const a of assets) {
        if (a.name.toLowerCase() === basename) return a;
    }

    // 2. Fuzzy: same extension + significant token overlap.
    const ext = _extOf(basename);
    if (!ext) return null;

    const userTokens = _fileTokens(basename);
    if (userTokens.size === 0) return null;

    let best: UpdateAsset | null = null;
    let bestScore = 0;
    for (const a of assets) {
        const aLower = a.name.toLowerCase();
        if (!aLower.endsWith(ext)) continue;
        const aTokens = _fileTokens(a.name);
        if (aTokens.size === 0) continue;
        let overlap = 0;
        userTokens.forEach((t) => { if (aTokens.has(t)) overlap += 1; });
        // Score by how many of the user's tokens the asset covers.
        const score = overlap / userTokens.size;
        if (score > bestScore) {
            bestScore = score;
            best = a;
        }
    }
    return bestScore >= 0.6 ? best : null;
}

/** Wrap any inline `<code>` element whose text matches a release asset
 *  in an `<a>` so the file becomes clickable. Skips code blocks (`<pre>`)
 *  and codes already inside an `<a>`. Idempotent. */
function _linkInlineAssetCode(root: HTMLElement): void {
    const assets = (_lastStatus?.release?.assets ?? []).filter(
        (a) => _safeDownloadHref(a.download_url) !== null,
    );
    if (assets.length === 0) return;

    const codes = root.querySelectorAll<HTMLElement>('code');
    codes.forEach((code) => {
        if (code.closest('pre')) return;        // skip code-fence blocks
        if (code.closest('a')) return;          // already linked
        const text = code.textContent || '';
        const asset = _findMatchingAsset(text, assets);
        if (!asset) return;
        const href = _safeDownloadHref(asset.download_url);
        if (!href) return;

        const a = document.createElement('a');
        a.href = href;
        a.setAttribute('download', '');
        a.target = '_blank';
        a.rel = 'noopener noreferrer';
        a.classList.add('rn-code-link');
        // Build a richer tooltip: filename · description · size, when available.
        const sizeText = _formatBytes(asset.size);
        const descText = _describeAsset(asset.name);
        const tooltipParts = [asset.name];
        if (descText) tooltipParts.push(descText);
        if (sizeText) tooltipParts.push(sizeText);
        a.title = tooltipParts.join(' · ');

        const parent = code.parentNode;
        if (!parent) return;
        parent.insertBefore(a, code);
        a.appendChild(code);
    });
}

/** Recognise checksum / signature side-files. These are 100-byte text
 *  artifacts paired with a binary, useful only for power-user
 *  verification — they make the rack noisy without adding download
 *  value, so we hide them. */
function _isChecksumAsset(name: string): boolean {
    const lower = name.toLowerCase();
    return /\.(sha256|sha512|sha1|sha384|md5|sig|asc|sigstore|sbom|json\.sig)$/.test(lower)
        || /\.(sha256|sha512|sha1|md5)\.txt$/.test(lower);
}

/** Derive a short human description for an asset from its filename
 *  pattern (installer / portable / archive / mobile build). Falls back
 *  to '' when nothing recognisable matches — the description line is
 *  then hidden. Strings come from the locale bundle. */
function _describeAsset(name: string): string {
    const lower = name.toLowerCase();

    // Windows family
    if (/setup\.exe$/.test(lower)) return t('update.assets.desc.windows_installer');
    if (/\.msi$/.test(lower))      return t('update.assets.desc.windows_msi');
    if (/\.exe$/.test(lower) && /\bwin/.test(lower)) {
        return t('update.assets.desc.windows_exe');
    }
    if (/\.zip$/.test(lower) && /\bwin/.test(lower)) {
        return t('update.assets.desc.windows_portable');
    }

    // Linux family
    if (/\.appimage$/.test(lower)) return t('update.assets.desc.linux_appimage');
    if (/\.deb$/.test(lower))      return t('update.assets.desc.linux_deb');
    if (/\.rpm$/.test(lower))      return t('update.assets.desc.linux_rpm');
    if (/\.flatpak$/.test(lower) || /\.snap$/.test(lower)) {
        return t('update.assets.desc.linux_sandbox');
    }
    if (/(\.tar\.(gz|bz2|xz)|\.tgz)$/.test(lower) && /linux/.test(lower)) {
        return t('update.assets.desc.linux_tarball');
    }

    // macOS family
    if (/\.dmg$/.test(lower)) return t('update.assets.desc.macos_dmg');
    if (/\.pkg$/.test(lower)) return t('update.assets.desc.macos_installer');

    // Android / iOS
    if (/\.apk$/.test(lower)) return t('update.assets.desc.android');
    if (/\.aab$/.test(lower)) return t('update.assets.desc.android_bundle');
    if (/\.ipa$/.test(lower)) return t('update.assets.desc.ios');

    // Generic archive fallbacks
    if (/\.zip$/.test(lower))                       return t('update.assets.desc.zip_archive');
    if (/(\.tar\.(gz|bz2|xz)|\.tgz)$/.test(lower))  return t('update.assets.desc.tarball');
    if (/\.(7z|rar)$/.test(lower))                  return t('update.assets.desc.archive');

    return '';
}

/** Render the explicit "Downloads" rack at the top of the overlay body
 *  using the `assets` list from the release JSON. Each asset becomes a
 *  rack-style chip with icon + filename + description + size + ext badge.
 *  Checksum / signature side-files are filtered out. The rack hides when
 *  there are no installable assets. DOM nodes are built imperatively so
 *  attacker-controlled `name`/`url` strings can never inject HTML. */
function _renderReleaseAssets(): void {
    const wrap = document.getElementById('release-notes-assets');
    if (!wrap) return;

    const assets = (_lastStatus?.release?.assets ?? []).filter(
        (a) => _safeDownloadHref(a.download_url) !== null
            && !_isChecksumAsset(a.name),
    );
    if (assets.length === 0) {
        wrap.hidden = true;
        wrap.replaceChildren();
        return;
    }
    wrap.hidden = false;

    // Header row: dot + title + count pill
    const head = document.createElement('div');
    head.className = 'rn-assets__head';
    const dot = _span('rn-assets__dot');
    dot.setAttribute('aria-hidden', 'true');
    head.append(
        dot,
        _span('rn-assets__title', t('update.assets.title')),
        _span('rn-assets__count', String(assets.length)),
    );

    // Asset list
    const list = document.createElement('div');
    list.className = 'rn-assets__list';

    for (const a of assets) {
        const href = _safeDownloadHref(a.download_url);
        if (!href) continue;

        const row = document.createElement('a');
        row.className = 'rn-asset';
        row.href = href;
        row.setAttribute('download', '');
        row.target = '_blank';
        row.rel = 'noopener noreferrer';
        row.title = a.name;

        // Icon — ICON_DOWNLOAD is a known-safe SVG string from our own bundle.
        const icon = _span('rn-asset__icon');
        icon.setAttribute('aria-hidden', 'true');
        icon.innerHTML = ICON_DOWNLOAD;

        // Filename + description stacked in a single column so they
        // share the 1fr grid slot and the description doesn't push the
        // size / extension chips around.
        const col = document.createElement('div');
        col.className = 'rn-asset__col';
        col.append(_span('rn-asset__name', a.name));
        const desc = _describeAsset(a.name);
        if (desc) col.append(_span('rn-asset__desc', desc));

        const ext = _span('rn-asset__ext', _assetExt(a.name));

        row.append(icon, col);
        const sizeText = _formatBytes(a.size);
        if (sizeText) row.append(_span('rn-asset__size', sizeText));
        row.append(ext);

        list.append(row);
    }

    wrap.replaceChildren(head, list);
}

export function openReleaseNotes(): void {
    const overlay = document.getElementById('release-notes-overlay');
    const content = document.getElementById('release-notes-content');
    if (!overlay || !content) return;

    _renderReleaseNotesHeader();

    import('marked').then(({ marked }) => {
        content.innerHTML = marked.parse(_releaseNotesBody) as string;
        _linkInlineAssetCode(content);
        _decorateReleaseNotesLinks(content);
        overlay.style.display = 'flex';
        // Restart the staggered reveal animation each time we open.
        // Forcing a reflow re-triggers `animation` on the freshly-parsed children.
        // eslint-disable-next-line @typescript-eslint/no-unused-expressions
        void content.offsetWidth;
    });
}

export function closeReleaseNotes(): void {
    const overlay = document.getElementById('release-notes-overlay');
    if (overlay) overlay.style.display = 'none';
}
