// animations.jsx — text animation primitives used by templates in video mode.
//
// Two components, both no-ops when `enabled` is false (so we can call them
// unconditionally and only "turn on" in video mode):
//
//   <Rise enabled delay={0.2}>...</Rise>
//     Slide up + fade in. Wraps inline-block so existing inline styles still
//     apply to the child.
//
//   <Scramble enabled value="39°28′12″N" delay={0.4} duration={1.0}/>
//     Digit-scrambler. Cycles random digits left-to-right then settles to
//     the real value. Non-digit characters (°, ′, ″, N/S/E/W, space) stay
//     put so the layout doesn't jitter.
//
// CSS keyframes are injected once on first load.

(function injectAnimStyles() {
  if (document.getElementById('tpl-anim-styles')) return;
  const s = document.createElement('style');
  s.id = 'tpl-anim-styles';
  s.textContent = `
    @keyframes tpl-rise {
      from { opacity: 0; transform: translateY(10px); filter: blur(2px); }
      to   { opacity: 1; transform: translateY(0);    filter: blur(0); }
    }
    .tpl-rise {
      display: inline-block;
      animation: tpl-rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
      will-change: opacity, transform;
    }
  `;
  document.head.appendChild(s);
})();

function Rise({ enabled, delay = 0, children, as = 'span', style }) {
  const Tag = as;
  if (!enabled) return <Tag style={style}>{children}</Tag>;
  return (
    <Tag
      className="tpl-rise"
      style={{
        // The .tpl-rise CSS class sets display: inline-block (needed for
        // transform on a <span>). For block-level tags (div), force back
        // to display: block so children like AutoFitText can measure
        // parent.clientWidth against the real layout width, not just the
        // intrinsic content width.
        ...(as !== 'span' ? { display: 'block' } : null),
        animationDelay: `${delay}s`,
        ...style,
      }}
    >{children}</Tag>
  );
}

// Scramble — uses an internal interval to swap random digits in for the real
// ones; reveals left-to-right over `duration` seconds; holds the final value
// after. Restarts when `value` or `delay` changes.
function Scramble({ enabled, value, delay = 0, duration = 1.0, fps = 18 }) {
  const [display, setDisplay] = React.useState(value || '');
  React.useEffect(() => {
    if (!enabled) { setDisplay(value || ''); return; }
    const v = value || '';
    if (!v) { setDisplay(''); return; }
    // Random digit substitute that respects non-digit positions.
    const scrambled = () => v.replace(/\d/g, () => String(Math.floor(Math.random() * 10)));
    setDisplay(scrambled());
    const startAt = performance.now() + delay * 1000;
    const endAt   = startAt + duration * 1000;
    const interval = 1000 / fps;
    let last = 0;
    let raf;
    const tick = (now) => {
      if (now < startAt) {
        setDisplay(scrambled());
        raf = requestAnimationFrame(tick);
        return;
      }
      const frac = Math.min(1, (now - startAt) / (endAt - startAt));
      if (now - last >= interval) {
        last = now;
        // Reveal characters left-to-right; non-digits already match.
        const revealCount = Math.floor(frac * v.length);
        const out = v.split('').map((ch, i) => {
          if (i < revealCount) return ch;
          if (/\d/.test(ch)) return String(Math.floor(Math.random() * 10));
          return ch;
        }).join('');
        setDisplay(out);
      }
      if (frac < 1) raf = requestAnimationFrame(tick);
      else setDisplay(v);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [enabled, value, delay, duration, fps]);
  return <>{display}</>;
}

window.Rise = Rise;
window.Scramble = Scramble;

// ─────────────────────────────────────────────────────────────────────────
// useResizable — drag-corner scale for a card. Stores scale in localStorage
// (per ratio if storageKey varies). Returns scale + handle onPointerDown +
// resizing flag. Resize math: distance from card center to pointer relative
// to its starting distance scales the card up/down.
// ─────────────────────────────────────────────────────────────────────────
function useResizable(storageKey, defaultScale = 1, minScale = 0.4, maxScale = 2.5) {
  const [scale, setScale] = React.useState(() => {
    if (!storageKey) return defaultScale;
    try {
      const v = localStorage.getItem(storageKey);
      if (v) {
        const n = parseFloat(v);
        if (!isNaN(n)) return Math.max(minScale, Math.min(maxScale, n));
      }
    } catch (e) {}
    return defaultScale;
  });
  React.useEffect(() => {
    if (!storageKey) return;
    try { localStorage.setItem(storageKey, String(scale)); } catch (e) {}
  }, [scale, storageKey]);
  // Re-read when storageKey changes (ratio switch).
  React.useEffect(() => {
    if (!storageKey) return;
    try {
      const v = localStorage.getItem(storageKey);
      const n = v ? parseFloat(v) : NaN;
      setScale(!isNaN(n) ? Math.max(minScale, Math.min(maxScale, n)) : defaultScale);
    } catch (e) { setScale(defaultScale); }
  }, [storageKey]);

  const [resizing, setResizing] = React.useState(false);

  const onPointerDown = (e) => {
    if (e.button != null && e.button !== 0) return;
    e.preventDefault();
    e.stopPropagation();
    const handle = e.currentTarget;
    // Walk up to the card (closest [data-resizable-card]).
    let card = handle.parentElement;
    while (card && !card.hasAttribute('data-resizable-card')) card = card.parentElement;
    if (!card) return;
    const cardRect = card.getBoundingClientRect();
    const cardCenterX = cardRect.left + cardRect.width / 2;
    const cardCenterY = cardRect.top + cardRect.height / 2;
    const startDist = Math.hypot(e.clientX - cardCenterX, e.clientY - cardCenterY);
    if (startDist < 1) return;
    const startScale = scale;
    setResizing(true);
    const onMove = (me) => {
      const dist = Math.hypot(me.clientX - cardCenterX, me.clientY - cardCenterY);
      const next = Math.max(minScale, Math.min(maxScale, startScale * (dist / startDist)));
      setScale(next);
    };
    const onUp = () => {
      setResizing(false);
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
    };
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
  };

  return { scale, resizing, onPointerDown };
}

// ResizeHandle — small corner grip rendered inside a [data-resizable-card]
// element. Hidden by default, visible on hover. Bake pipeline can hide
// these via [data-no-export].
function ResizeHandle({ onPointerDown, color = '#61D4F2', bg = 'rgba(2,16,43,0.6)' }) {
  return (
    <div
      data-no-export="1"
      data-resize-handle="1"
      onPointerDown={onPointerDown}
      title="Drag to resize"
      style={{
        position: 'absolute',
        right: -4, bottom: -4,
        width: 14, height: 14,
        cursor: 'nwse-resize',
        background: bg,
        color: color,
        display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end',
        opacity: 0.35,
        transition: 'opacity .15s',
        zIndex: 5,
        touchAction: 'none',
      }}
      onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
      onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.35'; }}
    >
      <svg width="8" height="8" viewBox="0 0 8 8" style={{ display: 'block' }}>
        <path d="M 0 8 L 8 0 M 3 8 L 8 3 M 6 8 L 8 6" stroke={color} strokeWidth="1" />
      </svg>
    </div>
  );
}

window.useResizable = useResizable;
window.ResizeHandle = ResizeHandle;

// Map compass direction strings to degrees (0 = N, clockwise).
function dirToDegrees(dir) {
  if (dir == null) return 0;
  const raw = String(dir).trim();
  // 1. Numeric — accept "45", "180°", "67.5" etc., with leading sign.
  const num = parseFloat(raw.replace(/[^\d.\-]/g, ''));
  if (raw && !isNaN(num) && /\d/.test(raw)) {
    // Normalize to [0, 360).
    return ((num % 360) + 360) % 360;
  }
  // 2. Word form — normalize "north east", "south-east", "NORTH" before
  // stripping to N/E/S/W chars. Order matters: compound forms first.
  let s = raw.toUpperCase();
  s = s
    .replace(/NORTH[\s\-]*NORTH[\s\-]*EAST/g, 'NNE')
    .replace(/NORTH[\s\-]*NORTH[\s\-]*WEST/g, 'NNW')
    .replace(/SOUTH[\s\-]*SOUTH[\s\-]*EAST/g, 'SSE')
    .replace(/SOUTH[\s\-]*SOUTH[\s\-]*WEST/g, 'SSW')
    .replace(/EAST[\s\-]*NORTH[\s\-]*EAST/g, 'ENE')
    .replace(/EAST[\s\-]*SOUTH[\s\-]*EAST/g, 'ESE')
    .replace(/WEST[\s\-]*NORTH[\s\-]*WEST/g, 'WNW')
    .replace(/WEST[\s\-]*SOUTH[\s\-]*WEST/g, 'WSW')
    .replace(/NORTH[\s\-]*EAST/g, 'NE')
    .replace(/NORTH[\s\-]*WEST/g, 'NW')
    .replace(/SOUTH[\s\-]*EAST/g, 'SE')
    .replace(/SOUTH[\s\-]*WEST/g, 'SW')
    .replace(/NORTH/g, 'N')
    .replace(/SOUTH/g, 'S')
    .replace(/EAST/g, 'E')
    .replace(/WEST/g, 'W');
  // Strip everything except NESW and look up.
  const clean = s.replace(/[^NESW]/g, '');
  const map = {
    N:0, NNE:22.5, NE:45, ENE:67.5, E:90, ESE:112.5, SE:135, SSE:157.5,
    S:180, SSW:202.5, SW:225, WSW:247.5, W:270, WNW:292.5, NW:315, NNW:337.5,
  };
  return map[clean] ?? 0;
}

// CompassArrow — SVG compass with an animated needle that swings to the
// supplied direction. Uses Web Animations API on the needle <g> so the
// swing replays whenever `dir` changes.
function CompassArrow({ dir, size, navy = '#02102B', accent = '#0F6D96' }) {
  const groupRef = React.useRef(null);
  const lastDeg = React.useRef(0);
  const rafRef = React.useRef(null);
  const deg = dirToDegrees(dir);
  React.useEffect(() => {
    if (!groupRef.current) return;
    const from = lastDeg.current;
    // Shortest-path delta in [-180, 180).
    const diff = ((deg - from) % 360 + 540) % 360 - 180;
    const to = from + diff;
    const start = performance.now();
    const duration = 900;
    // Brand easing (cubic-bezier-out approximation).
    const easeOut = (t) => 1 - Math.pow(1 - t, 3);
    if (rafRef.current) cancelAnimationFrame(rafRef.current);
    const tick = (now) => {
      const t = Math.min(1, (now - start) / duration);
      const angle = from + (to - from) * easeOut(t);
      if (groupRef.current) {
        // Use SVG's native transform attribute with rotate(angle cx cy) so
        // the pivot is reliably the compass center regardless of any CSS
        // transform-box / transform-origin quirks.
        groupRef.current.setAttribute('transform', `rotate(${angle} 40 40)`);
      }
      if (t < 1) rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    lastDeg.current = to;
    return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
  }, [deg]);
  return (
    <svg width={size} height={size} viewBox="0 0 80 80" style={{ flex: '0 0 auto' }}>
      <circle cx="40" cy="40" r="38" fill="rgba(2,16,43,0.05)" stroke={navy} strokeWidth="1" />
      <circle cx="40" cy="40" r="30" fill="none" stroke={navy} strokeWidth="0.5" opacity="0.3" />
      {/* Tick marks on the cardinal directions */}
      <g stroke={navy} strokeWidth="0.8" opacity="0.45">
        <line x1="40" y1="3"  x2="40" y2="9" />
        <line x1="77" y1="40" x2="71" y2="40" />
        <line x1="40" y1="77" x2="40" y2="71" />
        <line x1="3"  y1="40" x2="9"  y2="40" />
      </g>
      <g ref={groupRef} transform="rotate(0 40 40)">
        <path d="M 40 10 L 46 30 L 40 26 L 34 30 Z" fill={accent} />
        <line x1="40" y1="30" x2="40" y2="60" stroke={accent} strokeWidth="1.5" />
      </g>
      <text x="40" y="20" textAnchor="middle" fontSize="6" fill={navy} fontFamily="Bai Jamjuree" fontWeight="700" letterSpacing="0.15em">N</text>
    </svg>
  );
}

window.CompassArrow = CompassArrow;
window.dirToDegrees = dirToDegrees;

// ─────────────────────────────────────────────────────────────────────────
// AutoFitText — single-line headline that scales its own font-size DOWN to
// fit its parent's width. Use for big Heavitas display headlines so long
// place names (e.g. "WASHINGTON") shrink to fit rather than wrap.
// Pass baseSize (px) — that's the ceiling. minScale is the lower bound.
//
// Robust against:
//   - Flex/grid layouts (sets min-width: 0 up the ancestor chain so the
//     flex item can actually shrink below its content's intrinsic width;
//     default min-width:auto prevents this and makes the auto-fit no-op).
//   - Parent resizes (ResizeObserver re-fits whenever the available width
//     changes; handles tweaks, ratio switches, mode switches).
// ─────────────────────────────────────────────────────────────────────────
function AutoFitText({ children, baseSize, minScale = 0.3, style, as = 'div' }) {
  const ref = React.useRef(null);

  const fit = React.useCallback(() => {
    const el = ref.current;
    if (!el || !el.parentElement) return;
    // Set min-width: 0 on self + the next few ancestors so flex/grid items
    // can shrink past their intrinsic content min-width. Without this,
    // a flex item containing nowrap text refuses to go narrower than the
    // text, so parent.clientWidth == textW and the fit becomes a no-op.
    el.style.minWidth = '0';
    let p = el.parentElement;
    for (let i = 0; i < 4 && p && p !== document.body; i++) {
      p.style.minWidth = '0';
      p = p.parentElement;
    }
    // Reset to base size and measure intrinsic text width at that size.
    el.style.fontSize = baseSize + 'px';
    const containerW = el.parentElement.clientWidth || el.parentElement.offsetWidth;
    if (!containerW || containerW < 10) return;
    const textW = el.scrollWidth;
    if (textW > containerW) {
      const scale = Math.max(minScale, (containerW / textW) * 0.97);
      el.style.fontSize = (baseSize * scale) + 'px';
    }
  }, [baseSize, minScale]);

  React.useLayoutEffect(() => { fit(); });

  // Re-fit when the parent's width changes (ratio swap, tweak panel changes,
  // window resize, etc.).
  React.useEffect(() => {
    if (typeof ResizeObserver === 'undefined') return;
    const el = ref.current;
    if (!el || !el.parentElement) return;
    const ro = new ResizeObserver(() => fit());
    ro.observe(el.parentElement);
    return () => ro.disconnect();
  }, [fit]);

  const Tag = as;
  return (
    <Tag ref={ref} style={{ whiteSpace: 'nowrap', ...style, fontSize: baseSize + 'px' }}>{children}</Tag>
  );
}

window.AutoFitText = AutoFitText;
