/*
 * fee[dB]ack v0.3.0 — player profile: first-run onboarding overlay, the topbar
 * profile badge, and the #v3-profile screen.
 *
 * Vanilla JS (constitution P-II). Reads /api/profile + /api/profile/progress
 * (one call for the whole badge). Degrades gracefully when stats endpoints
 * (prompt 14) aren't present yet. window.v3Onboarding is defined synchronously
 * so the shell boot (shell.js) can call it regardless of script order.
 */
(function () {
    'use strict';

    let _profile = null;     // {display_name, avatar_url, player_hash, onboarded}
    let _progress = null;    // {level, xp, xp_in_level, xp_to_next, current_streak, best_streak}

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

    function avatarImg(url, sizeCls, shape) {
        shape = shape || 'rounded-full';
        if (url) {
            return '<img src="' + esc(url) + '" alt="" class="' + sizeCls + ' ' + shape +
                ' object-cover bg-fb-card border border-fb-border/50">';
        }
        // Neutral fallback glyph.
        return '<span class="' + sizeCls + ' ' + shape + ' bg-fb-card border border-fb-border/50 ' +
            'inline-flex items-center justify-center text-fb-textDim">' +
            '<svg class="w-1/2 h-1/2" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">' +
            '<path stroke-linecap="round" stroke-linejoin="round" d="M12 12a4 4 0 100-8 4 4 0 000 8zm-7 8a7 7 0 0114 0"/></svg></span>';
    }

    // ── API ──────────────────────────────────────────────────────────────────
    async function fetchProfile() {
        try { const r = await fetch('/api/profile'); if (r.ok) _profile = await r.json(); } catch (e) { /* P15 */ }
        return _profile;
    }
    async function fetchProgress() {
        try { const r = await fetch('/api/profile/progress'); if (r.ok) _progress = await r.json(); } catch (e) { /* P15 */ }
        return _progress;
    }

    // ── Topbar profile badge ───────────────────────────────────────────────--
    function renderBadge() {
        const host = document.getElementById('v3-badge-profile');
        if (!host) return;
        if (!_profile || !_profile.onboarded) { host.innerHTML = ''; return; }
        const p = _progress || { current_streak: 0 };
        // Progression (spec 010): the badge shows Mastery Rank + Decibels.
        // Layout still matches the Google Stitch "Profile Card Component"
        // (dark rounded card, white avatar tile, flame + "N DAYS"), but the
        // 6-bar equalizer now meters CURRENT CHALLENGE-SET progress (completed
        // challenges across all paths' active sets / required total) and the
        // big number is the Mastery Rank, with the spendable dB balance beside.
        const prog = (window.v3Progression && window.v3Progression.get()) || null;
        const rank = prog ? prog.mastery_rank : 0;
        const balance = prog && prog.wallet ? prog.wallet.balance : 0;
        let challengesDone = 0, challengesRequired = 0;
        ((prog && prog.paths) || []).forEach((path) => {
            if (path.next) {
                challengesDone += Math.min(path.next.completed, path.next.required);
                challengesRequired += path.next.required;
            }
        });
        const flame = '<svg class="w-5 h-5 text-[#FF4B4B]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">' +
            '<path d="M17.557 9.414c.18-.73.242-1.501.183-2.29-.325-4.374-4.507-7.124-7.592-7.124-3.085 0-4.27 2.112-3.288 3.876.983 1.764 1.446 3.828.623 5.462-.76.712-1.503.73-2.146 1.054-1.288.65-2.112 1.954-2.112 3.414 0 4.418 3.582 8 8 8s8-3.582 8-8c0-1.85-.63-3.551-1.668-4.892zm-7.411 11.586c-2.209 0-4-1.791-4-4 0-1.398 1.065-2.201 1.065-2.201.272.775 1.015 1.327 1.885 1.327 1.105 0 2-.895 2-2s-.895-2-2-2c-.372 0-.719.102-1.018.277.29-.533.72-1.002 1.258-1.332 1.303-.801 2.94-.8 4.243.001 1.316.809 1.972 2.33 1.972 3.927 0 3.314-2.686 6-6 6z"/></svg>';
        const HEIGHTS = ['h-2', 'h-4', 'h-8', 'h-6', 'h-10', 'h-12'];
        const FILL = ['#3B82F6', '#22C55E', '#FACC15', '#F97316', '#D1D5DB', '#22C55E'];
        const filled = challengesRequired > 0
            ? Math.max(0, Math.min(6, Math.round((challengesDone / challengesRequired) * 6))) : 0;
        const bars = HEIGHTS.map((h, i) =>
            '<div class="w-2 ' + h + ' rounded-sm" style="background-color:' + (i < filled ? FILL[i] : '#3f3f46') + '"></div>').join('');
        host.innerHTML =
            '<button type="button" data-v3-open-profile class="bg-fb-card border border-fb-border/50 text-white rounded-2xl flex items-center gap-3 p-2 pr-3 shadow-lg ' +
            'hover:ring-1 hover:ring-fb-primary/40 transition" title="Profile">' +
            '<div data-v3-avatar-tile class="bg-white w-12 h-12 rounded-xl overflow-hidden flex items-center justify-center shrink-0">' +
            (_profile.avatar_url
                ? '<img alt="User Avatar" src="' + esc(_profile.avatar_url) + '" class="w-full h-full object-cover object-center" onerror="this.style.visibility=\'hidden\'">'
                : '<svg class="w-2/3 h-2/3 text-gray-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 12a4 4 0 100-8 4 4 0 000 8zm-7 8a7 7 0 0114 0"/></svg>') +
            '</div>' +
            '<div class="flex flex-col justify-between py-0.5">' +
            '<div class="flex items-center gap-1.5 mb-1">' + flame +
            '<span class="text-base font-extrabold tracking-tight leading-none">' + (p.current_streak || 0) + ' DAYS</span></div>' +
            '<div class="flex items-end gap-2">' +
            '<div class="flex flex-col leading-none">' +
            '<span class="text-gray-400 text-[10px] font-medium">Rank:</span>' +
            '<span class="text-xl font-bold leading-none">' + rank + '</span></div>' +
            '<div class="flex items-end gap-1">' + bars + '</div>' +
            '<span class="text-[10px] font-semibold text-fb-gold leading-none">' + Number(balance).toLocaleString() + ' dB</span>' +
            '</div></div></button>';
        // Equipped avatar frame (spec 010 cosmetics).
        if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
            window.v3Theme.applyFrame(host.querySelector('[data-v3-avatar-tile]'));
        }
        const btn = host.querySelector('[data-v3-open-profile]');
        if (btn) btn.addEventListener('click', () => window.showScreen && window.showScreen('v3-profile'));
    }

    // ── Profile screen (#v3-profile) ────────────────────────────────────────-
    // The screen is tabbed exactly like the v3 Settings page (.fb-tabbar /
    // .fb-tab[data-tab] / .fb-tabpanel[data-tab]; achievements epic): a
    // **Profile** (main) tab and an **Achievements** tab. Core only ships the
    // shell + two mount points; the bundled achievements plugin renders the
    // Feats trophy shelf (#v3-profile-feats-slot, earned-only) and the full
    // competency catalogue (#v3-profile-achievements-mount) into them on the
    // `v3:profile-rendered` event below. Active tab persists in localStorage,
    // mirroring settings.js.
    var PROFILE_TAB_KEY = 'v3-profile-tab';
    var PROFILE_DEFAULT_TAB = 'profile';

    function activateProfileTab(tab) {
        var root = document.getElementById('v3-profile');
        if (!root) return;
        var bar = root.querySelector('#v3-profile-tabbar');
        var tabs = [];
        if (bar) bar.querySelectorAll('.fb-tab').forEach(function (b) { if (b.dataset.tab) tabs.push(b.dataset.tab); });
        if (tabs.indexOf(tab) === -1) tab = tabs.length ? tabs[0] : PROFILE_DEFAULT_TAB;
        root.querySelectorAll('#v3-profile-tabbar .fb-tab').forEach(function (b) {
            b.classList.toggle('active', b.dataset.tab === tab);
        });
        root.querySelectorAll('.fb-tabpanel').forEach(function (p) {
            p.classList.toggle('active', p.dataset.tab === tab);
        });
        try { localStorage.setItem(PROFILE_TAB_KEY, tab); } catch (_) { /* private mode */ }
    }

    function wireProfileTabs() {
        var bar = document.getElementById('v3-profile-tabbar');
        if (bar && bar.dataset.wired !== '1') {
            bar.dataset.wired = '1';
            bar.addEventListener('click', function (e) {
                var btn = e.target.closest ? e.target.closest('.fb-tab') : null;
                if (btn && btn.dataset.tab) activateProfileTab(btn.dataset.tab);
            });
        }
        var saved = PROFILE_DEFAULT_TAB;
        try { saved = localStorage.getItem(PROFILE_TAB_KEY) || PROFILE_DEFAULT_TAB; } catch (_) { /* noop */ }
        activateProfileTab(saved);
    }

    function renderProfileScreen() {
        const root = document.getElementById('v3-profile');
        if (!root) return;
        const p = _progress || { current_streak: 0, best_streak: 0 };
        const name = (_profile && _profile.display_name) || 'Player';
        // Progression (spec 010): the profile header shows Mastery Rank,
        // per-path levels, and Decibels (balance + lifetime) — the old
        // XP-level meter is replaced by the rank/challenge system.
        const prog = (window.v3Progression && window.v3Progression.get()) || null;
        const rank = prog ? prog.mastery_rank : 0;
        const wallet = (prog && prog.wallet) || { balance: 0, lifetime_db: 0 };
        const pathChips = ((prog && prog.paths) || []).map((path) =>
            '<span class="inline-flex items-center gap-1 bg-fb-bg/40 border border-fb-border/50 rounded-full px-3 py-1 text-xs text-fb-text">' +
            esc(path.name) + ' <span class="text-fb-primary font-semibold">Lv ' + path.level + '</span></span>').join(' ');
        // Header card (Profile main tab).
        const headerCard =
            '<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50 flex flex-col sm:flex-row items-center gap-6">' +
            '<span data-v3-avatar-frame class="inline-block rounded-full">' +
            avatarImg(_profile && _profile.avatar_url, 'w-24 h-24') + '</span>' +
            '<div class="flex-1 w-full text-center sm:text-left">' +
            '<h2 class="text-3xl font-bold text-fb-text">' + esc(name) + '</h2>' +
            '<div class="mt-2 flex items-center justify-center sm:justify-start gap-4 text-sm text-fb-textDim flex-wrap">' +
            '<span class="text-fb-text font-semibold">Mastery Rank ' + rank + '</span>' +
            '<span class="text-fb-gold font-semibold">' + Number(wallet.balance).toLocaleString() + ' dB</span>' +
            '<span>' + Number(wallet.lifetime_db).toLocaleString() + ' dB lifetime</span>' +
            '<span class="text-fb-accent">🔥 ' + (p.current_streak || 0) + '-day streak</span>' +
            '<span>Best: ' + (p.best_streak || 0) + '</span></div>' +
            (pathChips ? '<div class="mt-3 flex items-center justify-center sm:justify-start gap-2 flex-wrap">' + pathChips + '</div>' : '') +
            '<div class="mt-4 flex items-center justify-center sm:justify-start gap-4">' +
            '<button type="button" data-v3-edit-profile class="text-sm text-fb-primary hover:text-fb-primaryHi">Edit name &amp; avatar</button>' +
            '<button type="button" data-v3-open-progress class="text-sm text-fb-primary hover:text-fb-primaryHi">View challenges &amp; quests →</button>' +
            '</div></div></div>';
        // Per-song bests — top scored songs from /api/stats/top, filled by
        // renderBests() after innerHTML is set. The placeholder text shows
        // during load and when nothing's been scored yet.
        const bestsCard =
            '<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
            '<h3 class="text-lg font-bold text-fb-text mb-2">Your best scores</h3>' +
            '<div id="v3-profile-bests" class="text-sm text-fb-textDim">Play a song to start tracking your accuracy and best scores.</div>' +
            '</div>';
        const playerIdFooter = (_profile && _profile.player_hash
            ? '<p class="text-center text-[10px] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
            : '');
        root.innerHTML =
            '<div class="max-w-4xl mx-auto p-6 md:p-8">' +
            '<div class="fb-tabbar" id="v3-profile-tabbar">' +
            '<button type="button" class="fb-tab" data-tab="profile">Profile</button>' +
            '<button type="button" class="fb-tab" data-tab="achievements">Achievements</button>' +
            '</div>' +
            // ── Profile (main) panel ──────────────────────────────────────────
            '<div class="fb-tabpanel" data-tab="profile">' +
            '<div class="space-y-6">' +
            headerCard +
            bestsCard +
            // Feats of Power trophy shelf — rendered by the achievements plugin
            // (earned Feats only; hidden-until-earned, so empty when none).
            '<div id="v3-profile-feats-slot"></div>' +
            playerIdFooter +
            '</div></div>' +
            // ── Achievements panel ────────────────────────────────────────────
            '<div class="fb-tabpanel" data-tab="achievements">' +
            '<div id="v3-profile-achievements-mount"></div>' +
            '<p class="fb-tabpanel-empty" data-empty-for="v3-profile-achievements-mount">Install the Achievements plugin to track your skill milestones.</p>' +
            '</div>' +
            '</div>';
        const edit = root.querySelector('[data-v3-edit-profile]');
        if (edit) edit.addEventListener('click', () => show(_profile, { editing: true }));
        const openProgress = root.querySelector('[data-v3-open-progress]');
        if (openProgress) openProgress.addEventListener('click', () => window.showScreen && window.showScreen('v3-progress'));
        if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
            window.v3Theme.applyFrame(root.querySelector('[data-v3-avatar-frame]'));
        }
        wireProfileTabs();
        renderBests();
        // Tell the achievements plugin (or any profile consumer) the shell +
        // mount points exist now, so it can (re)inject on every profile entry —
        // innerHTML above wipes prior injected content. Mirrors
        // `v3:settings-rendered`. Harmless if no listener is attached.
        try { document.dispatchEvent(new CustomEvent('v3:profile-rendered')); } catch (_) { /* noop */ }
    }

    // Fill the "Your best scores" panel from /api/stats/top (top scored songs,
    // best first). Leaves the placeholder text in place on error / no scores so
    // a fresh profile still reads sensibly. Accuracy is 0–1 (matches the library
    // grid + dashboard badges).
    async function renderBests() {
        const host = document.getElementById('v3-profile-bests');
        if (!host) return;
        let rows = [];
        try { const r = await fetch('/api/stats/top?limit=5'); if (r.ok) rows = await r.json(); } catch (e) { /* P15 — keep placeholder */ }
        if (!Array.isArray(rows) || !rows.length) return;   // keep the placeholder text
        const accColor = (a) => (a >= 0.9 ? 'text-fb-good' : a >= 0.5 ? 'text-fb-mid' : 'text-fb-low');
        host.innerHTML =
            '<ol class="space-y-2">' + rows.map((s, i) => {
                const acc = Number(s.best_accuracy) || 0;
                const pct = Math.round(acc * 100);
                const score = Number(s.best_score) || 0;
                return '<li data-fn="' + esc(s.filename) + '" class="flex items-center gap-3 cursor-pointer rounded-md px-2 py-1.5 hover:bg-fb-card transition">' +
                    '<span class="w-5 text-center text-fb-textDim font-semibold shrink-0">' + (i + 1) + '</span>' +
                    '<span class="flex-1 min-w-0">' +
                    '<span class="block text-fb-text truncate">' + esc(s.title || s.filename) + '</span>' +
                    (s.artist ? '<span class="block text-xs text-fb-textDim truncate">' + esc(s.artist) + '</span>' : '') +
                    '</span>' +
                    '<span class="shrink-0 text-right">' +
                    '<span class="block font-bold ' + accColor(acc) + '">' + pct + '%</span>' +
                    '<span class="block text-xs text-fb-textDim">' + score.toLocaleString() + ' pts</span>' +
                    '</span></li>';
            }).join('') + '</ol>';
        // Click a row to play that song (default arrangement).
        host.querySelectorAll('[data-fn]').forEach((li) => {
            li.addEventListener('click', () => {
                const fn = li.getAttribute('data-fn');
                if (fn && typeof window.playSong === 'function') window.playSong(encodeURIComponent(fn));
            });
        });
    }

    // ── First-run onboarding (and edit) overlay ───────────────────────────────
    // First-run is a 3-step flow (spec 010): 1) name + avatar, 2) pick one or
    // more instrument paths, 3) the calibration challenge offer (play the
    // diagnostic sloppak at 100% — or skip and reach Mastery Rank 1 anyway).
    // The profile POST always lands before the step-3 choice so onboarded=1 is
    // never blocked by the calibration decision. Editing keeps the single form.
    // Run the input-device setup wizard (the input_setup plugin's
    // `input-calibration` domain) for the chosen instrument paths — BETWEEN path
    // selection and the calibration challenge — so the diagnostic runs against a
    // calibrated input. Capability-idiomatic: dispatch `run` (fire-and-launch)
    // and await the `calibration-done` event. Degrades gracefully when the
    // plugin/runtime is absent so onboarding can never be stranded.
    // Wait (bounded) for the bundled input_setup plugin to finish registering
    // its input-calibration owner. Plugins load asynchronously, so onboarding
    // can reach this step before the plugin is ready — without this wait the
    // mandatory wizard is skipped by a load-order race (it dispatches, gets a
    // no-owner outcome, and falls through to the calibration challenge). The
    // public global is set at the end of the plugin's screen.js, after the
    // owner is registered, so it is a reliable readiness signal.
    function waitForInputSetup(timeoutMs) {
        const ready = () => !!(window.feedBackInputSetup && typeof window.feedBackInputSetup.launch === 'function');
        return new Promise((resolve) => {
            if (ready()) { resolve(true); return; }
            const t0 = Date.now();
            const iv = setInterval(() => {
                if (ready()) { clearInterval(iv); resolve(true); }
                else if (Date.now() - t0 >= timeoutMs) { clearInterval(iv); resolve(false); }
            }, 100);
        });
    }

    async function runInputSetup(paths) {
        const instruments = (Array.isArray(paths) ? paths : []).map((p) => String(p).toLowerCase());
        if (!instruments.length) return;
        // Don't let a plugin-load race skip the mandatory input-setup step.
        if (!(await waitForInputSetup(8000))) return;
        // Hide this onboarding modal while the input-setup wizard (its own
        // full-screen overlay) runs on top. Otherwise both stay stacked, and when
        // the note-detect calibration wizard minimizes to expose the Tuner the
        // onboarding modal shows through behind the tuner. Restored in `finally`
        // before we advance to the calibration-challenge step.
        const ob = document.getElementById('v3-onboarding');
        const obPrevDisplay = ob ? ob.style.display : '';
        if (ob) ob.style.display = 'none';
        const restoreOnboarding = () => {
            const o = document.getElementById('v3-onboarding');
            if (o) o.style.display = obPrevDisplay;
        };
        try {
            const caps = window.feedBack && window.feedBack.capabilities;
            if (!caps || typeof caps.command !== 'function') {
                try { await window.feedBackInputSetup.launch(instruments); } catch (e) { /* proceed */ }
                return;
            }
            await new Promise((resolve) => {
                let settled = false;
                let unsub = null;
                const done = () => { if (settled) return; settled = true; try { unsub && unsub(); } catch (e) { /* noop */ } resolve(); };
                try { unsub = typeof caps.subscribe === 'function' ? caps.subscribe('input-calibration:calibration-done', done) : null; } catch (e) { unsub = null; }
                // `run` is fire-and-launch; completion arrives via the event above.
                // A non-handled outcome (no owner / plugin absent / error) means
                // nothing was launched, so proceed immediately.
                caps.command('input-calibration', 'run', { requester: 'onboarding', payload: { instruments } })
                    .then((r) => { if (!r || r.outcome !== 'handled') done(); })
                    .catch(() => done());
            });
        } finally {
            restoreOnboarding();
        }
    }

    function show(profile, opts) {
        opts = opts || {};
        const editing = !!opts.editing;
        document.getElementById('v3-onboarding')?.remove();

        // The amp-sim opt-in step (step 5) only exists in the desktop app — the
        // pure-web build has no native amp sims to monitor through, so the step
        // is skipped there (calibration is the last step at index 5 on web, 6 on
        // desktop). See feedBack-desktop#46.
        const isDesktop = !!window.feedBackDesktop;
        const lastStep = isDesktop ? 6 : 5;

        const stepDots = editing ? '' :
            '<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' +
            Array.from({ length: lastStep }, (_, i) => i + 1).map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
            '</div>';

        const overlay = document.createElement('div');
        overlay.id = 'v3-onboarding';
        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 rounded-xl border border-fb-border/50 w-full max-w-lg p-6 space-y-5">' +
            '<div class="text-center">' +
            '<div class="text-2xl">' + (window.fbBrand ? window.fbBrand.wordmarkHTML({ size: 'text-2xl' }) : 'fee[dB]ack') + '</div>' +
            '<p id="v3-ob-subtitle" class="text-sm text-fb-textDim mt-1">' + (editing ? 'Edit your player profile' : 'Set up your player profile') + '</p>' +
            stepDots + '</div>' +
            // Step 1 — name + avatar (the original form, unchanged DOM).
            '<div id="v3-ob-step1">' +
            '<div><label class="block text-xs uppercase tracking-wider text-fb-textDim mb-1">Display name</label>' +
            '<input id="v3-ob-name" type="text" maxlength="32" placeholder="Your name" ' +
            'class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text ' +
            'focus:border-fb-primary focus:ring-1 focus:ring-fb-primary outline-none"></div>' +
            '<div class="mt-4"><label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Avatar</label>' +
            '<div id="v3-ob-avatars" class="grid grid-cols-4 sm:grid-cols-6 gap-2"></div>' +
            '<div class="mt-2 flex items-center gap-3">' +
            '<button type="button" id="v3-ob-upload-btn" class="text-sm text-fb-primary hover:text-fb-primaryHi">Upload your own</button>' +
            '<input type="file" id="v3-ob-upload" accept="image/*" class="hidden">' +
            '<span id="v3-ob-preview"></span></div></div></div>' +
            // Step 2 — song directory (where the user's songs live).
            '<div id="v3-ob-step2" class="hidden">' +
            '<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Song directory</label>' +
            '<p class="text-sm text-fb-textDim mb-3">Choose the folder where your songs are stored. We’ll scan it to build your library. You can change this later in Settings.</p>' +
            '<div class="flex gap-2">' +
            '<input id="v3-ob-songdir" type="text" placeholder="Path to your songs folder" ' +
            'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary focus:ring-1 focus:ring-fb-primary">' +
            '<button type="button" id="v3-ob-songdir-browse" class="hidden px-3 py-2 rounded-md text-sm bg-gray-800/50 border border-gray-700 text-fb-text hover:border-fb-primary whitespace-nowrap">Browse…</button>' +
            '</div></div>' +
            // Step 3 — Achievements wall opt-in (first-run only; default OFF).
            '<div id="v3-ob-step3" class="hidden">' +
            '<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Feats of Power</label>' +
            '<p class="text-sm text-fb-textDim mb-3">As you practise you’ll earn rare <span class="text-fb-text">Feats of Power</span> — silly, bombastic activity trophies. Want to show them off on the public <span class="text-fb-text">Feats wall</span>?</p>' +
            '<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
            '<input type="checkbox" id="v3-ob-optin" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
            '<span class="text-sm text-fb-text">Share my Feats on the wall' +
            '<span class="block text-xs text-fb-textDim mt-1">Publishes only your display name and the Feats you earn — never songs, skills, or scores. You can change this any time in Settings, and remove yourself with one click.</span></span>' +
            '</label>' +
            '<p class="text-xs text-fb-textDim mt-2">Leave it unticked to keep everything private. This is off by default.</p></div>' +
            // Step 4 — instrument paths (first-run only; tiles filled on entry).
            '<div id="v3-ob-step4" class="hidden">' +
            '<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' +
            '<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' +
            '<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' +
            // Step 5 — amp-sim opt-in (DESKTOP ONLY; default OFF / own-rig first).
            // Hidden div is always present in the DOM; setStep only navigates to
            // it on desktop. See feedBack-desktop#46.
            '<div id="v3-ob-step5" class="hidden">' +
            '<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">How do you want to hear yourself?</label>' +
            '<p class="text-sm text-fb-textDim mb-3">fee[dB]ack can run your guitar through built-in <span class="text-fb-text">amp simulations</span> (NAM / IRs / plugins) so you hear a processed tone. If you already play through your <span class="text-fb-text">own amp or rig</span>, leave this off — you’ll get clean, silent monitoring and never an idle buzz.</p>' +
            '<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
            '<input type="checkbox" id="v3-ob-ampsims" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
            '<span class="text-sm text-fb-text">Use in-app amp simulations' +
            '<span class="block text-xs text-fb-textDim mt-1">Loads your saved tone chain for monitoring. You can change this any time in the desktop Audio settings.</span></span>' +
            '</label>' +
            '<p class="text-xs text-fb-textDim mt-2">Leave it unticked if you monitor through your own gear. This is off by default.</p></div>' +
            // Step 6 — calibration offer (first-run only).
            '<div id="v3-ob-step6" class="hidden">' +
            '<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' +
            '<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">fee[dB]ack Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
            '<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and you’ll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' +
            '<p id="v3-ob-error" class="text-sm text-fb-accent hidden"></p>' +
            '<div class="flex justify-end gap-3">' +
            (editing ? '<button type="button" id="v3-ob-cancel" class="px-4 py-2 rounded-md text-sm text-fb-textDim hover:text-fb-text">Cancel</button>' : '') +
            '<button type="button" id="v3-ob-skip" class="hidden px-4 py-2 rounded-md text-sm text-fb-textDim hover:text-fb-text">Skip for now</button>' +
            '<button type="button" id="v3-ob-submit" disabled class="bg-fb-primary hover:bg-fb-primaryHi disabled:opacity-40 disabled:cursor-not-allowed text-white px-6 py-2 rounded-md font-medium shadow-lg shadow-fb-primary/20 transition-colors">' +
            (editing ? 'Save' : 'Next') + '</button></div></div>';
        document.body.appendChild(overlay);

        const nameEl = overlay.querySelector('#v3-ob-name');
        const grid = overlay.querySelector('#v3-ob-avatars');
        const submit = overlay.querySelector('#v3-ob-submit');
        const errEl = overlay.querySelector('#v3-ob-error');
        const fileEl = overlay.querySelector('#v3-ob-upload');
        const preview = overlay.querySelector('#v3-ob-preview');
        const skipBtn = overlay.querySelector('#v3-ob-skip');
        let selected = null;      // { type:'default', value } | { type:'upload', value:url }
        let step = 1;             // first-run wizard step (editing stays on 1)
        let songDir = '';         // step-2 song directory pick
        let selectedPaths = [];   // step-3 picks
        let pathsAvailable = false;      // any tiles rendered? (false → don't strand the user)
        let diagnosticFilename = null;   // from /api/progression (step-4 "Play it now")
        const songDirEl = overlay.querySelector('#v3-ob-songdir');
        const songDirBrowse = overlay.querySelector('#v3-ob-songdir-browse');

        if (editing && profile) {
            nameEl.value = profile.display_name || '';
        }

        function refreshSubmit() {
            if (editing || step === 1) {
                // First-run onboarding requires picking an avatar. When editing an
                // existing profile, a name-only change is allowed: leaving `selected`
                // null omits `avatar` from the POST, and the server keeps the current
                // one — including a custom upload that isn't in the bundled grid (so
                // Save no longer stays disabled for those).
                const haveAvatar = !!selected || (editing && profile && !!profile.avatar_url);
                submit.disabled = !(nameEl.value.trim().length >= 1 && haveAvatar);
            } else if (step === 2) {
                // Song directory — require a non-empty path to proceed; "Skip
                // for now" is available for users who'll set it later.
                submit.disabled = !songDir.trim();
            } else if (step === 3) {
                // Achievements opt-in — either choice is valid; always enabled.
                submit.disabled = false;
            } else if (step === 4) {
                // ≥1 path required — unless none could be offered (offline /
                // empty content), where blocking would strand onboarding.
                submit.disabled = pathsAvailable && selectedPaths.length < 1;
            } else {
                submit.disabled = false;
            }
        }

        function setStep(n) {
            step = n;
            errEl.classList.add('hidden');
            for (let i = 1; i <= 6; i++) {
                overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n);
            }
            overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => {
                d.classList.toggle('bg-fb-primary', Number(d.getAttribute('data-dot')) <= n);
                d.classList.toggle('bg-fb-border', Number(d.getAttribute('data-dot')) > n);
            });
            const subtitle = overlay.querySelector('#v3-ob-subtitle');
            if (subtitle) {
                subtitle.textContent = n === 1 ? 'Set up your player profile'
                    : n === 2 ? 'Point us at your songs'
                    : n === 3 ? 'Feats of Power (optional)'
                    : n === 4 ? 'Choose your instrument paths'
                    : n === 5 ? 'How do you want to monitor?'
                    : 'One last thing — calibrate your setup';
            }
            submit.textContent = n === 6 ? 'Play it now' : 'Next';
            // Skip is offered on the song-directory step (configure later) and
            // the calibration challenge (the last step).
            skipBtn.classList.toggle('hidden', !(n === 2 || n === 6));
            refreshSubmit();
        }

        async function loadPathTiles() {
            const host = overlay.querySelector('#v3-ob-paths');
            let available = [];
            try {
                const r = await fetch('/api/progression');
                if (r.ok) {
                    const data = await r.json();
                    diagnosticFilename = (data.onboarding || {}).diagnostic_filename || null;
                    available = (data.available_paths || []).concat(
                        (data.paths || []).map((p) => ({ id: p.id, name: p.name })));
                }
            } catch (e) { /* offline — leave empty, Next stays disabled */ }
            pathsAvailable = available.length > 0;
            host.innerHTML = available.map((p) =>
                '<button type="button" data-ob-path="' + esc(p.id) + '" ' +
                'class="rounded-lg border border-fb-border/50 bg-fb-bg/40 px-3 py-4 text-sm font-medium text-fb-text ' +
                'hover:border-fb-primary/50 transition">' + esc(p.name) + '</button>').join('') ||
                '<p class="text-sm text-fb-textDim col-span-3">Couldn’t load instrument paths — you can pick them later on the Progress screen.</p>';
            refreshSubmit();
            host.querySelectorAll('[data-ob-path]').forEach((b) => {
                b.addEventListener('click', () => {
                    const id = b.getAttribute('data-ob-path');
                    const idx = selectedPaths.indexOf(id);
                    if (idx >= 0) selectedPaths.splice(idx, 1); else selectedPaths.push(id);
                    b.classList.toggle('ring-2', idx < 0);
                    b.classList.toggle('ring-fb-primary', idx < 0);
                    refreshSubmit();
                });
            });
        }
        nameEl.addEventListener('input', refreshSubmit);
        // Reflect the pre-filled name + existing avatar immediately so an edit
        // with no changes can still Save (button doesn't stay disabled until
        // the user touches the form).
        refreshSubmit();

        function selectTile(el, choice) {
            selected = choice;
            grid.querySelectorAll('[data-av]').forEach((t) => t.classList.remove('ring-2', 'ring-fb-primary'));
            if (el) el.classList.add('ring-2', 'ring-fb-primary');
            refreshSubmit();
        }

        // Load bundled defaults. The default avatar's stored value is its
        // bundled FILENAME (e.g. "pick.svg"), which the server validates
        // against the bundled set.
        fetch('/api/profile/avatars').then((r) => r.ok ? r.json() : []).then((list) => {
            grid.innerHTML = (list || []).map((a) =>
                '<button type="button" data-av data-name="' + esc(a.name) + '" data-url="' + esc(a.url) + '" ' +
                'class="aspect-square rounded-lg overflow-hidden bg-fb-bg/40 hover:ring-2 hover:ring-fb-primary/50 transition">' +
                '<img src="' + esc(a.url) + '" alt="' + esc(a.name) + '" class="w-full h-full object-cover"></button>').join('');
            grid.querySelectorAll('[data-av]').forEach((b) => {
                b.addEventListener('click', () => selectTile(b, { type: 'default', value: b.dataset.name }));
            });
            // Pre-select the current avatar when editing.
            if (editing && profile && profile.avatar_url) {
                const match = Array.from(grid.querySelectorAll('[data-av]')).find((b) => b.dataset.url === profile.avatar_url);
                if (match) selectTile(match, { type: 'default', value: match.dataset.name });
            }
        }).catch(() => { /* offline / server restart — leave the avatar grid empty rather than throw */ });

        // Upload handler — base64 to /api/profile/avatar (mirrors art upload).
        overlay.querySelector('#v3-ob-upload-btn').addEventListener('click', () => fileEl.click());
        fileEl.addEventListener('change', () => {
            const f = fileEl.files && fileEl.files[0];
            if (!f) return;
            if (f.size > 6 * 1024 * 1024) { showErr('Image too large (max 6 MB).'); return; }
            const reader = new FileReader();
            reader.onload = async () => {
                try {
                    const res = await fetch('/api/profile/avatar', {
                        method: 'POST', headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({ image: reader.result }),
                    });
                    const body = await res.json();
                    if (!res.ok) { showErr(body.error || 'Upload failed.'); return; }
                    preview.innerHTML = '<img src="' + esc(body.url) + '" class="w-10 h-10 rounded-full object-cover inline-block align-middle">';
                    grid.querySelectorAll('[data-av]').forEach((t) => t.classList.remove('ring-2', 'ring-fb-primary'));
                    selected = { type: 'upload', value: body.url };
                    refreshSubmit();
                } catch (e) { showErr('Upload failed.'); }
            };
            reader.readAsDataURL(f);
        });

        function showErr(msg) { errEl.textContent = msg; errEl.classList.remove('hidden'); }

        if (editing) overlay.querySelector('#v3-ob-cancel')?.addEventListener('click', () => overlay.remove());

        // ── Song directory (step 2) ──────────────────────────────────────────
        if (songDirEl) {
            songDirEl.addEventListener('input', () => { songDir = songDirEl.value.trim(); refreshSubmit(); });
        }
        // Native folder picker on desktop; web users type/paste the path.
        const _desktop = window.feedBackDesktop;
        if (songDirBrowse && _desktop && typeof _desktop.pickDirectory === 'function') {
            songDirBrowse.classList.remove('hidden');
            songDirBrowse.addEventListener('click', async () => {
                try {
                    const picked = await _desktop.pickDirectory();
                    if (picked) { songDirEl.value = picked; songDir = picked; refreshSubmit(); }
                } catch (e) { /* user cancelled / unavailable */ }
            });
        }
        // Save the song directory to settings + kick a library scan so the
        // user's songs appear. Throws (with a message) on an invalid folder.
        async function saveSongDir() {
            const dir = ((songDirEl && songDirEl.value) || '').trim();
            if (!dir) return;   // skipped — leave unconfigured (settable later)
            const res = await fetch('/api/settings', {
                method: 'POST', headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ dlc_dir: dir }),
            });
            // /api/settings reports an invalid folder as a 200 with an `error`
            // field (a bare dict return, not a non-2xx status), so a res.ok-only
            // check would treat the failure as success and advance without saving.
            // Inspect the body too.
            let data = null;
            try { data = await res.json(); } catch (e) { /* non-JSON body */ }
            if (!res.ok || (data && data.error)) {
                throw new Error((data && data.error) || 'That folder couldn’t be set — check the path and try again.');
            }
            // Non-fatal: scan kicks off the library build in the background.
            try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* best-effort */ }
        }

        async function postProfile() {
            const res = await fetch('/api/profile', {
                method: 'POST', headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ display_name: nameEl.value.trim(), avatar: selected || undefined }),
            });
            const body = await res.json();
            if (!res.ok) throw new Error(body.error || 'Could not save profile.');
            return body;
        }

        async function finish(finishOpts) {
            finishOpts = finishOpts || {};
            overlay.remove();
            await fetchProgress();
            if (window.v3Progression && typeof window.v3Progression.refresh === 'function') {
                await window.v3Progression.refresh();
            }
            renderBadge();
            renderProfileScreen();
            if (window.feedBack && window.feedBack.emit) window.feedBack.emit('v3:profile-updated', _profile);
            // First-run only: after a genuine onboarding completion (not a
            // profile edit), kick off the one-time home tour — but NOT when we're
            // about to launch the diagnostic ("Play it now"), which navigates to
            // the player; the tour would otherwise spotlight hidden home elements
            // and steal focus. The Skip path stays on home, so it runs there.
            // The engine's seen/dismissed state keeps it once; replayable from "?".
            if (!editing && !finishOpts.launchingSong &&
                window.v3OnboardingTour && typeof window.v3OnboardingTour.startFirstRun === 'function') {
                try { window.v3OnboardingTour.startFirstRun(); } catch (e) { /* never block onboarding */ }
            } else if (!editing && finishOpts.launchingSong &&
                window.v3OnboardingTour && typeof window.v3OnboardingTour.armPendingFirstRun === 'function') {
                // "Play it now" navigates straight to the player, so the home
                // tour can't run now — arm it to fire the first time the user
                // returns to home, instead of silently never showing.
                try { window.v3OnboardingTour.armPendingFirstRun(); } catch (e) { /* never block onboarding */ }
            }
        }

        submit.addEventListener('click', async () => {
            errEl.classList.add('hidden');
            if (editing) {
                submit.disabled = true;
                try {
                    _profile = await postProfile();
                    await finish();
                } catch (e) { showErr(e.message || 'Could not save profile.'); submit.disabled = false; }
                return;
            }
            if (step === 1) {
                setStep(2);
                setTimeout(() => { try { songDirEl && songDirEl.focus(); } catch (e) { /* noop */ } }, 50);
                return;
            }
            if (step === 2) {
                // Save the song directory + kick a library scan, then continue
                // to the achievements opt-in. "Skip for now" leaves it unconfigured.
                submit.disabled = true;
                try {
                    await saveSongDir();
                    setStep(3);
                } catch (e) { showErr(e.message || 'Could not set the song directory.'); refreshSubmit(); }
                return;
            }
            if (step === 3) {
                // Persist the wall opt-in choice (default OFF) then continue to
                // instrument paths. Best-effort — a failed write must not block
                // onboarding; the user can still set it later in Settings.
                submit.disabled = true;
                try {
                    const optEl = overlay.querySelector('#v3-ob-optin');
                    const optedIn = !!(optEl && optEl.checked);
                    try {
                        await fetch('/api/settings', {
                            method: 'POST', headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({ achievements_enabled: optedIn }),
                        });
                        try { localStorage.setItem('achievementsEnabled', optedIn ? '1' : '0'); } catch (_) { /* noop */ }
                    } catch (e) { /* best-effort — settable later */ }
                    setStep(4);
                    loadPathTiles();
                } finally { refreshSubmit(); }
                return;
            }
            if (step === 4) {
                // Create the profile (onboarded=1) BEFORE the calibration choice
                // so closing the overlay at the challenge can never lose the profile.
                submit.disabled = true;
                try {
                    _profile = await postProfile();
                    if (selectedPaths.length) {
                        // A failed path save must NOT advance — step 4's skip
                        // requires ≥1 selected path (spec invariant) and would
                        // otherwise leave a pathless rank-1 profile.
                        const res = await fetch('/api/progression/paths', {
                            method: 'POST', headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({ add: selectedPaths }),
                        });
                        if (!res.ok) {
                            let msg = 'Could not save your instrument paths — try again.';
                            try { msg = (await res.json()).error || msg; } catch (e) { /* keep default */ }
                            throw new Error(msg);
                        }
                    }
                    // New step: input-device selection + calibration, between
                    // path selection and the note-detect calibration challenge.
                    await runInputSetup(selectedPaths);
                    setStep(isDesktop ? 5 : 6);
                } catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
                return;
            }
            if (step === 5) {
                // Step 5 (desktop only) — persist the amp-sim opt-in (default OFF
                // / own-rig). Best-effort: a failed write must not block onboarding;
                // it's settable later from the desktop Audio settings.
                submit.disabled = true;
                try {
                    const ampEl = overlay.querySelector('#v3-ob-ampsims');
                    const useAmpSims = !!(ampEl && ampEl.checked);
                    try {
                        await fetch('/api/settings', {
                            method: 'POST', headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({ use_amp_sims: useAmpSims }),
                        });
                    } catch (e) { /* best-effort — settable later */ }
                    setStep(6);
                } finally { refreshSubmit(); }
                return;
            }
            // Step 6 — "Play it now": leave calibration pending (it completes
            // through the normal scored-stats path) and launch the diagnostic.
            const target = diagnosticFilename;
            await finish({ launchingSong: !!target });
            if (target && typeof window.playSong === 'function') window.playSong(target);
        });

        skipBtn.addEventListener('click', async () => {
            // Step 2 — skip the song directory (the user can set it later in
            // Settings). Proceed to the achievements opt-in.
            if (step === 2) {
                setStep(3);
                return;
            }
            // Calibration step (last) — skip: Mastery Rank 1 immediately,
            // calibration stays replayable from the Progress screen.
            skipBtn.disabled = true;
            try {
                const res = await fetch('/api/progression/onboarding', {
                    method: 'POST', headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ action: 'skip' }),
                });
                if (!res.ok) { skipBtn.disabled = false; return; }
            } catch (e) { /* offline — skippable later from Progress */ }
            await finish();
        });

        if (!editing) setStep(1);
        setTimeout(() => nameEl.focus(), 50);
    }

    window.v3Onboarding = { show: show };
    window.v3Profile = {
        refresh: async function () { await fetchProfile(); await fetchProgress(); renderBadge(); renderProfileScreen(); },
        get: () => _profile,
    };

    async function boot() {
        await fetchProfile();
        await fetchProgress();
        renderBadge();
        renderProfileScreen();
        // Rank / dB / frames re-render whenever progression state or equipped
        // cosmetics move (progression-core.js / theme-core.js own the fetches).
        if (window.feedBack && typeof window.feedBack.on === 'function') {
            window.feedBack.on('progression:updated', () => { renderBadge(); renderProfileScreen(); });
            window.feedBack.on('v3:cosmetics-applied', () => { renderBadge(); renderProfileScreen(); });
            // Re-render on every Profile entry so the Feats shelf + Achievements
            // catalogue refresh (and `v3:profile-rendered` re-fires for the
            // plugin) — the plugin may have loaded after the initial boot render.
            window.feedBack.on('screen:changed', (e) => {
                if (e && e.detail && e.detail.id === 'v3-profile') renderProfileScreen();
            });
        }
    }
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', boot, { once: true });
    } else {
        boot();
    }
})();
