import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
import './webauthn'; // exposes window.clusevWebauthn (register/login ceremonies)

window.Pusher = Pusher;

// Reverb (Pusher protocol). The endpoint is injected at RUNTIME by the app layout
// (window.__clusev.reverb), derived from the effective panel domain — so changing the
// domain from the dashboard needs no JS rebuild. The prod build deliberately bakes NO
// VITE_REVERB_* env (it would freeze the build-time domain); those env reads are only a
// dev/HMR convenience.
//
// CRITICAL: this MUST NOT throw at module top level. Auth pages (login / forced password
// change) use a layout that injects no __clusev, and the prod bundle has no VITE_REVERB_*
// fallback, so the config is legitimately absent there. A thrown `new Echo({key: undefined})`
// would abort this whole module BEFORE the alpine:init block below registers cmdk /
// modalTrigger / dualChart — leaving the command-palette's `x-data="cmdk(...)"` unresolved.
// Alpine then strips x-cloak but can't evaluate `x-show="open"`, so the palette renders as a
// full-screen, undismissable overlay. So: construct only when a real config is present, guard
// it, and (re)try on navigation since the config first appears on app-layout pages.
function ensureEcho() {
    if (window.Echo) return; // singleton — construct once
    const rc = (typeof window !== 'undefined' && window.__clusev && window.__clusev.reverb) || {};
    const key = rc.key ?? import.meta.env.VITE_REVERB_APP_KEY;
    const host = rc.host ?? import.meta.env.VITE_REVERB_HOST;
    if (! key || ! host) return; // no realtime config (e.g. auth pages) — skip, never throw

    const scheme = rc.scheme ?? import.meta.env.VITE_REVERB_SCHEME ?? 'https';
    const port = Number(rc.port ?? import.meta.env.VITE_REVERB_PORT ?? (scheme === 'https' ? 443 : 80));
    // Channels are PRIVATE: Echo POSTs to /broadcasting/auth (web group → PanelScheme + session)
    // before subscribing, so we must send the CSRF token the layout exposes as <meta name="csrf-token">.
    const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
    try {
        window.Echo = new Echo({
            broadcaster: 'reverb',
            key,
            wsHost: host,
            wsPort: port,
            wssPort: port,
            forceTLS: scheme === 'https',
            enabledTransports: ['ws', 'wss'],
            auth: { headers: { 'X-CSRF-TOKEN': csrfToken } },
        });
    } catch (e) {
        // A realtime/websocket misconfig must never take down the rest of the UI.
        console.error('[clusev] realtime (Echo/Reverb) init failed — continuing without live updates', e);
    }
}
ensureEcho();
// The reverb config first appears when an app-layout page mounts; on a SPA navigation INTO it
// (e.g. forced password change → dashboard) this module does not re-run, so try again then.
document.addEventListener('livewire:navigated', ensureEcho);

// ── Modal-open detection ────────────────────────────────────────────────
// wire-elements/modal emits NO "opened" event; the only reliable, package-version-
// agnostic signal is that it adds `overflow-y-hidden` to <body> in setShowPropertyTo(true).
// watchModalOpen() resolves the moment that class appears, or fires onDone(true) after
// `timeout` ms so a silently-failed open (e.g. a click that raced a morph) still surfaces
// an error instead of a dead button. Returns a silent cancel() for teardown.
function watchModalOpen(timeout, onDone) {
    let settled = false;
    let timer = null;
    const settle = (timedOut) => {
        if (settled) return;
        settled = true;
        obs.disconnect();
        if (timer) clearTimeout(timer);
        onDone?.(timedOut);
    };
    const obs = new MutationObserver(() => {
        if (document.body.classList.contains('overflow-y-hidden')) settle(false);
    });
    obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
    timer = setTimeout(() => settle(true), timeout);

    return () => { // silent teardown (component destroyed) — no onDone
        if (settled) return;
        settled = true;
        obs.disconnect();
        if (timer) clearTimeout(timer);
    };
}

// Surface a red (level:error) toast through the shared toaster (partials/toaster.blade.php).
function fireErrorToast(message) {
    window.dispatchEvent(new CustomEvent('notify', { detail: { message, level: 'error' } }));
}

// Last time ANY Livewire request was sent. A modal trigger uses this to tell a genuine
// client-side resolution failure (the click never reached the server — NO commit, e.g. a
// click that raced a morph) apart from a legitimate server-side no-op (the method ran and
// chose not to open a modal, or rendered a validation error — which DID commit). Only the
// former warrants the "could not open" toast.
//
// Deliberately GLOBAL (not filtered to the modal-manager component): a server method that
// no-ops (e.g. confirmTlsMode on the already-active mode) commits the PAGE component, not the
// manager — filtering to the manager would wrongly flag those as failures (false-positive
// toast). The accepted cost is the reverse: on a polling page a poll commit landing inside the
// watchdog window can mask a genuinely-lost click (missed toast). We err toward never crying
// wolf — the spinner still showed, so the click never looks dead.
let lastLivewireCommitAt = 0;
document.addEventListener('livewire:init', () => {
    window.Livewire.hook('commit', () => { lastLivewireCommitAt = Date.now(); });
});

document.addEventListener('alpine:init', () => {
    // ── wire-elements/modal close-path guard (vendor bug workaround) ─────
    // The package's modal.js closingModal() reads components[activeComponent].name
    // with NO null-guard — unlike its sibling getActiveComponentModalAttribute(),
    // which DOES guard the very same access (`components[activeComponent] !== undefined`).
    // When Alpine's `activeComponent` and the Livewire-side `components` map are
    // momentarily out of sync at close time — a window that @persist'ing the modal
    // manager across wire:navigate widens (the manager + its Alpine state survive a
    // navigation while Livewire re-hydrates `components`) — that read throws
    //   TypeError: Cannot read properties of undefined (reading 'name')
    // on the click-away path (closeModalOnClickAway → closingModal). The modal still
    // closes, so it is console noise, but it violates R12 (zero console errors).
    //
    // We wrap the global factory to apply the SAME guard the vendor already uses
    // elsewhere: when there is no live component for the active id there is nothing to
    // notify and nothing can veto the close, so we report "closing allowed" (return
    // true) and let closeModal() proceed exactly as before. The in-sync path is
    // untouched (delegates to the original closingModal, preserving a modal's ability
    // to veto its own close). Runs at alpine:init — before Alpine evaluates the
    // persisted manager's x-data="LivewireUIModal()" — so every instance is guarded.
    // Keeps the @persist navigate fix and the <x-modal-trigger> spinner/error-toast UX
    // intact (this file is the only thing touched).
    const modalFactory = window.LivewireUIModal;
    if (typeof modalFactory === 'function' && !modalFactory.__clusevGuarded) {
        const guarded = function () {
            const data = modalFactory();
            const original = data.closingModal;
            if (typeof original === 'function') {
                data.closingModal = function (eventName) {
                    const components = this.$wire?.get('components');
                    if (!components || components[this.activeComponent] === undefined) {
                        return true; // desync / already torn down: nothing to notify, allow close
                    }
                    return original.call(this, eventName);
                };
            }
            return data;
        };
        guarded.__clusevGuarded = true;
        window.LivewireUIModal = guarded;
    }

    // ── Onboarding tour ─────────────────────────────────────────────────
    // First-run spotlight tour. `steps` come from the Livewire component; a step with a `target`
    // spotlights the sidebar nav item carrying data-tour="<target>", otherwise the card is centred.
    // Auto-opens once (autostart), relaunchable via the `onboarding:start` window event; finishing
    // or skipping calls $wire.markSeen() so it never auto-opens again.
    window.Alpine.data('onboardingTour', (steps = [], autostart = false) => ({
        steps,
        open: false,
        i: 0,
        rect: null, // spotlight rect (fixed-viewport coords) or null → centred card
        get step() { return this.steps[this.i] || {}; },
        get isLast() { return this.i === this.steps.length - 1; },

        init() {
            this._start = () => this.launch();
            this._reflow = () => { if (this.open) this.place(); };
            window.addEventListener('onboarding:start', this._start);
            window.addEventListener('resize', this._reflow);
            window.addEventListener('scroll', this._reflow, true);
            // Auto-open once — but not if it was already dismissed this session (guards the window
            // between finish() and markSeen() persisting, e.g. a fast reload). @persist covers navigation.
            if (autostart && !this._dismissed()) this.$nextTick(() => this.launch());
        },
        _dismissed() { try { return sessionStorage.getItem('clusev.tour.dismissed') === '1'; } catch (_) { return false; } },
        destroy() {
            window.removeEventListener('onboarding:start', this._start);
            window.removeEventListener('resize', this._reflow);
            window.removeEventListener('scroll', this._reflow, true);
        },

        launch() { this.i = 0; this.open = true; this.$nextTick(() => this.place()); },
        place() {
            const t = this.step.target;
            if (!t) { this.rect = null; return; }
            const el = document.querySelector(`[data-tour="${t}"]`);
            const r = el && el.getBoundingClientRect();
            // Hidden target (e.g. the mobile drawer is closed) → fall back to a centred card.
            this.rect = (r && r.width > 0 && r.height > 0) ? { top: r.top, left: r.left, width: r.width, height: r.height } : null;
        },
        next() { if (this.isLast) return this.finish(); this.i++; this.$nextTick(() => this.place()); },
        prev() { if (this.i > 0) { this.i--; this.$nextTick(() => this.place()); } },
        skip() { this.finish(); },
        finish() {
            this.open = false;
            this.rect = null;
            try { sessionStorage.setItem('clusev.tour.dismissed', '1'); } catch (_) {}
            try { this.$wire.markSeen(); } catch (_) {}
        },

        spotStyle() {
            const r = this.rect; if (!r) return 'display:none';
            const p = 6;
            return `top:${r.top - p}px;left:${r.left - p}px;width:${r.width + p * 2}px;height:${r.height + p * 2}px`;
        },
        cardStyle() {
            const r = this.rect; if (!r) return '';
            const gap = 18, w = 380, h = 250;
            // Prefer to the right of the target (the sidebar sits on the left); flip left if it overflows.
            let left = r.left + r.width + gap;
            if (left + w > window.innerWidth - 12) left = Math.max(12, r.left - w - gap);
            const top = Math.max(12, Math.min(r.top, window.innerHeight - h - 12));
            return `top:${top}px;left:${left}px`;
        },
    }));

    // ── Modal trigger ───────────────────────────────────────────────────
    // Reused by <x-modal-trigger>. Gives EVERY modal-opening control an immediate
    // spinner (pending flips synchronously on click — no round-trip needed) that clears
    // the instant the modal mounts, and an error toast if it never appears within the
    // timeout. open() = client-side dispatch; arm() = a server wire:click method that
    // dispatches openModal itself (we only watch + spin).
    window.Alpine.data('modalTrigger', (component = null, args = {}, failMsg = '', timeout = 2200) => ({
        pending: false,
        _cancel: null,

        open() {
            if (this.pending) return;
            this._begin();
            window.Livewire?.dispatch('openModal', { component, arguments: args });
        },

        arm() {
            if (this.pending) return;
            this._begin();
        },

        _begin() {
            this.pending = true;
            const clickedAt = Date.now();
            this._cancel = watchModalOpen(this._timeout(), (timedOut) => {
                this.pending = false;
                this._cancel = null;
                // Error ONLY when the modal never opened AND nothing committed since the click
                // (a true silent failure — the click didn't reach the server). A server method
                // that legitimately committed without opening (no-op / validation) stays quiet;
                // it surfaces its own feedback via Livewire.
                if (timedOut && lastLivewireCommitAt < clickedAt) fireErrorToast(failMsg);
            });
        },

        _timeout() {
            return Number.isFinite(timeout) && timeout > 0 ? timeout : 2200;
        },

        destroy() {
            if (this._cancel) {
                this._cancel();
                this._cancel = null;
            }
            this.pending = false;
        },
    }));

    // Live dual-series chart (CPU + MEM). Seeds from server-rendered data, then
    // appends each MetricsTicked broadcast and redraws (viewBox 0 0 300 100).
    window.Alpine.data('dualChart', (cpuSeed = [], memSeed = [], max = 40, server = null) => ({
        cpu: cpuSeed.slice(-max),
        mem: memSeed.slice(-max),
        max,
        server,
        connected: false,

        init() {
            const conn = window.Echo?.connector?.pusher?.connection;
            this.connected = conn?.state === 'connected';
            this._onState = (s) => { this.connected = s.current === 'connected'; };
            conn?.bind('state_change', this._onState);
            // PRIVATE channel: authorized via /broadcasting/auth (see routes/channels.php).
            this._channel = window.Echo?.private('metrics');
            this._channel?.listen('.tick', (e) => {
                // only react to the server this chart is showing
                if (this.server && e.server && e.server !== this.server) return;
                this.append(this.cpu, e.cpu);
                this.append(this.mem, e.mem);
            });
        },

        destroy() {
            // Echo.leave() tears down the public, private- and presence- variants of the
            // name, so this correctly unsubscribes the private-metrics channel.
            window.Echo?.leave('metrics');
            window.Echo?.connector?.pusher?.connection?.unbind('state_change', this._onState);
        },

        append(arr, v) {
            const n = Number(v);
            if (! Number.isFinite(n)) return;
            arr.push(n);
            if (arr.length > this.max) arr.shift();
        },

        _coords(arr) {
            const n = arr.length, w = 300, h = 100, pad = 6;
            return arr
                .map((v, i) => {
                    const x = n > 1 ? (i / (n - 1)) * w : 0;
                    const y = h - pad - (Math.max(0, Math.min(100, v)) / 100) * (h - 2 * pad);
                    return `${x.toFixed(1)},${y.toFixed(1)}`;
                })
                .join(' ');
        },

        get cpuLine() { return this._coords(this.cpu); },
        get cpuArea() { return `0,100 ${this._coords(this.cpu)} 300,100`; },
        get memLine() { return this._coords(this.mem); },
        get memArea() { return `0,100 ${this._coords(this.mem)} 300,100`; },
    }));

    // Persistent history chart (Server-Details). Self-contained island: fetches /servers/<uuid>/
    // history.json per range, draws smooth (Catmull-Rom) lines + area fills, a hover crosshair +
    // tooltip, and switches range client-side (no server round-trip). The line is continuous and
    // breaks only on a REAL gap (Δt > 2.5× the bucket width = the server was actually down).
    window.Alpine.data('metricChart', (uuid) => ({
        uuid,
        range: '24h',
        loading: true,
        data: { points: [], from: 0, now: 0, bucket: 60 },
        hover: null,
        W: 1000,
        H: 260,

        ranges: ['1h', '24h', '7d', '30d'],

        init() {
            // Restore the range from the URL (?range=) so a reload of the SAME page keeps the choice;
            // opening the page fresh (no param) falls back to the 24h default.
            const r = new URL(window.location.href).searchParams.get('range');
            if (this.ranges.includes(r)) this.range = r;
            this.reload();
            this._timer = setInterval(() => this.reload(true), 60000); // keep it fresh
        },
        destroy() { clearInterval(this._timer); },

        setRange(r) {
            if (r === this.range || ! this.ranges.includes(r)) return;
            this.range = r;
            // Reflect the choice in the URL (replace, not push — no history spam) so it survives a reload.
            const url = new URL(window.location.href);
            url.searchParams.set('range', r);
            window.history.replaceState(window.history.state, '', url);
            this.reload();
        },

        reload(silent = false) {
            if (! silent) this.loading = true;
            fetch(`/servers/${this.uuid}/history.json?range=${this.range}`, { headers: { Accept: 'application/json' }, cache: 'no-store' })
                .then((r) => (r.ok ? r.json() : null))
                .then((d) => { if (d && Array.isArray(d.points)) this.data = d; this.loading = false; })
                .catch(() => { this.loading = false; });
        },

        get hasData() { return (this.data.points || []).length > 0; },

        _x(t) { const span = Math.max(1, this.data.now - this.data.from); return ((t - this.data.from) / span) * this.W; },
        _y(v) { return this.H - (Math.max(0, Math.min(100, v)) / 100) * this.H; },

        // Split points into continuous runs; a Δt larger than 2.5× the bucket = a real outage → break.
        _runs(key) {
            const pts = this.data.points || [], bucket = this.data.bucket || 60, runs = [];
            let cur = [];
            for (let i = 0; i < pts.length; i++) {
                if (cur.length && (pts[i].t - pts[i - 1].t) > bucket * 2.5) { runs.push(cur); cur = []; }
                cur.push({ x: this._x(pts[i].t), y: this._y(pts[i][key]) });
            }
            if (cur.length) runs.push(cur);
            return runs;
        },

        // Catmull-Rom → cubic-bezier smoothing for an organic, non-angular curve.
        _smooth(p) {
            if (p.length < 2) return p.length ? `M${p[0].x.toFixed(1)},${p[0].y.toFixed(1)}` : '';
            let d = `M${p[0].x.toFixed(1)},${p[0].y.toFixed(1)}`;
            for (let i = 0; i < p.length - 1; i++) {
                const p0 = p[i - 1] || p[i], p1 = p[i], p2 = p[i + 1], p3 = p[i + 2] || p2;
                const c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6;
                const c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6;
                d += ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
            }
            return d;
        },

        line(key) { return this._runs(key).map((r) => this._smooth(r)).join(' '); },
        area(key) {
            return this._runs(key).map((r) => (r.length
                ? `${this._smooth(r)} L${r[r.length - 1].x.toFixed(1)},${this.H} L${r[0].x.toFixed(1)},${this.H} Z`
                : '')).join(' ');
        },

        onMove(ev) {
            const pts = this.data.points || [];
            if (! pts.length) return;
            const rect = ev.currentTarget.getBoundingClientRect();
            const tx = this.data.from + ((ev.clientX - rect.left) / Math.max(1, rect.width)) * (this.data.now - this.data.from);
            let bi = 0, bd = Infinity;
            for (let i = 0; i < pts.length; i++) { const dd = Math.abs(pts[i].t - tx); if (dd < bd) { bd = dd; bi = i; } }
            this.hover = { i: bi, p: pts[bi], left: (this._x(pts[bi].t) / this.W) * 100 };
        },
        onLeave() { this.hover = null; },

        fmtTime(t) {
            return new Date(t * 1000).toLocaleString([], { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
        },
    }));

    // Interactive terminal (xterm.js). xterm + its CSS are dynamically imported so they only load on
    // the terminal page (code-split chunk). On a 'terminal-open' event (from the Livewire rail) it
    // opens a same-origin WebSocket to /terminal/ws?token=… — the sidecar holds the PTY. A real PTY
    // means native shell features work, including Tab/Shift-Tab autocompletion.
    window.Alpine.data('terminalView', () => ({
        term: null,
        fit: null,
        ws: null,
        status: 'idle', // idle | connecting | connected | closed | error
        title: '',
        _ro: null,
        _pending: null, // a token that arrived before xterm finished its async load

        async init() {
            const [{ Terminal }, { FitAddon }] = await Promise.all([
                import('@xterm/xterm'),
                import('@xterm/addon-fit'),
            ]);
            await import('@xterm/xterm/css/xterm.css');

            this.term = new Terminal({
                fontFamily: "'JetBrains Mono', ui-monospace, 'SF Mono', monospace",
                fontSize: 13,
                cursorBlink: true,
                scrollback: 5000,
                theme: {
                    background: '#0B0E12', foreground: '#E9EEF3', cursor: '#FF6B2C', cursorAccent: '#0B0E12',
                    selectionBackground: 'rgba(255,107,44,0.25)',
                    black: '#0F1318', red: '#FF5247', green: '#35D07F', yellow: '#E8B931',
                    blue: '#43C1D8', magenta: '#FF8A4D', cyan: '#74D7E8', white: '#9DAAB6',
                    brightBlack: '#707B85', brightWhite: '#E9EEF3',
                },
            });
            this.fit = new FitAddon();
            this.term.loadAddon(this.fit);
            this.term.open(this.$refs.screen);
            // Fit now, next frame (after layout), and again once the web font finishes loading.
            // The last one is critical: FitAddon measures the cell width to pick the column count;
            // if it runs while JetBrains Mono is still loading it measures the (narrower) fallback,
            // over-counts columns, then the wider real font overflows and the right edge gets clipped.
            this._refit();

            this.term.onData((d) => { if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify({ type: 'data', data: d })); });
            this._ro = new ResizeObserver(() => { try { this.fit.fit(); this._sendResize(); } catch (_) {} });
            this._ro.observe(this.$refs.screen);

            // A target picked before xterm finished loading is connected now.
            if (this._pending) { const t = this._pending; this._pending = null; this._connect(t); }
        },

        // Re-measure + resize the grid to the container: immediately, after the next layout frame, and
        // once web fonts settle (so column math uses the real monospace metrics, not a fallback).
        _refit() {
            const f = () => { try { this.fit.fit(); this._sendResize(); } catch (_) {} };
            f();
            requestAnimationFrame(f);
            if (document.fonts && document.fonts.ready) document.fonts.ready.then(f).catch(() => {});
        },

        destroy() {
            this._close();
            if (this._ro) this._ro.disconnect();
            try { if (this.term) this.term.dispose(); } catch (_) {}
        },

        open(detail) {
            if (!detail || !detail.token) return;
            this.title = detail.title || '';
            if (this.term) this._connect(detail.token);
            else this._pending = detail.token; // xterm still loading → connect on init
        },

        _connect(token) {
            this._close();
            this.status = 'connecting';
            if (this.term) this.term.reset();
            const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
            // Hand the current grid size to the sidecar so the PTY (and the remote shell's MOTD/prompt)
            // is born at the visible width — otherwise it starts at 80×24 and the first screenful wraps
            // awkwardly on a narrow (mobile) viewport until the first resize lands.
            const cols = (this.term && this.term.cols) || 80;
            const rows = (this.term && this.term.rows) || 24;
            const ws = new WebSocket(`${proto}://${window.location.host}/terminal/ws?token=${encodeURIComponent(token)}&cols=${cols}&rows=${rows}`);
            this.ws = ws;
            // Every handler is bound to THIS socket: a superseded connection (the user picked another
            // target) still fires onclose/onerror asynchronously, which would otherwise clobber the
            // freshly-connecting terminal's state. Ignore any event from a socket we've moved past.
            ws.onmessage = (ev) => {
                if (this.ws !== ws) return;
                let m;
                try { m = JSON.parse(ev.data); } catch (_) { return; }
                if (m.type === 'data') this.term.write(m.data);
                else if (m.type === 'ready') { this.status = 'connected'; this._refit(); setTimeout(() => this.term.focus(), 0); }
                else if (m.type === 'error') { this.status = 'error'; this.term.write(`\r\n\x1b[31m${m.data}\x1b[0m\r\n`); }
                else if (m.type === 'exit') { this.status = 'closed'; this.term.write('\r\n\x1b[90m— Sitzung beendet —\x1b[0m\r\n'); }
            };
            ws.onclose = () => { if (this.ws !== ws) return; if (this.status === 'connected' || this.status === 'connecting') { this.status = 'closed'; this.term.write('\r\n\x1b[90m— Verbindung getrennt —\x1b[0m\r\n'); } };
            ws.onerror = () => { if (this.ws !== ws) return; if (this.status !== 'closed') this.status = 'error'; };
        },

        _sendResize() {
            if (this.ws && this.ws.readyState === 1 && this.term) this.ws.send(JSON.stringify({ type: 'resize', cols: this.term.cols, rows: this.term.rows }));
        },

        _close() {
            if (this.ws) { try { this.ws.close(); } catch (_) {} this.ws = null; }
        },

        clear() { if (this.term) this.term.clear(); },
    }));

    // ── Command palette + keyboard shortcuts ────────────────────────────
    // Nav + action item labels are passed in from the Blade markup (already
    // translated via __()), so the palette stays in sync with the active locale.
    // CMDK_GO maps leader-key mnemonics to routes (i=dIenste, l=Log,
    // e=Einstellungen, y=sYstem — d/s already taken) and stays in JS.
    // `h` (Help) is intentionally absent — it resolves via the config-driven nav list in go()
    // so it can point at the external docs URL instead of an in-app route.
    const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions', t: '/terminal' };

    window.Alpine.data('cmdk', (nav = [], actions = [], servers = [], failMsg = '') => ({
        nav,
        actions,
        servers,
        failMsg,
        open: false,
        help: false,
        query: '',
        active: 0,
        leader: false,
        leaderTimer: null,
        _cancelModalWatch: null,

        init() {
            // Reset overlays after an SPA navigation. The component is recreated on each
            // wire:navigate, so we MUST remove this document listener in destroy() — else
            // every navigation would accumulate another permanent callback.
            this._onNav = () => {
                this.open = false;
                this.help = false;
                this.leader = false;
            };
            document.addEventListener('livewire:navigated', this._onNav);
        },

        destroy() {
            document.removeEventListener('livewire:navigated', this._onNav);
            if (this._cancelModalWatch) {
                this._cancelModalWatch();
                this._cancelModalWatch = null;
            }
        },

        get items() {
            const q = this.query.trim().toLowerCase();
            // Empty query stays compact (nav + actions). Servers join the pool only while
            // searching, matched on name OR IP (the `search` field), so the fleet never
            // clutters the default list but is one keystroke away.
            const base = [...this.nav, ...this.actions];
            if (q === '') return base;
            return [...base, ...this.servers].filter((i) => (i.search || i.label).toLowerCase().includes(q));
        },

        toggle(force) {
            this.open = force === true ? true : ! this.open;
            if (this.open) {
                this.help = false;
                this.query = '';
                this.active = 0;
                this.$nextTick(() => this.$refs.input?.focus());
            }
        },

        close() {
            this.open = false;
            this.help = false;
        },

        move(d) {
            const n = this.items.length;
            if (n) this.active = (this.active + d + n) % n;
        },

        run(item) {
            item = item || this.items[this.active] || null;
            if (! item) return;
            if (item.href) {
                this.navigate(item.href);
            } else if (item.action === 'create-server') {
                this.close();
                // No per-button spinner here (the palette closes), but still surface an error
                // toast if the modal never appears AND nothing committed (same discriminator as
                // modalTrigger). Store the canceller so destroy() can tear it down on navigation.
                if (this._cancelModalWatch) this._cancelModalWatch(); // tear down any in-flight watcher first
                const clickedAt = Date.now();
                window.Livewire?.dispatch('openModal', { component: 'modals.create-server' });
                const watch = watchModalOpen(2200, (timedOut) => {
                    if (this._cancelModalWatch === watch) this._cancelModalWatch = null; // only clear our own handle
                    if (timedOut && lastLivewireCommitAt < clickedAt) fireErrorToast(this.failMsg);
                });
                this._cancelModalWatch = watch;
            }
        },

        navigate(href) {
            this.close();
            // Absolute URLs (e.g. the online docs) open in a new tab; in-app routes use SPA nav.
            if (/^https?:\/\//i.test(href)) { window.open(href, '_blank', 'noopener'); return; }
            if (window.Livewire?.navigate) window.Livewire.navigate(href);
            else window.location.href = href;
        },

        focusSearch() {
            const el = document.querySelector('[data-page-search]');
            if (el) {
                el.focus();

                return true;
            }

            return false;
        },

        go(key) {
            if (CMDK_GO[key]) { this.navigate(CMDK_GO[key]); return; }
            // Keys not in the static map resolve via the (translated, config-driven) nav list —
            // e.g. "g h" → the Help entry, whose href points at the online docs.
            const item = this.nav.find((n) => n.hint === `g ${key}`);
            if (item?.href) this.navigate(item.href);
        },

        onKey(e) {
            // Cmd/Ctrl-K toggles the palette even from within an input.
            if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
                e.preventDefault();
                this.toggle();
                return;
            }
            if (this.open || this.help) return; // overlay keys handled in the markup
            const t = e.target;
            if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
            if (e.metaKey || e.ctrlKey || e.altKey) return;
            // Only consume "/" when a page search exists — otherwise leave the browser's quick-find.
            if (e.key === '/') { if (this.focusSearch()) e.preventDefault(); return; }
            if (e.key === '?') { e.preventDefault(); this.help = true; return; }
            if (this.leader) {
                this.leader = false;
                clearTimeout(this.leaderTimer);
                this.go(e.key.toLowerCase());
                return;
            }
            if (e.key === 'g' || e.key === 'G') {
                this.leader = true;
                this.leaderTimer = setTimeout(() => { this.leader = false; }, 1200);
            }
        },
    }));
});

// ── Console branding + self-XSS warning ─────────────────────────────────────────────────────────
// Printed once on load. The security notice is not decoration: a control panel for Linux servers is
// a prime target for "paste this in your console" social-engineering (self-XSS) — surfacing the
// warning where the attack happens protects admins. Styling is best-effort; never throws.
(() => {
    try {
        const big   = 'color:#FF6B2C;font:800 44px/1.1 "JetBrains Mono",ui-monospace,monospace;text-shadow:0 0 14px rgba(255,107,44,.45);padding:6px 0';
        const sub   = 'color:#9DAAB6;font:13px ui-monospace,monospace';
        const warnH = 'color:#FF5247;font:700 15px ui-monospace,monospace';
        const warnB = 'color:#E8B931;font:12px/1.5 ui-monospace,monospace';
        const link  = 'color:#43C1D8;font:12px ui-monospace,monospace';
        console.log('%cCLUSEV', big);
        console.log('%cSelf-hosted control plane für deine Linux-Flotte.', sub);
        console.log('%c⚠  Stopp — diese Konsole ist für Entwickler.', warnH);
        console.log('%cFüg hier NICHTS ein, das dir jemand geschickt hat. Solche „Tricks" sind fast\nimmer Betrug (Self-XSS) und können deine Sitzung und deine Server übernehmen.', warnB);
        console.log('%cNeugierig? Fragen? Mitbauen?  →  https://github.com/clusev/clusev   ·   AGPL-3.0', link);
    } catch (_) {
        // console styling unsupported — ignore
    }
})();
