// Creator Studio — AI Ad Creator + Quote Card Generator

const STUDIO_IMAGE_MODELS = [
  { id: "fal-ai/nano-banana-2",              provider: "fal", label: "Nano Banana 2",    badge: "Fast",           cost: "~$0.02" },
  { id: "fal-ai/nano-banana-pro",            provider: "fal", label: "Nano Banana Pro",  badge: "Pro",            cost: "~$0.04" },
  { id: "fal-ai/flux-pro/v1.1",              provider: "fal", label: "Flux 1.1 Pro",     badge: "Photorealistic", cost: "~$0.05" },
  { id: "fal-ai/flux-pro/v1.1-ultra",        provider: "fal", label: "Flux Pro Ultra",   badge: "2K Detail",      cost: "~$0.06" },
  { id: "fal-ai/flux/dev",                   provider: "fal", label: "Flux Dev",          badge: "Quick draft",    cost: "~$0.01" },
  { id: "fal-ai/stable-diffusion-v35-large", provider: "fal", label: "SD 3.5 Large",     badge: "Artistic",       cost: "~$0.04" },
  { id: "fal-ai/recraft-v3",                 provider: "fal", label: "Recraft V3",        badge: "Design + Text",  cost: "~$0.04" },
];

const STUDIO_VIDEO_MODELS = [
  { id: "bytedance/seedance-2.0/text-to-video",        provider: "fal", label: "Seedance 2.0",       badge: "Cinematic",   cost: "~$0.50" },
  { id: "bytedance/seedance-2.0/fast/text-to-video",   provider: "fal", label: "Seedance 2.0 Fast",  badge: "Fast",        cost: "~$0.20" },
  { id: "fal-ai/kling-video/v3/pro/text-to-video",     provider: "fal", label: "Kling 3 Pro",        badge: "Latest",      cost: "~$0.45" },
  { id: "fal-ai/kling-video/v2.6/pro/text-to-video",   provider: "fal", label: "Kling 2.6 Pro",      badge: "Best quality",cost: "~$0.35" },
  { id: "fal-ai/kling-video/v1.6/pro/text-to-video",   provider: "fal", label: "Kling 1.6 Pro",      badge: "Balanced",    cost: "~$0.28" },
  { id: "fal-ai/minimax/hailuo-2.3/pro/text-to-video", provider: "fal", label: "MiniMax Hailuo 2.3", badge: "Quick",       cost: "~$0.20" },
  { id: "fal-ai/luma-dream-machine/ray-2",             provider: "fal", label: "Luma Ray 2",         badge: "Film-like",   cost: "~$0.50" },
];

const AD_FORMATS = [
  { id: "feed-image",   label: "Feed Image Ad",  icon: "Image",    ratio: "1:1",  w: 1080, h: 1080, type: "image", desc: "Square post for Facebook, Instagram & X feed" },
  { id: "story-image",  label: "Story / Reel",   icon: "Play",     ratio: "9:16", w: 1080, h: 1920, type: "image", desc: "Full-screen vertical for Stories, Reels & TikTok" },
  { id: "landscape",    label: "Landscape Ad",   icon: "Image",    ratio: "16:9", w: 1920, h: 1080, type: "image", desc: "Wide banner for YouTube, X & Facebook feed" },
  { id: "feed-video",   label: "Feed Video Ad",  icon: "Play",     ratio: "1:1",  w: 1080, h: 1080, type: "video", desc: "Square video for Facebook & Instagram feed" },
  { id: "story-video",  label: "Story Video",    icon: "Play",     ratio: "9:16", w: 1080, h: 1920, type: "video", desc: "Vertical video ad for Stories & Reels" },
  { id: "youtube-ad",   label: "YouTube Ad",     icon: "Play",     ratio: "16:9", w: 1920, h: 1080, type: "video", desc: "Pre-roll or in-feed video for YouTube" },
];

const OVERLAY_POSITIONS = [
  { id: "top",    label: "Top" },
  { id: "center", label: "Center" },
  { id: "bottom", label: "Bottom" },
];

// ─────────────────────────────────────────────────────────────────────────
// Quote Card Studio — branded motion graphics for sermon-quote recaps.
// Renders a quote card on <canvas> frame-by-frame; exports a still
// (canvas.toBlob) or an animated clip (canvas.captureStream → MediaRecorder).
// Text is pixel-perfect and brand-exact — AI video models can't render
// reliable typography, so a canvas renderer is the right tool for
// text-driven, on-brand, repeatable recaps. Colors + logo default to the
// active business's real brand (hub.data.workspace.brandColor / logoDataUrl).
// ─────────────────────────────────────────────────────────────────────────

const QUOTE_FORMATS = [
  { id: "square",    label: "Square",       ratio: "1:1",  w: 1080, h: 1080, icon: "Image" },
  { id: "story",     label: "Story / Reel", ratio: "9:16", w: 1080, h: 1920, icon: "Play" },
  { id: "landscape", label: "Landscape",    ratio: "16:9", w: 1920, h: 1080, icon: "Image" },
];

const QUOTE_MOTIONS = [
  { id: "fadeup",    label: "Fade Up",        desc: "Quote rises and fades in" },
  { id: "kinetic",   label: "Kinetic Words",  desc: "Words appear one by one" },
  { id: "drift",     label: "Gradient Drift", desc: "Living gradient background" },
  { id: "spotlight", label: "Spotlight",      desc: "Light sweeps behind the text" },
  { id: "still",     label: "Still Image",    desc: "No motion — export a still card" },
];

const QUOTE_TYPE_STYLES = [
  { id: "statement", label: "Bold Statement", quoteFont: "Satoshi", quoteWeight: 900, kickerFont: "Inter",         attrFont: "Inter",         leading: 1.12, quoteMark: false },
  { id: "editorial", label: "Editorial",      quoteFont: "Satoshi", quoteWeight: 700, kickerFont: "Inter",         attrFont: "Inter",         leading: 1.26, quoteMark: true  },
  { id: "modern",    label: "Modern Sans",    quoteFont: "Inter",   quoteWeight: 700, kickerFont: "Inter",         attrFont: "Inter",         leading: 1.18, quoteMark: false },
  { id: "mono",      label: "Mono Accent",    quoteFont: "Satoshi", quoteWeight: 700, kickerFont: "JetBrains Mono", attrFont: "JetBrains Mono", leading: 1.18, quoteMark: false },
];

const QUOTE_PALETTES = [
  { id: "brand",    label: "Brand" }, // derived from workspace brandColor at runtime
  { id: "onyx",     label: "Onyx",     bg: "#0a0a0a", text: "#ffffff", accent: "#22c55e" }, // black + green (ChurchGrid)
  { id: "midnight", label: "Midnight", bg: "#14100e", text: "#f5ece4", accent: "#f0653f" },
  { id: "cream",    label: "Cream",    bg: "#f5ece4", text: "#1c140f", accent: "#f0653f" },
  { id: "forest",   label: "Forest",   bg: "#0f2e22", text: "#eafff5", accent: "#1ad29c" },
  { id: "indigo",   label: "Indigo",   bg: "#1a1740", text: "#ece9ff", accent: "#8b7bff" },
];

const QUOTE_PLACEHOLDER = "Type your sermon quote here and watch it come to life.";
const QUOTE_DURATIONS = [5, 8, 12];

// ── color + text helpers (q-prefixed to avoid collisions) ──
function qHexToRgb(hex) {
  const h = String(hex || "").replace("#", "");
  const f = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
  const n = parseInt(f || "000000", 16);
  return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function qShade(hex, pct) {
  const { r, g, b } = qHexToRgb(hex);
  const m = (c) => (pct >= 0 ? Math.round(c + (255 - c) * pct) : Math.round(c * (1 + pct)));
  const to = (c) => ("0" + Math.max(0, Math.min(255, m(c))).toString(16)).slice(-2);
  return `#${to(r)}${to(g)}${to(b)}`;
}
function qAlpha(hex, a) { const { r, g, b } = qHexToRgb(hex); return `rgba(${r},${g},${b},${a})`; }
function qLuma(hex) { const { r, g, b } = qHexToRgb(hex); return (0.299 * r + 0.587 * g + 0.114 * b) / 255; }
function qClamp(x, a, b) { return Math.max(a, Math.min(b, x)); }
function qEaseOut(x) { const c = qClamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }

function qWrap(ctx, text, maxW) {
  const raw = String(text).split(/\s+/).filter(Boolean);
  // Hard-break any single token wider than the line so it can never overflow
  // horizontally (e.g. a long URL or run-on word).
  const words = [];
  for (const w of raw) {
    if (ctx.measureText(w).width <= maxW) { words.push(w); continue; }
    let chunk = "";
    for (const ch of w) {
      if (chunk && ctx.measureText(chunk + ch).width > maxW) { words.push(chunk); chunk = ch; }
      else chunk += ch;
    }
    if (chunk) words.push(chunk);
  }
  const lines = []; let cur = "";
  for (const w of words) {
    const test = cur ? cur + " " + w : w;
    if (ctx.measureText(test).width > maxW && cur) { lines.push(cur); cur = w; } else cur = test;
  }
  if (cur) lines.push(cur);
  return lines.length ? lines : [""];
}
function qFitQuote(ctx, text, maxW, maxH, ts) {
  let size = Math.floor(maxW * 0.115);
  const min = Math.floor(maxW * 0.038);
  let lines = [], lineH = 0;
  while (size >= min) {
    ctx.font = `${ts.quoteWeight} ${size}px "${ts.quoteFont}", system-ui, sans-serif`;
    lines = qWrap(ctx, text, maxW);
    lineH = size * ts.leading;
    if (lines.length * lineH <= maxH && lines.length <= 8) break;
    size -= Math.max(2, Math.floor(size * 0.06));
  }
  const font = `${ts.quoteWeight} ${size}px "${ts.quoteFont}", system-ui, sans-serif`;
  // Even at the minimum size an extreme quote can exceed 8 lines — hard-cap and
  // ellipsize the last line instead of drawing off-canvas.
  if (lines.length > 8) {
    ctx.font = font;
    lines = lines.slice(0, 8);
    let last = lines[7];
    while (last && ctx.measureText(last + "…").width > maxW) last = last.replace(/\s*\S$/, "");
    lines[7] = (last || lines[7]) + "…";
  }
  return { size, lines, lineH, font };
}
function qCoverDraw(ctx, img, W, H) {
  const ir = img.width / img.height, cr = W / H;
  let dw, dh;
  if (ir > cr) { dh = H; dw = H * ir; } else { dw = W; dh = W / ir; }
  ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
}

// Layout (font fit-shrink + per-word measureText) depends only on text +
// format + typography — NOT on time or colour. Computing it every frame was
// the source of the choppiness, so we compute it ONCE here and cache it; the
// per-frame draw below then only applies cheap alpha/offset.
let qMeasureCtx = null;
function qMeasure() {
  if (!qMeasureCtx) { qMeasureCtx = document.createElement("canvas").getContext("2d"); }
  return qMeasureCtx;
}
function qComputeLayout(W, H, o) {
  const ctx = qMeasure();
  const ts = o.typeStyle;
  const pad = W * 0.10, maxW = W - pad * 2;
  const ql = qFitQuote(ctx, (o.quote || " "), maxW, H * 0.52, ts);
  const kSize = Math.max(13, Math.round(W * 0.0225));
  const aSize = Math.max(15, Math.round(W * 0.027));
  const hasKicker = Boolean((o.kicker || "").trim());
  const hasAttr = Boolean((o.attribution || "").trim());
  const ruleGap = Math.round(kSize * 1.2);
  const ruleH = Math.max(3, Math.round(W * 0.004));
  const kickerH = hasKicker ? (kSize + ruleGap + ruleH + Math.round(kSize * 1.4)) : 0;
  const quoteBlockH = ql.lines.length * ql.lineH;
  const attrH = hasAttr ? Math.round(aSize * 1.9) : 0;
  const markH = ts.quoteMark ? Math.round(W * 0.10) : 0;
  const groupH = markH + kickerH + quoteBlockH + attrH;
  const L = { pad, maxW, ql, kSize, aSize, hasKicker, hasAttr, ruleGap, ruleH, kickerH, attrH, markH, quoteBlockH };
  let y = (H - groupH) / 2;
  L.markY = y - Math.round(W * 0.03);
  if (markH) y += markH;
  L.kickerY = y;
  L.ruleY = y + kSize + ruleGap;
  if (hasKicker) y += kickerH;
  L.quoteY = y;
  if (o.motion === "kinetic") {
    ctx.font = ql.font;
    const spaceW = ctx.measureText(" ").width;
    let yy = y, idx = 0;
    const words = [];
    for (const line of ql.lines) {
      const toks = line.split(" ");
      const widths = toks.map((w) => ctx.measureText(w).width);
      const lineW = widths.reduce((s, w) => s + w, 0) + spaceW * Math.max(0, toks.length - 1);
      let x = W / 2 - lineW / 2;
      toks.forEach((tk, i) => { words.push({ text: tk, x, y: yy, idx }); x += widths[i] + spaceW; idx++; });
      yy += ql.lineH;
    }
    L.words = words;
  }
  y += quoteBlockH;
  L.attrY = y + Math.round(aSize * 0.5);
  return L;
}

// Cheap per-frame renderer: takes a precomputed layout `L` (or computes one if
// omitted) and only applies the time-based alpha/offset for the chosen motion.
// `t` is seconds. Used by both the live preview and the MediaRecorder export.
function drawQuoteFrame(ctx, W, H, t, o, L) {
  if (!L) L = qComputeLayout(W, H, o);
  ctx.save();
  ctx.clearRect(0, 0, W, H);

  // ── background ──
  if (o.bgImg) {
    qCoverDraw(ctx, o.bgImg, W, H);
    ctx.fillStyle = qAlpha("#000000", 0.52);
    ctx.fillRect(0, 0, W, H);
  } else if (o.bgStyle === "gradient" || o.motion === "drift") {
    const ang = o.motion === "drift" ? 0.6 + t * 0.12 : 0.7;
    const cx = W / 2, cy = H / 2, r = Math.hypot(W, H) / 2;
    const dx = Math.cos(ang) * r, dy = Math.sin(ang) * r;
    const g = ctx.createLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy);
    g.addColorStop(0, qShade(o.bgColor, 0.16));
    g.addColorStop(0.55, o.bgColor);
    g.addColorStop(1, qShade(o.bgColor, -0.30));
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, W, H);
  } else {
    ctx.fillStyle = o.bgColor;
    ctx.fillRect(0, 0, W, H);
  }
  if (o.motion === "spotlight") {
    const px = W * (0.5 + 0.34 * Math.sin(t * 0.8)), py = H * 0.40;
    const rg = ctx.createRadialGradient(px, py, 0, px, py, Math.max(W, H) * 0.75);
    rg.addColorStop(0, qAlpha(qShade(o.accentColor, 0.35), 0.30));
    rg.addColorStop(1, qAlpha(o.accentColor, 0));
    ctx.fillStyle = rg;
    ctx.fillRect(0, 0, W, H);
  }

  const ts = o.typeStyle;
  const still = o.motion === "still";
  const enter = (delay, span = 0.6) => (still ? 1 : qEaseOut((t - delay) / span));
  ctx.textBaseline = "top";

  // Subtle continuous float so a held frame still feels alive (skipped for
  // still exports). Tiny amplitude — it never drifts off-centre.
  if (!still) ctx.translate(0, Math.sin(t * 0.7) * (W * 0.0035));

  // ── quote mark (editorial) ──
  if (L.markH) {
    const a = enter(0.15, 0.5);
    ctx.globalAlpha = 0.22 * a;
    ctx.fillStyle = o.accentColor;
    ctx.textAlign = "center";
    ctx.font = `900 ${Math.round(W * 0.16)}px Georgia, "Satoshi", serif`;
    ctx.fillText("“", W / 2, L.markY);
    ctx.globalAlpha = 1;
  }

  // ── kicker + accent rule ──
  if (L.hasKicker) {
    const a = enter(0.1, 0.5);
    ctx.globalAlpha = a;
    ctx.textAlign = "center";
    ctx.fillStyle = o.accentColor;
    if ("letterSpacing" in ctx) ctx.letterSpacing = `${Math.round(L.kSize * 0.22)}px`;
    ctx.font = `700 ${L.kSize}px "${ts.kickerFont}", system-ui, sans-serif`;
    ctx.fillText(String(o.kicker).toUpperCase(), W / 2, L.kickerY);
    if ("letterSpacing" in ctx) ctx.letterSpacing = "0px";
    const rw = Math.round(W * 0.07 * a);
    ctx.fillStyle = qAlpha(o.accentColor, 0.9);
    ctx.fillRect(W / 2 - rw / 2, L.ruleY, rw, L.ruleH);
    ctx.globalAlpha = 1;
  }

  // ── quote ──
  ctx.fillStyle = o.textColor;
  ctx.font = L.ql.font;
  if (o.motion === "kinetic" && L.words) {
    ctx.textAlign = "left";
    for (const w of L.words) {
      const a = enter(0.45 + w.idx * 0.11, 0.45);
      ctx.globalAlpha = a;
      ctx.fillText(w.text, w.x, w.y + (1 - a) * 14);
    }
    ctx.globalAlpha = 1;
  } else {
    ctx.textAlign = "center";
    let yy = L.quoteY;
    L.ql.lines.forEach((line, i) => {
      const a = enter(0.4 + i * 0.12, 0.6);
      ctx.globalAlpha = a;
      const dy = o.motion === "fadeup" ? (1 - a) * 42 : 0;
      ctx.fillText(line, W / 2, yy + dy);
      yy += L.ql.lineH;
    });
    ctx.globalAlpha = 1;
  }

  // ── attribution ──
  if (L.hasAttr) {
    const attrDelay = 0.4 + (o.motion === "kinetic" ? 0.9 : L.ql.lines.length * 0.12 + 0.3);
    const a = enter(attrDelay, 0.6);
    ctx.globalAlpha = a;
    ctx.textAlign = "center";
    ctx.fillStyle = qAlpha(o.textColor, 0.78);
    if ("letterSpacing" in ctx) ctx.letterSpacing = `${Math.round(L.aSize * 0.05)}px`;
    ctx.font = `500 ${L.aSize}px "${ts.attrFont}", system-ui, sans-serif`;
    ctx.fillText(o.attribution, W / 2, L.attrY + (1 - a) * 10);
    if ("letterSpacing" in ctx) ctx.letterSpacing = "0px";
    ctx.globalAlpha = 1;
  }

  // ── logo (data URL → canvas-safe) ──
  if (o.logoImg) {
    const a = enter(0.05, 0.5);
    const maxLW = W * (o.format === "story" ? 0.20 : 0.15);
    const lw = Math.min(maxLW, o.logoImg.width);
    const lh = lw * (o.logoImg.height / o.logoImg.width);
    const lx = W / 2 - lw / 2;
    const ly = o.logoPos === "bottom" ? H - L.pad - lh : L.pad * 0.8;
    ctx.globalAlpha = a;
    ctx.drawImage(o.logoImg, lx, ly, lw, lh);
    ctx.globalAlpha = 1;
  }

  ctx.restore();
}

// Best-supported recording mime across browsers; prefers MP4 (broad social
// support), falls back to WebM. With audio, prefer an MP4+AAC codec string.
// Returns "" if MediaRecorder can't record video.
function qPickVideoMime(withAudio) {
  if (typeof window === "undefined" || !window.MediaRecorder) return "";
  const cands = withAudio ? [
    "video/mp4;codecs=avc1.42E01E,mp4a.40.2",
    "video/mp4;codecs=h264,aac",
    "video/mp4",
  ] : [
    "video/mp4;codecs=avc1.42E01E",
    "video/mp4;codecs=h264",
    "video/mp4",
    "video/webm;codecs=vp9",
    "video/webm;codecs=vp8",
    "video/webm",
  ];
  for (const c of cands) { try { if (MediaRecorder.isTypeSupported(c)) return c; } catch (e) {} }
  return "";
}

// Shared AudioContext for decoding uploaded audio + mixing it into exports.
let qAudioCtx = null;
function qGetAudioCtx() {
  if (!qAudioCtx) {
    const AC = window.AudioContext || window.webkitAudioContext;
    qAudioCtx = AC ? new AC() : null;
  }
  return qAudioCtx;
}

function QuoteCardStudio() {
  const hub = window.useClearCast();
  const ws = (hub && hub.data && hub.data.workspace) || {};
  const businesses = (hub && hub.data && hub.data.workspaces) || [];
  const activeBizId = (hub && hub.data && hub.data.activeWorkspaceId) || null;
  const isValidHex = (c) => typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c);
  const brand = isValidHex(ws.brandColor) ? ws.brandColor : "#14b88a";
  const brandText = qLuma(brand) > 0.6 ? "#1c140f" : "#ffffff";
  const brandAccent = qLuma(brand) > 0.6 ? qShade(brand, -0.4) : qShade(brand, 0.4);

  const [bizId, setBizId] = React.useState(activeBizId);
  const [fmt, setFmt] = React.useState(QUOTE_FORMATS[0]);
  const [quote, setQuote] = React.useState("");
  const [attribution, setAttribution] = React.useState("");
  const [kicker, setKicker] = React.useState("WEEKLY RECAP");
  // Keywords → hashtags for reach. Held over to the next quote (see newQuote) so
  // a weekly batch keeps the same discovery tags. tagPlacement decides whether
  // they go in the caption or a first comment (also held over).
  const [keywords, setKeywords] = React.useState("");
  const [tagPlacement, setTagPlacement] = React.useState("caption"); // "caption" | "comment"
  const [motion, setMotion] = React.useState("fadeup");
  const [typeStyleId, setTypeStyleId] = React.useState("statement");
  const [duration, setDuration] = React.useState(8);
  const [bgStyle, setBgStyle] = React.useState("gradient");
  const [bgColor, setBgColor] = React.useState(brand);
  const [textColor, setTextColor] = React.useState(brandText);
  const [accentColor, setAccentColor] = React.useState(brandAccent);
  const [logoDataUrl, setLogoDataUrl] = React.useState(ws.logoDataUrl || "");
  const [showLogo, setShowLogo] = React.useState(Boolean(ws.logoDataUrl));
  const [logoPos, setLogoPos] = React.useState("top");
  const [bgImageUrl, setBgImageUrl] = React.useState("");
  const [logoImg, setLogoImg] = React.useState(null);
  const [bgImg, setBgImg] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [rendering, setRendering] = React.useState(false);
  const [audioBuffer, setAudioBuffer] = React.useState(null);
  const [audioName, setAudioName] = React.useState("");
  const [schedOpen, setSchedOpen] = React.useState(false);
  const [selChannels, setSelChannels] = React.useState(null); // null = all connected
  const [schedDate, setSchedDate] = React.useState(() => { const d = new Date(); d.setDate(d.getDate() + 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; }); // local calendar, not UTC
  const [schedTime, setSchedTime] = React.useState("09:00");

  const canvasRef = React.useRef();
  const exportingRef = React.useRef(false);
  const logoInputRef = React.useRef();
  const bgInputRef = React.useRef();
  const audioInputRef = React.useRef();
  const audioBufferRef = React.useRef(null);
  audioBufferRef.current = audioBuffer;

  const typeStyle = QUOTE_TYPE_STYLES.find((s) => s.id === typeStyleId) || QUOTE_TYPE_STYLES[0];
  const opts = {
    quote: (quote.trim() || QUOTE_PLACEHOLDER),
    attribution: attribution.trim(),
    kicker,
    motion,
    typeStyle,
    duration,
    bgStyle,
    bgColor,
    textColor,
    accentColor,
    logoImg: showLogo ? logoImg : null,
    logoPos,
    bgImg,
    format: fmt.id,
  };
  const optsRef = React.useRef(opts);
  optsRef.current = opts;

  // Cache the expensive text layout; recompute only when text / format /
  // typography / motion change — never on colour changes or per frame.
  const layout = React.useMemo(
    () => qComputeLayout(fmt.w, fmt.h, opts),
    [opts.quote, opts.attribution, opts.kicker, fmt.w, fmt.h, motion, typeStyleId]
  );
  const layoutRef = React.useRef(layout);
  layoutRef.current = layout;

  // Load uploaded images into HTMLImageElements for canvas drawing.
  React.useEffect(() => {
    if (!logoDataUrl) { setLogoImg(null); return; }
    let cancelled = false;
    const img = new Image();
    img.onload = () => { if (!cancelled) setLogoImg(img); };
    img.onerror = () => { if (!cancelled) setLogoImg(null); };
    img.src = logoDataUrl;
    return () => { cancelled = true; };
  }, [logoDataUrl]);
  React.useEffect(() => {
    if (!bgImageUrl) { setBgImg(null); return; }
    let cancelled = false;
    const img = new Image();
    img.onload = () => { if (!cancelled) setBgImg(img); };
    img.onerror = () => { if (!cancelled) setBgImg(null); };
    img.src = bgImageUrl;
    return () => { cancelled = true; };
  }, [bgImageUrl]);
  // Reset channel selection to "all connected" when the active business changes
  // so a stale selection from another business can't carry over.
  React.useEffect(() => { setSelChannels(null); }, [activeBizId]);

  // Live preview loop — reads latest opts via ref so controls feel instant.
  React.useEffect(() => {
    const c = canvasRef.current;
    if (!c) return;
    c.width = fmt.w;
    c.height = fmt.h;
    const ctx = c.getContext("2d");
    let raf;
    const startedAt = performance.now();
    const loop = (now) => {
      raf = requestAnimationFrame(loop);
      if (exportingRef.current) return; // export loop owns the canvas while recording
      const o = optsRef.current;
      const L = layoutRef.current;
      if (o.motion === "still") { drawQuoteFrame(ctx, fmt.w, fmt.h, 9999, o, L); return; }
      const period = o.duration + 1.4; // play, then brief hold, then replay
      const tt = ((now - startedAt) / 1000) % period;
      drawQuoteFrame(ctx, fmt.w, fmt.h, tt, o, L);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [fmt.w, fmt.h]);

  const readFileAsDataUrl = async (file) => {
    let blob = file;
    const isHeic = file.type === "image/heic" || file.type === "image/heif" || /\.heic$/i.test(file.name) || /\.heif$/i.test(file.name);
    if (isHeic && window.heic2any) {
      blob = await window.heic2any({ blob: file, toType: "image/jpeg", quality: 0.88 });
      if (Array.isArray(blob)) blob = blob[0];
    }
    return await new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result);
      reader.onerror = reject;
      reader.readAsDataURL(blob);
    });
  };
  const onLogoFile = async (e) => {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    try { const url = await readFileAsDataUrl(file); setLogoDataUrl(url); setShowLogo(true); }
    catch (err) { hub.toast("Could not load that logo"); }
    finally { if (logoInputRef.current) logoInputRef.current.value = ""; }
  };
  const onBgFile = async (e) => {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    try { const url = await readFileAsDataUrl(file); setBgImageUrl(url); }
    catch (err) { hub.toast("Could not load that image"); }
    finally { if (bgInputRef.current) bgInputRef.current.value = ""; }
  };
  const onAudioFile = async (e) => {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    const ctx = qGetAudioCtx();
    if (!ctx) { hub.toast("Audio isn't supported in this browser"); return; }
    try {
      const arr = await file.arrayBuffer();
      // callback form of decodeAudioData for Safari compatibility; slice() so the
      // ArrayBuffer isn't detached if we ever re-decode.
      const buf = await new Promise((resolve, reject) => ctx.decodeAudioData(arr.slice(0), resolve, reject));
      setAudioBuffer(buf);
      setAudioName(file.name);
    } catch (err) { hub.toast("Couldn't read that audio file — try MP3, M4A or WAV"); }
    finally { if (audioInputRef.current) audioInputRef.current.value = ""; }
  };

  const applyPalette = (p) => {
    if (p.id === "brand") { setBgColor(brand); setTextColor(brandText); setAccentColor(brandAccent); }
    else { setBgColor(p.bg); setTextColor(p.text); setAccentColor(p.accent); }
  };

  // Derive a legible bg/text/accent trio from a single brand colour.
  const applyBrandColors = (bc) => {
    if (!isValidHex(bc)) return;
    setBgColor(bc);
    setTextColor(qLuma(bc) > 0.6 ? "#1c140f" : "#ffffff");
    setAccentColor(qLuma(bc) > 0.6 ? qShade(bc, -0.4) : qShade(bc, 0.4));
  };
  // Design for a specific business: pull its brand colour (from the list, instant)
  // and its logo (active business is already in memory; others fetched on demand).
  const applyBusiness = async (id) => {
    setBizId(id);
    const biz = businesses.find((b) => b.id === id);
    if (biz) applyBrandColors(biz.brandColor);
    if (id === activeBizId) {
      setLogoDataUrl(ws.logoDataUrl || "");
      setShowLogo(Boolean(ws.logoDataUrl));
      return;
    }
    try {
      const r = (window.ClearCastAPI && window.ClearCastAPI.getWorkspaceBrand) ? await window.ClearCastAPI.getWorkspaceBrand(id) : null;
      if (r) {
        applyBrandColors(r.brandColor);
        setLogoDataUrl(r.logoDataUrl || "");
        setShowLogo(Boolean(r.logoDataUrl));
      }
    } catch (e) { hub.toast("Couldn't load that business's logo — you can upload one below."); }
  };

  // Render the current card to a Blob — a still image (toBlob) or an animated
  // clip (captureStream → MediaRecorder, recorded in real time).
  const produce = async () => {
    if (document.fonts && document.fonts.ready) { try { await document.fonts.ready; } catch (e) {} }
    const c = canvasRef.current;
    const ctx = c.getContext("2d");
    const o = optsRef.current;
    const L = qComputeLayout(fmt.w, fmt.h, o); // compute layout once for the whole render

    if (o.motion === "still") {
      drawQuoteFrame(ctx, fmt.w, fmt.h, 9999, o, L);
      const blob = await new Promise((r) => c.toBlob(r, "image/jpeg", 0.95));
      return { blob, ext: "jpg", mime: "image/jpeg", kind: "image" };
    }

    const wantAudio = !!audioBufferRef.current;

    // Helper: draw the final frame and return a still JPEG (used as the graceful
    // fallback whenever video recording isn't possible on this browser).
    const stillFallback = async (msg) => {
      drawQuoteFrame(ctx, fmt.w, fmt.h, 9999, o, L);
      const blob = await new Promise((r) => c.toBlob(r, "image/jpeg", 0.95));
      if (msg) hub.toast(msg);
      return { blob, ext: "jpg", mime: "image/jpeg", kind: "image" };
    };

    if (typeof c.captureStream !== "function" || typeof window.MediaRecorder === "undefined") {
      return await stillFallback("Video export isn't supported in this browser — saved a still card instead.");
    }

    exportingRef.current = true;
    try {
      const fps = 30;
      const TOTAL = o.duration;
      // captureStream(fps) auto-samples the canvas at a fixed frame rate, giving
      // the encoder a defined rate so it initializes reliably. (captureStream(0)
      // + manual requestFrame triggered "encoder initialization failed" on some
      // Chrome builds.) A setInterval loop keeps advancing the canvas so the
      // animation is captured even if rAF would be throttled.
      const stream = c.captureStream(fps);
      const vTrack = (stream.getVideoTracks && stream.getVideoTracks()[0]) || null;

      // Optional audio track (built once, attached only to audio-capable configs).
      let aStart = () => {}, aCleanup = () => {}, aTrack = null;
      if (wantAudio) {
        try {
          const actx = qGetAudioCtx();
          // Resume so the audio clock is running before we start (a suspended
          // context would clip the audio) — but bound the wait: if resume never
          // settles (e.g. the gesture was lost) we must not hang the export.
          try { await Promise.race([actx.resume(), new Promise((r) => setTimeout(r, 1500))]); } catch (e) {}
          const dest = actx.createMediaStreamDestination();
          const gain = actx.createGain();
          gain.connect(dest);
          const srcNode = actx.createBufferSource();
          srcNode.buffer = audioBufferRef.current;
          srcNode.connect(gain);
          aTrack = dest.stream.getAudioTracks()[0] || null;
          aStart = () => {
            try {
              const t0 = actx.currentTime + 0.04;
              gain.gain.setValueAtTime(1, t0);
              gain.gain.setValueAtTime(1, t0 + Math.max(0.1, TOTAL - 0.6));
              gain.gain.linearRampToValueAtTime(0.0001, t0 + TOTAL);
              srcNode.start(t0);
            } catch (e) {}
          };
          aCleanup = () => { try { srcNode.stop(); } catch (e) {} try { srcNode.disconnect(); } catch (e) {} try { gain.disconnect(); } catch (e) {} };
        } catch (e) { aTrack = null; }
      }

      // MP4/H.264 only (publishable everywhere; WebM is rejected by X/IG/FB). We
      // actually construct AND start() each candidate until one succeeds, because
      // isTypeSupported() can report true for a mime the encoder still fails to
      // initialize. If none start, we fall back to a publishable still image.
      const avStream = aTrack ? new MediaStream([vTrack, aTrack]) : null;
      const configs = [];
      if (aTrack) {
        configs.push({ mime: "video/mp4;codecs=avc1.42E01E,mp4a.40.2", stream: avStream, audio: true });
        configs.push({ mime: "video/mp4", stream: avStream, audio: true });
      }
      configs.push({ mime: "video/mp4;codecs=avc1.42E01E", stream, audio: false });
      configs.push({ mime: "video/mp4;codecs=avc1", stream, audio: false });
      configs.push({ mime: "video/mp4", stream, audio: false });

      const chunks = [];
      let rec = null, chosen = null;
      drawQuoteFrame(ctx, fmt.w, fmt.h, 0, o, L); // prime a frame before starting
      for (const cfg of configs) {
        let ok = false;
        try { ok = MediaRecorder.isTypeSupported(cfg.mime); } catch (e) { ok = false; }
        if (!ok) continue;
        let r;
        try { r = new MediaRecorder(cfg.stream, { mimeType: cfg.mime, videoBitsPerSecond: 12000000 }); }
        catch (e) { continue; } // constructor rejected this mime → try next
        r.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
        // Start, then briefly probe: some browsers pass isTypeSupported + start()
        // but error ASYNCHRONOUSLY right after (e.g. can't mux the audio track),
        // producing an empty file. Catch that here so we fall through to a
        // simpler config (e.g. video-only) instead of shipping a broken clip.
        const started = await new Promise((resolve) => {
          let done = false;
          const settle = (v) => { if (!done) { done = true; resolve(v); } };
          r.onerror = () => settle(false);
          try { r.start(500); } catch (e) { settle(false); return; }
          setTimeout(() => settle(r.state === "recording"), 250);
        });
        if (!started) { try { r.stop(); } catch (e) {} chunks.length = 0; continue; }
        rec = r; chosen = cfg; break;
      }

      if (!rec) {
        aCleanup();
        return await stillFallback("This browser couldn't start the video encoder — saved a still card instead.");
      }

      // Kick off audio only if the chosen encoder actually carries it; otherwise
      // clean it up and tell the user the clip is silent.
      if (chosen.audio) aStart();
      else if (aTrack) { aCleanup(); hub.toast("Couldn't add audio on this browser — exported the video without sound."); }

      const mime = chosen.mime;
      // The recording promise ALWAYS settles — normal stop, recorder error, or a
      // hard safety timeout — so the UI can never deadlock on a stuck recorder.
      const blob = await new Promise((resolve) => {
        let iv = null, guard = null, settled = false;
        const cleanup = () => { if (iv) { clearInterval(iv); iv = null; } if (guard) { clearTimeout(guard); guard = null; } if (chosen.audio) aCleanup(); };
        const finish = () => { if (settled) return; settled = true; cleanup(); resolve(new Blob(chunks, { type: mime })); };
        rec.onstop = finish;
        rec.onerror = () => finish(); // salvage whatever was captured
        guard = setTimeout(() => { try { rec.requestData(); } catch (e) {} try { rec.stop(); } catch (e) {} setTimeout(finish, 150); }, (TOTAL + 4) * 1000);
        const start = performance.now();
        iv = setInterval(() => {
          try {
            const tt = (performance.now() - start) / 1000;
            drawQuoteFrame(ctx, fmt.w, fmt.h, tt, o, L);
            if (tt >= TOTAL) {
              if (iv) { clearInterval(iv); iv = null; }
              try { rec.requestData(); } catch (e) {}
              setTimeout(() => { try { rec.stop(); } catch (e) {} }, 80); // onstop → finish()
            }
          } catch (err) { finish(); }
        }, 1000 / fps);
      });

      if (!blob || blob.size < 2048) {
        // A few dozen bytes = a header with no real frames (recorder errored) →
        // treat as failure and hand back a usable still instead.
        return await stillFallback("Video export came back empty — saved a still card instead.");
      }
      return { blob, ext: mime.indexOf("mp4") !== -1 ? "mp4" : "webm", mime, kind: "video" };
    } finally {
      exportingRef.current = false;
    }
  };

  const canExport = quote.trim().length > 1;

  const connectedChannels = (hub.data.integrations || []).filter((i) => i.connected);
  const connectedIds = connectedChannels.map((i) => i.id);
  const chanActive = (id) => (selChannels ? selChannels.includes(id) : true);
  const toggleChannel = (id) => setSelChannels((prev) => { const base = prev || connectedIds; return base.includes(id) ? base.filter((x) => x !== id) : [...base, id]; });
  const bizMismatch = Boolean(bizId && activeBizId && bizId !== activeBizId);
  const activeName = (businesses.find((b) => b.id === activeBizId) || {}).name || "your business";
  const selectedName = (businesses.find((b) => b.id === bizId) || {}).name || "this business";
  // Resume the shared AudioContext under the click gesture so mixed-in audio plays.
  const primeAudio = () => { if (audioBuffer) { try { const a = qGetAudioCtx(); if (a && a.resume) a.resume(); } catch (e) {} } };

  // Turn the free-text keywords field into clean hashtags. Accepts commas or
  // spaces; multi-word keywords ("Sunday service") become one PascalCase tag
  // (#SundayService). Strips a leading # the user may add, drops symbols/emoji,
  // and de-dupes case-insensitively while keeping the first spelling.
  const keywordHashtags = (raw) => {
    const parts = String(raw || "").includes(",") ? String(raw).split(",") : String(raw || "").split(/\s+/);
    const seen = new Set();
    const tags = [];
    for (const part of parts) {
      const words = part.replace(/#/g, " ").replace(/[^\p{L}\p{N}\s]/gu, " ").trim().split(/\s+/).filter(Boolean);
      if (!words.length) continue;
      const tag = "#" + words.map((w) => w[0].toUpperCase() + w.slice(1)).join("");
      const key = tag.toLowerCase();
      if (!seen.has(key)) { seen.add(key); tags.push(tag); }
    }
    return tags.join(" ");
  };

  // The post's message body: the quote and its attribution ALWAYS go in the
  // caption (not just on the image). Keyword hashtags follow only when the user
  // keeps them "In caption"; in "First comment" mode they're returned separately.
  const buildCaption = () => {
    const body = [quote.trim(), attribution.trim()].filter(Boolean).join("\n");
    if (tagPlacement === "comment") return body;
    const tags = keywordHashtags(keywords);
    return [body, tags].filter(Boolean).join("\n\n");
  };
  // The hashtags when the user wants them in a first comment (empty otherwise).
  const buildFirstComment = () => (tagPlacement === "comment" ? keywordHashtags(keywords) : "");

  const newQuote = () => {
    // Keep keywords (and every style setting) — only the quote text + attribution
    // clear, so a weekly batch carries the same discovery hashtags forward.
    setQuote(""); setAttribution(""); setSchedOpen(false);
    const ta = document.querySelector(".quote-controls textarea");
    if (ta) { try { ta.focus(); } catch (e) {} }
  };

  const onDownload = async () => {
    if (!canExport || busy) return;
    primeAudio();
    setBusy(true); setRendering(true);
    try {
      const r = await produce();
      const url = URL.createObjectURL(r.blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `sermon-quote-${Date.now()}.${r.ext}`;
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 5000);
    } catch (e) { hub.toast("Export failed — try a shorter clip"); }
    finally { setBusy(false); setRendering(false); }
  };

  const uploadProduced = async (r) => {
    const file = new File([r.blob], `sermon-quote-${Date.now()}.${r.ext}`, { type: r.mime });
    return await hub.actions.uploadMedia(file, "all", { generated: true });
  };

  const onSave = async () => {
    if (!canExport || busy) return;
    primeAudio();
    setBusy(true); setRendering(true);
    try {
      const r = await produce();
      await uploadProduced(r);
      hub.toast("Saved your quote card to the Media Library");
    } catch (e) { hub.toast(e.message || "Could not save — try again"); }
    finally { setBusy(false); setRendering(false); }
  };

  const onComposer = async () => {
    if (!canExport || busy) return;
    primeAudio();
    setBusy(true); setRendering(true);
    try {
      const r = await produce();
      const asset = await uploadProduced(r);
      const caption = buildCaption();
      const firstComment = buildFirstComment();
      const payload = { url: asset.url, type: r.kind, mediaId: asset.id };
      if (caption) payload.caption = caption;
      if (firstComment) payload.firstComment = firstComment;
      try { sessionStorage.setItem("studio-inject-media", JSON.stringify(payload)); } catch (e) {}
      hub.setPage("create");
      hub.toast("Quote card dropped into the composer");
    } catch (e) { hub.toast(e.message || "Could not send to composer — try again"); }
    finally { setBusy(false); setRendering(false); }
  };

  // Schedule directly from the studio (reuses the same createPost pipeline as
  // the composer). Targets the ACTIVE business; guarded against a brand/active
  // mismatch so a card never schedules to the wrong business.
  const onSchedule = async () => {
    if (!canExport || busy) return;
    if (bizMismatch) { hub.toast(`Switch your active business to “${selectedName}” (top-left) to schedule there.`); return; }
    // Only ever schedule to channels actually connected on the active business
    // (guards against a stale selection carried over from another business).
    const channels = (selChannels || connectedIds).filter((id) => connectedIds.includes(id));
    if (!channels.length) { hub.toast("Connect a channel first (Settings → Integrations) to schedule."); return; }
    if (!schedDate || !schedTime) { hub.toast("Pick a date and time to schedule."); return; }
    primeAudio();
    setBusy(true); setRendering(true);
    try {
      const r = await produce();
      const asset = await uploadProduced(r);
      const caption = buildCaption();
      const contentType = (r.kind === "video" && fmt.id === "story") ? "reel" : "feed";
      // Match the server's approval policy exactly: it treats approvalRequired
      // !== false as "approval required" (undefined counts as ON for legacy
      // workspaces). The old `=== true` check told the user "Scheduled ✓" while
      // the server quietly routed the post to Approvals instead.
      const needsApproval = (((hub.data.workspace || {}).publishingRules || {}).approvalRequired !== false);
      await hub.actions.createPost({
        caption: caption || "(quote card)",
        firstComment: buildFirstComment(),
        platforms: channels,
        date: schedDate,
        time: schedTime,
        timezone: (hub.data.workspace && hub.data.workspace.timezone) || "America/New_York",
        contentType,
        media: [asset.id],
        status: "scheduled",
        requireApproval: needsApproval,
      });
      hub.toast(needsApproval ? "Sent to Approvals ✓ — it publishes once approved. Next quote?" : "Scheduled ✓ — add your next quote, your style is kept.");
      newQuote(); // quick-next: clear only the text, keep every style setting
    } catch (e) { hub.toast(e.message || "Could not schedule — try again"); }
    finally { setBusy(false); setRendering(false); }
  };

  const isVideo = motion !== "still";

  return (
    <div className="quote-studio">
      <div className="muted" style={{ fontSize: 13, marginBottom: 18 }}>
        Turn a sermon quote into a branded {isVideo ? "motion graphic" : "card"} — your colours, your logo, pixel-perfect text. Great for weekly recaps.
      </div>
      <div className="quote-studio-grid">
        {/* ── Controls ──
            Locked while an export is recording: changing Format (or any control
            that re-runs the preview effect) resizes the SAME canvas the
            MediaRecorder is capturing — resizing wipes it mid-stream and the
            rest of the exported clip comes out blank/mis-scaled. */}
        <div className="quote-controls" style={busy ? { pointerEvents: "none", opacity: 0.55 } : undefined} aria-disabled={busy || undefined}>
          {businesses.length > 0 && (
            <>
              <label className="quote-field-label">Designing for</label>
              <select className="input" value={bizId || ""} onChange={(e) => applyBusiness(e.target.value)}>
                {businesses.map((b) => (
                  <option key={b.id} value={b.id}>{b.name}{b.id === activeBizId ? " (current)" : ""}</option>
                ))}
              </select>
              <div className="muted" style={{ fontSize: 11.5, marginTop: 5 }}>
                Loads this business's brand colour + logo. Tweak the palette/colours below anytime.
              </div>
            </>
          )}

          <label className="quote-field-label">Quote</label>
          <textarea
            className="input"
            rows={3}
            maxLength={320}
            placeholder="“Faith is taking the first step even when you don't see the whole staircase.”"
            value={quote}
            onChange={(e) => setQuote(e.target.value)}
            style={{ resize: "vertical", lineHeight: 1.5 }}
          />

          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
            <div>
              <label className="quote-field-label">Attribution</label>
              <input className="input" placeholder="Pastor John · June 22" value={attribution} onChange={(e) => setAttribution(e.target.value)} />
            </div>
            <div>
              <label className="quote-field-label">Kicker label</label>
              <input className="input" placeholder="WEEKLY RECAP" value={kicker} onChange={(e) => setKicker(e.target.value)} />
            </div>
          </div>

          <label className="quote-field-label">Keywords for reach <span style={{ opacity: 0.6, fontWeight: 400 }}>(become hashtags · kept for your next quote)</span></label>
          <input className="input" placeholder="faith, Sunday service, hope" value={keywords} onChange={(e) => setKeywords(e.target.value)} />
          {keywordHashtags(keywords) && (
            <>
              <div className="studio-toggle" style={{ marginTop: 8 }}>
                <button className={`studio-toggle-btn ${tagPlacement === "caption" ? "active" : ""}`} onClick={() => setTagPlacement("caption")}>In caption</button>
                <button className={`studio-toggle-btn ${tagPlacement === "comment" ? "active" : ""}`} onClick={() => setTagPlacement("comment")}>In first comment</button>
              </div>
              <div className="muted" style={{ fontSize: 11.5, marginTop: 5 }}>
                <span style={{ color: "var(--accent-2)" }}>{keywordHashtags(keywords)}</span>
                {tagPlacement === "comment"
                  ? " — posted as a real first comment on X & Mastodon; on Facebook, Instagram & YouTube it's added to the caption (a separate first comment there needs extra platform permissions)."
                  : " — added to the caption."}
              </div>
            </>
          )}

          <label className="quote-field-label">Format</label>
          <div className="studio-toggle">
            {QUOTE_FORMATS.map((f) => (
              <button key={f.id} className={`studio-toggle-btn ${fmt.id === f.id ? "active" : ""}`} onClick={() => setFmt(f)}>
                {f.label} <span style={{ opacity: 0.6, marginLeft: 4 }}>{f.ratio}</span>
              </button>
            ))}
          </div>

          <label className="quote-field-label">Motion style</label>
          <div className="quote-motion-grid">
            {QUOTE_MOTIONS.map((m) => (
              <button key={m.id} className={`quote-motion ${motion === m.id ? "active" : ""}`} onClick={() => setMotion(m.id)}>
                <div className="quote-motion-name">{m.label}</div>
                <div className="quote-motion-desc">{m.desc}</div>
              </button>
            ))}
          </div>

          {isVideo && (
            <>
              <label className="quote-field-label">Clip length</label>
              <div className="studio-toggle" style={{ maxWidth: 280 }}>
                {QUOTE_DURATIONS.map((d) => (
                  <button key={d} className={`studio-toggle-btn ${duration === d ? "active" : ""}`} onClick={() => setDuration(d)}>{d}s</button>
                ))}
              </div>
            </>
          )}

          <label className="quote-field-label">Typography</label>
          <div className="studio-toggle">
            {QUOTE_TYPE_STYLES.map((s) => (
              <button key={s.id} className={`studio-toggle-btn ${typeStyleId === s.id ? "active" : ""}`} onClick={() => setTypeStyleId(s.id)}>{s.label}</button>
            ))}
          </div>

          <label className="quote-field-label">Palette</label>
          <div className="quote-palettes">
            {QUOTE_PALETTES.map((p) => {
              const sw = p.id === "brand" ? { bg: brand, text: brandText, accent: brandAccent } : p;
              return (
                <button key={p.id} className="quote-palette" onClick={() => applyPalette(p)}>
                  <span className="quote-palette-dots">
                    <i style={{ background: sw.bg }} />
                    <i style={{ background: sw.accent }} />
                    <i style={{ background: sw.text }} />
                  </span>
                  {p.label}
                </button>
              );
            })}
          </div>

          <div className="quote-color-row" style={{ marginTop: 12 }}>
            <label className="quote-color"><span>Background</span><input type="color" value={bgColor} onChange={(e) => setBgColor(e.target.value)} /></label>
            <label className="quote-color"><span>Text</span><input type="color" value={textColor} onChange={(e) => setTextColor(e.target.value)} /></label>
            <label className="quote-color"><span>Accent</span><input type="color" value={accentColor} onChange={(e) => setAccentColor(e.target.value)} /></label>
          </div>

          <label className="quote-field-label">Background style</label>
          <div className="studio-toggle" style={{ maxWidth: 280 }}>
            <button className={`studio-toggle-btn ${bgStyle === "solid" ? "active" : ""}`} onClick={() => setBgStyle("solid")}>Solid</button>
            <button className={`studio-toggle-btn ${bgStyle === "gradient" ? "active" : ""}`} onClick={() => setBgStyle("gradient")}>Gradient</button>
          </div>
          <div style={{ display: "flex", gap: 8, marginTop: 8, flexWrap: "wrap" }}>
            <input ref={bgInputRef} type="file" accept="image/*,.heic,.heif" style={{ display: "none" }} onChange={onBgFile} />
            {!bgImg
              ? <button className="btn btn-sm" onClick={() => bgInputRef.current && bgInputRef.current.click()}><Icon.Image width="13" height="13" /> Add background photo</button>
              : <button className="btn btn-sm" onClick={() => { setBgImageUrl(""); }} style={{ color: "var(--danger)" }}>Remove background photo</button>}
          </div>

          <label className="quote-field-label">Logo</label>
          <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
            <input ref={logoInputRef} type="file" accept="image/*,.heic,.heif" style={{ display: "none" }} onChange={onLogoFile} />
            <label className="studio-toggle" style={{ flex: "0 0 auto" }}>
              <button className={`studio-toggle-btn ${showLogo ? "active" : ""}`} onClick={() => setShowLogo(true)}>Show</button>
              <button className={`studio-toggle-btn ${!showLogo ? "active" : ""}`} onClick={() => setShowLogo(false)}>Hide</button>
            </label>
            {showLogo && (
              <div className="studio-toggle" style={{ flex: "0 0 auto" }}>
                <button className={`studio-toggle-btn ${logoPos === "top" ? "active" : ""}`} onClick={() => setLogoPos("top")}>Top</button>
                <button className={`studio-toggle-btn ${logoPos === "bottom" ? "active" : ""}`} onClick={() => setLogoPos("bottom")}>Bottom</button>
              </div>
            )}
            <button className="btn btn-sm" onClick={() => logoInputRef.current && logoInputRef.current.click()}>
              <Icon.Upload width="13" height="13" /> {logoDataUrl ? "Replace logo" : "Upload logo"}
            </button>
          </div>
          {!logoDataUrl && (
            <div className="muted" style={{ fontSize: 11.5, marginTop: 6 }}>
              Tip: set your logo once in Settings → Business and it'll auto-fill here.
            </div>
          )}

          {isVideo && (
            <>
              <label className="quote-field-label">Sound <span className="muted" style={{ fontWeight: 400 }}>— optional, video only</span></label>
              <input ref={audioInputRef} type="file" accept="audio/*,.mp3,.m4a,.wav,.ogg,.aac" style={{ display: "none" }} onChange={onAudioFile} />
              {!audioBuffer ? (
                <button className="btn btn-sm" onClick={() => audioInputRef.current && audioInputRef.current.click()}>
                  <Icon.Music width="13" height="13" /> Add music / audio
                </button>
              ) : (
                <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
                  <span className="pill" style={{ display: "inline-flex", alignItems: "center", gap: 6, maxWidth: 240, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                    <Icon.Music width="12" height="12" /> {audioName}
                  </span>
                  <button className="btn btn-sm" style={{ color: "var(--danger)" }} onClick={() => { setAudioBuffer(null); setAudioName(""); }}>Remove</button>
                </div>
              )}
              <div className="muted" style={{ fontSize: 11.5, marginTop: 6 }}>
                Plays under the clip with a gentle fade-out. Use audio you have the rights to.
              </div>
            </>
          )}
          {!isVideo && audioBuffer && (
            <div className="muted" style={{ fontSize: 11.5, marginTop: 10, color: "var(--warn)" }}>
              ♪ “{audioName}” is loaded but a Still card has no sound — pick an animated motion style to include it.
            </div>
          )}
        </div>

        {/* ── Live preview + export ── */}
        <div className="quote-preview">
          <div className="quote-canvas-wrap">
            <canvas ref={canvasRef} aria-label="Quote card preview" />
          </div>
          <div className="quote-actions">
            <button className="btn btn-primary" disabled={!canExport || busy} onClick={onComposer}>
              {rendering ? <><span className="spinner" style={{ width: 13, height: 13, borderWidth: 2 }} /> Rendering…</> : <><Icon.Edit width="14" height="14" /> Post this now</>}
            </button>
            <button className={`btn ${schedOpen ? "btn-primary" : ""}`} disabled={!canExport || busy} onClick={() => setSchedOpen((v) => !v)}>
              <Icon.Calendar width="14" height="14" /> Schedule…
            </button>

            {schedOpen && (
              <div className="quote-sched">
                {bizMismatch ? (
                  <div className="muted" style={{ fontSize: 12 }}>
                    You're designing for <b>{selectedName}</b>, but posts schedule to your active business (<b>{activeName}</b>). Switch business top-left to schedule for {selectedName}.
                  </div>
                ) : connectedIds.length === 0 ? (
                  <div className="muted" style={{ fontSize: 12 }}>
                    No channels connected for <b>{activeName}</b>. Connect one in Settings → Integrations, then schedule here.
                  </div>
                ) : (
                  <>
                    <div className="quote-field-label" style={{ marginTop: 0 }}>Channels</div>
                    <div className="quote-chan-row">
                      {connectedChannels.map((i) => (
                        <button key={i.id} className={`quote-chan ${chanActive(i.id) ? "active" : ""}`} onClick={() => toggleChannel(i.id)}>
                          {i.name || i.id}
                        </button>
                      ))}
                    </div>
                    <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginTop: 10 }}>
                      <div>
                        <label className="quote-field-label" style={{ marginTop: 0 }}>Date</label>
                        <input className="input" type="date" value={schedDate} onChange={(e) => setSchedDate(e.target.value)} />
                      </div>
                      <div>
                        <label className="quote-field-label" style={{ marginTop: 0 }}>Time</label>
                        <input className="input" type="time" value={schedTime} onChange={(e) => setSchedTime(e.target.value)} />
                      </div>
                    </div>
                    <button className="btn btn-primary" style={{ marginTop: 10, width: "100%" }} disabled={!canExport || busy} onClick={onSchedule}>
                      {rendering ? <><span className="spinner" style={{ width: 13, height: 13, borderWidth: 2 }} /> Scheduling…</> : <><Icon.Calendar width="14" height="14" /> Schedule for {activeName}</>}
                    </button>
                    {!canExport && <div className="muted" style={{ fontSize: 11.5, marginTop: 6 }}>Add a quote above to schedule.</div>}
                  </>
                )}
              </div>
            )}

            <button className="btn" disabled={!canExport || busy} onClick={onSave}>
              <Icon.Image width="14" height="14" /> Save to Media Library
            </button>
            <button className="btn" disabled={!canExport || busy} onClick={onDownload}>
              <Icon.Download width="14" height="14" /> Download {isVideo ? "video" : "image"}
            </button>
            <button className="btn btn-sm" disabled={busy} onClick={newQuote} title="Clear the text but keep all your style settings">
              <Icon.Plus width="13" height="13" /> New quote — keep style
            </button>
            {isVideo && <div className="muted" style={{ fontSize: 11.5, textAlign: "center" }}>Renders in real time (~{duration}s){audioBuffer ? " + audio" : ""}.</div>}
            {!canExport && <div className="muted" style={{ fontSize: 11.5, textAlign: "center" }}>Add a quote to enable export.</div>}
          </div>
        </div>
      </div>
    </div>
  );
}

function StudioPage() {
  const hub = window.useClearCast();
  const [studioMode, setStudioMode] = React.useState("ads"); // ads | quote
  const [step, setStep] = React.useState(0); // 0=format 1=describe 2=generate 3=overlay 4=preview
  const [format, setFormat] = React.useState(null);
  const [prompt, setPrompt] = React.useState("");
  const [mediaType, setMediaType] = React.useState("image"); // image | video
  const [model, setModel] = React.useState(null);
  const [generating, setGenerating] = React.useState(false);
  const [generated, setGenerated] = React.useState(null); // { url, type }
  const [genError, setGenError] = React.useState("");
  const [headline, setHeadline] = React.useState("");
  const [subtext, setSubtext] = React.useState("");
  const [cta, setCta] = React.useState("");
  const [overlayPos, setOverlayPos] = React.useState("bottom");
  const [saving, setSaving] = React.useState(false);
  const [refImage, setRefImage] = React.useState(null); // { dataUrl, name }
  const [refLoading, setRefLoading] = React.useState(false);
  const refInputRef = React.useRef();

  const handleRefImage = async (e) => {
    const file = e.target.files?.[0];
    if (!file) return;
    setRefLoading(true);
    try {
      let blob = file;
      const isHeic = file.type === "image/heic" || file.type === "image/heif" || /\.heic$/i.test(file.name) || /\.heif$/i.test(file.name);
      if (isHeic && window.heic2any) {
        blob = await window.heic2any({ blob: file, toType: "image/jpeg", quality: 0.88 });
        if (Array.isArray(blob)) blob = blob[0];
      }
      const dataUrl = await new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = () => resolve(reader.result);
        reader.onerror = reject;
        reader.readAsDataURL(blob);
      });
      setRefImage({ dataUrl, name: file.name });
    } catch (err) {
      console.error("ref image load failed", err);
    } finally {
      setRefLoading(false);
      if (refInputRef.current) refInputRef.current.value = "";
    }
  };

  const canGenerate = prompt.trim().length > 3 && model;
  const models = mediaType === "video" ? STUDIO_VIDEO_MODELS : STUDIO_IMAGE_MODELS;

  // Pick default model when mediaType changes
  React.useEffect(() => {
    setModel(mediaType === "video" ? STUDIO_VIDEO_MODELS[0] : STUDIO_IMAGE_MODELS[0]);
    setGenerated(null);
    setGenError("");
  }, [mediaType]);

  // Sync mediaType to format selection
  React.useEffect(() => {
    if (format) setMediaType(format.type);
  }, [format]);

  const [genProgress, setGenProgress] = React.useState(""); // status message while polling

  const hasOverlay = Boolean((headline || "").trim() || (subtext || "").trim() || (cta || "").trim());

  const generate = async () => {
    if (!canGenerate) return;
    setGenerating(true);
    setGenError("");
    setGenerated(null);
    setGenProgress("");
    // Server image route + Fal both read `aspect`; ratio strings ("9:16","1:1") parse correctly.
    const aspect = format ? format.ratio : (mediaType === "video" ? "9:16" : "1:1");
    try {
      if (mediaType === "image") {
        setGenProgress("Generating image…");
        // hub.actions.generateImage hits /api/ai/image, which also persists the
        // asset to the media library, applies fresh data, and returns the asset.
        const asset = await hub.actions.generateImage({ prompt: prompt.trim(), model: model.id, provider: "fal", aspect, referenceImage: refImage?.dataUrl || null });
        if (!asset || !asset.url) throw new Error("Image generated but no URL returned");
        setGenerated({ url: asset.url, type: "image", assetId: asset.id });
        setStep(3);
      } else {
        setGenProgress("Submitting to Fal.ai…");
        // Async video: submit + poll happens inside the client action; it persists
        // the finished clip to the media library and returns the saved asset.
        const asset = await hub.actions.generateVideo(
          { prompt: prompt.trim(), model: model.id, provider: "fal", aspect },
          (elapsed) => {
            const mins = Math.floor(elapsed / 60);
            const secs = elapsed % 60;
            setGenProgress(`Generating video… ${mins > 0 ? `${mins}m ` : ""}${secs}s elapsed`);
          }
        );
        if (!asset || !asset.url) throw new Error("Video generated but no URL returned");
        setGenerated({ url: asset.url, type: "video", assetId: asset.id });
        setStep(3);
      }
    } catch (e) {
      setGenError(e.message || "Something went wrong");
    } finally {
      setGenerating(false);
      setGenProgress("");
    }
  };

  // Burn the headline/subtext/CTA overlay into the image on a <canvas> so the
  // exported/posted file actually contains the text (the live preview is just CSS).
  // Returns a Blob, or null if there's no overlay (caller uses the raw asset).
  const compositeImageBlob = async () => {
    if (generated?.type !== "image" || !hasOverlay) return null;
    const resp = await fetch(generated.url);
    const srcBlob = await resp.blob();
    const objUrl = URL.createObjectURL(srcBlob);
    try {
      const img = new Image();
      img.src = objUrl;
      await img.decode();
      const W = img.naturalWidth || 1080;
      const H = img.naturalHeight || 1080;
      const canvas = document.createElement("canvas");
      canvas.width = W; canvas.height = H;
      const ctx = canvas.getContext("2d");
      ctx.drawImage(img, 0, 0, W, H);

      const pad = Math.round(W * 0.06);
      const hSize = Math.round(W * 0.058);
      const sSize = Math.round(W * 0.034);
      const cSize = Math.round(W * 0.032);
      const lineGap = Math.round(hSize * 0.28);
      const wrap = (text, font, maxW) => {
        ctx.font = font;
        const words = String(text).split(/\s+/);
        const lines = []; let cur = "";
        for (const w of words) {
          const test = cur ? cur + " " + w : w;
          if (ctx.measureText(test).width > maxW && cur) { lines.push(cur); cur = w; } else cur = test;
        }
        if (cur) lines.push(cur);
        return lines;
      };
      const maxTextW = W - pad * 2;
      const hLines = headline.trim() ? wrap(headline.trim(), `700 ${hSize}px Inter, system-ui, sans-serif`, maxTextW) : [];
      const sLines = subtext.trim() ? wrap(subtext.trim(), `400 ${sSize}px Inter, system-ui, sans-serif`, maxTextW) : [];
      const ctaH = cta.trim() ? Math.round(cSize * 2.4) : 0;
      const blockH = hLines.length * (hSize + lineGap) + (sLines.length ? Math.round(hSize * 0.4) + sLines.length * (sSize + lineGap) : 0) + (ctaH ? ctaH + lineGap : 0);

      // Scrim + text origin by position.
      let topY;
      if (overlayPos === "top") {
        const g = ctx.createLinearGradient(0, 0, 0, blockH + pad * 2);
        g.addColorStop(0, "rgba(0,0,0,0.72)"); g.addColorStop(1, "rgba(0,0,0,0)");
        ctx.fillStyle = g; ctx.fillRect(0, 0, W, blockH + pad * 2);
        topY = pad;
      } else if (overlayPos === "center") {
        ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(0, H / 2 - blockH / 2 - pad, W, blockH + pad * 2);
        topY = H / 2 - blockH / 2;
      } else {
        const g = ctx.createLinearGradient(0, H - blockH - pad * 2, 0, H);
        g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(1, "rgba(0,0,0,0.78)");
        ctx.fillStyle = g; ctx.fillRect(0, H - blockH - pad * 2, W, blockH + pad * 2);
        topY = H - blockH - pad;
      }

      ctx.textBaseline = "top";
      ctx.textAlign = "left";
      let y = topY;
      ctx.fillStyle = "#ffffff";
      ctx.font = `700 ${hSize}px Inter, system-ui, sans-serif`;
      for (const line of hLines) { ctx.fillText(line, pad, y); y += hSize + lineGap; }
      if (sLines.length) {
        y += Math.round(hSize * 0.4);
        ctx.fillStyle = "rgba(255,255,255,0.9)";
        ctx.font = `400 ${sSize}px Inter, system-ui, sans-serif`;
        for (const line of sLines) { ctx.fillText(line, pad, y); y += sSize + lineGap; }
      }
      if (cta.trim()) {
        y += lineGap;
        ctx.font = `700 ${cSize}px Inter, system-ui, sans-serif`;
        const tw = ctx.measureText(cta.trim()).width;
        const bx = pad, bw = tw + cSize * 1.6, bh = cSize * 1.9;
        ctx.fillStyle = "#f0653f";
        const r = Math.round(bh * 0.28);
        ctx.beginPath();
        ctx.moveTo(bx + r, y); ctx.arcTo(bx + bw, y, bx + bw, y + bh, r);
        ctx.arcTo(bx + bw, y + bh, bx, y + bh, r); ctx.arcTo(bx, y + bh, bx, y, r);
        ctx.arcTo(bx, y, bx + bw, y, r); ctx.closePath(); ctx.fill();
        ctx.fillStyle = "#ffffff";
        ctx.fillText(cta.trim(), bx + cSize * 0.8, y + (bh - cSize) / 2 - cSize * 0.08);
      }
      return await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.92));
    } finally {
      URL.revokeObjectURL(objUrl);
    }
  };

  // Returns the final asset to use: composites text in if needed and uploads it,
  // otherwise returns the already-saved generation. Returns { url, mediaId, type }.
  const finalizeAsset = async () => {
    if (generated.type === "image" && hasOverlay) {
      const blob = await compositeImageBlob();
      if (blob) {
        const file = new File([blob], `studio-ad-${Date.now()}.jpg`, { type: "image/jpeg" });
        const asset = await hub.actions.uploadMedia(file, "all", { generated: true });
        return { url: asset.url, mediaId: asset.id, type: "image" };
      }
    }
    return { url: generated.url, mediaId: generated.assetId, type: generated.type };
  };

  const overlayCaption = () => [headline.trim(), subtext.trim(), cta.trim()].filter(Boolean).join("\n");

  const saveToMedia = async () => {
    if (!generated) return;
    setSaving(true);
    try {
      if (generated.type === "image" && hasOverlay) {
        await finalizeAsset(); // composites + uploads the text version
        hub.toast("Saved your ad (with text) to the Media Library");
      } else {
        // Raw generation was already persisted server-side on generate.
        hub.toast("Saved to your Media Library");
      }
    } catch (e) {
      hub.toast(e.message || "Could not save — try again");
    } finally {
      setSaving(false);
    }
  };

  const downloadFinal = async () => {
    if (!generated) return;
    try {
      let href = generated.url;
      let revoke = false;
      if (generated.type === "image" && hasOverlay) {
        const blob = await compositeImageBlob();
        if (blob) { href = URL.createObjectURL(blob); revoke = true; }
      } else {
        // The `download` attribute is IGNORED for cross-origin URLs (Supabase
        // storage) — a.click() navigated the tab to the raw file and dumped the
        // user out of the app. Pull the bytes and download via an object URL.
        const res = await fetch(generated.url);
        if (!res.ok) throw new Error("fetch failed");
        href = URL.createObjectURL(await res.blob());
        revoke = true;
      }
      const a = document.createElement("a");
      a.href = href;
      a.download = `studio-ad.${generated.type === "video" ? "mp4" : "jpg"}`;
      document.body.appendChild(a); a.click(); a.remove();
      if (revoke) setTimeout(() => URL.revokeObjectURL(href), 4000);
    } catch (e) { hub.toast("Could not download — try again"); }
  };

  const sendToComposer = async () => {
    if (!generated) return;
    setSaving(true);
    try {
      const fin = await finalizeAsset();
      const payload = { url: fin.url, type: fin.type, mediaId: fin.mediaId };
      // Video can't burn text in client-side — pass the overlay copy as a caption seed.
      if (fin.type === "video" && hasOverlay) payload.caption = overlayCaption();
      try { sessionStorage.setItem("studio-inject-media", JSON.stringify(payload)); } catch (e) {}
      hub.setPage("create");
      hub.toast("Ad media dropped into the composer");
    } catch (e) {
      hub.toast(e.message || "Could not send to composer — try again");
    } finally {
      setSaving(false);
    }
  };

  const reset = () => {
    setStep(0); setFormat(null); setPrompt(""); setGenerated(null); setGenError("");
    setHeadline(""); setSubtext(""); setCta(""); setOverlayPos("bottom"); setMediaType("image");
    setRefImage(null);
  };

  const STEPS = ["Format", "Describe", "Generate", "Overlay", "Preview"];

  return (
    <div className={`studio-page page page-fade ${studioMode === "quote" ? "studio-wide" : ""}`}>
      {/* Header */}
      <div className="studio-header">
        <div>
          <h1 style={{ margin: 0, fontSize: 22, fontWeight: 750 }}>Creator Studio</h1>
          <div className="muted" style={{ fontSize: 13, marginTop: 2 }}>
            {studioMode === "quote"
              ? "Branded quote cards & motion graphics — no AI render time, no per-clip cost."
              : "Build an ad in minutes — generate visuals, add your message, post or save."}
          </div>
        </div>
        {step > 0 && studioMode === "ads" && (
          <button className="btn btn-sm" onClick={reset}>
            <Icon.Refresh width="13" height="13" /> Start over
          </button>
        )}
      </div>

      {/* Mode tabs */}
      <div className="studio-toggle" style={{ maxWidth: 380, marginBottom: 22 }}>
        <button className={`studio-toggle-btn ${studioMode === "ads" ? "active" : ""}`} onClick={() => setStudioMode("ads")}>
          <Icon.Sparkles width="14" height="14" /> AI Ad Creator
        </button>
        <button className={`studio-toggle-btn ${studioMode === "quote" ? "active" : ""}`} onClick={() => setStudioMode("quote")}>
          <Icon.Edit width="14" height="14" /> Quote Cards
        </button>
      </div>

      {studioMode === "quote" && <QuoteCardStudio />}

      {studioMode === "ads" && (<>
      {/* Step indicator */}
      <div className="studio-steps">
        {STEPS.map((s, i) => (
          <React.Fragment key={s}>
            <div className={`studio-step ${i < step ? "done" : i === step ? "active" : ""}`}>
              <div className="studio-step-dot">{i < step ? <Icon.Check width="11" height="11" /> : i + 1}</div>
              <div className="studio-step-label">{s}</div>
            </div>
            {i < STEPS.length - 1 && <div className={`studio-step-line ${i < step ? "done" : ""}`} />}
          </React.Fragment>
        ))}
      </div>

      {/* ── Step 0: Format ── */}
      {step === 0 && (
        <div className="studio-section">
          <h2 className="studio-sh">What type of ad are you creating?</h2>
          <div className="studio-formats">
            {AD_FORMATS.map((f) => (
              <button
                key={f.id}
                className={`studio-format-card ${format?.id === f.id ? "selected" : ""}`}
                onClick={() => setFormat(f)}
              >
                <div className="studio-format-icon">
                  {f.type === "video" ? <Icon.Play width="20" height="20" /> : <Icon.Image width="20" height="20" />}
                </div>
                <div className="studio-format-label">{f.label}</div>
                <div className="studio-format-ratio">{f.ratio}</div>
                <div className="studio-format-desc">{f.desc}</div>
              </button>
            ))}
          </div>
          <div style={{ display: "flex", gap: 10, marginTop: 8 }}>
            <button className="btn btn-primary" disabled={!format} onClick={() => setStep(1)}>
              Next <Icon.ChevronRight width="14" height="14" />
            </button>
            <button className="btn" onClick={() => { setFormat(null); setStep(1); }}>
              Skip — I'll choose later
            </button>
          </div>
        </div>
      )}

      {/* ── Step 1: Describe ── */}
      {step === 1 && (
        <div className="studio-section">
          <h2 className="studio-sh">Describe your ad</h2>
          <div className="studio-describe-grid">
            <div>
              <label className="studio-label">What should the visual show?</label>
              <textarea
                className="input studio-prompt"
                rows={5}
                placeholder="e.g. A warm sunlit coffee shop, cozy atmosphere, latte art on a wooden table, warm tones — photorealistic"
                value={prompt}
                onChange={(e) => setPrompt(e.target.value)}
              />
              <div className="muted" style={{ fontSize: 12, marginTop: 4 }}>Be specific — include mood, lighting, colours, subject, and style.</div>

              <label className="studio-label" style={{ marginTop: 16 }}>Reference image <span className="muted" style={{ fontWeight: 400 }}>— optional</span></label>
              <input ref={refInputRef} type="file" accept="image/*,.heic,.heif" style={{ display: "none" }} onChange={handleRefImage} />
              {!refImage ? (
                <button className="studio-ref-upload" disabled={refLoading} onClick={() => refInputRef.current?.click()}>
                  {refLoading
                    ? <><span className="spinner" style={{ width: 13, height: 13, borderWidth: 2 }} /> Converting…</>
                    : <><Icon.Upload width="14" height="14" /> Upload reference image<span className="muted" style={{ fontSize: 11, marginLeft: 4 }}>HEIC, JPG, PNG, WebP</span></>
                  }
                </button>
              ) : (
                <div className="studio-ref-preview">
                  <img src={refImage.dataUrl} alt="Reference" className="studio-ref-thumb" />
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 12, fontWeight: 600, color: "var(--text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{refImage.name}</div>
                    <div className="muted" style={{ fontSize: 11, marginTop: 2 }}>AI will use this as a style/composition guide</div>
                  </div>
                  <button className="btn btn-sm" style={{ color: "var(--danger)" }} onClick={() => setRefImage(null)}>Remove</button>
                </div>
              )}
            </div>

            <div>
              <label className="studio-label">Media type</label>
              <div className="studio-toggle">
                <button className={`studio-toggle-btn ${mediaType === "image" ? "active" : ""}`} onClick={() => setMediaType("image")}>
                  <Icon.Image width="14" height="14" /> Image
                </button>
                <button className={`studio-toggle-btn ${mediaType === "video" ? "active" : ""}`} onClick={() => setMediaType("video")}>
                  <Icon.Play width="14" height="14" /> Video
                </button>
              </div>

              <label className="studio-label" style={{ marginTop: 16 }}>AI model</label>
              <div className="studio-model-list">
                {models.map((m) => (
                  <button
                    key={m.id}
                    className={`studio-model-item ${model?.id === m.id ? "selected" : ""}`}
                    onClick={() => setModel(m)}
                  >
                    <div style={{ flex: 1 }}>
                      <span style={{ fontWeight: 650, fontSize: 13 }}>{m.label}</span>
                      <span className="pill" style={{ marginLeft: 6, padding: "1px 6px", fontSize: 10 }}>{m.badge}</span>
                    </div>
                    <span className="muted" style={{ fontSize: 12 }}>{m.cost}</span>
                  </button>
                ))}
              </div>
            </div>
          </div>

          <div style={{ display: "flex", gap: 10, marginTop: 16 }}>
            <button className="btn" onClick={() => setStep(0)}><Icon.ChevronLeft width="14" height="14" /> Back</button>
            <button className="btn btn-primary" disabled={!canGenerate} onClick={() => { setGenError(""); setStep(2); }}>
              Next <Icon.ChevronRight width="14" height="14" />
            </button>
          </div>
        </div>
      )}

      {/* ── Step 2: Generate ── */}
      {step === 2 && (
        <div className="studio-section">
          <h2 className="studio-sh">Generate your visual</h2>
          <div className="studio-gen-summary">
            <div><span className="muted">Prompt:</span> {prompt}</div>
            <div><span className="muted">Model:</span> {model?.label} <span className="pill" style={{ padding: "1px 6px", fontSize: 10, marginLeft: 4 }}>{model?.cost}</span></div>
            {format && <div><span className="muted">Format:</span> {format.label} ({format.ratio})</div>}
          </div>

          {genError && <div className="studio-error">{genError}</div>}

          {!generated && (
            <button className="btn btn-primary studio-gen-btn" disabled={generating} onClick={generate}>
              {generating
                ? <><span className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> {genProgress || "Generating…"}</>
                : <><Icon.Sparkles width="14" height="14" /> Generate — {model?.cost}</>
              }
            </button>
          )}

          {generated && (
            <div className="studio-preview-wrap">
              {generated.type === "image"
                ? <img src={generated.url} alt="Generated ad" className="studio-preview-media" />
                : <video src={generated.url} controls className="studio-preview-media" />
              }
              <div style={{ display: "flex", gap: 8, marginTop: 10 }}>
                <button className="btn btn-sm" onClick={() => { setGenerated(null); setGenError(""); }}>Regenerate</button>
                <button className="btn btn-sm btn-primary" onClick={() => setStep(3)}>Add text overlay <Icon.ChevronRight width="13" height="13" /></button>
                <button className="btn btn-sm" onClick={() => setStep(4)}>Skip to preview</button>
              </div>
            </div>
          )}

          <div style={{ marginTop: 14 }}>
            <button className="btn" onClick={() => { setGenError(""); setStep(1); }}><Icon.ChevronLeft width="14" height="14" /> Back</button>
          </div>
        </div>
      )}

      {/* ── Step 3: Overlay ── */}
      {step === 3 && (
        <div className="studio-section">
          <h2 className="studio-sh">Add your message <span className="muted" style={{ fontWeight: 400, fontSize: 14 }}>— optional</span></h2>
          {generated?.type === "video" && hasOverlay && (
            <div className="muted" style={{ fontSize: 12.5, marginBottom: 12, padding: "8px 12px", background: "var(--card-2)", borderRadius: 8, border: "1px solid var(--border)" }}>
              Text can't be burned into a video here — for video ads, your message is carried over as the post caption when you hit “Post this ad now.”
            </div>
          )}
          <div className="studio-overlay-grid">
            <div className="studio-overlay-fields">
              <label className="studio-label">Headline</label>
              <input className="input" placeholder="e.g. Start your free trial today" value={headline} onChange={(e) => setHeadline(e.target.value)} />

              <label className="studio-label" style={{ marginTop: 12 }}>Sub-text</label>
              <input className="input" placeholder="e.g. No credit card required." value={subtext} onChange={(e) => setSubtext(e.target.value)} />

              <label className="studio-label" style={{ marginTop: 12 }}>Call to action</label>
              <input className="input" placeholder="e.g. Shop now · Learn more · Book a demo" value={cta} onChange={(e) => setCta(e.target.value)} />

              <label className="studio-label" style={{ marginTop: 12 }}>Text position</label>
              <div className="studio-toggle">
                {OVERLAY_POSITIONS.map((p) => (
                  <button key={p.id} className={`studio-toggle-btn ${overlayPos === p.id ? "active" : ""}`} onClick={() => setOverlayPos(p.id)}>
                    {p.label}
                  </button>
                ))}
              </div>
            </div>

            {/* Live preview of overlay */}
            <div className="studio-overlay-preview">
              {generated && (
                <div className="studio-overlay-canvas">
                  {generated.type === "image"
                    ? <img src={generated.url} alt="Ad preview" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: 8 }} />
                    : <video src={generated.url} muted autoPlay loop style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: 8 }} />
                  }
                  {(headline || subtext || cta) && (
                    <div className={`studio-overlay-text studio-overlay-${overlayPos}`}>
                      {headline && <div className="studio-overlay-headline">{headline}</div>}
                      {subtext && <div className="studio-overlay-subtext">{subtext}</div>}
                      {cta && <div className="studio-overlay-cta">{cta}</div>}
                    </div>
                  )}
                </div>
              )}
            </div>
          </div>

          <div style={{ display: "flex", gap: 10, marginTop: 16 }}>
            <button className="btn" onClick={() => setStep(2)}><Icon.ChevronLeft width="14" height="14" /> Back</button>
            <button className="btn btn-primary" onClick={() => setStep(4)}>Preview & Export <Icon.ChevronRight width="14" height="14" /></button>
          </div>
        </div>
      )}

      {/* ── Step 4: Preview & Export ── */}
      {step === 4 && (
        <div className="studio-section">
          <h2 className="studio-sh">Your ad is ready</h2>
          <div className="studio-export-grid">
            <div className="studio-export-preview">
              {generated && (
                <div className="studio-overlay-canvas">
                  {generated.type === "image"
                    ? <img src={generated.url} alt="Final ad" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: 10 }} />
                    : <video src={generated.url} controls style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: 10 }} />
                  }
                  {(headline || subtext || cta) && (
                    <div className={`studio-overlay-text studio-overlay-${overlayPos}`}>
                      {headline && <div className="studio-overlay-headline">{headline}</div>}
                      {subtext && <div className="studio-overlay-subtext">{subtext}</div>}
                      {cta && <div className="studio-overlay-cta">{cta}</div>}
                    </div>
                  )}
                </div>
              )}
            </div>

            <div className="studio-export-actions">
              <div className="studio-export-meta">
                <div><span className="muted">Format:</span> {format?.label || "Custom"}</div>
                <div><span className="muted">Model:</span> {model?.label}</div>
                {format && <div><span className="muted">Dimensions:</span> {format.w} × {format.h}</div>}
              </div>

              <div style={{ display: "flex", flexDirection: "column", gap: 10, marginTop: 20 }}>
                <button className="btn btn-primary" disabled={saving} onClick={sendToComposer}>
                  <Icon.Edit width="14" height="14" /> Post this ad now
                </button>
                <button className="btn" disabled={saving} onClick={saveToMedia}>
                  {saving ? <><span className="spinner" style={{ width: 13, height: 13, borderWidth: 2 }} /> Saving…</> : <><Icon.Image width="14" height="14" /> Save to Media Library</>}
                </button>
                <button className="btn" onClick={downloadFinal}>
                  <Icon.Upload width="14" height="14" /> Download file
                </button>
              </div>

              <div style={{ borderTop: "1px solid var(--border)", marginTop: 20, paddingTop: 16 }}>
                <button className="btn btn-sm" onClick={reset}><Icon.Refresh width="13" height="13" /> Create another ad</button>
              </div>
            </div>
          </div>
        </div>
      )}
      </>)}
    </div>
  );
}

window.StudioPage = StudioPage;
