/*
 * fee[dB]ack v0.3.0 — Playlists + Saved for Later screens.
 *
 * Vanilla JS (constitution P-II). Core REST (/api/playlists, /api/saved/*),
 * no capability domain. Renders #v3-playlists (list + detail with drag-
 * reorder) and #v3-saved (the reserved system playlist). Exposes
 * window.v3Saved.toggle(filename) for the "Save for later" affordance on song
 * cards/rows (used by the library/dashboard).
 */
(function () {
    'use strict';
    const sm = window.feedBack;

    const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
        { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));

    // Return null (don't throw) on a network/connection failure so a fetch
    // rejection can't abort rendering — matches the "degrades gracefully"
    // contract and the other v3 modules' jget/jsend.
    async function jget(u) { try { const r = await fetch(u); return r.ok ? r.json() : null; } catch (e) { return null; } }
    async function jsend(method, u, body) {
        try {
            const r = await fetch(u, {
                method, headers: { 'Content-Type': 'application/json' },
                body: body == null ? undefined : JSON.stringify(body),
            });
            return r.ok ? r.json() : null;
        } catch (e) { return null; }
    }

    // Content-dependent playlist cover: a custom uploaded cover wins; otherwise
    // the playlist's own song art — the icon when empty, one cover for a few
    // songs, a 2×2 mosaic at 4+. `art_urls` / `cover_url` come from /api/playlists.
    function playlistCoverHtml(p) {
        const box = 'w-full aspect-square rounded-lg overflow-hidden bg-fb-bg/50 mb-3';
        const img = (u, cls) => '<img src="' + esc(u) + '" alt="" class="' + cls + '" onerror="this.style.visibility=\'hidden\'">';
        if (p.cover_url) return '<div class="' + box + '">' + img(p.cover_url, 'w-full h-full object-cover') + '</div>';
        const arts = Array.isArray(p.art_urls) ? p.art_urls : [];
        if (!arts.length) {
            return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.kind === 'album' ? '💿' : p.system_key ? '🔖' : '🎵') + '</div>';
        }
        if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0], 'w-full h-full object-cover') + '</div>';
        return '<div class="' + box + ' grid grid-cols-2 grid-rows-2 gap-px">' +
            arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '</div>';
    }

    // The slot's pinned-arrangement INDEX, resolved from the stored NAME
    // against the slot's current chart (names survive rescans; an index
    // wouldn't). null = no pin / the name isn't on this chart → full song.
    function _slotArrIndex(s) {
        if (!s.arrangement || !Array.isArray(s.arrangements)) return null;
        const m = s.arrangements.find((a) => a && (a.smart_name === s.arrangement || a.name === s.arrangement));
        return (m && m.index != null) ? m.index : null;
    }

    function songRow(s, opts) {
        opts = opts || {};
        const handle = opts.draggable
            ? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : '';
        const tuning = s.tuning_name
            ? '<span class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
        // ── Curated-album slot extras (P6) — mixes/saved emit none of this ──
        // A slot plays its RESOLVED chart (data-play-fn: the pinned file, or
        // the work's current keeper when the pinned file is gone) with its
        // pinned arrangement (data-play-arr); `missing` = the whole work left
        // the library, so the row dims and loses play (denominator stays
        // honest). ▾ opens the slot editor (chart + arrangement pin).
        const isAlbum = !!opts.album;
        const missing = isAlbum && !!s.missing;
        const playFn = s.resolved_filename || s.filename;
        const arrIdx = isAlbum ? _slotArrIndex(s) : null;
        const playAttrs = isAlbum && !missing
            ? ' data-play-fn="' + esc(playFn) + '"' + (arrIdx != null ? ' data-play-arr="' + arrIdx + '"' : '')
            : '';
        const acc = (isAlbum && typeof opts.acc === 'number')
            ? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(opts.acc * 100) + '%</span>'
            : '';
        const pin = (isAlbum && s.arrangement)
            ? '<span class="ml-2 text-[0.625rem] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : '';
        const orphan = (isAlbum && s.resolved_from_orphan)
            ? '<span class="ml-2 text-[0.625rem] text-fb-textDim" title="The pinned chart is gone — playing this song\'s current keeper instead">(auto)</span>' : '';
        const slotBtn = (isAlbum && !missing)
            ? '<button data-slot aria-label="Choose chart / arrangement" title="Choose chart / arrangement" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-text text-sm px-2">▾</button>' : '';
        return '<li data-fn="' + esc(s.filename) + '"' + playAttrs + (opts.draggable ? ' draggable="true"' : '') +
            ' class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-fb-card/50 group' + (missing ? ' opacity-50' : '') + '">' +
            handle +
            '<img src="' + esc(s.art_url) + '" alt="" class="w-10 h-10 rounded object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
            '<span class="flex-1 min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(s.title) + tuning + pin + orphan + '</span>' +
            '<span class="block text-xs text-fb-textDim truncate">' + (missing ? 'Missing — no version of this song is in your library' : esc(s.artist)) + '</span></span>' +
            acc +
            (missing ? '' : '<button data-v3-play aria-label="Play" class="opacity-0 group-hover:opacity-100 text-fb-primary hover:text-fb-primaryHi text-sm px-2" title="Play">▶</button>') +
            slotBtn +
            '<button data-remove aria-label="Remove from playlist" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-accent text-sm px-2" title="Remove">✕</button>' +
            '</li>';
    }

    function wireSongRows(listEl, pid, onChange) {
        listEl.querySelectorAll('li[data-fn]').forEach((li) => {
            const fn = li.getAttribute('data-fn');
            li.querySelector('[data-v3-play]')?.addEventListener('click', () => {
                // playSong decodeURIComponent()s its arg for the highway WS, so
                // pass an encoded filename (like the rest of v3) — a raw name
                // with %/#/?/ in it would otherwise misroute or throw.
                // Album slots override the play target (data-play-fn = the
                // orphan-resolved chart) + pass the pinned arrangement index;
                // mix/saved rows carry neither attribute and behave as before.
                const pfn = li.getAttribute('data-play-fn') || fn;
                const pa = li.getAttribute('data-play-arr');
                if (typeof window.playSong === 'function') {
                    window.playSong(encodeURIComponent(pfn), pa == null ? undefined : Number(pa));
                }
            });
            li.querySelector('[data-remove]')?.addEventListener('click', async () => {
                await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' });
                onChange();
            });
        });
        // Drag-reorder.
        let dragEl = null;
        listEl.querySelectorAll('li[draggable="true"]').forEach((li) => {
            li.addEventListener('dragstart', () => { dragEl = li; li.classList.add('opacity-50'); });
            li.addEventListener('dragend', () => { li.classList.remove('opacity-50'); });
            li.addEventListener('dragover', (e) => {
                e.preventDefault();
                if (!dragEl || dragEl === li) return;
                const rect = li.getBoundingClientRect();
                const after = (e.clientY - rect.top) > rect.height / 2;
                li.parentNode.insertBefore(dragEl, after ? li.nextSibling : li);
            });
            li.addEventListener('drop', async (e) => {
                e.preventDefault();
                const order = Array.from(listEl.querySelectorAll('li[data-fn]')).map((x) => x.getAttribute('data-fn'));
                await jsend('POST', '/api/playlists/' + pid + '/reorder', { order });
                // Re-sync from the server: if /reorder was rejected (concurrent
                // change) or the request failed, the optimistic DOM order would
                // otherwise diverge from what was actually persisted.
                onChange();
            });
        });
    }

    // ── #v3-playlists ─────────────────────────────────────────────────────--
    async function renderPlaylists() {
        const root = document.getElementById('v3-playlists');
        if (!root) return;
        const lists = (await jget('/api/playlists')) || [];
        root.innerHTML =
            '<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' +
            '<div class="flex items-center justify-end gap-2 mb-6">' +
            // Curated album (P6): a hand-picked ORDERED set with a chosen chart
            // per track — same machinery as a playlist, kind='album'.
            '<button id="v3-pl-new-album" title="A hand-picked, ordered set of songs — your version of an album, with your chosen chart per track" class="bg-fb-card/80 hover:bg-fb-card border border-fb-border/60 text-fb-text px-4 py-2 rounded-md text-sm font-medium">💿 New album</button>' +
            '<button id="v3-pl-new" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm font-medium shadow-lg shadow-fb-primary/20">New playlist</button>' +
            '</div>' +
            (lists.length
                ? '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
                    '<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
                    playlistCoverHtml(p) +
                    '<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
                    '<div class="text-xs text-fb-textDim">' + (p.kind === 'album' ? '💿 Album · ' : '') + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
                    '</button>').join('') + '</div>'
                : '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
            '</div>';
        root.querySelector('#v3-pl-new')?.addEventListener('click', async () => {
            const name = ((await window.uiPrompt({ title: 'New Playlist', label: 'Playlist name', okLabel: 'Create', placeholder: 'My Playlist' })) || '').trim();
            if (!name) return;
            await jsend('POST', '/api/playlists', { name });
            renderPlaylists();
        });
        root.querySelector('#v3-pl-new-album')?.addEventListener('click', async () => {
            const name = ((await window.uiPrompt({ title: 'New Album', label: 'Album name', okLabel: 'Create', placeholder: 'My Album' })) || '').trim();
            if (!name) return;
            await jsend('POST', '/api/playlists', { name, kind: 'album' });
            renderPlaylists();
        });
        root.querySelectorAll('[data-pl]').forEach((b) =>
            b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
    }

    async function renderPlaylistDetail(pid) {
        const root = document.getElementById('v3-playlists');
        if (!root) return;
        const pl = await jget('/api/playlists/' + pid);
        if (!pl) { renderPlaylists(); return; }
        const isSystem = !!pl.system_key;
        const isAlbum = pl.kind === 'album';
        // Set-scoped repertoire (P6, §7.2): an album is a bounded practice SET
        // with a denominator — "N of M mastered", per-track accuracy, never one
        // album score. Same 0.9 threshold as the library meter/green badge.
        let best = {};
        if (isAlbum) best = (await jget('/api/stats/best')) || {};
        const slotAcc = (s) => best[s.resolved_filename || s.filename];
        let meter = '';
        if (isAlbum && pl.songs.length) {
            const tracks = pl.songs.filter((s) => !s.missing);
            const mastered = tracks.filter((s) => (slotAcc(s) || 0) >= 0.9).length;
            const started = tracks.filter((s) => { const b = slotAcc(s); return typeof b === 'number' && b > 0 && b < 0.9; }).length;
            const pct = tracks.length ? Math.max(0, Math.min(100, Math.round((mastered / tracks.length) * 100))) : 0;
            meter =
                '<div class="mb-6">' +
                '<div class="flex items-baseline justify-between gap-3 mb-1">' +
                '<span class="text-sm font-semibold text-fb-text">Album repertoire</span>' +
                '<span class="text-xs text-fb-textDim">' + mastered + ' of ' + tracks.length + ' mastered' +
                (started ? ' &middot; ' + started + ' in progress' : '') + '</span></div>' +
                '<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
                '</div>';
        }
        root.innerHTML =
            '<div class="max-w-3xl mx-auto p-6 md:p-8">' +
            '<button id="v3-pl-back" class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Playlists</button>' +
            '<div class="flex items-center justify-between mb-6 gap-3">' +
            '<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' +
            '<div class="flex gap-2 shrink-0 items-center">' +
            (pl.songs.length
                ? '<button id="v3-pl-shuffle" class="px-2 py-2 rounded-md" aria-pressed="false">' +
                  '<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/></svg>' +
                  '</button>' +
                  '<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>'
                : '') +
            (isSystem ? '' :
                '<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
                (pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
                '<button id="v3-pl-rename" class="text-sm text-fb-textDim hover:text-fb-text px-2">Rename</button>' +
                '<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button>' +
                '<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden">') +
            '</div>' +
            '</div>' +
            meter +
            (pl.songs.length
                ? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem, album: isAlbum, acc: isAlbum ? slotAcc(s) : undefined })).join('') + '</ul>'
                : '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
            '</div>';
        root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
        // Shuffle toggle (crossing arrows, next to Play). Persisted globally —
        // one preference, not per playlist. The queue is shuffled once when
        // Play starts (playQueue.start's shuffle opt); the stored playlist
        // order is never touched.
        const shuffleBtn = root.querySelector('#v3-pl-shuffle');
        const shuffleOn = () => { try { return localStorage.getItem('v3PlaylistShuffle') === '1'; } catch (_) { return false; } };
        const paintShuffle = () => {
            if (!shuffleBtn) return;
            const on = shuffleOn();
            shuffleBtn.className = on
                ? 'px-2 py-2 rounded-md border border-fb-primary bg-fb-primary hover:bg-fb-primaryHi text-white'
                : 'px-2 py-2 rounded-md border border-fb-border text-fb-textDim hover:text-fb-text';
            shuffleBtn.title = on ? 'Shuffle: on' : 'Shuffle: off';
            shuffleBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
        };
        paintShuffle();
        shuffleBtn?.addEventListener('click', () => {
            try { localStorage.setItem('v3PlaylistShuffle', shuffleOn() ? '0' : '1'); } catch (_) { /* private mode */ }
            paintShuffle();
        });
        // Play all: start the play-queue with this playlist's songs (auto-advances
        // track to track). Falls back to playing the first song on an older core
        // without the queue, so the button always does something. An ALBUM plays
        // each slot's resolved chart with its pinned arrangement (playQueue's
        // per-index arrangements array, #685) and skips missing works.
        root.querySelector('#v3-pl-playall')?.addEventListener('click', () => {
            const files = [], arrs = [];
            (pl.songs || []).forEach((s) => {
                if (isAlbum && s.missing) return;
                const fn = s.resolved_filename || s.filename;
                if (!fn) return;
                files.push(fn);
                const idx = isAlbum ? _slotArrIndex(s) : null;
                arrs.push(idx == null ? undefined : idx);
            });
            if (!files.length) return;
            if (window.feedBack && window.feedBack.playQueue) {
                window.feedBack.playQueue.start(files, isAlbum
                    ? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
                    : { source: pl.name, shuffle: shuffleOn() });
            } else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
        });
        const listEl = root.querySelector('#v3-pl-songs');
        if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
        // Album slot editor (▾ per row): pick the slot's chart + arrangement.
        if (listEl && isAlbum) {
            listEl.querySelectorAll('li[data-fn]').forEach((li) => {
                li.querySelector('[data-slot]')?.addEventListener('click', () => {
                    const fn = li.getAttribute('data-fn');
                    const slot = (pl.songs || []).find((x) => x.filename === fn);
                    if (slot) openSlotPicker(pid, slot, () => renderPlaylistDetail(pid));
                });
            });
        }
        root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => {
            const name = ((await window.uiPrompt({ title: 'Rename Playlist', label: 'Playlist name', value: pl.name, okLabel: 'Rename' })) || '').trim();
            if (!name) return;
            await jsend('PATCH', '/api/playlists/' + pid, { name });
            renderPlaylistDetail(pid);
        });
        root.querySelector('#v3-pl-delete')?.addEventListener('click', async () => {
            if (!window.confirm('Delete "' + pl.name + '"?')) return;
            await fetch('/api/playlists/' + pid, { method: 'DELETE' });
            renderPlaylists();
        });
        // Custom cover: pick an image → upload as a data URL → the playlist card
        // shows it (overriding the song-art cover). Re-render the detail so the
        // Remove-cover button appears; the grid picks up the new cover on return.
        const coverFile = root.querySelector('#v3-pl-cover-file');
        root.querySelector('#v3-pl-cover')?.addEventListener('click', () => coverFile && coverFile.click());
        coverFile?.addEventListener('change', () => {
            const f = coverFile.files && coverFile.files[0];
            if (!f) return;
            const reader = new FileReader();
            reader.onload = async (e) => {
                await jsend('POST', '/api/playlists/' + pid + '/cover', { image: e.target.result });
                renderPlaylistDetail(pid);
            };
            reader.readAsDataURL(f);
        });
        root.querySelector('#v3-pl-cover-rm')?.addEventListener('click', async () => {
            await fetch('/api/playlists/' + pid + '/cover', { method: 'DELETE' });
            renderPlaylistDetail(pid);
        });
    }

    // ── Curated-album slot editor (P6, §7.2) ─────────────────────────────────
    // Pins THIS slot's chart + arrangement. The per-slot pick is deliberately
    // independent of the work's global preferred — a rehearsed set must stay
    // the same notes even if the global keeper is re-picked later. Charts come
    // from the work-charts API; the arrangement pin is stored as a NAME (it
    // survives rescans; the index is resolved at play). A compact overlay for
    // now — unifying with the library's Charts drawer (slot-scoped mode) is a
    // follow-up once the in-flight drawer changes land.
    async function openSlotPicker(pid, slot, onChange) {
        const curFn = slot.resolved_filename || slot.filename;
        let wk = slot.work_key;
        if (!wk) {
            const w = await jget('/api/chart/' + encodeURIComponent(curFn) + '/work');
            wk = w && w.work_key;
        }
        const charts = wk ? await jget('/api/work/' + encodeURIComponent(wk) + '/charts') : null;
        const chartList = (charts && Array.isArray(charts.charts)) ? charts.charts : [];
        const radio = (name, value, checked, label, sub) =>
            '<label class="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-fb-card/60 cursor-pointer">' +
            '<input type="radio" name="' + name + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + ' class="accent-fb-primary mt-0.5">' +
            '<span class="min-w-0 flex-1"><span class="block text-sm text-fb-text truncate">' + label + '</span>' +
            (sub ? '<span class="block text-[0.625rem] text-fb-textDim truncate">' + sub + '</span>' : '') +
            '</span></label>';
        // Checked = the stored pin; an orphaned slot (stored file gone from the
        // list) pre-checks the chart it currently resolves to, so Apply re-pins
        // what's actually playing.
        const slotInList = chartList.some((x) => x.filename === slot.filename);
        const chartRows = chartList.map((c) => radio(
            'slot-chart', c.filename,
            c.filename === slot.filename || (!slotInList && c.filename === curFn),
            esc(c.title) + (c.is_representative ? ' <span class="text-[0.625rem] text-fb-primary">● preferred</span>' : ''),
            esc((c.tuning_name ? c.tuning_name + ' · ' : '') + c.filename))).join('');
        const arrRows = [radio('slot-arr', '', !slot.arrangement, 'Full song <span class="text-[0.625rem] text-fb-textDim">(default)</span>', '')]
            .concat((slot.arrangements || []).map((a) => {
                const name = (a && (a.smart_name || a.name)) || '';
                if (!name) return '';
                return radio('slot-arr', name,
                    slot.arrangement === name || slot.arrangement === a.name,
                    esc(name), '');
            })).join('');
        const overlay = document.createElement('div');
        overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
        overlay.innerHTML =
            '<div class="bg-fb-card w-full max-w-md rounded-xl border border-fb-border/60 p-5 space-y-4 max-h-[80vh] overflow-y-auto v3-scroll">' +
            '<div class="flex items-center justify-between gap-2">' +
            '<h3 class="text-lg font-semibold text-fb-text truncate">' + esc(slot.title) + '</h3>' +
            '<button type="button" data-x class="text-fb-textDim hover:text-fb-text text-xl leading-none" aria-label="Close">✕</button></div>' +
            (chartRows
                ? '<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Chart for this slot</div>' + chartRows + '</div>'
                : '') +
            '<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Arrangement</div>' + arrRows + '</div>' +
            '<div data-err class="hidden text-xs text-red-400"></div>' +
            '<div class="flex justify-end gap-2">' +
            '<button type="button" data-cancel class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Cancel</button>' +
            '<button type="button" data-apply class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-5 py-2 rounded-md">Apply</button>' +
            '</div></div>';
        const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
        function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
        document.addEventListener('keydown', onKey);
        overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
        overlay.querySelector('[data-x]').addEventListener('click', close);
        overlay.querySelector('[data-cancel]').addEventListener('click', close);
        overlay.querySelector('[data-apply]').addEventListener('click', async () => {
            const chart = overlay.querySelector('input[name="slot-chart"]:checked');
            const arr = overlay.querySelector('input[name="slot-arr"]:checked');
            const body = {};
            if (chart && chart.value && chart.value !== slot.filename) body.chart_filename = chart.value;
            if (arr) {
                const v = arr.value || null;
                if (v !== (slot.arrangement || null)) body.arrangement = v;
            }
            if (Object.keys(body).length) {
                // jsend → null on a non-2xx (e.g. swap-to-other-work rejected, or
                // the resolved target duplicates another slot's pin). Surfacing it
                // and keeping the picker open beats silently closing "as saved".
                const res = await jsend('PATCH', '/api/playlists/' + pid + '/songs/' + encodeURIComponent(slot.filename), body);
                if (!res) {
                    const err = overlay.querySelector('[data-err]');
                    if (err) { err.textContent = 'Could not update this slot.'; err.classList.remove('hidden'); }
                    return;   // keep the picker open — not a success
                }
            }
            close();
            onChange();
        });
        document.body.appendChild(overlay);
    }

    // ── #v3-saved ─────────────────────────────────────────────────────────--
    async function renderSaved() {
        const root = document.getElementById('v3-saved');
        if (!root) return;
        const lists = (await jget('/api/playlists')) || [];
        const saved = lists.find((p) => p.system_key === 'saved_for_later');
        const pl = saved ? await jget('/api/playlists/' + saved.id) : null;
        root.innerHTML =
            '<div class="max-w-3xl mx-auto px-6 md:px-8 pb-8">' +
            (pl && pl.songs.length
                ? '<ul id="v3-saved-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, {})).join('') + '</ul>'
                : '<p class="text-fb-textDim">Nothing saved yet. Use “Save for later” on a song to add it here.</p>') +
            '</div>';
        const listEl = root.querySelector('#v3-saved-songs');
        if (listEl && pl) wireSongRows(listEl, pl.id, renderSaved);
    }

    // ── Public: Save-for-later toggle for song cards/rows ─────────────────--
    window.v3Saved = {
        toggle: async function (filename) {
            const res = await jsend('POST', '/api/saved/toggle', { filename });
            return res ? res.saved : null;
        },
    };
    window.v3Playlists = { refresh: renderPlaylists, refreshSaved: renderSaved };

    // Lazy-render when these screens are shown (data can change between visits).
    if (sm && typeof sm.on === 'function') {
        sm.on('screen:changed', (e) => {
            const id = e && e.detail && e.detail.id;
            if (id === 'v3-playlists') renderPlaylists();
            else if (id === 'v3-saved') renderSaved();
        });
    }
    function boot() { renderPlaylists(); renderSaved(); }
    // `defer` runs this at readyState 'interactive' — later scripts have not
    // evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
    if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
    else boot();
})();
