// data.jsx — live data loader. The original mock-data version is preserved as
// data.seed.jsx for reference. At script-load time, we publish empty arrays
// + the static enums on `window` so the rest of the JSX modules can import
// them. After auth, index.html calls `flowboardLoad()` which fetches from the
// REST API and re-publishes the populated arrays before the React app mounts.

// ── Static enums (identical to seed) ────────────────────────────────────
const STATUSES = [
  { id: "backlog", label: "Backlog", cls: "pill-backlog" },
  { id: "todo", label: "To Do", cls: "pill-todo" },
  { id: "progress", label: "In Progress", cls: "pill-progress" },
  { id: "review", label: "In Review", cls: "pill-review" },
  // "Reopened" — a task that was Done but the story reviewer pressed
  // "Request changes". Distinct from In Progress so the dev can tell
  // at a glance this came back from review (not a fresh task).
  // See migration 042_task_status_reopened.
  { id: "reopened", label: "Reopened", cls: "pill-reopened" },
  { id: "done", label: "Done", cls: "pill-done" },
  { id: "blocked", label: "Blocked", cls: "pill-blocked" },
];

const PRIORITIES = [
  { id: "critical", label: "Critical", cls: "pill-prio-critical" },
  { id: "high", label: "High", cls: "pill-prio-high" },
  { id: "medium", label: "Medium", cls: "pill-prio-medium" },
  { id: "low", label: "Low", cls: "pill-prio-low" },
  { id: "none", label: "—", cls: "pill-prio-none" },
];

// Task type metadata. The `type` column on tasks accepts these four ids
// (set in new-task-modal.jsx and the drawer). Single source of truth so
// the Kanban card, table row, and toolbar Type filter all render the
// same emoji + label + color for a given type.
// Default for legacy / unset is "task".
const TASK_TYPES = [
  { id: "task",  label: "Task",    emoji: "📋", color: "#a25ddc", cls: "type-task"  },
  { id: "bug",   label: "Bug",     emoji: "🐞", color: "#e2445c", cls: "type-bug"   },
  { id: "chore", label: "Chore",   emoji: "🔧", color: "#676879", cls: "type-chore" },
  { id: "spike", label: "Spike",   emoji: "🔬", color: "#fdab3d", cls: "type-spike" },
];
const TASK_TYPE_BY_ID = Object.fromEntries(TASK_TYPES.map(t => [t.id, t]));
function taskTypeMeta(t) {
  return TASK_TYPE_BY_ID[(t && t.type) || "task"] || TASK_TYPE_BY_ID.task;
}

const WS_ROLES = [
  { id: "owner",  label: "Owner",  desc: "Full control — billing, members, all projects",    count: 0 },
  { id: "admin",  label: "Admin",  desc: "Manage members & projects; cannot change billing", count: 0 },
  { id: "member", label: "Member", desc: "Access assigned projects; create own projects",    count: 0 },
  { id: "guest",  label: "Guest",  desc: "View-only unless explicitly granted edit rights",  count: 0 },
];

const PROJECT_ROLES = [
  { id: "owner",     label: "Owner",     desc: "Manage project, members, and settings", icon: "Star"   },
  // Co-owner: same permissions as Owner — middleware/role.js aliases
  // it back to 'owner' so every existing permission check Just Works.
  // Distinct label so the People panel can name a deputy without
  // implying the original creator handed over the title.
  { id: "co_owner",  label: "Co-owner",  desc: "Same permissions as Owner — a named deputy", icon: "Star" },
  { id: "editor",    label: "Editor",    desc: "Create & edit tasks, sprints, epics",   icon: "Check"  },
  { id: "commenter", label: "Commenter", desc: "View + comment; cannot edit tasks",     icon: "Chat"   },
  { id: "viewer",    label: "Viewer",    desc: "Read-only access",                      icon: "Eye"    },
];

const QA_STATUS = {
  pending: { label: "Pending QA",  tone: "amber" },
  passed:  { label: "QA passed",   tone: "green" },
  failed:  { label: "QA failed",   tone: "red"   },
};

const NO_EPIC = { id: null, title: "Quick tasks", color: "#a3a8b6" };

// User-story status enum — must stay in lockstep with VALID_STATUSES in
// routes/stories.routes.js. The frontend renders the label and chip
// colour from this map; backend is the source of truth for the id list.
const STORY_STATUSES = [
  { id: "backlog",     label: "Backlog",         cls: "pill-backlog"  },
  { id: "in_progress", label: "In Progress",     cls: "pill-progress" },
  { id: "review",      label: "In Review",       cls: "pill-review"   },
  { id: "changes_req", label: "Changes Requested", cls: "pill-blocked" },
  { id: "done",        label: "Done",            cls: "pill-done"     },
];

// ── Mutable arrays — populated by flowboardLoad() ───────────────────────
let PEOPLE          = [];
let PROJECTS        = [];
let SPRINTS         = [];
let EPICS           = [];
let USER_STORIES    = [];
let ALL_TASKS       = [];
let MY_TASKS        = [];
let MY_MENTIONS     = [];
let MY_REVIEWS      = [];
let MY_STREAK       = { current: 0, best: 0, thisWeek: 0, lastWeek: 0 };
let WORKSPACE       = { name: "", plan: "", seatsUsed: 0, seatsTotal: 0, billingCycle: "monthly", nextBill: "—" };
// Multi-workspace (May 2026). WORKSPACES holds every workspace
// visible to the signed-in user — owner sees all; everyone else
// sees only the ones they're members of (or have a project in).
// WORKSPACE singular stays for backwards compat = the first one.
let WORKSPACES      = [];
let PROJECT_ACCESS  = {};
let QA_TASKS        = [];
// Per-user favorite project ids. Mutated by:
//   * _applyBootstrap (read from server payload)
//   * api.users.addFavorite / removeFavorite (optimistic flip + sync)
//   * SSE user.favorites_changed (other tabs of same user)
// Sidebar reads it via window.MY_FAVORITES on render. We dispatch a
// `flowboard:favorites:changed` window event whenever it mutates so
// the sidebar re-renders without a full bootstrap reload.
let MY_FAVORITES    = [];

// ── Date helpers — DB stores ISO; UI expects "Apr 24" ───────────────────
function fmtDay(iso) {
  if (!iso) return "—";
  if (typeof iso === "string" && /^[A-Za-z]{3}\s+\d{1,2}$/.test(iso)) return iso; // already short
  const d = new Date(iso);
  if (isNaN(d)) return "—";
  return d.toLocaleString("en-US", { month: "short", day: "2-digit" });
}
function fmtRelative(iso) {
  if (!iso) return "—";
  const d = new Date(iso);
  if (isNaN(d)) return "—";
  const sec = (Date.now() - d.getTime()) / 1000;
  if (sec < 60)        return "just now";
  if (sec < 3600)      return Math.floor(sec / 60) + "m";
  if (sec < 86400)     return Math.floor(sec / 3600) + "h";
  if (sec < 604800)    return Math.floor(sec / 86400) + "d";
  return Math.floor(sec / 604800) + "w";
}

// ── Normalizers — turn API shape into the shape the JSX modules expect ──
// Coerce the JSON column into a plain { module: bool } object. Missing keys
// mean "allowed" — we only store explicit overrides.
function _normalizeModuleAccess(raw) {
  if (raw == null) return null;
  let obj = raw;
  if (typeof raw === "string") {
    try { obj = JSON.parse(raw); } catch { return null; }
  }
  if (!obj || typeof obj !== "object") return null;
  const out = {};
  for (const k of Object.keys(obj)) {
    out[k] = obj[k] !== false;
  }
  return Object.keys(out).length ? out : null;
}

function normalizeUser(u) {
  return {
    id:           u.id,
    name:         u.name,
    color:        u.color || "#7c7c7c",
    email:        u.email,
    title:        u.title || "—",
    team:         u.team  || "—",
    wsRole:       u.ws_role || "member",
    // Attendance / HR tracking flag (migration 048). Opt-in: defaults to
    // false unless the member is explicitly enabled as an employee. The
    // backend omits the field on older DBs, where we treat everyone as
    // tracked (true) so nothing breaks before the migration runs.
    isEmployee:   (u.is_employee === undefined || u.is_employee === null)
                    ? true
                    : (Number(u.is_employee) !== 0),
    status:       u.status  || "active",
    joined:       u.joined || "—",
    lastActive:   u.last_active || "—",
    avatar:       u.avatar || null,
    moduleAccess: _normalizeModuleAccess(u.module_access),
    // IANA timezone (migration 026). Falls back to IST when the
    // column doesn't exist yet on this DB.
    timezone:     u.timezone || "Asia/Kolkata",
  };
}

function normalizeProject(p) {
  return {
    id: p.id,
    name: p.name,
    color: p.color,
    count: Number(p.task_count ?? 0),
    visibility: p.visibility || null,
    // workspace_id (multi-workspace, May 2026). Tasks / sidebar
    // group projects by this. Falls back to 1 for the legacy
    // single-tenant deployment.
    workspaceId: p.workspace_id ? Number(p.workspace_id) : 1,
    // Per-project flag that turns on the social-media channel tag
    // and the channel-aware calendar chips. Off by default so non-
    // content projects stay clean. Comes from migration 021; falls
    // back to false when the column doesn't exist yet.
    isContentCalendar: !!(p.is_content_calendar || p.isContentCalendar),
    // Default reviewer for new user stories created under this project.
    // Comes from migration 041. NewStoryModal reads this to pre-fill
    // the reviewer field so coordinator-led teams don't pick the same
    // tester on every story.
    defaultReviewerId: p.default_reviewer_id || p.defaultReviewerId || null,
  };
}

function normalizeSprint(s) {
  return {
    id: s.id,
    label: s.label,
    dates: s.dates || "",
    active: !!s.active,
    team: s.team || null,
    project_id: s.project_id || null,
  };
}

function normalizeTask(t) {
  return {
    id: t.id,
    name: t.name,
    description: t.description || "",
    status: t.status,
    prio: t.priority || "none",
    // Task classification (task / bug / chore / spike). Persisted in
    // tasks.type (migration 047). Defaults to "task" so rows on older
    // data — or any row where the column is somehow absent — still
    // render with a sensible type.
    type: t.type || "task",
    owners: (t.owners || []).map(o => o.id),
    reviewers: (t.reviewers || []).map(r => r.id),
    labels: (t.labels || []).map(l => ({ id: l.id, name: l.name, color: l.color })),
    due: t.due_date || "—",
    points: Number(t.points || 0),
    sprint: t.sprint_id || null,
    sprintLabel: t.sprint_label || null,
    updated: t.updated || (t.updated_at ? fmtRelative(t.updated_at) : "—"),
    subtasks: Number(t.subtasks || 0),
    subtasks_done: Number(t.subtasks_done || 0),
    comments: Number(t.comments || 0),
    attachments: t.attachments || [],
    qa: t.qa || [],
    qaRequired: !!t.qa_required,
    qaStatus: t.qa_status || null,
    reopenCount: Number(t.reopen_count || 0),
    epicId: t.epic_id || null,
    epicTitle: t.epic_title || null,
    epicColor: t.epic_color || null,
    userStoryId: t.user_story_id || null,
    // Parent task id — non-null for subtasks. The TableView uses this
    // to render an indented row with a "↳ in <parent>" chip.
    parentTaskId: t.parent_task_id || null,
    // depth = 0 for top-level tasks, 1..4 for nested subtasks. Stored
    // on the row by migration 039; the frontend uses it for indented
    // rendering + hiding the "+ Subtask" button at the cap.
    depth: Math.max(0, Number(t.depth) || 0),
    projectId: t.project_id,
    projectName: t.project_name || null,
    projectColor: t.project_color || null,
    completedAt: t.completed_at || null,
    outcome: t.outcome || null,
    delayReason: t.delay_reason || null,
    delayNote: t.delay_note || null,
    blockedBy: t.blocked_by || null,
    // Social-media platform tag. Only meaningful when the parent
    // project has isContentCalendar=true. Stored verbatim on the
    // task; the calendar view colors chips by it when set.
    channel: t.channel || null,
    // Recurring-task metadata — set by lib/recurrence.js. The template
    // task carries is_recurring_template=1 + recurrence_rule_id; each
    // materialised instance carries recurrence_rule_id only. Anywhere
    // we render a row we can show a small ↻ icon when either flag is
    // set so the user sees the row is part of a series.
    recurrenceRuleId: t.recurrence_rule_id || null,
    isRecurringTemplate: !!t.is_recurring_template,
    // Who created this row — backend sets tasks.created_by on insert.
    // Used by the project task table's "Creator" filter, so users can
    // narrow down to tasks they (or anyone) raised.
    createdBy: t.created_by || null,
  };
}

function normalizeWorkspace(w) {
  return {
    id:           Number(w.id),
    name:         w.name || "Workspace",
    plan:         w.plan || "",
    seatsUsed:    Number(w.seats_used || 0),
    seatsTotal:   Number(w.seats_total || 0),
    billingCycle: w.billing_cycle || "monthly",
    nextBill:     w.next_bill || "—",
  };
}

function normalizeUserStory(s) {
  return {
    id:          s.id,
    projectId:   s.project_id,
    epicId:      s.epic_id || null,
    title:       s.title,
    description: s.description || "",
    status:      s.status || "backlog",
    ownerId:     s.owner_id || null,
    createdBy:   s.created_by || null,
    reviewers:   Array.isArray(s.reviewers) ? s.reviewers : [],
    createdAt:   s.created_at || null,
    updatedAt:   s.updated_at || null,
  };
}

function normalizeEpic(e, allTasks) {
  const tasks = allTasks
    .filter(t => t.epicId === e.id)
    .sort((a, b) => (a.position || 0) - (b.position || 0));
  return {
    id: e.id,
    title: e.title,
    color: e.color,
    project_id: e.project_id,
    owner_id: e.owner_id,
    status: e.status,
    progress: e.progress,
    tasks,
  };
}

// ── localStorage cache (stale-while-revalidate) ────────────────────────
// We persist the bootstrap payload after every successful load and rehydrate
// from it synchronously on the next page visit, so return users see the app
// instantly without the "Loading your workspace…" splash. The fresh fetch
// runs in the background; window globals are updated when it lands.
const FB_CACHE_VERSION = 2;
function _fbCacheKey() {
  const u = (window.api && api.getUser()) || null;
  if (!u || !u.id) return null;
  return "fb.bootstrap.v" + FB_CACHE_VERSION + "." + u.id;
}
function _fbCacheRead() {
  try {
    const k = _fbCacheKey();
    if (!k) return null;
    const raw = window.localStorage.getItem(k);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    if (!parsed || !parsed.payload) return null;
    // Drop caches older than 7 days — schemas evolve.
    if (parsed.savedAt && (Date.now() - parsed.savedAt) > 7 * 24 * 3600 * 1000) return null;
    return parsed.payload;
  } catch (_) { return null; }
}
function _fbCacheWrite(payload) {
  try {
    const k = _fbCacheKey();
    if (!k) return;
    window.localStorage.setItem(k, JSON.stringify({ savedAt: Date.now(), payload }));
  } catch (_) { /* quota or disabled — ignore */ }
}
function _fbCacheClear() {
  try {
    const k = _fbCacheKey();
    if (k) window.localStorage.removeItem(k);
  } catch (_) {}
}

// Hydrate the in-memory arrays from a bootstrap-shaped payload (used by both
// the network path and the localStorage cache path). Pure shape work; no I/O.
function _applyBootstrap({ users, projects, sprints, tasksRaw, ws, workspaces, epicsRaw, membersByProject, leads, stories, favorite_project_ids }) {
  // Per-user favorites — array of project ids the signed-in user has
  // starred. The sidebar reads window.MY_FAVORITES and renders the
  // "★ Favorites" group at the top.
  MY_FAVORITES = Array.isArray(favorite_project_ids) ? favorite_project_ids.filter(x => typeof x === "string") : [];
  if (typeof window !== "undefined") window.MY_FAVORITES = MY_FAVORITES;
  // Defensive dedup by user id. The server query was supposed to
  // return one row per user, but if a stale bootstrap cache (or a
  // future LEFT JOIN regression) slips duplicates through, the
  // People admin page would render the same user N times. Keep the
  // first occurrence — the server orders by status / name so that's
  // the canonical row.
  const _seenUserIds = new Set();
  PEOPLE = (users || [])
    .filter(u => { if (!u || !u.id) return false; if (_seenUserIds.has(u.id)) return false; _seenUserIds.add(u.id); return true; })
    .map(normalizeUser);
  for (const r of WS_ROLES) r.count = 0;
  for (const u of PEOPLE) {
    const r = WS_ROLES.find(x => x.id === u.wsRole);
    if (r) r.count++;
  }
  if (ws) WORKSPACE = {
    id: ws.id ? Number(ws.id) : 1,
    name: ws.name,
    plan: ws.plan,
    seatsUsed: Number(ws.seats_used || PEOPLE.length),
    seatsTotal: Number(ws.seats_total || 0),
    billingCycle: ws.billing_cycle || "monthly",
    nextBill: ws.next_bill || "—",
  };
  // Hydrate the multi-workspace array. Falls back to the singular
  // WORKSPACE so the sidebar always has at least one workspace
  // to render.
  WORKSPACES = Array.isArray(workspaces) && workspaces.length
    ? workspaces.map(normalizeWorkspace)
    : (WORKSPACE && WORKSPACE.name ? [{
        id: WORKSPACE.id || 1, name: WORKSPACE.name, plan: WORKSPACE.plan,
        seatsUsed: WORKSPACE.seatsUsed, seatsTotal: WORKSPACE.seatsTotal,
        billingCycle: WORKSPACE.billingCycle, nextBill: WORKSPACE.nextBill,
      }] : []);
  PROJECTS = (projects || []).map(normalizeProject);
  SPRINTS  = (sprints  || []).map(normalizeSprint);

  // Defensive id-dedupe — backend should already return one row per
  // task (multi-workspace JOIN bug fixed in routes/bootstrap.routes.js
  // + tasks.routes.js), but a regression in any future query-builder
  // change shouldn't double-render every task in the table. Keeps the
  // first occurrence so ordering / position is preserved.
  const _allTasksRaw = (tasksRaw || []).map(t => ({ ...normalizeTask(t), position: Number(t.position || 0) }));
  const _seenIds = new Set();
  const allTasks = [];
  for (const t of _allTasksRaw) {
    if (!t || !t.id || _seenIds.has(t.id)) continue;
    _seenIds.add(t.id);
    allTasks.push(t);
  }

  // Compute depth from the parentTaskId chain for every task. The
  // server's `depth` column (migration 039) is the source of truth
  // once it's set, but legacy rows created before the migration ran
  // can have depth=0 even when nested. Walking the chain here makes
  // the table indent work regardless of server-side state — and acts
  // as a self-heal for any drift we might miss in the future. Cap at
  // 4 (max 5 levels) to match the schema. Map lookup so this is O(n)
  // total, not O(n × depth) — we cache each parent's depth as we go.
  {
    const byId = new Map(allTasks.map(t => [t.id, t]));
    const cache = new Map();
    function depthOf(t, seen) {
      if (!t || !t.parentTaskId) return 0;
      if (cache.has(t.id)) return cache.get(t.id);
      // Cycle guard — bail at 5 hops or if we re-visit a node.
      if (seen && seen.has(t.id)) return 0;
      const parent = byId.get(t.parentTaskId);
      if (!parent) return 0;
      const s = seen || new Set();
      s.add(t.id);
      const d = Math.min(4, depthOf(parent, s) + 1);
      cache.set(t.id, d);
      return d;
    }
    for (const t of allTasks) {
      const computed = depthOf(t);
      // Trust server depth only when it agrees with the chain. If
      // they disagree (legacy row), take the chain-computed value.
      if ((t.depth || 0) !== computed) {
        t.depth = computed;
      }
    }
  }

  ALL_TASKS = allTasks;
  EPICS = (epicsRaw || []).map(e => normalizeEpic(e, allTasks));
  USER_STORIES = (stories || []).map(normalizeUserStory);

  const me = window.api && api.getUser();
  const myId = me && me.id;
  MY_TASKS = myId
    ? allTasks
        .filter(t => t.owners.includes(myId) || t.reviewers.includes(myId))
        .map(t => ({
          id: "m_" + t.id, taskId: t.id, name: t.name,
          status: t.status, prio: t.prio, due: t.due, points: t.points,
          owners: t.owners, reviewers: t.reviewers,
          project: t.projectName, projectId: t.projectId, projectColor: t.projectColor,
          epic: t.epicTitle, epicColor: t.epicColor,
          source: t.sprint ? "sprint" : "backlog",
          sprintLabel: t.sprintLabel,
        }))
    : [];

  PROJECT_ACCESS = {};
  (projects || []).forEach((projectInfo) => {
    const id = projectInfo.id;
    const ml = (membersByProject && membersByProject[id]) || [];
    PROJECT_ACCESS[id] = {
      visibility: projectInfo.visibility || "workspace",
      owner: projectInfo.owner_id || null,
      members: ml.map(m => ({ id: m.user_id || m.id, role: m.role })),
    };
  });

  QA_TASKS = allTasks
    .filter(t => t.qaStatus === "pending" || t.qaStatus === "failed")
    .map(t => ({
      id: "qa-" + t.id, forTaskId: t.id, forTaskName: t.name,
      projectId: t.projectId, projectColor: t.projectColor,
      assignee: t.owners[0] || null, reportedBy: null,
      status: "progress", prio: t.prio, due: t.due,
      createdAt: t.updated, checklist: t.qa || [],
    }));

  MY_MENTIONS = [];
  MY_REVIEWS  = [];
  MY_STREAK   = { current: 0, best: 0, thisWeek: 0, lastWeek: 0 };

  Object.assign(window, {
    PEOPLE, PROJECTS, SPRINTS, EPICS, USER_STORIES,
    ALL_TASKS, MY_TASKS, MY_MENTIONS, MY_REVIEWS, MY_STREAK,
    WORKSPACE, WORKSPACES, PROJECT_ACCESS, QA_TASKS,
  });

  // CRM leads — push into the leads-data module via setLeads(), so that
  // both the module-scoped LEADS array and any consumer using getLeads()
  // see the freshly-loaded payload. Degrades silently if the CRM module
  // isn't loaded yet.
  if (typeof window.setLeads === "function") {
    window.setLeads(Array.isArray(leads) ? leads : []);
  }
}

// Synchronous cache hydration. Returns true if the app can be rendered
// immediately from cache (caller should then start a background refresh).
function flowboardLoadFromCache() {
  const cached = _fbCacheRead();
  if (!cached) return false;
  try {
    _applyBootstrap(cached);
    return true;
  } catch (_) { return false; }
}

// Notify any listener (Root in index.html) that fresh data has landed.
function _emitRefreshed() {
  try { window.dispatchEvent(new CustomEvent("flowboard:refreshed")); } catch (_) {}
}

// ── Main loader — called from index.html after login ───────────────────
//
// Fast path: a single GET /api/bootstrap returns everything in one round
// trip (users, projects, sprints, tasks, epics, members). Slow path: fall
// back to the per-route endpoints, with all groups fanned out in parallel
// so we still only pay one round-trip's worth of latency in the worst
// case (vs the original three sequential waves).
async function flowboardLoad() {
  let users, projects, sprints, tasksRaw, ws, workspaces, members, epicsRaw, membersByProject, leads, stories;
  let favorite_project_ids = [];
  let usedBootstrap = false;

  try {
    const b = await api.bootstrap();
    users             = b.users || [];
    projects          = b.projects || [];
    sprints           = b.sprints || [];
    tasksRaw          = b.tasks || [];
    ws                = b.workspace || null;
    workspaces        = Array.isArray(b.workspaces) ? b.workspaces : (ws ? [ws] : []);
    members           = b.workspaceMembers || [];
    epicsRaw          = b.epics || [];
    membersByProject  = b.membersByProject || {};
    leads             = Array.isArray(b.leads) ? b.leads : [];
    stories           = Array.isArray(b.stories) ? b.stories : [];
    favorite_project_ids = Array.isArray(b.favorite_project_ids) ? b.favorite_project_ids : [];
    usedBootstrap = true;
  } catch (_) {
    // Fallback: parallelize EVERYTHING the old loader did. Even in this
    // path we no longer pay the 3-wave penalty — projects + epics +
    // members are now all in flight simultaneously.
    const [u, p, s, t, w, m] = await Promise.all([
      api.users.list(),
      api.projects.list(),
      api.sprints.list(),
      api.tasks.list(),
      api.workspace.current().catch(() => null),
      api.workspace.members().catch(() => []),
    ]);
    users = u; projects = p; sprints = s; tasksRaw = t; ws = w; members = m;

    // Per-project fan-out — done in one parallel burst.
    const [epicLists, memberLists] = await Promise.all([
      Promise.all(projects.map(pj => api.epics.list(pj.id).catch(() => []))),
      Promise.all(projects.map(pj => api.projects.members(pj.id).catch(() => []))),
    ]);
    epicsRaw = epicLists.flat();
    membersByProject = {};
    projects.forEach((pj, i) => {
      membersByProject[pj.id] = memberLists[i] || [];
    });
    // Leads — best-effort, never blocks app load if the table is missing.
    try {
      leads = await api.leads.list();
      if (!Array.isArray(leads)) leads = [];
    } catch (_) { leads = []; }
    // User stories — same defensive treatment. Migration 024 adds the
    // table; older deploys just see an empty list.
    try {
      stories = (api.stories && (await api.stories.list())) || [];
      if (!Array.isArray(stories)) stories = [];
    } catch (_) { stories = []; }
    // Workspaces — best-effort. Falls back to the singular workspace
    // shape so the multi-workspace UI still renders SOMETHING on
    // older deploys that don't have the list endpoint yet.
    try {
      workspaces = (api.workspaces && (await api.workspaces.list())) || [];
      if (!Array.isArray(workspaces)) workspaces = [];
    } catch (_) { workspaces = ws ? [ws] : []; }
  }

  // Per-user favorites — best-effort fetch on the fallback path so
  // the sidebar's "★ Favorites" group still works even when bootstrap
  // is unavailable. Bootstrap path already populated this above.
  if (!usedBootstrap) {
    try {
      const r = await api.users.listFavorites();
      favorite_project_ids = (r && Array.isArray(r.favorite_project_ids)) ? r.favorite_project_ids : [];
    } catch (_) { favorite_project_ids = []; }
  }

  // Single normalization path for both fast & fallback branches.
  const payload = { users, projects, sprints, tasksRaw, ws, workspaces, epicsRaw, membersByProject, leads, stories, favorite_project_ids };
  _applyBootstrap(payload);

  // Persist to localStorage so the next visit can hydrate instantly.
  _fbCacheWrite(payload);

  // Tell any listeners (Root in index.html) that fresh data has landed,
  // in case we already rendered the app from cache.
  _emitRefreshed();

  return { PEOPLE, PROJECTS, SPRINTS, EPICS, USER_STORIES, ALL_TASKS, MY_TASKS, WORKSPACE, WORKSPACES, _usedBootstrap: usedBootstrap };
}

// ── Per-user favorites helpers ────────────────────────────────────
// Optimistic toggle. Flips local state immediately + dispatches
// `flowboard:favorites:changed` so the sidebar re-renders, then
// hits the server. On failure, rolls back AND re-dispatches so the
// sidebar reverts. Returns true if the project ended up favorited.
function _setFavoritesAndBroadcast(ids) {
  MY_FAVORITES = Array.from(new Set(ids || []));
  if (typeof window !== "undefined") {
    window.MY_FAVORITES = MY_FAVORITES;
    try { window.dispatchEvent(new CustomEvent("flowboard:favorites:changed", { detail: { ids: MY_FAVORITES } })); }
    catch {}
  }
}
async function flowboardToggleFavorite(projectId) {
  if (!projectId || typeof projectId !== "string") return false;
  const before = MY_FAVORITES.slice();
  const isFav  = before.includes(projectId);
  const next   = isFav ? before.filter(id => id !== projectId) : [...before, projectId];
  _setFavoritesAndBroadcast(next);
  try {
    const r = isFav
      ? await api.users.removeFavorite(projectId)
      : await api.users.addFavorite(projectId);
    if (r && Array.isArray(r.favorite_project_ids)) {
      _setFavoritesAndBroadcast(r.favorite_project_ids);
    }
    return !isFav;
  } catch (e) {
    // Rollback to the snapshot so the star doesn't lie.
    _setFavoritesAndBroadcast(before);
    if (typeof window !== "undefined" && typeof window.fbToast === "function") {
      window.fbToast("Couldn't update favorite: " + ((e && e.message) || "network error"), 4000);
    }
    return isFav;
  }
}

// SSE listener — when another tab of the SAME user toggles a
// favorite, sync this tab's MY_FAVORITES so the sidebar matches
// without a round-trip.
//
// CRITICAL: lib/events.js publish() broadcasts to every connected
// client in the workspace, NOT just the originating user — so
// without a user_id check here, User A starring a project would
// also flip the star for User B (we'd be overwriting B's own
// MY_FAVORITES with A's list). The server-side publish includes
// `user_id` in the payload exactly so we can filter here.
if (typeof window !== "undefined") {
  window.addEventListener("flowboard:rt:user.favorites_changed", (ev) => {
    const detail = ev && ev.detail;
    if (!detail) return;
    // Resolve the signed-in user. `api.getUser()` is the source of
    // truth (it reflects act-as / impersonation too). Bail safely
    // if the user record isn't loaded yet.
    let me = null;
    try { me = (window.api && window.api.getUser && window.api.getUser()) || null; } catch {}
    const myId = me && me.id;
    if (!myId) return;
    // Different user's event — ignore it. This is the fix for
    // "User A's favorites showing for User B" — each user's
    // sidebar should only ever update from their OWN events.
    if (detail.user_id && detail.user_id !== myId) return;
    if (Array.isArray(detail.favorite_project_ids)) {
      _setFavoritesAndBroadcast(detail.favorite_project_ids);
    }
  });
}

// Publish stub arrays + enums immediately so JSX evaluation never breaks.
Object.assign(window, {
  STATUSES, PRIORITIES, WS_ROLES, PROJECT_ROLES, QA_STATUS, NO_EPIC, STORY_STATUSES,
  TASK_TYPES, TASK_TYPE_BY_ID, taskTypeMeta,
  PEOPLE, PROJECTS, SPRINTS, EPICS, USER_STORIES, WORKSPACES,
  ALL_TASKS, MY_TASKS, MY_MENTIONS, MY_REVIEWS, MY_STREAK,
  WORKSPACE, PROJECT_ACCESS, QA_TASKS,
  MY_FAVORITES,
  flowboardLoad, flowboardLoadFromCache, flowboardToggleFavorite,
  fmtDay, fmtRelative, normalizeTask, normalizeUser,
  _fbCacheClear,
});
