// MIDI tab — port config (top, restart-required) + live mapping
// table (KL-71, Alpine conversion KL-130). The mapping table polls
// /api/midi/status at 2Hz while the tab is visible so the
// recent-message ticker, learning state, and binding changes all
// surface without a 10Hz state-push firehose. Outside that visibility
// window the polling stops.
//
// Learn flow: clicking Learn next to an action POSTs /api/midi/learn
// {action}, then the status poll surfaces the captured binding once
// the listener fires `_maybe_consume_for_learn`. Cancel clears the
// armed action via DELETE /api/midi/learn.

const POLL_INTERVAL_MS = 500;
const RECENT_DISPLAY_MAX = 5;

let _statusPollTimer = null;
let _tabVisible = false;

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

function findBindingForAction(mapping, actionId) {
    const notes = mapping?.notes || {};
    for (const [k, v] of Object.entries(notes)) {
        if (v === actionId) return {kind: 'note', number: parseInt(k, 10)};
    }
    const cc = mapping?.cc || {};
    for (const [k, v] of Object.entries(cc)) {
        if (v === actionId) return {kind: 'cc', number: parseInt(k, 10)};
    }
    return null;
}

function storeDefinition() {
    return {
        // --- Port config (top section, restart-required) ---
        enabled: false,
        inputPort: 'auto',
        outputPort: 'auto',
        inputPorts: [],
        outputPorts: [],
        cfgStatusText: 'Loading…',
        saveResultText: '',
        saveResultKind: '',     // '' | 'ok' | 'err'
        showSaveResult: false,

        // --- Mapping table (live polled) ---
        connected: false,
        deviceName: '',
        tierAllowed: false,
        recentMessages: [],
        actions: {},            // {actionId: {label, tooltip}}
        mapping: {notes: {}, cc: {}},
        learning: null,         // armed actionId or null

        // --- Computed: connection ---
        get connText() {
            if (this.connected && this.deviceName) return this.deviceName;
            if (this.tierAllowed) return 'No MIDI device detected';
            return 'MIDI requires Pro tier';
        },
        get connState() {
            return this.connected ? 'on' : 'off';
        },

        // --- Computed: <select> options for input/output ports ---
        get inputPortOptions() {
            return this._buildPortOptions(this.inputPorts, this.inputPort);
        },
        get outputPortOptions() {
            return this._buildPortOptions(this.outputPorts, this.outputPort);
        },
        _buildPortOptions(ports, current) {
            const opts = [{value: 'auto', label: 'Auto-detect'}];
            (ports || []).forEach(name => opts.push({value: name, label: name}));
            // If the saved port no longer appears in the live ports
            // list (e.g. controller unplugged), still surface it as
            // a "(disconnected)" option so the user sees their stale
            // selection instead of silently snapping to Auto-detect.
            if (current && current !== 'auto' && !(ports || []).includes(current)) {
                opts.push({value: current, label: `${current} (disconnected)`});
            }
            return opts;
        },

        // --- Computed: rendered rows for the mapping table ---
        get tableRows() {
            const out = [];
            for (const [actionId, meta] of Object.entries(this.actions || {})) {
                const binding = findBindingForAction(this.mapping, actionId);
                const isLearning = this.learning === actionId;
                out.push({
                    actionId,
                    label: meta.label,
                    tooltip: meta.tooltip || '',
                    bindingState: isLearning ? 'armed' : (binding ? 'set' : 'unset'),
                    bindingText: isLearning
                        ? 'Press a control…'
                        : (binding
                            ? `${binding.kind === 'note' ? 'Note' : 'CC'} ${binding.number}`
                            : 'unmapped'),
                    isArmed: isLearning,
                    learnLabel: isLearning ? 'Cancel' : 'Learn',
                    hasBinding: !!binding,
                });
            }
            return out;
        },

        // --- Computed: recent-message strip ---
        // Rendered via x-html so the per-message fade-in animation
        // keyed off element creation in CSS still triggers when new
        // messages arrive (x-for index-keyed reuse would skip the
        // animation). Inputs are server-trusted ints — no XSS surface.
        get recentHtml() {
            const msgs = (this.recentMessages || []).slice(-RECENT_DISPLAY_MAX);
            if (msgs.length === 0) {
                return '<span class="midi-recent-empty">Press a button or move a fader on your controller to test…</span>';
            }
            return msgs.map(m => {
                const kind = m.type === 'note' ? 'NOTE' : 'CC';
                return `<span class="midi-recent-msg"><span class="midi-recent-msg-kind">${kind}</span> ${escapeHtml(String(m.number))} · ${escapeHtml(String(m.value))}</span>`;
            }).join('');
        },

        // --- Methods ---
        async refreshConfig() {
            try {
                const [cfgRes, portsRes] = await Promise.all([
                    fetch('/api/midi/config'),
                    fetch('/api/midi/ports'),
                ]);
                const cfg = await cfgRes.json();
                const ports = await portsRes.json();
                this.inputPorts = ports.input || [];
                this.outputPorts = ports.output || [];
                this.inputPort = cfg.input_port || 'auto';
                this.outputPort = cfg.output_port || 'auto';
                this.enabled = !!cfg.enabled;
                this.cfgStatusText = cfg.enabled
                    ? `Active: in=${cfg.input_port} · out=${cfg.output_port}`
                    : 'Disabled.';
            } catch (e) {
                this.cfgStatusText = 'Failed to load.';
            }
        },

        async saveConfig() {
            this.showSaveResult = false;
            this.saveResultKind = '';
            const body = {
                enabled: this.enabled,
                input_port: this.inputPort,
                output_port: this.outputPort,
            };
            try {
                const r = await fetch('/api/midi/config', {
                    method: 'PUT',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify(body),
                });
                const data = await r.json();
                this.showSaveResult = true;
                if (!r.ok) {
                    this.saveResultKind = 'err';
                    this.saveResultText = data.error || 'Save failed.';
                    return;
                }
                this.saveResultKind = 'ok';
                this.saveResultText = 'Saved. Restart Conductør to apply.';
                await this.refreshConfig();
            } catch (e) {
                this.showSaveResult = true;
                this.saveResultKind = 'err';
                this.saveResultText = `Save failed: ${e}`;
            }
        },

        async fetchStatus() {
            try {
                const r = await fetch('/api/midi/status');
                if (!r.ok) return null;
                return await r.json();
            } catch (_) {
                return null;
            }
        },

        applyStatus(status) {
            if (!status) return;
            this.connected = !!status.connected;
            this.deviceName = status.device_name || '';
            this.tierAllowed = !!status.tier_allowed;
            this.recentMessages = status.recent_messages || [];
            this.actions = status.actions || {};
            this.mapping = status.mapping || {notes: {}, cc: {}};
            this.learning = status.learning || null;
        },

        async learn(actionId) {
            const isArmed = this.learning === actionId;
            if (isArmed) {
                await fetch('/api/midi/learn', {method: 'DELETE'});
            } else {
                // Cancel any other armed action first to keep "one
                // action being learned" invariant. The backend
                // overwrites cleanly but we want predictable UX.
                await fetch('/api/midi/learn', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({action: actionId}),
                });
            }
            // Force-refresh so the user sees the armed/cancel state
            // without waiting up to POLL_INTERVAL_MS for the next tick.
            this.applyStatus(await this.fetchStatus());
        },

        async clearBinding(actionId) {
            const ok = confirm(
                `Clear the binding for "${actionId}"?\n\n`
                + 'You can re-Learn it any time.'
            );
            if (!ok) return;
            await fetch(`/api/midi/mapping/${encodeURIComponent(actionId)}`, {
                method: 'DELETE',
            });
            this.applyStatus(await this.fetchStatus());
        },

        async restoreDefaults() {
            const ok = confirm(
                'Restore the default MIDI mapping?\n\n'
                + 'This wipes your custom bindings and reverts to:\n'
                + '  Note 36 → Blackout\n'
                + '  Note 37 → Manual flash\n'
                + '  Note 39 → Stop\n'
                + '  CC 1 → Strobe multiplier\n'
                + '  CC 2 → Movement multiplier'
            );
            if (!ok) return;
            await fetch('/api/midi/mapping/restore_defaults', {method: 'POST'});
            this.applyStatus(await this.fetchStatus());
        },
    };
}

function ensureStore() {
    if (!window.Alpine) return null;
    if (!window.Alpine.store('midi')) {
        window.Alpine.store('midi', storeDefinition());
    }
    return window.Alpine.store('midi');
}

if (window.Alpine) {
    ensureStore();
} else {
    document.addEventListener('alpine:init', ensureStore);
}

async function pollTick() {
    const store = ensureStore();
    if (!store) return;
    const status = await store.fetchStatus();
    store.applyStatus(status);
}

function startPolling() {
    if (_statusPollTimer !== null) return;
    pollTick();
    _statusPollTimer = setInterval(pollTick, POLL_INTERVAL_MS);
}

function stopPolling() {
    if (_statusPollTimer === null) return;
    clearInterval(_statusPollTimer);
    _statusPollTimer = null;
}

export function initMidiConfig() {
    const panel = document.querySelector('[data-tab-panel="midi"]');
    if (!panel) return;

    let store = ensureStore();
    if (!store) {
        document.addEventListener('alpine:init', () => {
            ensureStore()?.refreshConfig();
        }, {once: true});
    } else {
        store.refreshConfig();
    }

    // Visibility-aware polling. The MIDI tab pulls /api/midi/status
    // every POLL_INTERVAL_MS only while it's the active tab — no
    // point taxing the server at 2Hz while the user is on Show or
    // Director. The tabs store toggles the panel's `hidden` attribute
    // via :hidden binding; we listen via MutationObserver because
    // Alpine doesn't expose a global tab-changed event.
    const observer = new MutationObserver(() => {
        const visible = !panel.hidden;
        if (visible && !_tabVisible) {
            _tabVisible = true;
            startPolling();
        } else if (!visible && _tabVisible) {
            _tabVisible = false;
            stopPolling();
        }
    });
    observer.observe(panel, {attributes: true, attributeFilter: ['hidden']});

    // Initial state — if MIDI is the active tab on load, kick off
    // polling immediately.
    if (!panel.hidden) {
        _tabVisible = true;
        startPolling();
    }
}
