// Interactive node-graph background for the hero.
// • Many small clusters (3–6 nodes each) spread across the hero
// • Connections form by proximity, but only within the same cluster
// • Strong springs + drag-boost so dragging moves the whole cluster
// • Soft collision rebound between every pair (clusters bump off each other)
// • Honours [data-anim="off"] (settles to rest, drag still works)

(function () {
  const { useEffect, useRef } = React;

  function NodeGraph({ density = "regular", intensity = "subtle" }) {
    const canvasRef = useRef(null);
    const rafRef = useRef(0);

    useEffect(() => {
      const canvas = canvasRef.current;
      if (!canvas) return;
      const ctx = canvas.getContext("2d");
      let w = 0,h = 0,dpr = Math.min(window.devicePixelRatio || 1, 2);

      const animOff = () => document.documentElement.getAttribute("data-anim") === "off";

      // ─── tuning ─────────────────────────────────────────────────────────
      const LINK_DIST = () => (intensity === "lively" ? 95 : 85) * dpr;
      const REST_LEN = () => LINK_DIST() * 0.55;
      const HIT_RAD = () => 22 * dpr;
      const K_SPRING = 0.008; // strong intra-cluster cohesion
      const K_DRAG = 2.6; // extra stiffness when an endpoint is being dragged
      const K_REPEL = 0.35;
      const COLLIDE_R = (n) => n.r * 7;

      let nodes = [];

      // ─── seed: clusters of varied sizes spread across the canvas ───────
      const seed = () => {
        // Total nodes scales with area
        const area = w * h / (dpr * dpr);
        const total = Math.max(40, Math.min(140, Math.floor(area / 11000)));
        const variance = Math.floor((Math.random() - 0.5) * 10);
        const N = total + variance;

        // Weighted cluster sizes — pairs through to 10+ nodes
        const rollSize = () => {
          const r = Math.random();
          if (r < 0.32) return 2; // pairs
          if (r < 0.55) return 3; // triples
          if (r < 0.72) return 4 + Math.floor(Math.random() * 2); // 4–5
          if (r < 0.88) return 6 + Math.floor(Math.random() * 3); // 6–8
          if (r < 0.97) return 9 + Math.floor(Math.random() * 3); // 9–11
          return 12 + Math.floor(Math.random() * 5); // 12–16 (rare)
        };

        const clusterPlan = [];
        let used = 0;
        while (used < N - 1) {
          const size = Math.min(rollSize(), N - used);
          if (size < 2) break;
          clusterPlan.push(size);
          used += size;
        }
        // shuffle so order isn't predictable
        for (let i = clusterPlan.length - 1; i > 0; i--) {
          const j = Math.floor(Math.random() * (i + 1));
          [clusterPlan[i], clusterPlan[j]] = [clusterPlan[j], clusterPlan[i]];
        }
        const groupCount = clusterPlan.length;

        // Jittered-grid placement of cluster centres so they fill the canvas
        const aspect = w / Math.max(h, 1);
        const cols = Math.max(2, Math.round(Math.sqrt(groupCount * aspect)));
        const rows = Math.max(2, Math.ceil(groupCount / cols));
        const cellW = w / cols,cellH = h / rows;
        const slots = [];
        for (let r = 0; r < rows; r++) {
          for (let c = 0; c < cols; c++) slots.push({ r, c });
        }
        for (let i = slots.length - 1; i > 0; i--) {
          const j = Math.floor(Math.random() * (i + 1));
          [slots[i], slots[j]] = [slots[j], slots[i]];
        }

        const centres = [];
        for (let g = 0; g < groupCount; g++) {
          const slot = slots[g % slots.length];
          const jx = (Math.random() * 0.6 + 0.2) * cellW;
          const jy = (Math.random() * 0.6 + 0.2) * cellH;
          centres.push({
            cx: slot.c * cellW + jx,
            cy: slot.r * cellH + jy,
            hue: g % 4
          });
        }

        nodes = [];
        for (let g = 0; g < groupCount; g++) {
          const c = centres[g];
          const size = clusterPlan[g];
          // bigger clusters get a bigger spawn radius
          const spread = (14 + size * 6) * dpr;
          for (let i = 0; i < size; i++) {
            const angle = Math.random() * Math.PI * 2;
            const dist = Math.sqrt(Math.random()) * spread;
            nodes.push({
              x: c.cx + Math.cos(angle) * dist,
              y: c.cy + Math.sin(angle) * dist,
              vx: (Math.random() - 0.5) * 0.3 * dpr,
              vy: (Math.random() - 0.5) * 0.3 * dpr,
              r: (Math.random() * 1.6 + 1.2) * dpr,
              hue: c.hue,
              group: g,
              alpha: 1, // render opacity (for fade in/out)
              fadeState: "alive", // alive | fadingOut | fadingIn
              zoneTime: 0 // frames spent inside a text zone
            });
          }
        }
      };

      // ─── canvas sizing ──────────────────────────────────────────────────
      const resize = () => {
        const rect = canvas.getBoundingClientRect();
        const newW = Math.floor(rect.width * dpr);
        const newH = Math.floor(rect.height * dpr);
        if (w && h && nodes.length) {
          const fx = newW / w,fy = newH / h;
          for (const n of nodes) {n.x *= fx;n.y *= fy;}
        }
        w = canvas.width = newW;
        h = canvas.height = newH;
        canvas.style.width = rect.width + "px";
        canvas.style.height = rect.height + "px";
        if (!nodes.length) seed();
      };

      // ─── theme sampling ─────────────────────────────────────────────────
      const sample = () => {
        const root = getComputedStyle(document.documentElement);
        const fg = root.getPropertyValue("--fg").trim() || "#0E0E0C";
        const isDark = document.documentElement.getAttribute("data-theme") === "dark";
        const pastels = [
        root.getPropertyValue("--pastel-1").trim(),
        root.getPropertyValue("--pastel-3").trim(),
        root.getPropertyValue("--pastel-5").trim(),
        root.getPropertyValue("--pastel-2").trim()].
        filter(Boolean);
        return { fg, isDark, pastels };
      };
      let theme = sample();
      const reTheme = () => {theme = sample();};
      const obs = new MutationObserver(reTheme);
      obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });

      // ─── text-zone exclusion ──────────────────────────────────────────
      // Bounding boxes of the hero text/CTA elements — nodes are softly pushed out.
      let textZones = [];
      const refreshZones = () => {
        const zones = [];
        const hero = canvas.closest(".hero");
        if (hero) {
          const cRect = canvas.getBoundingClientRect();
          const targets = hero.querySelectorAll(
            ".hero-eyebrow, .hero-title, .hero-sub, .hero-ctas, .hero-bullets"
          );
          const padX = 16,padY = 10; // CSS px padding around each element
          for (const el of targets) {
            const r = el.getBoundingClientRect();
            if (r.width === 0 || r.height === 0) continue;
            zones.push({
              l: (r.left - cRect.left - padX) * dpr,
              t: (r.top - cRect.top - padY) * dpr,
              r: (r.right - cRect.left + padX) * dpr,
              b: (r.bottom - cRect.top + padY) * dpr
            });
          }
        }
        textZones = zones;
      };

      resize();
      refreshZones();
      window.addEventListener("resize", () => {resize();refreshZones();});
      // re-measure periodically — covers font swap, reveal animation finishing, etc.
      const zoneTimer = setInterval(refreshZones, 400);

      // ─── interaction ────────────────────────────────────────────────────
      const ptr = { x: -9999, y: -9999, down: false, dragId: -1, lastT: 0, lastX: 0, lastY: 0, vx: 0, vy: 0 };

      const toCanvasCoords = (e) => {
        const rect = canvas.getBoundingClientRect();
        return { x: (e.clientX - rect.left) * dpr, y: (e.clientY - rect.top) * dpr };
      };

      const pickNode = (cx, cy) => {
        let best = -1,bestD = HIT_RAD() * HIT_RAD();
        for (let i = 0; i < nodes.length; i++) {
          const n = nodes[i];
          const dx = cx - n.x,dy = cy - n.y;
          const d2 = dx * dx + dy * dy;
          const r = Math.max(HIT_RAD(), n.r * 6);
          if (d2 < r * r && d2 < bestD) {bestD = d2;best = i;}
        }
        return best;
      };

      const onPointerMove = (e) => {
        const { x, y } = toCanvasCoords(e);
        ptr.x = x;ptr.y = y;
        if (ptr.dragId >= 0) {
          const n = nodes[ptr.dragId];
          const now = performance.now();
          const dt = Math.max(8, now - ptr.lastT);
          ptr.vx = (x - ptr.lastX) / dt * 16;
          ptr.vy = (y - ptr.lastY) / dt * 16;
          n.x = x;n.y = y;
          n.vx = 0;n.vy = 0;
          ptr.lastT = now;ptr.lastX = x;ptr.lastY = y;
        } else {
          const hit = pickNode(x, y);
          canvas.style.cursor = hit >= 0 ? "grab" : "default";
        }
      };

      const onPointerDown = (e) => {
        const { x, y } = toCanvasCoords(e);
        const id = pickNode(x, y);
        if (id < 0) return;
        e.preventDefault();
        canvas.setPointerCapture?.(e.pointerId);
        ptr.down = true;ptr.dragId = id;
        ptr.lastT = performance.now();
        ptr.lastX = x;ptr.lastY = y;
        ptr.vx = 0;ptr.vy = 0;
        canvas.style.cursor = "grabbing";
      };

      const onPointerUp = (e) => {
        if (ptr.dragId >= 0) {
          const n = nodes[ptr.dragId];
          const clamp = 10 * dpr;
          n.vx = Math.max(-clamp, Math.min(clamp, ptr.vx));
          n.vy = Math.max(-clamp, Math.min(clamp, ptr.vy));
        }
        ptr.down = false;ptr.dragId = -1;
        canvas.releasePointerCapture?.(e.pointerId);
        canvas.style.cursor = "default";
      };

      canvas.addEventListener("pointerdown", onPointerDown);
      canvas.addEventListener("pointermove", onPointerMove);
      window.addEventListener("pointerup", onPointerUp);
      window.addEventListener("pointercancel", onPointerUp);

      // ─── pick a respawn position + initial velocity ───────────────────
      // Two modes (mixed):
      //  • near-cluster: place near an existing cluster's centroid so the node
      //    rejoins something and lines reappear immediately (no inward velocity)
      //  • edge spawn: enter from a canvas side with inward velocity, so the
      //    node drifts into the space and may collide with / join clusters
      const findRespawn = (excludeNode) => {
        const margin = 30 * dpr;

        const inAnyZone = (x, y) => {
          for (let z = 0; z < textZones.length; z++) {
            const zone = textZones[z];
            if (x > zone.l && x < zone.r && y > zone.t && y < zone.b) return true;
          }
          return false;
        };

        // ~45% chance: spawn near a viable cluster centroid
        if (Math.random() < 0.45) {
          const cent = new Map();
          for (const m of nodes) {
            if (m === excludeNode) continue;
            if (m.alpha < 0.25) continue;
            if (m.group < 0) continue;
            let c = cent.get(m.group);
            if (!c) { c = { sx: 0, sy: 0, count: 0 }; cent.set(m.group, c); }
            c.sx += m.x; c.sy += m.y; c.count++;
          }
          const viable = [];
          for (const [g, c] of cent) {
            if (c.count === 0) continue;
            const cx = c.sx / c.count, cy = c.sy / c.count;
            if (!inAnyZone(cx, cy)) viable.push({ g, cx, cy });
          }
          if (viable.length) {
            const t = viable[Math.floor(Math.random() * viable.length)];
            for (let attempt = 0; attempt < 6; attempt++) {
              const offsetR = (24 + Math.random() * 46) * dpr;
              const angle = Math.random() * Math.PI * 2;
              const x = t.cx + Math.cos(angle) * offsetR;
              const y = t.cy + Math.sin(angle) * offsetR;
              if (!inAnyZone(x, y) && x > margin && x < w - margin && y > margin && y < h - margin) {
                // gentle drift toward centroid so it tucks into the cluster
                const dx = t.cx - x, dy = t.cy - y;
                const dn = Math.hypot(dx, dy) || 1;
                const sp = 0.3 * dpr;
                return { x, y, vx: (dx / dn) * sp, vy: (dy / dn) * sp, groupOverride: t.g };
              }
            }
          }
        }

        // Otherwise: spawn from a random canvas edge with inward velocity.
        // groupOverride = -1 marks the node as solo (no springs/lines) until
        // it collides with a real cluster and joins it.
        const sides = ["top", "right", "bottom", "left"];
        for (let attempt = 0; attempt < 14; attempt++) {
          const side = sides[Math.floor(Math.random() * 4)];
          const v = (0.9 + Math.random() * 1.0) * dpr; // base inward velocity
          const lateral = (Math.random() - 0.5) * v * 0.5;
          let x, y, vx, vy;
          if (side === "top") {
            x = margin + Math.random() * (w - margin * 2);
            y = margin * 0.4;
            vx = lateral; vy = v;
          } else if (side === "bottom") {
            x = margin + Math.random() * (w - margin * 2);
            y = h - margin * 0.4;
            vx = lateral; vy = -v;
          } else if (side === "left") {
            x = margin * 0.4;
            y = margin + Math.random() * (h - margin * 2);
            vx = v; vy = lateral;
          } else {
            x = w - margin * 0.4;
            y = margin + Math.random() * (h - margin * 2);
            vx = -v; vy = lateral;
          }
          if (!inAnyZone(x, y)) return { x, y, vx, vy, groupOverride: -1 };
        }

        // last resort
        return { x: w * 0.85, y: h * 0.5, vx: -1 * dpr, vy: 0, groupOverride: -1 };
      };

      // ─── simulation step ────────────────────────────────────────────────
      const step = () => {
        const off = animOff();
        const linkDist = LINK_DIST();
        const restLen = REST_LEN();
        const N = nodes.length;

        // While dragging: allow the held node to switch clusters when it moves
        // closer to a different cluster's centroid than its own.
        if (ptr.dragId >= 0) {
          const n = nodes[ptr.dragId];
          // Centroids per group, excluding the dragged node
          const cent = new Map();
          for (let k = 0; k < N; k++) {
            if (k === ptr.dragId) continue;
            const m = nodes[k];
            let c = cent.get(m.group);
            if (!c) {c = { sx: 0, sy: 0, count: 0 };cent.set(m.group, c);}
            c.sx += m.x;c.sy += m.y;c.count++;
          }
          let dCurrent = Infinity,dBest = Infinity,bestGroup = n.group;
          for (const [g, c] of cent) {
            if (c.count === 0) continue;
            const cx = c.sx / c.count,cy = c.sy / c.count;
            const dx = n.x - cx,dy = n.y - cy;
            const d2 = dx * dx + dy * dy;
            if (g === n.group) dCurrent = d2;
            if (d2 < dBest) {dBest = d2;bestGroup = g;}
          }
          // hysteresis — switch only if the new cluster is meaningfully closer
          if (bestGroup !== n.group && dBest < dCurrent * 0.55) {
            n.group = bestGroup;
            n.hue = bestGroup % 4;
          }
        }

        for (let i = 0; i < N; i++) {
          const a = nodes[i];
          for (let j = i + 1; j < N; j++) {
            const b = nodes[j];
            let dx = b.x - a.x,dy = b.y - a.y;
            let d2 = dx * dx + dy * dy;
            if (d2 === 0) {d2 = 0.01;dx = 0.1;}
            const d = Math.sqrt(d2);

            // spring — every same-cluster pair, regardless of current distance.
            // (gating by linkDist would leave drifted nodes stranded.)
            // Solo nodes (group < 0) stay free until they collide with a cluster.
            if (a.group === b.group && a.group >= 0) {
              const stretch = d - restLen;
              const dragBoost = i === ptr.dragId || j === ptr.dragId ? K_DRAG : 1;
              const f = stretch * K_SPRING * dragBoost;
              const fx = dx / d * f,fy = dy / d * f;
              if (i !== ptr.dragId) {a.vx += fx;a.vy += fy;}
              if (j !== ptr.dragId) {b.vx -= fx;b.vy -= fy;}
            }

            // collision — every pair. If a solo node touches a clustered one,
            // it adopts that cluster's group on contact.
            const minD = COLLIDE_R(a) + COLLIDE_R(b) - (a.r + b.r) * 3;
            if (d < minD) {
              if (a.group < 0 && b.group >= 0) { a.group = b.group; a.hue = b.group % 4; }
              else if (b.group < 0 && a.group >= 0) { b.group = a.group; b.hue = a.group % 4; }
              const overlap = (minD - d) / 2;
              const ux = dx / d,uy = dy / d;
              if (i !== ptr.dragId) {a.x -= ux * overlap;a.y -= uy * overlap;}
              if (j !== ptr.dragId) {b.x += ux * overlap;b.y += uy * overlap;}
              const k = K_REPEL;
              if (i !== ptr.dragId) {a.vx -= ux * k;a.vy -= uy * k;}
              if (j !== ptr.dragId) {b.vx += ux * k;b.vy += uy * k;}
            }
          }
        }

        // integrate
        const damping = off ? 0.85 : 0.985;
        const drift = off ? 0 : 0.075 * dpr;
        const fadeSpeed = 0.04; // ~25 frames per fade direction
        const zoneDwell = 24; // frames inside a zone before we start fading

        for (let i = 0; i < N; i++) {
          const n = nodes[i];
          const dragged = i === ptr.dragId;

          // — text-zone fade lifecycle —
          if (!dragged && n.fadeState === "alive") {
            let inZone = false;
            for (let z = 0; z < textZones.length; z++) {
              const zone = textZones[z];
              if (n.x > zone.l && n.x < zone.r && n.y > zone.t && n.y < zone.b) {
                inZone = true;break;
              }
            }
            if (inZone) {
              n.zoneTime++;
              if (n.zoneTime >= zoneDwell) n.fadeState = "fadingOut";
            } else {
              n.zoneTime = 0;
            }
          }
          if (n.fadeState === "fadingOut") {
            n.alpha -= fadeSpeed;
            if (n.alpha <= 0) {
              n.alpha = 0;
              // respawn — near-cluster (joining) or edge-entry (with inward velocity)
              const spot = findRespawn(n);
              n.x = spot.x; n.y = spot.y;
              n.vx = spot.vx; n.vy = spot.vy;
              if (spot.groupOverride !== undefined) {
                n.group = spot.groupOverride;
                if (n.group >= 0) n.hue = n.group % 4;
              }
              n.zoneTime = 0;
              n.fadeState = "fadingIn";
            }
          } else if (n.fadeState === "fadingIn") {
            n.alpha += fadeSpeed;
            if (n.alpha >= 1) {n.alpha = 1;n.fadeState = "alive";}
          }

          if (dragged) continue;

          n.vx += (Math.random() - 0.5) * drift;
          n.vy += (Math.random() - 0.5) * drift;
          const vMax = 1.0 * dpr;
          const sp = Math.hypot(n.vx, n.vy);
          if (sp > vMax) {n.vx = n.vx / sp * vMax;n.vy = n.vy / sp * vMax;}
          n.vx *= damping;
          n.vy *= damping;
          n.x += n.vx;
          n.y += n.vy;

          const pad = n.r;
          if (n.x < pad) {n.x = pad;n.vx *= -0.6;}
          if (n.x > w - pad) {n.x = w - pad;n.vx *= -0.6;}
          if (n.y < pad) {n.y = pad;n.vy *= -0.6;}
          if (n.y > h - pad) {n.y = h - pad;n.vy *= -0.6;}
        }
      };

      // ─── render ─────────────────────────────────────────────────────────
      const draw = () => {
        ctx.clearRect(0, 0, w, h);
        const linkDist = LINK_DIST();
        const linkAlpha = theme.isDark ? 0.32 : 0.24;
        ctx.lineWidth = 1 * dpr;

        // lines — same-cluster pair only, capped at maxLineDist so respawning
        // nodes can't draw threads back to faraway peers.
        const maxLineDist = linkDist * 1.6;
        for (let i = 0; i < nodes.length; i++) {
          const a = nodes[i];
          if (a.alpha <= 0.02) continue;
          if (a.group < 0) continue;
          for (let j = i + 1; j < nodes.length; j++) {
            const b = nodes[j];
            if (b.alpha <= 0.02) continue;
            if (a.group !== b.group) continue;
            const dx = a.x - b.x,dy = a.y - b.y;
            const d2 = dx * dx + dy * dy;
            if (d2 > maxLineDist * maxLineDist) continue;
            const d = Math.sqrt(d2);
            const t = 1 - d / maxLineDist;
            const isDragLink = i === ptr.dragId || j === ptr.dragId;
            const lineAlpha = Math.min(a.alpha, b.alpha);
            ctx.strokeStyle = hexA(theme.fg, linkAlpha * t * (isDragLink ? 1.8 : 1) * lineAlpha);
            ctx.beginPath();
            ctx.moveTo(a.x, a.y);
            ctx.lineTo(b.x, b.y);
            ctx.stroke();
          }
        }

        // nodes
        for (let i = 0; i < nodes.length; i++) {
          const n = nodes[i];
          if (n.alpha <= 0.02) continue;
          const color = theme.pastels[n.hue % theme.pastels.length] || theme.fg;
          const isDrag = i === ptr.dragId;
          ctx.beginPath();
          ctx.arc(n.x, n.y, n.r * (isDrag ? 5.2 : 3.6), 0, Math.PI * 2);
          ctx.fillStyle = hexA(color, (isDrag ? 0.42 : 0.28) * n.alpha);
          ctx.fill();
          ctx.beginPath();
          ctx.arc(n.x, n.y, n.r * (isDrag ? 1.4 : 1), 0, Math.PI * 2);
          ctx.fillStyle = hexA(theme.fg, (theme.isDark ? 1.0 : 0.88) * n.alpha);
          ctx.fill();
        }
      };

      let lastTime = 0;
      const TARGET_DT = 1000 / 30;
      const tick = (now) => {
        rafRef.current = requestAnimationFrame(tick);
        const dt = now - lastTime;
        if (dt < TARGET_DT * 0.8) return;
        lastTime = now - (dt % TARGET_DT);
        step();
        draw();
      };
      rafRef.current = requestAnimationFrame(tick);

      return () => {
        cancelAnimationFrame(rafRef.current);
        clearInterval(zoneTimer);
        window.removeEventListener("resize", resize);
        canvas.removeEventListener("pointerdown", onPointerDown);
        canvas.removeEventListener("pointermove", onPointerMove);
        window.removeEventListener("pointerup", onPointerUp);
        window.removeEventListener("pointercancel", onPointerUp);
        obs.disconnect();
      };
    }, [density, intensity]);

    return (
      <div className="hero-canvas-wrap" aria-hidden="true">
      <canvas ref={canvasRef} data-comment-anchor="9b5bbe2dd1-canvas-479-7" />
    </div>);

  }

  function hexA(c, a) {
    c = (c || "").trim();
    if (c.startsWith("#") && c.length === 7) {
      const r = parseInt(c.slice(1, 3), 16);
      const g = parseInt(c.slice(3, 5), 16);
      const b = parseInt(c.slice(5, 7), 16);
      return `rgba(${r},${g},${b},${a})`;
    }
    return `rgba(170,170,170,${a})`;
  }

  window.NodeGraph = NodeGraph;
})();