// Shared utilities and hooks for the practice prototype.
// Loaded after React; relies on globals: React, VOCAB, CATEGORY_META.

const { useState, useEffect, useRef, useMemo, useCallback } = React;

// ---------- answer normalization ----------
// Lower-case, strip leading/trailing whitespace, collapse internal whitespace,
// drop trailing punctuation/article noise, and strip diacritics for forgiving compare.
function stripAccents(s) {
  // Strip combining diacritical marks (U+0300–U+036F) after NFD decomposition.
  // NB: do NOT use /\p{Diacritic}/u here — that Unicode property escape is a
  // parse-time SyntaxError in some Safari/JSC versions, which would kill this
  // whole (early, critical) file and blank the entire app in Safari.
  return s.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function normalize(raw) {
  let s = (raw || "").toLowerCase().trim();
  s = s.replace(/\s+/g, " ");
  s = s.replace(/[.!?]+$/g, "");
  return s;
}
function stripSpaces(s) {
  return String(s || "").replace(/\s+/g, "");
}
function checkAnswer(user, item) {
  const u = normalize(user);
  if (!u) return { ok: false, exact: false };
  const candidates = [item.answer, ...(item.alts || [])].map(normalize);
  // 1) Exact (post-normalize) match.
  if (candidates.includes(u)) return { ok: true, exact: true };
  // 2) Space-insensitive match — "werereducing" == "were reducing",
  //    "mostbeautiful" == "most beautiful". Treated as fully correct.
  const uNoSpace = stripSpaces(u);
  if (candidates.map(stripSpaces).includes(uNoSpace)) return { ok: true, exact: true };
  // 3) Accent-tolerant match — still ok, but flag inexact.
  const uPlain = stripAccents(u);
  if (candidates.map(stripAccents).includes(uPlain)) return { ok: true, exact: false };
  return { ok: false, exact: false };
}

// ---------- per-user storage namespacing ----------
const STATS_KEY_BASE    = "vp.stats.v1";
const SETTINGS_KEY      = "vp.settings.v1";   // settings stay device-wide (sound/dark)
const CURRENT_USER_KEY  = "vp.currentUser.v1";
const ACCOUNTS_KEY      = "vp.accounts.v1";

function statsKeyFor(userKey) {
  return `${STATS_KEY_BASE}__${userKey || "guest"}`;
}

// One-shot migration: if the user has stats from before namespacing was added,
// move them into the guest bucket so they keep their progress.
(function migrateLegacyStatsOnce() {
  try {
    const legacy = localStorage.getItem(STATS_KEY_BASE);
    const guest = localStorage.getItem(statsKeyFor("guest"));
    if (legacy && !guest) {
      localStorage.setItem(statsKeyFor("guest"), legacy);
    }
  } catch (e) {}
})();

// ---------- lifetime stats persistence ----------
function loadStats(userKey) {
  try {
    const raw = localStorage.getItem(statsKeyFor(userKey));
    if (!raw) return defaultStats();
    const parsed = JSON.parse(raw);
    return { ...defaultStats(), ...parsed };
  } catch (e) {
    return defaultStats();
  }
}
function defaultStats() {
  return {
    totalAnswered: 0,
    totalCorrect: 0,
    totalScore: 0,             // cumulative lifetime points (sum of session scores)
    bestSessionScore: 0,
    bestStreak: 0,
    sessions: [],              // [{ ts, score, total, correct, accuracy, durationMs }]
    perItem: {},               // id -> { seen, correct, lastSeen, lastCorrect }
    perCategory: {},           // cat -> { seen, correct }
    dailyHistory: {},          // 'YYYY-MM-DD' -> { answered, correct }
  };
}
function saveStats(userKey, stats) {
  try { localStorage.setItem(statsKeyFor(userKey), JSON.stringify(stats)); } catch (e) {}
}

// OGE/EGE stats are tracked separately but stored together as one bundle
// { ege, oge } (both locally and on the server). Legacy flat stats (from before
// exam modes) are adopted as the EGE track so nobody loses progress.
// OGE passage item-ids were renamed oge_p_<CODE> -> oge_g_<CODE> when the bank
// was rebuilt against the answer key. Carry old per-item stats (incl. mistakes)
// over so they don't orphan — otherwise the mistakes badge counts them but the
// list can't find them in VOCAB.
function migrateItemIds(perItem) {
  if (!perItem || typeof perItem !== "object") return {};
  const out = {};
  for (const id in perItem) {
    const newId = id.indexOf("oge_p_") === 0 ? "oge_g_" + id.slice(6) : id;
    const prev = out[newId];
    if (prev) {
      out[newId] = {
        seen: (prev.seen || 0) + (perItem[id].seen || 0),
        correct: (prev.correct || 0) + (perItem[id].correct || 0),
        lastSeen: Math.max(prev.lastSeen || 0, perItem[id].lastSeen || 0),
        lastCorrect: perItem[id].lastCorrect,
      };
    } else {
      out[newId] = perItem[id];
    }
  }
  return out;
}
function withMigratedItems(track) {
  const t = { ...defaultStats(), ...(track || {}) };
  t.perItem = migrateItemIds(t.perItem);
  return t;
}
function normalizeBundle(obj) {
  if (obj && typeof obj === "object" && (obj.ege || obj.oge)) {
    return { ege: withMigratedItems(obj.ege), oge: withMigratedItems(obj.oge) };
  }
  return { ege: withMigratedItems(obj || {}), oge: defaultStats() };
}
function loadBundle(userKey) {
  try {
    const raw = localStorage.getItem(statsKeyFor(userKey));
    if (!raw) return { ege: defaultStats(), oge: defaultStats() };
    return normalizeBundle(JSON.parse(raw));
  } catch (e) { return { ege: defaultStats(), oge: defaultStats() }; }
}
function saveBundle(userKey, bundle) {
  try { localStorage.setItem(statsKeyFor(userKey), JSON.stringify(bundle)); } catch (e) {}
}
function todayKey() {
  const d = new Date();
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}

function useStats(userKey, syncTick, examMode) {
  const mode = examMode === "oge" ? "oge" : "ege";
  // Stats are tracked SEPARATELY per exam mode. The stored unit (locally + server)
  // is a bundle { ege, oge }; the app sees the ACTIVE mode's slice as flat `stats`.
  // Switching mode just swaps the slice — no progress is lost.
  const [bundle, setBundle] = useState(() => loadBundle(userKey));
  const stats = bundle[mode] || defaultStats();
  const setStats = useCallback((updater) => {
    setBundle(b => {
      const cur = b[mode] || defaultStats();
      return { ...b, [mode]: typeof updater === "function" ? updater(cur) : updater };
    });
  }, [mode]);
  const signedIn = userKey && userKey !== "guest";
  const TS_KEY = `vp.stats.ts.v1__${userKey}`;
  const updatedAtRef = useRef(0);          // time of our latest LOCAL change
  const lastSyncedRef = useRef(null);      // JSON of the copy we know the server has
  const prevSyncTickRef = useRef(syncTick);

  // (Re)load when the user changes or the auth layer hands us a fresh payload.
  useEffect(() => {
    const b = loadBundle(userKey);
    setBundle(b);
    // A syncTick bump = auth just handed us data (sign-in pulled the server copy,
    // or sign-up migrated local data) → (re)push so the server definitely has it.
    // A plain mount / user-swap marks the copy as already-synced so we don't
    // clobber a possibly-newer server copy before our first pull lands.
    const tickChanged = syncTick !== prevSyncTickRef.current;
    prevSyncTickRef.current = syncTick;
    lastSyncedRef.current = tickChanged ? "force-resync" : JSON.stringify(b);
    try { updatedAtRef.current = Number(localStorage.getItem(TS_KEY)) || 0; } catch (e) { updatedAtRef.current = 0; }
  }, [userKey, syncTick]);

  // Persist locally on every change (NOT keyed on userKey — see note above).
  useEffect(() => { saveBundle(userKey, bundle); }, [bundle]);

  // Adopt a server copy (a bundle, or legacy flat → EGE) newer than our local one.
  const adopt = useCallback((serverData, serverTs) => {
    const b = normalizeBundle(serverData);
    updatedAtRef.current = serverTs;
    lastSyncedRef.current = JSON.stringify(b);
    try { localStorage.setItem(TS_KEY, String(serverTs)); } catch (e) {}
    saveBundle(userKey, b);
    setBundle(b);
  }, [userKey, TS_KEY]);

  // Pull the server's copy; adopt only if it's newer than us.
  const pull = useCallback(() => {
    if (!signedIn) return;
    const token = localStorage.getItem(TOKEN_KEY);
    if (!token) return;
    fetch("/api/stats/sync", { headers: { "Authorization": `Bearer ${token}` } })
      .then(r => r.json())
      .then(d => { if (d && d.ok && d.stats && (d.updatedAt || 0) > updatedAtRef.current) adopt(d.stats, d.updatedAt); })
      .catch(() => {});
  }, [signedIn, adopt]);

  // Debounced push on local change, stamped with the REAL change time so a
  // stale device can't clobber a fresher one. If the server had something
  // newer, adopt it instead of overwriting.
  const pendingTimer = useRef(null);
  useEffect(() => {
    if (!signedIn) return;
    const js = JSON.stringify(bundle);
    if (js === lastSyncedRef.current) return; // nothing new (freshly loaded/adopted)
    const ts = Date.now();
    updatedAtRef.current = ts;
    try { localStorage.setItem(TS_KEY, String(ts)); } catch (e) {}
    if (pendingTimer.current) clearTimeout(pendingTimer.current);
    pendingTimer.current = setTimeout(() => {
      const token = localStorage.getItem(TOKEN_KEY);
      if (!token) return;
      fetch("/api/stats/sync", {
        method: "POST",
        headers: { "content-type": "application/json", "Authorization": `Bearer ${token}` },
        body: JSON.stringify({ stats: bundle, updatedAt: ts }),
      }).then(r => r.json()).then(d => {
        if (!d || !d.ok) return;
        if (d.wrote === false && d.stats && (d.updatedAt || 0) > updatedAtRef.current) adopt(d.stats, d.updatedAt);
        else lastSyncedRef.current = js; // our copy is now the server's
      }).catch(() => { /* offline — local already saved */ });
    }, 1500);
    return () => { if (pendingTimer.current) clearTimeout(pendingTimer.current); };
  }, [bundle, signedIn]);

  // Pull on mount + whenever the app regains focus/visibility + every 45s while
  // visible, so an idle device converges with what other devices logged (fixes
  // the "last day didn't count on my phone" cross-device desync).
  useEffect(() => {
    if (!signedIn) return;
    pull();
    const onVis = () => { if (document.visibilityState === "visible") pull(); };
    document.addEventListener("visibilitychange", onVis);
    window.addEventListener("focus", pull);
    const iv = setInterval(() => { if (document.visibilityState === "visible") pull(); }, 45000);
    return () => {
      document.removeEventListener("visibilitychange", onVis);
      window.removeEventListener("focus", pull);
      clearInterval(iv);
    };
  }, [signedIn, userKey, pull]);

  // `gaps` (optional) = { correct, total } for OGE passages — a passage counts
  // as `total` words toward lifetime/category/daily accuracy, with `correct`
  // right, so a 7/9 passage is 7 right of 9 (not all-or-nothing). The per-ITEM
  // entry stays whole-passage (it's a mistake unless every gap is right).
  const recordAnswer = useCallback((item, correct, gaps) => {
    const gTotal = gaps ? Math.max(1, gaps.total) : 1;
    const gCorrect = gaps ? Math.min(gTotal, Math.max(0, gaps.correct)) : (correct ? 1 : 0);
    setStats(prev => {
      const next = { ...prev };
      next.totalAnswered = prev.totalAnswered + gTotal;
      next.totalCorrect = prev.totalCorrect + gCorrect;

      const pi = prev.perItem[item.id] || { seen: 0, correct: 0, lastSeen: 0 };
      next.perItem = { ...prev.perItem, [item.id]: {
        seen: pi.seen + 1,
        correct: pi.correct + (correct ? 1 : 0),
        lastSeen: Date.now(),
        lastCorrect: correct,
      }};

      const pc = prev.perCategory[item.category] || { seen: 0, correct: 0 };
      next.perCategory = { ...prev.perCategory, [item.category]: {
        seen: pc.seen + gTotal,
        correct: pc.correct + gCorrect,
      }};

      const tk = todayKey();
      const td = prev.dailyHistory[tk] || { answered: 0, correct: 0 };
      next.dailyHistory = { ...prev.dailyHistory, [tk]: {
        answered: td.answered + gTotal,
        correct: td.correct + gCorrect,
      }};

      return next;
    });
  }, []);

  const recordSession = useCallback((session) => {
    setStats(prev => {
      const next = { ...prev };
      // Keep per-question review detail only for the last 10 sessions; strip it
      // from older ones so the synced stats blob stays small.
      next.sessions = [...prev.sessions, session].slice(-50)
        .map((s, i, arr) => (i < arr.length - 10 && s.questions && s.questions.length ? { ...s, questions: [] } : s));
      next.bestSessionScore = Math.max(prev.bestSessionScore, session.score);
      next.bestStreak = Math.max(prev.bestStreak, session.bestStreak || 0);
      // cumulative lifetime points (seed once from existing sessions for stats
      // saved before this field existed).
      const prevTotal = prev.totalScore != null
        ? prev.totalScore
        : (prev.sessions || []).reduce((a, s) => a + (Number(s && s.score) || 0), 0);
      next.totalScore = prevTotal + (Number(session.score) || 0);
      return next;
    });
  }, []);

  const resetStats = useCallback(() => {
    setStats(defaultStats());
  }, []);

  return { stats, recordAnswer, recordSession, resetStats };
}

// ---------- mistakes pool ----------
// An item is "in the mistakes pool" if the last attempt was wrong, OR if it has
// no lastCorrect recorded but seen > correct (legacy entries from before the field existed).
function isMistakeStat(stat) {
  if (!stat || stat.seen === 0) return false;
  if (stat.lastCorrect === false) return true;
  if (stat.lastCorrect === undefined && stat.seen > stat.correct) return true;
  return false;
}
function mistakeItems(stats) {
  return Object.entries(stats.perItem || {})
    .filter(([, s]) => isMistakeStat(s))
    .map(([id]) => VOCAB.find(v => v.id === id))
    .filter(Boolean);
}

// ---------- session shuffle ----------
function shuffle(arr) {
  const a = arr.slice();
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}
function pickSession({ categories, length }) {
  // An item's *task type* is its family for the 3-type picker. Some grammar/WF
  // categories also contain multiple-choice items (cue-less items shown as MC),
  // so honour the enabled families by item.type — picking only Grammar + Word
  // Formation must not yield multiple-choice questions.
  const fams = new Set(categories.map(c => (window.CATEGORY_META[c] || {}).family).filter(Boolean));
  let pool = VOCAB.filter(v => categories.includes(v.category) && fams.has(v.type));
  if (pool.length === 0) pool = VOCAB.filter(v => categories.includes(v.category));
  if (pool.length === 0) pool = VOCAB.slice();
  // De-dup by content so the same task never appears twice in one set (endless
  // sessions still cycle once the unique pool is exhausted).
  const seenKey = new Set();
  pool = pool.filter(v => { const k = v.sentence || v.text || v.id; if (seenKey.has(k)) return false; seenKey.add(k); return true; });
  pool = shuffle(pool);
  if (length === Infinity) return pool;
  return pool.slice(0, Math.min(length, pool.length));
}
function pickMistakesSession({ stats, length }) {
  const pool = shuffle(mistakeItems(stats));
  if (length === Infinity || !length) return pool;
  return pool.slice(0, Math.min(length, pool.length));
}

// ---------- sound (Web Audio API tones) ----------
function makeBeeper() {
  let ctx = null;
  function play(freq, duration = 0.08, type = "sine", gain = 0.04) {
    try {
      if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
      const o = ctx.createOscillator();
      const g = ctx.createGain();
      o.type = type; o.frequency.value = freq;
      g.gain.value = gain;
      o.connect(g); g.connect(ctx.destination);
      const t = ctx.currentTime;
      g.gain.setValueAtTime(gain, t);
      g.gain.exponentialRampToValueAtTime(0.0001, t + duration);
      o.start(t); o.stop(t + duration);
    } catch (e) {}
  }
  return {
    correct: () => { play(660, 0.09); setTimeout(() => play(880, 0.11), 70); },
    wrong:   () => { play(220, 0.18, "triangle", 0.05); },
    tick:    () => { play(1200, 0.02, "square", 0.015); },
  };
}

// ---------- date helpers for the history chart ----------
function lastNDays(n) {
  const out = [];
  const now = new Date();
  for (let i = n - 1; i >= 0; i--) {
    const d = new Date(now);
    d.setDate(now.getDate() - i);
    const y = d.getFullYear();
    const m = String(d.getMonth() + 1).padStart(2, "0");
    const day = String(d.getDate()).padStart(2, "0");
    out.push(`${y}-${m}-${day}`);
  }
  return out;
}
function formatDuration(ms) {
  const s = Math.round(ms / 1000);
  const m = Math.floor(s / 60);
  const r = s % 60;
  if (m === 0) return `${r}s`;
  return `${m}m ${String(r).padStart(2, "0")}s`;
}

// expose
Object.assign(window, {
  checkAnswer, normalize, stripAccents,
  useStats, defaultStats, todayKey,
  shuffle, pickSession, pickMistakesSession,
  mistakeItems, isMistakeStat,
  makeBeeper,
  lastNDays, formatDuration,
  SETTINGS_KEY, CURRENT_USER_KEY, ACCOUNTS_KEY,
});
