// Personal Connectors — connector definitions + mock "composio" client.
//
// This is the data/logic layer for the PERSONAL-mode Connectors surface
// (org mode keeps the existing ConnectorsHub). Three apps connect through
// Composio OAuth (Google / Notion / GitHub); "Local Folder" is a separate
// `local` provider driven by the on-machine daemon: it pops a native folder
// picker and hashes + prechecks + uploads the documents (deduped by device +
// path), so it needs the daemon installed rather than a Chromium browser.
//
// Everything async here has a mock fallback. The live path talks to the Parcle
// backend for OAuth connectors and uploads selected local folders through the
// regular ingestion endpoint.

const { useState: usePcState } = React;

// ── Connector catalog ──────────────────────────────────────────────────────
// `logo` keys into the BrandMark component in personal_connectors.jsx.
// `provider` discriminates the OAuth apps from the local-folder path.
const PERSONAL_APPS = [
  {
    id: 'google', name: 'Google', provider: 'composio', toolkit: 'googlesuper',
    logo: 'google', tileBg: '#ffffff',
    blurb: 'Drive, Gmail, Calendar & Contacts — one sign-in reads everything.',
    scopes: [
      'Google Drive — files & folders',
      'Gmail — messages & threads',
      'Calendar — events',
      'Contacts — people directory',
    ],
  },
  {
    id: 'notion', name: 'Notion', provider: 'composio', toolkit: 'notion',
    logo: 'notion', tileBg: '#ffffff',
    blurb: 'Pages, databases and wikis from your workspace.',
    scopes: [
      'Read pages & databases',
      'Read comments',
      'Workspace member directory',
    ],
  },
  {
    id: 'github', name: 'GitHub', provider: 'composio', toolkit: 'github',
    logo: 'github', tileBg: '#24292F',
    blurb: 'Repos, issues and pull requests you can access.',
    scopes: [
      'Read repositories & code',
      'Read issues & pull requests',
      'Read organization membership',
    ],
  },
  {
    id: 'hubspot', name: 'HubSpot', provider: 'composio', toolkit: 'hubspot',
    logo: 'hubspot', tileBg: '#ffffff',
    blurb: 'Contacts, companies and deals from your CRM.',
    scopes: [
      'Read contacts & companies',
      'Read deals & pipelines',
    ],
  },
  {
    id: 'linear', name: 'Linear', provider: 'composio', toolkit: 'linear',
    logo: 'linear', tileBg: '#5E6AD2',
    blurb: 'Issues and projects from your workspace.',
    scopes: [
      'Read issues',
      'Read projects',
    ],
  },
  {
    id: 'localfolder', name: 'Local Folder', provider: 'local',
    logo: 'folder', tileBg: '#475569',
    blurb: 'Index a folder on this computer into your personal workspace.',
    scopes: [
      'Read files in the selected folder',
    ],
  },
];

const PERSONAL_APP_BY_ID = PERSONAL_APPS.reduce((m, a) => { m[a.id] = a; return m; }, {});

// ── Connector content buckets ───────────────────────────────────────────────
// One Google OAuth fans out to four product buckets server-side; the content
// list endpoint (/personal/connectors/sources) tags each item with its bucket
// key, so the UI splits Google into four columns without parsing metadata.
// Other apps are a single bucket. Order here is the display order.
const PC_CONNECTOR_BUCKETS = {
  google: ['google_drive', 'google_gmail', 'google_calendar', 'google_contacts'],
  notion: ['notion'],
  github: ['github'],
  hubspot: ['hubspot'],
  linear: ['linear'],
};

const PC_BUCKET_LABEL = {
  google_drive: 'Drive',
  google_gmail: 'Gmail',
  google_calendar: 'Calendar',
  google_contacts: 'Contacts',
  notion: 'Notion',
  github: 'GitHub',
  hubspot: 'HubSpot',
  linear: 'Linear',
};

// Group a flat source list into the buckets for one app, in display order.
// Always returns every defined bucket (count 0 included) so e.g. Google shows
// all four columns even before some products have synced content.
function pcAppBuckets(appId, sources) {
  const keys = PC_CONNECTOR_BUCKETS[appId];
  if (!keys) return [];
  const list = Array.isArray(sources) ? sources : [];
  return keys.map(key => ({
    key,
    label: PC_BUCKET_LABEL[key] || key,
    items: list.filter(s => s.connector === key),
  }));
}

// Files (connector === 'localfolder') belonging to one added folder. The stable
// key is the folder's full path (folder_path): two folders that share a basename
// (/a/docs and /b/docs) stay separate. Falls back to basename matching when the
// folder record predates folder_path (older daemon / stored record), preserving
// the previous behaviour rather than showing nothing.
function pcLocalFolderFiles(sources, folder) {
  const list = (Array.isArray(sources) ? sources : []).filter(s => s.connector === 'localfolder');
  if (folder && folder.folderPath) {
    return list.filter(s => s.folder_path === folder.folderPath);
  }
  const name = (folder && folder.name) || '';
  return list.filter(s => (s.folder || '') === name);
}

// Compact relative time for content rows ("just now", "5m ago", "3h ago",
// "2d ago", or a locale date past a week). Input is a backend ISO timestamp.
function pcTimeAgo(iso) {
  if (!iso) return '';
  const t = Date.parse(iso);
  if (Number.isNaN(t)) return '';
  const secs = Math.max(0, Math.floor((Date.now() - t) / 1000));
  if (secs < 45) return 'just now';
  const mins = Math.floor(secs / 60);
  if (mins < 60) return `${mins}m ago`;
  const hrs = Math.floor(mins / 60);
  if (hrs < 24) return `${hrs}h ago`;
  const days = Math.floor(hrs / 24);
  if (days < 7) return `${days}d ago`;
  return new Date(t).toLocaleDateString();
}

// ── Mock persistence ────────────────────────────────────────────────────────
const PC_STORE_KEY = 'parcle.personalConnectors';

// Identity each app reports once "authorized" (mock OAuth account labels).
const PC_MOCK_IDENTITY = {
  google: 'alex@gmail.com',
  notion: "Alex's Workspace",
  github: '@alexdev',
};

function pcId(prefix) {
  return prefix + '_' + Math.random().toString(36).slice(2, 10);
}

// What survives a reload: OAuth connectors only when `active` (a refresh
// mid-OAuth would otherwise leave a card stuck), and the local-folder connector
// whenever it holds folders (each restored as 'ready', never stuck 'indexing').
function pcLoad() {
  let raw;
  try { raw = JSON.parse(localStorage.getItem(PC_STORE_KEY)) || {}; }
  catch (e) { return {}; }
  const clean = {};
  Object.keys(raw).forEach(id => {
    const r = raw[id];
    if (!r) return;
    if (id === 'localfolder') {
      const folders = Array.isArray(r.folders) ? r.folders.filter(Boolean) : [];
      if (folders.length) {
        clean[id] = { ...r, appId: 'localfolder', folders: folders.map(f => ({ ...f, status: 'ready' })) };
      }
    } else if (r.status === 'active') {
      clean[id] = r;
    }
  });
  return clean;
}

function pcSave(map) {
  try { localStorage.setItem(PC_STORE_KEY, JSON.stringify(map)); } catch (e) {}
}

// ── Local folder (File System Access API) ───────────────────────────────────
function pcSupportsLocalFolder() {
  return typeof window.showDirectoryPicker === 'function';
}

const PC_LOCAL_SKIP_FILES = new Set(['.DS_Store', 'Thumbs.db']);

function pcLocalEntrySkipped(name, kind) {
  if (!name) return true;
  if (PC_LOCAL_SKIP_FILES.has(name)) return true;
  return false;
}

function pcLocalJoin(base, name) {
  return base ? `${base}/${name}` : name;
}

async function pcCollectLocalFiles(handle, basePath = '') {
  const files = [];
  for await (const [name, entry] of handle.entries()) {
    if (pcLocalEntrySkipped(name, entry.kind)) continue;
    const relativePath = pcLocalJoin(basePath, name);
    if (entry.kind === 'file') {
      files.push({ file: await entry.getFile(), relativePath });
    } else if (entry.kind === 'directory') {
      files.push(...await pcCollectLocalFiles(entry, relativePath));
    }
  }
  return files;
}

// Opens the native directory picker and recursively collects files. Throws
// AbortError if the user cancels the picker.
async function pcPickLocalFolder() {
  const handle = await window.showDirectoryPicker();
  const files = await pcCollectLocalFiles(handle);
  return { folderName: handle.name, fileCount: files.length, files };
}

// ── Mock Composio client ────────────────────────────────────────────────────
// Shaped like the real connection lifecycle. Swap these bodies for fetch()
// calls against the backend when wiring live — signatures stay identical.
const PersonalConnectorsAPI = {
  // Real: POST /connections → { connectionId, redirectUrl, status:'pending' }.
  initiate(appId) {
    return Promise.resolve({
      connectionId: pcId('conn'),
      redirectUrl: `https://connect.composio.dev/oauth/${appId}`,
      status: 'pending',
    });
  },
  // Real: poll GET /connections/{id} until status === 'active'.
  waitForActive(appId, connectionId) {
    return new Promise(resolve => setTimeout(() => resolve({
      connectionId,
      status: 'active',
      identity: PC_MOCK_IDENTITY[appId] || 'Connected',
    }), 1400));
  },
  // Real: DELETE /connections/{id}.
  disconnect(appId, connectionId) {
    return Promise.resolve();
  },
};

// ── Live backend wiring ─────────────────────────────────────────────────────
// Personal mode only exists with a live backend (mode is derived from a
// signed-in account), so in practice we always hit the real endpoints here.
// The mock branch above stays as a fallback when PARCLE_API_BASE is unset so
// the page still demos standalone.
//
// Composio is reached through the parcle backend, NOT the TS connector service:
//   POST   /personal/connectors/{slug}/connect            → { redirect_url, ... }
//   GET    /personal/connectors/{slug}/connection         → { status, is_active, ... }
//   GET    /personal/connectors/{slug}/connection/wait    → long-poll until active, then backend starts ingestion
//   POST   /personal/connectors/{slug}/sync               → manual ingestion
//   GET    /ingestion/jobs/{job_id}                       → ingestion job status
//   DELETE /personal/connectors/{slug}/connection         → revoke at Composio + clear record
// userId is derived server-side from the signed-in session (parcle_{account}).
function pcLive() {
  try { return !!(typeof parcleApiBase === 'function' && parcleApiBase()); }
  catch (e) { return false; }
}

// Frontend app id → backend connector slug. The backend connection layer
// exposes a single umbrella "google" connector (one OAuth = the whole
// googlesuper toolkit). Local folder does not use a connector slug because it
// uploads directly to /ingestion/file after the browser grants directory access.
const PC_BACKEND_SLUG = { google: 'google', notion: 'notion', github: 'github', hubspot: 'hubspot', linear: 'linear' };

const PC_OAUTH_CALLBACK = 'oauth_callback.html';
const PC_WAIT_TIMEOUT_S = 30;          // each server-side long-poll blocks up to this
const PC_OVERALL_TIMEOUT_MS = 180_000; // give up waiting for OAuth after this
const PC_SYNC_POLL_MS = 2000;
const PC_SYNC_POLL_ACTIVE_MS = 1000;   // faster poll while a sync runs, so the live discovered count visibly climbs
const PC_SYNC_TIMEOUT_MS = 30 * 60_000;
const PC_SYNC_ITEMS_PAGE = 50;         // per-item status list page size (offset pagination)

function pcHeaders() {
  const auth = (typeof parcleAuthHeaders === 'function') ? parcleAuthHeaders() : {};
  return { 'Content-Type': 'application/json', ...auth };
}

async function pcApi(method, path, body) {
  const res = await fetch(parcleApiBase() + path, {
    method,
    headers: pcHeaders(),
    body: body ? JSON.stringify(body) : undefined,
  });
  if (res.status === 401 && typeof parcleNotifyUnauthorized === 'function') parcleNotifyUnauthorized();
  if (!res.ok) {
    let detail = '';
    try { detail = (await res.json()).detail || ''; } catch (e) {}
    throw new Error(`${method} ${path} → ${res.status}${detail ? ' ' + detail : ''}`);
  }
  if (res.status === 204) return {};
  try { return await res.json(); } catch (e) { return {}; }
}

async function pcUploadFile(path, file, filename) {
  const form = new FormData();
  form.append('file', file, filename || file.name || 'upload');
  const res = await fetch(parcleApiBase() + path, {
    method: 'POST',
    headers: (typeof parcleAuthHeaders === 'function') ? parcleAuthHeaders() : {},
    body: form,
  });
  if (res.status === 401 && typeof parcleNotifyUnauthorized === 'function') parcleNotifyUnauthorized();
  if (!res.ok) {
    let detail = '';
    try { detail = (await res.json()).detail || ''; } catch (e) {}
    throw new Error(`POST ${path} → ${res.status}${detail ? ' ' + detail : ''}`);
  }
  try { return await res.json(); } catch (e) { return {}; }
}

const PersonalConnectorsLiveAPI = {
  connect(slug, callbackUrl) {
    return pcApi('POST', `/personal/connectors/${slug}/connect`, { callback_url: callbackUrl, settings: {} });
  },
  status(slug) {
    return pcApi('GET', `/personal/connectors/${slug}/connection`);
  },
  // List connector-loaded content, newest first. Optionally scope to one bucket
  // (e.g. 'google_drive'); omitted = all buckets across all connectors.
  listSources(connector) {
    const q = connector ? `?connector=${encodeURIComponent(connector)}` : '';
    return pcApi('GET', `/personal/connectors/sources${q}`);
  },
  // Per-item status of the connector's most recent sync job, paginated. The
  // backend resolves the latest job server-side, so this works after a reload
  // without the client tracking a job id. Returns { job_id, job_status, total,
  // items: [{ item_name, item_type, connector, status, error, ... }] }.
  listSyncItems(slug, { status, limit = PC_SYNC_ITEMS_PAGE, offset = 0 } = {}) {
    const params = new URLSearchParams();
    if (status) params.set('status', status);
    params.set('limit', String(limit));
    params.set('offset', String(offset));
    return pcApi('GET', `/personal/connectors/${slug}/sync-items?${params.toString()}`);
  },
  // Server-side long-poll: blocks until active or `timeoutSeconds` elapse.
  // accountId is the connected_account_id returned by connect — the backend
  // waits on it and only persists the connection record once it goes active.
  wait(slug, timeoutSeconds, accountId) {
    const acc = accountId ? `&account_id=${encodeURIComponent(accountId)}` : '';
    return pcApi('GET', `/personal/connectors/${slug}/connection/wait?timeout=${timeoutSeconds}${acc}`);
  },
  sync(slug) {
    return pcApi('POST', `/personal/connectors/${slug}/sync`, { settings: {} });
  },
  job(jobId) {
    return pcApi('GET', `/ingestion/jobs/${encodeURIComponent(jobId)}`);
  },
  ingestFile(file, filename) {
    return pcUploadFile('/ingestion/file', file, filename);
  },
  // Disconnect: revoke at Composio + clear the local record (so it doesn't
  // re-appear connected on the next status read).
  disconnect(slug) {
    return pcApi('DELETE', `/personal/connectors/${slug}/connection`);
  },
};

// Wait until Composio reports the connection active, using the backend's
// server-side long-poll (one request blocks up to PC_WAIT_TIMEOUT_S — the
// recommended Composio "wait_for_connection" pattern). The OAuth popup may be
// cross-origin and COOP-isolated, so do not read the popup's closed state as a success or
// cancel signal; the backend connection state is authoritative.
const PC_TERMINAL_FAIL = ['FAILED', 'EXPIRED', 'REVOKED', 'INACTIVE'];

async function pcWaitForActive(slug, accountId) {
  const deadline = Date.now() + PC_OVERALL_TIMEOUT_MS;
  let last = null;
  while (Date.now() < deadline) {
    last = await PersonalConnectorsLiveAPI.wait(slug, PC_WAIT_TIMEOUT_S, accountId)
      .then(r => r, () => null);
    if (last && last.is_active) return last;
    // Stop early on a terminal failure instead of retrying until the overall
    // timeout — the OAuth won't recover on its own.
    if (last && last.status && PC_TERMINAL_FAIL.includes(last.status)) return last;
  }
  return last;
}

function pcClosePopup(popup) {
  try { if (popup) popup.close(); } catch (e) {}
}

async function pcWaitForJob(jobId) {
  const deadline = Date.now() + PC_SYNC_TIMEOUT_MS;
  while (Date.now() < deadline) {
    const current = await PersonalConnectorsLiveAPI.job(jobId);
    if (current && (current.status === 'done' || current.status === 'failed')) return current;
    await new Promise(r => setTimeout(r, PC_SYNC_POLL_MS));
  }
  return { id: jobId, status: 'failed', error: 'sync_timeout' };
}

// Normalize a sync job payload + job-level progress into the shape the connector
// card renders. The backend's three-phase pipeline (discover → export → ingest)
// reports its progress as:
//   payload.phase            'discovering' | 'exporting' | 'ingesting' | 'done' | 'failed'
//   payload.counts           job-wide per-item counts: { total, pending,
//                            processing, done, failed, skipped_too_large,
//                            skipped_unsupported } — total is known right after
//                            the manifest phase, so it gives early feedback even
//                            on huge syncs.
//   payload.products_detail  { <product>: { status, files:{total,done}, current } }
//   jobProgress              the job's 0..1 progress (already smoothed across
//                            products server-side) — drives the progress bar.
// `total`/`processed` come from counts (authoritative across all products);
// `current` is the item name of whichever product is mid-flight.
function pcSyncProgress(payload, jobProgress) {
  if (!payload || typeof payload !== 'object') return null;
  const counts = payload.counts || null;
  const phase = payload.phase || null;
  if (!counts && !phase) return null;

  const total = counts ? (counts.total || 0) : 0;
  const processed = counts
    ? (counts.done || 0) + (counts.failed || 0)
      + (counts.skipped_too_large || 0) + (counts.skipped_unsupported || 0)
      + (counts.skipped_too_small || 0)
    : 0;

  // The "current" file comes from the product that's actively running.
  let current = '';
  let product = payload.product || null;
  const detail = payload.products_detail;
  if (detail && typeof detail === 'object') {
    const active = Object.keys(detail).find(k => {
      const s = detail[k] && detail[k].status;
      return s === 'discovering' || s === 'exporting' || s === 'ingesting';
    });
    const pick = active || product;
    if (pick && detail[pick]) {
      current = detail[pick].current || '';
      if (active) product = active;
    }
  }

  return {
    phase,
    product,
    total,
    processed,
    current,
    progress: typeof jobProgress === 'number' ? jobProgress : null,
  };
}

// ── Hook ────────────────────────────────────────────────────────────────────
// Owns the appId → connection map. Two record shapes:
//   OAuth apps:   { appId, status: 'connecting'|'active'|'error',
//                   connectionId|connectedAccountId, identity, connectedAt, lastSync,
//                   syncing, syncJobId, error }
//   Local folder: { appId, folders: [{ id, name, fileCount, status, addedAt,
//                   uploadedCount, doneCount, failedCount, error }] }
function usePersonalConnectors() {
  const [conns, setConns] = usePcState(pcLoad);
  // Flat list of connector-loaded content (all buckets). Drives both the
  // per-bucket counts on each card and the item lists in the manage drawer.
  const [sources, setSources] = usePcState([]);
  const [sourcesLoading, setSourcesLoading] = usePcState(false);

  const refreshSources = async () => {
    if (!pcLive()) return;
    setSourcesLoading(true);
    try {
      const res = await PersonalConnectorsLiveAPI.listSources();
      setSources(Array.isArray(res && res.sources) ? res.sources : []);
    } catch (e) {
      // Leave the previous list in place; stale counts self-correct on the next
      // successful load (mount, sync completion, or reconnect).
    } finally {
      setSourcesLoading(false);
    }
  };

  const patch = (appId, fields) => {
    setConns(prev => {
      const next = { ...prev, [appId]: { ...(prev[appId] || {}), appId, ...fields } };
      pcSave(next);
      return next;
    });
  };

  const patchExisting = (appId, fields) => {
    setConns(prev => {
      if (!prev[appId]) return prev;
      const next = { ...prev, [appId]: { ...prev[appId], appId, ...fields } };
      pcSave(next);
      return next;
    });
  };

  const trackSyncJob = async (appId, job) => {
    if (!pcLive() || !job || !job.id) return;
    const deadline = Date.now() + PC_SYNC_TIMEOUT_MS;
    patchExisting(appId, { syncing: true, syncJobId: job.id, lastSync: 'syncing', lastSyncEmpty: false, error: null, syncProgress: null });
    while (Date.now() < deadline) {
      let current;
      try { current = await PersonalConnectorsLiveAPI.job(job.id); }
      catch (e) {
        patchExisting(appId, { syncing: false, lastSync: 'sync unknown', error: (e && e.message) || String(e), syncProgress: null });
        return;
      }
      if (current && current.status === 'done') {
        // Distinguish "synced, nothing found" (discovered 0 items) from a normal
        // sync so the card can say "No items found" instead of a bare "Connected".
        const finalSp = pcSyncProgress(current.payload, current.progress);
        const empty = !!finalSp && finalSp.total === 0;
        patchExisting(appId, {
          syncing: false,
          lastSync: 'just now',
          lastSyncEmpty: empty,
          syncJobId: job.id, error: null, syncProgress: null,
        });
        refreshSources();  // new content landed — refresh bucket counts + lists
        return;
      }
      if (current && current.status === 'failed') {
        patchExisting(appId, { syncing: false, lastSync: 'failed', syncJobId: job.id, error: current.error || 'sync_failed', syncProgress: null });
        return;
      }
      // Surface the server's mid-run progress (server-driven connectors report
      // per-item counts + the item being processed in the job payload, plus a
      // smoothed 0..1 job progress for the bar).
      const sp = pcSyncProgress(current && current.payload, current && current.progress);
      if (sp) patchExisting(appId, { syncProgress: sp });
      await new Promise(r => setTimeout(r, PC_SYNC_POLL_ACTIVE_MS));
    }
    patchExisting(appId, { syncing: false, lastSync: 'sync unknown', syncJobId: job.id, error: 'sync_timeout', syncProgress: null });
  };

  const patchLocalFolder = (folderId, fields) => {
    setConns(prev => {
      const rec = prev.localfolder;
      if (!rec) return prev;
      let found = false;
      const folders = (rec.folders || []).map(f => {
        if (f.id !== folderId) return f;
        found = true;
        return { ...f, ...fields };
      });
      if (!found) return prev;
      const next = { ...prev, localfolder: { ...rec, folders } };
      pcSave(next);
      return next;
    });
  };

  const clear = (appId) => {
    setConns(prev => {
      const next = { ...prev };
      delete next[appId];
      pcSave(next);
      return next;
    });
  };

  // Hydrate real connection state from the backend on mount (live mode only).
  // Backend is the source of truth; a stale local 'active' is dropped if the
  // account is no longer connected server-side.
  React.useEffect(() => {
    if (!pcLive()) return;
    let cancelled = false;
    refreshSources();  // load connector content once for cards + drawer
    (async () => {
      for (const app of PERSONAL_APPS) {
        const slug = PC_BACKEND_SLUG[app.id];
        if (!slug) continue; // local folder is hydrated from localStorage
        let s;
        try { s = await PersonalConnectorsLiveAPI.status(slug); }
        catch (e) { continue; } // leave as-is; surfaced on user action
        if (cancelled) return;
        if (s && s.is_active) {
          patch(app.id, {
            status: 'active',
            connectedAccountId: s.connected_account_id,
            identity: null, // clears any opaque id saved by an older build
            connectedAt: Date.now(),
            lastSync: 'synced',
            syncing: false,
            syncJobId: null,
            error: null,
          });
          // A sync may still be running server-side (e.g. the user refreshed
          // mid-sync). Resume tracking the latest job so progress isn't lost on
          // reload — the backend resolves "latest job" for us, so the client
          // doesn't need to have persisted a job id.
          try {
            const latest = await PersonalConnectorsLiveAPI.listSyncItems(slug, { limit: 1 });
            if (cancelled) return;
            const jobId = latest && latest.job_id;
            const jobStatus = latest && latest.job_status;
            if (jobId && (jobStatus === 'running' || jobStatus === 'pending')) {
              trackSyncJob(app.id, { id: jobId });  // sets syncing:true + polls to completion
            }
          } catch (e) {
            // No resumable job / endpoint unavailable — leave as connected.
          }
        } else {
          clear(app.id);
        }
      }
    })();
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // OAuth apps (Google / Notion / GitHub) — simulated when no live backend.
  const connectMock = async (appId) => {
    patch(appId, { status: 'connecting', error: null });
    try {
      const init = await PersonalConnectorsAPI.initiate(appId);
      const done = await PersonalConnectorsAPI.waitForActive(appId, init.connectionId);
      patch(appId, {
        status: 'active',
        connectionId: done.connectionId,
        identity: done.identity,
        connectedAt: Date.now(),
        lastSync: 'just now',
        error: null,
      });
    } catch (e) {
      patch(appId, { status: 'error', error: (e && e.message) || String(e) });
    }
  };

  // Live OAuth: open a popup at Composio's hosted consent, then poll the
  // backend until the account is active. window.open is called up front (a
  // later open, after the await, is blocked as a non-user-gesture popup).
  const connectComposio = async (appId) => {
    const slug = PC_BACKEND_SLUG[appId];
    patch(appId, { status: 'connecting', error: null });
    // Open the popup inside the click gesture (before any await) so it isn't
    // blocked. If the browser still blocks it, bail immediately rather than
    // stranding the user on the spinner until the poll times out.
    const popup = window.open('about:blank', 'composio_oauth', 'width=520,height=660');
    if (!popup) {
      patch(appId, { status: 'error', error: 'popup_blocked' });
      return;
    }
    try {
      const callbackUrl = `${window.location.origin}/${PC_OAUTH_CALLBACK}`;
      const link = await PersonalConnectorsLiveAPI.connect(slug, callbackUrl);
      const accountId = link && link.connected_account_id;
      if (link && link.redirect_url) popup.location = link.redirect_url;
      const final = await pcWaitForActive(slug, accountId);
      pcClosePopup(popup);
      if (final && final.is_active) {
        const syncJob = final.sync_job || null;
        patch(appId, {
          status: 'active',
          connectedAccountId: final.connected_account_id,
          identity: null, // live has no human identity; show "Connected" not the account id
          connectedAt: Date.now(),
          lastSync: syncJob ? 'syncing' : 'just now',
          syncing: !!syncJob,
          syncJobId: syncJob && syncJob.id,
          error: null,
        });
        if (syncJob) trackSyncJob(appId, syncJob);
      } else {
        patch(appId, { status: 'error', error: 'not_completed' });
      }
    } catch (e) {
      pcClosePopup(popup);
      patch(appId, { status: 'error', error: (e && e.message) || String(e) });
    }
  };

  const connect = (appId) => pcLive() ? connectComposio(appId) : connectMock(appId);

  const syncNow = async (appId) => {
    const slug = PC_BACKEND_SLUG[appId];
    if (!pcLive() || !slug) {
      patch(appId, { syncing: true, lastSync: 'syncing', error: null });
      await new Promise(r => setTimeout(r, 1200));
      patchExisting(appId, { syncing: false, lastSync: 'just now', error: null });
      return;
    }
    try {
      patch(appId, { syncing: true, lastSync: 'syncing', error: null });
      const job = await PersonalConnectorsLiveAPI.sync(slug);
      await trackSyncJob(appId, job);
    } catch (e) {
      patchExisting(appId, { syncing: false, lastSync: 'failed', lastSyncEmpty: false, error: (e && e.message) || String(e) });
    }
  };

  // Local folder — the on-machine daemon pops a native OS folder picker, then
  // hashes + prechecks + uploads the documents inside (deduped by device+path).
  // The browser only triggers it and shows the result; device_id stays in the
  // daemon. Requires the daemon (Devices section) — there's no browser fallback.
  const addLocalFolder = async () => {
    const appId = 'localfolder';
    const daemon = (typeof window.tsLocalDaemon === 'function') ? await window.tsLocalDaemon() : null;
    if (!daemon) {
      patch(appId, { error: 'no_daemon' });
      return;
    }
    if (!daemon.gui_available) {
      patch(appId, { error: 'This machine has no graphical session to open a folder picker.' });
      return;
    }
    // The daemon owns the pick → we don't know the folder until it returns, so
    // show a transient picking row instead of a per-file progress bar.
    const tmpId = pcId('fld');
    const pending = {
      id: tmpId, name: 'Selecting folder…', fileCount: 0,
      status: 'indexing', addedAt: Date.now(),
      uploadedCount: 0, doneCount: 0, failedCount: 0, error: null,
    };
    setConns(prev => {
      const rec = prev.localfolder || { appId, folders: [] };
      const next = { ...prev, localfolder: { ...rec, appId, error: null, folders: [...(rec.folders || []), pending] } };
      pcSave(next);
      return next;
    });
    let r;
    try {
      r = await window.tsLocalPickAndIngest();
    } catch (e) {
      patchLocalFolder(tmpId, { status: 'error', error: (e && e.message) || String(e) });
      return;
    }
    if (r && r.reason === 'cancelled') {
      removeLocalFolder(tmpId);   // user backed out of the picker — drop the row
      return;
    }
    if (!r || !r.ok) {
      patchLocalFolder(tmpId, { status: 'error', error: (r && (r.error || r.reason)) || 'Ingest failed.' });
      return;
    }
    // Skipped = already up to date on a re-add; count it as done for the label.
    patchLocalFolder(tmpId, {
      name: r.folder || 'Folder',
      folderPath: r.folder_path || null,  // stable key for grouping its files (basename can collide)
      fileCount: r.total || 0,
      uploadedCount: (r.uploaded || 0) + (r.skipped || 0),
      doneCount: (r.uploaded || 0) + (r.skipped || 0),
      failedCount: r.failed || 0,
      status: r.failed ? 'error' : 'ready',
      error: r.failed ? `${r.failed} files failed` : null,
    });
    refreshSources();  // pull the just-uploaded files so the folder can expand into them
  };

  // Remove a single folder; drop the whole record once the last one is gone.
  const removeLocalFolder = (folderId) => {
    setConns(prev => {
      const rec = prev.localfolder;
      if (!rec) return prev;
      const folders = (rec.folders || []).filter(f => f.id !== folderId);
      const next = { ...prev };
      if (folders.length) next.localfolder = { ...rec, folders };
      else delete next.localfolder;
      pcSave(next);
      return next;
    });
  };

  const start = (app) => app.provider === 'local' ? addLocalFolder() : connect(app.id);

  const disconnect = async (appId) => {
    const slug = PC_BACKEND_SLUG[appId];
    if (pcLive() && slug) {
      // Clear locally even if the backend call fails — the user asked to remove it.
      try { await PersonalConnectorsLiveAPI.disconnect(slug); } catch (e) {}
    }
    clear(appId);
    refreshSources();  // drop the disconnected connector's content from the lists
  };

  return { conns, start, connect, syncNow, addLocalFolder, removeLocalFolder, disconnect, sources, sourcesLoading, refreshSources };
}

Object.assign(window, {
  PERSONAL_APPS, PERSONAL_APP_BY_ID, PC_SYNC_ITEMS_PAGE, PC_SYNC_POLL_MS, PC_BACKEND_SLUG,
  PC_CONNECTOR_BUCKETS, PC_BUCKET_LABEL, pcAppBuckets, pcLocalFolderFiles, pcTimeAgo,
  PersonalConnectorsAPI, PersonalConnectorsLiveAPI, usePersonalConnectors,
  pcSupportsLocalFolder, pcLive, pcPickLocalFolder,
});
