/* ============================================================
   Save-anywhere — a shared heart toggle + a tiny store.

   2026-06-05 7:47PM — Part B of doc 03 (translate-and-saved).
   • <SaveHeart {item, lemmaId, kind, surface}> — one shared
     affordance dropped on every Russian surface (StackedGloss
     header, dictionary entries, translate results, saved rows).
   • window.SavedStore — list/isSaved/save/unsave/mergeOnSignIn.
     Signed-in → Supabase `saved_items` (RLS owner-scoped).
     Anonymous → localStorage 'ru_saved_v1' (merged on sign-in
     via upsert do-nothing onConflict 'user_id,dedupe_key').
   What: net-new save model, no existing users affected.
   Why:  doc 03 §B — keep any Russian you meet, find it in Saved.
   Status: ⚠️ PENDING USER TESTING
   ============================================================ */
(function () {
  const { useState, useEffect, useCallback } = React;

  const LS_KEY = 'ru_saved_v1';
  const MERGED_FLAG = 'ru_saved_merged_v1';

  /* ---- dedupe key -------------------------------------------------
     lemma saves  → 'lemma:<id>'
     ad-hoc saves → 'text:' + sha256hex(normalized_ru + '|' + direction)
     sha256 via crypto.subtle when available (async); tiny sync
     FNV-1a fallback when it isn't (file://, no subtle, etc.). The
     fallback is deterministic so client/server stay consistent
     within the anonymous-only path; lemma keys never need hashing. */
  function normRu(s) {
    return (s || '').trim().toLowerCase().replace(/\s+/g, ' ');
  }
  function fnv1aHex(str) {
    let h = 0x811c9dc5;
    for (let i = 0; i < str.length; i++) {
      h ^= str.charCodeAt(i);
      h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
    }
    return ('00000000' + h.toString(16)).slice(-8);
  }
  async function sha256hex(str) {
    try {
      if (window.crypto && window.crypto.subtle) {
        const buf = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
        return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
      }
    } catch (e) { /* fall through */ }
    return fnv1aHex(str);
  }
  // Sync best-effort key for first render (UI optimism); the async
  // version is authoritative for writes. For lemma items it's exact.
  function dedupeKeySync(item, lemmaId, kind) {
    if (kind === 'lemma' && lemmaId != null) return 'lemma:' + lemmaId;
    if (kind === 'lesson' && item && item.lessonId) return 'lesson:' + item.lessonId;
    // 2026-06-07 12:30AM VERIFIED — photo translations dedupe on their stable imageId.
    if (kind === 'image' && item && item.imageId) return 'image:' + item.imageId;
    return 'text:' + fnv1aHex(normRu(item && item.ru) + '|' + ((item && item.direction) || 'ru'));
  }
  async function dedupeKey(item, lemmaId, kind) {
    if (kind === 'lemma' && lemmaId != null) return 'lemma:' + lemmaId;
    if (kind === 'lesson' && item && item.lessonId) return 'lesson:' + item.lessonId;
    if (kind === 'image' && item && item.imageId) return 'image:' + item.imageId;
    const hex = await sha256hex(normRu(item && item.ru) + '|' + ((item && item.direction) || 'ru'));
    return 'text:' + hex;
  }

  /* ---- session helpers ------------------------------------------- */
  async function getSession() {
    try {
      if (!window.sb) return null;
      const { data } = await window.sb.auth.getSession();
      return (data && data.session) || null;
    } catch (e) { return null; }
  }

  /* ---- localStorage (anonymous) ---------------------------------- */
  function lsRead() {
    try { return JSON.parse(localStorage.getItem(LS_KEY) || '[]'); }
    catch (e) { return []; }
  }
  function lsWrite(arr) {
    try { localStorage.setItem(LS_KEY, JSON.stringify(arr)); } catch (e) {}
  }

  /* ---- store ------------------------------------------------------ */
  const listeners = new Set();
  function emit() { listeners.forEach(fn => { try { fn(); } catch (e) {} }); }

  async function list() {
    const session = await getSession();
    if (session) {
      const { data, error } = await window.sb
        .from('saved_items')
        .select('id,kind,lemma_id,dedupe_key,payload,source_surface,created_at')
        .order('created_at', { ascending: false });
      if (error) { console.error('[saved] list failed', error.message); throw error; }
      return data || [];
    }
    return lsRead().slice().sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
  }

  // Synchronous snapshot for the heart's initial state (anon path is
  // instant; signed-in path is hydrated by a one-time fetch elsewhere).
  let _signedInKeys = null; // Set of dedupe_keys, lazily filled
  async function refreshSignedInKeys() {
    const session = await getSession();
    if (!session) { _signedInKeys = null; return; }
    try {
      const { data } = await window.sb.from('saved_items').select('dedupe_key');
      _signedInKeys = new Set((data || []).map(r => r.dedupe_key));
    } catch (e) { _signedInKeys = _signedInKeys || new Set(); }
  }
  function isSavedSync(key) {
    if (_signedInKeys) return _signedInKeys.has(key);
    return lsRead().some(x => x.dedupe_key === key);
  }
  async function isSaved(key) {
    const session = await getSession();
    if (session) {
      const { data, error } = await window.sb
        .from('saved_items').select('id').eq('dedupe_key', key).limit(1);
      if (error) return false;
      return !!(data && data.length);
    }
    return lsRead().some(x => x.dedupe_key === key);
  }

  async function save({ item, lemmaId, kind, surface }) {
    const key = await dedupeKey(item, lemmaId, kind);
    const payload = item; // canonical StackedGloss item snapshot
    const session = await getSession();
    if (session) {
      const row = {
        user_id: session.user.id,
        kind,
        lemma_id: (kind === 'lemma' && lemmaId != null) ? lemmaId : null,
        dedupe_key: key,
        payload,
        source_surface: surface || null,
      };
      const { error } = await window.sb
        .from('saved_items')
        .upsert(row, { onConflict: 'user_id,dedupe_key', ignoreDuplicates: true });
      if (error) { console.error('[saved] save failed', error.message); throw error; }
      if (_signedInKeys) _signedInKeys.add(key);
    } else {
      const arr = lsRead();
      if (!arr.some(x => x.dedupe_key === key)) {
        arr.push({ kind, lemma_id: (kind === 'lemma' ? lemmaId : null), dedupe_key: key,
                   payload, source_surface: surface || null, created_at: new Date().toISOString() });
        lsWrite(arr);
      }
    }
    emit();
    return key;
  }

  async function unsave({ item, lemmaId, kind, dedupeKey: providedKey }) {
    const key = providedKey || await dedupeKey(item, lemmaId, kind);
    const session = await getSession();
    if (session) {
      const { error } = await window.sb.from('saved_items').delete().eq('dedupe_key', key);
      if (error) { console.error('[saved] unsave failed', error.message); throw error; }
      if (_signedInKeys) _signedInKeys.delete(key);
    } else {
      lsWrite(lsRead().filter(x => x.dedupe_key !== key));
    }
    emit();
    return key;
  }

  /* ---- merge-on-signin ------------------------------------------- */
  async function mergeOnSignIn() {
    try {
      if (localStorage.getItem(MERGED_FLAG) === '1') return;
      const session = await getSession();
      if (!session) return;
      const local = lsRead();
      if (local.length) {
        const rows = local.map(x => ({
          user_id: session.user.id,
          kind: x.kind,
          lemma_id: x.lemma_id != null ? x.lemma_id : null,
          dedupe_key: x.dedupe_key,
          payload: x.payload,
          source_surface: x.source_surface || null,
        }));
        const { error } = await window.sb
          .from('saved_items')
          .upsert(rows, { onConflict: 'user_id,dedupe_key', ignoreDuplicates: true });
        if (error) {
          console.error('[saved] merge failed — keeping local for retry', error.message);
          return; // do NOT mark merged; leave local intact to retry
        }
      }
      localStorage.setItem(MERGED_FLAG, '1');
      await refreshSignedInKeys();
      emit();
    } catch (e) {
      console.error('[saved] merge error', e);
    }
  }

  // React to auth — merge anonymous saves when a session appears.
  if (window.sb && window.sb.auth && window.sb.auth.onAuthStateChange) {
    window.sb.auth.onAuthStateChange((event, session) => {
      if (session) {
        // a fresh sign-in resets the merged flag intent only if it was
        // never merged for THIS account; the flag is global per-device,
        // which is fine for the single-learner prototype.
        mergeOnSignIn();
        refreshSignedInKeys().then(emit);
      } else {
        _signedInKeys = null;
        emit();
      }
    });
    // Prime keys for an already-active session on load.
    refreshSignedInKeys().then(emit);
  }

  window.SavedStore = {
    list, isSaved, isSavedSync, save, unsave, mergeOnSignIn,
    dedupeKey, dedupeKeySync, refreshSignedInKeys,
    subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
  };

  /* ---- the heart component --------------------------------------- */
  function Heart({ filled, size = 20, color }) {
    return (
      <svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ display:'block' }}>
        <path d="M12 20.5l-1.4-1.27C5.4 14.5 2.5 11.9 2.5 8.6 2.5 6.06 4.5 4 7.05 4
                 c1.54 0 3.04.72 3.95 1.86C11.9 4.72 13.4 4 14.95 4 17.5 4 19.5 6.06 19.5 8.6
                 c0 3.3-2.9 5.9-8.1 10.63L12 20.5z"
          fill={filled ? (color || 'var(--primary)') : 'none'}
          stroke={filled ? (color || 'var(--primary)') : 'var(--muted)'}
          strokeWidth="1.8" strokeLinejoin="round" />
      </svg>
    );
  }

  function SaveHeart({ item, lemmaId, kind = 'phrase', surface, size = 20, style }) {
    // Authoritative key is async (sha256 for ad-hoc, exact for lemma).
    // Compute it once on mount; until it resolves, fall back to the sync
    // key for first paint. All save/unsave/isSaved checks use `key`.
    const [key, setKey] = useState(() => dedupeKeySync(item, lemmaId, kind));
    const [saved, setSaved] = useState(() => SavedStore.isSavedSync(dedupeKeySync(item, lemmaId, kind)));
    const [busy, setBusy] = useState(false);
    const [pop, setPop] = useState(false);

    useEffect(() => {
      let alive = true;
      dedupeKey(item, lemmaId, kind).then(k => { if (alive) { setKey(k); setSaved(SavedStore.isSavedSync(k)); } });
      return () => { alive = false; };
    }, [item && item.ru, lemmaId, kind]);

    // Keep in sync with store changes (other hearts, merge, auth flips).
    useEffect(() => {
      const refresh = () => setSaved(SavedStore.isSavedSync(key));
      refresh();
      return SavedStore.subscribe(refresh);
    }, [key]);

    const toggle = useCallback(async (e) => {
      if (e) { e.stopPropagation(); e.preventDefault(); }
      if (busy) return;
      const next = !saved;
      setSaved(next);            // optimistic
      if (next) setPop(true);
      setBusy(true);
      try {
        if (next) await SavedStore.save({ item, lemmaId, kind, surface });
        else await SavedStore.unsave({ item, lemmaId, kind, dedupeKey: key });
      } catch (err) {
        setSaved(!next);         // rollback — fail loudly
        if (window.toast) window.toast("Couldn't save — try again");
        else console.error('[saved] toggle failed', err);
      } finally {
        setBusy(false);
        setTimeout(() => setPop(false), 240);
      }
    }, [saved, busy, item, lemmaId, kind, surface, key]);

    return (
      <button onClick={toggle} aria-label={saved ? 'Saved' : 'Save'} aria-pressed={saved}
        style={{ appearance:'none', border:'none', background:'transparent', cursor:'pointer',
          padding:6, borderRadius:999, display:'flex', alignItems:'center', justifyContent:'center',
          flexShrink:0, transition:'transform .12s',
          transform: pop ? 'scale(1.25)' : 'scale(1)', ...style }}>
        <Heart filled={saved} size={size} />
      </button>
    );
  }

  /* ---- LessonProgress: which lessons are completed (persists + reflects) ----
     2026-06-06 — completion lives under a DEDICATED key 'ru_lesson_done_<id>'
     (written by the Complete screen + WordGrid's "mark complete") plus
     user_progress (item_type='lesson', status='done'). Per-word checks live
     under 'ru_lesson_<id>' ({<ru>:1}) and must NOT count as done. We union the
     completion sources into a doneSet so lesson lists reflect real completion.
     2026-06-06 — BUG FIX: previously key-existence of 'ru_lesson_<id>' marked a
     lesson done, so checking ONE word completed the lesson. Now done requires
     the dedicated done-key OR a legacy completion object ({at}|{accuracy}).
     PENDING USER TESTING */
  const lpListeners = new Set();
  const lpEmit = () => lpListeners.forEach(fn => { try { fn(); } catch (e) {} });
  let lpDone = new Set();
  function lpReadLocal() {
    const s = new Set();
    try {
      for (let i = 0; i < localStorage.length; i++) {
        const k = localStorage.key(i);
        if (!k) continue;
        if (k.indexOf('ru_lesson_done_') === 0) {
          // dedicated completion marker — always counts as done
          s.add(k.slice('ru_lesson_done_'.length));
        } else if (k.indexOf('ru_lesson_') === 0) {
          // legacy 'ru_lesson_<id>' — done ONLY if a legacy completion object
          // ({at}|{accuracy}); a word-checks-only object ({<ru>:1}) is NOT done.
          let parsed = null;
          try { parsed = JSON.parse(localStorage.getItem(k)); } catch (e) {}
          if (parsed && (parsed.at || parsed.accuracy)) s.add(k.slice('ru_lesson_'.length));
        }
      }
    } catch (e) {}
    return s;
  }
  async function lpRefresh() {
    lpDone = lpReadLocal();
    lpEmit();
    try {
      if (!window.sb) return;
      const { data } = await window.sb.auth.getSession();
      if (!(data && data.session)) return;
      const { data: rows, error } = await window.sb.from('user_progress')
        .select('item_key').eq('item_type', 'lesson').eq('status', 'done');
      if (error) { console.error('[lessonprogress] read', error.message); return; }
      (rows || []).forEach(r => { if (r.item_key) lpDone.add(r.item_key); });
      lpEmit();
    } catch (e) { console.error('[lessonprogress] refresh', e); }
  }
  window.LessonProgress = {
    isDone: (id) => lpDone.has(id),
    markDone: (id) => { lpDone.add(id); lpEmit(); },
    refresh: lpRefresh,
    subscribe(fn) { lpListeners.add(fn); return () => lpListeners.delete(fn); },
  };
  if (window.sb && window.sb.auth && window.sb.auth.onAuthStateChange) {
    window.sb.auth.onAuthStateChange(() => lpRefresh());
  }
  lpRefresh();

  window.SaveHeart = SaveHeart;
})();
