import { Show } from './Show.js';
import { TimelineStrip } from './ui/TimelineStrip.js';
import { ParamPanel } from './ui/ParamPanel.js';
import { formatTime } from './audio/decode.js';
import { describeLook } from './look/LookGenerator.js';
import { toHex } from './look/palette.js';
import { applyGrainToPost, describeGrain, GRAIN_MASKS, GRAIN_MODES } from './look/grain.js';
import { renderClickTrack, audioBufferToWavBlob } from './audio/clicktrack.js';
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported } from './export/Exporter.js';

const QUALITY = {
    draft: 0.5,     // half resolution — for scrubbing heavy stacks
    full: 1.0,
};

const dom = {
    canvas: document.getElementById('canvas'),
    stage: document.getElementById('stage'),
    overlay: document.getElementById('overlay'),
    dropzone: document.getElementById('dropzone'),
    fileInput: document.getElementById('file-input'),
    audio: document.getElementById('audio'),
    timelineCanvas: document.getElementById('timeline-canvas'),
    timeDisplay: document.getElementById('time-display'),
    sectionDisplay: document.getElementById('section-display'),
    panelBody: document.getElementById('panel-body'),
    panelTabs: document.getElementById('panel-tabs'),
    hud: document.getElementById('hud'),
    toast: document.getElementById('toast'),
    osd: document.getElementById('btn-osd'),
    play: document.getElementById('btn-play'),
    thLabel: document.getElementById('th-label'),
    thName: document.getElementById('th-name'),
    thProgress: document.getElementById('th-progress'),
    thStep: document.getElementById('th-step'),
    thFill: document.getElementById('th-fill'),
    changeTrack: document.getElementById('btn-change-track'),
};

const state = {
    show: new Show({ canvas: dom.canvas, width: 1280, height: 720 }),
    playing: false,
    quality: 'full',
    loopSection: -1,
    tab: 'look',
    hudVisible: false,
    osdVisible: true,
    busy: false,
    rerollSalt: 0,
    lastFrameTime: 0,
    fps: 0,
};

const strip = new TimelineStrip(dom.timelineCanvas, { onSeek: seekTo });
const paramPanel = new ParamPanel(document.createElement('div'), onParamChange);

// ---------------------------------------------------------------- loading

async function loadFile(file) {
    if (state.busy) return;
    state.busy = true;
    stopPlayback();

    if (dom.overlay) {
        dom.overlay.hidden = true;
        dom.overlay.style.display = 'none';
    }
    dom.thLabel.textContent = 'loading track';
    dom.thName.hidden = false;
    dom.thName.textContent = file.name.replace(/\.[^/.]+$/, '');
    dom.thName.title = file.name;
    dom.thProgress.hidden = false;
    dom.changeTrack.hidden = true;

    try {
        await state.show.load(file, (stage, fraction) => {
            dom.thStep.textContent = stage;
            dom.thFill.style.width = `${Math.round((fraction || 0) * 100)}%`;
        });

        dom.audio.src = URL.createObjectURL(file);
        dom.thLabel.textContent = 'loaded track';
        dom.thName.textContent = state.show.fileName;
        dom.thName.title = state.show.fileName;
        dom.thProgress.hidden = true;
        dom.changeTrack.hidden = false;
        dom.changeTrack.textContent = 'change track';

        strip.setShow(state.show);
        if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
        resize();
        seekTo(0);
        renderPanel();
        document.title = `flow-state · ${state.show.fileName}`;
        console.info('[flow-state]', describeLook(state.show.look));
    } catch (err) {
        dom.thLabel.textContent = 'analysis failed';
        dom.thStep.textContent = `failed: ${err.message}`;
        console.error(err);
        dom.changeTrack.hidden = false;
        dom.changeTrack.textContent = 'try again';
        if (dom.overlay) {
            dom.overlay.hidden = false;
            dom.overlay.style.display = 'flex';
        }
    } finally {
        state.busy = false;
    }
}

if (dom.dropzone) dom.dropzone.addEventListener('click', () => dom.fileInput.click());
if (dom.changeTrack) dom.changeTrack.addEventListener('click', () => dom.fileInput.click());
dom.fileInput.addEventListener('change', (e) => {
    if (e.target.files[0]) loadFile(e.target.files[0]);
});
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', (e) => {
    e.preventDefault();
    const file = [...e.dataTransfer.files].find(
        (f) => f.type.startsWith('audio/') || /\.(mp3|flac|wav|ogg|m4a)$/i.test(f.name));
    if (file) loadFile(file);
});

// ---------------------------------------------------------------- transport

function seekTo(frame, options = {}) {
    if (!state.show.ready) return;
    const clamped = Math.max(0, Math.min(state.show.frameCount - 1, Math.round(frame)));
    dom.audio.currentTime = clamped / state.show.fps;
    state.show.seek(clamped, options);
    strip.setFrame(clamped);
}

/**
 * Force the transport to a stopped state.
 *
 * Loading a track replaces `audio.src`, which stops playback without telling
 * anyone — so `state.playing` stayed true, the button stayed on ❚❚, and the
 * first click after a track change only toggled the flag back rather than
 * starting anything. Every path that stops playback behind the UI's back has
 * to come through here.
 */
function stopPlayback() {
    state.playing = false;
    dom.audio.pause();
    dom.play.textContent = '▶';
}

function togglePlay() {
    if (!state.show.ready) return;
    state.playing = !state.playing;
    if (state.playing) {
        dom.audio.play().catch((err) => {
            console.warn('[flow-state] playback failed:', err);
            state.playing = false;
            dom.play.textContent = '▶';
        });
    } else {
        dom.audio.pause();
    }
    dom.play.textContent = state.playing ? '❚❚' : '▶';
}

function jumpSection(direction) {
    if (!state.show.ready) return;
    seekTo(state.show.track.boundaryFrame(state.show.timeline.frame, direction));
}

function toggleLoop() {
    if (!state.show.ready) return;
    const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
    state.loopSection = state.loopSection === index ? -1 : index;
    strip.setLoopSection(state.loopSection);
}

dom.play.addEventListener('click', togglePlay);
document.getElementById('btn-prev-section').addEventListener('click', () => jumpSection(-1));
document.getElementById('btn-next-section').addEventListener('click', () => jumpSection(1));
document.getElementById('btn-loop').addEventListener('click', toggleLoop);
document.getElementById('btn-hud').addEventListener('click', () => {
    state.hudVisible = !state.hudVisible;
    dom.hud.hidden = !state.hudVisible;
});
if (dom.osd) dom.osd.addEventListener('click', () => setOSDVisible(!state.osdVisible));
document.getElementById('sel-quality').addEventListener('change', (e) => {
    state.quality = e.target.value;
    resize();
});

function setOSDVisible(visible) {
    state.osdVisible = !!visible;
    state.show.setOSDEnabled(state.osdVisible);
    if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
}

document.addEventListener('keydown', (e) => {
    if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
    switch (e.key) {
        case ' ': e.preventDefault(); togglePlay(); break;
        case 'ArrowLeft': jumpSection(-1); break;
        case 'ArrowRight': jumpSection(1); break;
        case 'l': case 'L': toggleLoop(); break;
        case 'd': case 'D':
            state.hudVisible = !state.hudVisible;
            dom.hud.hidden = !state.hudVisible;
            break;
        case 'o': case 'O': setOSDVisible(!state.osdVisible); break;
        case ',': seekTo(state.show.timeline.frame - 1); break;
        case '.': seekTo(state.show.timeline.frame + 1); break;
        default: break;
    }
});

// ---------------------------------------------------------------- look edits

document.getElementById('btn-reroll').addEventListener('click', () => {
    if (!state.show.ready) return;
    state.show.reroll((state.show.look.seed ^ (++state.rerollSalt * 0x9e3779b9)) >>> 0);
    refreshAfterLookChange();
});

document.getElementById('btn-reroll-section').addEventListener('click', () => {
    if (!state.show.ready) return;
    const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
    state.show.rerollSection(index, ++state.rerollSalt);
    refreshAfterLookChange();
});

document.getElementById('btn-lock').addEventListener('click', () => {
    if (!state.show.ready) return;
    const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
    const section = state.show.look.sections[index];
    section.locked = !section.locked;
    renderPanel();
});

function refreshAfterLookChange() {
    state.show.seek(state.show.timeline.frame);
    renderPanel();
}

function onParamChange(name, value) {
    if (!state.show.ready) return;
    const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
    state.show.setSectionParam(index, name, value);
}

// ---------------------------------------------------------------- panel

dom.panelTabs.addEventListener('click', (e) => {
    const tab = e.target.dataset.tab;
    if (!tab) return;
    state.tab = tab;
    [...dom.panelTabs.children].forEach((b) => b.classList.toggle('active', b.dataset.tab === tab));
    renderPanel();
});

function renderPanel() {
    if (!state.show.ready) return;
    const show = state.show;
    const index = show.track.sectionIndexAt(show.timeline.frame);
    const section = show.look.sections[index];
    dom.panelBody.innerHTML = '';
    dom.panelBody.oninput = null;

    if (state.tab === 'scene') {
        paramPanel.container = dom.panelBody;
        paramPanel.build(section.layers[0].module, section.layers[0].params);

        // The section's stage visuals, with the one currently on screen marked.
        // Params above edit the anchor (variant 0) — the image the section opens
        // and returns to.
        if (section.variants && section.variants.length > 1) {
            const active = show.arc.state.variant || 0;
            const shots = section.shots || [];
            const list = document.createElement('div');
            list.className = 'pp-reactive';
            list.innerHTML = `<div class="pp-sub">stage visuals · ${shots.length} shots</div>` +
                section.variants.map((stack, v) =>
                    `<div class="pp-react-row${v === active ? ' current' : ''}">` +
                    `<span>${v === 0 ? '&#9679;' : '&#9675;'} ${stack[0].module.name}</span>` +
                    `<span class="pp-feature">${shots.filter((s) => s.variant === v).length}&times;</span>` +
                    `</div>`).join('');
            dom.panelBody.appendChild(list);
        }

        if (section.layers.length > 1) {
            const note = document.createElement('div');
            note.className = 'pp-reactive';
            note.innerHTML = '<div class="pp-sub">accent layer</div>' +
                section.layers.slice(1).map((l) =>
                    `<div class="pp-react-row"><span>${l.module.name}</span>` +
                    `<span class="pp-feature">${l.blend}</span>` +
                    `<span class="pp-amount">${l.opacity.toFixed(2)}</span></div>`).join('');
            dom.panelBody.appendChild(note);
        }
        return;
    }

    if (state.tab === 'look') {
        const summary = show.track.summary;
        // The track's production design. Scenes that cannot express what it is
        // built on were never cast — see look/Personality.js.
        const personality = show.look.personality;
        dom.panelBody.innerHTML = `
            <div class="pp-heading"><span class="pp-name">${show.fileName || 'track'}</span></div>
            <div class="kv"><span>seed</span><b>${show.look.seed.toString(16)}</b></div>
            <div class="kv"><span>bpm</span><b>${summary.bpm.toFixed(1)}</b></div>
            <div class="kv"><span>tempo conf.</span><b>${show.track.tempo.confidence.toFixed(2)}</b></div>
            <div class="kv"><span>duration</span><b>${formatTime(show.duration)}</b></div>
            <div class="kv"><span>sections</span><b>${show.track.sections.length}</b></div>
            <div class="kv"><span>scheme</span><b>${show.look.paletteScheme}</b></div>
            <div class="kv"><span>built on</span><b>${personality.signature.join(' + ') || 'nothing'}</b></div>
            <div class="kv"><span>form</span><b>${personality.shape.sides || 'round'}${
                personality.shape.sides ? '-sided' : ''}</b></div>
            <div class="kv"><span>camera</span><b>${(personality.camera.driftRate * 100).toFixed(1)} drift · ${
                personality.camera.spin >= 0 ? '+' : ''}${personality.camera.spin.toFixed(3)} spin</b></div>
            <div class="kv"><span>art</span><b>${personality.style.symmetry > 1
                ? personality.style.symmetry + '-fold · ' : ''}line ${personality.style.lineWeight.toFixed(2)}</b></div>
            <div class="kv"><span>brightness</span><b>${summary.meanCentroid.toFixed(3)}</b></div>
            <div class="kv"><span>dynamics</span><b>${summary.dynamicRange.toFixed(3)}</b></div>
            <div class="swatches">${show.look.palette.map((c) =>
                `<span class="sw" style="background:${toHex(c)}" title="${toHex(c)}"></span>`).join('')}</div>
            <div class="pp-sub">sections</div>
            ${show.look.sections.map((s, i) => `
                <div class="kv ${i === index ? 'current' : ''}">
                    <span>${s.kind}${s.locked ? ' &#128274;' : ''}
                        ${s.shots ? `<i class="dim">${s.shots.length} shots</i>` : ''}</span>
                    <b>${(s.variants || [s.layers]).map((v) => v[0].module.name).join(' / ')}</b>
                </div>`).join('')}
            <button id="btn-clicktrack" class="wide">download click track</button>
            <div class="hint">Mixes clicks onto the detected beat grid. If they don't sit on
            the beat, tempo detection is wrong and everything downstream inherits it.</div>`;
        document.getElementById('btn-clicktrack').addEventListener('click', downloadClickTrack);
        return;
    }

    if (state.tab === 'post') {
        const post = show.look.post;
        const fb = show.look.feedback;
        const wide = new Set(['contrast', 'saturation', 'exposure']);
        // Grain has its own block below: its fields are a mode, a mask id and a
        // pixel size, none of which are a 0..1 slider.
        const rows = Object.entries(post)
            .filter(([key]) => !key.startsWith('grain'))
            .map(([key, value]) => `
            <div class="pp-row">
                <label class="pp-label">${key}</label>
                <input class="pp-input" type="range" min="0" max="${wide.has(key) ? 2 : 1}"
                       step="0.01" value="${value}" data-post="${key}">
                <span class="pp-value">${(+value).toFixed(2)}</span>
            </div>`).join('');
        const fbRows = Object.entries(fb).map(([key, value]) => `
            <div class="pp-row">
                <label class="pp-label">${key}</label>
                <input class="pp-input" type="range"
                       min="${key === 'rotate' ? -0.02 : key === 'zoom' ? 0.97 : 0}"
                       max="${key === 'zoom' ? 1.03 : key === 'rotate' ? 0.02 : 1}"
                       step="0.001" value="${value}" data-feedback="${key}">
                <span class="pp-value">${(+value).toFixed(3)}</span>
            </div>`).join('');
        dom.panelBody.innerHTML =
            `<div class="pp-sub">post</div>${rows}` +
            `<div class="pp-sub">grain</div>${grainRows(show.look.grain)}` +
            `<div class="pp-sub">feedback</div>${fbRows}`;
        dom.panelBody.oninput = (e) => {
            const t = e.target;
            if (t.dataset.post) {
                post[t.dataset.post] = +t.value;
                t.nextElementSibling.textContent = (+t.value).toFixed(2);
            }
            if (t.dataset.feedback) {
                fb[t.dataset.feedback] = +t.value;
                t.nextElementSibling.textContent = (+t.value).toFixed(3);
            }
            if (t.dataset.grain) {
                const key = t.dataset.grain;
                if (key === 'kinds') {
                    const kinds = new Set(show.look.grain.kinds);
                    if (t.checked) kinds.add(t.value); else kinds.delete(t.value);
                    show.look.grain.kinds = [...kinds];
                } else {
                    show.look.grain[key] = t.tagName === 'SELECT' ? t.value : +t.value;
                }
                // Turning grain on for a track that was generated without it
                // would otherwise select a mode and still show nothing.
                if (show.look.grain.mode !== 'off' && show.look.grain.amount <= 0) {
                    show.look.grain.amount = 0.04;
                }
                applyGrainToPost(show.look.grain, post);
                // The mode decides which of the other controls exist, so it is
                // the one edit that has to rebuild the panel.
                if (key === 'mode') { renderPanel(); return; }
                if (t.nextElementSibling) {
                    t.nextElementSibling.textContent = (+t.value).toFixed(3);
                }
                const desc = dom.panelBody.querySelector('#grain-desc');
                if (desc) desc.textContent = describeGrain(show.look.grain);
            }
        };
        dom.panelBody.onchange = dom.panelBody.oninput;
        return;
    }

    if (state.tab === 'export') {
        dom.panelBody.innerHTML = `
            <div class="pp-sub">export</div>
            <div class="pp-row">
                <label class="pp-label">preset</label>
                <select id="sel-preset" class="pp-input">
                    ${Object.keys(PRESETS).map((k) =>
                        `<option ${k === '1080p' ? 'selected' : ''}>${k}</option>`).join('')}
                </select>
                <span class="pp-value"></span>
            </div>
            <div id="export-status" class="hint">${isSupported()
                ? 'WebCodecs available. Test render first — it uses the same encoder at the same settings.'
                : 'WebCodecs VideoEncoder is unavailable here; export will not work in this browser.'}</div>
            <div class="an-bar"><div class="an-fill" id="export-fill"></div></div>`;
    }
}

/**
 * Grain controls for the post tab.
 *
 * Grain is the one part of the grade with a shape rather than a level — when it
 * is present, how coarse it is, how often it refreshes and where it lands — so
 * it gets its own block instead of five sliders that all read 0..1.
 */
function grainRows(grain) {
    const slider = (key, min, max, step, value) => `
        <div class="pp-row">
            <label class="pp-label">${key}</label>
            <input class="pp-input" type="range" min="${min}" max="${max}" step="${step}"
                   value="${value}" data-grain="${key}">
            <span class="pp-value">${(+value).toFixed(3)}</span>
        </div>`;

    const modeRow = `
        <div class="pp-row">
            <label class="pp-label">mode</label>
            <select class="pp-input" data-grain="mode">
                ${GRAIN_MODES.map((m) =>
                    `<option ${m === grain.mode ? 'selected' : ''}>${m}</option>`).join('')}
            </select>
            <span class="pp-value"></span>
        </div>`;

    if (grain.mode === 'off') {
        return modeRow + `<div class="hint" id="grain-desc">${describeGrain(grain)}</div>`;
    }

    const maskRow = `
        <div class="pp-row">
            <label class="pp-label">mask</label>
            <select class="pp-input" data-grain="mask">
                ${Object.keys(GRAIN_MASKS).map((m) =>
                    `<option ${m === grain.mask ? 'selected' : ''}>${m}</option>`).join('')}
            </select>
            <span class="pp-value"></span>
        </div>`;

    const kindsRow = grain.mode !== 'sections' ? '' : `
        <div class="pp-row">
            <label class="pp-label">sections</label>
            <span class="pp-input">
                ${['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'].map((k) => `
                    <label class="dim"><input type="checkbox" data-grain="kinds" value="${k}"
                        ${grain.kinds.includes(k) ? 'checked' : ''}>${k}</label>`).join(' ')}
            </span>
        </div>`;

    const swellRows = grain.mode !== 'swell' ? ''
        : slider('period', 4, 60, 1, grain.period) + slider('duty', 0.05, 1, 0.01, grain.duty);

    return modeRow
        + slider('amount', 0, 0.25, 0.002, grain.amount)
        + slider('scale', 1, 8, 0.5, grain.scale)
        + slider('rate', 1, 8, 1, grain.rate)
        + maskRow
        + slider('chroma', 0, 1, 0.01, grain.chroma)
        + kindsRow
        + swellRows
        + `<div class="hint" id="grain-desc">${describeGrain(grain)}</div>`;
}

// ---------------------------------------------------------------- export

function currentPreset() {
    const el = document.getElementById('sel-preset');
    return el ? el.value : '1080p';
}

/**
 * Surface a message over the stage.
 *
 * Errors stay until dismissed: an export that fails after several minutes of
 * rendering must not scroll past unnoticed, which is exactly what the status
 * line inside the export panel allowed.
 */
function showToast(message, { ok = false, timeout = 0 } = {}) {
    if (!dom.toast) return;
    clearTimeout(showToast._timer);
    dom.toast.textContent = message;
    dom.toast.classList.toggle('ok', ok);
    dom.toast.hidden = false;
    if (timeout) showToast._timer = setTimeout(() => { dom.toast.hidden = true; }, timeout);
}

if (dom.toast) dom.toast.addEventListener('click', () => { dom.toast.hidden = true; });

function exportProgress(p) {
    const status = document.getElementById('export-status');
    const fill = document.getElementById('export-fill');
    if (status) status.textContent = `${p.stage} — ${Math.round(p.fraction * 100)}% (${p.frame}/${p.total})`;
    if (fill) fill.style.width = `${Math.round(p.fraction * 100)}%`;
}

async function runExport(segment) {
    if (!state.show.ready || state.busy) return;
    if (state.playing) togglePlay();
    state.busy = true;
    state.tab = 'export';
    [...dom.panelTabs.children].forEach((b) => b.classList.toggle('active', b.dataset.tab === 'export'));
    renderPanel();

    if (dom.toast) dom.toast.hidden = true;
    try {
        const preset = currentPreset();
        const exporter = new Exporter(state.show);
        const blob = segment
            ? await exportSegment(state.show, state.show.timeline.frame,
                { seconds: 20, preset, onProgress: exportProgress, exporter })
            : await exporter.export({ preset, onProgress: exportProgress });

        const suffix = segment ? `-segment-${state.show.timeline.frame}` : '';
        downloadBlob(blob, `${state.show.fileName || 'flow-state'}${suffix}-${preset}.mp4`);
        const status = document.getElementById('export-status');
        // Codec fallbacks must never be silent: a video that quietly lost its
        // audio track is worse than one that says so.
        const warnings = (exporter.warnings || []).join(' · ');
        if (status) {
            status.textContent = `done — ${(blob.size / 1e6).toFixed(1)} MB` +
                (warnings ? ` (${warnings})` : '');
        }
        showToast(`export done — ${(blob.size / 1e6).toFixed(1)} MB` +
            (warnings ? `\n${warnings}` : ''), { ok: true, timeout: 6000 });
    } catch (err) {
        const status = document.getElementById('export-status');
        if (status) status.textContent = `failed: ${err.message}`;
        showToast(`export failed\n${err.message}`);
        console.error(err);
    } finally {
        state.busy = false;
        resize();
        state.show.seek(state.show.timeline.frame);
    }
}

document.getElementById('btn-export').addEventListener('click', () => runExport(false));
document.getElementById('btn-segment').addEventListener('click', () => runExport(true));

async function downloadClickTrack() {
    if (!state.show.ready) return;
    const button = document.getElementById('btn-clicktrack');
    button.textContent = 'rendering…';
    try {
        const buffer = await renderClickTrack(state.show.audioBuffer, state.show.track.tempo);
        downloadBlob(audioBufferToWavBlob(buffer), `${state.show.fileName}-clicktrack.wav`);
        button.textContent = 'download click track';
    } catch (err) {
        button.textContent = `failed: ${err.message}`;
    }
}

// ---------------------------------------------------------------- loop

function resize() {
    const rect = dom.stage.getBoundingClientRect();
    const scale = QUALITY[state.quality];
    const width = Math.max(64, Math.round(rect.width * scale));
    const height = Math.max(36, Math.round(((rect.width * 9) / 16) * scale));
    state.show.setSize(width, height);
    dom.canvas.style.width = '100%';
    dom.canvas.style.height = 'auto';
    strip.resize();
}
window.addEventListener('resize', resize);

let lastPanelSection = -1;
let lastRenderedFrame = -1;

function frame(now) {
    requestAnimationFrame(frame);
    const show = state.show;
    if (!show.ready || state.busy) return;

    if (state.playing) {
        if (state.loopSection >= 0) {
            const section = show.track.sections[state.loopSection];
            if (show.timeline.frame >= section.endFrame - 1) {
                dom.audio.currentTime = section.startFrame / show.fps;
            }
        }
        show.timeline.syncToAudio(dom.audio.currentTime);
        if (dom.audio.ended) stopPlayback();
    }

    if (state.playing || show.timeline.frame !== lastRenderedFrame) {
        show.present(show.renderFrame(show.timeline.frame));
        lastRenderedFrame = show.timeline.frame;
    }

    strip.setFrame(show.timeline.frame);
    strip.draw();

    const dt = now - state.lastFrameTime;
    state.lastFrameTime = now;
    if (dt > 0) state.fps = state.fps * 0.9 + (1000 / dt) * 0.1;

    dom.timeDisplay.textContent = `${formatTime(show.timeline.time)} / ${formatTime(show.duration)}`;

    const sectionIndex = show.track.sectionIndexAt(show.timeline.frame);
    const arc = show.arc.state;
    dom.sectionDisplay.textContent =
        `${arc.kind || ''} · ${arc.sceneName || ''}` +
        `${arc.crossfade > 0 ? ` · fade ${arc.crossfade.toFixed(2)}` : ''}`;

    if (sectionIndex !== lastPanelSection) {
        lastPanelSection = sectionIndex;
        if (state.tab === 'scene' || state.tab === 'look') renderPanel();
    }

    if (state.hudVisible) {
        const f = show.track.at(show.timeline.frame);
        dom.hud.innerHTML =
            `<div>${state.fps.toFixed(0)} fps · frame ${show.timeline.frame}/${show.frameCount}</div>` +
            `<div>${show.engine.width}×${show.engine.height} · ${state.quality}</div>` +
            `<div>section ${sectionIndex} ${arc.kind} · ${arc.sceneName}</div>` +
            `<div>layers ${show.arc.activeLayers.length} · build ${(f.buildSlope || 0).toFixed(2)}</div>` +
            `<div>loud ${f.loudness.toFixed(2)} low ${f.bandLow.toFixed(2)} high ${f.bandHigh.toFixed(2)}</div>` +
            `<div>beat ${f.beat.toFixed(2)} bar ${f.barPhase.toFixed(2)} flux ${f.flux.toFixed(2)}</div>`;
    }
}

// Dev-only handle for the console and for browser automation. The render loop is
// requestAnimationFrame-driven, and rAF does not fire in some headless/automated
// contexts, so `tick()` provides a way to advance the app by hand.
if (import.meta.env && import.meta.env.DEV) {
    window.__flowState = { state, dom, strip, tick: (t) => frame(t ?? 0), resize, seekTo, renderPanel };
}

requestAnimationFrame(frame);
resize();
