// ============================================================
// SoulSync — Worker Orbs
// Dashboard header buttons shrink to floating orbs, expand on hover
// ============================================================

(function () {
    'use strict';

    // Disable on mobile
    if (window.innerWidth <= 768 || /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) return;

    // ── Worker definitions with brand colors ──
    const WORKER_DEFS = [
        { container: '.mb-button-container',              color: [186, 85, 211], id: 'musicbrainz' },
        { container: '.audiodb-button-container',          color: [0, 188, 212],  id: 'audiodb' },
        { container: '.deezer-button-container',           color: [162, 56, 255], id: 'deezer' },
        { container: '.spotify-enrich-button-container',   color: [30, 215, 96],  id: 'spotify-enrichment' },
        { container: '.itunes-enrich-button-container',    color: [251, 91, 137], id: 'itunes-enrichment' },
        { container: '.lastfm-enrich-button-container',    color: [213, 16, 7],   id: 'lastfm-enrichment' },
        { container: '.genius-enrich-button-container',    color: [255, 255, 100], id: 'genius-enrichment' },
        { container: '.tidal-enrich-button-container',     color: [180, 180, 255], id: 'tidal-enrichment' },
        { container: '.qobuz-enrich-button-container',     color: [1, 112, 239],  id: 'qobuz-enrichment' },
        { container: '.discogs-button-container',          color: [180, 180, 180], id: 'discogs' },
        { container: '.amazon-enrich-button-container',    color: [255, 153, 0],  id: 'amazon-enrichment' },
        { container: '.similar-artists-enrich-button-container', color: [168, 85, 247], id: 'similar_artists' },
        { container: '.hydrabase-button-container',        color: [200, 200, 200], id: 'hydrabase' },
        { container: '.soulid-button-container',          color: [29, 185, 84], rainbow: true, id: 'soulid' },
        { container: '.repair-button-container',           color: [180, 130, 255], rainbow: true, id: 'repair' },
        { container: '.em-manage-btn',                     color: [168, 85, 247], hub: true },
    ];

    const ERROR_COLOR = [255, 80, 80];   // pulses fired on real worker errors
    const PULSE_CAP = 12;                 // max pulses queued per status update
    // Status pushes arrive ~every 2s (120 frames). Spread each window's pulses
    // across that interval so they drip steadily instead of bursting on arrival.
    const STATUS_FRAMES = 120;
    const MIN_RELEASE_RATE = 1 / 45;     // a lone event still appears within ~0.75s

    const ORB_RADIUS = 7;
    const ORB_DIAMETER = ORB_RADIUS * 2;
    const CONNECTION_DIST = 70;
    const LERP_SPEED = 0.08;
    const EXPAND_STAGGER = 35;
    const MAX_SPARKS = 60;       // global spark pool cap
    const SPARK_RATE = 0.12;     // chance per frame per active orb to emit
    const MAX_INFLOWS = 48;      // hub inbound-pulse pool cap
    const INFLOW_RATE = 0.05;    // chance per frame per active orb to send a pulse inward

    let dashboardHeader = null;
    let headerActions = null;
    let canvas = null;
    let ctx = null;
    let orbs = [];
    let sparks = [];             // particle emissions from active orbs
    let inflows = [];            // pulses traveling from active orbs into the hub
    let ripples = [];            // impact rings where a pulse lands on the nucleus
    const MAX_RIPPLES = 24;

    // ── Sleep/wake ──
    // With zero workers active (and no pulses in flight) for SLEEP_AFTER_MS,
    // the system drowses: nucleus dims to embers, spokes fade, drift slows to
    // a crawl. First sign of work snaps it awake (~0.3s) with a ripple bloom
    // from the nucleus. The idle/busy contrast is what makes activity read.
    const SLEEP_AFTER_MS = 75000;
    let sleepLevel = 0;          // 0 awake .. 1 asleep (eased, never snaps)
    let lastActivityAt = performance.now();
    let wakeBurstDone = true;
    let errorHeat = 0;           // 0..1 aggregate "stress" — bumps on real worker errors, decays over time
    let state = 'idle';
    let animFrame = null;
    let onDashboard = false;
    let expandProgress = 0;
    let staggerTimers = [];
    let collapseDelay = null;
    const COLLAPSE_DELAY_MS = 7000;

    // SoulSync logo, drawn as the hub/nucleus once loaded
    let hubImage = null;
    let hubImageReady = false;

    // ── Init ──

    function init() {
        dashboardHeader = document.querySelector('#dashboard-page .dashboard-header');
        headerActions = document.querySelector('#dashboard-page .header-actions');
        if (!dashboardHeader || !headerActions) return;

        if (!hubImage) {
            hubImage = new Image();
            hubImage.onload = () => { hubImageReady = true; };
            hubImage.src = '/static/trans2.png';
        }

        canvas = document.createElement('canvas');
        canvas.className = 'worker-orb-canvas';
        canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5;';
        dashboardHeader.appendChild(canvas);
        ctx = canvas.getContext('2d');

        WORKER_DEFS.forEach((def, i) => {
            const el = headerActions.querySelector(def.container);
            if (!el) return;

            orbs.push({
                el,
                btn: el.matches('button') ? el : el.querySelector('button'),
                id: def.id || null,
                color: def.color,
                rainbow: def.rainbow || false,
                hub: def.hub || false,
                index: i,
                x: 0, y: 0,
                vx: (Math.random() - 0.5) * 0.6,
                vy: (Math.random() - 0.5) * 0.6,
                homeX: 0, homeY: 0,
                visible: true,
                phase: Math.random() * Math.PI * 2,
                // Pseudo-3D orbital depth: z oscillates -1 (behind the nucleus,
                // smaller + dimmer) to +1 (in front, larger + brighter). Pure
                // draw-time effect — the force physics stay 2D.
                z: 0,
                zPhase: Math.random() * Math.PI * 2,
                zSpeed: 0.22 + Math.random() * 0.18,
                kick: 0,              // excitement-kick energy (decays)
                active: false,
                statusSeen: false,    // has a real WS status arrived for this worker?
                lastProcessed: 0,     // cumulative matched+not_found seen last update
                lastErrors: 0,        // cumulative error count seen last update
                pendingWork: 0,       // brand-colour pulses still to release
                pendingErr: 0,        // red pulses still to release (real errors)
                workRate: 0,          // pulses/frame, set so pending drains over the interval
                errRate: 0,
                workCarry: 0,         // fractional-pulse accumulators
                errCarry: 0,
            });
        });

        computeHomes();
        centerOrbs();

        dashboardHeader.addEventListener('mouseenter', onMouseEnter);
        dashboardHeader.addEventListener('mouseleave', onMouseLeave);
        window.addEventListener('resize', onResize);
        document.addEventListener('visibilitychange', onVisibility);
    }

    function computeHomes() {
        if (!dashboardHeader || !headerActions) return;
        const headerRect = dashboardHeader.getBoundingClientRect();

        orbs.forEach(orb => {
            const elRect = orb.el.getBoundingClientRect();
            orb.homeX = (elRect.left - headerRect.left) + elRect.width / 2;
            orb.homeY = (elRect.top - headerRect.top) + elRect.height / 2;
            orb.visible = orb.el.offsetParent !== null;
        });
    }

    function centerOrbs() {
        // Spawn the whole cluster dead-center and let the physics bloom it
        // outward. Positions EVERY orb (visible or not): the old random
        // scatter skipped not-yet-visible orbs, so on page load they all sat
        // at the canvas origin and drifted in from the top-left corner.
        if (!canvas) return;
        const w = canvas.clientWidth || 600;
        const h = canvas.clientHeight || 80;

        orbs.forEach(orb => {
            // A few px of jitter so the separation force can split the stack
            // (it ignores pairs closer than 0.1px).
            orb.x = w / 2 + (Math.random() - 0.5) * 6;
            orb.y = h / 2 + (Math.random() - 0.5) * 6;
            orb.vx = (Math.random() - 0.5) * 0.6;
            orb.vy = (Math.random() - 0.5) * 0.6;
        });
    }

    function resizeCanvas() {
        if (!canvas) return;
        canvas.width = canvas.clientWidth;
        canvas.height = canvas.clientHeight;
    }

    // ── Rainbow color cycle (matches repair button's CSS rainbow) ──

    const RAINBOW = [
        [255, 0, 0],
        [255, 136, 0],
        [255, 255, 0],
        [0, 255, 0],
        [0, 136, 255],
        [136, 0, 255],
    ];

    function getRainbowColor(time) {
        const t = ((time * 0.33) % 1 + 1) % 1; // ~3s cycle to match CSS 3s
        const idx = t * RAINBOW.length;
        const i = Math.floor(idx);
        const f = idx - i;
        const a = RAINBOW[i % RAINBOW.length];
        const b = RAINBOW[(i + 1) % RAINBOW.length];
        return [
            Math.round(a[0] + (b[0] - a[0]) * f),
            Math.round(a[1] + (b[1] - a[1]) * f),
            Math.round(a[2] + (b[2] - a[2]) * f),
        ];
    }

    // ── Glow sprite cache ──
    // Radial gradients are the expensive part of canvas glows. Bake one soft
    // glow sprite per colour into an offscreen canvas and blit it with
    // drawImage — a single cheap GPU copy instead of allocating a gradient
    // every frame. Colours are quantised to 8-step buckets to bound the cache
    // (the tint shift is imperceptible in a glow, and keeps the rainbow path
    // from minting a new sprite every frame).
    const GLOW_SIZE = 64;
    const _glowCache = new Map();

    function getGlowSprite(r, g, b) {
        const qr = r & ~7, qg = g & ~7, qb = b & ~7;
        const key = (qr << 16) | (qg << 8) | qb;
        let spr = _glowCache.get(key);
        if (spr) return spr;

        spr = document.createElement('canvas');
        spr.width = spr.height = GLOW_SIZE;
        const gctx = spr.getContext('2d');
        const c = GLOW_SIZE / 2;
        const grad = gctx.createRadialGradient(c, c, 0, c, c, c);
        grad.addColorStop(0, `rgba(${qr}, ${qg}, ${qb}, 1)`);
        grad.addColorStop(1, `rgba(${qr}, ${qg}, ${qb}, 0)`);
        gctx.fillStyle = grad;
        gctx.fillRect(0, 0, GLOW_SIZE, GLOW_SIZE);

        _glowCache.set(key, spr);
        return spr;
    }

    // Blit a cached glow of the given radius/alpha centred at (x, y)
    function drawGlow(ctx, x, y, radius, r, g, b, alpha) {
        if (alpha <= 0 || radius <= 0) return;
        ctx.globalAlpha = alpha;
        ctx.drawImage(getGlowSprite(r, g, b), x - radius, y - radius, radius * 2, radius * 2);
        ctx.globalAlpha = 1;
    }

    // ── Spark system ──

    function emitSpark(orb, colorOverride) {
        if (sparks.length >= MAX_SPARKS) return;
        const angle = Math.random() * Math.PI * 2;
        const speed = 0.4 + Math.random() * 0.8;
        sparks.push({
            x: orb.x,
            y: orb.y,
            vx: Math.cos(angle) * speed,
            vy: Math.sin(angle) * speed,
            life: 1.0,           // 1.0 → 0.0
            decay: 0.012 + Math.random() * 0.012,
            color: colorOverride || orb.color,
            radius: 1.5 + Math.random() * 1.5,
        });
    }

    function updateSparks() {
        for (let i = sparks.length - 1; i >= 0; i--) {
            const s = sparks[i];
            s.x += s.vx;
            s.y += s.vy;
            s.vx *= 0.98;
            s.vy *= 0.98;
            s.life -= s.decay;
            if (s.life <= 0) {
                sparks.splice(i, 1);
            }
        }
    }

    function drawSparks(ctx) {
        for (const s of sparks) {
            const [r, g, b] = s.color;
            const alpha = s.life * 0.6;
            const radius = s.radius * s.life;

            // Spark glow (cached sprite)
            drawGlow(ctx, s.x, s.y, radius * 3, r, g, b, alpha * 0.4);

            // Spark core
            ctx.beginPath();
            ctx.arc(s.x, s.y, radius, 0, Math.PI * 2);
            ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
            ctx.fill();
        }
    }

    // ── Inbound pulses (active worker → hub) ──
    // Each carries an active worker's color into the nucleus, so the hub
    // visibly "collects" the output of whatever is running.

    function emitInflow(orb, color) {
        if (inflows.length >= MAX_INFLOWS) return;
        inflows.push({
            orb,                        // source orb (positions resolved live)
            color: color || orb.color,
            t: 0,                       // 0 at source → 1 at hub
            speed: 0.012 + Math.random() * 0.01,
        });
    }

    function updateInflows(hub) {
        for (let i = inflows.length - 1; i >= 0; i--) {
            const p = inflows[i];
            p.t += p.speed;
            if (p.t >= 1) {
                // The pulse lands: ripple + a couple of debris sparks at the
                // contact point on the nucleus rim, so the hub visibly absorbs
                // it instead of the dot just vanishing.
                if (hub) {
                    const dx = hub.x - p.orb.x, dy = hub.y - p.orb.y;
                    const d = Math.sqrt(dx * dx + dy * dy) || 1;
                    const ux = dx / d, uy = dy / d;
                    const rim = ORB_RADIUS + 4;
                    emitImpact(hub.x - ux * rim, hub.y - uy * rim, p.color, ux, uy);
                }
                inflows.splice(i, 1);
            }
        }
    }

    // ── Impact ripples (pulse → nucleus contact) ──

    function emitImpact(x, y, color, dirX, dirY) {
        if (ripples.length < MAX_RIPPLES) {
            ripples.push({ x, y, color, age: 0, life: 26 + Math.random() * 8 });
        }
        // Two debris sparks splash back roughly the way the pulse came, fanned.
        for (let i = 0; i < 2 && sparks.length < MAX_SPARKS; i++) {
            const spread = (Math.random() - 0.5) * 1.6;
            const cos = Math.cos(spread), sin = Math.sin(spread);
            const rx = -(dirX * cos - dirY * sin);
            const ry = -(dirX * sin + dirY * cos);
            const speed = 0.5 + Math.random() * 0.7;
            sparks.push({
                x, y, vx: rx * speed, vy: ry * speed,
                life: 0.8, decay: 0.03 + Math.random() * 0.02,
                color, radius: 1 + Math.random() * 1.2,
            });
        }
    }

    function updateRipples() {
        for (let i = ripples.length - 1; i >= 0; i--) {
            if (++ripples[i].age >= ripples[i].life) ripples.splice(i, 1);
        }
    }

    function drawRipples(ctx) {
        for (const rp of ripples) {
            if (rp.age < 0) continue;              // staggered (wake bloom)
            const t = rp.age / rp.life;            // 0 → 1
            const [r, g, b] = rp.color;
            ctx.beginPath();
            ctx.arc(rp.x, rp.y, 3 + t * 11, 0, Math.PI * 2);
            ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${(1 - t) * 0.45})`;
            ctx.lineWidth = 1.5 * (1 - t) + 0.5;
            ctx.stroke();
        }
    }

    function drawInflows(ctx, hub) {
        if (!hub) return;
        for (const p of inflows) {
            const [r, g, b] = p.color;
            // Ease toward hub so pulses accelerate as they arrive
            const e = p.t * p.t;
            const x = p.orb.x + (hub.x - p.orb.x) * e;
            const y = p.orb.y + (hub.y - p.orb.y) * e;
            const alpha = 0.55 * (1 - Math.abs(p.t - 0.5) * 0.6); // fade in/out at the ends
            const radius = 2.2;

            // Comet tail — ghost positions at trailing t values. The path is
            // parametric, so no position history is needed; the same easing
            // evaluated slightly in the past gives a tail that stretches as
            // the pulse accelerates into the hub.
            for (let i = 3; i >= 1; i--) {
                const tt = p.t - i * p.speed * 2.4;
                if (tt <= 0) continue;
                const te = tt * tt;
                const tx = p.orb.x + (hub.x - p.orb.x) * te;
                const ty = p.orb.y + (hub.y - p.orb.y) * te;
                ctx.beginPath();
                ctx.arc(tx, ty, Math.max(0.4, radius * (1 - i * 0.24)), 0, Math.PI * 2);
                ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha * (1 - i / 4) * 0.5})`;
                ctx.fill();
            }

            drawGlow(ctx, x, y, radius * 3, r, g, b, alpha * 0.5);

            ctx.beginPath();
            ctx.arc(x, y, radius, 0, Math.PI * 2);
            ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
            ctx.fill();
        }
    }

    // ── State machine ──

    function enterOrbState() {
        if (state === 'orbs') return;
        state = 'orbs';
        expandProgress = 0;

        orbs.forEach(orb => {
            orb.el.classList.add('worker-orb-hidden');
        });

        canvas.style.opacity = '1';
        canvas.style.display = '';
        resizeCanvas();
        // Dashboard (re)activation: the canvas just got its real size — init may
        // have run while the header was hidden (0x0), leaving positions stale.
        // Bloom the cluster from dead center instead of wherever that left them.
        centerOrbs();
        startLoop();
    }

    function enterExpandedState() {
        state = 'expanded';
        expandProgress = 1;

        clearStaggerTimers();
        orbs.forEach((orb, i) => {
            const t = setTimeout(() => {
                orb.el.classList.remove('worker-orb-hidden');
                orb.el.classList.add('worker-orb-reveal');
            }, i * EXPAND_STAGGER);
            staggerTimers.push(t);
        });

        canvas.style.opacity = '0';
        setTimeout(() => {
            if (state === 'expanded') {
                canvas.style.display = 'none';
                stopLoop();
            }
        }, 400);
    }

    function enterCollapsingState() {
        state = 'collapsing';
        clearStaggerTimers();

        const total = orbs.length;
        orbs.forEach((orb, i) => {
            const t = setTimeout(() => {
                orb.el.classList.remove('worker-orb-reveal');
                orb.el.classList.add('worker-orb-hidden');
            }, (total - 1 - i) * 20);
            staggerTimers.push(t);
        });

        canvas.style.display = '';
        canvas.style.opacity = '1';
        resizeCanvas();
        computeHomes();
        inflows = [];   // drop in-flight pulses; positions are about to jump
        ripples = [];
        orbs.forEach(orb => {
            orb.x = orb.homeX;
            orb.y = orb.homeY;
            orb.vx = (Math.random() - 0.5) * 0.4;
            orb.vy = (Math.random() - 0.5) * 0.4;
        });

        startLoop();

        setTimeout(() => {
            if (state === 'collapsing') {
                state = 'orbs';
                expandProgress = 0;
            }
        }, total * 20 + 100);
    }

    function clearStaggerTimers() {
        staggerTimers.forEach(t => clearTimeout(t));
        staggerTimers = [];
    }

    // ── Events ──

    function onMouseEnter() {
        if (!onDashboard) return;
        // Cancel any pending collapse
        if (collapseDelay) {
            clearTimeout(collapseDelay);
            collapseDelay = null;
        }
        if (state === 'orbs' || state === 'collapsing') {
            state = 'expanding';
            expandProgress = 0;
        }
    }

    function onMouseLeave() {
        if (!onDashboard) return;
        if (state === 'expanded' || state === 'expanding') {
            // Delay before collapsing back to orbs
            if (collapseDelay) clearTimeout(collapseDelay);
            collapseDelay = setTimeout(() => {
                collapseDelay = null;
                if (state === 'expanded' || state === 'expanding') {
                    enterCollapsingState();
                }
            }, COLLAPSE_DELAY_MS);
        }
    }

    function onResize() {
        computeHomes();
        resizeCanvas();
        const w = canvas ? canvas.width : 600;
        const h = canvas ? canvas.height : 80;
        orbs.forEach(orb => {
            orb.x = Math.max(ORB_RADIUS, Math.min(w - ORB_RADIUS, orb.x));
            orb.y = Math.max(ORB_RADIUS, Math.min(h - ORB_RADIUS, orb.y));
        });
    }

    function onVisibility() {
        if (document.hidden) {
            stopLoop();
        } else if (onDashboard && (state === 'orbs' || state === 'expanding' || state === 'collapsing')) {
            startLoop();
        }
    }

    // ── Animation loop ──

    let frameCount = 0;

    let _scrollPauseUntil = 0;
    (function attachScrollPause() {
        const scroller = document.querySelector('.main-content') || window;
        scroller.addEventListener('scroll', () => {
            _scrollPauseUntil = performance.now() + 180;
        }, { passive: true });
    })();

    function startLoop() {
        if (animFrame) return;
        tick();
    }

    function stopLoop() {
        if (animFrame) {
            cancelAnimationFrame(animFrame);
            animFrame = null;
        }
    }

    function tick() {
        animFrame = requestAnimationFrame(tick);
        if (!canvas || !ctx) return;

        // Yield the frame to active scrolling (orbs freeze, resume on idle).
        if (performance.now() < _scrollPauseUntil) return;

        frameCount++;
        // Fully asleep: render at ~20fps. The drift is at crawl speed so the
        // difference is invisible, and the canvas GPU cost drops by two thirds
        // for the hours the dashboard sits idle. Wakes re-run every frame.
        if (sleepLevel > 0.95 && frameCount % 3 !== 0) return;
        const time = frameCount / 60;
        const w = canvas.width;
        const h = canvas.height;

        if (w === 0 || h === 0) {
            resizeCanvas();
            return;
        }

        // Health stress cools off when errors stop (~6s to settle from a spike)
        if (errorHeat > 0.0001) errorHeat *= 0.992; else errorHeat = 0;

        // Check active state every 30 frames (button ref is cached at init)
        if (frameCount % 30 === 0) {
            orbs.forEach(orb => {
                orb.visible = orb.el.offsetParent !== null;
                const nowActive = orb.btn ? orb.btn.classList.contains('active') : false;
                if (nowActive && !orb.active && !orb.hub) {
                    // Excitement kick: a freshly-woken worker jolts into motion
                    // (brief overspeed + size wobble + a few sparks) instead of
                    // just getting brighter. Decays in physics/draw.
                    const ang = Math.random() * Math.PI * 2;
                    orb.vx += Math.cos(ang) * 1.6;
                    orb.vy += Math.sin(ang) * 1.6;
                    orb.kick = 1.0;
                    for (let i = 0; i < 3; i++) {
                        emitSpark(orb, orb.rainbow ? getRainbowColor(time) : null);
                    }
                }
                orb.active = nowActive;
            });
        }

        const visibleOrbs = orbs.filter(o => o.visible);
        const hub = visibleOrbs.find(o => o.hub);

        // Sleep/wake bookkeeping (see declarations for the design).
        if (visibleOrbs.some(o => !o.hub && o.active) || inflows.length) {
            lastActivityAt = performance.now();
        }
        if (performance.now() - lastActivityAt > SLEEP_AFTER_MS) {
            sleepLevel = Math.min(1, sleepLevel + 0.004);   // ~4s drowse-in
            wakeBurstDone = false;
        } else {
            if (!wakeBurstDone && sleepLevel > 0.4 && hub) {
                // Wake bloom: three staggered rings out of the nucleus.
                for (let i = 0; i < 3 && ripples.length < MAX_RIPPLES; i++) {
                    ripples.push({
                        x: hub.x, y: hub.y,
                        color: hub.rainbow ? getRainbowColor(time) : hub.color,
                        age: -i * 6, life: 30,
                    });
                }
            }
            wakeBurstDone = true;
            sleepLevel = Math.max(0, sleepLevel - 0.05);    // fast wake (~0.3s)
        }

        if (state === 'orbs' || state === 'collapsing') {
            updatePhysics(visibleOrbs, w, h);
        } else if (state === 'expanding') {
            updateExpanding(visibleOrbs, w, h);
        }

        // Sparks (ambient aura while active) + inbound pulses to the hub.
        // Pulses are event-driven: one per real item matched / error reported,
        // drained a couple per frame so bursts stagger nicely up the spoke.
        for (const orb of visibleOrbs) {
            if (orb.hub) continue;

            if (orb.active && Math.random() < SPARK_RATE) {
                emitSpark(orb, orb.rainbow ? getRainbowColor(time) : null);
            }

            if (!hub) continue;

            if (orb.statusSeen) {
                // Release queued pulses at a steady drip so a 2s window of
                // events streams up the spoke instead of arriving all at once.
                if (orb.pendingWork > 0) {
                    orb.workCarry += orb.workRate;
                    while (orb.workCarry >= 1 && orb.pendingWork > 0) {
                        emitInflow(orb, orb.rainbow ? getRainbowColor(time) : null);
                        orb.workCarry -= 1; orb.pendingWork -= 1;
                    }
                } else {
                    orb.workCarry = 0;
                }
                if (orb.pendingErr > 0) {
                    orb.errCarry += orb.errRate;
                    while (orb.errCarry >= 1 && orb.pendingErr > 0) {
                        emitInflow(orb, ERROR_COLOR);
                        orb.errCarry -= 1; orb.pendingErr -= 1;
                    }
                } else {
                    orb.errCarry = 0;
                }
            } else if (orb.active && Math.random() < INFLOW_RATE) {
                // No real status yet — keep the old ambient trickle as fallback
                emitInflow(orb, orb.rainbow ? getRainbowColor(time) : null);
            }
        }
        updateSparks();
        updateInflows(hub);
        updateRipples();

        // Draw
        ctx.clearRect(0, 0, w, h);

        drawConnections(ctx, visibleOrbs, time);
        drawSparks(ctx);
        drawInflows(ctx, hub);
        drawRipples(ctx);
        drawOrbs(ctx, visibleOrbs, time);
    }

    // ── Physics ──

    function updatePhysics(visible, w, h) {
        const cx = w * 0.5;
        const cy = h * 0.5;

        for (const orb of visible) {
            // The hub is a nucleus — it settles at canvas center and stays put
            // while every worker orb drifts around it. No jitter, strong pull home.
            if (orb.hub) {
                orb.vx += (cx - orb.x) * 0.02;
                orb.vy += (cy - orb.y) * 0.02;
                orb.vx *= 0.85;
                orb.vy *= 0.85;
                orb.x += orb.vx;
                orb.y += orb.vy;
                continue;
            }

            // Active orbs drift faster
            const driftStrength = orb.active ? 0.04 : 0.02;
            orb.vx += (Math.random() - 0.5) * driftStrength;
            orb.vy += (Math.random() - 0.5) * driftStrength;

            // Subtle gravity toward center — keeps orbs loosely grouped
            const gx = cx - orb.x;
            const gy = cy - orb.y;
            const gDist = Math.sqrt(gx * gx + gy * gy);
            if (gDist > 1) {
                const gStrength = 0.004;
                orb.vx += (gx / gDist) * gStrength;
                orb.vy += (gy / gDist) * gStrength;

                // Orbital rotation — a tangential nudge (perpendicular to the
                // pull home) so the cluster slowly revolves around the nucleus
                // like electrons round an atom. Stronger when the orb is active.
                const tStrength = orb.active ? 0.008 : 0.005;
                orb.vx += (-gy / gDist) * tStrength;
                orb.vy += (gx / gDist) * tStrength;
            }

            // Damping
            orb.vx *= 0.993;
            orb.vy *= 0.993;

            // Speed cap — active orbs move a bit faster; an excitement kick
            // briefly lifts the cap so the jolt actually darts, then decays.
            orb.kick = (orb.kick || 0) * 0.94;
            const maxSpeed = (orb.active ? 0.8 : 0.5) * (1 + orb.kick * 2);
            const speed = Math.sqrt(orb.vx * orb.vx + orb.vy * orb.vy);
            if (speed > maxSpeed) {
                const scale = maxSpeed / speed;
                orb.vx *= scale;
                orb.vy *= scale;
            }

            // Soft repulsion from other orbs
            for (const other of visible) {
                if (other === orb) continue;
                const dx = orb.x - other.x;
                const dy = orb.y - other.y;
                const dist = Math.sqrt(dx * dx + dy * dy);
                if (dist < 35 && dist > 0.1) {
                    const force = 0.03 * (1 - dist / 35);
                    orb.vx += (dx / dist) * force;
                    orb.vy += (dy / dist) * force;
                }
            }

            // Move — asleep, the drift slows to a crawl (velocities keep
            // integrating so motion stays continuous through wake)
            const drowse = 1 - sleepLevel * 0.75;
            orb.x += orb.vx * drowse;
            orb.y += orb.vy * drowse;

            // Boundary bounce
            if (orb.x < ORB_RADIUS) { orb.x = ORB_RADIUS; orb.vx *= -0.7; }
            if (orb.x > w - ORB_RADIUS) { orb.x = w - ORB_RADIUS; orb.vx *= -0.7; }
            if (orb.y < ORB_RADIUS) { orb.y = ORB_RADIUS; orb.vy *= -0.7; }
            if (orb.y > h - ORB_RADIUS) { orb.y = h - ORB_RADIUS; orb.vy *= -0.7; }
        }
    }

    function updateExpanding(visible) {
        let allClose = true;

        for (const orb of visible) {
            const dx = orb.homeX - orb.x;
            const dy = orb.homeY - orb.y;
            orb.x += dx * LERP_SPEED;
            orb.y += dy * LERP_SPEED;

            orb.vx *= 0.9;
            orb.vy *= 0.9;

            const dist = Math.sqrt(dx * dx + dy * dy);
            if (dist > 3) allClose = false;
        }

        expandProgress = Math.min(1, expandProgress + 0.03);

        if (allClose || expandProgress >= 1) {
            enterExpandedState();
        }
    }

    // ── Drawing ──

    function drawOrbs(ctx, visible, time) {
        // ── Depth pass ──
        // Each worker orb drifts on a slow z-oscillation; the hub sits at z=0.
        // Painter's order (back → front) makes orbs visibly pass BEHIND the
        // nucleus and swing back in front — the flat drift reads as an atom.
        // The effect eases out during the hover-expand morph so orbs land on
        // their buttons at natural size.
        const depthFade = state === 'expanding' ? (1 - expandProgress) : 1;
        for (const orb of visible) {
            orb.z = orb.hub ? 0 : Math.sin(time * orb.zSpeed + orb.zPhase) * depthFade;
        }
        const ordered = [...visible].sort((a, b) => a.z - b.z);

        for (const orb of ordered) {
            const [r, g, b] = orb.rainbow ? getRainbowColor(time) : orb.color;
            // -1 (back): ~18% smaller, dimmer. +1 (front): ~18% larger, full.
            // Sleep folds in as a further global dim (embers, not blackout).
            const dScale = 1 + orb.z * 0.18;
            const dAlpha = (0.78 + 0.22 * ((orb.z + 1) / 2)) * (1 - sleepLevel * 0.45);

            // ── The hub: an energy-reactive nucleus ──
            // Calm + dim when nothing's running; bigger, brighter and faster
            // the more workers are active. The animation reads as a gauge.
            if (orb.hub) {
                const workers = visible.filter(o => !o.hub);
                const activeCount = workers.filter(o => o.active).length;
                const energy = workers.length ? activeCount / workers.length : 0; // 0..1
                const stress = errorHeat;                        // 0..1 health gauge

                // Health shows as a gentle, gradual warm-red shift in the
                // nucleus — never a fast flicker. Stress does NOT speed up the
                // heartbeat (that read as jitter); only the colour eases over.
                const beatSpeed = 1.0 + energy * 1.4;
                const slow = 0.5 + 0.5 * Math.sin(time * beatSpeed);
                // Barely-there breathing — the nucleus is mostly steady
                const hubR = (ORB_RADIUS + 3 + energy * 4) + slow * (0.6 + energy * 0.8);
                const tint = stress * 0.55;                      // softened, never full alarm-red
                const hr = Math.round(r + (235 - r) * tint);
                const hg = Math.round(g + (60 - g) * tint);
                const hb = Math.round(b + (60 - b) * tint);

                // Wide ambient glow — steady, only gently lifting with energy.
                // Asleep, the nucleus dims to embers.
                const ember = 1 - sleepLevel * 0.55;
                const glowR = hubR * (4 + energy * 1.5);
                drawGlow(ctx, orb.x, orb.y, glowR, hr, hg, hb,
                    (0.16 + energy * 0.16 + slow * 0.04 + stress * 0.08) * ember);

                if (hubImageReady) {
                    // SoulSync logo as the nucleus — fit to the pulsing radius while
                    // preserving the image's natural aspect ratio (no stretch)
                    const natW = hubImage.naturalWidth || 1;
                    const natH = hubImage.naturalHeight || 1;
                    const fit = (hubR * 3.2) / Math.max(natW, natH);
                    const dw = natW * fit;
                    const dh = natH * fit;
                    ctx.save();
                    ctx.globalAlpha = Math.min(1, 0.9 + energy * 0.1 + slow * 0.03) * (1 - sleepLevel * 0.35);
                    ctx.drawImage(hubImage, orb.x - dw / 2, orb.y - dh / 2, dw, dh);
                    ctx.restore();
                } else {
                    // Fallback while the logo loads: solid bright core + highlight
                    ctx.beginPath();
                    ctx.arc(orb.x, orb.y, hubR, 0, Math.PI * 2);
                    ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${0.8 + energy * 0.15})`;
                    ctx.fill();
                    ctx.beginPath();
                    ctx.arc(orb.x, orb.y, hubR * 0.5, 0, Math.PI * 2);
                    ctx.fillStyle = `rgba(255, 255, 255, ${0.3 + energy * 0.25 + slow * 0.2})`;
                    ctx.fill();
                }

                // A single, very faint expanding ring — only when workers are
                // actually busy, and barely visible so it reads as a soft halo,
                // not a throbbing pulse.
                if (energy > 0.02) {
                    const ringPhase = (time * 0.35) % 1;
                    const ringR = hubR + ringPhase * hubR * 1.4;
                    ctx.beginPath();
                    ctx.arc(orb.x, orb.y, ringR, 0, Math.PI * 2);
                    ctx.strokeStyle = `rgba(${hr}, ${hg}, ${hb}, ${(1 - ringPhase) * 0.08 * energy})`;
                    ctx.lineWidth = 1;
                    ctx.stroke();
                }

                // Health warning: a single soft red ring that breathes slowly
                // (no flicker) and fades in/out gradually as stress rises/cools.
                if (stress > 0.04) {
                    const warn = 0.5 + 0.5 * Math.sin(time * 1.4);
                    const wr = hubR + 3 + warn * 3;
                    ctx.beginPath();
                    ctx.arc(orb.x, orb.y, wr, 0, Math.PI * 2);
                    ctx.strokeStyle = `rgba(255, 90, 90, ${stress * (0.12 + warn * 0.10)})`;
                    ctx.lineWidth = 1.5;
                    ctx.stroke();
                }
                continue;
            }

            const pulse = 0.5 + 0.5 * Math.sin(time * 2 + orb.phase);

            // Active orbs are larger and breathe — size oscillates
            let baseRadius = orb.active ? ORB_RADIUS + 3 : ORB_RADIUS;
            if (orb.active) {
                baseRadius += 2 * Math.sin(time * 3 + orb.phase);
            }
            // Excitement-kick wobble: fast, shallow, gone in under a second
            if (orb.kick > 0.02) {
                baseRadius += orb.kick * 2.5 * Math.sin(time * 14 + orb.phase);
            }

            // Scale up during expand transition; depth scales the whole orb
            const currentRadius = (state === 'expanding'
                ? baseRadius + expandProgress * 4
                : baseRadius) * dScale;

            // Inactive orbs are dimmer
            const activeMult = orb.active ? 1.0 : 0.45;

            // Outer glow — much larger and brighter for active
            const glowRadius = orb.active ? currentRadius * 5 : currentRadius * 3;
            const glowAlpha = (orb.active
                ? (0.25 + pulse * 0.2) * activeMult
                : (0.06 + pulse * 0.03) * activeMult) * dAlpha;
            drawGlow(ctx, orb.x, orb.y, glowRadius, r, g, b, glowAlpha);

            // Core
            const coreAlpha = (orb.active
                ? 0.85 + pulse * 0.15
                : (0.3 + pulse * 0.08) * activeMult) * dAlpha;
            ctx.beginPath();
            ctx.arc(orb.x, orb.y, Math.max(1, currentRadius), 0, Math.PI * 2);
            ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${coreAlpha})`;
            ctx.fill();

            // Inactive: subtle border ring so they're visible against dark backgrounds
            if (!orb.active) {
                ctx.beginPath();
                ctx.arc(orb.x, orb.y, currentRadius, 0, Math.PI * 2);
                ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.3 + pulse * 0.1})`;
                ctx.lineWidth = 1;
                ctx.stroke();
            }

            // Active: expanding pulse ring that fades
            if (orb.active) {
                // Inner ring — tight, bright
                const ring1 = currentRadius + 2 + pulse * 3;
                ctx.beginPath();
                ctx.arc(orb.x, orb.y, ring1, 0, Math.PI * 2);
                ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.25 + pulse * 0.15})`;
                ctx.lineWidth = 1;
                ctx.stroke();

                // Outer ring — wide, faint, slower pulse
                const pulse2 = 0.5 + 0.5 * Math.sin(time * 1.2 + orb.phase + 1);
                const ring2 = currentRadius + 6 + pulse2 * 6;
                ctx.beginPath();
                ctx.arc(orb.x, orb.y, ring2, 0, Math.PI * 2);
                ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.06 + pulse2 * 0.06})`;
                ctx.lineWidth = 0.5;
                ctx.stroke();
            }
        }
    }

    function drawConnections(ctx, visible, time) {
        // Hub spokes — the nucleus is wired to every worker orb, full length,
        // so it always reads as the center that "manages" the cluster.
        const hub = visible.find(o => o.hub);
        if (hub) {
            const [hr, hg, hb] = hub.color;
            for (const orb of visible) {
                if (orb === hub) continue;
                const dx = hub.x - orb.x;
                const dy = hub.y - orb.y;
                const dist = Math.sqrt(dx * dx + dy * dy) || 1;
                // Gentle traveling pulse along each spoke (fades while asleep)
                const flow = 0.5 + 0.5 * Math.sin(time * 2 - dist * 0.05);
                const alpha = (0.10 + flow * 0.10 + (orb.active ? 0.06 : 0)) * (1 - sleepLevel * 0.7);
                ctx.beginPath();
                ctx.moveTo(hub.x, hub.y);
                ctx.lineTo(orb.x, orb.y);
                ctx.strokeStyle = `rgba(${hr}, ${hg}, ${hb}, ${alpha})`;
                ctx.lineWidth = orb.active ? 1.0 : 0.6;
                ctx.stroke();
            }
        }

        for (let i = 0; i < visible.length; i++) {
            for (let j = i + 1; j < visible.length; j++) {
                const a = visible[i], b = visible[j];
                if (a.hub || b.hub) continue; // hub spokes handled above
                const dx = a.x - b.x;
                const dy = a.y - b.y;
                const dist = Math.sqrt(dx * dx + dy * dy);

                if (dist < CONNECTION_DIST) {
                    // Connections between active orbs are brighter
                    const activePair = a.active && b.active;
                    const anyActive = a.active || b.active;
                    const baseAlpha = activePair ? 0.3 : (anyActive ? 0.2 : 0.15);
                    const alpha = (1 - dist / CONNECTION_DIST) * baseAlpha * (1 - sleepLevel * 0.7);

                    const [r1, g1, b1] = a.rainbow ? getRainbowColor(time) : a.color;
                    const [r2, g2, b2] = b.rainbow ? getRainbowColor(time) : b.color;
                    const mr = (r1 + r2) >> 1;
                    const mg = (g1 + g2) >> 1;
                    const mb = (b1 + b2) >> 1;

                    ctx.beginPath();
                    ctx.moveTo(a.x, a.y);
                    ctx.lineTo(b.x, b.y);
                    ctx.strokeStyle = `rgba(${mr}, ${mg}, ${mb}, ${alpha})`;
                    ctx.lineWidth = anyActive ? 0.8 : 0.5;
                    ctx.stroke();
                }
            }
        }
    }

    // ── Page awareness ──

    function isEnabled() {
        return window._workerOrbsEnabled !== false && !window._reduceEffectsActive;
    }

    // ── Real telemetry → pulses ──
    // Fed by the WebSocket enrichment status pushes (see core.js). We diff the
    // cumulative counters between updates and queue one inbound pulse per real
    // item processed (brand colour) or error (red). No status yet → the loop
    // falls back to an ambient trickle so active orbs still animate.
    function onStatus(id, data) {
        if (!id || !data) return;
        const orb = orbs.find(o => o.id === id);
        if (!orb) return;

        const s = data.stats || {};
        const num = (v) => (typeof v === 'number' && isFinite(v) ? v : 0);
        // "processed" = every flavour of completed item across the worker zoo
        const processed = num(s.matched) + num(s.not_found) + num(s.repaired)
                        + num(s.synced) + num(s.scanned);
        const errors = num(s.errors);

        if (!orb.statusSeen) {
            // First sample is just a baseline — don't dump the whole backlog
            orb.statusSeen = true;
            orb.lastProcessed = processed;
            orb.lastErrors = errors;
            return;
        }

        const dWork = processed - orb.lastProcessed;
        const dErr = errors - orb.lastErrors;
        orb.lastProcessed = processed;
        orb.lastErrors = errors;

        // Queue the new events and (re)set a drip rate that empties the current
        // backlog over the interval until the next push — steady stream, not a burst.
        if (dWork > 0) {
            orb.pendingWork = Math.min(PULSE_CAP, orb.pendingWork + dWork);
            orb.workRate = Math.max(MIN_RELEASE_RATE, orb.pendingWork / STATUS_FRAMES);
        }
        if (dErr > 0) {
            orb.pendingErr = Math.min(PULSE_CAP, orb.pendingErr + dErr);
            orb.errRate = Math.max(MIN_RELEASE_RATE, orb.pendingErr / STATUS_FRAMES);
            // Feed the nucleus health gauge — each real error eases the hub's
            // stress up gradually (404s are not_found now, so this only fires on
            // true failures). Small bump so it ramps in softly, never spikes.
            errorHeat = Math.min(0.85, errorHeat + 0.1 * dErr);
        }
    }

    function setPage(pageId) {
        const wasDashboard = onDashboard;
        onDashboard = (pageId === 'dashboard') && isEnabled();

        if (onDashboard && !wasDashboard) {
            computeHomes();
            resizeCanvas();
            sparks = [];
            ripples = [];
            enterOrbState();
        } else if (!onDashboard && wasDashboard) {
            if (collapseDelay) { clearTimeout(collapseDelay); collapseDelay = null; }
            stopLoop();
            state = 'idle';
            sparks = [];
            ripples = [];
            orbs.forEach(orb => {
                orb.el.classList.remove('worker-orb-hidden', 'worker-orb-reveal');
            });
            if (canvas) {
                canvas.style.display = 'none';
                canvas.style.opacity = '0';
            }
        }
    }

    // ── Bootstrap ──

    function bootstrap() {
        init();
        if (!dashboardHeader) return;

        window.workerOrbs = { setPage, onStatus };

        const activePage = document.querySelector('.page.active');
        if (activePage && activePage.id === 'dashboard-page' && isEnabled()) {
            setTimeout(() => {
                computeHomes();
                resizeCanvas();
                enterOrbState();
                onDashboard = true;
            }, 300);
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', bootstrap);
    } else {
        setTimeout(bootstrap, 100);
    }

})();
