// shared.jsx — common atoms used by all three directions
// noise textures, marble placeholders, audio waveforms, icons, etc.

// SVG noise filter — call NoiseFilter() once at root and reference url(#noise)
function NoiseFilter({ id = "noise", baseFreq = 0.9, opacity = 0.4 }) {
  return (
    <svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
      <filter id={id}>
        <feTurbulence type="fractalNoise" baseFrequency={baseFreq} numOctaves="2" stitchTiles="stitch" />
        <feColorMatrix values={`0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 ${opacity} 0`} />
      </filter>
    </svg>
  );
}

// A marble-ish texture made of layered radial gradients + noise.
// Pass a palette to recolor for each direction.
function MarbleField({ palette, style, seed = 1 }) {
  const p = palette;
  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        background: `
          radial-gradient(ellipse at ${20 + seed * 7}% ${30 + seed * 5}%, ${p[1]} 0%, transparent 50%),
          radial-gradient(ellipse at ${70 - seed * 3}% ${60 + seed * 4}%, ${p[2]} 0%, transparent 60%),
          radial-gradient(ellipse at ${50 + seed * 2}% ${85 - seed * 6}%, ${p[3]} 0%, transparent 55%),
          ${p[0]}
        `,
        ...style,
      }}
    />
  );
}

// Subtle cracked-marble vein lines using SVG paths
function MarbleVeins({ color = "rgba(255,255,255,0.08)", style }) {
  return (
    <svg viewBox="0 0 800 600" preserveAspectRatio="none" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", ...style }}>
      <g fill="none" stroke={color} strokeWidth="0.6">
        <path d="M -20 120 C 100 80, 180 200, 280 140 S 480 220, 580 160 S 780 240, 820 180" />
        <path d="M -20 280 C 80 320, 220 240, 320 300 S 520 360, 620 290 S 780 340, 820 310" />
        <path d="M -20 460 C 120 420, 240 500, 380 440 S 580 510, 700 460 S 820 470, 820 470" />
        <path d="M 80 -20 C 60 100, 140 200, 100 320 S 60 480, 90 620" strokeWidth="0.4" />
        <path d="M 420 -20 C 460 140, 380 280, 440 400 S 480 540, 460 620" strokeWidth="0.4" />
      </g>
      <g fill="none" stroke={color} strokeWidth="0.3" opacity="0.6">
        <path d="M 0 80 L 200 100 L 400 60 L 600 110 L 800 70" />
        <path d="M 0 380 L 180 360 L 380 410 L 600 370 L 800 400" />
      </g>
    </svg>
  );
}

// Static-style audio waveform bars
function Waveform({ bars = 80, color = "currentColor", playedColor, progress = 0.34, height = 40, gap = 2, width }) {
  // deterministic pseudo-random
  const heights = React.useMemo(() => {
    const a = [];
    let s = 1;
    for (let i = 0; i < bars; i++) {
      s = (s * 9301 + 49297) % 233280;
      const r = s / 233280;
      // smoothed shape: louder in middle
      const env = Math.sin((i / bars) * Math.PI) * 0.6 + 0.4;
      a.push(Math.max(0.1, Math.min(1, r * 0.7 * env + 0.15)));
    }
    return a;
  }, [bars]);
  const playedC = playedColor || color;
  return (
    <div style={{ display: "flex", alignItems: "center", gap, height, width: width || "100%" }}>
      {heights.map((h, i) => {
        const played = i / bars < progress;
        return (
          <div
            key={i}
            style={{
              flex: 1,
              height: `${h * 100}%`,
              background: played ? playedC : color,
              opacity: played ? 1 : 0.35,
              borderRadius: 0.5,
            }}
          />
        );
      })}
    </div>
  );
}

// Simple play/pause icon button
function PlayBtn({ playing, onClick, size = 48, color = "#fff", bg = "transparent", border }) {
  return (
    <button
      onClick={onClick}
      aria-label={playing ? "pause" : "play"}
      style={{
        width: size,
        height: size,
        borderRadius: "50%",
        border: border || `1.5px solid ${color}`,
        background: bg,
        color,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        cursor: "pointer",
        padding: 0,
        transition: "transform .15s, background .15s",
      }}
      onMouseEnter={(e) => (e.currentTarget.style.transform = "scale(1.05)")}
      onMouseLeave={(e) => (e.currentTarget.style.transform = "scale(1)")}
    >
      {playing ? (
        <svg width={size * 0.34} height={size * 0.34} viewBox="0 0 24 24" fill={color}>
          <rect x="6" y="5" width="4" height="14" />
          <rect x="14" y="5" width="4" height="14" />
        </svg>
      ) : (
        <svg width={size * 0.34} height={size * 0.34} viewBox="0 0 24 24" fill={color} style={{ marginLeft: size * 0.04 }}>
          <path d="M7 4 L20 12 L7 20 Z" />
        </svg>
      )}
    </button>
  );
}

// ─── Engagement (heart + voice-memo play counters) ──────────────────────────
// Single browser-persistent client_id; same fan = same id across page loads.
// Bypassable via clearing localStorage; for a small artist site that's an
// acceptable abuse trade vs the cost of real auth.
function diavalGetClientId() {
  try {
    const KEY = "diavalClientId";
    let cid = localStorage.getItem(KEY);
    if (!cid) {
      // 22-char random base62-ish id
      cid = ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
        (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c/4).toString(16),
      ).replace(/-/g, "").slice(0, 22);
      localStorage.setItem(KEY, cid);
    }
    return cid;
  } catch { return "anonymous-no-storage"; }
}
window.diavalGetClientId = diavalGetClientId;

// Bump a counter on the server. Idempotent per (client_id, kind, row_id, counter)
// thanks to the engagements table's unique constraint, so calling twice from the
// same browser only counts once.
window.diavalEngage = async function (kind, rowId, counter) {
  const url = `${window.diavalSupabaseUrl || "https://ewehdfzcfjbzrlhgedxv.supabase.co"}/functions/v1/engage`;
  try {
    const r = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${window.diavalSupabaseAnonKey || ""}`,
        "apikey": window.diavalSupabaseAnonKey || "",
      },
      body: JSON.stringify({
        client_id: diavalGetClientId(),
        kind, row_id: String(rowId), counter,
      }),
    });
    return await r.json();
  } catch (e) {
    return { ok: false, error: String(e) };
  }
};

// Heart button — small icon + count, click toggles "liked" locally + fires increment.
// Liking is one-way (no un-like) — matches the UX of letterboxd / spotify's heart.
// Visual state persisted in localStorage so it stays "filled" across reloads.
//
// Public counts hide under 10 to avoid sad "1 like" optics. Admin sees raw counts
// in MythsEditor / TransmissionsEditor stats row.
//
// `forceShowCount` prop bypasses the threshold (admin views can pass true) — public
// surfaces leave it default.
const HEART_RED = "#dc143c"; // crimson — distinct from brand bloodHot (electric blue)
const HEART_PUBLIC_THRESHOLD = 10;
function Heart({ kind, rowId, initialCount = 0, S, size = 16, forceShowCount = false }) {
  const [count, setCount] = React.useState(initialCount);
  const [liked, setLiked] = React.useState(false);
  React.useEffect(() => { setCount(initialCount); }, [initialCount]);
  React.useEffect(() => {
    try {
      const k = `diavalLiked_${kind}_${rowId}`;
      if (localStorage.getItem(k) === "1") setLiked(true);
    } catch {}
  }, [kind, rowId]);

  const onClick = async (e) => {
    e.stopPropagation();
    if (liked) return; // already counted
    setLiked(true);
    setCount(c => c + 1); // optimistic
    try { localStorage.setItem(`diavalLiked_${kind}_${rowId}`, "1"); } catch {}
    const r = await window.diavalEngage(kind, rowId, "like");
    if (r && typeof r.count === "number") setCount(r.count);
  };

  const fmt = (n) => n >= 1000 ? `${(n/1000).toFixed(1)}k` : String(n);
  const showCount = forceShowCount || count >= HEART_PUBLIC_THRESHOLD;

  return (
    <button onClick={onClick} aria-label={liked ? "liked" : "like"} style={{
      display: "inline-flex", alignItems: "center", gap: 6,
      background: "transparent", border: "none", padding: "4px 6px",
      cursor: liked ? "default" : "pointer",
      color: liked ? HEART_RED : S.ash,
      fontFamily: S.meta, fontSize: 12, letterSpacing: "0.18em",
      transition: "color .15s",
    }}
    onMouseEnter={(e) => { if (!liked) e.currentTarget.style.color = HEART_RED; }}
    onMouseLeave={(e) => { if (!liked) e.currentTarget.style.color = S.ash; }}>
      {/* Heart svg — filled when liked, outline otherwise. Red regardless of state. */}
      <svg width={size} height={size} viewBox="0 0 24 24" fill={liked ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
        <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
      </svg>
      {showCount && count > 0 && <span>{fmt(count)}</span>}
    </button>
  );
}

// Pre-release helpers — a myth with release_date in the future (or presave_enabled set)
// shows a PRE-SAVE CTA pointing to its hyperfollow_url instead of streaming links.
// Used by mythology + listen + home pages so logic stays consistent.
// Normalize "2026.04.17" or "2026-04-17" to ISO so JS Date parses correctly.
// release_date is stored as text in dot format throughout; many JS Date parsers
// reject dot separators when combined with T<time>.
function _normalizeReleaseDate(raw) {
  if (!raw) return null;
  return String(raw).replace(/\./g, "-");
}
function isPrerelease(releaseDate) {
  if (!releaseDate) return false;
  const iso = _normalizeReleaseDate(releaseDate);
  const today = new Date(); today.setHours(0, 0, 0, 0);
  const release = new Date(iso + "T00:00:00");
  if (Number.isNaN(release.getTime())) return false;
  return release.getTime() > today.getTime();
}
function countdownParts(releaseDate) {
  if (!releaseDate) return null;
  const iso = _normalizeReleaseDate(releaseDate);
  const release = new Date(iso + "T00:00:00");
  if (Number.isNaN(release.getTime())) return null;
  const ms = release.getTime() - Date.now();
  if (ms <= 0) return null;
  return {
    days:    Math.floor(ms / 86400000),
    hours:   Math.floor((ms % 86400000) / 3600000),
    minutes: Math.floor((ms % 3600000) / 60000),
  };
}
function useCountdown(releaseDate) {
  const [, setTick] = React.useState(0);
  React.useEffect(() => {
    if (!releaseDate || !isPrerelease(releaseDate)) return;
    const id = setInterval(() => setTick(t => t + 1), 60000);
    return () => clearInterval(id);
  }, [releaseDate]);
  return countdownParts(releaseDate);
}

// Resolve a pasted video URL to an embed strategy. Used by transmissions to render
// YouTube/Vimeo as iframes and direct mp4/webm/mov as native <video controls />.
// Anything unrecognized falls back to a "WATCH ↗" external link.
function videoEmbedFor(url) {
  if (!url || typeof url !== "string") return null;
  const u = url.trim();
  if (!u) return null;
  // YouTube: youtube.com/watch?v=ID, youtu.be/ID, youtube.com/shorts/ID
  let m = u.match(/(?:youtube\.com\/(?:watch\?v=|shorts\/|embed\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
  if (m) return { kind: "iframe", src: `https://www.youtube.com/embed/${m[1]}?rel=0` };
  // Vimeo: vimeo.com/12345 or player.vimeo.com/video/12345
  m = u.match(/vimeo\.com\/(?:video\/)?(\d+)/);
  if (m) return { kind: "iframe", src: `https://player.vimeo.com/video/${m[1]}` };
  // Direct video files
  if (/\.(mp4|webm|mov|m4v)(?:\?.*)?$/i.test(u)) return { kind: "video", src: u };
  // Unknown — link out
  return { kind: "link", src: u };
}
window.diavalVideoEmbedFor = videoEmbedFor;

// Strip ElevenLabs expressive-voice stage directions like "[nervous cough]" from
// text before public display. The DB body keeps them so TTS still acts on them;
// admin still sees them in editors. Used by mythology + transmissions public renders.
function stripVoiceCues(s) {
  if (!s) return s;
  return String(s)
    .replace(/[ \t]*\[[^\]]*\][ \t]*/g, ' ')  // strip [cue] + adjacent space/tab
    .replace(/[ \t]{2,}/g, ' ')                // collapse runs of spaces/tabs
    .replace(/^[ \t]+/gm, '')                  // trim leading spaces on each line
    .replace(/[ \t]+$/gm, '');                 // trim trailing spaces on each line
}

// VoicePlayer — styled audio player used for myth voice memos + transmission voice memos.
// Real <audio> playback under the hood; the styled UI shows our waveform/speed/play button.
//   props: voiceUrl, eyebrow (top-bar text), caption (bottom-right text), S
//          mode: "expandable" (default; collapsed pill until clicked) | "static" (always expanded, no HIDE)
//          collapsedLabel (overrides "LISTEN · m:ss" pill text)
//          fallbackDur (used until audio metadata loads, in seconds)
function VoicePlayer({ voiceUrl, eyebrow, caption, S, mode = "expandable", collapsedLabel, fallbackDur = 0, engageKind, engageRowId }) {
  const [expanded, setExpanded] = React.useState(mode === "static");
  const [playing, setPlaying] = React.useState(false);
  const [currentTime, setCurrentTime] = React.useState(0);
  const [duration, setDuration] = React.useState(0);
  const [speed, setSpeed] = React.useState(1);
  const audioRef = React.useRef(null);
  const playFiredRef = React.useRef(false);
  const { isMobile } = (window.useViewport ? window.useViewport() : { isMobile: false });

  React.useEffect(() => {
    const a = audioRef.current;
    if (!a) return;
    if (playing) {
      a.play().catch(() => setPlaying(false));
      // Fire voice-play count once per browser per row (server dedupes via client_id+row+counter)
      if (!playFiredRef.current && engageKind && engageRowId && window.diavalEngage) {
        playFiredRef.current = true;
        window.diavalEngage(engageKind, engageRowId, "voice_play");
      }
    } else {
      a.pause();
    }
  }, [playing, engageKind, engageRowId]);

  React.useEffect(() => {
    if (audioRef.current) audioRef.current.playbackRate = speed;
  }, [speed]);

  if (!voiceUrl) return null;

  const fmt = (s) => { s = Math.max(0, Math.floor(s)); return `${Math.floor(s/60)}:${String(s%60).padStart(2,"0")}`; };
  const progress = duration > 0 ? currentTime / duration : 0;
  const displayDur = duration > 0 ? duration : fallbackDur;

  // Public player: hidden HTML audio, no native controls, no download button.
  // controlsList="nodownload" + disablePictureInPicture + onContextMenu block
  // are belt-and-suspenders — the audio src URL is still in the page source,
  // but the visible UI provides no download path.
  const audioEl = (
    <audio
      ref={audioRef}
      src={voiceUrl}
      preload="metadata"
      controlsList="nodownload noplaybackrate"
      disablePictureInPicture
      onContextMenu={(e) => e.preventDefault()}
      onLoadedMetadata={(e) => setDuration(e.currentTarget.duration || 0)}
      onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime || 0)}
      onEnded={() => { setPlaying(false); setCurrentTime(0); }}
      style={{ display: "none" }}
    />
  );

  if (!expanded) {
    return (
      <>
        <button onClick={(e) => { e.stopPropagation(); setExpanded(true); }} style={{
          display: "flex", alignItems: "center", gap: 10, marginBottom: 14,
          background: "transparent", border: `1px solid ${S.concreteHi}`,
          padding: "8px 14px", color: S.ash, cursor: "pointer",
          fontFamily: "'JetBrains Mono', monospace", fontSize: 12, letterSpacing: "0.25em",
          transition: "border .15s, color .15s",
        }}
        onMouseEnter={(e) => { e.currentTarget.style.borderColor = S.bloodHot; e.currentTarget.style.color = S.bone; }}
        onMouseLeave={(e) => { e.currentTarget.style.borderColor = S.concreteHi; e.currentTarget.style.color = S.ash; }}>
          <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4 L20 12 L7 20 Z"/></svg>
          {collapsedLabel || `LISTEN · ${fmt(displayDur)}`}
          <span style={{ color: S.bloodHot, marginLeft: 4 }}>◢</span>
        </button>
        {audioEl}
      </>
    );
  }

  // Click + drag to seek. Translates pointer x → ratio of width → audio.currentTime.
  const onSeekPointerDown = (e) => {
    if (!audioRef.current || !duration) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    const seek = (clientX) => {
      const rect = e.currentTarget.getBoundingClientRect();
      const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
      if (audioRef.current) audioRef.current.currentTime = ratio * duration;
    };
    seek(e.clientX);
    const move = (ev) => seek(ev.clientX);
    const up = () => {
      e.currentTarget?.removeEventListener?.("pointermove", move);
      e.currentTarget?.removeEventListener?.("pointerup", up);
      e.currentTarget?.removeEventListener?.("pointercancel", up);
    };
    e.currentTarget.addEventListener("pointermove", move);
    e.currentTarget.addEventListener("pointerup", up);
    e.currentTarget.addEventListener("pointercancel", up);
  };

  const PlayButton = (
    <button onClick={() => setPlaying(!playing)} style={{
      width: 40, height: 40, borderRadius: "50%",
      background: S.bloodHot, border: "none", color: S.bone,
      cursor: "pointer", padding: 0, display: "flex", alignItems: "center", justifyContent: "center",
      flexShrink: 0,
    }}>
      {playing
        ? <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>
        : <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4 L20 12 L7 20 Z"/></svg>}
    </button>
  );

  const SpeedPills = (
    <div style={{ display: "flex", gap: 0, border: `1px solid ${S.concreteHi}`, flexShrink: 0 }}>
      {[1, 1.25, 1.5].map((sp) => (
        <button key={sp} onClick={() => setSpeed(sp)} style={{
          padding: "6px 10px", fontSize: 11, letterSpacing: "0.12em",
          background: speed === sp ? S.bloodHot : "transparent",
          color: speed === sp ? S.bone : S.ash, border: "none", cursor: "pointer",
          fontFamily: "'JetBrains Mono', monospace",
        }}>{sp}×</button>
      ))}
    </div>
  );

  const Wave = (
    <div onPointerDown={onSeekPointerDown}
      style={{ cursor: duration > 0 ? "pointer" : "default", touchAction: "none", userSelect: "none" }}
      title={duration > 0 ? "click or drag to seek" : ""}>
      <Waveform bars={isMobile ? 56 : 48} color={S.ashDim} playedColor={S.bloodHot} progress={progress} height={isMobile ? 36 : 22} />
    </div>
  );

  return (
    <div onClick={(e) => e.stopPropagation()} style={{
      marginBottom: 14, border: `1px solid ${S.concreteHi}`, background: S.concrete,
      borderLeft: `3px solid ${S.bloodHot}`,
    }}>
      {/* Eyebrow row — click to collapse on expandable mode (replaces explicit HIDE button).
          Eyebrow text truncates with ellipsis instead of wrapping awkwardly on narrow screens. */}
      <div
        onClick={mode === "expandable" ? () => { setPlaying(false); setExpanded(false); } : undefined}
        role={mode === "expandable" ? "button" : undefined}
        aria-expanded={mode === "expandable" ? true : undefined}
        style={{
          padding: "10px 14px", borderBottom: `1px solid ${S.concreteHi}`,
          display: "flex", justifyContent: "space-between", alignItems: "center",
          fontSize: 12, letterSpacing: "0.3em", gap: 12,
          cursor: mode === "expandable" ? "pointer" : "default",
          userSelect: "none",
        }}>
        <span style={{ color: S.bloodHot, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 }}>
          {eyebrow || "◢ VOICE MEMO"}
        </span>
        {mode === "expandable" && (
          <span style={{
            fontSize: 11, color: S.ashDim, fontFamily: "'JetBrains Mono', monospace",
            letterSpacing: "0.2em",
            display: "inline-flex", alignItems: "center", gap: 6, flexShrink: 0,
          }}>
            <span style={{ display: "inline-block", transform: "rotate(180deg)" }}>▾</span>
          </span>
        )}
      </div>

      {/* Player body — mobile stacks vertically with a big full-width waveform.
          Desktop keeps the original single row. */}
      {isMobile ? (
        <div style={{ padding: 14, display: "flex", flexDirection: "column", gap: 12 }}>
          {Wave}
          <div style={{
            display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10,
            fontSize: 12, letterSpacing: "0.25em", color: S.ash,
          }}>
            <span>{fmt(currentTime)} / {fmt(displayDur)}</span>
            {caption && <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, textAlign: "right" }}>{caption}</span>}
          </div>
          <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
            {PlayButton}
            {SpeedPills}
          </div>
        </div>
      ) : (
        <div style={{ padding: 14, display: "flex", alignItems: "center", gap: 14 }}>
          {PlayButton}
          <div style={{ flex: 1, minWidth: 0 }}>
            {Wave}
            <div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, fontSize: 12, letterSpacing: "0.25em", color: S.ash, gap: 14 }}>
              <span>{fmt(currentTime)} / {fmt(displayDur)}</span>
              {caption && <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{caption}</span>}
            </div>
          </div>
          {SpeedPills}
        </div>
      )}
      {audioEl}
    </div>
  );
}

// Streaming platform glyphs (mono, simple)
function Glyph({ name, size = 16, color = "currentColor" }) {
  const s = size;
  switch (name) {
    case "spotify":
      return (
        <svg width={s} height={s} viewBox="0 0 24 24" fill={color}>
          <circle cx="12" cy="12" r="10" fill="none" stroke={color} strokeWidth="1.6" />
          <path d="M7 9 Q12 7.5 17 10" fill="none" stroke={color} strokeWidth="1.6" strokeLinecap="round" />
          <path d="M7.5 12 Q12 11 16 13.2" fill="none" stroke={color} strokeWidth="1.4" strokeLinecap="round" />
          <path d="M8 15 Q12 14.3 15.5 16" fill="none" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
        </svg>
      );
    case "apple":
      return (
        <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5">
          <path d="M16.5 13c0-2.5 2-3.5 2-3.5-1-1.5-2.7-1.7-3.3-1.8-1.4-.1-2.7.9-3.4.9-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.4 3 2.3 1.2-.05 1.6-.8 3-.8 1.4 0 1.8.8 3 .8 1.3-.02 2.1-1.2 2.9-2.3.6-.8 1-1.6 1.3-2.5-2.6-1-2.9-3.6-2.9-3.9z" />
          <path d="M14 5.5c.6-.7 1-1.7.9-2.7-.9.05-2 .6-2.6 1.3-.5.6-1 1.6-.9 2.6 1 .1 2-.5 2.6-1.2z" />
        </svg>
      );
    case "youtube":
      return (
        <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5">
          <rect x="2.5" y="6" width="19" height="12" rx="2.5" />
          <path d="M10 9.5 L15 12 L10 14.5 Z" fill={color} stroke="none" />
        </svg>
      );
    case "instagram":
      return (
        <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5">
          <rect x="3.5" y="3.5" width="17" height="17" rx="4" />
          <circle cx="12" cy="12" r="3.8" />
          <circle cx="17" cy="7" r="0.8" fill={color} />
        </svg>
      );
    case "raven":
      return (
        <svg width={s} height={s} viewBox="0 0 24 24" fill={color}>
          <path d="M3 14 C 5 11, 7 9, 11 9 L 13 7 L 14.5 7.6 L 14 9.2 C 17 9.6 19 11 20.5 13.5 L 18 13 C 19 14.5 19.5 16 19 17.5 L 17 16 C 17 17.8 16 19 14 19.5 L 13 18 L 12 19.5 L 10.5 18.2 L 9 19.5 L 8 17.8 C 6 17.5 4.5 16.5 3 14 Z M 13.8 8 L 15.5 7 L 14.6 8.3 Z" />
          <circle cx="13" cy="11" r="0.6" fill="#fff" />
        </svg>
      );
    case "arrow":
      return (
        <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="square">
          <path d="M5 12 H19 M13 6 L19 12 L13 18" />
        </svg>
      );
    case "ytmusic":
      return (
        <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5">
          <rect x="2.5" y="6" width="19" height="12" rx="2.5" />
          <path d="M10 9.5 L15 12 L10 14.5 Z" fill={color} stroke="none" />
        </svg>
      );
    case "tidal":
    case "deezer":
    case "amazon":
    case "amazonstore":
    case "soundcloud":
    case "pandora":
    case "anghami":
    case "audius":
    case "boomplay":
    case "napster":
    case "yandex":
    case "itunes":
      return (
        <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5">
          <rect x="3.5" y="3.5" width="17" height="17" />
          <path d="M10 8.5 L16 12 L10 15.5 Z" fill={color} stroke="none" />
        </svg>
      );
    default:
      return null;
  }
}

// Viewport hook — three tiers:
//   isMobile : w < 768           (phones — hamburger nav)
//   isCompact: 768 <= w < 1280   (small laptops / iPad — full nav, tightened)
//   isDesktop: w >= 1280         (full nav with subtitle + broadcast)
//
// IMPORTANT: throttled and breakpoint-aware. On iOS Safari, the URL bar shows/hides
// during scroll which fires `resize`. If we re-render every component on every such
// event, scrolling stutters. We only update state when actually crossing one of the
// breakpoint boundaries (which never happens during a scroll-induced toolbar resize).
function useViewport() {
  const get = () => (typeof window === "undefined" ? 1200 : window.innerWidth);
  const tierFor = (w) => (w < 768 ? "mobile" : w < 1280 ? "compact" : "desktop");
  const [tier, setTier] = React.useState(() => tierFor(get()));
  React.useEffect(() => {
    let raf = 0;
    const on = () => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        const next = tierFor(get());
        setTier(prev => prev === next ? prev : next);
      });
    };
    window.addEventListener("resize", on);
    return () => { window.removeEventListener("resize", on); cancelAnimationFrame(raf); };
  }, []);
  return {
    isMobile: tier === "mobile",
    isCompact: tier === "compact",
    isDesktop: tier === "desktop",
  };
}

// ─── Image strip helper ──────────────────────────────────────
// Re-encodes an image via canvas, which guarantees EXIF/XMP/IPTC metadata is dropped
// (the canvas API has no way to carry it through). Auto-rotates iPhone photos using
// the EXIF Orientation tag before stripping. Returns a fresh Blob ready to upload.
//
// Output strategy: ALWAYS WebP at q=0.85 (alpha preserved, ~30% smaller than JPEG/PNG,
// universally supported in modern browsers). Falls back to JPEG if the browser refuses
// WebP encoding (very rare — old iOS <14, etc).
async function stripImageMetadata(file) {
  if (!(file instanceof File || file instanceof Blob)) {
    throw new Error("stripImageMetadata: expected a File/Blob");
  }
  const head = new Uint8Array(await file.slice(0, 16).arrayBuffer());
  if (head.length < 12) throw new Error("File too small to be a valid image");

  const isJPEG = head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff;
  const isPNG  = head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47 &&
                 head[4] === 0x0d && head[5] === 0x0a && head[6] === 0x1a && head[7] === 0x0a;
  const isGIF  = head[0] === 0x47 && head[1] === 0x49 && head[2] === 0x46 && head[3] === 0x38;
  const isWebP = head[0] === 0x52 && head[1] === 0x49 && head[2] === 0x46 && head[3] === 0x46 &&
                 head[8] === 0x57 && head[9] === 0x45 && head[10] === 0x42 && head[11] === 0x50;
  const isHEIC = head[4] === 0x66 && head[5] === 0x74 && head[6] === 0x79 && head[7] === 0x70 &&
                 ["heic", "heix", "hevc", "mif1", "msf1"]
                   .includes(String.fromCharCode(head[8], head[9], head[10], head[11]));

  if (isHEIC) throw new Error("HEIC not supported in browser. Export as JPEG first (Photos → File → Export → JPEG).");
  if (!isJPEG && !isPNG && !isGIF && !isWebP) throw new Error("Unrecognized image format. Use JPEG, PNG, GIF, or WebP.");

  // createImageBitmap with imageOrientation: "from-image" auto-rotates per EXIF Orientation
  let bitmap;
  try {
    bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
  } catch (e) {
    throw new Error(`Could not decode image: ${e.message}`);
  }

  const canvas = document.createElement("canvas");
  canvas.width = bitmap.width;
  canvas.height = bitmap.height;
  const ctx = canvas.getContext("2d");
  ctx.drawImage(bitmap, 0, 0);
  bitmap.close();

  // Try WebP first; if the browser returns null (rare), fall back to JPEG q=0.92.
  let blob = await new Promise((resolve) => {
    canvas.toBlob(b => resolve(b), "image/webp", 0.85);
  });
  let outputType = "image/webp";
  let extension = "webp";
  if (!blob) {
    blob = await new Promise((resolve, reject) => {
      canvas.toBlob(b => b ? resolve(b) : reject(new Error("Canvas toBlob returned null")), "image/jpeg", 0.92);
    });
    outputType = "image/jpeg";
    extension = "jpg";
  }

  return {
    blob,
    contentType: outputType,
    extension,
    width: canvas.width,
    height: canvas.height,
    originalSize: file.size,
    strippedSize: blob.size,
    detectedFormat: isJPEG ? "jpeg" : isPNG ? "png" : isGIF ? "gif" : "webp",
    animatedSourceWarning: isGIF || isWebP, // we only kept the first frame
  };
}

// Slugify a filename: lowercase, ascii, single hyphens, drop extension.
function slugifyFilename(name) {
  const base = String(name).replace(/\.[^.]+$/, "");
  return base
    .toLowerCase()
    .normalize("NFKD")
    .replace(/[^\w\s-]/g, "")
    .trim()
    .replace(/[\s_]+/g, "-")
    .replace(/-+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 80) || "image";
}

// expose to window for cross-script access.
// Note: the song catalog now lives in Supabase table `diaval_songs` — query it directly when needed.
// Decode HTML entities (e.g. &#x27; → ', &amp; → &). Spotify's API returns
// playlist descriptions HTML-encoded; same trick fixes any other server-imported
// text that comes back with entity refs.
function decodeHtml(s) {
  if (!s || typeof s !== "string") return s;
  if (typeof document === "undefined") return s;
  const ta = document.createElement("textarea");
  ta.innerHTML = s;
  return ta.value;
}

Object.assign(window, { NoiseFilter, MarbleField, MarbleVeins, Waveform, PlayBtn, Glyph, useViewport, stripImageMetadata, slugifyFilename, decodeHtml });

// ─── Typewriter "self-correcting" effect ─────────────────────
// Cycles through a list of variants, types, pauses, backspaces, retypes.
// Diaval correcting himself — feels like he's still figuring out the words.
function TypewriterCycle({ variants, finalIndex = 0, typeMs = 70, eraseMs = 40, holdMs = 1800, finalHoldMs = 5000, cursor = "▌", style = {}, cycleOnce = false }) {
  const [text, setText] = React.useState(variants[finalIndex] || "");
  const [showCursor, setShowCursor] = React.useState(true);
  const [done, setDone] = React.useState(false);

  React.useEffect(() => {
    let cancelled = false;
    let i = 0; // visit order: start at finalIndex, then cycle to others, end on finalIndex
    const order = [];
    for (let k = 0; k < variants.length; k++) {
      order.push((finalIndex + k) % variants.length);
    }

    async function tick() {
      while (!cancelled) {
        // hold
        await sleep(i === 0 ? 1500 : holdMs);
        if (cancelled) return;
        // cycleOnce: after we've shown every variant once and are back on the
        // final one, stop here so we don't loop forever (height stays stable
        // and the user isn't visually distracted on every page revisit).
        if (cycleOnce && i >= variants.length) {
          setDone(true);
          return;
        }
        let cur = variants[order[i % order.length]];
        const next = variants[order[(i + 1) % order.length]];
        let prefix = 0;
        while (prefix < cur.length && prefix < next.length && cur[prefix] === next[prefix]) prefix++;
        for (let n = cur.length; n > prefix; n--) {
          if (cancelled) return;
          setText(cur.slice(0, n - 1));
          await sleep(eraseMs);
        }
        for (let n = prefix + 1; n <= next.length; n++) {
          if (cancelled) return;
          setText(next.slice(0, n));
          await sleep(typeMs + Math.random() * 30);
        }
        i++;
      }
    }
    function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

    tick();
    const blink = setInterval(() => setShowCursor(s => !s), 530);
    return () => { cancelled = true; clearInterval(blink); };
  }, []); // run once

  return (
    <span style={style}>
      {text}
      <span style={{ opacity: done ? 0 : (showCursor ? 1 : 0), transition: "opacity 0.05s", marginLeft: 2 }}>{cursor}</span>
    </span>
  );
}

window.TypewriterCycle = TypewriterCycle;

// ─── Social icons row ───
// Reads diaval_socials, renders only the rows with is_visible AND a non-empty url.
// Returns null when nothing to show — pages can mount unconditionally.
function SocialIcons({ S, align = "flex-start", size = "default" }) {
  const [items, setItems] = React.useState([]);
  React.useEffect(() => {
    let cancelled = false;
    window.diavalSupabase
      .from("diaval_socials")
      .select("platform, label, url, position")
      .eq("is_visible", true)
      .order("position", { ascending: true })
      .then(({ data }) => {
        if (cancelled) return;
        const visible = (data || []).filter(s => s.url && String(s.url).trim());
        setItems(visible);
      });
    return () => { cancelled = true; };
  }, []);
  if (!items.length) return null;
  const padY = size === "small" ? 5 : 7;
  const padX = size === "small" ? 10 : 12;
  const fs = size === "small" ? 10 : 11;
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8, justifyContent: align }}>
      {items.map(s => (
        <a key={s.platform} href={s.url} target="_blank" rel="noreferrer"
           style={{
             fontSize: fs, letterSpacing: "0.25em", color: S.ash,
             textDecoration: "none", border: `1px solid ${S.concreteHi}`,
             padding: `${padY}px ${padX}px`, fontFamily: S.meta,
             transition: "color .15s, border-color .15s",
           }}
           onMouseEnter={(e) => { e.currentTarget.style.color = S.bloodHot; e.currentTarget.style.borderColor = S.bloodHot; }}
           onMouseLeave={(e) => { e.currentTarget.style.color = S.ash; e.currentTarget.style.borderColor = S.concreteHi; }}>
          {(s.label || s.platform.toUpperCase())} ↗
        </a>
      ))}
    </div>
  );
}
window.SocialIcons = SocialIcons;

// Site overlay — water shimmer + optional uploaded image, both rendered as
// fixed full-viewport layers behind all content. Reads diaval_site_overlay.
// Water shimmer: feTurbulence is computed ONCE (no <animate> on baseFrequency)
// and the whole SVG layer is GPU-translated via CSS transform. Disabled on
// mobile and when the user prefers reduced motion. Was melting phones before.
function SiteOverlay() {
  const { isMobile } = (window.useViewport ? window.useViewport() : { isMobile: false });
  const cachedCfg = window.diavalCacheGet ? window.diavalCacheGet("site-overlay") : null;
  const [cfg, setCfg] = React.useState(cachedCfg);
  React.useEffect(() => {
    let cancelled = false;
    const fetcher = async () => {
      const { data } = await window.diavalSupabase
        .from("diaval_site_overlay")
        .select("image_url, image_opacity, image_blend_mode, water_enabled, water_intensity")
        .eq("id", 1)
        .maybeSingle();
      return data;
    };
    (window.diavalCachedFetch ? window.diavalCachedFetch("site-overlay", fetcher) : fetcher())
      .then((data) => { if (!cancelled) setCfg(data); });
    return () => { cancelled = true; };
  }, []);

  if (!cfg) return null;
  const showWater = cfg.water_enabled && (cfg.water_intensity ?? 0) > 0 && !isMobile;
  const showImage = !!cfg.image_url && (cfg.image_opacity ?? 0) > 0;
  if (!showWater && !showImage) return null;

  return (
    <>
      <style>{`
        @media (prefers-reduced-motion: no-preference) {
          .diaval-water-svg {
            animation: diavalWaterDrift 36s ease-in-out infinite;
            will-change: transform;
          }
        }
        @keyframes diavalWaterDrift {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1.06); }
          33%      { transform: translate3d(-1.5%, 1%, 0) scale(1.09); }
          66%      { transform: translate3d(1%, -1%, 0) scale(1.04); }
        }
      `}</style>

      {showImage && (
        <div aria-hidden="true" style={{
          position: "fixed", inset: 0, pointerEvents: "none", zIndex: 0,
          backgroundImage: `url(${cfg.image_url})`,
          backgroundSize: "cover", backgroundPosition: "center",
          opacity: (cfg.image_opacity || 0) / 100,
          mixBlendMode: cfg.image_blend_mode || "overlay",
        }} />
      )}

      {showWater && (
        <svg
          aria-hidden="true"
          className="diaval-water-svg"
          xmlns="http://www.w3.org/2000/svg"
          style={{
            position: "fixed", inset: 0, width: "100%", height: "100%",
            pointerEvents: "none", zIndex: 0,
            opacity: (cfg.water_intensity || 0) / 100,
            mixBlendMode: "screen",
            transformOrigin: "center center",
          }}>
          <filter id="diaval-water-turb">
            <feTurbulence type="fractalNoise" baseFrequency="0.013" numOctaves="2" seed="7" />
            <feColorMatrix values="
              0 0 0 0 0.12
              0 0 0 0 0.30
              0 0 0 0 0.55
              0 0 0 0.55 0" />
          </filter>
          <rect width="100%" height="100%" filter="url(#diaval-water-turb)" />
        </svg>
      )}
    </>
  );
}
window.SiteOverlay = SiteOverlay;

// Back-to-top — fixed bottom-right, fades in only after the user has scrolled
// past 50% of the page. No-op on pages that don't scroll. Quietly disappears
// when scrolled back near the top so it never crowds the UI.
function BackToTop({ S }) {
  const [show, setShow] = React.useState(false);
  React.useEffect(() => {
    const onScroll = () => {
      const max = (document.documentElement.scrollHeight || 0) - window.innerHeight;
      if (max <= 0) { setShow(false); return; }
      setShow(window.scrollY > max * 0.5);
    };
    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, []);
  const click = () => window.scrollTo({ top: 0, behavior: "smooth" });
  return (
    <button
      onClick={click}
      aria-label="Back to top"
      style={{
        position: "fixed", right: 16, bottom: "calc(20px + env(safe-area-inset-bottom, 0px))",
        width: 44, height: 44, padding: 0,
        background: "rgba(2,3,10,0.78)", color: S.ash,
        border: `1px solid ${S.concreteHi}`,
        backdropFilter: "blur(8px)", WebkitBackdropFilter: "blur(8px)",
        cursor: "pointer", zIndex: 45,
        display: "flex", alignItems: "center", justifyContent: "center",
        opacity: show ? 1 : 0,
        pointerEvents: show ? "auto" : "none",
        transform: show ? "translateY(0)" : "translateY(8px)",
        transition: "opacity .25s ease, transform .25s ease, color .15s, border-color .15s",
        fontFamily: "'JetBrains Mono', monospace", fontSize: 16,
      }}
      onMouseEnter={(e) => { e.currentTarget.style.color = S.bloodHot; e.currentTarget.style.borderColor = S.bloodHot; }}
      onMouseLeave={(e) => { e.currentTarget.style.color = S.ash; e.currentTarget.style.borderColor = S.concreteHi; }}>
      ▲
    </button>
  );
}
window.BackToTop = BackToTop;
