// Connectors Hub — HERO surface

const { useState: useCx, useMemo: useMx } = React;

// A live backend means the page shows ONLY the eight connectors that are
// really implemented (Slack, Notion, Google Drive, Gmail, Google Calendar,
// Telegram, Databricks, local upload) with real numbers above them.
// The mock catalog, the mock "Your sources" grid and the design-wizard entry
// points are demo-mode furniture and stay hidden there.
function cxLive() {
  try { return !!(typeof parcleApiBase === 'function' && parcleApiBase()); }
  catch (e) { return false; }
}

function ConnectorsHub({ sourcesPopulated, setDrawerVendor, openAddFlow }) {
  const [query, setQuery] = useCx('');
  const [filter, setFilter] = useCx('all');

  const live = cxLive();
  const startWithSlack = () => openAddFlow('chatroom');
  const allVendorIds = Object.keys(VENDORS);
  const connected = sourcesPopulated ? CONNECTED_SOURCES : [];
  const connectedIds = new Set(connected.map(c => c.id));

  const filteredVendors = useMx(() => {
    const q = query.trim().toLowerCase();
    return allVendorIds.filter(id => {
      const v = VENDORS[id];
      if (q && !v.name.toLowerCase().includes(q) && !v.cat.toLowerCase().includes(q)) return false;
      return true;
    });
  }, [query]);

  const byCategory = useMx(() => {
    const map = {};
    CATEGORY_ORDER.forEach(c => map[c] = []);
    filteredVendors.forEach(id => {
      const v = VENDORS[id];
      if (map[v.cat]) map[v.cat].push(id);
    });
    return map;
  }, [filteredVendors]);

  const totalItems = '1.24M';
  const totalSize = '10.5 TB';
  const totalRels = '2.2M';

  return (
    <div style={{ padding: '32px 32px 80px', maxWidth: 1280, margin: '0 auto' }}>
      {/* Page title */}
      <div style={{ marginBottom: 8 }}>
        <h1 className="t-h1" style={{ margin: 0 }}>Connectors</h1>
        <div className="text-secondary t-body" style={{ marginTop: 4 }}>
          One API for everything your org knows.
        </div>
      </div>

      {/* Hero strip — bold emphasis. Live backend → real numbers; demo mode
          keeps the mocked showcase. */}
      {live ? <ConnectorsHeroLive/> : (
        <div style={{
          marginTop: 24,
          border: '1px solid var(--border-subtle)',
          borderRadius: 12,
          background: 'var(--surface)',
          overflow: 'hidden',
        }}>
          <div style={{ padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 32, borderBottom: sourcesPopulated ? '1px solid var(--border-subtle)' : 'none', flexWrap: 'wrap' }}>
            <HeroStat label="Connected" value={connected.length.toString()} sub={`of ${allVendorIds.length} available`} accent />
            <HeroStat label="Indexed" value={sourcesPopulated ? totalSize : '0'} sub={sourcesPopulated ? totalItems + ' items' : ''} />
            <HeroStat label="Relationships" value={sourcesPopulated ? totalRels : '0'} sub="cross-source edges" />
            <HeroStat label="Retrieval calls" value="14.9k" sub="last 7 days" />
            <div style={{ flex: 1 }}/>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              <StatusDot kind={sourcesPopulated ? 'live' : 'paused'} />
              <span className="mono-sm mono text-secondary">{sourcesPopulated ? 'Live' : 'No sources yet'}</span>
            </div>
          </div>

          {sourcesPopulated && (
            <div style={{ padding: '20px 24px', display: 'flex', gap: 18, alignItems: 'center', flexWrap: 'wrap' }}>
              {connected.map(c => (
                <HeroLogoPill key={c.id} source={c} onClick={() => setDrawerVendor(c.id)} />
              ))}
            </div>
          )}
        </div>
      )}

      {/* Live connectors — real backend-backed sources. Render nothing in
          mock/demo mode. */}
      {typeof OrgSlackSection === 'function' && <OrgSlackSection/>}
      {typeof OrgNotionSection === 'function' && <OrgNotionSection/>}
      {typeof OrgGoogleDriveSection === 'function' && <OrgGoogleDriveSection/>}
      {typeof OrgGmailSection === 'function' && <OrgGmailSection/>}
      {typeof OrgGCalendarSection === 'function' && <OrgGCalendarSection/>}
      {typeof OrgTelegramSection === 'function' && <OrgTelegramSection/>}
      {typeof OrgDatabricksSection === 'function' && (
        <OrgDatabricksSection onManage={() => openAddFlow('databricks')}/>
      )}
      {typeof OrgUploadSection === 'function' && (
        <OrgUploadSection onUpload={() => openAddFlow('upload')}/>
      )}

      {/* Everything below is the demo catalog — connectors that aren't built
          yet. Hidden on a live backend so the page only offers what actually
          works. */}

      {/* Search + actions */}
      {!live && (
      <div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
        <div style={{ position: 'relative', flex: 1, maxWidth: 420 }}>
          <Icons.Search size={14} style={{ position: 'absolute', left: 12, top: 10, color: 'var(--text-tertiary)' }}/>
          <input className="input" placeholder="Search connectors…"
            value={query} onChange={e => setQuery(e.target.value)}
            style={{ paddingLeft: 34 }}/>
        </div>
        <button className="btn"><Icons.Filter size={13}/> All <Icons.ChevronD size={11}/></button>
        <button className="btn"><Icons.Dot size={10}/> Status <Icons.ChevronD size={11}/></button>
        <div style={{ flex: 1 }}/>
        {/* Upload lives in its own card above, next to Slack and Databricks. */}
        {/* Wrap the call: onClick={openAddFlow} would pass the click event as
            the vendor id, and no vendor matches an event object. */}
        <button className="btn accent" onClick={() => openAddFlow()}><Icons.Plus size={13}/> Add source</button>
      </div>
      )}

      {/* Your sources */}
      {!live && sourcesPopulated && (
        <section style={{ marginTop: 32 }}>
          <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 14 }}>
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
              <h2 className="t-h2" style={{ margin: 0 }}>Your sources</h2>
              <span className="mono-sm mono text-tertiary">{connected.length}</span>
            </div>
            <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
              <span className="mono-sm mono text-tertiary">Last sync 34s ago</span>
              <button className="btn sm ghost">Manage</button>
            </div>
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 12 }}>
            {connected.map(c => (
              <ConnectedTile key={c.id} source={c} onClick={() => setDrawerVendor(c.id)} />
            ))}
          </div>
        </section>
      )}

      {/* Empty state banner when no sources */}
      {!live && !sourcesPopulated && (
        <div className="card" style={{ marginTop: 32, padding: '24px 28px', display: 'flex', alignItems: 'center', gap: 24 }}>
          <div style={{ flex: 1 }}>
            <div className="t-h2">Connect your first source to power retrieval</div>
            <div className="text-secondary t-body" style={{ marginTop: 4 }}>Pick anything from the catalog below. Most teams start with Slack or Notion.</div>
          </div>
          <button className="btn accent" onClick={startWithSlack}><Icons.Plus size={13}/> Start with Slack</button>
          <button className="btn">Browse all</button>
        </div>
      )}

      {/* Catalog */}
      {!live && (
      <section style={{ marginTop: 48 }}>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 4, paddingBottom: 14, borderBottom: '1px solid var(--border-subtle)' }}>
          <h2 className="t-h2" style={{ margin: 0 }}>Catalog</h2>
          <span className="mono-sm mono text-tertiary">{allVendorIds.length} connectors</span>
        </div>

        {CATEGORY_ORDER.map(cat => {
          const vendors = byCategory[cat];
          if (!vendors || vendors.length === 0) return null;
          return (
            <div key={cat} style={{ marginTop: 28 }}>
              <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 12 }}>
                <h3 className="t-h3" style={{ margin: 0 }}>{CATEGORY_LABEL[cat]}</h3>
                <span className="mono-sm mono text-tertiary">{vendors.length}</span>
              </div>
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 12 }}>
                {vendors.map(id => (
                  <CatalogTile key={id} vendor={id}
                    connected={connectedIds.has(id)}
                    onClick={() => connectedIds.has(id) ? setDrawerVendor(id) : openAddFlow(id)} />
                ))}
              </div>
            </div>
          );
        })}

        <div style={{ marginTop: 40, padding: '20px 24px', border: '1px dashed var(--border)', borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
          <div>
            <div className="t-h3">Don't see yours?</div>
            <div className="text-secondary t-small mt-4">Request a connector and we'll prioritize it based on demand.</div>
          </div>
          <button className="btn">Request a connector →</button>
        </div>
      </section>
      )}
    </div>
  );
}

// ── Live hero stats ─────────────────────────────────────────────────────────
// On a live backend every number above the connectors is read from the backend:
//
//   Connected     — how many of the eight real connectors actually hold
//                   something: Slack authorized (GET …/slack/connection),
//                   Notion authorized (GET …/notion/connection),
//                   Google Drive / Gmail / Google Calendar authorized (ONE
//                   probe of the shared "google" connection fills all three
//                   tiles, so one Google sign-in counts all three),
//                   Telegram signed in (GET …/telegram/connection),
//                   Databricks connected (GET …/databricks/connection),
//                   at least one uploaded file (GET /ingestion/files)
//   Indexed       — documents and tables in the org's knowledge graph
//   Concepts      — business concepts learned from them, and the semantic edges
//                   between them
//                   Both are counted off the GET /skill/graph snapshot the app
//                   already primes at mount — the first read comes free from
//                   that shared cache, and it is only refetched on a slow
//                   cadence while this page is actually on screen.
//   Retrieval calls — `total` of the /skill/retrievals audit trail
//
// A number stays '—' until its source answers, and a failed read keeps the last
// known value: falling back to 0 would read as "your data disappeared".
//
// The eight cards below poll the same connection endpoints on their own, faster
// cadences, so right after a change the strip can trail its card by a moment;
// the change events the cards fire keep that window short.
const CX_TICK_MS = 5000;              // cheap visibility check; the work below is rate-limited
const CX_STATS_POLL_MS = 30000;
const CX_GRAPH_RELOAD_MS = 300000;    // /skill/graph is the whole graph — refetch sparingly

// 940 → "940", 8200 → "8.2k", 14900 → "15k", 1240000 → "1.24M".
function cxNum(n) {
  const v = Number(n);
  if (!isFinite(v)) return '—';
  if (v < 1000) return String(Math.round(v));
  const k = v / 1000;
  if (v < 9950) return `${k.toFixed(1)}k`;   // below the point where .toFixed(1) would read "10.0k"
  if (Math.round(k) < 1000) return `${Math.round(k)}k`;
  const m = v / 1e6;
  return m < 10 ? `${m.toFixed(2)}M` : `${Math.round(m)}M`;
}

// Structure edges (table↔column, document↔chunk) are the graph's own plumbing —
// counting them as "relationships" would drown the number the user cares about,
// the same reason the Learning stream skips them.
function cxGraphCounts(snap) {
  if (!snap) return null;
  const factual = snap.factual_nodes || [];
  const ofType = (t) => factual.filter(n => n.type === t).length;
  const semanticEdges = ['mapping_edges', 'dependency_edges', 'relation_edges', 'category_edges']
    .reduce((sum, key) => sum + (snap[key] || []).length, 0);
  return {
    documents: ofType('document'),
    tables: ofType('table'),
    concepts: (snap.concept_nodes || []).length,
    edges: semanticEdges,
  };
}

// `graphMock: true` in config.js serves the demo graph even against a real
// backend. These tiles promise real numbers, so in that configuration they show
// nothing rather than the mock's counts.
function cxGraphMock() {
  return typeof graphApiMockEnabled === 'function' && graphApiMockEnabled();
}

// Never let one unreachable endpoint reject the whole batch.
function cxSafe(fn) {
  try { return Promise.resolve(fn()).catch(() => null); }
  catch (e) { return Promise.resolve(null); }
}

// A probe that keeps failing this many times in a row is reported as "not
// connected" rather than holding the whole tile at '—' forever: a blip must not
// drop a connector out of the count, but a route that is genuinely broken on a
// deployment must not make the number permanently unreadable either.
const CX_PROBE_GIVE_UP = 3;

function ConnectorsHeroLive() {
  const { useState, useEffect, useRef, useCallback } = React;
  // Per connector: true / false / null (nothing usable yet). The tile stays '—'
  // until all eight have a value.
  const [conns, setConns] = useState({ slack: null, notion: null, googledrive: null, gmail: null, gcalendar: null, telegram: null, databricks: null, upload: null });
  const [graph, setGraph] = useState(null);
  const [asks, setAsks] = useState(null);
  const alive = useRef(true);
  const rootRef = useRef(null);
  const fails = useRef({ slack: 0, notion: 0, googledrive: 0, gmail: 0, gcalendar: 0, telegram: 0, databricks: 0, upload: 0 });
  useEffect(() => () => { alive.current = false; }, []);

  // The Connectors route stays mounted and is merely display:none'd when the
  // user navigates away, so a poll would otherwise keep running off-screen
  // forever. offsetParent is null exactly when an ancestor is display:none.
  const onScreen = () => !document.hidden && !!(rootRef.current && rootRef.current.offsetParent !== null);

  const refresh = useCallback(async () => {
    // Drive/Gmail/Calendar share ONE "google" connection (each of their
    // /connection endpoints round-trips Composio for the same record), so the
    // strip probes it once through the Drive API and reuses the answer for all
    // three — is_active is identical by construction, only the per-product
    // sync_job differs and this count doesn't read it.
    const [slack, notion, google, telegram, dbx, files, page] = await Promise.all([
      cxSafe(() => OrgSlackAPI.status()),
      cxSafe(() => OrgNotionAPI.status()),
      cxSafe(() => OrgGoogleDriveAPI.status()),
      cxSafe(() => OrgTelegramAPI.status()),
      cxSafe(() => DatabricksAPI.connection()),
      cxSafe(() => ParcleUploadAPI.files(0)),   // limit=0 → counts only
      cxSafe(() => ParcleRetrievalsAPI.list(1, 1)),
    ]);
    if (!alive.current) return;
    // A probe that failed keeps whatever it last reported (see CX_PROBE_GIVE_UP).
    const settle = (key, answered, value, prev) => {
      if (answered) { fails.current[key] = 0; return value; }
      fails.current[key] += 1;
      if (prev !== null) return prev;
      return fails.current[key] >= CX_PROBE_GIVE_UP ? false : null;
    };
    setConns(prev => ({
      slack: settle('slack', !!slack, !!(slack && slack.is_active), prev.slack),
      notion: settle('notion', !!notion, !!(notion && notion.is_active), prev.notion),
      googledrive: settle('googledrive', !!google, !!(google && google.is_active), prev.googledrive),
      gmail: settle('gmail', !!google, !!(google && google.is_active), prev.gmail),
      gcalendar: settle('gcalendar', !!google, !!(google && google.is_active), prev.gcalendar),
      telegram: settle('telegram', !!telegram, !!(telegram && telegram.is_active), prev.telegram),
      databricks: settle('databricks', !!dbx, !!(dbx && dbxIsConnected(dbx)), prev.databricks),
      upload: settle('upload', !!files, !!(files && (files.total || 0) > 0), prev.upload),
    }));
    if (page && typeof page.total === 'number') setAsks(page.total);
  }, []);

  // One ticker drives both refreshes. It runs often but does nothing off-screen,
  // and refreshes the moment the page comes BACK on screen (route switch or tab
  // switch) so a returning user never reads numbers from before they left.
  const lastStats = useRef(0);
  const lastGraph = useRef(0);
  const wasOnScreen = useRef(true);
  useEffect(() => {
    lastStats.current = Date.now();
    lastGraph.current = Date.now();   // the app-mount prime is this page's first read
    refresh();
    const tick = () => {
      const on = onScreen();
      const returned = on && !wasOnScreen.current;
      wasOnScreen.current = on;
      if (!on) return;
      const now = Date.now();
      if (returned || now - lastStats.current >= CX_STATS_POLL_MS) {
        lastStats.current = now;
        refresh();
      }
      // Ingestion finishes minutes after an upload does, so without this the
      // graph counts would sit frozen at whatever the prime saw.
      if (now - lastGraph.current >= CX_GRAPH_RELOAD_MS) {
        lastGraph.current = now;
        if (!cxGraphMock()) reloadGraphSnapshot();
      }
    };
    const t = setInterval(tick, CX_TICK_MS);
    document.addEventListener('visibilitychange', tick);
    return () => { clearInterval(t); document.removeEventListener('visibilitychange', tick); };
  }, [refresh]);

  // The connectors announce their own writes, so the strip catches up at once
  // instead of a poll interval later. Uploads fire on every queue change, so
  // react to the active→idle EDGE only (same rule as the upload card).
  const wasUploading = useRef(false);
  useEffect(() => {
    const onConnectorChanged = () => {
      lastStats.current = Date.now();
      refresh();
      // A connect / disconnect / finished upload is also the most likely moment
      // for the graph to have moved. reloadGraphSnapshot coalesces, so asking
      // opportunistically is safe; the slow tick still covers ingestion that
      // only lands minutes later.
      lastGraph.current = Date.now();
      if (!cxGraphMock()) reloadGraphSnapshot();
    };
    const onUploadChanged = (e) => {
      // Refresh-only announce (a delete in the upload drawer): counts moved
      // without any batch running, so skip the edge detection.
      if (e && e.detail && e.detail.refresh) { onConnectorChanged(); return; }
      const active = !!(e && e.detail && e.detail.active);
      if (wasUploading.current && !active) onConnectorChanged();
      wasUploading.current = active;
    };
    window.addEventListener(ODB_CHANGED_EVENT, onConnectorChanged);
    window.addEventListener(OS_CHANGED_EVENT, onConnectorChanged);
    window.addEventListener(ON_CHANGED_EVENT, onConnectorChanged);
    window.addEventListener(GOOGLE_CHANGED_EVENT, onConnectorChanged);
    window.addEventListener(OT_CHANGED_EVENT, onConnectorChanged);
    window.addEventListener(PARCLE_UPLOAD_EVENT, onUploadChanged);
    return () => {
      window.removeEventListener(ODB_CHANGED_EVENT, onConnectorChanged);
      window.removeEventListener(OS_CHANGED_EVENT, onConnectorChanged);
      window.removeEventListener(ON_CHANGED_EVENT, onConnectorChanged);
      window.removeEventListener(GOOGLE_CHANGED_EVENT, onConnectorChanged);
      window.removeEventListener(OT_CHANGED_EVENT, onConnectorChanged);
      window.removeEventListener(PARCLE_UPLOAD_EVENT, onUploadChanged);
    };
  }, [refresh]);

  useEffect(() => {
    if (cxGraphMock()) return undefined;
    let cancelled = false;
    const apply = (s) => { if (!cancelled && alive.current) setGraph(cxGraphCounts(s)); };
    fetchGraphSnapshotCached().then(apply).catch(() => { /* '—' until a later reload lands */ });
    const unsub = subscribeGraphSnapshot(apply);
    return () => { cancelled = true; unsub(); };
  }, []);

  const states = [conns.slack, conns.notion, conns.googledrive, conns.gmail, conns.gcalendar, conns.telegram, conns.databricks, conns.upload];
  const count = states.every(s => s !== null) ? states.filter(Boolean).length : null;

  return (
    <div ref={rootRef} style={{
      marginTop: 24,
      border: '1px solid var(--border-subtle)',
      borderRadius: 12,
      background: 'var(--surface)',
      overflow: 'hidden',
    }}>
      <div style={{ padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 32, flexWrap: 'wrap' }}>
        <HeroStat label="Connected" value={count === null ? '—' : String(count)} sub="of 8 available" accent />
        <HeroStat label="Indexed" value={graph ? cxNum(graph.documents + graph.tables) : '—'}
          sub={graph ? `${cxNum(graph.documents)} docs · ${cxNum(graph.tables)} tables` : ''} />
        <HeroStat label="Concepts" value={graph ? cxNum(graph.concepts) : '—'}
          sub={graph ? `${cxNum(graph.edges)} relationships` : ''} />
        <HeroStat label="Retrieval calls" value={asks === null ? '—' : cxNum(asks)} sub="all time" />
        <div style={{ flex: 1 }}/>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <StatusDot kind={count === null ? 'syncing' : count ? 'live' : 'paused'} />
          <span className="mono-sm mono text-secondary">
            {count === null ? 'Checking…' : count ? 'Live' : 'No sources yet'}
          </span>
        </div>
      </div>
    </div>
  );
}

function HeroStat({ label, value, sub, accent }) {
  return (
    <div>
      <div className="t-display" style={{ color: accent ? 'var(--accent-text)' : 'var(--text-primary)', letterSpacing: '-0.02em' }}>{value}</div>
      <div className="mono-sm mono text-tertiary" style={{ marginTop: 2 }}>
        {label}{sub && <span> · {sub}</span>}
      </div>
    </div>
  );
}

function HeroLogoPill({ source, onClick }) {
  const v = VENDORS[source.id];
  return (
    <button onClick={onClick} style={{
      display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
      padding: 0, background: 'transparent',
    }}>
      <VendorLogo vendor={source.id} size={36} radius={8}/>
      <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
        <StatusDot kind={source.status === 'syncing' ? 'syncing' : source.status === 'partial' ? 'partial' : 'live'} size={6}/>
        <span className="mono-sm mono" style={{ color: 'var(--text-secondary)', fontSize: 11 }}>{source.volume.split(' ')[0]}</span>
      </div>
    </button>
  );
}

function ConnectedTile({ source, onClick }) {
  const v = VENDORS[source.id];
  const status = source.status;
  const borderColor =
    status === 'error' ? 'var(--error)' :
    status === 'partial' ? 'var(--warning)' :
    'var(--border-subtle)';
  const dotKind = status === 'syncing' ? 'syncing' : status === 'partial' ? 'partial' : status === 'error' ? 'error' : 'live';
  const statusLabel = status === 'syncing' ? `Syncing · ${source.progress}%`
    : status === 'partial' ? 'Partial'
    : status === 'paused' ? 'Paused'
    : 'Connected';
  const isColdUsed = source.used && (source.used.includes('d ago') || source.used === 'Never');

  return (
    <button onClick={onClick} style={{
      background: 'var(--surface)',
      border: `1px solid ${borderColor}`,
      borderRadius: 10,
      padding: 16, textAlign: 'left',
      display: 'flex', flexDirection: 'column', gap: 10,
      minHeight: 170, position: 'relative', overflow: 'hidden',
      transition: 'border-color 120ms, transform 120ms',
    }}
    onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--border-strong)'}
    onMouseLeave={e => e.currentTarget.style.borderColor = borderColor}
    >
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
        <VendorLogo vendor={source.id} size={32}/>
        {status === 'partial' && <Icons.AlertTri size={14} style={{ color: 'var(--warning)' }}/>}
      </div>
      <div>
        <div className="t-h3">{v.name}</div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 }}>
          <StatusDot kind={dotKind} size={6}/>
          <span className="mono-sm mono" style={{ color: status === 'error' ? 'var(--error)' : status === 'partial' ? 'var(--warning)' : 'var(--text-secondary)' }}>
            {statusLabel}
          </span>
        </div>
      </div>
      <div style={{ marginTop: 'auto' }}>
        <div className="mono-sm mono" style={{ color: 'var(--text-primary)' }}>{source.volume}</div>
        {source.size && (
          <div className="mono-sm mono text-secondary" style={{ marginTop: 2 }}>
            {source.size} indexed
          </div>
        )}
        {source.rels && (
          <div className="mono-sm mono" style={{ color: 'var(--accent-text)', marginTop: 1 }}>
            {source.rels} relationships
          </div>
        )}
        <div className="mono-sm mono text-tertiary" style={{ marginTop: 4 }}>Synced {source.synced} · Used {source.used}</div>
      </div>
      {status === 'syncing' && (
        <div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 3 }}>
          <Progress value={source.progress} height={3}/>
        </div>
      )}
    </button>
  );
}

function CatalogTile({ vendor, connected, onClick }) {
  const v = VENDORS[vendor];
  return (
    <button onClick={onClick} style={{
      background: 'var(--surface)',
      border: connected ? '1px solid var(--border-subtle)' : '1px dashed var(--border)',
      borderRadius: 10, padding: 14,
      display: 'flex', flexDirection: 'column', gap: 10,
      textAlign: 'left', minHeight: 130,
      transition: 'border-color 120ms, background 120ms',
    }}
    onMouseEnter={e => {
      e.currentTarget.style.borderColor = 'var(--border-strong)';
      e.currentTarget.style.background = 'var(--hover)';
    }}
    onMouseLeave={e => {
      e.currentTarget.style.borderColor = connected ? 'var(--border-subtle)' : 'var(--border)';
      e.currentTarget.style.background = 'var(--surface)';
    }}
    >
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
        <VendorLogo vendor={vendor} size={28}/>
        {connected && <Icons.Check size={14} style={{ color: 'var(--success)' }}/>}
      </div>
      <div>
        <div style={{ fontSize: 14, fontWeight: 500 }}>{v.name}</div>
        <div className="mono-sm mono text-tertiary" style={{ marginTop: 2 }}>{CAT_LABEL_SHORT[v.glyph] || v.kind}</div>
      </div>
      <div style={{ marginTop: 'auto' }}>
        {connected ? (
          <span className="chip success" style={{ padding: '2px 6px' }}><span className="dot"/> Connected</span>
        ) : (
          <span className="mono-sm mono" style={{ color: 'var(--text-secondary)' }}>+ Add</span>
        )}
      </div>
    </button>
  );
}

Object.assign(window, { ConnectorsHub });
