// Personal Connectors — PERSONAL-mode Connectors surface.
//
// Rendered instead of the org ConnectorsHub when the session is in personal
// mode (see shell.jsx). A small set of personal apps you connect via OAuth
// (Google / Notion / GitHub) plus a local folder. Data + mock client live in
// personal_connectors_data.jsx; this file is presentation + flow only.

const { useState: usePcUi } = React;

// ── Brand marks ─────────────────────────────────────────────────────────────
// Real, recognizable logos (a deliberate departure from the org catalog's
// abstract category glyphs). Rendered inside a rounded tile.
function BrandGlyph({ logo, size = 22 }) {
  switch (logo) {
    case 'google':
      return (
        <svg width={size} height={size} viewBox="0 0 48 48">
          <path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3C33.7 32.9 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.5 6.5 29.5 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.3-.4-3.5z"/>
          <path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.5 6.5 29.5 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/>
          <path fill="#4CAF50" d="M24 44c5.4 0 10.3-2.1 14-5.4l-6.5-5.5C29.6 34.6 26.9 36 24 36c-5.2 0-9.6-3.1-11.3-7.5l-6.5 5C9.6 39.6 16.2 44 24 44z"/>
          <path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4.1 5.5l6.5 5.5C39.1 40.6 44 36 44 24c0-1.3-.1-2.3-.4-3.5z"/>
        </svg>
      );
    case 'notion':
      return (
        <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
          <path fill="#111827" d="M7 5h2.4l6.6 8.6V5H18v14h-2.3L9 10.2V19H7z"/>
        </svg>
      );
    case 'github':
      return (
        <svg width={size} height={size} viewBox="0 0 24 24">
          <path fill="#fff" d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.8 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 016 0C17 4.6 18 4.9 18 4.9c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.5-2.7 5.5-5.3 5.8.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/>
        </svg>
      );
    case 'hubspot':
      return (
        <svg width={size} height={size} viewBox="0 0 24 24" fill="#FF7A59">
          <path d="M17.1 8.2V5.9a1.8 1.8 0 10-1.3 0v2.3a5.3 5.3 0 00-2.4 1L7.1 4.6a2 2 0 10-1 1.3l6.2 4.7a5.2 5.2 0 00.6 6.3l-1.9 1.9a1.7 1.7 0 00-.5-.1 1.7 1.7 0 101.7 1.7c0-.2 0-.3-.1-.5l1.9-1.9a5.3 5.3 0 103.1-9.5zm-1 8a2.7 2.7 0 110-5.5 2.7 2.7 0 010 5.5z"/>
        </svg>
      );
    case 'linear':
      return (
        <svg width={size} height={size} viewBox="0 0 100 100" fill="#fff">
          <path d="M1.2 61.5a1 1 0 011.3-.2L39 97.7a1 1 0 01-.2 1.3A50 50 0 011.2 61.5zM.2 47.6a1 1 0 00.3.8l51.4 51.4a1 1 0 00.8.3 49.7 49.7 0 008.2-1.5 1 1 0 00.5-1.7L3.4 38.9a1 1 0 00-1.7.5A49.7 49.7 0 00.2 47.6zM6 28.2a1 1 0 00.2 1.2l64.6 64.6a1 1 0 001.2.2 50.2 50.2 0 005.4-3.2 1 1 0 00.1-1.5L10.7 22.7a1 1 0 00-1.5.1A50.2 50.2 0 006 28.2zM16.7 14.9a1 1 0 01-.1-1.4 50 50 0 0170 70 1 1 0 01-1.4-.1z"/>
        </svg>
      );
    case 'folder':
      return (
        <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.7" strokeLinejoin="round">
          <path d="M3 7h6l2 2.5h10V19H3z"/>
        </svg>
      );
    default:
      return null;
  }
}

function BrandLogo({ app, size = 40, radius = 10 }) {
  return (
    <div style={{
      width: size, height: size, borderRadius: radius,
      background: app.tileBg || 'var(--surface)',
      border: app.tileBg === '#ffffff' ? '1px solid var(--border-subtle)' : 'none',
      display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
      flexShrink: 0,
    }}>
      <BrandGlyph logo={app.logo} size={Math.round(size * 0.55)} />
    </div>
  );
}

function Spin({ size = 13 }) {
  return <Icons.Spinner size={size} style={{ animation: 'parcle-spin .8s linear infinite' }} />;
}

// ── Page ────────────────────────────────────────────────────────────────────
function PersonalConnectors({ session }) {
  const { conns, start, syncNow, disconnect, addLocalFolder, removeLocalFolder, sources, sourcesLoading, refreshSources } = usePersonalConnectors();
  const [manageId, setManageId] = usePcUi(null);
  // Local Folder is daemon-driven now; presence is detected on click (the daemon
  // pops the picker). Surface a hint when a click found no daemon on this machine.
  const noDaemon = conns.localfolder && conns.localfolder.error === 'no_daemon';

  const isConnected = (app) => {
    const c = conns[app.id];
    if (!c) return false;
    if (app.provider === 'local') return Array.isArray(c.folders) && c.folders.length > 0;
    return c.status === 'active';
  };
  const connectedCount = PERSONAL_APPS.filter(isConnected).length;

  // OAuth app currently mid-handshake drives the overlay (local folder shows
  // its own inline indexing, so it's excluded).
  const busyId = PERSONAL_APPS
    .map(a => a.id)
    .find(id => conns[id] && conns[id].status === 'connecting');
  const busyApp = busyId ? PERSONAL_APP_BY_ID[busyId] : null;

  const manageApp = manageId ? PERSONAL_APP_BY_ID[manageId] : null;

  // Content lists only exist with a live backend (the sources endpoint). In the
  // standalone demo there's nothing to fetch, so hide the per-bucket UI rather
  // than render a row of misleading zeros.
  const bucketsFor = (appId) => (pcLive() ? pcAppBuckets(appId, sources) : []);

  return (
    <div style={{ padding: '32px 32px 80px', maxWidth: 880, margin: '0 auto' }}>
      {/* Title */}
      <div style={{ marginBottom: 8 }}>
        <h1 className="t-h1" style={{ margin: 0 }}>Connectors</h1>
        <div className="text-secondary t-body" style={{ marginTop: 4 }}>
          Connect your personal tools. Parcle reads them to power your workspace — read-only.
        </div>
      </div>

      {/* Summary strip */}
      <div style={{
        marginTop: 24, padding: '14px 20px',
        border: '1px solid var(--border-subtle)', borderRadius: 12,
        background: 'var(--surface)',
        display: 'flex', alignItems: 'center', gap: 10,
      }}>
        <StatusDot kind={connectedCount ? 'live' : 'paused'} />
        <span className="mono-sm mono text-secondary">
          {connectedCount
            ? `${connectedCount} of ${PERSONAL_APPS.length} connected`
            : 'No connections yet'}
        </span>
      </div>

      {/* Cards */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: 14, marginTop: 20 }}>
        {PERSONAL_APPS.map(app => app.provider === 'local' ? (
          <LocalFolderCard
            key={app.id}
            app={app}
            rec={conns[app.id]}
            supported={true}
            sources={pcLive() ? sources : []}
            sourcesLoading={sourcesLoading}
            onRefresh={pcLive() ? refreshSources : null}
            onAdd={() => addLocalFolder()}
            onRemove={removeLocalFolder}
          />
        ) : (
          <ConnectorCard
            key={app.id}
            app={app}
            conn={conns[app.id]}
            buckets={bucketsFor(app.id)}
            onStart={() => start(app)}
            onManage={() => setManageId(app.id)}
          />
        ))}
      </div>

      {noDaemon && (
        <div className="t-small text-tertiary" style={{ marginTop: 14, display: 'flex', alignItems: 'center', gap: 6 }}>
          <Icons.AlertTri size={13} style={{ color: 'var(--warning)' }} />
          No daemon detected on this machine. Install it from the Devices section to add a local folder.
        </div>
      )}

      {busyApp && <HandshakeOverlay app={busyApp} />}

      {manageApp && (
        <PersonalConnectorDrawer
          app={manageApp}
          conn={conns[manageApp.id]}
          buckets={bucketsFor(manageApp.id)}
          sourcesLoading={sourcesLoading}
          onClose={() => setManageId(null)}
          onSync={() => syncNow(manageApp.id)}
          onDisconnect={() => { disconnect(manageApp.id); setManageId(null); }}
        />
      )}
    </div>
  );
}

// Turn a product key ('google_calendar', 'github') into a short display label.
function prettyProduct(p) {
  if (!p) return '';
  const s = String(p).replace(/^google_/, '');
  return s.charAt(0).toUpperCase() + s.slice(1);
}

// Status text while a server-driven connector sync runs. During the export
// phase there are no file counts yet (one opaque fetch), so show "Fetching…";
// once ingestion starts, reuse the Local Folder "{done}/{total}" idiom and
// append the item currently being processed.
function connectorSyncLabel(conn) {
  const sp = conn && conn.syncProgress;
  if (!sp) return 'Syncing initial data';
  // Discovery streams items in — surface the live discovered count as it grows
  // (the backend updates total every ~0.5s during the manifest phase) instead of
  // an "Indexing 0/N", since processing hasn't started yet.
  if (sp.phase === 'discovering') {
    if (sp.total > 0) return `Discovering ${sp.total} items…`;
    return sp.product ? `Discovering ${sp.product}…` : 'Discovering items…';
  }
  // A product that exported successfully but had nothing to ingest (e.g. an empty
  // calendar/contacts). Surface it explicitly instead of skipping past silently.
  if (sp.phase === 'skipped_empty') {
    return sp.product ? `No content in ${prettyProduct(sp.product)} — skipped` : 'Skipped (no content)';
  }
  if (sp.total > 0) {
    const base = `Indexing ${sp.processed}/${sp.total}`;
    return sp.current ? `${base} · ${sp.current}` : base;
  }
  if (sp.phase === 'exporting') return sp.product ? `Fetching ${sp.product}…` : 'Fetching data…';
  if (sp.phase === 'ingesting') return 'Indexing…';
  return 'Syncing initial data';
}

// Thin progress bar shown under the status line while a sync runs. Prefers the
// server's smoothed 0..1 job progress (monotonic across products, so it doesn't
// jump backwards when a later product's manifest grows the total); falls back to
// processed/total. During the discovery phase neither is meaningful yet, so it
// renders an indeterminate sliding bar instead of a frozen empty track.
function ConnectorSyncBar({ sp }) {
  // During discovery the denominator is still growing, so a fractional bar would
  // be misleading — show an indeterminate sweep until processing begins.
  const discovering = sp && sp.phase === 'discovering';
  let frac = null;
  if (!discovering) {
    if (sp && typeof sp.progress === 'number') frac = sp.progress;
    else if (sp && sp.total > 0) frac = sp.processed / sp.total;
  }
  const determinate = typeof frac === 'number' && frac > 0;
  const pct = determinate ? Math.max(2, Math.min(100, Math.round(frac * 100))) : 30;
  return (
    <div style={{ height: 4, borderRadius: 999, background: 'var(--border-subtle)', overflow: 'hidden' }}>
      <div style={{
        height: '100%', width: `${pct}%`, borderRadius: 999, background: 'var(--accent)',
        transition: 'width 300ms ease',
        animation: determinate ? 'none' : 'pc-indeterminate 1.1s ease-in-out infinite',
      }} />
    </div>
  );
}

// Status line for a connected connector when it is NOT syncing. A failed/unknown
// last sync must be surfaced here — the `errored` branch below only covers
// connection failures (status==='error'), so without this a failed sync would
// silently render as "Connected".
function connectorRestLabel(conn) {
  if (conn.lastSync === 'failed') return { text: 'Sync failed', color: 'var(--error)', bad: true };
  if (conn.lastSync === 'sync unknown') return { text: 'Sync status unknown', color: 'var(--warning)', bad: true };
  // A completed sync that discovered nothing — distinguish it from a fresh
  // "Connected" so an empty Notion (no shared pages) doesn't look like it never ran.
  if (conn.lastSyncEmpty) return { text: 'No items found', color: 'var(--text-tertiary)', bad: false };
  return { text: conn.identity || 'Connected', color: 'var(--text-secondary)', bad: false };
}

// ── Card ────────────────────────────────────────────────────────────────────
function ConnectorCard({ app, conn, buckets, onStart, onManage }) {
  const status = conn && conn.status;
  const active = status === 'active';
  const busy = status === 'connecting';
  const errored = status === 'error';
  // Resting (not-syncing) status for a connected connector — surfaces a failed sync.
  const rest = (active && conn && !conn.syncing) ? connectorRestLabel(conn) : null;

  return (
    <div style={{
      background: 'var(--surface)',
      border: '1px solid var(--border-subtle)',
      borderRadius: 12, padding: 18,
      display: 'flex', flexDirection: 'column', gap: 14, minHeight: 168,
    }}>
      <div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
        <BrandLogo app={app} size={44} />
        <div style={{ flex: 1, minWidth: 0 }}>
          <div className="t-h3">{app.name}</div>
          <div className="text-secondary t-small" style={{ marginTop: 3, lineHeight: '18px' }}>{app.blurb}</div>
        </div>
      </div>

      {/* Status line (+ progress bar while syncing) */}
      {active && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
            {conn.syncing
              ? <Spin size={12} />
              : (rest.bad
                  ? <Icons.AlertTri size={12} style={{ color: rest.color, flexShrink: 0 }} />
                  : <StatusDot kind="live" size={7} />)}
            <span className="mono-sm mono" title={(!conn.syncing && conn.error) || undefined} style={{
              color: conn.syncing ? 'var(--text-secondary)' : rest.color,
              overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
            }}>
              {conn.syncing ? connectorSyncLabel(conn) : rest.text}
            </span>
          </div>
          {conn.syncing && <ConnectorSyncBar sp={conn.syncProgress} />}
        </div>
      )}
      {errored && conn.error !== 'unsupported' && (
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <StatusDot kind="error" size={7} />
          <span className="mono-sm mono" style={{ color: 'var(--error)' }}>Couldn't connect</span>
        </div>
      )}

      {/* Per-bucket content counts (Google splits into Drive/Gmail/Calendar/Contacts) */}
      {active && buckets && buckets.length > 0 && (
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
          {buckets.map(b => (
            <span key={b.key} className="mono-sm mono" style={{
              display: 'inline-flex', alignItems: 'center', gap: 5,
              padding: '3px 8px', borderRadius: 6,
              border: '1px solid var(--border-subtle)', background: 'var(--bg)',
              color: 'var(--text-secondary)',
            }}>
              {b.label} <span style={{ color: 'var(--text-tertiary)' }}>{b.items.length}</span>
            </span>
          ))}
        </div>
      )}

      {/* Action row */}
      <div style={{ marginTop: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
        {active ? (
          <>
            <span className="chip success" style={{ padding: '3px 8px' }}><span className="dot" /> Connected</span>
            <div style={{ flex: 1 }} />
            <button className="btn sm ghost" onClick={onManage}>Manage</button>
          </>
        ) : busy ? (
          <button className="btn sm" disabled style={{ opacity: 0.8 }}>
            <Spin /> Connecting…
          </button>
        ) : (
          <button className="btn accent sm" onClick={onStart}>
            {errored
              ? <><Icons.Refresh size={13} /> Retry</>
              : <><Icons.Plug size={13} /> Connect</>}
          </button>
        )}
      </div>
    </div>
  );
}

// Initial rows shown per expandable list (local folders and the Manage drawer's
// per-bucket lists). The rest stay collapsed behind a "Show more" control so a
// large source (e.g. hundreds of files/contacts) doesn't blow up the DOM until
// the user asks for it; each click reveals another page.
const PC_DRAWER_ITEM_LIMIT = 50;

// ── Local folder card (multiple folders, add/remove individually) ───────────
function localFolderStatusLabel(folder) {
  const total = folder.fileCount || 0;
  const uploaded = folder.uploadedCount || 0;
  const done = folder.doneCount || 0;
  const failed = folder.failedCount || 0;
  if (folder.status === 'indexing') {
    if (uploaded < total) return `Uploading ${uploaded}/${total}`;
    return `Indexing ${done}/${total}`;
  }
  if (folder.status === 'error') return failed ? `${failed} failed` : 'Failed';
  return `${total} files`;
}

// One added folder: a clickable header (expands into its file list, mirroring
// the per-bucket lists other connectors show) plus the existing status + remove.
// File titles come from the connector content endpoint, grouped by folder.
function LocalFolderRow({ folder, files, sourcesLoading, onRefresh, onRemove }) {
  const [open, setOpen] = usePcUi(false);
  const [visible, setVisible] = usePcUi(PC_DRAWER_ITEM_LIMIT);
  // Expandability keys off the locally-known file count, not the fetched list:
  // a just-added folder reports its count immediately while its files are still
  // being registered server-side, so it must stay openable to trigger the pull.
  const canExpand = (folder.fileCount || 0) > 0 || files.length > 0;
  const shown = files.slice(0, visible);
  const remaining = files.length - shown.length;
  const statusColor = folder.status === 'error'
    ? 'var(--error)'
    : (folder.status === 'indexing' ? 'var(--accent-text)' : 'var(--text-secondary)');
  // Lazily pull file titles when the folder is opened — the after-add refresh
  // can race the background ingestion, so re-fetch on demand.
  const toggle = () => setOpen(o => {
    const next = !o;
    if (next && files.length === 0 && onRefresh) onRefresh();
    return next;
  });
  return (
    <div style={{ border: '1px solid var(--border-subtle)', borderRadius: 8, overflow: 'hidden' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 10px' }}>
        <button
          className="btn sm ghost"
          style={{ padding: 2, flexShrink: 0, opacity: canExpand ? 1 : 0.3, cursor: canExpand ? 'pointer' : 'default' }}
          disabled={!canExpand}
          onClick={toggle}
          title={canExpand ? (open ? 'Collapse' : 'Show files') : 'No files to show'}
        >
          <Icons.ChevronR size={13} style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 120ms' }} />
        </button>
        <Icons.Box size={14} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
        <span style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={folder.folderPath || folder.name}>{folder.name}</span>
        <div style={{ flex: 1 }} />
        <span className="mono-sm mono" style={{ color: statusColor, display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>
          {folder.status === 'indexing' && <Spin size={11} />}
          {folder.status === 'error' && <Icons.AlertTri size={12} />}
          {localFolderStatusLabel(folder)}
        </span>
        <button className="btn sm ghost" style={{ padding: 4 }} onClick={() => onRemove(folder.id)} title="Remove folder">
          <Icons.X size={13} />
        </button>
      </div>
      {open && canExpand && (
        <div style={{ padding: '4px 10px 8px 34px', borderTop: '1px solid var(--border-subtle)', background: 'var(--bg)' }}>
          {files.length === 0 ? (
            <div className="t-small text-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
              {sourcesLoading ? <><Spin size={11} /> Loading…</> : 'No files indexed yet'}
            </div>
          ) : (
            <>
              {shown.map(it => (
                <div key={it.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
                  <span style={{ flex: 1, minWidth: 0, fontSize: 12.5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={it.name}>
                    {it.name}
                  </span>
                  <span className="mono-sm mono text-tertiary" style={{ flexShrink: 0 }}>{pcTimeAgo(it.ingested_at)}</span>
                </div>
              ))}
              {remaining > 0 && (
                <button
                  className="btn sm ghost"
                  style={{ marginTop: 4 }}
                  onClick={() => setVisible(v => v + PC_DRAWER_ITEM_LIMIT)}
                >
                  Show {Math.min(remaining, PC_DRAWER_ITEM_LIMIT)} more · {remaining} left
                </button>
              )}
            </>
          )}
        </div>
      )}
    </div>
  );
}

function LocalFolderCard({ app, rec, supported, sources, sourcesLoading, onRefresh, onAdd, onRemove }) {
  const folders = (rec && rec.folders) || [];
  const has = folders.length > 0;
  return (
    <div style={{
      background: 'var(--surface)',
      border: '1px solid var(--border-subtle)',
      borderRadius: 12, padding: 18,
      display: 'flex', flexDirection: 'column', gap: 14, minHeight: 168,
    }}>
      <div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
        <BrandLogo app={app} size={44} />
        <div style={{ flex: 1, minWidth: 0 }}>
          <div className="t-h3">{app.name}</div>
          <div className="text-secondary t-small" style={{ marginTop: 3, lineHeight: '18px' }}>{app.blurb}</div>
        </div>
        {has && (
          <span className="chip success" style={{ padding: '3px 8px' }}>
            <span className="dot" /> {folders.length}
          </span>
        )}
      </div>

      {has && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
          {folders.map(f => (
            <LocalFolderRow
              key={f.id}
              folder={f}
              files={pcLocalFolderFiles(sources, f)}
              sourcesLoading={sourcesLoading}
              onRefresh={onRefresh}
              onRemove={onRemove}
            />
          ))}
        </div>
      )}

      <div style={{ marginTop: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
        {!has && <span className="mono-sm mono text-tertiary">No folders yet</span>}
        <div style={{ flex: 1 }} />
        <button className="btn accent sm" onClick={onAdd} disabled={!supported}
          style={!supported ? { opacity: 0.5, cursor: 'not-allowed' } : {}}>
          <Icons.Plus size={13} /> Add folder
        </button>
      </div>
    </div>
  );
}

// ── Handshake overlay (simulated OAuth redirect) ────────────────────────────
function HandshakeOverlay({ app }) {
  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,.4)', zIndex: 95,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      animation: 'fadeIn 160ms',
    }}>
      <div style={{
        width: 380, background: 'var(--surface-raised)',
        borderRadius: 14, border: '1px solid var(--border)',
        boxShadow: 'var(--shadow-2)', padding: '32px 28px',
        display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16,
        textAlign: 'center',
        animation: 'slideUp 240ms cubic-bezier(.2,.8,.2,1)',
      }}>
        <BrandLogo app={app} size={52} radius={12} />
        <div>
          <div className="t-h3">Connecting to {app.name}</div>
          <div className="text-secondary t-small" style={{ marginTop: 4 }}>
            Redirecting to authorize, then waiting for approval…
          </div>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--accent-text)' }}>
          <Spin size={15} />
          <span className="mono-sm mono">Authorizing</span>
        </div>
      </div>
    </div>
  );
}

// ── Per-item sync status ────────────────────────────────────────────────────
// item status → chip styling, mirroring the conversations panel's per-item
// status chips. Covers the six backend connector_sync_items states.
const PC_ITEM_STATUS_META = {
  pending:             { label: 'Queued',      cls: '',        Icon: Icons.Dot },
  processing:          { label: 'Indexing',    cls: 'accent',  Icon: Icons.Spinner, spin: true },
  done:                { label: 'Done',        cls: 'success', Icon: Icons.Check },
  failed:              { label: 'Failed',      cls: 'error',   Icon: Icons.AlertTri },
  skipped_too_large:   { label: 'Too large',   cls: '',        Icon: Icons.X },
  skipped_unsupported: { label: 'Unsupported', cls: '',        Icon: Icons.X },
  skipped_too_small:   { label: 'Empty',       cls: '',        Icon: Icons.X },
};

function ConnectorItemStatus({ status }) {
  const m = PC_ITEM_STATUS_META[status];
  if (!m) return null;
  const Icon = m.Icon;
  return (
    <span className={'chip' + (m.cls ? ' ' + m.cls : '')}
      style={{ height: 20, padding: '2px 7px', fontSize: 11, gap: 5, flexShrink: 0 }}>
      <Icon size={11} style={m.spin ? { animation: 'parcle-spin .8s linear infinite' } : null} />
      {m.label}
    </span>
  );
}

// Per-file status list for the connector's most-recent sync job. Server-side
// offset pagination ("Show more" reveals another page); while a sync is running
// it polls so each row's status advances (queued → indexing → done/failed). The
// backend resolves the latest job, so this works after a reload too.
function ConnectorSyncItemsList({ slug, syncing }) {
  const [items, setItems] = usePcUi([]);
  const [total, setTotal] = usePcUi(0);
  const [loaded, setLoaded] = usePcUi(PC_SYNC_ITEMS_PAGE);
  const [loading, setLoading] = usePcUi(true);
  const [error, setError] = usePcUi(null);

  React.useEffect(() => {
    if (!slug) return;
    let alive = true;
    let timer = null;
    const load = async () => {
      try {
        const res = await PersonalConnectorsLiveAPI.listSyncItems(slug, { limit: loaded, offset: 0 });
        if (!alive) return;
        setItems(Array.isArray(res && res.items) ? res.items : []);
        setTotal((res && res.total) || 0);
        setError(null);
      } catch (e) {
        if (alive) setError((e && e.message) || String(e));
      } finally {
        if (alive) setLoading(false);
      }
    };
    load();
    if (syncing) timer = setInterval(load, PC_SYNC_POLL_MS);
    return () => { alive = false; if (timer) clearInterval(timer); };
  }, [slug, syncing, loaded]);

  const remaining = Math.max(0, total - items.length);

  if (loading && items.length === 0) {
    return (
      <div className="t-small text-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <Spin size={12} /> Loading…
      </div>
    );
  }
  if (error && items.length === 0) {
    return <div className="t-small text-tertiary">Couldn't load file status.</div>;
  }
  if (items.length === 0) {
    return <div className="t-small text-tertiary">No files synced yet</div>;
  }
  return (
    <div style={{ display: 'flex', flexDirection: 'column' }}>
      {items.map(it => (
        <div key={it.item_id} style={{
          display: 'flex', alignItems: 'center', gap: 8,
          padding: '6px 0', borderBottom: '1px solid var(--border-subtle)',
        }}>
          <span style={{ flex: 1, minWidth: 0, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
            title={it.error || it.item_name || it.item_id}>
            {it.item_name || it.item_id}
          </span>
          <ConnectorItemStatus status={it.status} />
        </div>
      ))}
      {remaining > 0 && (
        <button
          className="btn sm ghost"
          style={{ marginTop: 8, alignSelf: 'flex-start' }}
          onClick={() => setLoaded(l => l + PC_SYNC_ITEMS_PAGE)}
        >
          Show {Math.min(remaining, PC_SYNC_ITEMS_PAGE)} more · {remaining} left
        </button>
      )}
    </div>
  );
}

// ── Manage drawer ───────────────────────────────────────────────────────────
// One bucket's content list, with incremental "Show more" expansion. Holds its
// own visible-count state so expanding one source doesn't re-render the others.
function ConnectorBucketList({ bucket }) {
  const [visible, setVisible] = usePcUi(PC_DRAWER_ITEM_LIMIT);
  const items = bucket.items;
  const shown = items.slice(0, visible);
  const remaining = items.length - shown.length;
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
        <span className="t-small" style={{ fontWeight: 600 }}>{bucket.label}</span>
        <span className="mono-sm mono text-tertiary">{items.length}</span>
      </div>
      {items.length === 0 ? (
        <div className="t-small text-tertiary">No items yet</div>
      ) : (
        <div style={{ display: 'flex', flexDirection: 'column' }}>
          {shown.map(it => (
            <div key={it.id} style={{
              display: 'flex', alignItems: 'center', gap: 8,
              padding: '6px 0', borderBottom: '1px solid var(--border-subtle)',
            }}>
              <span style={{ flex: 1, minWidth: 0, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={it.name}>
                {it.name}
              </span>
              <span className="mono-sm mono text-tertiary" style={{ flexShrink: 0 }}>
                {pcTimeAgo(it.ingested_at)}
              </span>
            </div>
          ))}
          {remaining > 0 && (
            <button
              className="btn sm ghost"
              style={{ marginTop: 8, alignSelf: 'flex-start' }}
              onClick={() => setVisible(v => v + PC_DRAWER_ITEM_LIMIT)}
            >
              Show {Math.min(remaining, PC_DRAWER_ITEM_LIMIT)} more · {remaining} left
            </button>
          )}
        </div>
      )}
    </div>
  );
}

function PersonalConnectorDrawer({ app, conn, buckets, sourcesLoading, onClose, onSync, onDisconnect }) {
  const hasAnyContent = (buckets || []).some(b => b.items.length > 0);
  // OAuth connectors expose a per-file sync status list (live backend only).
  const slug = PC_BACKEND_SLUG[app.id];
  return (
    <div onClick={onClose} style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,.25)', zIndex: 90,
      display: 'flex', justifyContent: 'flex-end', animation: 'fadeIn 160ms',
    }}>
      <div onClick={e => e.stopPropagation()} style={{
        width: 480, height: '100%', background: 'var(--surface)',
        borderLeft: '1px solid var(--border)', boxShadow: 'var(--shadow-1)',
        display: 'flex', flexDirection: 'column',
        animation: 'slideInR 240ms cubic-bezier(.2,.8,.2,1)',
      }}>
        {/* Header */}
        <div style={{ padding: '20px 24px', borderBottom: '1px solid var(--border-subtle)', display: 'flex', alignItems: 'flex-start', gap: 14 }}>
          <BrandLogo app={app} size={44} />
          <div style={{ flex: 1, minWidth: 0 }}>
            <div className="t-h2">{app.name}</div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
              <StatusDot kind="live" size={7} />
              <span className="mono-sm mono text-secondary">
                {conn.identity || conn.connectedAccountId || 'Connected'}
              </span>
            </div>
          </div>
          <button className="btn sm ghost" onClick={onClose}><Icons.X size={14} /></button>
        </div>

        {/* Body */}
        <div style={{ flex: 1, overflowY: 'auto', padding: 24, display: 'flex', flexDirection: 'column', gap: 24 }}>
          <MetaGrid rows={[
            ['Account', conn.identity || conn.connectedAccountId || '—'],
            ['Last sync', conn.lastSync || '—'],
            ['Provider', 'Composio'],
            ['Access', 'Read-only'],
          ]} />

          <div>
            <div className="t-micro text-tertiary" style={{ marginBottom: 10 }}>
              AUTHORIZED SCOPES
            </div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
              {app.scopes.map(s => (
                <div key={s} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13 }}>
                  <Icons.Check size={14} style={{ color: 'var(--success)', flexShrink: 0 }} /> {s}
                </div>
              ))}
            </div>
          </div>

          {buckets && buckets.length > 0 && (
            <div>
              <div className="t-micro text-tertiary" style={{ marginBottom: 10 }}>
                CONTENT
              </div>
              {sourcesLoading && !hasAnyContent ? (
                <div className="t-small text-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                  <Spin size={12} /> Loading…
                </div>
              ) : (
                <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
                  {buckets.map(b => (
                    <ConnectorBucketList key={b.key} bucket={b} />
                  ))}
                </div>
              )}
            </div>
          )}

          {pcLive() && slug && (
            <div>
              <div className="t-micro text-tertiary" style={{ marginBottom: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
                SYNC STATUS
                {conn.syncing && <Spin size={11} />}
              </div>
              <ConnectorSyncItemsList slug={slug} syncing={!!conn.syncing} />
            </div>
          )}

          <div className="t-small text-tertiary" style={{ padding: 12, background: 'var(--bg)', borderRadius: 8, borderLeft: '2px solid var(--accent)' }}>
            Parcle never writes back to {app.name}. Read-only by default.
          </div>
        </div>

        {/* Footer */}
        <div style={{ padding: '16px 24px', borderTop: '1px solid var(--border-subtle)', display: 'flex', gap: 8 }}>
          <button className="btn" onClick={onSync} disabled={!!conn.syncing}>
            {conn.syncing ? <><Spin size={13} /> Syncing</> : <><Icons.Refresh size={13} /> Sync now</>}
          </button>
          <div style={{ flex: 1 }} />
          <button className="btn" onClick={onDisconnect} style={{ color: 'var(--error)', borderColor: 'var(--error-bg)' }}>
            <Icons.Trash size={13} /> Disconnect
          </button>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { PersonalConnectors });
