// core.jsx — Tally shared logic + UI atoms. Exports to window.
// Append-only ledger model: groups hold an immutable `entries` array of events
// (expense | settle | edit | void). Edits & deletions are themselves entries
// that must be approved by involved parties before they take effect.

const { useState, useEffect, useMemo } = React;

const C = {
  blue: '#2E6BFF', blueDk: '#1B4FD9', blueDeep: '#0F2E8C',
  orange: '#FF7A1A', orangeDk: '#E8650A', green: '#16A34A', red: '#E5484D',
  ink: '#0E1726', sub: '#62708A', faint: '#9AA6BC',
  line: '#E6EBF3', soft: '#F4F7FC', soft2: '#EDF2FB', bg: '#FFFFFF',
};
const PALETTE = ['#2E6BFF', '#FF7A1A', '#16A34A', '#9333EA', '#E5484D', '#0891B2', '#CA8A04', '#DB2777'];
const uid = () => Math.random().toString(36).slice(2, 9);
const r2 = n => Math.round(n * 100) / 100;
function personColor(name) {
  if (name === 'You' || name === 'me') return C.ink;
  let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
  return PALETTE[h % PALETTE.length];
}
function equalSplit(amount, ids) {
  const out = {}; let rem = Math.round((amount || 0) * 100); const n = ids.length || 1;
  ids.forEach((id, i) => { const c = Math.round(rem / (n - i)); out[id] = c / 100; rem -= c; });
  return out;
}
// nudge rounding so a split sums exactly to the total (percent/shares modes)
function fixRound(split, amount, ids) {
  let s = 0; ids.forEach(id => { s += split[id] || 0; });
  const diff = Math.round((amount - s) * 100) / 100;
  if (Math.abs(diff) >= 0.01 && ids.length) split[ids[0]] = Math.round(((split[ids[0]] || 0) + diff) * 100) / 100;
  return split;
}
const CATS = {
  general: ['General', '🧾'], food: ['Food & dining', '🍽️'], groceries: ['Groceries', '🛒'],
  rent: ['Rent & housing', '🏠'], travel: ['Travel', '✈️'], transport: ['Transport', '🚗'],
  utilities: ['Utilities', '💡'], entertainment: ['Entertainment', '🎬'], shopping: ['Shopping', '🛍️'],
  health: ['Health', '💊'], drinks: ['Drinks', '🍻'], gifts: ['Gifts', '🎁'], other: ['Other', '📦'],
};
const catEmoji = k => (CATS[k] || CATS.general)[1];
const catName = k => (CATS[k] || CATS.general)[0];

// ── persistence (v2 = ledger model) ───────────────────────────
// ── event-sourced storage ───────────────────────────────
// The log is the single source of truth: an ordered, append-only array of
// immutable past-tense events. Every screen is a projection of project(events).
// Events: GroupCreated, MemberAdded, ExpenseAdded, PaymentRecorded,
// ExpenseEditRequested, ExpenseVoidRequested, EntryConfirmationSet,
// CommentPosted, GroupDeletionRequested|Cancelled|Approved.
const EKEY = 'tally_events_v1';
const newEvent = (type, streamId, data, actor = 'me', ts = Date.now()) => ({ id: uid(), ts, actor, type, streamId, data: data || {} });

// fold the log into the read-model the UI renders (groups + derived activity)
function project(events) {
  const db = { currency: '$', groups: [], activity: [], profiles: { You: { email: '', phone: '' } } };
  const cur = db.currency;
  const gmap = {};
  // The local account may act under the literal 'me' (offline) OR under its
  // Ed25519 pubkey (when synced). TALLY_SELF_IDS lets the projection treat both
  // as "You" without forking the reducer. Defaults to just 'me' (pure offline).
  const SELF = (typeof window !== 'undefined' && window.TALLY_SELF_IDS) || { me: 1 };
  const isSelf = id => id === 'me' || !!SELF[id];
  // pubkey → display name, learned from synced MemberIdentity events. Lets peers'
  // events (authored under a 64-char Ed25519 pubkey) render as a human name.
  const pubNames = {};
  // Local user's own display name — used to label OUR member entry/avatar (not the
  // balance logic, which still keys the local user as "You" — see who() below).
  const myName = (typeof window !== 'undefined' && window.TALLY_displayName && window.TALLY_displayName()) || '';
  // never show a raw 64-char key; fall back to a short, stable label
  const shortId = id => (typeof id === 'string' && id.length > 12) ? ('Member ' + id.slice(0, 4)) : (id || '?');
  // who(): resolution used by balance/activity logic. Keep self === "You" here so the
  // name-keyed balance math (consolidated/globalPairwise) is unchanged. Peers resolve
  // to their announced name, else a short label (never a raw key).
  const who = (g, id) => {
    if (isSelf(id)) return 'You';
    if (pubNames[id]) return pubNames[id];
    const m = g && g.members.find(x => x.id === id);
    if (m && m.name && !(typeof m.name === 'string' && m.name.length > 12)) return m.name;
    return shortId(id);
  };
  // memberLabel(): name shown in member chips/avatars. Here our OWN name is shown
  // (falling back to "You" only if we never set one). Safe — display only.
  const memberLabel = id => {
    if (isSelf(id)) return myName || 'You';
    if (pubNames[id]) return pubNames[id];
    return shortId(id);
  };
  const prof = nm => (db.profiles[nm] = db.profiles[nm] || { email: '', phone: '' });
  // ensure any author (pubkey or 'me') appears as a group member so they show in lists
  const ensureMember = (g, id) => {
    if (!g || !id) return;
    if (g._removed && g._removed[id]) return;              // don't resurrect a removed member
    if (!g.members.find(m => m.id === id)) {
      g.members.push({ id, name: memberLabel(id), email: '', verified: false, pubkey: id !== 'me' && id.length > 12 });
    }
  };
  const findE = (g, id) => g.entries.find(e => e.id === id);
  const log = (ev, g, text, detail) => db.activity.push({ id: ev.id, ts: ev.ts, actorId: ev.actor, actorName: g ? who(g, ev.actor) : (isSelf(ev.actor) ? 'You' : ev.actor), text, detail: detail || '', groupName: g ? g.name : '', groupEmoji: g ? g.emoji : '' });
  for (const ev of events) {
    const g = gmap[ev.streamId]; const d = ev.data || {};
    // learn pubkey→name globally from identity announcements (before rendering)
    if (ev.type === 'MemberIdentity') {
      if (d.pubKey && d.name) {
        if (!isSelf(d.pubKey)) pubNames[d.pubKey] = d.name;   // peers' names; never overwrite our own label source
        if (g && !(g._removed && g._removed[d.pubKey])) {
          const m = g.members.find(x => x.id === d.pubKey);
          if (m) { m.name = isSelf(d.pubKey) ? memberLabel(d.pubKey) : d.name; if (d.email) m.email = d.email; }
          else { g.members.push({ id: d.pubKey, name: isSelf(d.pubKey) ? memberLabel(d.pubKey) : d.name, email: d.email || '', verified: false, pubkey: true }); }
        }
      }
      continue;
    }
    // any author (pubkey or 'me') that acts in a group should appear as a member
    if (g && ev.actor) ensureMember(g, ev.actor);
    if (ev.type === 'GroupCreated') {
      const ng = { id: ev.streamId, name: d.name, emoji: d.emoji || '👥', members: [{ id: ev.actor, name: memberLabel(ev.actor), verified: true }], entries: [], recurring: [], deletion: null, archived: false };
      gmap[ev.streamId] = ng; db.groups.push(ng); log(ev, ng, 'created the group', `members: ${memberLabel(ev.actor)}`);
    } else if (!g) { continue; }
    else if (ev.type === 'MemberAdded') { if (g._removed && g._removed[d.memberId]) { delete g._removed[d.memberId]; } g.members.push({ id: d.memberId, name: d.name, email: d.email || '', verified: !!d.verified }); if (d.email) prof(d.name).email = d.email; log(ev, g, `added ${d.name} to the group`, `members now: ${g.members.map(m => m.name).join(', ')}`); }
    else if (ev.type === 'MemberRemoved') { g._removed = g._removed || {}; g._removed[d.memberId] = true; const idx = g.members.findIndex(x => x.id === d.memberId); const nm = idx >= 0 ? g.members[idx].name : memberLabel(d.memberId); if (idx >= 0) g.members.splice(idx, 1); log(ev, g, `removed ${nm} from the group`, ''); }
    else if (ev.type === 'MemberVerified') { const m = g.members.find(x => x.id === d.memberId); if (m) { m.verified = true; log(ev, g, `verified ${m.name}’s key`, 'safety code matched'); } }
    else if (ev.type === 'ProfileSet') { const p = prof(d.name); if (d.email !== undefined) p.email = d.email; if (d.phone !== undefined) p.phone = d.phone; log(ev, g, `updated ${d.name === 'You' ? 'your' : d.name + '’s'} profile`, d.email ? `email: ${d.email}` : ''); }
    else if (ev.type === 'ExpenseAdded') { const e = { id: d.expenseId, type: 'expense', date: d.date || ev.ts, by: ev.actor, desc: d.desc, amount: d.amount, paidBy: d.paidBy, split: d.split, category: d.category || 'general', note: d.note || '', unequal: d.unequal, items: d.items || null, recurringId: d.recurringId || null, approvals: { [ev.actor]: d.date || ev.ts }, comments: [] }; g.entries.push(e); log(ev, g, `added “${e.desc}”`, describeEntry(g, e, cur)); }
    else if (ev.type === 'PaymentRecorded') { const e = { id: d.paymentId, type: 'settle', date: ev.ts, by: ev.actor, from: d.from, to: d.to, amount: d.amount, approvals: { [ev.actor]: ev.ts }, comments: [] }; g.entries.push(e); log(ev, g, `recorded a payment to ${who(g, d.to)}`, describeEntry(g, e, cur)); }
    else if (ev.type === 'ExpenseEditRequested') { const e = { id: d.editId, type: 'edit', date: ev.ts, by: ev.actor, targetId: d.targetId, patch: d.patch, approvals: { [ev.actor]: ev.ts } }; g.entries.push(e); const t = findE(g, d.targetId); log(ev, g, `requested a change to “${t ? t.desc : '—'}”`, describeEdit(g, e, cur)); }
    else if (ev.type === 'ExpenseVoidRequested') { const e = { id: d.voidId, type: 'void', date: ev.ts, by: ev.actor, targetId: d.targetId, approvals: { [ev.actor]: ev.ts } }; g.entries.push(e); const t = findE(g, d.targetId); log(ev, g, `requested deletion of “${t ? t.desc : '—'}”`, t ? describeEntry(g, t, cur) : ''); }
    else if (ev.type === 'EntryConfirmationSet') { const e = findE(g, d.entryId); if (!e) continue; e.approvals = e.approvals || {}; if (d.confirmed) e.approvals[ev.actor] = ev.ts; else delete e.approvals[ev.actor]; const label = e.type === 'expense' ? `“${e.desc}”` : e.type === 'settle' ? 'a payment' : e.type === 'edit' ? 'a change' : 'a deletion'; const verb = d.confirmed ? ((e.type === 'edit' || e.type === 'void') ? 'approved' : 'confirmed') : 'withdrew confirmation on'; let detail = ''; if (e.type === 'edit') detail = describeEdit(g, e, cur); else if (e.type === 'void') { const t = findE(g, e.targetId); detail = t ? describeEntry(g, t, cur) : ''; } else detail = describeEntry(g, e, cur); log(ev, g, `${verb} ${label}`, detail); }
    else if (ev.type === 'EntryDisputed') { const e = findE(g, d.entryId); if (!e) continue; e.dispute = { by: ev.actor, reason: d.reason || '', date: ev.ts, status: 'open' }; e.comments = e.comments || []; if (d.reason) e.comments.push({ by: ev.actor, text: d.reason, date: ev.ts, dispute: true }); log(ev, g, `disputed “${e.desc || (e.type === 'settle' ? 'a payment' : 'an entry')}”`, d.reason ? `“${d.reason}”` : ''); }
    else if (ev.type === 'DisputeResolved') { const e = findE(g, d.entryId); if (!e || !e.dispute) continue; const withdrew = ev.actor === e.dispute.by; e.dispute.status = 'resolved'; e.dispute.resolvedBy = ev.actor; e.dispute.resolvedAt = ev.ts; log(ev, g, `${withdrew ? 'withdrew the dispute on' : 'resolved the dispute on'} “${e.desc || (e.type === 'settle' ? 'a payment' : 'an entry')}”`, ''); }
    else if (ev.type === 'CommentPosted') { const e = findE(g, d.entryId); if (!e) continue; e.comments = e.comments || []; e.comments.push({ by: ev.actor, text: d.text, date: ev.ts }); log(ev, g, `commented on “${e.desc || 'an entry'}”`, `“${d.text}”`); }
    else if (ev.type === 'RecurringAdded') { g.recurring.push({ id: d.recurringId, desc: d.desc, amount: d.amount, paidBy: d.paidBy, split: d.split, category: d.category || 'general', note: d.note || '', items: d.items || null, frequency: d.frequency, anchorTs: d.anchorTs || ev.ts, by: ev.actor, active: true }); log(ev, g, `set up a recurring expense`, `${d.desc} · ${cur}${Number(d.amount).toFixed(2)} · ${d.frequency}`); }
    else if (ev.type === 'RecurringStopped') { const r = g.recurring.find(x => x.id === d.recurringId); if (r) { r.active = false; log(ev, g, 'stopped a recurring expense', r.desc); } }
    else if (ev.type === 'GroupDeletionRequested') { g.deletion = { by: ev.actor, date: ev.ts, approvals: { [ev.actor]: ev.ts } }; if (g.members.every(m => g.deletion.approvals[m.id])) g.archived = true; log(ev, g, 'requested to delete the group', `“${g.name}” · members: ${g.members.map(m => m.name).join(', ')}`); }
    else if (ev.type === 'GroupDeletionCancelled') { g.deletion = null; g.archived = false; log(ev, g, 'restored the group', `“${g.name}”`); }
    else if (ev.type === 'EntryRestored') { g.entries.filter(x => x.type === 'void' && x.targetId === d.targetId).forEach(v => { v.cancelled = true; }); const t = findE(g, d.targetId); log(ev, g, `restored “${t ? t.desc : 'an entry'}”`, ''); }
    else if (ev.type === 'GroupDeletionApproved') { if (g.deletion) { g.deletion.approvals[ev.actor] = ev.ts; const done = g.members.every(m => g.deletion.approvals[m.id]); if (done) g.archived = true; log(ev, g, done ? 'approved & closed the group' : 'approved the group deletion', `“${g.name}”`); } }
  }
  // Disambiguation: within a group, if two members share a display name, flag them so
  // the UI can distinguish (email is shown on hover; a small marker shows inline).
  db.groups.forEach(g => {
    const byName = {};
    g.members.forEach(m => { const k = (m.name || '').toLowerCase(); (byName[k] = byName[k] || []).push(m); });
    Object.values(byName).forEach(list => {
      if (list.length > 1) list.forEach((m, i) => { m.dupName = true; m.dupIndex = i + 1; });
    });
  });
  return db;
}

function seedEvents() {
  const gid = uid(), maya = uid(), jon = uid(), t = Date.now();
  const ev = (type, dt, data, actor = 'me') => newEvent(type, gid, data, actor, t - dt);
  const ids = ['me', maya, jon];
  const ax = uid(), gx = uid(), dx = uid();
  // second + third groups (different friend circles, minimal overlap)
  const gidA = uid(), pmaya = uid(), priya = uid(); const idsA = ['me', pmaya, priya];
  const evA = (type, dt, data, actor = 'me') => newEvent(type, gidA, data, actor, t - dt);
  const ar = uid(), aw = uid(), ac = uid(), ap = uid();
  const gidB = uid(), liam = uid(), sara = uid(), noah = uid(); const idsB = ['me', liam, sara, noah];
  const evB = (type, dt, data, actor = 'me') => newEvent(type, gidB, data, actor, t - dt);
  const bc = uid(), bl = uid(), bg = uid(), bf = uid(), bp = uid();
  return [
    ev('GroupCreated', 864e5 * 4, { name: 'Trip to Lisbon', emoji: '✈️' }),
    ev('ProfileSet', 864e5 * 4 + 1, { name: 'You', email: 'you@tally.app' }),
    ev('MemberAdded', 864e5 * 4 - 1e3, { memberId: maya, name: 'Maya', email: 'maya@example.com' }),
    ev('MemberAdded', 864e5 * 4 - 2e3, { memberId: jon, name: 'Jon', email: 'jon@example.com' }),
    ev('MemberVerified', 864e5 * 4 - 1500, { memberId: maya }),
    ev('ExpenseAdded', 864e5 * 3, { expenseId: ax, desc: 'Airbnb', amount: 420, paidBy: 'me', category: 'rent', split: equalSplit(420, ids) }),
    ev('EntryConfirmationSet', 864e5 * 3 - 1e3, { entryId: ax, confirmed: true }, maya),
    ev('ExpenseAdded', 864e5 * 2, { expenseId: gx, desc: 'Groceries', amount: 86.4, paidBy: maya, category: 'groceries', split: equalSplit(86.4, ids) }, maya),
    ev('EntryConfirmationSet', 864e5 * 2 - 1e3, { entryId: gx, confirmed: true }, 'me'),
    ev('EntryConfirmationSet', 864e5 * 2 - 2e3, { entryId: gx, confirmed: true }, jon),
    ev('ExpenseAdded', 864e5, { expenseId: dx, desc: 'Dinner at Time Out', amount: 96, paidBy: 'me', category: 'food', note: 'Jon had the wine pairing', unequal: true, split: { me: 24, [maya]: 24, [jon]: 48 } }),

    // ── Apartment (You · Maya · Priya) ──
    evA('GroupCreated', 864e5 * 30, { name: 'Apartment', emoji: '🏠' }),
    evA('MemberAdded', 864e5 * 30 - 1e3, { memberId: pmaya, name: 'Maya', email: 'maya@example.com' }),
    evA('MemberAdded', 864e5 * 30 - 2e3, { memberId: priya, name: 'Priya', email: 'priya@example.com' }),
    evA('MemberVerified', 864e5 * 30 - 3e3, { memberId: pmaya }),
    evA('ExpenseAdded', 864e5 * 28, { expenseId: ar, desc: 'October rent', amount: 1800, paidBy: 'me', category: 'rent', split: equalSplit(1800, idsA) }),
    evA('EntryConfirmationSet', 864e5 * 28 - 1e3, { entryId: ar, confirmed: true }, pmaya),
    evA('EntryConfirmationSet', 864e5 * 28 - 2e3, { entryId: ar, confirmed: true }, priya),
    evA('ExpenseAdded', 864e5 * 20, { expenseId: aw, desc: 'Wifi & utilities', amount: 96, paidBy: pmaya, category: 'utilities', split: equalSplit(96, idsA) }, pmaya),
    evA('ExpenseAdded', 864e5 * 12, { expenseId: ac, desc: 'Cleaner', amount: 75, paidBy: priya, category: 'general', split: equalSplit(75, idsA) }, priya),
    evA('EntryConfirmationSet', 864e5 * 12 - 1e3, { entryId: ac, confirmed: true }, 'me'),
    evA('EntryConfirmationSet', 864e5 * 12 - 2e3, { entryId: ac, confirmed: true }, pmaya),
    evA('PaymentRecorded', 864e5 * 6, { paymentId: ap, from: priya, to: 'me', amount: 250 }, priya),
    evA('EntryConfirmationSet', 864e5 * 6 - 1e3, { entryId: ap, confirmed: true }, 'me'),

    // ── Ski trip (You · Liam · Sara · Noah) ──
    evB('GroupCreated', 864e5 * 18, { name: 'Ski trip', emoji: '⛷️' }),
    evB('MemberAdded', 864e5 * 18 - 1e3, { memberId: liam, name: 'Liam', email: 'liam@example.com' }),
    evB('MemberAdded', 864e5 * 18 - 2e3, { memberId: sara, name: 'Sara', email: 'sara@example.com' }),
    evB('MemberAdded', 864e5 * 18 - 3e3, { memberId: noah, name: 'Noah', email: 'noah@example.com' }),
    evB('MemberVerified', 864e5 * 18 - 4e3, { memberId: liam }),
    evB('ExpenseAdded', 864e5 * 16, { expenseId: bc, desc: 'Chalet booking', amount: 1200, paidBy: 'me', category: 'rent', split: equalSplit(1200, idsB) }),
    evB('EntryConfirmationSet', 864e5 * 16 - 1e3, { entryId: bc, confirmed: true }, liam),
    evB('EntryConfirmationSet', 864e5 * 16 - 2e3, { entryId: bc, confirmed: true }, sara),
    evB('EntryConfirmationSet', 864e5 * 16 - 3e3, { entryId: bc, confirmed: true }, noah),
    evB('ExpenseAdded', 864e5 * 15, { expenseId: bl, desc: 'Lift passes', amount: 640, paidBy: liam, category: 'general', split: equalSplit(640, idsB) }, liam),
    evB('EntryConfirmationSet', 864e5 * 15 - 1e3, { entryId: bl, confirmed: true }, 'me'),
    evB('EntryConfirmationSet', 864e5 * 15 - 2e3, { entryId: bl, confirmed: true }, sara),
    evB('ExpenseAdded', 864e5 * 14, { expenseId: bg, desc: 'Gear rental', amount: 310, paidBy: sara, category: 'general', split: equalSplit(310, idsB) }, sara),
    evB('ExpenseAdded', 864e5 * 12, { expenseId: bf, desc: 'Groceries & raclette', amount: 128, paidBy: 'me', category: 'groceries', split: equalSplit(128, idsB) }),
    evB('EntryConfirmationSet', 864e5 * 12 - 1e3, { entryId: bf, confirmed: true }, liam),
    evB('PaymentRecorded', 864e5 * 5, { paymentId: bp, from: liam, to: 'me', amount: 300 }, liam),
    evB('EntryConfirmationSet', 864e5 * 5 - 1e3, { entryId: bp, confirmed: true }, 'me'),
  ];
}

// convert an old v2 read-model snapshot into the equivalent event log
function migrateV2ToEvents(v2) {
  const out = [];
  (v2.groups || []).forEach(g => {
    const created = (g.entries && g.entries[0] && g.entries[0].date) || Date.now();
    out.push(newEvent('GroupCreated', g.id, { name: g.name, emoji: g.emoji }, 'me', created - 1e4));
    g.members.filter(m => m.id !== 'me').forEach((m, i) => out.push(newEvent('MemberAdded', g.id, { memberId: m.id, name: m.name, email: m.email || '' }, 'me', created - 9e3 + i)));
    (g.entries || []).slice().sort((a, b) => a.date - b.date).forEach(e => {
      if (e.type === 'expense') out.push(newEvent('ExpenseAdded', g.id, { expenseId: e.id, desc: e.desc, amount: e.amount, paidBy: e.paidBy, category: e.category, note: e.note, unequal: e.unequal, split: e.split }, e.by, e.date));
      else if (e.type === 'settle') out.push(newEvent('PaymentRecorded', g.id, { paymentId: e.id, from: e.from, to: e.to, amount: e.amount }, e.by, e.date));
      else if (e.type === 'edit') out.push(newEvent('ExpenseEditRequested', g.id, { editId: e.id, targetId: e.targetId, patch: e.patch }, e.by, e.date));
      else if (e.type === 'void') out.push(newEvent('ExpenseVoidRequested', g.id, { voidId: e.id, targetId: e.targetId }, e.by, e.date));
      Object.keys(e.approvals || {}).forEach(aid => { if (aid !== e.by) out.push(newEvent('EntryConfirmationSet', g.id, { entryId: e.id, confirmed: true }, aid, e.date + 1)); });
      (e.comments || []).forEach(c => out.push(newEvent('CommentPosted', g.id, { entryId: e.id, commentId: uid(), text: c.text }, c.by, c.date)));
    });
    if (g.deletion) out.push(newEvent('GroupDeletionRequested', g.id, {}, g.deletion.by, g.deletion.date));
  });
  return out;
}

function load() {
  try { const r = localStorage.getItem(EKEY); if (r) { const p = JSON.parse(r); if (p && Array.isArray(p.events)) return p.events; if (Array.isArray(p)) return p; } } catch (e) {}
  try { const v2 = localStorage.getItem('tally_state_v2'); if (v2) { const ev = migrateV2ToEvents(JSON.parse(v2)); if (ev.length) return ev; } } catch (e) {}
  // Demo seed data is for the pure OFFLINE PWA only. A synced, logged-in account
  // should start EMPTY (its real data arrives from the server), not with demo groups.
  try {
    const syncOn = typeof window !== 'undefined' && !!window.TALLY_API_BASE;
    const loggedIn = typeof window !== 'undefined' && window.TallySync && window.TallySync.isLoggedIn && window.TallySync.isLoggedIn();
    if (syncOn && loggedIn) return [];
  } catch (e) {}
  return seedEvents();
}

// step a timestamp forward by a recurrence frequency
function recurStep(ts, freq) {
  const d = new Date(ts);
  if (freq === 'daily') d.setDate(d.getDate() + 1);
  else if (freq === 'weekly') d.setDate(d.getDate() + 7);
  else if (freq === 'biweekly') d.setDate(d.getDate() + 14);
  else d.setMonth(d.getMonth() + 1); // monthly default
  return d.getTime();
}
// generate ExpenseAdded events for any recurring occurrences due up to `now`
function materializeDue(events, now = Date.now()) {
  const db = project(events);
  const out = [];
  db.groups.forEach(g => {
    if (g.archived) return;
    (g.recurring || []).forEach(rule => {
      if (!rule.active) return;
      const occs = g.entries.filter(e => e.type === 'expense' && e.recurringId === rule.id).map(e => e.date);
      let last = occs.length ? Math.max(...occs) : rule.anchorTs - 1;
      let t = recurStep(last, rule.frequency);
      let guard = 0;
      while (t <= now && guard < 60) {
        out.push(newEvent('ExpenseAdded', g.id, { expenseId: uid(), desc: rule.desc, amount: rule.amount, paidBy: rule.paidBy, split: rule.split, category: rule.category, note: rule.note, items: rule.items, unequal: true, recurringId: rule.id, date: t }, rule.paidBy === 'me' ? 'me' : rule.paidBy, t));
        last = t; t = recurStep(last, rule.frequency); guard++;
      }
    });
  });
  return out;
}

// ── ledger semantics ──────────────────────────────────────────
const uniq = a => Array.from(new Set(a));
function involvedOf(entry, group) {
  if (entry.type === 'expense') {
    const edits = group.entries.filter(x => x.type === 'edit' && x.targetId === entry.id).sort((a, b) => a.date - b.date);
    let split = entry.split || {}, paidBy = entry.paidBy;
    edits.forEach(ed => { if (ed.patch && ed.patch.split) split = ed.patch.split; if (ed.patch && ed.patch.paidBy) paidBy = ed.patch.paidBy; });
    return uniq([paidBy, ...Object.keys(split)]);
  }
  if (entry.type === 'settle') {
    const edits = group.entries.filter(x => x.type === 'edit' && x.targetId === entry.id).sort((a, b) => a.date - b.date);
    let from = entry.from, to = entry.to;
    edits.forEach(ed => { if (ed.patch && ed.patch.from) from = ed.patch.from; if (ed.patch && ed.patch.to) to = ed.patch.to; });
    return uniq([from, to]);
  }
  if (entry.type === 'edit') {
    const t = group.entries.find(x => x.id === entry.targetId);
    const base = t ? involvedOf(t, group) : [];
    const p = entry.patch || {};
    const ids = [...base, ...(p.split ? Object.keys(p.split) : []), ...(p.paidBy ? [p.paidBy] : [])];
    return uniq(ids);
  }
  if (entry.type === 'void') {
    const t = group.entries.find(x => x.id === entry.targetId);
    return t ? involvedOf(t, group) : [];
  }
  return [];
}
const requiredApprovers = (entry, group) => involvedOf(entry, group);
// an edit only needs approval if it changes who pays / who owes / how much
function isMonetaryEdit(group, ed) { return editDiffs(group, ed).some(d => ['money', 'person', 'split'].includes(d[3])); }
// Who authored the CURRENT (most-recent) version of an entry? That person implicitly
// approves it — you never have to confirm your own latest version. For an expense/
// settle this is the latest edit's author (or the original author if unedited); an
// edit/void is implicitly approved by whoever made it.
function latestAuthorOf(entry, group) {
  if (entry.type === 'edit' || entry.type === 'void') return entry.by;
  const edits = (group.entries || []).filter(x => x.type === 'edit' && x.targetId === entry.id).sort((a, b) => a.date - b.date);
  return edits.length ? edits[edits.length - 1].by : entry.by;
}
function approvalOf(entry, group) {
  if (entry.type === 'edit' && !isMonetaryEdit(group, entry)) return { approved: 0, total: 0, done: true, req: [], nonMonetary: true };
  const req = requiredApprovers(entry, group);
  const ap = { ...(entry.approvals || {}) };
  const latestBy = latestAuthorOf(entry, group);      // implicit self-approval
  if (latestBy && !ap[latestBy]) ap[latestBy] = entry.editedAt || entry.date || true;
  const approved = req.filter(i => ap[i]).length;
  return { approved, total: req.length, done: req.length > 0 && approved === req.length, req };
}
// fold approved deletions + the MOST RECENT edit (edits auto-apply) onto a base entry
function effectiveEntry(group, E) {
  const voids = group.entries.filter(x => x.type === 'void' && x.targetId === E.id && !x.cancelled);
  const voided = voids.length > 0;
  const voidApproved = voids.some(v => approvalOf(v, group).done);
  const pendingVoid = voids.some(v => !approvalOf(v, group).done);
  const edits = group.entries.filter(x => x.type === 'edit' && x.targetId === E.id).sort((a, b) => a.date - b.date);
  const latest = edits[edits.length - 1];
  const eff = { desc: E.desc, amount: E.amount, paidBy: E.paidBy, from: E.from, to: E.to, split: { ...(E.split || {}) }, unequal: E.unequal, category: E.category || 'general', note: E.note || '', items: E.items || null, recurringId: E.recurringId || null };
  if (latest) Object.assign(eff, latest.patch);
  return {
    ...eff, voided, pendingVoid, voidApproved,
    amended: edits.length > 0,
    editedBy: latest ? latest.by : null,
    editedAt: latest ? latest.date : null,
    latestEditId: latest ? latest.id : null,
    pendingEdit: latest ? !approvalOf(latest, group).done : false,
  };
}
// state of a base entry just before a given edit was made (for showing diffs)
function effectiveBeforeEdit(group, E, editEntry) {
  const edits = group.entries.filter(x => x.type === 'edit' && x.targetId === E.id && x.date < editEntry.date).sort((a, b) => a.date - b.date);
  const eff = { desc: E.desc, amount: E.amount, paidBy: E.paidBy, from: E.from, to: E.to, split: { ...(E.split || {}) }, category: E.category || 'general', note: E.note || '' };
  edits.forEach(ed => Object.assign(eff, ed.patch));
  return eff;
}
// [field, before, after, kind] list of what an edit changed
function editDiffs(group, editEntry) {
  const target = group.entries.find(x => x.id === editEntry.targetId); if (!target) return [];
  const b = effectiveBeforeEdit(group, target, editEntry); const p = editEntry.patch || {};
  const d = [];
  if (p.amount != null && r2(p.amount) !== r2(b.amount)) d.push(['Amount', b.amount, p.amount, 'money']);
  if (p.desc != null && p.desc !== b.desc) d.push(['Description', b.desc, p.desc, 'text']);
  if (p.category != null && p.category !== (b.category || 'general')) d.push(['Category', b.category || 'general', p.category, 'meta']);
  if (p.note != null && p.note !== (b.note || '')) d.push(['Note', b.note || '', p.note, 'meta']);
  if (p.paidBy && p.paidBy !== b.paidBy) d.push(['Paid by', b.paidBy, p.paidBy, 'person']);
  if (p.split && JSON.stringify(p.split) !== JSON.stringify(b.split)) d.push(['Split', b.split, p.split, 'split']);
  return d;
}
// per-person before→after share changes for a split diff, as [name, before, after]
function splitDeltas(g, before, after) {
  const who = id => (g.members.find(m => m.id === id) || { name: '?' }).name;
  const ids = Array.from(new Set([...Object.keys(before || {}), ...Object.keys(after || {})]));
  return ids.map(id => [who(id), Number((before || {})[id] || 0), Number((after || {})[id] || 0)]).filter(r => Math.abs(r[1] - r[2]) > 0.005);
}
// exact, human-readable detail strings for the audit log
function fmtSplit(g, split, cur) {
  return Object.entries(split || {}).map(([id, v]) => `${(g.members.find(m => m.id === id) || { name: '?' }).name} ${cur}${Number(v).toFixed(2)}`).join(', ');
}
function describeEntry(g, e, cur) {
  const who = id => (g.members.find(m => m.id === id) || { name: '?' }).name;
  if (e.type === 'expense') { let s = `${cur}${Number(e.amount).toFixed(2)} · paid by ${who(e.paidBy)} · ${catName(e.category || 'general')} · split: ${fmtSplit(g, e.split, cur)}`; if (e.note) s += ` · note: “${e.note}”`; return s; }
  if (e.type === 'settle') return `${who(e.from)} → ${who(e.to)} · ${cur}${Number(e.amount).toFixed(2)}`;
  return '';
}
function describeEdit(g, editEntry, cur) {
  const who = id => (g.members.find(m => m.id === id) || { name: '?' }).name;
  const diffs = editDiffs(g, editEntry);
  if (!diffs.length) return 'no field changes';
  return diffs.map(d => {
    if (d[3] === 'money') return `amount ${cur}${Number(d[1]).toFixed(2)} → ${cur}${Number(d[2]).toFixed(2)}`;
    if (d[3] === 'text') return `description “${d[1]}” → “${d[2]}”`;
    if (d[3] === 'meta') return `${d[0].toLowerCase()} “${d[1]}” → “${d[2]}”`;
    if (d[3] === 'person') return `paid by ${who(d[1])} → ${who(d[2])}`;
    if (d[3] === 'split') return 'shares — ' + splitDeltas(g, d[1], d[2]).map(r => `${r[0]} ${cur}${r[1].toFixed(2)}→${cur}${r[2].toFixed(2)}`).join(', ');
    return '';
  }).filter(Boolean).join('; ');
}
const baseEntries = group => group.entries.filter(e => e.type === 'expense' || e.type === 'settle');

function netBalances(group) {
  const bal = {}; group.members.forEach(m => { bal[m.id] = 0; });
  baseEntries(group).forEach(E => {
    const eff = effectiveEntry(group, E); if (eff.voided) return;
    if (E.type === 'settle') { bal[eff.from] += eff.amount; bal[eff.to] -= eff.amount; return; }
    bal[eff.paidBy] = (bal[eff.paidBy] || 0) + eff.amount;
    Object.entries(eff.split).forEach(([mid, amt]) => { bal[mid] = (bal[mid] || 0) - amt; });
  });
  Object.keys(bal).forEach(k => { bal[k] = r2(bal[k]); });
  return bal;
}
function simplify(bal) {
  const cred = [], deb = [];
  Object.entries(bal).forEach(([id, v]) => { if (v > 0.005) cred.push({ id, v }); else if (v < -0.005) deb.push({ id, v: -v }); });
  cred.sort((a, b) => b.v - a.v); deb.sort((a, b) => b.v - a.v);
  const tx = []; let i = 0, j = 0;
  while (i < deb.length && j < cred.length) {
    const pay = Math.min(deb[i].v, cred[j].v);
    tx.push({ from: deb[i].id, to: cred[j].id, amount: r2(pay) });
    deb[i].v -= pay; cred[j].v -= pay;
    if (deb[i].v < 0.005) i++; if (cred[j].v < 0.005) j++;
  }
  return tx;
}
function globalPairwise(state) {
  const owe = {}, names = {};
  const add = (d, c, amt) => { if (d === c) return; owe[d] = owe[d] || {}; owe[d][c] = (owe[d][c] || 0) + amt; };
  state.groups.forEach(g => {
    if (g.archived) return;
    const nm = id => { const m = g.members.find(x => x.id === id); return m ? m.name : '?'; };
    g.members.forEach(m => { names[m.name] = 1; });
    baseEntries(g).forEach(E => {
      const eff = effectiveEntry(g, E); if (eff.voided) return;
      const payer = E.type === 'settle' ? eff.from : eff.paidBy;
      const split = E.type === 'settle' ? { [eff.to]: eff.amount } : eff.split;
      Object.entries(split).forEach(([mid, amt]) => { if (mid === payer) return; add(nm(mid), nm(payer), amt); });
    });
  });
  const list = Object.keys(names); const out = [];
  for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) {
    const a = list[i], b = list[j];
    const net = ((owe[a] && owe[a][b]) || 0) - ((owe[b] && owe[b][a]) || 0);
    if (net > 0.005) out.push({ from: a, to: b, amount: r2(net) });
    else if (net < -0.005) out.push({ from: b, to: a, amount: r2(-net) });
  }
  return out.sort((x, y) => y.amount - x.amount);
}
function consolidated(state) {
  const map = {};
  state.groups.forEach(g => {
    if (g.archived) return;
    const b = netBalances(g);
    g.members.forEach(m => {
      const e = map[m.name] || (map[m.name] = { name: m.name, net: 0, groups: [] });
      const v = b[m.id] || 0; e.net += v;
      if (Math.abs(v) > 0.005) e.groups.push({ name: g.name, emoji: g.emoji, net: r2(v) });
    });
  });
  return Object.values(map).map(p => ({ ...p, net: r2(p.net) })).sort((a, b) => b.net - a.net);
}
// everything currently awaiting MY confirmation, across all groups
function pendingForMe(state, me = 'me') {
  const out = [];
  state.groups.forEach(g => {
    if (g.archived) return;
    g.entries.forEach(e => {
      const ap = e.approvals || {}; const req = requiredApprovers(e, g);
      if (!req.includes(me)) return;
      if (ap[me] || latestAuthorOf(e, g) === me) return;   // already approved, or I made the latest version
      if (e.type === 'edit' && !isMonetaryEdit(g, e)) return;
      if (e.type === 'expense' || e.type === 'settle') { if (effectiveEntry(g, e).voided) return; }
      if (e.type === 'edit') { const t = g.entries.find(x => x.id === e.targetId); if (!t) return; const ef = effectiveEntry(g, t); if (ef.voided || ef.latestEditId !== e.id) return; }
      if (e.type === 'void') { const t = g.entries.find(x => x.id === e.targetId); if (t && effectiveEntry(g, t).voided) return; }
      out.push({ type: 'entry', g, e });
    });
    if (g.deletion && g.members.some(m => m.id === me) && !g.deletion.approvals[me]) out.push({ type: 'groupdel', g });
  });
  return out;
}
// A member "has activity" if they paid for, owe on, or authored any non-voided entry.
// Used to gate removal: only members with no activity can be removed (beta rule).
function memberHasActivity(group, id) {
  if (!group || !id) return false;
  return (group.entries || []).some(e => {
    const eff = effectiveEntry(group, e);
    if (eff.voided) return false;
    if (e.by === id || eff.paidBy === id || eff.from === id || eff.to === id) return true;
    if (eff.split && eff.split[id] != null) return true;
    return false;
  });
}
function relDate(ts) {
  const dt = new Date(ts);
  const time = dt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
  const days = Math.floor((Date.now() - ts) / 864e5);
  let day;
  if (days <= 0) day = 'Today'; else if (days === 1) day = 'Yesterday'; else if (days < 7) day = days + 'd ago';
  else day = dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  return `${day} · ${time}`;
}

// ── UI atoms ──────────────────────────────────────────────────
function Avatar({ member, name, size = 34 }) {
  const nm = (member && member.name) || name || '?';
  return (
    <span title={(member && member.email) || undefined} style={{ width: size, height: size, borderRadius: '50%', background: personColor(nm), flexShrink: 0, color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.4, fontWeight: 700 }}>
      {(nm.trim()[0] || '?').toUpperCase()}
    </span>
  );
}
function AvatarStack({ members, size = 22, max = 5 }) {
  const show = members.slice(0, max);
  return (
    <div style={{ display: 'flex', alignItems: 'center' }}>
      {show.map((m, i) => (
        <span key={i} style={{ marginLeft: i ? -7 : 0, borderRadius: '50%', boxShadow: '0 0 0 2px #fff', display: 'inline-flex' }}><Avatar member={m} size={size} /></span>
      ))}
      {members.length > max && <span style={{ marginLeft: 4, fontSize: 11, fontWeight: 700, color: C.sub }}>+{members.length - max}</span>}
    </div>
  );
}
function Btn({ children, onClick, color = C.blue, kind = 'solid', style = {}, disabled }) {
  const base = { height: 50, borderRadius: 14, border: 'none', cursor: disabled ? 'default' : 'pointer', fontSize: 16, fontWeight: 650, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, width: '100%', fontFamily: 'inherit', opacity: disabled ? 0.45 : 1, transition: 'transform .08s' };
  const sk = kind === 'solid' ? { background: color, color: '#fff', boxShadow: `0 8px 18px ${color}33` }
    : kind === 'ghost' ? { background: C.soft, color: C.ink, border: `1px solid ${C.line}` }
    : { background: 'transparent', color: C.sub };
  return <button onClick={disabled ? undefined : onClick} style={{ ...base, ...sk, ...style }}
    onMouseDown={e => !disabled && (e.currentTarget.style.transform = 'scale(0.97)')}
    onMouseUp={e => (e.currentTarget.style.transform = 'scale(1)')}
    onMouseLeave={e => (e.currentTarget.style.transform = 'scale(1)')}>{children}</button>;
}
function Sheet({ open, onClose, children, title }) {
  if (!open) return null;
  return (
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(14,23,38,0.45)', display: 'flex', alignItems: 'flex-end', justifyContent: 'center', animation: 'tlyFade .18s ease' }}>
      <div onClick={e => e.stopPropagation()} style={{ width: '100%', maxWidth: 460, background: C.bg, borderRadius: '24px 24px 0 0', padding: '10px 20px 28px', maxHeight: '92vh', overflowY: 'auto', animation: 'tlyUp .26s cubic-bezier(.2,.8,.2,1)' }}>
        <div style={{ width: 38, height: 5, borderRadius: 3, background: C.line, margin: '0 auto 14px' }} />
        {title && <div style={{ fontSize: 20, fontWeight: 800, color: C.ink, marginBottom: 16, letterSpacing: -0.4 }}>{title}</div>}
        {children}
      </div>
    </div>
  );
}
function Input({ value, onChange, placeholder, prefix, type = 'text', autoFocus, style = {} }) {
  return (
    <div style={{ height: 52, borderRadius: 14, border: `1.5px solid ${C.line}`, background: C.bg, display: 'flex', alignItems: 'center', padding: '0 14px', gap: 6, ...style }}>
      {prefix && <span style={{ fontSize: 17, color: C.sub, fontWeight: 600 }}>{prefix}</span>}
      <input value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder} type={type} autoFocus={autoFocus} inputMode={type === 'number' ? 'decimal' : undefined}
        style={{ flex: 1, border: 'none', outline: 'none', fontSize: 17, color: C.ink, background: 'transparent', fontFamily: 'inherit', minWidth: 0 }} />
    </div>
  );
}
const Label = ({ children }) => <div style={{ fontSize: 13, fontWeight: 700, color: C.sub, margin: '4px 0 8px' }}>{children}</div>;
const chipStyle = on => ({ display: 'flex', alignItems: 'center', gap: 7, padding: '7px 13px 7px 7px', borderRadius: 999, cursor: 'pointer', fontFamily: 'inherit', fontSize: 14, fontWeight: 600, background: on ? `${C.blue}14` : C.soft, color: on ? C.blue : C.ink, border: `1.5px solid ${on ? C.blue : C.line}` });
const Empty = ({ text }) => <div style={{ textAlign: 'center', color: C.faint, fontSize: 14, padding: '26px 0' }}>{text}</div>;
function PageHead({ title, sub, back, right }) {
  return (
    <div style={{ padding: '18px 20px 0', display: 'flex', alignItems: 'center', gap: 12 }}>
      {back && <button onClick={back} style={{ width: 38, height: 38, borderRadius: 11, background: C.soft, border: `1px solid ${C.line}`, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
        <svg width="9" height="16" viewBox="0 0 9 16"><path d="M7.5 1.5L1.5 8l6 6.5" stroke={C.ink} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
      </button>}
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 22, fontWeight: 800, color: C.ink, letterSpacing: -0.5 }}>{title}</div>
        {sub && <div style={{ fontSize: 13, color: C.sub, marginTop: 1 }}>{sub}</div>}
      </div>
      {right}
    </div>
  );
}
function ApprovalPill({ a }) {
  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 700, color: a.done ? C.green : C.orangeDk, background: a.done ? `${C.green}14` : `${C.orange}14`, padding: '2px 8px', borderRadius: 999 }}>
      {a.done
        ? <svg width="11" height="11" viewBox="0 0 24 24"><path d="M5 13l4 4L19 6" stroke={C.green} strokeWidth="3.4" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
        : <span style={{ width: 6, height: 6, borderRadius: '50%', background: C.orange }} />}
      {a.done ? 'Confirmed' : `${a.approved}/${a.total}`}
    </span>
  );
}
const Arrow = () => <svg width="22" height="14" viewBox="0 0 22 14" style={{ flexShrink: 0 }}><path d="M1 7h18m0 0l-5-5m5 5l-5 5" stroke={C.faint} strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>;
function Mark({ size = 40 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 100 100" style={{ display: 'block' }}>
      <defs><clipPath id={`m-${size}`}><path d="M50 6 A44 44 0 0 1 50 94 Z" /></clipPath></defs>
      <circle cx="50" cy="50" r="44" fill={C.blue} />
      <g clipPath={`url(#m-${size})`}><circle cx="50" cy="50" r="44" fill={C.orange} /></g>
      <rect x="47.5" y="6" width="5" height="88" rx="2.5" fill="#fff" opacity="0.95" />
    </svg>
  );
}

Object.assign(window, {
  C, PALETTE, uid, r2, personColor, equalSplit, fixRound, CATS, catEmoji, catName, load, EKEY, project, materializeDue, recurStep,
  involvedOf, requiredApprovers, approvalOf, effectiveEntry, effectiveBeforeEdit, editDiffs, baseEntries,
  netBalances, simplify, globalPairwise, consolidated, pendingForMe, relDate, uniq, describeEntry, describeEdit, fmtSplit, isMonetaryEdit, splitDeltas, memberHasActivity,
  Avatar, AvatarStack, Btn, Sheet, Input, Label, chipStyle, Empty, PageHead, ApprovalPill, Arrow, Mark,
});
