/* Sculptex — single-app file. Production-feel UI, mock AI generation. */

const { useState, useEffect, useRef, useCallback } = React;
const { createPortal } = ReactDOM;

/* Client-side mirror of lib/orgs.js#parseEmailList, used only to size the
   'Send invitation(s)' button label. Don't rely on this for validation —
   the server is the source of truth. */
function parseEmailsCount(s) {
  if (typeof s !== 'string') return 0;
  let n = 0;
  for (const tok of s.split(/[,\s]+/)) {
    const e = tok.trim().toLowerCase();
    if (e && /^\S+@\S+\.\S+$/.test(e)) n++;
  }
  return n;
}

/* ─────────────────────────── Plans ─────────────────────────── */
// User-facing usage is expressed as a relative multiplier (1× / 5× / 25×)
// + a percent of the weekly limit. The raw weeklyCredits / cost numbers
// stay internal — they drive math, never user-facing copy. Keep in sync
// with lib/plans.js (server-side source of truth).
const PLANS = {
  free:     { label: 'Free',     weeklyCredits: 10,    qualities: ['Draft', 'Standard'],         price: 0,   usageMultiplier: 1 },
  pro:      { label: 'Pro',      weeklyCredits: 50,    qualities: ['Draft', 'Standard', 'Max'],  price: 20,  usageMultiplier: 5 },
  max:      { label: 'Max',      weeklyCredits: 250,   qualities: ['Draft', 'Standard', 'Max'],  price: 100, usageMultiplier: 25 },
  business: { label: 'Business', weeklyCredits: 250,   qualities: ['Draft', 'Standard', 'Max'],  price: 105, usageMultiplier: 25 },
  admin:    { label: 'Admin',    weeklyCredits: 99999, qualities: ['Draft', 'Standard', 'Max'],  price: 0,   hidden: true, usageMultiplier: null },
};
function usageLabelForPlan(planKey) {
  const p = PLANS[planKey];
  if (!p) return '1×';
  if (p.usageMultiplier == null) return 'unlimited';
  return `${p.usageMultiplier}×`;
}
const QUALITY_COST = { Draft: 0.5, Standard: 1, Max: 2 };
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;

/* ─────────────────────────── API client ─────────────────────────── */
class ApiError extends Error {
  constructor(message, status, debug) {
    super(message);
    this.status = status;
    this.debug = debug || null;
  }
}
async function jsonFetch(url, opts = {}) {
  const method = (opts.method || 'GET').toUpperCase();
  let r;
  try {
    r = await fetch(url, {
      credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
      ...opts,
    });
  } catch (fetchErr) {
    // Network / DNS / CORS / aborted request. fetch() throws TypeError on
    // most platforms ('Failed to fetch') and 'Load failed' on Safari. Pin
    // every available diagnostic onto debug so admins can see what actually
    // happened (online flag, the underlying error name + message + stack).
    if (typeof console !== 'undefined') console.error('jsonFetch fetch error', url, fetchErr);
    throw new ApiError(
      fetchErr?.message || 'Network request failed',
      0,
      {
        kind: 'fetch_error',
        request: { url, method },
        errorName: fetchErr?.name || null,
        errorMessage: fetchErr?.message || String(fetchErr || ''),
        errorStack: fetchErr?.stack || null,
        online: typeof navigator !== 'undefined' ? navigator.onLine : null,
        ua: typeof navigator !== 'undefined' ? navigator.userAgent : null,
        at: new Date().toISOString(),
      },
    );
  }
  let body, bodyParseError = null;
  try { body = await r.json(); } catch (e) { body = null; bodyParseError = String(e?.message || e); }
  if (!r.ok) {
    const { error: _ignored, ...rest } = body || {};
    if (typeof console !== 'undefined') console.error(`jsonFetch HTTP ${r.status}`, url, body || bodyParseError);
    const debug = {
      kind: 'http_error',
      request: { url, method },
      status: r.status,
      statusText: r.statusText || null,
      ...(rest && Object.keys(rest).length ? rest : {}),
      ...(bodyParseError ? { bodyParseError } : {}),
    };
    throw new ApiError(body?.error || `Request failed (${r.status} ${r.statusText || ''})`.trim(), r.status, debug);
  }
  return body;
}
const api = {
  me: () => jsonFetch('/api/me'),
  publicConfig: () => jsonFetch('/api/public-config'),
  signup: (data) => jsonFetch('/api/auth/signup', { method: 'POST', body: JSON.stringify(data) }),
  signin: (data) => jsonFetch('/api/auth/signin', { method: 'POST', body: JSON.stringify(data) }),
  supabaseSession: (access_token) => jsonFetch('/api/auth/supabase', { method: 'POST', body: JSON.stringify({ access_token }) }),
  signout: () => jsonFetch('/api/auth/signout', { method: 'POST' }),
  upgrade: (data) => jsonFetch('/api/upgrade', { method: 'POST', body: JSON.stringify(data) }),
  adminListUsers: () => jsonFetch('/api/admin/users'),
  adminSimPlan: (planOrBody) => jsonFetch('/api/admin/sim-plan', {
    method: 'POST',
    body: JSON.stringify(typeof planOrBody === 'string' ? { plan: planOrBody } : planOrBody),
  }),
  adminListOrgs: () => jsonFetch('/api/admin/orgs'),
  adminPatchUser: (uid, patch) => jsonFetch(`/api/admin/users/${uid}`, { method: 'PATCH', body: JSON.stringify(patch) }),
  adminDeleteUser: (uid, body) => jsonFetch(`/api/admin/users/${uid}`, { method: 'DELETE', body: JSON.stringify(body || {}) }),
  adminSimReset: () => jsonFetch('/api/admin/sim-reset', { method: 'POST' }),
  patchMe: (patch) => jsonFetch('/api/me', { method: 'PATCH', body: JSON.stringify(patch) }),
  // Org admin endpoints (also accessible to app admins for any org).
  getOrg: (id) => jsonFetch(`/api/orgs/${id}`),
  patchOrg: (id, patch) => jsonFetch(`/api/orgs/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
  listOrgMembers: (id) => jsonFetch(`/api/orgs/${id}/members`),
  addOrgMember: (id, body) => jsonFetch(`/api/orgs/${id}/members`, { method: 'POST', body: JSON.stringify(body) }),
  patchOrgMember: (id, uid, patch) => jsonFetch(`/api/orgs/${id}/members/${uid}`, { method: 'PATCH', body: JSON.stringify(patch) }),
  deleteOrgMember: (id, uid, body) => jsonFetch(`/api/orgs/${id}/members/${uid}`, { method: 'DELETE', body: JSON.stringify(body || {}) }),
  // Project-centric (new top-level entity)
  listProjects:    () => jsonFetch('/api/projects'),
  createProject:   (data) => jsonFetch('/api/projects', { method: 'POST', body: JSON.stringify(data || {}) }),
  getProject:      (id) => jsonFetch(`/api/projects/${id}`),
  patchProject:    (id, patch) => jsonFetch(`/api/projects/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
  deleteProject:   (id) => jsonFetch(`/api/projects/${id}`, { method: 'DELETE' }),
  generateInProject: (id, body) => jsonFetch(`/api/projects/${id}/generate`, { method: 'POST', body: JSON.stringify(body) }),
  chatInProject: (id, body) => jsonFetch(`/api/projects/${id}/chat`, { method: 'POST', body: JSON.stringify(body) }),
  // Streams the chat reply over Server-Sent Events. `on` callbacks receive
  // each event as it arrives. Returns the final 'done' payload, or throws
  // a structured Error on a stream-level failure. Falls back to the plain
  // chatInProject when the user agent or proxy can't handle the stream.
  chatInProjectStream: async (id, body, on = {}, opts = {}) => {
    const url = `/api/projects/${id}/chat`;
    let r;
    try {
      r = await fetch(url, {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
        body: JSON.stringify({ ...body, stream: true }),
        signal: opts.signal,
      });
    } catch (fetchErr) {
      if (typeof console !== 'undefined') console.error('chatInProjectStream fetch error', url, fetchErr);
      throw new ApiError(
        fetchErr?.message || 'Network request failed',
        0,
        {
          kind: 'fetch_error',
          request: { url, method: 'POST' },
          errorName: fetchErr?.name || null,
          errorMessage: fetchErr?.message || String(fetchErr || ''),
          errorStack: fetchErr?.stack || null,
          online: typeof navigator !== 'undefined' ? navigator.onLine : null,
          at: new Date().toISOString(),
        },
      );
    }
    if (!r.ok) {
      let parsed = null, bodyParseError = null;
      try { parsed = await r.json(); } catch (e) { bodyParseError = String(e?.message || e); }
      if (typeof console !== 'undefined') console.error(`chatInProjectStream HTTP ${r.status}`, url, parsed || bodyParseError);
      throw new ApiError(parsed?.error || `Stream failed (${r.status})`, r.status, {
        kind: 'http_error',
        request: { url, method: 'POST' },
        status: r.status,
        statusText: r.statusText || null,
        ...(parsed || {}),
        ...(bodyParseError ? { bodyParseError } : {}),
      });
    }
    if (!r.body) throw new ApiError('Stream not supported', 0, { kind: 'no_stream', request: { url, method: 'POST' } });
    const reader = r.body.getReader();
    const dec = new TextDecoder();
    let buf = '';
    let done = null;
    while (true) {
      let chunk;
      try {
        chunk = await reader.read();
      } catch (readErr) {
        if (typeof console !== 'undefined') console.error('chatInProjectStream read error', url, readErr);
        throw new ApiError(
          readErr?.message || 'Stream interrupted',
          0,
          {
            kind: 'stream_read_error',
            request: { url, method: 'POST' },
            errorName: readErr?.name || null,
            errorMessage: readErr?.message || String(readErr || ''),
            errorStack: readErr?.stack || null,
            bytesBuffered: buf.length,
            online: typeof navigator !== 'undefined' ? navigator.onLine : null,
            at: new Date().toISOString(),
          },
        );
      }
      const { value, done: streamDone } = chunk;
      if (streamDone) break;
      buf += dec.decode(value, { stream: true });
      // SSE frames are separated by '\n\n'. Each frame may have multiple
      // lines: 'event: <name>' and 'data: <json>'. Comment lines start with ':'.
      let idx;
      while ((idx = buf.indexOf('\n\n')) >= 0) {
        const frame = buf.slice(0, idx);
        buf = buf.slice(idx + 2);
        if (!frame.trim() || frame.startsWith(':')) continue;
        let evt = 'message';
        let dataStr = '';
        for (const line of frame.split('\n')) {
          if (line.startsWith('event:')) evt = line.slice(6).trim();
          else if (line.startsWith('data:')) dataStr += line.slice(5).trim();
        }
        if (!dataStr) continue;
        let data;
        try { data = JSON.parse(dataStr); } catch { continue; }
        // 'connected' is a header-flush event the server sends immediately
        // after writeHead so proxies don't drop the body before Claude's
        // first delta arrives. We ack it silently — no handler needed.
        if (evt === 'connected') continue;
        if (evt === 'done') done = data;
        else if (evt === 'error') {
          throw new ApiError(data.error || 'Stream error', 502, { kind: 'server_stream_error', ...(data.debug ?? {}) });
        } else on[evt]?.(data);
      }
    }
    if (!done) throw new ApiError('Stream ended without completion', 0, null);
    return done;
  },
  uploadProjectThumb: (id, modelId, dataUrl) => jsonFetch(`/api/projects/${id}/thumbnail`, { method: 'POST', body: JSON.stringify({ modelId, dataUrl }) }),
  recoverAccount: (data) => jsonFetch('/api/auth/recover', { method: 'POST', body: JSON.stringify(data) }),
  acceptInvite: (orgId) => jsonFetch('/api/me/accept-invite', { method: 'POST', body: JSON.stringify({ orgId }) }),
  declineInvite: (orgId) => jsonFetch('/api/me/decline-invite', { method: 'POST', body: JSON.stringify({ orgId }) }),

  // Share / print / orders
  shareCreate: (projectId, modelId) => jsonFetch(`/api/projects/${projectId}/share`, { method: 'POST', body: JSON.stringify({ modelId }) }),
  shareRevoke: (projectId, modelId) => jsonFetch(`/api/projects/${projectId}/share-revoke`, { method: 'POST', body: JSON.stringify({ modelId }) }),
  shareEmail: (projectId, modelId, email, role) => jsonFetch(`/api/projects/${projectId}/share-email`, { method: 'POST', body: JSON.stringify({ modelId, email, role }) }),
  shareOrg: (projectId, modelId) => jsonFetch(`/api/projects/${projectId}/share-org`, { method: 'POST', body: JSON.stringify({ modelId }) }),
  publicShare: (token) => jsonFetch(`/api/share/${encodeURIComponent(token)}`),
  sharedWithMe: () => jsonFetch('/api/shares/incoming'),
  validateMesh: (projectId, modelId) => jsonFetch(`/api/projects/${projectId}/mesh-validate`, { method: 'POST', body: JSON.stringify({ modelId }) }),
  claudeReviewMesh: (projectId, modelId) => jsonFetch(`/api/projects/${projectId}/mesh-claude-review`, { method: 'POST', body: JSON.stringify({ modelId }) }),
  claudeFixMesh: (projectId, modelId, body) => jsonFetch(`/api/projects/${projectId}/mesh-claude-fix`, { method: 'POST', body: JSON.stringify({ modelId, ...(body || {}) }) }),
  repairMesh: (projectId, modelId) => jsonFetch(`/api/projects/${projectId}/mesh-repair`, { method: 'POST', body: JSON.stringify({ modelId }) }),
  printQuote: (projectId, modelId) => jsonFetch(`/api/projects/${projectId}/print-quote`, { method: 'POST', body: JSON.stringify({ modelId }) }),
  printOrder: (projectId, modelId, shippingAddress) => jsonFetch(`/api/projects/${projectId}/print-order`, { method: 'POST', body: JSON.stringify({ modelId, shippingAddress }) }),
  myOrders: () => jsonFetch('/api/my-orders'),
  getOrder: (orderId) => jsonFetch(`/api/orders/${encodeURIComponent(orderId)}`),
  adminOrders: () => jsonFetch('/api/admin/orders'),
  adminOrderUpdate: (orderId, patch) => jsonFetch('/api/admin/order-update', { method: 'POST', body: JSON.stringify({ orderId, ...patch }) }),
  pushVapid: () => jsonFetch('/api/push/vapid'),
  pushSubscribe: (subscription) => jsonFetch('/api/push/subscribe', { method: 'POST', body: JSON.stringify({ subscription }) }),
  pushUnsubscribe: (endpoint) => jsonFetch('/api/push/unsubscribe', { method: 'POST', body: JSON.stringify({ endpoint }) }),
};

/* ─────────────────────────── Supabase (passwordless auth) ────────────────
   Sign-in is Supabase-backed and passwordless: Google OAuth OR email
   magic link. The Supabase SDK is preloaded via a <script type="module">
   in index.html (the no-bundler Babel-standalone pipeline turns dynamic
   import() into require() calls which crash in the browser; preloading
   side-steps that). The module exposes window.__supabase = { createClient }
   and fires a 'supabase-ready' event.

   Config (SUPABASE_URL, SUPABASE_ANON_KEY) is fetched from /api/public-
   config so deploys can be reconfigured without code changes.

   Callback flow — same for Google and magic link:
   1. User triggers sign-in (clicks Google button OR types email + "send
      magic link"). Browser leaves Sculptex.
   2. After Google consent / clicking the email link, Supabase redirects
      back to the site origin with access_token in the URL hash.
   3. AppShell's boot effect detects the hash, asks Supabase for the
      session, POSTs the access_token to /api/auth/supabase. The server
      validates with Supabase's /auth/v1/user, looks up or creates a local
      account by email, sets the site session cookie. */
function waitForSupabaseLib() {
  if (typeof window !== 'undefined' && window.__supabase) return Promise.resolve(window.__supabase);
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => reject(new Error('Supabase library did not load. Refresh and try again.')), 12000);
    window.addEventListener('supabase-ready', () => { clearTimeout(t); resolve(window.__supabase); }, { once: true });
  });
}

let _supabasePromise = null;
async function getSupabase() {
  if (_supabasePromise) return _supabasePromise;
  _supabasePromise = (async () => {
    const cfg = await api.publicConfig();
    if (!cfg.googleSignInEnabled) {
      const err = new Error('Sign-in is not configured on this deployment.');
      err.code = 'not_configured';
      throw err;
    }
    const lib = await waitForSupabaseLib();
    const client = lib.createClient(cfg.supabaseUrl, cfg.supabaseAnonKey, {
      auth: {
        detectSessionInUrl: true,
        persistSession: true,
        autoRefreshToken: true,
        // Implicit flow puts the access_token directly in the URL hash so
        // any browser opening the magic link gets signed in. PKCE (the
        // SDK default) requires a code_verifier from localStorage of the
        // browser that initiated the flow, which breaks "copy link, open
        // on phone" UX. Implicit is what Vercel / Notion / GitHub / most
        // consumer products use for magic links.
        flowType: 'implicit',
      },
    });
    return client;
  })();
  return _supabasePromise;
}

/* Construct the public share URL from a token. The backend's share-create
   route returns it directly; we recompute here so the menu can rehydrate
   from a persisted `model.shareToken` without needing a round-trip. */
function buildShareUrl(token) {
  if (typeof window === 'undefined' || !token) return '';
  return `${window.location.origin}/s/${token}`;
}

/* True iff the current URL fragment looks like a Supabase callback —
   shape is the same for Google OAuth and email magic link. */
function hasOAuthCallbackHash() {
  if (typeof window === 'undefined') return false;
  const h = window.location.hash || '';
  return /access_token=|error_description=/.test(h);
}

/* ─────────────────────────── 3D readiness ─────────────────────────── */
function useSculptex3DReady() {
  const [ready, setReady] = useState(!!window.Sculptex3D);
  useEffect(() => {
    if (window.Sculptex3D) { setReady(true); return; }
    const onReady = () => setReady(true);
    window.addEventListener('sculptex3d-ready', onReady);
    const t = setInterval(() => { if (window.Sculptex3D) { setReady(true); clearInterval(t); } }, 200);
    return () => { window.removeEventListener('sculptex3d-ready', onReady); clearInterval(t); };
  }, []);
  return ready;
}

/* ─────────────────────────── Icons ─────────────────────────── */
const I = {
  brand:    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round"><polygon points="12,3 21,8 21,17 12,22 3,17 3,8" /><polyline points="3,8 12,13 21,8" /><line x1="12" y1="13" x2="12" y2="22" /></svg>,
  user:     <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" strokeLinecap="round"><circle cx="12" cy="8" r="4" /><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8" /></svg>,
  send:     <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round"><path d="M5 12h14" /><path d="M13 6l6 6-6 6" /></svg>,
  stop:     <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2" /></svg>,
  attach:   <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>,
  clock:    <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><polyline points="12,7 12,12 15.5,14" /></svg>,
  link:     <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M10 14a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.5 1.5" /><path d="M14 10a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.5-1.5" /></svg>,
  mail:     <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2" /><polyline points="3,7 12,13 21,7" /></svg>,
  team:     <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><circle cx="9" cy="9" r="3.2" /><circle cx="17" cy="10.5" r="2.5" /><path d="M2.5 19c0-3 2.9-5.2 6.5-5.2s6.5 2.2 6.5 5.2" /><path d="M16 14.5c2.6.2 5 2 5 4.5" /></svg>,
  printer:  <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><polyline points="6,9 6,3 18,3 18,9" /><rect x="3" y="9" width="18" height="9" rx="2" /><rect x="7" y="14" width="10" height="6" rx="1" /><circle cx="17.5" cy="12" r="0.6" fill="currentColor" /></svg>,
  search:   <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" strokeLinecap="round"><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>,
  cube:     <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round"><polygon points="12,3 21,8 21,17 12,22 3,17 3,8" /><polyline points="3,8 12,13 21,8" /><line x1="12" y1="13" x2="12" y2="22" /></svg>,
  chat:     <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round"><path d="M5 5h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-7l-4 3v-3H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z" /></svg>,
  grid:     <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"><rect x="3" y="4" width="7" height="7" rx="1.5" /><rect x="14" y="4" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" /></svg>,
  tag:      <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"><path d="M20.6 13.4l-7.2 7.2a2 2 0 0 1-2.8 0L3 13V4h9l8.6 8.6a2 2 0 0 1 0 .8z" /><circle cx="8" cy="9" r="1.4" /></svg>,
  home:     <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"><polygon points="12,3 21,8 21,17 12,22 3,17 3,8" /><polyline points="3,8 12,13 21,8" /></svg>,
  check:    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><polyline points="20,6 9,17 4,12" /></svg>,
  download: <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" strokeLinecap="round"><path d="M12 3v12" /><polyline points="6,11 12,17 18,11" /><path d="M5 21h14" /></svg>,
  rotate:   <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" strokeLinecap="round"><path d="M3 12a9 9 0 0 1 15-6.7L21 8" /><polyline points="21,3 21,8 16,8" /><path d="M21 12a9 9 0 0 1-15 6.7L3 16" /><polyline points="3,21 3,16 8,16" /></svg>,
  plus:     <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>,
  minus:    <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" strokeLinecap="round"><line x1="5" y1="12" x2="19" y2="12" /></svg>,
  trash:    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" strokeLinecap="round"><polyline points="3,6 5,6 21,6" /><path d="M19,6v14a2,2,0,0,1-2,2H7a2,2,0,0,1-2-2V6m3,0V4a2,2,0,0,1,2-2h4a2,2,0,0,1,2,2V6" /></svg>,
  share:    <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" strokeLinecap="round"><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><line x1="8.6" y1="13.5" x2="15.4" y2="17.5" /><line x1="15.4" y1="6.5" x2="8.6" y2="10.5" /></svg>,
  signOut:  <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" strokeLinecap="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16,17 21,12 16,7" /><line x1="21" y1="12" x2="9" y2="12" /></svg>,
};

const SUGGESTIONS = [
  "a brass diving helmet, weathered",
  "low-poly fox, sitting",
  "art-deco lamp base",
  "stylized potion bottle",
  "modular sci-fi crate",
];

/* ─────────────────────────── Toast ─────────────────────────── */
const ToastCtx = React.createContext({ push: () => {}, setIsAdmin: () => {} });
function ToastProvider({ children }) {
  const [toasts, setToasts] = useState([]);
  // Tracked here so the toast cards can show the Copy button for admins
  // without each call site having to forward admin status. AppShell calls
  // setIsAdmin in a useEffect when the signed-in user changes.
  const [isAdmin, setIsAdmin] = useState(false);

  const push = useCallback((text, kind = 'default', opts = {}) => {
    const id = Math.random().toString(36).slice(2);
    const debug = opts && typeof opts === 'object' ? opts.debug ?? null : null;
    setToasts((ts) => [...ts, { id, text, kind, debug, at: Date.now() }]);
    // Errors stay up longer so the user can read + copy them. Successes auto-hide.
    const ttl = kind === 'error' ? 9000 : 3200;
    setTimeout(() => setToasts((ts) => ts.filter((t) => t.id !== id)), ttl);
  }, []);
  const dismiss = useCallback((id) => setToasts((ts) => ts.filter((t) => t.id !== id)), []);

  return (
    <ToastCtx.Provider value={{ push, setIsAdmin }}>
      {children}
      <div className="toast-stack">
        {toasts.map((t) => (
          <ToastCard key={t.id} toast={t} isAdmin={isAdmin} onDismiss={() => dismiss(t.id)} />
        ))}
      </div>
    </ToastCtx.Provider>
  );
}
function useToast() { return React.useContext(ToastCtx); }

/* ─────────────────────────── Web Push enablement ───────────────────────────
   Called from the admin panel on first visit. Asks the browser for
   notification permission, subscribes against the server's VAPID public
   key, and persists the subscription with /api/push/subscribe.

   Push works on Chrome, Edge, Firefox (all platforms) and Safari (macOS
   16.4+ / iOS 16.4+ for installed PWAs). Silently no-ops if the browser
   doesn't support push or the server hasn't been configured with VAPID
   keys yet. */
function urlBase64ToUint8Array(b64) {
  const padding = '='.repeat((4 - b64.length % 4) % 4);
  const base64 = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/');
  const raw = atob(base64);
  const out = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; ++i) out[i] = raw.charCodeAt(i);
  return out;
}
async function enablePushIfNeeded(toast) {
  if (typeof window === 'undefined') return;
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    return; // browser doesn't support push
  }
  // Bail if the user has already been asked (denied or granted) — we don't
  // re-prompt on every visit because that triggers a permanent block.
  if (Notification.permission === 'denied') return;
  try {
    const cfg = await api.pushVapid();
    if (!cfg.enabled || !cfg.publicKey) return; // server not configured
    const reg = await navigator.serviceWorker.register('/sw.js');
    const existing = await reg.pushManager.getSubscription();
    if (existing) {
      // Already subscribed; ensure server has it. Best-effort.
      try { await api.pushSubscribe(existing.toJSON()); } catch {}
      return;
    }
    // Only prompt on the first attempt — permission === 'default'.
    if (Notification.permission === 'default') {
      const perm = await Notification.requestPermission();
      if (perm !== 'granted') return;
    }
    const sub = await reg.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(cfg.publicKey),
    });
    await api.pushSubscribe(sub.toJSON());
    toast?.push('Push notifications enabled.', 'default');
  } catch (e) {
    // Don't surface push failures as user-facing errors — push is a nicety.
    console.warn('push enable failed:', e);
  }
}

/* One notification card. Errors render as a card in the top-right with a
   dismiss × and (for admins) an inline debug payload + Copy button. */
function ToastCard({ toast, isAdmin, onDismiss }) {
  const [copied, setCopied] = useState(false);
  function copy() {
    const payload = {
      kind: toast.kind,
      message: toast.text,
      debug: toast.debug ?? null,
      at: new Date(toast.at || Date.now()).toISOString(),
    };
    const blob = JSON.stringify(payload, null, 2);
    if (navigator.clipboard?.writeText) {
      navigator.clipboard.writeText(blob).then(() => {
        setCopied(true);
        setTimeout(() => setCopied(false), 1500);
      });
    } else {
      // Fallback for very old browsers — shouldn't really hit this.
      const ta = document.createElement('textarea');
      ta.value = blob;
      document.body.appendChild(ta); ta.select();
      try { document.execCommand('copy'); setCopied(true); setTimeout(() => setCopied(false), 1500); }
      finally { ta.remove(); }
    }
  }
  const debugStr = toast.debug == null
    ? (isAdmin && toast.kind === 'error'
        ? '(no structured debug — check the browser console: a console.error was logged with the underlying object)'
        : null)
    : (typeof toast.debug === 'string' ? toast.debug : JSON.stringify(toast.debug, null, 2));
  return (
    <div className={`toast toast-card toast-${toast.kind}`} role={toast.kind === 'error' ? 'alert' : 'status'}>
      <div className="toast-row">
        <div className="toast-text">{toast.text}</div>
        <button className="toast-x" onClick={onDismiss} aria-label="Dismiss">×</button>
      </div>
      {debugStr && isAdmin && (
        <pre className="toast-debug">{debugStr}</pre>
      )}
      {isAdmin && toast.kind === 'error' && (
        <div className="toast-actions">
          <button className="toast-action" onClick={copy}>{copied ? 'Copied' : 'Copy'}</button>
        </div>
      )}
    </div>
  );
}


/* ─────────────────────────── App root ─────────────────────────── */
function App() {
  return (
    <ToastProvider>
      <AppShell />
    </ToastProvider>
  );
}

// Route names map 1:1 to view names; adding here keeps the SPA URL in sync
// with the internal `view` state. vercel.json rewrites the SPA paths to
// /index.html so those URLs hit the SPA.
const URL_TO_VIEW = {
  '/':            'home',
  '/projects':    'projects',
  '/library':     'library',
  '/pricing':     'pricing',
  '/docs':        'docs',
  '/account':     'account',
  '/admin':       'admin',
  '/privacy':     'privacy',
  '/terms':       'terms',
};
const VIEW_TO_URL = Object.fromEntries(Object.entries(URL_TO_VIEW).map(([k, v]) => [v, k]));

function initialView() {
  if (typeof window === 'undefined') return 'home';
  const p = window.location.pathname.toLowerCase().replace(/\/$/, '') || '/';
  if (p.match(/^\/project\/([\w-]+)$/)) return 'project';
  if (p.match(/^\/order\/([\w-]+)$/)) return 'order';
  if (p.match(/^\/s\/([\w-]+)$/)) return 'publicShare';
  return URL_TO_VIEW[p] || 'home';
}
function initialProjectId() {
  if (typeof window === 'undefined') return null;
  const m = window.location.pathname.match(/^\/project\/([\w-]+)$/);
  return m ? m[1] : null;
}
function initialOrderId() {
  if (typeof window === 'undefined') return null;
  const m = window.location.pathname.match(/^\/order\/([\w-]+)$/);
  return m ? m[1] : null;
}
function initialShareToken() {
  if (typeof window === 'undefined') return null;
  const m = window.location.pathname.match(/^\/s\/([\w-]+)$/);
  return m ? m[1] : null;
}

function AppShell() {
  const toast = useToast();

  const [user, setUser] = useState(null);
  const [userLoaded, setUserLoaded] = useState(false);
  // MFA gate: full-screen overlay that blocks the app until the user has
  // satisfied either (a) a step-up challenge for an existing TOTP factor
  // or (b) a forced enrollment because their org requires MFA.
  // null = no gate; { mode: 'challenge' | 'enroll', orgRequired: boolean }
  const [mfaGate, setMfaGate] = useState(null);
  const [models, setModels] = useState([]);
  const [projects, setProjects] = useState([]);
  const [activeProjectId, setActiveProjectId] = useState(initialProjectId);
  const [activeProject, setActiveProject] = useState(null);
  const [view, setViewRaw] = useState(initialView);
  const setView = useCallback((v, opts = {}) => {
    setViewRaw(v);
    let url = VIEW_TO_URL[v];
    if (v === 'project' && opts.projectId) url = `/project/${opts.projectId}`;
    if (url && typeof window !== 'undefined') {
      const want = url === '/' ? '/' : url;
      if (window.location.pathname.toLowerCase() !== want.toLowerCase()) {
        window.history.pushState({ view: v, projectId: opts.projectId || null }, '', want);
      }
    }
  }, []);
  // Browser back/forward
  useEffect(() => {
    function onPop() {
      setViewRaw(initialView());
      setActiveProjectId(initialProjectId());
    }
    window.addEventListener('popstate', onPop);
    return () => window.removeEventListener('popstate', onPop);
  }, []);
  const [prompt, setPrompt] = useState('');
  const [activePrompt, setActivePrompt] = useState('');
  const [activeModelId, setActiveModelId] = useState(null);
  const [quality, setQuality] = useState('Standard');
  const [authModal, setAuthModal] = useState(null);
  // PrintWizard target lives at the app level so the chat tool result for
  // send_to_print (handled inside runGenerateInProject) can open the wizard
  // without prop-drilling refs back into ProjectWorkspace.
  const [printWizardFor, setPrintWizardFor] = useState(null);
  const [checkoutPlan, setCheckoutPlan] = useState(null);
  const [generating, setGenerating] = useState(false);
  // AbortController for the active chat stream so the workspace can let the
  // user hit Stop mid-stream. Lives on a ref so the controller's identity
  // isn't tied to a render; we swap the controller on each new turn and
  // call .abort() on the previous one.
  const abortRef = useRef(null);
  function stopGenerating() { try { abortRef.current?.abort(); } catch {} }
  const generatingRef = useRef(false);

  // initial load
  useEffect(() => {
    (async () => {
      try {
        // Handle a Supabase sign-in callback — same shape for Google OAuth
        // and email magic link. Supabase parks the access token in the URL
        // hash; we trade it for our own session cookie before /api/me runs
        // so the user appears signed in on first render.
        if (hasOAuthCallbackHash()) {
          try {
            const supabase = await getSupabase();
            // Wait for supabase-js to finish detectSessionInUrl. It calls
            // /auth/v1/user to validate the access_token from the hash —
            // can take 200-1500ms. We listen for SIGNED_IN before polling
            // so we don't race; the listener resolves immediately if a
            // session is already in place, otherwise it waits up to 6s.
            const session = await new Promise(async (resolve) => {
              const timeout = setTimeout(() => resolve(null), 6000);
              const { data: { subscription } } = supabase.auth.onAuthStateChange((event, s) => {
                if (s?.access_token) {
                  clearTimeout(timeout);
                  try { subscription?.unsubscribe(); } catch {}
                  resolve(s);
                }
              });
              // Belt-and-suspenders: check immediately too in case the
              // session was already in place before our listener attached.
              try {
                const { data } = await supabase.auth.getSession();
                if (data?.session?.access_token) {
                  clearTimeout(timeout);
                  try { subscription?.unsubscribe(); } catch {}
                  resolve(data.session);
                }
              } catch {}
            });
            // Strip the hash so a refresh doesn't re-run the flow.
            try { window.history.replaceState({}, '', window.location.pathname + window.location.search); } catch {}
            if (session?.access_token) {
              console.info('[auth] supabase session captured; exchanging for site cookie');
              const res = await api.supabaseSession(session.access_token);
              if (res?.user) {
                toast.push('Signed in', 'success');
              }
            } else {
              console.warn('[auth] OAuth callback returned, but no session was set by supabase-js within 6s');
              toast.push('Sign-in didn\'t complete. Try again — if it persists, share the browser console with support.', 'error');
            }
          } catch (e) {
            // Log the FULL error to the console so DevTools shows what
            // actually broke. The toast still gets the human message.
            console.error('[auth] Sign-in callback failed', {
              name: e?.name,
              message: e?.message,
              status: e?.status,
              debug: e?.debug,
              stack: e?.stack,
            });
            const msg = e?.message ? `Sign-in failed: ${e.message}` : 'Sign-in failed';
            toast.push(msg, 'error');
          }
        }
        const { user, org, invitations } = await api.me();
        if (user) setUser({ ...user, orgInfo: org || null, invitations: invitations || [] });
        if (user) {
          // Pull both legacy models (so existing data still shows up while we
          // migrate) and the new project list.
          await refreshAll();
          // MFA gating — checked AFTER the user is loaded so we have org info.
          // Two cases that block the app:
          //   - User has a verified TOTP factor but session is still aal1 →
          //     challenge them to step up.
          //   - User's org has requireMfa=true and they have NO verified
          //     factors → force enrollment.
          try {
            const supabase = await getSupabase();
            const [{ data: aal }, { data: factors }] = await Promise.all([
              supabase.auth.mfa.getAuthenticatorAssuranceLevel(),
              supabase.auth.mfa.listFactors(),
            ]);
            const verifiedTotp = (factors?.totp || []).filter((f) => f.status === 'verified');
            const orgRequires = !!org?.requireMfa;
            if (aal?.nextLevel === 'aal2' && aal.nextLevel !== aal.currentLevel) {
              setMfaGate({ mode: 'challenge', orgRequired: orgRequires });
            } else if (orgRequires && verifiedTotp.length === 0) {
              setMfaGate({ mode: 'enroll', orgRequired: true });
            }
          } catch (e) {
            console.warn('[mfa] gate check failed (continuing without)', e);
          }
        }
      } catch (e) {
        toast.push('Could not reach server', 'error');
      } finally {
        setUserLoaded(true);
      }
    })();
  }, []);

  // Whenever the URL has a /project/<id>, fetch that project.
  // Sync admin status to the toast provider so error cards can show the
  // Copy button + inline debug payload when an admin is signed in.
  useEffect(() => { toast.setIsAdmin?.(!!user?.admin); }, [user?.admin]);

  // Global error / unhandled-rejection handlers. Admins get a full toast
  // with the underlying error name + stack + (when available) request URL
  // so they can copy the JSON and triage straight from the UI. Non-admins
  // see nothing — these are runtime gotchas, not user errors.
  useEffect(() => {
    if (!user?.admin) return;
    function onError(ev) {
      const err = ev?.error || ev;
      const debug = {
        kind: 'window_error',
        message: err?.message || String(err || 'unknown'),
        name: err?.name || null,
        stack: err?.stack || null,
        source: ev?.filename || null,
        line: ev?.lineno ?? null,
        column: ev?.colno ?? null,
        at: new Date().toISOString(),
      };
      console.error('window.error', ev);
      toast.push(err?.message || 'Uncaught error', 'error', { debug });
    }
    function onRejection(ev) {
      const reason = ev?.reason;
      const debug = {
        kind: 'unhandled_rejection',
        message: reason?.message || String(reason || 'unknown'),
        name: reason?.name || null,
        stack: reason?.stack || null,
        // ApiError carries its own debug; surface it verbatim so admins
        // see the structured payload too.
        apiDebug: reason?.debug || null,
        apiStatus: reason?.status ?? null,
        at: new Date().toISOString(),
      };
      console.error('unhandled rejection', ev);
      toast.push(reason?.message || 'Unhandled promise rejection', 'error', { debug });
    }
    window.addEventListener('error', onError);
    window.addEventListener('unhandledrejection', onRejection);
    return () => {
      window.removeEventListener('error', onError);
      window.removeEventListener('unhandledrejection', onRejection);
    };
  }, [user?.admin]);

  useEffect(() => {
    if (view !== 'project' || !activeProjectId || !user) return;
    (async () => {
      try {
        const { project } = await api.getProject(activeProjectId);
        setActiveProject(project);
      } catch (e) {
        toast.push(e.message || 'Project not found', 'error', { debug: e.debug });
        setView('projects');
      }
    })();
  }, [view, activeProjectId, user?.id]);

  async function refreshAll() {
    try {
      const { projects } = await api.listProjects();
      setProjects(projects || []);
    } catch {}
  }

  async function refreshModels() {
    try {
      // Legacy library list — projects supersede this; just refresh projects.
      const { projects } = await api.listProjects();
      setProjects(projects || []);
    } catch {}
  }

  function handleAuthSuccess(u) {
    setUser(u);
    setAuthModal(null);
    refreshAll();
    setView('projects');
  }

  async function openProject(p) {
    setActiveProjectId(p.id);
    setActiveProject(null);
    setView('project', { projectId: p.id });
  }

  async function createNewProject(seedPrompt) {
    try {
      const { project } = await api.createProject({ fromPrompt: seedPrompt || undefined });
      setProjects((prev) => [{
        id: project.id, name: project.name, createdAt: project.createdAt,
        updatedAt: project.updatedAt, modelCount: 0, chatCount: 1, coverThumbnail: null,
      }, ...prev]);
      setActiveProject(project);
      setActiveProjectId(project.id);
      setView('project', { projectId: project.id });
      if (seedPrompt) {
        // Kick off a generation right away with that prompt
        await runGenerateInProject(project, project.chats[0].id, seedPrompt);
      }
      return project;
    } catch (e) {
      toast.push(e.message || 'Could not create project', 'error', { debug: e.debug });
      return null;
    }
  }

  async function deleteProject(id) {
    const prev = projects;
    setProjects((p) => p.filter((x) => x.id !== id));
    if (activeProjectId === id) { setActiveProjectId(null); setActiveProject(null); setView('projects'); }
    try {
      const r = await api.deleteProject(id);
      if (r?.failed) {
        toast.push(`Project removed but ${r.failed} blob(s) failed to delete${user?.admin ? ` — see console` : ''}`, 'error');
        if (user?.admin) console.warn('Project delete partial failure:', r);
      } else {
        toast.push('Project deleted', 'success');
      }
    } catch (e) {
      setProjects(prev);
      toast.push(e.message || 'Failed to delete', 'error', { debug: e.debug });
    }
  }

  async function runGenerateInProject(project, chatId, promptText, attachments) {
    if (!project) return;
    setGenerating(true);
    // Fresh AbortController per turn — the workspace's stop button calls
    // abort() through stopGenerating().
    abortRef.current = new AbortController();
    // Optimistically render the user message + an empty AI placeholder so
    // tokens land somewhere as the SSE stream arrives. When 'done' fires,
    // we replace the project entirely with the server's canonical version.
    const targetChatId = chatId || project.chats[0]?.id;
    const userOpt = { from: 'you', text: promptText, at: Date.now(), ...(attachments?.length ? { files: attachments } : {}) };
    const aiOpt = { from: 'ai', text: '', at: Date.now() + 1, toolCalls: [], _streaming: true };
    setActiveProject((prev) => {
      if (!prev || prev.id !== project.id) return prev;
      const chats = prev.chats.map((c) => c.id === targetChatId
        ? { ...c, messages: [...c.messages, userOpt, aiOpt] }
        : c);
      // Optimistically mirror composer attachments into project.files so the
      // Files tab reflects them immediately — before the SSE 'done' event
      // lands the canonical state, AND in the abort/error case where 'done'
      // never arrives. The server already persisted them when it appended
      // the user message (api/projects-handler.js mirrors at write-time), so
      // the optimistic copy matches what's on the server even if the chat
      // turn never completes.
      let files = prev.files || [];
      if (attachments?.length) {
        const knownIds = new Set(files.map((f) => f.id));
        const fresh = attachments.filter((f) => !knownIds.has(f.id));
        if (fresh.length) files = [...files, ...fresh];
      }
      return { ...prev, chats, files };
    });
    const patchLastAi = (mut) => setActiveProject((prev) => {
      if (!prev || prev.id !== project.id) return prev;
      const chats = prev.chats.map((c) => {
        if (c.id !== targetChatId) return c;
        const msgs = c.messages.slice();
        for (let i = msgs.length - 1; i >= 0; i--) {
          if (msgs[i].from === 'ai' && msgs[i]._streaming) { msgs[i] = mut(msgs[i]); break; }
        }
        return { ...c, messages: msgs };
      });
      return { ...prev, chats };
    });

    try {
      const done = await api.chatInProjectStream(project.id, {
        message: promptText,
        chatId: targetChatId,
        files: Array.isArray(attachments) ? attachments : undefined,
      }, {
        text: ({ text }) => patchLastAi((m) => ({ ...m, text: (m.text || '') + text })),
        tool_start: (call) => patchLastAi((m) => ({ ...m, toolCalls: [...(m.toolCalls || []), call] })),
        tool_result: (call) => {
          patchLastAi((m) => {
            const calls = (m.toolCalls || []).slice();
            for (let i = calls.length - 1; i >= 0; i--) {
              if (calls[i].type === call.type && calls[i].result == null) { calls[i] = call; return { ...m, toolCalls: calls }; }
            }
            calls.push(call);
            return { ...m, toolCalls: calls };
          });
          // The send_to_print tool resolves with action='open_print_wizard'
          // so we can pop the wizard immediately. The model may not be in
          // local project state yet if it was generated earlier in the same
          // turn (project.models only updates at SSE 'done') — store the
          // modelId only and let ProjectWorkspace resolve it on render.
          if (call.type === 'send_to_print' && call.result?.ok && call.result?.action === 'open_print_wizard') {
            const mid = call.result.modelId;
            if (mid) setPrintWizardFor({ modelId: mid });
          }
        },
      }, { signal: abortRef.current.signal });
      // Targeted merge: keep our optimistic message order (so React doesn't
      // remount the user bubble mid-render), just swap the streaming AI
      // placeholder for the canonical server message, and pick up name /
      // models / files updates from the server. Replacing the whole
      // project caused intermittent flicker on the user bubble during the
      // transition from streaming to settled.
      setActiveProject((prev) => {
        if (!prev || prev.id !== done.project.id) return done.project;
        const serverChats = new Map((done.project.chats || []).map((c) => [c.id, c]));
        return {
          ...prev,
          name: done.project.name,
          updatedAt: done.project.updatedAt,
          models: done.project.models,
          files: done.project.files,
          chats: prev.chats.map((c) => {
            const sc = serverChats.get(c.id);
            if (!sc) return c;
            if (c.id !== targetChatId) return { ...c, name: sc.name };
            // Drop the streaming placeholder + optimistic user message,
            // append the server's canonical last-two messages (user + ai).
            const survivors = c.messages.filter((m) => !m._streaming);
            // Drop the trailing optimistic user message — server has it
            // with the same content + correct `at`. Identify by 'you' with
            // a Date.now()-ish timestamp; safer: just trim the last 'you'
            // that has no toolCalls and no modelId (the optimistic shape).
            for (let i = survivors.length - 1; i >= 0; i--) {
              if (survivors[i].from === 'you') { survivors.splice(i, 1); break; }
            }
            const serverTail = sc.messages.slice(-2);
            return { ...c, name: sc.name, messages: [...survivors, ...serverTail] };
          }),
        };
      });
      if (done.user) setUser(done.user);
      setProjects((prev) => {
        const summary = {
          id: done.project.id, name: done.project.name, createdAt: done.project.createdAt,
          updatedAt: done.project.updatedAt, modelCount: done.project.models.length, chatCount: done.project.chats.length,
          coverThumbnail: done.project.models.find((m) => m.thumbnailUrl)?.thumbnailUrl || null,
        };
        return [summary, ...prev.filter((p) => p.id !== done.project.id)];
      });
    } catch (e) {
      const aborted = e?.debug?.errorName === 'AbortError' || /aborted/i.test(e?.message || '');
      const partialStream = /stream ended without completion/i.test(e?.message || '');
      // Auto-retry once on a 'stream ended without completion' that the
      // user didn't cause. This usually means a TRELLIS cold start that
      // edged past the 60s function wall — the next run benefits from
      // the Modal memory snapshot and should finish quickly. We tell the
      // user we're warming up so the silence feels purposeful.
      if (partialStream && !aborted && !runGenerateInProject._retried) {
        runGenerateInProject._retried = true;
        toast.push('Warming up the generator… retrying.', 'default');
        // Roll back optimistic placeholder before retrying.
        setActiveProject((prev) => {
          if (!prev || prev.id !== project.id) return prev;
          const chats = prev.chats.map((c) => c.id === targetChatId
            ? { ...c, messages: c.messages.filter((m) => !m._streaming) }
            : c);
          return { ...prev, chats };
        });
        setGenerating(false);
        abortRef.current = null;
        // Re-fire the same turn. The user's message is already saved on
        // the server (we append it before streaming), so the retry sends
        // an empty 'message' value would be weird — instead we just call
        // ourselves with the same args.
        try { await runGenerateInProject(project, chatId, promptText, attachments); }
        finally { runGenerateInProject._retried = false; }
        return;
      }
      if (aborted) {
        // User hit Stop. Don't wipe the partial AI bubble — keep whatever
        // text + toolCalls streamed before the abort so the conversation
        // shows what happened. Just drop the _streaming flag and mark it
        // _stopped so the UI can render a "(stopped)" affordance later.
        setActiveProject((prev) => {
          if (!prev || prev.id !== project.id) return prev;
          const chats = prev.chats.map((c) => {
            if (c.id !== targetChatId) return c;
            const msgs = c.messages.slice();
            for (let i = msgs.length - 1; i >= 0; i--) {
              if (msgs[i].from === 'ai' && msgs[i]._streaming) {
                const { _streaming, ...rest } = msgs[i];
                msgs[i] = { ...rest, _stopped: true };
                break;
              }
            }
            return { ...c, messages: msgs };
          });
          return { ...prev, chats };
        });
      } else {
        // Real failure (network, server 5xx, parse error). Roll back the
        // empty AI bubble so the user can retry without a stranded
        // placeholder. Their user message stays put.
        setActiveProject((prev) => {
          if (!prev || prev.id !== project.id) return prev;
          const chats = prev.chats.map((c) => c.id === targetChatId
            ? { ...c, messages: c.messages.filter((m) => !m._streaming) }
            : c);
          return { ...prev, chats };
        });
        toast.push(e.message || 'Assistant failed', 'error', { debug: e.debug });
      }
      // Server-side note: aborting the fetch terminates the SSE connection
      // but does NOT stop trellis if it's already running there. The user
      // still pays for any work that completed up to the abort.
    } finally {
      setGenerating(false);
      abortRef.current = null;
    }
  }

  async function patchActiveProject(patch) {
    if (!activeProject) return;
    const optimistic = { ...activeProject, ...patch };
    setActiveProject(optimistic);
    try { await api.patchProject(activeProject.id, patch); } catch (e) {
      toast.push(e.message || 'Save failed', 'error', { debug: e.debug });
    }
  }

  async function uploadProjectThumbnail(modelId, dataUrl) {
    if (!activeProject) return;
    try {
      const { project } = await api.uploadProjectThumb(activeProject.id, modelId, dataUrl);
      setActiveProject(project);
    } catch {}
  }

  async function handleSignOut() {
    try { await api.signout(); } catch {}
    setUser(null);
    setModels([]);
    setView('home');
  }

  // Legacy single-model generate / library helpers were removed when the
  // /api/models endpoints were dropped in favour of /api/projects. The
  // signed-in path goes through createNewProject → runGenerateInProject;
  // signed-out clicks open the signup modal.
  async function startGenerate() {
    if (!user) { setAuthModal('signup'); return; }
    setView('projects');
  }
  function openModelFromLibrary(model) {
    if (model?.id) {
      setActiveProjectId(model.id);
      setView('project', { projectId: model.id });
    }
  }
  async function deleteModel() {}
  async function uploadThumbnail() {}
  async function persistChat() {}

  async function applyUpgrade(plan, billing, extras = {}) {
    try {
      const { user: u } = await api.upgrade({ plan, billing, ...extras });
      setUser(u);
      setCheckoutPlan(null);
      toast.push(`Welcome to ${PLANS[plan].label}`, 'success');
    } catch (e) {
      if (e.status === 401) { setAuthModal('signin'); return; }
      toast.push(e.message || 'Upgrade failed', 'error');
    }
  }

  // MFA gate: blocks the app entirely until the user satisfies a step-up
  // challenge or an org-required enrollment. Renders before anything else
  // so a signed-in user with an outstanding factor can't peek at app data.
  if (user && mfaGate) {
    return (
      <MfaGate
        mode={mfaGate.mode}
        orgRequired={mfaGate.orgRequired}
        onSatisfied={() => setMfaGate(null)}
        onSignOut={handleSignOut}
      />
    );
  }

  return (
    <>
      <Nav
        view={view} go={setView} user={user}
        onSignIn={() => setAuthModal('signin')}
        onSignUp={() => setAuthModal('signup')}
        onSignOut={handleSignOut}
      />
      {view === 'home' && !user && (
        <Home
          prompt={prompt} setPrompt={setPrompt}
          quality={quality} setQuality={setQuality}
          user={user} userLoaded={userLoaded}
          onGenerate={startGenerate}
          onUpgrade={() => setView('pricing')}
          onSignUp={() => setAuthModal('signup')}
        />
      )}
      {view === 'home' && user && (
        // Signed in users get the projects home (workspace + project list)
        <ProjectsHome
          user={user}
          projects={projects}
          onOpen={openProject}
          onCreate={createNewProject}
          onDelete={deleteProject}
        />
      )}
      {view === 'projects' && user && (
        <ProjectsHome
          user={user}
          projects={projects}
          onOpen={openProject}
          onCreate={createNewProject}
          onDelete={deleteProject}
        />
      )}
      {view === 'projects' && !user && (
        <SignedOutPlaceholder onSignIn={() => setAuthModal('signin')} />
      )}
      {view === 'publicShare' && (
        <PublicShareView token={initialShareToken()} />
      )}
      {view === 'order' && !user && (
        <SignedOutPlaceholder onSignIn={() => setAuthModal('signin')} />
      )}
      {view === 'order' && user && (
        <OrderStatusView orderId={initialOrderId()} user={user} onBack={() => setView('account')} toast={toast} />
      )}
      {view === 'project' && !user && (
        <SignedOutPlaceholder onSignIn={() => setAuthModal('signin')} />
      )}
      {view === 'project' && user && (
        <ProjectWorkspace
          project={activeProject}
          generating={generating}
          onStop={stopGenerating}
          user={user}
          onPatch={patchActiveProject}
          onGenerate={(prompt, chatId, files) => runGenerateInProject(activeProject, chatId, prompt, files)}
          onBack={() => setView('projects')}
          onDelete={(id) => { if (confirm('Delete this project? Files will be removed from storage.')) deleteProject(id); }}
          uploadThumbnail={uploadProjectThumbnail}
          printWizardFor={printWizardFor}
          setPrintWizardFor={setPrintWizardFor}
        />
      )}
      {view === 'admin' && (
        user?.admin
          ? <AdminPanel user={user} setUser={setUser} toast={toast} />
          : (user?.orgRole === 'admin' && user?.orgId
              ? <OrgAdminPanel orgId={user.orgId} user={user} toast={toast} />
              : <SignedOutPlaceholder onSignIn={() => setAuthModal('signin')} adminGate />)
      )}
      {view === 'pricing' && (
        <Pricing
          user={user}
          onCheckout={(plan, billing, extras) => {
            if (!user) { setAuthModal('signup'); return; }
            if (plan === 'free') { applyUpgrade('free', billing); return; }
            setCheckoutPlan({ plan, billing, ...(extras || {}) });
          }}
        />
      )}
      {/* Legacy /library route → forward to projects (the new home base) */}
      {view === 'library' && user && (() => { setView('projects'); return null; })()}
      {view === 'library' && !user && <SignedOutPlaceholder onSignIn={() => setAuthModal('signin')} />}
      {view === 'docs' && <Docs />}
      {view === 'privacy' && <PrivacyPage />}
      {view === 'terms' && <TermsPage />}
      {view === 'account' && (
        user
          ? <Account user={user} setUser={setUser} onSignOut={handleSignOut} onUpgrade={() => setView('pricing')} go={setView} />
          : <SignedOutPlaceholder onSignIn={() => setAuthModal('signin')} />
      )}

      {view !== 'work' && view !== 'project' && (
        <Footer go={setView} onSignUp={() => setAuthModal('signup')} />
      )}

      {authModal && (
        <AuthModal
          mode={authModal}
          setMode={setAuthModal}
          onClose={() => setAuthModal(null)}
          onSuccess={handleAuthSuccess}
        />
      )}
      {checkoutPlan && (
        <CheckoutModal
          plan={checkoutPlan.plan}
          billing={checkoutPlan.billing}
          bizTier={checkoutPlan.bizTier}
          onClose={() => setCheckoutPlan(null)}
          onConfirm={(extras) => applyUpgrade(checkoutPlan.plan, checkoutPlan.billing, { bizTier: checkoutPlan.bizTier, ...(extras || {}) })}
        />
      )}
    </>
  );
}

/* ─────────────────────────── Nav ─────────────────────────── */
function Nav({ view, go, user, onSignIn, onSignUp, onSignOut }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
    if (open) document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);
  const initials = user
    ? (user.name || user.email).split(/[\s@.]+/).filter(Boolean).slice(0, 2).map((s) => s[0]?.toUpperCase()).join('')
    : '';
  // If the user is in an org with pooled usage, show the pool balance
  // instead of their personal credits; the deduction happens on the org
  // server-side. Personal credits stay refilled but unused while pooled.
  const pooled = user?.orgInfo?.pooledUsage;
  const credits = user
    ? (pooled
        ? Math.max(0, +(user.orgInfo.poolCredits || 0).toFixed(2))
        : user.credits)
    : null;
  const plan = user ? PLANS[user.plan].label : null;
  return (
    <header className="nav" ref={ref}>
      <div className="nav-inner">
        <button className="brand" onClick={() => go(user ? 'projects' : 'home')} aria-label="Sculptex">
          <span className="brand-mark" aria-hidden="true">
            <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><polygon points="12,2.4 22,8 12,13.6 2,8" /><polygon points="2,8 2,16.2 12,21.6 12,13.6" opacity="0.62" /><polygon points="22,8 22,16.2 12,21.6 12,13.6" opacity="0.36" /></svg>
          </span>
          <span className="brand-name">Sculptex</span>
        </button>
        {!user ? (
          // Signed-out nav: only Pricing + Docs + auth.
          <nav className="nav-links" aria-label="Primary">
            <button className="nav-link" data-active={view === 'pricing'} onClick={() => go('pricing')}>Pricing</button>
            <button className="nav-link" data-active={view === 'docs'} onClick={() => go('docs')}>Docs</button>
          </nav>
        ) : (
          // Signed-in nav: no marketing links; brand goes to Projects.
          <span className="nav-links" />
        )}
        <div className="nav-actions">
          {user && user.plan === 'admin' && (
            <button
              className="credits-pill admin-pill"
              title="Admin · unlimited generations · open admin panel"
              onClick={() => go('admin')}
            >
              <span className="credits-dot" />
              <span className="credits-num">ADMIN</span>
              <span className="credits-label">∞</span>
            </button>
          )}
          {user && user.plan !== 'admin' && (() => {
            // Usage pill. Shows a horizontal progress bar + the percent of
            // this week's allotment used. Internal credit counts are never
            // shown to the user; only relative usage (percent) lands here.
            const weekly = (PLANS[user.plan] || PLANS.free).weeklyCredits;
            const used = Math.max(0, weekly - credits);
            const fillPct = Math.max(0, Math.min(100, (used / weekly) * 100));
            const overdraft = credits < 0;
            const pctLabel = fillPct === 0 ? '0%' : fillPct < 1 ? `${fillPct.toFixed(1)}%` : `${Math.round(fillPct)}%`;
            return (
              <button
                className="credits-pill usage-pill"
                data-low={credits <= weekly * 0.1 && credits > 0}
                data-empty={credits <= 0}
                data-pooled={pooled ? 'true' : undefined}
                title={
                  overdraft
                    ? 'Over budget — the overrun rolls to next refill'
                    : (pooled ? `Shared with your team · ${user.orgInfo?.name || 'Organisation'}` : `${plan} plan · ${usageLabelForPlan(user.plan)} usage · resets weekly`)
                }
                onClick={() => go('pricing')}
              >
                <span className="usage-bar"><span className="usage-fill" style={{ width: fillPct + '%' }} /></span>
                <span className="credits-num">{overdraft ? '100%+' : pctLabel}</span>
                <span className="credits-label">{overdraft ? 'over budget' : (pooled ? 'pooled' : 'used')}</span>
              </button>
            );
          })()}
          {!user ? (
            <>
              <button className="btn btn-ghost" onClick={onSignIn}>Sign in</button>
              <button className="btn btn-primary" onClick={onSignUp}>Start free<span className="btn-arrow" aria-hidden="true">→</span></button>
            </>
          ) : (
            <button className="avatar-initials" aria-label="Account" onClick={() => setOpen((o) => !o)}>
              {initials || I.user}
            </button>
          )}
        </div>
      </div>
      {open && user && (
        <div className="acct-menu">
          <div className="acct-head">
            <div className="acct-name">{user.name || 'Account'}</div>
            <div className="acct-email">{user.email}</div>
            <span className="acct-plan">{plan} plan</span>
          </div>
          <button className="acct-item" onClick={() => { go('account'); setOpen(false); }}>{I.user}<span>Account</span></button>
          <button className="acct-item" onClick={() => { go('pricing'); setOpen(false); }}>{I.tag}<span>Plans & billing</span></button>
          {user.admin && <button className="acct-item admin-item" onClick={() => { go('admin'); setOpen(false); }}>{I.cube}<span>Admin panel</span></button>}
          {!user.admin && user.orgRole === 'admin' && user.orgId && (
            <button className="acct-item admin-item" onClick={() => { go('admin'); setOpen(false); }}>{I.cube}<span>Org admin</span></button>
          )}
          <button className="acct-item danger" onClick={() => { onSignOut(); setOpen(false); }}>{I.signOut}<span>Sign out</span></button>
        </div>
      )}
    </header>
  );
}

/* Hero 3D centerpiece — mounts the iridescent sculpt via viewer.js */
function HeroSculpt() {
  const ref = useRef(null);
  const ready = useSculptex3DReady();
  useEffect(() => {
    if (!ready || !ref.current) return;
    const node = ref.current;
    const handle = window.Sculptex3D.mountHero(node);
    return () => handle.dispose();
  }, [ready]);
  return (
    <div className="hero-stage">
      <div ref={ref} className="hero-canvas" aria-hidden="true" />
      {!ready && <div className="hero-canvas-fallback" aria-hidden="true" />}
    </div>
  );
}

/* ─────────────────────────── Home ─────────────────────────── */
function Home({ prompt, setPrompt, quality, setQuality, user, userLoaded, onGenerate, onUpgrade, onSignUp }) {
  const ref = useRef(null);
  const fileRef = useRef(null);
  const [files, setFiles] = useState([]);
  useEffect(() => { ref.current?.focus({ preventScroll: true }); }, []);

  const cost = QUALITY_COST[quality];
  const allowedQualities = user ? PLANS[user.plan].qualities : ['Draft', 'Standard'];
  const credits = user?.credits ?? 0;
  const insufficient = !!user && credits < cost;
  const costLabel = cost === 0.5 ? '½ credit' : `${cost} credit${cost === 1 ? '' : 's'}`;

  function pickFiles(e) {
    const list = Array.from(e.target.files || []).filter((f) => f.type.startsWith('image/'));
    setFiles((fs) => [...fs, ...list].slice(0, 4));
    e.target.value = '';
  }
  function removeFile(i) { setFiles((fs) => fs.filter((_, j) => j !== i)); }

  return (
    <main className="page">
      {/* HERO — centered headline + iridescent 3D sculpt + composer below */}
      <section className="hero">
        <div className="hero-grid-bg" aria-hidden="true" />
        <div className="hero-pill-row">
          <button className="hero-pill" onClick={() => onUpgrade()}>
            <span className="hero-pill-tag">News</span>
            <span>Sculptex AI — text → printable mesh in under a minute</span>
            <span className="hero-pill-arrow" aria-hidden="true">→</span>
          </button>
        </div>
        <h1 className="hero-title">
          Generate 3D models<br />from a sentence.
        </h1>
        <p className="hero-sub">
          Sculptex turns plain language into watertight, printable meshes.<br />
          Hosted in the cloud — generate from any browser, in seconds.
        </p>

        <HeroSculpt />

        {insufficient && (
          <div className="credits-banner">
            <span>You're out of credits this week. Upgrade for more, or wait for the weekly reset.</span>
            <button onClick={onUpgrade}>Upgrade</button>
          </div>
        )}

        <div className="composer">
          {files.length > 0 && (
            <div className="composer-files">
              {files.map((f, i) => (
                <span key={i} className="composer-file">
                  <img src={URL.createObjectURL(f)} alt="" />
                  <span className="composer-file-name">{f.name}</span>
                  <button onClick={() => removeFile(i)} aria-label="Remove">×</button>
                </span>
              ))}
            </div>
          )}
          <textarea
            ref={ref}
            className="composer-input"
            placeholder="A brass diving helmet, weathered, with an art-deco faceplate…"
            value={prompt}
            onChange={(e) => setPrompt(e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) onGenerate(); }}
            rows={2}
          />
          <div className="composer-row">
            <span className="composer-grow" />
            <input ref={fileRef} type="file" multiple accept="image/*" style={{ display: 'none' }} onChange={pickFiles} />
            <button className="composer-attach" title="Attach reference image" onClick={() => fileRef.current?.click()}>{I.plus}</button>
            <button
              className="composer-send"
              disabled={!prompt.trim() || (userLoaded && insufficient)}
              onClick={() => onGenerate()}
              aria-label="Generate"
              title={insufficient ? 'Out of credits — upgrade to continue' : 'Generate'}
            >{I.send}</button>
          </div>
        </div>

        <div className="suggestions">
          <span className="suggestions-label">Try</span>
          {SUGGESTIONS.map((s) => (
            <button key={s} className="sugg" onClick={() => onGenerate(s)}>{s}</button>
          ))}
        </div>
      </section>

      {/* PRODUCT GRID — Vercel-style cells with hairline dividers */}
      <section className="cells">
        <div className="cells-eye">YOUR SCULPTS, DELIVERED.</div>
        <div className="cells-grid" data-rows="2">
          <article className="cell cell-lead">
            <h2 className="cell-h-lead">From prompt to printable, in one pipeline.</h2>
            <p className="cell-p">Sculptex handles geometry, topology, materials, and export so you can stay in the design loop, not the file format loop.</p>
            <div className="cell-cta">
              <button className="btn btn-primary" onClick={() => onGenerate()}>Open the studio<span className="btn-arrow" aria-hidden="true">→</span></button>
            </div>
          </article>

          <article className="cell">
            <div className="cell-eye">Generation</div>
            <h3 className="cell-h">Text · Image · Sketch</h3>
            <p className="cell-p">Describe a model. Drop a reference photo. Sketch a silhouette. The same pipeline understands all three.</p>
            <div className="cell-art cell-art-input">
              <div className="mini-input"><span>What will you sculpt?</span><span className="mini-send">{I.send}</span></div>
              <div className="mini-chiprow">
                <span className="mini-chip">Text</span>
                <span className="mini-chip">Image</span>
                <span className="mini-chip">Sketch</span>
              </div>
            </div>
          </article>

          <article className="cell">
            <div className="cell-eye">Output</div>
            <h3 className="cell-h">STL, GLB, OBJ</h3>
            <p className="cell-p">Watertight, manifold, decimated to a poly budget you choose. Drop straight into Bambu Studio or Cura.</p>
            <div className="cell-art cell-art-formats">
              <span className="format-chip">STL</span>
              <span className="format-chip">GLB</span>
              <span className="format-chip">OBJ</span>
              <span className="format-chip muted">USDZ</span>
              <span className="format-chip muted">FBX</span>
            </div>
          </article>

          <article className="cell">
            <div className="cell-eye">Refinement</div>
            <h3 className="cell-h">Iterate in chat</h3>
            <p className="cell-p">Tell Sculptex what to change — taller, thinner, brass instead of steel. It re-rolls just the part you mention.</p>
            <div className="cell-art cell-art-chat">
              <div className="chat-bubble you">make it 30% taller</div>
              <div className="chat-bubble ai">on it — re-rolling height only</div>
            </div>
          </article>

          <article className="cell">
            <div className="cell-eye">Hosted</div>
            <h3 className="cell-h">Generate from any browser</h3>
            <p className="cell-p">No setup, no install, no GPU on your desk. Everything renders server-side and arrives as a clean GLB.</p>
            <div className="cell-art cell-art-runtime">
              <div className="runtime-row"><span className="runtime-dot" /><code>browser · stream · &lt; 1 min</code></div>
              <div className="runtime-row"><span className="runtime-dot" /><code>watertight · printable · STL</code></div>
            </div>
          </article>

          <article className="cell">
            <div className="cell-eye">Quality</div>
            <h3 className="cell-h">Watertight by default</h3>
            <p className="cell-p">Every export is manifold and slicer-ready. No leaks, no flipped normals, no lost faces.</p>
            <div className="cell-art cell-art-checks">
              <div className="check-row"><span className="check-tick">{I.check}</span> manifold</div>
              <div className="check-row"><span className="check-tick">{I.check}</span> consistent normals</div>
              <div className="check-row"><span className="check-tick">{I.check}</span> printable</div>
            </div>
          </article>
        </div>
      </section>

      {/* FRAMEWORK-DEFINED INFRASTRUCTURE-style closer */}
      <section className="closer">
        <div className="closer-eye">FROM PROMPT TO PRINT</div>
        <h2 className="closer-title">Designed for makers who ship every week.</h2>
        <p className="closer-sub">A studio for everyone who's tired of fighting CAD just to print a one-off.</p>
        <div className="closer-cta">
          <button className="btn btn-primary" onClick={onSignUp}>Start free<span className="btn-arrow" aria-hidden="true">→</span></button>
          <button className="btn btn-ghost" onClick={() => onGenerate()}>Open the studio</button>
        </div>
      </section>
    </main>
  );
}

/* ─────────────────────────── Workspace ─────────────────────────── */
function Viewer3D({ prompt, seed, glbUrl, onReady, onSnapshot, onLoadInfo }) {
  const ref = useRef(null);
  const ready = useSculptex3DReady();
  const [loading, setLoading] = useState(!!glbUrl);
  useEffect(() => {
    if (!ready || !ref.current) return;
    const node = ref.current;
    let viewer = null;
    let cancelled = false;
    let snapshotTimer;
    setLoading(!!glbUrl);
    (async () => {
      try {
        const result = await window.Sculptex3D.mount(node, prompt, seed || '', glbUrl || null);
        if (cancelled) { result.viewer.dispose(); return; }
        viewer = result.viewer;
        setLoading(false);
        onReady?.(result);
        onLoadInfo?.({ backend: result.backend, loadError: result.loadError });
        snapshotTimer = setTimeout(() => {
          if (cancelled || !viewer) return;
          const snap = viewer.snapshot();
          if (snap) onSnapshot?.(snap);
        }, glbUrl ? 1500 : 800);
      } catch (e) {
        console.error('viewer mount failed', e);
        setLoading(false);
        onLoadInfo?.({ backend: 'error', loadError: e?.message || String(e) });
      }
    })();
    return () => {
      cancelled = true;
      clearTimeout(snapshotTimer);
      if (viewer) viewer.dispose();
    };
  }, [ready, prompt, seed, glbUrl]);
  return (
    <>
      <div ref={ref} className="vp-canvas" />
      {(!ready || loading) && <div className="vp-loading">{loading ? 'Loading model…' : 'Loading 3D viewer…'}</div>}
    </>
  );
}

function Workspace({ prompt, quality, modelId, model, generating, onNew, onRegen, uploadThumbnail, persistChat }) {
  const [tab, setTab] = useState('model');
  // Chat state hydrates from the model's persisted chat (or from the initial
  // prompt for fresh generations) and writes back to the server on each turn.
  const initialChat = () => {
    if (model?.chat?.length) return model.chat;
    return prompt ? [{ from: 'you', text: prompt, at: Date.now() }] : [];
  };
  const [chat, setChat] = useState(initialChat);
  const [text, setText] = useState('');
  const [files, setFiles] = useState([]);
  const [loadInfo, setLoadInfo] = useState(null);
  const fileRef = useRef(null);
  const viewerHandleRef = useRef(null);
  const snapshotUploadedRef = useRef(false);
  const lastModelIdRef = useRef(modelId);
  const lastGeneratingRef = useRef(generating);
  const chatLogRef = useRef(null);
  const toast = useToast();

  // When a fresh model arrives (generation completed for this prompt), append
  // an "AI confirms" message and persist the conversation. We only do this
  // on the leading edge of generating: true → false.
  useEffect(() => {
    if (lastGeneratingRef.current && !generating && model?.glbUrl) {
      setChat((c) => {
        const next = [...c, { from: 'ai', text: 'Sculpt ready. Tell me what to refine — proportions, materials, details, or scale.', at: Date.now() }];
        if (modelId) persistChat?.(modelId, next);
        return next;
      });
    }
    lastGeneratingRef.current = generating;
  }, [generating, model?.glbUrl, modelId]);

  // When the active model changes via library open, hydrate chat from the new
  // model's persisted history.
  useEffect(() => {
    if (modelId && modelId !== lastModelIdRef.current) {
      lastModelIdRef.current = modelId;
      snapshotUploadedRef.current = false;
      if (model?.chat?.length) setChat(model.chat);
    }
  }, [modelId, model]);

  // Auto-scroll chat to bottom on new messages or while generating.
  useEffect(() => {
    const el = chatLogRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [chat.length, generating]);

  const realised = loadInfo?.backend;
  const glbUrl = model?.glbUrl || null;
  const showError = !generating && (!glbUrl || realised === 'error');
  const errorReason = !glbUrl
    ? 'No GLB was returned by the API.'
    : (loadInfo?.loadError || 'The 3D viewer could not load this sculpt.');

  function onViewerReady({ viewer }) { viewerHandleRef.current = viewer; }
  function onSnapshot(dataUrl) {
    if (modelId && !snapshotUploadedRef.current) {
      snapshotUploadedRef.current = true;
      uploadThumbnail(modelId, dataUrl);
    }
  }

  function send() {
    const t = text.trim();
    if (!t && files.length === 0) return;
    if (generating) return;
    const note = files.length ? ` (${files.length} attachment${files.length > 1 ? 's' : ''})` : '';
    const userMsg = { from: 'you', text: (t || 'Reference attached') + note, at: Date.now() };
    setChat((c) => {
      const next = [...c, userMsg];
      if (modelId) persistChat?.(modelId, next);
      return next;
    });
    setText('');
    setFiles([]);
    onRegen(t || prompt);
  }
  function onPick(e) {
    const list = Array.from(e.target.files || []);
    if (list.length) setFiles((f) => [...f, ...list]);
    e.target.value = '';
  }

  function downloadSTL() {
    if (!viewerHandleRef.current) { toast.push('Viewer not ready yet', 'error'); return; }
    const safe = (prompt || 'model').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 32) || 'model';
    window.Sculptex3D.exportSTL(viewerHandleRef.current, `${safe}.stl`);
    toast.push('Downloaded ' + safe + '.stl', 'success');
  }

  const chatPanel = (
    <aside className="chat-col" data-active={tab === 'chat'}>
      <header className="chat-h">
        <div>
          <div className="chat-h-title">Chat</div>
          <div className="chat-h-sub">Refine with natural language</div>
        </div>
        <button className="btn btn-ghost" style={{ padding: '6px 10px' }} onClick={onNew}>{I.plus}<span style={{ fontSize: 13 }}>New</span></button>
      </header>
      <div className="chat-log" ref={chatLogRef}>
        {chat.map((m, i) => (
          <div key={i} className={`msg ${m.from}`}>
            <div className="msg-row">
              {m.from === 'ai' && <div className="msg-avatar">S</div>}
              <div className="msg-bubble">
                <div className="msg-from">{m.from === 'ai' ? 'Sculptex' : 'You'}</div>
                <div className="msg-text">{m.text}</div>
                {m.files && m.files.length > 0 && (
                  <div className="msg-files">
                    {m.files.map((fn, j) => <span key={j} className="file-pill">{fn}</span>)}
                  </div>
                )}
              </div>
            </div>
          </div>
        ))}
        {generating && (
          <div className="msg ai">
            <div className="msg-row">
              <div className="msg-avatar thinking"><span className="msg-avatar-ring" aria-hidden="true" /></div>
              <div className="msg-bubble">
                <div className="msg-from">Sculptex</div>
                <div className="msg-text">
                  <span className="thinking-dots"><span></span><span></span><span></span></span>
                  Sculpting "{prompt || '…'}"
                </div>
              </div>
            </div>
          </div>
        )}
      </div>
      <div className="chat-composer">
        {files.length > 0 && (
          <div className="chat-files">
            {files.map((f, i) => (
              <span key={i} className="chat-file">
                <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 160 }}>{f.name}</span>
                <button className="chat-file-x" onClick={() => setFiles((fs) => fs.filter((_, j) => j !== i))} aria-label="Remove">×</button>
              </span>
            ))}
          </div>
        )}
        <div className="cbox">
          <input ref={fileRef} type="file" multiple accept="image/*,.stl" style={{ display: 'none' }} onChange={onPick} />
          <button className="icon-btn" onClick={() => fileRef.current?.click()} aria-label="Attach" disabled={generating}>{I.plus}</button>
          <textarea
            className="cbox-input"
            placeholder={generating ? 'Sculpting — message after this finishes' : 'Make it taller. Add a brass handle.'}
            value={text}
            onChange={(e) => setText(e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
            rows={1}
            disabled={generating}
          />
          <button className="icon-btn send" disabled={generating || (!text.trim() && files.length === 0)} onClick={send} aria-label="Send">{I.send}</button>
        </div>
      </div>
    </aside>
  );

  const stagePanel = (
    <section className="stage" data-active={tab === 'model'} data-generating={generating ? 'true' : 'false'}>
      <div className="stage-toolbar">
        <div className="stage-name">
          <span className={`backend-pill ${generating ? 'pending' : (glbUrl && realised !== 'error' ? 'ok' : 'bad')}`}>
            <span className="backend-dot" />
            {generating ? 'SCULPTING' : (glbUrl && realised !== 'error' ? 'READY' : 'ERROR')}
          </span>
          <span className="stage-name-text">{prompt || 'Untitled model'}</span>
        </div>
        <div className="stage-tools">
          <button className="tool" disabled={!glbUrl} onClick={() => viewerHandleRef.current?.resetView()} title="Reset view">{I.rotate}</button>
          <button className="btn btn-soft" style={{ padding: '6px 14px', fontSize: 13, borderRadius: 'var(--r-sm)' }} onClick={downloadSTL} title="Download STL" disabled={!glbUrl || generating}>
            {I.download}<span>Download STL</span>
          </button>
        </div>
      </div>
      {showError && (
        <div className="viewer-banner">
          <strong>Couldn't load this sculpt.</strong>
          <span>{errorReason}</span>
        </div>
      )}
      <div className="viewport">
        {generating && <div className="viewport-pulse" aria-hidden="true" />}
        {glbUrl ? (
          <Viewer3D
            prompt={prompt}
            seed={modelId}
            glbUrl={glbUrl}
            onReady={onViewerReady}
            onSnapshot={onSnapshot}
            onLoadInfo={setLoadInfo}
          />
        ) : (
          <div className="vp-empty">
            {generating
              ? <span className="vp-empty-spin" aria-hidden="true" />
              : <span className="vp-empty-text">No model</span>}
          </div>
        )}
        <div className="vp-hint">{generating ? 'sculpting…' : 'drag to rotate · scroll to zoom'}</div>
        <div className="vp-zoom">
          <button onClick={() => viewerHandleRef.current?.zoom(1.15)} aria-label="Zoom out" disabled={!glbUrl || generating}>{I.minus}</button>
          <button onClick={() => viewerHandleRef.current?.zoom(0.85)} aria-label="Zoom in" disabled={!glbUrl || generating}>{I.plus}</button>
        </div>
      </div>
    </section>
  );

  return (
    <>
      <div className="work-mtabs">
        <button className="work-mtab" data-active={tab === 'chat'} onClick={() => setTab('chat')}>{I.chat} Chat</button>
        <button className="work-mtab" data-active={tab === 'model'} onClick={() => setTab('model')}>{I.cube} Model</button>
      </div>
      <main className="work">
        {chatPanel}
        {stagePanel}
      </main>
    </>
  );
}

/* ─────────────────────────── Library ─────────────────────────── */
function Library({ user, models, onOpen, onDelete, onNew, onSignIn, onRefresh }) {
  const [q, setQ] = useState('');
  useEffect(() => { if (user) onRefresh?.(); }, []);
  if (!user) return <SignedOutPlaceholder onSignIn={onSignIn} />;
  const filtered = models.filter((m) => !q.trim() || m.prompt.toLowerCase().includes(q.toLowerCase()));
  return (
    <main className="page library">
      <div className="page-head">
        <div className="page-eye">Library</div>
        <h1 className="page-title">Your sculpts.</h1>
        <p className="page-sub">{models.length} model{models.length === 1 ? '' : 's'} · synced to your account.</p>
      </div>
      <div className="library-toolbar">
        <div className="library-search">
          <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search prompts" />
        </div>
        <button className="btn btn-primary" onClick={onNew}>{I.plus}<span>New sculpt</span></button>
      </div>
      {filtered.length === 0 ? (
        <div className="library-empty">
          {models.length === 0 ? (
            <>
              <div className="cells-eye">EMPTY</div>
              <h3>No sculpts yet</h3>
              <p>Generate your first model and it'll show up here, ready to download.</p>
              <button className="btn btn-primary" onClick={onNew}>Open the studio<span className="btn-arrow" aria-hidden="true">→</span></button>
            </>
          ) : (
            <>
              <div className="cells-eye">NO MATCHES</div>
              <h3>Nothing for "{q}"</h3>
              <p>Try a different search, or clear the filter.</p>
            </>
          )}
        </div>
      ) : (
        <div className="library-grid">
          {filtered.map((m) => (
            <article key={m.id} className="lib-card" onClick={() => onOpen(m)}>
              <div className="lib-card-thumb">
                {m.thumbnailUrl
                  ? <img src={m.thumbnailUrl} alt="" />
                  : <div className="lib-card-thumb-empty" aria-hidden="true">{I.cube}</div>}
              </div>
              <div className="lib-card-info">
                <div className="lib-card-prompt">{m.prompt}</div>
                <div className="lib-card-meta">
                  <span>{m.quality || 'Standard'}</span>
                  <span className="dot-sep">·</span>
                  <span>{relTime(m.createdAt)}</span>
                </div>
              </div>
              <button
                className="lib-card-del"
                aria-label="Delete"
                onClick={(e) => { e.stopPropagation(); if (confirm('Delete this model?')) onDelete(m.id); }}
              >{I.trash}</button>
            </article>
          ))}
        </div>
      )}
    </main>
  );
}

function relTime(ts) {
  const d = Date.now() - ts;
  if (d < 60_000) return 'just now';
  if (d < 3_600_000) return Math.round(d / 60_000) + 'm ago';
  if (d < 86_400_000) return Math.round(d / 3_600_000) + 'h ago';
  return Math.round(d / 86_400_000) + 'd ago';
}

function SignedOutPlaceholder({ onSignIn, adminGate }) {
  return (
    <main className="page library">
      <div className="library-empty">
        <div className="cells-eye">{adminGate ? 'ADMIN ONLY' : 'SIGN IN'}</div>
        <h3>{adminGate ? 'This area is for owners and developers' : 'Your library lives in your account'}</h3>
        <p>{adminGate
          ? 'Sign in with an account whose email is in ADMIN_EMAILS to access the admin panel.'
          : 'Saved sculpts, weekly credits, and your plan are tied to your Sculptex account.'}</p>
        <button className="btn btn-primary" onClick={onSignIn}>Sign in<span className="btn-arrow" aria-hidden="true">→</span></button>
      </div>
    </main>
  );
}

/* ─────────────────────────── ProjectsHome (signed-in landing) ─────────────────────────── */
function ProjectCardMenu({ onOpen, onDelete }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
    if (open) document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);
  return (
    <div className="card-menu" ref={ref} onClick={(e) => e.stopPropagation()}>
      <button className="card-menu-trigger" onClick={() => setOpen((o) => !o)} aria-label="More">⋯</button>
      {open && (
        <div className="card-menu-popover" role="menu">
          <button className="card-menu-item" onClick={() => { setOpen(false); onOpen(); }}>{I.cube}<span>Open</span></button>
          <button className="card-menu-item danger" onClick={() => { setOpen(false); onDelete(); }}>{I.trash}<span>Delete</span></button>
        </div>
      )}
    </div>
  );
}

function ProjectsHome({ user, projects, onOpen, onCreate, onDelete }) {
  const [seed, setSeed] = useState('');
  const [sharedItems, setSharedItems] = useState([]);
  const ref = useRef(null);
  useEffect(() => { ref.current?.focus({ preventScroll: true }); }, []);
  useEffect(() => {
    let alive = true;
    api.sharedWithMe()
      .then((r) => { if (alive) setSharedItems(r.items || []); })
      .catch(() => { /* incoming-shares is best-effort; ignore on failure */ });
    return () => { alive = false; };
  }, []);
  return (
    <main className="page projects-home">
      <section className="ph-hero">
        <div className="page-eye">STUFF</div>
        <h1 className="page-title">{user.name?.split(' ')[0] || 'Welcome'} — start a sculpt.</h1>
        <p className="page-sub">Type a prompt to create a new project, or open one below to keep working.</p>

        <div className="composer ph-composer">
          <textarea
            ref={ref}
            className="composer-input"
            placeholder="A brass diving helmet, weathered…"
            value={seed}
            onChange={(e) => setSeed(e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey || !e.shiftKey)) { e.preventDefault(); if (seed.trim()) { onCreate(seed.trim()); setSeed(''); } } }}
            rows={2}
          />
          <div className="composer-row">
            <span className="composer-grow" />
            <button className="btn btn-ghost" onClick={() => onCreate('')}>{I.plus}<span>New empty project</span></button>
            <button
              className="composer-send"
              disabled={!seed.trim()}
              onClick={() => { onCreate(seed.trim()); setSeed(''); }}
              aria-label="Create project + sculpt"
            >{I.send}</button>
          </div>
        </div>

        <div className="suggestions">
          <span className="suggestions-label">Try</span>
          {SUGGESTIONS.map((s) => (
            <button key={s} className="sugg" onClick={() => onCreate(s)}>{s}</button>
          ))}
        </div>
      </section>

      {sharedItems.length > 0 && (
        <section className="ph-projects ph-shared">
          <div className="ph-projects-h">
            <div className="cells-eye">SHARED WITH YOU · {sharedItems.length}</div>
          </div>
          <div className="library-grid">
            {sharedItems.map((it) => (
              <a
                key={`${it.projectId}_${it.modelId}`}
                href={it.shareUrl}
                target="_blank"
                rel="noreferrer"
                className="lib-card lib-card-shared"
              >
                <div className="lib-card-thumb">
                  {it.thumbnailUrl
                    ? <img src={it.thumbnailUrl} alt="" />
                    : <div className="lib-card-thumb-empty" aria-hidden="true">{I.cube}</div>}
                </div>
                <div className="lib-card-info">
                  <div className="lib-card-prompt">{it.modelPrompt || 'Shared model'}</div>
                  <div className="lib-card-meta">
                    <span>from {it.ownerName || it.ownerEmail || 'someone'}</span>
                    <span className="dot-sep">·</span>
                    <span>{relTime(it.at)}</span>
                  </div>
                </div>
              </a>
            ))}
          </div>
        </section>
      )}

      <section className="ph-projects">
        <div className="ph-projects-h">
          <div className="cells-eye">YOUR STUFF · {projects.length}</div>
        </div>
        {projects.length === 0 ? (
          <div className="library-empty">
            <h3>Nothing here yet</h3>
            <p>Type a prompt above and your first project will appear here.</p>
          </div>
        ) : (
          <div className="library-grid">
            {projects.map((p) => (
              <article key={p.id} className="lib-card" onClick={() => onOpen(p)}>
                <div className="lib-card-thumb">
                  {p.coverThumbnail
                    ? <img src={p.coverThumbnail} alt="" />
                    : <div className="lib-card-thumb-empty" aria-hidden="true">{I.cube}</div>}
                </div>
                <div className="lib-card-info">
                  <div className="lib-card-prompt">{p.name}</div>
                  <div className="lib-card-meta">
                    <span>{p.modelCount} model{p.modelCount === 1 ? '' : 's'}</span>
                    <span className="dot-sep">·</span>
                    <span>{p.chatCount} chat{p.chatCount === 1 ? '' : 's'}</span>
                    <span className="dot-sep">·</span>
                    <span>{relTime(p.updatedAt || p.createdAt)}</span>
                  </div>
                </div>
                <ProjectCardMenu
                  onOpen={() => onOpen(p)}
                  onDelete={() => { if (confirm(`Delete "${p.name}"? Files will be permanently removed from storage.`)) onDelete(p.id); }}
                />
              </article>
            ))}
          </div>
        )}
      </section>
    </main>
  );
}

/* ─────────────────────────── ShareMenu ───────────────────────────
   Single viewer-bar entry point for everything you can do with a finished
   model. Top of the menu = downloads (STL/GLB/OBJ); below = link/email/org
   shares; bottom = Send to Print. */
function ShareMenu({ project, model, disabled, user, toast, onSendToPrint, onDownload }) {
  const [open, setOpen] = useState(false);
  const [busy, setBusy] = useState(false);
  const [emailMode, setEmailMode] = useState(false);
  const [email, setEmail] = useState('');
  const [emailRole, setEmailRole] = useState('viewer'); // 'viewer' | 'editor'
  // Public-link state is driven by the model's persisted shareToken.
  // Toggling ON fires share-create (which sets the token); OFF fires
  // share-revoke (which clears it + deletes the public mirror).
  const [publicUrl, setPublicUrl] = useState(() => model?.shareToken ? buildShareUrl(model.shareToken) : '');
  const isPublic = !!publicUrl;
  const ref = useRef(null);
  useEffect(() => {
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) { setOpen(false); setEmailMode(false); } }
    if (open) document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);

  // Only surface "share with org" when the user is actually IN an org.
  // Business plan + admin alone aren't enough — without an orgId there's
  // nothing to share to.
  const canShareOrg = !!user?.orgId && (user?.plan === 'business' || !!user?.admin);
  const showShareOrg = !!user?.orgId;
  const printLocked = !user || user.plan === 'free';

  async function togglePublicLink() {
    if (!project || !model) return;
    setBusy(true);
    try {
      if (isPublic) {
        await api.shareRevoke(project.id, model.id);
        setPublicUrl('');
        toast.push('Public link disabled', 'default');
      } else {
        const r = await api.shareCreate(project.id, model.id);
        setPublicUrl(r.shareUrl);
        try { await navigator.clipboard.writeText(r.shareUrl); }
        catch { /* clipboard might be blocked — the URL is visible in the menu */ }
        toast.push('Public link enabled — copied to clipboard', 'success');
      }
    } catch (e) {
      toast.push(e.message || 'Could not change link status', 'error');
    } finally { setBusy(false); }
  }

  async function copyPublicLinkText() {
    if (!publicUrl) return;
    try {
      await navigator.clipboard.writeText(publicUrl);
      toast.push('Link copied', 'success');
    } catch {
      toast.push('Couldn\'t access clipboard — select and copy manually', 'default');
    }
  }

  async function submitEmail() {
    if (!project || !model) return;
    const trimmed = email.trim();
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
      toast.push('Enter a valid email', 'error'); return;
    }
    setBusy(true);
    try {
      await api.shareEmail(project.id, model.id, trimmed, emailRole);
      const noun = emailRole === 'editor' ? 'editor' : 'viewer';
      toast.push(`Shared with ${trimmed} as a ${noun}.`, 'success');
      setEmail(''); setEmailMode(false); setOpen(false);
    } catch (e) {
      toast.push(e.message || 'Failed', 'error');
    } finally { setBusy(false); }
  }

  async function shareWithOrg() {
    if (!project || !model) return;
    setBusy(true);
    try {
      await api.shareOrg(project.id, model.id);
      toast.push('Shared with your organisation.', 'default');
      setOpen(false);
    } catch (e) {
      toast.push(e.message || 'Failed', 'error');
    } finally { setBusy(false); }
  }

  return (
    <div className="dl-menu" ref={ref}>
      <button className="btn btn-soft" disabled={disabled || !model || busy} onClick={() => setOpen((o) => !o)}>
        {I.share || I.download}<span>Share</span><span className="dl-caret" aria-hidden="true">▾</span>
      </button>
      {open && (
        <div className="dl-popover share-popover" role="menu">
          {!emailMode && (<>
            <div className="dl-section-label">Download</div>
            {[
              { ext: 'stl', label: 'STL', sub: 'Print-ready · binary mesh' },
              { ext: 'glb', label: 'GLB', sub: 'Original · with materials & textures' },
              { ext: 'obj', label: 'OBJ', sub: 'Universal · text mesh' },
            ].map((f) => (
              <button
                key={f.ext}
                className="dl-item"
                role="menuitem"
                onClick={() => { setOpen(false); onDownload?.(model, f.ext); }}
                disabled={busy || !onDownload}
              >
                <span className="dl-item-icon">{I.download}</span>
                <div className="dl-item-text">
                  <span className="dl-item-label">{f.label}</span>
                  <span className="dl-item-sub">{f.sub}</span>
                </div>
              </button>
            ))}
            <div className="dl-divider" role="separator" />
            <div className="dl-section-label">Share</div>
            <div className="dl-public-row">
              <span className="dl-item-icon">{I.link}</span>
              <div className="dl-public-label">
                <span className="dl-item-label">Anyone with the link</span>
                <span className="dl-item-sub">{isPublic ? 'On — link below works for anyone' : 'Off — only people you invite can view'}</span>
              </div>
              <button
                className={`switch ${isPublic ? 'on' : 'off'}`}
                onClick={togglePublicLink}
                disabled={busy}
                aria-pressed={isPublic}
                aria-label="Toggle public link"
              >
                <span className="switch-knob" />
              </button>
            </div>
            {isPublic && publicUrl && (
              <div className="dl-public-link-row">
                <input
                  className="dl-public-url"
                  readOnly
                  value={publicUrl}
                  onFocus={(e) => e.target.select()}
                />
                <button className="btn btn-soft" onClick={copyPublicLinkText}>Copy</button>
              </div>
            )}
            <button className="dl-item" role="menuitem" onClick={() => setEmailMode(true)} disabled={busy}>
              <span className="dl-item-icon">{I.mail}</span>
              <div className="dl-item-text">
                <span className="dl-item-label">Share with an email</span>
                <span className="dl-item-sub">They'll see it in their Sculptex inbox</span>
              </div>
            </button>
            {showShareOrg && (
              <button
                className="dl-item"
                role="menuitem"
                onClick={canShareOrg ? shareWithOrg : () => toast.push('Sharing with your organisation requires a Business plan.', 'default')}
                disabled={busy}
                data-locked={!canShareOrg || undefined}
              >
                <span className="dl-item-icon">{I.team}</span>
                <div className="dl-item-text">
                  <span className="dl-item-label">Share with my organisation{!canShareOrg ? ' 🔒' : ''}</span>
                  <span className="dl-item-sub">{canShareOrg ? 'Every member sees the model in their workspace' : 'Business plan only'}</span>
                </div>
              </button>
            )}
            <button
              className="dl-item"
              role="menuitem"
              onClick={() => {
                setOpen(false);
                if (printLocked) toast.push('Upgrade to Pro to unlock Print to Door.', 'default');
                else onSendToPrint(model);
              }}
              disabled={busy}
              data-locked={printLocked || undefined}
            >
              <span className="dl-item-icon">{I.printer}</span>
              <div className="dl-item-text">
                <span className="dl-item-label">Send to print{printLocked ? ' 🔒' : ''}</span>
                <span className="dl-item-sub">{printLocked ? 'Upgrade to Pro to unlock' : 'Have us print and ship the model'}</span>
              </div>
            </button>
          </>)}
          {emailMode && (
            <div className="share-email-form">
              <input
                className="share-email-input"
                type="email"
                placeholder="recipient@example.com"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                autoFocus
                onKeyDown={(e) => { if (e.key === 'Enter') submitEmail(); if (e.key === 'Escape') setEmailMode(false); }}
              />
              <div className="share-role-row" role="radiogroup" aria-label="Recipient role">
                <button
                  type="button"
                  role="radio"
                  aria-checked={emailRole === 'viewer'}
                  className={`share-role-pill ${emailRole === 'viewer' ? 'is-active' : ''}`}
                  onClick={() => setEmailRole('viewer')}
                >
                  Viewer
                  <span className="share-role-sub">can view + download</span>
                </button>
                <button
                  type="button"
                  role="radio"
                  aria-checked={emailRole === 'editor'}
                  className={`share-role-pill ${emailRole === 'editor' ? 'is-active' : ''}`}
                  onClick={() => setEmailRole('editor')}
                >
                  Editor
                  <span className="share-role-sub">can also remix + regenerate</span>
                </button>
              </div>
              <div className="share-email-row">
                <button className="btn btn-ghost" onClick={() => setEmailMode(false)}>Cancel</button>
                <button className="btn btn-soft" onClick={submitEmail} disabled={busy}>Share</button>
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

/* ─────────────────────────── PrintWizard ───────────────────────────
   Multi-step modal: validate → (repair?) → address → quote → confirm.

   The wizard NEVER computes pricing client-side. Every step calls the
   server so the user can't tamper with the quote. The pricing config is
   in lib/print-pricing.js. */
function PrintWizard({ project, model, user, toast, onClose, onOrderPlaced, onProjectRefresh }) {
  // Start at 'pick' so the user confirms which model to print (the wizard
  // can be opened from a specific model card OR from the chat tool). The
  // currently-passed model is preselected so the common case is one click.
  const [step, setStep] = useState('pick'); // pick | validate | repair | claude_review | address | quote | placed
  const [busy, setBusy] = useState(false);
  const [report, setReport] = useState(null);
  const [review, setReview] = useState(null);
  const [fixing, setFixing] = useState(false);
  const [quote, setQuote] = useState(null);
  const [order, setOrder] = useState(null);
  const [address, setAddress] = useState(() => user?.shippingAddress || {
    fullName: user?.name || '', street: '', city: '', state: '', zip: '', country: 'United States',
  });
  const [currentModelId, setCurrentModelId] = useState(model.id);

  // Re-validate whenever the model changes AFTER we've left the picker
  // (repair / regenerate / claude_fix create new revisions and bump
  // currentModelId). On first mount we sit on the pick step instead so
  // the user confirms the file first.
  useEffect(() => {
    if (step === 'pick') return;
    runValidate();
    /* eslint-disable-next-line react-hooks/exhaustive-deps */
  }, [currentModelId]);

  function confirmPickedModel(modelId) {
    setCurrentModelId(modelId);
    setStep('validate');
    // useEffect won't fire if the id is unchanged (e.g. user confirmed the
    // already-selected model), so call validate explicitly to be safe.
    if (modelId === currentModelId) runValidate();
  }

  async function runValidate() {
    setBusy(true); setReport(null); setReview(null);
    try {
      const r = await api.validateMesh(project.id, currentModelId);
      setReport(r.report);
      if (r.report.ok) {
        // pymeshfix passed → ask Claude to look for design-level issues.
        await runClaudeReview();
      } else {
        setStep('repair');
      }
    } catch (e) {
      toast.push(e.message || 'Validation failed', 'error');
    } finally { setBusy(false); }
  }

  async function runClaudeReview() {
    setStep('claude_review');
    setBusy(true);
    try {
      const r = await api.claudeReviewMesh(project.id, currentModelId);
      setReview(r.review);
      if (r.review.printable) {
        // Brief pause so the user sees the "all clear" before we jump on.
        setTimeout(() => setStep('address'), 600);
      }
    } catch (e) {
      // Soft-fail — proceed to address. AI review is an extra safety net,
      // not a hard gate; pymeshfix already passed.
      toast.push('Design review skipped — proceeding.', 'default');
      setStep('address');
    } finally { setBusy(false); }
  }

  async function runRepair() {
    setBusy(true);
    try {
      const r = await api.repairMesh(project.id, currentModelId);
      const newId = r.newModelId;
      toast.push('Mesh repaired — re-validating…', 'default');
      setCurrentModelId(newId); // triggers re-validate via useEffect
    } catch (e) {
      toast.push(e.message || 'Repair failed', 'error');
    } finally { setBusy(false); }
  }

  async function runFix() {
    setBusy(true);
    setFixing(true);
    try {
      // Ask Claude to decide: deterministic repair, or refined regenerate?
      // Server calls the right tool, executes it, and hands back the new
      // model id. Wizard then re-validates the new revision automatically.
      toast.push('Sculptai is choosing a fix…', 'default');
      const r = await api.claudeFixMesh(project.id, currentModelId);
      if (r.chosen === 'repair') {
        toast.push(r.reason || 'Repaired the mesh — re-checking…', 'default');
      } else if (r.chosen === 'regenerate') {
        toast.push(r.reason || 'Regenerated with a print-friendlier prompt — re-checking…', 'default');
      }
      if (r.project) onProjectRefresh?.(r.project);
      if (r.newModelId) {
        setCurrentModelId(r.newModelId); // re-validates + re-reviews via useEffect
      }
    } catch (e) {
      toast.push(e.message || 'Fix failed', 'error');
    } finally {
      setBusy(false);
      setFixing(false);
    }
  }

  async function fetchQuote() {
    setBusy(true);
    try {
      const r = await api.printQuote(project.id, currentModelId);
      setQuote(r.quote);
      setStep('quote');
    } catch (e) { toast.push(e.message || 'Quote failed', 'error'); }
    finally { setBusy(false); }
  }

  function continueToQuote() {
    // Lightweight address validation client-side. Server re-validates.
    for (const k of ['fullName', 'street', 'city', 'state', 'zip', 'country']) {
      if (!String(address[k] || '').trim()) { toast.push(`Please fill ${k}`, 'error'); return; }
    }
    fetchQuote();
  }

  async function placeOrder() {
    setBusy(true);
    try {
      const r = await api.printOrder(project.id, currentModelId, address);
      setOrder(r.order);
      setStep('placed');
      onOrderPlaced?.(r.order);
    } catch (e) {
      toast.push(e.message || 'Order failed', 'error');
    } finally { setBusy(false); }
  }

  return (
    <div className="modal-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget && !busy) onClose(); }}>
      <div className="modal print-wizard" role="dialog" aria-label="Send to Print">
        <header className="modal-head">
          <h2>Send to Print</h2>
          <button className="modal-x" onClick={onClose} disabled={busy}>✕</button>
        </header>

        {/* Step indicator */}
        <div className="pw-steps">
          {['pick', 'validate', 'repair', 'claude_review', 'address', 'quote', 'placed'].map((s, i) => (
            <span key={s} className={`pw-step ${step === s ? 'active' : ''} ${stepOrder(step) > i ? 'done' : ''}`}>
              {['File', 'Validate', 'Repair', 'Review', 'Address', 'Quote', 'Done'][i]}
            </span>
          ))}
        </div>

        <div className="modal-body">
          {step === 'pick' && (() => {
            const models = (project.models || []).filter((m) => m.glbUrl);
            // No models with a usable GLB — nothing to print. Shouldn't
            // happen in practice (the print button is gated), but bail
            // gracefully if it does.
            if (models.length === 0) {
              return (
                <div className="pw-pick">
                  <p>This project doesn't have a generated 3D model yet. Generate one in the chat first, then come back.</p>
                  <div className="pw-actions">
                    <button className="btn btn-primary" onClick={onClose}>Close</button>
                  </div>
                </div>
              );
            }
            return (
              <div className="pw-pick">
                <p>Which file would you like to print?</p>
                <div className="pw-pick-grid">
                  {models.slice().reverse().map((m) => (
                    <button
                      key={m.id}
                      type="button"
                      className={`pw-pick-card ${currentModelId === m.id ? 'is-selected' : ''}`}
                      onClick={() => setCurrentModelId(m.id)}
                    >
                      <div className="pw-pick-thumb">
                        {m.thumbnailUrl
                          ? <img src={m.thumbnailUrl} alt="" />
                          : <div className="pw-pick-noprev">3D</div>}
                      </div>
                      <div className="pw-pick-meta">
                        <div className="pw-pick-prompt">{m.prompt || '(no prompt)'}</div>
                        <div className="pw-pick-sub">{m.quality || 'Standard'} · {new Date(m.createdAt || 0).toLocaleDateString()}</div>
                      </div>
                    </button>
                  ))}
                </div>
                <div className="pw-actions">
                  <button className="btn btn-ghost" onClick={onClose}>Cancel</button>
                  <button
                    className="btn btn-primary"
                    onClick={() => confirmPickedModel(currentModelId)}
                    disabled={!currentModelId}
                  >
                    Use this file
                  </button>
                </div>
              </div>
            );
          })()}

          {step === 'validate' && (
            <div className="pw-validate">
              {busy ? (
                <div className="pw-loading">
                  <span className="pw-spinner" aria-hidden="true" />
                  <span className="pw-loading-label">Validating model</span>
                </div>
              ) : (
                <>
                  <p>Press the button to validate your model.</p>
                  <button className="btn btn-primary" onClick={runValidate}>Validate</button>
                </>
              )}
            </div>
          )}

          {step === 'repair' && report && (
            <div className="pw-repair">
              <p>We found {report.issues.length} issue{report.issues.length === 1 ? '' : 's'} that need attention before this can be printed:</p>
              <ul className="pw-issues">
                {report.issues.map((iss, i) => (
                  <li key={i} className={`pw-issue pw-issue-${iss.severity}`}>
                    <strong>{iss.severity === 'critical' ? 'Critical' : 'Warning'}:</strong> {iss.plain}
                  </li>
                ))}
              </ul>
              <p className="pw-help">
                Click <em>Fix issues</em> to hand this off to Sculptai. It'll decide whether to patch the geometry or re-prompt the generator, then walk you back to this wizard once it's done.
              </p>
              <div className="pw-actions">
                <button className="btn btn-ghost" onClick={onClose} disabled={busy}>Cancel</button>
                <button className="btn btn-primary" onClick={runFix} disabled={busy}>
                  {fixing ? 'Fixing…' : 'Fix issues'}
                </button>
              </div>
            </div>
          )}

          {step === 'claude_review' && (
            <div className="pw-claude-review">
              {busy && !review && (
                <div className="pw-loading">
                  <span className="pw-spinner" aria-hidden="true" />
                  <span className="pw-loading-label">Reviewing design</span>
                </div>
              )}
              {review?.printable && (
                <div className="pw-review-ok">
                  <p><strong>All clear.</strong> Continuing to shipping…</p>
                </div>
              )}
              {review && !review.printable && (
                <>
                  <p>The mesh checked out structurally, but the design has issues that printing alone can't fix:</p>
                  <ul className="pw-issues">
                    {(review.issues || []).map((iss, i) => (
                      <li key={i} className="pw-issue pw-issue-warning">{iss}</li>
                    ))}
                  </ul>
                  {review.suggestedPrompt && (
                    <p className="pw-help">Sculptai would suggest: <em>"{review.suggestedPrompt}"</em></p>
                  )}
                  <div className="pw-actions">
                    <button className="btn btn-ghost" onClick={onClose} disabled={busy}>Cancel</button>
                    <button className="btn btn-soft" onClick={() => setStep('address')} disabled={busy}>Print anyway</button>
                    <button className="btn btn-primary" onClick={runFix} disabled={busy}>
                      {fixing ? 'Fixing…' : 'Fix issues'}
                    </button>
                  </div>
                </>
              )}
            </div>
          )}

          {step === 'address' && (
            <div className="pw-address">
              <p>Ship the print to:</p>
              <div className="pw-form">
                {[
                  ['fullName', 'Full name'],
                  ['street', 'Street address'],
                  ['city', 'City'],
                  ['state', 'State / Region'],
                  ['zip', 'ZIP / Postal code'],
                  ['country', 'Country'],
                ].map(([k, label]) => (
                  <label key={k} className="pw-field">
                    <span>{label}</span>
                    <input
                      value={address[k] || ''}
                      onChange={(e) => setAddress({ ...address, [k]: e.target.value })}
                      autoComplete={k === 'fullName' ? 'name' : k}
                    />
                  </label>
                ))}
              </div>
              <div className="pw-actions">
                <button className="btn btn-ghost" onClick={onClose}>Cancel</button>
                <button className="btn btn-primary" onClick={continueToQuote} disabled={busy}>See quote</button>
              </div>
            </div>
          )}

          {step === 'quote' && quote && (
            <div className="pw-quote">
              <h3>Your quote</h3>
              <table className="pw-quote-table">
                <tbody>
                  <tr><td>Print cost</td><td>${(quote.customer.printTotal + (quote.customer.discountAmount || 0)).toFixed(2)}</td></tr>
                  {quote.customer.discountAmount > 0 && (
                    <tr className="pw-discount-row"><td>{quote.customer.discountPct}% plan discount</td><td>−${quote.customer.discountAmount.toFixed(2)}</td></tr>
                  )}
                  <tr><td>Shipping</td><td>${quote.customer.shipping.toFixed(2)}</td></tr>
                  <tr className="pw-quote-total"><td><strong>Total</strong></td><td><strong>${quote.customer.grandTotal.toFixed(2)}</strong></td></tr>
                </tbody>
              </table>
              <p className="pw-mock-note">No payment is collected yet — Stripe isn't wired in this build. The order will be marked as a mock so the team knows.</p>
              <div className="pw-actions">
                <button className="btn btn-ghost" onClick={() => setStep('address')}>Back</button>
                <button className="btn btn-primary" onClick={placeOrder} disabled={busy}>Place Order (Mock)</button>
              </div>
            </div>
          )}

          {step === 'placed' && order && (
            <div className="pw-placed">
              <h3>Order placed</h3>
              <p>Order id: <code>{order.id}</code></p>
              <p>You can track this order from your account. We'll update the status as it moves through printing and shipping.</p>
              <div className="pw-actions">
                <button className="btn btn-primary" onClick={onClose}>Done</button>
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function stepOrder(step) {
  return ['pick', 'validate', 'repair', 'claude_review', 'address', 'quote', 'placed'].indexOf(step);
}

/* ─────────────────────────── ProjectWorkspace ───────────────────────────
   Two columns: chat panel (left, narrow) and viewer panel (right, wide).
   Divider between is draggable; width persists in localStorage.
   Left column: horizontal chat tabs + a "Chat history" list of closed chats.
   Right column: closable Files tab + one tab per generated model. */

function useDraggableWidth(storageKey, defaultPx, minPx, maxPx) {
  const [w, setW] = useState(() => {
    if (typeof window === 'undefined') return defaultPx;
    const v = parseInt(window.localStorage.getItem(storageKey) || '', 10);
    return Number.isFinite(v) ? Math.max(minPx, Math.min(maxPx, v)) : defaultPx;
  });
  const draggingRef = useRef(false);
  const startRef = useRef({ x: 0, w: 0 });
  function onDown(e) {
    draggingRef.current = true;
    startRef.current = { x: e.clientX, w };
    document.body.classList.add('resizing');
    e.preventDefault();
  }
  useEffect(() => {
    function move(e) {
      if (!draggingRef.current) return;
      const dx = e.clientX - startRef.current.x;
      const next = Math.max(minPx, Math.min(maxPx, startRef.current.w + dx));
      setW(next);
    }
    function up() {
      if (!draggingRef.current) return;
      draggingRef.current = false;
      document.body.classList.remove('resizing');
      try { window.localStorage.setItem(storageKey, String(w)); } catch {}
    }
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
    return () => {
      window.removeEventListener('mousemove', move);
      window.removeEventListener('mouseup', up);
    };
  }, [w, minPx, maxPx, storageKey]);
  return [w, onDown];
}

function ProjectWorkspace({ project, generating, onStop, user, onPatch, onGenerate, onBack, onDelete, uploadThumbnail, printWizardFor, setPrintWizardFor }) {
  // Persist which chat tabs the user has open per-project so closing a tab
  // sticks across navigation away/back (which unmounts ProjectWorkspace).
  // Falls back to "all chats open" on first visit. We reconcile against
  // project.chats below so a stale id whose chat was deleted doesn't keep
  // a ghost tab around, and so a newly-added chat that the user hasn't
  // explicitly closed still appears.
  const openChatsStorageKey = project ? `sculptex.openChats.${project.id}` : null;
  const [openChats, setOpenChats] = useState(() => {
    if (typeof window === 'undefined' || !project) return project?.chats?.map((c) => c.id) || [];
    try {
      const raw = window.localStorage.getItem(`sculptex.openChats.${project.id}`);
      if (raw) {
        const ids = JSON.parse(raw);
        if (Array.isArray(ids)) {
          const valid = new Set(project.chats?.map((c) => c.id) || []);
          return ids.filter((id) => valid.has(id));
        }
      }
    } catch {}
    return project?.chats?.map((c) => c.id) || [];
  });
  // Persist openChats whenever it changes so a remount picks them up.
  useEffect(() => {
    if (!openChatsStorageKey || typeof window === 'undefined') return;
    try { window.localStorage.setItem(openChatsStorageKey, JSON.stringify(openChats)); } catch {}
  }, [openChats, openChatsStorageKey]);
  // Drop stale ids when project.chats changes (e.g., a chat was deleted on
  // the server). Don't auto-add new ones — the user gets to choose whether
  // a freshly-created chat opens (addChat already pushes it; AI-side adds
  // get the next addChat from the user).
  useEffect(() => {
    if (!project?.chats) return;
    const valid = new Set(project.chats.map((c) => c.id));
    setOpenChats((prev) => prev.filter((id) => valid.has(id)));
  }, [project?.chats?.length]);
  const [activeChatId, setActiveChatId] = useState(project?.chats?.[0]?.id || null);

  // Right pane: list of open tabs. Files tab is ALWAYS open (the file
  // browser), so it doesn't appear in this list and isn't closable. This
  // list only tracks ad-hoc viewer tabs:
  //   { kind: 'model',  id: '<modelId>' }
  //   { kind: 'upload', id: '<fileId>' }
  // activeRightTab is either 'files' (default) or one of those tab keys.
  const [openTabs, setOpenTabs] = useState([]); // array of { kind, id }
  const [activeRightTab, setActiveRightTab] = useState('files');

  const [text, setText] = useState('');
  // Files staged on the chat composer — attached to the next sent message.
  // Same shape as project.files: { id, name, contentType, sizeBytes, dataUrl }.
  const [pendingFiles, setPendingFiles] = useState([]);
  const [loadInfo, setLoadInfo] = useState(null);
  const [showHistory, setShowHistory] = useState(false);
  // Mobile master tab: 'chat' shows the chat column full-bleed, 'viewer'
  // shows the right pane full-bleed. Only used at narrow widths.
  // MUST stay above the `if (!project) return …` early return — hooks have
  // to run in the same order on every render or React unmounts (= white screen).
  const [mobileTab, setMobileTab] = useState('chat');
  const viewerRef = useRef(null);
  const snapshotDoneRef = useRef(new Set());
  const chatLogRef = useRef(null);
  const composerFileRef = useRef(null);
  const toast = useToast();

  const [chatColW, startChatDrag] = useDraggableWidth('sculptex.chatW', 380, 280, 720);

  // Sync openChats / activeChat when the project changes
  useEffect(() => {
    if (!project) return;
    const valid = new Set(project.chats.map((c) => c.id));
    setOpenChats((prev) => {
      const filtered = prev.filter((id) => valid.has(id));
      // If a brand-new chat was added externally, surface it
      const newOnes = project.chats.filter((c) => !prev.includes(c.id)).map((c) => c.id);
      return filtered.concat(newOnes);
    });
    if (!project.chats.find((c) => c.id === activeChatId)) {
      setActiveChatId(project.chats[0]?.id || null);
    }
  }, [project?.id, project?.chats?.length]);

  // Auto-open the latest model in the right pane when generation finishes.
  useEffect(() => {
    if (!project) return;
    const last = project.models[project.models.length - 1];
    if (!last) return;
    const key = `model:${last.id}`;
    setOpenTabs((prev) => prev.find((t) => t.kind === 'model' && t.id === last.id) ? prev : [...prev, { kind: 'model', id: last.id }]);
    setActiveRightTab(key);
  }, [project?.models?.length]);

  // Scroll chat log to bottom
  useEffect(() => {
    const el = chatLogRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [project?.chats, generating, activeChatId]);

  // (The + reopen menu is gone — Files tab is always present, and closed
  // model/upload tabs reopen by clicking the file row in Files.)

  if (!project) {
    return (
      <main className="pw">
        <div className="pw-loading"><span className="vp-empty-spin" aria-hidden="true" /><span>Loading project…</span></div>
      </main>
    );
  }

  const activeChat = project.chats.find((c) => c.id === activeChatId) || project.chats[0];
  const closedChats = project.chats.filter((c) => !openChats.includes(c.id));

  // activeRightTab is one of: 'files', 'model:<id>', 'upload:<id>'.
  // Resolve to the underlying object for the active tab.
  const activeIs = activeRightTab === 'files' ? 'files'
    : activeRightTab.startsWith('model:') ? 'model'
    : activeRightTab.startsWith('upload:') ? 'upload'
    : 'files';
  const activeId = activeRightTab.includes(':') ? activeRightTab.split(':').slice(1).join(':') : null;
  const activeModel = activeIs === 'model' ? project.models.find((m) => m.id === activeId) : null;
  const activeUpload = activeIs === 'upload' ? (project.files || []).find((f) => f.id === activeId) : null;

  function send() {
    const t = text.trim();
    if ((!t && pendingFiles.length === 0) || generating) return;
    const filesToSend = pendingFiles;
    setText('');
    setPendingFiles([]);
    onGenerate(t, activeChat?.id, filesToSend);
  }

  async function handleComposerPick(list) {
    // Same data-URL stub as the upload section — keeps everything inline in
    // project JSON so it survives reloads. Caps mirror those (4 MB / file,
    // 8 attachments / message). Only accept formats the assistant can
    // actually see (PNG, JPEG, WebP, GIF). The 3D generator doesn't take
    // images directly; the assistant describes the image in text when
    // calling the generator, so unsupported types are useless dead weight.
    const SUPPORTED = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
    const added = [];
    for (const f of list.slice(0, 8)) {
      const type = (f.type || '').toLowerCase();
      if (!SUPPORTED.includes(type)) {
        toast.push(`${f.name}: ${type || 'unknown type'} isn't supported. Use PNG, JPEG, WebP, or GIF.`, 'error');
        continue;
      }
      if (f.size > 4_000_000) {
        toast.push(`${f.name} is too large (skipping). 4 MB max.`, 'error');
        continue;
      }
      const dataUrl = await new Promise((res) => {
        const r = new FileReader();
        r.onload = () => res(r.result);
        r.readAsDataURL(f);
      });
      added.push({
        id: 'a_' + Math.random().toString(36).slice(2, 10),
        name: f.name,
        contentType: type,
        sizeBytes: f.size,
        dataUrl,
      });
    }
    if (added.length) setPendingFiles((prev) => [...prev, ...added].slice(0, 8));
  }

  function addChat() {
    const id = 'c_' + Math.random().toString(36).slice(2, 10);
    const nextChats = [...project.chats, { id, name: `Chat ${project.chats.length + 1}`, messages: [] }];
    onPatch({ chats: nextChats });
    setOpenChats((prev) => [...prev, id]);
    setActiveChatId(id);
  }

  function closeChat(id) {
    setOpenChats((prev) => {
      const next = prev.filter((x) => x !== id);
      if (id === activeChatId) setActiveChatId(next[0] || null);
      return next;
    });
  }

  function reopenChat(id) {
    setOpenChats((prev) => prev.includes(id) ? prev : [...prev, id]);
    setActiveChatId(id);
    setShowHistory(false);
  }

  function deleteChat(id) {
    if (!confirm('Permanently delete this chat?')) return;
    const nextChats = project.chats.filter((c) => c.id !== id);
    if (nextChats.length === 0) {
      // Always keep at least one chat. Create a fresh one.
      nextChats.push({ id: 'c_default', name: 'Chat', messages: [] });
    }
    onPatch({ chats: nextChats });
    setOpenChats((prev) => prev.filter((x) => x !== id));
    if (activeChatId === id) setActiveChatId(nextChats[0].id);
  }

  function closeRightTab(key) {
    // 'files' is permanent and not closable.
    if (key === 'files') return;
    setOpenTabs((prev) => {
      const idx = prev.findIndex((t) => `${t.kind}:${t.id}` === key);
      if (idx < 0) return prev;
      const next = prev.slice(0, idx).concat(prev.slice(idx + 1));
      if (key === activeRightTab) {
        // Fall back to the previous tab, or to Files if nothing else is open.
        const fallback = next[idx - 1] || next[idx] || null;
        setActiveRightTab(fallback ? `${fallback.kind}:${fallback.id}` : 'files');
      }
      return next;
    });
  }

  function openModelTab(modelId) {
    const key = `model:${modelId}`;
    setOpenTabs((prev) => prev.find((t) => t.kind === 'model' && t.id === modelId) ? prev : [...prev, { kind: 'model', id: modelId }]);
    setActiveRightTab(key);
  }

  function openUploadTab(fileId) {
    const key = `upload:${fileId}`;
    setOpenTabs((prev) => prev.find((t) => t.kind === 'upload' && t.id === fileId) ? prev : [...prev, { kind: 'upload', id: fileId }]);
    setActiveRightTab(key);
  }

  function renameProject() {
    const next = prompt('Rename project', project.name);
    if (next && next.trim() && next !== project.name) onPatch({ name: next.trim().slice(0, 120) });
  }
  function renameChat(c) {
    if (!c) return;
    const next = prompt('Rename chat', c.name || 'Chat');
    if (!next || !next.trim() || next === c.name) return;
    const trimmed = next.trim().slice(0, 60);
    onPatch({ chats: project.chats.map((x) => x.id === c.id ? { ...x, name: trimmed } : x) });
  }

  async function downloadModel(m, format) {
    if (!m) return;
    const safe = (m.prompt || 'model').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 32) || 'model';
    try {
      if (format === 'glb') {
        if (!m.glbUrl) throw new Error('No GLB URL');
        await window.Sculptex3D.exportGLB(m.glbUrl, `${safe}.glb`);
      } else if (format === 'obj') {
        if (!viewerRef.current || activeRightTab !== m.id) { toast.push('Open the model tab first', 'error'); return; }
        window.Sculptex3D.exportOBJ(viewerRef.current, `${safe}.obj`);
      } else { // stl
        if (!viewerRef.current || activeRightTab !== m.id) { toast.push('Open the model tab first', 'error'); return; }
        window.Sculptex3D.exportSTL(viewerRef.current, `${safe}.stl`);
      }
      toast.push(`Downloaded ${safe}.${format}`, 'success');
    } catch (e) {
      toast.push(`Download failed: ${e?.message || e}`, 'error');
    }
  }

  const visibleChatTabs = project.chats.filter((c) => openChats.includes(c.id));

  return (
    <main className="pw" data-mobile-tab={mobileTab}>
      <header className="pw-toolbar">
        <button className="pw-back" onClick={onBack} title="Back to projects">←</button>
        <button className="pw-title" onClick={renameProject} title="Rename">{project.name}</button>
        <span className="pw-spacer" />
        <span className="pw-meta">{project.models.length} model{project.models.length === 1 ? '' : 's'} · {project.chats.length} chat{project.chats.length === 1 ? '' : 's'}</span>
        {/* Project-level Share/Print entry — operates on whichever model is
           open in the viewer, or falls back to the most recent generation.
           Stays disabled (greyed out) until the project has at least one
           model. This is what surfaces Share when the user is on the Files
           tab and hasn't clicked into a specific model yet. */}
        <ShareMenu
          project={project}
          model={activeModel || project.models[project.models.length - 1] || null}
          disabled={generating}
          user={user}
          toast={toast}
          onSendToPrint={(model) => setPrintWizardFor({ modelId: model.id })}
        />
      </header>

      <nav className="pw-mtabs" aria-label="View">
        <button className="pw-mtab" data-active={mobileTab === 'chat'} onClick={() => setMobileTab('chat')}>
          {I.chat}<span>Chat</span>
        </button>
        <button className="pw-mtab" data-active={mobileTab === 'viewer'} onClick={() => setMobileTab('viewer')}>
          {I.cube}<span>Model viewer</span>
        </button>
      </nav>

      <div className="pw-body pw-body-2col" style={{ gridTemplateColumns: `${chatColW}px 6px 1fr` }}>
        {/* LEFT: chat panel — single active chat shown; switch via the
            clock-icon history dropdown. Tab strip was removed in favor
            of always-one-chat-visible. */}
        <section className="pw-chat">
          <div className="pw-chat-bar">
            <button
              className="pw-history-toggle"
              onClick={() => setShowHistory((v) => !v)}
              title="Chat history"
              aria-label="Chat history"
              aria-expanded={showHistory}
            >
              <span className="pw-history-icon">{I.clock}</span>
              <span className="pw-history-active-name">{activeChat?.name || 'Chat'}</span>
              <span className="pw-history-count">{project.chats.length}</span>
            </button>
            <button className="pw-htab-add" onClick={addChat} title="New chat" aria-label="New chat">{I.plus}</button>
          </div>
          {showHistory && (
            <div className="pw-history">
              {project.chats.length === 0 && <div className="pw-history-empty">No chats yet.</div>}
              {project.chats.map((c) => (
                <div key={c.id} className={`pw-history-row ${c.id === activeChatId ? 'is-active' : ''}`}>
                  <button
                    className="pw-history-name"
                    onClick={() => { setActiveChatId(c.id); setShowHistory(false); }}
                    onDoubleClick={() => renameChat(c)}
                    title={`${c.name} — double-click to rename`}
                  >
                    {I.chat}
                    <span>{c.name}</span>
                    <span className="pw-history-meta">{c.messages.length} msg</span>
                  </button>
                  {c.id === activeChatId && <span className="pw-history-open">active</span>}
                  <button className="pw-history-del" onClick={() => deleteChat(c.id)} aria-label="Delete chat" title="Delete">{I.trash}</button>
                </div>
              ))}
            </div>
          )}

          <div className="chat-log" ref={chatLogRef}>
            {activeChat && activeChat.messages.length === 0 && !generating && (
              <div className="pw-chat-empty">Type a prompt below to generate the first sculpt.</div>
            )}
            {activeChat && activeChat.messages.map((m, i) => (
              // The user's messages live inside a chat bubble. The
              // assistant's reply renders bare on the page (no bubble) —
              // chat bubbles for both speakers makes the panel feel
              // claustrophobic; flowing assistant text reads better.
              <div key={i} className={`msg ${m.from}`}>
                <div className="msg-row">
                  {m.from === 'ai' && <div className="msg-avatar">S</div>}
                  <div className={m.from === 'ai' ? 'msg-bare' : 'msg-bubble'}>
                    <div className="msg-from">{m.from === 'ai' ? 'Sculptex' : 'You'}</div>
                    {/* Visible cards for every tool the assistant called this
                        turn: web search (with clickable sources), 3D
                        generation (with the prompt that was sent), file
                        list / read. Renders above the spoken reply. */}
                    {Array.isArray(m.toolCalls) && m.toolCalls.length > 0 && (
                      <div className="tool-cards">
                        {m.toolCalls.map((tc, k) => <ToolCallCard key={k} call={tc} project={project} chat={activeChat} />)}
                      </div>
                    )}
                    {m.text && <div className="msg-text"><MarkdownText text={m.text} /></div>}
                    {/* Streaming placeholder: while the assistant is still
                        in-flight and has yet to emit text or tool cards,
                        show the typing dots inline so the bubble doesn't
                        sit empty. Disappears as soon as anything arrives. */}
                    {m._streaming && !m.text && !(Array.isArray(m.toolCalls) && m.toolCalls.length > 0) && (
                      <div className="msg-text">
                        <span className="thinking-dots"><span></span><span></span><span></span></span>
                        Thinking…
                      </div>
                    )}
                    {m._stopped && (
                      <div className="msg-stopped">stopped</div>
                    )}
                    {Array.isArray(m.files) && m.files.length > 0 && (
                      <div className="msg-attachments">
                        {m.files.map((f, k) => (
                          <button
                            key={k}
                            className="msg-attachment"
                            onClick={() => {
                              // If this file is already in project.files, open
                              // its viewer; otherwise just open it as a one-off.
                              const inProject = (project.files || []).find((x) => x.id === f.id || x.dataUrl === f.dataUrl);
                              if (inProject) openUploadTab(inProject.id);
                              else if (f.dataUrl) {
                                const w = window.open();
                                if (w) w.document.write(`<title>${f.name}</title><img src="${f.dataUrl}" style="max-width:100%">`);
                              }
                            }}
                            title={f.name}
                          >
                            {f.contentType?.startsWith('image/') && f.dataUrl
                              ? <img src={f.dataUrl} alt="" />
                              : <span className="msg-attachment-icon">{I.attach}</span>}
                            <span className="msg-attachment-name">{f.name}</span>
                          </button>
                        ))}
                      </div>
                    )}
                    {m.modelId && (
                      <button className="msg-open-model" onClick={() => openModelTab(m.modelId)}>
                        {I.cube}<span>Open in viewer</span>
                      </button>
                    )}
                  </div>
                </div>
              </div>
            ))}
            {/* Only show the global thinking placeholder if there's no
                optimistic streaming AI message yet (e.g. the very first
                tick before the user message gets appended). */}
            {generating && !activeChat?.messages?.some((m) => m.from === 'ai' && m._streaming) && (
              <div className="msg ai">
                <div className="msg-row">
                  <div className="msg-avatar thinking"><span className="msg-avatar-ring" aria-hidden="true" /></div>
                  <div className="msg-bubble">
                    <div className="msg-from">Sculptex</div>
                    <div className="msg-text">
                      <span className="thinking-dots"><span></span><span></span><span></span></span>
                      Thinking…
                    </div>
                  </div>
                </div>
              </div>
            )}
          </div>
          <div className="chat-composer">
            {pendingFiles.length > 0 && (
              <div className="composer-pending">
                {pendingFiles.map((f) => (
                  <span key={f.id} className="composer-pending-chip" title={f.name}>
                    {f.contentType?.startsWith('image/')
                      ? <img src={f.dataUrl} alt="" />
                      : <span className="composer-pending-icon">{I.attach}</span>}
                    <span className="composer-pending-name">{f.name}</span>
                    <button
                      className="composer-pending-x"
                      onClick={() => setPendingFiles((prev) => prev.filter((p) => p.id !== f.id))}
                      aria-label="Remove attachment"
                    >×</button>
                  </span>
                ))}
              </div>
            )}
            <div className="cbox">
              <input
                ref={composerFileRef}
                type="file"
                multiple
                accept="image/png,image/jpeg,image/webp,image/gif"
                style={{ display: 'none' }}
                onChange={(e) => { handleComposerPick(Array.from(e.target.files || [])); e.target.value = ''; }}
              />
              <button
                className="icon-btn"
                onClick={() => composerFileRef.current?.click()}
                disabled={generating}
                aria-label="Attach file"
                title="Attach file"
              >{I.plus}</button>
              <textarea
                className="cbox-input"
                placeholder={generating ? 'Sculpting — message after this finishes' : 'Describe the sculpt, or refine the last one…'}
                value={text}
                onChange={(e) => setText(e.target.value)}
                onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
                rows={1}
                disabled={generating}
              />
              {generating
                ? (
                  <button
                    className="icon-btn stop"
                    onClick={() => onStop?.()}
                    aria-label="Stop generating"
                    title="Stop"
                  >{I.stop}</button>
                )
                : (
                  <button
                    className="icon-btn send"
                    disabled={(!text.trim() && pendingFiles.length === 0)}
                    onClick={send}
                    aria-label="Send"
                  >{I.send}</button>
                )}
            </div>
          </div>
        </section>

        {/* DRAGGABLE DIVIDER */}
        <div className="pw-divider" onMouseDown={startChatDrag} role="separator" aria-orientation="vertical" title="Drag to resize" />

        {/* RIGHT: model + files panel */}
        <section className="pw-right">
          <div className="pw-right-tabs">
            {/* Files tab — permanent, no close button. */}
            <div className={`pw-rtab files-tab ${activeRightTab === 'files' ? 'active' : ''}`}>
              <button className="pw-rtab-label" onClick={() => setActiveRightTab('files')} title="Project files">
                {I.grid}<span>Files</span>
              </button>
            </div>
            {/* Open ad-hoc tabs (model viewers + upload previews) — closable. */}
            {openTabs.map((t) => {
              const key = `${t.kind}:${t.id}`;
              if (t.kind === 'model') {
                const m = project.models.find((mm) => mm.id === t.id);
                if (!m) return null;
                const idx = project.models.findIndex((mm) => mm.id === m.id);
                return (
                  <div key={key} className={`pw-rtab ${activeRightTab === key ? 'active' : ''}`}>
                    <button className="pw-rtab-label" onClick={() => setActiveRightTab(key)} title={m.prompt}>
                      {I.cube}<span>Model {idx + 1}</span>
                    </button>
                    <button className="pw-rtab-x" onClick={() => closeRightTab(key)} aria-label="Close tab">×</button>
                  </div>
                );
              }
              const f = (project.files || []).find((ff) => ff.id === t.id);
              if (!f) return null;
              const isImg = (f.contentType || '').startsWith('image/');
              return (
                <div key={key} className={`pw-rtab ${activeRightTab === key ? 'active' : ''}`}>
                  <button className="pw-rtab-label" onClick={() => setActiveRightTab(key)} title={f.name}>
                    {isImg ? I.tag : I.attach}<span>{f.name}</span>
                  </button>
                  <button className="pw-rtab-x" onClick={() => closeRightTab(key)} aria-label="Close tab">×</button>
                </div>
              );
            })}
          </div>

          {activeIs === 'files' && (
            <FilesTab
              project={project}
              onPatch={onPatch}
              onOpenModel={openModelTab}
              onOpenUpload={openUploadTab}
              user={user}
            />
          )}

          {activeIs === 'model' && activeModel && (
            <div className="pw-stage" data-generating={generating ? 'true' : 'false'}>
              <div className="pw-stage-h">
                <div className="pw-stage-name">{activeModel.prompt}</div>
                <div className="pw-stage-tools">
                  <ShareMenu
                    project={project}
                    model={activeModel}
                    disabled={generating}
                    user={user}
                    toast={toast}
                    onSendToPrint={(model) => setPrintWizardFor({ modelId: model.id })}
                    onDownload={downloadModel}
                  />
                </div>
              </div>
              <div className="viewport">
                {generating && <div className="viewport-pulse" aria-hidden="true" />}
                <Viewer3D
                  prompt={activeModel.prompt}
                  seed={activeModel.id}
                  glbUrl={activeModel.glbUrl}
                  onReady={({ viewer }) => { viewerRef.current = viewer; }}
                  onSnapshot={(dataUrl) => {
                    if (snapshotDoneRef.current.has(activeModel.id)) return;
                    snapshotDoneRef.current.add(activeModel.id);
                    uploadThumbnail(activeModel.id, dataUrl);
                  }}
                  onLoadInfo={setLoadInfo}
                />
                <div className="vp-hint">drag to rotate · scroll to zoom</div>
              </div>
            </div>
          )}

          {activeIs === 'upload' && activeUpload && (
            <UploadViewer file={activeUpload} />
          )}
        </section>
      </div>
      {printWizardFor && (() => {
        // Resolve the modelId now that we have access to project.models.
        // Tool-driven opens can fire slightly before the SSE 'done' event
        // lands the new model into local state, so just render nothing
        // until the lookup succeeds — the next render will pick it up.
        const m = (project.models || []).find((x) => x.id === printWizardFor.modelId);
        if (!m) return null;
        return (
          <PrintWizard
            project={project}
            model={m}
            user={user}
            toast={toast}
            onClose={() => setPrintWizardFor(null)}
            onOrderPlaced={() => { /* server already persisted; nothing else needed */ }}
            onProjectRefresh={(p) => onPatch?.(p)}
          />
        );
      })()}
    </main>
  );
}

/* ─────────────────────────── UploadViewer ───────────────────────────
   Built-in viewer for an uploaded file. Picks the right rendering based on
   contentType:
     image/* → centered <img>
     model/gltf-binary or *.glb extension → 3D viewer (the existing Three.js
       scene), reading from the inline data URL.
     text/*, application/json, application/xml → mono <pre>
     anything else → metadata + download button. */
function UploadViewer({ file }) {
  const ct = (file.contentType || '').toLowerCase();
  const name = (file.name || '').toLowerCase();
  const isImage = ct.startsWith('image/');
  const isGLB = ct === 'model/gltf-binary' || name.endsWith('.glb');
  const isText = ct.startsWith('text/') || ct === 'application/json' || ct === 'application/xml' || /\.(txt|md|json|xml|csv|log|yml|yaml)$/.test(name);

  const [textBody, setTextBody] = useState('');
  useEffect(() => {
    if (!isText || !file.dataUrl) return;
    // data URL: data:<mime>;base64,<...>
    const m = /^data:[^;]+;base64,(.+)$/.exec(file.dataUrl);
    if (!m) { setTextBody(''); return; }
    try { setTextBody(atob(m[1])); } catch { setTextBody(''); }
  }, [file.id, isText]);

  function downloadOriginal() {
    if (!file.dataUrl) return;
    const a = document.createElement('a');
    a.href = file.dataUrl; a.download = file.name || 'file';
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
  }

  function fmtBytes(n) {
    if (!n) return '—';
    if (n < 1024) return `${n} B`;
    if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
    return `${(n / (1024 * 1024)).toFixed(1)} MB`;
  }

  return (
    <div className="pw-stage upload-viewer">
      <div className="pw-stage-h">
        <div className="pw-stage-name">{file.name}</div>
        <div className="pw-stage-tools">
          <button className="btn btn-soft" onClick={downloadOriginal}>{I.download}<span>Download original</span></button>
        </div>
      </div>
      <div className="upload-body">
        {isImage && file.dataUrl && (
          <div className="upload-image-wrap"><img src={file.dataUrl} alt={file.name} /></div>
        )}
        {!isImage && isGLB && file.dataUrl && (
          <Viewer3D prompt={file.name} seed={file.id} glbUrl={file.dataUrl} />
        )}
        {!isImage && !isGLB && isText && (
          <pre className="upload-text">{textBody || '(empty)'}</pre>
        )}
        {!isImage && !isGLB && !isText && (
          <div className="upload-generic">
            <div className="cells-eye">FILE</div>
            <h3>{file.name}</h3>
            <div className="upload-generic-meta">
              <span>{file.contentType || 'application/octet-stream'}</span>
              <span className="dot-sep">·</span>
              <span>{fmtBytes(file.sizeBytes)}</span>
            </div>
            <p>No built-in preview for this format. Use Download to save the original.</p>
          </div>
        )}
      </div>
    </div>
  );
}

/* ─────────────────────────── FilesTab ─────────────────────────── */
function FilesTab({ project, onPatch, onOpenModel, onOpenUpload, user }) {
  const dropRef = useRef(null);
  const [over, setOver] = useState(false);
  const toast = useToast();

  function fmtBytes(n) {
    if (!n) return '—';
    if (n < 1024) return `${n} B`;
    if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
    return `${(n / (1024 * 1024)).toFixed(1)} MB`;
  }

  async function handleFiles(list) {
    // Local-only stub upload: store as data URLs in project.files. (Uploading
    // large binaries through the function would hit the 4.5MB request cap;
    // a real backend file upload is a follow-up.)
    const added = [];
    for (const f of list) {
      if (f.size > 4_000_000) {
        toast.push(`${f.name} is too large (${fmtBytes(f.size)}); skipping`, 'error');
        continue;
      }
      const dataUrl = await new Promise((res) => {
        const r = new FileReader();
        r.onload = () => res(r.result);
        r.readAsDataURL(f);
      });
      added.push({
        id: 'f_' + Math.random().toString(36).slice(2, 10),
        name: f.name,
        contentType: f.type || 'application/octet-stream',
        sizeBytes: f.size,
        dataUrl, // inline; not persisted to blob store yet
        uploadedAt: Date.now(),
      });
    }
    if (added.length) {
      onPatch({ files: [...(project.files || []), ...added] });
      toast.push(`Added ${added.length} file${added.length === 1 ? '' : 's'}`, 'success');
    }
  }

  function onDrop(e) {
    e.preventDefault();
    setOver(false);
    handleFiles(Array.from(e.dataTransfer.files || []));
  }

  function removeFile(id) {
    if (!confirm('Remove this file from the project?')) return;
    onPatch({ files: (project.files || []).filter((f) => f.id !== id) });
  }

  const designFiles = project.models || [];
  const uploads = project.files || [];

  return (
    <div className="pw-files">
      <div className="pw-files-section">
        <div className="pw-files-section-h">
          <div className="cells-eye">DESIGN FILES · {designFiles.length}</div>
          <span className="pw-files-section-sub">Generated GLB / STL · click to open in the viewer</span>
        </div>
        {designFiles.length === 0 && (
          <div className="pw-files-empty-small">Nothing yet — generate a sculpt and its GLB will appear here.</div>
        )}
        {designFiles.map((m, i) => (
          <div key={m.id} className="pw-file" onClick={() => onOpenModel(m.id)}>
            <div className="pw-file-thumb">
              {m.thumbnailUrl ? <img src={m.thumbnailUrl} alt="" /> : <div className="lib-card-thumb-empty">{I.cube}</div>}
            </div>
            <div className="pw-file-info">
              <div className="pw-file-name">model_{i + 1}.glb</div>
              <div className="pw-file-meta">{m.prompt}</div>
              <div className="pw-file-meta-mono">
                {fmtBytes(m.sizeBytes)} · {m.quality} · {relTime(m.createdAt)}
              </div>
            </div>
          </div>
        ))}
      </div>

      <div className="pw-files-section">
        <div className="pw-files-section-h">
          <div className="cells-eye">UPLOADS · {uploads.length}</div>
          <span className="pw-files-section-sub">Reference images / mesh files you've added</span>
        </div>
        <div
          ref={dropRef}
          className={`pw-drop ${over ? 'over' : ''}`}
          onDragEnter={(e) => { e.preventDefault(); setOver(true); }}
          onDragOver={(e) => { e.preventDefault(); setOver(true); }}
          onDragLeave={() => setOver(false)}
          onDrop={onDrop}
          onClick={() => {
            const inp = document.createElement('input');
            inp.type = 'file'; inp.multiple = true;
            inp.onchange = () => handleFiles(Array.from(inp.files || []));
            inp.click();
          }}
        >
          <div className="pw-drop-icon">{I.attach}</div>
          <div className="pw-drop-text">Drop files here or click to upload</div>
          <div className="pw-drop-sub">Images, .stl, .glb, .obj — up to 4 MB each</div>
        </div>
        {uploads.map((f) => (
          <div key={f.id} className="pw-file" onClick={() => onOpenUpload && onOpenUpload(f.id)} role="button" tabIndex={0}>
            <div className="pw-file-thumb">
              {f.contentType?.startsWith('image/')
                ? <img src={f.dataUrl} alt="" />
                : <div className="lib-card-thumb-empty">{I.attach}</div>}
            </div>
            <div className="pw-file-info">
              <div className="pw-file-name">{f.name}</div>
              <div className="pw-file-meta-mono">
                {fmtBytes(f.sizeBytes)} · {f.contentType} · {relTime(f.uploadedAt)}
              </div>
            </div>
            <button
              className="pw-file-del"
              onClick={(e) => { e.stopPropagation(); removeFile(f.id); }}
              aria-label="Delete"
            >{I.trash}</button>
          </div>
        ))}
      </div>
    </div>
  );
}


/* ─────────────────────────── PublicShareView ───────────────────────────
   Renders a single shared model at /s/<token>. Anonymous; reads only
   the public-blob copy of the GLB. No auth required. */
function PublicShareView({ token }) {
  const [data, setData] = useState(null);
  const [err, setErr] = useState(null);
  const viewerRef = useRef(null);
  useEffect(() => {
    if (!token) { setErr('No share token in URL'); return; }
    api.publicShare(token).then(setData).catch((e) => setErr(e.message || 'Share not found'));
  }, [token]);
  useEffect(() => {
    if (!data?.publicGlbUrl || !viewerRef.current || !window.Sculptex3D) return;
    const handle = window.Sculptex3D.mount(viewerRef.current, { glbUrl: data.publicGlbUrl, autoRotate: true });
    return () => window.Sculptex3D.unmount(handle);
  }, [data]);
  return (
    <main className="page public-share">
      <header className="page-head">
        <h1>Shared model</h1>
        {data?.modelPrompt && <p className="page-sub">"{data.modelPrompt}"</p>}
      </header>
      {err && <div className="public-share-error">{err}</div>}
      {!err && !data && <div className="public-share-loading">Loading…</div>}
      {data && (
        <div className="public-share-stage">
          <div ref={viewerRef} className="vp-canvas-host" />
          <p className="public-share-cta">Want to sculpt your own? <a href="/">Try Sculptex</a></p>
        </div>
      )}
    </main>
  );
}

/* ─────────────────────────── OrderStatusView ───────────────────────────
   Customer-facing order tracking. Reads /api/orders/<orderId> and shows
   the progress steps + tracking code once shipped. */
const ORDER_STEPS = [
  { key: 'pending',        label: 'Ordered' },
  { key: 'printing',       label: 'Printing' },
  { key: 'quality_check',  label: 'Quality check' },
  { key: 'shipped',        label: 'Shipped' },
  { key: 'delivered',      label: 'Delivered' },
];
function orderStepIndex(status) {
  if (status === 'issue') return -1;
  const i = ORDER_STEPS.findIndex((s) => s.key === status);
  return i === -1 ? 0 : i;
}
function carrierTrackingUrl(carrier, code) {
  if (!code) return null;
  const c = (carrier || '').toLowerCase();
  if (c.includes('usps')) return `https://tools.usps.com/go/TrackConfirmAction?tLabels=${encodeURIComponent(code)}`;
  if (c.includes('ups')) return `https://www.ups.com/track?tracknum=${encodeURIComponent(code)}`;
  if (c.includes('fedex')) return `https://www.fedex.com/fedextrack/?trknbr=${encodeURIComponent(code)}`;
  if (c.includes('dhl')) return `https://www.dhl.com/en/express/tracking.html?AWB=${encodeURIComponent(code)}`;
  return null;
}
function OrderStatusView({ orderId, onBack, toast }) {
  const [order, setOrder] = useState(null);
  const [err, setErr] = useState(null);
  useEffect(() => {
    if (!orderId) { setErr('No order id'); return; }
    api.getOrder(orderId).then((r) => setOrder(r.order)).catch((e) => setErr(e.message || 'Order not found'));
  }, [orderId]);
  if (err) return <main className="page order-status"><div className="order-error">{err}</div></main>;
  if (!order) return <main className="page order-status"><div>Loading…</div></main>;
  const idx = orderStepIndex(order.status);
  const isIssue = order.status === 'issue';
  const trackingUrl = carrierTrackingUrl(order.carrier, order.trackingCode);
  return (
    <main className="page order-status">
      <header className="page-head">
        <button className="btn btn-ghost" onClick={onBack}>← Back</button>
        <h1>Your order</h1>
        <p className="page-sub">{order.modelName} · placed {new Date(order.createdAt).toLocaleDateString()}</p>
      </header>

      <div className="order-progress" data-issue={isIssue || undefined}>
        {ORDER_STEPS.map((s, i) => (
          <div key={s.key} className={`order-step ${i <= idx ? 'done' : ''} ${i === idx ? 'current' : ''}`}>
            <span className="order-step-dot" />
            <span className="order-step-label">{s.label}</span>
          </div>
        ))}
      </div>

      {isIssue && (
        <div className="order-issue">
          There's an issue with this order. We've notified the team and someone will be in touch.
        </div>
      )}

      <div className="order-card">
        <div className="order-row"><span>Order id</span><code>{order.id}</code></div>
        <div className="order-row"><span>Status</span><span>{order.status.replace('_', ' ')}</span></div>
        <div className="order-row"><span>Ship to</span><span>{order.shippingAddress.fullName}, {order.shippingAddress.city}</span></div>
        {order.trackingCode && (
          <div className="order-row">
            <span>Tracking</span>
            <span>
              {trackingUrl
                ? <a href={trackingUrl} target="_blank" rel="noopener noreferrer">{order.trackingCode}</a>
                : <code>{order.trackingCode}</code>}
              {order.carrier && <span className="order-carrier"> · {order.carrier}</span>}
            </span>
          </div>
        )}
        <div className="order-row"><span>Total</span><strong>${order.customerQuote.grandTotal.toFixed(2)}</strong></div>
      </div>
    </main>
  );
}


/* ─────────────────────────── AdminOrdersPanel ───────────────────────────
   Lives inside AdminPanel. Lists print orders with status controls. Admin
   can mark printed (advances → quality_check), add tracking code + carrier
   (advances → shipped), mark delivered, or flag an issue. */
function AdminOrdersPanel({ toast }) {
  const [orders, setOrders] = useState(null);
  const [loading, setLoading] = useState(false);
  const [selected, setSelected] = useState(null);

  async function load() {
    setLoading(true);
    try { const r = await api.adminOrders(); setOrders(r.orders || []); }
    catch (e) { toast.push(e.message || 'Failed to load orders', 'error'); }
    finally { setLoading(false); }
  }
  useEffect(() => { load(); /* eslint-disable-next-line */ }, []);

  // Auto-prompt to enable push notifications on first visit.
  useEffect(() => {
    try { enablePushIfNeeded(toast); } catch {}
  }, []); // eslint-disable-line

  async function updateOrder(orderId, patch) {
    try {
      const r = await api.adminOrderUpdate(orderId, patch);
      setOrders((prev) => (prev || []).map((o) => o.id === orderId ? r.order : o));
      if (selected?.id === orderId) setSelected(r.order);
      toast.push('Order updated', 'default');
    } catch (e) { toast.push(e.message || 'Failed', 'error'); }
  }

  return (
    <section className="admin-section">
      <div className="admin-row-h">
        <div className="cells-eye">PRINT ORDERS{orders ? ` · ${orders.length}` : ''}</div>
        <button className="btn btn-ghost" onClick={load} disabled={loading}>{loading ? 'Loading…' : 'Refresh'}</button>
      </div>
      {!orders && !loading && <p className="admin-sub">Loading orders…</p>}
      {orders && orders.length === 0 && <p className="admin-sub">No print orders yet.</p>}
      {orders && orders.length > 0 && (
        <table className="admin-orders-table">
          <thead>
            <tr>
              <th>Order</th><th>Customer</th><th>Model</th><th>Plan</th>
              <th>Date</th><th>Status</th><th>Charged</th><th>Cost (us)</th><th></th>
            </tr>
          </thead>
          <tbody>
            {orders.map((o) => (
              <tr key={o.id} data-status={o.status} data-issue={o.status === 'issue' || undefined}>
                <td><code>{o.id.slice(-8)}</code></td>
                <td>{o.customerName}<br /><span className="admin-sub2">{o.customerEmail}</span></td>
                <td>{o.modelName}</td>
                <td>{o.plan}</td>
                <td>{new Date(o.createdAt).toLocaleDateString()}</td>
                <td><span className={`order-status-pill order-status-${o.status}`}>{o.status.replace('_', ' ')}</span></td>
                <td>${o.customerQuote.grandTotal.toFixed(2)}</td>
                <td>${o.internalCost?.subtotal?.toFixed(2) || '—'}</td>
                <td><button className="btn btn-ghost btn-sm" onClick={() => setSelected(o)}>Open</button></td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
      {selected && (
        <AdminOrderDetail order={selected} onClose={() => setSelected(null)} onUpdate={updateOrder} />
      )}
    </section>
  );
}

function AdminOrderDetail({ order, onClose, onUpdate }) {
  const [trackingCode, setTrackingCode] = useState(order.trackingCode || '');
  const [carrier, setCarrier] = useState(order.carrier || '');
  const [notes, setNotes] = useState(order.notes || '');
  return (
    <div className="modal-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="modal admin-order-detail" role="dialog" aria-label="Order detail">
        <header className="modal-head">
          <h2>Order {order.id.slice(-8)}</h2>
          <button className="modal-x" onClick={onClose}>✕</button>
        </header>
        <div className="modal-body">
          <div className="aod-cols">
            <div>
              <h4>Customer</h4>
              <p>{order.customerName} ({order.customerEmail})</p>
              <p>Plan: {order.plan}</p>
              <h4>Ship to</h4>
              <p>{order.shippingAddress.fullName}<br />
                {order.shippingAddress.street}<br />
                {order.shippingAddress.city}, {order.shippingAddress.state} {order.shippingAddress.zip}<br />
                {order.shippingAddress.country}</p>
            </div>
            <div>
              <h4>Model</h4>
              <p>{order.modelName}</p>
              <p>{order.inputs.grams}g · {order.inputs.hours}h on {order.inputs.printer}</p>
              {order.publicGlbUrl && <p><a href={order.publicGlbUrl} target="_blank" rel="noopener noreferrer">Download GLB</a></p>}
              <h4>Pricing</h4>
              <p>Charged: <strong>${order.customerQuote.grandTotal.toFixed(2)}</strong></p>
              <p className="admin-sub2">Internal subtotal: ${order.internalCost.subtotal.toFixed(2)} · Profit: ${order.internalCost.profit.toFixed(2)}</p>
            </div>
          </div>

          <h4>Status</h4>
          <div className="aod-status-row">
            {['pending', 'printing', 'quality_check', 'shipped', 'delivered', 'issue'].map((s) => (
              <button
                key={s}
                className={`btn btn-ghost btn-sm ${order.status === s ? 'active' : ''}`}
                onClick={() => onUpdate(order.id, { status: s })}
              >{s.replace('_', ' ')}</button>
            ))}
          </div>

          <h4>Tracking</h4>
          <div className="aod-tracking">
            <input placeholder="Carrier (USPS / UPS / FedEx / DHL)" value={carrier} onChange={(e) => setCarrier(e.target.value)} />
            <input placeholder="Tracking code" value={trackingCode} onChange={(e) => setTrackingCode(e.target.value)} />
            <button className="btn btn-primary btn-sm" onClick={() => onUpdate(order.id, { carrier, trackingCode })}>
              Save (notifies customer)
            </button>
          </div>

          <h4>Notes (admin only)</h4>
          <textarea
            value={notes}
            onChange={(e) => setNotes(e.target.value)}
            onBlur={() => { if (notes !== order.notes) onUpdate(order.id, { notes }); }}
            rows={3}
            placeholder="Internal notes about this order…"
          />
        </div>
      </div>
    </div>
  );
}

/* ─────────────────────────── AccountOrdersSection ───────────────────────────
   Shows the signed-in user their own print orders. Links each row to the
   full order status page at /order/<id>. */
function AccountOrdersSection() {
  const [orders, setOrders] = useState(null);
  useEffect(() => { api.myOrders().then((r) => setOrders(r.orders || [])).catch(() => setOrders([])); }, []);
  if (orders === null) return null;
  if (orders.length === 0) return null;
  return (
    <div className="account-orders">
      <div className="cells-eye">YOUR PRINT ORDERS</div>
      <ul className="account-orders-list">
        {orders.map((o) => (
          <li key={o.id} className="account-order-row">
            <a href={`/order/${o.id}`} className="account-order-link">
              <span className="account-order-name">{o.modelName}</span>
              <span className={`order-status-pill order-status-${o.status}`}>{o.status.replace('_', ' ')}</span>
              <span className="account-order-date">{new Date(o.createdAt).toLocaleDateString()}</span>
              <span className="account-order-total">${o.customerQuote.grandTotal.toFixed(2)}</span>
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

/* ─────────────────────────── Admin Panel ─────────────────────────── */
function AdminPanel({ user, setUser, toast }) {
  const [users, setUsers] = useState(null);
  const [usersErr, setUsersErr] = useState(null);
  const [orgs, setOrgs] = useState([]);
  const [loading, setLoading] = useState(false);
  const [simBusy, setSimBusy] = useState(null);
  const [drillOrgId, setDrillOrgId] = useState(null);
  const [search, setSearch] = useState('');
  const [collapsed, setCollapsed] = useState(() => new Set()); // org ids that are collapsed
  // Panel chooser: 'app' (Sculptex Admin) or 'org' (Business Admin for the
  // user's own org). Defaults to 'app' for app admins. Shown only when the
  // user has both panels available.
  const hasOrgPanel = !!(user.orgId && user.orgRole === 'admin');
  const [panel, setPanel] = useState('app');

  async function loadUsers() {
    setLoading(true);
    try {
      const [{ users }, { orgs }] = await Promise.all([
        api.adminListUsers(),
        api.adminListOrgs().catch(() => ({ orgs: [] })),
      ]);
      setUsers(users);
      setOrgs(orgs || []);
      setUsersErr(null);
    } catch (e) {
      setUsersErr(e.message + (e.debug ? ` — ${JSON.stringify(e.debug)}` : ''));
    } finally { setLoading(false); }
  }
  useEffect(() => { loadUsers(); }, []);

  // App admin can drill into any org's panel (delegated org-admin view).
  if (drillOrgId) {
    return <OrgAdminPanel orgId={drillOrgId} user={user} toast={toast} onBack={() => setDrillOrgId(null)} />;
  }
  // If the admin elected the Org panel for their own org, render that.
  if (panel === 'org' && hasOrgPanel) {
    return <OrgAdminPanel orgId={user.orgId} user={user} toast={toast} onBack={() => setPanel('app')} />;
  }

  // Every state-changing action below runs through this gate. The admin
  // confirms their password once per action; the helper prompts for it,
  // sends it as `adminPassword`, and surfaces server errors. We don't
  // cache the password — the user said 'each time' — but we keep the
  // friction low (single prompt() box, no nesting).
  async function withAdminPassword(action, perform) {
    const adminPassword = prompt(`Confirm your admin password to ${action}:`);
    if (!adminPassword) return null;
    try {
      const r = await perform(adminPassword);
      return r;
    } catch (e) {
      if (e.status === 401) toast.push(e.message || 'Wrong password', 'error');
      else toast.push(e.message || `${action} failed`, 'error');
      throw e;
    }
  }

  async function changeUserPlan(u, plan, bizTier) {
    try {
      await withAdminPassword(`change ${u.email}'s plan`, (adminPassword) =>
        api.adminPatchUser(u.id, { plan, bizTier, adminPassword }));
      const label = plan === 'business' ? `Business · ${bizTier === 'max' ? 'Max' : 'Pro'}` : (PLANS[plan]?.label || plan);
      toast.push(`${u.email} → ${label}`, 'success');
      loadUsers();
    } catch {}
  }
  async function resetUserPassword(u) {
    if (!confirm(`Reset password for ${u.email}? They'll have to use the temp password we generate.`)) return;
    try {
      const r = await withAdminPassword(`reset ${u.email}'s password`, (adminPassword) =>
        api.adminPatchUser(u.id, { resetPassword: true, adminPassword }));
      if (r?.tempPassword) prompt(`Temp password for ${u.email} (copy now):`, r.tempPassword);
      loadUsers();
    } catch {}
  }
  async function changeUserEmail(u) {
    const next = prompt(`New email for ${u.email}`, u.email);
    if (!next || next === u.email) return;
    try {
      await withAdminPassword(`change ${u.email}'s email`, (adminPassword) =>
        api.adminPatchUser(u.id, { email: next.trim().toLowerCase(), adminPassword }));
      toast.push('Email updated', 'success');
      loadUsers();
    } catch {}
  }
  async function deleteUserAccount(u) {
    const allowRecovery = confirm(
      `Delete ${u.email}?\n\nClick OK to allow recovery (the user can sign in within 30 days to restore the account).\nClick Cancel to delete WITHOUT recovery.`
    );
    // Confirmation flow: allow them to back out entirely.
    if (!confirm(`Confirm: ${allowRecovery ? 'soft-delete with 30-day recovery' : 'soft-delete with NO recovery'} for ${u.email}?\n\nThe user will see an 'Account deleted' page on next sign-in.`)) return;
    try {
      await withAdminPassword(`delete ${u.email}`, (adminPassword) =>
        api.adminDeleteUser(u.id, { allowRecovery, adminPassword }));
      toast.push(`Deleted · recovery ${allowRecovery ? 'allowed' : 'blocked'}`, 'success');
      loadUsers();
    } catch {}
  }
  function toggleOrgCollapse(orgId) {
    setCollapsed((prev) => {
      const next = new Set(prev);
      if (next.has(orgId)) next.delete(orgId); else next.add(orgId);
      return next;
    });
  }

  async function setSim(plan, bizTier) {
    const key = plan === 'business' ? `business_${bizTier}` : plan;
    setSimBusy(key);
    try {
      const { user: u } = await api.adminSimPlan(plan === 'business' ? { plan, bizTier } : { plan });
      setUser(u);
      const label = plan === 'business' ? `Business · ${bizTier === 'max' ? 'Max' : 'Pro'} tier` : PLANS[plan].label;
      toast.push(`Now simulating ${label}`, 'success');
    } catch (e) {
      toast.push(`Sim failed: ${e.message}`, 'error');
    } finally { setSimBusy(null); }
  }

  // Each entry below maps to one tile. Business is split into two tiles (one
  // per sub-tier) so the user explicitly picks Pro vs Max.
  const planTiles = [
    { key: 'free',         plan: 'free',                  label: 'Free',              meta: '5 cr/wk · $0/mo' },
    { key: 'pro',          plan: 'pro',                   label: 'Pro',               meta: '25 cr/wk · $20/mo' },
    { key: 'max',          plan: 'max',                   label: 'Max',               meta: '125 cr/wk · $100/mo' },
    { key: 'business_pro', plan: 'business', bizTier: 'pro', label: 'Business · Pro', meta: '25 cr/seat · $25/mo' },
    { key: 'business_max', plan: 'business', bizTier: 'max', label: 'Business · Max', meta: '125 cr/seat · $105/mo' },
    { key: 'admin',        plan: 'admin',                 label: 'Admin',             meta: '∞ unlimited' },
  ];
  const currentKey = user.plan === 'business'
    ? `business_${user.bizTier === 'max' ? 'max' : 'pro'}`
    : user.plan;

  // Build the unified directory: orgs (with members nested) + personal users.
  const usersByOrg = new Map();
  const personalUsers = [];
  for (const u of (users || [])) {
    if (u.orgId) {
      if (!usersByOrg.has(u.orgId)) usersByOrg.set(u.orgId, []);
      usersByOrg.get(u.orgId).push(u);
    } else {
      personalUsers.push(u);
    }
  }
  const q = search.trim().toLowerCase();
  const matchUser = (u) =>
    !q ||
    (u.email || '').toLowerCase().includes(q) ||
    (u.name || '').toLowerCase().includes(q) ||
    (u.plan || '').toLowerCase().includes(q);
  const matchOrg = (o) =>
    !q ||
    (o.name || '').toLowerCase().includes(q) ||
    (o.ownerEmail || '').toLowerCase().includes(q);

  return (
    <main className="page admin-page">
      <div className="page-head">
        <div className="page-eye">Admin · {user.email}</div>
        <h1 className="page-title">Sculptex admin.</h1>
        <p className="page-sub">Plan simulator + every account on the platform. Click into any organisation to act as its admin.</p>
      </div>

      {/* Panel chooser — only visible when both panels are accessible. */}
      {hasOrgPanel && (
        <div className="panel-chooser">
          <button data-active={panel === 'app'} onClick={() => setPanel('app')}>
            <span className="panel-chooser-label">Sculptex Admin</span>
            <span className="panel-chooser-sub">All users + every org</span>
          </button>
          <button data-active={panel === 'org'} onClick={() => setPanel('org')}>
            <span className="panel-chooser-label">Org Admin · your org</span>
            <span className="panel-chooser-sub">Only members of your business</span>
          </button>
        </div>
      )}

      <section className="admin-section">
        <div className="cells-eye">PLAN SIMULATOR</div>
        <p className="admin-sub">Switch the plan stored on your account to test entitlement gates. Admin powers come from <code>ADMIN_EMAILS</code> and survive every plan change. Picking <b>Business</b> attaches you to a Demo org so the Org Admin panel has something to manage.</p>
        <div className="admin-plans">
          {planTiles.map((t) => (
            <button
              key={t.key}
              className={`admin-plan ${currentKey === t.key ? 'active' : ''}`}
              disabled={simBusy === t.key}
              onClick={() => setSim(t.plan, t.bizTier)}
            >
              <div className="admin-plan-label">{t.label}</div>
              <div className="admin-plan-meta">{t.meta}</div>
              {currentKey === t.key && <span className="admin-plan-active">CURRENT</span>}
            </button>
          ))}
        </div>
      </section>

      <section className="admin-section">
        <div className="cells-eye">WEEKLY RESET SIMULATOR</div>
        <p className="admin-sub">Pretend a week has passed. Refills your credits to the current plan's allotment instantly. If your sim org has pooled usage on, the pool refills too — no need to wait seven days to test the boundary.</p>
        <button
          className="btn btn-ghost"
          disabled={simBusy === 'reset'}
          onClick={async () => {
            setSimBusy('reset');
            try {
              const { user: u } = await api.adminSimReset();
              setUser(u);
              toast.push('Credits reset — fresh week', 'success');
            } catch (e) { toast.push(`Reset failed: ${e.message || e}`, 'error'); }
            finally { setSimBusy(null); }
          }}
        >
          {simBusy === 'reset' ? 'Resetting…' : 'Simulate weekly reset'}
        </button>
      </section>

      {/* One unified directory chart. Sections inside (Personal / Business)
          act as collapsible separators that nest into the SAME table. Each
          org inside Business is also collapsible and starts COLLAPSED by
          default. Personal + Business themselves start expanded.

          The whole chart sits inside a max-width, overflow-x:auto wrapper —
          so the page itself doesn't have to scroll horizontally; the chart
          does. */}
      <AdminOrdersPanel toast={toast} />

      <section className="admin-section">
        <div className="admin-row-h">
          <div className="cells-eye">DIRECTORY · {users ? users.length : '…'} {orgs.length ? `· ${orgs.length} org${orgs.length === 1 ? '' : 's'}` : ''}</div>
          <button className="btn btn-ghost" onClick={loadUsers} disabled={loading}>{loading ? 'Loading…' : 'Refresh'}</button>
        </div>
        <div className="admin-search">
          <input
            type="search"
            placeholder="Search by email, name, plan, or organisation"
            value={search}
            onChange={(e) => setSearch(e.target.value)}
          />
        </div>
        {usersErr && <div className="admin-err">{usersErr}</div>}

        {(() => {
          const personalCollapsed = collapsed.has('__personal__');
          const bizCollapsed = collapsed.has('__business__');
          const personalVisible = personalUsers.filter(matchUser);
          const totalBizMembers = orgs.reduce((s, o) => s + (o.memberCount || 0), 0);
          const visibleOrgs = orgs.filter((o) => {
            if (!q) return true;
            if (matchOrg(o)) return true;
            return (usersByOrg.get(o.id) || []).some(matchUser);
          });

          return (
            <div className="chart-card">
              <div className="chart-scroll">
                <table className="dir-table">
                  <thead>
                    <tr>
                      <th className="dir-th-star" aria-label="You" />
                      <th>Email</th>
                      <th>Name</th>
                      <th>Plan</th>
                      <th className="dir-th-actions" aria-label="Actions" />
                    </tr>
                  </thead>
                  <tbody>
                    {/* ─ Personal separator ─ */}
                    <tr className="dir-sep">
                      <td colSpan={5}>
                        <button className="dir-sep-btn" onClick={() => toggleOrgCollapse('__personal__')}>
                          <span className="dir-org-caret" data-open={!personalCollapsed}>▾</span>
                          <span className="dir-sep-label">Personal accounts</span>
                          <span className="dir-sep-meta">{personalVisible.length} not in any organisation</span>
                        </button>
                      </td>
                    </tr>
                    {!personalCollapsed && personalVisible.length === 0 && (
                      <tr><td colSpan={5} className="dir-table-empty">No matching personal accounts.</td></tr>
                    )}
                    {!personalCollapsed && personalVisible.map((u) => (
                      <UserTableRow key={u.id} u={u} currentUid={user.id} />
                    ))}

                    {/* ─ Business separator ─ */}
                    <tr className="dir-sep dir-sep-biz">
                      <td colSpan={5}>
                        <button className="dir-sep-btn" onClick={() => toggleOrgCollapse('__business__')}>
                          <span className="dir-org-caret" data-open={!bizCollapsed}>▾</span>
                          <span className="dir-sep-label">Business accounts</span>
                          <span className="dir-sep-meta">{orgs.length} org{orgs.length === 1 ? '' : 's'} · {totalBizMembers} member{totalBizMembers === 1 ? '' : 's'}</span>
                        </button>
                      </td>
                    </tr>
                    {!bizCollapsed && visibleOrgs.length === 0 && (
                      <tr><td colSpan={5} className="dir-table-empty">{orgs.length === 0 ? 'No business orgs yet.' : 'No matching organisations.'}</td></tr>
                    )}
                    {!bizCollapsed && visibleOrgs.map((o) => {
                      // Orgs DEFAULT to collapsed — the user explicitly asked
                      // for this. `collapsed` is the set of "open" overrides
                      // for personal/business but the inverse for orgs. We
                      // track it with a different key so we get the right
                      // default per type:
                      //    'open:<orgId>'  — user clicked to expand this org
                      const isOpen = collapsed.has('open:' + o.id);
                      const orgMembers = (usersByOrg.get(o.id) || []).filter(matchUser);
                      return (
                        <React.Fragment key={o.id}>
                          <tr className="dir-sep dir-sep-org">
                            <td colSpan={5}>
                              <div className="dir-org-row">
                                <button className="dir-sep-btn dir-sep-org-btn" onClick={() => toggleOrgCollapse('open:' + o.id)}>
                                  <span className="dir-org-caret" data-open={isOpen}>▾</span>
                                  <span className="dir-sep-label">{o.name}</span>
                                  {o.isDemo && <span className="dir-org-demo">demo</span>}
                                  <span className="dir-sep-meta">Business · {o.bizTier === 'max' ? 'Max' : 'Pro'} · {o.memberCount} member{o.memberCount === 1 ? '' : 's'} · owner {o.ownerEmail}</span>
                                </button>
                                <button className="btn btn-ghost btn-small" onClick={() => setDrillOrgId(o.id)}>Open org panel →</button>
                              </div>
                            </td>
                          </tr>
                          {isOpen && orgMembers.length === 0 && (
                            <tr><td colSpan={5} className="dir-table-empty">No members{q ? ' match the search' : ' yet'}.</td></tr>
                          )}
                          {isOpen && orgMembers.map((u) => (
                            <UserTableRow key={u.id} u={u} currentUid={user.id} nested />
                          ))}
                        </React.Fragment>
                      );
                    })}
                  </tbody>
                </table>
              </div>
            </div>
          );
        })()}
      </section>

      <section className="admin-section">
        <div className="cells-eye">ERROR DETAIL MODE</div>
        <p className="admin-sub">When you're signed in as an admin, every API error response includes a <code>_debug</code> field with the underlying message + stack. The toast also shows it. Regular accounts get a clean message only.</p>
      </section>
    </main>
  );
}

/* Tiny markdown renderer used for chat messages. Supports bold (**…**),
   italic (*…* / _…_), inline code (`…`), fenced code blocks (```…```),
   bulleted / numbered lists, and links ([text](url)). No headings — the
   chat panel is too narrow and the system prompt tells the assistant not
   to use them. Everything is escaped before rendering, so a user can't
   inject HTML by typing `<script>` etc. */
function escHTML(s) {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function inlineMd(text) {
  // Operate on escaped HTML so user content can't introduce tags. Order
  // matters: code first (so its contents are protected from the bold /
  // italic / link passes), then links, then bold, then italic.
  let s = escHTML(text);
  s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
  s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_, label, href) =>
    `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>`);
  s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
  s = s.replace(/(^|[\s(])\*([^*\n]+)\*(?=[\s).,!?;:]|$)/g, '$1<em>$2</em>');
  s = s.replace(/(^|[\s(])_([^_\n]+)_(?=[\s).,!?;:]|$)/g, '$1<em>$2</em>');
  return s;
}
function MarkdownText({ text }) {
  if (!text) return null;
  // Split into block-level chunks: fenced code blocks, lists, paragraphs.
  const blocks = [];
  const src = text.replace(/\r\n/g, '\n');
  let i = 0;
  const lines = src.split('\n');
  while (i < lines.length) {
    const line = lines[i];
    // Fenced code block
    const fence = /^```(\w*)\s*$/.exec(line);
    if (fence) {
      const lang = fence[1] || '';
      const buf = [];
      i++;
      while (i < lines.length && !/^```\s*$/.test(lines[i])) { buf.push(lines[i]); i++; }
      if (i < lines.length) i++; // skip closing fence
      blocks.push({ type: 'code', lang, text: buf.join('\n') });
      continue;
    }
    // Bulleted list (- or *)
    if (/^\s*[-*]\s+/.test(line)) {
      const items = [];
      while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
        items.push(lines[i].replace(/^\s*[-*]\s+/, ''));
        i++;
      }
      blocks.push({ type: 'ul', items });
      continue;
    }
    // Numbered list
    if (/^\s*\d+\.\s+/.test(line)) {
      const items = [];
      while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
        items.push(lines[i].replace(/^\s*\d+\.\s+/, ''));
        i++;
      }
      blocks.push({ type: 'ol', items });
      continue;
    }
    // Blank line
    if (!line.trim()) { i++; continue; }
    // Paragraph: collect until blank line / list / fence
    const buf = [line];
    i++;
    while (i < lines.length && lines[i].trim() && !/^```/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i]) && !/^\s*\d+\.\s+/.test(lines[i])) {
      buf.push(lines[i]); i++;
    }
    blocks.push({ type: 'p', text: buf.join(' ') });
  }
  return (
    <div className="md">
      {blocks.map((b, k) => {
        if (b.type === 'code') {
          return <pre key={k} className="md-pre"><code>{b.text}</code></pre>;
        }
        if (b.type === 'ul') {
          return <ul key={k}>{b.items.map((it, j) => <li key={j} dangerouslySetInnerHTML={{ __html: inlineMd(it) }} />)}</ul>;
        }
        if (b.type === 'ol') {
          return <ol key={k}>{b.items.map((it, j) => <li key={j} dangerouslySetInnerHTML={{ __html: inlineMd(it) }} />)}</ol>;
        }
        return <p key={k} dangerouslySetInnerHTML={{ __html: inlineMd(b.text) }} />;
      })}
    </div>
  );
}

/* Visible card rendered inside an assistant chat bubble for each tool the
   assistant called this turn. Click to expand and see details: web-search
   sources (clickable links), the prompt sent to the 3D generator, etc.
   We never render the underlying model name or infrastructure. */
/* Resolve an image-attachment id against the project's saved files AND
   the current chat's message attachments. Returns the data URL if found. */
function resolveAttachmentDataUrl(id, project, chat) {
  if (!id) return null;
  const fromProj = (project?.files || []).find((f) => f.id === id);
  if (fromProj?.dataUrl) return { dataUrl: fromProj.dataUrl, name: fromProj.name };
  for (const m of (chat?.messages || [])) {
    const hit = (m.files || []).find((f) => f.id === id);
    if (hit?.dataUrl) return { dataUrl: hit.dataUrl, name: hit.name };
  }
  return null;
}

function ToolCallCard({ call, project, chat }) {
  const [open, setOpen] = useState(false);
  const t = call?.type;
  if (!t) return null;
  const status = call.isError ? 'error' : 'ok';

  if (t === 'web_search') {
    const sources = call.result?.sources || [];
    return (
      <div className={`tool-card tool-${t} tool-${status}`}>
        <button className="tool-head" onClick={() => setOpen((o) => !o)}>
          <span className="tool-icon">{I.search || '🔎'}</span>
          <span className="tool-line">
            <span className="tool-label">Searched the web</span>
            <span className="tool-detail">"{call.input?.query || '…'}"</span>
          </span>
          <span className="tool-meta">{call.isError ? 'failed' : `${sources.length} result${sources.length === 1 ? '' : 's'}`}</span>
          <span className={`tool-caret ${open ? 'open' : ''}`}>▾</span>
        </button>
        {open && (
          <div className="tool-body">
            {call.isError && <div className="tool-empty">Search failed: {call.result?.error || 'unknown error'}</div>}
            {!call.isError && sources.length === 0 && <div className="tool-empty">No results.</div>}
            {!call.isError && sources.map((s, i) => (
              <a key={i} className="tool-source" href={s.url} target="_blank" rel="noopener noreferrer">
                <span className="tool-source-title">{s.title || s.url}</span>
                <span className="tool-source-host">{(() => { try { return new URL(s.url).hostname; } catch { return s.url; } })()}</span>
              </a>
            ))}
          </div>
        )}
      </div>
    );
  }

  if (t === 'generate_3d_model') {
    const p = (call.input?.prompt || '').slice(0, 240);
    const q = call.input?.quality || 'Standard';
    // Three states for this card:
    //   running — tool was invoked but the server hasn't returned yet
    //             (result == null, isError == false)
    //   done    — result.ok === true
    //   failed  — isError === true
    const running = call.result == null && !call.isError;
    const label = running
      ? 'Generating a 3D model…'
      : (call.isError ? 'Generation failed' : 'Generated a 3D model');
    return (
      <div className={`tool-card tool-${t} tool-${status} ${running ? 'tool-running' : ''}`}>
        <button className="tool-head" onClick={() => setOpen((o) => !o)}>
          <span className="tool-icon">
            {running ? <span className="tool-spin" aria-hidden="true" /> : I.cube}
          </span>
          <span className="tool-line">
            <span className="tool-label">{label}</span>
            <span className="tool-detail">{p}</span>
          </span>
          <span className="tool-meta">{q}</span>
          <span className={`tool-caret ${open ? 'open' : ''}`}>▾</span>
        </button>
        {open && (
          <div className="tool-body">
            <div className="tool-kv"><span>Prompt sent</span><pre>{call.input?.prompt || ''}</pre></div>
            <div className="tool-kv"><span>Quality</span><span>{q}</span></div>
            {running && <div className="tool-empty">Sculpting… cold starts can take 60-90s the first time.</div>}
            {call.isError && <div className="tool-empty">Error: {call.result?.error || 'unknown'}</div>}
          </div>
        )}
      </div>
    );
  }

  if (t === 'generate_3d_model_from_image') {
    // Single image. Old persisted tool-call entries from the multi-image
    // era may still have `imageFileIds` (array) — fall back to the first
    // entry so historical chats keep rendering thumbnails.
    const id = typeof call.input?.imageFileId === 'string'
      ? call.input.imageFileId
      : (Array.isArray(call.input?.imageFileIds) ? call.input.imageFileIds[0] : null);
    const displayName = call.input?.displayName;
    const running = call.result == null && !call.isError;
    const label = running
      ? 'Generating from reference image…'
      : (call.isError ? 'Image-conditioned generation failed' : 'Generated from reference image');
    const preview = id ? { id, ...(resolveAttachmentDataUrl(id, project, chat) || {}) } : null;
    return (
      <div className={`tool-card tool-${t} tool-${status} ${running ? 'tool-running' : ''}`}>
        <button className="tool-head" onClick={() => setOpen((o) => !o)}>
          <span className="tool-icon">
            {running ? <span className="tool-spin" aria-hidden="true" /> : I.cube}
          </span>
          <span className="tool-line">
            <span className="tool-label">{label}</span>
            <span className="tool-detail">{displayName || preview?.name || ''}</span>
          </span>
          <span className="tool-thumbs">
            {preview?.dataUrl
              ? <img src={preview.dataUrl} alt={preview.name || ''} title={preview.name || preview.id} />
              : preview ? <span className="tool-thumb-missing" title="Reference image not found">?</span> : null}
          </span>
          <span className={`tool-caret ${open ? 'open' : ''}`}>▾</span>
        </button>
        {open && (
          <div className="tool-body">
            {preview && (
              <div className="tool-thumb-cell">
                {preview.dataUrl
                  ? <img src={preview.dataUrl} alt={preview.name || ''} />
                  : <div className="tool-thumb-missing-big">image not available</div>}
                <div className="tool-thumb-name">{preview.name || preview.id}</div>
              </div>
            )}
            {displayName && <div className="tool-kv"><span>Display name</span><span>{displayName}</span></div>}
            {running && <div className="tool-empty">Generating from image… cold starts can take 60-90s the first time. Single-image only — no text channel on this path.</div>}
            {call.isError && <div className="tool-empty">Error: {call.result?.error || 'unknown'}</div>}
          </div>
        )}
      </div>
    );
  }

  if (t === 'list_project_files') {
    return (
      <div className={`tool-card tool-${t} tool-${status}`}>
        <div className="tool-head tool-head-static">
          <span className="tool-icon">{I.attach}</span>
          <span className="tool-line">
            <span className="tool-label">Looked at your uploads</span>
            <span className="tool-detail">{call.result?.count || 0} file{call.result?.count === 1 ? '' : 's'} in this project</span>
          </span>
        </div>
      </div>
    );
  }

  if (t === 'read_project_file') {
    const name = call.result?.name || 'a file';
    return (
      <div className={`tool-card tool-${t} tool-${status}`}>
        <div className="tool-head tool-head-static">
          <span className="tool-icon">{I.attach}</span>
          <span className="tool-line">
            <span className="tool-label">{call.isError ? 'Couldn\'t read' : 'Read'}</span>
            <span className="tool-detail">{name}</span>
          </span>
          {call.result?.contentType && <span className="tool-meta">{call.result.contentType}</span>}
        </div>
      </div>
    );
  }

  if (t === 'list_other_chats') {
    const count = call.result?.count || 0;
    return (
      <div className={`tool-card tool-${t} tool-${status}`}>
        <div className="tool-head tool-head-static">
          <span className="tool-icon">{I.chat}</span>
          <span className="tool-line">
            <span className="tool-label">Looked at your other chats</span>
            <span className="tool-detail">{count} other thread{count === 1 ? '' : 's'} in this project</span>
          </span>
        </div>
      </div>
    );
  }

  if (t === 'read_chat') {
    const name = call.result?.name || 'another chat';
    const messageCount = call.result?.messageCount;
    return (
      <div className={`tool-card tool-${t} tool-${status}`}>
        <div className="tool-head tool-head-static">
          <span className="tool-icon">{I.chat}</span>
          <span className="tool-line">
            <span className="tool-label">{call.isError ? 'Couldn\'t read chat' : 'Read another chat'}</span>
            <span className="tool-detail">{name}</span>
          </span>
          {messageCount != null && <span className="tool-meta">{messageCount} msg</span>}
        </div>
      </div>
    );
  }

  // Unknown tool — don't render anything user-visible.
  return null;
}

/* Renders one block of user rows with the per-row admin actions. Reused for
   the org-grouped sections and the personal-accounts section. */
/* A single row in the unified directory chart. Lives inside its parent
   <table> so it inherits the columns. Star (★) appears in the leading cell
   when this row is the currently-signed-in admin's own account. */
function UserTableRow({ u, currentUid, nested = false }) {
  const isMe = u.id === currentUid;
  const optedOut = u.allowSupportAccess === false;
  const planLabel = u.plan === 'business'
    ? `Business · ${u.bizTier === 'max' ? 'Max' : 'Pro'}`
    : (PLANS[u.plan]?.label || u.plan);
  return (
    <tr className={(optedOut ? 'dir-row-opted-out ' : '') + (nested ? 'dir-row-nested' : '')}>
      <td className="dir-cell-star">{isMe ? <span title="You">★</span> : null}</td>
      <td className="admin-mono dir-cell-email">{u.email}</td>
      <td className="dir-cell-name">
        {u.name}
        {u.admin && <span className="dir-tag dir-tag-admin">admin</span>}
        {u.orgRole === 'admin' && <span className="dir-tag">org admin</span>}
        {optedOut && <span className="dir-tag dir-tag-mute">opted out</span>}
      </td>
      <td className="dir-cell-plan">{planLabel}</td>
      <td className="dir-cell-actions" />
    </tr>
  );
}

/* Per-row 3-dots menu. The trigger is greyed out (and most items disabled)
   when the user has opted out — except Delete account, which the user said
   should always work. Self-account is locked: the admin can't change their
   own email, password, or delete themselves through this panel. */
function UserRowMenu({ user, isMe, optedOut, allowPlan = true, onChangeEmail, onResetPassword, onChangePlan, onDeleteAccount }) {
  const [open, setOpen] = useState(false);
  const [planOpen, setPlanOpen] = useState(false);
  const [coords, setCoords] = useState(null); // { top, left } in viewport coords
  const triggerRef = useRef(null);
  const popRef = useRef(null);

  // Close on outside click / Escape / scroll / resize. We position the popover
  // with position: fixed anchored to the trigger's bounding rect, so it floats
  // above any overflow-clipped scroll container (which is the whole point —
  // the previous in-table-cell absolute positioning got clipped).
  useEffect(() => {
    if (!open) return;
    function onDoc(e) {
      if (popRef.current?.contains(e.target)) return;
      if (triggerRef.current?.contains(e.target)) return;
      setOpen(false); setPlanOpen(false);
    }
    function onScrollOrResize() { setOpen(false); setPlanOpen(false); }
    function onKey(e) { if (e.key === 'Escape') { setOpen(false); setPlanOpen(false); } }
    document.addEventListener('mousedown', onDoc);
    window.addEventListener('scroll', onScrollOrResize, true);
    window.addEventListener('resize', onScrollOrResize);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDoc);
      window.removeEventListener('scroll', onScrollOrResize, true);
      window.removeEventListener('resize', onScrollOrResize);
      document.removeEventListener('keydown', onKey);
    };
  }, [open]);

  function toggle() {
    if (isMe) return;
    if (open) { setOpen(false); setPlanOpen(false); return; }
    const r = triggerRef.current?.getBoundingClientRect();
    if (!r) return;
    // Anchor pop to the trigger's right edge, opening below. Flip up if there
    // isn't room. Min menu width 200px.
    const menuW = 220;
    const menuH = optedOut ? 70 : 220;
    const below = r.bottom + menuH < window.innerHeight - 16;
    setCoords({
      top: below ? r.bottom + 6 : r.top - menuH - 6,
      left: Math.max(8, Math.min(window.innerWidth - menuW - 8, r.right - menuW)),
    });
    setOpen(true);
  }

  const planChoices = [
    { plan: 'free', label: 'Free' },
    { plan: 'pro', label: 'Pro' },
    { plan: 'max', label: 'Max' },
    { plan: 'business', bizTier: 'pro', label: 'Business · Pro' },
    { plan: 'business', bizTier: 'max', label: 'Business · Max' },
  ];

  return (
    <>
      <button
        ref={triggerRef}
        className={`row-menu-trigger ${optedOut ? 'muted' : ''}`}
        onClick={toggle}
        aria-label="Account actions"
        disabled={isMe}
        title={isMe ? 'You can\'t modify your own account from the admin panel.' : (optedOut ? 'This user has opted out of support access — only delete is available.' : 'Account actions')}
      >⋯</button>
      {open && coords && createPortal(
        <div
          ref={popRef}
          className="row-menu-pop floating"
          style={{ top: coords.top, left: coords.left }}
          role="menu"
        >
          <button
            className="row-menu-item"
            disabled={optedOut}
            onClick={() => { setOpen(false); onChangeEmail(); }}
          >Change email</button>
          <button
            className="row-menu-item"
            disabled={optedOut}
            onClick={() => { setOpen(false); onResetPassword(); }}
          >Reset password</button>
          {allowPlan && (
            <div className="row-menu-sub">
              <button
                className="row-menu-item"
                disabled={optedOut}
                onClick={() => setPlanOpen((o) => !o)}
              >Change plan ▸</button>
              {planOpen && !optedOut && (
                <div className="row-menu-sub-pop">
                  {planChoices.map((p) => {
                    const matched = (user.plan === p.plan) && (p.plan !== 'business' || user.bizTier === p.bizTier);
                    return (
                      <button
                        key={`${p.plan}:${p.bizTier || ''}`}
                        className={`row-menu-item ${matched ? 'current' : ''}`}
                        onClick={() => { setOpen(false); setPlanOpen(false); onChangePlan(p.plan, p.bizTier); }}
                      >{p.label}{matched ? ' · current' : ''}</button>
                    );
                  })}
                </div>
              )}
            </div>
          )}
          <div className="row-menu-divider" />
          <button
            className="row-menu-item danger"
            onClick={() => { setOpen(false); onDeleteAccount(); }}
          >Delete account</button>
        </div>,
        document.body
      )}
    </>
  );
}

/* ─────────────────────────── OrgAdminPanel ───────────────────────────
   Renders the management panel for one organisation. Used both:
     - when an org admin (a member with orgRole === 'admin') opens /admin —
       they see only THEIR org
     - when a Sculptex app-admin drills into any org from their global panel
   Permissions are enforced server-side; this component just hides actions
   when the caller is the only admin (e.g. don't let them demote themselves
   into an empty admin set). */
function OrgAdminPanel({ orgId, user, toast, onBack }) {
  const [org, setOrg] = useState(null);
  const [members, setMembers] = useState([]);
  const [invitations, setInvitations] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [busy, setBusy] = useState(null);
  // Inline + button add — state lives at the panel level so the new-user row
  // can render inside the chart instead of in its own card.
  const [addOpen, setAddOpen] = useState(false);
  const [addEmails, setAddEmails] = useState('');
  const [addRole, setAddRole] = useState('user');
  const [addForceChange, setAddForceChange] = useState(false);
  const [colorDraft, setColorDraft] = useState('');
  const [adminCount, setAdminCount] = useState(0);

  async function refresh() {
    setLoading(true);
    try {
      const [{ org: o }, listResp] = await Promise.all([
        api.getOrg(orgId), api.listOrgMembers(orgId),
      ]);
      setOrg(o);
      setMembers(listResp.members || []);
      setInvitations(listResp.invitations || []);
      setAdminCount((listResp.members || []).filter((m) => m.orgRole === 'admin').length);
      setError(null);
    } catch (e) {
      setError(e.message + (e.debug ? ` — ${JSON.stringify(e.debug).slice(0, 240)}` : ''));
    } finally { setLoading(false); }
  }
  useEffect(() => { if (orgId) refresh(); }, [orgId]);

  async function patchOrg(patch) {
    try { const { org: o } = await api.patchOrg(orgId, patch); setOrg(o); }
    catch (e) { toast.push(e.message || 'Save failed', 'error'); }
  }

  async function submitAdd() {
    const emails = addEmails.trim();
    if (!emails) return;
    setBusy('add');
    try {
      const r = await api.addOrgMember(orgId, { emails, role: addRole, forceChangePw: addForceChange });
      const inv = r?.invited?.length || 0;
      const skipped = r?.skipped?.length || 0;
      if (inv > 0) toast.push(`Invited ${inv} ${inv === 1 ? 'person' : 'people'}${skipped ? ` · ${skipped} skipped` : ''}`, 'success');
      else if (skipped) toast.push(`All ${skipped} skipped: ${r.skipped.map((s) => `${s.email} (${s.reason})`).join(', ')}`, 'error');
      else toast.push('Nothing to do', 'error');
      setAddEmails(''); setAddRole('user'); setAddForceChange(false); setAddOpen(false);
      refresh();
    } catch (e) {
      toast.push(e.message || 'Invite failed', 'error');
    } finally { setBusy(null); }
  }

  async function rescindInvite(email) {
    if (!confirm(`Rescind invitation to ${email}?`)) return;
    const adminPassword = prompt(`Confirm your admin password to rescind ${email}'s invite:`);
    if (!adminPassword) return;
    try {
      await jsonFetch(`/api/orgs/${orgId}/invitations?email=${encodeURIComponent(email)}`, {
        method: 'DELETE', body: JSON.stringify({ adminPassword }),
      });
      toast.push('Invite rescinded', 'success');
      refresh();
    } catch (e) { toast.push(e.message || 'Could not rescind', 'error'); }
  }

  // Wraps every mutating member action in a single-prompt password gate.
  async function withPw(action, perform) {
    const adminPassword = prompt(`Confirm your admin password to ${action}:`);
    if (!adminPassword) return null;
    try { return await perform(adminPassword); }
    catch (e) {
      if (e.status === 401) toast.push(e.message || 'Wrong password', 'error');
      else toast.push(e.message || `${action} failed`, 'error');
      throw e;
    }
  }

  async function changeMemberEmail(m) {
    const next = prompt(`New email for ${m.email}`, m.email);
    if (!next || next === m.email) return;
    try {
      await withPw(`change ${m.email}'s email`, (adminPassword) =>
        api.patchOrgMember(orgId, m.id, { email: next.trim().toLowerCase(), adminPassword }));
      toast.push('Email updated', 'success'); refresh();
    } catch {}
  }
  async function resetMemberPassword(m) {
    if (!confirm(`Reset password for ${m.email}?`)) return;
    try {
      const r = await withPw(`reset ${m.email}'s password`, (adminPassword) =>
        api.patchOrgMember(orgId, m.id, { resetPassword: true, adminPassword }));
      if (r?.tempPassword) prompt(`Temp password for ${m.email} (copy now):`, r.tempPassword);
      refresh();
    } catch {}
  }
  async function deleteMember(m) {
    const allowRecovery = confirm(`Delete ${m.email}?\n\nOK → allow 30-day recovery.\nCancel → no recovery.`);
    if (!confirm(`Confirm: ${allowRecovery ? 'soft-delete with recovery' : 'soft-delete WITHOUT recovery'} for ${m.email}?`)) return;
    try {
      await withPw(`delete ${m.email}`, (adminPassword) =>
        api.deleteOrgMember(orgId, m.id, { adminPassword, allowRecovery }));
      toast.push(`Deleted · recovery ${allowRecovery ? 'allowed' : 'blocked'}`, 'success');
      refresh();
    } catch {}
  }

  if (loading && !org) return (
    <main className="page admin-page"><div className="page-head"><div className="page-eye">Org admin</div><h1 className="page-title">Loading…</h1></div></main>
  );
  if (error) return (
    <main className="page admin-page"><div className="page-head"><div className="page-eye">Org admin</div><h1 className="page-title">Couldn't load.</h1><p className="page-sub">{error}</p></div></main>
  );
  if (!org) return null;

  return (
    <main className="page admin-page">
      <div className="page-head">
        <div className="page-eye">{onBack ? 'Org admin · drilled in from app admin' : 'Organisation admin'}</div>
        <h1 className="page-title">{org.name}</h1>
        <p className="page-sub">
          Tier: <b>Business · {org.bizTier === 'max' ? 'Max' : 'Pro'}</b> · {members.length} member{members.length === 1 ? '' : 's'}{invitations.length ? ` · ${invitations.length} pending` : ''}
          {onBack && <> · <button type="button" className="form-link" onClick={onBack}>← back to all orgs</button></>}
        </p>
      </div>

      {/* Members + invitations + add — all inside one chart. */}
      <section className="admin-section">
        <div className="cells-eye">MEMBERS · {members.length}{invitations.length ? `  · ${invitations.length} invited` : ''}</div>
        <p className="admin-sub">Click <b>+</b> to invite by email. You can paste a comma-separated list — spaces don't matter.</p>
        <div className="chart-card">
          <div className="chart-scroll">
            <table className="dir-table">
              <thead>
                <tr>
                  <th aria-label="" style={{ width: 28 }} />
                  <th>Email</th>
                  <th>Name</th>
                  <th>Role · Tier</th>
                  <th className="dir-th-actions" aria-label="Actions">
                    <button className="row-menu-trigger" onClick={() => setAddOpen((o) => !o)} aria-label="Invite member">+</button>
                  </th>
                </tr>
              </thead>
              <tbody>
                {addOpen && (
                  <tr className="dir-add-row">
                    <td colSpan={5}>
                      <div className="add-row-grid">
                        <input
                          className="field-input"
                          placeholder="email@company.com, another@x.com, …"
                          value={addEmails}
                          autoFocus
                          onChange={(e) => setAddEmails(e.target.value)}
                          onKeyDown={(e) => { if (e.key === 'Enter') submitAdd(); }}
                        />
                        <select className="field-input" value={addRole} onChange={(e) => setAddRole(e.target.value)}>
                          <option value="user">user</option>
                          <option value="admin">admin</option>
                        </select>
                        <label className="add-row-check">
                          <input type="checkbox" checked={addForceChange} onChange={(e) => setAddForceChange(e.target.checked)} />
                          Force password change on first sign-in
                        </label>
                        <div className="add-row-actions">
                          <button className="btn btn-ghost" onClick={() => { setAddOpen(false); setAddEmails(''); }}>Cancel</button>
                          <button className="btn btn-primary" disabled={busy === 'add' || !addEmails.trim()} onClick={submitAdd}>
                            {busy === 'add' ? 'Inviting…' : 'Send invitation' + (parseEmailsCount(addEmails) > 1 ? 's' : '')}
                          </button>
                        </div>
                      </div>
                    </td>
                  </tr>
                )}

                {/* Pending invitations */}
                {invitations.length > 0 && (
                  <tr className="dir-sep dir-sep-pending">
                    <td colSpan={5}>
                      <div className="dir-sep-btn dir-sep-static">
                        <span className="dir-sep-label">Pending invitations</span>
                        <span className="dir-sep-meta">{invitations.length} awaiting acceptance</span>
                      </div>
                    </td>
                  </tr>
                )}
                {invitations.map((inv) => (
                  <tr key={inv.email} className="dir-row-invited">
                    <td className="dir-cell-star" />
                    <td className="admin-mono dir-cell-email">{inv.email}</td>
                    <td className="dir-cell-name">
                      <span className="dir-tag dir-tag-mute">invited {relTime(inv.addedAt)}</span>
                    </td>
                    <td className="dir-cell-plan">{inv.role === 'admin' ? 'Admin' : 'User'} · Business · {org.bizTier === 'max' ? 'Max' : 'Pro'}</td>
                    <td className="dir-cell-actions">
                      <button className="form-link danger" onClick={() => rescindInvite(inv.email)}>Rescind</button>
                    </td>
                  </tr>
                ))}

                {/* Active members */}
                {members.length > 0 && (
                  <tr className="dir-sep">
                    <td colSpan={5}>
                      <div className="dir-sep-btn dir-sep-static">
                        <span className="dir-sep-label">Members</span>
                        <span className="dir-sep-meta">{members.length} accepted</span>
                      </div>
                    </td>
                  </tr>
                )}
                {members.length === 0 && invitations.length === 0 && !addOpen && (
                  <tr><td colSpan={5} className="dir-table-empty">No members yet — click + above to invite.</td></tr>
                )}
                {members.map((m) => (
                  <UserTableRow key={m.id} u={m} currentUid={user.id} />
                ))}
              </tbody>
            </table>
          </div>
        </div>
      </section>

      {/* ─── Support access policy (org-wide) ─── */}
      <section className="admin-section">
        <div className="cells-eye">SUPPORT ACCESS</div>
        <p className="admin-sub">Controls whether Sculptex support can manage your members' accounts (change plan, reset password, change email, delete). Per-user is the default — every member sees a toggle in their account settings. Force-on or Force-off overrides their toggle.</p>
        {(() => {
          const current = org.denySupportAccess ? 'force_off' : (org.lockSupportAccess ? 'force_on' : 'per_user');
          const setPolicy = (next) => {
            if (next === 'per_user') patchOrg({ lockSupportAccess: false, denySupportAccess: false });
            else if (next === 'force_on') patchOrg({ lockSupportAccess: true, denySupportAccess: false });
            else if (next === 'force_off') patchOrg({ lockSupportAccess: false, denySupportAccess: true });
          };
          const opts = [
            { key: 'per_user',  label: 'Per-user choice', sub: 'Each member toggles support access in their settings.' },
            { key: 'force_on',  label: 'Force on',        sub: 'Members can\'t opt out. Sculptex support can always reach every member.' },
            { key: 'force_off', label: 'Force off',       sub: 'Sculptex support can\'t modify any member of this org. Account deletions still work for compliance.' },
          ];
          return (
            <div className="policy-options">
              {opts.map((o) => (
                <button
                  key={o.key}
                  type="button"
                  className={`policy-option ${current === o.key ? 'active' : ''}`}
                  onClick={() => setPolicy(o.key)}
                  aria-pressed={current === o.key}
                >
                  <div className="policy-option-row">
                    <span className={`policy-radio ${current === o.key ? 'on' : ''}`} aria-hidden="true" />
                    <span className="policy-option-label">{o.label}</span>
                  </div>
                  <div className="policy-option-sub">{o.sub}</div>
                </button>
              ))}
            </div>
          );
        })()}
      </section>

      {/* ─── Pooled usage toggle ─── */}
      <section className="admin-section">
        <div className="account-toggle-row" style={{ marginTop: 0 }}>
          <div>
            <div className="cells-eye" style={{ marginBottom: 6 }}>POOLED USAGE</div>
            <div className="account-toggle-title">Share one credit pool across the team</div>
            <div className="account-toggle-sub">
              {org.pooledUsage
                ? <>Pool refills weekly to <b>{org.members.length} × {org.bizTier === 'max' ? 125 : 25}</b> credits ({org.members.length * (org.bizTier === 'max' ? 125 : 25)}/wk). Currently <b>{Math.max(0, +(org.poolCredits || 0).toFixed(2))}</b> available.</>
                : <>When on, every member draws from a single pool sized at member-count × tier credits. Useful when usage is uneven across the team. Off (default) gives each member their own per-seat allotment.</>}
            </div>
          </div>
          <button
            className={`switch ${org.pooledUsage ? 'on' : 'off'}`}
            onClick={() => patchOrg({ pooledUsage: !org.pooledUsage })}
            aria-pressed={!!org.pooledUsage}
            aria-label="Toggle pooled usage"
          >
            <span className="switch-knob" />
          </button>
        </div>

        {/* ─── Require MFA for all members ───
            Hard policy: any member without a verified MFA factor is shown
            the MfaGate forced-enrollment screen on next sign-in and can't
            use the app until they enroll. Enforced client-side on every
            page load (via the boot-time aal check) — bypassing requires
            direct API calls which are also gated by the same check on
            server endpoints that read org config. */}
        <div className="account-toggle-row" style={{ marginTop: 18 }}>
          <div>
            <div className="cells-eye" style={{ marginBottom: 6 }}>SECURITY · MFA</div>
            <div className="account-toggle-title">Require multi-factor authentication for everyone</div>
            <div className="account-toggle-sub">
              {org.requireMfa
                ? <>On. Members without a verified authenticator app will be forced to set one up before they can use Sculptex.</>
                : <>When on, every member of <b>{org.name}</b> must have an authenticator app (1Password, Bitwarden, Google Authenticator, etc.) enrolled. They'll be prompted on next sign-in and can't access the app until they finish enrollment.</>}
            </div>
          </div>
          <button
            className={`switch ${org.requireMfa ? 'on' : 'off'}`}
            onClick={() => patchOrg({ requireMfa: !org.requireMfa })}
            aria-pressed={!!org.requireMfa}
            aria-label="Toggle require MFA"
          >
            <span className="switch-knob" />
          </button>
        </div>
      </section>

      {/* ─── Org settings: brand, default export, password policy ─── */}
      <section className="admin-section">
        <div className="cells-eye">BRAND COLORS</div>
        <p className="admin-sub">The studio mentions these as your brand palette in the prompt — the model uses them where they fit naturally, not as a hard constraint. Optional: leave empty if you don't want any color influence.</p>
        <div className="brand-row">
          {(org.brandColors || []).map((c, i) => (
            <span key={i} className="brand-chip" style={{ background: c }}>
              <span className="brand-chip-hex">{c}</span>
              <button onClick={() => patchOrg({ brandColors: org.brandColors.filter((_, j) => j !== i) })} aria-label="Remove">×</button>
            </span>
          ))}
          {(org.brandColors || []).length < 6 && (
            <span className="brand-add">
              {/* No default — the user has to actively pick a color before
                  Add is enabled, so we never seed black. */}
              <input
                type="color"
                value={colorDraft || '#888888'}
                onChange={(e) => setColorDraft(e.target.value)}
                style={colorDraft ? undefined : { opacity: 0.4 }}
                title="Pick a color"
              />
              <button
                className="btn btn-soft"
                disabled={!colorDraft}
                onClick={() => {
                  patchOrg({ brandColors: [...(org.brandColors || []), colorDraft] });
                  setColorDraft('');
                }}
              >{I.plus}<span>Add color</span></button>
            </span>
          )}
        </div>
      </section>

      <section className="admin-section">
        <div className="cells-eye">DEFAULT EXPORT FORMAT</div>
        <p className="admin-sub">Shown as the recommended download choice for everyone in the organisation.</p>
        <div className="seg">
          {['stl', 'glb', 'obj'].map((f) => (
            <button
              key={f}
              data-active={(org.defaultExportFormat || 'stl') === f}
              onClick={() => patchOrg({ defaultExportFormat: f })}
            >{f.toUpperCase()}</button>
          ))}
        </div>
      </section>

      <section className="admin-section">
        <div className="cells-eye">PASSWORD POLICY</div>
        <p className="admin-sub">Applies when org members create or change a password.</p>
        <div className="org-policy">
          <label>Minimum length: <input type="number" min="6" max="64" value={org.passwordPolicy?.minLength ?? 8} onChange={(e) => patchOrg({ passwordPolicy: { ...org.passwordPolicy, minLength: +e.target.value || 8 } })} /></label>
          <label><input type="checkbox" checked={!!org.passwordPolicy?.requireSymbol} onChange={(e) => patchOrg({ passwordPolicy: { ...org.passwordPolicy, requireSymbol: e.target.checked } })} /> Require a symbol</label>
          <label><input type="checkbox" checked={!!org.passwordPolicy?.requireUppercase} onChange={(e) => patchOrg({ passwordPolicy: { ...org.passwordPolicy, requireUppercase: e.target.checked } })} /> Require an uppercase letter</label>
          <label><input type="checkbox" checked={!!org.passwordPolicy?.requireNumber} onChange={(e) => patchOrg({ passwordPolicy: { ...org.passwordPolicy, requireNumber: e.target.checked } })} /> Require a number</label>
          <p className="admin-hint" style={{ marginTop: 8 }}>Force-password-change on first sign-in is now a per-user flag — toggle it when sending an invitation.</p>
        </div>
      </section>
    </main>
  );
}

/* ─────────────────────────── Pricing ─────────────────────────── */
function Pricing({ user, onCheckout }) {
  const [annual, setAnnual] = useState(false);
  const [bizTier, setBizTier] = useState('pro');

  const businessFeatures = [
    'Admin console — roles, seats, billing',
    'Brand colors & default export per team',
    'Per-seat billing, one centralised invoice',
    'Team workspace with shared projects',
    'Priority support',
  ];

  const plans = [
    { id: 'free', name: 'Free', tag: 'Try Sculptex with no credit card.', free: true, cta: 'Start free',
      features: ['1× weekly usage', 'Draft and Standard quality', 'Slow queue — paid users go first', 'Personal use', 'Community support'] },
    { id: 'pro', name: 'Pro', tag: 'For makers and hobbyists generating models on the side.', monthly: 20, yearly: 16,
      cta: 'Start Pro', featured: true,
      features: ['5× weekly usage', 'Draft, Standard, and Max quality', 'Standard queue', 'Personal commercial use', 'Email support'] },
    { id: 'max', name: 'Max', tag: 'For working designers shipping models every day.', monthly: 100, yearly: 80,
      cta: 'Start Max',
      features: ['25× weekly usage', 'Draft, Standard, and Max quality', 'Priority queue · 4× faster', 'Multi-format export (STL, GLB, OBJ)', 'Version history & branching', 'Priority email + chat support'] },
    { id: 'business', name: 'Business', tag: 'Pick a tier, then add admin controls for your team.',
      monthly: bizTier === 'pro' ? 25 : 105, yearly: bizTier === 'pro' ? 20 : 84,
      perSeat: true, isBusiness: true, cta: 'Start Business',
      features: [bizTier === 'pro' ? 'Everything in Pro, per seat' : 'Everything in Max, per seat', ...businessFeatures] },
  ];

  return (
    <main className="page pricing">
      <header className="page-head">
        <div className="page-eye">Plans</div>
        <h1 className="page-title">For one maker,<br />or a whole studio.</h1>
        <p className="page-sub">Generate printable STL models from a sentence. Cancel anytime.</p>
        <p className="pricing-credits">
          Credits scale with quality: <b>½ credit</b> for Draft · <b>1 credit</b> for Standard · <b>2 credits</b> for Max.
        </p>
        <div className="billing-toggle" role="tablist">
          <button data-active={!annual} onClick={() => setAnnual(false)}>Monthly</button>
          <button data-active={annual} onClick={() => setAnnual(true)}>Annual <span className="save-badge">−20%</span></button>
        </div>
      </header>

      <section className="plans">
        {plans.map((p) => {
          const current = user && user.plan === p.id;
          return (
            <article key={p.name} className={`plan ${p.featured ? 'featured' : ''}`}>
              {p.featured && <div className="plan-badge">Most popular</div>}
              <div className="plan-name">{p.name}</div>
              <div className="plan-tag">{p.tag}</div>
              {p.isBusiness && (
                <div className="biz-pick">
                  <button data-active={bizTier === 'pro'} onClick={() => setBizTier('pro')}>Pro tier</button>
                  <button data-active={bizTier === 'max'} onClick={() => setBizTier('max')}>Max tier</button>
                </div>
              )}
              <div className="plan-price">
                {p.free ? (
                  <span className="free">Free</span>
                ) : (
                  <>
                    <span className="currency">$</span>
                    <span className="amount">{annual ? p.yearly : p.monthly}</span>
                    <span className="period">/ {p.perSeat ? 'seat · mo' : 'mo'}</span>
                  </>
                )}
              </div>
              <button
                className={`btn ${p.featured ? 'btn-primary' : 'btn-ghost'} btn-block`}
                disabled={current}
                onClick={() => onCheckout(p.id, annual ? 'annual' : 'monthly', p.id === 'business' ? { bizTier } : undefined)}
              >
                {current ? 'Current plan' : p.cta}
              </button>
              <ul className="plan-features">
                {p.features.map((f, i) => (
                  <li key={i}><span className="plan-check">{I.check}</span>{f}</li>
                ))}
              </ul>
            </article>
          );
        })}
      </section>

      <section className="plan-faq">
        <div className="faq-cell">
          <div className="cells-eye">FAQ</div>
          <h4>Can I switch plans?</h4>
          <p>Yes — upgrade, downgrade, or cancel from your account at any time. Annual savings prorate.</p>
        </div>
        <div className="faq-cell">
          <div className="cells-eye">FAQ</div>
          <h4>Do I own what I generate?</h4>
          <p>Every sculpt you generate is yours, including for commercial use, on every paid plan.</p>
        </div>
        <div className="faq-cell">
          <div className="cells-eye">FAQ</div>
          <h4>What formats can I export?</h4>
          <p>STL, GLB, and OBJ on every paid plan. Hit the Download button on any sculpt and pick the format you need.</p>
        </div>
      </section>
    </main>
  );
}

/* ─────────────────────────── Auth Modal ─────────────────────────── */
/* Passwordless. Two ways in:
   1. Continue with Google
   2. Email magic link — type your address, hit "send link", click the
      link in your inbox. The link returns to sculptex.app with the
      Supabase session in the URL hash; AppShell's boot effect trades
      that for a site session cookie.
   No password field. The `mode` prop is kept (signin/signup) but only
   changes the title — same flow either way. Optional TOTP MFA kicks
   in after primary sign-in via the MfaGate component. */
function AuthModal({ mode, setMode, onClose, onSuccess }) {
  const [email, setEmail] = useState('');
  const [err, setErr] = useState('');
  const [busy, setBusy] = useState(false);
  const [linkSent, setLinkSent] = useState(false);
  const [supabaseEnabled, setSupabaseEnabled] = useState(false);
  const toast = useToast();

  // Probe the deployment. If Supabase isn't configured the modal renders
  // a "setup pending" message instead of broken buttons.
  useEffect(() => {
    (async () => {
      try {
        const cfg = await api.publicConfig();
        setSupabaseEnabled(!!cfg.googleSignInEnabled);
      } catch { /* leave disabled on fetch failure */ }
    })();
  }, []);

  async function signInWithGoogle() {
    setErr('');
    setBusy(true);
    try {
      const supabase = await getSupabase();
      const { error } = await supabase.auth.signInWithOAuth({
        provider: 'google',
        options: { redirectTo: window.location.origin + window.location.pathname },
      });
      if (error) throw error;
      // The browser is navigating away; keep busy=true until redirect.
    } catch (e) {
      setBusy(false);
      setErr(e?.code === 'not_configured' ? 'Sign-in is not configured on this deployment.' : (e?.message || 'Google sign-in failed.'));
    }
  }

  async function sendMagicLink(e) {
    if (e) e.preventDefault();
    setErr('');
    const cleanEmail = email.trim().toLowerCase();
    if (!/^\S+@\S+\.\S+$/.test(cleanEmail)) { setErr('Enter a valid email.'); return; }
    setBusy(true);
    try {
      const supabase = await getSupabase();
      const { error } = await supabase.auth.signInWithOtp({
        email: cleanEmail,
        options: {
          // Same callback as Google — the boot handler in AppShell trades
          // the token in the URL hash for a site session cookie.
          emailRedirectTo: window.location.origin + window.location.pathname,
        },
      });
      if (error) throw error;
      setLinkSent(true);
    } catch (e) {
      setErr(e?.code === 'not_configured' ? 'Sign-in is not configured on this deployment.' : (e?.message || 'Could not send magic link.'));
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose} aria-label="Close">×</button>
        <h2 className="modal-title">{mode === 'signup' ? 'Create your account' : 'Sign in to Sculptex'}</h2>
        <p className="modal-sub">{mode === 'signup' ? 'Free forever, no credit card. 5 credits a week.' : 'Welcome back.'}</p>

        {!supabaseEnabled && (
          <p className="field-error" style={{ textAlign: 'center', margin: '12px 0' }}>
            Sign-in isn't configured on this deployment yet.
          </p>
        )}

        {supabaseEnabled && !linkSent && (
          <>
            <button
              type="button"
              className="gsi-material-button"
              onClick={signInWithGoogle}
              disabled={busy}
              aria-label={mode === 'signup' ? 'Sign up with Google' : 'Sign in with Google'}
            >
              <div className="gsi-material-button-state" />
              <div className="gsi-material-button-content-wrapper">
                <div className="gsi-material-button-icon">
                  <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" style={{ display: 'block' }}>
                    <path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
                    <path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
                    <path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
                    <path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
                    <path fill="none" d="M0 0h48v48H0z" />
                  </svg>
                </div>
                <span className="gsi-material-button-contents">{mode === 'signup' ? 'Sign up with Google' : 'Sign in with Google'}</span>
              </div>
            </button>
            <div className="auth-or"><span>or with email</span></div>
            <form onSubmit={sendMagicLink}>
              <div className="field">
                <label className="field-label">Email</label>
                <input
                  className="field-input"
                  type="email"
                  value={email}
                  onChange={(e) => setEmail(e.target.value)}
                  placeholder="you@studio.com"
                  autoFocus
                />
                {err && <div className="field-error">{err}</div>}
              </div>
              <button type="submit" className="btn btn-primary btn-block" disabled={busy}>
                {busy ? 'Sending…' : 'Email me a sign-in link'}
              </button>
              <p style={{ fontSize: 12, color: 'var(--ink-3)', textAlign: 'center', marginTop: 12, lineHeight: 1.5 }}>
                No password needed. We'll send a one-time link that signs you in instantly.
              </p>
            </form>
          </>
        )}

        {supabaseEnabled && linkSent && (
          <div style={{ textAlign: 'center', padding: '8px 0 4px' }}>
            <p style={{ fontSize: 15, marginBottom: 6 }}><strong>Check your inbox.</strong></p>
            <p style={{ fontSize: 13.5, color: 'var(--ink-2)', lineHeight: 1.5 }}>
              We sent a sign-in link to <strong>{email}</strong>. Click it to come back signed in. The link expires in an hour.
            </p>
            <button
              type="button"
              className="form-link"
              style={{ marginTop: 14 }}
              onClick={() => { setLinkSent(false); setErr(''); }}
            >
              ← use a different email
            </button>
          </div>
        )}
      </div>
    </div>
  );
}

/* ─────────────────────────── Checkout Modal ─────────────────────────── */
function CheckoutModal({ plan, billing, bizTier, onClose, onConfirm }) {
  const [card, setCard] = useState('');
  const [exp, setExp] = useState('');
  const [cvc, setCvc] = useState('');
  const [zip, setZip] = useState('');
  const [orgName, setOrgName] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const toast = useToast();
  const planInfo = PLANS[plan];
  const monthly = planInfo.price;
  const price = billing === 'annual' ? Math.round(monthly * 0.8) : monthly;
  const isBusiness = plan === 'business';

  function submit(e) {
    e.preventDefault();
    if (card.replace(/\s+/g, '').length < 12) { toast.push('Enter a valid card number', 'error'); return; }
    if (isBusiness && !orgName.trim()) { toast.push('Enter your company name', 'error'); return; }
    setSubmitting(true);
    setTimeout(() => onConfirm(isBusiness ? { orgName: orgName.trim() } : undefined), 700);
  }
  function fmtCard(v) { return v.replace(/[^\d]/g, '').slice(0, 16).replace(/(.{4})/g, '$1 ').trim(); }
  function fmtExp(v) { const d = v.replace(/[^\d]/g, '').slice(0, 4); return d.length > 2 ? d.slice(0, 2) + '/' + d.slice(2) : d; }

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal modal-wide" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose} aria-label="Close">×</button>
        <h2 className="modal-title">Upgrade to {planInfo.label}{isBusiness && bizTier ? ` · ${bizTier === 'max' ? 'Max' : 'Pro'} tier` : ''}</h2>
        <p className="modal-sub">{isBusiness ? "You'll become this organisation's first admin and can invite teammates from the org panel." : 'You can cancel anytime from your account.'}</p>

        <div className="checkout-summary">
          <div>
            <div className="ck-plan">{planInfo.label}{isBusiness && bizTier ? ` · ${bizTier === 'max' ? 'Max' : 'Pro'}` : ''} · {billing}</div>
            <div className="ck-period">{usageLabelForPlan(plan)} weekly usage</div>
          </div>
          <div>
            <div className="ck-price">${price}<span className="ck-period"> / {isBusiness ? 'seat · mo' : 'mo'}</span></div>
          </div>
        </div>

        <form onSubmit={submit}>
          {isBusiness && (
            <div className="field">
              <label className="field-label">Company / organisation name</label>
              <input
                className="field-input"
                value={orgName}
                onChange={(e) => setOrgName(e.target.value)}
                placeholder="Acme Studio"
                autoFocus
              />
              <div className="field-hint">Shown on the org admin panel and on invoices. Editable later.</div>
            </div>
          )}
          <div className="field">
            <label className="field-label">Card number</label>
            <input className="field-input" value={card} onChange={(e) => setCard(fmtCard(e.target.value))} placeholder="4242 4242 4242 4242" inputMode="numeric" />
          </div>
          <div className="card-row">
            <div className="field">
              <label className="field-label">Expiry</label>
              <input className="field-input" value={exp} onChange={(e) => setExp(fmtExp(e.target.value))} placeholder="MM / YY" inputMode="numeric" />
            </div>
            <div className="field">
              <label className="field-label">CVC</label>
              <input className="field-input" value={cvc} onChange={(e) => setCvc(e.target.value.replace(/[^\d]/g, '').slice(0, 4))} placeholder="123" inputMode="numeric" />
            </div>
          </div>
          <div className="field">
            <label className="field-label">Postal / ZIP</label>
            <input className="field-input" value={zip} onChange={(e) => setZip(e.target.value)} placeholder="94107" />
          </div>
          <button type="submit" className="btn btn-primary btn-block" disabled={submitting}>
            {submitting ? 'Processing…' : `Subscribe for $${price}/mo`}
          </button>
          <p style={{ fontSize: 12, color: 'var(--ink-3)', textAlign: 'center', marginTop: 12 }}>
            Demo checkout — no card is charged.
          </p>
        </form>
      </div>
    </div>
  );
}

/* ─────────────────────────── Docs ─────────────────────────── */
function Docs() {
  return (
    <main className="page docs-page">
      <div className="page-eye">Docs</div>
      <h1 className="page-title">Get started with Sculptex.</h1>
      <p className="page-sub">Sculptex turns plain language into 3D models you can slice and print. Here's the short version.</p>

      <h2>1. Write a prompt</h2>
      <p>Be specific about shape, materials, and proportions. Good prompts read like a brief: <code>"low-poly fox, sitting, palm-sized"</code>.</p>

      <h2>2. Pick a quality</h2>
      <ul>
        <li><b>Draft</b> — fastest, ½ credit. Great for iterating ideas.</li>
        <li><b>Standard</b> — 1 credit. Default print-ready quality.</li>
        <li><b>Max</b> — 2 credits. Highest detail, longest queue. Pro and Max plans only.</li>
      </ul>

      <h2>3. Refine in chat</h2>
      <p>Open a model in the workspace and tell Sculptex what to change — <code>"taller, with a thicker handle"</code>. Each refinement costs the same as a fresh generation.</p>

      <h2>4. Download the STL</h2>
      <p>Hit <b>Download</b> in the workspace and pick a format — STL for slicers (Bambu Studio, Cura, PrusaSlicer), GLB for 3D viewers and game engines, OBJ for universal mesh editors.</p>

      <h2>Credits</h2>
      <p>Every paid plan refills credits weekly. Unused credits don't roll over. Free accounts get 5 credits a week.</p>

      <h2>Privacy</h2>
      <p>Sculpts stream over HTTPS, are stored privately to your account, and aren't used for training. You can permanently delete a project at any time — the underlying GLB and thumbnail are removed from storage immediately.</p>
    </main>
  );
}

/* ─────────────────────────── Privacy Policy ─────────────────────────── */
function PrivacyPage() {
  return (
    <main className="page docs-page">
      <div className="page-eye">Legal · Privacy Policy</div>
      <h1 className="page-title">Privacy policy.</h1>
      <p className="page-sub">Last updated 10 May 2026. Plain English. If anything's unclear, email <code>privacy@sculptex.app</code>.</p>

      <h2>What we collect</h2>
      <ul>
        <li><b>Account info</b> — email and name you give us when signing up.</li>
        <li><b>Authentication data</b> — a salted, scrypt-hashed password (we never store the plaintext) and an HMAC-signed session cookie.</li>
        <li><b>Project content</b> — every prompt you send, the chat threads, and the 3D files we generate for you.</li>
        <li><b>Billing info</b> — handled by our payment processor. We see the plan you chose and a redacted card descriptor; we never receive or store your full card number.</li>
        <li><b>Operational logs</b> — IP address and request metadata for the last 30 days, used to debug errors and rate-limit abuse.</li>
      </ul>

      <h2>What we do with it</h2>
      <ul>
        <li>Run your account, render your sculpts, and store the results so you can re-open them later.</li>
        <li>Send transactional email (account, billing, security). No marketing email without your opt-in.</li>
        <li>Investigate abuse, debug failures, and improve reliability.</li>
      </ul>

      <h2>What we don't do</h2>
      <ul>
        <li><b>We don't sell your data.</b> Ever.</li>
        <li><b>We don't use your prompts or generated 3D files to train any model.</b></li>
        <li><b>We don't share your project content</b> with anyone outside Sculptex except the third parties listed below, and only as needed to run the service.</li>
      </ul>

      <h2>Third parties we use</h2>
      <ul>
        <li><b>Hosting + storage</b> — we run on Vercel; your project files live in Vercel Blob storage.</li>
        <li><b>3D generation compute</b> — sent to a GPU compute provider that runs the model; the prompt is sent over an authenticated HTTPS request and the resulting 3D file is returned and stored on your account.</li>
        <li><b>Payments</b> — handled by a PCI-compliant card processor; we receive a token, not your card.</li>
      </ul>
      <p>Each provider only receives the data they need to do their job, and is contractually bound to delete data on request.</p>

      <h2>Cookies</h2>
      <p>We set a single <code>HttpOnly</code> session cookie when you sign in. No third-party tracking cookies. No analytics tags that fingerprint you.</p>

      <h2>Your rights</h2>
      <ul>
        <li><b>Access</b> — your projects, prompts, and chat history are visible to you in the app at any time.</li>
        <li><b>Delete</b> — deleting a project removes the GLB, thumbnail, and project record from blob storage immediately. Deleting your account removes all of it.</li>
        <li><b>Export</b> — every sculpt is downloadable as STL / GLB / OBJ from the workspace.</li>
        <li><b>Correction</b> — change your name or email from your account settings. Org admins can change the email of users they manage.</li>
      </ul>

      <h2>Children</h2>
      <p>Sculptex isn't directed at children under 13. We don't knowingly collect data from anyone under 13.</p>

      <h2>Changes</h2>
      <p>If we change this policy in a way that affects what we do with your data, we'll notify signed-in users by email at least 7 days before the change takes effect.</p>

      <h2>Contact</h2>
      <p>Privacy questions or requests: <code>privacy@sculptex.app</code>.</p>
    </main>
  );
}

/* ─────────────────────────── Terms of Service ─────────────────────────── */
function TermsPage() {
  return (
    <main className="page docs-page">
      <div className="page-eye">Legal · Terms of Service</div>
      <h1 className="page-title">Terms of service.</h1>
      <p className="page-sub">Last updated 10 May 2026. By using Sculptex you agree to these terms.</p>

      <h2>1. The service</h2>
      <p>Sculptex turns text prompts into 3D mesh files. The service is provided "as-is" while we're in active development; features may change as we improve quality and reliability.</p>

      <h2>2. Your account</h2>
      <ul>
        <li>You must be 13 or older.</li>
        <li>You're responsible for what happens under your account. Keep your password private.</li>
        <li>One person, one account. Don't share credentials.</li>
        <li>You can delete your account at any time. We'll wipe your projects from storage when you do.</li>
      </ul>

      <h2>3. Acceptable use</h2>
      <p>Don't use Sculptex to generate content that:</p>
      <ul>
        <li>Infringes on someone else's intellectual property.</li>
        <li>Is sexual content involving minors, or non-consensual intimate imagery.</li>
        <li>Promotes self-harm, violent extremism, or imminent harm to a real person.</li>
        <li>Constitutes a functional weapon part designed for lethal use, or controlled-substance equipment.</li>
        <li>Violates any applicable law where you live or where your prints will be sold.</li>
      </ul>
      <p>Repeated or egregious violations can result in account suspension and removal of generated content.</p>

      <h2>4. What you make is yours</h2>
      <p>You own the 3D files Sculptex generates from your prompts. You can use them for personal or commercial purposes on every paid plan, subject to acceptable use above. We claim no rights over your prompts or generated files.</p>

      <h2>5. Plans, credits, billing</h2>
      <ul>
        <li><b>Free</b> — 5 credits per week, no card required.</li>
        <li><b>Pro / Max / Business</b> — billed monthly or annually as you choose. Annual is 20% off.</li>
        <li><b>Credits</b> reset weekly on every paid plan and don't roll over.</li>
        <li><b>Cancellation</b> — cancel any time from your account; you keep paid features through the end of the current billing period.</li>
        <li><b>Refunds</b> — we'll refund any charge made within the last 14 days that you didn't intend, no questions asked. Email <code>billing@sculptex.app</code>.</li>
      </ul>

      <h2>6. Business plans</h2>
      <p>The first person to subscribe at the Business tier becomes the admin of that organisation and can invite, manage, and remove members. Members billed under a Business subscription are subject to the org admin's role assignments and any policies the org admin sets (default export format, brand color palette, password requirements).</p>

      <h2>7. Privacy</h2>
      <p>Our handling of your data is described in the <button type="button" className="form-link" onClick={() => { window.history.pushState({}, '', '/privacy'); window.dispatchEvent(new PopStateEvent('popstate')); }}>Privacy Policy</button>. Short version: we don't sell your data, we don't train on your prompts, and you can delete everything at any time.</p>

      <h2>8. Service availability</h2>
      <p>We aim for reliable service but we don't guarantee 100% uptime. We may schedule maintenance windows; we'll do our best to give notice for anything user-affecting.</p>

      <h2>9. Liability</h2>
      <p>Sculptex is provided "as-is". To the extent permitted by law, our total liability for any claim related to the service is capped at the greater of (a) the amount you paid us in the 12 months before the claim or (b) US$50. We're not liable for incidental, consequential, or special damages.</p>
      <p>You're responsible for verifying that any 3D file you print is safe to print and use. We don't engineer for structural, medical, or legally regulated applications.</p>

      <h2>10. Changes to these terms</h2>
      <p>We may update these terms occasionally. Material changes will be announced to signed-in users by email at least 7 days before they take effect; continuing to use Sculptex after that date counts as acceptance.</p>

      <h2>11. Termination</h2>
      <p>You can stop using Sculptex any time. We can suspend or terminate accounts that violate these terms. On termination we delete your projects from storage; we'll keep account-level records (for billing reconciliation) for as long as required by law.</p>

      <h2>12. Contact</h2>
      <p>Questions about these terms: <code>legal@sculptex.app</code>.</p>
    </main>
  );
}

/* ─────────────────────────── Account ─────────────────────────── */
function Account({ user, onSignOut, onUpgrade, go, setUser }) {
  const plan = PLANS[user.plan];
  const nextReset = new Date((user.creditsResetAt || Date.now()) + WEEK_MS);
  const toast = useToast();
  const invitations = user.invitations || [];
  async function respondToInvite(orgId, action) {
    try {
      if (action === 'accept') {
        const { user: u } = await api.acceptInvite(orgId);
        setUser?.({ ...u, orgInfo: null, invitations: (user.invitations || []).filter((i) => i.orgId !== orgId) });
        toast.push('Joined the organisation', 'success');
      } else {
        await api.declineInvite(orgId);
        setUser?.({ ...user, invitations: (user.invitations || []).filter((i) => i.orgId !== orgId) });
        toast.push('Invitation declined', 'success');
      }
    } catch (e) { toast.push(e.message || 'Action failed', 'error'); }
  }

  return (
    <main className="page account">
      <div className="page-head">
        <div className="page-eye">Account{user.admin ? ' · ADMIN' : ''}</div>
        <h1 className="page-title">{user.name || 'Your account'}.</h1>
        <p className="page-sub">Manage your plan, credits, and session.</p>
      </div>

      {invitations.length > 0 && (
        <div className="invite-banner">
          <div className="cells-eye">INVITATIONS · {invitations.length}</div>
          {invitations.map((inv) => (
            <div key={inv.orgId} className="invite-row">
              <div>
                <div className="invite-title"><b>{inv.orgName}</b> invited you as <b>{inv.role === 'admin' ? 'admin' : 'user'}</b></div>
                <div className="invite-sub admin-mono">Business · {inv.bizTier === 'max' ? 'Max' : 'Pro'} · sent by {inv.addedBy} · {relTime(inv.addedAt)}</div>
              </div>
              <div className="invite-actions">
                <button className="btn btn-ghost" onClick={() => respondToInvite(inv.orgId, 'decline')}>Decline</button>
                <button className="btn btn-primary" onClick={() => respondToInvite(inv.orgId, 'accept')}>Accept</button>
              </div>
            </div>
          ))}
        </div>
      )}

      {user.admin && (
        <div className="admin-banner">
          <div className="admin-banner-row">
            <div>
              <div className="cells-eye" style={{ color: 'var(--md-rose, #d6336c)' }}>ADMIN ACTIVE</div>
              <div className="admin-banner-title">You have admin powers on every page.</div>
              <div className="admin-banner-sub">
                Unlimited generations · all qualities unlocked · verbose API errors · plan simulator and user list at <code className="md-code">/admin</code>.
                Granted via <code className="md-code">ADMIN_EMAILS</code> in Vercel env, derived per-request from your email.
              </div>
            </div>
            <button className="btn btn-primary" onClick={() => go('admin')}>Open admin panel<span className="btn-arrow" aria-hidden="true">→</span></button>
          </div>
        </div>
      )}

      <div className="account-card">
        <div className="account-row">
          <div className="lbl">Name</div>
          <div className="val">{user.name}</div>
        </div>
        <div className="account-row">
          <div className="lbl">Email</div>
          <div className="val">{user.email}</div>
        </div>
        <div className="account-row">
          <div className="lbl">Plan</div>
          <div className="val tag">{plan.label}</div>
        </div>
        <div className="account-row">
          <div className="lbl">Usage this week</div>
          <div className="val">
            {user.plan === 'admin'
              ? <span className="mono">∞ unlimited</span>
              : (() => {
                  const c = user.orgInfo?.pooledUsage ? (user.orgInfo.poolCredits || 0) : (user.credits || 0);
                  const allot = user.orgInfo?.pooledUsage
                    ? (user.orgInfo.bizTier === 'max' ? 125 : 25) * (1) // pooled per-seat × org members; rough per-user view
                    : plan.weeklyCredits;
                  // Used = allot - remaining (clamped at allot for over-budget).
                  const used = Math.max(0, allot - c);
                  const pct = allot > 0 ? Math.min(100, (used / allot) * 100) : 0;
                  // Format the percent: integers for >= 1%, one decimal under that,
                  // and just '0%' when literally untouched.
                  const pctLabel = pct === 0 ? '0%' : pct < 1 ? `${pct.toFixed(1)}%` : `${Math.round(pct)}%`;
                  // Over budget gets its own treatment — pct caps at 100 but we
                  // also surface the overrun amount so the user knows by how much.
                  if (c < 0) {
                    return (
                      <div className="usage-row">
                        <div className="usage-bar usage-bar-wide" data-state="over"><span className="usage-fill" style={{ width: '100%' }} /></div>
                        <div className="usage-stats">
                          <span className="usage-pct">100%+</span>
                          <span className="dimmed">over budget by <b>{Math.abs(c).toFixed(1)}</b> — rolls forward to next refill</span>
                        </div>
                      </div>
                    );
                  }
                  const state = pct >= 100 ? 'empty' : pct >= 90 ? 'low' : 'ok';
                  return (
                    <div className="usage-row">
                      <div className="usage-bar usage-bar-wide" data-state={state}><span className="usage-fill" style={{ width: pct + '%' }} /></div>
                      <div className="usage-stats">
                        <span className="usage-pct">{pctLabel}</span>
                        <span className="dimmed">used · {c.toFixed(c < 10 ? 1 : 0)} of {allot} {user.orgInfo?.pooledUsage ? 'shared with ' + user.orgInfo.name : 'remaining'}</span>
                      </div>
                    </div>
                  );
                })()}
          </div>
        </div>
        {user.plan !== 'admin' && (
          <div className="account-row">
            <div className="lbl">Next refill</div>
            <div className="val mono">{nextReset.toLocaleDateString()} · {nextReset.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>
          </div>
        )}
      </div>

      <SecurityCard orgRequiresMfa={!!user.orgInfo?.requireMfa} />

      <AccountOrdersSection />

      <div className="account-actions">
        <button className="btn btn-primary" onClick={onUpgrade}>{user.plan === 'free' ? 'Upgrade plan' : 'Manage plan'}<span className="btn-arrow" aria-hidden="true">→</span></button>
        <button className="btn btn-ghost" onClick={onSignOut}>{I.signOut}<span>Sign out</span></button>
      </div>
    </main>
  );
}

/* ─────────────────────────── SecurityCard ───────────────────────────
   TOTP multi-factor authentication. Enroll with a QR code from an
   authenticator app (1Password, Bitwarden, Google Authenticator, Authy)
   plus a 6-digit verification. Once verified, supabase.auth.mfa raises
   the session AAL from aal1 → aal2 and MfaGate challenges on next sign-in.
   (Passkey UI was removed pending Supabase shipping a dashboard toggle
   for the experimental WebAuthn flag — TOTP is fully GA today.) */
function SecurityCard({ orgRequiresMfa = false }) {
  const toast = useToast();
  const [busy, setBusy] = useState(false);
  const [factors, setFactors] = useState([]);
  const [enrolling, setEnrolling] = useState(null); // null | { factorId, qrCode, secret, verifyCode }

  async function refreshFactors() {
    try {
      const supabase = await getSupabase();
      const { data, error } = await supabase.auth.mfa.listFactors();
      if (error) throw error;
      // listFactors returns { all, totp, phone } — surface TOTP factors.
      setFactors(data?.totp || []);
    } catch (e) {
      console.warn('[security] listFactors failed', e);
    }
  }

  useEffect(() => { refreshFactors(); }, []);

  /* ──── TOTP MFA flow ────
     Three steps per Supabase's MFA guide:
       1. enroll({ factorType: 'totp' }) → returns factorId + QR code SVG + secret
       2. challenge({ factorId }) → returns challengeId
       3. verify({ factorId, challengeId, code }) → activates the factor
     The QR code is an SVG returned as a string (sometimes data URL,
     sometimes raw SVG); we render it as an <img> with the data URL
     prefix when needed. */
  async function startEnrollTotp() {
    setBusy(true);
    try {
      const supabase = await getSupabase();
      const { data, error } = await supabase.auth.mfa.enroll({
        factorType: 'totp',
        friendlyName: 'Authenticator app',
        issuer: 'Sculptex',
      });
      if (error) throw error;
      const rawQr = data?.totp?.qr_code || '';
      const qrSrc = rawQr.startsWith('data:') ? rawQr : (rawQr.startsWith('<svg') ? `data:image/svg+xml;utf8,${encodeURIComponent(rawQr)}` : rawQr);
      setEnrolling({
        factorId: data.id,
        qrCode: qrSrc,
        secret: data?.totp?.secret || '',
        verifyCode: '',
      });
    } catch (e) {
      toast.push(e?.message || 'Could not start enrollment.', 'error');
    } finally {
      setBusy(false);
    }
  }

  async function confirmEnrollTotp() {
    if (!enrolling) return;
    const code = (enrolling.verifyCode || '').replace(/\D/g, '').slice(0, 6);
    if (code.length !== 6) { toast.push('Enter the 6-digit code from your authenticator app.', 'error'); return; }
    setBusy(true);
    try {
      const supabase = await getSupabase();
      const { data: ch, error: chErr } = await supabase.auth.mfa.challenge({ factorId: enrolling.factorId });
      if (chErr) throw chErr;
      const { error: vErr } = await supabase.auth.mfa.verify({
        factorId: enrolling.factorId,
        challengeId: ch.id,
        code,
      });
      if (vErr) throw vErr;
      toast.push('Authenticator app enabled. We\'ll ask for a code next time you sign in.', 'success');
      setEnrolling(null);
      await refreshFactors();
    } catch (e) {
      toast.push(e?.message || 'Code didn\'t verify — check the app and try again.', 'error');
    } finally {
      setBusy(false);
    }
  }

  async function cancelEnrollTotp() {
    if (!enrolling) return;
    // Unenroll the unverified factor so it doesn't linger in listFactors.
    try {
      const supabase = await getSupabase();
      await supabase.auth.mfa.unenroll({ factorId: enrolling.factorId });
    } catch {}
    setEnrolling(null);
  }

  async function removeFactor(factorId) {
    if (orgRequiresMfa && factors.filter((f) => f.status === 'verified').length === 1) {
      toast.push('Your organisation requires MFA. Add another factor before removing this one.', 'error');
      return;
    }
    if (!window.confirm('Remove this authenticator? You\'ll only have your primary sign-in method until you add another factor.')) return;
    setBusy(true);
    try {
      const supabase = await getSupabase();
      const { error } = await supabase.auth.mfa.unenroll({ factorId });
      if (error) throw error;
      toast.push('Authenticator removed', 'success');
      await refreshFactors();
    } catch (e) {
      toast.push(e?.message || 'Could not remove authenticator.', 'error');
    } finally {
      setBusy(false);
    }
  }

  const verifiedFactors = factors.filter((f) => f.status === 'verified');

  return (
    <div className="account-card">
      {/* ────────── Authenticator app (TOTP MFA) ────────── */}
      <div className="account-row" style={{ borderBottom: '1px solid var(--line-2)', paddingBottom: 14, marginBottom: 14 }}>
        <div className="lbl">Authenticator app</div>
        <div className="val" style={{ fontSize: 13.5, color: 'var(--ink-2)', lineHeight: 1.5 }}>
          Add a second factor with an app like 1Password, Bitwarden, Google Authenticator, or Authy. You'll be prompted for a 6-digit code on every sign-in.
          {orgRequiresMfa && <span style={{ color: 'var(--md-rose, #d6336c)', display: 'block', marginTop: 6 }}><b>Your organisation requires MFA.</b> Set one up here to keep using Sculptex.</span>}
        </div>
      </div>

      {verifiedFactors.length > 0 && (
        <ul className="passkey-list" style={{ marginBottom: 12 }}>
          {verifiedFactors.map((f) => (
            <li key={f.id} className="passkey-row">
              <div>
                <div className="passkey-name">{f.friendly_name || 'Authenticator app'}</div>
                <div className="passkey-meta admin-mono">
                  added {f.created_at ? new Date(f.created_at).toLocaleDateString() : '—'} · verified
                </div>
              </div>
              <button className="btn btn-ghost" disabled={busy} onClick={() => removeFactor(f.id)}>Remove</button>
            </li>
          ))}
        </ul>
      )}

      {!enrolling && (
        <div>
          <button className="btn btn-primary" disabled={busy} onClick={startEnrollTotp}>
            {busy ? 'Working…' : (verifiedFactors.length > 0 ? 'Add another authenticator' : 'Set up authenticator app')}
          </button>
        </div>
      )}

      {enrolling && (
        <div className="totp-enroll">
          <p style={{ fontSize: 13.5, color: 'var(--ink-2)', margin: '0 0 10px' }}>
            <b>Step 1.</b> Scan this QR code in your authenticator app (1Password, Bitwarden, Google Authenticator, etc.).
          </p>
          {enrolling.qrCode && (
            <div style={{ display: 'flex', justifyContent: 'center', padding: 12, background: 'var(--paper-1)', borderRadius: 10, border: '1px solid var(--line-2)', marginBottom: 12 }}>
              <img src={enrolling.qrCode} alt="MFA QR code" style={{ width: 200, height: 200, display: 'block' }} />
            </div>
          )}
          {enrolling.secret && (
            <p style={{ fontSize: 12, color: 'var(--ink-3)', margin: '0 0 14px', textAlign: 'center' }}>
              Can't scan? Enter this code manually: <code className="admin-mono" style={{ userSelect: 'all' }}>{enrolling.secret}</code>
            </p>
          )}
          <p style={{ fontSize: 13.5, color: 'var(--ink-2)', margin: '6px 0 8px' }}>
            <b>Step 2.</b> Enter the 6-digit code your app generates.
          </p>
          <input
            type="text"
            inputMode="numeric"
            autoComplete="one-time-code"
            placeholder="123456"
            className="field-input"
            value={enrolling.verifyCode}
            onChange={(e) => setEnrolling({ ...enrolling, verifyCode: e.target.value.replace(/\D/g, '').slice(0, 6) })}
            style={{ letterSpacing: '0.2em', fontFamily: 'var(--font-mono)', textAlign: 'center', fontSize: 18 }}
          />
          <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
            <button className="btn btn-ghost" disabled={busy} onClick={cancelEnrollTotp}>Cancel</button>
            <button className="btn btn-primary" disabled={busy || (enrolling.verifyCode || '').length !== 6} onClick={confirmEnrollTotp}>
              {busy ? 'Verifying…' : 'Verify & enable'}
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

/* ─────────────────────────── MfaGate ───────────────────────────
   Full-screen overlay that blocks the rest of the app until either:
     - mode='challenge' : user has a verified TOTP factor; they enter the
       current 6-digit code and the session is elevated aal1 → aal2.
     - mode='enroll'    : org policy requires MFA but no factor is enrolled;
       user must set up an authenticator before continuing.
   onSatisfied() is called once the gate condition is cleared. */
function MfaGate({ mode, orgRequired, onSatisfied, onSignOut }) {
  const toast = useToast();
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');
  const [challengeCode, setChallengeCode] = useState('');
  const [enroll, setEnroll] = useState(null); // null | { factorId, qrCode, secret }
  const [verifyCode, setVerifyCode] = useState('');

  // For challenge mode, kick off a challenge against the user's first
  // verified TOTP factor on mount.
  useEffect(() => {
    if (mode !== 'challenge') return;
    (async () => {
      try {
        const supabase = await getSupabase();
        const { data: factors, error: lfErr } = await supabase.auth.mfa.listFactors();
        if (lfErr) throw lfErr;
        const verified = (factors?.totp || []).find((f) => f.status === 'verified');
        if (!verified) { onSatisfied(); return; }
        // The challenge stays alive for a few minutes; verify will reference it.
        const { data, error } = await supabase.auth.mfa.challenge({ factorId: verified.id });
        if (error) throw error;
        setEnroll({ factorId: verified.id, challengeId: data.id, qrCode: null, secret: null });
      } catch (e) {
        setErr(e?.message || 'Could not start MFA challenge.');
      }
    })();
    /* eslint-disable-next-line react-hooks/exhaustive-deps */
  }, []);

  // For enroll mode, start a fresh TOTP enrollment.
  useEffect(() => {
    if (mode !== 'enroll') return;
    (async () => {
      setBusy(true);
      try {
        const supabase = await getSupabase();
        const { data, error } = await supabase.auth.mfa.enroll({
          factorType: 'totp', friendlyName: 'Authenticator app', issuer: 'Sculptex',
        });
        if (error) throw error;
        const rawQr = data?.totp?.qr_code || '';
        const qrSrc = rawQr.startsWith('data:') ? rawQr : (rawQr.startsWith('<svg') ? `data:image/svg+xml;utf8,${encodeURIComponent(rawQr)}` : rawQr);
        setEnroll({ factorId: data.id, qrCode: qrSrc, secret: data?.totp?.secret || '', challengeId: null });
      } catch (e) {
        setErr(e?.message || 'Could not start enrollment.');
      } finally {
        setBusy(false);
      }
    })();
    /* eslint-disable-next-line react-hooks/exhaustive-deps */
  }, []);

  async function submitChallenge() {
    setErr('');
    const code = challengeCode.replace(/\D/g, '').slice(0, 6);
    if (code.length !== 6 || !enroll?.challengeId) { setErr('Enter the 6-digit code.'); return; }
    setBusy(true);
    try {
      const supabase = await getSupabase();
      const { error } = await supabase.auth.mfa.verify({
        factorId: enroll.factorId, challengeId: enroll.challengeId, code,
      });
      if (error) throw error;
      // Re-trade the now-elevated Supabase session for a fresh site cookie
      // — our backend doesn't track AAL but it doesn't hurt to refresh.
      const { data: s } = await supabase.auth.getSession();
      if (s?.session?.access_token) {
        try { await api.supabaseSession(s.session.access_token); } catch {}
      }
      toast.push('Verified', 'success');
      onSatisfied();
    } catch (e) {
      setErr(e?.message || 'Code didn\'t verify.');
    } finally {
      setBusy(false);
    }
  }

  async function submitEnroll() {
    setErr('');
    const code = verifyCode.replace(/\D/g, '').slice(0, 6);
    if (code.length !== 6 || !enroll?.factorId) { setErr('Enter the 6-digit code from your authenticator app.'); return; }
    setBusy(true);
    try {
      const supabase = await getSupabase();
      const { data: ch, error: chErr } = await supabase.auth.mfa.challenge({ factorId: enroll.factorId });
      if (chErr) throw chErr;
      const { error: vErr } = await supabase.auth.mfa.verify({
        factorId: enroll.factorId, challengeId: ch.id, code,
      });
      if (vErr) throw vErr;
      const { data: s } = await supabase.auth.getSession();
      if (s?.session?.access_token) {
        try { await api.supabaseSession(s.session.access_token); } catch {}
      }
      toast.push('MFA enabled', 'success');
      onSatisfied();
    } catch (e) {
      setErr(e?.message || 'Code didn\'t verify.');
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="mfa-gate">
      <div className="mfa-gate-card">
        <div className="mfa-gate-eye">SECURITY · MULTI-FACTOR AUTHENTICATION</div>
        {mode === 'challenge' && (
          <>
            <h1 className="mfa-gate-title">Enter your 6-digit code.</h1>
            <p className="mfa-gate-sub">
              Open your authenticator app and enter the current code to continue.
            </p>
            <input
              type="text"
              inputMode="numeric"
              autoComplete="one-time-code"
              autoFocus
              placeholder="123456"
              className="field-input mfa-gate-input"
              value={challengeCode}
              onChange={(e) => setChallengeCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
              onKeyDown={(e) => { if (e.key === 'Enter') submitChallenge(); }}
            />
            {err && <div className="field-error" style={{ textAlign: 'center', marginTop: 10 }}>{err}</div>}
            <div className="mfa-gate-actions">
              <button className="btn btn-ghost" onClick={onSignOut}>Sign out</button>
              <button className="btn btn-primary" disabled={busy || challengeCode.length !== 6} onClick={submitChallenge}>
                {busy ? 'Verifying…' : 'Continue'}
              </button>
            </div>
          </>
        )}

        {mode === 'enroll' && (
          <>
            <h1 className="mfa-gate-title">Set up multi-factor authentication.</h1>
            <p className="mfa-gate-sub">
              {orgRequired
                ? <>Your organisation requires MFA. Scan the QR code below in an authenticator app (1Password, Bitwarden, Google Authenticator, Authy), then enter the 6-digit code to finish.</>
                : <>Scan the QR code below in an authenticator app, then enter the 6-digit code to finish.</>}
            </p>
            {enroll?.qrCode && (
              <div className="mfa-gate-qr-wrap">
                <img src={enroll.qrCode} alt="MFA QR code" className="mfa-gate-qr" />
              </div>
            )}
            {enroll?.secret && (
              <p style={{ fontSize: 12, color: 'var(--ink-3)', textAlign: 'center', margin: '0 0 14px' }}>
                Can't scan? Type this code: <code className="admin-mono" style={{ userSelect: 'all' }}>{enroll.secret}</code>
              </p>
            )}
            <input
              type="text"
              inputMode="numeric"
              autoComplete="one-time-code"
              placeholder="123456"
              className="field-input mfa-gate-input"
              value={verifyCode}
              onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
              onKeyDown={(e) => { if (e.key === 'Enter') submitEnroll(); }}
            />
            {err && <div className="field-error" style={{ textAlign: 'center', marginTop: 10 }}>{err}</div>}
            <div className="mfa-gate-actions">
              <button className="btn btn-ghost" onClick={onSignOut}>Sign out</button>
              <button className="btn btn-primary" disabled={busy || verifyCode.length !== 6} onClick={submitEnroll}>
                {busy ? 'Verifying…' : 'Verify & continue'}
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

/* ─────────────────────────── Global Footer ─────────────────────────── */
function Footer({ go, onSignUp }) {
  const [version, setVersion] = useState(null);
  useEffect(() => {
    let alive = true;
    api.publicConfig().then((cfg) => { if (alive) setVersion(cfg?.version || null); }).catch(() => {});
    return () => { alive = false; };
  }, []);
  return (
    <footer className="footer">
      <div className="footer-grid footer-grid-tight">
        <div className="footer-col">
          <div className="footer-eye">PRODUCT</div>
          <button type="button" onClick={() => go('home')}>Studio</button>
          <button type="button" onClick={onSignUp}>Start free</button>
          <button type="button" onClick={() => go('pricing')}>Pricing</button>
        </div>
        <div className="footer-col">
          <div className="footer-eye">DOCS</div>
          <button type="button" onClick={() => go('docs')}>Get started</button>
          <button type="button" onClick={() => go('docs')}>Refinement</button>
          <button type="button" onClick={() => go('docs')}>Export formats</button>
        </div>
        <div className="footer-col">
          <div className="footer-eye">LEGAL</div>
          <button type="button" onClick={() => go('terms')}>Terms of service</button>
          <button type="button" onClick={() => go('privacy')}>Privacy policy</button>
        </div>
        <div className="footer-col footer-brand">
          <button className="brand brand-foot" onClick={() => go('home')} aria-label="Sculptex">
            <svg viewBox="0 0 24 24" width="32" height="32" fill="currentColor"><polygon points="12,2.4 22,8 12,13.6 2,8" /><polygon points="2,8 2,16.2 12,21.6 12,13.6" opacity="0.62" /><polygon points="22,8 22,16.2 12,21.6 12,13.6" opacity="0.36" /></svg>
          </button>
        </div>
      </div>

      <div className="footer-bar">
        <span className="status-pill"><span className="status-dot" />ALL SYSTEMS NORMAL.</span>
        <span className="footer-copy">© Sculptex 2026 — built for makers.</span>
        {version?.commitShort && (
          <span
            className="footer-version"
            title={version.message ? `${version.commitShort} · ${version.message}` : version.commitShort}
          >
            build {version.commitShort}{version.env && version.env !== 'production' ? ` · ${version.env}` : ''}
          </span>
        )}
      </div>
    </footer>
  );
}

/* (Mobile bottom bar removed — was stale, didn't reflect signed-in routes,
   and overlapped important content on small screens. Mobile now relies on
   the top nav + the per-page master tabs in ProjectWorkspace.) */

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
