/* Species Editor — port of prototype species-editor.jsx (tabbed variant
   per docs/screens.md), wired to /api/species/:id. The editor reads
   the species record (incl. media + translations) and saves changes
   via PUT /api/species/:id.

   Sections that aren't backed by real fields yet (animation, voiceover)
   render the prototype's controls but mark themselves as
   "not yet wired" — the inputs are still editable for design review. */

/* ─── Table of contents ─────────────────────────────────────────────
   File: public/cms/species-editor.jsx
   Total top-level defs: 28
   Line numbers are post-TOC (jump directly to them).
   ────────────────────────────────────────────────────────────────

   Form primitives:
     L87  SE_SectionLabel
     L96  SE_Field
     L107  SE_Input
     L124  SE_Textarea
     L136  SE_Select
     L161  SE_Btn

   Tab sections:
     L183  IdentitySection
     L274  MediaSection
     L1133  AnimationSection
     L2028  VoiceoverSection
     L2788  DescriptionSection
     L3189  EnrichmentSection

   Sub-controls:
     L1032  ClipZoomSlider
     L1679  SpeciesMoreMenu
     L2553  RegenButton
     L2840  SE_EnrichmentList
     L3053  SE_MigrationPathEditor
     L3120  SE_IucnSyncBlock
     L3323  SpeciesPreview

   Modals:
     L652  ImageSearchModal

   Page root:
     L3626  SpeciesEditorScreen

   Other / page-level:
     L57  SE_IUCN_OPTIONS
     L74  SE_TABS
     L1025  AUTO_FIT_VERSION
     L3316  SE_PREVIEW_DIMS
   ──────────────────────────────────────────────────────────────── */

const SE_IUCN_OPTIONS = [
  'Not Evaluated',
  'Data Deficient',
  'Least Concern',
  'Near Threatened',
  'Vulnerable',
  'Endangered',
  'Critically Endangered',
  'Extinct in the Wild',
  'Extinct',
];

// Tab definitions. `platformOnly` tabs render only for aquaos_admin —
// they expose paid pipelines (fal video gen, ElevenLabs voiceover,
// Anthropic translation) + occurrence-record fetching that org/site/
// operator users shouldn't trigger. The Identity tab's "Refresh
// distribution data" card is similarly gated inside IdentitySection.
const SE_TABS = [
  { id: 'identity', label: 'Identity' },
  { id: 'media', label: 'Media' },
  { id: 'animation', label: 'Animation', platformOnly: true },
  { id: 'voiceover', label: 'Voiceover', platformOnly: true },
  { id: 'description', label: 'Description' },
  /* Enrichment tab removed May 2026 — the product brief is now
     "fully automated, no curator entry". IUCN history auto-populates
     on species create (if IUCN_API_TOKEN is set); tracked individuals
     populate from the Movebank worker. */
];

function SE_SectionLabel({ children, hint }) {
  return (
    <div className="se-section-label">
      <span>{children}</span>
      {hint && <span className="se-section-hint">{hint}</span>}
    </div>
  );
}

function SE_Field({ label, hint, children, mono }) {
  return (
    <label className={`se-field ${mono ? 'is-mono' : ''}`}>
      <span className="se-field-label">
        {label}{hint && <span className="se-field-hint"> · {hint}</span>}
      </span>
      <span className="se-field-control">{children}</span>
    </label>
  );
}

function SE_Input({ value, onChange, placeholder, mono }) {
  // Rendered as a single-row textarea (not <input>) so long text wraps to
  // a second line and the field grows in height instead of scrolling
  // horizontally. `field-sizing: content` (.se-input CSS) drives the
  // growth; Enter is suppressed so the field still feels single-line.
  return (
    <textarea
      className={`se-input ${mono ? 'is-mono' : ''}`}
      rows={1}
      value={value || ''}
      placeholder={placeholder}
      onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) e.preventDefault(); }}
      onChange={(e) => onChange && onChange((e.target.value || '').replace(/\n/g, ''))}
    />
  );
}

function SE_Textarea({ value, onChange, placeholder, rows = 4 }) {
  return (
    <textarea
      className="se-textarea"
      rows={rows}
      value={value || ''}
      placeholder={placeholder}
      onChange={(e) => onChange && onChange(e.target.value)}
    />
  );
}

function SE_Select({ value, onChange, options }) {
  // Accepts either ['a', 'b', 'c'] (flat strings) or
  // [{value, label}] (objects with display vs underlying value).
  // The voiceover panel needs the object form for "Rachel ★" etc. labels
  // distinct from the voice ID; keeping flat-string support so other
  // call sites (orientation, language code list) keep working unchanged.
  const items = (options || []).map((o) =>
    typeof o === 'object' && o !== null && 'value' in o
      ? { value: o.value, label: o.label != null ? o.label : o.value }
      : { value: o, label: o }
  );
  return (
    <span className="se-select">
      <select value={value || ''} onChange={(e) => onChange && onChange(e.target.value)}>
        {items.map((o, i) => (
          <option key={(o.value != null ? String(o.value) : '') + ':' + i} value={o.value || ''}>
            {o.label}
          </option>
        ))}
      </select>
      <Icon name="chevron-down" size={12} />
    </span>
  );
}

function SE_Btn({ icon, children, primary, ghost, onClick, disabled }) {
  return (
    <button
      className={`se-btn ${primary ? 'is-primary' : ''} ${ghost ? 'is-ghost' : ''}`}
      onClick={onClick}
      disabled={disabled}
    >
      {icon && <Icon name={icon} size={12} />}{children}
    </button>
  );
}

function hueFor(s) {
  let h = 0;
  for (let i = 0; i < (s || '').length; i++) {
    h = ((h << 5) - h + s.charCodeAt(i)) | 0;
  }
  return Math.abs(h) % 360;
}

/* ── Section bodies ────────────────────────────────────────── */

function IdentitySection({ form, set, onRefreshDistribution, isPlatformAdmin }) {
  const [refreshing, setRefreshing] = useState(false);
  const [distroErr, setDistroErr] = useState(null);
  const [distroMsg, setDistroMsg] = useState(null);

  async function refreshDistro() {
    if (!form.id) return;
    setRefreshing(true); setDistroErr(null); setDistroMsg(null);
    try {
      const res = await apiFetch(`/api/species/${form.id}/fetch-distribution`, { method: 'POST' });
      const count = (res && (res.occurrences_count || res.count)) || 0;
      setDistroMsg(count
        ? `Distribution refreshed — ${count} occurrence record${count === 1 ? '' : 's'}`
        : 'Distribution refresh queued. Check back shortly.');
      if (onRefreshDistribution) onRefreshDistribution();
    } catch (err) {
      setDistroErr(err.message);
    } finally {
      setRefreshing(false);
    }
  }

  return (
    <div className="se-section-body">
      <div className="se-grid-2">
        <SE_Field label="Common name">
          <SE_Input value={form.common_name} onChange={(v) => set('common_name', v)} />
        </SE_Field>
        <SE_Field label="Scientific name" mono>
          <SE_Input value={form.scientific_name} onChange={(v) => set('scientific_name', v)} mono />
        </SE_Field>
      </div>
      <SE_Field label="IUCN status">
        <SE_Select value={form.iucn_status} onChange={(v) => set('iucn_status', v)} options={SE_IUCN_OPTIONS} />
      </SE_Field>
      <div className="se-grid-2">
        <SE_Field label="Genus">
          <SE_Input value={form.genus} onChange={(v) => set('genus', v)} mono />
        </SE_Field>
        <SE_Field label="Family">
          <SE_Input value={form.family} onChange={(v) => set('family', v)} mono />
        </SE_Field>
      </div>
      {/* Vital stats — these biology fields ship on the kiosk + detail
          pages but previously had no editor inputs, so curators couldn't
          see or correct them. */}
      <div className="se-grid-2">
        <SE_Field label="Max size">
          <SE_Input value={form.max_size} onChange={(v) => set('max_size', v)} />
        </SE_Field>
        <SE_Field label="Lifespan">
          <SE_Input value={form.lifespan} onChange={(v) => set('lifespan', v)} />
        </SE_Field>
      </div>
      <div className="se-grid-2">
        <SE_Field label="Depth range">
          <SE_Input value={form.depth_range} onChange={(v) => set('depth_range', v)} />
        </SE_Field>
        <SE_Field label="Temperature range">
          <SE_Input value={form.temperature_range} onChange={(v) => set('temperature_range', v)} />
        </SE_Field>
      </div>
      <SE_Field label="Diet">
        <SE_Input value={form.diet} onChange={(v) => set('diet', v)} />
      </SE_Field>
      <SE_Field label="Habitat">
        <SE_Input value={form.habitat} onChange={(v) => set('habitat', v)} />
      </SE_Field>
      <SE_Field label="Distribution label">
        <SE_Input value={form.distribution_label || form.distribution} onChange={(v) => set('distribution_label', v)} />
      </SE_Field>
      {/* Focus point (focus_lng / focus_lat) and Origin code intentionally hidden:
          focus point is auto-populated by /api/species/:id/fetch-distribution from
          GBIF/OBIS/WoRMS and the kiosk doesn't render it directly; origin code was
          a frontend-only computed badge with no schema column. The Distribution
          data refresh button below is the supported workflow. */}

      {/* Distribution data refresh — platform-admin only. Pulls
          occurrence records from GBIF / OBIS / WoRMS which is a
          paid/rate-limited platform pipeline that org and site users
          shouldn't be triggering ad-hoc. The job is async on the
          backend; the polling editor `refresh` picks up updated
          focus_lat/lng once it lands. */}
      {isPlatformAdmin && (
        <div style={{
          marginTop: 18,
          display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
        }}>
          <Icon name="globe" size={14} />
          <div style={{ flex: 1, minWidth: 200 }}>
            <div style={{ fontSize: 12.5, color: 'var(--aq-text)' }}>Distribution data</div>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-faint)' }}>
              {distroMsg || distroErr || 'Pull occurrence records from GBIF / OBIS / WoRMS for this species.'}
            </div>
          </div>
          {/* Quiet ghost action — no bordered box. */}
          <button
            onClick={refreshDistro}
            disabled={!form.id || refreshing}
            style={{
              padding: '6px 10px', fontSize: 12,
              background: 'transparent',
              border: 0,
              borderRadius: 7, color: 'var(--aq-text-dim)',
              cursor: (form.id && !refreshing) ? 'pointer' : 'not-allowed',
              opacity: (form.id && !refreshing) ? 1 : 0.5,
            }}
            onMouseEnter={(e) => { if (form.id && !refreshing) { e.currentTarget.style.background = 'rgba(255,255,255,0.05)'; e.currentTarget.style.color = 'var(--aq-text)'; } }}
            onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--aq-text-dim)'; }}
          >{refreshing ? 'Refreshing…' : 'Refresh now'}</button>
        </div>
      )}
    </div>
  );
}

/* Pick which photo the sharpness check should grade: the most recent
   non-derived image, so an Enhanced tile supersedes the soft original it
   was made from. Shared by MediaSection (chip) + AnimationSection
   (pre-generate warning). */
function sharpnessTargetFor(form) {
  const list = (form.media || []).filter((m) => m && m.url && m.id);
  const isVid = (m) => /video/i.test(m.media_type || m.kind || '') || /\.(mp4|webm|mov)$/i.test(m.url);
  const isDeriv = (m) => /nobg|cms-thumb/i.test(`${m.url || ''} ${m.filename || ''}`);
  const cands = list.filter((m) => !isVid(m) && !isDeriv(m));
  return cands.length ? cands[cands.length - 1] : null;
}

/* Free, local blur heuristic (Laplacian variance, computed server-side
   with sharp — no AI spend). verdict: 'soft' | 'ok' | 'sharp'. */
function useSharpness(form) {
  const target = sharpnessTargetFor(form);
  const targetId = target ? target.id : null;
  const [result, setResult] = useState(null);
  useEffect(() => {
    let cancelled = false;
    setResult(null);
    if (!form.id || !targetId) return;
    apiFetch(`/api/species/${form.id}/media/${targetId}/sharpness`)
      .then((r) => { if (!cancelled) setResult(r); })
      .catch(() => {});
    return () => { cancelled = true; };
  }, [form.id, targetId]);
  return result;
}

function MediaSection({ form, onMediaChanged }) {
  const hue = hueFor(form.scientific_name || form.common_name);
  const allMedia = (form.media || []).filter((m) => m && m.url);
  /* Derived pipeline outputs (2026-06-08, Oli): the new-species flow
     generates a bg-removed 'nobg' cutout AND a 4:3 'cms-thumb' for the
     library grid — both landed in this gallery looking like duplicate
     photos. Hide them by default; the hero is always shown even when it
     IS the cutout, and the meta line gets a show/hide toggle. Manual
     "Remove BG" flips the toggle on so its result tile stays visible
     (May 2026 feedback c4668f98 expects it to appear). */
  const isDerived = (m) => !m.is_primary && /nobg|cms-thumb/i.test(`${m.url || ''} ${m.filename || ''}`);
  const [showDerived, setShowDerived] = useState(false);
  const derivedCount = allMedia.filter(isDerived).length;
  /* Role label per tile (2026-06-08, Oli: "confusing to tell what image
     is what") — the original photo, the bg-removed cutout, an enhanced
     copy and the grid thumb can all look near-identical at 120px. Every
     tile now carries a small role caption, and the grid orders hero
     first, then original → enhanced → cutout → grid thumb. */
  const mediaRole = (m) => {
    const s = `${m.url || ''} ${m.filename || ''}`;
    if (/video/i.test(m.media_type || m.kind || '') || /\.(mp4|webm|mov)$/i.test(m.url)) return 'video';
    if (/cms-thumb/i.test(s)) return 'grid thumb';
    if (/nobg/i.test(s)) return 'cutout';
    if (/enhanced/i.test(s)) return 'enhanced';
    return 'original';
  };
  const ROLE_ORDER = { original: 0, enhanced: 1, video: 2, cutout: 3, 'grid thumb': 4 };
  const media = (showDerived ? allMedia : allMedia.filter((m) => !isDerived(m)))
    .slice()
    .sort((a, b) =>
      ((b.is_primary ? 1 : 0) - (a.is_primary ? 1 : 0)) ||
      ((ROLE_ORDER[mediaRole(a)] ?? 9) - (ROLE_ORDER[mediaRole(b)] ?? 9))
    );
  const sharpness = useSharpness(form);
  const hero = form.primary_image_url || form.thumb_url;
  const fileInputRef = useRef(null);
  const [busy, setBusy] = useState(null);
  const [err, setErr] = useState(null);
  const [searchOpen, setSearchOpen] = useState(false);
  /* Inline modal for "Add by URL" — replaces two stacked prompt() calls
     with a proper form so curators can paste long URLs and write a
     real caption. */
  const [urlModalOpen, setUrlModalOpen] = useState(false);
  const [urlInput, setUrlInput] = useState('');
  const [captionInput, setCaptionInput] = useState('');

  const canEdit = !!form.id; /* Backend rejects media writes without an id */

  async function uploadFile(file) {
    if (!file) return;
    setBusy('upload'); setErr(null);
    try {
      const fd = new FormData();
      fd.append('file', file);
      const token = Auth.getToken();
      const res = await fetch(`/api/species/${form.id}/media`, {
        method: 'POST',
        headers: token ? { Authorization: `Bearer ${token}` } : {},
        body: fd,
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || `${res.status}`);
      if (onMediaChanged) onMediaChanged();
    } catch (e) {
      setErr(`Upload failed: ${e.message}`);
    } finally {
      setBusy(null);
    }
  }

  function addUrl() {
    /* Open the inline modal — submission happens in submitUrl(). */
    setUrlInput(''); setCaptionInput(''); setErr(null);
    setUrlModalOpen(true);
  }

  async function submitUrl(e) {
    e && e.preventDefault();
    if (!urlInput.trim()) { setErr('URL is required.'); return; }
    setBusy('url'); setErr(null);
    try {
      await apiFetch(`/api/species/${form.id}/media/url`, {
        method: 'POST',
        body: JSON.stringify({ url: urlInput.trim(), caption: captionInput.trim() }),
      });
      setUrlModalOpen(false);
      window.toast && window.toast.success('Media added');
      if (onMediaChanged) onMediaChanged();
    } catch (e2) {
      setErr(`Add URL failed: ${e2.message}`);
    } finally {
      setBusy(null);
    }
  }

  async function setPrimary(mediaId) {
    setBusy(`primary:${mediaId}`); setErr(null);
    try {
      await apiFetch(`/api/species/${form.id}/media/${mediaId}/primary`, { method: 'PUT' });
      if (onMediaChanged) onMediaChanged();
    } catch (e) {
      setErr(`Set hero failed: ${e.message}`);
    } finally {
      setBusy(null);
    }
  }

  async function removeBg(mediaId) {
    setBusy(`bg:${mediaId}`); setErr(null);
    try {
      await apiFetch(`/api/species/${form.id}/media/${mediaId}/remove-bg`, {
        method: 'POST',
        headers: { 'X-AquaOS-Source': 'editor-manual' }, // identifies this caller in remove-bg's dedup log
      });
      setShowDerived(true); // the result is a derived tile — don't hide it from the person who just made it
      if (onMediaChanged) onMediaChanged();
    } catch (e) {
      setErr(`Background removal failed: ${e.message}`);
    } finally {
      setBusy(null);
    }
  }

  async function enhance(mediaId) {
    setBusy(`enh:${mediaId}`); setErr(null);
    try {
      await apiFetch(`/api/species/${form.id}/media/${mediaId}/enhance`, { method: 'POST' });
      window.toast && window.toast.success('Enhanced photo added as a new tile — run Remove BG on it to make it the hero');
      if (onMediaChanged) onMediaChanged();
    } catch (e) {
      setErr(`Enhance failed: ${e.message}`);
    } finally {
      setBusy(null);
    }
  }

  async function deleteMedia(mediaId) {
    if (!window.confirm('Remove this media item?')) return;
    setBusy(`del:${mediaId}`); setErr(null);
    try {
      await apiFetch(`/api/species/${form.id}/media/${mediaId}`, { method: 'DELETE' });
      if (onMediaChanged) onMediaChanged();
    } catch (e) {
      setErr(`Delete failed: ${e.message}`);
    } finally {
      setBusy(null);
    }
  }

  return (
    <div className="se-section-body">
      <div className="se-media-row">
        <div className="se-media-thumb">
          {hero ? (
            <div
              className="se-media-thumb-art"
              style={{ background: `url(${hero}) center/cover`, '--sp-hue': hue }}
            />
          ) : (
            <div className="se-media-thumb-art" style={{ '--sp-hue': hue }} />
          )}
        </div>
        <div className="se-media-actions">
          <input
            ref={fileInputRef}
            type="file"
            accept="image/*,video/*"
            style={{ display: 'none' }}
            onChange={(e) => {
              const f = e.target.files && e.target.files[0];
              e.target.value = '';
              if (f) uploadFile(f);
            }}
          />
          <SE_Btn icon="plus" disabled={!canEdit || busy === 'upload'} onClick={() => fileInputRef.current && fileInputRef.current.click()}>
            {busy === 'upload' ? 'Uploading…' : 'Upload'}
          </SE_Btn>
          <SE_Btn icon="globe" disabled={!canEdit || busy === 'url'} onClick={addUrl}>
            {busy === 'url' ? 'Adding…' : 'Add URL'}
          </SE_Btn>
          <SE_Btn icon="search" disabled={!canEdit} onClick={() => setSearchOpen(true)}>
            Search
          </SE_Btn>
        </div>
      </div>

      {!canEdit && (
        <div style={{ marginTop: 10, fontSize: 11.5, color: 'var(--aq-text-faint)' }}>
          Save the species first — uploads attach to the saved record.
        </div>
      )}
      {err && (
        <div style={{
          marginTop: 10, fontSize: 12, padding: '8px 10px',
          color: 'var(--aq-danger)', borderRadius: 6,
          background: 'color-mix(in srgb, var(--aq-danger) 12%, transparent)',
        }}>{err}</div>
      )}

      {/* Real species_media gallery — populated from /api/species/:id */}
      {media.length > 0 && (
        <div style={{
          marginTop: 14, display: 'grid',
          gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: 8,
        }}>
          {media.map((m) => {
            const isVideo = /video/i.test(m.media_type || m.kind || '') || /\.(mp4|webm|mov)$/i.test(m.url);
            const id = m.id;
            return (
              <div
                key={id || m.url}
                style={{
                  aspectRatio: '4/3', borderRadius: 8, overflow: 'hidden',
                  position: 'relative',
                  /* No border — the image fills the tile. Faint fill only
                     shows through while an image loads/fails. */
                  background: 'rgba(255,255,255,0.03)',
                }}
              >
                {isVideo ? (
                  <video src={m.url} muted playsInline style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
                ) : (
                  <img
                    src={m.url}
                    alt={m.alt_text || m.caption || ''}
                    style={{ width: '100%', height: '100%', objectFit: 'cover' }}
                    onError={(e) => { e.currentTarget.style.opacity = 0.3; }}
                  />
                )}
                {m.is_primary && (
                  <span style={{
                    position: 'absolute', top: 4, left: 4, zIndex: 2,
                    fontFamily: 'var(--aq-ff-mono)', fontSize: 8.5,
                    padding: '1px 5px', borderRadius: 3,
                    background: 'rgba(255,255,255,0.92)', color: '#0c1119',
                    letterSpacing: '0.06em',
                  }}>HERO</span>
                )}
                {/* Role caption — what this image actually IS. Bottom-left
                    so it never collides with HERO (top-left) or VIDEO
                    (top-right). */}
                {mediaRole(m) !== 'video' && (
                  <span style={{
                    position: 'absolute', bottom: 4, left: 4, zIndex: 2,
                    fontFamily: 'var(--aq-ff-mono)', fontSize: 8.5,
                    padding: '1px 5px', borderRadius: 3,
                    background: 'rgba(0,0,0,0.6)', color: 'rgba(255,255,255,0.78)',
                    letterSpacing: '0.06em', textTransform: 'uppercase',
                  }}>{mediaRole(m)}</span>
                )}
                {isVideo && (
                  <span style={{
                    position: 'absolute', top: 4, right: 4, zIndex: 2,
                    fontFamily: 'var(--aq-ff-mono)', fontSize: 8.5,
                    padding: '1px 5px', borderRadius: 3,
                    background: 'rgba(0,0,0,0.6)', color: 'rgba(255,255,255,0.85)',
                    letterSpacing: '0.06em',
                  }}>VIDEO</span>
                )}
                {/* Hover overlay with row actions — appears on parent hover */}
                <div
                  className="se-media-overlay"
                  style={{
                    position: 'absolute', inset: 0, zIndex: 3,
                    background: 'linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.55) 60%, rgba(0,0,0,0.85) 100%)',
                    opacity: 0, transition: 'opacity 120ms',
                    display: 'flex', flexDirection: 'column', justifyContent: 'flex-end',
                    padding: 6, gap: 4,
                  }}
                  onMouseEnter={(e) => { e.currentTarget.style.opacity = 1; }}
                  onMouseLeave={(e) => { e.currentTarget.style.opacity = 0; }}
                >
                  <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
                    {!m.is_primary && (
                      <button
                        onClick={() => setPrimary(id)}
                        disabled={!id || busy != null}
                        title="Use as the species hero image"
                        style={mediaBtnStyle()}
                      >Set hero</button>
                    )}
                    {/* Source-only actions: re-matting a cutout or enhancing
                        the grid thumb is never what anyone wants — only the
                        original / enhanced photos get these. */}
                    {(mediaRole(m) === 'original' || mediaRole(m) === 'enhanced') && (
                      <button
                        onClick={() => removeBg(id)}
                        disabled={!id || busy != null}
                        title="Run background removal job"
                        style={mediaBtnStyle()}
                      >Remove BG</button>
                    )}
                    {(mediaRole(m) === 'original' || mediaRole(m) === 'enhanced') && (
                      <button
                        onClick={() => enhance(id)}
                        disabled={!id || busy != null}
                        title="AI deblur + detail recovery (Topaz, ~8¢). Adds the result as a new tile."
                        style={mediaBtnStyle()}
                      >{busy === `enh:${id}` ? 'Enhancing…' : 'Enhance'}</button>
                    )}
                    <button
                      onClick={() => deleteMedia(id)}
                      disabled={!id || busy != null}
                      title="Delete this media item"
                      style={mediaBtnStyle('danger')}
                    >Delete</button>
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      )}

      <div style={{
        marginTop: 10, fontSize: 11, color: 'var(--aq-text-faint)',
        fontFamily: 'var(--aq-ff-mono)', letterSpacing: '0.04em',
      }}>
        {allMedia.length > 0
          ? `${allMedia.length} media item${allMedia.length === 1 ? '' : 's'} · ${allMedia.filter(m => m.is_primary).length || 0} hero`
          : 'No media attached yet'}
        {form.animated_clip_status && (
          <> · clip: {form.animated_clip_status}</>
        )}
        {derivedCount > 0 && (
          <>
            {' · '}{derivedCount} derived{' '}
            <button
              onClick={() => setShowDerived(!showDerived)}
              style={{
                background: 'none', border: 'none', padding: 0, cursor: 'pointer',
                font: 'inherit', color: 'var(--aq-text-dim)', textDecoration: 'underline',
                textUnderlineOffset: 2,
              }}
            >{showDerived ? 'hide' : 'show'}</button>
          </>
        )}
        {sharpness && sharpness.score != null && (
          <> · photo: <span style={{ color: sharpness.verdict === 'soft' ? 'var(--aq-danger)' : 'inherit' }}>
            {sharpness.verdict} ({sharpness.score})
          </span>{sharpness.verdict === 'soft' ? ' — try Enhance on the photo tile' : ''}</>
        )}
      </div>

      <ImageSearchModal
        open={searchOpen}
        seedQuery={form.scientific_name || form.common_name || ''}
        onClose={() => setSearchOpen(false)}
        onPick={async (img) => {
          setSearchOpen(false);
          setBusy('url'); setErr(null);
          try {
            await apiFetch(`/api/species/${form.id}/media/url`, {
              method: 'POST',
              body: JSON.stringify({
                url: img.full_url || img.thumbnail_url,
                caption: img.title || '',
                alt_text: img.title || '',
                source: img.source,
                license: img.license,
                attribution: img.author,
              }),
            });
            if (onMediaChanged) onMediaChanged();
          } catch (e) {
            setErr(`Add image failed: ${e.message}`);
          } finally {
            setBusy(null);
          }
        }}
      />

      {urlModalOpen && (
        <div
          onClick={(e) => { if (e.target === e.currentTarget) setUrlModalOpen(false); }}
          style={{
            position: 'fixed', inset: 0, zIndex: 220,
            background: 'rgba(6, 7, 10, 0.78)', backdropFilter: 'blur(6px)',
            display: 'grid', placeItems: 'center', padding: 24,
          }}
        >
          <form
            onSubmit={submitUrl}
            style={{
              width: 'min(520px, 100%)',
              background: 'var(--aq-surface)',
              border: '1px solid var(--aq-line)',
              borderRadius: 14, boxShadow: 'var(--aq-shadow-2)',
              display: 'flex', flexDirection: 'column', maxHeight: '82vh',
            }}
          >
            <header style={{
              padding: '16px 18px 12px',
              display: 'flex', alignItems: 'center', gap: 10,
            }}>
              <div style={{ flex: 1, fontFamily: 'var(--aq-ff-display)', fontSize: 14.5, color: 'var(--aq-text)', fontWeight: 500 }}>
                Add image or video by URL
              </div>
              <button type="button" className="aq-icon-btn" onClick={() => setUrlModalOpen(false)}><Icon name="close" size={13} /></button>
            </header>
            <div style={{ overflowY: 'auto', padding: '0 18px 16px', display: 'flex', flexDirection: 'column', gap: 14 }}>
              <label style={{ display: 'block' }}>
                <div style={{ fontSize: 11.5, color: 'var(--aq-text-dim)', marginBottom: 5 }}>URL</div>
                <input
                  type="url" required autoFocus
                  value={urlInput} onChange={(e) => setUrlInput(e.target.value)}
                  placeholder="https://example.com/photo.jpg"
                  style={{
                    width: '100%', padding: '9px 11px', fontSize: 13,
                    background: 'rgba(255,255,255,0.04)', border: '1px solid transparent',
                    borderRadius: 9, color: 'var(--aq-text)',
                    fontFamily: 'var(--aq-ff-mono)', outline: 0,
                  }}
                />
              </label>
              <label style={{ display: 'block' }}>
                <div style={{ fontSize: 11.5, color: 'var(--aq-text-dim)', marginBottom: 5 }}>
                  Caption <span style={{ color: 'var(--aq-text-faint)' }}>(optional)</span>
                </div>
                <input
                  type="text"
                  value={captionInput} onChange={(e) => setCaptionInput(e.target.value)}
                  placeholder="Photo by Jane Doe · CC BY 2.0"
                  style={{
                    width: '100%', padding: '9px 11px', fontSize: 13,
                    background: 'rgba(255,255,255,0.04)', border: '1px solid transparent',
                    borderRadius: 9, color: 'var(--aq-text)', font: 'inherit', outline: 0,
                  }}
                />
              </label>
              {err && (
                <div style={{
                  padding: '8px 10px', borderRadius: 6,
                  background: 'color-mix(in srgb, var(--aq-danger) 12%, transparent)',
                  color: 'var(--aq-danger)', fontSize: 12,
                }}>{err}</div>
              )}
            </div>
            <footer style={{
              padding: '12px 18px',
              display: 'flex', justifyContent: 'flex-end', gap: 8,
            }}>
              <button type="button" className="x-btn ghost" onClick={() => setUrlModalOpen(false)}>Cancel</button>
              <button
                type="submit"
                className="x-btn"
                disabled={busy === 'url' || !urlInput.trim()}
                style={{
                  background: 'rgba(255,255,255,0.12)', color: 'var(--aq-text)',
                  border: '1px solid rgba(255,255,255,0.14)', boxShadow: 'none',
                }}
              >
                {busy === 'url' ? 'Adding…' : 'Add media'}
              </button>
            </footer>
          </form>
        </div>
      )}
    </div>
  );
}

function mediaBtnStyle(tone) {
  return {
    padding: '3px 7px', fontSize: 10.5, fontFamily: 'var(--aq-ff-mono)',
    letterSpacing: '0.06em', textTransform: 'uppercase',
    background: tone === 'danger' ? 'color-mix(in srgb, var(--aq-danger) 88%, transparent)' : 'rgba(255,255,255,0.16)',
    color: '#fff', border: 0, borderRadius: 4, cursor: 'pointer',
  };
}

/* ── Image search modal — Wikipedia → iNat → Wikimedia Commons ─── */
function ImageSearchModal({ open, seedQuery, onClose, onPick }) {
  const [query, setQuery] = useState('');
  const [running, setRunning] = useState(false);
  const [hits, setHits] = useState([]);
  const [err, setErr] = useState(null);

  useEffect(() => {
    if (!open) { setQuery(''); setRunning(false); setHits([]); setErr(null); return; }
    setQuery(seedQuery || '');
    if (seedQuery) runSearch(seedQuery);
  }, [open]); // eslint-disable-line react-hooks/exhaustive-deps

  async function runSearch(q) {
    if (!q || q.trim().length < 2) return;
    setRunning(true); setErr(null);
    try {
      const r = await apiFetch(`/api/ai/search-images?q=${encodeURIComponent(q.trim())}`);
      setHits(r.images || r || []);
    } catch (e) {
      setErr(e.message); setHits([]);
    } finally {
      setRunning(false);
    }
  }

  if (!open) return null;
  return (
    <div
      onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
      style={{
        position: 'fixed', inset: 0, zIndex: 220,
        background: 'rgba(6, 7, 10, 0.78)', backdropFilter: 'blur(6px)',
        display: 'grid', placeItems: 'center', padding: 24,
      }}
    >
      <div style={{
        width: 'min(720px, 100%)', maxHeight: '88vh',
        background: 'var(--aq-surface)',
        border: '1px solid var(--aq-line)',
        borderRadius: 14,
        display: 'flex', flexDirection: 'column', overflow: 'hidden',
      }}>
        <header style={{
          padding: '14px 18px', borderBottom: '1px solid var(--aq-line)',
          display: 'flex', alignItems: 'center', gap: 12,
        }}>
          <div style={{
            width: 28, height: 28, borderRadius: 8,
            background: 'rgba(255,255,255,0.06)', color: 'var(--aq-text)',
            display: 'grid', placeItems: 'center',
          }}><Icon name="search" size={14} /></div>
          <div style={{ flex: 1 }}>
            <div style={{ fontFamily: 'var(--aq-ff-display)', fontSize: 14, color: 'var(--aq-text)' }}>
              Find an image
            </div>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-faint)' }}>
              CC-licensed photos from Wikipedia → iNaturalist → Wikimedia Commons.
            </div>
          </div>
          <button className="aq-icon-btn" onClick={onClose}><Icon name="close" size={13} /></button>
        </header>

        <div style={{
          padding: '12px 18px', display: 'flex', gap: 8,
          borderBottom: '1px solid var(--aq-line)',
        }}>
          <div style={{
            flex: 1, display: 'flex', alignItems: 'center', gap: 8,
            background: 'var(--aq-surface-2)', border: '1px solid var(--aq-line)',
            borderRadius: 7, padding: '6px 10px',
          }}>
            <Icon name="search" size={13} />
            <input
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              onKeyDown={(e) => { if (e.key === 'Enter') runSearch(query); }}
              placeholder="e.g. Amphiprion ocellaris"
              style={{
                flex: 1, background: 'transparent', border: 0, outline: 0,
                color: 'var(--aq-text)', font: 'inherit', fontSize: 13,
              }}
            />
          </div>
          <button onClick={() => runSearch(query)} className="aq-btn-primary" disabled={running}>
            {running ? 'Searching…' : 'Search'}
          </button>
        </div>

        <div style={{ overflowY: 'auto', padding: 12, flex: 1, minHeight: 200 }}>
          {err && (
            <div style={{
              padding: '8px 12px', borderRadius: 6,
              background: 'color-mix(in srgb, var(--aq-danger) 12%, transparent)',
              color: 'var(--aq-danger)', fontSize: 12,
            }}>{err}</div>
          )}
          {!running && !err && hits.length === 0 && (
            <div style={{ textAlign: 'center', color: 'var(--aq-text-faint)', padding: 30, fontSize: 12.5 }}>
              No images yet — search above.
            </div>
          )}
          {hits.length > 0 && (
            <div style={{
              display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 8,
            }}>
              {hits.map((h, i) => (
                <button
                  key={(h.full_url || h.thumbnail_url) + i}
                  onClick={() => onPick(h)}
                  style={{
                    background: 'transparent', border: '1px solid var(--aq-line)',
                    borderRadius: 6, overflow: 'hidden', cursor: 'pointer', padding: 0,
                    textAlign: 'left',
                  }}
                  title={`${h.title || ''}\n${h.author || ''}\n${h.license || ''}`}
                >
                  <div style={{
                    aspectRatio: '4/3',
                    background: `url(${h.thumbnail_url || h.full_url}) center/cover`,
                  }} />
                  <div style={{
                    padding: '6px 8px', fontSize: 10.5,
                    color: 'var(--aq-text-faint)',
                    borderTop: '1px solid var(--aq-line)',
                  }}>
                    <div style={{ color: 'var(--aq-text)', fontSize: 11.5, marginBottom: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                      {h.title || h.source}
                    </div>
                    <div style={{ fontFamily: 'var(--aq-ff-mono)', letterSpacing: '0.04em' }}>{h.license || h.source}</div>
                  </div>
                </button>
              ))}
            </div>
          )}
        </div>

        <footer style={{
          padding: '10px 18px', borderTop: '1px solid var(--aq-line)',
          background: 'var(--aq-surface-2)', fontSize: 11.5, color: 'var(--aq-text-faint)',
          textAlign: 'right',
        }}>Click a thumbnail to attach to the species.</footer>
      </div>
    </div>
  );
}

/* Auto-fit helper — analyses a matted WebM in a hidden <video>, scans
   the alpha channel of sampled frames, and returns the framing values
   the renderer needs (zoom + transform-origin) so the subject fills
   the canvas instead of floating tiny in a corner.

   How it works:
     1. Loads the clip via /api/species/:id/animated-clip/raw — same-
        origin proxy, so canvas reads stay clean regardless of R2 CORS.
     2. Seeks to N evenly-spaced timestamps across the duration. We
        sample (rather than scrubbing every frame) because cropdetect-
        style accuracy isn't needed — fish swim mostly in-place, the
        bbox at any sampled frame is within ~5% of every other frame.
     3. For each frame, scans the alpha byte of every Nth pixel (16-px
        stride) for the union bounding box. 16-px stride is ~250×
        faster than per-pixel and the resulting bbox is within 1-2%
        of a full scan — fine for what we use it for.
     4. Pads the bbox by 8% on every side (breathing room) and
        computes a zoom that fills 92% of the panel's matched edge.
     5. Origin = bbox centre, in % of frame.

   Returns null if the video taints the canvas (CORS hardened) or if
   no opaque pixels were ever found (clip is all-alpha-zero — likely
   a busted matte). The caller treats null as "leave framing alone". */
async function computeAutoFit(speciesId) {
  return new Promise((resolve) => {
    const log = (...args) => console.log('[auto-fit]', ...args);
    log('starting for species', speciesId);

    const v = document.createElement('video');
    v.muted = true;
    v.playsInline = true;
    // No crossOrigin attribute — the URL is same-origin (our /raw
    // proxy) and setting `anonymous` would suppress the JWT cookie that
    // the auth middleware needs. Without crossOrigin the browser sends
    // cookies as normal AND the canvas stays untainted because the
    // resource is same-origin.
    v.preload = 'auto';
    v.src = `/api/species/${speciesId}/animated-clip/raw`;

    let cleaned = false;
    const cleanup = (val) => {
      if (cleaned) return;
      cleaned = true;
      try { v.pause(); } catch (_) {}
      v.removeAttribute('src');
      try { v.load(); } catch (_) {}
      resolve(val);
    };
    // Hard timeout — if the proxy hangs or the video never reaches
    // metadata for some reason, never leave the caller spinning.
    const overall = setTimeout(() => { log('timed out after 30s'); cleanup(null); }, 30000);

    v.addEventListener('error', (e) => {
      log('video error', v.error && v.error.code, v.error && v.error.message);
      clearTimeout(overall); cleanup(null);
    });
    v.addEventListener('loadedmetadata', async () => {
      const W = v.videoWidth, H = v.videoHeight;
      const dur = isFinite(v.duration) ? v.duration : 0;
      log('loadedmetadata', { W, H, dur });
      if (!W || !H || dur <= 0) { clearTimeout(overall); cleanup(null); return; }

      // 8 samples is plenty — most clips are 5-8 seconds, fish bbox is
      // near-stationary, and going to 16 samples doubles the time
      // without changing the result by more than ~1px.
      const N = 8;
      const stops = [];
      for (let i = 0; i < N; i++) stops.push(((i + 0.5) / N) * dur);

      const canvas = document.createElement('canvas');
      canvas.width = W; canvas.height = H;
      const ctx = canvas.getContext('2d', { willReadFrequently: true });

      let minX = W, minY = H, maxX = 0, maxY = 0, found = false;

      const seekTo = (t) => new Promise((res) => {
        const onSeeked = () => { v.removeEventListener('seeked', onSeeked); res(); };
        v.addEventListener('seeked', onSeeked, { once: true });
        try { v.currentTime = Math.max(0, Math.min(dur - 0.05, t)); }
        catch (_) { v.removeEventListener('seeked', onSeeked); res(); }
      });

      try {
        for (const t of stops) {
          await seekTo(t);
          ctx.clearRect(0, 0, W, H);
          ctx.drawImage(v, 0, 0, W, H);
          let data;
          try {
            data = ctx.getImageData(0, 0, W, H).data;
          } catch (e) {
            log('canvas tainted, abort', e.message);
            clearTimeout(overall); cleanup(null); return;
          }
          // 8-px stride for tighter bbox detection. Alpha threshold of
          // 200 only counts pixels that are at least ~78% opaque —
          // strict enough to ignore BiRefNet's halo / soft edges
          // (typically alpha 30-150) and pull back the "where is the
          // subject" bbox to the actually-solid body of the fish.
          // Earlier draft used threshold 24 which counted essentially
          // every matted pixel including the wide soft halo, producing
          // a bbox close to the full frame and a useless ~1.0× fit.
          const stride = 8;
          const ALPHA_THRESHOLD = 200;
          for (let y = 0; y < H; y += stride) {
            const rowOffset = y * W * 4;
            for (let x = 0; x < W; x += stride) {
              if (data[rowOffset + x * 4 + 3] >= ALPHA_THRESHOLD) {
                if (x < minX) minX = x;
                if (y < minY) minY = y;
                if (x > maxX) maxX = x;
                if (y > maxY) maxY = y;
                found = true;
              }
            }
          }
        }
      } catch (_) { clearTimeout(overall); cleanup(null); return; }

      clearTimeout(overall);
      const rawBboxFrac = found ? ((maxX - minX) * (maxY - minY)) / (W * H) : 0;
      log('bbox raw px', { minX, minY, maxX, maxY, found, fracOfFrame: rawBboxFrac.toFixed(3) });
      if (!found || maxX <= minX || maxY <= minY) { cleanup(null); return; }

      // Sanity check — if the "bbox" covers more than 85% of the frame,
      // the matte probably failed (almost everything came back opaque).
      // Fall back to a centred 2× so something visibly happens.
      if (rawBboxFrac > 0.85) {
        log('bbox covers >85% of frame, probably bad matte; falling back to centered 2×');
        cleanup({ zoom: 2.0, originX: 50, originY: 55 });
        return;
      }

      // No bbox padding — the user wants the fish to fill the panel.
      // Stripped previous 8% breathing room.
      const x0 = minX, y0 = minY, x1 = maxX, y1 = maxY;
      const bw = x1 - x0, bh = y1 - y0;

      // ── Letterbox-aware fit to KIOSK PANEL, not to video frame ──
      //
      // Critical fix: with object-fit:contain, a 16:9 video letterboxed
      // into a 4:5 kiosk panel only occupies ~45% of the panel height.
      // If we naively compute "zoom that fits bbox to video frame", a
      // 50%-of-video-height fish ends up at 50% × 0.45 = 22.5% of the
      // panel height — still tiny. We need to compute the zoom that
      // fits the bbox to the PANEL.
      //
      // Same problem on the origin: transform-origin coords are panel-
      // relative (the <video> element fills the panel), but the bbox
      // centre is computed in video pixel coords. Below we project the
      // bbox centre through the contain-letterbox math so the origin
      // lands at the right spot in panel space.
      //
      // PANEL_ASPECT = 4/5 (0.8) matches both this editor's kiosk
      // preview AND the typical landscape-kiosk photo-panel
      // (45% × 100% of a 16:9 screen ≈ 4:5). Doesn't perfectly match
      // mobile/portrait surfaces; an acceptable compromise until we
      // teach the renderer to compute zoom client-side from a stored
      // video-coord bbox.
      const PANEL_ASPECT = 4 / 5;
      const VR = W / H;
      let videoWFactor = 1, videoHFactor = 1;
      let letterboxXFrac = 0, letterboxYFrac = 0;
      if (VR > PANEL_ASPECT) {
        // Video wider than panel → letterbox top/bottom
        videoHFactor = PANEL_ASPECT / VR;
        letterboxYFrac = (1 - videoHFactor) / 2;
      } else {
        // Video taller than panel → letterbox left/right
        videoWFactor = VR / PANEL_ASPECT;
        letterboxXFrac = (1 - videoWFactor) / 2;
      }

      // Zoom to fit bbox INSIDE panel — "contain" framing, not "cover."
      // Z_x_fit is "scale needed so bbox width = panel width."
      // Z_y_fit is "scale needed so bbox height = panel height."
      // Using the SMALLER of the two means the bbox fits comfortably
      // inside both panel dimensions (the smaller-fit dimension just
      // touches an edge; the other has slack). This is what the user
      // expects: the subject sits within the photo panel bounds, not
      // spilling out.
      //
      // fillFrac 0.85 = 15% breathing room so the subject doesn't kiss
      // the panel edges. Tweak up to 0.92 if framing feels too loose.
      // No artificial floor — if the subject already nicely fills the
      // video frame, 1× is the right answer. Cap at 3× as a safety
      // limit (keeps tiny-subject auto-fits sane).
      const Z_x_fit = W / Math.max(1, bw * videoWFactor);
      const Z_y_fit = H / Math.max(1, bh * videoHFactor);
      const fillFrac = 0.85;
      const zRaw = Math.min(Z_x_fit, Z_y_fit) * fillFrac;
      const zoom = Math.max(1.0, Math.min(3.0, zRaw));

      // Bbox centre in VIDEO-frame fractions
      const cxVidFrac = (x0 + bw / 2) / W;
      const cyVidFrac = (y0 + bh / 2) / H;
      // Project to PANEL-frame fractions through the contain letterbox
      const cxPanelFrac = letterboxXFrac + cxVidFrac * videoWFactor;
      const cyPanelFrac = letterboxYFrac + cyVidFrac * videoHFactor;
      const cx = cxPanelFrac * 100;
      const cy = cyPanelFrac * 100;
      log('fit math', {
        Z_x_fit: Z_x_fit.toFixed(2), Z_y_fit: Z_y_fit.toFixed(2),
        zoom: zoom.toFixed(2),
        videoHFactor: videoHFactor.toFixed(2),
        videoCoords: { x: (cxVidFrac*100).toFixed(1), y: (cyVidFrac*100).toFixed(1) },
        panelCoords: { x: cx.toFixed(1), y: cy.toFixed(1) },
      });

      const result = {
        zoom: Math.round(zoom * 100) / 100,
        originX: Math.round(cx * 10) / 10,
        originY: Math.round(cy * 10) / 10,
        // ── Orientation-independent subject box ──────────────────────
        // zoom/origin above are computed against a fixed 4:5 panel
        // (PANEL_ASPECT) and are therefore only correct on a landscape
        // kiosk. The box below is the subject's bounds in the CLIP's own
        // coordinate space (fractions of the video frame, 0–1) — it does
        // not depend on the display at all. The kiosk recomputes the
        // actual zoom from this box against whatever panel it's rendering
        // into (landscape / portrait / mobile), so a single bake is
        // correct on every screen. zoom/origin are kept as a baked
        // fallback for any renderer not yet taught to read the box.
        bbox: {
          x: Math.round((x0 / W) * 10000) / 10000,
          y: Math.round((y0 / H) * 10000) / 10000,
          w: Math.round((bw / W) * 10000) / 10000,
          h: Math.round((bh / H) * 10000) / 10000,
        },
        // Stamp the algorithm version so future tweaks to the math
        // (different fillFrac, threshold, fit strategy) can be detected
        // by the on-mount stale check and re-fit silently.
        version: AUTO_FIT_VERSION,
      };
      log('result', result);
      cleanup(result);
    });
  });
}

// Bump this whenever computeAutoFit's math changes so previously-saved
// auto-fit values get re-computed on next species open. Manual fits
// (user dragged the slider) are NEVER overridden, version-bump or not.
const AUTO_FIT_VERSION = 3;

// ── Per-panel framing (mirror of computeClipFraming in display/index.html)
// Given a meta blob + the panel it's painting into + the clip's intrinsic
// size, return the {zoom, originX, originY} that fills THIS panel. Kept in
// lock-step with the kiosk so the editor preview is true WYSIWYG.
//   • manual fit  → null (honour the baked slider value verbatim)
//   • has bbox    → fit the subject box to the panel
//   • no bbox + no meaningful baked zoom → cover-fill (never letterbox the
//     subject because a stale/failed auto-fit left a junk ~1.0–1.05 value)
//   • otherwise   → null (honour the legacy baked zoom that did zoom in)
function computeClipFramingFromMeta(meta, panelW, panelH, videoW, videoH) {
  const PA = panelW / panelH, VR = videoW / videoH;
  if (!(PA > 0) || !(VR > 0)) return null;
  if (meta && meta.clip_framing_source === 'manual') return null;

  let vW = 1, vH = 1, lbX = 0, lbY = 0;
  if (VR > PA) { vH = PA / VR; lbY = (1 - vH) / 2; }
  else { vW = VR / PA; lbX = (1 - vW) / 2; }
  const cz = (z) => Math.max(1.0, Math.min(3.0, z));
  const cp = (n) => Math.max(0, Math.min(100, n));

  const bx = Number(meta && meta.clip_bbox_x), by = Number(meta && meta.clip_bbox_y);
  const bw = Number(meta && meta.clip_bbox_w), bh = Number(meta && meta.clip_bbox_h);
  if ([bx, by, bw, bh].every(Number.isFinite) && bw > 0 && bh > 0) {
    const zoom = cz(Math.min(1 / Math.max(1e-4, bw * vW), 1 / Math.max(1e-4, bh * vH)) * 0.85);
    return {
      zoom,
      originX: cp((lbX + (bx + bw / 2) * vW) * 100),
      originY: cp((lbY + (by + bh / 2) * vH) * 100),
    };
  }
  const bakedZoom = Number(meta && meta.clip_zoom);
  if (!(Number.isFinite(bakedZoom) && bakedZoom > 1.05)) {
    return { zoom: cz(Math.max(VR / PA, PA / VR)), originX: 50, originY: 55 };
  }
  return null;
}

/* Slider for animated_clip_meta.clip_zoom — the kiosk reads this value
   when sizing the matted WebM on the species card. Range 0.5-2.0 with
   0.05 steps. Persists via PUT /api/species/:id/animated-clip/zoom
   (already exists in src/routes/species-clip.js L317). Debounced so
   dragging doesn't fire dozens of writes. */
function ClipZoomSlider({ form, onChanged }) {
  // Pull initial value from the meta blob attached to the species. The
  // editor's `form.animated_clip_meta` is whatever the API returned —
  // could be a JSON string or already-parsed object.
  const initial = (() => {
    const m = form.animated_clip_meta;
    if (!m) return 1;
    let parsed = m;
    if (typeof m === 'string') { try { parsed = JSON.parse(m); } catch (_) { parsed = {}; } }
    const z = Number(parsed && parsed.clip_zoom);
    return Number.isFinite(z) ? z : 1;
  })();
  const [zoom, setZoom] = useState(initial);
  const [autoBusy, setAutoBusy] = useState(false);
  const [autoErr, setAutoErr] = useState(null);
  const debounceRef = useRef(null);

  // When the species changes (form.id flip) re-pull the initial value.
  useEffect(() => { setZoom(initial); setAutoErr(null); }, [form.id]); // eslint-disable-line react-hooks/exhaustive-deps

  function commit(v) {
    if (debounceRef.current) clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(async () => {
      try {
        await apiFetch(`/api/species/${form.id}/animated-clip/zoom`, {
          method: 'PUT',
          body: JSON.stringify({ zoom: v }),
        });
        if (onChanged) onChanged();
        // Tell the live-preview iframe to reload so the curator sees
        // the new zoom land on the kiosk view too, not just on the
        // small WYSIWYG thumbnail in this panel.
        window.dispatchEvent(new CustomEvent('aquaos:species-preview-refresh', {
          detail: { speciesId: form.id, reason: 'zoom-slider' },
        }));
      } catch (_) { /* slider write is best-effort; next save will retry */ }
    }, 350);
  }

  async function autoFit() {
    if (!form.id || autoBusy) return;
    setAutoBusy(true); setAutoErr(null);
    try {
      const r = await computeAutoFit(form.id);
      if (!r) {
        setAutoErr("Couldn't detect a subject — leaving framing as-is.");
        return;
      }
      // Persist all three fields atomically, then reflect zoom in the
      // slider so the visual jump matches what the kiosk will show.
      await apiFetch(`/api/species/${form.id}/animated-clip/zoom`, {
        method: 'PUT',
        // Send the whole result (zoom + origin + bbox + version) so the
        // orientation-independent subject box is persisted, not just the
        // 4:5-panel zoom number.
        body: JSON.stringify(r),
      });
      setZoom(r.zoom);
      if (onChanged) onChanged();
      window.dispatchEvent(new CustomEvent('aquaos:species-preview-refresh', {
        detail: { speciesId: form.id, reason: 'auto-fit-button' },
      }));
    } catch (e) {
      setAutoErr(e.message || 'Auto-fit failed.');
    } finally {
      setAutoBusy(false);
    }
  }

  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
        <input
          type="range"
          min="0.5" max="2" step="0.05"
          value={Math.min(2, zoom)}
          onChange={(e) => { const v = Number(e.target.value); setZoom(v); commit(v); }}
          style={{ flex: 1 }}
        />
        <span style={{
          fontFamily: 'var(--aq-ff-mono)', fontSize: 12,
          color: 'var(--aq-text-dim)', minWidth: 42, textAlign: 'right',
        }}>{zoom.toFixed(2)}×</span>
        <button
          type="button"
          onClick={autoFit}
          disabled={autoBusy}
          title="Detect the subject in the clip and zoom to fill the panel"
          style={{
            fontSize: 11, padding: '4px 9px', borderRadius: 5,
            border: '1px solid var(--aq-line)', background: 'var(--aq-surface-2)',
            color: 'var(--aq-text-dim)', cursor: autoBusy ? 'wait' : 'pointer',
            whiteSpace: 'nowrap',
          }}>
          {autoBusy ? 'Fitting…' : 'Auto-fit'}
        </button>
      </div>
      {autoErr && (
        <div style={{ marginTop: 4, fontSize: 11, color: 'var(--aq-text-faint)' }}>{autoErr}</div>
      )}
    </div>
  );
}

function AnimationSection({ form, onChanged }) {
  const [providers, setProviders] = useState([]);
  const [provider, setProvider] = useState(null);
  /* Motion profile (2026-06-08, Oli — frozen tiger). 'auto' lets the
     server pick from taxonomy (land classes → natural, everything else →
     calm). 'calm' = seamless pinned loop, aquatic tuning. 'natural' =
     unpinned, real body movement for land animals (loop point is a cut).
     Override exists because marine mammals/penguins straddle the line. */
  const [motion, setMotion] = useState('auto');
  /* Loop style (2026-06-08, Oli — seahorse loop-pause). 'pinned' (default)
     = seamless end-frame loop but the motion eases to a stop at the seam.
     'boomerang' = forward+reverse, no pause, ideal for swaying/hovering
     subjects (seahorse, jellyfish) but wrong for directional swimmers (a
     shark reversed swims backward). Curator picks per species. */
  const [loop, setLoop] = useState(form.animated_clip_meta?.loop_style || 'pinned');
  const [prompt, setPrompt] = useState(form.animated_clip_suggested_prompt || '');
  // Full status object — keeps every field the backend's clipStatusPayload
  // returns so we can render stage label + percent + ETA + cost while a
  // job is in flight. Earlier we kept only url/state/provider, which is
  // why the panel said "pending" with no detail. Bug feedback May 2026:
  // "the old system used to give me more detailed info so I knew it was
  // working".
  const [status, setStatus] = useState({
    url: form.animated_clip_url,
    state: form.animated_clip_status || 'idle',
    provider: form.animated_clip_provider || null,
    stage: null, progress: null,
    overallPercent: 0, stagePercent: 0, etaSeconds: null,
    queuePosition: null,
    costAudCentsSoFar: null, costAudCents: null,
    error: null,
    // Framing fields. Default 50/55 mirrors the kiosk's CSS default
    // (transform-origin: 50% 55% on .photo-panel video) so the preview
    // shows the same framing the kiosk would, even before /animated-clip
    // GET lands.
    clip_zoom: 1, clip_origin_x: 50, clip_origin_y: 55,
    clip_framing_source: null,
    motionProfile: form.animated_clip_meta?.motion_profile || null,
  });
  const [busy, setBusy] = useState(null);
  const [err, setErr] = useState(null);
  const sharpness = useSharpness(form);
  const pollRef = useRef(null);

  /* Pull the provider list once. The endpoint is global, not per-species. */
  useEffect(() => {
    apiFetch('/api/species/animated-clip/providers')
      .then((res) => {
        const list = res.providers || [];
        setProviders(list);
        if (list.length > 0) setProvider(res.default || list[0].id);
      })
      .catch(() => { /* providers list is best-effort */ });
  }, []);

  // Helper to merge a /animated-clip GET payload into local state. The
  // backend returns the rich shape (status, stage, overallPercent,
  // etaSeconds, costAudCentsSoFar, etc.) — we keep it all so the
  // progress display has everything it needs.
  function applyClipPayload(p) {
    if (!p || typeof p !== 'object') return;
    setStatus((prev) => ({
      ...prev,
      url: p.url || prev.url || null,
      state: p.status || prev.state,
      provider: p.provider || prev.provider,
      stage: p.stage || null,
      progress: p.progress || null,
      overallPercent: Number.isFinite(Number(p.overallPercent)) ? Number(p.overallPercent) : 0,
      stagePercent: Number.isFinite(Number(p.stagePercent)) ? Number(p.stagePercent) : 0,
      etaSeconds: typeof p.etaSeconds === 'number' ? p.etaSeconds : null,
      queuePosition: p.queuePosition || null,
      costAudCentsSoFar: typeof p.costAudCentsSoFar === 'number' ? p.costAudCentsSoFar : null,
      costAudCents: typeof p.costAudCents === 'number' ? p.costAudCents : null,
      error: p.error || null,
      // Framing values — used by the WYSIWYG preview directly above the
      // controls. Defaults match the kiosk's CSS defaults so the preview
      // shows something sensible when these fields aren't set yet.
      clip_zoom: typeof p.clip_zoom === 'number' ? p.clip_zoom : (prev.clip_zoom ?? 1),
      clip_origin_x: typeof p.clip_origin_x === 'number' ? p.clip_origin_x : (prev.clip_origin_x ?? 50),
      clip_origin_y: typeof p.clip_origin_y === 'number' ? p.clip_origin_y : (prev.clip_origin_y ?? 55),
      clip_framing_source: p.clip_framing_source ?? prev.clip_framing_source ?? null,
      // What the last generate actually used — lets the curator confirm
      // what 'auto' resolved to from taxonomy.
      motionProfile: p.motionProfile ?? prev.motionProfile ?? null,
      loopStyle: p.loopStyle ?? prev.loopStyle ?? null,
      // Orientation-independent subject box — drives the per-panel preview
      // framing. null on legacy clips never fit under the box pipeline.
      clip_bbox_x: typeof p.clip_bbox_x === 'number' ? p.clip_bbox_x : (prev.clip_bbox_x ?? null),
      clip_bbox_y: typeof p.clip_bbox_y === 'number' ? p.clip_bbox_y : (prev.clip_bbox_y ?? null),
      clip_bbox_w: typeof p.clip_bbox_w === 'number' ? p.clip_bbox_w : (prev.clip_bbox_w ?? null),
      clip_bbox_h: typeof p.clip_bbox_h === 'number' ? p.clip_bbox_h : (prev.clip_bbox_h ?? null),
    }));
  }

  // Tracks species IDs we've already kicked off an auto-fit for in
  // this session, so the polling effect and the on-mount fetch don't
  // both fire computeAutoFit for the same clip (e.g. opening a species
  // → ready response triggers fit; polling tick sees ready and would
  // try again).
  const autoFitFiredRef = useRef(new Set());

  /* Re-sync local state when the form switches species or refresh()
     pulls a new record. We only know what the species row carries —
     full progress detail comes from a fresh GET below. */
  useEffect(() => {
    setStatus((prev) => ({
      ...prev,
      url: form.animated_clip_url,
      state: form.animated_clip_status || 'idle',
      provider: form.animated_clip_provider || null,
    }));
    if (form.animated_clip_suggested_prompt) setPrompt(form.animated_clip_suggested_prompt);
    // Pull full status (progress meta lives in animated_clip_meta which
    // the editor's main fetch may not parse; the dedicated GET unpacks it).
    if (form.id) {
      const speciesId = form.id;
      apiFetch(`/api/species/${speciesId}/animated-clip`).then((r) => {
        applyClipPayload(r);
        // Backfill auto-fit when:
        //   • Clip is ready, AND
        //   • framing_source is null (never set), OR was 'auto' but the
        //     previous run produced a near-1× zoom (meaning the older,
        //     halo-tolerant algorithm failed and we can safely overwrite
        //     with the stricter detector). 'manual' is always respected.
        //   • we haven't already tried this species this session.
        // Auto-fit fires ONCE — when a clip first becomes ready and has
        // no framing saved yet. Once values are saved (whether by auto
        // or manual), opening the species again ALWAYS uses the saved
        // values verbatim. No version-based re-triggers, no algorithm
        // upgrades that override existing data.
        //
        // To re-fit a species after an algorithm change, the user
        // clicks the "Auto-fit" button explicitly. This keeps refresh
        // behaviour deterministic: open → instant render at saved
        // framing, no zoom-in animation, no surprise overrides.
        const noFraming = r && !r.clip_framing_source;
        if (r && r.status === 'ready' && noFraming
            && !autoFitFiredRef.current.has(speciesId)) {
          autoFitFiredRef.current.add(speciesId);
          (async () => {
            const fit = await computeAutoFit(speciesId);
            if (!fit) {
              if (window.toast) window.toast('Auto-fit could not detect a subject — leaving framing as-is.', 'warning');
              return;
            }
            try {
              await apiFetch(`/api/species/${speciesId}/animated-clip/zoom`, {
                method: 'PUT',
                body: JSON.stringify(fit),
              });
              const fresh = await apiFetch(`/api/species/${speciesId}/animated-clip`);
              applyClipPayload(fresh);
              if (onChanged) onChanged();
              window.dispatchEvent(new CustomEvent('aquaos:species-preview-refresh', {
                detail: { speciesId, reason: 'auto-fit-backfill' },
              }));
              if (window.toast) {
                window.toast(`Auto-fitted clip framing (${fit.zoom.toFixed(2)}× at ${fit.originX.toFixed(0)}/${fit.originY.toFixed(0)}).`, 'success');
              }
            } catch (e) {
              if (window.toast) window.toast(`Auto-fit save failed: ${e.message}`, 'error');
            }
          })();
        }
      }).catch(() => {});
    }
  }, [form.id, form.animated_clip_url, form.animated_clip_status, form.animated_clip_suggested_prompt]);

  /* Poll the status while a job is in-flight. Backend `status` field
     is 'idle' | 'pending' | 'ready' | 'error'. Poll every 4s while
     pending. Bug feedback May 2026: a setTimeout-based poll only
     fired once because the `[status.state, form.id]` dependency
     compared 'pending'→'pending' as unchanged — the effect never
     re-scheduled. Switched to setInterval which keeps ticking, with
     an `if (!live) return` guard inside each tick so polling
     auto-pauses when status flips to ready/error/idle. */
  useEffect(() => {
    if (!form.id) return;
    const tick = async () => {
      // Re-read latest state via ref to avoid a stale closure — useState
      // values inside an interval that was set up in a now-stale
      // useEffect would otherwise see the original state object.
      const liveNow = statusRef.current && statusRef.current.state === 'pending';
      if (!liveNow) return;
      try {
        const r = await apiFetch(`/api/species/${form.id}/animated-clip`);
        applyClipPayload(r);
        if (r && r.status === 'ready') {
          // Auto-fit on first-ready: if the clip has never been framed
          // (server returns clip_framing_source === null) and we haven't
          // already kicked one off this session, run the alpha-bbox
          // analyzer silently and PUT the result. Default 50/55 framing
          // is fine for centred subjects; this fixes the common I2V
          // case where the model placed the fish in a corner.
          const speciesId = form.id;
          if (!r.clip_framing_source && !autoFitFiredRef.current.has(speciesId)) {
            autoFitFiredRef.current.add(speciesId);
            (async () => {
              const fit = await computeAutoFit(speciesId);
              if (!fit) return;
              try {
                await apiFetch(`/api/species/${speciesId}/animated-clip/zoom`, {
                  method: 'PUT',
                  body: JSON.stringify(fit),
                });
                // Re-pull so the local panel reflects the new framing.
                const fresh = await apiFetch(`/api/species/${speciesId}/animated-clip`);
                applyClipPayload(fresh);
                if (onChanged) onChanged();
                window.dispatchEvent(new CustomEvent('aquaos:species-preview-refresh', {
                  detail: { speciesId, reason: 'auto-fit-on-ready' },
                }));
              } catch (_) { /* best-effort */ }
            })();
          }
          if (onChanged) onChanged();
        }
      } catch (_) { /* keep polling — transient errors are fine */ }
    };
    // Fire one immediately so the bar updates without waiting 4s for
    // the first tick; then schedule the regular interval.
    tick();
    const interval = setInterval(tick, 4000);
    return () => clearInterval(interval);
  }, [form.id]);

  // Mirror of `status` into a ref — the polling tick reads from this
  // so it always sees the current value, not a closure capture.
  const statusRef = useRef(status);
  useEffect(() => { statusRef.current = status; }, [status]);

  async function suggestPrompt() {
    if (!form.id) return;
    setBusy('suggest'); setErr(null);
    try {
      const r = await apiFetch(`/api/species/${form.id}/animated-clip/suggest-prompt`, { method: 'POST' });
      setPrompt(r.prompt || '');
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(null);
    }
  }

  async function generate() {
    if (!form.id) return;
    setBusy('generate'); setErr(null);
    try {
      const body = { provider };
      if (prompt && prompt.trim()) body.prompt = prompt.trim();
      if (motion !== 'auto') body.motion_profile = motion; // 'auto' → server resolves from taxonomy
      if (loop !== 'pinned') body.loop_style = loop;       // 'pinned' is the server default
      const r = await apiFetch(`/api/species/${form.id}/animated-clip`, {
        method: 'POST',
        body: JSON.stringify(body),
      });
      // Bug feedback May 2026 (React error #31): the POST endpoint
      // returns `{ accepted: true, status: <full payload object> }` —
      // not the payload at the top level like the GET. Previously we
      // treated `r.status` as if it were the status string, which
      // shoved the whole object into React state, and rendering
      // `{status.state}` as JSX crashed with "objects are not valid as
      // a React child". Unwrap the nested payload here so all consumers
      // see the same flat shape.
      const payload = (r && typeof r === 'object' && r.status && typeof r.status === 'object') ? r.status : r;
      // Use the merge helper so we capture the full progress shape
      // (stage / overallPercent / etaSeconds / cost) right from kickoff,
      // not just url/state/provider — otherwise the bar starts at "0%"
      // for the first 4 seconds until the first poll lands.
      applyClipPayload(payload);
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(null);
    }
  }

  async function clearClip() {
    if (!form.id) return;
    // Confirm message reflects what we're actually doing — clearing a
    // finished clip vs aborting a stuck/in-flight job vs resetting an
    // errored one. The DELETE endpoint behaves identically in all three
    // cases (full reset to idle), but the language matters for the user.
    const msg =
      status.state === 'pending'
        ? 'Cancel this clip generation and reset back to idle? You can start a fresh run after.'
        : status.state === 'error'
          ? 'Reset the failed clip state? You can start a fresh run after.'
          : 'Delete the existing animated clip? You can regenerate later.';
    if (!window.confirm(msg)) return;
    setBusy('clear'); setErr(null);
    try {
      await apiFetch(`/api/species/${form.id}/animated-clip`, { method: 'DELETE' });
      setStatus((prev) => ({
        ...prev,
        url: null, state: 'idle', provider: null,
        stage: null, progress: null,
        overallPercent: 0, stagePercent: 0, etaSeconds: null,
        queuePosition: null,
        costAudCentsSoFar: null, costAudCents: null,
        error: null,
      }));
      if (onChanged) onChanged();
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(null);
    }
  }

  // Backend `status` enum is 'idle' | 'pending' | 'ready' | 'error'.
  // Earlier this list used different labels (queued/rendering/...) that
  // never actually appeared in the payload — so `live` was permanently
  // false and the bar never showed. Aligned with the real enum now.
  const live = status.state === 'pending';
  const stateColor = ({
    idle: 'var(--aq-text-faint)',
    pending: 'var(--aq-text-dim)',
    ready: 'var(--aq-success)',
    error: 'var(--aq-danger)',
  }[status.state]) || 'var(--aq-text-faint)';

  // Stage labels mirror cms-original L16199 — what the curator actually
  // sees while the pipeline progresses through fal.ai's stages.
  const STAGE_LABELS = {
    generate:    'Generating video',
    interpolate: 'Smoothing motion',
    upscale:     'Upscaling to HD',
    matte:       'Removing background',
    upload:      'Saving to storage',
  };
  const stageLabel = STAGE_LABELS[status.stage] || (status.state === 'pending' ? 'Queuing' : null);
  const isQueued = status.progress === 'queued' || status.progress === 'in_queue';
  const stageSuffix = isQueued
    ? (status.queuePosition ? ` · queue position ${status.queuePosition}` : ' · waiting for GPU')
    : '';
  const pct = Math.max(0, Math.min(100, Number(status.overallPercent || 0)));
  const eta = (typeof status.etaSeconds === 'number' && status.etaSeconds > 0) ? status.etaSeconds : null;
  function fmtAud(cents) {
    if (cents == null || !Number.isFinite(Number(cents))) return null;
    return `~A$${(Number(cents) / 100).toFixed(2)}`;
  }
  const spentSuffix = (status.costAudCentsSoFar && status.costAudCentsSoFar > 0)
    ? ` · ${fmtAud(status.costAudCentsSoFar)} so far`
    : '';

  return (
    <div className="se-section-body">
      {!form.id && (
        <div style={{ marginBottom: 12, fontSize: 11.5, color: 'var(--aq-text-faint)' }}>
          Save the species first — animated clips attach to the saved record.
        </div>
      )}

      {/* Status row + clip preview. While `live`, render the old app's
          rich progress block: stage label + suffix, % bar, ETA + cost.
          Mirrors cms-original L16191-16265. */}
      <div style={{
        display: 'flex', gap: 12, alignItems: 'flex-start', marginBottom: 12,
      }}>
        <span style={{
          width: 8, height: 8, borderRadius: '50%', marginTop: 5,
          background: stateColor, boxShadow: live ? `0 0 8px ${stateColor}` : 'none',
          flexShrink: 0,
        }} />
        <div style={{ flex: 1, fontSize: 12.5, minWidth: 0 }}>
          {live ? (
            <>
              <div style={{
                display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 6,
              }}>
                <div style={{ flex: 1, color: 'var(--aq-text)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                  {stageLabel}{stageSuffix}…
                </div>
                <div style={{
                  fontFamily: 'var(--aq-ff-mono)', fontSize: 12,
                  color: 'var(--aq-text)', fontWeight: 600,
                }}>{pct}%</div>
              </div>
              {/* Bar */}
              <div style={{
                height: 6, borderRadius: 99,
                background: 'rgba(255,255,255,0.06)', overflow: 'hidden',
                marginBottom: 6,
              }}>
                <div style={{
                  height: '100%', width: pct + '%',
                  background: stateColor,
                  transition: 'width 0.6s ease-out',
                }} />
              </div>
              {/* ETA + cost line */}
              <div style={{ color: 'var(--aq-text-faint)', fontSize: 11.5 }}>
                {eta != null
                  ? (eta >= 60
                      ? `~${Math.floor(eta / 60)}m ${eta % 60}s remaining`
                      : `~${eta}s remaining`)
                  : (isQueued ? 'Waiting in provider queue' : 'Taking longer than usual…')
                }{spentSuffix}
                {' · provider: '}{status.provider || '—'}
              </div>
            </>
          ) : (
            <>
              <div style={{ color: 'var(--aq-text)', textTransform: 'capitalize' }}>
                {status.state === 'idle' && !status.url ? 'No clip yet' : status.state}
              </div>
              <div style={{ color: 'var(--aq-text-faint)', fontSize: 11.5 }}>
                {status.url
                  ? (status.provider ? `Provider: ${status.provider}` + (status.costAudCents ? ' · ' + fmtAud(status.costAudCents) : '') : 'Clip ready')
                  : 'Ready to generate'}
                {status.state === 'error' && status.error && (
                  <span style={{ color: 'var(--aq-danger)', marginLeft: 6 }}>· {String(status.error).slice(0, 120)}</span>
                )}
              </div>
            </>
          )}
        </div>
      </div>

      {/* WYSIWYG preview — when a clip is ready, render it in a panel
          that mirrors the kiosk's `.photo-panel` (45% width × full height
          on landscape screens → roughly 4:5 portrait). The video uses
          object-fit:contain (same as kiosk) and the same
          transform-origin + scale derived from animated_clip_meta. So
          what you see here is what ships on the screen. */}
      {status.url && (
        <div style={{
          marginTop: 10,
          // Headline: this preview is the kiosk frame, not a thumbnail.
          // Aspect ratio 4/5 mirrors a typical landscape kiosk's left
          // photo-panel (45% wide × 100% tall on a 16:9 screen).
          aspectRatio: '4 / 5',
          maxWidth: 280,
          margin: '10px auto 0',
          borderRadius: 8,
          overflow: 'hidden',
          // Dark kiosk-body fill; no border — the clip is self-contained.
          background: 'linear-gradient(135deg, #0a1628, #060e1a)',
          position: 'relative',
        }}>
          <video
            key={status.url /* force remount when url changes so new clip plays */}
            src={status.url}
            muted autoPlay loop playsInline
            ref={(el) => {
              if (!el) return;
              // Apply the EXACT same per-panel framing the kiosk computes,
              // measuring this preview box + the clip's intrinsic size, so
              // the thumbnail is true WYSIWYG (including the cover-fill
              // floor that rescues stale ~1.0× auto-fits like the seal).
              const meta = {
                clip_zoom: status.clip_zoom, clip_origin_x: status.clip_origin_x,
                clip_origin_y: status.clip_origin_y, clip_framing_source: status.clip_framing_source,
                clip_bbox_x: status.clip_bbox_x, clip_bbox_y: status.clip_bbox_y,
                clip_bbox_w: status.clip_bbox_w, clip_bbox_h: status.clip_bbox_h,
              };
              const apply = () => {
                const pw = el.clientWidth, ph = el.clientHeight;
                const vw = el.videoWidth, vh = el.videoHeight;
                let f = (pw > 0 && ph > 0 && vw > 0 && vh > 0)
                  ? computeClipFramingFromMeta(meta, pw, ph, vw, vh) : null;
                if (!f) {
                  f = {
                    zoom: typeof status.clip_zoom === 'number' ? Math.max(0.5, Math.min(3, status.clip_zoom)) : 1,
                    originX: typeof status.clip_origin_x === 'number' ? status.clip_origin_x : 50,
                    originY: typeof status.clip_origin_y === 'number' ? status.clip_origin_y : 55,
                  };
                }
                el.style.transformOrigin = `${f.originX.toFixed(1)}% ${f.originY.toFixed(1)}%`;
                el.style.transform = `scale(${f.zoom.toFixed(4)})`;
              };
              if (el.videoWidth > 0) apply();
              else el.addEventListener('loadedmetadata', apply, { once: true });
            }}
            style={{
              width: '100%',
              height: '100%',
              objectFit: 'contain',
              filter: 'brightness(.92) contrast(1.05)', // matches kiosk .photo-panel video
              // No transition — saved framing snaps to position. The
              // animation only ran during the first auto-fit; on every
              // subsequent open the value is hardcoded and instant.
              display: 'block',
            }}
          />
          {/* Tiny corner badge so the curator knows this is a live
              preview honouring the saved framing — not the raw clip. */}
          <div style={{
            position: 'absolute', top: 6, right: 8,
            fontSize: 10, letterSpacing: '0.04em',
            color: 'rgba(255,255,255,0.55)',
            background: 'rgba(0,0,0,0.35)',
            padding: '2px 6px', borderRadius: 4,
            backdropFilter: 'blur(2px)',
            pointerEvents: 'none',
          }}>
            kiosk preview
          </div>
        </div>
      )}

      <div className="se-grid-2">
        <SE_Field label="Provider">
          <SE_Select
            value={provider || ''}
            onChange={(v) => setProvider(v)}
            options={providers.map((p) => p.id || p.name).filter(Boolean)}
          />
        </SE_Field>
        <SE_Field label="Cost (approx)">
          <SE_Input
            mono
            value={(() => {
              const p = providers.find((x) => (x.id || x.name) === provider);
              if (!p) return '—';
              const aud = p.approxFullPipelineAudCents || p.approxCostAudCents;
              return aud != null ? `A$${(aud / 100).toFixed(2)}` : '—';
            })()}
          />
        </SE_Field>
        <SE_Field label="Motion" hint="auto picks from taxonomy — calm = aquatic tuning, natural = land animals (looser motion, 10s, still a true loop)">
          <SE_Select
            value={motion}
            onChange={(v) => setMotion(v)}
            options={['auto', 'calm', 'natural']}
          />
          {/* Confirm what auto resolved to on the last run. Only shown
              when left on auto AND a clip has been generated — otherwise
              the explicit choice IS the answer. */}
          {motion === 'auto' && status.motionProfile && (
            <div style={{ marginTop: 4, fontSize: 11, color: 'var(--aq-text-faint)' }}>
              last run used: <strong style={{ color: 'var(--aq-text-dim)' }}>{status.motionProfile}</strong>
            </div>
          )}
        </SE_Field>
        <SE_Field label="Loop" hint="pinned = seamless but can pause at the seam · boomerang = forward+reverse, no pause, best for swaying/hovering species (NOT directional swimmers)">
          <SE_Select
            value={loop}
            onChange={(v) => setLoop(v)}
            options={['pinned', 'boomerang']}
          />
        </SE_Field>
      </div>

      <SE_Field label="Motion prompt" hint="describe motion only — appearance comes from the hero image">
        <SE_Textarea
          rows={4}
          value={prompt}
          onChange={setPrompt}
          placeholder="The fish glides slowly across the frame, fins fluttering gently…"
        />
      </SE_Field>

      {/* Display scale (clip_zoom) — old app exposed this as a slider
          on the animated clip card (cms-original L14401). The kiosk
          rendering reads animated_clip_meta.clip_zoom to scale the
          subject; without this control curators couldn't fine-tune
          framing after generation. Saves via the dedicated
          PUT /api/species/:id/animated-clip/zoom endpoint. */}
      {status.url && form.id && (
        <SE_Field label="Display scale" hint="how the kiosk crops the clip — 1.0 = native, larger zooms in">
          <ClipZoomSlider form={form} onChanged={onChanged} />
        </SE_Field>
      )}

      {err && (
        <div style={{
          padding: '8px 12px', borderRadius: 6, marginTop: 10,
          background: 'color-mix(in srgb, var(--aq-danger) 12%, transparent)',
          color: 'var(--aq-danger)', fontSize: 12,
        }}>{err}</div>
      )}

      {/* Soft-source warning (2026-06-08, Oli): Kling treats the input
          photo as the clip's first frame, so a blurry hero produces a
          blurry clip with the blur faithfully animated. Surface it HERE,
          before the 35¢ spend, with the fix one tab away. */}
      {sharpness && sharpness.verdict === 'soft' && (
        <div style={{ marginTop: 10, fontSize: 12, color: 'var(--aq-danger)' }}>
          Source photo looks soft (sharpness {sharpness.score}) — the clip will inherit the blur.
          Consider Enhance on the photo in the Media tab first.
        </div>
      )}

      <div className="se-row-actions" style={{ marginTop: 10 }}>
        <SE_Btn icon="spark" ghost disabled={!form.id || busy != null} onClick={suggestPrompt}>
          {busy === 'suggest' ? 'Suggesting…' : 'Suggest prompt'}
        </SE_Btn>
        <SE_Btn icon="plus" disabled={!form.id || busy != null || live} onClick={generate}>
          {busy === 'generate' ? 'Starting…' : (status.url ? 'Re-generate' : (status.state === 'error' ? 'Try again' : 'Generate'))}
        </SE_Btn>
        {/* Show Clear/Reset whenever there's *something* to reset — a finished
            URL, an in-flight pending job, or a previous error. Without the
            pending/error cases, a stuck job left curators with no escape:
            Generate disabled while live, Clear hidden because no URL yet.
            Bug feedback May 2026. */}
        {(status.url || status.state === 'pending' || status.state === 'error') && (
          <SE_Btn ghost disabled={!form.id || busy != null} onClick={clearClip}>
            {busy === 'clear'
              ? 'Clearing…'
              : (status.state === 'pending' ? 'Cancel & reset'
                : status.state === 'error' ? 'Reset'
                : 'Clear clip')}
          </SE_Btn>
        )}
      </div>
    </div>
  );
}

/* Ingestion issues drawer — server-side log of warnings/errors raised
   by the import pipeline (IUCN miss, FishBase down, AI safety block,
   etc.). The list is global; we filter in-browser to issues attached
   to this species (or to its scientific name when there's no FK yet). */
/* The three-dot kebab in the editor header. Previously a dead button —
   now a real popover with the actions curators expect at this level
   (duplicate, delete, audit log). The button itself shows the dot icon
   regardless of state; the popover anchors below it via `position: fixed`
   so it escapes any stacking context the header creates. */
function SpeciesMoreMenu({ form, isNew, backHref }) {
  const [open, setOpen] = useState(false);
  const [busy, setBusy] = useState(false);
  const btnRef = useRef(null);
  const popRef = useRef(null);
  const [anchor, setAnchor] = useState(null);

  function reposition() {
    if (!btnRef.current) return;
    const r = btnRef.current.getBoundingClientRect();
    setAnchor({ top: r.bottom + 6, right: window.innerWidth - r.right });
  }

  useEffect(() => {
    if (!open) return;
    reposition();
    const onDoc = (e) => {
      if (popRef.current && !popRef.current.contains(e.target)
          && btnRef.current && !btnRef.current.contains(e.target)) {
        setOpen(false);
      }
    };
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    const onResize = () => reposition();
    document.addEventListener('mousedown', onDoc);
    document.addEventListener('keydown', onKey);
    window.addEventListener('resize', onResize);
    window.addEventListener('scroll', onResize, true);
    return () => {
      document.removeEventListener('mousedown', onDoc);
      document.removeEventListener('keydown', onKey);
      window.removeEventListener('resize', onResize);
      window.removeEventListener('scroll', onResize, true);
    };
  }, [open]);

  async function doDelete() {
    if (!form.id) return;
    if (busy) return;
    setBusy(true);
    // Pre-check impact so the confirm dialog tells the truth about
    // what gets affected. Falls through with safe defaults if the
    // usage endpoint fails (curator can still abort the confirm).
    let usage = { screen_count: 0, exhibit_count: 0, is_global: false };
    try {
      usage = await apiFetch(`/api/species/${encodeURIComponent(form.id)}/usage`);
    } catch (_) { /* keep defaults */ }
    const onScreens = (usage.screen_count || 0) + (usage.exhibit_count || 0);
    const lines = [
      `Delete "${form.common_name || 'this species'}"?`,
      '',
      onScreens > 0
        ? `This species is currently on ${onScreens} ${onScreens === 1 ? 'screen' : 'screens'}. ` +
          `Soft-deleting it removes it from those screens immediately, but the screen ` +
          `assignments are preserved — restoring the species within 30 days will put it ` +
          `back on the same screens automatically.`
        : 'No screens are currently using this species.',
      '',
      usage.is_global
        ? 'This is a GLOBAL species — deleting affects every organisation that uses it.'
        : '',
      'Soft-delete is recoverable within 30 days.',
    ].filter(Boolean).join('\n');
    if (!window.confirm(lines)) {
      setBusy(false);
      return;
    }
    try {
      const r = await fetch(`/api/species/${encodeURIComponent(form.id)}`, {
        method: 'DELETE', credentials: 'include',
      });
      if (!r.ok) {
        const body = await r.json().catch(() => ({}));
        alert(body.error || `Couldn't delete (${r.status}). Permission may be the issue.`);
        return;
      }
      /* May 2026 fix: previously hardcoded to '#species' which isn't
         the library route — that's '#library' (or '#global-species'
         when the editor was opened from the global catalogue). The
         backHref prop already carries the correct destination so we
         match the Cancel button's behaviour. */
      window.location.hash = backHref || '#library';
    } catch (e) {
      alert(`Network error while deleting: ${e.message}`);
    } finally {
      setBusy(false);
      setOpen(false);
    }
  }

  async function doDuplicate() {
    if (!form.id || busy) return;
    setBusy(true);
    try {
      /* Switched to the server-side /fork endpoint May 2026 (was
         GET-source + POST-copy client-side). The old client-side flow
         lost the heavy fields (distribution_points, animated_clip_*,
         voiceover_*) because POST /api/species's allowedFields
         whitelist drops them, and it left scope='global' on the new
         row in some cases (since the POST handler kept the source's
         scope and the demote-to-pending_global branch only fires on
         is_verified=false). The /fork endpoint stamps scope='local',
         org_id from caller, fresh timestamps, and copies all the
         heavy fields + species_media + species_translations +
         occurrence_records + species_voiceovers server-side in one
         shot. */
      const created = await apiFetch(`/api/species/${encodeURIComponent(form.id)}/fork`, {
        method: 'POST', body: JSON.stringify({}),
      });
      if (created && created.id) {
        window.location.hash = `#species/${created.id}`;
      }
    } catch (e) {
      alert(`Couldn't duplicate: ${e.message}`);
    } finally {
      setBusy(false);
      setOpen(false);
    }
  }

  function viewAudit() {
    setOpen(false);
    if (!form.id) return;
    window.location.hash = `#audit?entity=species&id=${form.id}`;
  }

  // Gating per May 2026 feedback (a3c16989 + 3091e300):
  //  • Audit log: platform admins only. The audit page itself is gated
  //    at #audit anyway, so curators clicking through just bounce off
  //    the auth wall — better to hide the entry point.
  //  • Delete on GLOBAL species (organisation_id is null): platform
  //    admins only. Org curators shouldn't be able to remove rows
  //    that affect every customer. Local species delete is unchanged.
  const isPlatformAdmin = !!(Auth.canSeePlatform && Auth.canSeePlatform());
  const isGlobalSpecies = !form.organisation_id;
  const showAudit  = !isNew && isPlatformAdmin;
  const showDelete = !isNew && (!isGlobalSpecies || isPlatformAdmin);

  const menu = (open && anchor) ? (
    <div
      ref={popRef}
      style={{
        position: 'fixed', top: anchor.top, right: anchor.right,
        background: 'var(--aqos-surface)', border: '1px solid var(--aqos-border)',
        borderRadius: 10, boxShadow: 'var(--aqos-sh-2)', minWidth: 220, padding: 6,
        zIndex: 9999, fontSize: 13.5,
      }}
    >
      {!isNew && (
        <button onClick={doDuplicate} disabled={busy} className="se-menu-item" style={menuItemStyle}>
          Duplicate species
        </button>
      )}
      {showAudit && (
        <button onClick={viewAudit} className="se-menu-item" style={menuItemStyle}>
          View audit log
        </button>
      )}
      {showDelete && !isNew && <div style={{ height: 1, background: 'var(--aqos-border)', margin: '4px 0' }} />}
      {showDelete && (
        <button
          onClick={doDelete}
          disabled={busy || isNew}
          className="se-menu-item"
          style={{ ...menuItemStyle, color: 'var(--aqos-danger)' }}
        >
          {isNew ? 'Delete (save first)' : 'Delete species…'}
        </button>
      )}
    </div>
  ) : null;

  return (
    <>
      <button
        ref={btnRef}
        className="se-icon-btn"
        aria-label="More actions"
        aria-haspopup="menu"
        aria-expanded={open}
        onClick={() => setOpen((v) => !v)}
      >
        <Icon name="more" />
      </button>
      {menu}
    </>
  );
}
const menuItemStyle = {
  display: 'block', width: '100%', textAlign: 'left',
  padding: '8px 10px', background: 'transparent', border: 0,
  borderRadius: 6, cursor: 'pointer', color: 'inherit',
  font: 'inherit',
};

function VoiceoverSection({ form, onChanged, restricted }) {
  // State key naming matches the backend route bodies — see
  // src/routes/species-voiceover.js: PUT and POST read req.body.languageCode
  // and req.body.voiceId, NOT language / voice_id. We previously used
  // snake_case here, which silently dropped both fields on every save and
  // every generate, so the dropdowns and the script never round-tripped.
  // Bug feedback May 2026: "the script disappears when i refresh, it should
  // stay. when i press generate, nothing is happening or showing in terminal."
  const [settings, setSettings] = useState({
    provider: 'elevenlabs',
    languageCode: 'en',
    voiceId: '',
    // V3 is the only model exposed in the picker as of May 2026 (Oli) —
    // every species voiceover runs on V3 for consistent prosody. Backend
    // ELEVENLABS_MODELS confirms this; the fallback list below also
    // collapses to V3 only.
    modelId: 'eleven_v3',
    // Per-generation knobs sent to ElevenLabs. Keys are snake_case to
    // match the API body — see VOICE_SETTINGS_DEFAULTS in
    // src/services/elevenLabsVoiceover.js. These initial values are the
    // agreed Charlotte/V3 narrator preset; backend uses identical defaults
    // so an unsaved species generates with the same voice.
    voiceSettings: {
      stability: 0.55,
      similarity_boost: 0.75,
      style: 1.0,
      speed: 1.2,
      use_speaker_boost: false,
    },
    script: '',
  });
  const [audio, setAudio] = useState(null); /* { url, status, language } */
  const [busy, setBusy] = useState(null);
  const [err, setErr] = useState(null);
  // Short status line shown under the buttons during generate so the user
  // sees forward motion while the async pipeline is still running.
  const [progress, setProgress] = useState(null);

  /* Load voice catalogue once. Backend shape (matches cms-original
     L15604+): {
       providers: [{id,label,configured,defaultVoiceId}, ...],
       voices: { piper: [...], elevenlabs: [...] },   // keyed by provider
       languages: [{code,label}, ...],
       defaultProvider: 'elevenlabs' | 'piper',
       defaultVoiceId: '...'
     }
     Earlier this component dropped everything except `voices` and then
     tried to flatten an object as an array — which produced an empty
     dropdown. Bug feedback May 2026: voiceover voices not loading,
     Generate audio dead. Fix: keep the whole catalogue + read provider/
     language/voice lists straight from it. */
  const [catalogue, setCatalogue] = useState(null);
  useEffect(() => {
    apiFetch('/api/species/voiceover/voices')
      .then((res) => {
        setCatalogue(res || {});
        // Adopt server defaults whenever they come back. The settings
        // useState initialiser ran before this fetch, so it's still
        // pointing at empty values — set them here so the dropdowns
        // have a sensible starting selection.
        setSettings((s) => ({
          ...s,
          provider: s.provider || res.defaultProvider || 'elevenlabs',
          voiceId: s.voiceId || res.defaultVoiceId || '',
          modelId: s.modelId || res.defaultModelId || 'eleven_v3',
        }));
      })
      .catch(() => { /* not fatal */ });
  }, []);

  /* Pull existing voiceover settings + audio for this species.
     The route spreads `trackPayload(track, fresh, languageCode)` at the
     top level of the response, so script/provider/voiceId/languageCode/
     url/status all live on the root object. There is no `settings` or
     `audio_url` wrapper; the previous `if (res.settings)` check therefore
     never fired, which is why the script vanished on every refresh. */
  useEffect(() => {
    if (!form.id) return;
    apiFetch(`/api/species/${form.id}/voiceover`)
      .then((res) => {
        if (!res) return;
        setSettings((s) => ({
          ...s,
          provider: res.provider || s.provider,
          languageCode: res.languageCode || s.languageCode,
          voiceId: res.voiceId || s.voiceId,
          modelId: res.modelId || s.modelId,
          // Server returns the full set merged with defaults — adopt as-is
          // when present, otherwise keep whatever local state already has.
          voiceSettings: (res.voiceSettings && typeof res.voiceSettings === 'object')
            ? { ...s.voiceSettings, ...res.voiceSettings }
            : s.voiceSettings,
          script: typeof res.script === 'string' ? res.script : s.script,
        }));
        if (res.url) {
          setAudio({
            url: res.url,
            status: res.status || 'ready',
            language: res.languageCode || 'en',
          });
        }
      })
      .catch(() => { /* species may have no voiceover yet — that's normal */ });
  }, [form.id]);

  async function saveSettings(patch) {
    if (!form.id) return;
    const next = { ...settings, ...patch };
    setSettings(next);
    try {
      await apiFetch(`/api/species/${form.id}/voiceover`, {
        method: 'PUT',
        body: JSON.stringify(next),
      });
    } catch (_) { /* save is best-effort — generate will resend the body anyway */ }
  }

  // Update a single key inside settings.voiceSettings without nuking the
  // others. Sliders fire onChange continuously, so each tick PUTs — fine
  // for a single-curator dev environment; if it ever feels chatty, debounce
  // the apiFetch in saveSettings rather than this helper so the local state
  // still updates instantly.
  function updateVoiceSetting(key, value) {
    saveSettings({
      voiceSettings: { ...(settings.voiceSettings || {}), [key]: value },
    });
  }

  async function suggestScript() {
    if (!form.id) return;
    setBusy('suggest'); setErr(null);
    try {
      const r = await apiFetch(`/api/species/${form.id}/voiceover/suggest-script`, {
        method: 'POST',
        body: JSON.stringify({
          languageCode: settings.languageCode,
          provider: settings.provider,
          voiceId: settings.voiceId,
          // modelId tells the suggester whether to enable V3 audio-tag
          // guidance ([curious], [pause], etc). V2 ignores it.
          modelId: settings.modelId,
        }),
      });
      // The route already persists the suggested script on the track, so
      // a subsequent refresh would pick it up — but mirror it locally
      // immediately so the textarea updates without a round-trip.
      const next = { ...settings, script: r.script || r.text || settings.script };
      setSettings(next);
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(null);
    }
  }

  /* The /voiceover/generate route returns 202 immediately and runs the
     TTS + storage upload asynchronously (see runVoiceoverPipeline in
     src/routes/species-voiceover.js). Poll the GET endpoint until the
     track flips to 'ready' (with a url) or 'error' so the UI doesn't
     just sit there silently after the button press.

     1500 ms cadence comfortably covers ElevenLabs (~2–6 s end-to-end)
     and Piper (~1–3 s for short scripts). 90 s overall ceiling because
     a stalled fly worker would otherwise hang the UI indefinitely. */
  async function pollUntilReady(languageCode) {
    const deadline = Date.now() + 90_000;
    let lastStage = null;
    while (Date.now() < deadline) {
      await new Promise((resolve) => setTimeout(resolve, 1500));
      try {
        const r = await apiFetch(
          `/api/species/${form.id}/voiceover?lang=${encodeURIComponent(languageCode || 'en')}`
        );
        if (!r) continue;
        const stage = (r.meta && r.meta.stage) || r.status || null;
        if (stage && stage !== lastStage) {
          lastStage = stage;
          setProgress(`Generating · ${stage}…`);
        }
        if (r.status === 'ready' && r.url) {
          return { ok: true, url: r.url, languageCode: r.languageCode };
        }
        if (r.status === 'error') {
          const msg = (r.meta && r.meta.error) || 'Generation failed.';
          return { ok: false, error: msg };
        }
      } catch (_) {
        // Transient — keep polling until the deadline.
      }
    }
    return { ok: false, error: 'Generation timed out after 90 s.' };
  }

  async function generateAudio() {
    if (!form.id) return;
    if (!settings.script || !settings.script.trim()) {
      setErr('Add a script first — Suggest can write one for you.');
      return;
    }
    setBusy('generate'); setErr(null); setProgress('Generating · queued…');
    try {
      // Kick off generation. Backend returns 202 with status='pending';
      // the actual audio URL is populated by the async pipeline.
      await apiFetch(`/api/species/${form.id}/voiceover/generate`, {
        method: 'POST',
        body: JSON.stringify(settings),
      });
      const result = await pollUntilReady(settings.languageCode);
      if (result.ok) {
        setAudio({
          url: result.url,
          status: 'ready',
          language: result.languageCode || settings.languageCode,
        });
        if (onChanged) onChanged();
      } else {
        setErr(result.error);
      }
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(null);
      setProgress(null);
    }
  }

  async function clearAudio() {
    if (!form.id) return;
    if (!window.confirm('Delete the current voiceover audio? You can regenerate later.')) return;
    setBusy('clear'); setErr(null);
    try {
      await apiFetch(`/api/species/${form.id}/voiceover`, { method: 'DELETE' });
      setAudio(null);
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(null);
    }
  }

  /* Provider, language, and voice options come straight from the
     catalogue — no hardcoded lists. Mirrors cms-original L15610+
     where providers/languages/voices all read from the single
     /voiceover/voices payload. */
  const providerOptions = useMemo(() => {
    const arr = (catalogue && catalogue.providers) || [];
    return arr.map((p) => ({
      value: p.id,
      label: p.configured ? p.label : `${p.label} (needs API key)`,
    }));
  }, [catalogue]);

  const languageOptions = useMemo(() => {
    const arr = (catalogue && catalogue.languages) || [{ code: 'en', label: 'English' }];
    return arr.map((l) => ({ value: l.code, label: l.label || l.code }));
  }, [catalogue]);

  // For ElevenLabs the backend includes a flag `featured: true` on
  // entries from the curated preset; we sort featured first so the
  // org-recommended voices land at the top.
  const voiceOptions = useMemo(() => {
    if (!catalogue || !catalogue.voices) return [];
    const provider = settings.provider || catalogue.defaultProvider || 'elevenlabs';
    const list = catalogue.voices[provider] || [];
    return list
      .slice()
      .sort((a, b) => Number(!!b.featured) - Number(!!a.featured))
      .map((v) => ({
        value: v.id,
        label:
          (v.name || v.id) +
          (v.featured ? ' ★' : '') +
          (v.installed === false ? ' (not configured)' : ''),
      }));
  }, [catalogue, settings.provider]);

  // ElevenLabs model picker. Only rendered when provider === 'elevenlabs'
  // — Piper has one model per voice (the .onnx file IS the model).
  // V3-only as of May 2026 (Oli): every species voiceover runs on V3 for
  // consistent prosody. Fallback below matches ELEVENLABS_MODELS in
  // src/services/elevenLabsVoiceover.js so the picker is correct even if
  // /voiceover/voices hasn't loaded yet.
  const modelOptions = useMemo(() => {
    const arr = (catalogue && catalogue.models) || [
      { id: 'eleven_v3', label: 'V3 · Expressive' },
    ];
    return arr.map((m) => ({ value: m.id, label: m.label || m.id }));
  }, [catalogue]);

  return (
    <div className="se-section-body">
      {!form.id && (
        <div style={{ marginBottom: 12, fontSize: 11.5, color: 'var(--aq-text-faint)' }}>
          Save the species first — voiceover audio attaches to the saved record.
        </div>
      )}

      {/* Provider / Language / Voice — hidden for restricted (org) users:
          they only get the script + Generate; the voice config is fixed. */}
      {!restricted && (
      <div className="se-grid-3">
        <SE_Field label="Provider">
          <SE_Select
            value={settings.provider || (catalogue && catalogue.defaultProvider) || 'elevenlabs'}
            onChange={(v) => {
              // Switching providers wipes the voice selection — voice
              // IDs are namespaced per-provider, so a Piper id is
              // meaningless to ElevenLabs and vice versa. Pick that
              // provider's default voice if one's marked.
              const arr = (catalogue && catalogue.providers) || [];
              const match = arr.find((p) => p.id === v);
              saveSettings({ provider: v, voiceId: (match && match.defaultVoiceId) || '' });
            }}
            options={providerOptions.length ? providerOptions : [{ value: 'elevenlabs', label: 'ElevenLabs' }]}
          />
        </SE_Field>
        <SE_Field label="Language">
          <SE_Select
            value={settings.languageCode}
            onChange={(v) => saveSettings({ languageCode: v })}
            options={languageOptions}
          />
        </SE_Field>
        <SE_Field label="Voice">
          <SE_Select
            value={settings.voiceId}
            onChange={(v) => saveSettings({ voiceId: v })}
            options={voiceOptions.length > 0 ? voiceOptions : [{ value: '', label: '(loading voices…)' }]}
          />
        </SE_Field>
      </div>
      )}

      {/* Model picker is ElevenLabs-only. Hidden for Piper because Piper
          has one onnx model per voice — switching models there means
          switching voices, which the Voice dropdown above already handles.
          Persisted to track meta as `model_id`; the next regenerate runs
          on whatever's selected here. */}
      {!restricted && settings.provider === 'elevenlabs' && (
        <div style={{ marginTop: 8 }}>
          <SE_Field label="Model (quality)">
            <SE_Select
              value={settings.modelId || 'eleven_v3'}
              onChange={(v) => saveSettings({ modelId: v })}
              options={modelOptions}
            />
          </SE_Field>
        </div>
      )}

      {/* Voice-level knobs that ElevenLabs accepts on every TTS call.
          Snake_case keys match the API body (and the persisted shape on
          track.meta.voice_settings) so we don't need a translation layer.
          Sliders fire saveSettings on every tick — chatty but fine for
          dev; debounce inside saveSettings if it ever becomes an issue. */}
      {!restricted && settings.provider === 'elevenlabs' && (
        <div style={{ marginTop: 16 }}>
          <div style={{
            fontSize: 10.5, fontWeight: 600, letterSpacing: '0.06em',
            textTransform: 'uppercase', color: 'var(--aq-text-faint)',
            marginBottom: 10,
          }}>
            Voice settings
          </div>

          {[
            {
              key: 'stability',
              label: 'Stability',
              hint: 'Higher = more consistent / monotone, lower = more variation',
              min: 0, max: 1, step: 0.05,
            },
            {
              key: 'similarity_boost',
              label: 'Similarity',
              hint: 'How closely the output matches the original voice timbre',
              min: 0, max: 1, step: 0.05,
            },
            {
              key: 'style',
              label: 'Style',
              hint: 'Emotional emphasis — V3 makes use of it for expressive delivery',
              min: 0, max: 1, step: 0.05,
            },
            {
              key: 'speed',
              label: 'Speed',
              hint: 'Playback speed without pitch shift',
              min: 0.7, max: 1.2, step: 0.05,
            },
          ].map((cfg) => {
            const v = (settings.voiceSettings && settings.voiceSettings[cfg.key] != null)
              ? Number(settings.voiceSettings[cfg.key])
              : 0;
            return (
              <div key={cfg.key} style={{
                display: 'grid',
                gridTemplateColumns: '140px 1fr 48px',
                gap: 12, alignItems: 'center', marginBottom: 8,
              }}>
                <div>
                  <div style={{ fontSize: 12, color: 'var(--aq-text)' }}>{cfg.label}</div>
                  <div style={{ fontSize: 10, color: 'var(--aq-text-faint)', lineHeight: 1.3 }}>
                    {cfg.hint}
                  </div>
                </div>
                <input
                  type="range"
                  min={cfg.min} max={cfg.max} step={cfg.step}
                  value={v}
                  onChange={(e) => updateVoiceSetting(cfg.key, Number(e.target.value))}
                  style={{ width: '100%' }}
                />
                <div style={{
                  fontSize: 11, fontFamily: 'var(--aq-ff-mono)',
                  color: 'var(--aq-text-faint)', textAlign: 'right',
                }}>
                  {v.toFixed(2)}
                </div>
              </div>
            );
          })}

          {/* use_speaker_boost is the one boolean knob — render as a
              checkbox row aligned with the slider grid for visual rhythm. */}
          <div style={{
            display: 'grid',
            gridTemplateColumns: '140px 1fr 48px',
            gap: 12, alignItems: 'center', marginTop: 6,
          }}>
            <div>
              <div style={{ fontSize: 12, color: 'var(--aq-text)' }}>Speaker boost</div>
              <div style={{ fontSize: 10, color: 'var(--aq-text-faint)', lineHeight: 1.3 }}>
                Clarity post-process — toggle off if voice sounds over-bright
              </div>
            </div>
            <label style={{
              display: 'inline-flex', gap: 8, alignItems: 'center',
              fontSize: 11, color: 'var(--aq-text-faint)', cursor: 'pointer',
            }}>
              <input
                type="checkbox"
                checked={!!(settings.voiceSettings && settings.voiceSettings.use_speaker_boost)}
                onChange={(e) => updateVoiceSetting('use_speaker_boost', e.target.checked)}
              />
              <span>{(settings.voiceSettings && settings.voiceSettings.use_speaker_boost) ? 'on' : 'off'}</span>
            </label>
            <div />
          </div>
        </div>
      )}

      <SE_Field label="Script">
        <SE_Textarea
          rows={6}
          value={settings.script}
          onChange={(v) => setSettings((s) => ({ ...s, script: v }))}
          placeholder="Write or paste the spoken script…"
        />
      </SE_Field>

      {audio && audio.url && (
        <div style={{
          marginTop: 10, padding: '10px 12px',
          background: 'var(--aq-surface-2)',
          border: '1px solid var(--aq-line)', borderRadius: 8,
          display: 'flex', gap: 12, alignItems: 'center',
        }}>
          <Icon name="spark" size={14} />
          <div style={{ flex: 1 }}>
            <div style={{ fontSize: 12.5, color: 'var(--aq-text)' }}>Voiceover ready</div>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-faint)' }}>
              {(audio.language || '').toUpperCase()} · {audio.status}
            </div>
          </div>
          <audio src={audio.url} controls style={{ height: 32 }} />
        </div>
      )}

      {err && (
        <div style={{
          padding: '8px 12px', borderRadius: 6, marginTop: 10,
          background: 'color-mix(in srgb, var(--aq-danger) 12%, transparent)',
          color: 'var(--aq-danger)', fontSize: 12,
        }}>{err}</div>
      )}

      {progress && busy === 'generate' && (
        <div style={{
          padding: '8px 12px', borderRadius: 6, marginTop: 10,
          background: 'var(--aq-surface-2)',
          border: '1px solid var(--aq-line)',
          color: 'var(--aq-text-faint)', fontSize: 12,
          fontFamily: 'var(--aq-ff-mono)',
        }}>{progress}</div>
      )}

      <div className="se-row-actions" style={{ marginTop: 10 }}>
        <SE_Btn icon="spark" ghost disabled={!form.id || busy != null} onClick={suggestScript}>
          {busy === 'suggest' ? 'Drafting…' : 'Suggest script'}
        </SE_Btn>
        <SE_Btn icon="plus" disabled={!form.id || busy != null} onClick={generateAudio}>
          {busy === 'generate' ? 'Generating…' : (audio ? 'Regenerate audio' : 'Generate audio')}
        </SE_Btn>
        {audio && (
          <SE_Btn ghost disabled={!form.id || busy != null} onClick={clearAudio}>
            {busy === 'clear' ? 'Clearing…' : 'Clear audio'}
          </SE_Btn>
        )}
      </div>
    </div>
  );
}

/* Per-field AI regenerate. Used by DescriptionSection to put a small
   "AI" button next to each editable field. Calls /api/ai/regenerate-field
   with the species id + field name; the backend grounds the regen in
   the rest of the species record so the result stays anchored to the
   correct organism. Curator gets the result placed directly into the
   form — they can still edit before saving. */
function RegenButton({ form, field, onPick }) {
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);
  async function regen() {
    if (!form.id) {
      setErr('Save the species first');
      return;
    }
    setBusy(true); setErr(null);
    try {
      const instruction = window.prompt('Optional steer (e.g. "make it shorter", "kid-friendly", "lean technical"):');
      const body = { species_id: form.id, field };
      if (instruction) body.instruction = instruction;
      const res = await apiFetch('/api/ai/regenerate-field', {
        method: 'POST',
        body: JSON.stringify(body),
      });
      if (res && typeof res.value !== 'undefined') {
        onPick(String(res.value));
      }
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(false);
    }
  }
  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
      <button
        onClick={regen}
        disabled={busy || !form.id}
        title="Regenerate with AI"
        style={{
          fontFamily: 'var(--aq-ff-mono)', fontSize: 9.5,
          letterSpacing: '0.06em', textTransform: 'uppercase',
          padding: '2px 7px', borderRadius: 4,
          background: 'rgba(255,255,255,0.06)',
          color: 'var(--aq-text-dim)', border: 0, cursor: busy ? 'wait' : 'pointer',
          opacity: (busy || !form.id) ? 0.55 : 1,
        }}
      >{busy ? 'AI…' : 'AI'}</button>
      {err && <span style={{ color: 'var(--aq-danger)', fontSize: 10.5 }}>{err}</span>}
    </span>
  );
}


function DescriptionSection({ form, set }) {
  /* Helper that bundles the field hint + AI button so each row stays
     compact. The set() call is the same one the manual edit path uses. */
  const hint = (field) => <RegenButton form={form} field={field} onPick={(v) => set(field, v)} />;
  return (
    <div className="se-section-body">
      <SE_Field label="General audience" hint={hint('description_general')}>
        <SE_Textarea
          rows={4}
          value={form.description_general}
          onChange={(v) => set('description_general', v)}
        />
      </SE_Field>
      <SE_Field label="Kids version" hint={hint('description_kids')}>
        <SE_Textarea
          rows={3}
          value={form.description_kids}
          onChange={(v) => set('description_kids', v)}
        />
      </SE_Field>
      <SE_Field label="Expert version" hint={hint('description_expert')}>
        <SE_Textarea
          rows={4}
          value={form.description_expert}
          onChange={(v) => set('description_expert', v)}
        />
      </SE_Field>
      <SE_Field label="Fun fact" hint={hint('fun_fact')}>
        <SE_Textarea rows={2} value={form.fun_fact} onChange={(v) => set('fun_fact', v)} />
      </SE_Field>
      <SE_Field label="Kid fact" hint={hint('kid_fact')}>
        <SE_Textarea rows={2} value={form.kid_fact} onChange={(v) => set('kid_fact', v)} />
      </SE_Field>
      <SE_Field label="Conservation note" hint={hint('conservation_note')}>
        <SE_Textarea rows={2} value={form.conservation_note} onChange={(v) => set('conservation_note', v)} />
      </SE_Field>
    </div>
  );
}

/* ────────────────────────────────────────────────────────────
   EnrichmentSection — curator-facing CRUD for the four v70 tables
   plus the species.migration_path JSON column. Each sub-list shares
   a single SE_EnrichmentList component (fetch-on-mount, render rows,
   inline add form, edit/delete actions). Migration path uses its
   own textarea editor since it lives on the species row, not a
   separate table.
   ──────────────────────────────────────────────────────────── */

function SE_EnrichmentList({ title, hint, speciesId, listPath, itemPath, fields, sortKey }) {
  const [rows, setRows] = useState([]);
  const [loading, setLoading] = useState(true);
  const [err, setErr] = useState(null);
  const [adding, setAdding] = useState(false);
  const [draft, setDraft] = useState({});
  const [editId, setEditId] = useState(null);
  const [editDraft, setEditDraft] = useState({});
  const [busy, setBusy] = useState(false);

  async function load() {
    if (!speciesId) return;
    setLoading(true);
    try {
      const data = await window.apiFetch(`/api/species/${speciesId}/${listPath}`);
      setRows(Array.isArray(data) ? data : []);
      setErr(null);
    } catch (e) {
      setErr(e.message);
    } finally {
      setLoading(false);
    }
  }
  useEffect(() => { load(); }, [speciesId, listPath]);

  function fieldInput(value, onChange, f) {
    const v = value == null ? '' : value;
    if (f.type === 'textarea') {
      return (
        <textarea
          value={v} onChange={(e) => onChange(e.target.value)}
          placeholder={f.placeholder || ''} rows={f.rows || 2}
          style={{
            width: '100%', minHeight: 60, padding: '8px 10px',
            background: 'var(--aq-surface-1)', color: 'var(--aq-text)',
            border: '1px solid var(--aq-line)', borderRadius: 6,
            fontFamily: 'inherit', fontSize: 13, resize: 'vertical',
          }}
        />
      );
    }
    if (f.type === 'number') {
      return (
        <input type="number" value={v}
          onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
          placeholder={f.placeholder || ''} step={f.step || '1'}
          style={inputStyle}
        />
      );
    }
    return (
      <input type={f.type || 'text'} value={v}
        onChange={(e) => onChange(e.target.value)}
        placeholder={f.placeholder || ''}
        style={inputStyle}
      />
    );
  }

  const inputStyle = {
    width: '100%', padding: '8px 10px',
    background: 'var(--aq-surface-1)', color: 'var(--aq-text)',
    border: '1px solid var(--aq-line)', borderRadius: 6,
    fontFamily: 'inherit', fontSize: 13,
  };

  async function handleAdd() {
    setBusy(true); setErr(null);
    try {
      await window.apiFetch(`/api/species/${speciesId}/${listPath}`, {
        method: 'POST', body: JSON.stringify(draft),
      });
      setAdding(false); setDraft({});
      await load();
    } catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  }
  async function handleSaveEdit(rowId) {
    setBusy(true); setErr(null);
    try {
      await window.apiFetch(`/api/${itemPath}/${rowId}`, {
        method: 'PUT', body: JSON.stringify(editDraft),
      });
      setEditId(null); setEditDraft({});
      await load();
    } catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  }
  async function handleDelete(rowId, label) {
    if (!confirm(`Delete "${label}"? This can't be undone.`)) return;
    setBusy(true); setErr(null);
    try {
      await window.apiFetch(`/api/${itemPath}/${rowId}`, { method: 'DELETE' });
      await load();
    } catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  }

  return (
    <div style={{ marginBottom: 24 }}>
      <SE_SectionLabel hint={hint}>{title}</SE_SectionLabel>
      {loading && <div style={{ fontSize: 12, color: 'var(--aq-text-faint)', padding: 8 }}>Loading…</div>}
      {err && <div style={{ fontSize: 12, color: 'var(--aq-danger)', padding: 8 }}>{err}</div>}
      {!loading && rows.length === 0 && !adding && (
        <div style={{
          fontSize: 12, color: 'var(--aq-text-faint)', fontStyle: 'italic',
          padding: '12px 14px', background: 'var(--aq-surface-1)',
          border: '1px dashed var(--aq-line)', borderRadius: 6, marginBottom: 8,
        }}>
          No entries yet — the kiosk will fall back to demo data for famous species.
        </div>
      )}
      {rows.map((row) => {
        const isEditing = editId === row.id;
        if (isEditing) {
          return (
            <div key={row.id} style={cardEditing}>
              {fields.map((f) => (
                <div key={f.key} style={{ marginBottom: 8 }}>
                  <div style={speciesFieldLabel}>{f.label}</div>
                  {fieldInput(editDraft[f.key], (v) => setEditDraft({ ...editDraft, [f.key]: v }), f)}
                </div>
              ))}
              <div style={rowActions}>
                <SE_Btn ghost onClick={() => { setEditId(null); setEditDraft({}); }}>Cancel</SE_Btn>
                <SE_Btn primary disabled={busy} onClick={() => handleSaveEdit(row.id)}>Save</SE_Btn>
              </div>
            </div>
          );
        }
        return (
          <div key={row.id} style={card}>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ fontWeight: 500, fontSize: 13, marginBottom: 2 }}>
                {row[sortKey || fields[0].key] || '—'}
              </div>
              {fields.slice(1, 3).map((f) => row[f.key] && (
                <div key={f.key} style={{ fontSize: 11.5, color: 'var(--aq-text-muted)', marginTop: 2 }}>
                  <span style={{ opacity: 0.5 }}>{f.label}: </span>
                  {String(row[f.key]).slice(0, 80)}
                  {String(row[f.key]).length > 80 ? '…' : ''}
                </div>
              ))}
            </div>
            <div style={{ display: 'flex', gap: 6 }}>
              <button style={iconBtn} onClick={() => {
                setEditId(row.id);
                const d = {};
                fields.forEach((f) => { d[f.key] = row[f.key]; });
                setEditDraft(d);
              }} title="Edit">
                <i className="fas fa-pen" />
              </button>
              <button style={{ ...iconBtn, color: 'var(--aq-danger)' }}
                onClick={() => handleDelete(row.id, row[fields[0].key])}
                disabled={busy}
                title="Delete">
                <i className="fas fa-trash" />
              </button>
            </div>
          </div>
        );
      })}
      {adding ? (
        <div style={cardEditing}>
          {fields.map((f) => (
            <div key={f.key} style={{ marginBottom: 8 }}>
              <div style={speciesFieldLabel}>{f.label}</div>
              {fieldInput(draft[f.key], (v) => setDraft({ ...draft, [f.key]: v }), f)}
            </div>
          ))}
          <div style={rowActions}>
            <SE_Btn ghost onClick={() => { setAdding(false); setDraft({}); }}>Cancel</SE_Btn>
            <SE_Btn primary disabled={busy} onClick={handleAdd}>Add</SE_Btn>
          </div>
        </div>
      ) : (
        <button style={addBtn} onClick={() => { setAdding(true); setDraft({}); }}>
          <i className="fas fa-plus" /> Add {title.toLowerCase().replace(/s$/, '')}
        </button>
      )}
    </div>
  );
}

const card = {
  display: 'flex', alignItems: 'flex-start', gap: 12,
  padding: '10px 14px', background: 'var(--aq-surface-1)',
  border: '1px solid var(--aq-line)', borderRadius: 6,
  marginBottom: 6,
};
const cardEditing = {
  padding: '12px 14px', background: 'var(--aq-surface-1)',
  border: '1px solid rgba(255,255,255,0.20)', borderRadius: 6,
  marginBottom: 6,
};
const speciesFieldLabel = {
  fontSize: 10, textTransform: 'uppercase', letterSpacing: 1.2,
  color: 'var(--aq-text-faint)', marginBottom: 4, fontWeight: 600,
};
const rowActions = { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 8 };
const iconBtn = {
  background: 'transparent', border: '1px solid var(--aq-line)',
  color: 'var(--aq-text-muted)', cursor: 'pointer', borderRadius: 4,
  width: 28, height: 28, padding: 0, fontSize: 11,
};
const addBtn = {
  width: '100%', padding: '8px 14px', background: 'transparent',
  border: '1px dashed var(--aq-line)', borderRadius: 6,
  color: 'var(--aq-text-muted)', cursor: 'pointer', fontSize: 12,
  display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
};

function SE_MigrationPathEditor({ form, set }) {
  /* Migration path lives on the species row as a JSON-encoded TEXT
     column: [[lng, lat], [lng, lat], ...]. Curator edits as JSON
     directly for now — fancier map-based polyline editor is a Phase
     2 enhancement. */
  const [text, setText] = useState('');
  const [err, setErr] = useState(null);

  useEffect(() => {
    const v = form.migration_path;
    if (v == null) { setText(''); return; }
    try {
      const parsed = typeof v === 'string' ? JSON.parse(v) : v;
      setText(JSON.stringify(parsed, null, 2));
    } catch (_) {
      setText(typeof v === 'string' ? v : '');
    }
  }, [form && form.migration_path]);

  function commit(s) {
    setText(s);
    if (!s.trim()) { set('migration_path', null); setErr(null); return; }
    try {
      const parsed = JSON.parse(s);
      if (!Array.isArray(parsed)) throw new Error('must be an array');
      for (const pt of parsed) {
        if (!Array.isArray(pt) || pt.length < 2
            || typeof pt[0] !== 'number' || typeof pt[1] !== 'number') {
          throw new Error('each point must be [lng, lat]');
        }
      }
      set('migration_path', JSON.stringify(parsed));
      setErr(null);
    } catch (e) { setErr(e.message); }
  }

  return (
    <div style={{ marginBottom: 24 }}>
      <SE_SectionLabel hint="JSON: [[lng, lat], [lng, lat], …] — one waypoint per pair. Kiosk animates a dot along this loop.">
        Migration path
      </SE_SectionLabel>
      <textarea
        value={text}
        onChange={(e) => commit(e.target.value)}
        placeholder='[[-36, -54], [-30, -55], [-25, -58], [0, -50]]'
        rows={6}
        style={{
          width: '100%', padding: '10px 12px',
          background: 'var(--aq-surface-1)', color: 'var(--aq-text)',
          border: `1px solid ${err ? 'var(--aq-danger)' : 'var(--aq-line)'}`,
          borderRadius: 6, fontFamily: 'var(--aq-ff-mono, monospace)',
          fontSize: 12, resize: 'vertical',
        }}
      />
      {err && <div style={{ fontSize: 11, color: 'var(--aq-danger)', marginTop: 6 }}>{err}</div>}
      {!err && text && (
        <div style={{ fontSize: 11, color: 'var(--aq-text-faint)', marginTop: 6 }}>
          {(() => {
            try { return JSON.parse(text).length + ' waypoints — saved with Save button below'; }
            catch (_) { return ''; }
          })()}
        </div>
      )}
    </div>
  );
}

function SE_IucnSyncBlock({ form }) {
  /* Manual IUCN refresh trigger. Calls POST /api/species/:id/iucn/sync
     which fetches the species' assessment history from the IUCN Red
     List and upserts iucn_history rows. Server-side this requires the
     IUCN_API_TOKEN env var — if absent the call returns 503 and we
     show a clear "not configured" hint to the curator. */
  const [busy, setBusy] = useState(false);
  const [result, setResult] = useState(null);
  const [err, setErr] = useState(null);

  async function handleSync() {
    setBusy(true); setErr(null); setResult(null);
    try {
      const r = await window.apiFetch(`/api/species/${form.id}/iucn/sync`, { method: 'POST' });
      setResult(r);
      if (window.toast && r.ok) {
        window.toast.success
          ? window.toast.success(`IUCN: ${r.inserted} new · ${r.updated} updated`)
          : window.toast(`IUCN: ${r.inserted} new · ${r.updated} updated`);
      }
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(false);
    }
  }

  return (
    <div style={{
      padding: '10px 14px', marginBottom: 12,
      background: 'var(--aq-surface-1)', border: '1px solid var(--aq-line)',
      borderRadius: 6, display: 'flex', alignItems: 'center', gap: 12,
      flexWrap: 'wrap',
    }}>
      <div style={{ flex: 1, minWidth: 200 }}>
        <div style={{ fontSize: 12, fontWeight: 500, marginBottom: 2 }}>
          Sync from IUCN Red List
        </div>
        <div style={{ fontSize: 11, color: 'var(--aq-text-faint)' }}>
          Auto-populates the history below from the IUCN API. Manual edits below are preserved.
        </div>
        {result && result.ok && (
          <div style={{ fontSize: 11, color: 'var(--aq-success)', marginTop: 4 }}>
            ✓ Fetched {result.fetched} · {result.inserted} added · {result.updated} updated · {result.skipped} skipped
          </div>
        )}
        {result && !result.ok && (
          <div style={{ fontSize: 11, color: 'var(--aq-warn)', marginTop: 4 }}>
            {result.error || 'sync failed'}
          </div>
        )}
        {err && (
          <div style={{ fontSize: 11, color: 'var(--aq-danger)', marginTop: 4 }}>{err}</div>
        )}
      </div>
      <button
        onClick={handleSync}
        disabled={busy || !form.scientific_name}
        style={{
          padding: '6px 14px', background: 'rgba(255,255,255,0.92)',
          color: '#0c1119', border: 0, borderRadius: 4,
          cursor: busy ? 'wait' : 'pointer', fontSize: 12, fontWeight: 500,
          opacity: busy || !form.scientific_name ? 0.5 : 1,
        }}
      >{busy ? 'Syncing…' : 'Sync now'}</button>
    </div>
  );
}

function EnrichmentSection({ form, set }) {
  if (!form.id) {
    return (
      <div style={{
        padding: 24, color: 'var(--aq-text-faint)', fontStyle: 'italic',
        textAlign: 'center', fontSize: 13,
      }}>
        Save the species first — enrichment is stored against the species ID.
      </div>
    );
  }
  return (
    <div className="se-section-body">
      <div style={{
        padding: '12px 14px', marginBottom: 20,
        background: 'var(--aq-surface-1)', border: '1px solid var(--aq-line)',
        borderRadius: 6, fontSize: 11.5, color: 'var(--aq-text-muted)',
        lineHeight: 1.55,
      }}>
        Map-feature data — surfaces on the kiosk via the distribution map.
        Each list saves immediately. The migration path saves with the main
        species "Save" button at the bottom of the editor.
      </div>

      <SE_EnrichmentList
        title="Famous individuals"
        hint="Named animals of this species with stories (e.g. Granny the orca, Migaloo the white humpback)."
        speciesId={form.id}
        listPath="individuals"
        itemPath="individuals"
        sortKey="name"
        fields={[
          { key: 'name', label: 'Name', placeholder: 'Migaloo' },
          { key: 'year_range', label: 'Years', placeholder: '1991–' },
          { key: 'story', label: 'Story', type: 'textarea', rows: 3,
            placeholder: 'All-white adult male humpback off east Australia.' },
          { key: 'photo_url', label: 'Photo URL', placeholder: 'https://…' },
          { key: 'source_url', label: 'Source URL', placeholder: 'https://…' },
        ]}
      />

      <SE_EnrichmentList
        title="Hydrophone audio"
        hint="Underwater audio recordings of this species (whale song, fish clicks, dolphin whistles)."
        speciesId={form.id}
        listPath="audio"
        itemPath="audio"
        sortKey="title"
        fields={[
          { key: 'title', label: 'Title', placeholder: 'Humpback song · NOAA' },
          { key: 'audio_url', label: 'Audio URL', placeholder: 'https://…' },
          { key: 'source_credit', label: 'Credit', placeholder: 'Recorded by NOAA' },
          { key: 'source_url', label: 'Source URL', placeholder: 'https://…' },
          { key: 'duration_sec', label: 'Duration (sec)', type: 'number', step: '0.1' },
        ]}
      />

      <SE_EnrichmentList
        title="Tracked individuals"
        hint="Satellite-tagged animals with current/last-known GPS positions (Movebank / OCEARCH / ATN)."
        speciesId={form.id}
        listPath="tracked"
        itemPath="tracked"
        sortKey="name"
        fields={[
          { key: 'name', label: 'Name', placeholder: 'Mary Lee' },
          { key: 'tag_id', label: 'Tag ID', placeholder: 'OCEARCH 0823' },
          { key: 'last_lat', label: 'Latitude', type: 'number', step: '0.0001' },
          { key: 'last_lng', label: 'Longitude', type: 'number', step: '0.0001' },
          { key: 'last_seen_at', label: 'Last seen (ISO)', placeholder: '2026-05-15' },
          { key: 'source', label: 'Source', placeholder: 'OCEARCH' },
          { key: 'source_url', label: 'Source URL', placeholder: 'https://…' },
          { key: 'notes', label: 'Notes', type: 'textarea', rows: 2 },
        ]}
      />

      <SE_IucnSyncBlock form={form} />

      <SE_EnrichmentList
        title="IUCN status history"
        hint="Conservation status assessments over time (LC / NT / VU / EN / CR / DD / EX). The Sync button above auto-populates from the IUCN Red List API."
        speciesId={form.id}
        listPath="iucn-history"
        itemPath="iucn-history"
        sortKey="year"
        fields={[
          { key: 'year', label: 'Year', type: 'number', placeholder: '2018' },
          { key: 'status', label: 'Status', placeholder: 'VU' },
          { key: 'criteria', label: 'IUCN criteria', placeholder: 'A2bd+3bd+4bd' },
          { key: 'source', label: 'Source', placeholder: 'IUCN' },
          { key: 'source_url', label: 'Source URL', placeholder: 'https://www.iucnredlist.org/…' },
          { key: 'notes', label: 'Notes', type: 'textarea', rows: 2 },
        ]}
      />

      <SE_MigrationPathEditor form={form} set={set} />

      <SE_EnrichmentList
        title="Climate range projections"
        hint="Range polygons per (year, IPCC scenario). Future: auto-populate from AquaMaps. Today: enter manually or import via the bulk script."
        speciesId={form.id}
        listPath="climate-projections"
        itemPath="climate-projections"
        sortKey="year"
        fields={[
          { key: 'year', label: 'Year', type: 'number', placeholder: '2050' },
          { key: 'scenario', label: 'Scenario', placeholder: 'RCP4.5' },
          { key: 'range_polygon', label: 'Range polygon (GeoJSON)', type: 'textarea', rows: 5,
            placeholder: '{"type":"Polygon","coordinates":[[[lng,lat],…]]}' },
          { key: 'area_km2', label: 'Area km²', type: 'number', step: '0.1' },
          { key: 'source', label: 'Source', placeholder: 'AquaMaps' },
          { key: 'source_url', label: 'Source URL', placeholder: 'https://www.aquamaps.org/…' },
          { key: 'notes', label: 'Notes', type: 'textarea', rows: 2 },
        ]}
      />
    </div>
  );
}

/* ── Preview card (left half) ──────────────────────────────── */
/* Mirrors the old vanilla CMS species editor preview: an iframe pointed
   at the kiosk display engine with the species pinned, sized to a chosen
   device dimension (phone / tablet / standard / portrait), then scaled
   to fit the wrap with `transform: scale()` from the top-left. The same
   route the gallery kiosk serves is what the curator sees while editing,
   so there's zero gap between editor preview and live screen. */

const SE_PREVIEW_DIMS = {
  phone:    { w: 390,  h: 844,  label: 'Phone',         icon: 'monitor' },
  tablet:   { w: 1024, h: 768,  label: 'Tablet',        icon: 'monitor' },
  standard: { w: 1920, h: 1080, label: 'Kiosk',         icon: 'monitor' },
  portrait: { w: 1080, h: 1920, label: 'Portrait',      icon: 'monitor' },
};

function SpeciesPreview({ form }) {
  const wrapRef = useRef(null);
  const frameRef = useRef(null);
  const iframeRef = useRef(null);
  const [device, setDevice] = useState('standard');
  const [scale, setScale] = useState(1);
  const [reloadTick, setReloadTick] = useState(0);

  /* Listen for cross-component refresh requests. Auto-fit, manual zoom
     drags, and any other side-panel write that affects rendering
     dispatches `aquaos:species-preview-refresh` on window with a
     `detail.speciesId`. Without this the iframe sticks at whatever
     state it loaded with, so curators see the kiosk preview thumbnail
     update but the live preview stays stale until they tap ↻. */
  useEffect(() => {
    const onRefresh = (e) => {
      if (!e || !e.detail) return;
      // Only respond to our species — avoids unrelated ticks when
      // the user is editing a different species in another tab.
      if (e.detail.speciesId && form && e.detail.speciesId !== form.id) return;
      setReloadTick((n) => n + 1);
    };
    window.addEventListener('aquaos:species-preview-refresh', onRefresh);
    return () => window.removeEventListener('aquaos:species-preview-refresh', onRefresh);
  }, [form && form.id]);

  // Belt-and-braces: extract a framing fingerprint from the species
  // record so the iframe key changes whenever clip_zoom / origin /
  // animated_clip_url do. React unmounts and remounts the iframe on
  // key change, forcing a fresh fetch of /api/screens/.../display/
  // data. This is the safety net for cases where the cross-component
  // event fires before the parent has refetched form (the iframe is
  // already showing stale data when the event arrives, but on the next
  // form refetch the key changes and the iframe reloads cleanly).
  // Also extract numeric framing values for direct postMessage push.
  const { framingKey, framingValues } = useMemo(() => {
    if (!form) return { framingKey: 'no-form', framingValues: null };
    let zoom = 1, ox = 50, oy = 55;
    try {
      const m = typeof form.animated_clip_meta === 'string'
        ? JSON.parse(form.animated_clip_meta)
        : (form.animated_clip_meta || {});
      if (Number.isFinite(Number(m.clip_zoom))) zoom = Number(m.clip_zoom);
      if (Number.isFinite(Number(m.clip_origin_x))) ox = Number(m.clip_origin_x);
      if (Number.isFinite(Number(m.clip_origin_y))) oy = Number(m.clip_origin_y);
    } catch (_) {}
    return {
      framingKey: `${form.id}|${form.animated_clip_url || ''}|${zoom.toFixed(2)}|${ox.toFixed(1)}|${oy.toFixed(1)}|${reloadTick}`,
      framingValues: { zoom, originX: ox, originY: oy },
    };
  }, [form, reloadTick]);

  // postMessage push to the iframe.
  //
  // Why we ALWAYS post (not just on change): the iframe's own data
  // fetch reads animated_clip_meta from /api/species/public/:id at
  // load time, but that snapshot can be stale relative to what the
  // user has just saved (slider drag, auto-fit) — and we have NO way
  // to know what the iframe ended up with. The parent IS the source
  // of truth: it just refetched the species record after the PUT.
  // So push the parent's known values into the iframe on every render
  // where framingValues is defined. The iframe's handler is set to
  // `transition: none` so applying the same values is a visual no-op,
  // and applying NEW values snaps instantly with no animation.
  //
  // Two reposts (immediate, 800ms) cover the case where the iframe is
  // still booting and hasn't registered its message listener yet —
  // common on first mount and on any framingKey-driven remount.
  useEffect(() => {
    if (!framingValues || !iframeRef.current) return;
    const post = () => {
      try {
        const w = iframeRef.current && iframeRef.current.contentWindow;
        if (!w) return;
        w.postMessage({
          type: 'aquaos:apply-clip-framing',
          zoom: framingValues.zoom,
          originX: framingValues.originX,
          originY: framingValues.originY,
        }, '*');
      } catch (_) { /* iframe sandbox / not ready */ }
    };
    post();
    const t = setTimeout(post, 800);
    return () => clearTimeout(t);
  }, [framingValues && framingValues.zoom, framingValues && framingValues.originX, framingValues && framingValues.originY, framingKey]);

  /* Only mount the iframe when we have a saved species id. New (unsaved)
     species can't be pinned because the display engine queries the DB. */
  const canPreview = !!form.id;
  const dims = SE_PREVIEW_DIMS[device] || SE_PREVIEW_DIMS.standard;

  /* Mirror the old site's iframe URL exactly. `t` busts the iframe's
     internal cache when we tap Refresh. */
  const previewSrc = canPreview
    ? `/display/PREVIEW-KIOSK?preview=kiosk&pinSpecies=${encodeURIComponent(form.id)}&t=${reloadTick}`
    : null;

  /* Recompute scale whenever the wrap resizes or the device changes.
     Scale = min(availW/iw, availH/ih, 1). Top-left transform-origin
     means the bottom/right margin needs negative-padding to collapse
     the un-rendered space — same trick as the old CMS. */
  useEffect(() => {
    const wrap = wrapRef.current;
    const frame = frameRef.current;
    if (!wrap || !frame) return;

    const recompute = () => {
      const cs = getComputedStyle(wrap);
      const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
      const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
      const availW = wrap.clientWidth - padX;
      const availH = wrap.clientHeight - padY;
      if (availW <= 0 || availH <= 0) return;
      const s = Math.max(0.1, Math.min(availW / dims.w, availH / dims.h, 1));
      setScale(s);
    };

    recompute();
    const ro = new ResizeObserver(recompute);
    ro.observe(wrap);
    window.addEventListener('resize', recompute);
    return () => {
      ro.disconnect();
      window.removeEventListener('resize', recompute);
    };
  }, [dims.w, dims.h]);

  return (
    <div className="se-preview" style={{ display: 'flex', flexDirection: 'column', background: 'transparent' }}>
      {/* Toolbar — device picker on the left, refresh + pop-out on the right.
          Transparent so it sits on the grey stage, not a darker strip. */}
      <div style={{
        display: 'flex', alignItems: 'center', gap: 8,
        padding: '8px 10px', flexShrink: 0,
        background: 'transparent',
      }}>
        <span style={{
          fontFamily: 'var(--aq-ff-mono)', fontSize: 10,
          letterSpacing: '0.08em', textTransform: 'uppercase',
          color: 'rgba(255,255,255,0.55)',
        }}>Live preview</span>
        {/* Segmented device picker — no track box; just the labels, the
            active one carried by a soft fill pill. */}
        <div style={{ marginLeft: 'auto', display: 'flex', gap: 2 }}>
          {Object.entries(SE_PREVIEW_DIMS).map(([id, d]) => (
            <button
              key={id}
              type="button"
              onClick={() => setDevice(id)}
              title={`${d.label} — ${d.w}×${d.h}`}
              style={{
                padding: '4px 10px', borderRadius: 6, border: 0,
                background: device === id ? 'rgba(255,255,255,0.10)' : 'transparent',
                color: device === id ? 'white' : 'rgba(255,255,255,0.55)',
                fontFamily: 'var(--aq-ff-mono)', fontSize: 10,
                letterSpacing: '0.06em', cursor: 'pointer',
                transition: 'background 120ms ease, color 120ms ease',
              }}
              onMouseEnter={(e) => { if (device !== id) e.currentTarget.style.color = 'rgba(255,255,255,0.85)'; }}
              onMouseLeave={(e) => { if (device !== id) e.currentTarget.style.color = 'rgba(255,255,255,0.55)'; }}
            >{d.label}</button>
          ))}
        </div>
        {/* Borderless ghost icon buttons — fill only appears on hover. */}
        {[
          { key: 'refresh', glyph: '↻', label: 'Refresh preview', onClick: () => setReloadTick((n) => n + 1), href: null },
          { key: 'popout', glyph: '↗', label: 'Open full preview in new tab', onClick: null, href: canPreview ? previewSrc : '#' },
        ].map((b) => {
          const common = {
            title: b.label,
            style: {
              display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
              padding: '5px 8px', borderRadius: 6, border: 0,
              background: 'transparent', color: 'rgba(255,255,255,0.6)',
              cursor: canPreview ? 'pointer' : 'not-allowed', opacity: canPreview ? 1 : 0.5,
              fontSize: 13, textDecoration: 'none',
            },
            onMouseEnter: (e) => { if (canPreview) { e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; e.currentTarget.style.color = 'white'; } },
            onMouseLeave: (e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'rgba(255,255,255,0.6)'; },
          };
          return b.href != null ? (
            <a key={b.key} href={b.href} target="_blank" rel="noopener noreferrer" {...common}>{b.glyph}</a>
          ) : (
            <button key={b.key} type="button" onClick={b.onClick} disabled={!canPreview} {...common}>{b.glyph}</button>
          );
        })}
      </div>

      {/* Preview wrap — scales the kiosk-sized iframe to fit available space */}
      <div
        ref={wrapRef}
        style={{
          flex: 1, minHeight: 0, padding: '20px 18px',
          display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
          overflow: 'hidden',
        }}
      >
        {canPreview ? (
          <div
            ref={frameRef}
            style={{
              width: dims.w,
              height: dims.h,
              transformOrigin: 'top left',
              transform: `scale(${scale})`,
              /* Negative margins collapse the un-rendered post-scale gap so
                 flexbox alignment still works as if the frame were ${w*scale}×${h*scale}. */
              marginRight:  ((1 - scale) * dims.w) * -1,
              marginBottom: ((1 - scale) * dims.h) * -1,
              position: 'relative',
              background: '#06090f',
              boxShadow: '0 12px 40px rgba(0,0,0,0.5)',
              borderRadius: 4,
              overflow: 'hidden',
            }}
          >
            <iframe
              key={framingKey /* remount when species or framing changes — see framingKey memo above */}
              ref={iframeRef}
              src={previewSrc}
              title={`Kiosk preview — ${form.common_name || 'species'}`}
              loading="lazy"
              allow="camera; microphone"
              style={{
                position: 'absolute', inset: 0,
                width: '100%', height: '100%', border: 0,
                background: '#06090f',
              }}
            />
          </div>
        ) : (
          <div style={{
            display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
            gap: 8, color: 'rgba(255,255,255,0.5)', textAlign: 'center', maxWidth: 320,
          }}>
            <Icon name="monitor" size={28} />
            <div style={{ fontFamily: 'var(--aq-ff-display)', fontSize: 14, color: 'rgba(255,255,255,0.8)' }}>
              Save the species to see live preview
            </div>
            <div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>
              The kiosk display engine pins the saved record. New species need an ID first.
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

/* ── Main editor ───────────────────────────────────────────── */

function SpeciesEditorScreen({ param, query }) {
  /* Where the curator came from. The hash router passes ?from=global
     when they clicked a card on #global-species. Cancel + 404 "Back"
     buttons honour this so the curator returns to the right surface.
     Feedback 50c320fc (Oli, 2026-05-13): "in global species if i
     click a species, when i press cancel in the species it takes me
     to species library, not global species". */
  const cameFromGlobal = !!(query && query.from === 'global');
  const backHref = cameFromGlobal ? '#global-species' : '#library';
  /* Review mode — the Global Species "Review N" button stores the ordered
     pending queue in sessionStorage and opens the first species here with
     ?review=1. Approve & next / Skip walk the queue using the FULL editor,
     so the curator can check everything before approving. */
  const reviewMode = !!(query && query.review);
  const reviewQueue = useMemo(() => {
    if (!reviewMode) return [];
    try { return JSON.parse(sessionStorage.getItem('aquaos.gs.reviewQueue') || '[]'); } catch (_) { return []; }
  }, [reviewMode]);
  const reviewIdx = reviewMode ? reviewQueue.indexOf(param) : -1;
  const reviewTotal = reviewQueue.length;
  function goToReviewNext() {
    const next = reviewIdx >= 0 ? reviewQueue[reviewIdx + 1] : null;
    if (next) {
      window.location.hash = `#species/${next}?from=global&review=1`;
    } else {
      try { sessionStorage.removeItem('aquaos.gs.reviewQueue'); } catch (_) {}
      window.location.hash = '#global-species';
    }
  }
  function exitReview() {
    try { sessionStorage.removeItem('aquaos.gs.reviewQueue'); } catch (_) {}
    window.location.hash = '#global-species';
  }
  /* `param === 'new'` means "create a new species". Skip the GET, mount
     an empty form, and POST instead of PUT on save. The original CMS
     opened a WoRMS/FishBase lookup modal for this — that's deferred;
     curators can fill the form by hand for now (a follow-up will wire
     /api/lookup/search to autofill the form on pick). */
  const isNew = param === 'new';
  const [data, setData] = useState(null);
  const [form, setForm] = useState(null);
  const [tab, setTab] = useState('identity');
  const [loading, setLoading] = useState(!isNew);
  const [error, setError] = useState(null);
  const [saving, setSaving] = useState(false);
  const [savedAt, setSavedAt] = useState(null);

  useEffect(() => {
    if (!param) return;
    if (isNew) {
      /* Empty form scaffold — every field the editor reads needs a
         default so React's controlled inputs don't flip to uncontrolled
         on save. id is empty until the POST returns one. */
      const blank = {
        id: '',
        common_name: '',
        scientific_name: '',
        genus: '',
        family: '',
        iucn_status: 'Not Evaluated',
        habitat: '',
        description_general: '',
        description_kids: '',
        description_expert: '',
        fun_fact: '',
        kid_fact: '',
        conservation_note: '',
        is_verified: 0,
        media: [],
      };
      setForm(blank);
      setData(blank);
      setLoading(false);
      return;
    }
    let cancelled = false;
    setLoading(true);
    apiFetch(`/api/species/${encodeURIComponent(param)}`)
      .then((res) => {
        if (cancelled) return;
        setData(res);
        setForm({ ...res });
        setLoading(false);
      })
      .catch((err) => {
        if (cancelled) return;
        setError(err.message);
        setLoading(false);
      });
    return () => { cancelled = true; };
  }, [param, isNew]);

  function set(key, value) {
    setForm((f) => ({ ...f, [key]: value }));
  }

  /* Re-fetch the species record from the server. Used by media upload,
     distribution refresh, animation/voiceover regenerate — anything
     that mutates the record server-side and needs the local form +
     preview kept in sync. Preserves the user's in-progress edits to
     non-server-derived fields (descriptions, fun fact, etc.) by
     spreading the new data UNDER the current form, then re-applying
     fields the curator has touched. */
  const dirtyKeysRef = useRef(new Set());
  async function refresh() {
    if (!form || !form.id) return;
    try {
      const res = await apiFetch(`/api/species/${form.id}`);
      setData(res);
      setForm((cur) => {
        const next = { ...res };
        /* Re-apply user's in-progress edits so a media upload doesn't
           wipe an unsaved description tweak. */
        dirtyKeysRef.current.forEach((k) => {
          if (cur && cur[k] !== undefined) next[k] = cur[k];
        });
        return next;
      });
    } catch (err) {
      setError(err.message);
    }
  }

  async function save(approveAfter = false) {
    if (!form) return;
    setSaving(true);
    try {
      const payload = {};
      // Audit May 2026: previous fields list only persisted 12 columns,
      // so edits to distribution_label, focus point, expert_detail,
      // accent_color, gradient, etc. were silently lost on save. The
      // old app's ceSubmit (cms-original ~L14592) sent the full
      // descriptive + display field set. Match that here so every
      // field exposed in the editor actually round-trips.
      const fields = [
        'common_name', 'scientific_name',
        'kingdom', 'phylum', 'class', 'order', 'family', 'genus',
        'iucn_status', 'iucn_color',
        'max_size', 'typical_size', 'weight', 'lifespan',
        'habitat', 'depth_range', 'temperature_range', 'distribution',
        'diet', 'diet_type', 'social_behaviour', 'activity_pattern',
        'description_general', 'description_kids', 'description_expert',
        'fun_fact', 'kid_fact', 'conservation_note', 'expert_detail',
        'accent_color', 'gradient', 'primary_image_url',
        'distribution_label', 'focus_lng', 'focus_lat',
        // Lifecycle + curator notes — old app exposed these in the
        // species modal (see cms-original) and they're respected by
        // the kiosk + screen rotation logic.
        'lifecycle_status', 'individual_name',
        // Scope / publish_status — only org_admin / aquaos_admin can
        // change scope (backend enforces); platform admin's reject UX
        // already calls dedicated /reject endpoint, but allowing the
        // editor to send an explicit scope on save matches old app
        // behaviour and makes "save as draft" possible.
        'scope', 'publish_status',
      ];
      fields.forEach((k) => { if (form[k] != null && form[k] !== '') payload[k] = form[k]; });

      if (isNew || !form.id) {
        /* Create. Backend requires common_name + scientific_name (per
           speciesCreateSchema) — bail with a friendly message rather
           than a 400 round-trip. */
        if (!payload.common_name || !payload.scientific_name) {
          setError('Common name and scientific name are required to create a species.');
          setSaving(false);
          return;
        }
        const created = await apiFetch('/api/species', {
          method: 'POST',
          body: JSON.stringify(payload),
        });
        setSavedAt(new Date());
        /* Hop to the saved record's URL so subsequent saves PUT and the
           preview iframe (which needs an id to pin) wakes up. */
        if (created && created.id) {
          window.location.hash = `#species/${created.id}`;
        }
      } else {
        const updated = await apiFetch(`/api/species/${form.id}`, {
          method: 'PUT',
          body: JSON.stringify(payload),
        });
        setSavedAt(new Date());
        /* Copy-on-write: the backend forks a global species into a fresh
           local copy on edit and returns its NEW id. Follow it so the
           curator lands on — and keeps editing — the record their changes
           were actually saved to. Without this the edits "vanish" because
           the editor stayed pinned to the unchanged source id. */
        if (updated && updated.id && updated.id !== form.id) {
          window.location.hash = `#species/${updated.id}`;
          return;
        }
        /* Sync the form to the persisted row so server-normalised values
           show, and the next save diffs cleanly. Preserve media (the PUT
           response doesn't join it). */
        if (updated && updated.id) {
          setData(updated);
          setForm((cur) => ({ ...cur, ...updated, media: (cur && cur.media) || updated.media }));
        }
        /* Reload the live preview so the just-saved edits render
           immediately — the iframe reads from the backend, so it only
           reflects changes after a save + reload. */
        try {
          window.dispatchEvent(new CustomEvent('aquaos:species-preview-refresh', {
            detail: { speciesId: (updated && updated.id) || form.id },
          }));
        } catch (_) {}
        /* "Save & approve" — persist edits first, then move the record
           from Pending review to the All species list. Approve is only
           reachable through this path now (the standalone header button
           was removed), so a curator can't approve stale, unsaved copy. */
        if (approveAfter && form.id) {
          try {
            await apiFetch(`/api/species/${form.id}/approve`, { method: 'POST' });
            // In review mode, jump straight to the next pending species.
            if (reviewMode) { goToReviewNext(); return; }
            await refresh();
          } catch (err) {
            setError('Saved, but approve failed: ' + err.message);
          }
        }
      }
    } catch (err) {
      setError(err.message);
    } finally {
      setSaving(false);
    }
  }

  if (loading) {
    return (
      <div style={{ flex: 1, display: 'grid', placeItems: 'center', color: 'var(--aq-text-faint)' }}>
        Loading species…
      </div>
    );
  }

  if (error || !form) {
    return (
      <div style={{ flex: 1, padding: 32 }}>
        <div className="x-err-stage">
          <div className="x-err-card">
            <div className="x-err-code">Species not found</div>
            <div className="x-err-glyph is-warn"><Icon name="alert" size={28} /></div>
            <h1 className="x-err-headline">{param || '—'}</h1>
            <p className="x-err-body">{error || 'No species with this ID.'}</p>
            <div className="x-err-actions">
              <button className="x-btn ghost" onClick={() => { window.location.hash = backHref; }}>
                {cameFromGlobal ? 'Back to global species' : 'Back to library'}
              </button>
            </div>
          </div>
        </div>
      </div>
    );
  }

  /* Read-only gating for global species (May 2026).

     A curator editing a global species used to trigger the backend's
     auto-fork branch in PUT /api/species/:id (server-side copy-on-write
     into their org). That fork is currently broken on Postgres —
     "invalid input syntax for type json" when one of the JSON columns
     round-trips through the INSERT. Rather than paper over that with a
     fix, the correct UX is to not let an org curator silently mutate
     a record owned by every other tenant: the editor is read-only,
     and a clear Duplicate button creates a local copy they can edit.

     Platform admins (aquaos_admin) stay fully editable on globals —
     they're the canonical editors of the shared catalogue. */
  const isAquaOSAdmin   = !!(Auth.isAquaOSAdmin && Auth.isAquaOSAdmin());
  const isGlobalSpecies = !form.organisation_id;
  const readOnly        = !isNew && isGlobalSpecies && !isAquaOSAdmin;

  /* Duplicate the current species via the dedicated server-side fork
     endpoint. Earlier iteration did this client-side (GET source then
     POST the payload) but that silently dropped distribution_points,
     migration_path, animated_clip_*, voiceover_*, and other "heavy"
     fields because they're not in POST /api/species's allowedFields
     whitelist. The fork endpoint handles all of that server-side: it
     copies every species column including JSONB blobs, copies
     species_media rows (so photos stay visible until the curator
     swaps them), and copies species_translations rows. */
  async function duplicateAsLocal() {
    if (!form.id) return;
    setSaving(true);
    try {
      const created = await apiFetch(`/api/species/${encodeURIComponent(form.id)}/fork`, {
        method: 'POST',
        body: JSON.stringify({}),
      });
      if (created && created.id) {
        window.location.hash = `#species/${created.id}`;
      }
    } catch (e) {
      alert(`Couldn't create local copy: ${e.message}`);
    } finally {
      setSaving(false);
    }
  }

  return (
    <div className="se-screen" style={{ flex: 1, minHeight: 0 }}>
      <SpeciesPreview form={form} />
      <aside className="se-panel">
        <div className="se-shell">
          <header className="se-header" style={{ position: 'relative' }}>
            <div className="se-header-title">
              <h2>{isNew ? 'New species' : 'Edit species'}</h2>
              <div className="se-header-meta">
                {/* Status dot + label only when it needs attention —
                    pending review or an unsaved draft. Approved is the
                    expected state, so it's left implicit (no dot/text). */}
                {!form.is_verified && (
                  <>
                    <span className="se-status-dot is-warn" />
                    <span>{isNew ? 'Draft — unsaved' : 'Pending review'}</span>
                    <span className="se-sep">·</span>
                  </>
                )}
                <span>
                  {savedAt
                    ? `Saved ${savedAt.toLocaleTimeString()}`
                    : (form.id ? `ID ${form.id.slice(0, 8)}` : 'No ID yet')}
                </span>
              </div>
            </div>
            <div style={{ display: 'flex', gap: 8, alignItems: 'center', position: 'relative' }}>
              {/* Approve is reachable only via the footer "Save & approve"
                  button (so edits can't be approved while stale). Reject and
                  the ingestion-issues drawer were removed May 2026 — curators
                  delete bad rows from the list directly, and the issues
                  counter surfaced pipeline noise they couldn't act on. */}
              <SpeciesMoreMenu form={form} isNew={isNew} backHref={backHref} />
            </div>
          </header>

          {/* Read-only banner — global species + non-platform-admin
              curator. Explains why the form's locked and offers the
              one-click duplicate path. Sits above the tabs so it's
              the first thing the curator sees when they land on the
              editor. */}
          {readOnly && (
            <div
              style={{
                margin: '0 0 12px',
                padding: '10px 14px',
                background: 'rgba(255,255,255,0.04)',
                border: '1px solid rgba(255,255,255,0.12)',
                borderRadius: 8,
                display: 'flex',
                alignItems: 'center',
                gap: 12,
                fontSize: 12.5,
                color: 'var(--aq-text)',
              }}
            >
              <Icon name="info" size={14} />
              <div style={{ flex: 1, lineHeight: 1.4 }}>
                <strong style={{ fontWeight: 600 }}>Global species — read only.</strong>{' '}
                <span style={{ color: 'var(--aq-text-dim)' }}>Duplicate to edit.</span>
              </div>
              <button
                className="se-btn is-primary"
                onClick={duplicateAsLocal}
                disabled={saving}
                style={{ flex: '0 0 auto' }}
              >{saving ? 'Duplicating…' : 'Duplicate'}</button>
            </div>
          )}

          {/* Filter out platformOnly tabs for non-aquaos_admin users. Their
              backing pipelines (fal video / ElevenLabs / GBIF occurrence
              fetch) are billable + platform-managed, so site/org/operator
              accounts shouldn't see them at all. */}
          {(() => {
            const isPlatformAdmin = !!(Auth && Auth.isAquaOSAdmin && Auth.isAquaOSAdmin());
            const visibleTabs = SE_TABS.filter((t) => {
              // Voiceover is available to org users on species they OWN
              // (so they can add voiceovers to their own / duplicated
              // records). On a read-only global species it shows but stays
              // greyed + locked like every other tab — edits to a global
              // record must go through Duplicate first.
              if (t.id === 'voiceover') return true;
              return !t.platformOnly || isPlatformAdmin;
            });
            // Defensive: if the current tab got filtered out (e.g. role
            // change mid-session), fall back to identity.
            const activeTab = visibleTabs.some((t) => t.id === tab) ? tab : 'identity';
            // Read-only greys out + locks the WHOLE record, voiceover
            // included — nothing on a global species is editable in place.
            const bodyDisabled = readOnly;
            const bodyDim = readOnly ? 0.45 : null;
            return (
              <>
                {/* Tabs themselves stay clickable even when readOnly,
                    so the curator can browse Description / Media etc.
                    The fieldset wraps only the body sections so each
                    section's inputs + action buttons go inert. */}
                <nav className="se-tabs">
                  {visibleTabs.map((t) => (
                    <button
                      key={t.id}
                      className={`se-tab ${activeTab === t.id ? 'is-active' : ''}`}
                      onClick={() => setTab(t.id)}
                    >
                      {t.label}
                    </button>
                  ))}
                </nav>

                {/* fieldset[disabled] propagates the disabled state to
                    every native input/textarea/select/button inside,
                    so a single attribute makes the whole body
                    non-interactive without per-component plumbing.
                    `display: contents` keeps the fieldset out of the
                    layout flow so it doesn't break the scroll container. */}
                <fieldset
                  disabled={bodyDisabled}
                  style={{ display: 'contents', border: 0, padding: 0, margin: 0, minInlineSize: 0 }}
                >
                  <div
                    className="se-body se-body-scroll"
                    style={bodyDim != null ? { opacity: bodyDim, transition: 'opacity 120ms ease' } : undefined}
                  >
                    {activeTab === 'identity' && (
                      <section className="se-section is-flush">
                        <IdentitySection form={form} set={set} onRefreshDistribution={refresh} isPlatformAdmin={isPlatformAdmin} />
                      </section>
                    )}
                    {activeTab === 'media' && (
                      <section className="se-section is-flush">
                        <MediaSection form={form} onMediaChanged={refresh} />
                      </section>
                    )}
                    {activeTab === 'animation' && isPlatformAdmin && (
                      <section className="se-section is-flush">
                        <AnimationSection form={form} onChanged={refresh} />
                      </section>
                    )}
                    {activeTab === 'voiceover' && (
                      <section className="se-section is-flush">
                        {/* restricted = non-platform (org) users: script +
                            Generate only, no provider/voice/model picking.
                            When the species is read-only the whole section
                            is greyed + locked by the fieldset above. */}
                        <VoiceoverSection form={form} onChanged={refresh} restricted={!isPlatformAdmin} />
                      </section>
                    )}
                    {activeTab === 'description' && (
                      <section className="se-section is-flush">
                        <DescriptionSection form={form} set={set} />
                      </section>
                    )}
                  </div>
                </fieldset>
              </>
            );
          })()}

          <footer className="se-footer">
            {reviewMode ? (
              <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                <button className="se-btn is-ghost" onClick={exitReview} title="Exit review">Exit</button>
                {reviewTotal > 0 && (
                  <span style={{ fontSize: 11, color: 'var(--aq-text-faint)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>
                    {reviewIdx >= 0 ? reviewIdx + 1 : '—'} / {reviewTotal}
                  </span>
                )}
              </div>
            ) : (
              <button
                className="se-btn is-ghost"
                onClick={() => { window.location.hash = backHref; }}
              >Cancel</button>
            )}
            <div className="se-footer-actions">
              {/* When the editor is read-only (global species, non-platform
                  curator), the Save / Save & approve buttons are
                  replaced with the same Duplicate action surfaced in
                  the banner. Curator's only forward action is to make
                  a local copy. */}
              {readOnly ? (
                <button
                  className="se-btn is-primary"
                  onClick={duplicateAsLocal}
                  disabled={saving}
                >{saving ? 'Duplicating…' : 'Duplicate'}</button>
              ) : (
                <>
                  {/* In review mode, Skip jumps to the next pending species
                      without approving. */}
                  {reviewMode && (
                    <button
                      className="se-btn is-ghost"
                      onClick={goToReviewNext}
                      disabled={saving}
                      title="Skip without approving — move to the next pending species"
                    >Skip</button>
                  )}
                  {!isNew && (
                    <button
                      className="se-btn"
                      onClick={() => save(true)}
                      disabled={saving}
                      title={reviewMode
                        ? 'Save edits, approve, and move to the next pending species'
                        : 'Save edits and move from Pending review to the All species list'}
                    >{reviewMode ? 'Approve & next' : 'Save & approve'}</button>
                  )}
                  <button
                    className="se-btn is-primary"
                    onClick={() => save()}
                    disabled={saving}
                  >{saving
                    ? 'Saving…'
                    : (isNew ? 'Create species' : 'Save changes')}
                  </button>
                </>
              )}
            </div>
          </footer>
        </div>
      </aside>
    </div>
  );
}

window.SpeciesEditorScreen = SpeciesEditorScreen;
