/* ────────────────────────────────────────────────────────────
   pt-particles - faint floating ambient particles
   ────────────────────────────────────────────────────────────
   A canvas full of small, dim dots that drift in a slow,
   organic manner. Used on Section 5 (Roadmap) as an atmospheric
   layer behind the sticky image pane on the left.

   Design notes (Axion-on-brand):
     - Particles are bone-tinted (#EDEAE2), VERY low opacity
       (per-particle alpha 0.06–0.18). No glow, no trails.
     - Sizes 0.6–1.6 px (HiDPI-aware via devicePixelRatio).
     - Motion: slow vector drift + a tiny sinusoidal wobble per
       particle so they don't all march in parallel. Speeds are
       on the order of 4–14 px/sec - calm, never rushing.
     - Subtle "breath" in opacity (slow sine modulation per
       particle) so the field gently twinkles without flashing.
     - Wraps around the canvas edges (toroidal) so density is
       constant and there's no spawn/despawn churn.
     - Respects prefers-reduced-motion (renders a single static
       frame and stops the rAF loop).

   Props:
     density        particles per 10,000 px² (default 0.18)
     style          merged into the canvas wrapper
     className      forwarded to the wrapper
     color          base color (default "#EDEAE2")
     accentColor    color used by ~12% of particles (default brass)
     accentRatio    fraction of particles drawn in accentColor
   ──────────────────────────────────────────────────────────── */

function PtParticles({
  density = 0.18,
  speedScale = 1,
  style,
  className,
  color = "#EDEAE2",
  accentColor = "#D4A24C",
  accentRatio = 0.12
}) {
  const canvasRef = React.useRef(null);
  const wrapRef = React.useRef(null);

  React.useEffect(() => {
    const canvas = canvasRef.current;
    const wrap = wrapRef.current;
    if (!canvas || !wrap) return;

    const ctx = canvas.getContext("2d");
    const reduceMotion = window.matchMedia &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let particles = [];
    let dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
    let W = 0, H = 0;
    let rafId = 0;
    let lastT = 0;
    let running = true;

    // Parse the two base colors once into rgb arrays so we can
    // assemble per-particle rgba strings cheaply each frame.
    const parseHex = (hex) => {
      const h = hex.replace("#", "");
      const v = h.length === 3 ?
        h.split("").map((c) => c + c).join("") : h;
      return [
        parseInt(v.slice(0, 2), 16),
        parseInt(v.slice(2, 4), 16),
        parseInt(v.slice(4, 6), 16)
      ];
    };
    const baseRGB = parseHex(color);
    const accentRGB = parseHex(accentColor);

    const seedParticle = (p) => {
      // Random direction; slow magnitude. Mostly horizontal-ish
      // drift with a vertical bias (very gentle upward float)
      // so the field reads like fine motes in still air.
      const angle = Math.random() * Math.PI * 2;
      // Per-particle base speed + caller's overall scale. The scale
      // applies to drift AND upward bias so motion stays coherent -
      // bumping it up makes particles feel a touch livelier without
      // changing the character of the field.
      const speed = (4 + Math.random() * 10) * speedScale;            // px/sec
      const upBias = (-2 - Math.random() * 3) * speedScale;            // px/sec
      p.x = Math.random() * W;
      p.y = Math.random() * H;
      p.vx = Math.cos(angle) * speed * 0.6;
      p.vy = Math.sin(angle) * speed * 0.4 + upBias;
      p.r = (0.6 + Math.random() * 1.0) * dpr;          // radius in device px
      // Per-particle base alpha + a "breath" amplitude/phase so
      // each one twinkles independently.
      p.aBase = 0.06 + Math.random() * 0.12;
      p.aAmp = 0.02 + Math.random() * 0.05;
      p.aPhase = Math.random() * Math.PI * 2;
      p.aFreq = 0.25 + Math.random() * 0.55;            // Hz-ish
      // Wobble - perpendicular sinusoid so paths gently curve.
      p.wAmp = 4 + Math.random() * 8;                   // px
      p.wFreq = 0.10 + Math.random() * 0.20;            // Hz-ish
      p.wPhase = Math.random() * Math.PI * 2;
      p.accent = Math.random() < accentRatio;
    };

    const reseedAll = () => {
      const cssW = wrap.clientWidth;
      const cssH = wrap.clientHeight;
      W = Math.floor(cssW * dpr);
      H = Math.floor(cssH * dpr);
      canvas.width = W;
      canvas.height = H;
      canvas.style.width = cssW + "px";
      canvas.style.height = cssH + "px";

      const area = cssW * cssH;
      const target = Math.max(20, Math.round(area / 10000 * density * 100));
      // density is "particles per 10,000 px²" - multiply ×100 since
      // the default density of 0.18 should yield ~180 particles in
      // a 1000×1000 area, which feels right for "very faint field."

      particles = new Array(target);
      for (let i = 0; i < target; i++) {
        particles[i] = {};
        seedParticle(particles[i]);
      }
    };

    const wrapAround = (p) => {
      // Toroidal wrap with a small margin so particles fade in/out
      // naturally at the edges via their aBase alpha rather than
      // popping at the exact boundary.
      const m = 8 * dpr;
      if (p.x < -m) p.x = W + m;
      else if (p.x > W + m) p.x = -m;
      if (p.y < -m) p.y = H + m;
      else if (p.y > H + m) p.y = -m;
    };

    const draw = (tSec) => {
      ctx.clearRect(0, 0, W, H);
      // Composite mode: 'lighter' would create halos / over-bright
      // clusters. We want dim and atomic, so default 'source-over'
      // with low per-particle alpha.
      for (let i = 0; i < particles.length; i++) {
        const p = particles[i];
        const a = Math.max(
          0,
          p.aBase + Math.sin(tSec * p.aFreq * Math.PI * 2 + p.aPhase) * p.aAmp
        );
        const rgb = p.accent ? accentRGB : baseRGB;
        ctx.fillStyle =
          "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + a.toFixed(3) + ")";
        // Wobble offset perpendicular to motion vector - but for
        // simplicity (and because particles drift slowly), just
        // add a small sinusoidal x-offset; visually identical at
        // these speeds and avoids per-frame trig per axis.
        const wob = Math.sin(tSec * p.wFreq * Math.PI * 2 + p.wPhase) * p.wAmp * dpr;
        ctx.beginPath();
        ctx.arc(p.x + wob, p.y, p.r, 0, Math.PI * 2);
        ctx.fill();
      }
    };

    const tick = (tMs) => {
      if (!running) return;
      if (!lastT) lastT = tMs;
      const dt = Math.min(0.066, (tMs - lastT) / 1000); // clamp big jumps
      lastT = tMs;
      const tSec = tMs / 1000;

      for (let i = 0; i < particles.length; i++) {
        const p = particles[i];
        p.x += p.vx * dt * dpr;
        p.y += p.vy * dt * dpr;
        wrapAround(p);
      }
      draw(tSec);
      rafId = requestAnimationFrame(tick);
    };

    // Pause when the wrapper isn't visible - saves work when the
    // user scrolls past the section.
    const io = new IntersectionObserver((entries) => {
      const visible = entries.some((e) => e.isIntersecting);
      if (visible && !running && !reduceMotion) {
        running = true;
        lastT = 0;
        rafId = requestAnimationFrame(tick);
      } else if (!visible && running) {
        running = false;
        cancelAnimationFrame(rafId);
      }
    }, { threshold: 0 });
    io.observe(wrap);

    let resizeT = 0;
    const onResize = () => {
      clearTimeout(resizeT);
      resizeT = setTimeout(() => {
        dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
        reseedAll();
      }, 120);
    };
    window.addEventListener("resize", onResize);

    reseedAll();
    if (reduceMotion) {
      // Single static frame.
      draw(0);
      running = false;
    } else {
      rafId = requestAnimationFrame(tick);
    }

    return () => {
      running = false;
      cancelAnimationFrame(rafId);
      io.disconnect();
      window.removeEventListener("resize", onResize);
      clearTimeout(resizeT);
    };
  }, [color, accentColor, accentRatio, density]);

  return (
    <div
      ref={wrapRef}
      aria-hidden="true"
      className={className}
      style={{
        position: "absolute",
        inset: 0,
        pointerEvents: "none",
        overflow: "hidden",
        ...style
      }}>
      <canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
    </div>);

}

window.PtParticles = PtParticles;
