/* global React, THREE */
/* ============================================================
   motion.jsx — Three.js scenes + scroll-driven motion utilities
   for Revolute Systems. Wireframe / topographic agritech aesthetic.
   ============================================================ */

const { useEffect, useRef, useState, useLayoutEffect } = React;

const PRM = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = (n, a, b) => Math.max(a, Math.min(b, n));

/* ============================================================
   useReveal — IntersectionObserver-based fade+slide reveal.
   ============================================================ */
window.useReveal = function useReveal(dep) {
  useEffect(() => {
    // Reset all previously-revealed elements so they animate in fresh on each route
    document.querySelectorAll('[data-reveal].is-revealed').forEach(el => el.classList.remove('is-revealed'));

    if (PRM()) {
      document.querySelectorAll('[data-reveal]').forEach(el => el.classList.add('is-revealed'));
      return;
    }
    const io = new IntersectionObserver((entries) => {
      entries.forEach(e => {
        if (e.isIntersecting) {
          e.target.classList.add('is-revealed');
          io.unobserve(e.target);
        }
      });
    }, { threshold: 0.15, rootMargin: '0px 0px -10% 0px' });
    const id = setTimeout(() => {
      document.querySelectorAll('[data-reveal]:not(.is-revealed)').forEach(el => io.observe(el));
    }, 50);
    return () => { clearTimeout(id); io.disconnect(); };
  }, [dep]);
};

/* ============================================================
   useScrollProgress — fractional 0..1 progress of an element
   through the viewport. 0 when entering bottom, 1 when leaving top.
   ============================================================ */
window.useScrollProgress = function useScrollProgress(ref, { start = 0, end = 1 } = {}) {
  const [p, setP] = useState(0);
  useEffect(() => {
    if (PRM()) { setP(1); return; }
    const update = () => {
      const el = ref.current; if (!el) return;
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight;
      // 0 when top of el hits bottom of viewport, 1 when bottom of el hits top of viewport.
      const total = r.height + vh;
      const passed = vh - r.top;
      const k = clamp(passed / total, 0, 1);
      // Re-map to start..end window
      const mapped = clamp((k - start) / (end - start), 0, 1);
      setP(mapped);
    };
    window.addEventListener('scroll', update, { passive: true });
    window.addEventListener('resize', update);
    update();
    return () => {
      window.removeEventListener('scroll', update);
      window.removeEventListener('resize', update);
    };
  }, [ref, start, end]);
  return p;
};

/* ============================================================
   ScanlineSweep — one-shot horizontal scanner line that crosses
   an element when it first enters view. Pure CSS overlay.
   Usage: <div style={{position:'relative'}}>
     <ScanlineSweep direction="horizontal" />
     ...content
   </div>
   ============================================================ */
window.ScanlineSweep = function ScanlineSweep({
  direction = 'vertical', // vertical = top→bottom, horizontal = left→right
  duration = 1400,
  color = '#B8DC73',
  thickness = 2,
  delay = 200,
}) {
  const ref = useRef(null);
  const [active, setActive] = useState(false);
  useEffect(() => {
    if (PRM()) return;
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver((entries) => {
      entries.forEach(e => {
        if (e.isIntersecting) {
          setTimeout(() => setActive(true), delay);
          io.unobserve(e.target);
        }
      });
    }, { threshold: 0.25 });
    io.observe(el);
    return () => io.disconnect();
  }, [delay]);

  const isV = direction === 'vertical';
  const lineStyle = isV ? {
    left: 0, right: 0, height: thickness, top: 0,
    transform: active ? 'translateY(100%)' : 'translateY(0%)',
    boxShadow: `0 0 12px ${color}, 0 0 24px ${color}80`,
  } : {
    top: 0, bottom: 0, width: thickness, left: 0,
    transform: active ? 'translateX(100%)' : 'translateX(0%)',
    boxShadow: `0 0 12px ${color}, 0 0 24px ${color}80`,
  };
  const wash = isV ? {
    left: 0, right: 0, top: 0, height: '100%',
    background: `linear-gradient(180deg, transparent 0%, ${color}26 50%, transparent 60%)`,
    transform: active ? 'translateY(100%)' : 'translateY(-100%)',
  } : {
    top: 0, bottom: 0, left: 0, width: '100%',
    background: `linear-gradient(90deg, transparent 0%, ${color}26 50%, transparent 60%)`,
    transform: active ? 'translateX(100%)' : 'translateX(-100%)',
  };
  return (
    <div ref={ref} aria-hidden="true" style={{ position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'hidden', zIndex: 4 }}>
      <div style={{ position: 'absolute', ...wash, transition: `transform ${duration}ms cubic-bezier(0.7, 0, 0.3, 1)` }} />
      <div style={{ position: 'absolute', background: color, ...lineStyle, transition: `transform ${duration}ms cubic-bezier(0.7, 0, 0.3, 1)` }} />
    </div>
  );
};

/* ============================================================
   LatchIn — wraps content. On enter, content fades from 0
   with a tiny upward shift, then "latches" in (no overshoot).
   Companion overlay: optional scanline that paints across.
   This is a lighter, faster, more "instrument-y" reveal than
   the default data-reveal — for hero charts, key numbers.
   ============================================================ */
window.LatchIn = function LatchIn({ children, scanline = true, delay = 0, direction = 'vertical', style = {} }) {
  const ref = useRef(null);
  const [shown, setShown] = useState(PRM());
  useEffect(() => {
    if (PRM()) return;
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver((entries) => {
      entries.forEach(e => {
        if (e.isIntersecting) {
          setTimeout(() => setShown(true), delay);
          io.unobserve(e.target);
        }
      });
    }, { threshold: 0.25 });
    io.observe(el);
    return () => io.disconnect();
  }, [delay]);
  return (
    <div ref={ref} style={{
      position: 'relative',
      opacity: shown ? 1 : 0,
      transform: shown ? 'translateY(0)' : 'translateY(8px)',
      filter: shown ? 'blur(0)' : 'blur(2px)',
      transition: 'opacity 360ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 360ms cubic-bezier(0.2, 0.8, 0.2, 1), filter 360ms cubic-bezier(0.2, 0.8, 0.2, 1)',
      ...style,
    }}>
      {children}
      {scanline && <window.ScanlineSweep direction={direction} delay={delay + 80} />}
    </div>
  );
};

/* ============================================================
   Counter — eased numeric tick when in view.
   ============================================================ */
window.Counter = function Counter({ to, duration = 1400, format = (n) => Math.round(n).toLocaleString(), suffix = '' }) {
  const ref = useRef(null);
  const [val, setVal] = useState(PRM() ? to : 0);
  useEffect(() => {
    if (PRM()) return;
    const el = ref.current;
    if (!el) return;
    let raf, started = false;
    const io = new IntersectionObserver((entries) => {
      entries.forEach(e => {
        if (e.isIntersecting && !started) {
          started = true;
          const t0 = performance.now();
          const tick = (t) => {
            const k = Math.min(1, (t - t0) / duration);
            const eased = 1 - Math.pow(1 - k, 3);
            setVal(to * eased);
            if (k < 1) raf = requestAnimationFrame(tick);
          };
          raf = requestAnimationFrame(tick);
          io.unobserve(el);
        }
      });
    }, { threshold: 0.4 });
    io.observe(el);
    return () => { io.disconnect(); cancelAnimationFrame(raf); };
  }, [to, duration]);
  return <span ref={ref}>{format(val)}{suffix}</span>;
};

/* ============================================================
   Odometer — rolling-digits effect. Each digit is a vertical
   strip of 0..9 that translates to land on the current value.
   `value` can be a number; we render its toLocaleString digits.
   Suffixes like "M" or "ha" should be siblings, not part of value.
   ============================================================ */
window.Odometer = function Odometer({ to, decimals = 0, duration = 1800, locale = 'en-ZA' }) {
  const ref = useRef(null);
  const [val, setVal] = useState(PRM() ? to : 0);
  useEffect(() => {
    if (PRM()) return;
    const el = ref.current;
    if (!el) return;
    let raf, started = false;
    const io = new IntersectionObserver((entries) => {
      entries.forEach(e => {
        if (e.isIntersecting && !started) {
          started = true;
          const t0 = performance.now();
          const tick = (t) => {
            const k = Math.min(1, (t - t0) / duration);
            // gentle ease-out cubic, no bounce — instruments don't bounce
            const eased = 1 - Math.pow(1 - k, 3);
            setVal(to * eased);
            if (k < 1) raf = requestAnimationFrame(tick);
          };
          raf = requestAnimationFrame(tick);
          io.unobserve(el);
        }
      });
    }, { threshold: 0.4 });
    io.observe(el);
    return () => { io.disconnect(); cancelAnimationFrame(raf); };
  }, [to, duration]);

  // Format with locale (handles thousands sep + decimals)
  const formatted = val.toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
  // For digit roll, we need a continuous fractional digit value.
  // Strategy: render each character. If digit → roll strip. If not → static.
  // Per-digit roll: for digit at position i, compute its "live" decimal value
  // by extracting the relevant portion of `val`.
  const targetStr = to.toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
  const chars = targetStr.split('');

  return (
    <span ref={ref} style={{ display: 'inline-flex', alignItems: 'baseline', verticalAlign: 'baseline' }}>
      {chars.map((ch, i) => {
        if (!/\d/.test(ch)) {
          return <span key={i}>{ch}</span>;
        }
        // Find this digit's place value in the *integer* + decimal target.
        // Count digits from end. We treat the formatted string position-by-position.
        // For roll: each digit represents (val / 10^place) mod 10.
        // Compute place by counting digits to the right of this one in target.
        let digitsRight = 0;
        for (let j = i + 1; j < chars.length; j++) if (/\d/.test(chars[j])) digitsRight++;
        // If decimals > 0, last `decimals` digits are after the decimal point.
        const place = digitsRight - decimals; // integer place if >=0, else fractional
        const scaled = val / Math.pow(10, place);
        const live = ((scaled % 10) + 10) % 10; // 0..10
        // Translate the strip
        const H = '1em';
        return (
          <span key={i} style={{
            display: 'inline-block', width: '0.62em', height: H, overflow: 'hidden',
            position: 'relative', verticalAlign: 'baseline',
            fontVariantNumeric: 'tabular-nums',
          }}>
            <span style={{
              display: 'block', position: 'absolute', left: 0, top: 0,
              transform: `translateY(${-live * 100}%)`,
              transition: 'none',
              willChange: 'transform',
            }}>
              {[0,1,2,3,4,5,6,7,8,9].map(d => (
                <span key={d} style={{ display: 'block', height: H, lineHeight: H, textAlign: 'center' }}>{d}</span>
              ))}
            </span>
          </span>
        );
      })}
    </span>
  );
};

/* ============================================================
   HeroStack — layered build of real block imagery.
   Sequence (~3.2s total):
     0.0s  Aerial photograph fades up (60% bright, slightly desaturated)
     0.7s  Soil EC layer fades in over it (multiply blend)
     1.4s  NDVI layer fades in (overlay blend)
     2.1s  Per-tree dots latch in with a left→right scanline
     2.6s  Telemetry labels & block ID lock on
   Holds in final state. On scroll, parallax-shifts gently.
   ============================================================ */
window.HeroStack = function HeroStack() {
  const wrapRef = useRef(null);
  const [phase, setPhase] = useState(PRM() ? 5 : 1);

  useEffect(() => {
    if (PRM()) return;
    const stages = [700, 700, 500, 400];
    let cancelled = false;
    let acc = 0;
    const timers = stages.map((d, i) => {
      acc += d;
      return setTimeout(() => { if (!cancelled) setPhase(i + 2); }, acc);
    });
    return () => { cancelled = true; timers.forEach(clearTimeout); };
  }, []);

  // Parallax on scroll
  const [scrollY, setScrollY] = useState(0);
  useEffect(() => {
    if (PRM()) return;
    const onScroll = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);
  const py = Math.min(60, scrollY * 0.12);

  // Tree dot positions — pseudo-random but stable
  const dots = React.useMemo(() => {
    const arr = [];
    let seed = 7;
    const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
    // Sparser, smaller dots — concentrated in lower-right quadrant so headline stays clean
    for (let r = 0; r < 9; r++) {
      for (let c = 0; c < 11; c++) {
        const jitterX = (rnd() - 0.5) * 0.8;
        const jitterY = (rnd() - 0.5) * 0.8;
        const x = 52 + c * 4.0 + jitterX;
        const y = 50 + r * 4.6 + jitterY;
        if (x < 50 || x > 96 || y < 48 || y > 92) continue;
        if (r > 6 && c > 8) continue;
        const size = 0.32 + rnd() * 0.22;
        const vigour = 0.4 + rnd() * 0.6;
        arr.push({ x, y, size, vigour, id: `${r}-${c}` });
      }
    }
    return arr;
  }, []);

  return (
    <div ref={wrapRef} aria-hidden="true" style={{
      position: 'absolute', inset: 0, overflow: 'hidden',
      background: '#0F1410',
    }}>
      {/* Layer 1 — RevScout S in the field, real footage */}
      <div data-img-file="assets/home-hero-field.mp4 (home hero video)" style={{position:'absolute', inset:0, pointerEvents:'none'}}></div>
      <video
        src="assets/home-hero-field.mp4"
        autoPlay muted loop playsInline preload="auto"
        ref={(el) => {
          if (!el) return;
          if (el.__io) { el.__io.disconnect(); el.__io = null; }
          el.playbackRate = 0.8;
          const io = new IntersectionObserver((entries) => {
            entries.forEach(en => { if (en.isIntersecting) el.play().catch(()=>{}); else el.pause(); });
          }, { threshold: 0.05 });
          io.observe(el);
          el.__io = io;
        }}
        poster="assets/revscout-field.png"
        aria-hidden="true"
        style={{
          position: 'absolute', inset: -20,
          width: 'calc(100% + 40px)', height: 'calc(100% + 40px)',
          objectFit: 'cover',
          opacity: phase >= 1 ? 1 : 0,
          filter: 'saturate(0.95) brightness(1) contrast(1.02)',
          transform: `translateY(${-py * 0.3}px) scale(1.04)`,
          transition: 'opacity 1100ms cubic-bezier(0.2, 0.8, 0.2, 1)',
          display: 'block',
        }}
      />

      {/* Vignette so headlines pop — left-side only, video stays bright on the right */}
      <div style={{
        position: 'absolute', inset: 0,
        background: 'linear-gradient(90deg, rgba(15,20,16,0.78) 0%, rgba(15,20,16,0.45) 30%, rgba(15,20,16,0) 55%, rgba(15,20,16,0) 100%)',
        pointerEvents: 'none', zIndex: 1,
      }} />
      <div style={{
        position: 'absolute', inset: 0,
        background: 'linear-gradient(180deg, rgba(15,20,16,0.25) 0%, rgba(15,20,16,0) 25%, rgba(15,20,16,0) 70%, rgba(15,20,16,0.45) 100%)',
        pointerEvents: 'none', zIndex: 1,
      }} />

      {/* Layer 2 — NDVI mosaic, very subtle in lower-right corner only */}
      <div style={{
        position: 'absolute', top: '55%', right: '-2%', width: '38%', height: '50%',
        backgroundImage: "url('assets/ndvi-mosaic.png')",
        backgroundSize: 'cover', backgroundPosition: 'center',
        opacity: phase >= 2 ? 0.14 : 0,
        mixBlendMode: 'screen',
        filter: 'saturate(1.2)',
        maskImage: 'radial-gradient(ellipse at 70% 70%, black 15%, transparent 70%)',
        WebkitMaskImage: 'radial-gradient(ellipse at 70% 70%, black 15%, transparent 70%)',
        transform: `translateY(${-py * 0.25}px) scale(1.04)`,
        transition: 'opacity 900ms cubic-bezier(0.2, 0.8, 0.2, 1)',
        zIndex: 2,
      }} />

      {/* Layer 3 — removed; was fighting the video */}

      {/* Scanline that paints across as dots latch in (phase 4) */}
      <div style={{
        position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 5,
        opacity: phase === 4 ? 1 : 0,
        transition: 'opacity 200ms',
      }}>
        <div style={{
          position: 'absolute', top: 0, bottom: 0, width: 2,
          left: phase >= 4 ? '100%' : '0%',
          background: '#B8DC73',
          boxShadow: '0 0 16px #B8DC73, 0 0 40px #B8DC7390',
          transition: 'left 1200ms cubic-bezier(0.65, 0, 0.35, 1)',
        }} />
        <div style={{
          position: 'absolute', top: 0, bottom: 0, left: 0,
          width: phase >= 4 ? '100%' : '0%',
          background: 'linear-gradient(90deg, transparent 0%, rgba(184,220,115,0.18) 80%, rgba(184,220,115,0.35) 100%)',
          transition: 'width 1200ms cubic-bezier(0.65, 0, 0.35, 1)',
        }} />
      </div>

      {/* Per-tree dots — SVG, latch in column-by-column once phase>=4 */}
      <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice" style={{
        position: 'absolute', inset: 0, width: '100%', height: '100%',
        zIndex: 4, transform: `translateY(${-py * 0.15}px)`,
      }}>
        {dots.map((d, i) => {
          const colDelay = (d.x / 100) * 1100; // ms
          const visible = phase >= 4;
          const fill = d.vigour > 0.7 ? '#CDEE85' : (d.vigour > 0.5 ? '#B8DC73' : '#7BA245');
          return (
            <circle
              key={d.id}
              cx={d.x} cy={d.y} r={visible ? d.size * 0.65 : 0}
              fill={fill}
              opacity={visible ? 0.45 : 0}
              style={{
                transition: `r 320ms cubic-bezier(0.2, 0.8, 0.2, 1) ${colDelay}ms, opacity 320ms ${colDelay}ms`,
              }}
            />
          );
        })}
      </svg>

      {/* Lock-on telemetry — appears in phase 5 */}
      <div className="hero-stack-locked" style={{
        position: 'absolute', zIndex: 6, top: '14%', right: '6%',
        opacity: phase >= 5 ? 1 : 0,
        transform: phase >= 5 ? 'translateX(0)' : 'translateX(8px)',
        transition: 'opacity 400ms, transform 400ms',
        fontFamily: 'JetBrains Mono, monospace',
        color: '#B8DC73', fontSize: 10, letterSpacing: '0.12em',
        background: 'rgba(15,20,16,0.6)', padding: '8px 12px',
        border: '1px solid rgba(184,220,115,0.25)', borderRadius: 4,
        backdropFilter: 'blur(4px)',
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
          <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#B8DC73', boxShadow: '0 0 8px #B8DC73' }} />
          BLOCK B-04 · LOCKED
        </div>
        <div style={{ opacity: 0.7, fontSize: 9 }}>33.92°S · 19.45°E</div>
        <div style={{ opacity: 0.7, fontSize: 9, marginTop: 4 }}>4 LAYERS · 1,824 TREES</div>
      </div>

      {/* Layer legend — bottom-right, out of the way of the headline + button */}
      <div style={{
        position: 'absolute', zIndex: 6, bottom: '4%', right: '6%',
        fontFamily: 'JetBrains Mono, monospace', fontSize: 9, letterSpacing: '0.1em',
        color: '#B8DC73', display: 'flex', flexDirection: 'row', gap: 18,
        background: 'rgba(15,20,16,0.4)', padding: '6px 12px',
        borderRadius: 4, backdropFilter: 'blur(2px)',
      }}>
        {[
          { label: 'FIELD',     p: 1, color: '#9DA89C' },
          { label: 'NDVI',      p: 2, color: '#6E9CC4' },
          { label: 'TREES',     p: 4, color: '#B8DC73' },
        ].map((l, i) => (
          <div key={i} style={{
            display: 'flex', alignItems: 'center', gap: 6,
            opacity: phase >= l.p ? 1 : 0.2,
            transition: 'opacity 300ms',
          }}>
            <span style={{
              width: 8, height: 2, background: l.color,
              boxShadow: phase >= l.p ? `0 0 6px ${l.color}` : 'none',
              transition: 'box-shadow 300ms',
            }} />
            <span style={{ color: phase >= l.p ? '#CDEE85' : 'rgba(184,220,115,0.4)' }}>{l.label}</span>
          </div>
        ))}
      </div>
    </div>
  );
};

/* Backwards-compat alias — pages-home references HeroTopo3D */
window.HeroTopo3D = function HeroTopo3D() { return <window.HeroStack />; };

/* ============================================================
   OrchardCube3D — replaces HubGlobe3D. A wireframe cube of
   orchard rows: dots arranged as a 3D grid (rows × trees × layers).
   A horizontal scan plane sweeps along one axis, lighting up
   dots as it passes. Reads as "we sweep your orchard, row by row."
   ============================================================ */
window.OrchardCube3D = function OrchardCube3D() {
  const canvasRef = useRef(null);
  useEffect(() => {
    if (PRM() || !window.THREE) return;
    const THREE = window.THREE;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100);
    camera.position.set(4.6, 3.2, 4.6);
    camera.lookAt(0, 0, 0);

    // Wireframe cube outline
    const cubeSize = 3.2;
    const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    const cubeWire = new THREE.LineSegments(
      new THREE.EdgesGeometry(cubeGeo),
      new THREE.LineBasicMaterial({ color: 0xB8DC73, transparent: true, opacity: 0.45 })
    );
    scene.add(cubeWire);

    // Internal grid dots — rows × cols × depth, like trees in a block
    const ROWS = 7, COLS = 9, LAYERS = 4;
    const total = ROWS * COLS * LAYERS;
    const dotGeo = new THREE.BufferGeometry();
    const dotPos = new Float32Array(total * 3);
    const dotCol = new Float32Array(total * 3);
    const baseColor = new THREE.Color(0x4a6a3a); // dim
    let idx = 0;
    for (let r = 0; r < ROWS; r++) {
      for (let c = 0; c < COLS; c++) {
        for (let l = 0; l < LAYERS; l++) {
          const x = (c / (COLS - 1) - 0.5) * cubeSize * 0.86;
          const y = (l / (LAYERS - 1) - 0.5) * cubeSize * 0.86;
          const z = (r / (ROWS - 1) - 0.5) * cubeSize * 0.86;
          dotPos[idx*3] = x; dotPos[idx*3+1] = y; dotPos[idx*3+2] = z;
          dotCol[idx*3] = baseColor.r; dotCol[idx*3+1] = baseColor.g; dotCol[idx*3+2] = baseColor.b;
          idx++;
        }
      }
    }
    dotGeo.setAttribute('position', new THREE.BufferAttribute(dotPos, 3));
    dotGeo.setAttribute('color', new THREE.BufferAttribute(dotCol, 3));
    const dotMat = new THREE.PointsMaterial({
      size: 0.085, transparent: true, opacity: 0.95,
      sizeAttenuation: true, vertexColors: true,
    });
    const dots = new THREE.Points(dotGeo, dotMat);
    scene.add(dots);

    // Scan plane — translucent quad that moves along Z axis
    const planeGeo = new THREE.PlaneGeometry(cubeSize * 0.92, cubeSize * 0.92);
    planeGeo.rotateY(Math.PI / 2);
    const planeMat = new THREE.MeshBasicMaterial({
      color: 0xB8DC73, transparent: true, opacity: 0.18, side: THREE.DoubleSide,
    });
    const scanPlane = new THREE.Mesh(planeGeo, planeMat);
    scene.add(scanPlane);

    // Bright leading edge
    const edgeGeo = new THREE.BufferGeometry();
    const edgePos = new Float32Array([
      0, -cubeSize/2, 0,  0, cubeSize/2, 0,
      0, -cubeSize/2, 0,  0, -cubeSize/2, 0, // placeholder
    ]);
    // Simpler: a line loop forming the rectangle of the scan plane
    const halfSize = cubeSize * 0.46;
    const rectPts = [
      new THREE.Vector3(0, -halfSize, -halfSize),
      new THREE.Vector3(0,  halfSize, -halfSize),
      new THREE.Vector3(0,  halfSize,  halfSize),
      new THREE.Vector3(0, -halfSize,  halfSize),
      new THREE.Vector3(0, -halfSize, -halfSize),
    ];
    const edgeGeoLine = new THREE.BufferGeometry().setFromPoints(rectPts);
    const edgeMat = new THREE.LineBasicMaterial({ color: 0xCDEE85, transparent: true, opacity: 0.95 });
    const scanEdge = new THREE.Line(edgeGeoLine, edgeMat);
    scene.add(scanEdge);

    const resize = () => {
      const w = canvas.clientWidth, h = canvas.clientHeight;
      renderer.setSize(w, h, false);
      camera.aspect = w / h;
      camera.updateProjectionMatrix();
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(canvas);

    const colArr = dotGeo.attributes.color.array;
    const litColor = new THREE.Color(0xCDEE85);
    const passColor = new THREE.Color(0xB8DC73);
    const dimColor = new THREE.Color(0x4a6a3a);

    let raf;
    const t0 = performance.now();
    const loop = (t) => {
      const dt = (t - t0) / 1000;
      // Slow yaw of whole scene for depth (subtle, not a rotating-model)
      cubeWire.rotation.y = dt * 0.08;
      dots.rotation.y = dt * 0.08;
      scanPlane.rotation.y = dt * 0.08;
      scanEdge.rotation.y = dt * 0.08;

      // Scan plane sweeps along x axis (within rotated frame),
      // period 4s, ping-pong with linger at edges
      const period = 4.4;
      const phase = (dt % period) / period; // 0..1
      // Triangle wave 0..1..0
      const tri = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
      const eased = easeInOutQuad(tri);
      const sx = (eased - 0.5) * cubeSize * 0.95;
      scanPlane.position.x = sx;
      scanEdge.position.x = sx;

      // Update dot colors based on distance to scan plane (in local x)
      // dotPos is constant in local space — we compare local x.
      for (let i = 0; i < total; i++) {
        const lx = dotPos[i*3];
        const d = Math.abs(lx - sx);
        let c;
        if (d < 0.15) {
          c = litColor; // active
        } else if (lx < sx - 0.0001) {
          // already passed — bright but not as hot
          c = passColor;
        } else {
          c = dimColor;
        }
        colArr[i*3] = c.r; colArr[i*3+1] = c.g; colArr[i*3+2] = c.b;
      }
      dotGeo.attributes.color.needsUpdate = true;

      renderer.render(scene, camera);
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);

    function easeInOutQuad(k){ return k < 0.5 ? 2*k*k : 1 - Math.pow(-2*k+2,2)/2; }

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      renderer.dispose();
      cubeGeo.dispose(); dotGeo.dispose(); planeGeo.dispose(); edgeGeoLine.dispose();
    };
  }, []);

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

/* ============================================================
   Backwards-compat alias for HubGlobe3D — now points at OrchardCube3D
   ============================================================ */
window.HubGlobe3D = function HubGlobe3D() { return <window.OrchardCube3D />; };

/* ============================================================
   TerrainOrb — calm wireframe terrain that loops slowly behind UI.
   ============================================================ */
window.TerrainOrb = function TerrainOrb() {
  const canvasRef = useRef(null);
  useEffect(() => {
    if (PRM() || !window.THREE) return;
    const THREE = window.THREE;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 200);
    camera.position.set(0, 18, 30); camera.lookAt(0, 0, 0);
    const SIZE = 80, SEG = 50;
    const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEG, SEG);
    geo.rotateX(-Math.PI / 2);
    const pos = geo.attributes.position;
    const n2 = (x, z) => Math.sin(x*0.18)*Math.cos(z*0.14)*2.6 + Math.sin(x*0.34+z*0.2)*0.9 + Math.cos(z*0.42)*0.5;
    for (let i = 0; i < pos.count; i++) pos.setY(i, n2(pos.getX(i), pos.getZ(i)));
    geo.computeVertexNormals();
    const wireMat = new THREE.LineBasicMaterial({ color: 0xB8DC73, transparent: true, opacity: 0.45 });
    const wire = new THREE.LineSegments(new THREE.WireframeGeometry(geo), wireMat);
    scene.add(wire);
    const fill = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: 0x14211A, transparent: true, opacity: 0.85 }));
    fill.position.y = -0.15; scene.add(fill);
    const resize = () => {
      const w = canvas.clientWidth, h = canvas.clientHeight;
      renderer.setSize(w, h, false); camera.aspect = w/h; camera.updateProjectionMatrix();
    };
    resize();
    const ro = new ResizeObserver(resize); ro.observe(canvas);
    let raf; const t0 = performance.now();
    const loop = (t) => {
      const s = (t - t0) / 1000;
      wire.rotation.y = s * 0.06; fill.rotation.y = s * 0.06;
      renderer.render(scene, camera);
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => { cancelAnimationFrame(raf); ro.disconnect(); renderer.dispose(); geo.dispose(); wireMat.dispose(); };
  }, []);
  return <canvas ref={canvasRef} aria-hidden="true" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }} />;
};

/* ============================================================
   AmbientParticles — no-op stub (component removed for performance)
   ============================================================ */
window.AmbientParticles = function AmbientParticles() { return null; };

/* ============================================================
   ScrollProgress — slim top bar.
   ============================================================ */
window.ScrollProgress = function ScrollProgress() {
  const ref = useRef(null);
  useEffect(() => {
    const onScroll = () => {
      const el = ref.current;
      if (!el) return;
      const h = document.documentElement.scrollHeight - window.innerHeight;
      const k = h > 0 ? window.scrollY / h : 0;
      el.style.transform = `scaleX(${k})`;
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener('scroll', onScroll);
  }, []);
  return <div ref={ref} style={{
    position: 'fixed', top: 0, left: 0, right: 0, height: 2,
    background: 'var(--lime)', transformOrigin: '0 0',
    transform: 'scaleX(0)', zIndex: 100, pointerEvents: 'none',
    opacity: 1,
    transition: 'transform 80ms linear',
  }} aria-hidden="true" />;
};

/* ============================================================
   RowSweepDiagram — for the RevScout S page.
   Stylised top-down view of orchard rows being swept by the
   camera. Trees light up as the scan line passes. Pure SVG,
   GPU-friendly, scrubbable on intersection.
   ============================================================ */
window.RowSweepDiagram = function RowSweepDiagram({ height = 360 }) {
  const ref = useRef(null);
  const [t, setT] = useState(0); // 0..1 sweep progress
  const [running, setRunning] = useState(false);

  useEffect(() => {
    if (PRM()) { setT(1); return; }
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver((entries) => {
      entries.forEach(e => {
        if (e.isIntersecting) setRunning(true);
        else setRunning(false);
      });
    }, { threshold: 0.3 });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  useEffect(() => {
    if (!running || PRM()) return;
    let raf, t0 = performance.now();
    const period = 5200;
    const tick = (now) => {
      const k = ((now - t0) % period) / period;
      setT(k);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [running]);

  // Layout: 8 rows of 14 trees; ATV moves left→right along the bottom track
  const ROWS = 8, COLS = 14;
  const W = 1200, H = 480;
  const padX = 80, padY = 80;
  const colStep = (W - padX*2) / (COLS - 1);
  const rowStep = (H - padY*2 - 60) / (ROWS - 1); // leave space at bottom for ATV
  const atvY = H - 40;

  // Sweep advances along x. atvX in [padX, W - padX].
  const atvX = padX + (W - padX*2) * t;
  // Camera "cone" — narrow forward-pointing wedge from ATV up into rows
  const coneHalfWidth = 60;

  // For each tree, compute its lit state based on whether scan x has passed it
  // (with a slight per-row offset so we read the diagonal sweep)
  return (
    <div ref={ref} style={{ width: '100%', height, position: 'relative' }}>
      <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet" style={{ width: '100%', height: '100%', display: 'block' }} aria-label="RevScout S sweeping orchard rows">
        <defs>
          <linearGradient id="rsd-cone" x1="0" y1="1" x2="0" y2="0">
            <stop offset="0%" stopColor="#B8DC73" stopOpacity="0.55" />
            <stop offset="100%" stopColor="#B8DC73" stopOpacity="0" />
          </linearGradient>
          <radialGradient id="rsd-tree-lit" cx="0.5" cy="0.5" r="0.5">
            <stop offset="0%" stopColor="#CDEE85" stopOpacity="1" />
            <stop offset="60%" stopColor="#B8DC73" stopOpacity="0.9" />
            <stop offset="100%" stopColor="#B8DC73" stopOpacity="0" />
          </radialGradient>
        </defs>

        {/* Faint ground grid */}
        <g stroke="#B8DC73" strokeWidth="0.5" opacity="0.12">
          {Array.from({ length: 14 }, (_, i) => (
            <line key={'gx'+i} x1={0} y1={(H/14)*i} x2={W} y2={(H/14)*i} />
          ))}
          {Array.from({ length: 24 }, (_, i) => (
            <line key={'gy'+i} x1={(W/24)*i} y1={0} x2={(W/24)*i} y2={H} />
          ))}
        </g>

        {/* Row guides */}
        <g>
          {Array.from({ length: ROWS }, (_, r) => {
            const y = padY + r * rowStep;
            return <line key={'r'+r} x1={padX} y1={y} x2={W - padX} y2={y} stroke="#345030" strokeWidth="1" strokeDasharray="2 6" opacity="0.8" />;
          })}
        </g>

        {/* Camera cone */}
        <path
          d={`M ${atvX} ${atvY} L ${atvX - coneHalfWidth} ${padY - 10} L ${atvX + coneHalfWidth} ${padY - 10} Z`}
          fill="url(#rsd-cone)"
        />

        {/* Trees */}
        <g>
          {Array.from({ length: ROWS }).map((_, r) =>
            Array.from({ length: COLS }).map((_, c) => {
              const tx = padX + c * colStep;
              const ty = padY + r * rowStep;
              // Lit if atvX has passed tx (with small per-row delay so it reads as a diagonal sweep front)
              const rowOffset = r * 12; // px lag per row going up
              const lit = atvX >= (tx + rowOffset);
              const inCone = Math.abs(tx - atvX) < coneHalfWidth * (ty - atvY) / (padY - 10 - atvY);
              const active = inCone && !lit;
              return (
                <g key={`t-${r}-${c}`}>
                  {lit && <circle cx={tx} cy={ty} r="11" fill="url(#rsd-tree-lit)" opacity="0.5" />}
                  <circle
                    cx={tx} cy={ty}
                    r={active ? 5 : (lit ? 4 : 3)}
                    fill={active ? '#CDEE85' : (lit ? '#B8DC73' : '#3A4D32')}
                    stroke={active ? '#CDEE85' : 'none'}
                    strokeWidth={active ? 2 : 0}
                    style={{ transition: 'r 200ms, fill 200ms' }}
                  />
                </g>
              );
            })
          )}
        </g>

        {/* ATV / RevScout S marker */}
        <g transform={`translate(${atvX}, ${atvY})`}>
          <rect x="-22" y="-14" width="44" height="20" rx="4" fill="#0F1410" stroke="#B8DC73" strokeWidth="1.5" />
          <circle cx="-13" cy="9" r="4" fill="#0F1410" stroke="#B8DC73" strokeWidth="1.2" />
          <circle cx="13" cy="9" r="4" fill="#0F1410" stroke="#B8DC73" strokeWidth="1.2" />
          {/* lens */}
          <circle cx="0" cy="-4" r="3" fill="#CDEE85" />
          <text x="0" y="-22" fontFamily="JetBrains Mono, monospace" fontSize="10" fill="#B8DC73" textAnchor="middle" letterSpacing="1.5">REVSCOUT S</text>
        </g>

        {/* Counter readouts — top-left and top-right */}
        <g fontFamily="JetBrains Mono, monospace" fontSize="11" fill="#B8DC73" letterSpacing="1.5">
          <text x="40" y="40">TREES SCANNED</text>
          <text x="40" y="58" fontSize="22" fill="#CDEE85">{Math.round(t * ROWS * COLS).toString().padStart(3, '0')} / {ROWS * COLS}</text>

          <text x={W - 40} y="40" textAnchor="end">FRUIT COUNTED</text>
          <text x={W - 40} y="58" fontSize="22" fill="#CDEE85" textAnchor="end">{Math.round(t * 38420).toLocaleString()}</text>
        </g>

        {/* Calibration tick: blink every 1.0 in t */}
        <g opacity={(t * 4) % 1 < 0.15 ? 1 : 0} style={{ transition: 'opacity 80ms' }}>
          <text x={W/2} y={H - 12} fontFamily="JetBrains Mono, monospace" fontSize="10" fill="#B8DC73" textAnchor="middle" letterSpacing="2">⟨ CALIBRATING — REVFIELD GROUND-TRUTH ⟩</text>
        </g>
      </svg>
    </div>
  );
};
