// Global app store — React state backed by Supabase (via window.db).
// Exposes a Provider + useStore() so screens read/write through one place.
// Optimistic updates everywhere so the UI feels instant.

const StoreContext = React.createContext(null);

const INITIAL = {
  status: 'loading',          // 'loading' | 'signed-out' | 'ready' | 'error'
  error: null,
  user: null,                 // Supabase auth user
  household: null,            // { id, name, owner_id }
  kids: [],                   // array of kid objects (see hydrateFromDb)
  tasks: [],                  // behaviors: { id, kid_id, kind, emoji, label, amount }
  history: [],                // history rows: { id, kid_id, amount, reason, emoji, kind, ts }
  jars: [],                   // reward jars: { id, kid_id, emoji, label, color, goal, current, note, sortOrder }
  settings: { sound: true, speech: true, voice_name: null },
  members: [],                // [{ user_id, email, full_name, avatar_url, role, joined_at }]
  invitations: [],            // [{ id, household_id, invited_email, role, token, status, ... }]
  inviteRedeemed: null,       // { household_name, role } — set after a join succeeds
  inviteError: null,          // string — set if a pending invite failed to redeem
};

// ── Shape adapters ─────────────────────────────────────────────────────────

// Map DB row → in-memory kid the UI expects.
// Theme/avatar map onto the design's `color` and (optional) initials/photo.
function adaptKid(row) {
  // Map our 6 themes (lavender/sky/mint/peach/butter/rose) to the design's 6 colors.
  const themeToColor = {
    lavender: 'periwinkle',
    sky:      'periwinkle',
    mint:     'mint',
    peach:    'mango',
    butter:   'butter',
    rose:     'blush',
    purple:   'periwinkle',
    candy:    'blush',
    ocean:    'periwinkle',
    forest:   'mint',
    sunset:   'mango',
    royal:    'berry',
  };
  // Allow the avatar field to store a design color directly (mango/mint/…)
  const knownColors = ['mango','mint','periwinkle','blush','butter','berry'];
  let color = knownColors.includes(row.theme) ? row.theme :
              (themeToColor[row.theme] || 'mango');
  return {
    id: row.id,
    name: row.name,
    speechName: row.speech_name || row.name,
    initials: (row.name || '?').trim().charAt(0).toUpperCase(),
    avatar: row.avatar || '🌟',
    color,
    theme: row.theme,
    points: row.points || 0,
    goal: row.goal || 50,
    goalLabel: 'their next reward',
    goalAt: row.goal || 50,
    streak: row.streak_daily || 0,
    bestDaily: row.best_daily || 0,
    bestWeekly: row.best_weekly || 0,
    weeklyStreak: row.streak_weekly || 0,
    lastPositiveDate: row.last_positive_date,
    sortOrder: row.sort_order || 0,
    createdAt: row.created_at,
  };
}

function adaptTask(row) {
  return {
    id: row.id,
    kid_id: row.kid_id,
    emoji: row.emoji || '✨',
    label: row.label,
    points: row.kind === 'bad' ? -Math.abs(row.amount) : Math.abs(row.amount),
    kind: row.kind,
    cadence: 'As it happens',
    assignedTo: [row.kid_id],
  };
}

function adaptJar(row) {
  return {
    id: row.id,
    kid_id: row.kid_id,
    emoji: row.emoji || '🏆',
    label: row.label,
    color: row.color || 'mango',
    goal: row.goal || 20,
    current: row.current || 0,
    note: row.note || '',
    sortOrder: row.sort_order || 0,
    createdAt: row.created_at,
  };
}

function adaptHistory(row) {
  const d = new Date(row.created_at);
  return {
    id: row.id,
    kid_id: row.kid_id,
    who: row.kid_id,
    task: row.reason || (row.amount >= 0 ? 'Earned stars' : 'Lost stars'),
    emoji: row.emoji || (row.amount >= 0 ? '🌟' : '💭'),
    delta: row.amount,
    kind: row.kind || (row.amount >= 0 ? 'good' : 'bad'),
    ts: d.getTime(),
    day: formatDay(d),
    when: d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
  };
}

function formatDay(d) {
  const now = new Date();
  if (sameDay(d, now)) return 'Today';
  const y = new Date(now); y.setDate(now.getDate() - 1);
  if (sameDay(d, y)) return 'Yesterday';
  return d.toLocaleDateString([], { weekday: 'short' });
}
function sameDay(a, b) {
  return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}

// ── Store provider ─────────────────────────────────────────────────────────

function StoreProvider({ children }) {
  const [state, setState] = React.useState(INITIAL);
  // Tracks user.id of the currently-running hydrate, so getSession() + onAuthChange
  // don't fire ensureHousehold() in parallel for the same user.
  const hydratingFor = React.useRef(null);

  const patch = React.useCallback((upd) => {
    setState(prev => ({ ...prev, ...(typeof upd === 'function' ? upd(prev) : upd) }));
  }, []);

  // Hydrate the in-memory store from a fresh DB snapshot
  const hydrate = React.useCallback(async (user) => {
    if (hydratingFor.current === user.id) return;
    hydratingFor.current = user.id;
    try {
      const household = await window.db.ensureHousehold(
        (user.user_metadata && user.user_metadata.full_name) || undefined
      );
      const snap = await window.db.loadHouseholdData(household.id);
      const kids    = (snap.kids || []).map(adaptKid).sort((a,b) => a.sortOrder - b.sortOrder);
      const tasks   = (snap.behaviors || []).map(adaptTask);
      const history = (snap.history || []).map(adaptHistory);
      const jars    = (snap.jars || []).map(adaptJar);
      const settings = snap.settings ? {
        sound:  snap.settings.sound !== false,
        speech: snap.settings.speech !== false,
        voice_name: snap.settings.voice_name || null,
      } : INITIAL.settings;
      // Members + invitations are best-effort — don't block hydrate.
      let members = [];
      let invitations = [];
      try { members = await window.db.listHouseholdMembers(household.id); } catch (e) { console.warn('[store] listHouseholdMembers', e); }
      try { invitations = await window.db.listInvitations(household.id); } catch (e) { console.warn('[store] listInvitations', e); }

      // Surface a successful redeem result, if ensureHousehold just consumed one.
      let inviteRedeemed = null;
      let inviteError = null;
      if (typeof sessionStorage !== 'undefined') {
        const err = sessionStorage.getItem('sirisora.lastInviteError');
        if (err) { inviteError = err; sessionStorage.removeItem('sirisora.lastInviteError'); }
        const ok = sessionStorage.getItem('sirisora.lastInviteJoined');
        if (ok) {
          try { inviteRedeemed = JSON.parse(ok); } catch {}
          sessionStorage.removeItem('sirisora.lastInviteJoined');
        }
      }

      setState(s => ({ ...s, status: 'ready', error: null, user, household, kids, tasks, history, jars, settings, members, invitations, inviteRedeemed, inviteError }));
    } catch (err) {
      console.error('[store] hydrate failed', err);
      hydratingFor.current = null;
      setState(s => ({ ...s, status: 'error', error: (err && err.message) || 'Failed to load' }));
    }
  }, []);

  const refreshMembers = React.useCallback(async () => {
    if (!state.household) return;
    try {
      const members = await window.db.listHouseholdMembers(state.household.id);
      patch({ members });
    } catch (e) { console.warn('[store.refreshMembers]', e); }
  }, [state.household, patch]);

  const refreshInvitations = React.useCallback(async () => {
    if (!state.household) return;
    try {
      const invitations = await window.db.listInvitations(state.household.id);
      patch({ invitations });
    } catch (e) { console.warn('[store.refreshInvitations]', e); }
  }, [state.household, patch]);

  const createInvitation = React.useCallback(async (email, role) => {
    if (!state.household) throw new Error('No household');
    const row = await window.db.createInvitation(state.household.id, email, role);
    patch(s => ({ invitations: [row, ...s.invitations.filter(i => i.id !== row.id)] }));
    const delivery = await window.db.sendInviteEmail(row.id);
    return { row, delivery };
  }, [state.household, patch]);

  const revokeInvitation = React.useCallback(async (id) => {
    patch(s => ({ invitations: s.invitations.map(i => i.id === id ? { ...i, status: 'revoked' } : i) }));
    try { await window.db.revokeInvitation(id); }
    catch (e) { console.warn('[store.revokeInvitation]', e); refreshInvitations(); }
  }, [patch, refreshInvitations]);

  const clearInviteFlash = React.useCallback(() => {
    patch({ inviteRedeemed: null, inviteError: null });
  }, [patch]);

  const rehydrate = React.useCallback(async () => {
    const user = state.user || (await window.db.getUser());
    if (!user) return;
    hydratingFor.current = null;
    await hydrate(user);
  }, [state.user, hydrate]);

  // Boot — figure out auth state, hydrate if signed in.
  React.useEffect(() => {
    if (!window.db) {
      setState(s => ({ ...s, status: 'error', error: 'db.js failed to load' }));
      return;
    }

    let cancelled = false;

    const handleSession = async (session) => {
      if (cancelled) return;
      if (!session || !session.user) {
        hydratingFor.current = null;
        setState(s => ({ ...s, status: 'signed-out', user: null, household: null, kids: [], tasks: [], history: [], jars: [] }));
        return;
      }
      // Skip duplicate hydrate calls for the same user (getSession() and the auth
      // listener both fire after sign-in and would otherwise race).
      if (hydratingFor.current === session.user.id) return;
      setState(s => {
        if (s.user && s.user.id === session.user.id && s.status === 'ready') return s;
        return { ...s, status: 'loading', user: session.user };
      });
      hydrate(session.user);
    };

    window.db.onAuthChange((event, session) => {
      if (event === 'SIGNED_OUT') {
        handleSession(null);
      } else if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
        handleSession(session);
      }
    });

    window.db.getSession().then(handleSession);
    return () => { cancelled = true; };
  }, [hydrate]);

  // ── Actions ────────────────────────────────────────────────────────────

  const signInWithGoogle = React.useCallback(async () => {
    await window.db.signInWithGoogle();
  }, []);

  const signOut = React.useCallback(async () => {
    await window.db.signOut();
  }, []);

  // Apply a delta to a kid's points + log history. Used by KidView / Dashboard.
  const markAction = React.useCallback(async ({ kidId, amount, reason, emoji, kind }) => {
    const tmpId = 'tmp-' + Math.random().toString(36).slice(2);
    const k = (kind || (amount >= 0 ? 'good' : 'bad'));
    const now = new Date();

    patch(s => {
      const kids = s.kids.map(x => x.id === kidId ? { ...x, points: x.points + amount } : x);
      const entry = {
        id: tmpId, kid_id: kidId, who: kidId,
        task: reason || (amount >= 0 ? 'Earned stars' : 'Lost stars'),
        emoji: emoji || (amount >= 0 ? '🌟' : '💭'),
        delta: amount, kind: k, ts: now.getTime(),
        day: 'Today', when: now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
      };
      return { kids, history: [entry, ...s.history].slice(0, 200) };
    });

    try {
      const kid = state.kids.find(x => x.id === kidId);
      const newPoints = (kid ? kid.points : 0) + amount;
      const [, savedHist] = await Promise.all([
        window.db.updateKid(kidId, { points: newPoints }),
        window.db.insertHistory(kidId, { amount, reason, emoji, kind: k }),
      ]);
      // Replace tmp id with real DB id so undo works
      patch(s => ({ history: s.history.map(h => h.id === tmpId ? { ...h, id: savedHist.id } : h) }));
    } catch (err) {
      console.error('[store.markAction]', err);
      // Roll back optimistic update
      patch(s => {
        const kids = s.kids.map(x => x.id === kidId ? { ...x, points: x.points - amount } : x);
        const history = s.history.filter(h => h.id !== tmpId);
        return { kids, history };
      });
      throw err;
    }
  }, [patch, state.kids]);

  const undoHistory = React.useCallback(async (historyId) => {
    const entry = state.history.find(h => h.id === historyId);
    if (!entry) return;
    patch(s => ({
      history: s.history.filter(h => h.id !== historyId),
      kids: s.kids.map(k => k.id === entry.kid_id ? { ...k, points: k.points - entry.delta } : k),
    }));
    try {
      const kid = state.kids.find(k => k.id === entry.kid_id);
      const newPoints = (kid ? kid.points : 0) - entry.delta;
      await Promise.all([
        window.db.deleteHistory(historyId),
        window.db.updateKid(entry.kid_id, { points: newPoints }),
      ]);
    } catch (err) {
      console.error('[store.undoHistory]', err);
    }
  }, [patch, state.history, state.kids]);

  // Create a new kid + initial behaviors. Used by Onboarding + "Add kid" flow.
  const addKid = React.useCallback(async ({ name, color, age, goal, photo, tasks }) => {
    try {
      const dbKid = await window.db.insertKid(state.household.id, {
        name, theme: color, avatar: '🌟', goal: goal || 50, sort_order: state.kids.length,
      });
      let savedBehaviors = [];
      if (Array.isArray(tasks) && tasks.length) {
        const rows = tasks.map(t => ({
          kind: t.points >= 0 ? 'good' : 'bad',
          emoji: t.emoji, label: t.label,
          amount: Math.abs(t.points),
        }));
        savedBehaviors = await window.db.insertBehaviorsBulk(dbKid.id, rows);
      }
      patch(s => ({
        kids:  [...s.kids, adaptKid(dbKid)],
        tasks: [...s.tasks, ...savedBehaviors.map(adaptTask)],
      }));
      return adaptKid(dbKid);
    } catch (err) {
      console.error('[store.addKid]', err);
      throw err;
    }
  }, [patch, state.household, state.kids.length]);

  const updateKid = React.useCallback(async (kidId, fields) => {
    patch(s => ({ kids: s.kids.map(k => k.id === kidId ? { ...k, ...fields } : k) }));
    try {
      const dbPatch = {};
      if (fields.name !== undefined) dbPatch.name = fields.name;
      if (fields.speechName !== undefined) dbPatch.speechName = fields.speechName;
      if (fields.color !== undefined) dbPatch.theme = fields.color;
      if (fields.theme !== undefined) dbPatch.theme = fields.theme;
      if (fields.avatar !== undefined) dbPatch.avatar = fields.avatar;
      if (fields.goal !== undefined) dbPatch.goal = fields.goal;
      if (fields.points !== undefined) dbPatch.points = fields.points;
      await window.db.updateKid(kidId, dbPatch);
    } catch (err) { console.error('[store.updateKid]', err); }
  }, [patch]);

  const removeKid = React.useCallback(async (kidId) => {
    patch(s => ({
      kids: s.kids.filter(k => k.id !== kidId),
      tasks: s.tasks.filter(t => t.kid_id !== kidId),
      history: s.history.filter(h => h.kid_id !== kidId),
      jars:    s.jars.filter(j => j.kid_id !== kidId),
    }));
    try { await window.db.deleteKid(kidId); }
    catch (err) { console.error('[store.removeKid]', err); }
  }, [patch]);

  // Task (behavior) CRUD
  const addTask = React.useCallback(async ({ kidIds, emoji, label, points, kind }) => {
    const k = kind || (points >= 0 ? 'good' : 'bad');
    const amount = Math.abs(points);
    const created = [];
    for (const kidId of kidIds) {
      try {
        const row = await window.db.insertBehavior(kidId, { kind: k, emoji, label, amount });
        created.push(adaptTask(row));
      } catch (err) { console.error('[store.addTask]', err); }
    }
    patch(s => ({ tasks: [...s.tasks, ...created] }));
    return created;
  }, [patch]);

  const updateTask = React.useCallback(async (taskId, fields) => {
    patch(s => ({ tasks: s.tasks.map(t => t.id === taskId ? { ...t, ...fields } : t) }));
    // For now we re-insert+delete to keep db.js minimal. Behaviors rarely change.
    // (We don't have an updateBehavior in db.js yet — adding here.)
    try {
      const task = state.tasks.find(t => t.id === taskId);
      if (!task) return;
      const patchRow = {};
      if (fields.label !== undefined) patchRow.label = fields.label;
      if (fields.emoji !== undefined) patchRow.emoji = fields.emoji;
      if (fields.points !== undefined) {
        patchRow.amount = Math.abs(fields.points);
        patchRow.kind = fields.points >= 0 ? 'good' : 'bad';
      }
      if (fields.kind !== undefined) patchRow.kind = fields.kind;
      const { error } = await window.db.client
        .from('behaviors').update(patchRow).eq('id', taskId);
      if (error) throw error;
    } catch (err) { console.error('[store.updateTask]', err); }
  }, [patch, state.tasks]);

  const removeTask = React.useCallback(async (taskId) => {
    patch(s => ({ tasks: s.tasks.filter(t => t.id !== taskId) }));
    try { await window.db.deleteBehavior(taskId); }
    catch (err) { console.error('[store.removeTask]', err); }
  }, [patch]);

  // ── Jars ──────────────────────────────────────────────────────────────────

  const addJar = React.useCallback(async (kidId, fields) => {
    try {
      const existingForKid = state.jars.filter(j => j.kid_id === kidId).length;
      const row = await window.db.insertJar(kidId, {
        emoji: fields.emoji,
        label: fields.label,
        color: fields.color,
        goal:  fields.goal,
        note:  fields.note,
        sort_order: existingForKid,
      });
      const j = adaptJar(row);
      patch(s => ({ jars: [...s.jars, j] }));
      return j;
    } catch (err) {
      console.error('[store.addJar]', err);
      throw err;
    }
  }, [patch, state.jars]);

  const updateJar = React.useCallback(async (jarId, fields) => {
    patch(s => ({ jars: s.jars.map(j => j.id === jarId ? { ...j, ...fields } : j) }));
    try { await window.db.updateJar(jarId, fields); }
    catch (err) { console.error('[store.updateJar]', err); }
  }, [patch]);

  const removeJar = React.useCallback(async (jarId) => {
    patch(s => ({ jars: s.jars.filter(j => j.id !== jarId) }));
    try { await window.db.deleteJar(jarId); }
    catch (err) { console.error('[store.removeJar]', err); }
  }, [patch]);

  // Bump (or top-down trim) a jar's current count. Used when the kid drags a
  // star from the pile into a jar — kid.points stays the same; the jar just
  // reflects how many of the kid's existing stars live inside it.
  const adjustJar = React.useCallback(async (jarId, delta) => {
    const jar = state.jars.find(j => j.id === jarId);
    if (!jar) return;
    const next = Math.max(0, jar.current + delta);
    if (next === jar.current) return;
    patch(s => ({ jars: s.jars.map(j => j.id === jarId ? { ...j, current: next } : j) }));
    try { await window.db.updateJar(jarId, { current: next }); }
    catch (err) {
      console.error('[store.adjustJar]', err);
      patch(s => ({ jars: s.jars.map(j => j.id === jarId ? { ...j, current: jar.current } : j) }));
    }
  }, [patch, state.jars]);

  // Move N stars from one jar to another. Used by the kid view drag-drop.
  const transferJar = React.useCallback(async (fromJarId, toJarId, amount = 1) => {
    if (fromJarId === toJarId || amount <= 0) return;
    const from = state.jars.find(j => j.id === fromJarId);
    const to   = state.jars.find(j => j.id === toJarId);
    if (!from || !to) return;
    const move = Math.min(amount, from.current);
    if (move <= 0) return;
    const newFrom = from.current - move;
    const newTo   = to.current   + move;
    patch(s => ({
      jars: s.jars.map(j =>
        j.id === fromJarId ? { ...j, current: newFrom } :
        j.id === toJarId   ? { ...j, current: newTo   } : j
      ),
    }));
    try { await window.db.transferJarStars(fromJarId, toJarId, move, from.current, to.current); }
    catch (err) {
      console.error('[store.transferJar]', err);
      patch(s => ({
        jars: s.jars.map(j =>
          j.id === fromJarId ? { ...j, current: from.current } :
          j.id === toJarId   ? { ...j, current: to.current   } : j
        ),
      }));
    }
  }, [patch, state.jars]);

  // Redeem a jar — "use these stars". The jar empties, kid.points drops by
  // the redeemed amount, and a history row records what was used.
  const redeemJar = React.useCallback(async (jarId) => {
    const jar = state.jars.find(j => j.id === jarId);
    if (!jar) return;
    const used = jar.current;
    if (used <= 0) return;
    const kid = state.kids.find(k => k.id === jar.kid_id);
    if (!kid) return;
    const newPoints = Math.max(0, kid.points - used);
    const tmpId = 'tmp-redeem-' + Math.random().toString(36).slice(2);
    const now = new Date();
    patch(s => ({
      jars: s.jars.map(j => j.id === jarId ? { ...j, current: 0 } : j),
      kids: s.kids.map(k => k.id === jar.kid_id ? { ...k, points: newPoints } : k),
      history: [{
        id: tmpId, kid_id: jar.kid_id, who: jar.kid_id,
        task: `Redeemed: ${jar.emoji} ${jar.label}`,
        emoji: jar.emoji || '🎁',
        delta: -used, kind: 'good', ts: now.getTime(),
        day: 'Today',
        when: now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
      }, ...s.history].slice(0, 200),
    }));
    try {
      const [, savedHist] = await Promise.all([
        window.db.updateJar(jarId, { current: 0 }),
        window.db.updateKid(jar.kid_id, { points: newPoints }),
        window.db.insertHistory(jar.kid_id, {
          amount: -used,
          reason: `Redeemed: ${jar.emoji} ${jar.label}`,
          emoji: jar.emoji || '🎁',
          kind: 'good',
        }).then(row => {
          patch(s => ({ history: s.history.map(h => h.id === tmpId ? { ...h, id: row.id } : h) }));
          return row;
        }),
      ]);
      return savedHist;
    } catch (err) {
      console.error('[store.redeemJar]', err);
      patch(s => ({
        jars: s.jars.map(j => j.id === jarId ? { ...j, current: jar.current } : j),
        kids: s.kids.map(k => k.id === jar.kid_id ? { ...k, points: kid.points } : k),
        history: s.history.filter(h => h.id !== tmpId),
      }));
      throw err;
    }
  }, [patch, state.jars, state.kids]);

  const updateSettings = React.useCallback(async (fields) => {
    patch(s => ({ settings: { ...s.settings, ...fields } }));
    if (!state.household) return;
    try {
      const dbPatch = {};
      if (fields.sound !== undefined) dbPatch.sound = fields.sound;
      if (fields.speech !== undefined) dbPatch.speech = fields.speech;
      if (fields.voice_name !== undefined) dbPatch.voiceName = fields.voice_name;
      await window.db.upsertSettings(state.household.id, dbPatch);
    } catch (err) { console.error('[store.updateSettings]', err); }
  }, [patch, state.household]);

  const renameHousehold = React.useCallback(async (name) => {
    if (!state.household) return;
    patch(s => ({ household: { ...s.household, name } }));
    try {
      const { error } = await window.db.client.from('households')
        .update({ name }).eq('id', state.household.id);
      if (error) throw error;
    } catch (err) { console.error('[store.renameHousehold]', err); }
  }, [patch, state.household]);

  const value = React.useMemo(() => ({
    ...state,
    signInWithGoogle, signOut,
    markAction, undoHistory,
    addKid, updateKid, removeKid,
    addTask, updateTask, removeTask,
    addJar, updateJar, removeJar, adjustJar, transferJar, redeemJar,
    updateSettings, renameHousehold,
    createInvitation, revokeInvitation,
    refreshMembers, refreshInvitations, clearInviteFlash, rehydrate,
  }), [state, signInWithGoogle, signOut, markAction, undoHistory,
       addKid, updateKid, removeKid, addTask, updateTask, removeTask,
       addJar, updateJar, removeJar, adjustJar, transferJar, redeemJar,
       updateSettings, renameHousehold,
       createInvitation, revokeInvitation,
       refreshMembers, refreshInvitations, clearInviteFlash, rehydrate]);

  return React.createElement(StoreContext.Provider, { value }, children);
}

function useStore() {
  const ctx = React.useContext(StoreContext);
  if (!ctx) throw new Error('useStore must be inside <StoreProvider>');
  return ctx;
}

// ── Voice + sprinkle helpers (used by mascot screens) ──────────────────────

function speak(text, opts = {}) {
  if (!window.speechSynthesis) return;
  try {
    speechSynthesis.cancel();
    const u = new SpeechSynthesisUtterance(text);
    u.rate  = opts.rate  ?? 1.0;
    u.pitch = opts.pitch ?? 1.2;
    u.volume = opts.volume ?? 1.0;
    speechSynthesis.speak(u);
  } catch {}
}

// ── Mascot chirps (Duolingo-style "oyiii", "ohhooh", "awww") ───────────────
// Short bundled MP3 clips. Preloaded once on first use, then re-used so taps
// feel instant. Each type can have multiple variants — we pick one at random
// per call so it doesn't sound robotic.
//
// Drop your audio files into /assets/sounds/ with the names below. Missing
// files are silently ignored so the app still works without them.
const MASCOT_SOUND_MAP = {
  cheer: ['assets/sounds/cheer-1.mp3', 'assets/sounds/cheer-2.mp3'],
  oyii:  ['assets/sounds/oyii-1.mp3',  'assets/sounds/oyii-2.mp3'],
  oho:   ['assets/sounds/oho-1.mp3'],
  aww:   ['assets/sounds/aww-1.mp3',   'assets/sounds/aww-2.mp3'],
  ohno:  ['assets/sounds/ohno-1.mp3'],
  sniff: ['assets/sounds/sniff-1.mp3'],
};
const mascotAudioCache = new Map();

function getMascotAudio(src) {
  let a = mascotAudioCache.get(src);
  if (!a) {
    a = new Audio(src);
    a.preload = 'auto';
    a.volume = 0.9;
    mascotAudioCache.set(src, a);
  }
  return a;
}

function preloadMascotSounds() {
  Object.values(MASCOT_SOUND_MAP).flat().forEach(src => {
    try { getMascotAudio(src).load(); } catch {}
  });
}

// Track the currently-running mascot sound so we can cancel it when a new
// reaction kicks off (e.g. kid taps a good task while a sad chirp is still
// running). Always cancel before starting a new one.
const ACTIVE_SOUND = { stop: null };
function stopMascotSound() {
  if (ACTIVE_SOUND.stop) { try { ACTIVE_SOUND.stop(); } catch {} }
  ACTIVE_SOUND.stop = null;
}

function playMascotSound(type, opts = {}) {
  stopMascotSound();
  const duration = opts.duration ?? 0;

  // Try the bundled MP3 first; fall through to synth on any failure.
  const list = MASCOT_SOUND_MAP[type];
  if (list && list.length > 0) {
    const src = list[Math.floor(Math.random() * list.length)];
    try {
      const a = getMascotAudio(src);
      if (!a.error && a.networkState !== 3) {
        a.currentTime = 0;
        a.volume = opts.volume ?? 0.9;
        a.loop = duration > 0;
        const p = a.play();
        if (p && typeof p.catch === 'function') {
          p.catch(() => playSynthChirpLoop(type, duration, opts));
        }
        let stopAt = null;
        if (duration > 0) {
          stopAt = setTimeout(() => { try { a.pause(); a.loop = false; } catch {} }, duration);
        }
        ACTIVE_SOUND.stop = () => {
          if (stopAt) clearTimeout(stopAt);
          try { a.pause(); a.loop = false; } catch {}
        };
        return;
      }
    } catch { /* fall through */ }
  }
  playSynthChirpLoop(type, duration, opts);
}

// ── Web Audio synth chirps (free, offline) ─────────────────────────────────
// Each chirp picks a "voice" preset — a full DSP recipe (waveform, filter,
// envelope, detune, end-of-note pitch bend, loop pacing) — so happy and sad
// reactions sound *audibly* different, not just musically.
//
//   `bright`  → punchy/cheery: triangle voice, open filter, fast envelope,
//               tiny upward note-tail lift, tight loop gaps.
//   `warm`    → mellow/sighing: pure sine voice, closed filter, slow
//               envelope, slight detune (gentle warble), downward note-tail
//               bend (the iconic sigh), looser loop gaps.
//
// Each chirp type then layers its own note phrases + vibrato character on
// top of the voice. Every type has 2-3 phrase variations so loops never
// sound robotic.
const SYNTH_VOICES = {
  bright: {
    mainType: 'triangle',
    subType: 'sine',
    subGain: 0.18,        // sub stays in the background — primary leads
    filterCutoff: 4400,   // wide-open: lets the triangle harmonics through
    filterQ: 1.1,
    vol: 0.18,
    attack: 0.008,        // snappy onset
    release: 0.045,
    detuneCents: 0,
    endBendSemi: 0.3,     // tiny upward lift on the last sustained note
    loopGapMs: [110, 230], // quick repeats — excited
  },
  warm: {
    mainType: 'sine',
    subType: 'sine',
    subGain: 0.45,        // pronounced sub — adds weight/sadness
    filterCutoff: 1100,   // closed filter — soft, dark
    filterQ: 0.6,
    vol: 0.14,
    attack: 0.045,        // slower swell
    release: 0.18,        // long mournful tail
    detuneCents: 7,       // gentle out-of-tune warble between voices
    endBendSemi: -0.85,   // downward sigh on the last sustained note
    loopGapMs: [380, 720], // longer gaps between phrases — reflective
  },
};

const SYNTH_CHIRPS = {
  // ── Happy (bright voice) ────────────────────────────────────────────────
  cheer: {
    voice: 'bright', vibrato: { rate: 6.5, depth: 12 },
    variants: [
      [{f:523,d:0.10},{f:659,d:0.10},{f:784,d:0.30}],            // do-mi-sol↑
      [{f:587,d:0.10},{f:740,d:0.10},{f:880,d:0.30}],            // up a tone
      [{f:494,d:0.08},{f:587,d:0.08},{f:659,d:0.08},{f:784,d:0.30}], // 4-note run-up
    ],
  },
  oyii: {
    voice: 'bright', vibrato: { rate: 7.0, depth: 14 },
    variants: [
      [{f:740,d:0.09},{f:880,d:0.10},{f:698,d:0.08},{f:988,d:0.22}], // oy-yi-oy-YI!
      [{f:880,d:0.08},{f:740,d:0.08},{f:988,d:0.10},{f:740,d:0.20}],
      [{f:659,d:0.08},{f:880,d:0.10},{f:740,d:0.08},{f:1047,d:0.24}],
    ],
  },
  oho: {
    voice: 'bright', vibrato: { rate: 6.0, depth: 16 },
    variants: [
      [{f:880,d:0.09},{f:659,d:0.09},{f:880,d:0.26}],            // wo-ho-HOO!
      [{f:988,d:0.08},{f:740,d:0.08},{f:988,d:0.26}],
      [{f:784,d:0.10},{f:587,d:0.10},{f:784,d:0.10},{f:988,d:0.20}],
    ],
  },
  // ── Sad (warm voice) ────────────────────────────────────────────────────
  aww: {
    voice: 'warm', vibrato: { rate: 3.8, depth: 8 },
    variants: [
      [{f:440,d:0.18},{f:349,d:0.26},{f:262,d:0.50}],            // descending sigh
      [{f:494,d:0.18},{f:392,d:0.26},{f:294,d:0.50}],
      [{f:392,d:0.16},{f:330,d:0.22},{f:262,d:0.20},{f:220,d:0.46}],
    ],
  },
  ohno: {
    voice: 'warm', vibrato: { rate: 4.2, depth: 12 },
    variants: [
      [{f:659,d:0.12},{f:392,d:0.42}],                            // oh-NOOO
      [{f:740,d:0.10},{f:440,d:0.40}],
      [{f:587,d:0.10},{f:349,d:0.10},{f:294,d:0.44}],
    ],
  },
  sniff: {
    voice: 'warm', vibrato: { rate: 3.0, depth: 4 },
    variants: [
      [{f:220,d:0.07},{f:0,d:0.06},{f:220,d:0.07},{f:0,d:0.06},{f:196,d:0.12}], // sniff sniff sniff
      [{f:262,d:0.06},{f:0,d:0.06},{f:240,d:0.06},{f:0,d:0.06},{f:220,d:0.12}],
    ],
  },
};

function _getAudioCtx() {
  if (window.__suri_audio_ctx) return window.__suri_audio_ctx;
  const C = window.AudioContext || window.webkitAudioContext;
  if (!C) return null;
  try { window.__suri_audio_ctx = new C(); return window.__suri_audio_ctx; } catch { return null; }
}

// Plays one short chirp phrase. Returns the total phrase length in seconds,
// so the looper knows when to schedule the next phrase.
function playSynthChirp(type, opts = {}) {
  const ctx = _getAudioCtx();
  if (!ctx) return 0;
  if (ctx.state === 'suspended') { try { ctx.resume(); } catch {} }
  const def = SYNTH_CHIRPS[type];
  if (!def) return 0;
  const voice = SYNTH_VOICES[def.voice] || SYNTH_VOICES.bright;
  const notes = def.variants[Math.floor(Math.random() * def.variants.length)];

  const vol = opts.volume ?? voice.vol;
  const att = voice.attack;
  const rel = voice.release;
  const bendRatio = Math.pow(2, (voice.endBendSemi || 0) / 12);

  // Primary "voice" + sub-octave layer — both shaped by the voice preset so
  // bright/warm chirps occupy distinct timbral spaces.
  const oscA = ctx.createOscillator(); oscA.type = voice.mainType;
  const oscB = ctx.createOscillator(); oscB.type = voice.subType;
  oscB.detune.value = voice.detuneCents || 0;
  const filter = ctx.createBiquadFilter();
  filter.type = 'lowpass';
  filter.frequency.value = voice.filterCutoff;
  filter.Q.value = voice.filterQ;
  const env = ctx.createGain(); env.gain.value = 0;
  const subGain = ctx.createGain(); subGain.gain.value = voice.subGain;

  oscA.connect(filter); oscB.connect(subGain); subGain.connect(filter);
  filter.connect(env); env.connect(ctx.destination);

  // Vibrato — LFO modulating both oscillators' frequency
  let lfo, lfoGain;
  if (def.vibrato) {
    lfo = ctx.createOscillator(); lfo.type = 'sine';
    lfo.frequency.value = def.vibrato.rate;
    lfoGain = ctx.createGain(); lfoGain.gain.value = def.vibrato.depth;
    lfo.connect(lfoGain);
    lfoGain.connect(oscA.frequency);
    lfoGain.connect(oscB.frequency);
  }

  let t = ctx.currentTime + 0.01;
  const setF = (f, time) => {
    const safe = Math.max(20, f);
    oscA.frequency.setValueAtTime(safe, time);
    oscB.frequency.setValueAtTime(safe / 2, time);
  };
  const glideF = (f, time) => {
    const safe = Math.max(20, f);
    oscA.frequency.exponentialRampToValueAtTime(safe, time);
    oscB.frequency.exponentialRampToValueAtTime(safe / 2, time);
  };
  setF(notes[0].f || 220, t);

  for (let i = 0; i < notes.length; i++) {
    const n = notes[i];
    if (n.f === 0) {
      // rest — silence for n.d seconds
      env.gain.setValueAtTime(0, t);
      t += n.d;
      continue;
    }
    env.gain.setValueAtTime(0, t);
    env.gain.linearRampToValueAtTime(vol, t + att);
    env.gain.setValueAtTime(vol, t + Math.max(att, n.d - rel));
    env.gain.linearRampToValueAtTime(0, t + n.d);

    // End-of-note pitch bend on the final sustained note. This is the
    // signature move per voice — a downward "sigh" for sad chirps, a tiny
    // upward lift for happy chirps. Only applied to long enough tails so
    // the bend is audible.
    const restOnly = notes.slice(i + 1).every(nn => nn.f === 0);
    const isLast = (i === notes.length - 1) || restOnly;
    if (isLast && voice.endBendSemi && n.d > 0.18) {
      const bendStart = t + n.d * 0.55;
      const targetF = Math.max(20, n.f * bendRatio);
      oscA.frequency.setValueAtTime(n.f, bendStart);
      oscB.frequency.setValueAtTime(n.f / 2, bendStart);
      oscA.frequency.exponentialRampToValueAtTime(targetF, t + n.d);
      oscB.frequency.exponentialRampToValueAtTime(targetF / 2, t + n.d);
    }

    if (i < notes.length - 1) {
      const next = notes[i + 1];
      if (next.f !== 0) {
        glideF(next.f, t + n.d);
      } else {
        setF(notes[i].f, t + n.d); // hold pitch into the rest
      }
    }
    t += n.d + 0.012;
  }

  oscA.start(ctx.currentTime); oscB.start(ctx.currentTime);
  if (lfo) lfo.start(ctx.currentTime);
  const stopAt = t + 0.06;
  oscA.stop(stopAt); oscB.stop(stopAt);
  if (lfo) lfo.stop(stopAt);
  oscA.onended = () => {
    try {
      oscA.disconnect(); oscB.disconnect();
      subGain.disconnect(); filter.disconnect(); env.disconnect();
      if (lfo) { lfo.disconnect(); lfoGain.disconnect(); }
    } catch {}
  };
  return stopAt - ctx.currentTime;
}

// Loops `playSynthChirp` until `durationMs` elapses, with small gaps between
// repeats so it doesn't feel mechanical. Gap range comes from the voice
// preset so happy reactions feel quick/excited and sad reactions feel
// slower and more reflective. Stoppable via ACTIVE_SOUND.stop.
function playSynthChirpLoop(type, durationMs, opts = {}) {
  const def = SYNTH_CHIRPS[type];
  const voice = SYNTH_VOICES[def?.voice] || SYNTH_VOICES.bright;
  const [gapMin, gapMax] = voice.loopGapMs || [220, 440];
  let cancelled = false;
  let nextTimer = null;
  const tick = () => {
    if (cancelled) return;
    const phraseSec = playSynthChirp(type, opts);
    if (!durationMs) return; // single-shot
    const gap = gapMin + Math.random() * (gapMax - gapMin);
    nextTimer = setTimeout(tick, Math.max(80, phraseSec * 1000) + gap);
  };
  tick();
  let endTimer = null;
  if (durationMs) {
    endTimer = setTimeout(() => {
      cancelled = true;
      if (nextTimer) clearTimeout(nextTimer);
    }, durationMs);
  }
  ACTIVE_SOUND.stop = () => {
    cancelled = true;
    if (nextTimer) clearTimeout(nextTimer);
    if (endTimer) clearTimeout(endTimer);
  };
}


// Sprinkle stars helper used by Onboarding finish + KidView celebrate
function sprinkleStars({ count = 36 } = {}) {
  for (let i = 0; i < count; i++) {
    const el = document.createElement('div');
    el.className = 'sprinkle-star';
    el.style.left = (Math.random() * 100) + 'vw';
    el.style.setProperty('--drift', ((Math.random() - 0.5) * 200) + 'px');
    el.style.setProperty('--spin', (Math.random() * 720 - 360) + 'deg');
    el.style.animationDuration = (1.4 + Math.random() * 1.6) + 's';
    el.style.animationDelay = (Math.random() * 0.4) + 's';
    el.style.fontSize = (14 + Math.random() * 18) + 'px';
    el.textContent = '⭐';
    document.body.appendChild(el);
    setTimeout(() => el.remove(), 3500);
  }
}

Object.assign(window, {
  StoreProvider, useStore, adaptKid, adaptTask, adaptHistory, adaptJar,
  speak, sprinkleStars,
  playMascotSound, preloadMascotSounds, playSynthChirp, stopMascotSound,
});
