// React bindings over the SPMotion vanilla core (motion.js) — the terminal
// motion system: scramble-decode text, staggered print-in reveals, typed
// terminal lines. Thin by design: every timing constant lives in SPMotion.T,
// every hidden-state style in theme.css behind html.sp-motion.
//
// All components render their real children/text from first paint; animations
// mutate or overlay what is already there, so crawlers and no-JS/reduced-
// motion visitors always get the finished page and layout never shifts.

// <Decode> — character-scramble decode of its real text content.
// trigger: 'view' (scroll into view, default) | 'load' (mount) |
//          'hover' (240ms re-decode; set hoverParent to listen on the parent,
//          e.g. a NavLink's <a>) | 'manual' (decode on view, then replay
//          whenever playKey changes to a non-null value).
function Decode({
  as = 'span', trigger = 'view', playKey, delay = 0, duration, onSettle,
  hoverParent = false, children, style, className, ...rest
}) {
  const ref = React.useRef(null);
  const cancelRef = React.useRef(null);
  const onSettleRef = React.useRef(onSettle);
  onSettleRef.current = onSettle;

  const run = (dur) => {
    const el = ref.current;
    const M = window.SPMotion;
    const fin = () => {
      if (el) el.removeAttribute('aria-label');
      if (onSettleRef.current) onSettleRef.current();
    };
    if (!el || !M || M.reduced) { fin(); return; }
    el.setAttribute('aria-label', el.textContent);
    cancelRef.current = M.scrambleTree(el, {
      delay, duration: dur != null ? dur : duration, onDone: fin,
    });
  };

  React.useEffect(() => {
    const el = ref.current;
    const M = window.SPMotion;
    if (!el || !M) { if (onSettleRef.current) onSettleRef.current(); return; }
    let unobserve = null;
    let target = null;
    let onEnter = null;
    // whenStable defers the first animations past the load-time main-thread
    // jank (runtime Babel) so they play visibly on phones; once the page is
    // stable it is a straight pass-through.
    if (trigger === 'load') {
      M.whenStable(() => run());
    } else if (trigger === 'view' || trigger === 'manual') {
      unobserve = M.observe(el, () => M.whenStable(() => run()));
    } else if (trigger === 'hover') {
      target = hoverParent ? (el.parentElement || el) : el;
      onEnter = () => run(M.T.reveal);
      target.addEventListener('mouseenter', onEnter);
    }
    return () => {
      if (unobserve) unobserve();
      if (target) target.removeEventListener('mouseenter', onEnter);
      if (cancelRef.current) { cancelRef.current(); cancelRef.current = null; }
      if (el) el.removeAttribute('aria-label');
    };
  }, [trigger]);

  const firstKey = React.useRef(true);
  React.useEffect(() => {
    if (firstKey.current) { firstKey.current = false; return; }
    if (trigger === 'manual' && playKey != null && playKey !== false) run();
  }, [playKey]);

  const As = as;
  return <As ref={ref} style={style} className={className} {...rest}>{children}</As>;
}

// <DecodeValue value={...}> — width-stable scramble-settle for stat and
// telemetry values. null / '--' / '·' render static and never animate.
// trigger: 'view' (one decode when scrolled into view, waits for real data) |
//          'change' (settle whenever the rendered string changes — live
//          tickers; use duration={260} for high-frequency ones).
function DecodeValue({
  value, trigger = 'view', duration, delay = 0, as = 'span',
  style, className, ...rest
}) {
  const ref = React.useRef(null);
  const cancelRef = React.useRef(null);
  const text = value == null ? '·' : String(value);
  const isStatic = value == null || text === '--' || text === '·';

  React.useEffect(() => {
    if (trigger !== 'view' || isStatic) return;
    const el = ref.current;
    const M = window.SPMotion;
    if (!el || !M) return;
    const unobserve = M.observe(el, () => M.whenStable(() => {
      cancelRef.current = M.scrambleTree(el, {
        delay, duration: duration != null ? duration : M.T.valueDur,
      });
    }));
    return () => { unobserve(); };
  }, [trigger, isStatic]);

  const prev = React.useRef(text);
  React.useEffect(() => {
    if (trigger !== 'change' || prev.current === text) { prev.current = text; return; }
    prev.current = text;
    const el = ref.current;
    const M = window.SPMotion;
    if (!el || !M || isStatic) return;
    cancelRef.current = M.settle(el, duration != null ? duration : M.T.valueDur);
  }, [text, trigger]);

  React.useEffect(() => () => { if (cancelRef.current) cancelRef.current(); }, []);

  const As = as;
  return <As ref={ref} style={style} className={className} {...rest}>{text}</As>;
}

// <Reveal> — terminal print-in (stepped opacity + hard 6px rise) when the
// element scrolls into view. Stagger via index (clamped to 6 × 70ms) or an
// explicit delay in ms. Always its own element — pass the layout styles the
// slot needs (grid/flex children adopt them cleanly).
function Reveal({ as = 'div', index = 0, step, delay, children, style, className, ...rest }) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const el = ref.current;
    const M = window.SPMotion;
    if (!el) return;
    if (!M) { el.classList.add('is-in'); return; }
    // Single-column layouts reveal items one at a time, so a cascade reads
    // as lag on phones — no index stagger there at all.
    const cap = M.smallScreen && M.smallScreen() ? 0 : 6;
    const d = delay != null ? delay
      : Math.min(index, cap) * (step != null ? step : M.T.step);
    return M.observe(el, () => M.whenStable(() => {
      el.style.transitionDelay = d + 'ms';
      el.classList.add('is-in');
      setTimeout(() => { el.style.transitionDelay = ''; }, d + M.T.reveal + 60);
    }));
  }, []);
  const As = as;
  return (
    <As ref={ref} className={'sp-reveal' + (className ? ' ' + className : '')}
        style={style} {...rest}>{children}</As>
  );
}

// <Stagger> — wraps each child in a <Reveal> with an incremental delay.
// lineMode: terminal-transcript pacing (160ms/line, no clamp).
// childStyle/childAs shape the wrapper elements for grid/flex slots.
function Stagger({ children, step, base = 0, lineMode = false, childAs = 'div', childStyle }) {
  const M = window.SPMotion;
  const s = step != null ? step : (M ? (lineMode ? M.T.lineStep : M.T.step) : 70);
  // Phones get no grid cascade (items enter the viewport one at a time, so
  // per-item delays read as lag); terminal lineMode keeps its sequence.
  const cap = M && M.smallScreen() && !lineMode ? 0 : 6;
  const kids = React.Children.toArray(children);
  return (
    <>
      {kids.map((c, i) => (
        <Reveal key={i} as={childAs} style={childStyle}
                delay={base + (lineMode ? i : Math.min(i, cap)) * s}>{c}</Reveal>
      ))}
    </>
  );
}

// <Typeline> — a terminal line typed on left-to-right. The complete string is
// in the DOM from first paint (untyped tail at opacity 0, char widths
// reserved → zero layout shift). segments: [{ t: 'text', c: color? }, …]
// keeps inline colored spans; prompt renders instantly and inherits color.
function Typeline({
  segments, text, prompt, delay = 0, speed, trigger = 'view',
  as = 'p', style, className,
}) {
  const segs = segments || [{ t: text || '' }];
  const total = segs.reduce((sum, s) => sum + s.t.length, 0);
  const sig = segs.map(s => s.t).join(' ');
  const M = window.SPMotion;
  const animated = !!(M && !M.reduced);
  const [n, setN] = React.useState(animated ? 0 : total);
  const ref = React.useRef(null);
  const totalRef = React.useRef(total);
  totalRef.current = total;
  const runningRef = React.useRef(false);

  React.useEffect(() => {
    if (!animated) return;
    const el = ref.current;
    const sp = speed != null ? speed : M.T.type;
    let raf = 0, timer = 0, prev = 0, acc = 0, unobserve = null;
    // Clamped-delta clock (like the scramble engine): a dropped-frame gap
    // slows the typing down instead of completing the line in one jump.
    const tickFn = (now) => {
      acc += prev ? Math.min(now - prev, 64) : 16;
      prev = now;
      const k = Math.floor(acc / sp);
      const t = totalRef.current;
      setN(k >= t ? t : k);
      if (k < t) raf = requestAnimationFrame(tickFn);
      else runningRef.current = false;
    };
    const begin = () => M.whenStable(() => {
      timer = setTimeout(() => {
        prev = 0;
        acc = 0;
        runningRef.current = true;
        raf = requestAnimationFrame(tickFn);
      }, delay);
    });
    if (trigger === 'load') begin();
    else unobserve = M.observe(el, begin);
    return () => {
      if (unobserve) unobserve();
      clearTimeout(timer);
      cancelAnimationFrame(raf);
      runningRef.current = false;
    };
  }, []);

  // Live prop change after the loop ended (or before it began): snap the new
  // text fully visible rather than leaving a tail stuck at opacity 0.
  const firstSig = React.useRef(true);
  React.useEffect(() => {
    if (firstSig.current) { firstSig.current = false; return; }
    if (!runningRef.current) setN(totalRef.current);
  }, [sig]);

  // The untyped tail keeps opacity 0 (not visibility/display), so assistive
  // tech always reads the complete line — only sighted users see the typing.
  const As = as;
  let off = 0;
  return (
    <As ref={ref} style={style} className={className}>
      {prompt != null ? <span>{prompt}{' '}</span> : null}
      {segs.map((s, i) => {
        const localOff = off;
        off += s.t.length;
        const vis = Math.max(0, Math.min(s.t.length, n - localOff));
        return (
          <span key={i} style={s.c ? { color: s.c } : undefined}>
            {s.t.slice(0, vis)}
            <span style={{ opacity: 0 }}>{s.t.slice(vis)}</span>
          </span>
        );
      })}
    </As>
  );
}

// useInView(ref) — fire-once boolean for scroll-into-view state.
function useInView(ref) {
  const [inView, setInView] = React.useState(false);
  React.useEffect(() => {
    if (!window.SPMotion) { setInView(true); return; }
    return window.SPMotion.observe(ref.current, () => setInView(true));
  }, []);
  return inView;
}

Object.assign(window, { Decode, DecodeValue, Reveal, Stagger, Typeline, useInView });
