/* HOLO · Veo Console — Studio 2026 */
const { useState, useEffect, useRef, useCallback } = React;

// ── Config ──
const TIERS = [
  { id: "lite",    label: "Lite",    desc: "Fastest preview" },
  { id: "fast",    label: "Fast",    desc: "Balanced" },
  { id: "quality", label: "Quality", desc: "Highest fidelity" },
];
const DURATIONS = [
  { id: 4, label: "4 seconds", desc: "Quick test" },
  { id: 6, label: "6 seconds", desc: "Standard" },
  { id: 8, label: "8 seconds", desc: "Full take" },
];
const ORIENTATIONS = [
  { id: "landscape", label: "Landscape", desc: "16:9" },
  { id: "portrait",  label: "Portrait",  desc: "9:16" },
];
const RESOLUTIONS = [
  { id: "720p",  label: "720p",  desc: "Standard" },
  { id: "1080p", label: "1080p", desc: "8 sec only" },
  { id: "4k",    label: "4K",    desc: "8 sec only" },
];

// ── Image-mode config ──
const IMAGE_FAMILIES = [
  { id: "gemini-3.0-pro",  label: "Gemini 3.0 Pro",  desc: "Detail focus" },
  { id: "gemini-3.1-flash", label: "Gemini 3.1 Flash", desc: "Faster, lower cost" },
  { id: "imagen",          label: "Imagen 4.0",       desc: "Photorealistic preview" },
  { id: "gpt-images",      label: "GPT-Images 2.0",   desc: "OpenAI-based, ratio control" },
];
const GEMINI_ORIENTS = [
  { id: "landscape",   label: "Landscape",   desc: "16:9" },
  { id: "portrait",    label: "Portrait",    desc: "9:16" },
  { id: "square",      label: "Square",      desc: "1:1" },
  { id: "four-three",  label: "4:3",         desc: "Classic" },
  { id: "three-four",  label: "3:4",         desc: "Tall classic" },
];
const IMAGEN_ORIENTS = [
  { id: "landscape", label: "Landscape", desc: "16:9" },
  { id: "portrait",  label: "Portrait",  desc: "9:16" },
];
const GPT_RATIOS = [
  { id: "1:1",  label: "1:1" },
  { id: "3:2",  label: "3:2" },
  { id: "2:3",  label: "2:3" },
  { id: "4:3",  label: "4:3" },
  { id: "3:4",  label: "3:4" },
  { id: "5:4",  label: "5:4" },
  { id: "4:5",  label: "4:5" },
  { id: "16:9", label: "16:9" },
  { id: "9:16", label: "9:16" },
  { id: "21:9", label: "21:9" },
];
const IMAGE_RES = [
  { id: "std", label: "Standard", desc: "Default" },
  { id: "2k",  label: "2K",       desc: "High detail" },
  { id: "4k",  label: "4K",       desc: "Highest" },
];

const COST_HINT = (mode, tier, dur, res) => {
  let base = tier === "lite" ? 6 : tier === "fast" ? 14 : 22;
  if (res === "1080p") base += 6;
  if (res === "4k")    base += 16;
  if (dur === 4) base = Math.round(base * 0.5);
  if (dur === 6) base = Math.round(base * 0.75);
  if (mode !== "t2v") base += 2;
  return base;
};

const shortId = id => id ? id.slice(0, 8) : "—";

// API can return error as either a string or {code, message} object.
// Normalize to a string so it's safe to render and merge into task state.
const normalizeError = (e) => {
  if (e == null) return null;
  if (typeof e === "string") return e;
  if (typeof e === "object") return e.message || e.code || JSON.stringify(e);
  return String(e);
};

// ── Glyph icons (inline SVG) ──
const Icn = ({ name, size = 14 }) => {
  const props = { width: size, height: size, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
  switch (name) {
    case "image":    return <svg {...props}><rect x="2" y="3" width="12" height="10" rx="1.5"/><circle cx="6" cy="7" r="1.2"/><path d="M2.5 12 6 9l4 3.5L13.5 9"/></svg>;
    case "tier":     return <svg {...props}><path d="M2 13 8 3l6 10z"/></svg>;
    case "clock":    return <svg {...props}><circle cx="8" cy="8" r="6"/><path d="M8 5v3l2 1.5"/></svg>;
    case "frame":    return <svg {...props}><rect x="2" y="3" width="12" height="10" rx="1"/></svg>;
    case "frame-p":  return <svg {...props}><rect x="4" y="2" width="8" height="12" rx="1"/></svg>;
    case "res":      return <svg {...props}><path d="M2 8h12M8 2v12"/></svg>;
    case "stack":    return <svg {...props}><path d="M2 5l6-3 6 3-6 3z"/><path d="M2 8l6 3 6-3"/><path d="M2 11l6 3 6-3"/></svg>;
    case "x":        return <svg {...props}><path d="M3 3l10 10M13 3 3 13"/></svg>;
    case "dl":       return <svg {...props}><path d="M8 2v9m-3-3 3 3 3-3M3 13h10"/></svg>;
    case "redo":     return <svg {...props}><path d="M13 7a5 5 0 1 0-1.5 3.5L13 12V8h-4"/></svg>;
    case "play":     return <svg {...props}><path d="M5 3v10l8-5z" fill="currentColor"/></svg>;
    default: return null;
  }
};

// ── Popover hook ──
function usePop() {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [open]);
  return [open, setOpen, ref];
}

// ── Generic popover dropdown ──
function PopChoice({ icon, valueLabel, options, value, onPick }) {
  const [open, setOpen, ref] = usePop();
  return (
    <div ref={ref} style={{ position: "relative" }}>
      <button className="ctl-pop" onClick={() => setOpen(o => !o)}>
        {icon && <Icn name={icon} />}
        <span className="v">{valueLabel}</span>
        <span className="caret">▾</span>
      </button>
      {open && (
        <div className="pop">
          {options.map(o => (
            <div key={o.id}
                 className={"pop-row" + (o.id === value ? " active" : "") + (o.disabled ? " disabled" : "")}
                 onClick={() => { if (!o.disabled) { onPick(o.id); setOpen(false); } }}>
              <span style={{display:"flex", alignItems:"center", gap:10}}>
                <span className="check"></span>
                <span>{o.label}</span>
              </span>
              <span className="meta">{o.desc}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ── Count picker ──
function CountPop({ value, onPick }) {
  const [open, setOpen, ref] = usePop();
  return (
    <div ref={ref} style={{ position: "relative" }}>
      <button className="ctl-pop" onClick={() => setOpen(o => !o)}>
        <Icn name="stack" />
        <span className="v">{value} {value === 1 ? "take" : "takes"}</span>
        <span className="caret">▾</span>
      </button>
      {open && (
        <div className="pop count-pop" style={{ minWidth: 260 }}>
          <div className="count-pop-head">Generate · 1 to 10 takes</div>
          <div className="count-pills">
            {Array.from({length: 10}, (_, i) => i + 1).map(n => (
              <button key={n} className={n === value ? "active" : ""} onClick={() => { onPick(n); setOpen(false); }}>{n}</button>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

// ── Account chip (Credits popover) ──
function AccountChip({ account }) {
  const [open, setOpen, ref] = usePop();
  const credits = account?.credits != null ? Math.round(account.credits).toLocaleString() : "—";

  // Volume tier for img + vid (independent ladders).
  const thresholds = account?.tier_thresholds || [1000, 5000, 10000];
  const ladder = (vol) => {
    let idx = 0;
    for (let i = 0; i < thresholds.length; i++) if (vol >= thresholds[i]) idx = i + 1;
    const next = idx < thresholds.length ? thresholds[idx] : null;
    const prev = idx === 0 ? 0 : thresholds[idx - 1];
    const pct = next != null ? Math.min(100, Math.round(((vol - prev) / (next - prev)) * 100)) : 100;
    return { idx, next, prev, pct, label: `PL${idx + 1}` };
  };
  const imgLadder = ladder(account?.img_30d ?? 0);
  const vidLadder = ladder(account?.vid_30d ?? 0);

  const dim = (n) => n == null ? "—" : n.toLocaleString();
  const limit = (n) => n == null || n === 0 ? "—" : n.toLocaleString();

  return (
    <div ref={ref} style={{ position: "relative" }}>
      <button className="chip chip-btn" onClick={() => setOpen(o => !o)}>
        Credits <b>{credits}</b><span className="caret">▾</span>
      </button>
      {open && (
        <div className="pop account-pop">
          <div className="acct-head">
            <div className="acct-name">{account?.account_name || account?.name || "Account"}</div>
            {account?.key_label && <div className="acct-key">{account.key_label}</div>}
          </div>

          <div className="acct-row">
            <div className="acct-cell">
              <span className="lbl">Available</span>
              <span className="val big">{credits}</span>
            </div>
            <div className="acct-cell">
              <span className="lbl">Frozen</span>
              <span className="val">{dim(account?.frozen_credits != null ? Math.round(account.frozen_credits) : null)}</span>
            </div>
          </div>

          <div className="acct-section-h">Today</div>
          <div className="acct-row">
            <div className="acct-cell"><span className="lbl">Images</span><span className="val">{dim(account?.today_img)}</span></div>
            <div className="acct-cell"><span className="lbl">Videos</span><span className="val">{dim(account?.today_vid)}</span></div>
            <div className="acct-cell"><span className="lbl">Requests</span><span className="val">{dim(account?.daily_used)}</span></div>
            <div className="acct-cell"><span className="lbl">Spent</span><span className="val">{dim(account?.daily_credits_used != null ? Math.round(account.daily_credits_used) : null)}</span></div>
          </div>

          <div className="acct-section-h">Limits</div>
          <div className="acct-row">
            <div className="acct-cell"><span className="lbl">Daily</span><span className="val">{limit(account?.daily_limit)}</span></div>
            <div className="acct-cell"><span className="lbl">RPM</span><span className="val">{limit(account?.rpm_limit)}</span></div>
          </div>

          <div className="acct-section-h">Volume tier (30d)</div>
          {[
            { l: "Images", x: imgLadder, vol: account?.img_30d },
            { l: "Videos", x: vidLadder, vol: account?.vid_30d },
          ].map(({l, x, vol}) => (
            <div key={l} className="acct-ladder">
              <div className="acct-ladder-h">
                <span>{l} · <b>{x.label}</b></span>
                <span className="meta">
                  {dim(vol)}{x.next != null ? ` / ${x.next.toLocaleString()}` : " · top"}
                </span>
              </div>
              <div className="acct-bar"><div style={{width: x.pct + "%"}}></div></div>
            </div>
          ))}

          <a className="acct-link" href="https://api.dealonhorizon.us/dashboard" target="_blank" rel="noopener noreferrer">
            Open dashboard ↗
          </a>
        </div>
      )}
    </div>
  );
}

// Two-state segmented toggle for choosing between video and image output.
function ModeToggle({ value, onPick }) {
  return (
    <div className="mode-toggle" role="tablist">
      {[
        { id: "video", label: "Video" },
        { id: "image", label: "Image" },
      ].map(o => (
        <button key={o.id}
          className={"mode-toggle-btn" + (o.id === value ? " active" : "")}
          onClick={() => onPick(o.id)}
          role="tab"
          aria-selected={o.id === value}>
          {o.label}
        </button>
      ))}
    </div>
  );
}

// ── Composer ──
// Reference slot shape: { id, kind:"image"|"video", name, previewUrl, dataUrl, fileObj? }
// previewUrl is a blob: URL we own (preview only). dataUrl is what gets sent
// to the API (for video, it's the extracted first frame).
const newSlotId = () => Math.random().toString(36).slice(2, 9);

function Composer({ onSubmit, submitting, account, reuseSeed }) {
  const [mediaKind, setMediaKind] = useState("video"); // "video" | "image"
  const [promptText, setPromptText] = useState("");
  const [slots, setSlots] = useState([]);             // ref slot list (max 3 for video, 1 for image)
  const [dragging, setDragging] = useState(false);
  // Video-only state
  const [tier, setTier] = useState("fast");
  const [duration, setDuration] = useState(8);
  const [orient, setOrient] = useState("landscape");
  const [res, setRes] = useState("720p");
  // Image-only state
  const [imageFamily, setImageFamily] = useState("gemini-3.0-pro");
  const [imageOrient, setImageOrient] = useState("landscape");
  const [imageRes, setImageRes] = useState("std");
  const [gptRatio, setGptRatio] = useState("1:1");
  const [count, setCount] = useState(1);
  const fileRef = useRef(null);

  // Slot count drives video sub-mode: 0→t2v, 1→i2v, 2+→r2v.
  const mode = slots.length === 0 ? "t2v" : slots.length === 1 ? "i2v" : "r2v";
  const isR2V = mediaKind === "video" && mode === "r2v";

  useEffect(() => {
    if (duration !== 8 && res !== "720p") setRes("720p");
  }, [duration, res]);

  // Apply a "reuse settings" payload from a Reel card. Seed is a fresh object
  // each click (driven by App state) so even the same seed re-applies cleanly.
  useEffect(() => {
    if (!reuseSeed) return;
    const c = reuseSeed.config || {};
    if (reuseSeed.prompt != null) setPromptText(reuseSeed.prompt);
    if (c.mediaKind === "image") {
      setMediaKind("image");
      if (c.imageFamily) setImageFamily(c.imageFamily);
      if (c.orientation) setImageOrient(c.orientation);
      if (c.resolution)  setImageRes(c.resolution);
      if (c.ratio)       setGptRatio(c.ratio);
    } else if (c.mediaKind === "video" || c.mode) {
      setMediaKind("video");
      if (c.tier)        setTier(c.tier);
      if (c.duration)    setDuration(c.duration);
      if (c.orientation) setOrient(c.orientation);
      if (c.resolution)  setRes(c.resolution);
    }
  }, [reuseSeed]);  // eslint-disable-line react-hooks/exhaustive-deps

  // R2V locks duration to 8s and disallows quality tier.
  useEffect(() => {
    if (!isR2V) return;
    if (duration !== 8) setDuration(8);
    if (tier === "quality") setTier("fast");
  }, [isR2V, duration, tier]);

  // Lite (any video sub-mode) is 720p only.
  useEffect(() => {
    if (mediaKind === "video" && tier === "lite" && res !== "720p") setRes("720p");
  }, [mediaKind, tier, res]);

  // Switching to image mode caps slots to 1 and drops video refs.
  useEffect(() => {
    if (mediaKind !== "image") return;
    setSlots(s => {
      const cleaned = s.filter(x => {
        if (x.kind === "video") { revokeIfBlob(x.previewUrl); return false; }
        return true;
      });
      if (cleaned.length <= 1) return cleaned;
      cleaned.slice(1).forEach(x => revokeIfBlob(x.previewUrl));
      return cleaned.slice(0, 1);
    });
  }, [mediaKind]);  // eslint-disable-line react-hooks/exhaustive-deps

  // Imagen + non-Gemini families: when switching to one that doesn't support
  // square/four-three/three-four, fall back to landscape so the model name
  // we build is always valid.
  useEffect(() => {
    if (mediaKind !== "image") return;
    if (imageFamily === "imagen" && !["landscape", "portrait"].includes(imageOrient)) {
      setImageOrient("landscape");
    }
  }, [mediaKind, imageFamily, imageOrient]);

  // Decode one frame at a given timestamp (default 0.05s). Reused for both
  // the auto-pick first frame and the user-driven scrub picker. The temp URL
  // is revoked on every exit path (success / error / timeout).
  const extractFrame = (file, time = 0.05) => new Promise((resolve, reject) => {
    const tmpUrl = URL.createObjectURL(file);
    const video = document.createElement("video");
    video.preload = "auto"; video.muted = true; video.playsInline = true;
    video.crossOrigin = "anonymous";
    video.src = tmpUrl;

    let settled = false;
    const cleanup = () => {
      video.onloadeddata = video.onseeked = video.onerror = null;
      try { video.removeAttribute("src"); video.load(); } catch {}
      URL.revokeObjectURL(tmpUrl);
    };
    const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(to); cleanup(); fn(arg); };
    const to = setTimeout(() => finish(reject, new Error("Video decode timeout")), 10000);

    video.onloadeddata = () => {
      try { video.currentTime = Math.max(0, Math.min(time, (video.duration || time) - 0.01)); } catch {}
    };
    video.onseeked = () => {
      try {
        const c = document.createElement("canvas");
        c.width = video.videoWidth || 1280;
        c.height = video.videoHeight || 720;
        const ctx = c.getContext("2d");
        ctx.drawImage(video, 0, 0, c.width, c.height);
        finish(resolve, { dataUrl: c.toDataURL("image/jpeg", 0.9), duration: video.duration });
      } catch (e) { finish(reject, e); }
    };
    video.onerror = () => finish(reject, new Error("Cannot read video"));
  });

  // Revoke any blob: URL we created.
  const revokeIfBlob = (url) => { if (url && url.startsWith("blob:")) URL.revokeObjectURL(url); };

  const maxSlots = mediaKind === "image" ? 1 : 3;
  const slotsFull = slots.length >= maxSlots;

  const addSlotFromFile = async (file) => {
    if (!file) return;
    if (slotsFull) return;
    if (file.type.startsWith("image/")) {
      const dataUrl = await HOLO.fileToDataUrl(file);
      setSlots(s => [...s, {
        id: newSlotId(), kind: "image", name: file.name,
        previewUrl: URL.createObjectURL(file), dataUrl,
      }]);
    } else if (file.type.startsWith("video/")) {
      if (mediaKind === "image") {
        alert("Image mode only accepts image references.");
        return;
      }
      try {
        const { dataUrl, duration } = await extractFrame(file, 0.05);
        setSlots(s => [...s, {
          id: newSlotId(), kind: "video", name: file.name,
          previewUrl: URL.createObjectURL(file), dataUrl, fileObj: file,
          frameTime: 0.05, mediaDuration: duration || null,
        }]);
      } catch (e) {
        alert("Could not read video: " + e.message);
      }
    }
  };

  const handleDrop = async (e) => {
    e.preventDefault();
    setDragging(false);
    const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
    for (const f of files) {
      if (slots.length + 1 > maxSlots) break;
      await addSlotFromFile(f);
    }
  };

  const removeSlot = (id) => {
    setSlots(s => s.filter(x => {
      if (x.id !== id) return true;
      revokeIfBlob(x.previewUrl);
      return false;
    }));
  };

  const clearAllSlots = () => {
    slots.forEach(x => revokeIfBlob(x.previewUrl));
    setSlots([]);
  };

  // Re-extract a video slot's frame at a new timestamp.
  const setSlotFrameTime = async (id, t) => {
    const slot = slots.find(s => s.id === id);
    if (!slot || slot.kind !== "video" || !slot.fileObj) return;
    try {
      const { dataUrl } = await extractFrame(slot.fileObj, t);
      setSlots(s => s.map(x => x.id === id ? { ...x, dataUrl, frameTime: t } : x));
    } catch (e) {
      console.warn("scrub frame failed:", e);
    }
  };

  // On unmount: revoke any blob: URLs we still own.
  useEffect(() => () => slots.forEach(x => revokeIfBlob(x.previewUrl)), []);  // eslint-disable-line react-hooks/exhaustive-deps

  // R2I detection: image + image_url → server bills as R2I automatically
  // (same model name as T2I per spec).
  const imageMode = slots.length > 0 ? "r2i" : "t2i";
  const config = mediaKind === "image"
    ? {
        mediaKind: "image",
        mode: imageMode,
        imageFamily,
        orientation: imageOrient,
        resolution: imageRes,
        ratio: gptRatio,
      }
    : { mediaKind: "video", mode, tier, duration, orientation: orient, resolution: res };

  const previewModel = HOLO.buildModel(config);
  const realCost = estimateCost(previewModel, config, account);
  const costPer = realCost
    ? realCost.credits
    : (mediaKind === "image" ? 12 : COST_HINT(mode, tier, duration, res));
  const canSubmit = promptText.trim().length > 0 && !submitting;

  const submit = () => {
    if (!canSubmit) return;
    const imageUrls = slots.map(s => s.dataUrl).filter(Boolean);
    onSubmit({
      prompt: promptText.trim(),
      imageUrl: imageUrls[0] || null,    // back-compat for code that reads .imageUrl
      imageUrls,
      model: previewModel,
      config,
      count,
    });
  };

  // Tooltip: surface tier + remaining volume to next tier so users see
  // why the price is what it is.
  const costTitle = realCost
    ? (realCost.isFixed
        ? `Fixed pricing — ${realCost.matchedPrefix}`
        : (realCost.nextThreshold != null
            ? `${realCost.tierLabel} · ${realCost.volume} / ${realCost.nextThreshold} ${realCost.isVideo ? "videos" : "images"} (30d) — ${realCost.nextThreshold - realCost.volume} to next tier`
            : `${realCost.tierLabel} · ${realCost.volume} ${realCost.isVideo ? "videos" : "images"} (30d, top tier)`))
    : "Estimate (pricing data not loaded)";

  const resOpts = RESOLUTIONS.map(r => ({ ...r, disabled: duration !== 8 && r.id !== "720p" }));

  return (
    <div className="composer"
         onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
         onDragLeave={(e) => { if (e.currentTarget === e.target) setDragging(false); }}
         onDrop={handleDrop}>
      {dragging && <div className="drop-overlay">Drop image or video — first frame becomes the opening shot</div>}
      <div className="composer-inner">
        {slots.length > 0 && (
          <div className="attached-strip multi">
            <div className="attached-head">
              <span className="lbl">
                {mediaKind === "image"
                  ? "Reference-to-Image · R2I"
                  : mode === "r2v" ? `Reference-to-Video · R2V · ${slots.length} refs`
                  : mode === "i2v" ? "Image-to-Video · I2V"
                  : "Reference"}
              </span>
              {slots.length > 1 && (
                <button className="attached-clear" onClick={clearAllSlots}>Clear all</button>
              )}
            </div>
            <div className="slot-list">
              {slots.map((s) => (
                <div key={s.id} className="slot-card">
                  {s.kind === "video"
                    ? <video src={s.previewUrl} className="slot-thumb" muted autoPlay loop playsInline />
                    : <img src={s.previewUrl} className="slot-thumb" alt="ref" />}
                  {s.kind === "video" && (
                    <div className="slot-frame-badge">frame {(s.frameTime ?? 0).toFixed(2)}s</div>
                  )}
                  <button className="slot-x" onClick={() => removeSlot(s.id)} title="Remove"><Icn name="x" /></button>
                  {s.kind === "video" && s.mediaDuration > 0 && (
                    <input type="range" className="slot-scrub"
                      min="0" max={Math.max(0.1, s.mediaDuration - 0.05)} step="0.05"
                      value={s.frameTime ?? 0.05}
                      onChange={(e) => setSlotFrameTime(s.id, parseFloat(e.target.value))}
                      title="Pick the start frame" />
                  )}
                </div>
              ))}
            </div>
          </div>
        )}

        <div className="prompt-wrap">
          <textarea
            value={promptText}
            onChange={(e) => setPromptText(e.target.value)}
            placeholder="Describe the shot — a slow dolly-in on a fox stepping out of a foggy forest at dawn…"
            rows={3}
          />
        </div>

        <div className="controls">
          <ModeToggle value={mediaKind} onPick={setMediaKind} />

          <button className="ctl-btn"
                  onClick={() => fileRef.current?.click()}
                  disabled={slotsFull}
                  title={slotsFull ? `Max ${maxSlots} reference${maxSlots > 1 ? "s" : ""}` : ""}>
            <Icn name="image" />
            <span>
              {slots.length === 0
                ? (mediaKind === "image" ? "Add reference image" : "Add image / video")
                : slotsFull
                  ? `${slots.length}/${maxSlots} ref${maxSlots > 1 ? "s" : ""}`
                  : `Add ref (${slots.length}/${maxSlots})`}
            </span>
            <input ref={fileRef} type="file"
                   accept={mediaKind === "image" ? "image/*" : "image/*,video/mp4,video/webm,video/quicktime"}
                   hidden
                   onChange={(e) => addSlotFromFile(e.target.files?.[0])} />
          </button>

          {mediaKind === "video" ? (
            <>
              <PopChoice icon="tier"
                valueLabel={TIERS.find(t => t.id === tier).label}
                options={TIERS.map(t => ({ ...t, disabled: isR2V && t.id === "quality" }))}
                value={tier} onPick={setTier} />
              <PopChoice icon="clock"
                valueLabel={duration + "s"}
                options={DURATIONS.map(d => ({ ...d, disabled: isR2V && d.id !== 8 }))}
                value={duration} onPick={setDuration} />
              <PopChoice icon={orient === "landscape" ? "frame" : "frame-p"}
                valueLabel={ORIENTATIONS.find(o => o.id === orient).label}
                options={ORIENTATIONS} value={orient} onPick={setOrient} />
              <PopChoice icon="res"
                valueLabel={RESOLUTIONS.find(r => r.id === res).label}
                options={resOpts.map(r => ({
                  ...r,
                  disabled: r.disabled
                    || (tier === "lite" && r.id !== "720p")
                    || (isR2V && tier !== "fast" && r.id !== "720p"),
                }))}
                value={res} onPick={setRes} />
            </>
          ) : (
            <>
              <PopChoice icon="tier"
                valueLabel={IMAGE_FAMILIES.find(f => f.id === imageFamily).label}
                options={IMAGE_FAMILIES} value={imageFamily} onPick={setImageFamily} />
              {imageFamily === "gpt-images" ? (
                <PopChoice icon="frame"
                  valueLabel={gptRatio}
                  options={GPT_RATIOS.map(r => ({ ...r, desc: r.desc || "" }))}
                  value={gptRatio} onPick={setGptRatio} />
              ) : (
                <PopChoice icon={imageOrient === "landscape" ? "frame" : "frame-p"}
                  valueLabel={(imageFamily === "imagen" ? IMAGEN_ORIENTS : GEMINI_ORIENTS).find(o => o.id === imageOrient)?.label || imageOrient}
                  options={imageFamily === "imagen" ? IMAGEN_ORIENTS : GEMINI_ORIENTS}
                  value={imageOrient} onPick={setImageOrient} />
              )}
              {imageFamily !== "gpt-images" && imageFamily !== "imagen" && (
                <PopChoice icon="res"
                  valueLabel={IMAGE_RES.find(r => r.id === imageRes).label}
                  options={IMAGE_RES} value={imageRes} onPick={setImageRes} />
              )}
            </>
          )}

          <CountPop value={count} onPick={setCount} />

          <span className="ctl-spacer"></span>

          <span className="cost-pill" title={costTitle}>
            {realCost ? "" : "≈ "}<b>{costPer * count}</b>&nbsp;cr
            {realCost && <span className="cost-tier">{realCost.tierLabel}</span>}
          </span>

          <button className="btn-go" disabled={!canSubmit} onClick={submit}>
            <span>Generate</span>
            <span className="arr">↗</span>
          </button>
        </div>
      </div>
    </div>
  );
}

// ── Result Card ──
function ResultCard({ idx, total, task, onLazyLoad, onCancel, onReuse, onOpen }) {
  const isPortrait = task.config?.orientation === "portrait";
  const sCls = "s-" + task.status;
  const cardRef = useRef(null);

  // Revoke the result's blob URL when this card unmounts (Clear all / batch removal).
  // App.handleClear also revokes proactively in case React keeps the node mounted.
  useEffect(() => {
    const u = task.fileBlobUrl;
    return () => { if (u && u.startsWith("blob:")) URL.revokeObjectURL(u); };
  }, [task.fileBlobUrl]);

  // Lazy-load: completed tasks without a file URL (typically backfilled on
  // mount) request the blob the first time they enter the viewport.
  useEffect(() => {
    if (!onLazyLoad) return;
    if (task.status !== "completed" || task.fileBlobUrl || !task.taskId) return;
    const el = cardRef.current;
    if (!el) return;
    if (typeof IntersectionObserver === "undefined") {
      onLazyLoad(task.localId, task.taskId, task.fileExt);
      return;
    }
    const io = new IntersectionObserver((entries) => {
      if (entries.some(e => e.isIntersecting)) {
        onLazyLoad(task.localId, task.taskId, task.fileExt);
        io.disconnect();
      }
    }, { rootMargin: "200px" });
    io.observe(el);
    return () => io.disconnect();
  }, [task.status, task.fileBlobUrl, task.taskId, task.fileExt, task.localId, onLazyLoad]);

  return (
    <article className="card" ref={cardRef}
             onDoubleClick={() => task.fileUrl && onOpen && onOpen(task)}>
      <div className={"card-stage" + (isPortrait ? " portrait" : "")}>
        {task.fileUrl ? (
          <>
            {task.fileExt === "mp4"
              ? <video src={task.fileUrl} controls autoPlay loop muted playsInline />
              : <img src={task.fileUrl} alt="result" />}
            <a className="stage-dl" href={task.fileBlobUrl}
               download={`veo-${shortId(task.taskId)}.${task.fileExt || "mp4"}`}
               title="Download">
              <Icn name="dl" size={16} />
            </a>
          </>
        ) : (
          <div className="stage-overlay">
            {task.status === "queued" && onCancel && (
              <button className="stage-cancel" onClick={() => onCancel(task)} title="Cancel queued task">
                <Icn name="x" size={14} />
              </button>
            )}
            <div className="stage-num">
              {String(idx + 1).padStart(2, "0")}
              <span style={{color:"var(--ink-4)", fontSize:24, marginLeft:4}}>/{String(total).padStart(2,"0")}</span>
            </div>
            <div className={"stage-status " + sCls}>
              <span className="pulse"></span>
              {task.status === "submitting" ? "Submitting"
                : task.status === "queued" ? `Queued${task.position != null ? ` · #${task.position}` : ""}`
                : task.status === "processing" ? `Rendering${task.elapsed != null ? ` · ${task.elapsed}s` : ""}`
                : task.status === "cancelled" ? "Cancelled"
                : task.status === "failed" ? "Failed" : task.status}
            </div>
            {task.status === "processing" && <div className="scan-bar"></div>}
          </div>
        )}
      </div>

      <div className="card-foot">
        <div className="prompt-text">{task.prompt}</div>
        <div className="card-foot-row">
          <div className="card-tags">
            <span className="tag pri">{task.config?.mode?.toUpperCase()}</span>
            {task.config?.mediaKind === "image" ? (
              <>
                {task.config?.imageFamily && <span className="tag">{task.config.imageFamily}</span>}
                {task.config?.ratio
                  ? <span className="tag">{task.config.ratio}</span>
                  : task.config?.orientation && <span className="tag">{task.config.orientation}</span>}
                {task.config?.resolution && task.config.resolution !== "std" && <span className="tag">{task.config.resolution}</span>}
              </>
            ) : (
              <>
                <span className="tag">{task.config?.tier}</span>
                <span className="tag">{task.config?.duration}s</span>
                <span className="tag">{task.config?.resolution}</span>
              </>
            )}
            {task.cost != null && <span className="tag">{task.cost} cr</span>}
          </div>
          <div className="card-actions">
            {onReuse && task.config && (
              <button className="reuse-btn" onClick={() => onReuse(task)} title="Load these settings into the composer">
                <Icn name="redo" />
                <span>Reuse</span>
              </button>
            )}
            {task.fileBlobUrl && (
              <a className="dl-btn" href={task.fileBlobUrl}
                 download={`veo-${shortId(task.taskId)}.${task.fileExt || "mp4"}`}
                 title="Download">
                <Icn name="dl" />
                <span>Download</span>
              </a>
            )}
          </div>
        </div>
      </div>
      {task.error && <div className="card-err">{normalizeError(task.error)}</div>}
    </article>
  );
}

// Status priority: terminal states beat in-flight states. A late "queued"
// poll response must not overwrite a "completed" file already attached.
const STATUS_RANK = { submitting: 0, queued: 1, processing: 2, failed: 3, cancelled: 3, completed: 4 };

// Real-cost estimator using /me's effective_pricing + 30-day volume.
// Returns { credits, tierIdx, tierLabel, nextThreshold, isFixed } or null
// if we lack the data to compute (caller falls back to a heuristic hint).
const VIDEO_RE = /_(t2v|i2v|r2v)_/;
function estimateCost(model, config, account) {
  if (!account || !account.effective_pricing) return null;
  const pricing = account.effective_pricing;

  // Longest-prefix match against the priced model keys.
  let bestKey = null;
  for (const k of Object.keys(pricing)) {
    if (model.startsWith(k) && (!bestKey || k.length > bestKey.length)) bestKey = k;
  }
  if (!bestKey) return null;
  const entry = pricing[bestKey];
  const tiers = entry.tiers || [];
  if (tiers.length === 0) return null;

  // Volume drives tier (images vs videos counted separately per spec).
  const isVideo = VIDEO_RE.test(model);
  const vol = isVideo ? (account.vid_30d ?? 0) : (account.img_30d ?? 0);
  const thresholds = account.tier_thresholds || [1000, 5000, 10000];
  let tierIdx = 0;
  if (entry.is_fixed) tierIdx = 0;
  else for (let i = 0; i < thresholds.length; i++) if (vol >= thresholds[i]) tierIdx = i + 1;
  tierIdx = Math.min(tierIdx, tiers.length - 1);

  let cost = tiers[tierIdx];

  // Duration / resolution multipliers — heuristic (spec doesn't enumerate
  // per-duration prices yet). Matches the prior COST_HINT shape so totals
  // don't shift drastically until backend exposes a precise schedule.
  if (config?.duration === 4) cost = cost * 0.5;
  else if (config?.duration === 6) cost = cost * 0.75;
  if (config?.resolution === "1080p") cost += 6;
  else if (config?.resolution === "4k") cost += 16;
  cost = Math.round(cost);

  const nextThreshold = !entry.is_fixed && tierIdx < thresholds.length ? thresholds[tierIdx] : null;
  return {
    credits: cost,
    tierIdx,
    tierLabel: `PL${tierIdx + 1}`,
    nextThreshold,
    volume: vol,
    isVideo,
    isFixed: !!entry.is_fixed,
    matchedPrefix: bestKey,
  };
}

// Banner severity heuristic — the API doesn't ship a severity field, so we
// look for keywords that indicate the service is intentionally paused. When
// any of these match, Generate is blocked until the banner clears.
function isCriticalBanner(b) {
  if (!b || !b.visible) return false;
  const text = (b.text || "") + " " + (Array.isArray(b.banners) ? b.banners.map(x => x?.text || "").join(" ") : "");
  return /\b(paused|halted|down|critical|outage|emergency|maintenance)\b/i.test(text);
}

// Reverse-parse a veo model name (e.g. "veo_3_1_t2v_fast_landscape_1080p")
// into the same {mode,tier,duration,orientation,resolution} shape we build.
// Best-effort: anything we can't tell falls back to safe defaults so the UI
// still renders without throwing.
function parseModel(model = "") {
  const m = String(model);
  const mode = /_t2v_/.test(m) ? "t2v" : /_i2v_/.test(m) ? "i2v" : /_r2v_/.test(m) ? "r2v" : "t2v";
  const orientation = /_portrait/.test(m) ? "portrait" : "landscape";
  const resolution = /_4k($|\b)/.test(m) ? "4k" : /_1080p($|\b)/.test(m) ? "1080p" : "720p";
  const dMatch = m.match(/_(\d)s_/);
  const duration = dMatch ? parseInt(dMatch[1], 10) : 8;
  let tier = "fast";
  if (/_lite_/.test(m)) tier = "lite";
  else if (/_quality_/.test(m) || /_i2v_s_/.test(m)) tier = "quality";
  else if (mode === "t2v" && duration === 8 && !/_fast_/.test(m) && !/_lite_/.test(m)) tier = "quality"; // veo_3_1_t2v_landscape* shape
  else if (/_fast_/.test(m)) tier = "fast";
  return { mode, tier, duration, orientation, resolution };
}
function mergeTaskPatch(prev, patch) {
  const next = { ...prev };
  const incomingRank = patch.status != null ? (STATUS_RANK[patch.status] ?? -1) : -1;
  const currentRank  = prev.status   != null ? (STATUS_RANK[prev.status]   ?? -1) : -1;
  const accept = patch.status == null || incomingRank >= currentRank;

  for (const k in patch) {
    const v = patch[k];
    if (k === "status" || k === "position") {
      if (!accept) continue;          // stale ordering signal — drop
      if (k === "position" && v == null) continue; // never clear a known position with undefined
      next[k] = v;
    } else if (k === "fileBlobUrl" || k === "fileUrl" || k === "fileExt") {
      // Once a file is attached, don't let a later patch null it out.
      if (v == null && prev[k] != null) continue;
      next[k] = v;
    } else {
      next[k] = v;
    }
  }
  return next;
}

// ── App ──
function App() {
  const [tasks, setTasks] = useState([]);
  const [toast, setToast] = useState(null);
  const [account, setAccount] = useState(null);
  const [health, setHealth] = useState(null);   // {status, capacity}
  const [banner, setBanner] = useState(null);   // {text, visible, banners}
  const [filter, setFilter] = useState("all"); // all|queued|processing|done|failed
  const [lightbox, setLightbox] = useState(null); // task or null
  const [reuseSeed, setReuseSeed] = useState(null);
  const tasksRef = useRef(tasks);
  tasksRef.current = tasks;

  useEffect(() => {
    HOLO.me().then(setAccount).catch(() => {});
  }, []);

  // ── /banner: pull once on mount ──
  useEffect(() => {
    HOLO.banner().then(setBanner).catch(() => {});
  }, []);

  // ── /health: poll every 60s (and immediately) ──
  useEffect(() => {
    let cancelled = false;
    const pull = () => HOLO.health()
      .then(h => { if (!cancelled) setHealth(h); })
      .catch(() => { if (!cancelled) setHealth({ status: "down", capacity: "unknown" }); });
    pull();
    const i = setInterval(pull, 60000);
    return () => { cancelled = true; clearInterval(i); };
  }, []);

  // ── Backfill on mount ──
  // Pull the most recent 20 tasks so a refresh doesn't drop in-flight work.
  // queued/processing rejoin the polling loop; completed sit in the reel and
  // lazy-load their files (ResultCard requests when it scrolls into view).
  useEffect(() => {
    let cancelled = false;
    HOLO.listTasks({ limit: 20 }).then((data) => {
      if (cancelled) return;
      const list = Array.isArray(data?.tasks) ? data.tasks : [];
      if (list.length === 0) return;
      const restored = list.map((s, i) => {
        const config = parseModel(s.model);
        return {
          localId: `restored-${s.task_id}`,
          batchTime: s.created_at ? new Date(s.created_at.replace(" ", "T") + "Z").getTime() : Date.now() - i,
          prompt: s.prompt || "",
          imageUrl: null,
          model: s.model,
          config,
          status: s.status,
          taskId: s.task_id,
          cost: s.cost ?? null,
          position: null,
          startedAt: null,
          elapsed: null,
          fileUrl: null,
          fileBlobUrl: null,
          fileExt: s.result?.file_ext ?? null,
          error: normalizeError(s.error),
          errorStatus: null,
          restored: true,
        };
      });
      // Merge with any tasks already in state (none on cold start, but guard anyway).
      setTasks(prev => {
        const seen = new Set(prev.map(t => t.taskId).filter(Boolean));
        const fresh = restored.filter(t => !seen.has(t.taskId));
        return [...prev, ...fresh].sort((a, b) => b.batchTime - a.batchTime);
      });
    }).catch((e) => console.warn("listTasks failed:", e));
    return () => { cancelled = true; };
  }, []);

  // auto-dismiss toast
  useEffect(() => {
    if (!toast) return;
    const t = setTimeout(() => setToast(null), 3600);
    return () => clearTimeout(t);
  }, [toast]);

  const updateTask = useCallback((localId, patch) => {
    setTasks(prev => prev.map(t => t.localId === localId ? mergeTaskPatch(t, patch) : t));
  }, []);

  // ── Polling loop ──
  // Self-rescheduling setTimeout (not setInterval): the next tick is queued
  // only after the current one finishes, so a slow tick can't double-fire.
  // File downloads run in a separate queue keyed by taskId so a slow blob
  // fetch never blocks the next poll round.
  const inFlightRef = useRef(false);
  const fileQueueRef = useRef(new Map()); // taskId -> Promise (in-flight download)
  const errCountRef = useRef(0);

  const enqueueFileDownload = useCallback((localId, taskId, fileExt) => {
    if (fileQueueRef.current.has(taskId)) return;
    const p = (async () => {
      try {
        const blobUrl = await HOLO.fetchFileBlobUrl(taskId);
        updateTask(localId, { fileBlobUrl: blobUrl, fileUrl: blobUrl, fileExt });
      } catch (e) {
        updateTask(localId, { error: "File fetch failed: " + e.message });
      } finally {
        fileQueueRef.current.delete(taskId);
      }
    })();
    fileQueueRef.current.set(taskId, p);
  }, [updateTask]);

  useEffect(() => {
    let stopped = false;
    let timer = null;

    const tick = async () => {
      if (stopped || inFlightRef.current) return scheduleNext();
      inFlightRef.current = true;

      const live = tasksRef.current.filter(t => t.taskId && (t.status === "queued" || t.status === "processing"));
      let hadError = false;

      for (const t of live) {
        if (stopped) break;
        try {
          const s = await HOLO.poll(t.taskId);
          const patch = {
            status: s.status,
            position: s.position ?? t.position,
            cost: s.cost ?? t.cost,
          };
          if (s.started_at && t.startedAt == null) patch.startedAt = Date.now();
          if (t.startedAt) patch.elapsed = Math.round((Date.now() - t.startedAt) / 1000);

          if (s.status === "completed" && s.result) {
            // Don't await the file download — kick it off async and continue polling.
            enqueueFileDownload(t.localId, t.taskId, s.result.file_ext);
          } else if (s.status === "failed") {
            patch.error = normalizeError(s.error) || "Unknown error";
          }
          updateTask(t.localId, patch);
        } catch (e) {
          hadError = true;
          console.warn("poll failed:", t.taskId, e);
        }
      }

      errCountRef.current = hadError ? errCountRef.current + 1 : 0;
      inFlightRef.current = false;
      scheduleNext();
    };

    const scheduleNext = () => {
      if (stopped) return;
      const live = tasksRef.current.filter(t => t.taskId && (t.status === "queued" || t.status === "processing"));
      let base;
      if (live.length === 0) base = 30000;
      else if (live.some(t => t.status === "processing")) base = 5000;
      else base = 10000;

      if (errCountRef.current > 0) {
        const back = Math.min(60000, base * Math.pow(2, errCountRef.current));
        const jitter = Math.floor(Math.random() * 500);
        base = back + jitter;
      }
      timer = setTimeout(tick, base);
    };

    scheduleNext();
    return () => { stopped = true; if (timer) clearTimeout(timer); };
  }, [updateTask, enqueueFileDownload]);

  const submittingRef = useRef(false);
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit = async ({ prompt, imageUrl, imageUrls, model, config, count }) => {
    if (submittingRef.current) return; // dedupe rapid re-clicks

    // Pre-flight: refuse to submit when service is reported down.
    try {
      const h = await HOLO.health();
      setHealth(h);
      if (h.status && h.status !== "ok") {
        setToast({ msg: `Service ${h.status} — try again later`, kind: "err" });
        return;
      }
    } catch {
      setToast({ msg: "Service unreachable — try again later", kind: "err" });
      return;
    }

    if (isCriticalBanner(banner)) {
      setToast({ msg: "Service paused — see banner", kind: "err" });
      return;
    }

    submittingRef.current = true;
    setSubmitting(true);
    const batchTime = Date.now();
    const refCount = Array.isArray(imageUrls) ? imageUrls.length : (imageUrl ? 1 : 0);
    const videoMode = refCount === 0 ? "t2v" : refCount === 1 ? "i2v" : "r2v";
    const newTasks = Array.from({ length: count }, (_, i) => ({
      localId: `${batchTime}-${i}`,
      batchTime, prompt, imageUrl, imageUrls, model,
      config: {
        ...config,
        mode: config.mediaKind === "image"
          ? (refCount > 0 ? "r2i" : "t2i")
          : videoMode,
      },
      status: "submitting",
      taskId: null, cost: null, position: null,
      startedAt: null, elapsed: null,
      fileUrl: null, fileBlobUrl: null, fileExt: null, error: null, errorStatus: null,
    }));
    setTasks(prev => [...newTasks, ...prev]);
    setToast({ msg: `Submitting ${count} task${count > 1 ? "s" : ""}…`, kind: "info" });

    // Submit one task with up to 3 retries on 429/503; fail fast on 4xx.
    const submitOne = async (t) => {
      const jitter = () => 100 + Math.floor(Math.random() * 200);
      let lastErr;
      for (let attempt = 0; attempt < 4; attempt++) {
        try {
          const r = await HOLO.submit({ prompt, imageUrl, imageUrls, model });
          updateTask(t.localId, {
            taskId: r.task_id,
            status: r.status || "queued",
            position: r.position,
            cost: r.cost,
          });
          return;
        } catch (e) {
          lastErr = e;
          const retryable = e.status === 429 || e.status === 503;
          if (!retryable || attempt === 3) break;
          const back = Math.min(8000, 500 * Math.pow(2, attempt)) + jitter();
          await new Promise(r => setTimeout(r, back));
        }
      }
      updateTask(t.localId, {
        status: "failed",
        error: lastErr.message,
        errorStatus: lastErr.status ?? null,
      });
      // Surface a semantic toast for the first failure of this batch.
      const semantic = lastErr.status === 401 ? "Auth failed — check API key"
                     : lastErr.status === 402 ? "Insufficient credits"
                     : lastErr.status === 400 ? "Invalid request"
                     : lastErr.status === 429 ? "Rate limited — try again in a moment"
                     : lastErr.status === 503 ? "Service busy — try again shortly"
                     : `Submit failed: ${lastErr.message}`;
      setToast({ msg: semantic, kind: "err" });
    };

    // Concurrency-3 worker pool with 100-300ms jitter between launches.
    const queue = newTasks.slice();
    const worker = async () => {
      while (queue.length) {
        const t = queue.shift();
        await submitOne(t);
        await new Promise(r => setTimeout(r, 100 + Math.floor(Math.random() * 200)));
      }
    };
    await Promise.allSettled([worker(), worker(), worker()]);

    submittingRef.current = false;
    setSubmitting(false);
    HOLO.me().then(setAccount).catch(() => {});
  };

  const handleClear = () => {
    if (tasks.some(t => t.status === "queued" || t.status === "processing")) {
      if (!confirm("Some tasks are still running. Clear anyway?")) return;
    }
    tasks.forEach(t => {
      if (t.fileBlobUrl && t.fileBlobUrl.startsWith("blob:")) URL.revokeObjectURL(t.fileBlobUrl);
    });
    setTasks([]);
  };

  const handleReuse = useCallback((task) => {
    setReuseSeed({ prompt: task.prompt, config: task.config, ts: Date.now() });
  }, []);

  const handleDownloadAllCompleted = useCallback(() => {
    const done = tasksRef.current.filter(t => t.status === "completed" && t.fileBlobUrl);
    if (done.length === 0) {
      setToast({ msg: "No completed files to download", kind: "info" });
      return;
    }
    // Programmatic anchor click per file (browsers throttle but allow).
    done.forEach((t, i) => {
      setTimeout(() => {
        const a = document.createElement("a");
        a.href = t.fileBlobUrl;
        a.download = `veo-${shortId(t.taskId)}.${t.fileExt || "mp4"}`;
        a.style.display = "none";
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
      }, i * 250);
    });
  }, []);

  const handleDeleteFailed = useCallback(() => {
    setTasks(prev => prev.filter(t => t.status !== "failed"));
  }, []);

  // Esc closes lightbox.
  useEffect(() => {
    if (!lightbox) return;
    const onKey = (e) => { if (e.key === "Escape") setLightbox(null); };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [lightbox]);

  const handleCancel = useCallback(async (task) => {
    if (!task.taskId) return;
    if (!confirm("Cancel this queued task? Credits will be refunded.")) return;
    try {
      const r = await HOLO.cancelTask(task.taskId);
      updateTask(task.localId, { status: "cancelled", refunded: r.refunded ?? null });
      // Server refunds immediately — refresh credits.
      HOLO.me().then(setAccount).catch(() => {});
    } catch (e) {
      const msg = e.status === 400 || e.status === 409
        ? "Task already started — cannot cancel"
        : `Cancel failed: ${e.message}`;
      setToast({ msg, kind: "err" });
    }
  }, [updateTask]);

  // Health → chip color/label.
  const healthState = !health ? "unknown"
    : health.status !== "ok" ? "down"
    : health.capacity === "busy" ? "busy"
    : "online";
  const healthLabel = healthState === "online" ? "Online"
                     : healthState === "busy"  ? "Busy"
                     : healthState === "down"  ? "Down"
                     : "—";
  const bannerCritical = isCriticalBanner(banner);

  // group tasks by batch
  const batches = (() => {
    const m = {};
    tasks.forEach(t => { (m[t.batchTime] = m[t.batchTime] || []).push(t); });
    return Object.values(m).sort((a, b) => b[0].batchTime - a[0].batchTime);
  })();

  return (
    <>
      <header className="topbar">
        <div className="brand">
          <span className="brand-mark">V</span>
          <span>Veo Studio</span>
          <span className="brand-tag">HOLO · v1.4</span>
        </div>
        <div className="topmeta">
          <span className={"chip health-" + healthState}><span className="dot"></span>{healthLabel}</span>
          <AccountChip account={account} />
        </div>
      </header>

      {banner?.visible && banner.text && (
        <div className={"service-banner" + (bannerCritical ? " critical" : "")}>
          <span className="dot"></span>
          <span>{banner.text}</span>
        </div>
      )}

      <main className="shell">
        <div className="hero">
          <h1>Generate up to 10 takes <em>at once.</em></h1>
          <p>Type a prompt. Drop a frame. Pick a tier. Get back a reel of cinematic videos in seconds.</p>
        </div>

        <Composer onSubmit={handleSubmit} submitting={submitting || bannerCritical || healthState === "down"} account={account} reuseSeed={reuseSeed} />

        <div className="section-h">
          <h2>The reel<span className="count">{tasks.length > 0 ? tasks.length : ""}</span></h2>
          <div className="reel-actions">
            {tasks.length > 0 && (
              <div className="filter-chips">
                {[
                  { id: "all",        label: "All" },
                  { id: "queued",     label: "Queued" },
                  { id: "processing", label: "Processing" },
                  { id: "done",       label: "Done" },
                  { id: "failed",     label: "Failed" },
                ].map(f => (
                  <button key={f.id}
                          className={"filter-chip" + (filter === f.id ? " active" : "")}
                          onClick={() => setFilter(f.id)}>
                    {f.label}
                  </button>
                ))}
              </div>
            )}
            {tasks.some(t => t.status === "completed" && t.fileBlobUrl) && (
              <button className="btn-ghost" onClick={handleDownloadAllCompleted}>Download all done</button>
            )}
            {tasks.some(t => t.status === "failed") && (
              <button className="btn-ghost" onClick={handleDeleteFailed}>Delete failed</button>
            )}
            {tasks.length > 0 && <button className="btn-ghost" onClick={handleClear}>Clear all</button>}
          </div>
        </div>

        {tasks.length === 0 ? (
          <div className="empty">
            <div className="empty-glyph"></div>
            <h3>No takes yet</h3>
            <p>Submit a generation above and your videos will appear here.</p>
          </div>
        ) : (() => {
          const matchFilter = (t) => {
            if (filter === "all") return true;
            if (filter === "done") return t.status === "completed";
            if (filter === "failed") return t.status === "failed" || t.status === "cancelled";
            return t.status === filter;
          };
          const visible = batches
            .map(b => b.filter(matchFilter))
            .filter(b => b.length > 0);
          if (visible.length === 0) {
            return <div className="empty"><h3>No takes match “{filter}”</h3><p>Try a different filter.</p></div>;
          }
          return visible.map((batch, bi) => {
            const hasPortrait = batch.some(t => t.config?.orientation === "portrait");
            return (
              <React.Fragment key={batch[0].batchTime}>
                <div className="batch-h">
                  Batch {String(visible.length - bi).padStart(2, "0")} · {batch.length} take{batch.length > 1 ? "s" : ""} · {new Date(batch[0].batchTime).toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"})}
                </div>
                <div className={"reel-grid" + (hasPortrait ? " has-portrait" : "")}>
                  {batch.map((t, i) => (
                    <ResultCard key={t.localId} idx={i} total={batch.length} task={t}
                                onLazyLoad={enqueueFileDownload}
                                onCancel={handleCancel}
                                onReuse={handleReuse}
                                onOpen={setLightbox} />
                  ))}
                </div>
              </React.Fragment>
            );
          });
        })()}
      </main>

      {lightbox && (
        <div className="lightbox" onClick={() => setLightbox(null)}>
          <button className="lightbox-x" onClick={(e) => { e.stopPropagation(); setLightbox(null); }} title="Close (Esc)">
            <Icn name="x" size={18} />
          </button>
          <div className="lightbox-stage" onClick={(e) => e.stopPropagation()}>
            {lightbox.fileExt === "mp4"
              ? <video src={lightbox.fileUrl} controls autoPlay loop playsInline />
              : <img src={lightbox.fileUrl} alt="result" />}
            <div className="lightbox-foot">
              <div className="lightbox-prompt">{lightbox.prompt}</div>
            </div>
          </div>
        </div>
      )}

      {toast && <div className={"toast " + (toast.kind === "err" ? "err" : "")}>
        <span className="dot"></span>{toast.msg}
      </div>}
    </>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);

// auto-dismiss toast
window.__toastTimer && clearTimeout(window.__toastTimer);
