// user.jsx — Central logged-in-user model.
// Every screen reads name + stats from here.
//
// TWO MODES, same public API:
//   • LIVE   — config.js has real Supabase keys → reads/writes the `profiles`
//              table for the signed-in auth user.
//   • DEMO   — no keys yet → local-only, deterministic per-handle stats so the
//              preview works instantly. (Great for design review / offline.)
//
// Public API (unchanged for the screens):
//   useUser()        React hook → the current profile object
//   loadProfile()    synchronous best-known profile (cache)
//   saveProfile(p)   merge + persist (DB in live mode, localStorage in demo)
//   clearProfile()   wipe local cache
//   signUp/signIn/signInWithProvider/signOut  (live auth; no-ops in demo)
//   isConfigured()   → boolean

const { useState: useStateU, useEffect: useEffectU } = React;

const USER_COLORS = ['#C72820', '#7A5AE0', '#CAFF33', '#FBBF24', '#22C55E', '#EC4899', '#06B6D4', '#F97316', '#A855F7'];

function sb() { return window.LAUNCH_SUPABASE || null; }
function isConfigured() { return !!window.LAUNCH_CONFIGURED && !!sb(); }

// ── helpers ────────────────────────────────────────────────────────────────
function hashStr(str) {
  let h = 2166136261;
  for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
  return h >>> 0;
}
function seededRand(seed) {
  let s = seed % 233280;
  return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
}
function pick(rand, lo, hi) { return Math.floor(lo + rand() * (hi - lo + 1)); }

function nameFromHandle(handle) {
  return (handle || 'new.artist').split(/[.\s_]+/).filter(Boolean)
    .map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
}
function initialsFrom(displayName, handle) {
  const src = (displayName || nameFromHandle(handle) || '').trim();
  const parts = src.split(/\s+/).filter(Boolean);
  if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
  if (parts.length === 1 && parts[0].length >= 2) return parts[0].slice(0, 2).toUpperCase();
  return (handle || 'NA').slice(0, 2).toUpperCase();
}
function colorFor(handle) { return USER_COLORS[hashStr(handle || 'x') % USER_COLORS.length]; }

const GENRE_BIO = {
  pop:        "pop / hyperpop. big hooks, bigger feelings. writing for the front row 🎤",
  rb:         "bedroom pop / soul. brooklyn. writing songs you'd play on a slow drive home 🌒",
  hiphop:     "hip hop / rap. bars first, everything else after. studio rat 🎧",
  electronic: "electronic / house. late-night sound design, 4am bounces 🌌",
  indie:      "indie / alt. fuzzy guitars and honest lyrics. diy forever 🎸",
  country:    "country / americana. nashville. three chords and the truth 🤠",
  rock:       "rock. loud amps, real drums, no autotune 🤘",
  jazz:       "jazz / neo-soul. chords nobody else would dare. live takes only 🎹",
};

const TIERS = [
  { min: 0,    tier: 'ROOKIE',        sub: 'DAY ONE · WELCOME ABOARD' },
  { min: 400,  tier: 'IGNITION',      sub: 'BUILDING MOMENTUM' },
  { min: 1200, tier: 'RISING ARTIST', sub: 'KEEP COOKING' },
  { min: 3000, tier: 'IN ORBIT',      sub: 'THE SQUAD KNOWS YOUR NAME' },
  { min: 6000, tier: 'HEADLINER',     sub: 'STOPPING ROOMS' },
];
function tierFor(xp) { return [...TIERS].reverse().find(t => xp >= t.min) || TIERS[0]; }
function subForTier(tier) { return (TIERS.find(t => t.tier === tier) || TIERS[0]).sub; }

// Canonical demo account — keeps curated content coherent in demo mode.
const MAYA = {
  firstName: 'Maya', lastName: 'Stokes', displayName: 'Maya Stokes',
  handle: 'maya.sounds', initials: 'MS', color: '#C72820',
  genre: 'rb', experience: 'releasing', goal: 'release',
  tier: 'RISING ARTIST', tierSub: '11 MONTHS IN · KEEP COOKING',
  bio: GENRE_BIO.rb,
  streak: 37, dayOfPath: 37, pathLength: 90,
  xpThisWeek: 2640, xpEarnedToday: 847,
  reviewsPending: 3, sessionsThisWeek: 2,
  reviewsReceived: 12, sessionsTotal: 12, releases: 4, upcoming: 2,
  followers: 312, isNew: false,
};

// Decorate a partial profile into the full shape every screen expects.
function decorate(p) {
  const handle = (p.handle || 'new.artist').toLowerCase();
  const displayName = (p.displayName || nameFromHandle(handle)).trim();
  const tier = p.tier || tierFor(p.xpThisWeek || 0).tier;
  return {
    firstName: displayName.split(' ')[0],
    lastName: displayName.split(' ').slice(1).join(' '),
    displayName, handle,
    initials: p.initials || initialsFrom(displayName, handle),
    color: p.color || colorFor(handle),
    avatarUrl: avatarUrlFor(p.avatarUrl || p.avatar_path),
    genre: p.genre || 'pop',
    genres: (p.genres && p.genres.length) ? p.genres : [p.genre || 'pop'],
    experience: p.experience || 'starting',
    goal: p.goal || 'release',
    goals: (p.goals && p.goals.length) ? p.goals : [p.goal || 'release'],
    tier,
    tierSub: p.tierSub || subForTier(tier),
    bio: p.bio || GENRE_BIO[p.genre] || GENRE_BIO.pop,
    streak: num(p.streak, 1), dayOfPath: num(p.dayOfPath, 1), pathLength: num(p.pathLength, 90),
    xpThisWeek: num(p.xpThisWeek, 0), xpEarnedToday: num(p.xpEarnedToday, 0),
    reviewsPending: num(p.reviewsPending, 0), sessionsThisWeek: num(p.sessionsThisWeek, 0),
    reviewsReceived: num(p.reviewsReceived, 0), sessionsTotal: num(p.sessionsTotal, 0),
    releases: num(p.releases, 0), upcoming: num(p.upcoming, 0),
    followers: num(p.followers, 0),
    role: p.role || 'member',
    isNew: !!p.isNew,
  };
}
function num(v, d) { return (v === null || v === undefined || isNaN(v)) ? d : Number(v); }

// DEMO: build a deterministic, tailored profile from a handle.
function buildProfile(raw) {
  const handle = (raw.handle || 'new.artist').toLowerCase();
  if (handle === MAYA.handle) {
    // Allow overriding the canonical demo account's name/bio from edits, and
    // recompute the derived fields (firstName/lastName/initials) so the change
    // actually shows up instead of staying "Maya".
    const over = {};
    if (raw.displayName) { over.displayName = raw.displayName; over.initials = undefined; }
    if (raw.bio) over.bio = raw.bio;
    if (raw.color) over.color = raw.color;
    if (raw.avatarUrl || raw.avatar_path) over.avatarUrl = raw.avatarUrl || raw.avatar_path;
    return decorate({ ...MAYA, ...over });
  }

  const displayName = raw.displayName || nameFromHandle(handle);
  const seed = hashStr(handle);
  const rand = seededRand(seed);
  const isNew = !!raw.isNew;
  const xpThisWeek = isNew ? pick(rand, 0, 60) : pick(rand, 220, 3400);
  const t = tierFor(isNew ? 0 : xpThisWeek + pick(rand, 200, 2000));

  return decorate({
    handle, displayName,
    color: USER_COLORS[seed % USER_COLORS.length],
    avatarUrl: raw.avatarUrl || raw.avatar_path,
    genre: raw.genre || 'pop', experience: raw.experience || 'starting', goal: raw.goal || 'release',
    tier: isNew ? 'ROOKIE' : t.tier,
    tierSub: isNew ? 'DAY ONE · WELCOME ABOARD' : t.sub,
    bio: GENRE_BIO[raw.genre] || GENRE_BIO.pop,
    streak: isNew ? 1 : pick(rand, 2, 58),
    dayOfPath: isNew ? 1 : pick(rand, 2, 84), pathLength: 90,
    xpThisWeek, xpEarnedToday: isNew ? pick(rand, 0, 40) : pick(rand, 60, 900),
    reviewsPending: isNew ? 0 : pick(rand, 0, 5), sessionsThisWeek: isNew ? 0 : pick(rand, 0, 3),
    reviewsReceived: isNew ? 0 : pick(rand, 0, 18), sessionsTotal: isNew ? 0 : pick(rand, 0, 16),
    releases: isNew ? 0 : pick(rand, 0, 6), upcoming: isNew ? 0 : pick(rand, 0, 3),
    followers: isNew ? pick(rand, 0, 12) : pick(rand, 30, 900),
    isNew,
  });
}

// LIVE: map a DB row (snake_case) → the profile shape.
function mapRow(row) {
  return decorate({
    handle: row.handle, displayName: row.display_name,
    avatarUrl: row.avatar_path,
    genre: row.genre, experience: row.experience, goal: row.goal,
    tier: row.tier, bio: row.bio,
    streak: row.streak, dayOfPath: row.day_of_path, pathLength: row.path_length,
    xpThisWeek: row.xp_this_week, xpEarnedToday: row.xp_today,
    reviewsPending: row.reviews_pending, sessionsThisWeek: row.sessions_this_week,
    reviewsReceived: row.reviews_received, sessionsTotal: row.sessions_total,
    releases: row.releases, upcoming: row.upcoming, followers: row.followers,
    role: row.role || 'member',
    isNew: (row.xp_this_week || 0) < 50,
  });
}
// profile shape → DB columns (snake_case) for writes.
function toRow(p) {
  const out = {};
  const m = {
    handle: 'handle', displayName: 'display_name', genre: 'genre', experience: 'experience',
    goal: 'goal', tier: 'tier', bio: 'bio', streak: 'streak', dayOfPath: 'day_of_path',
    pathLength: 'path_length', xpThisWeek: 'xp_this_week', xpEarnedToday: 'xp_today',
    reviewsPending: 'reviews_pending', sessionsThisWeek: 'sessions_this_week',
    reviewsReceived: 'reviews_received', sessionsTotal: 'sessions_total',
    releases: 'releases', upcoming: 'upcoming', followers: 'followers',
  };
  Object.keys(m).forEach(k => { if (p[k] !== undefined) out[m[k]] = p[k]; });
  return out;
}

// ── cache (so synchronous loadProfile works in both modes) ───────────────────
const STORE_KEY = 'launch.profile';
let _cached = null;

function loadProfile() {
  if (_cached) return _cached;
  try {
    const raw = localStorage.getItem(STORE_KEY);
    if (raw) { _cached = buildProfile(JSON.parse(raw)); return _cached; }
  } catch (e) {}
  _cached = { ...MAYA };
  return _cached;
}

function emitChange() {
  try { window.dispatchEvent(new Event('launch.profilechange')); } catch (e) {}
}

// Local edits (display name / handle / bio / avatar color) saved by the user.
// Applied on top of the DB row so changes always show even if a write was
// blocked by RLS or there's no confirmed session yet. Cleared on sign out.
function localOverride() {
  try { return JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); } catch (e) { return {}; }
}

// ── LIVE fetch ───────────────────────────────────────────────────────────────
async function fetchLiveProfile() {
  const client = sb();
  if (!client) return null;
  const { data: { user } } = await client.auth.getUser();
  if (!user) return null;
  const { data, error } = await client.from('profiles').select('*').eq('id', user.id).single();
  const ov = localOverride();
  const hasOv = ov && Object.keys(ov).length > 0;
  if (error || !data) {
    // No row yet — still personalize from any local edits.
    if (hasOv) { _cached = buildProfile(ov); return _cached; }
    return null;
  }
  const row = mapRow(data);
  if (hasOv) {
    const merged = { ...row, ...ov };
    if (ov.displayName) merged.initials = undefined; // recompute from new name
    _cached = decorate(merged);
  } else {
    _cached = row;
  }
  return _cached;
}

// ── public: saveProfile ───────────────────────────────────────────────────────
async function saveProfile(partial) {
  // Always record the edit locally first, so the change shows immediately and
  // survives a remount no matter the backend state (demo, RLS, unconfirmed
  // email…). fetchLiveProfile layers this on top of the DB row.
  let merged = {};
  try {
    const existing = (() => { try { return JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); } catch (e) { return {}; } })();
    merged = { ...existing, ...partial };
    localStorage.setItem(STORE_KEY, JSON.stringify(merged));
    if (merged.handle) localStorage.setItem('launch.handle', merged.handle);
  } catch (e) {}

  if (isConfigured()) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (user) {
        await client.from('profiles').update(toRow(partial)).eq('id', user.id);
        await fetchLiveProfile(); // merges the local edit on top of the row
        emitChange();
        return _cached;
      }
    } catch (e) { console.warn('[LAUNCH] saveProfile (live) failed:', e); }
  }
  // demo mode (or not signed in yet): build from the merged local profile
  _cached = buildProfile(merged);
  emitChange();
  return _cached;
}

function clearProfile() {
  _cached = null;
  try { localStorage.removeItem(STORE_KEY); } catch (e) {}
  emitChange();
}

// ── public: auth (live). In demo mode these just persist locally. ─────────────
async function signUp({ email, password, handle, displayName }) {
  if (isConfigured()) {
    const client = sb();
    const { data, error } = await client.auth.signUp({
      email, password,
      options: { data: { handle: (handle || '').toLowerCase(), display_name: displayName || '' } },
    });
    if (error) return { error };
    // If email confirmation is OFF, a session exists now and the trigger made the row.
    if (data.session) { await fetchLiveProfile(); emitChange(); }
    return { data, needsConfirmation: !data.session };
  }
  await saveProfile({ handle, displayName, isNew: true });
  try { localStorage.setItem('launch.signedIn', '1'); } catch (e) {}
  return { data: { demo: true } };
}

async function signIn({ email, password }) {
  if (isConfigured()) {
    const client = sb();
    const { data, error } = await client.auth.signInWithPassword({ email, password });
    if (error) return { error };
    await fetchLiveProfile(); emitChange();
    return { data };
  }
  try { localStorage.setItem('launch.signedIn', '1'); } catch (e) {}
  return { data: { demo: true } };
}

async function signInWithProvider(provider) {
  if (isConfigured()) {
    // OAuth can only redirect back to an http(s) origin that's whitelisted in
    // Supabase. Opening the app from a file:// URL (double-clicking index.html)
    // can never complete the round-trip, so fail with a clear message instead
    // of bouncing the browser to a dead end.
    if (window.location.protocol === 'file:') {
      return { error: { message: 'OAuth needs the app served over http. Open it with Live Server (http://localhost:5500), not by double-clicking the file.' } };
    }
    const client = sb();
    // Use origin + path (no hash/query) so it matches the Redirect URL you add
    // in Supabase → Authentication → URL Configuration.
    const redirectTo = window.location.origin + window.location.pathname;
    // Leave a breadcrumb so that when the browser lands back on the app after
    // the provider round-trip, app.jsx knows to finish sign-in (seed profile,
    // skip onboarding) instead of showing the welcome flow again.
    try { localStorage.setItem('launch.oauthPending', provider); } catch (e) {}
    const { data, error } = await client.auth.signInWithOAuth({ provider, options: { redirectTo } });
    if (error) {
      try { localStorage.removeItem('launch.oauthPending'); } catch (e) {}
      const enableHint = ' — make sure the "' + provider + '" provider is enabled in Supabase → Authentication → Providers, and that ' + redirectTo + ' is listed under URL Configuration → Redirect URLs.';
      return { error: { message: (error.message || 'OAuth failed') + enableHint } };
    }
    return { data };
  }
  try { localStorage.setItem('launch.signedIn', '1'); } catch (e) {}
  return { data: { demo: true } };
}

// Call once on app load after returning from a provider (Google/Apple) redirect.
// The Supabase client parses the OAuth tokens out of the URL during init, so a
// session is available shortly after. We confirm it, make sure the provider's
// real name lands on the auto-created profile row, and report success so the
// shell can drop the user straight into the app.
async function completeOAuthSignIn() {
  if (!isConfigured()) return { signedIn: false };
  const client = sb();
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
  let session = null;
  for (let i = 0; i < 8 && !session; i++) {
    try { const r = await client.auth.getSession(); session = r.data.session; } catch (e) {}
    if (!session) await wait(250);
  }
  if (!session || !session.user) return { signedIn: false };
  const meta = session.user.user_metadata || {};
  // The DB trigger already created a profiles row (handle derived from the
  // email). Providers expose the display name under full_name/name — push it
  // onto the row so the profile shows the real name, not the email handle.
  const realName = meta.full_name || meta.name || meta.display_name || '';
  if (realName) {
    await saveProfile({ displayName: String(realName).trim() });
  } else {
    await fetchLiveProfile();
  }
  try { localStorage.setItem('launch.signedIn', '1'); } catch (e) {}
  emitChange();
  return { signedIn: true };
}

async function signOut() {
  if (isConfigured()) { try { await sb().auth.signOut(); } catch (e) {} }
  clearProfile();
  _pointsCache = null;
  _planCache = null;
  try {
    localStorage.removeItem('launch.signedIn');
    localStorage.removeItem('launch.handle');
    localStorage.removeItem('launch.points');
    localStorage.removeItem('launch.plan');
    localStorage.removeItem('launch.benefitusage');
    sessionStorage.removeItem('launch.appOpened');
  } catch (e) {}
}

// ── public: permanently delete the signed-in user's account ──────────────────
// Irreversible. Verifies the password by re-authenticating, then calls the
// `delete_account` RPC (SECURITY DEFINER — see supabase/delete-account.sql),
// which removes the auth.users row and cascades EVERY table that references it
// (profiles → submissions → evaluations, habit_logs, posts, follows, points,
// messages, tracks, song_agreements, subscriptions, bookings, credits) plus the
// user's stored files. Finally clears the local session.
// Returns { ok:true } | { error }.
// Which login methods the current user has. { hasPassword, providers }.
// Used by the delete-account sheet to decide whether to ask for a password
// (email/password accounts) or a typed "DELETE" (OAuth-only, e.g. Google).
async function authProviders() {
  if (!isConfigured()) return { hasPassword: true, providers: ['email'] };
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    const ids = (user && user.identities) || [];
    const providers = ids.map(i => i.provider);
    return { hasPassword: providers.includes('email'), providers };
  } catch (e) { return { hasPassword: true, providers: [] }; }
}

async function deleteAccount(password) {
  if (!isConfigured()) {
    // Demo mode: no backend — just clear local state so the UI returns to login.
    await signOut();
    return { ok: true, demo: true };
  }
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user || !user.email) return { error: 'You are not signed in.' };
    // Email/password accounts confirm with their password (re-authenticating
    // validates it without changing anything). OAuth-only accounts (e.g. Google)
    // have NO password, so the client confirms with a typed "DELETE" instead and
    // we skip the password re-auth here.
    const hasPassword = ((user.identities) || []).some(i => i.provider === 'email');
    if (hasPassword) {
      if (!password) return { error: 'Enter your password to confirm.' };
      const { error: pwErr } = await client.auth.signInWithPassword({ email: user.email, password });
      if (pwErr) return { error: 'Incorrect password. Please try again.' };
    }
    // Remove the user's stored files first. Supabase blocks direct SQL deletes on
    // storage.objects, so the delete_account RPC no longer touches storage — we
    // clean the files here via the Storage API (owner-scoped). Best-effort: any
    // bucket without a delete policy just leaves harmless orphaned files.
    try {
      const uid = user.id;
      for (const bucket of ['submissions', 'tracks', 'avatars', 'eval-voice-notes']) {
        try {
          const { data: files } = await client.storage.from(bucket).list(uid, { limit: 100 });
          if (files && files.length) {
            await client.storage.from(bucket).remove(files.map(f => `${uid}/${f.name}`));
          }
        } catch (e) { /* ignore — orphaned files are harmless */ }
      }
    } catch (e) { /* ignore */ }
    // Wipe the account + all data server-side.
    const { error: delErr } = await client.rpc('delete_account');
    if (delErr) return { error: delErr.message || 'Could not delete your account.' };
    // Drop the local session + caches.
    await signOut();
    return { ok: true };
  } catch (e) {
    return { error: String((e && e.message) || e) };
  }
}

// ── hook ──────────────────────────────────────────────────────────────────────
function useUser() {
  const [user, setUser] = useStateU(loadProfile);
  useEffectU(() => {
    let alive = true;
    const refresh = () => { if (alive) setUser(loadProfile()); };

    // In live mode, pull the real row + subscribe to auth changes.
    if (isConfigured()) {
      fetchLiveProfile().then(p => { if (alive && p) setUser(p); });
      const { data: sub } = sb().auth.onAuthStateChange(() => {
        fetchLiveProfile().then(p => { if (alive && p) setUser(p); });
      });
      window.addEventListener('launch.profilechange', refresh);
      return () => {
        alive = false;
        window.removeEventListener('launch.profilechange', refresh);
        try { sub.subscription.unsubscribe(); } catch (e) {}
      };
    }

    // Demo mode: react to local changes.
    window.addEventListener('launch.profilechange', refresh);
    window.addEventListener('storage', refresh);
    return () => {
      alive = false;
      window.removeEventListener('launch.profilechange', refresh);
      window.removeEventListener('storage', refresh);
    };
  }, []);
  return user;
}

// ── public: live leaderboard ─────────────────────────────────────────────────
// Returns competitors ranked by points earned in `week` ('current'|'previous').
// Null in demo mode (caller uses its own seeded list).
// Each row: { handle, initials, color, xp, streak, you }.
async function fetchLeaderboard(limit = 20, week = 'current') {
  if (!isConfigured()) return null;
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    // Preferred: REAL weekly ranking (weekly-leaderboard.sql RPC).
    const wk = await client.rpc('weekly_leaderboard', { p_week: week, p_limit: limit });
    if (!wk.error && wk.data) {
      return wk.data.map(r => ({
        handle: r.handle,
        initials: initialsFrom(r.display_name, r.handle),
        color: colorFor(r.handle),
        genre: r.genre || '',
        xp: num(r.earned, 0),
        streak: num(r.streak, 0),
        delta: '',
        you: user && r.id === user.id,
      }));
    }
    // Fallback: all-time points ranking (leaderboard.sql RPC).
    const rpc = await client.rpc('leaderboard_points', { p_limit: limit });
    if (!rpc.error && rpc.data) {
      return rpc.data.map(r => ({
        handle: r.handle,
        initials: initialsFrom(r.display_name, r.handle),
        color: colorFor(r.handle),
        genre: r.genre || '',
        xp: num(r.earned, 0),
        streak: num(r.streak, 0),
        delta: '',
        you: user && r.id === user.id,
      }));
    }
    // Last resort (no RPCs deployed): old xp_this_week ordering.
    const { data, error } = await client
      .from('profiles')
      .select('id, handle, display_name, genre, xp_this_week, streak, xp_today')
      .order('xp_this_week', { ascending: false })
      .limit(limit);
    if (error || !data) return null;
    return data.map(r => ({
      handle: r.handle,
      initials: initialsFrom(r.display_name, r.handle),
      color: colorFor(r.handle),
      genre: r.genre || '',
      xp: num(r.xp_this_week, 0),
      streak: num(r.streak, 0),
      delta: '+' + Math.max(1, Math.round(num(r.xp_today, 0) / 80)),
      you: user && r.id === user.id,
    }));
  } catch (e) { return null; }
}

// The signed-in member's per-category XP for the week ('current'|'previous').
// Returns [{ action, points }] (real ledger) or null in demo mode.
async function fetchMyWeeklyXp(week = 'current') {
  if (!isConfigured()) return null;
  const client = sb();
  try {
    const { data, error } = await client.rpc('my_weekly_xp', { p_week: week });
    if (error || !data) return null;
    return data.map(r => ({ action: r.action, points: num(r.points, 0) }));
  } catch (e) { return null; }
}

// Current week's [start, end) as ms timestamps, so the UI can count down to the
// Monday-00:00-ET reset. Null in demo mode (UI shows a static countdown).
async function fetchWeekBounds() {
  if (!isConfigured()) return null;
  const client = sb();
  try {
    const { data, error } = await client.rpc('week_bounds');
    if (error || !data) return null;
    const row = Array.isArray(data) ? (data[0] || {}) : data;
    if (!row.w_end) return null;
    return { start: new Date(row.w_start).getTime(), end: new Date(row.w_end).getTime() };
  } catch (e) { return null; }
}

// Weekly prize: was the member top-3 LAST (finished) week, and did they claim?
// Returns { eligible, rank, alreadyClaimed, weekStart, credit } or null in demo.
async function weeklyPrizeStatus() {
  if (!isConfigured()) return null;
  const client = sb();
  try {
    const { data, error } = await client.rpc('weekly_prize_status');
    if (error || !data) return null;
    const r = Array.isArray(data) ? (data[0] || {}) : data;
    return {
      eligible: !!r.eligible,
      rank: r.rank == null ? null : num(r.rank, 0),
      alreadyClaimed: !!r.already_claimed,
      weekStart: r.week_start || null,
      credit: num(r.credit, 0),
    };
  } catch (e) { return null; }
}

// Claim the top-3 prize → grants $40 of review-only credit (once per won week).
// Returns { ok, message, credit } or { error }. Emits creditchange on success.
async function claimWeeklyPrize() {
  if (!isConfigured()) return { error: 'Only available in live mode.' };
  const client = sb();
  try {
    const { data, error } = await client.rpc('claim_weekly_prize');
    if (error) return { error: error.message };
    const r = Array.isArray(data) ? (data[0] || {}) : data;
    if (r && r.ok) {
      try { window.dispatchEvent(new Event('launch.creditchange')); } catch (e) {}
      return { ok: true, credit: num(r.credit, 0) };
    }
    return { ok: false, message: (r && r.message) || 'Could not claim the prize.' };
  } catch (e) { return { error: String(e.message || e) }; }
}

// ── public: community feed (live) ────────────────────────────────────────────
// All of these are no-ops / null in demo mode so the FeedScreen falls back to
// its built-in sample posts. In live mode they read/write the posts, likes and
// comments tables created by supabase/feed.sql.

// Returns an array of feed posts (newest first) or null in demo mode.
// Each row: { id, kind, title, body, tag, audio, cover_pal, cover_lbl,
//             cover_sub, author_handle, author_name, like_count,
//             comment_count, liked_by_me }.
async function fetchFeed(limit = 50) {
  if (!isConfigured()) return null;
  const client = sb();
  try {
    const { data, error } = await client
      .from('feed_posts')
      .select('*')
      .limit(limit);
    if (error || !data) return null;
    return data;
  } catch (e) { return null; }
}

// Toggle the current user's like on a post. Returns { liked } or { error }.
async function togglePostLike(postId, currentlyLiked) {
  if (!isConfigured()) return { liked: !currentlyLiked };
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return { error: 'Not signed in' };
    if (currentlyLiked) {
      const { error } = await client.from('likes')
        .delete().eq('post_id', postId).eq('user_id', user.id);
      if (error) return { error: error.message };
      return { liked: false };
    } else {
      const { error } = await client.from('likes')
        .insert({ post_id: postId, user_id: user.id });
      if (error) return { error: error.message };
      return { liked: true };
    }
  } catch (e) { return { error: 'Like failed' }; }
}

// Fetch comments for a post (oldest first). Returns array or null in demo mode.
// Each row: { id, body, created_at, author_handle, author_name }.
async function fetchComments(postId) {
  if (!isConfigured()) return null;
  const client = sb();
  try {
    const { data, error } = await client
      .from('feed_comments')
      .select('id, body, created_at, author_handle, author_name')
      .eq('post_id', postId)
      .order('created_at', { ascending: true });
    if (error || !data) return null;
    return data.map(c => ({
      id: c.id, body: c.body, created_at: c.created_at,
      author_handle: c.author_handle || '',
      author_name: c.author_name || '',
    }));
  } catch (e) { return null; }
}

// Add a comment. Returns { comment } or { error }.
async function addComment(postId, body) {
  if (!isConfigured()) return { comment: { body, author_handle: 'you' } };
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return { error: 'Not signed in' };
    const { data, error } = await client.from('comments')
      .insert({ post_id: postId, author_id: user.id, body })
      .select('id, body, created_at')
      .single();
    if (error) return { error: error.message };
    return { comment: data };
  } catch (e) { return { error: 'Comment failed' }; }
}

// Create a post. Returns { post } or { error }.
async function createPost(post) {
  if (!isConfigured()) return { post };
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return { error: 'Not signed in' };
    const { data, error } = await client.from('posts')
      .insert({ author_id: user.id, ...post })
      .select('*').single();
    if (error) return { error: error.message };
    return { post: data };
  } catch (e) { return { error: 'Post failed' }; }
}

// ── public: peer tracks + ratings ────────────────────────────────────────────
// A member uploads an unreleased song (audio + title) as a feed post
// (kind='track'); any OTHER member rates it on the SAME /50 rubric as the $11
// evaluation. +10 pts to the uploader, +10 to each rater. This is separate from
// coach evaluations. Live → posts + `tracks` storage bucket + track_ratings
// (see supabase/tracks.sql). Demo → local-only so the preview still works.

// ── public: profile photo (avatar) ───────────────────────────────────────────
// Public bucket `avatars` (see supabase/avatars.sql). One photo per user, stored
// at avatars/<uid>/avatar-<ts>.<ext>; profiles.avatar_path remembers it.

// Build the public URL for a stored avatar path. Passes through full URLs / data
// URLs untouched (demo mode stores a data URL).
function avatarUrlFor(path) {
  if (!path) return null;
  if (/^(https?:|data:|blob:)/.test(path)) return path;
  const base = (window.LAUNCH_CONFIG && window.LAUNCH_CONFIG.SUPABASE_URL) || '';
  return base ? base + '/storage/v1/object/public/avatars/' + path : null;
}

// Upload (or replace) the signed-in user's profile photo. Returns { url } or { error }.
async function uploadAvatar(file) {
  if (!file) return { error: 'Pick an image first.' };
  if (!String(file.type || '').startsWith('image/')) return { error: 'That file is not an image.' };
  if (file.size > 5 * 1024 * 1024) return { error: 'Image too large (max 5 MB).' };

  // Demo / not configured: keep a local data URL so the preview still works.
  if (!isConfigured()) {
    return await new Promise((resolve) => {
      const r = new FileReader();
      r.onload = (ev) => { const url = ev.target.result; _persistAvatar(url, url); resolve({ url, demo: true }); };
      r.onerror = () => resolve({ error: 'Could not read that image.' });
      r.readAsDataURL(file);
    });
  }

  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return { error: 'Sign in to set a photo.' };
    const ext = ((file.name && file.name.split('.').pop()) || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '') || 'jpg';
    const path = user.id + '/avatar-' + Date.now() + '.' + ext;
    const up = await client.storage.from('avatars').upload(path, file, { upsert: true, contentType: file.type || undefined });
    if (up.error) return { error: 'Upload failed: ' + up.error.message };
    const { error: dbErr } = await client.from('profiles').update({ avatar_path: up.data.path }).eq('id', user.id);
    if (dbErr) return { error: 'Saved the file but could not update your profile: ' + dbErr.message };
    const url = avatarUrlFor(up.data.path);
    _persistAvatar(url, up.data.path);
    return { url };
  } catch (e) { return { error: String((e && e.message) || e) }; }
}

// Remove the signed-in user's photo (back to initials). Returns { ok } or { error }.
async function removeAvatar() {
  if (!isConfigured()) { _persistAvatar(null, null); return { ok: true, demo: true }; }
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return { error: 'Sign in first.' };
    await client.from('profiles').update({ avatar_path: null }).eq('id', user.id);
    _persistAvatar(null, null);
    return { ok: true };
  } catch (e) { return { error: String((e && e.message) || e) }; }
}

// Persist the avatar locally (so it shows instantly + survives reload) and notify.
function _persistAvatar(url, path) {
  try {
    const existing = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
    existing.avatarUrl = url || undefined;
    existing.avatar_path = path || undefined;
    localStorage.setItem(STORE_KEY, JSON.stringify(existing));
  } catch (e) {}
  _cached = null;
  emitChange();
}

// ── Song Submission Agreement (consent gate before any audio upload) ─────────
// The user must READ the agreement PDF and tick "I agree" before they can
// upload a track for evaluation. We persist the acceptance in Supabase
// (`song_agreements`) so we have proof they consented to how their audio is
// handled. Bump SONG_AGREEMENT_VERSION whenever the document changes — that
// re-prompts everyone for fresh consent.
const SONG_AGREEMENT_VERSION = '2026-06-29';
const SONG_AGREEMENT_URL = 'documents/Song_Submission_Agreement_Launch.pdf';
const SONG_AGREEMENT_KEY = 'launch.songagreement';   // demo / offline cache

function _localAgreedVersion() {
  try { return localStorage.getItem(SONG_AGREEMENT_KEY) || null; } catch (e) { return null; }
}
function _setLocalAgreed(version) {
  try { localStorage.setItem(SONG_AGREEMENT_KEY, version); } catch (e) {}
}

// Has the signed-in user accepted the CURRENT agreement version?
// → true | false. Falls back to the local cache in demo / on error.
async function hasAcceptedSongAgreement() {
  if (_localAgreedVersion() === SONG_AGREEMENT_VERSION) return true;
  if (!isConfigured()) return false;
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return false;
    const { data, error } = await client
      .from('song_agreements')
      .select('version')
      .eq('user_id', user.id)
      .eq('version', SONG_AGREEMENT_VERSION)
      .maybeSingle();
    if (error) return false;
    if (data) { _setLocalAgreed(SONG_AGREEMENT_VERSION); return true; }
    return false;
  } catch (e) { return false; }
}

// Record that the user read + agreed to the current agreement. Returns { ok }
// or { error }. Idempotent (unique on user_id+version → duplicate is fine).
async function acceptSongAgreement() {
  if (!isConfigured()) { _setLocalAgreed(SONG_AGREEMENT_VERSION); return { ok: true, demo: true }; }
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return { error: 'Sign in first.' };
    const ua = (typeof navigator !== 'undefined' && navigator.userAgent) ? navigator.userAgent.slice(0, 400) : null;
    const { error } = await client
      .from('song_agreements')
      .upsert({ user_id: user.id, version: SONG_AGREEMENT_VERSION, user_agent: ua },
              { onConflict: 'user_id,version', ignoreDuplicates: true });
    // A duplicate (already accepted this version) is success, not an error.
    if (error && !/duplicate|unique|conflict/i.test(error.message)) return { error: error.message };
    _setLocalAgreed(SONG_AGREEMENT_VERSION);
    return { ok: true };
  } catch (e) { return { error: String((e && e.message) || e) }; }
}

async function uploadTrack(opts) {
  const { title, audioFile } = opts || {};
  if (!title || !String(title).trim()) return { error: 'Give your song a title.' };
  // Consent gate: no agreement, no upload.
  if (!(await hasAcceptedSongAgreement())) return { error: 'AGREEMENT_REQUIRED' };
  if (isConfigured()) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (!user) return { error: 'Sign in to upload.' };
      let audio_path = null;
      if (audioFile) {
        const ext = ((audioFile.name && audioFile.name.split('.').pop()) || 'mp3').toLowerCase();
        const path = user.id + '/' + Date.now() + '.' + ext;
        const up = await client.storage.from('tracks')
          .upload(path, audioFile, { upsert: false, contentType: audioFile.type || undefined });
        if (up.error) return { error: 'Upload failed: ' + up.error.message };
        audio_path = up.data.path;
      }
      const { data, error } = await client.from('posts').insert({
        author_id: user.id, kind: 'track', title: String(title).trim(),
        body: '', audio: true, audio_path,
        agreement_version: SONG_AGREEMENT_VERSION, agreed_at: new Date().toISOString(),
      }).select('*').single();
      if (error) return { error: error.message };
      try { await awardPoints('track', { meta: { postId: data.id } }); } catch (e) {}
      return { post: data };
    } catch (e) { return { error: String(e.message || e) }; }
  }
  // demo: no storage — award + return a local post shell
  try { await awardPoints('track'); } catch (e) {}
  return { post: { id: 'demo-track-' + Date.now(), kind: 'track', title: String(title).trim(),
    audio: true, audio_name: audioFile ? audioFile.name : null } };
}

// Submit a peer rating. scores = { structure, hook, idea, lyrics, melody } 1–10.
// Returns { ok, total } or { error }.
async function rateTrack(postId, scores, notes) {
  const total = Object.values(scores || {}).reduce((a, b) => a + (Number(b) || 0), 0);
  if (isConfigured() && !String(postId).startsWith('demo-')) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (!user) return { error: 'Sign in to rate.' };
      const { error } = await client.from('track_ratings').insert({
        post_id: postId, rater_id: user.id, scores: scores || {}, notes: notes || {}, total,
      });
      if (error) {
        if (/duplicate|unique/i.test(error.message)) return { error: 'You already rated this song.' };
        if (/row-level|violates|policy/i.test(error.message)) return { error: "You can't rate your own song." };
        return { error: error.message };
      }
      try { await awardPoints('rate', { meta: { postId } }); } catch (e) {}
      return { ok: true, total };
    } catch (e) { return { error: String(e.message || e) }; }
  }
  // demo
  try { await awardPoints('rate'); } catch (e) {}
  return { ok: true, total };
}

// Per-track rating summary + which tracks the current user already rated.
// → { summary: { [postId]: { votes, avg } }, mine: { [postId]: total } } | null (demo).
async function fetchTrackRatings() {
  if (!isConfigured()) return null;
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    const summary = {};
    const { data: sum } = await client.from('track_rating_summary').select('post_id, votes, avg_total');
    (sum || []).forEach(r => { summary[r.post_id] = { votes: r.votes, avg: r.avg_total }; });
    const mine = {};
    if (user) {
      const { data: mr } = await client.from('track_ratings').select('post_id, total').eq('rater_id', user.id);
      (mr || []).forEach(r => { mine[r.post_id] = r.total; });
    }
    return { summary, mine };
  } catch (e) { return null; }
}

// Short-lived signed URL to stream an uploaded peer track (live only).
async function trackAudioUrl(audioPath) {
  if (!isConfigured() || !audioPath) return null;
  const client = sb();
  try {
    const { data, error } = await client.storage.from('tracks').createSignedUrl(audioPath, 3600);
    return (error || !data) ? null : data.signedUrl;
  } catch (e) { return null; }
}

// Delete an uploaded track. Removes the post row (RLS limits this to its author)
// and its audio file, then takes back the +10 points earned for uploading it.
// Returns { ok } or { error }.
async function deleteTrack(post) {
  const postId = post && (typeof post === 'object' ? post.id : post);
  const audioPath = post && typeof post === 'object' ? post.audioPath : null;
  if (!postId) return { error: 'Nothing to delete.' };
  if (isConfigured() && !String(postId).startsWith('demo-')) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (!user) return { error: 'Sign in to delete.' };
      const { error } = await client.from('posts').delete().eq('id', postId).eq('author_id', user.id);
      if (error) return { error: error.message };
      if (audioPath) { try { await client.storage.from('tracks').remove([audioPath]); } catch (e) {} }
      // Take back the points awarded on upload.
      try { await awardPoints('track', { undo: true, meta: { postId, deleted: true } }); } catch (e) {}
      return { ok: true };
    } catch (e) { return { error: String(e.message || e) }; }
  }
  // demo (or not signed in): just take back the points locally
  try { await awardPoints('track', { undo: true }); } catch (e) {}
  return { ok: true };
}

// ── public: song submissions → coaches ───────────────────────────────────────
// A member submits a song (audio + title + notes) addressed to a coach. Coaches
// read the queue, listen, and post an evaluation. Dual-mode like everything else:
//   • LIVE — Supabase: audio → 'submissions' storage bucket, rows in the
//            `submissions` / `evaluations` tables (see supabase/setup.sql).
//   • DEMO — one shared localStorage list so you can submit as a member and then
//            open the coach queue in the same preview. (Audio can't persist in
//            demo, so only the file name is kept.)
const SUBS_KEY = 'launch.songsubmissions';
function _loadSubs() {
  try { return JSON.parse(localStorage.getItem(SUBS_KEY) || '[]'); } catch (e) { return []; }
}
function _saveSubs(list) {
  try { localStorage.setItem(SUBS_KEY, JSON.stringify(list)); } catch (e) {}
}

// Member submits a song. opts: { title, notes, tier, coach, audioFile (File) }.
// Returns { submission } or { error }.
async function submitSongForReview(opts) {
  const { title, notes, tier, coach, audioFile } = opts || {};
  if (!title || !String(title).trim()) return { error: 'A track title is required.' };
  // Consent gate: no agreement, no upload.
  if (!(await hasAcceptedSongAgreement())) return { error: 'AGREEMENT_REQUIRED' };

  if (isConfigured()) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (!user) return { error: 'Not signed in.' };
      let audio_path = null;
      if (audioFile) {
        const ext = (audioFile.name && audioFile.name.split('.').pop() || 'dat').toLowerCase();
        const path = user.id + '/' + Date.now() + '.' + ext;
        const up = await client.storage.from('submissions')
          .upload(path, audioFile, { upsert: false, contentType: audioFile.type || undefined });
        if (up.error) return { error: 'Upload failed: ' + up.error.message };
        audio_path = up.data.path;
      }
      const { data, error } = await client.from('submissions').insert({
        member_id: user.id,
        coach: coach || null,
        title: String(title).trim(),
        notes: notes || null,
        tier: tier || 'quick',
        audio_path,
        status: 'pending',
        agreement_version: SONG_AGREEMENT_VERSION, agreed_at: new Date().toISOString(),
      }).select('*').single();
      if (error) return { error: error.message };
      try { await awardPoints('song', { meta: { title: String(title).trim() } }); } catch (e) {}
      return { submission: data };
    } catch (e) { return { error: 'Submission failed: ' + (e.message || e) }; }
  }

  // demo: keep a local row (audio can't persist — store the file name only)
  const list = _loadSubs();
  const me = loadProfile();
  const sub = {
    id: 's-' + Date.now(),
    member_id: 'demo',
    member_handle: me.handle || 'you',
    member_name: me.displayName || 'You',
    coach: coach || null,
    title: String(title).trim(),
    notes: notes || null,
    tier: tier || 'quick',
    audio_path: null,
    audio_name: audioFile ? audioFile.name : null,
    status: 'pending',
    created_at: new Date().toISOString(),
  };
  list.unshift(sub);
  _saveSubs(list);
  try { await awardPoints('song', { meta: { title: String(title).trim() } }); } catch (e) {}
  return { submission: sub };
}

// Coach/admin: pending-first queue of submissions. Array (RLS scopes it in live).
async function fetchCoachQueue() {
  if (isConfigured()) {
    const client = sb();
    try {
      const { data, error } = await client
        .from('submissions')
        .select('*, profiles (handle, display_name)')
        .eq('paid', true)   // only tracks the member actually paid for reach a coach
        .order('status', { ascending: true })
        .order('created_at', { ascending: true });
      if (error || !data) return [];
      return data.map(s => ({
        ...s,
        member_handle: s.profiles ? s.profiles.handle : '',
        member_name: s.profiles ? s.profiles.display_name : '',
      }));
    } catch (e) { return []; }
  }
  return _loadSubs();
}

// Member: their own submissions (newest first) with current status.
async function fetchMySubmissions() {
  if (isConfigured()) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (!user) return [];
      const { data, error } = await client
        .from('submissions').select('*')
        .eq('member_id', user.id)
        .order('created_at', { ascending: false });
      if (error || !data) return [];
      return data;
    } catch (e) { return []; }
  }
  return _loadSubs();
}

// ── public: coaches directory (bookable mentors / reviewers) ─────────────────
// Live mode → the admin-managed `coaches` table (see supabase/coaches.sql).
// Demo mode → the built-in two-coach list so the preview works with no backend.
// Consumed by the "PICK YOUR COACH" picker; each entry also carries the email +
// google_calendar_id the scheduling backend will use to book sessions.
const DEMO_COACHES = [
  { id: 'jay', lbl: 'JB', name: 'Jay Brunswick', displayName: 'Jay', email: 'jay@launch.app', role: 'PRODUCER · A&R · VINTAGE MUSIC GROUP', color: '#C72820', palette: ['#C72820', '#7A1A0A'], rate: 100, availability: '2 SLOTS THIS WEEK', googleCalendarId: 'jay@launch.app',
    bio: "RIAA Gold certified songwriter with 170+ cuts and two Billboard Top 40 country singles. Hears arrangement and structure first.",
    credits: ['RIAA Gold certified songwriter', '170+ cuts to his name', '2 Billboard Top 40 country singles'] },
  { id: 'pg', lbl: 'PG', name: 'PG Banker', displayName: 'PG', email: 'pg@launch.app', role: 'SONGWRITER · PRODUCER · LOGIC PRO CERTIFIED', color: '#7A5AE0', palette: ['#7A5AE0', '#150A33'], rate: 100, availability: '4 SLOTS THIS WEEK', googleCalendarId: 'pg@launch.app',
    bio: "Songwriter, Logic Pro certified engineer and producer with 1,000+ placements. Best for sharpening lyrics, topline and production.",
    credits: ['Logic Pro certified engineer', '1,000+ track placements', 'Placed on Fox Sports & major networks'] },
];

// Last successful fetch, cached so screens that need a coach synchronously
// (e.g. the payment label) can resolve one without re-querying.
let _coachesCache = null;

async function fetchCoaches() {
  if (isConfigured()) {
    const client = sb();
    try {
      const { data, error } = await client
        .from('coaches')
        .select('id, lbl, name, display_name, email, role, color, rate, bio, credits, availability, google_calendar_id, booking_url')
        .eq('active', true)
        .order('sort_order', { ascending: true })
        .order('name', { ascending: true });
      if (!error && data && data.length) {
        const mapped = data.map(c => ({
          id: c.id,
          lbl: c.lbl || initialsFrom(c.name, c.email),
          name: c.name,
          displayName: c.display_name || c.name,   // shown only in the PICK YOUR COACH picker
          email: c.email,
          role: c.role || '',
          color: c.color || '#C72820',
          palette: [c.color || '#C72820', '#0A0A0A'],
          rate: num(c.rate, 100),
          bio: c.bio || '',
          credits: c.credits || [],
          availability: c.availability || '',
          googleCalendarId: c.google_calendar_id || c.email,
          bookingUrl: c.booking_url || '',
        }));
        _coachesCache = mapped;
        return mapped;
      }
    } catch (e) { /* fall through to demo list */ }
  }
  _coachesCache = DEMO_COACHES.map(c => ({ ...c }));
  return _coachesCache.map(c => ({ ...c }));
}

// Resolve a coach from a 'mentor-<id>' payment/booking item using the cache.
// Returns null if coaches haven't loaded yet (callers fall back to a default).
function coachByItem(item) {
  const id = String(item || '').replace(/^mentor-/, '');
  const list = _coachesCache || DEMO_COACHES;
  return list.find(c => String(c.id) === id) || null;
}

// A short-lived signed URL to stream a submission's audio (live only).
async function audioUrlFor(audioPath) {
  if (!isConfigured() || !audioPath) return null;
  const client = sb();
  try {
    const { data, error } = await client.storage
      .from('submissions').createSignedUrl(audioPath, 3600);
    return (error || !data) ? null : data.signedUrl;
  } catch (e) { return null; }
}

// A short-lived signed URL to stream a coach's voice note (live only). The
// evaluations SELECT policy + the 'eval-voice-notes' bucket policy together mean
// only the submitting member (and coaches/admins) can mint this URL.
async function evalVoiceUrl(voicePath) {
  if (!isConfigured() || !voicePath) return null;
  const client = sb();
  try {
    const { data, error } = await client.storage
      .from('eval-voice-notes').createSignedUrl(voicePath, 3600);
    return (error || !data) ? null : data.signedUrl;
  } catch (e) { return null; }
}

// Read a recorded Blob into a data URL (demo-mode voice-note persistence).
function blobToDataUrl(blob) {
  return new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onload = () => resolve(r.result);
    r.onerror = () => reject(r.error || new Error('read failed'));
    r.readAsDataURL(blob);
  });
}

// Coach/admin: save an evaluation for a submission and mark it reviewed.
// payload: { scores:{...}, notes:{...}, verdict, total, voiceNote?(Blob), memberId? }.
// A voice note (In-Depth / $40 tier) is stored privately for that member only.
// Returns { ok } or { error }.
async function saveEvaluation(submissionId, payload) {
  const { scores, notes, verdict, total, voiceNote, memberId } = payload || {};
  if (isConfigured()) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (!user) return { error: 'Not signed in.' };
      // Optional coach voice note → private 'eval-voice-notes' bucket, filed
      // under the member's id so RLS lets only that member (+ coaches) play it.
      let voicePath = null;
      if (voiceNote && memberId) {
        const path = `${memberId}/${submissionId}.webm`;
        const up = await client.storage.from('eval-voice-notes')
          .upload(path, voiceNote, { upsert: true, contentType: voiceNote.type || 'audio/webm' });
        if (up.error) return { error: 'Voice note upload failed: ' + up.error.message };
        voicePath = path;
      }
      const { error: insErr } = await client.from('evaluations').insert({
        submission_id: submissionId, coach_id: user.id,
        scores: scores || {}, notes: notes || {},
        verdict: verdict || null, total: total ?? null,
        voice_note_path: voicePath,
      });
      if (insErr) return { error: insErr.message };
      await client.from('submissions').update({ status: 'reviewed' }).eq('id', submissionId);
      return { ok: true };
    } catch (e) { return { error: 'Save failed: ' + (e.message || e) }; }
  }
  // demo: attach the evaluation to the local row (voice note kept as a data URL)
  let voiceUrl = null;
  if (voiceNote) { try { voiceUrl = await blobToDataUrl(voiceNote); } catch (e) {} }
  const next = _loadSubs().map(s =>
    s.id === submissionId
      ? { ...s, status: 'reviewed', evaluation: { scores, notes, verdict, total, voiceUrl } }
      : s);
  _saveSubs(next);
  return { ok: true };
}

// Coach directory — maps the label stored on a submission ('JB'/'PG') to the
// reviewer identity the evaluation screens render.
const COACHES = {
  JB: { lbl: 'JB', name: 'Jay Brunswick', role: 'PROD · A&R VINTAGE MUSIC GROUP', color: '#C72820' },
  PG: { lbl: 'PG', name: 'PG Banker',     role: 'SONGWRITER · NASHVILLE',          color: '#7A5AE0' },
};
function fmtEvalDate(ts) {
  try {
    return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }).toUpperCase();
  } catch (e) { return ''; }
}
function tierLabel(t) {
  return t === 'full' ? 'IN-DEPTH · $40' : t === 'ar' ? 'A&R PANEL · $149' : 'QUICK SCORE · $11';
}
// Shape an evaluation (+ its submission) into what evaluations.jsx renders.
function mapEval(row, sub) {
  const coach = COACHES[(sub && sub.coach) || 'JB'] || COACHES.JB;
  const title = String((sub && sub.title) || 'UNTITLED').toUpperCase();
  const letters = title.replace(/[^A-Z]/g, '');
  return {
    id: row.id,
    track: title,
    coverPal: [coach.color, '#FBBF24'],
    coverLbl: letters.slice(0, 2) || 'LN',
    submitted: fmtEvalDate(row.created_at),
    reviewer: coach,
    tier: tierLabel(sub && sub.tier),
    scores: row.scores || {},
    notes: row.notes || {},
    verdict: row.verdict || '',
    voicePath: row.voice_note_path || null,   // live: signed on demand for the member
    voiceUrl: row.voiceUrl || null,            // demo: inline data URL
  };
}

// Coach/admin: one artist's full submission history with scores, newest first.
// Each row: { id, title, tier, status, created_at, audio_path, total, scores, verdict }.
async function fetchArtistHistory(memberId) {
  if (!memberId) return [];
  if (isConfigured()) {
    const client = sb();
    try {
      const { data, error } = await client
        .from('submissions')
        .select('id, title, tier, status, created_at, audio_path, evaluations ( total, scores, notes, verdict, created_at )')
        .eq('member_id', memberId)
        .order('created_at', { ascending: false });
      if (error || !data) return [];
      return data.map(s => {
        const ev = (Array.isArray(s.evaluations) && s.evaluations.length) ? s.evaluations[0] : null;
        return {
          id: s.id, title: s.title, tier: s.tier, status: s.status,
          created_at: s.created_at, audio_path: s.audio_path,
          total: ev ? ev.total : null,
          scores: ev ? ev.scores : null,
          verdict: ev ? ev.verdict : '',
        };
      });
    } catch (e) { return []; }
  }
  // demo: local submissions (no per-member split in demo) with any attached eval.
  return _loadSubs().map(s => ({
    id: s.id, title: s.title, tier: s.tier, status: s.status,
    created_at: s.created_at || s.ts, audio_path: null,
    total: s.evaluation ? s.evaluation.total : null,
    scores: s.evaluation ? s.evaluation.scores : null,
    verdict: s.evaluation ? s.evaluation.verdict : '',
  }));
}

// Member: their completed coach evaluations, newest first. [] when none.
async function fetchMyEvaluations() {
  if (isConfigured()) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (!user) return [];
      const { data, error } = await client
        .from('evaluations')
        .select('*, submissions!inner (title, coach, tier, member_id, created_at)')
        .eq('submissions.member_id', user.id)
        .order('created_at', { ascending: false });
      if (error || !data) return [];
      return data.map(r => mapEval(r, r.submissions));
    } catch (e) { return []; }
  }
  // demo: read evaluations attached to local submissions
  return _loadSubs()
    .filter(s => s.evaluation)
    .map(s => mapEval({
      id: s.id,
      scores: s.evaluation.scores, notes: s.evaluation.notes,
      verdict: s.evaluation.verdict, total: s.evaluation.total,
      voiceUrl: s.evaluation.voiceUrl,
      created_at: s.created_at,
    }, s));
}

// ── public: Stripe checkout (live only) ──────────────────────────────────────
// Redirects the browser to a Stripe Checkout Session created by the
// `create-checkout` Edge Function. Returns { demo:true } in demo mode (caller
// keeps its simulated flow) or { error } / { redirecting:true }.
async function startCheckout({ item, submissionId, bookingId }) {
  if (!isConfigured()) return { demo: true };
  const client = sb();
  try {
    const base = window.location.origin + window.location.pathname;
    const { data, error } = await client.functions.invoke('create-checkout', {
      body: {
        item,
        submissionId: submissionId || '',
        bookingId: bookingId || '',
        success_url: base + '?checkout=success',
        cancel_url: base + '?checkout=cancel',
      },
    });
    if (error) return { error: error.message || 'Checkout request failed.' };
    if (data && data.error) return { error: data.error };
    // Session fully covered by account credit → no Stripe redirect needed.
    if (data && data.done) return { done: true, freebie: !!data.freebie };
    if (data && data.url) { window.location.href = data.url; return { redirecting: true }; }
    return { error: 'No checkout URL returned.' };
  } catch (e) { return { error: String(e.message || e) }; }
}

// Called on return from Stripe to verify + record the payment server-side.
async function confirmCheckout(sessionId) {
  if (!isConfigured() || !sessionId) return { demo: true };
  const client = sb();
  try {
    const { data, error } = await client.functions.invoke('confirm-checkout', {
      body: { session_id: sessionId },
    });
    if (error) return { error: error.message };
    return data || {};
  } catch (e) { return { error: String(e.message || e) }; }
}

// ── public: request a 1:1 session → creates a PENDING booking ────────────────
// The Google Calendar event is NOT created here — it's created after payment is
// verified (confirm-checkout → schedule-session). This just records the intent
// so the booking id can travel through Stripe. payload: { coachId, startsAt(ISO),
// durationMin, topic }. Returns { booking } (with id) or { error }.
async function requestBooking({ coachId, startsAt, durationMin, topic }) {
  if (!isConfigured()) {
    // Demo: no backend — pretend a pending booking exists so the flow continues.
    return { ok: true, demo: true, booking: { id: 'demo-booking' } };
  }
  if (!coachId || !startsAt) return { error: 'Pick a coach and a time first.' };
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return { error: 'Sign in to book a session.' };
    const dur = durationMin || 60;
    const start = new Date(startsAt);
    const end = new Date(start.getTime() + dur * 60 * 1000);
    const { data, error } = await client.from('bookings').insert({
      member_id: user.id,
      coach_id: coachId,
      starts_at: start.toISOString(),
      ends_at: end.toISOString(),
      duration_min: dur,
      topic: topic || null,
      status: 'pending',
    }).select().single();
    if (error) return { error: error.message };
    return { ok: true, booking: data };
  } catch (e) { return { error: String(e.message || e) }; }
}

// Real busy intervals for a coach from Google Calendar (freebusy). LIVE → the
// coach-availability Edge Function. DEMO / no data → null so the booking grid
// keeps its simulated busy blocks. Returns [{ start, end }] (ISO strings).
async function fetchCoachAvailability(coachId, timeMin, timeMax) {
  if (!isConfigured() || !coachId) return null;
  const client = sb();
  try {
    const { data, error } = await client.functions.invoke('coach-availability', {
      body: { coachId, timeMin, timeMax },
    });
    if (error || !data || data.error) return null;
    return Array.isArray(data.busy) ? data.busy : null;
  } catch (e) { return null; }
}

// Cancel a booked session. Enforced server-side to be ≥24h before the start.
// LIVE → cancel-session (deletes the Google event + marks cancelled).
// DEMO → simulated. Returns { ok } or { error } (with the server's message).
async function cancelBooking(bookingId, resolution) {
  if (!isConfigured()) return { ok: true, demo: true, resolution: resolution || 'refund' };
  if (!bookingId) return { error: 'Missing booking.' };
  const client = sb();
  try {
    const { data, error } = await client.functions.invoke('cancel-session', {
      body: { bookingId, resolution: resolution || 'refund' },
    });
    if (error) {
      // Surface the function's error message (e.g. the 24h rule) if present.
      try { const b = await error.context.json(); if (b && b.error) return { error: b.error }; } catch (e) {}
      return { error: error.message || 'Could not cancel the session.' };
    }
    if (data && data.error) return { error: data.error };
    return data || { ok: true };
  } catch (e) { return { error: String(e.message || e) }; }
}

// Member's account credit balance, in cents (0 in demo / when none).
async function fetchMyCredit() {
  if (!isConfigured()) return 0;
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return 0;
    const { data } = await client.from('profiles').select('credit_cents').eq('id', user.id).single();
    return Math.max(0, Number((data && data.credit_cents) || 0));
  } catch (e) { return 0; }
}

// Review-only credit (cents) from redeemed points — usable ONLY for the $40
// coach review, never for sessions. 0 in demo / when none.
async function fetchMyReviewCredit() {
  if (!isConfigured()) return 0;
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return 0;
    const { data } = await client.from('profiles').select('review_credit_cents').eq('id', user.id).single();
    return Math.max(0, Number((data && data.review_credit_cents) || 0));
  } catch (e) { return 0; }
}

// Member: their upcoming/past sessions (newest first). Null in demo mode so the
// caller can fall back to its own placeholder.
async function fetchMyBookings() {
  if (!isConfigured()) return null;
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return [];
    const { data, error } = await client
      .from('bookings')
      .select('*, coaches (name, lbl, color)')
      .eq('member_id', user.id)
      .order('starts_at', { ascending: true });
    if (error || !data) return [];
    return data;
  } catch (e) { return []; }
}

// ── public: songwriting habit log ────────────────────────────────────────────
// A map { 'YYYY-MM-DD': 1 } of days the user marked. LIVE → the `habit_logs`
// table (one row per marked day). DEMO → a shared localStorage object so the
// preview keeps working offline.
const HABIT_LS_KEY = 'launch.habit';
function _loadHabitLS() {
  try {
    const r = JSON.parse(localStorage.getItem(HABIT_LS_KEY) || 'null');
    if (r && typeof r === 'object' && !Array.isArray(r)) return r;
  } catch (e) {}
  return {};
}
function _saveHabitLS(map) {
  try { localStorage.setItem(HABIT_LS_KEY, JSON.stringify(map)); } catch (e) {}
}

async function fetchHabitLog() {
  if (isConfigured()) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (user) {
        const { data, error } = await client.from('habit_logs').select('day').eq('user_id', user.id);
        if (!error && data) {
          const map = {};
          data.forEach(r => { map[r.day] = 1; });
          _saveHabitLS(map);   // keep a local cache in sync
          return map;
        }
      }
    } catch (e) {}
    // not signed in / query failed → fall back to the local copy so marks
    // never just vanish.
    return _loadHabitLS();
  }
  return _loadHabitLS();
}

// Mark (on=true) or clear (on=false) a single day. dayKey is 'YYYY-MM-DD'.
// Writes the local copy FIRST (instant, never vanishes) then mirrors to Supabase
// when signed in. Returns { ok } or { error } (the error is advisory — the local
// copy already holds the change).
async function setHabitDay(dayKey, on) {
  const local = _loadHabitLS();
  if (on) local[dayKey] = 1; else delete local[dayKey];
  _saveHabitLS(local);

  if (isConfigured()) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (!user) return { error: 'Not signed in — saved locally only.' };
      if (on) {
        const { error } = await client.from('habit_logs').upsert({ user_id: user.id, day: dayKey });
        if (error) return { error: error.message };
      } else {
        const { error } = await client.from('habit_logs').delete().eq('user_id', user.id).eq('day', dayKey);
        if (error) return { error: error.message };
      }
      return { ok: true };
    } catch (e) { return { error: String(e.message || e) }; }
  }
  return { ok: true };
}

// ── public: community points economy ────────────────────────────────────────
// Members earn points for community actions and redeem them for a $40 song
// review. Dual-mode like everything else:
//   • LIVE — Supabase: one row per earn in `points_ledger`, one row per redeem
//            in `redemptions`; balance = earned − spent (see supabase/points.sql).
//   • DEMO — a single localStorage object so the preview works with no backend.
//
// Earn rules (per the product spec):
//   like a post        → +1
//   comment on a post  → +2
//   share a post       → +5   (a post that "aporta al grupo")
//   submit a song      → +10
const POINTS_RULES = { like: 1, comment: 2, post: 5, song: 10, track: 10, rate: 10 };
const POINTS_LABELS = {
  like: 'Liked a post',
  comment: 'Commented on a post',
  post: 'Shared a post',
  song: 'Submitted a song for review',
  track: 'Uploaded an unreleased song',
  rate: 'Rated a song',
  redeem: 'Redeemed a $40 review',
};
const REVIEW_COST = 300;            // points needed to redeem one $40 review
const REVIEW_CREDIT_CENTS = 4000;  // $40 account credit granted on redeem (live)
const POINTS_KEY = 'launch.points';

function _emptyPoints() { return { balance: 0, earned: 0, spent: 0, history: [] }; }
function _loadPointsLS() {
  try {
    const r = JSON.parse(localStorage.getItem(POINTS_KEY) || 'null');
    if (r && typeof r === 'object' && !Array.isArray(r)) return { ..._emptyPoints(), ...r };
  } catch (e) {}
  return _emptyPoints();
}
function _savePointsLS(p) {
  try { localStorage.setItem(POINTS_KEY, JSON.stringify(p)); } catch (e) {}
}
let _pointsCache = null;

// Sync best-known points (cache). In live mode refreshPoints() updates it.
function loadPoints() {
  if (_pointsCache) return _pointsCache;
  _pointsCache = _loadPointsLS();
  return _pointsCache;
}
function pointsRules() { return { ...POINTS_RULES }; }
function reviewCost() { return REVIEW_COST; }
function emitPointsChange() {
  try { window.dispatchEvent(new Event('launch.pointschange')); } catch (e) {}
}

// LIVE: recompute the balance from the ledger + redemptions via an RPC.
async function refreshPoints() {
  if (!isConfigured()) { _pointsCache = _loadPointsLS(); return _pointsCache; }
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) { _pointsCache = _loadPointsLS(); return _pointsCache; }
    const { data, error } = await client.rpc('my_points_balance');
    if (!error && data) {
      const row = Array.isArray(data) ? (data[0] || {}) : data;
      _pointsCache = {
        balance: num(row.balance, 0),
        earned: num(row.earned, 0),
        spent: num(row.spent, 0),
        history: (_pointsCache && _pointsCache.history) || [],
      };
    }
  } catch (e) {}
  return _pointsCache || _emptyPoints();
}

// Award (or, with opts.undo, take back) points for a community action.
// action ∈ like | comment | post | song. Returns the new points object.
async function awardPoints(action, opts) {
  const o = opts || {};
  const base = POINTS_RULES[action] || 0;
  const pts = (o.undo ? -1 : 1) * base;
  if (!pts) return loadPoints();

  if (isConfigured()) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (user) {
        await client.from('points_ledger').insert({
          user_id: user.id, action, points: pts, meta: o.meta || null,
        });
        await refreshPoints();
        emitPointsChange();
        return _pointsCache;
      }
    } catch (e) { /* fall through to local so points never just vanish */ }
  }
  // demo (or not signed in): keep a local running balance
  const p = _loadPointsLS();
  p.balance = Math.max(0, num(p.balance, 0) + pts);
  p.earned = Math.max(0, num(p.earned, 0) + pts);
  if (pts > 0) {
    p.history = [{ action, points: pts, label: POINTS_LABELS[action] || action, ts: new Date().toISOString() }]
      .concat(p.history || []).slice(0, 50);
  }
  _savePointsLS(p);
  _pointsCache = p;
  emitPointsChange();
  return p;
}

// Redeem the current balance for a $40 review. Visual flow for now — it records
// the redemption and debits the points, but doesn't move real money yet.
// Returns { ok, redemption } or { error }.
async function redeemReview() {
  const cost = REVIEW_COST;
  if (isConfigured()) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (user) {
        // Secure server-side redeem: validates the balance, records the
        // redemption AND grants $40 of real account credit, all atomically.
        const { data, error } = await client.rpc('redeem_review');
        if (error) return { error: error.message };
        const row = Array.isArray(data) ? (data[0] || {}) : (data || {});
        if (!row.ok) return { error: row.message || "Couldn't redeem." };
        await refreshPoints();
        emitPointsChange();
        try { window.dispatchEvent(new Event('launch.creditchange')); } catch (e) {}
        return {
          ok: true, redemption: { kind: 'review', cost_points: cost, status: 'granted' },
          creditCents: num(row.credit, 0), grantedCents: REVIEW_CREDIT_CENTS,
        };
      }
    } catch (e) { return { error: String(e.message || e) }; }
  }
  const p = _loadPointsLS();
  if (num(p.balance, 0) < cost) return { error: "You don't have enough points yet." };
  p.balance = num(p.balance, 0) - cost;
  p.spent = num(p.spent, 0) + cost;
  p.history = [{ action: 'redeem', points: -cost, label: POINTS_LABELS.redeem, ts: new Date().toISOString() }]
    .concat(p.history || []).slice(0, 50);
  _savePointsLS(p);
  _pointsCache = p;
  emitPointsChange();
  return { ok: true, redemption: { kind: 'review', cost_points: cost, status: 'granted' } };
}

// Pay a review submission using account credit (from redeemed points) — no Stripe.
// Returns { ok, covered, credit } or { ok:false, error }. In demo mode it just
// reports covered so the flow completes.
async function payReviewWithCredit(submissionId, amountCents) {
  if (!isConfigured()) return { ok: true, covered: true };
  if (!submissionId || !amountCents) return { ok: false, error: 'Missing submission/amount.' };
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return { ok: false, error: 'Not signed in' };
    const { data, error } = await client.rpc('pay_review_with_credit', {
      p_submission_id: submissionId, p_amount_cents: amountCents,
    });
    if (error) return { ok: false, error: error.message };
    const row = Array.isArray(data) ? (data[0] || {}) : (data || {});
    if (row.ok) { try { window.dispatchEvent(new Event('launch.creditchange')); } catch (e) {} }
    return { ok: !!row.ok, covered: !!row.covered, error: row.ok ? null : row.message, credit: num(row.credit, 0) };
  } catch (e) { return { ok: false, error: String(e.message || e) }; }
}

// React hook → the current points object, reactive to earns/redeems.
function usePoints() {
  const [pts, setPts] = useStateU(loadPoints);
  useEffectU(() => {
    let alive = true;
    const refresh = () => { if (alive) setPts({ ...loadPoints() }); };
    if (isConfigured()) {
      refreshPoints().then(() => { if (alive) setPts({ ...(_pointsCache || loadPoints()) }); });
    }
    window.addEventListener('launch.pointschange', refresh);
    window.addEventListener('storage', refresh);
    return () => {
      alive = false;
      window.removeEventListener('launch.pointschange', refresh);
      window.removeEventListener('storage', refresh);
    };
  }, []);
  return pts;
}

// ── public: direct messages + presence (live) ───────────────────────────────
// Real 1:1 chat backed by the `messages` table (see supabase/messages.sql) plus
// Supabase Realtime Presence for online status. All of these return null / no-op
// in demo mode so social.jsx keeps its built-in sample threads + members.
//
// Shapes are kept identical to the demo data so the UI needs no special-casing:
//   member: { id, handle, name, lbl, color, pal, bio, tier, city, genres,
//             followers, following, releases, streak, online }
//   thread: { id:<handle>, member:<handle>, otherId, unread, lastTime,
//             messages:[{ from:'me'|'them', t, ts }] }

function fmtMsgTime(iso) {
  try {
    return new Date(iso).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
  } catch (e) { return 'now'; }
}

// Map a profiles row → the member shape the chat/profile screens expect.
function mapMember(r) {
  const name = r.display_name || nameFromHandle(r.handle);
  const color = colorFor(r.handle);
  return {
    id: r.id, handle: r.handle, name,
    lbl: initialsFrom(name, r.handle),
    color, pal: [color, '#0A0A0A'],
    avatarUrl: avatarUrlFor(r.avatar_path),
    bio: r.bio || '',
    tier: r.tier || 'ROOKIE',
    city: '',
    genres: r.genre ? [String(r.genre).toUpperCase()] : [],
    followers: num(r.followers, 0), following: num(r.following_count, 0),
    releases: num(r.releases, 0), streak: num(r.streak, 0),
    online: false,
  };
}

const MEMBER_COLS_BASE = 'id, handle, display_name, bio, tier, genre, followers, following_count, streak, releases';
const MEMBER_COLS = MEMBER_COLS_BASE + ', avatar_path';

// Whether the avatar_path column exists. Starts assumed-yes; flips to false the
// first time a query fails because the column isn't there yet (SQL not run), so
// the app keeps working before/after supabase/avatars.sql is applied.
let _hasAvatarCol = true;
function _memberCols() { return _hasAvatarCol ? MEMBER_COLS : MEMBER_COLS_BASE; }

// Directory of other members (handle → member), from profiles. Null in demo.
async function fetchMembersDirectory(limit = 200) {
  if (!isConfigured()) return null;
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    let { data, error } = await client.from('profiles').select(_memberCols()).limit(limit);
    // avatar_path column missing yet → retry without it (and remember).
    if (error && /avatar_path/.test(error.message || '')) {
      _hasAvatarCol = false;
      ({ data, error } = await client.from('profiles').select(MEMBER_COLS_BASE).limit(limit));
    }
    if (error || !data) return null;
    const dir = {};
    data.forEach(r => {
      if (user && r.id === user.id) return;   // don't list yourself
      dir[r.handle] = mapMember(r);
    });
    return dir;
  } catch (e) { return null; }
}

// Look up profiles by id → { [id]: row }.
async function _profilesByIds(ids) {
  const out = {};
  if (!ids || !ids.length) return out;
  try {
    let { data, error } = await sb().from('profiles').select(_memberCols()).in('id', ids);
    if (error && /avatar_path/.test(error.message || '')) {
      _hasAvatarCol = false;
      ({ data } = await sb().from('profiles').select(MEMBER_COLS_BASE).in('id', ids));
    }
    (data || []).forEach(r => { out[r.id] = r; });
  } catch (e) {}
  return out;
}

// Conversations (newest activity first-ish), grouped by the other participant.
// Null in demo mode. Each thread carries otherId so sends can target a real user.
async function fetchThreads() {
  if (!isConfigured()) return null;
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return null;
    const { data, error } = await client.from('messages')
      .select('id, sender_id, recipient_id, body, read_at, created_at')
      .or(`sender_id.eq.${user.id},recipient_id.eq.${user.id}`)
      .order('created_at', { ascending: true })
      .limit(500);
    if (error || !data) return [];
    const otherIds = [...new Set(data.map(m => m.sender_id === user.id ? m.recipient_id : m.sender_id))];
    const profMap = await _profilesByIds(otherIds);
    const byHandle = {};
    data.forEach(m => {
      const otherId = m.sender_id === user.id ? m.recipient_id : m.sender_id;
      const prof = profMap[otherId];
      const handle = prof ? prof.handle : otherId;
      if (!byHandle[handle]) byHandle[handle] = { id: handle, member: handle, otherId, unread: 0, lastTime: '', messages: [] };
      const th = byHandle[handle];
      th.messages.push({ from: m.sender_id === user.id ? 'me' : 'them', t: m.body, ts: fmtMsgTime(m.created_at) });
      th.lastTime = fmtMsgTime(m.created_at).toUpperCase();
      if (m.recipient_id === user.id && !m.read_at) th.unread++;
    });
    return Object.values(byHandle);
  } catch (e) { return []; }
}

// Send a DM to recipientId. Returns { message } or { error } (no-op in demo).
async function sendMessageLive(recipientId, body) {
  if (!isConfigured()) return { demo: true };
  if (!recipientId || !String(body || '').trim()) return { error: 'Nothing to send.' };
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return { error: 'Not signed in' };
    const { data, error } = await client.from('messages')
      .insert({ sender_id: user.id, recipient_id: recipientId, body: String(body).trim() })
      .select().single();
    if (error) return { error: error.message };
    return { message: data };
  } catch (e) { return { error: String(e.message || e) }; }
}

// Mark every unread message FROM otherId TO me as read. No-op in demo.
async function markThreadRead(otherId) {
  if (!isConfigured() || !otherId) return;
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return;
    await client.from('messages')
      .update({ read_at: new Date().toISOString() })
      .eq('recipient_id', user.id).eq('sender_id', otherId).is('read_at', null);
  } catch (e) {}
}

// Subscribe to INCOMING messages (recipient = me). onInsert(row) per new message.
// Returns an unsubscribe fn. No-op in demo.
function subscribeMessages(onInsert) {
  if (!isConfigured()) return () => {};
  const client = sb();
  let channel = null;
  (async () => {
    try {
      const { data: { user } } = await client.auth.getUser();
      if (!user) return;
      channel = client.channel('dm-inbox-' + user.id)
        .on('postgres_changes',
          { event: 'INSERT', schema: 'public', table: 'messages', filter: 'recipient_id=eq.' + user.id },
          (payload) => { try { onInsert(payload.new); } catch (e) {} })
        .subscribe();
    } catch (e) {}
  })();
  return () => { try { if (channel) client.removeChannel(channel); } catch (e) {} };
}

// Join the shared presence channel and report the set of online handles.
// onChange(Set<handle>) fires on every sync. Returns a leave fn. No-op in demo.
function joinPresence(onChange) {
  if (!isConfigured()) return () => {};
  const client = sb();
  let channel = null;
  (async () => {
    try {
      const { data: { user } } = await client.auth.getUser();
      const me = loadProfile();
      const myHandle = (me && me.handle) || (user && user.id) || 'anon';
      channel = client.channel('online-users', { config: { presence: { key: myHandle } } });
      channel.on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState();
        // Collect BOTH the handle AND the id of every present user, so the member
        // directory can be matched reliably — handles don't always line up between
        // a user's local profile and the DB, but the auth id always does. The live
        // count is the number of distinct presence keys (= distinct connected users).
        const ids = new Set();
        Object.values(state).forEach(arr => arr.forEach(p => {
          if (p && p.handle) ids.add(p.handle);
          if (p && p.id) ids.add(p.id);
        }));
        onChange(ids, Object.keys(state).length);
      });
      channel.subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          try { await channel.track({ handle: myHandle, id: user ? user.id : null, online_at: new Date().toISOString() }); } catch (e) {}
        }
      });
    } catch (e) {}
  })();
  return () => { try { if (channel) client.removeChannel(channel); } catch (e) {} };
}

// ── public: follow graph (live) ──────────────────────────────────────────────
// Real followers/following backed by the `follows` table (see supabase/follows.sql).
// A trigger keeps profiles.followers / following_count in sync, so the existing
// count reads stay accurate. No-ops / null in demo mode (the app keeps its local
// `following` list and the sample follower numbers).

// Handles the current user follows. Null in demo. → array of handles.
async function fetchFollowing() {
  if (!isConfigured()) return null;
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return null;
    const { data, error } = await client.from('follows').select('following_id').eq('follower_id', user.id);
    if (error || !data) return [];
    const ids = data.map(r => r.following_id);
    const profMap = await _profilesByIds(ids);
    return ids.map(id => (profMap[id] ? profMap[id].handle : id));
  } catch (e) { return []; }
}

// Follow targetId (a profile/auth id). Returns { ok } or { error }. No-op in demo.
async function followUser(targetId) {
  if (!isConfigured()) return { demo: true };
  if (!targetId) return { error: 'No target.' };
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return { error: 'Not signed in' };
    if (user.id === targetId) return { error: "Can't follow yourself" };
    const { error } = await client.from('follows').insert({ follower_id: user.id, following_id: targetId });
    if (error && !/duplicate|unique/i.test(error.message)) return { error: error.message };
    return { ok: true };
  } catch (e) { return { error: String(e.message || e) }; }
}

// Unfollow targetId. Returns { ok } or { error }. No-op in demo.
async function unfollowUser(targetId) {
  if (!isConfigured()) return { demo: true };
  if (!targetId) return { error: 'No target.' };
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) return { error: 'Not signed in' };
    const { error } = await client.from('follows').delete().eq('follower_id', user.id).eq('following_id', targetId);
    if (error) return { error: error.message };
    return { ok: true };
  } catch (e) { return { error: String(e.message || e) }; }
}

// ── public: subscriptions / plans ────────────────────────────────────────────
// free / basic ($9.99) / vip ($19.99), 30-day periods activated on payment.
//   • LIVE — profiles.plan + plan_expires_at + benefit_redemptions, via the
//            subscribe()/my_entitlements()/claim_free_eval()/book_free_class() RPCs.
//   • DEMO — localStorage so the preview works with no backend.
const PLAN_LS_KEY = 'launch.plan';
const BENEFIT_LS_KEY = 'launch.benefitusage';
const PLAN_LIMITS = {
  free:  { quick_eval: 0, full_eval: 0, class: 0 },
  basic: { quick_eval: 1, full_eval: 0, class: 0 },
  vip:   { quick_eval: 0, full_eval: 2, class: 1 },
};
function _loadPlanLS() {
  try { const r = JSON.parse(localStorage.getItem(PLAN_LS_KEY) || 'null'); if (r && typeof r === 'object') return r; } catch (e) {}
  return { plan: 'free', startedAt: null, expiresAt: null };
}
function _savePlanLS(p) { try { localStorage.setItem(PLAN_LS_KEY, JSON.stringify(p)); } catch (e) {} }
function _loadBenefitLS() { try { const r = JSON.parse(localStorage.getItem(BENEFIT_LS_KEY) || '[]'); return Array.isArray(r) ? r : []; } catch (e) { return []; } }
function _saveBenefitLS(l) { try { localStorage.setItem(BENEFIT_LS_KEY, JSON.stringify(l)); } catch (e) {} }

let _planCache = null;
function _emptyEntitlements() { return { plan: 'free', active: false, expiresAt: null, quickEval: 0, fullEval: 0, class: 0 }; }
function loadPlan() { return _planCache || _emptyEntitlements(); }
function emitPlanChange() { try { window.dispatchEvent(new Event('launch.planchange')); } catch (e) {} }

function _demoEntitlements() {
  const p = _loadPlanLS();
  const active = !!(p.expiresAt && new Date(p.expiresAt).getTime() > Date.now());
  const plan = active ? (p.plan || 'free') : 'free';
  const lim = PLAN_LIMITS[plan] || PLAN_LIMITS.free;
  const usage = active ? _loadBenefitLS().filter(u => p.startedAt && new Date(u.ts).getTime() >= new Date(p.startedAt).getTime()) : [];
  const usedOf = (b) => usage.filter(u => u.benefit === b).length;
  return {
    plan, active, expiresAt: active ? p.expiresAt : null,
    quickEval: Math.max(0, lim.quick_eval - usedOf('quick_eval')),
    fullEval: Math.max(0, lim.full_eval - usedOf('full_eval')),
    class: Math.max(0, lim.class - usedOf('class')),
  };
}

// The caller's plan + remaining free benefits this period. Updates _planCache.
async function fetchEntitlements() {
  if (!isConfigured()) { _planCache = _demoEntitlements(); return _planCache; }
  const client = sb();
  try {
    const { data: { user } } = await client.auth.getUser();
    if (!user) { _planCache = _emptyEntitlements(); return _planCache; }
    const { data, error } = await client.rpc('my_entitlements');
    if (!error && data) {
      const r = Array.isArray(data) ? (data[0] || {}) : data;
      _planCache = {
        plan: r.plan || 'free', active: !!r.active, expiresAt: r.expires_at || null,
        quickEval: num(r.quick_eval, 0), fullEval: num(r.full_eval, 0), class: num(r.class, 0),
      };
    }
  } catch (e) {}
  return _planCache || _emptyEntitlements();
}

// Activate a 30-day period for plan ('basic'|'vip'); 'free' cancels.
async function subscribe(plan) {
  if (isConfigured()) {
    const client = sb();
    try {
      const { data: { user } } = await client.auth.getUser();
      if (user) {
        const { error } = await client.rpc('subscribe', { p_plan: plan });
        if (error) return { error: error.message };
        await fetchEntitlements();
        try { await fetchLiveProfile(); } catch (e) {}
        emitPlanChange(); emitChange();
        return { ok: true, plan, entitlements: _planCache };
      }
    } catch (e) { return { error: String(e.message || e) }; }
  }
  // demo
  const now = new Date();
  if (plan !== 'basic' && plan !== 'vip') {
    _savePlanLS({ plan: 'free', startedAt: null, expiresAt: null });
  } else {
    const exp = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
    _savePlanLS({ plan, startedAt: now.toISOString(), expiresAt: exp.toISOString() });
  }
  _planCache = _demoEntitlements();
  emitPlanChange();
  return { ok: true, plan, entitlements: _planCache };
}

// Use a plan's free evaluation on a submission. tier 'quick' (basic) | 'full' (vip).
async function claimFreeEval(submissionId, tier) {
  if (isConfigured() && !String(submissionId).startsWith('demo')) {
    const client = sb();
    try {
      const { data, error } = await client.rpc('claim_free_eval', { p_submission_id: submissionId, p_tier: tier });
      if (error) return { ok: false, error: error.message };
      const r = Array.isArray(data) ? (data[0] || {}) : data;
      if (r.ok) { await fetchEntitlements(); emitPlanChange(); }
      return { ok: !!r.ok, error: r.ok ? null : r.message };
    } catch (e) { return { ok: false, error: String(e.message || e) }; }
  }
  const ent = _demoEntitlements();
  const benefit = tier === 'quick' ? 'quick_eval' : tier === 'full' ? 'full_eval' : null;
  const remaining = benefit === 'quick_eval' ? ent.quickEval : benefit === 'full_eval' ? ent.fullEval : 0;
  if (!benefit || remaining < 1) return { ok: false, error: 'No free evaluations left on your plan.' };
  const list = _loadBenefitLS(); list.push({ benefit, ts: new Date().toISOString(), ref: submissionId }); _saveBenefitLS(list);
  _planCache = _demoEntitlements(); emitPlanChange();
  return { ok: true };
}

// VIP: book a free 20-min class with a coach. Returns { ok, bookingId } or { error }.
async function bookFreeClass(coachId, startsAt, topic) {
  if (isConfigured()) {
    const client = sb();
    try {
      const { data, error } = await client.rpc('book_free_class', { p_coach_id: coachId, p_starts_at: startsAt, p_topic: topic || null });
      if (error) return { ok: false, error: error.message };
      const r = Array.isArray(data) ? (data[0] || {}) : data;
      if (r.ok) { await fetchEntitlements(); emitPlanChange(); }
      return { ok: !!r.ok, error: r.ok ? null : r.message, bookingId: r.booking_id };
    } catch (e) { return { ok: false, error: String(e.message || e) }; }
  }
  const ent = _demoEntitlements();
  if (ent.class < 1) return { ok: false, error: 'No free 20-min classes left on your plan.' };
  const list = _loadBenefitLS(); list.push({ benefit: 'class', ts: new Date().toISOString() }); _saveBenefitLS(list);
  _planCache = _demoEntitlements(); emitPlanChange();
  return { ok: true, bookingId: 'demo-class-' + Date.now() };
}

// React hook → the current plan + entitlements, reactive to subscribe/claim.
function usePlan() {
  const [ent, setEnt] = useStateU(loadPlan);
  useEffectU(() => {
    let alive = true;
    const refresh = () => { fetchEntitlements().then(() => { if (alive) setEnt({ ...(_planCache || _emptyEntitlements()) }); }); };
    refresh();
    window.addEventListener('launch.planchange', refresh);
    window.addEventListener('launch.profilechange', refresh);
    return () => { alive = false; window.removeEventListener('launch.planchange', refresh); window.removeEventListener('launch.profilechange', refresh); };
  }, []);
  return ent;
}

window.LAUNCH_USER = {
  useUser, loadProfile, saveProfile, clearProfile, buildProfile, decorate,
  nameFromHandle, initialsFrom, MAYA, isConfigured,
  subscribe, fetchEntitlements, usePlan, loadPlan, claimFreeEval, bookFreeClass,
  usePoints, loadPoints, awardPoints, redeemReview, refreshPoints, pointsRules, reviewCost,
  reviewCreditCents: () => REVIEW_CREDIT_CENTS, payReviewWithCredit,
  fetchMembersDirectory, fetchThreads, sendMessageLive, markThreadRead, subscribeMessages, joinPresence,
  fetchFollowing, followUser, unfollowUser,
  signUp, signIn, signInWithProvider, completeOAuthSignIn, signOut, deleteAccount, authProviders, fetchLiveProfile, fetchLeaderboard,
  fetchMyWeeklyXp, fetchWeekBounds, weeklyPrizeStatus, claimWeeklyPrize,
  fetchFeed, togglePostLike, fetchComments, addComment, createPost,
  uploadTrack, rateTrack, fetchTrackRatings, trackAudioUrl, deleteTrack,
  hasAcceptedSongAgreement, acceptSongAgreement,
  songAgreementVersion: () => SONG_AGREEMENT_VERSION,
  songAgreementUrl: () => SONG_AGREEMENT_URL,
  uploadAvatar, removeAvatar, avatarUrlFor,
  submitSongForReview, fetchCoachQueue, fetchMySubmissions, fetchCoaches, coachByItem, audioUrlFor, evalVoiceUrl, saveEvaluation,
  fetchMyEvaluations, fetchArtistHistory, startCheckout, confirmCheckout,
  requestBooking, fetchMyBookings, fetchCoachAvailability, cancelBooking, fetchMyCredit, fetchMyReviewCredit,
  fetchHabitLog, setHabitDay,
};
