/* Slate — Video Backgrounds (TEMP dev surface)
   ===================================================
   Per-org library of video clips that can replace the species-display
   kiosk's ambient particle scene. Two creation paths:

     • Generate with AI: prompt + Suggest button + Start button. Server
       runs a fire-and-forget worker that calls fal Kling text-to-video,
       writes status updates to the row, and the page polls until the
       row reaches status='ready' or 'error'. Mirrors the species-editor
       Animation tab pattern.

     • Upload: drop in an mp4/webm/mov directly. Useful for testing a
       clip from any source (Sora, Veo, Runway, hand-edited).

   Cards expose: live <video> preview, Set Active / Active badge,
   Delete, and (for pending rows) progress bar + stage + cost so far,
   (for errored rows) the error message + Retry.

   Auth: gated to aquaos_admin via ROLE_NAV; the page also renders an
   empty state if the user lacks the role (the GET 403s).

   Public exports:
     window.VideoBackgroundsScreen
*/

(function () {
  const { useState, useEffect, useRef, useCallback, useMemo } = React;

  /* ─── Helpers ─────────────────────────────────────────────────── */
  function fmtSize(bytes) {
    if (!bytes && bytes !== 0) return '—';
    const mb = bytes / (1024 * 1024);
    if (mb < 1) return (bytes / 1024).toFixed(0) + ' KB';
    return mb.toFixed(1) + ' MB';
  }

  function fmtDate(iso) {
    if (!iso) return '';
    try {
      return new Date(iso).toLocaleString('en-GB', {
        day: '2-digit', month: 'short', year: 'numeric',
        hour: '2-digit', minute: '2-digit',
      });
    } catch (_) { return iso; }
  }

  function fmtPercent(p) {
    if (p == null) return '';
    return Math.min(100, Math.max(0, Math.round(p * 100))) + '%';
  }

  function fmtCents(c) {
    if (c == null) return '—';
    return '$' + (c / 100).toFixed(2);
  }

  function stageLabel(stage) {
    return ({
      queued:     'Queued at fal',
      imaging:    'Painting still frame',
      generating: 'Animating',
      upscale:    'Upscaling to 1440p (Topaz)',
      uploading:  'Uploading to storage',
      ready:      'Ready',
      error:      'Error',
    })[stage] || stage || '…';
  }

  /* ─── XHR upload (apiFetch always sets JSON Content-Type) ─────── */
  async function uploadBackground(name, file, onProgress) {
    const token = Auth.getToken();
    const fd = new FormData();
    if (name) fd.append('name', name);
    fd.append('file', file);

    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open('POST', '/api/video-backgrounds');
      if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable && typeof onProgress === 'function') {
          onProgress(e.loaded / e.total);
        }
      };
      xhr.onload = () => {
        if (xhr.status >= 200 && xhr.status < 300) {
          try { resolve(JSON.parse(xhr.responseText)); } catch (_) { resolve(null); }
        } else {
          let msg = `${xhr.status} ${xhr.statusText}`;
          try { const body = JSON.parse(xhr.responseText); if (body.error) msg = body.error; } catch (_) {}
          reject(new Error(msg));
        }
      };
      xhr.onerror = () => reject(new Error('Network error'));
      xhr.send(fd);
    });
  }

  /* ─── Generate form (AI) ──────────────────────────────────────── */
  function GenerateForm({ onStarted }) {
    // Two prompt fields — image (sent to flux/schnell when needed) and
    // video (sent to Kling/Luma). For text-to-video providers like Luma
    // Ray 2, only the video prompt is used. For Kling i2v, both are used.
    const [imagePrompt, setImagePrompt] = useState('');
    const [videoPrompt, setVideoPrompt] = useState('');
    const [presets, setPresets] = useState([]);
    const [presetId, setPresetId] = useState('');
    const [provider, setProvider] = useState('luma-ray-2');
    const [duration, setDuration] = useState(5);
    const [aspectRatio, setAspectRatio] = useState('16:9');
    const [aspectRatios, setAspectRatios] = useState([]);
    const [providers, setProviders] = useState([]);
    const [busy, setBusy] = useState(false);
    const [err, setErr] = useState(null);
    const [name, setName] = useState('');

    // Photo-preview state — when set, the form renders the still inline
    // and swaps the "Start" button for Regenerate / Make video so the
    // curator can iterate on the photo before paying for the video.
    // The previewUrl is passed as seed_image_url into /generate so the
    // pipeline skips its own flux step. Cleared when the user edits
    // the image prompt or aspect ratio (those invalidate the still).
    const [previewUrl, setPreviewUrl] = useState(null);
    const [previewPrompt, setPreviewPrompt] = useState('');
    const [previewBusy, setPreviewBusy] = useState(false);
    const [previewErr, setPreviewErr] = useState(null);
    // Lightbox state — flipped true when the curator clicks the
    // preview thumbnail. Closes on backdrop click or Escape.
    const [lightboxOpen, setLightboxOpen] = useState(false);

    const previewStale = !!previewUrl && previewPrompt !== imagePrompt.trim();

    // Wire Escape to close the lightbox.
    useEffect(() => {
      if (!lightboxOpen) return;
      const onKey = (e) => { if (e.key === 'Escape') setLightboxOpen(false); };
      window.addEventListener('keydown', onKey);
      return () => window.removeEventListener('keydown', onKey);
    }, [lightboxOpen]);

    // Load provider list + preset list once. Presets pre-fill the
    // prompt textarea on selection; the user can still tweak before
    // hitting Start. Backend exports both so we never drift between
    // CMS labels and worker-accepted keys.
    useEffect(() => {
      apiFetch('/api/video-backgrounds/providers')
        .then((r) => {
          setProviders(r.providers || []);
          if (r.default) setProvider(r.default);
        })
        .catch(() => {});
      apiFetch('/api/video-backgrounds/aspect-ratios')
        .then((r) => {
          setAspectRatios(r.aspectRatios || []);
          if (r.default) setAspectRatio(r.default);
        })
        .catch(() => {});
      apiFetch('/api/video-backgrounds/prompt-presets')
        .then((r) => {
          const list = r.presets || [];
          setPresets(list);
          if (list.length > 0 && !imagePrompt && !videoPrompt) {
            setPresetId(list[0].id);
            setImagePrompt(list[0].imagePrompt || '');
            setVideoPrompt(list[0].videoPrompt || list[0].preview || '');
            if (!name) setName(list[0].label);
          }
        })
        .catch(() => {});
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    const onPickPreset = (id) => {
      setPresetId(id);
      const p = presets.find((x) => x.id === id);
      if (p) {
        setImagePrompt(p.imagePrompt || '');
        setVideoPrompt(p.videoPrompt || p.preview || '');
        if (!name || presets.some((x) => x.label === name)) {
          setName(p.label);
        }
      }
    };

    // Generate the still frame only (flux/schnell). Cheap + fast so
    // the curator can preview before committing to a video render.
    const onPreview = async () => {
      const ip = imagePrompt.trim();
      if (!ip) { setPreviewErr('Write an image prompt first.'); return; }
      setPreviewBusy(true); setPreviewErr(null);
      try {
        const r = await apiFetch('/api/video-backgrounds/preview-image', {
          method: 'POST',
          body: JSON.stringify({ image_prompt: ip, aspect_ratio: aspectRatio }),
        });
        if (r && r.url) {
          setPreviewUrl(r.url);
          setPreviewPrompt(ip);
        } else {
          setPreviewErr('Preview returned no image URL.');
        }
      } catch (e2) {
        setPreviewErr(e2.message || String(e2));
      } finally {
        setPreviewBusy(false);
      }
    };

    // Kick off the video render. When a fresh, in-sync preview exists,
    // pass its URL as seed_image_url so the backend skips flux. When
    // no preview was made, the pipeline runs flux internally as
    // before — the photo-preview flow is opt-in, not mandatory.
    const onSubmit = async (e) => {
      e.preventDefault();
      const ip = imagePrompt.trim();
      const vp = videoPrompt.trim();
      if (!ip && !vp) { setErr('Pick a scene or write at least one prompt.'); return; }
      // Guard rail — if the curator's edited the image prompt since
      // previewing, the still no longer matches. Don't silently use
      // a stale seed; surface the mismatch.
      if (previewStale) {
        setErr('You edited the image prompt since the last preview. Re-preview or clear the preview first.');
        return;
      }
      setBusy(true); setErr(null);
      try {
        const created = await apiFetch('/api/video-backgrounds/generate', {
          method: 'POST',
          body: JSON.stringify({
            name: name.trim() || undefined,
            // preset_id wins on the backend if set — backend resolves
            // the full preset prompt server-side. We clear preset_id
            // whenever the user edits either textarea (see onChange),
            // so if it's still here we know the user kept the preset.
            preset_id: presetId || undefined,
            // Always send both fields so backend can construct a split
            // prompt object even when preset_id is absent.
            image_prompt: ip || undefined,
            video_prompt: vp || undefined,
            provider,
            duration_seconds: duration,
            aspect_ratio: aspectRatio,
            // Skip flux when the curator already picked a preview.
            seed_image_url: previewUrl || undefined,
          }),
        });
        if (window.toast) window.toast('Generation started · ' + (created.name || ''));
        onStarted && onStarted(created);
        setName('');
        // Drop the preview now that it's been consumed.
        setPreviewUrl(null);
        setPreviewPrompt('');
      } catch (e2) {
        setErr(e2.message || String(e2));
      } finally {
        setBusy(false);
      }
    };

    const onDiscardPreview = () => {
      setPreviewUrl(null);
      setPreviewPrompt('');
      setPreviewErr(null);
    };

    const sel = providers.find((p) => p.id === provider);
    const costNote = sel ? `~${fmtCents(sel.approxCostCents * (duration / 5))} per ${duration}s clip on ${sel.label}` : '';

    return (
      <form onSubmit={onSubmit} className="aq-card" style={{ padding: 16, marginBottom: 16 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
          <h3 style={{ margin: 0, fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
            Generate with AI
          </h3>
          <span style={{
            padding: '2px 8px', fontSize: 10, fontWeight: 600,
            background: 'rgba(168,85,247,0.18)', color: '#d8b4fe',
            borderRadius: 4, textTransform: 'uppercase', letterSpacing: '0.04em',
          }}>
            Kling
          </span>
        </div>

        <div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 10, alignItems: 'center', marginBottom: 10 }}>
          <label style={{ fontSize: 12, color: 'var(--text-dim)' }}>Scene</label>
          <select
            value={presetId}
            onChange={(e) => onPickPreset(e.target.value)}
            disabled={busy || presets.length === 0}
            className="aq-input"
            style={{ padding: '8px 10px', fontSize: 13 }}
          >
            {presets.length === 0 && <option value="">Loading scenes…</option>}
            {presets.map((p) => (
              <option key={p.id} value={p.id}>{p.label}</option>
            ))}
          </select>
        </div>

        {/* Image prompt — sent to flux/schnell. Only used when the
            selected provider is image-to-video (Kling). For text-to-video
            providers like Luma Ray 2 this field is informational only. */}
        <div style={{ marginTop: 8 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 4 }}>
            <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
              Image prompt
            </label>
            <span style={{ fontSize: 10, color: 'var(--text-dim)' }}>
              Sent to flux/schnell for the still
            </span>
          </div>
          <textarea
            rows={3}
            placeholder="Describe the still image flux/schnell should paint."
            value={imagePrompt}
            onChange={(e) => { setImagePrompt(e.target.value); setPresetId(''); }}
            disabled={busy}
            className="aq-input"
            style={{
              width: '100%', padding: '10px 12px', resize: 'vertical',
              fontFamily: 'inherit', fontSize: 13, lineHeight: 1.5,
            }}
          />
        </div>

        {/* Video prompt — sent to Kling/Luma. The motion description. */}
        <div style={{ marginTop: 10 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 4 }}>
            <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
              Video prompt
            </label>
            <span style={{ fontSize: 10, color: 'var(--text-dim)' }}>
              {provider === 'luma-ray-2' ? 'Sent to Luma Ray 2 i2v (native loop)' : 'Sent to Kling for the motion'}
            </span>
          </div>
          <textarea
            rows={3}
            placeholder="Describe the motion the model should add."
            value={videoPrompt}
            onChange={(e) => { setVideoPrompt(e.target.value); setPresetId(''); }}
            disabled={busy}
            className="aq-input"
            style={{ width: '100%', padding: '10px 12px', resize: 'vertical', fontFamily: 'inherit', fontSize: 13, lineHeight: 1.5 }}
          />
        </div>

        {presetId && presets.find((p) => p.id === presetId) && (
          <div style={{ marginTop: 8, fontSize: 11, color: 'var(--text-dim)', lineHeight: 1.5 }}>
            {presets.find((p) => p.id === presetId).description}
          </div>
        )}

        <div style={{ display: 'grid', gridTemplateColumns: '1fr 200px 150px 120px auto', gap: 10, marginTop: 10, alignItems: 'center' }}>
          <input
            type="text"
            placeholder="Name (optional)"
            value={name}
            onChange={(e) => setName(e.target.value)}
            disabled={busy}
            className="aq-input"
            style={{ padding: '8px 10px', fontSize: 13 }}
          />
          <select
            value={provider}
            onChange={(e) => setProvider(e.target.value)}
            disabled={busy || providers.length === 0}
            className="aq-input"
            style={{ padding: '8px 10px', fontSize: 13 }}
          >
            {providers.length === 0 && <option value="kling-v2.5">Kling 2.5 Turbo Pro</option>}
            {providers.map((p) => (
              <option key={p.id} value={p.id}>{p.label}</option>
            ))}
          </select>
          <select
            value={aspectRatio}
            onChange={(e) => setAspectRatio(e.target.value)}
            disabled={busy || aspectRatios.length === 0}
            className="aq-input"
            style={{ padding: '8px 10px', fontSize: 13 }}
            title="Match this to the kiosk screen orientation"
          >
            {aspectRatios.length === 0 && <option value="16:9">Landscape (16:9)</option>}
            {aspectRatios.map((a) => (
              <option key={a.id} value={a.id}>{a.label}</option>
            ))}
          </select>
          <select
            value={duration}
            onChange={(e) => setDuration(parseInt(e.target.value))}
            disabled={busy}
            className="aq-input"
            style={{ padding: '8px 10px', fontSize: 13 }}
          >
            <option value={5}>5 seconds</option>
            <option value={10}>10 seconds</option>
          </select>
          {/* Preview-image button — runs flux only (~$0.003, 3-5s).
              The "Make video" button appears once a preview is in
              place; until then the curator iterates here cheaply. */}
          <button
            type="button"
            onClick={onPreview}
            disabled={previewBusy || busy || !imagePrompt.trim()}
            className="aq-btn"
            style={{ padding: '8px 18px', fontSize: 13 }}
            title="Generate the still frame only — fast and cheap (~$0.003). Iterate here, then click Make video."
          >
            {previewBusy ? 'Painting…' : (previewUrl ? 'Re-preview' : 'Preview image')}
          </button>
          <button
            type="submit"
            disabled={busy || previewBusy || (!imagePrompt.trim() && !videoPrompt.trim()) || previewStale}
            className="aq-btn aq-btn-primary"
            style={{ padding: '8px 22px', fontSize: 13 }}
            title={previewUrl
              ? 'Animate the previewed still verbatim — the i2v step uses this exact frame as its seed, no new flux pass.'
              : 'Skip preview and run the full pipeline (flux paints a still, then the i2v step animates it).'}
          >
            {busy ? 'Starting…' : (previewUrl ? 'Make video from this still' : 'Start')}
          </button>
        </div>

        {err && (
          <div style={{ marginTop: 10, padding: '8px 10px', background: 'rgba(239,68,68,0.12)', color: '#fda4af', fontSize: 13, borderRadius: 6 }}>
            {err}
          </div>
        )}
        {previewErr && (
          <div style={{ marginTop: 10, padding: '8px 10px', background: 'rgba(239,68,68,0.12)', color: '#fda4af', fontSize: 13, borderRadius: 6 }}>
            Preview failed: {previewErr}
          </div>
        )}

        {/* Preview block — appears after the first successful flux
            run. Shows the still + a hint about what to do next. The
            curator can: edit the image prompt and click Re-preview
            (cheap), edit the video prompt and click Make video
            (uses the previewed still as the seed verbatim), or
            Discard. Thumbnail is clickable → lightbox so curators
            can judge the image at full resolution before committing
            to the video render. */}
        {previewUrl && (
          <div style={{
            marginTop: 14, padding: 12,
            background: 'var(--surface-2, rgba(0,0,0,0.18))',
            border: '1px solid ' + (previewStale ? 'rgba(250,204,21,0.5)' : 'var(--border, rgba(255,255,255,0.08))'),
            borderRadius: 8,
            display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 12, alignItems: 'flex-start',
          }}>
            <button
              type="button"
              onClick={() => setLightboxOpen(true)}
              title="Click to view at full size"
              style={{
                padding: 0, border: 0, background: 'transparent',
                cursor: 'zoom-in', borderRadius: 6, position: 'relative',
                display: 'inline-block', lineHeight: 0,
              }}
            >
              <img
                src={previewUrl}
                alt="Preview still"
                style={{
                  width: 160, height: 90,
                  objectFit: 'cover', borderRadius: 6,
                  background: '#06090f',
                  display: 'block',
                }}
              />
              <span
                aria-hidden="true"
                style={{
                  position: 'absolute', bottom: 4, right: 4,
                  width: 22, height: 22, borderRadius: 4,
                  background: 'rgba(0,0,0,0.55)',
                  color: 'rgba(255,255,255,0.85)',
                  display: 'grid', placeItems: 'center',
                  fontSize: 12,
                  backdropFilter: 'blur(2px)',
                }}
              >⤢</span>
            </button>
            <div style={{ minWidth: 0 }}>
              <div style={{ fontSize: 12.5, color: 'var(--text)', fontWeight: 500 }}>
                {previewStale ? 'Preview is out of date' : 'Preview ready · click image to enlarge'}
              </div>
              <div style={{ fontSize: 11.5, color: 'var(--text-dim)', marginTop: 4, lineHeight: 1.5 }}>
                {previewStale
                  ? 'You edited the image prompt since the last preview. Click Re-preview to regenerate the still, or Discard to revert to a flux-on-the-fly video render.'
                  : (<>
                      Happy with this still? Press <strong style={{ color: 'var(--text)' }}>Make video</strong> — Kling/Luma will animate this exact frame (no second flux pass, no surprise re-rolls of the photo). Not quite right? Edit the image prompt and <strong style={{ color: 'var(--text)' }}>Re-preview</strong> — that's another ~$0.003 still.
                    </>)}
              </div>
              <div style={{ fontSize: 11, color: 'var(--text-dim)', marginTop: 6, fontFamily: 'var(--ff-mono, monospace)' }}>
                {previewPrompt.slice(0, 140)}{previewPrompt.length > 140 ? '…' : ''}
              </div>
            </div>
            <button
              type="button"
              onClick={onDiscardPreview}
              disabled={busy || previewBusy}
              className="aq-btn"
              style={{ padding: '6px 12px', fontSize: 12, alignSelf: 'flex-start' }}
              title="Forget this preview — Make video will run flux from scratch"
            >Discard</button>
          </div>
        )}

        {/* Lightbox — full-size view of the preview still. Backdrop
            click + Escape both close. Renders only while open; not a
            React Portal so it sits inside the form's stacking context
            (z-index 1000 is enough to clear the rest of the page). */}
        {lightboxOpen && previewUrl && (
          <div
            onClick={() => setLightboxOpen(false)}
            role="dialog"
            aria-label="Preview still — full size"
            style={{
              position: 'fixed', inset: 0, zIndex: 1000,
              background: 'rgba(6, 7, 10, 0.88)',
              backdropFilter: 'blur(6px)',
              display: 'flex', flexDirection: 'column',
              alignItems: 'center', justifyContent: 'center',
              padding: 32, cursor: 'zoom-out',
            }}
          >
            <img
              src={previewUrl}
              alt="Preview still"
              onClick={(e) => e.stopPropagation()}
              style={{
                maxWidth: '92vw', maxHeight: '78vh',
                objectFit: 'contain', cursor: 'default',
                borderRadius: 8, background: '#06090f',
                boxShadow: '0 24px 60px rgba(0,0,0,0.6)',
              }}
            />
            <div
              onClick={(e) => e.stopPropagation()}
              style={{
                marginTop: 12, maxWidth: '92vw',
                padding: '10px 14px',
                background: 'rgba(0,0,0,0.45)',
                border: '1px solid rgba(255,255,255,0.1)',
                borderRadius: 8,
                color: 'rgba(255,255,255,0.85)', fontSize: 12.5, lineHeight: 1.55,
                fontFamily: 'var(--ff-mono, monospace)',
                cursor: 'default',
                textAlign: 'center',
              }}
            >
              {previewPrompt}
            </div>
            <div style={{
              marginTop: 8, color: 'rgba(255,255,255,0.55)',
              fontSize: 11, letterSpacing: '0.05em',
            }}>Click anywhere or press Esc to close</div>
          </div>
        )}

        <div style={{ marginTop: 10, fontSize: 12, color: 'var(--text-dim)' }}>
          {costNote ? costNote + ' · ' : ''}
          {previewUrl
            ? 'Video render still takes 60-180s. The card appears below as soon as you click Make video.'
            : 'Preview is fast (~3-5s, ~$0.003). Video render takes 60-180s — the card appears below as soon as you click Start.'}
        </div>
      </form>
    );
  }

  /* ─── Upload form ─────────────────────────────────────────────── */
  function UploadForm({ onUploaded }) {
    const [name, setName] = useState('');
    const [file, setFile] = useState(null);
    const [progress, setProgress] = useState(null);
    const [busy, setBusy] = useState(false);
    const [err, setErr] = useState(null);
    const fileRef = useRef(null);

    const onPick = (e) => {
      const f = e.target.files && e.target.files[0];
      setFile(f || null);
      setErr(null);
      if (!name && f) {
        const base = f.name.replace(/\.[^.]+$/, '');
        setName(base);
      }
    };

    const onSubmit = async (e) => {
      e.preventDefault();
      if (!file) { setErr('Pick an mp4/webm/mov file first.'); return; }
      setBusy(true); setErr(null); setProgress(0);
      try {
        const created = await uploadBackground(name.trim() || file.name, file, setProgress);
        setName(''); setFile(null); setProgress(null);
        if (fileRef.current) fileRef.current.value = '';
        onUploaded && onUploaded(created);
        if (window.toast) window.toast('Uploaded ' + (created && created.name));
      } catch (e2) {
        setErr(e2.message || String(e2));
      } finally {
        setBusy(false);
        setTimeout(() => setProgress(null), 800);
      }
    };

    return (
      <form onSubmit={onSubmit} className="aq-card" style={{ padding: 16, marginBottom: 20 }}>
        <h3 style={{ margin: '0 0 12px 0', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
          Or upload an existing clip
        </h3>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 10, alignItems: 'center' }}>
          <input
            type="text"
            placeholder="Name (e.g. kling-2.5-warm-reef-3)"
            value={name}
            onChange={(e) => setName(e.target.value)}
            disabled={busy}
            className="aq-input"
            style={{ padding: '8px 10px' }}
          />
          <input
            ref={fileRef}
            type="file"
            accept="video/mp4,video/webm,video/quicktime"
            onChange={onPick}
            disabled={busy}
            style={{ padding: '6px 0', color: 'var(--text-dim)', fontSize: 13 }}
          />
          <button type="submit" disabled={busy || !file} className="aq-btn" style={{ padding: '8px 18px' }}>
            {busy ? 'Uploading…' : 'Upload'}
          </button>
        </div>
        {progress != null && (
          <div style={{ marginTop: 10, height: 4, background: 'rgba(255,255,255,0.08)', borderRadius: 2, overflow: 'hidden' }}>
            <div style={{ width: `${Math.round(progress * 100)}%`, height: '100%', background: 'var(--brand-primary, #0ea5e9)', transition: 'width 0.15s linear' }} />
          </div>
        )}
        {err && (
          <div style={{ marginTop: 10, padding: '8px 10px', background: 'rgba(239,68,68,0.12)', color: '#fda4af', fontSize: 13, borderRadius: 6 }}>
            {err}
          </div>
        )}
        <div style={{ marginTop: 10, fontSize: 12, color: 'var(--text-dim)' }}>
          mp4 / webm / mov, up to 50 MB. Best results: a seamless 4–8 second loop at 1080p.
        </div>
      </form>
    );
  }

  /* ─── Card ────────────────────────────────────────────────────── */
  function BackgroundCard({ row, onActivate, onDeactivate, onDelete, onRetry }) {
    const [confirming, setConfirming] = useState(false);
    const isPending = row.status === 'pending';
    const isError   = row.status === 'error';
    const isReady   = row.status === 'ready';
    const ringColor = row.is_active ? 'var(--brand-primary, #0ea5e9)'
                    : isPending     ? '#a78bfa'
                    : isError       ? '#f87171'
                    : 'var(--border)';

    const meta = row.meta || {};
    const stage = meta.stage || (isPending ? 'queued' : row.status);
    const pct = meta.percent != null ? meta.percent : (isReady ? 1 : 0);

    return (
      <div className="aq-card" style={{
        padding: 0,
        overflow: 'hidden',
        border: `1px solid ${ringColor}`,
        boxShadow: row.is_active ? '0 0 0 2px rgba(14,165,233,0.15)' : 'none',
      }}>
        {/* Preview area — video for ready rows, status placeholder otherwise. */}
        <div style={{ position: 'relative', background: '#000', aspectRatio: '16 / 9' }}>
          {isReady && row.url ? (
            <video
              src={row.url}
              autoPlay
              muted
              loop
              playsInline
              preload="metadata"
              style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
            />
          ) : (
            <div style={{
              width: '100%', height: '100%',
              display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
              gap: 12, color: 'var(--text-dim)', fontSize: 13,
              background: isError
                ? 'linear-gradient(135deg, rgba(127,29,29,0.4), rgba(0,0,0,0.6))'
                : 'linear-gradient(135deg, rgba(76,29,149,0.4), rgba(0,0,0,0.6))',
            }}>
              <div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.08em', opacity: 0.7 }}>
                {stageLabel(stage)}
              </div>
              {isPending && (
                <div style={{ width: '70%', height: 4, background: 'rgba(255,255,255,0.1)', borderRadius: 2, overflow: 'hidden' }}>
                  <div
                    style={{
                      width: fmtPercent(pct),
                      height: '100%',
                      background: 'linear-gradient(90deg, #a78bfa, #c4b5fd)',
                      transition: 'width 0.5s ease-out',
                    }}
                  />
                </div>
              )}
              {isPending && (
                <div style={{ fontSize: 11, opacity: 0.6, textAlign: 'center', lineHeight: 1.5 }}>
                  {meta.taking_longer_than_usual
                    ? `${fmtPercent(pct)} · taking longer than usual — fal queue may be busy`
                    : fmtPercent(pct)}
                  {/* fal request_id appears as soon as fal accepts the
                      submit. Copy-paste into fal.ai/dashboard/requests
                      if a job looks hung — gives the model-side view
                      that the worker logs can't. */}
                  {meta.fal_request_id && (
                    <div style={{ marginTop: 4, fontFamily: 'var(--ff-mono, monospace)', fontSize: 10, opacity: 0.8 }}>
                      fal id:{' '}
                      <span
                        onClick={(e) => {
                          e.stopPropagation();
                          try { navigator.clipboard.writeText(meta.fal_request_id); } catch (_) {}
                          if (window.toast) window.toast('Copied request id');
                        }}
                        title="Click to copy — paste into fal.ai/dashboard/requests"
                        style={{ cursor: 'pointer', textDecoration: 'underline dotted' }}
                      >{String(meta.fal_request_id).slice(0, 12)}…</span>
                    </div>
                  )}
                </div>
              )}
              {isError && (
                <div style={{ padding: '0 14px', fontSize: 12, color: '#fca5a5', textAlign: 'center', lineHeight: 1.4 }}>
                  {row.error || 'Generation failed'}
                </div>
              )}
            </div>
          )}
          {row.is_active && (
            <div style={{
              position: 'absolute', top: 8, left: 8,
              padding: '3px 8px', fontSize: 11, fontWeight: 600,
              background: 'var(--brand-primary, #0ea5e9)', color: 'white',
              borderRadius: 4, textTransform: 'uppercase', letterSpacing: '0.04em',
            }}>
              Active
            </div>
          )}
          {isPending && (
            <div style={{
              position: 'absolute', top: 8, left: 8,
              padding: '3px 8px', fontSize: 11, fontWeight: 600,
              background: 'rgba(168,85,247,0.9)', color: 'white',
              borderRadius: 4, textTransform: 'uppercase', letterSpacing: '0.04em',
            }}>
              Generating
            </div>
          )}
        </div>

        <div style={{ padding: '12px 14px' }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-bright)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }}>
                  {row.name}
                </div>
                {meta.aspect_ratio && (
                  <span title={`Generated for ${meta.aspect_ratio}`} style={{
                    flexShrink: 0,
                    padding: '1px 6px', fontSize: 10, fontWeight: 600,
                    background: 'rgba(255,255,255,0.06)', color: 'var(--text-dim)',
                    border: '1px solid var(--border)', borderRadius: 3,
                    letterSpacing: '0.04em',
                  }}>
                    {meta.aspect_ratio}
                  </span>
                )}
              </div>
              <div style={{ fontSize: 11, color: 'var(--text-dim)', marginTop: 2 }}>
                {isReady && fmtSize(row.size_bytes)}{isReady && ' · '}
                {row.provider || row.mime_type || 'video'} · {fmtDate(row.created_at)}
              </div>
            </div>

            {isReady && (row.is_active ? (
              <button onClick={() => onDeactivate(row)} className="aq-btn" style={{ padding: '6px 12px', fontSize: 12 }}>
                Deactivate
              </button>
            ) : (
              <button onClick={() => onActivate(row)} className="aq-btn aq-btn-primary" style={{ padding: '6px 12px', fontSize: 12 }}>
                Set Active
              </button>
            ))}
            {isError && (
              <button onClick={() => onRetry(row)} className="aq-btn" style={{ padding: '6px 12px', fontSize: 12, color: '#fda4af' }}>
                Retry
              </button>
            )}

            {/* Delete doubles as Cancel when the row is pending. The
                DELETE endpoint removes the row immediately; the
                orphaned fal job (if it ever completes) is harmless —
                its persistStatus calls will UPDATE a row that's no
                longer there. Confirm-on-click guards against
                accidental cancellation of a long-running gen. */}
            {confirming ? (
              <button
                onClick={() => onDelete(row)}
                className="aq-btn"
                style={{ padding: '6px 12px', fontSize: 12, background: 'rgba(239,68,68,0.18)', color: '#fda4af', border: '1px solid rgba(239,68,68,0.3)' }}
                onBlur={() => setConfirming(false)}
              >
                {isPending ? 'Confirm cancel' : 'Confirm'}
              </button>
            ) : (
              <button onClick={() => setConfirming(true)} className="aq-btn" style={{ padding: '6px 12px', fontSize: 12, color: 'var(--text-dim)' }}>
                {isPending ? 'Cancel' : 'Delete'}
              </button>
            )}
          </div>

          {row.prompt && (
            <div title={row.prompt} style={{
              marginTop: 8, padding: '6px 10px',
              background: 'rgba(255,255,255,0.04)', borderRadius: 4,
              fontSize: 11, color: 'var(--text-dim)',
              lineHeight: 1.4, maxHeight: 36, overflow: 'hidden',
              display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
            }}>
              {row.prompt}
            </div>
          )}
        </div>
      </div>
    );
  }

  /* ─── Screen ──────────────────────────────────────────────────── */
  function VideoBackgroundsScreen() {
    const [rows, setRows] = useState(null);
    const [err, setErr] = useState(null);

    const load = useCallback(async () => {
      try {
        const r = await apiFetch('/api/video-backgrounds');
        setRows(r.backgrounds || []);
        setErr(null);
      } catch (e) {
        setErr(e.message || String(e));
        setRows([]);
      }
    }, []);

    useEffect(() => { load(); }, [load]);

    // Poll every 4s while ANY row is in pending state. Same cadence as
    // the species-editor Animation tab. Stops when nothing is pending
    // so we're not hammering the server in the steady state.
    const hasPending = useMemo(
      () => Array.isArray(rows) && rows.some((r) => r.status === 'pending'),
      [rows]
    );
    useEffect(() => {
      if (!hasPending) return;
      const t = setInterval(load, 4000);
      return () => clearInterval(t);
    }, [hasPending, load]);

    const onUploaded = (created) => {
      if (created) setRows((prev) => [created, ...(prev || [])]);
    };

    const onGenerationStarted = (created) => {
      if (created) setRows((prev) => [created, ...(prev || [])]);
    };

    const onActivate = async (row) => {
      try {
        await apiFetch(`/api/video-backgrounds/${row.id}/activate`, { method: 'POST' });
        setRows((prev) => (prev || []).map(r => ({ ...r, is_active: r.id === row.id })));
        if (window.toast) window.toast('Activated · ' + row.name);
      } catch (e) {
        if (window.toast) window.toast('Activate failed: ' + e.message, 'error');
      }
    };

    const onDeactivate = async (row) => {
      try {
        await apiFetch(`/api/video-backgrounds/${row.id}/deactivate`, { method: 'POST' });
        setRows((prev) => (prev || []).map(r => r.id === row.id ? { ...r, is_active: false } : r));
        if (window.toast) window.toast('Deactivated');
      } catch (e) {
        if (window.toast) window.toast('Deactivate failed: ' + e.message, 'error');
      }
    };

    const onDelete = async (row) => {
      try {
        await apiFetch(`/api/video-backgrounds/${row.id}`, { method: 'DELETE' });
        setRows((prev) => (prev || []).filter(r => r.id !== row.id));
        if (window.toast) window.toast('Deleted');
      } catch (e) {
        if (window.toast) window.toast('Delete failed: ' + e.message, 'error');
      }
    };

    const onRetry = async (row) => {
      // Retry kicks off a fresh generation with the same prompt + provider
      // + duration. The errored row is not re-used (the storage_key
      // attached to it may already point at a partial upload); the new
      // row supersedes it visually until the user deletes the old one.
      try {
        const created = await apiFetch('/api/video-backgrounds/generate', {
          method: 'POST',
          body: JSON.stringify({
            name: row.name + ' (retry)',
            // Reuse the original preset_id when recorded so the retry
            // gets the FULL preset (split prompts), not the truncated
            // single-string prompt we stored for display.
            preset_id: (row.meta && row.meta.preset_id) || undefined,
            // If preset_id isn't recorded, fall back to the stored
            // prompt string for both stages — same as a custom run.
            prompt: row.prompt,
            provider: row.provider,
            duration_seconds: row.duration_seconds,
          }),
        });
        if (created) setRows((prev) => [created, ...(prev || [])]);
        if (window.toast) window.toast('Retrying generation');
      } catch (e) {
        if (window.toast) window.toast('Retry failed: ' + e.message, 'error');
      }
    };

    if (rows == null) {
      return <div style={{ padding: 28, color: 'var(--text-dim)' }}>Loading…</div>;
    }

    return (
      <div style={{ padding: 28, maxWidth: 1280, margin: '0 auto' }}>
        <div style={{ marginBottom: 18 }}>
          <h2 style={{ margin: 0, fontSize: 22, fontWeight: 600, color: 'var(--text-bright)' }}>
            Video Backgrounds
          </h2>
          <p style={{ margin: '6px 0 0 0', fontSize: 13, color: 'var(--text-dim)', maxWidth: 720 }}>
            Temporary dev surface. Generate ambient loop clips with AI
            (Kling 2.5) or upload your own. Set one active to replace the
            kiosk's particle scene with the video — every species display
            in this organisation picks up the change within ~30 seconds.
          </p>
        </div>

        <GenerateForm onStarted={onGenerationStarted} />
        <UploadForm onUploaded={onUploaded} />

        {err && (
          <div style={{ marginBottom: 14, padding: '10px 12px', background: 'rgba(239,68,68,0.12)', color: '#fda4af', fontSize: 13, borderRadius: 6 }}>
            {err}
          </div>
        )}

        {rows.length === 0 ? (
          <div style={{ padding: '40px 20px', textAlign: 'center', color: 'var(--text-dim)', border: '1px dashed var(--border)', borderRadius: 8 }}>
            No video backgrounds saved yet. Generate one with AI above, or upload a clip.
          </div>
        ) : (
          <div style={{
            display: 'grid',
            gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
            gap: 16,
          }}>
            {rows.map((r) => (
              <BackgroundCard
                key={r.id}
                row={r}
                onActivate={onActivate}
                onDeactivate={onDeactivate}
                onDelete={onDelete}
                onRetry={onRetry}
              />
            ))}
          </div>
        )}
      </div>
    );
  }

  window.VideoBackgroundsScreen = VideoBackgroundsScreen;
})();
