// tally-wiring.jsx — T1 (auth gate) + T2 (per-group sync), wired on top of
// tally-sync.js WITHOUT changing core.jsx's event semantics or breaking the
// offline local app. Loaded after core.jsx + app.jsx, before the ReactDOM mount.
//
// Design contract (matches the ACTUAL backend, not the aspirational handoff):
//   • Auth is COOKIE-based (httpOnly tally_session). There is no bearer token.
//   • Sync only turns on when window.TALLY_API_BASE is configured AND the user
//     is logged in. With no API base, this file is inert and the app runs exactly
//     as the offline prototype does today.
//
// Exposes on window:
//   TALLY_SYNC_ENABLED            — boolean, true once an API base is configured
//   AuthGate                      — React component wrapping <App/>
//   TallyGroupSync                — controller object (attach/append/open/markSynced)

(function (global) {
  const { useState, useEffect } = React;
  const C = global.C;

  // Sync is OFF unless an API base is explicitly configured. This is the single
  // switch that guarantees "don't break offline": no base → no gate, no network.
  const SYNC_ENABLED = !!global.TALLY_API_BASE;
  global.TALLY_SYNC_ENABLED = SYNC_ENABLED;

  // ───────────────────────── T2: per-group sync controller ─────────────────────
  // The app has ONE append point (dispatch in app.jsx). We hook it: every locally
  // appended event for a SYNCED group is also pushed to that group's channel.
  // Inbound peer events are merged back into the same React `events` array,
  // idempotently (dedupe by event id), so project() re-renders unchanged.
  const SYNCED_KEY = 'tally_synced_groups_v1';   // { [groupId]: { groupKey } }

  const TallyGroupSync = {
    _channels: {},        // groupId -> open channel handle
    _synced: {},          // groupId -> { groupKey }
    _addEvent: null,      // (tallyEvent) => void   — merges into React state
    _hasEvent: null,      // (id) => boolean        — idempotency check
    _getEvents: null,     // () => tallyEvent[]      — read the local log (for backlog replay)
    _ready: false,
    _replayed: {},        // groupId -> true once backlog has been pushed this session

    loadSynced() {
      try { this._synced = JSON.parse(localStorage.getItem(SYNCED_KEY) || '{}'); } catch { this._synced = {}; }
      return this._synced;
    },
    saveSynced() { try { localStorage.setItem(SYNCED_KEY, JSON.stringify(this._synced)); } catch {} },
    isSynced(groupId) { return !!this._synced[groupId]; },

    // App wires its state accessors in once, after mount.
    attach({ addEvent, hasEvent, getEvents }) {
      this._addEvent = addEvent;
      this._hasEvent = hasEvent;
      this._getEvents = getEvents;
      this._ready = true;
      if (SYNC_ENABLED && global.TallySync && TallySync.isLoggedIn()) this.resumeAll();
    },

    // Reopen channels for every group already marked synced (e.g. after reload).
    async resumeAll() {
      this.loadSynced();
      for (const groupId of Object.keys(this._synced)) {
        try { await this.open(groupId, this._synced[groupId].groupKey); } catch (e) { console.warn('resume sync failed', groupId, e); }
      }
    },

    // Mark a group as synced: create the server channel (creator=admin), remember
    // its group key locally, and open the live channel. Called on GroupCreated.
    async enableForGroup(groupId) {
      if (!SYNC_ENABLED || !global.TallySync || !TallySync.isLoggedIn()) return null;
      if (this._synced[groupId]) return this._channels[groupId] || this.open(groupId, this._synced[groupId].groupKey);
      const groupKey = TallySync.genGroupKey();
      await TallySync.createChannel(groupId);            // POST /channels {channelId, pubKey}
      this._synced[groupId] = { groupKey };
      this.saveSynced();
      return this.open(groupId, groupKey);
    },

    // Open (or reuse) the live channel for a group.
    async open(groupId, groupKey) {
      if (this._channels[groupId]) return this._channels[groupId];
      const chan = await TallySync.openChannel(groupId, groupKey, {
        onEvent: (tallyEvent) => {
          // idempotent inbound merge: ignore anything we already have (covers our
          // own echoes and re-delivered backfill). project() re-renders on add.
          if (this._hasEvent && this._hasEvent(tallyEvent.id)) return;
          this._addEvent && this._addEvent(tallyEvent);
        },
        onAck: () => {},
        onReject: (reason) => console.warn('sync reject', groupId, reason),
      });
      this._channels[groupId] = chan;
      this.replayBacklog(groupId);
      this.announceIdentity(groupId);
      return chan;
    },

    // Publish a MemberIdentity event (pubKey → display name) into the group so peers
    // can render our name instead of our raw pubkey. Self-published, synced like any
    // event. Idempotent-ish: only emits once per group per session, and only if we
    // have a name and a pubkey. (D1 decision: name travels as a synced event.)
    announceIdentity(groupId) {
      try {
        if (!global.TallySync || !TallySync.myAuthorId) return;
        if (!this._announced) this._announced = {};
        if (this._announced[groupId]) return;
        const name = (global.TALLY_displayName && global.TALLY_displayName()) || '';
        if (!name) return;
        this._announced[groupId] = true;
        const email = (global.TALLY_userEmail && global.TALLY_userEmail()) || '';
        const ev = { id: (global.uid ? global.uid() : Math.random().toString(36).slice(2)), seq: 0, ts: Date.now(), actor: TallySync.myAuthorId, type: 'MemberIdentity', streamId: groupId, data: { pubKey: TallySync.myAuthorId, name, email } };
        if (this._hasEvent && this._hasEvent(ev.id)) return;
        this._addEvent && this._addEvent(ev);          // local
        const chan = this._channels[groupId];
        if (chan) { try { chan.append(ev); } catch (e) { console.warn('announce append failed', e); } }
      } catch (e) { console.warn('announceIdentity failed', e); }
    },

    // Push any local events for this group that the server hasn't seen yet. The
    // channel's outbox + the DO's prevHash chain make this safe to call on every
    // open: already-synced events are deduped server-side by the chain, and the
    // local outbox is append-only. Runs once per group per session.
    replayBacklog(groupId) {
      if (this._replayed[groupId] || !this._getEvents) return;
      this._replayed[groupId] = true;
      const chan = this._channels[groupId];
      if (!chan) return;
      const local = this._getEvents().filter(e => e.streamId === groupId);
      // Only replay events we authored (others' events came FROM the channel and
      // must not be echoed back). Authored = actor is our pubkey or literal 'me'.
      const mine = global.TallySync && TallySync.myAuthorId;
      for (const ev of local) {
        if (ev.actor === mine || ev.actor === 'me') {
          try { chan.append(ev); } catch (e) { console.warn('replay append failed', e); }
        }
      }
    },

    // Called from dispatch() for EVERY locally appended event. If the event's
    // group is synced, push the full Tally event onto the channel (encrypt+sign+
    // queue happens inside tally-sync.js; offline-safe).
    onLocalAppend(tallyEvent) {
      if (!SYNC_ENABLED) return;
      const groupId = tallyEvent.streamId;
      const chan = this._channels[groupId];
      if (chan) { try { chan.append(tallyEvent); } catch (e) { console.warn('append failed', e); } }
    },

    closeAll() { Object.values(this._channels).forEach(c => { try { c.close(); } catch {} }); this._channels = {}; },

    // ── T4: invites ──────────────────────────────────────────────────────────
    // Produce an invite payload for a synced group: channelId + group key + a
    // snapshot of the group-defining events (GroupCreated + MemberAdded) so the
    // joiner adopts the SAME member-id space. Shared out-of-band (link/QR/text).
    createInvite(groupId) {
      if (!this._synced[groupId]) throw new Error('group is not synced');
      const all = (this._getEvents && this._getEvents()) || [];
      const snapshot = all.filter(e => e.streamId === groupId &&
        (e.type === 'GroupCreated' || e.type === 'MemberAdded' || e.type === 'ProfileSet'));
      const payload = { v: 1, channelId: groupId, groupKey: this._synced[groupId].groupKey, snapshot };
      // URL-safe base64 of the JSON; the caller builds the link/QR around it.
      const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(payload)))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
      return { code: b64, link: location.origin + '/?invite=' + b64, payload };
    },
    parseInvite(code) {
      let b64 = String(code).replace(/-/g, '+').replace(/_/g, '/');
      while (b64.length % 4) b64 += '=';            // restore stripped padding
      return JSON.parse(decodeURIComponent(escape(atob(b64))));
    },
    // Accept an invite: self-join the channel, adopt its group key, seed the
    // snapshot events locally (deduped), then open the channel to backfill the
    // rest. Returns the joined groupId.
    async acceptInvite(codeOrPayload) {
      if (!SYNC_ENABLED || !global.TallySync || !TallySync.isLoggedIn()) throw new Error('log in first');
      const payload = typeof codeOrPayload === 'string' ? this.parseInvite(codeOrPayload) : codeOrPayload;
      const { channelId, groupKey, snapshot } = payload;
      await TallySync.joinChannel(channelId);            // POST /channels/:id/join
      this._synced[channelId] = { groupKey };
      this.saveSynced();
      // seed group-defining events so member ids line up across devices
      (snapshot || []).forEach(ev => { if (this._addEvent) this._addEvent(ev); });
      await this.open(channelId, groupKey);              // backfills the ledger
      return channelId;
    },
  };
  global.TallyGroupSync = TallyGroupSync;

  // When synced, the local account acts under its Ed25519 pubkey. Tell core.jsx's
  // project() to render that id as "You" too (in addition to literal 'me').
  function refreshSelfIds() {
    const ids = { me: 1 };
    if (global.TallySync && TallySync.myAuthorId) ids[TallySync.myAuthorId] = 1;
    global.TALLY_SELF_IDS = ids;
  }
  global.TALLY_refreshSelfIds = refreshSelfIds;
  refreshSelfIds();

  // Local user's display name (D1: set at signup, stored locally, self-published as a
  // MemberIdentity event into each synced group). Exposed for the controller + core.
  const NAMEKEY = 'tally_display_name';
  const EMAILKEY = 'tally_user_email';
  function getDisplayName() { try { return localStorage.getItem(NAMEKEY) || ''; } catch { return ''; } }
  function setDisplayName(n) { try { localStorage.setItem(NAMEKEY, n || ''); } catch {} }
  function getUserEmail() { try { return localStorage.getItem(EMAILKEY) || ''; } catch { return ''; } }
  function setUserEmail(e) { try { localStorage.setItem(EMAILKEY, e || ''); } catch {} }
  global.TALLY_displayName = getDisplayName;
  global.TALLY_userEmail = getUserEmail;

  // ───────────────────────── T1: auth gate ─────────────────────────────────────
  // If sync is disabled, AuthGate is a pass-through — the offline app renders as-is.
  // If sync is enabled, we require login/signup before the app, restoring any
  // existing session silently first.
  function AuthScreen({ onAuthed }) {
    const [mode, setMode] = useState('login');     // 'login' | 'signup'
    const [name, setName] = useState('');
    const [email, setEmail] = useState('');
    const [pw, setPw] = useState('');
    const [busy, setBusy] = useState(false);
    const [err, setErr] = useState('');
    const [info, setInfo] = useState('');

    async function submit() {
      setErr(''); setInfo(''); setBusy(true);
      try {
        await TallySync.ready();
        if (mode === 'signup') {
          if (!name.trim()) { setErr('Please enter your name'); setBusy(false); return; }
          const res = await TallySync.signup(email.trim(), pw);
          setDisplayName(name.trim());                 // remember our display name (D1)
          setUserEmail(email.trim());                  // remember our email (disambiguator)
          // With email verification off (beta), the account is usable immediately, so
          // log straight in. If a deployment turns verification ON, signup returns
          // verified:false and we ask the user to verify first.
          if (res && res.verified === false) {
            setInfo('Account created. Check your email to verify, then log in.');
            setMode('login');
          } else {
            await TallySync.login(email.trim(), pw);
            refreshSelfIds();
            onAuthed();
          }
        } else {
          await TallySync.login(email.trim(), pw);
          setUserEmail(email.trim());                  // remember our email (disambiguator)
          refreshSelfIds();
          onAuthed();
        }
      } catch (e) {
        setErr(String(e && e.message || e));
      } finally { setBusy(false); }
    }

    const wrap = { minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '0 24px', background: C.soft2, maxWidth: 460, margin: '0 auto' };
    const card = { width: '100%', background: C.bg, borderRadius: 18, padding: 22, boxShadow: '0 20px 50px rgba(0,0,0,.10)' };
    return (
      <div style={wrap}>
        <div style={{ marginBottom: 18, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
          <window.Mark size={56} />
          <div style={{ fontSize: 22, fontWeight: 800, letterSpacing: -0.5, color: C.ink }}>Tally</div>
          <div style={{ fontSize: 13, fontWeight: 600, color: C.sub, marginTop: -6 }}>Split bills, keep friendships</div>
        </div>
        <div style={card}>
          <div style={{ display: 'flex', gap: 4, background: C.soft, borderRadius: 11, padding: 3, marginBottom: 16 }}>
            {[['login', 'Log in'], ['signup', 'Sign up']].map(([k, lbl]) => (
              <button key={k} onClick={() => { setMode(k); setErr(''); setInfo(''); }} style={{ flex: 1, height: 38, borderRadius: 8, border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 14, fontWeight: 700, background: mode === k ? C.bg : 'transparent', color: mode === k ? C.ink : C.sub, boxShadow: mode === k ? '0 1px 3px rgba(0,0,0,.08)' : 'none' }}>{lbl}</button>
            ))}
          </div>
          {mode === 'signup' && <>
            <window.Label>Your name</window.Label>
            <window.Input value={name} onChange={setName} placeholder="e.g. Alex" />
            <div style={{ height: 12 }} />
          </>}
          <window.Label>Email</window.Label>
          <window.Input value={email} onChange={setEmail} placeholder="you@example.com" type="email" />
          <div style={{ height: 12 }} />
          <window.Label>Password</window.Label>
          <window.Input value={pw} onChange={setPw} placeholder="••••••••" type="password" />
          {err && <div style={{ color: C.red, fontSize: 13, fontWeight: 600, marginTop: 12 }}>{err}</div>}
          {info && <div style={{ color: C.green, fontSize: 13, fontWeight: 600, marginTop: 12 }}>{info}</div>}
          <div style={{ height: 18 }} />
          <window.Btn disabled={busy || !email.trim() || !pw} onClick={submit}>
            {busy ? 'Working…' : mode === 'signup' ? 'Create account' : 'Log in'}
          </window.Btn>
        </div>
        <div style={{ fontSize: 12, color: C.faint, marginTop: 16, textAlign: 'center', lineHeight: 1.5 }}>
          Your private keys are generated on this device and never leave it in plaintext.
        </div>
      </div>
    );
  }

  function AuthGate({ children }) {
    // Pass-through when sync isn't configured — offline app is untouched.
    if (!SYNC_ENABLED) return children;
    const [state, setState] = useState('checking');   // 'checking' | 'authed' | 'anon'
    useEffect(() => {
      (async () => {
        try {
          await TallySync.ready();
          // restore() rehydrates the unlocked identity cached on this device.
          if (TallySync.restore && TallySync.restore()) { refreshSelfIds(); setState('authed'); return; }
        } catch {}
        setState('anon');
      })();
    }, []);
    if (state === 'checking') return null;             // splash stays up
    if (state === 'anon') return <AuthScreen onAuthed={() => setState('authed')} />;
    return <InviteCatcher>{children}</InviteCatcher>;
  }

  // Once authed, if the URL carries ?invite=<code>, accept it (self-join + seed),
  // then strip the param so a reload doesn't re-trigger. Best-effort and silent on
  // failure (e.g. invite for a channel you already joined → idempotent no-op).
  function InviteCatcher({ children }) {
    useEffect(() => {
      const params = new URLSearchParams(location.search);
      const code = params.get('invite');
      if (!code || !global.TallyGroupSync) return;
      (async () => {
        try { await TallyGroupSync.acceptInvite(code); }
        catch (e) { console.warn('invite accept failed', e); }
        finally {
          params.delete('invite');
          const qs = params.toString();
          history.replaceState({}, '', location.pathname + (qs ? '?' + qs : ''));
        }
      })();
    }, []);
    return children;
  }
  global.AuthGate = AuthGate;
})(window);
