/* Causal-flow animation — clean, story-driven, six stages + logo morph.

   STAGE FLOW:
     S1   0  →  s1   ONE REVIEW — sentence fades in, centered.
     S2   s1 → s2   DECOMPOSE — words highlight in the sentence; then each
                    highlighted phrase travels to its destination bubble:
                    "rude" → bottom-left navy cause bubble,
                    "long wait" → top-left navy cause bubble,
                    "wont come back" → right cyan effect bubble.
                    Each bubble grows from zero as its traveller lands.
                    Bubbles are OUTLINED (paper fill, brand-color stroke,
                    brand-color text) so the labels stay readable.
                    No tree yet — just LEFT (causes) / RIGHT (effect).
     S3   s2 → s3   MULTIPLICITY FLASH — small dots fly into the bubbles
                    briefly, then fade. Conveys "now do this for millions".
     S4   s3 → s4   TIMELINE — bubbles shrink onto a horizontal axis with
                    quarters Q1 2020 → Q2 2021. Effects row above, causes
                    below.
     S5   s4 → s5   METHODOLOGY — time-lag arrows curve from cause-quarters
                    to later effect-quarters. A small DAG card flashes to
                    hint at confounder isolation.
     S6   s5 → s6   TREE + LOGO MORPH — bubbles converge to a top-down
                    tree (effect on top, two causes below). Background
                    context bubbles sit on the SAME Y as the main bubbles,
                    spread horizontally. Y-shape lines connect the causes
                    to the effect; once the highlighted ATE callout lands,
                    a third bottom line connects the two causes — the
                    triangle is now the GenData logo. Bubbles transition
                    from outlined to filled, scale down to logo size, and
                    "GenData" wordmark fades in next to it.

   Brand palette: cyan #22D3EE (effect, top), navy #1D4ED8 (causes, bottom).
   Reduced motion: lands near the end of Stage 6 (logo visible).
   Stages prop: [s1, s2, s3, s4, s5, s6, total].
*/
const { useEffect: hUseEffect, useRef: hUseRef, useState: hUseState } = React;

const REVIEW_TEXT = "Service guy was super rude and gave me a long wait. Wont come back to this service.";

// Brand-aligned colours — exactly matching the GenData logo in icons.jsx.
const NAVY      = '#1D4ED8';   // logo bottom-left + bottom-right circles
const NAVY_DEEP = '#1538A3';
const NAVY_LINE = '#1E3A8A';   // logo connecting lines (slightly deeper)
const CYAN      = '#22D3EE';   // logo top circle
const CYAN_DEEP = '#0E96AE';

// Highlight ranges in REVIEW_TEXT
const HL = {
  rude:   { start: 22, end: 26, color: NAVY },
  wait:   { start: 41, end: 50, color: NAVY },
  effect: { start: 52, end: 66, color: CYAN },
};

// Stage-3 micro snippets — short phrases extracted from "many other reviews".
// Each one flies in from a canvas edge to its assigned bubble, conveying
// the SAME extraction process (review → cause/effect) repeated millions of
// times. `target` is one of: 'effect', 'causeL', 'causeR', or `bgEffect[i]`,
// `bgCause[i]` referencing the bg-bubble arrays.
const MICRO_S3 = [
  { text: 'long wait',       target: 'causeL'     },
  { text: 'two hours',       target: 'causeL'     },
  { text: 'kept waiting',    target: 'causeL'     },
  { text: 'queue forever',   target: 'causeL'     },
  { text: 'rude',            target: 'causeR'     },
  { text: 'tech rude',       target: 'causeR'     },
  { text: 'no callback',     target: 'causeR'     },
  { text: 'broken parts',    target: 'bgCause0'   },
  { text: 'failed repair',   target: 'bgCause0'   },
  { text: 'no loaner',       target: 'bgCause1'   },
  { text: 'rip-off',         target: 'bgCause3'   },
  { text: 'hidden fees',     target: 'bgCause3'   },
  { text: 'tech skill',      target: 'bgCause4'   },
  { text: 'wont come back',  target: 'effect'     },
  { text: 'switching',       target: 'effect'     },
  { text: 'never again',     target: 'effect'     },
  { text: 'cancelling',      target: 'effect'     },
  { text: '1 star',          target: 'bgEffect1'  },
  { text: 'see you next yr', target: 'bgEffect1'  },
  { text: 'lost faith',      target: 'bgEffect3'  },
  { text: 'trust gone',      target: 'bgEffect3'  },
];

function easeStd(t) {
  if (t <= 0) return 0;
  if (t >= 1) return 1;
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
const range01 = (v, a, b) => (b <= a ? 0 : Math.max(0, Math.min(1, (v - a) / (b - a))));
const lerp    = (a, b, t) => a + (b - a) * t;

function HeroAnimation({ totalDuration = 45, stages, paused = false, className = '' }) {
  const ends = stages && stages.length >= 7 ? stages : [4, 11, 17, 26, 34, 45, 45];
  const [s1, s2, s3, s4, s5, s6] = ends;

  const [t, setT] = hUseState(0);
  const rafRef   = hUseRef(0);
  const startRef = hUseRef(typeof performance !== 'undefined' ? performance.now() : 0);
  const reduced  = typeof window !== 'undefined'
    && window.matchMedia
    && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  hUseEffect(() => {
    if (paused || reduced) return;
    let mounted = true;
    startRef.current = performance.now();
    const tick = (now) => {
      if (!mounted) return;
      let elapsed = (now - startRef.current) / 1000;
      if (elapsed >= totalDuration) {
        startRef.current = now;
        elapsed = 0;
      }
      setT(elapsed);
      rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => { mounted = false; cancelAnimationFrame(rafRef.current); };
  }, [paused, reduced, totalDuration]);

  const tt = reduced ? lerp(s5, s6, 0.55) : t;

  // ── Layout ──────────────────────────────────────────────────────────
  const W = 1200, H = 600;

  // Stage-2 left/right positions — sentence sits high (y≈100), bubbles
  // get the lower half so they don't crowd the typed sentence.
  const s2EffectPos = { x: W * 0.72, y: 395, r: 60 };
  const s2CauseTop  = { x: W * 0.28, y: 320, r: 46 };  // Long Wait
  const s2CauseBot  = { x: W * 0.28, y: 470, r: 46 };  // Rude

  // Stage-6 tree positions (top-down, like the logo)
  const treeEffect = { x: W/2,        y: 165, r: 64 };
  const treeCauseL = { x: W/2 - 235,  y: 425, r: 50 };  // Long Wait
  const treeCauseR = { x: W/2 + 235,  y: 425, r: 50 };  // Rude

  // Stage-4 timeline positions
  const tlAxisY      = H/2 + 65;
  const tlEffectRowY = tlAxisY - 60;
  const tlCauseRowY  = tlAxisY + 60;
  const xStart = 175;
  const xEnd   = W - 175;
  const xRange = xEnd - xStart;
  const QUARTERS = ['Q1 2020', 'Q2 2020', 'Q3 2020', 'Q4 2020', 'Q1 2021', 'Q2 2021'];
  const quarterX = (i) => xStart + (i + 0.5) / QUARTERS.length * xRange;

  // Logo morph targets — matches icons.jsx Logo component exactly.
  // Original viewBox 0 0 44 44. Vertices at (22,9), (9,34), (35,34), r=6,
  // strokeWidth 2.2. Center of triangle ≈ (22, 22).
  // Scale chosen so the morphed logo sits prominently at canvas center.
  const LOGO_SIZE  = 110;             // overall logo footprint in px
  const LOGO_SCALE = LOGO_SIZE / 44;
  // Center the WHOLE composition (wordmark + gap + triangle) around W/2.
  // Estimated wordmark width at fontSize 92, weight 300, letter-spacing
  // -0.025: ~285px for "GenData". Gap 26, triangle width 65.
  const _wordmarkW = 285;
  const _gap = 26;
  const _triW = (35 - 9) * LOGO_SCALE;        // 65 at scale 2.5
  const _totalW = _wordmarkW + _gap + _triW;
  const _logoLeftEdge = W/2 - _totalW / 2;
  const _triLeftEdge  = _logoLeftEdge + _wordmarkW + _gap;
  const logoCenter = { x: _triLeftEdge + _triW / 2, y: H * 0.50 };
  const logoEffect = { x: logoCenter.x + (22 - 22) * LOGO_SCALE, y: logoCenter.y + (9  - 22) * LOGO_SCALE, r: 6 * LOGO_SCALE };
  const logoCauseL = { x: logoCenter.x + (9  - 22) * LOGO_SCALE, y: logoCenter.y + (34 - 22) * LOGO_SCALE, r: 6 * LOGO_SCALE };
  const logoCauseR = { x: logoCenter.x + (35 - 22) * LOGO_SCALE, y: logoCenter.y + (34 - 22) * LOGO_SCALE, r: 6 * LOGO_SCALE };
  const logoLineW  = 1.6 * LOGO_SCALE; // thinner, more elegant

  // ── Stage progress ──────────────────────────────────────────────────
  const reviewIn   = easeStd(range01(tt, 0,        s1 - 0.3));

  // Stage 2 sub-phases — sequential travelers, each gets 1.6s of clear
  // visibility so the eye can actually track which phrase goes where.
  const s2Span     = s2 - s1;
  const highlightP = easeStd(range01(tt, s1 - 0.1, s1 + 0.6));
  const trav = (idx) => {
    const startOffset = 0.8 + idx * 1.75;
    const endOffset   = startOffset + 1.55;
    return easeStd(range01(tt, s1 + startOffset, s1 + endOffset));
  };
  const travRude   = trav(0);  // 0.8s → 2.35s after s1
  const travWait   = trav(1);  // 2.55s → 4.10s after s1
  const travEff    = trav(2);  // 4.30s → 5.85s after s1

  const flashP     = easeStd(range01(tt, s2 + 0.3, s2 + (s3 - s2) * 0.45));
  const flashOut   = easeStd(range01(tt, s3 - 0.6, s3 - 0.05));
  const flashOpacity = flashP * (1 - flashOut);
  const tlInP      = easeStd(range01(tt, s3,       s3 + (s4 - s3) * 0.55));
  const tlOutP     = easeStd(range01(tt, s5 + (s6 - s5) * 0.05, s5 + (s6 - s5) * 0.30));
  const tlOpacity  = tlInP * (1 - tlOutP);
  const lagP       = easeStd(range01(tt, s4,       s4 + (s5 - s4) * 0.55));
  const dagFlashP  = easeStd(range01(tt, s4 + (s5 - s4) * 0.55, s4 + (s5 - s4) * 0.7))
                   * (1 - easeStd(range01(tt, s4 + (s5 - s4) * 0.85, s5 - 0.05)));

  // Stage 6 sub-phases — extended hold so the highlighted-path callout
  // stays visible long enough to read before the logo morph kicks in.
  const s6Span     = s6 - s5;
  const treeBackP  = easeStd(range01(tt, s5 + s6Span * 0.05, s5 + s6Span * 0.25));
  const contextP   = easeStd(range01(tt, s5 + s6Span * 0.20, s5 + s6Span * 0.40));
  const highlightStageP  = easeStd(range01(tt, s5 + s6Span * 0.28, s5 + s6Span * 0.45));
  const logoMorphP = easeStd(range01(tt, s5 + s6Span * 0.78, s5 + s6Span * 0.95));

  // ── Sentence visibility ─────────────────────────────────────────────
  // Sentence sits near the top (y=100) so the bubbles below have ample
  // breathing room. Fades out as Stage 3 begins.
  const sentenceY = 100;
  const sentenceFade  = easeStd(range01(tt, s2 + (s3 - s2) * 0.05, s2 + (s3 - s2) * 0.45));
  const sentenceOpacity = (1 - sentenceFade);

  // ── Sentence char-position approximation for travelers ───────────────
  // Sentence is 84 chars, fontSize 24, avg char width ~10.5px (Geist 500).
  const SENT_CHAR_W = 10.5;
  const sentenceVisualWidth = REVIEW_TEXT.length * SENT_CHAR_W;
  const sentenceLeftEdge = W/2 - sentenceVisualWidth / 2;
  const phraseCenterX = (start, end) => sentenceLeftEdge + ((start + end) / 2) * SENT_CHAR_W;

  const phrasePos = {
    rude:   { x: phraseCenterX(HL.rude.start, HL.rude.end),     y: sentenceY },
    wait:   { x: phraseCenterX(HL.wait.start, HL.wait.end),     y: sentenceY },
    effect: { x: phraseCenterX(HL.effect.start, HL.effect.end), y: sentenceY },
  };

  // ── Bubble positions per phase ──────────────────────────────────────
  // Three bubbles morph through stages: arrival (S2 traveler) → flash hold
  // → timeline → tree-rebuild → logo-morph.
  function bubblePosition(s2Pos, treePos, logoPos, isEffect, role, travProgress) {
    const tlIndex = 2; // Q3 2020 — example quarter
    const tlX = quarterX(tlIndex);
    const tlYpos = isEffect ? tlEffectRowY : tlCauseRowY;
    const tlR = isEffect ? 14 : 12;

    if (tt < s2) {
      // S2: bubble grows from r=0 to s2Pos.r as traveler arrives
      return {
        x: s2Pos.x, y: s2Pos.y,
        r: s2Pos.r * travProgress,
        phase: 'split',
      };
    }
    if (tt < s3) {
      return { x: s2Pos.x, y: s2Pos.y, r: s2Pos.r, phase: 's2-hold' };
    }

    const migrateP = easeStd(range01(tt, s3 + 0.2, s3 + (s4 - s3) * 0.7));
    if (tt < s4) {
      return {
        x: lerp(s2Pos.x, tlX, migrateP),
        y: lerp(s2Pos.y, tlYpos, migrateP),
        r: lerp(s2Pos.r, tlR, migrateP),
        phase: 'to-timeline',
      };
    }
    if (tt < s5) {
      return { x: tlX, y: tlYpos, r: tlR, phase: 'timeline' };
    }

    // S6
    const treeReturnX = lerp(tlX, treePos.x, treeBackP);
    const treeReturnY = lerp(tlYpos, treePos.y, treeBackP);
    const treeReturnR = lerp(tlR, treePos.r, treeBackP);

    if (logoMorphP < 0.02) {
      return { x: treeReturnX, y: treeReturnY, r: treeReturnR, phase: 'tree' };
    }

    return {
      x: lerp(treePos.x, logoPos.x, logoMorphP),
      y: lerp(treePos.y, logoPos.y, logoMorphP),
      r: lerp(treePos.r, logoPos.r, logoMorphP),
      phase: 'logo',
    };
  }

  const effectBubble = bubblePosition(s2EffectPos, treeEffect, logoEffect, true,  'effect', travEff);
  const causeLBubble = bubblePosition(s2CauseTop,  treeCauseL, logoCauseL, false, 'causeL', travWait);  // Long Wait
  const causeRBubble = bubblePosition(s2CauseBot,  treeCauseR, logoCauseR, false, 'causeR', travRude);  // Rude

  // ── Sentence rendering helpers ──────────────────────────────────────
  const T = REVIEW_TEXT;
  const segments = [
    { txt: T.slice(0, HL.rude.start) },
    { txt: T.slice(HL.rude.start, HL.rude.end), color: HL.rude.color, key: 'rude' },
    { txt: T.slice(HL.rude.end, HL.wait.start) },
    { txt: T.slice(HL.wait.start, HL.wait.end), color: HL.wait.color, key: 'wait' },
    { txt: T.slice(HL.wait.end, HL.effect.start) },
    { txt: T.slice(HL.effect.start, HL.effect.end), color: HL.effect.color, key: 'effect' },
    { txt: T.slice(HL.effect.end) },
  ];

  // Background bubbles — represent the broader landscape of additional
  // causes/effects beyond the example. They first APPEAR around the three
  // main bubbles during Stage 3 ("now do it a million times"), conveying
  // that there are many more drivers out there. Then in Stage 4 they fly
  // to timeline quarter positions. Then in Stage 6 they fly to tree
  // positions. Same bubbles travel through all three phases.
  const bgEffects = [
    { tlIdx: 0, treeX: 130,        label: null,              initDx: -78, initDy: -50 },
    { tlIdx: 1, treeX: 290,        label: 'Repeat Purchase', initDx:  68, initDy: -38 },
    { tlIdx: 3, treeX: 470,        label: null,              initDx: -52, initDy:  46 },
    { tlIdx: 4, treeX: W - 290,    label: 'Brand Trust',     initDx:  72, initDy:  38 },
    { tlIdx: 5, treeX: W - 130,    label: null,              initDx:   0, initDy: -78 },
  ];
  const bgCauses = [
    { tlIdx: 0, treeX: 95,         label: 'Repair Quality', initAround: 'top', initDx: -68, initDy: -34 },
    { tlIdx: 1, treeX: 235,        label: 'Loaner',          initAround: 'top', initDx:  62, initDy:  24 },
    { tlIdx: 3, treeX: 605,        label: null,              initAround: 'bot', initDx: -54, initDy:  32 },
    { tlIdx: 4, treeX: W - 235,    label: 'Pricing',         initAround: 'top', initDx:  42, initDy: -54 },
    { tlIdx: 5, treeX: W - 95,     label: 'Tech Skill',      initAround: 'bot', initDx:  68, initDy: -16 },
  ];

  // Tree connecting lines (S6)
  const treeLineFadeIn = easeStd(range01(tt, s5 + s6Span * 0.20, s5 + s6Span * 0.45));
  // Bottom (third) line — shows up only when the logo morph begins, completing the triangle
  const bottomLineP    = easeStd(range01(tt, s5 + s6Span * 0.55, s5 + s6Span * 0.80));

  // ── Render ──────────────────────────────────────────────────────────
  return (
    <div className={`absolute inset-0 ${className}`} aria-hidden="true">
      <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet"
           className="w-full h-full">
        <defs>
          <filter id="bubble-shadow" x="-30%" y="-30%" width="160%" height="160%">
            <feGaussianBlur in="SourceAlpha" stdDeviation="2.5"/>
            <feOffset dx="0" dy="2" result="offsetblur"/>
            <feFlood floodColor="#000" floodOpacity="0.10"/>
            <feComposite in2="offsetblur" operator="in"/>
            <feMerge>
              <feMergeNode/>
              <feMergeNode in="SourceGraphic"/>
            </feMerge>
          </filter>
          <marker id="arrow-cyan" viewBox="0 0 10 10" refX="9.5" refY="5"
            markerWidth="3.6" markerHeight="3.6" orient="auto">
            <path d="M 0 0 L 10 5 L 0 10 z" fill={CYAN}/>
          </marker>
        </defs>

        {/* Tech-stack hint, top-right */}
        <text x={W - 24} y={26} textAnchor="end"
          fontSize="9" fontFamily="Geist Mono, ui-monospace"
          opacity="0.4"
          style={{ fill: 'var(--mute)', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
          DAG-NLP · DOWHY · CI₉₅
        </text>

        {/* ── Review sentence ── */}
        {tt > -0.1 && (
          <foreignObject
            x={0}
            y={sentenceY - 30}
            width={W}
            height={60}
            style={{ overflow: 'visible', opacity: reviewIn * sentenceOpacity }}
          >
            <div xmlns="http://www.w3.org/1999/xhtml"
              style={{
                width: '100%', height: '100%',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                fontFamily: 'Geist, system-ui',
                fontSize: '24px',
                fontWeight: 500,
                color: 'var(--ink)',
                letterSpacing: '-0.01em',
                whiteSpace: 'pre',
                lineHeight: 1,
              }}>
              {segments.map((seg, i) => {
                if (seg.color) {
                  const bg = seg.color === CYAN
                    ? `rgba(34,211,238,${0.22 * highlightP})`
                    : `rgba(29,78,216,${0.20 * highlightP})`;
                  return (
                    <span key={i} style={{
                      display: 'inline-block',
                      padding: '1px 5px',
                      margin: '0 1px',
                      lineHeight: 1,
                      background: bg,
                      borderRadius: 4,
                      transition: 'background 200ms linear',
                      whiteSpace: 'pre',
                    }}>{seg.txt}</span>
                  );
                }
                return <span key={i} style={{ whiteSpace: 'pre' }}>{seg.txt}</span>;
              })}
            </div>
          </foreignObject>
        )}

        {/* ── Stage 2: subtle CAUSES / EFFECT row labels ── */}
        {(() => {
          const labelP = easeStd(range01(tt, s1 + 0.5, s2 - 0.5))
                       * (1 - easeStd(range01(tt, s2 + 0.4, s3 - 0.5)));
          if (labelP < 0.02) return null;
          return (
            <g opacity={labelP * 0.85}>
              <text x={W * 0.28} y={246} textAnchor="middle"
                fontSize="11" fontFamily="Geist Mono, ui-monospace"
                fill={NAVY}
                style={{ letterSpacing: '0.22em', textTransform: 'uppercase' }}>
                Causes
              </text>
              <text x={W * 0.72} y={246} textAnchor="middle"
                fontSize="11" fontFamily="Geist Mono, ui-monospace"
                fill={CYAN}
                style={{ letterSpacing: '0.22em', textTransform: 'uppercase' }}>
                Effect
              </text>
            </g>
          );
        })()}

        {/* ── STAGE 2 TRAVELERS — phrase pills flying from sentence to bubbles.
             Filled brand-color pill with white text for max readability mid-flight.
             ── */}
        {(() => {
          if (tt < s1 - 0.1 || tt > s2 + 0.2) return null;
          const items = [];
          const travelers = [
            { phrase: 'rude',           p: travRude,  src: phrasePos.rude,   dst: s2CauseBot,  color: NAVY, width: 70  },
            { phrase: 'long wait',      p: travWait,  src: phrasePos.wait,   dst: s2CauseTop,  color: NAVY, width: 110 },
            { phrase: 'wont come back', p: travEff,   src: phrasePos.effect, dst: s2EffectPos, color: CYAN, width: 152 },
          ];
          for (const trv of travelers) {
            if (trv.p <= 0 || trv.p >= 1) continue;
            const ease = easeStd(trv.p);
            const x = lerp(trv.src.x, trv.dst.x, ease);
            const y = lerp(trv.src.y, trv.dst.y, ease);
            // Fade in over first 12%, hold, fade out over last 18% (so it lands
            // softly into its bubble rather than vanishing abruptly).
            const fadeIn  = Math.min(1, trv.p / 0.12);
            const fadeOut = Math.min(1, (1 - trv.p) / 0.18);
            const fade = fadeIn * fadeOut;
            const pillH = 30;
            const pillW = trv.width;
            items.push(
              <g key={trv.phrase} opacity={fade}>
                <rect x={x - pillW/2} y={y - pillH/2} width={pillW} height={pillH} rx="15"
                  fill={trv.color}
                  filter="url(#bubble-shadow)"/>
                <text x={x} y={y + 5} textAnchor="middle"
                  fontSize="14" fontFamily="Geist, system-ui" fontWeight="600"
                  fill="white"
                  style={{ letterSpacing: '-0.005em' }}>
                  {trv.phrase}
                </text>
              </g>
            );
          }
          return <g>{items}</g>;
        })()}

        {/* ── STAGE 3 SNIPPET TRAVELERS — short phrases fly from canvas
             edges into the various bubbles (main + bg). Conveys the SAME
             extraction process repeated millions of times, not just for
             the one example review.                                    ── */}
        {(tt > s2 + 0.3 && tt < s3 + 0.3) && (() => {
          const targetForKey = (key) => {
            if (key === 'effect') return s2EffectPos;
            if (key === 'causeL') return s2CauseTop;
            if (key === 'causeR') return s2CauseBot;
            if (key.startsWith('bgEffect')) {
              const idx = parseInt(key.slice(8), 10);
              const b = bgEffects[idx];
              return { x: s2EffectPos.x + b.initDx, y: s2EffectPos.y + b.initDy };
            }
            if (key.startsWith('bgCause')) {
              const idx = parseInt(key.slice(7), 10);
              const b = bgCauses[idx];
              const baseX = (b.initAround === 'top' ? s2CauseTop.x : s2CauseBot.x);
              const baseY = (b.initAround === 'top' ? s2CauseTop.y : s2CauseBot.y);
              return { x: baseX + b.initDx, y: baseY + b.initDy };
            }
            return { x: W/2, y: H/2 };
          };
          const microStart = s2 + 0.5;
          const microEnd   = s3 - 0.4;
          const microSpan  = Math.max(0.5, microEnd - microStart);
          const items = [];
          MICRO_S3.forEach((p, i) => {
            const spawnT   = microStart + (i / MICRO_S3.length) * microSpan;
            const lifetime = 0.85;
            const localT   = tt - spawnT;
            if (localT < 0 || localT > lifetime) return;
            const localP = easeStd(localT / lifetime);
            const target = targetForKey(p.target);
            const angle  = ((i * 67) % 360) * Math.PI / 180;
            const radius = 380 + ((i * 13) % 80);
            const sx = target.x + Math.cos(angle) * radius;
            const sy = target.y + Math.sin(angle) * radius * 0.45;
            const x = lerp(sx, target.x, localP);
            const y = lerp(sy, target.y, localP);
            const isEffect = p.target === 'effect' || p.target.startsWith('bgEffect');
            const fill = isEffect ? CYAN_DEEP : NAVY_DEEP;
            const fadeIn  = Math.min(1, localP / 0.12);
            const fadeOut = Math.min(1, (1 - localP) / 0.25);
            const alpha = fadeIn * fadeOut * 0.85;
            if (alpha < 0.04) return;
            items.push(
              <text key={'ms' + i}
                x={x} y={y} textAnchor="middle"
                fontSize="11" fontFamily="Geist Mono, ui-monospace" fontWeight="500"
                fill={fill} opacity={alpha}
                style={{ letterSpacing: '0.04em' }}>
                {p.text}
              </text>
            );
          });
          return <g>{items}</g>;
        })()}

        {/* ── MULTIPLICITY COUNTER (S3) — exponential ramp to 4.5M reviews.
             The bg-bubbles cluster around the 3 main bubbles during Stage 3
             (rendered in the bg-bubbles block above). Together with the
             snippet travelers, this conveys the millionfold extraction. ── */}
        {(tt > s2 + 0.4 && tt < s3 + 0.2) && (() => {
          const counterP = easeStd(range01(tt, s2 + 0.6, s3 - 0.5));
          const counterFadeOut = easeStd(range01(tt, s3 - 0.4, s3 + 0.1));
          const counterAlpha = counterP * (1 - counterFadeOut);
          if (counterAlpha < 0.02) return null;
          const target = 4500000;
          const value = Math.floor(Math.pow(target, counterP));
          const counterText = value >= 1000000
            ? (value / 1000000).toFixed(1) + 'M'
            : value >= 1000
              ? Math.round(value / 1000) + 'K'
              : value.toString();
          return (
            <g opacity={counterAlpha}>
              <text x={W/2} y={H - 40} textAnchor="middle"
                fontSize="22" fontFamily="Geist, system-ui" fontWeight="600"
                fill="var(--ink)" style={{ letterSpacing: '-0.012em' }}>
                × {counterText} reviews
              </text>
            </g>
          );
        })()}

        {/* ── TIMELINE (S4-S5) ── */}
        {tlOpacity > 0.02 && (
          <g opacity={tlOpacity}>
            <line x1={xStart} y1={tlAxisY} x2={xEnd} y2={tlAxisY}
              stroke="rgb(var(--c-line))" strokeWidth="1"/>
            {QUARTERS.map((q, i) => {
              const px = quarterX(i);
              const isExample = i === 2;
              return (
                <g key={i}>
                  <line x1={px} y1={tlAxisY - 4} x2={px} y2={tlAxisY + 4}
                    stroke="rgb(var(--c-line))" strokeWidth="1"/>
                  <text x={px} y={tlAxisY} dy="20" textAnchor="middle"
                    fontSize="10" fontFamily="Geist Mono, ui-monospace"
                    fill="var(--mute)"
                    style={{ letterSpacing: '0.12em' }}>
                    {q}
                  </text>
                  {/* Per-quarter background bubbles are now rendered by the
                       dedicated bg-bubbles block below so they can morph
                       smoothly into tree positions during Stage 6.        */}
                </g>
              );
            })}
            <text x={xStart - 18} y={tlEffectRowY} dy="6" textAnchor="end"
              fontSize="17" fontFamily="Geist, system-ui" fontWeight="600"
              fill={CYAN}
              style={{ letterSpacing: '-0.012em' }}>
              Effects
            </text>
            <text x={xStart - 18} y={tlCauseRowY} dy="6" textAnchor="end"
              fontSize="17" fontFamily="Geist, system-ui" fontWeight="600"
              fill={NAVY}
              style={{ letterSpacing: '-0.012em' }}>
              Causes
            </text>
          </g>
        )}

        {/* ── Time-lag arrows (S5) ── */}
        {tlOpacity > 0.4 && lagP > 0 && (() => {
          const fade = 1 - tlOutP;
          if (fade < 0.05) return null;
          const pairs = [
            { causeQ: 1, effectQ: 3, delay: 0.0 },
            { causeQ: 3, effectQ: 5, delay: 0.3 },
          ];
          return (
            <g opacity={fade}>
              {pairs.map((p, i) => {
                const localP = easeStd(Math.max(0, Math.min(1, (lagP - p.delay) / Math.max(0.01, 1 - p.delay))));
                if (localP <= 0) return null;
                const cx = quarterX(p.causeQ);
                const ex = quarterX(p.effectQ);
                // Smooth elbow: cubic Bezier with vertical-leaning control points.
                // Path leaves the cause-bubble going up, sweeps right at apex,
                // enters the effect-bubble going up. Apple-style rounded curve.
                const apex = tlAxisY - 22;
                const dPath = `M ${cx} ${tlCauseRowY - 12} `
                            + `C ${cx} ${apex}, ${ex} ${apex}, ${ex} ${tlEffectRowY + 12}`;
                return (
                  <path key={i}
                    d={dPath}
                    fill="none" stroke={CYAN} strokeWidth="1.6"
                    strokeLinecap="round"
                    pathLength="1" strokeDasharray="1"
                    strokeDashoffset={1 - localP}
                    opacity="0.85"
                    markerEnd={localP > 0.92 ? 'url(#arrow-cyan)' : undefined}/>
                );
              })}
              {lagP > 0.5 && (
                <text x={W/2} y={tlAxisY - 110} textAnchor="middle"
                  fontSize="11" fontFamily="Geist Mono, ui-monospace"
                  fill="var(--mute)"
                  style={{ letterSpacing: '0.18em', textTransform: 'uppercase' }}>
                  Causes lead · effects follow
                </text>
              )}
            </g>
          );
        })()}

        {/* ── DAG flash (S5) ── */}
        {dagFlashP > 0.05 && (() => {
          const cx = W - 215, cy = 130;
          return (
            <g opacity={dagFlashP * 0.92}>
              <rect x={cx - 110} y={cy - 60} width="220" height="130" rx="12"
                fill="rgb(var(--c-paper))" stroke="rgb(var(--c-line))" strokeWidth="1"
                filter="url(#bubble-shadow)"/>
              <text x={cx} y={cy - 42} textAnchor="middle"
                fontSize="9" fontFamily="Geist Mono, ui-monospace"
                fill="var(--mute)"
                style={{ letterSpacing: '0.18em', textTransform: 'uppercase' }}>
                Isolate confounders
              </text>
              {[
                { x: cx,      y: cy - 20, label: 'C₁' },
                { x: cx - 40, y: cy + 28, label: 'C₂' },
                { x: cx + 40, y: cy + 28, label: 'C₃' },
              ].map((n, i) => (
                <g key={i}>
                  <circle cx={n.x} cy={n.y} r="6.5" fill="none" stroke="var(--mute)" strokeWidth="1" opacity="0.55"/>
                  <text x={n.x} y={n.y + 3} textAnchor="middle"
                    fontSize="8" fontFamily="Geist Mono, ui-monospace" fill="var(--mute)" opacity="0.6">
                    {n.label}
                  </text>
                </g>
              ))}
              <line x1={cx - 72} y1={cy + 5} x2={cx + 70} y2={cy + 5}
                stroke={CYAN} strokeWidth="1.6" strokeLinecap="round"
                markerEnd="url(#arrow-cyan)"/>
              <circle cx={cx - 80} cy={cy + 5} r="9"
                fill="rgb(var(--c-paper))" stroke={NAVY} strokeWidth="2"/>
              <text x={cx - 80} y={cy + 8} textAnchor="middle"
                fontSize="9" fontFamily="Geist Mono, ui-monospace" fill={NAVY_DEEP} fontWeight="600">X</text>
              <circle cx={cx + 80} cy={cy + 5} r="9"
                fill="rgb(var(--c-paper))" stroke={CYAN} strokeWidth="2"/>
              <text x={cx + 80} y={cy + 8} textAnchor="middle"
                fontSize="9" fontFamily="Geist Mono, ui-monospace" fill={CYAN_DEEP} fontWeight="600">Y</text>
            </g>
          );
        })()}

        {/* ── BACKGROUND BUBBLES — three phases, all softer/smaller than
             the main 3 (so they sit clearly behind the focal trio):
             A) Stage 3: appear in clusters around the 3 main bubbles
                ("now do it a million times" — many more drivers exist).
             B) Stage 4-5: fly to quarter positions on the timeline.
             C) Stage 6: fly to tree positions (same Y as main, spread
                horizontally). Labels reveal once they've settled. ── */}
        {(() => {
          // Visibility windows
          const phaseAFadeIn  = easeStd(range01(tt, s2 + (s3 - s2) * 0.10, s2 + (s3 - s2) * 0.45));
          const phaseAtoB     = easeStd(range01(tt, s3 + (s4 - s3) * 0.05, s3 + (s4 - s3) * 0.55));
          const phaseBtoC     = treeBackP;
          const fadeOutLogo   = easeStd(range01(tt, s5 + s6Span * 0.65, s5 + s6Span * 0.85));
          if (tt < s2 + 0.2 || phaseAFadeIn < 0.02) return null;

          const items = [];
          const renderBg = (b, i, isEffect) => {
            // Initial cluster position around main bubbles (Stage 3)
            const initBaseX = isEffect ? s2EffectPos.x
                            : (b.initAround === 'top' ? s2CauseTop.x : s2CauseBot.x);
            const initBaseY = isEffect ? s2EffectPos.y
                            : (b.initAround === 'top' ? s2CauseTop.y : s2CauseBot.y);
            const initX = initBaseX + b.initDx;
            const initY = initBaseY + b.initDy;
            // Timeline position
            const tlX  = quarterX(b.tlIdx);
            const tlYpos = isEffect ? tlEffectRowY : tlCauseRowY;
            // Tree position
            const treeXp = b.treeX;
            const treeYp = isEffect ? treeEffect.y : treeCauseL.y;

            // Compute current x/y/r based on tt
            let x, y, r;
            if (tt < s3) {
              // Phase A: around main bubble, small
              x = initX; y = initY;
              r = 8 * phaseAFadeIn;
            } else if (tt < s4) {
              // A → B: migrate to timeline
              x = lerp(initX, tlX, phaseAtoB);
              y = lerp(initY, tlYpos, phaseAtoB);
              r = lerp(8, 10, phaseAtoB);
            } else if (tt < s5) {
              // Phase B: at timeline
              x = tlX; y = tlYpos; r = 10;
            } else {
              // B → C: migrate to tree
              x = lerp(tlX, treeXp, phaseBtoC);
              y = lerp(tlYpos, treeYp, phaseBtoC);
              r = lerp(10, 14, phaseBtoC);
            }

            const stroke = isEffect ? CYAN : NAVY;
            const overallAlpha = phaseAFadeIn * (1 - fadeOutLogo);
            // Bg bubbles dimmer than main (0.45 vs 1.0) so they sit clearly
            // behind the focal trio.
            const bubbleAlpha = 0.45 * overallAlpha;
            // Labels visible during Phase A (Stage 3, around main bubbles)
            // and once settled at tree (Phase D). Hidden during the timeline
            // migration to keep the canvas legible.
            let labelAlpha = 0;
            if (b.label) {
              if (tt < s3) {
                labelAlpha = phaseAFadeIn * overallAlpha * 0.7;
              } else if (tt < s4 - 0.3) {
                labelAlpha = (1 - phaseAtoB) * overallAlpha * 0.7;
              } else if (phaseBtoC > 0.6) {
                labelAlpha = ((phaseBtoC - 0.6) / 0.4) * overallAlpha * 0.7;
              }
            }
            if (bubbleAlpha < 0.02) return null;
            return (
              <g key={(isEffect ? 'be' : 'bc') + i}>
                <circle cx={x} cy={y} r={r}
                  fill="rgb(var(--c-paper))"
                  stroke={stroke} strokeWidth="1.2"
                  opacity={bubbleAlpha}/>
                {labelAlpha > 0.05 && (
                  <text x={x} y={y + r + 14} textAnchor="middle"
                    fontSize="9" fontFamily="Geist Mono, ui-monospace"
                    fill="var(--mute)" opacity={labelAlpha}
                    style={{ letterSpacing: '0.10em' }}>
                    {b.label}
                  </text>
                )}
              </g>
            );
          };
          bgEffects.forEach((b, i) => items.push(renderBg(b, i, true)));
          bgCauses.forEach((b, i) => items.push(renderBg(b, i, false)));
          return <g>{items}</g>;
        })()}

        {/* ── TREE LINES (S6) ──
            During tree phase: 2 lines (causes → effect) — Y-shape.
            During logo morph: 3rd line (causeL ↔ causeR) fades in to
            complete the triangle, matching the GenData logo exactly.   */}
        {treeLineFadeIn > 0.02 && (() => {
          const baseAlpha = treeLineFadeIn;
          if (baseAlpha < 0.02) return null;
          // Stroke width interpolates from line-thin during tree to logo-line-width during morph
          const sw = lerp(1.6, logoLineW, logoMorphP);
          const isHighlightL = highlightStageP > 0;
          // All line endpoints sit at the bubbles' CENTERS (not edges) so
          // the circles cover them in the background — matches the brand
          // logo where lines visually emerge from inside each dot.
          return (
            <g>
              {/* CauseL → Effect (highlighted line during S6 highlight phase) */}
              <line
                x1={causeLBubble.x} y1={causeLBubble.y}
                x2={effectBubble.x} y2={effectBubble.y}
                stroke={logoMorphP > 0.4 ? NAVY_LINE : (isHighlightL && logoMorphP < 0.3 ? CYAN : NAVY_LINE)}
                strokeWidth={lerp(isHighlightL && logoMorphP < 0.3 ? 2.4 : 1.6, logoLineW, logoMorphP)}
                strokeLinecap="round"
                opacity={baseAlpha * (isHighlightL && logoMorphP < 0.3 ? lerp(0.5, 0.95, highlightStageP) : 0.7)}/>
              {/* CauseR → Effect */}
              <line
                x1={causeRBubble.x} y1={causeRBubble.y}
                x2={effectBubble.x} y2={effectBubble.y}
                stroke={NAVY_LINE}
                strokeWidth={sw}
                strokeLinecap="round"
                opacity={baseAlpha * 0.7}/>
              {/* Bottom line CauseL ↔ CauseR — completes the triangle for logo */}
              {bottomLineP > 0.02 && (
                <line
                  x1={causeLBubble.x} y1={causeLBubble.y}
                  x2={causeRBubble.x} y2={causeRBubble.y}
                  stroke={NAVY_LINE}
                  strokeWidth={sw}
                  strokeLinecap="round"
                  opacity={bottomLineP * 0.7}/>
              )}
            </g>
          );
        })()}

        {/* ── ATE callout — minimalist, single message: just the impact in
             plain language. Sits to the LEFT of the highlighted path so it
             doesn't overlap the line. Holds long enough to read.       ── */}
        {highlightStageP > 0.2 && logoMorphP < 0.5 && (() => {
          const alpha = highlightStageP * (1 - logoMorphP * 2);
          if (alpha < 0.05) return null;
          const mx = (causeLBubble.x + effectBubble.x) / 2 - 130;
          const my = (causeLBubble.y + effectBubble.y) / 2;
          const cardW = 230;
          const cardH = 76;
          return (
            <g opacity={alpha}>
              <rect x={mx - cardW/2} y={my - cardH/2} width={cardW} height={cardH} rx="10"
                fill="rgb(var(--c-paper))" stroke={CYAN} strokeWidth="1"
                filter="url(#bubble-shadow)"/>
              <text x={mx} y={my - 4} textAnchor="middle"
                fontSize="28" fontFamily="Geist, system-ui" fontWeight="600"
                fill={CYAN_DEEP}
                style={{ letterSpacing: '-0.022em' }}>
                ATE = +0.42
              </text>
              <text x={mx} y={my + 18} textAnchor="middle"
                fontSize="10.5" fontFamily="Geist Mono, ui-monospace"
                fill="var(--mute)"
                style={{ letterSpacing: '0.16em', textTransform: 'uppercase' }}>
                Long wait → churn
              </text>
            </g>
          );
        })()}

        {/* ── The three main bubbles ──
            Default = OUTLINED (paper fill + brand stroke + brand text).
            Logo morph = FILLED solid color (no stroke, no text — matches
            the actual GenData logo). Smooth alpha-crossfade between
            outlined and filled versions. ── */}
        {tt > s1 - 0.2 && (() => {
          const showLabels = (tt < s3 + 0.2 || (tt > s5 + s6Span * 0.30 && logoMorphP < 0.3));
          const items = [
            { b: effectBubble, color: CYAN, deep: CYAN_DEEP, logoColor: CYAN,      label: 'Service Churn', sub: 'Effect', fontSize: 16, role: 'effect',  isHighlight: false },
            { b: causeLBubble, color: NAVY, deep: NAVY_DEEP, logoColor: NAVY,      label: 'Long Wait',     sub: 'Cause',  fontSize: 14, role: 'causeL',  isHighlight: highlightStageP > 0 },
            { b: causeRBubble, color: NAVY, deep: NAVY_DEEP, logoColor: NAVY_LINE, label: 'Rude',          sub: 'Cause',  fontSize: 14, role: 'causeR',  isHighlight: false },
          ];
          return (
            <g>
              {items.map((it, i) => {
                if (it.b.r < 0.5) return null;
                const isHighlightActive = it.isHighlight && highlightStageP > 0 && logoMorphP < 0.3;
                const strokeW = isHighlightActive ? lerp(2.4, 3.4, highlightStageP) : 2.4;
                return (
                  <g key={i}>
                    {/* Highlight ring around the dominant cause */}
                    {isHighlightActive && (
                      <circle cx={it.b.x} cy={it.b.y} r={it.b.r + 9}
                        fill="none" stroke={CYAN} strokeWidth="1.4"
                        opacity={highlightStageP * 0.5 * (1 - logoMorphP * 3)}/>
                    )}
                    {/* Outlined version (default) */}
                    {logoMorphP < 1 && (
                      <g opacity={1 - logoMorphP}>
                        <circle cx={it.b.x} cy={it.b.y} r={it.b.r}
                          fill="rgb(var(--c-paper))"
                          stroke={isHighlightActive ? CYAN : it.color}
                          strokeWidth={strokeW}
                          filter={it.b.r > 8 ? 'url(#bubble-shadow)' : undefined}/>
                        {showLabels && it.b.r > 18 && (
                          <>
                            <text x={it.b.x} y={it.b.y + 2} textAnchor="middle"
                              fontSize={it.fontSize} fontFamily="Geist, system-ui" fontWeight="600"
                              fill={it.deep}
                              style={{ letterSpacing: '-0.01em' }}>
                              {it.label}
                            </text>
                            <text x={it.b.x} y={it.b.y + it.fontSize + 4} textAnchor="middle"
                              fontSize="8.5" fontFamily="Geist Mono, ui-monospace"
                              fill={it.deep} opacity="0.6"
                              style={{ letterSpacing: '0.18em', textTransform: 'uppercase' }}>
                              {it.sub}
                            </text>
                          </>
                        )}
                      </g>
                    )}
                    {/* Filled logo version (during morph) — uses logoColor
                         (right cause becomes deep navy to match the brand mark) */}
                    {logoMorphP > 0 && (
                      <circle cx={it.b.x} cy={it.b.y} r={it.b.r}
                        fill={it.logoColor || it.color}
                        opacity={logoMorphP}/>
                    )}
                  </g>
                );
              })}
            </g>
          );
        })()}

        {/* ── GenData wordmark — centered composition (wordmark left,
             triangle right). Thinner font (Outfit weight 200) for elegance.  ── */}
        {logoMorphP > 0.5 && (() => {
          const alpha = easeStd(range01(tt, s5 + s6Span * 0.83, s5 + s6Span * 0.97));
          if (alpha < 0.05) return null;
          const wordmarkFontSize = LOGO_SIZE * 0.84;  // ≈92
          // Right edge of wordmark sits gap-distance left of the triangle's left vertex
          const wordmarkRightX = logoCauseL.x - logoCauseL.r - 26;
          const wordmarkY = logoCenter.y + wordmarkFontSize * 0.34;
          return (
            <g opacity={alpha}>
              <text x={wordmarkRightX} y={wordmarkY} textAnchor="end"
                fontSize={wordmarkFontSize}
                fontFamily="Outfit, Geist, sans-serif" fontWeight="200"
                fill="var(--ink)"
                style={{ letterSpacing: '-0.025em' }}>
                GenData
              </text>
            </g>
          );
        })()}
      </svg>
    </div>
  );
}

window.HeroAnimation = HeroAnimation;
