/* Global Species Database — platform-admin only.
   Restores the original CMS page (cms-original/index.html L9609–9665)
   that listed every species across the fleet. Uses the same card grid
   chrome as the org-level Species Library (aq-species-grid +
   aq-species-card) so the visual language stays consistent — only the
   data scope (every org vs one org) and the row actions (platform-
   level approve/reject) differ.
   Backed by /api/species which already returns every org's rows
   plus an org_name join when the caller is aquaos_admin. */

// Tab order matters — first tab is the default landing view. Putting
// "Global" first matches the page's primary purpose (managing the
// platform-wide catalogue) so a platform admin lands on the curated
// list rather than the noisier "All" set. "All" stays as a no-filter
// escape hatch.
const GS_SCOPE_TABS = [
  { id: 'global',   label: 'Global' },       /* scope = 'global' — default */
  { id: 'all',      label: 'All' },
  { id: 'pending',  label: 'Pending review' }, /* scope = 'pending_global' */
  { id: 'local',    label: 'Local' },        /* org-submitted, NOT in global db (bug ac0b7c95) */
];

const GS_IUCN_TONE = {
  'Least Concern': 'success',
  'Near Threatened': 'warn',
  'Vulnerable': 'warn',
  'Endangered': 'danger',
  'Critically Endangered': 'danger',
  'Extinct in the Wild': 'danger',
  'Extinct': 'danger',
  'Data Deficient': 'neutral',
  'Not Evaluated': 'neutral',
};

function GS_hue(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;
}
function GS_originCode(sp) {
  const g = (sp.genus || sp.scientific_name || sp.id || '').toUpperCase().replace(/[^A-Z]/g, '');
  return `${(g || 'XXX').slice(0, 5)} / ${(sp.id || '').slice(0, 2).toUpperCase()}`;
}

/* Content-completeness check. Returns the list of content types a species
   is still missing, newest-curator-priority order. Animation comes from
   the card payload directly (animated_clip_status); voiceover /
   distribution / descriptions ride on lightweight presence booleans the
   /api/species card query computes (has_voiceover, has_distribution,
   has_descriptions — SQLite returns 1/0, Postgres true/false, both
   truthy-safe). Empty array = fully populated. Kept module-level so the
   card dot and the toolbar "Incomplete" filter read the exact same rule. */
function GS_missingContent(sp) {
  const missing = [];
  if (!(sp.animated_clip_url && sp.animated_clip_status === 'ready')) missing.push('Animation');
  if (!sp.has_voiceover) missing.push('Voiceover');
  if (!sp.has_distribution) missing.push('Distribution');
  if (!sp.has_descriptions) missing.push('Descriptions');
  return missing;
}

/* Per-card row for the platform grid. Reuses the same `.aq-species-*`
   chrome the local Species Library uses; the only platform-specific
   bits are the org-name strip + the inline Approve/Reject overlay
   for pending rows. */
function GS_Card({ sp, onChanged }) {
  /* Pending = explicit pending_global OR the schema-default
     scope='global' rows that aren't yet approved and have no org
     owner (see GlobalSpeciesScreen.isPendingGlobal for the full
     rationale). Approved = scope='global' AND is_verified. */
  const isPending = (sp.scope === 'pending_global')
    || (sp.scope === 'global' && !sp.is_verified && !sp.organisation_id);
  const missing = GS_missingContent(sp);
  const hue = GS_hue(sp.scientific_name || sp.common_name);
  const [busy, setBusy] = useState(null);
  // Track image-load failure so we can swap in the gradient
  // placeholder when the species's primary_image_url / thumb_url
  // points at a broken / missing resource. Previously onError just
  // hid the <img> and left an empty card — see the screenshot in
  // bug report about Banggai Cardinalfish + Bluestreak Cleaner
  // Wrasse rendering as blank tiles.
  const [imgFailed, setImgFailed] = useState(false);
  /* cms_thumb_url is the 4:3 landscape variant generated post-bg by
     /api/species/:id/media/:mediaId/make-cms-thumb. See species.jsx
     for the long comment. */
  const thumbSrc = sp.cms_thumb_url || sp.thumb_url || sp.primary_image_url;
  /* Per-image fit — see species.jsx for the long explanation.
     Landscape photos (fish) get `cover` so they fill the thumb;
     portrait photos (penguins, polar bears) get `contain` so heads
     aren't cropped. Threshold sits just below the thumb's own
     aspect ratio with a little slack. */
  const [thumbAspect, setThumbAspect] = useState(null);
  /* Animated clips play on hover only (not autoplay-loop) — keeps the
     grid calm and lets the admin scrub a card by mousing over it. */
  const hasClip = !!sp.animated_clip_url && sp.animated_clip_status === 'ready';
  /* Fit is driven by the STILL thumbnail's aspect, never the clip's.
     AI clips are often square, so basing the fit on the video made
     landscape subjects flip from `cover` (filling) to `contain`
     (letterboxed) once the clip loaded — the visible "zoom out" on
     play. Animated cards default to `cover` so they fill from the
     first frame; portrait stills still resolve to `contain`. */
  // Animated cards ALWAYS fill the card — never let a square/portrait
  // still flip them to `contain`, which letterboxes the clip and makes
  // the subject read as tiny (this is why the fur seal looked small while
  // landscape-still species looked fine). Only still-image cards pick
  // their fit from aspect: landscape covers, portrait contains.
  const thumbFit = hasClip
    ? 'cover'
    : (thumbAspect == null ? 'contain' : (thumbAspect >= 1.25 ? 'cover' : 'contain'));
  /* For animated cards, learn the aspect from the poster image (the
     thumbnail), not the <video>, so the still and the clip share one
     stable fit. */
  useEffect(() => {
    if (!hasClip || !thumbSrc) return;
    let cancelled = false;
    const probe = new Image();
    probe.onload = () => {
      if (cancelled || !probe.naturalWidth || !probe.naturalHeight) return;
      setThumbAspect(probe.naturalWidth / probe.naturalHeight);
    };
    probe.src = thumbSrc;
    return () => { cancelled = true; };
  }, [hasClip, thumbSrc]);
  const videoRef = useRef(null);
  function playClip() {
    const v = videoRef.current;
    if (v) { try { v.play(); } catch (_) {} }
  }
  function stopClip() {
    const v = videoRef.current;
    if (v) { try { v.pause(); v.currentTime = 0; } catch (_) {} }
  }

  async function approve() {
    setBusy('approve');
    try {
      await apiFetch(`/api/species/${sp.id}/approve`, {
        method: 'POST',
        body: JSON.stringify({ scope: 'global' }),
      });
      if (onChanged) onChanged();
    } catch (err) {
      window.alert(`Approve failed: ${err.message}`);
    } finally { setBusy(null); }
  }
  async function reject() {
    const note = window.prompt('Reason for rejection (sent back to the requesting org):');
    if (note === null) return;
    setBusy('reject');
    try {
      await apiFetch(`/api/species/${sp.id}/reject`, {
        method: 'POST',
        body: JSON.stringify({ reason: note || '' }),
      });
      if (onChanged) onChanged();
    } catch (err) {
      window.alert(`Reject failed: ${err.message}`);
    } finally { setBusy(null); }
  }

  return (
    <article
      className="aq-species-card"
      onMouseEnter={hasClip ? playClip : undefined}
      onMouseLeave={hasClip ? stopClip : undefined}
      onClick={(e) => {
        if (e.target.closest('[data-row-action]')) return;
        /* Carry a from=global flag so the species editor's breadcrumb
           points back to Global Species instead of the regular
           Species Library. Bug filed 2026-05-12 by Oli (feedback id
           ea70888e-…): "inside global species if i click a species
           it takes me to the species library, … it shouldn't take me
           away from global species management". */
        window.location.hash = `#species/${sp.id}?from=global`;
      }}
      style={{ cursor: 'pointer', position: 'relative' }}
    >
      <div className="aq-species-thumb">
        {hasClip ? (
          <video
            ref={videoRef}
            className="aq-species-thumb-art"
            src={sp.animated_clip_url}
            loop muted playsInline preload="auto"
            /* No poster: the clip's own first frame IS the resting
               still, so hovering plays from the exact same framing —
               no scale jump between still and motion. A tiny seek
               forces the first frame to paint on browsers that would
               otherwise show black until play. */
            onLoadedMetadata={(e) => { try { e.target.currentTime = 0.001; } catch (_) {} }}
            style={{ width: '100%', height: '100%', objectFit: thumbFit, display: 'block' }}
          />
        ) : thumbSrc && !imgFailed ? (
          <img
            className="aq-species-thumb-art"
            src={thumbSrc}
            alt={sp.common_name || ''}
            /* objectFit is per-image — see thumbFit comment above. */
            style={{ width: '100%', height: '100%', objectFit: thumbFit, display: 'block' }}
            onLoad={(e) => {
              const img = e.target;
              if (img.naturalWidth && img.naturalHeight) {
                setThumbAspect(img.naturalWidth / img.naturalHeight);
              }
            }}
            onError={() => setImgFailed(true)}
          />
        ) : (
          <div className="aq-species-thumb-art" style={{ '--sp-hue': hue }}>
            <span className="aq-species-thumb-mono">{GS_originCode(sp)}</span>
          </div>
        )}
        <div className="aq-species-badges">
          {isPending && (
            <span className="aq-pill is-warn">
              <span className="aq-pill-dot" />Pending review
            </span>
          )}
        </div>
        {/* Incomplete-content marker — a single subtle amber dot in the
            opposite (top-right) corner from the pending badge. Hover (or
            focus) reveals exactly what's missing via the native tooltip,
            so the resting state stays low-chrome. Hidden entirely when the
            species is fully populated. */}
        {missing.length > 0 && (
          <span
            className="aq-species-incomplete"
            title={`Missing: ${missing.join(', ')}`}
            aria-label={`Missing content: ${missing.join(', ')}`}
            style={{
              position: 'absolute', top: 8, right: 8, zIndex: 3,
              width: 9, height: 9, borderRadius: '50%',
              background: 'var(--aq-warn)',
              boxShadow: '0 0 0 3px color-mix(in srgb, var(--aq-warn) 20%, transparent)',
              cursor: 'help',
            }}
          />
        )}
      </div>
      <div className="aq-species-body">
        {/* Centered caption — sits on the subject's vertical axis
            since the artwork is centered in the thumb. */}
        <div className="aq-species-head" style={{ justifyContent: 'center', textAlign: 'center' }}>
          <div>
            <div className="aq-species-name">{sp.common_name || sp.scientific_name}</div>
            <div className="aq-species-latin">{sp.scientific_name}</div>
          </div>
        </div>

        {/* Pending-only inline actions — Approve / Reject the global
            promotion. Stops click bubbling so the card-click navigate
            doesn't fire. */}
        {isPending && (
          <div data-row-action style={{ display: 'flex', gap: 6, marginTop: 10 }}>
            <button
              onClick={(e) => { e.stopPropagation(); approve(); }}
              disabled={busy != null}
              style={{
                flex: 1, padding: '6px 10px', fontSize: 12, fontFamily: 'inherit',
                background: 'color-mix(in srgb, var(--aq-success) 18%, transparent)',
                border: '1px solid color-mix(in srgb, var(--aq-success) 40%, transparent)',
                color: 'var(--aq-success)', borderRadius: 6, cursor: 'pointer',
              }}
            >{busy === 'approve' ? 'Approving…' : 'Approve global'}</button>
            <button
              onClick={(e) => { e.stopPropagation(); reject(); }}
              disabled={busy != null}
              style={{
                padding: '6px 10px', fontSize: 12, fontFamily: 'inherit',
                background: 'transparent',
                border: '1px solid var(--aq-line)',
                color: 'var(--aq-danger)', borderRadius: 6, cursor: 'pointer',
              }}
            >{busy === 'reject' ? 'Rejecting…' : 'Reject'}</button>
          </div>
        )}
      </div>
    </article>
  );
}

function GlobalSpeciesScreen() {
  const [species, setSpecies] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  // Default to the Global tab — the page's primary working surface for
  // platform admins. Was 'all' which buried curated rows under noise.
  const [scope, setScope] = useState('global');
  const [query, setQuery] = useState('');
  /* "Incomplete only" — ANDs with the active scope tab so the admin can
     drill into just the species still missing content to fix them. */
  const [incompleteOnly, setIncompleteOnly] = useState(false);
  const [reload, setReload] = useState(0);
  const [searchFocused, setSearchFocused] = useState(false);
  /* SpeciesLookupModal lives in species.jsx (window-exported). We
     reuse it here instead of routing the curator into the bare
     species editor — feedback 9a1af1ba ("add species now goes to the
     edit screen, must be something you broke here"). When created
     from this entry point, every new row is tagged scope='pending_global'
     so it lands in the platform catalogue's Pending tab. */
  const [lookupOpen, setLookupOpen] = useState(false);
  /* Bulk-by-name paste flow. Shares the org library's BulkAddModal but
     tags every row scope='pending_global' so the additions land in the
     platform Pending-review tab. */
  const [bulkOpen, setBulkOpen] = useState(false);

  /* Defensive role check — sidebar already hides the entry but a
     direct hash navigation could land here. */
  if (!Auth.canSeePlatform()) {
    return (
      <div className="aq-content">
        <div className="aq-content-inner" style={{ maxWidth: 720 }}>
          <div className="x-err-stage" style={{ minHeight: 280 }}>
            <div className="x-err-card">
              <div className="x-err-code">Platform admin only</div>
              <div className="x-err-glyph is-warn"><Icon name="alert" size={28} /></div>
              <h1 className="x-err-headline">Restricted</h1>
              <p className="x-err-body">The global species catalogue is managed by aquaos_admin only.</p>
            </div>
          </div>
        </div>
      </div>
    );
  }

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    apiFetch('/api/species?limit=500')
      .then((r) => {
        if (cancelled) return;
        setSpecies(r.species || []);
        setLoading(false);
      })
      .catch((err) => {
        if (cancelled) return;
        setError(err.message);
        setLoading(false);
      });
    return () => { cancelled = true; };
  }, [reload]);

  /* Live add-queue phantoms (Jun 2026 fix).
     SUPERSEDES the old note here that claimed this page "never enqueues
     to the global queue". That stopped being true when SpeciesLookupModal
     moved its pipeline into window.AquaOSAddQueue (so adds survive leaving
     the page). Since then, adding from here ran entirely in the background
     with NO on-page feedback — the curator clicked Add and saw nothing
     until a manual refresh. We now subscribe, exactly like #species, but
     filter to source==='global' so org-library adds never bleed in (and
     ours never bleed into the org library). Requires the source tag the
     queue now preserves in buildEntry. */
  const [queueItems, setQueueItems] = useState(() =>
    (window.AquaOSAddQueue ? (window.AquaOSAddQueue.getState().items || []) : [])
  );
  const seenDoneRef = React.useRef(new Set());
  useEffect(() => {
    if (!window.AquaOSAddQueue) return undefined;
    const unsub = window.AquaOSAddQueue.subscribe((state) => {
      const list = (state && state.items) || [];
      setQueueItems(list);
      /* Refetch once per item the moment its save lands a real id, so the
         persisted pending_global card replaces the phantom. */
      let landed = false;
      list.forEach((it) => {
        if (it.source === 'global' && it.speciesId && !seenDoneRef.current.has(it.key)) {
          seenDoneRef.current.add(it.key);
          landed = true;
        }
      });
      if (landed) setReload((n) => n + 1);
    });
    return unsub;
  }, []);

  /* In-flight global adds that have no real DB row yet → render as
     phantom "processing" cards at the top of the grid. Terminal states
     (created-with-id, duplicate, error) drop out — created rows arrive
     via the refetch above; duplicates/errors surface in the queue pill. */
  const phantomEntries = useMemo(() => (
    queueItems.filter((it) => (
      it.source === 'global'
      && !it.speciesId
      && it.status !== 'duplicate'
      && it.status !== 'error'
    ))
  ), [queueItems]);

  /* Local = species submitted by an org/site, NOT promoted to the
     global catalogue. Useful for the platform admin to spot gaps in
     the global DB (org X has it, you should add it globally). */
  function isLocal(s) {
    return s.scope !== 'global' && s.scope !== 'pending_global' && !!(s.organisation_id || s.org_name);
  }
  /* "Pending platform review" combines two cases the platform admin
     needs to handle:
       • Explicit pending_global rows (added via the new pair-by-code
         global-species lookup flow).
       • scope='global' AND !is_verified AND !organisation_id rows —
         the leaked-into-library case from Oli's 2026-05-13 report.
         The lookup modal's POST landed scope='global' (the schema
         default) without an explicit pending state; they're really
         platform-pending, just mislabelled.
     "Approved global" is the strict positive: scope='global' AND
     is_verified. */
  function isPendingGlobal(s) {
    if (s.scope === 'pending_global') return true;
    if (s.scope === 'global' && !s.is_verified && !s.organisation_id) return true;
    return false;
  }
  function isApprovedGlobal(s) {
    return s.scope === 'global' && !!s.is_verified;
  }
  const counts = useMemo(() => {
    const c = { all: species.length, global: 0, pending: 0, local: 0 };
    species.forEach((s) => {
      if (isApprovedGlobal(s)) c.global++;
      else if (isPendingGlobal(s)) c.pending++;
      if (isLocal(s)) c.local++;
    });
    return c;
  }, [species]);

  const filtered = useMemo(() => {
    let list = species;
    if (scope === 'global') list = list.filter(isApprovedGlobal);
    else if (scope === 'pending') list = list.filter(isPendingGlobal);
    else if (scope === 'local')   list = list.filter(isLocal);

    if (query) {
      const q = query.toLowerCase();
      list = list.filter((s) =>
        (s.common_name || '').toLowerCase().includes(q) ||
        (s.scientific_name || '').toLowerCase().includes(q) ||
        (s.org_name || '').toLowerCase().includes(q)
      );
    }
    if (incompleteOnly) {
      list = list.filter((s) => GS_missingContent(s).length > 0);
    }
    /* Pending tab is naturally chronological — show newest first so a
       just-added species sits at the top while its pipeline runs. The
       other tabs keep API order (no sort flag is currently exposed on
       this page). */
    if (scope === 'pending') {
      list = [...list].sort((a, b) => {
        const at = new Date(a.created_at || 0).getTime();
        const bt = new Date(b.created_at || 0).getTime();
        return bt - at;
      });
    }
    return list;
  }, [species, scope, query, incompleteOnly]);

  /* How many species in the CURRENT scope are missing content — drives
     the toggle's count so the admin sees the size of the backlog before
     clicking in. Mirrors `filtered` minus the incompleteOnly step. */
  const incompleteCount = useMemo(() => {
    let list = species;
    if (scope === 'global') list = list.filter(isApprovedGlobal);
    else if (scope === 'pending') list = list.filter(isPendingGlobal);
    else if (scope === 'local') list = list.filter(isLocal);
    return list.reduce((n, s) => n + (GS_missingContent(s).length > 0 ? 1 : 0), 0);
  }, [species, scope]);

  function refresh() { setReload((n) => n + 1); }

  return (
    <div className="aq-content">
      <div className="aq-content-inner" style={{ maxWidth: 1400 }}>
        <section className="aq-hero">
          <div style={{ display: 'flex', alignItems: 'flex-end', gap: 12, justifyContent: 'space-between', flexWrap: 'wrap' }}>
            <div>
              <h1 style={{ marginTop: 12, fontWeight: 500 }}>Global Species</h1>
            </div>
            {/* Add species — opens the same WoRMS/FishBase lookup
                modal used on the regular Species Library, but with
                defaultScope='pending_global' so the new row goes into
                the platform catalogue's Pending tab. Was previously
                a hash-navigate to #species/new?platform=1 which
                dumped the curator into the bare editor (feedback
                9a1af1ba). Icon-only on this admin view — the curator
                knows what a plus does here (Oli, May 2026). */}
            <button
              type="button"
              className="aq-icon-btn"
              onClick={() => setLookupOpen(true)}
              disabled={!window.SpeciesLookupModal}
              title={!window.SpeciesLookupModal ? 'Species lookup not loaded yet — refresh the page.' : 'Add species'}
              aria-label="Add species"
            >
              <Icon name="plus" size={15} />
            </button>
          </div>
        </section>

        {/* Toolbar — same shape as the local Species Library toolbar. */}
        <div className="aq-lib-toolbar">
          {/* Recessed search — borderless at rest, a faint fill only
              appears on focus. Inline overrides keep the shared
              .aq-lib-search CSS untouched for the local library. */}
          <div
            className="aq-lib-search"
            style={{
              background: searchFocused ? 'var(--aq-surface)' : 'transparent',
              border: `1px solid ${searchFocused ? 'var(--aq-line)' : 'transparent'}`,
              transition: 'background 120ms ease, border-color 120ms ease',
            }}
          >
            <Icon name="search" size={13} />
            <input
              placeholder="Search common name, scientific name, or org…"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              onFocus={() => setSearchFocused(true)}
              onBlur={() => setSearchFocused(false)}
            />
            <span className="aq-kbd" style={{ opacity: 0.55 }}>⌘K</span>
          </div>
          <div className="aq-lib-tabs">
            {GS_SCOPE_TABS.map((t) => {
              const isPendingWarn = t.id === 'pending' && counts.pending > 0;
              return (
                <button
                  key={t.id}
                  className={`aq-tab ${scope === t.id ? 'is-active' : ''}`}
                  onClick={() => setScope(t.id)}
                >
                  <span>{t.label}</span>
                  {/* Plain muted numeral, not a filled chip. Amber is
                      reserved for Pending review when there's work. */}
                  <span style={{
                    fontSize: 11,
                    fontWeight: 500,
                    fontVariantNumeric: 'tabular-nums',
                    color: isPendingWarn ? 'var(--aq-warn)' : 'var(--aq-text-faint)',
                  }}>
                    {counts[t.id] || 0}
                  </span>
                </button>
              );
            })}
          </div>
          {/* Review — opens the step-through reviewer for the pending
              queue. Only on the Pending tab when there's something to
              review. Sits at the right end of the toolbar. */}
          {scope === 'pending' && filtered.length > 0 && (
            <button
              type="button"
              onClick={() => {
                /* Store the ordered pending queue, then open the FIRST
                   species in the standard editor with ?review=1. The
                   editor reads this queue and advances to the next on
                   Approve & next / Skip — so the full editing surface is
                   used for review, not a cut-down panel. */
                try { sessionStorage.setItem('aquaos.gs.reviewQueue', JSON.stringify(filtered.map((s) => s.id))); } catch (_) {}
                if (filtered[0]) window.location.hash = `#species/${filtered[0].id}?from=global&review=1`;
              }}
              title="Review the pending queue in the editor — approve and auto-advance to the next"
              style={{
                marginLeft: 'auto',
                display: 'inline-flex', alignItems: 'center', gap: 7,
                padding: '5px 12px', borderRadius: 6,
                fontSize: 12, fontFamily: 'inherit', cursor: 'pointer',
                color: 'var(--aq-text)',
                background: 'rgba(255,255,255,0.10)',
                border: '1px solid rgba(255,255,255,0.14)',
              }}
            >
              <Icon name="check" size={13} />
              <span>Review {filtered.length}</span>
            </button>
          )}
          {/* Incomplete-only toggle — borderless at rest, amber fill when
              active, matching the recessed-search / tab language. Sits to
              the right of the scope tabs. Shows the backlog size as a
              muted numeral; disabled (and reset) when nothing's missing. */}
          <button
            type="button"
            onClick={() => setIncompleteOnly((v) => !v)}
            disabled={incompleteCount === 0 && !incompleteOnly}
            title="Show only species missing animation, voiceover, distribution data, or descriptions"
            aria-pressed={incompleteOnly}
            style={{
              marginLeft: 'auto',
              display: 'inline-flex', alignItems: 'center', gap: 7,
              padding: '5px 10px', borderRadius: 6,
              fontSize: 12, fontFamily: 'inherit',
              cursor: (incompleteCount === 0 && !incompleteOnly) ? 'default' : 'pointer',
              color: incompleteOnly ? 'var(--aq-warn)' : 'var(--aq-text-faint)',
              background: incompleteOnly
                ? 'color-mix(in srgb, var(--aq-warn) 14%, transparent)'
                : 'transparent',
              border: `1px solid ${incompleteOnly ? 'color-mix(in srgb, var(--aq-warn) 34%, transparent)' : 'transparent'}`,
              transition: 'background 120ms ease, border-color 120ms ease, color 120ms ease',
            }}
          >
            <span style={{
              width: 7, height: 7, borderRadius: '50%',
              background: 'var(--aq-warn)',
              opacity: incompleteOnly ? 1 : 0.7,
            }} />
            <span>Incomplete</span>
            <span style={{
              fontSize: 11, fontWeight: 500, fontVariantNumeric: 'tabular-nums',
              color: incompleteOnly ? 'var(--aq-warn)' : 'var(--aq-text-faint)',
            }}>{incompleteCount}</span>
          </button>
        </div>

        {loading && (
          <div style={{ padding: 60, textAlign: 'center', color: 'var(--aq-text-faint)' }}>
            Loading global species…
          </div>
        )}
        {error && (
          <div style={{
            padding: '10px 14px',
            background: 'color-mix(in srgb, var(--aq-danger) 12%, transparent)',
            border: '1px solid color-mix(in srgb, var(--aq-danger) 30%, transparent)',
            borderRadius: 6, color: 'var(--aq-danger)', fontSize: 12,
          }}>{error}</div>
        )}

        {!loading && !error && (
          <section className="aq-species-grid">
            {/* Phantom processing cards for in-flight global adds — shown
                in the Pending and All tabs (where the new pending_global
                rows will land). Exposed by species.jsx as
                window.PhantomPendingCard for exactly this reuse. */}
            {(scope === 'pending' || scope === 'all') && window.PhantomPendingCard
              && phantomEntries.map((entry) => (
                <window.PhantomPendingCard key={entry.key} entry={entry} />
              ))}
            {filtered.map((sp) => <GS_Card key={sp.id} sp={sp} onChanged={refresh} />)}
            {filtered.length === 0 && phantomEntries.length === 0 && (
              <div style={{
                gridColumn: '1 / -1', padding: 60, textAlign: 'center',
                color: 'var(--aq-text-faint)', fontSize: 13,
              }}>
                {species.length === 0 ? 'No species in the platform catalogue yet.' : 'No species match this filter.'}
              </div>
            )}
          </section>
        )}
      </div>
      {/* Same lookup-modal experience as the regular Species Library,
          re-targeted to the platform catalogue. onComplete refreshes
          the list so the just-added species appear in the Pending
          tab without a manual reload. */}
      {window.SpeciesLookupModal && (
        <window.SpeciesLookupModal
          open={lookupOpen}
          /* Jump to the Pending tab on Add so the curator actually sees
             the new species — it lands as pending_global, but the page
             defaults to the Global tab where it would be invisible. */
          onComplete={() => { setScope('pending'); setReload((n) => n + 1); }}
          onClose={() => setLookupOpen(false)}
          onSwitchToBulk={() => { setLookupOpen(false); setBulkOpen(true); }}
          defaultScope="pending_global"
        />
      )}
      {window.BulkAddModal && (
        <window.BulkAddModal
          open={bulkOpen}
          onClose={() => setBulkOpen(false)}
          onComplete={() => { setScope('pending'); setReload((n) => n + 1); }}
          onSwitchToSearch={() => { setBulkOpen(false); setLookupOpen(true); }}
          defaultScope="pending_global"
        />
      )}
    </div>
  );
}

window.GlobalSpeciesScreen = GlobalSpeciesScreen;
