// Workspace — live data client.
// Replaces the prototype's hardcoded mock data. Fetches from /api/* on boot
// and on demand, exposes the same window globals that the rest of the .jsx
// modules read (`TASKS`, `PHASES`, `VAULT_TREE`, `RECENT_DOCS`, `ME`), plus
// helper hooks for components that need imperative access.

// ── tiny fetch wrapper ────────────────────────────────────────────────────
// Phase-2 multi-tenancy: every API request carries the active project as the
// `X-Project-Id` header. Routes that don't need it (login, /api/me, projects
// CRUD) ignore it; everything else uses it as the scope. The backend will
// 400 if the project isn't set on a route that requires it.
async function api(method, path, body) {
  const headers = body !== undefined ? { 'Content-Type': 'application/json' } : {};
  // Resolve the active project id with a localStorage fallback. Lucho hit
  // "projectId is required" while the kanban was rendered — symptom of
  // CURRENT_PROJECT_ID being temporarily null on a re-render after a stale
  // realtime event reset state. Falling back to the persisted id closes
  // that gap without forcing the user to re-pick the project.
  let pid = window.CURRENT_PROJECT_ID;
  if (!pid) {
    try { pid = localStorage.getItem('ws.currentProjectId') || null; } catch { /* noop */ }
  }
  if (pid) headers['X-Project-Id'] = pid;
  const res = await fetch(path, {
    method,
    credentials: 'same-origin',
    headers,
    body: body !== undefined ? JSON.stringify(body) : null,
  });
  let data = null;
  try { data = await res.json(); } catch { /* empty body */ }
  if (!res.ok) {
    const err = new Error((data && data.error) || `HTTP ${res.status}`);
    err.status = res.status;
    err.body = data;
    throw err;
  }
  return data;
}

// ── shape converters: real API → prototype's expected shape ───────────────
const minsAgoFromIso = (iso) => {
  if (!iso) return 0;
  return Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000));
};
const dueInFromIso = (iso) => {
  if (!iso) return undefined;
  const ms = new Date(iso).getTime() - Date.now();
  return Math.round(ms / 86_400_000);
};
const formatBytes = (b) => {
  if (!b) return '0 B';
  if (b < 1024) return `${b} B`;
  if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
  return `${(b / 1024 / 1024).toFixed(1)} MB`;
};

const taskToProto = (t) => ({
  id: t.id,
  title: t.title,
  status: t.status,
  phase: t.phaseId,
  prio: t.priority,
  assignee: t.assigneeId,
  comments: t.commentsCount ?? 0,
  attach: (t.attachments || []).length,
  blockedBy: Array.isArray(t.blockedBy) ? t.blockedBy : (t.blockedBy ? [t.blockedBy] : []),
  isReady: t.isReady ?? true,
  estimatedHours: t.estimatedHours ?? null,
  actualHours: t.actualHours ?? null,
  dueIn: dueInFromIso(t.dueAt),
});

const phaseToProto = (p) => ({
  id: p.id,
  name: p.name,
  description: p.description || '',
  done: p.done,
  total: p.total,
  startsAt: p.startsAt || null,
  dueAt: p.dueAt || null,
  ownerId: p.ownerId || null,
  tags: p.tags || [],
  due: p.dueAt
    ? new Date(p.dueAt + 'T00:00:00Z').toLocaleDateString('es', { day: 'numeric', month: 'short' })
    : '—',
  status: p.status,
});

const recentDocToProto = (d) => ({
  id: d.id,
  // `folder` is the sidebar slug (e.g. "specs"); the path is the full
  // "slug/file.md". Backend now returns folderSlug separately so the UI
  // doesn't have to split the path itself.
  folder: d.folderSlug || (d.path && d.path.split('/')[0]) || d.folderId,
  title: d.title,
  excerpt: 'Documento del Vault.',
  author: d.authorId,
  mins: minsAgoFromIso(d.updatedAt),
  path: d.path,
});

const folderDocToProto = (d) => ({
  id: d.slug,
  docId: d.id,
  title: d.title,
  author: d.authorId,
  mins: minsAgoFromIso(d.updatedAt),
  size: formatBytes(d.sizeBytes),
});

// ── subscriptions for re-render ───────────────────────────────────────────
const subscribers = new Set();
function publish() {
  subscribers.forEach((cb) => {
    try { cb(); } catch (e) { console.error('data subscriber failed', e); }
  });
}
function subscribeData(cb) {
  subscribers.add(cb);
  return () => subscribers.delete(cb);
}
function useData() {
  const [v, setV] = React.useState(0);
  React.useEffect(() => subscribeData(() => setV((n) => n + 1)), []);
  return v;
}

// ── boot loaders ──────────────────────────────────────────────────────────
async function loadMe() {
  try { window.ME = await api('GET', '/api/me'); }
  catch { window.ME = null; }
  // Register the logged-in user in the global ACTORS dict so any chrome
  // that does `ACTORS[identity].name` works even though the legacy ACTORS
  // map only knew about the four seeded ids.
  if (window.ME && window.ACTORS) {
    window.ACTORS[window.ME.id] = {
      id: window.ME.id,
      name: window.ME.name || window.ME.email || 'Yo',
      role: window.ME.role || '',
      color: window.ME.color || 'var(--accent)',
      initial: _initialFor(window.ME.name, window.ME.email),
      desc: '',
    };
    // Also register my per-user Claude agent. The OAuth flow attributes
    // claude.ai's actions to this agent (id = "cl_<myUserId-no-dashes>");
    // without registering it in ACTORS, comments authored by the agent
    // would render with the proxy fallback (truncated id, generic color).
    _registerClaudeAgentForUserId(window.ME.id, window.ME.name);
  }
}

// Per-vendor MCP agent registry. Mirrors src/services/mcpAgents.ts on the
// backend — when a user connects an MCP client (claude.ai, ChatGPT, …),
// the backend issues OAuth tokens bound to a per-(user, vendor) agent
// actor with id `<prefix>_<userId-no-dashes>`. The frontend ACTORS dict
// needs to know about each so that comments / activity rows produced via
// MCP render with the right name and brand color instead of falling
// through to the Proxy fallback.
const _MCP_VENDORS = [
  { prefix: 'cl',  name: 'Claude',  color: '#F97316', initial: 'C', via: 'Claude.ai' },
  { prefix: 'gpt', name: 'ChatGPT', color: '#10A37F', initial: 'G', via: 'ChatGPT' },
  { prefix: 'mcp', name: 'MCP',     color: '#94A3B8', initial: 'M', via: 'MCP' },
];

function _registerMcpAgentsForUserId(userId, ownerName) {
  if (!userId || !window.ACTORS) return;
  const noDash = String(userId).replace(/-/g, '');
  for (const v of _MCP_VENDORS) {
    const id = v.prefix + '_' + noDash;
    if (window.ACTORS[id] && !window.ACTORS[id]._isFallback) continue;
    window.ACTORS[id] = {
      id,
      name: v.name,
      role: 'AI Assistant',
      color: v.color,
      initial: v.initial,
      desc: ownerName ? `${v.via} de ${ownerName}` : `${v.via} por MCP`,
    };
  }
}

// Backwards-compat alias — pre-existing call sites use the old name.
function _registerClaudeAgentForUserId(userId, ownerName) {
  return _registerMcpAgentsForUserId(userId, ownerName);
}

// Load every workspace member into ACTORS so things like task assignee
// lookups and history "by X" lines render real names instead of UUIDs.
// Best-effort: silently skips on permission errors (legacy actors hitting
// the new endpoint, or pre-Fase-2 deployments).
async function loadActorsCatalog() {
  if (!window.ACTORS) return;
  try {
    const r = await api('GET', '/api/workspace/members');
    const ids = [];
    for (const m of (r.members || [])) {
      window.ACTORS[m.userId] = {
        id: m.userId,
        name: m.displayName || m.email || m.userId,
        role: m.role?.name || '',
        // Pick a color from a small palette based on userId hash so the
        // avatar background stays stable across reloads.
        color: _colorForId(m.userId),
        initial: _initialFor(m.displayName, m.email),
        desc: m.email || '',
      };
      // Pre-register each member's Claude agent too — they may have
      // claude.ai connected and authored comments will reference that
      // agent's id. Renders as "Claude" with the orange brand color.
      _registerClaudeAgentForUserId(m.userId, m.displayName || m.email);
      ids.push(m.userId);
    }
    // Expose the workspace member ids so the AssigneePopover (and any other
    // picker) shows the actual people in this workspace instead of the four
    // legacy seeded actors. Falls back to ACTOR_LIST when this stays empty
    // (e.g. legacy deploy without /api/workspace/members).
    window.MEMBER_IDS = ids;
  } catch { /* silently ignore — chrome falls back to id text */ }
}

function _colorForId(id) {
  const palette = ['var(--accent)', 'var(--founder)', 'var(--tlead)', 'var(--code)', 'var(--claude)', '#a855f7', '#ec4899', '#f59e0b', '#10b981'];
  let h = 0; for (let i = 0; i < (id || '').length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
  return palette[h % palette.length];
}
// Avatar initial: prefer the first letter of the display name, fall back to
// the first letter of the email's local part. Filters out leading whitespace
// so a name like " Genaro" still renders as "G", and ignores accidentally
// empty strings so we never show a blank avatar.
function _initialFor(name, email) {
  const cleanName = String(name || '').trim();
  if (cleanName) return cleanName.charAt(0).toUpperCase();
  const cleanEmail = String(email || '').trim();
  if (cleanEmail) return cleanEmail.charAt(0).toUpperCase();
  return '?';
}
async function loadFoldersIntoTree() {
  // Folders are now per-project (see services/folders.ts). Each entry the API
  // returns has both `id` (UUID, used in WS events and FKs) and `slug` (the
  // URL-friendly identifier shown in the sidebar). The frontend keys
  // VAULT_TREE entries by slug — that's what the URL hash uses.
  const folders = await api('GET', '/api/folders');
  const prev = new Map((window.VAULT_TREE || []).map((f) => [f.slug || f.id, f.children]));
  window.VAULT_TREE = folders.map((f) => ({ ...f, children: prev.get(f.slug || f.id) }));
}
async function ensureFolderChildren(folderSlug, opts = {}) {
  const idx = window.VAULT_TREE.findIndex((f) => (f.slug || f.id) === folderSlug);
  if (idx < 0) return null;
  if (!opts.force && window.VAULT_TREE[idx].children) return window.VAULT_TREE[idx];
  const res = await api('GET', `/api/folders/${encodeURIComponent(folderSlug)}/docs?sort=recent`);
  window.VAULT_TREE[idx] = { ...window.VAULT_TREE[idx], children: res.docs.map(folderDocToProto) };
  publish();
  return window.VAULT_TREE[idx];
}

// Re-fetch children for every folder we'd already loaded. Called from the
// WS handler on doc.updated/deleted so newly-created or moved docs show up
// in folder views immediately, without requiring a manual page refresh.
// Skips folders whose children are still null (those will refetch lazily
// the first time the user navigates to them).
async function refreshLoadedFolderChildren() {
  const tree = window.VAULT_TREE || [];
  const loaded = tree.filter((f) => f.children).map((f) => f.slug || f.id);
  if (loaded.length === 0) return;
  await Promise.all(loaded.map((slug) => ensureFolderChildren(slug, { force: true }).catch(() => {})));
}

// Folder CRUD — wrapped so components can call them without re-implementing
// the API surface. Each one re-loads VAULT_TREE so the sidebar updates
// immediately; the WS event lands a second later and confirms via publish().
async function createFolder(input) {
  const out = await api('POST', '/api/folders', input);
  await loadFoldersIntoTree();
  publish();
  return out;
}
async function updateFolder(folderRef, fields) {
  const out = await api('PATCH', `/api/folders/${encodeURIComponent(folderRef)}`, fields);
  await loadFoldersIntoTree();
  publish();
  return out;
}
async function deleteFolderApi(folderRef) {
  const out = await api('DELETE', `/api/folders/${encodeURIComponent(folderRef)}`);
  await loadFoldersIntoTree();
  publish();
  return out;
}
async function loadPhases() {
  const phases = await api('GET', '/api/phases');
  window.PHASES = phases.map(phaseToProto);
}
async function loadTasks() {
  const tasks = await api('GET', '/api/tasks');
  window.TASKS = tasks.map(taskToProto);
}
async function loadRecentDocs() {
  const docs = await api('GET', '/api/docs/recent?limit=12');
  window.RECENT_DOCS = docs.map(recentDocToProto);
}
// Boot order matters now that multi-tenancy is on:
//  1. Resolve identity (loadMe) — needed for any auth'd request.
//  2. Resolve projects (loadProjects) — fills window.PROJECTS.
//  3. Pick the active project (localStorage > first member project).
//     This populates window.CURRENT_PROJECT_ID, which the api() wrapper
//     reads on every subsequent call.
//  4. Load project-scoped data (folders, phases, tasks, recent docs).
async function loadAll() {
  // Steps 1 + 2 don't need a project scope.
  await Promise.all([loadMe(), loadProjects()]);

  // Step 3: pick the active project. Stays the same across reloads when the
  // actor remains a member; falls back to the first available one.
  let saved = null;
  try { saved = localStorage.getItem('ws.currentProjectId'); } catch {}
  const candidate = (Array.isArray(window.PROJECTS) ? window.PROJECTS : [])
    .find((p) => p.id === saved) || (window.PROJECTS && window.PROJECTS[0]) || null;
  window.CURRENT_PROJECT_ID = candidate ? candidate.id : null;
  if (candidate) {
    try { localStorage.setItem('ws.currentProjectId', candidate.id); } catch {}
  }

  // Step 4: only attempt to load project-scoped data if we have a project to
  // scope to. Without one, leave the lists empty and let the UI show an
  // empty-state until the user chooses / creates a project.
  if (window.CURRENT_PROJECT_ID) {
    await Promise.all([loadFoldersIntoTree(), loadPhases(), loadTasks(), loadRecentDocs()]);
    // Also re-fetch any folder we'd already loaded children for. Without
    // this, callers like NewDocModal that `loadAll()` after creating a doc
    // would still see stale folder.children — the new doc only appears in
    // the recent feed, not in its folder view.
    await refreshLoadedFolderChildren();
  } else {
    window.VAULT_TREE = [];
    window.PHASES = [];
    window.TASKS = [];
    window.RECENT_DOCS = [];
  }
  publish();
}

// Re-load every project-scoped listing for the *currently active* project.
// Called from setCurrentProject() when the user switches projects, and from
// boot. Cheaper than calling loadAll(): does NOT re-fetch /api/me or the
// projects list, which haven't changed.
async function loadProjectScopedData() {
  if (!window.CURRENT_PROJECT_ID) return;
  // Drop cached folder children so the next vault navigation refetches; the
  // children belong to the previous project and would mislead the user.
  window.VAULT_TREE = (window.VAULT_TREE || []).map((f) => ({ ...f, children: undefined }));
  await Promise.all([loadFoldersIntoTree(), loadPhases(), loadTasks(), loadRecentDocs()]);
  publish();
}

async function login(actorId, password) {
  const res = await api('POST', '/api/login', { actorId, password });
  window.ME = { id: res.id, name: res.name, role: res.role, color: res.color, kind: res.kind };
  publish();
  return res;
}

// New email-based login (Phase-3). Returns the same shape as legacy login
// plus user-level fields. Sets window.ME for UI consumers.
async function loginByEmail(email, password) {
  const res = await api('POST', '/api/auth/login', { email, password });
  window.ME = {
    id: res.id, name: res.name, role: res.role, color: res.color, kind: res.kind,
    userId: res.userId, email: res.email, emailVerified: res.emailVerified,
  };
  publish();
  return res;
}

// Self-service signup. Does NOT log the user in — they need to verify email
// first. Returns the workspace + verification URL (the URL is null when a
// real Resend send happened; non-null only when the stdout fallback driver
// is in use, useful for dev).
async function signup(email, password, displayName, inviteToken) {
  // inviteToken is optional. When provided AND valid for `email`, the
  // backend skips creating an auto-stub workspace — the new user joins the
  // inviter's workspace via accept-invite instead. Without this, every
  // invitee ended up with an extra empty "Workspace de <name>" they never
  // asked for, and the project switcher started on that stub instead of
  // the project they were invited to.
  const body = { email, password, displayName };
  if (inviteToken) body.inviteToken = inviteToken;
  return api('POST', '/api/auth/signup', body);
}

// Password reset — request: always returns ok regardless of email existence.
async function requestPasswordReset(email) {
  return api('POST', '/api/auth/password-reset/request', { email });
}

// Password reset — confirm: token comes from the email link; sets new password
// and auto-logs the user in (cookie is set by the backend).
async function confirmPasswordReset(token, newPassword) {
  return api('POST', '/api/auth/password-reset/confirm', { token, newPassword });
}
async function logout() {
  try { await api('POST', '/api/logout'); } catch {}
  window.ME = null;
  window.PROJECTS = [];
  window.CURRENT_PROJECT_ID = null;
  try { localStorage.removeItem('ws.currentProjectId'); } catch {}
  publish();
}

// Hard-delete the current user's account. Re-confirms the password serverside
// so a stolen cookie can't wipe the account silently. On success the cookie
// is cleared by the backend and we hard-reload so every cached fragment of
// state is dropped — partial in-memory data after a wipe makes the UI act
// like an authenticated user with no projects, which is confusing.
async function deleteMyAccount(password) {
  const res = await api('DELETE', '/api/me', { password });
  window.ME = null;
  window.PROJECTS = [];
  window.CURRENT_PROJECT_ID = null;
  try { localStorage.removeItem('ws.currentProjectId'); } catch {}
  try { localStorage.removeItem('ws.pendingInvite'); } catch {}
  // Hard reload to '/' — boot resolves /api/me as 401 and the SPA routes
  // back to the landing without any stale React state hanging around.
  try { window.location.replace('/'); } catch { publish(); }
  return res;
}

// ── projects ──────────────────────────────────────────────────────────────
async function loadProjects() {
  try { window.PROJECTS = await api('GET', '/api/projects'); }
  catch { window.PROJECTS = []; }
}
function getCurrentProject() {
  if (!Array.isArray(window.PROJECTS) || window.PROJECTS.length === 0) return null;
  const id = window.CURRENT_PROJECT_ID;
  return window.PROJECTS.find((p) => p.id === id) || window.PROJECTS[0];
}
async function setCurrentProject(projectId) {
  if (!projectId) return;
  if (!window.PROJECTS.find((p) => p.id === projectId)) return;
  if (window.CURRENT_PROJECT_ID === projectId) return; // no-op
  window.CURRENT_PROJECT_ID = projectId;
  try { localStorage.setItem('ws.currentProjectId', projectId); } catch {}
  // Reload everything project-scoped so the UI reflects the new tenant.
  await loadProjectScopedData();
}
async function createProject(input) {
  const p = await api('POST', '/api/projects', input);
  await loadProjects();
  await setCurrentProject(p.id); // also reloads project-scoped data + publishes
  return p;
}
async function archiveProject(projectId) {
  await api('POST', `/api/projects/${encodeURIComponent(projectId)}/archive`);
  await loadProjects();
  // If we just archived the current one, fall back to the first remaining.
  if (window.CURRENT_PROJECT_ID === projectId) {
    const next = (window.PROJECTS[0] && window.PROJECTS[0].id) || null;
    window.CURRENT_PROJECT_ID = next;
    try {
      if (next) localStorage.setItem('ws.currentProjectId', next);
      else localStorage.removeItem('ws.currentProjectId');
    } catch {}
    if (next) await loadProjectScopedData();
  }
  publish();
}

// ── doc + task helpers (used by editor / drawer / palette) ────────────────
async function fetchDocByPath(path) { return api('GET', `/api/docs/by-path/${path}`); }
async function patchDoc(id, bodyMd, baseUpdatedAt, summary) {
  return api('PATCH', `/api/docs/${id}`, { bodyMd, baseUpdatedAt, summary });
}
// Soft-delete via DELETE /api/docs/:id. The backend sets deletedAt + emits
// a doc.deleted event; our list queries already filter on it. We refresh
// project-scoped data so the document drops out of folder children + recent
// docs without waiting for the realtime event to be processed.
async function deleteDoc(id) {
  await api('DELETE', `/api/docs/${id}`);
  await loadProjectScopedData();
}
async function listDocVersions(id) { return api('GET', `/api/docs/${id}/versions`); }
async function searchDocs(q, limit = 12) {
  return api('GET', `/api/docs/search?q=${encodeURIComponent(q)}&limit=${limit}`);
}
async function fetchTask(id) { return api('GET', `/api/tasks/${id}`); }

async function listUploadsInFolder(folderId) {
  return api('GET', `/api/folders/${encodeURIComponent(folderId)}/uploads`);
}
async function uploadFileToFolder(folderId, file) {
  // Multipart uploads don't go through the api() wrapper (FormData needs the
  // browser to set its own multipart boundary), so we have to set headers
  // manually. CRITICAL: include X-Project-Id — the backend's
  // requireProjectForActor() reads it from there for multi-tenant scoping
  // and 400s if missing. Without this the upload fails silently (the empty
  // folder view used to swallow that error — fixed in vault-views.jsx).
  let res;
  try {
    const fd = new FormData();
    fd.append('file', file, file.name);
    const headers = {};
    if (window.CURRENT_PROJECT_ID) headers['X-Project-Id'] = window.CURRENT_PROJECT_ID;
    res = await fetch(`/api/folders/${encodeURIComponent(folderId)}/uploads`, {
      method: 'POST',
      credentials: 'same-origin',
      headers,
      body: fd,
    });
  } catch (netErr) {
    const err = new Error(`network: ${netErr.message || 'fetch failed'}`);
    err.status = 0;
    throw err;
  }
  let data = null;
  try { data = await res.json(); } catch {}
  if (!res.ok) {
    const detail = data?.code ? ` [${data.code}]` : '';
    const err = new Error((data && data.error ? data.error : `HTTP ${res.status}`) + detail);
    err.status = res.status;
    err.body = data;
    throw err;
  }
  return data;
}
async function deleteUpload(id) { return api('DELETE', `/api/uploads/${id}`); }
function uploadDownloadUrl(id) { return `/api/uploads/${id}/download`; }
async function patchTask(id, fields) {
  const t = await api('PATCH', `/api/tasks/${id}`, fields);
  const i = window.TASKS.findIndex((x) => x.id === id);
  if (i >= 0) window.TASKS[i] = taskToProto(t);
  publish();
  return t;
}
async function postComment(taskId, bodyMd) { return api('POST', `/api/tasks/${taskId}/comments`, { bodyMd }); }
async function fetchMine() { return api('GET', '/api/tasks/mine'); }

// ── realtime ──────────────────────────────────────────────────────────────
let _ws = null;
let _wsReconnect = null;
const realtimeListeners = new Set();
function onRealtime(cb) { realtimeListeners.add(cb); return () => realtimeListeners.delete(cb); }
function dispatchRealtime(msg) { realtimeListeners.forEach((cb) => { try { cb(msg); } catch {} }); }

function connectWS() {
  if (_ws) return _ws;
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
  let ws;
  try { ws = new WebSocket(`${proto}//${location.host}/api/ws`); }
  catch (e) { scheduleReconnect(); return null; }
  _ws = ws;
  ws.onmessage = async (ev) => {
    try {
      const msg = JSON.parse(ev.data);
      // Phase-2 multi-tenancy: only refresh listings when the event belongs
      // to the active project. Cross-project events still propagate to
      // listeners (claude code session pages may be looking at any project),
      // but we don't trigger a global re-fetch for them.
      const eventProject = msg.projectId;
      const matchesActive = !eventProject || eventProject === window.CURRENT_PROJECT_ID;
      if (matchesActive) {
        if (msg.type === 'task.changed' || msg.type === 'task.commented' || msg.type === 'task.deleted') {
          await loadTasks();
        } else if (msg.type === 'doc.updated' || msg.type === 'doc.deleted') {
          // 1) refresh the tree (folder counts) and recent docs.
          // 2) re-fetch children for every folder that's already been
          //    loaded — otherwise the cached `children` arrays go stale
          //    and newly-created docs only appear in "recent" but not in
          //    their folder view.
          await Promise.all([loadFoldersIntoTree(), loadRecentDocs(), refreshLoadedFolderChildren()]);
        } else if (msg.type === 'phase.changed') {
          await Promise.all([loadPhases(), loadTasks()]);
        } else if (msg.type === 'upload.changed' || msg.type === 'log.changed') {
          // Folder counts may have shifted. Bitácora reloads via its own page-level effect.
          await loadFoldersIntoTree();
        } else if (msg.type === 'folder.changed') {
          // Folder catalog itself changed (created / renamed / deleted).
          // Refresh the tree and refetch children for any folder we'd already
          // expanded so a rename doesn't leave stale doc lists.
          await loadFoldersIntoTree();
          await refreshLoadedFolderChildren();
        }
        publish();
      }
      // Always notify direct listeners (Claude Code page subscribes to its
      // session events regardless of project — the user can be looking at a
      // session in a different project than the one the rest of the UI is
      // showing).
      dispatchRealtime(msg);
    } catch {}
  };
  ws.onclose = () => { _ws = null; scheduleReconnect(); };
  ws.onerror = () => { /* close handler runs */ };
  return ws;
}
function scheduleReconnect() {
  if (_wsReconnect) return;
  _wsReconnect = setTimeout(() => { _wsReconnect = null; connectWS(); }, 3000);
}

// ── sync indicator ────────────────────────────────────────────────────────
let _lastSync = null;
async function pollSyncStatus() {
  try { _lastSync = await api('GET', '/api/sync/status'); publish(); } catch {}
}
function getLastSync() { return _lastSync; }

// ── seed globals so components don't crash on first render ────────────────
window.VAULT_TREE = [];
window.PHASES = [];
window.TASKS = [];
window.RECENT_DOCS = [];
window.ME = null;
window.PROJECTS = [];
window.CURRENT_PROJECT_ID = null;

// Boot the post-login app state. Safe to call multiple times — used both on
// first paint (after `loadMe` succeeds) and after a successful `login()`.
async function bootApp() {
  await loadAll();
  // Populate ACTORS with the workspace roster so any chrome that does
  // ACTORS[id].name renders the real name instead of the fallback. Runs
  // in parallel — chrome that needs ACTORS during its first paint will
  // hit the Proxy fallback once and re-render after this resolves.
  loadActorsCatalog();
  connectWS();
  pollSyncStatus();
  if (!window._syncPollHandle) {
    window._syncPollHandle = setInterval(pollSyncStatus, 30_000);
  }
  // Refresh the roster whenever the workspace membership changes. Without
  // this, the AssigneePopover keeps showing the stale member list — newly
  // invited collaborators don't appear in the picker until full reload.
  if (!window._memberWsHandle) {
    window._memberWsHandle = onRealtime((msg) => {
      if (msg.type === 'workspace.member.changed' || msg.type === 'workspace.role.changed') {
        loadActorsCatalog();
      }
    });
  }
}

async function bootData() {
  await loadMe();
  if (window.ME) await bootApp();
}

Object.assign(window, {
  api,
  bootData, bootApp, loadAll, login, loginByEmail, signup, requestPasswordReset, confirmPasswordReset, logout, deleteMyAccount, subscribeData, useData,
  loadProjects, createProject, archiveProject, getCurrentProject, setCurrentProject,
  loadProjectScopedData,
  ensureFolderChildren,
  createFolder, updateFolder,
  deleteFolder: deleteFolderApi,
  fetchDocByPath, patchDoc, deleteDoc, listDocVersions, searchDocs,
  fetchTask, patchTask, postComment, fetchMine,
  listUploadsInFolder, uploadFileToFolder, deleteUpload, uploadDownloadUrl,
  connectWS, getLastSync, onRealtime,
});
