import './echo';
import { Chart, registerables } from 'chart.js';

Chart.register(...registerables);

const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

// Resolve "token:name" / "token:name/0.12" strings in a chart config to real
// colours pulled from the CSS design tokens (keeps charts on-palette, R3).
function tokenColor(spec, css) {
    const [name, alpha] = spec.split('/');
    const value = css.getPropertyValue('--' + name.trim()).trim();
    if (!value) return spec;
    if (alpha && value.startsWith('#')) {
        let hex = value.slice(1);
        if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
        const r = parseInt(hex.slice(0, 2), 16);
        const g = parseInt(hex.slice(2, 4), 16);
        const b = parseInt(hex.slice(4, 6), 16);
        return `rgba(${r}, ${g}, ${b}, ${alpha})`;
    }
    return value;
}

function resolveTokens(node, css) {
    if (typeof node === 'string') return node.startsWith('token:') ? tokenColor(node.slice(6), css) : node;
    if (Array.isArray(node)) return node.map((n) => resolveTokens(n, css));
    if (node && typeof node === 'object') {
        const out = {};
        for (const key of Object.keys(node)) out[key] = resolveTokens(node[key], css);
        return out;
    }
    return node;
}

// Turn any resolved colour (#hex, rgb(), rgba()) into an rgba() with the given alpha.
function toRgba(color, alpha) {
    if (typeof color !== 'string') return color;
    if (color.startsWith('#')) {
        let hex = color.slice(1);
        if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
        const r = parseInt(hex.slice(0, 2), 16);
        const g = parseInt(hex.slice(2, 4), 16);
        const b = parseInt(hex.slice(4, 6), 16);
        return `rgba(${r}, ${g}, ${b}, ${alpha})`;
    }
    const m = color.match(/rgba?\(([^)]+)\)/);
    if (m) {
        const [r, g, b] = m[1].split(',').map((n) => n.trim());
        return `rgba(${r}, ${g}, ${b}, ${alpha})`;
    }
    return color;
}

// Every filled line dataset gets a vertical gradient from its line colour to
// transparent — the "colour fades to nothing under the line" look (R3/token-driven).
function applyLineGradients(cfg) {
    const isLine = (ds) => (ds.type || cfg.type) === 'line';
    for (const ds of cfg.data?.datasets ?? []) {
        if (!isLine(ds) || !ds.fill) continue;
        const base = Array.isArray(ds.borderColor) ? ds.borderColor[0] : ds.borderColor;
        if (!base) continue;
        ds.backgroundColor = (ctx) => {
            const { chart } = ctx;
            const area = chart.chartArea;
            if (!area) return toRgba(base, 0.18); // first paint before layout
            const g = chart.ctx.createLinearGradient(0, area.top, 0, area.bottom);
            g.addColorStop(0, toRgba(base, 0.32));
            g.addColorStop(1, toRgba(base, 0));
            return g;
        };
    }
}

// ── Connection health: a silent 419 recovery, and a banner for everything
//    that is not a Livewire-recognised failure at all ─────────────────────
//
// Livewire's OWN default handling of a failed request (vendor/livewire/
// livewire/dist/livewire.js, js/request/index.js — read directly, not
// assumed, since the previous comment here turned out to be wrong about it)
// is not what it looks like from the outside:
//
//   - A 419 (expired session or a stale CSRF token) triggers a native
//     "This page has expired…" browser dialog (window's own confirm prompt)
//     and THEN, whatever the operator answers, the same full-page HTML
//     overlay used for a 500 — both fire unconditionally unless the request
//     hook's preventDefault() is called.
//   - A request that never gets a response at all (offline, DNS failure,
//     connection refused — fetch() itself throwing) is already silent: no
//     overlay, no dialog, nothing. Correct for case 3 below, but it also
//     means a dropped connection currently gives the operator no feedback
//     whatsoever — the gap the banner fills.
//   - Anything else (500 and friends) is untouched — Livewire's own overlay
//     is the right behaviour for a real server error; this project shows
//     failures rather than hiding them (R22).
//
// preventDefault() runs synchronously inside Livewire's own fail(), before
// it decides whether to show that dialog and the overlay — so calling it
// here is not a race against window.location.reload() the way triggering
// the reload alone (the previous approach) was.
//
// A page left open across a deploy, or simply idle long enough, carries a
// CSRF token the server no longer accepts. Reloading gets a fresh one for a
// session that is still valid, and lands an actually-expired session
// straight on the sign-in page — both through completely ordinary Laravel
// middleware, nothing bespoke.
//
// sessionStorage survives the reload (same tab) but not a closed one:
// remembering that a reload was already tried means a SECOND 419 inside the
// window below is treated as "reloading did not fix it" rather than looped
// on forever, and sends the operator to sign in directly instead. A 419 in a
// fresh tab, or one long after the window has passed, always gets its own
// reload first.
const RELOAD_GUARD_KEY = 'clupilot:419-reload-at';
const RELOAD_GUARD_WINDOW_MS = 15000;

function recoverFrom419() {
    const triedAt = Number(sessionStorage.getItem(RELOAD_GUARD_KEY) || 0);

    if (Date.now() - triedAt < RELOAD_GUARD_WINDOW_MS) {
        sessionStorage.removeItem(RELOAD_GUARD_KEY);
        // Each shell carries its OWN sign-in route on its <body> (R21: the
        // console and the portal do not share one) — see layouts/admin.blade.php
        // and layouts/portal-app.blade.php.
        window.location.href = document.body.dataset.loginUrl || '/login';

        return;
    }

    sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now()));
    window.location.reload();
}

// A thin bar at the top of the window while a Livewire request is in flight.
//
// Reported as "man klickt und sieht nicht was passiert": every action in the
// console is a round trip, and a round trip with no sign of itself reads as a
// click that missed. wire:loading fixes that per element, one element at a time,
// and only where somebody remembered to add it — so this is the floor underneath
// all of them.
//
// Deliberately delayed: a request that answers in 80 ms does not need a bar, and
// flashing one for every keystroke on a .live field is worse than nothing.
// SHOW_AFTER_MS is the threshold below which a person perceives the response as
// immediate anyway.
const BUSY_SHOW_AFTER_MS = 140;
const BUSY_FADE_MS = 220;

function requestIndicator() {
    let bar = null;
    let showTimer = null;
    let inFlight = 0;

    const element = () => {
        if (bar !== null) return bar;

        bar = document.createElement('div');
        // Inline styles, not a class: this has to work on the login page, on the
        // 503 page, and anywhere else the app's stylesheet may not have loaded
        // the utilities it would otherwise need.
        bar.style.cssText = [
            'position:fixed', 'top:0', 'left:0', 'height:2px', 'width:0%',
            'background:#c2560a', 'z-index:2147483647', 'pointer-events:none',
            'transition:width .25s ease-out,opacity .22s linear', 'opacity:0',
        ].join(';');
        bar.setAttribute('aria-hidden', 'true');
        document.body.appendChild(bar);

        return bar;
    };

    const show = () => {
        const el = element();
        el.style.opacity = '1';
        // Not to 100: the bar must never claim to be finished while the answer
        // is still on its way. It creeps and then completes.
        el.style.width = '70%';
    };

    const hide = () => {
        if (bar === null) return;
        bar.style.width = '100%';
        bar.style.opacity = '0';
        window.setTimeout(() => {
            if (inFlight === 0 && bar !== null) bar.style.width = '0%';
        }, BUSY_FADE_MS);
    };

    return {
        start() {
            inFlight++;
            if (showTimer !== null) return;
            showTimer = window.setTimeout(() => {
                showTimer = null;
                if (inFlight > 0) show();
            }, BUSY_SHOW_AFTER_MS);
        },
        // Called for success AND failure: a request that fails is exactly the
        // one somebody must not be left waiting on.
        stop() {
            inFlight = Math.max(0, inFlight - 1);
            if (inFlight > 0) return;

            if (showTimer !== null) {
                window.clearTimeout(showTimer);
                showTimer = null;
            }

            hide();
        },
    };
}

const busy = requestIndicator();

document.addEventListener('livewire:init', () => {
    window.Livewire.hook('request', ({ fail, respond }) => {
        busy.start();
        // respond fires for every answer, whatever its status; fail() below
        // handles the ones worth acting on. Both paths must release the bar,
        // and respond runs first — so stopping here covers the failures too.
        respond(() => busy.stop());

        fail(({ status, preventDefault }) => {
            // 503: the application is in maintenance mode, which during a
            // deployment is not a fault — it is the event being watched.
            //
            // Livewire's default is to replace the document with the response
            // body, so a wire:poll that happened to fire mid-update swapped the
            // console for Laravel's 503 page: the operator saw the overlay,
            // then the error page, then the reload. Reported as "the 503 must
            // not appear at all during the update", and it must not.
            //
            // Swallowed for every Livewire request, not just the poll: any
            // action taken in that window would do the same thing.
            if (status === 503) {
                preventDefault();

                return;
            }

            if (status !== 419) return;

            preventDefault();
            recoverFrom419();
        });
    });
});

// A navigation (wire:navigate) is not a request hook, and the bar would
// otherwise sit there after the page it belonged to is gone.
document.addEventListener('livewire:navigating', () => busy.start());
document.addEventListener('livewire:navigated', () => busy.stop());

// Connection banner ("Keine Verbindung" / "Verbindung wiederhergestellt" —
// see connectionBanner() below): navigator.onLine and the browser's
// online/offline events are both used, but neither is trusted alone —
// onLine is only ever a hint (true on a wifi with no real route out, a
// captive portal, a VPN that dropped without the OS noticing), which is why
// an actual Livewire request completing or failing at the network level is
// wired in as a third, stronger signal.
//
// Wrapping fetch (module load, before Livewire's own bundle sends anything)
// rather than reading the request hook's fail() for this part: fail() only
// fires for a request Livewire itself made, and represents a thrown fetch as
// a synthetic status-503 failure — recognisable, but wrapping fetch directly
// observes the same thrown error without leaning on that internal detail.
const originalFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
    const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '';
    const isLivewireRequest = url.includes('/livewire/');

    let response;
    try {
        response = await originalFetch(...args);
    } catch (error) {
        if (isLivewireRequest) window.dispatchEvent(new CustomEvent('connection-lost'));
        throw error;
    }

    // Any response at all — even a 500 — proves the network path to our own
    // server works, which is all "back online" means here.
    if (isLivewireRequest) window.dispatchEvent(new CustomEvent('connection-restored'));

    return response;
};

document.addEventListener('alpine:init', () => {
    // ── Watching a deployment that restarts the watcher ──────────────────
    //
    // wire:poll cannot see an update through to the end, because the update
    // restarts the containers serving the poll. Mid-run every request fails;
    // afterwards a NEW build is being questioned by the OLD page's JavaScript.
    // The card therefore sat on "running" until somebody reloaded the page by
    // hand, which is how it was reported.
    //
    // This asks a plain JSON endpoint instead — no Livewire, no component
    // state, no assets — and, crucially, treats a failed request as the
    // restart rather than as an error worth giving up over.
    window.Alpine.data('updateWatcher', ({
        url, commit, running, step = null,
    }) => ({
        // The build this page was rendered from. When the server stops
        // agreeing, the page is stale by definition and must be replaced —
        // this is the only signal that survives the restart, since the run has
        // already ended by the time we can ask again.
        renderedCommit: commit,
        wasRunning: running,
        // Has the SERVER ever reported this run, as opposed to us opening the
        // overlay optimistically when the button was pressed?
        //
        // This is the gap the whole thing turned on. The agent consumes the
        // request file BEFORE it resolves the release — deliberately, because
        // update.sh may kill the shell and a request left in place would loop —
        // and only writes `state: running` once it has decided to go ahead. In
        // between, the request is gone and the status does not say running yet,
        // so the endpoint honestly answers "nothing is running". The watcher
        // read that as "the run has finished" and reloaded the page: the
        // overlay appeared on the click, vanished a poll later, and the 503
        // arrived after it. Reported twice, exactly that way.
        //
        // The run has ENDED only once the server has both confirmed it and then
        // stopped reporting it. Before the confirmation, silence means the agent
        // has not got there yet.
        serverConfirmed: running,
        unconfirmedPolls: 0,
        // Consecutive failed requests. Expected during the restart — that IS
        // the event — but not forever: if the application comes back broken,
        // every request fails and the overlay used to stay up over a console
        // nobody could then reach to find out why. Reported exactly that way,
        // together with the 500 that caused it.
        failedPolls: 0,
        // Shown once the failures stop looking like a restart. Not a "close"
        // button that is always there: the run does not stop because somebody
        // dismissed a dialog, and offering that at the wrong moment is a lie.
        stuck: false,
        // Already translated server-side ("Schritt: …") — the client only ever
        // displays it, never assembles it, so German text stays out of
        // JavaScript entirely.
        //
        // The elapsed time and the deployment's raw output used to be carried
        // here too and shown on the panel. Neither is anybody's question while
        // they wait: rsync listing every font file it copied is noise, and
        // holding the whole log in component state for the length of a
        // deployment bought nothing. The endpoint still reports both — the
        // settings page shows the log where somebody is actually looking for
        // it.
        step,
        timer: null,

        init() {
            this.schedule();
            // Nothing to watch while the tab is hidden; pick straight back up
            // rather than waiting out a whole interval on return.
            document.addEventListener('visibilitychange', () => {
                if (!document.hidden) this.check();
            });
        },

        schedule() {
            clearTimeout(this.timer);
            this.timer = setTimeout(() => this.check(), this.wasRunning ? 3000 : 20000);
        },

        async check() {
            try {
                const response = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' });

                // A redirect to the login page answers 200 with HTML. Reload
                // rather than parse it: the session is what needs attention.
                if (!response.ok || !response.headers.get('content-type')?.includes('json')) {
                    throw new Error('not ready');
                }

                const state = await response.json();

                if (state.commit && this.renderedCommit && state.commit !== this.renderedCommit) {
                    window.location.reload();

                    return;
                }

                if (state.running) {
                    this.serverConfirmed = true;
                    this.unconfirmedPolls = 0;
                }

                // The run ended without the build changing — a check that found
                // nothing, or a failure. Either way the card has something new
                // to say.
                if (this.wasRunning && this.serverConfirmed && !state.running) {
                    window.location.reload();

                    return;
                }

                // ...but an overlay that waits forever is its own bug. The
                // agent refuses a request with nothing to install, and says so
                // by writing nothing at all — so give it a bounded number of
                // polls before concluding that nobody picked it up.
                if (this.wasRunning && !this.serverConfirmed && ++this.unconfirmedPolls > 20) {
                    window.location.reload();

                    return;
                }

                // Stay open while we are waiting for the agent to confirm.
                this.wasRunning = state.running || (this.wasRunning && !this.serverConfirmed);
                this.failedPolls = 0;
                this.stuck = false;
                this.step = state.step ?? null;
            } catch {
                // Expected while the containers are down. Keep asking: this is
                // the middle of the very event being watched, not a fault.
                this.wasRunning = true;

                // A deployment restart is seconds. Two minutes of nothing is
                // not a restart any more — it is an installation that came back
                // broken, and the operator needs the page rather than a panel
                // telling them to wait.
                if (++this.failedPolls > 40) {
                    this.stuck = true;
                }
            }

            this.schedule();
        },

        // The way out, offered only once `stuck` is true.
        dismiss() {
            this.wasRunning = false;
            clearTimeout(this.timer);
        },

        destroy() {
            clearTimeout(this.timer);
        },
    }));

    // ── VPN config: copy / download ──────────────────────────────────────
    // navigator.clipboard only exists in a secure context, so over plain http
    // (the panel reached by IP) the copy button silently did nothing. Falls
    // back to the legacy selection copy, and says so when even that fails.
    window.Alpine.data('vpnConfigActions', (filename) => ({
        copied: false,
        failed: false,
        text() {
            return this.$refs.config.textContent;
        },
        async copy() {
            try {
                if (navigator.clipboard && window.isSecureContext) {
                    await navigator.clipboard.writeText(this.text());
                } else {
                    const area = document.createElement('textarea');
                    area.value = this.text();
                    area.setAttribute('readonly', '');
                    area.style.position = 'fixed';
                    area.style.opacity = '0';
                    document.body.appendChild(area);
                    area.select();
                    const ok = document.execCommand('copy');
                    document.body.removeChild(area);
                    if (!ok) throw new Error('copy rejected');
                }
                this.failed = false;
                this.copied = true;
                setTimeout(() => (this.copied = false), 2000);
            } catch (e) {
                this.copied = false;
                this.failed = true;
                setTimeout(() => (this.failed = false), 6000);
            }
        },
        download() {
            // Built in the browser from what is already on the page: the private
            // key never has to travel a second time, and no route has to exist.
            const url = URL.createObjectURL(new Blob([this.text()], { type: 'text/plain' }));
            const link = document.createElement('a');
            link.href = url;
            link.download = filename;
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
            setTimeout(() => URL.revokeObjectURL(url), 1000);
        },
    }));

    // ── Segmented OTP input ──────────────────────────────────────────────
    window.Alpine.data('otpInput', ({ length = 6 } = {}) => ({
        length,
        digits: Array(length).fill(''),
        get value() {
            return this.digits.join('');
        },
        cells() {
            return this.$root.querySelectorAll('input[inputmode="numeric"]');
        },
        focusCell(i) {
            const cell = this.cells()[i];
            if (cell) cell.focus();
        },
        onInput(i, e) {
            const digit = e.target.value.replace(/\D/g, '').slice(-1);
            this.digits[i] = digit;
            if (digit && i < this.length - 1) this.focusCell(i + 1);
            this.maybeSubmit();
        },
        onBackspace(i) {
            if (!this.digits[i] && i > 0) this.focusCell(i - 1);
        },
        onPaste(e) {
            const text = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, this.length);
            for (let i = 0; i < this.length; i++) this.digits[i] = text[i] || '';
            this.focusCell(Math.min(text.length, this.length - 1));
            this.maybeSubmit();
        },
        maybeSubmit() {
            if (this.value.length === this.length) {
                this.$nextTick(() => this.$root.closest('form')?.requestSubmit());
            }
        },
    }));

    // ── Chart.js island ──────────────────────────────────────────────────
    // Die Chart.js-Instanz liegt BEWUSST in der Closure und nicht als
    // Eigenschaft des zurückgegebenen Objekts.
    //
    // Alles, was Alpine.data() zurückgibt, wird reaktiv — also in ein Proxy
    // gehüllt. Chart.js' Plugin-Innenleben überlebt das nicht: `chart.update()`
    // stirbt in der Legende mit "Cannot set properties of undefined (setting
    // 'fullSize')". Konstruieren und Erstzeichnen gehen durch die Hülle noch,
    // deshalb ist es jahrelang nicht aufgefallen — bis zum Live-Chart hat kein
    // Chart in diesem Repo je update() gerufen.
    //
    // Gemessen, nicht vermutet: dieselbe Instanz wirft über das Proxy und läuft
    // über Alpine.raw(). Erzwungen durch tests/Feature/ChartLiveUpdateTest.php.
    window.Alpine.data('chart', (config, updateOn = null) => {
    let instance = null;
    let onFresh = null;

    return {
        init() {
            // Read tokens from this element so charts honour a scoped theme
            // (e.g. .theme-admin dark tokens), not just :root.
            const css = getComputedStyle(this.$el);
            Chart.defaults.font.family = css.getPropertyValue('--font-sans').trim() || 'sans-serif';
            Chart.defaults.color = css.getPropertyValue('--text-muted').trim() || '#667085';
            Chart.defaults.borderColor = css.getPropertyValue('--border').trim() || '#e4e7ec';

            const cfg = resolveTokens(config, css);
            applyLineGradients(cfg);
            cfg.options = {
                responsive: true,
                maintainAspectRatio: false,
                animation: prefersReducedMotion() ? false : cfg.options?.animation,
                ...cfg.options,
            };
            instance = new Chart(this.$refs.canvas, cfg);

            // Live charts. A chart under wire:ignore never sees a re-render, so
            // without this a polling page updates every figure around the
            // canvas and leaves the curve where it was an hour ago.
            if (updateOn) {
                onFresh = (e) => this.apply(e.detail);
                window.addEventListener(updateOn, onFresh);
            }
        },

        // Swap the data inside the live instance rather than rebuilding it:
        // rebuilding would drop the hovered tooltip and re-run the entry
        // animation on every tick.
        apply(detail) {
            const fresh = Array.isArray(detail) ? detail[0] : detail;
            if (!instance || !fresh) return;

            if (fresh.labels) instance.data.labels = fresh.labels;

            // Positional: dataset 0 stays dataset 0, so a series keeps its
            // colour across updates. Colour follows the entity, never its
            // position in whatever came back.
            (fresh.datasets || []).forEach((points, i) => {
                if (instance.data.datasets[i]) instance.data.datasets[i].data = points;
            });

            // 'none' — no animation. Re-animating every minute makes a calm
            // host look like a busy one out of the corner of an eye.
            instance.update('none');
        },

        destroy() {
            if (onFresh && updateOn) window.removeEventListener(updateOn, onFresh);
            instance?.destroy();
            instance = null;
        },
    };
    });

    // ── Connection banner ──────────────────────────────────────────────────
    // "Keine Verbindung" while offline, "Verbindung wiederhergestellt" for a
    // few seconds once a signal below says it is back, then nothing. Bound
    // once per page in each shell layout, listening for both the browser's
    // own online/offline events and the connection-lost/connection-restored
    // events the fetch wrapper above dispatches for real Livewire traffic.
    // Whichever notices first wins; a wrong optimistic "restored" from the
    // browser's online event self-corrects the moment the next Livewire
    // request actually fails again.
    window.Alpine.data('connectionBanner', () => ({
        // A page opened while already offline should say so immediately
        // rather than wait for the first failed request.
        offline: !navigator.onLine,
        justRestored: false,
        restoreTimer: null,

        markOffline() {
            clearTimeout(this.restoreTimer);
            this.justRestored = false;
            this.offline = true;
        },

        markOnline() {
            // Nothing to announce if this page was never marked offline.
            if (!this.offline) return;

            this.offline = false;
            this.justRestored = true;
            clearTimeout(this.restoreTimer);
            this.restoreTimer = setTimeout(() => { this.justRestored = false; }, 3000);
        },

        destroy() {
            clearTimeout(this.restoreTimer);
        },
    }));
});
