// Shared helpers used by every act.
// Everything lands on window so the other act files can use them without imports.

// Expose React hooks on window so every act file can pull them without
// redeclaring (each babel script is its own scope).
window.useState = React.useState;
window.useEffect = React.useEffect;
window.useRef = React.useRef;
window.useMemo = React.useMemo;
window.useLayoutEffect = React.useLayoutEffect;

// useReveal — returns [ref, visible] so sections can stage their entrance when
// they scroll into view. Every act uses this so the reveal vocabulary is
// consistent through the route.
function useReveal(threshold = 0.25) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    if (!ref.current) return;
    const io = new IntersectionObserver(
      (entries) => entries.forEach((e) => { if (e.isIntersecting) setVisible(true); }),
      { threshold }
    );
    io.observe(ref.current);
    return () => io.disconnect();
  }, [threshold]);
  return [ref, visible];
}

// useScrollProgress — 0..1 progress through `ref`'s vertical span relative to
// the viewport. Used by acts that want internal parallax or scroll-driven
// reveals (Act IV, Act V).
function useScrollProgress(ref) {
  const [p, setP] = useState(0);
  useEffect(() => {
    if (!ref.current) return;
    const onScroll = () => {
      const r = ref.current.getBoundingClientRect();
      const vh = window.innerHeight;
      // 0 when the bottom of the section hits the top of the viewport,
      // 1 when the top of the section hits the bottom.
      const total = r.height + vh;
      const passed = vh - r.top;
      setP(Math.max(0, Math.min(1, passed / total)));
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener('scroll', onScroll);
  }, [ref]);
  return p;
}

// ActFrame — the full-bleed 100vh stage every act mounts inside. Handles the
// letterbox + grain + act-number chrome. Children get a centered content slot.
function ActFrame({ act, index, total, bg = 'var(--petrol-deep)', children, noGrain = false, bleed = false }) {
  const [ref, visible] = useBeatReveal(0);
  return (
    <div
      ref={ref}
      className={noGrain ? '' : 'grain'}
      style={{
        position: 'relative',
        minHeight: '100vh',
        height: bleed ? '100vh' : undefined,
        background: bg,
        color: 'var(--parchment)',
        overflow: 'hidden',
        display: 'flex',
        alignItems: 'stretch',
        justifyContent: 'center',
        padding: bleed ? 0 : '64px 56px 56px',
      }}
    >
      {/* Corner act-number — upper-left anchor. Roman numeral + kicker
          sized up for readability across the deck (was 34px / 10px). */}
      <div
        style={{
          position: 'absolute',
          top: 36, left: 56,
          display: 'flex', alignItems: 'baseline', gap: 16,
          opacity: visible ? 1 : 0,
          transform: visible ? 'translateY(0)' : 'translateY(8px)',
          transition: 'opacity 1.2s ease, transform 1.2s ease',
          zIndex: 6,
        }}
      >
        <span className="font-display" style={{ fontSize: 44, color: 'var(--sage)', lineHeight: 1 }}>{act.roman}</span>
        <span style={{
          fontFamily: 'var(--font-ui)', fontSize: 13,
          letterSpacing: '0.22em', textTransform: 'uppercase',
          color: 'var(--taupe)',
        }}>{act.kicker}</span>
      </div>

      {/* Corner count — lower-right. Bumped from 10px to 13px. */}
      <div
        style={{
          position: 'absolute',
          bottom: 36, right: 56,
          fontFamily: 'var(--font-ui)',
          fontSize: 13,
          color: 'var(--taupe)',
          letterSpacing: '0.22em',
          textTransform: 'uppercase',
          zIndex: 6,
        }}
      >
        {String(index+1).padStart(2,'0')} / {String(total).padStart(2,'0')}
      </div>

      {/* Content slot — acts override the inner layout completely. */}
      <div style={{
        width: '100%',
        maxWidth: bleed ? 'none' : 1480,
        position: bleed ? 'absolute' : 'relative',
        inset: bleed ? 0 : undefined,
        zIndex: 4,
      }}>
        {children}
      </div>
    </div>
  );
}

// DeepLinkPin — a 'walk the real page' button for acts that map to a live page
// in the built app. Small, corner-pinned, does not compete with the act.
function DeepLinkPin({ href, label }) {
  if (!href) return null;
  return (
    <a
      href={href}
      style={{
        position: 'absolute',
        bottom: 36, left: 56,
        display: 'inline-flex', alignItems: 'center', gap: 10,
        padding: '10px 16px',
        border: '1px solid rgba(201,168,76,0.55)',
        borderRadius: 999,
        color: 'var(--gold)',
        fontFamily: 'var(--font-ui)',
        fontSize: 10,
        letterSpacing: '0.22em',
        textTransform: 'uppercase',
        textDecoration: 'none',
        background: 'rgba(201,168,76,0.04)',
        backdropFilter: 'blur(4px)',
        transition: 'all 0.3s ease',
        zIndex: 10,
      }}
      onMouseEnter={(e) => {
        e.currentTarget.style.background = 'var(--gold)';
        e.currentTarget.style.color = 'var(--petrol-deep)';
      }}
      onMouseLeave={(e) => {
        e.currentTarget.style.background = 'rgba(201,168,76,0.04)';
        e.currentTarget.style.color = 'var(--gold)';
      }}
    >
      <span>{label}</span>
      <span style={{ fontFamily: 'var(--font-display)', fontSize: 14 }}>↗</span>
    </a>
  );
}

// ActSeam — historically a top-strip transition device. ActFrame already
// renders the roman+kicker top-left and the page count bottom-right, so the
// seam was creating a duplicate header. Now a no-op; left importable so the
// existing act files don't need to be touched.
function ActSeam() { return null; }

// EditorialRule — sage hairline with optional label on top. Used as a section
// divider within acts.
function EditorialRule({ label, color = 'rgba(143,166,143,0.45)' }) {
  return (
    <div style={{ position: 'relative', margin: '18px 0' }}>
      <div style={{ height: 1, background: `linear-gradient(to right, transparent, ${color}, transparent)` }} />
      {label && (
        <div style={{
          position: 'absolute', top: -7, left: '50%', transform: 'translateX(-50%)',
          background: 'var(--petrol-deep)', padding: '0 14px',
        }}>
          <span className="label-text" style={{ color: 'var(--sage)' }}>{label}</span>
        </div>
      )}
    </div>
  );
}

// Ken-burns image — slow drift + scale, used on full-bleed hero plates.
function KenBurns({ src, style, duration = 24 }) {
  return (
    <div style={{ position: 'absolute', inset: 0, overflow: 'hidden', ...style }}>
      <img
        src={src}
        alt=""
        style={{
          position: 'absolute', inset: 0,
          width: '100%', height: '100%',
          objectFit: 'cover',
          animation: `kenburns ${duration}s ease-in-out infinite alternate`,
        }}
      />
      <style>{`
        @keyframes kenburns {
          0%   { transform: scale(1.02) translate(0%, 0%); }
          100% { transform: scale(1.12) translate(-2%, -1.5%); }
        }
      `}</style>
    </div>
  );
}

// ScrollBeats — the scroll-scrub container. Wraps an act in a tall sticky
// stage. The outer div claims `beats + 1` viewport-heights of scroll space.
// The inner content is position:sticky at the top of the viewport, so it
// pins while the presenter scrolls through the act's scroll-chunk. The hook
// `useBeatProgress(i)` inside returns 0..1 reveal progress for beat i,
// fading IN across the first 55% of its window and holding at 1 after.
//
// Usage:
//   <ScrollBeats beats={5}>
//     {(p) => <MyAct p={p} />}   // p is 0..1 over the whole stage
//   </ScrollBeats>
// Or simpler: children can call `useBeatProgressContext(i)` directly.
// Default sentinel lets useBeatReveal detect when a component is NOT inside
// a <ScrollBeats> provider — it falls back to IntersectionObserver.
const BeatCtx = React.createContext({ p: 0, beats: 0 });

function useBeatProgress(i) {
  const { p, beats } = React.useContext(BeatCtx);
  if (!beats || beats <= 0) return 1; // outside ScrollBeats — treat as fully revealed
  const w = 1 / beats;
  const start = i * w;
  const inEnd = start + w * 0.55;
  if (p < start) return 0;
  if (p >= inEnd) return 1;
  return (p - start) / (inEnd - start);
}

// useStaggeredBeats — returns an array of [0..1] progress values, one per
// item, where each item has its own sub-beat window within the act. `count`
// is the number of items to stage; `startBeat` is the 0-based beat at which
// the first item begins revealing (default 0); `span` is how many beats the
// staggered sequence should occupy (default `count`). Items ease in across
// the first 70% of their individual window and hold at 1.
function useStaggeredBeats(count, { startBeat = 0, span = null, ease = 0.7 } = {}) {
  const { p, beats } = React.useContext(BeatCtx);
  if (!beats || beats <= 0) return Array(count).fill(1);
  const itemSpan = (span ?? count) / beats;
  const beatP = p;                   // 0..1 overall
  const startP = startBeat / beats;
  const sub = itemSpan / count;       // each item's scroll chunk
  const out = new Array(count);
  for (let i = 0; i < count; i++) {
    const s = startP + i * sub;
    const e = s + sub * ease;
    if (beatP < s) out[i] = 0;
    else if (beatP >= e) out[i] = 1;
    else out[i] = (beatP - s) / (e - s);
  }
  return out;
}

// useBeatReveal — scroll-driven drop-in replacement for useReveal.
// Returns [ref, visible]. `visible` is true once the given beat's reveal has
// started. Inside a ScrollBeats context it scrubs with scroll (reverses when
// the user scrolls up); outside one it falls back to plain IntersectionObserver
// so acts that haven't been wrapped yet still work.
function useBeatReveal(beatIndex = 0) {
  const ctx = React.useContext(BeatCtx);
  const inScrollBeats = ctx && ctx.beats > 0 && typeof ctx.p === 'number';
  const r = useBeatProgress(beatIndex);
  const [fallbackVisible, setFallbackVisible] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    if (inScrollBeats) return;
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(
      ([e]) => { if (e.isIntersecting) setFallbackVisible(true); },
      { threshold: 0.15 }
    );
    io.observe(el);
    return () => io.disconnect();
  }, [inScrollBeats]);
  return [ref, inScrollBeats ? r > 0.02 : fallbackVisible];
}

// ScrollBeats — sticky-stage scroll-driven slide deck. Outer takes
// (beats+1)*100vh of scroll room; sticky inner pins at 100vh during the
// scroll-through so beat reveals scrub.
//
// Stage opacity handles slide-to-slide transitions: as an act enters from
// below the viewport, opacity ramps from 0 (just below viewport bottom) to
// 1 (sticky pinned at top); during the active scroll-through it holds at
// 1; as the sticky releases at the end, opacity ramps back to 0 (act
// content slides up out of view). The threshold below sets how aggressive
// the cross-fade is — a wider threshold (e.g. 0.5) means each act fully
// resolves into petrol before the next one becomes visible. A narrower
// threshold (0.15) gives a gentle cross-blend. 0.5 is the "no overlap"
// extreme; 0.25 is a comfortable mid.
const TRANSITION_THRESHOLD = 0.5;  // share of viewport over which fade resolves

function ScrollBeats({ beats, children, bg = 'var(--petrol-deep)' }) {
  const outerRef = useRef(null);
  const [p, setP] = useState(0);
  const [stageOpacity, setStageOpacity] = useState(1);
  useEffect(() => {
    const onScroll = () => {
      const el = outerRef.current;
      if (!el) return;
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight;
      const total = r.height - vh;
      const scrolled = -r.top;
      setP(Math.max(0, Math.min(1, scrolled / total)));

      // Viewport-based intro/outro fade
      // r.top > 0: act below or partly below viewport top — fade in from 0
      // r.top in [-total, 0]: act pinned and active — opacity 1
      // r.top < -total: sticky has released, act sliding up out of view — fade out
      const fadeWindow = vh * TRANSITION_THRESHOLD;
      let op = 1;
      if (r.top > 0) {
        // Entering — at r.top = fadeWindow, opacity = 0 (act not yet visible
        // enough to render); at r.top = 0, opacity = 1 (act fully pinned).
        op = Math.max(0, 1 - r.top / fadeWindow);
      } else if (r.top < -total) {
        // Leaving — at r.top = -total, opacity = 1; at r.top = -total - fadeWindow,
        // opacity = 0.
        const overflow = -r.top - total;
        op = Math.max(0, 1 - overflow / fadeWindow);
      }
      setStageOpacity(op);
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener('scroll', onScroll);
  }, []);
  return (
    <div ref={outerRef} style={{
      position: 'relative',
      height: `${(beats + 1) * 100}vh`,
      background: bg,
    }}>
      <div style={{
        position: 'sticky', top: 0,
        height: '100vh', overflow: 'hidden',
        opacity: stageOpacity,
        transition: 'opacity 0.18s ease',
      }}>
        <BeatCtx.Provider value={{ p, beats }}>
          {typeof children === 'function' ? children(p) : children}
        </BeatCtx.Provider>
      </div>
    </div>
  );
}

// Beat — declarative wrapper. `index` is the 0-based beat within the act.
function Beat({ index, children, translateY = 14, style, className }) {
  const r = useBeatProgress(index);
  const eased = 1 - Math.pow(1 - r, 3);
  return (
    <div className={className} style={{
      opacity: eased,
      transform: `translateY(${(1 - eased) * translateY}px)`,
      willChange: 'opacity, transform',
      ...style,
    }}>
      {children}
    </div>
  );
}

// QRCard — renders a QR code pointing at `url`, styled as a museum-chip.
// Uses the qrcode-generator global loaded in Presentation Cut.html.
function QRCard({ url, kicker = 'Scan to explore', label, size = 128, tint = 'var(--gold)' }) {
  const dataUrl = useMemo(() => {
    if (typeof window.qrcode !== 'function') return null;
    const qr = window.qrcode(0, 'M');
    qr.addData(url);
    qr.make();
    // Module pixel size chosen so the SVG data URL renders crisply at `size`.
    return qr.createDataURL(6, 0);
  }, [url]);
  return (
    <div style={{
      display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 10,
      padding: '14px 14px 12px',
      background: 'rgba(7,50,66,0.97)',
      border: `1px solid ${tint}`,
      boxShadow: '0 10px 28px rgba(0,0,0,0.45)',
    }}>
      {dataUrl ? (
        <img src={dataUrl} alt={`QR to ${url}`} width={size} height={size}
          style={{ display: 'block', imageRendering: 'pixelated' }} />
      ) : (
        <div style={{ width: size, height: size, background: '#eee' }} />
      )}
      <div style={{ textAlign: 'center' }}>
        <div className="label-text" style={{ color: 'var(--petrol-deep)', letterSpacing: '0.28em' }}>{kicker}</div>
        {label && (
          <div className="font-editorial" style={{
            fontSize: 11, color: 'var(--petrol)', marginTop: 3, fontStyle: 'italic',
          }}>{label}</div>
        )}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────
// God-mode redesign primitives (added 2026-04-28).
// Three-pattern system (Mood / Argument / Evidence) shares these primitives.
// See HANDOFF/2026-04-28-godmode-redesign-design.md for the full system.
// ─────────────────────────────────────────────────────────────────────────

// WallLabel — the museum-wall caption pair. Kicker (Libre Franklin small caps,
// sage) + body sentence (Inter, cream). The single canonical form for any
// labeled content in Mood / Argument / Evidence patterns.
function WallLabel({ kicker, body, align = 'left', tone = 'cream', maxWidth = 360, style }) {
  const bodyColor = tone === 'gold' ? 'var(--gold)'
                  : tone === 'sage' ? 'var(--sage)'
                  : tone === 'dim'  ? 'var(--parchment-dim)'
                  : 'var(--parchment)';
  return (
    <div style={{ textAlign: align, maxWidth, ...style }}>
      {kicker && (
        <div className="label-text" style={{
          color: 'var(--sage)',
          letterSpacing: '0.28em',
          marginBottom: 8,
        }}>{kicker}</div>
      )}
      {body && (
        <div className="font-editorial" style={{
          fontSize: 17, lineHeight: 1.55,
          color: bodyColor,
          fontStyle: 'normal',
          textWrap: 'pretty',
        }}>{body}</div>
      )}
    </div>
  );
}

// HeadlineNumber — display-weight number for punctum beats. $1.5M, 87%, 24×.
// Always lands on its own beat. Play italic, scaled clamp(60px,8vw,140px).
function HeadlineNumber({ value, sub, align = 'center', tone = 'parchment' }) {
  const color = tone === 'gold' ? 'var(--gold)'
              : tone === 'sage' ? 'var(--sage)'
              : 'var(--parchment)';
  return (
    <div style={{ textAlign: align }}>
      <div className="font-display" style={{
        fontSize: 'clamp(60px, 8vw, 140px)',
        lineHeight: 0.95,
        letterSpacing: '-0.04em',
        color,
        fontStyle: 'italic',
      }}>{value}</div>
      {sub && (
        <div className="font-editorial" style={{
          marginTop: 14,
          fontSize: 18, lineHeight: 1.45,
          color: 'var(--parchment-dim)',
          fontStyle: 'italic',
          textWrap: 'pretty',
        }}>{sub}</div>
      )}
    </div>
  );
}

// FullBleed — image fills the act stage end-to-end, with the unified
// image-grade filter applied. Use for Mood-pattern hero plates. Optional
// `vignette` darkens the edges for text legibility on top.
function FullBleed({ src, alt = '', position = 'center', grade = 'cream', vignette = false, scale = 1.0, style, children }) {
  const gradeFilter = grade === 'sepia'   ? 'sepia(0.18) saturate(0.92) contrast(1.04) brightness(0.98)'
                    : grade === 'cool'    ? 'saturate(0.85) contrast(1.08) brightness(0.94)'
                    : grade === 'cream'   ? 'sepia(0.06) saturate(0.95) contrast(1.05) brightness(0.99)'
                    : grade === 'archive' ? 'sepia(0.28) saturate(0.78) contrast(1.06) brightness(0.96)'
                    : 'none';
  return (
    <div style={{ position: 'absolute', inset: 0, overflow: 'hidden', ...style }}>
      <img
        src={src} alt={alt}
        style={{
          position: 'absolute', inset: 0,
          width: '100%', height: '100%',
          objectFit: 'cover',
          objectPosition: position,
          filter: gradeFilter,
          transform: `scale(${scale})`,
          transition: 'transform 1.4s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.8s ease',
        }}
      />
      {vignette && (
        <div style={{
          position: 'absolute', inset: 0,
          background: 'radial-gradient(ellipse at center, transparent 35%, rgba(0,24,37,0.55) 100%)',
          pointerEvents: 'none',
        }} />
      )}
      {children}
    </div>
  );
}

// CrossFade — beat-driven image swap. Renders two images stacked; the second
// fades in as `progress` (0..1) advances. Used for the Heirloom photo→twin
// punctum and any image-swap reveals between beats.
function CrossFade({ from, to, progress = 0, alt = '', position = 'center', grade = 'cream', objectFit = 'cover' }) {
  const gradeFilter = grade === 'sepia'   ? 'sepia(0.18) saturate(0.92) contrast(1.04) brightness(0.98)'
                    : grade === 'cool'    ? 'saturate(0.85) contrast(1.08) brightness(0.94)'
                    : grade === 'cream'   ? 'sepia(0.06) saturate(0.95) contrast(1.05) brightness(0.99)'
                    : grade === 'archive' ? 'sepia(0.28) saturate(0.78) contrast(1.06) brightness(0.96)'
                    : 'none';
  const eased = 1 - Math.pow(1 - Math.max(0, Math.min(1, progress)), 2);
  return (
    <div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
      <img src={from} alt={alt} style={{
        position: 'absolute', inset: 0, width: '100%', height: '100%',
        objectFit, objectPosition: position, filter: gradeFilter,
        opacity: 1 - eased, transition: 'opacity 0.6s ease',
      }} />
      <img src={to} alt={alt} style={{
        position: 'absolute', inset: 0, width: '100%', height: '100%',
        objectFit, objectPosition: position, filter: gradeFilter,
        opacity: eased, transition: 'opacity 0.6s ease',
      }} />
    </div>
  );
}

// MoodStage — the canonical Mood-pattern wrapper. Full-bleed image floor +
// a content layer for headline / wall label that floats over it. Acts compose
// inside ScrollBeats and use Beat to time the reveals.
//   <ScrollBeats beats={4}>
//     <MoodStage src="..." vignette>
//       <Beat index={0}>...</Beat>
//       ...
//     </MoodStage>
//   </ScrollBeats>
function MoodStage({ src, position = 'center', grade = 'cream', vignette = true, children, fromTo, progress }) {
  return (
    <div style={{ position: 'absolute', inset: 0 }}>
      {fromTo
        ? <CrossFade from={fromTo[0]} to={fromTo[1]} progress={progress} position={position} grade={grade} />
        : <FullBleed src={src} position={position} grade={grade} vignette={vignette} />}
      {vignette && fromTo && (
        <div style={{
          position: 'absolute', inset: 0,
          background: 'radial-gradient(ellipse at center, transparent 35%, rgba(0,24,37,0.55) 100%)',
          pointerEvents: 'none',
        }} />
      )}
      <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 3 }}>
        {children}
      </div>
    </div>
  );
}

// useActOutroFade — read BeatCtx.p and return an opacity that holds at 1
// until the very end of the act, then fades to 0 between threshold and 1.
// Eliminates the visual overlap when scrolling between acts: the previous
// act's content fades out before the next act's sticky stage takes over.
function useActOutroFade(threshold = 0.94) {
  const { p, beats } = React.useContext(BeatCtx);
  if (!beats || beats <= 0) return 1;
  if (p <= threshold) return 1;
  return Math.max(0, 1 - (p - threshold) / (1 - threshold));
}

// PullQuote — display-weight italic editorial line for Argument / Mood acts.
// Used where an act needs a single declarative sentence at scale.
function PullQuote({ children, color = 'var(--parchment)', size = 'clamp(36px, 4.6vw, 72px)', italic = true, align = 'left', maxWidth = 920 }) {
  return (
    <div className="font-display" style={{
      fontSize: size,
      lineHeight: 1.05,
      letterSpacing: '-0.02em',
      color,
      fontStyle: italic ? 'italic' : 'normal',
      textAlign: align,
      maxWidth,
      textWrap: 'balance',
    }}>{children}</div>
  );
}

Object.assign(window, {
  useReveal, useScrollProgress, useBeatProgress, useBeatReveal, useStaggeredBeats,
  ActFrame, DeepLinkPin, ActSeam, EditorialRule, KenBurns, QRCard,
  ScrollBeats, Beat, BeatCtx,
  // god-mode primitives
  WallLabel, HeadlineNumber, FullBleed, CrossFade, MoodStage, PullQuote,
  useActOutroFade,
});
