/* Nutri Store — single-user, Firestore-backed.
   No more "users[]" or "projectMeta[]" — the logged-in Firebase user IS the user.
   Local in-memory cache + optimistic UI; cloud writes are best-effort and
   re-queue when offline (Firestore SDK handles the retry).

   Shape:
     state = {
       uid: null | string,
       profile: { uid, displayName, email, photoURL, createdAt, updatedAt },
       macroGoals: { calories, protein_g, carbs_g, fat_g },
       meals: [{ id, date, meal, name, calories, protein_g, carbs_g, fat_g, items, ... }],
       finance: [{ id, date, kind, amount, category, title, notes, account, time }],
       goals: [{ id, name, emoji, timesPerDay, weekdays, color, log: { date: [slots] } }],
       diary: [{ id, dateKey, title, eventText, mood, tags, ... }],
       assistantChat: [{ role, text, html, at }],
       settings: { notes: {date: text}, menu: [], weights: {date: kg} },
       lastSyncAt: ISO,
       syncStatus: 'idle' | 'loading' | 'syncing' | 'error' | 'offline',
       syncError: null | string,
     }
*/
(function(){
  const LOCAL_KEY = 'nutri_state_v2';      // new shape; old v1 is migrated on first attach
  const LEGACY_KEY = 'nutri_state';        // v1 multi-user

  function blankState() {
    return {
      uid: null,
      profile: null,
      macroGoals: { calories: NUTRI_GOALS.calories, protein_g: NUTRI_GOALS.protein_g, carbs_g: NUTRI_GOALS.carbs_g, fat_g: NUTRI_GOALS.fat_g },
      meals: [],
      finance: [],
      goals: [],
      diary: [],
      assistantChat: [],
      settings: { notes: {}, menu: [], weights: {} },
      localLibrary: [],
      movies: [],
      lastSyncAt: null,
      syncStatus: 'idle',
      syncError: null,
    };
  }

  let state = blankState();
  const subscribers = new Set();

  function notify() {
    saveLocal();
    subscribers.forEach(fn => { try { fn(); } catch(_) {} });
  }
  function setState(updater) {
    if (typeof updater === 'function') updater(state); else Object.assign(state, updater);
    notify();
  }
  function saveLocal() {
    try { localStorage.setItem(LOCAL_KEY, JSON.stringify({ ...state, _savedAt: Date.now() })); } catch(_) {}
  }
  function loadLocal() {
    try {
      const raw = localStorage.getItem(LOCAL_KEY);
      if (!raw) return null;
      const d = JSON.parse(raw);
      if (d && d.uid) return d;
    } catch(_) {}
    return null;
  }

  // ── Cloud queueing — debounce writes per-collection ──
  const writeTimers = {};
  const pendingFns  = {};  // last-registered write fn per collection (used by flush)

  function queueCloud(name, fn, delay = 400) {
    if (!state.uid || !window.Cloud) return;
    clearTimeout(writeTimers[name]);
    pendingFns[name] = fn;   // keep the latest fn so flushPendingWrites can fire it
    setState({ syncStatus: 'syncing' });
    writeTimers[name] = setTimeout(async () => {
      delete pendingFns[name];
      try {
        await fn();
        setState({ syncStatus: 'idle', syncError: null, lastSyncAt: new Date().toISOString() });
      } catch (e) {
        const offline = !navigator.onLine || /offline|network/i.test((e && e.message) || '');
        setState({ syncStatus: offline ? 'offline' : 'error', syncError: e && e.message ? e.message : String(e) });
        if (window.NutriLogger) NutriLogger.error('firebase', 'queueCloud:' + name, (e && e.message) || String(e), { stack: e && e.stack });
      }
    }, delay);
  }

  // ── Flush pending writes immediately (called on visibilitychange/beforeunload) ──
  // This prevents the 400ms debounce window from losing data when the user closes the tab.
  function flushPendingWrites() {
    const names = Object.keys(pendingFns);
    if (!names.length || !state.uid || !window.Cloud) return;
    names.forEach(name => {
      clearTimeout(writeTimers[name]);
      const fn = pendingFns[name];
      delete pendingFns[name];
      if (typeof fn === 'function') {
        fn().catch(e => {
          if (window.NutriLogger) NutriLogger.error('firebase', 'flushPendingWrites:' + name, (e && e.message) || String(e));
        });
      }
    });
  }

  // ── Register page-visibility + unload flush hooks (once, at module init) ──
  (function registerFlushHooks() {
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') flushPendingWrites();
    });
    window.addEventListener('beforeunload', flushPendingWrites);
    // Also flush on pagehide (iOS Safari fires this instead of beforeunload).
    window.addEventListener('pagehide', flushPendingWrites);
  })();

  // ── Movie Night helpers (shared by the movie Store methods below) ──
  function _nowISO() { return new Date().toISOString(); }
  function _todayKey() {
    const d = new Date();
    return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
  }
  // Metadata fields that fresh TMDb data may refresh — user fields are NEVER in this list.
  const _MOVIE_META_FIELDS = ['imdbId','title','originalTitle','year','releaseDate','posterPath','posterUrl',
    'backdropPath','backdropUrl','overview','genres','genreIds','runtime','originalLanguage',
    'tmdbRating','voteCount','popularity','director','cast','tagline',
    'imdbRating','imdbVotes','imdbRatingSource','imdbRatingUpdatedAt']; // IMDb fields from OMDb
  function _mergeMovieMeta(existing, meta) {
    const out = { ...existing };
    _MOVIE_META_FIELDS.forEach(k => {
      const v = meta[k];
      if (v === undefined || v === null || v === '') return;
      if (Array.isArray(v) && v.length === 0) return;
      out[k] = v;
    });
    out.updatedAt = _nowISO();
    return out;
  }
  // ── Status / rating model ──────────────────────────────────────────────
  // Watched = watchedDate present. liked/disliked/watchlist are INDEPENDENT flags
  // (a movie can be watched AND liked). userStatus is a denormalized label kept in
  // sync by _normMovie (watched > disliked > liked > watchlist > new).
  // userRating may be legacy 1–10 (from IMDb CSV) or new 1–5; _movieStar maps to 1–5.
  function _normTitle(t) { return (t || '').toString().toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); }
  function _movieStar(r) {
    if (r == null || r === '') return 0;
    const n = +r; if (isNaN(n)) return 0;
    return n > 5 ? Math.max(1, Math.min(5, Math.round(n / 2))) : Math.max(1, Math.min(5, Math.round(n)));
  }
  function _movieLiked(m) { return !!(m && (m.liked || _movieStar(m.userRating) === 5)); }
  function _deriveMovieStatus(m) {
    if (m.watchedDate) return 'watched';
    if (m.disliked) return 'disliked';
    if (_movieLiked(m)) return 'liked';
    if (m.watchlist) return 'watchlist';
    return 'new';
  }
  // Map any legacy single-field userStatus into the independent flags (one-time, idempotent).
  function _mapLegacyStatus(m) {
    const out = { ...m };
    if (out.liked === undefined && out.disliked === undefined && out.watchlist === undefined) {
      out.liked = m.userStatus === 'liked';
      out.disliked = m.userStatus === 'disliked';
      out.watchlist = m.userStatus === 'watchlist';
    } else {
      out.liked = !!out.liked; out.disliked = !!out.disliked; out.watchlist = !!out.watchlist;
    }
    return out;
  }
  // Keep the denormalized userStatus label in sync. Applied to every movie on save.
  function _normMovie(m) {
    const x = _mapLegacyStatus(m);
    x.userStatus = _deriveMovieStatus(x);
    return x;
  }
  // Robust library lookup: tmdbId → imdbId → normalized title + year (±1).
  // This is what stops CSV-watched movies (no tmdbId) from re-appearing in discovery.
  function _findMovieRecord(meta, movies) {
    if (!meta) return null;
    if (meta.id && typeof meta.id === 'string' && meta.id.slice(0, 4) === 'mov_') {
      const self = movies.find(m => m.id === meta.id); if (self) return self;
    }
    if (meta.tmdbId != null) { const h = movies.find(m => m.tmdbId === meta.tmdbId); if (h) return h; }
    if (meta.imdbId) { const h = movies.find(m => m.imdbId && m.imdbId === meta.imdbId); if (h) return h; }
    const nt = _normTitle(meta.title); if (!nt) return null;
    const yrs = [meta.year, meta.year - 1, meta.year + 1].filter(y => y != null);
    return movies.find(m => _normTitle(m.title) === nt && (yrs.length === 0 || yrs.indexOf(m.year) >= 0)) || null;
  }
  // Merge an array of duplicate records into one — richest metadata + ALL user data preserved.
  function _mergeMovieGroup(group) {
    if (group.length === 1) return group[0];
    const base = group.find(m => m.tmdbId != null) || group[0];
    const out = { ...base };
    group.forEach(m => {
      if (m === base) return;
      _MOVIE_META_FIELDS.forEach(k => {
        const cur = out[k], v = m[k];
        const curEmpty = cur == null || cur === '' || (Array.isArray(cur) && !cur.length);
        if (curEmpty && v != null && v !== '' && !(Array.isArray(v) && !v.length)) out[k] = v;
      });
      if (out.tmdbId == null && m.tmdbId != null) out.tmdbId = m.tmdbId;
      if (!out.imdbId && m.imdbId) out.imdbId = m.imdbId;
      // user data — never lose anything
      if (m.watchedDate && (!out.watchedDate || m.watchedDate < out.watchedDate)) out.watchedDate = m.watchedDate;
      out.liked = out.liked || m.liked; out.disliked = out.disliked || m.disliked; out.watchlist = out.watchlist || m.watchlist;
      if (out.userRating == null && m.userRating != null) out.userRating = m.userRating;
      if (!out.userNotes && m.userNotes) out.userNotes = m.userNotes;
      // External IMDb rating (from OMDb) — keep whichever record has it.
      if (out.imdbRating == null && m.imdbRating != null) { out.imdbRating = m.imdbRating; out.imdbVotes = m.imdbVotes; out.imdbRatingSource = m.imdbRatingSource; out.imdbRatingUpdatedAt = m.imdbRatingUpdatedAt; }
      out.lists = Array.from(new Set([...(out.lists || []), ...(m.lists || [])]));
      if (m.addedDate && (!out.addedDate || m.addedDate < out.addedDate)) out.addedDate = m.addedDate;
      if (!out.importedDate && m.importedDate) out.importedDate = m.importedDate;
      if (!out.sourceAddedDateFromCSV && m.sourceAddedDateFromCSV) out.sourceAddedDateFromCSV = m.sourceAddedDateFromCSV;
      out.systemDateAdded = out.systemDateAdded && m.systemDateAdded;
      out.linkedDiaryMemoryIds = Array.from(new Set([...(out.linkedDiaryMemoryIds || []), ...(m.linkedDiaryMemoryIds || [])]));
    });
    out.updatedAt = _nowISO();
    return out;
  }
  function _newMovieRecord(meta, extra) {
    const e = extra || {};
    const id = 'mov_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
    return {
      id,
      tmdbId: meta.tmdbId != null ? meta.tmdbId : null,
      imdbId: meta.imdbId || '',
      title: meta.title || meta.originalTitle || 'Untitled',
      originalTitle: meta.originalTitle || '',
      year: meta.year || null,
      releaseDate: meta.releaseDate || '',
      posterPath: meta.posterPath || '', posterUrl: meta.posterUrl || null,
      backdropPath: meta.backdropPath || '', backdropUrl: meta.backdropUrl || null,
      overview: meta.overview || '',
      genres: Array.isArray(meta.genres) ? meta.genres.slice() : [],
      genreIds: Array.isArray(meta.genreIds) ? meta.genreIds.slice() : [],
      runtime: meta.runtime != null ? meta.runtime : null,
      originalLanguage: meta.originalLanguage || '',
      tmdbRating: meta.tmdbRating != null ? meta.tmdbRating : null,
      voteCount: meta.voteCount != null ? meta.voteCount : null,
      popularity: meta.popularity != null ? meta.popularity : null,
      imdbRating: meta.imdbRating != null ? meta.imdbRating : null,
      imdbVotes: meta.imdbVotes != null ? meta.imdbVotes : null,
      imdbRatingSource: meta.imdbRatingSource || null, imdbRatingUpdatedAt: meta.imdbRatingUpdatedAt || null,
      director: meta.director || '', cast: Array.isArray(meta.cast) ? meta.cast.slice() : [], tagline: meta.tagline || '',
      userStatus: 'new', userRating: null, userNotes: '',
      liked: false, disliked: false, watchlist: false,
      lists: [], importedFrom: e.importedFrom || 'browse',
      watchedDate: null,
      addedDate: e.addedDate || _todayKey(),
      importedDate: e.importedDate || null,
      sourceAddedDateFromCSV: e.sourceAddedDateFromCSV || null,
      importOrder: e.importOrder != null ? e.importOrder : null,
      linkedDiaryMemoryIds: [],
      systemDateAdded: !!e.systemDateAdded,
      createdAt: _nowISO(), updatedAt: _nowISO(),
    };
  }
  function _diaryDateKeyMap() {
    const map = {};
    (state.diary || []).forEach(en => {
      const pad = n => String(n).padStart(2, '0');
      const dk = en.dateKey || (typeof en.day === 'number' && typeof en.month === 'number' && typeof en.year === 'number'
        ? `${en.year}-${pad(en.month)}-${pad(en.day)}` : (en.date || ''));
      if (!dk || !en.id) return;
      (map[dk] = map[dk] || []).push(en.id);
    });
    return map;
  }
  function _moviePrimaryDate(m) {
    return m.watchedDate || m.sourceAddedDateFromCSV || m.importedDate || m.addedDate || '';
  }
  function _computeMovieDiaryLinks(m, diaryMap) {
    const ids = [];
    const dk = _moviePrimaryDate(m);
    if (dk) ((diaryMap || _diaryDateKeyMap())[dk] || []).forEach(id => ids.push(id));
    // Explicit links: diary entries that chose this movie via the "Movie for this day" field.
    if (m && m.id) (state.diary || []).forEach(en => { if (en.movieId && en.movieId === m.id && en.id) ids.push(en.id); });
    return Array.from(new Set(ids));
  }
  function _saveMovies(movies, immediate) {
    // Central sync point: every movie write normalizes userStatus from the flags/watchedDate
    // so all tabs + the assistant see one consistent record.
    // immediate=true → write now (delay 0) for deliberate one-shot actions (bulk move, list
    // membership). Default 400ms debounce batches rapid actions (e.g. Card swipes).
    setState({ movies: (movies || []).map(_normMovie) });
    queueCloud('movies', () => window.Cloud.writeMovies(state.uid, state.movies), immediate ? 0 : 400);
  }

  // ── Public API ──
  const Store = {
    subscribe(fn) { subscribers.add(fn); return () => subscribers.delete(fn); },
    // Flush all debounced pending writes immediately — use before navigation away.
    flushPendingWrites,

    // Auth lifecycle
    async attachUser(user) {
      if (!user || !user.uid) return;
      setState({ uid: user.uid, syncStatus: 'loading', syncError: null,
        profile: {
          uid: user.uid,
          email: user.email || '',
          displayName: user.displayName || (user.email ? user.email.split('@')[0] : 'Friend'),
          photoURL: user.photoURL || '',
        },
      });

      // 1. Try to load existing cloud data
      let cloud = null;
      try {
        cloud = await window.Cloud.loadAll(user.uid);
      } catch (e) {
        console.warn('[Store.attachUser] cloud load failed:', e && e.message);
        setState({ syncStatus: navigator.onLine ? 'error' : 'offline', syncError: e && e.message });
      }

      // 2. Always trust Firestore as the source of truth — a fresh account is empty.
      //    Legacy localStorage from the prototype is NOT auto-imported (it was just
      //    mock seed data). Users with real legacy backups go through Settings →
      //    "Migrate from Calories Analysis" with an explicit JSON file.
      // When cloud is null (load failed), fall back to whatever is already in state
      // (pre-loaded from localStorage) rather than wiping every collection to [].
      const next = {
        uid: user.uid,
        profile:      cloud ? (cloud.profile || state.profile)                         : state.profile,
        macroGoals:   cloud ? (cloud.macroGoals || { ...NUTRI_GOALS })                 : state.macroGoals,
        meals:        cloud ? (cloud.meals    || [])                                   : state.meals,
        finance:      cloud ? (cloud.finance  || [])                                   : state.finance,
        goals:        cloud ? (cloud.goals    || [])                                   : state.goals,
        diary:        cloud ? (cloud.diary    || [])                                   : state.diary,
        assistantChat:cloud ? (cloud.assistantChat || [])                              : state.assistantChat,
        settings:     cloud ? (cloud.settings || { notes:{}, menu:[], weights:{} })    : state.settings,
        localLibrary: cloud ? (cloud.localLibrary || [])                               : state.localLibrary,
        movies:       cloud ? (cloud.movies   || [])                                   : state.movies,
        lastSyncAt: new Date().toISOString(),
        syncStatus: cloud ? 'idle' : (navigator.onLine ? 'error' : 'offline'),
        syncError: null,
      };
      setState(next);

      // ── Post-attach data health checks (silent, non-destructive) ────────
      // 1. Auto-heal Movie Night orphaned list refs (settings write may have been lost).
      try { Store.ensureListsConsistent(); } catch (_) {}

      // Stale prototype localStorage is removed unconditionally.
      try { localStorage.removeItem(LEGACY_KEY); } catch(_) {}
    },
    detachUser() {
      // Flush any pending debounced writes BEFORE clearing state (prevents data loss on logout).
      flushPendingWrites();
      // Cancel any timers (flushPendingWrites already cleared their fns).
      Object.keys(writeTimers).forEach(k => clearTimeout(writeTimers[k]));
      state = blankState();
      try { localStorage.removeItem(LOCAL_KEY); } catch(_) {}
      notify();
    },

    // Profile
    async updateDisplayName(name) {
      if (!state.uid) return;
      const profile = { ...(state.profile || {}), displayName: name };
      setState({ profile });
      try {
        const u = window.firebaseAuth.currentUser;
        if (u) await u.updateProfile({ displayName: name });
        await window.Cloud.writeProfile(state.uid, { displayName: name });
        setState({ lastSyncAt: new Date().toISOString() });
      } catch (e) { setState({ syncError: e && e.message }); }
    },

    // Read accessors
    profile()        { return state.profile; },
    macroGoals()     { return state.macroGoals; },
    meals()          { return state.meals; },
    finance()        { return state.finance; },
    goals()          { return state.goals; },
    diary()          { return state.diary; },
    assistantChat()  { return state.assistantChat; },
    settings()       { return state.settings; },
    localLibrary()   { return state.localLibrary; },
    movies()         { return state.movies || []; },
    uid()            { return state.uid; },
    syncStatus()     { return state.syncStatus; },
    syncError()      { return state.syncError; },
    lastSyncAt()     { return state.lastSyncAt; },

    // ── Meals ──
    addMeals(rows) {
      if (!state.uid) return;
      setState({ meals: state.meals.concat(rows) });
      queueCloud('meals', () => window.Cloud.writeMeals(state.uid, state.meals));
    },
    deleteMeal(id) {
      setState({ meals: state.meals.filter(m => m.id !== id) });
      queueCloud('meals', () => window.Cloud.writeMeals(state.uid, state.meals));
    },
    updateMeal(id, patch) {
      setState({ meals: state.meals.map(m => m.id === id ? { ...m, ...patch } : m) });
      queueCloud('meals', () => window.Cloud.writeMeals(state.uid, state.meals));
    },
    replaceMealsForDate(date, rows) {
      const keep = state.meals.filter(m => (m.date || '') !== date);
      setState({ meals: keep.concat(rows) });
      queueCloud('meals', () => window.Cloud.writeMeals(state.uid, state.meals));
    },
    clearMeals() {
      setState({ meals: [] });
      queueCloud('meals', () => window.Cloud.writeMeals(state.uid, []));
    },
    setMacroGoals(g) {
      setState({ macroGoals: { ...(state.macroGoals || {}), ...g } });
      queueCloud('macro', () => window.Cloud.writeMacro(state.uid, state.macroGoals));
    },
    setDayNote(date, text) {
      const notes = { ...((state.settings && state.settings.notes) || {}) };
      const t = (text || '').trim();
      if (t) notes[date] = t; else delete notes[date];
      const settings = { ...(state.settings || {}), notes };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    setWeight(date, kg) {
      const weights = { ...((state.settings && state.settings.weights) || {}) };
      if (kg === '' || kg == null || isNaN(kg)) delete weights[date]; else weights[date] = +kg;
      const settings = { ...(state.settings || {}), weights };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    weightNotes() { return (state.settings && state.settings.weightNotes) || {}; },
    setWeightNote(date, text) {
      const notes = { ...((state.settings && state.settings.weightNotes) || {}) };
      const t = (text || '').trim();
      if (t) notes[date] = t; else delete notes[date];
      const settings = { ...(state.settings || {}), weightNotes: notes };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    deleteWeight(date) {
      const weights = { ...((state.settings && state.settings.weights) || {}) };
      const notes = { ...((state.settings && state.settings.weightNotes) || {}) };
      delete weights[date]; delete notes[date];
      const settings = { ...(state.settings || {}), weights, weightNotes: notes };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },

    // ── InBody entries (stored in settings; small, synced as a unit) ──
    inBodyEntries() { return (state.settings && state.settings.inBodyEntries) || []; },
    findInBodyByDate(date) { return ((state.settings && state.settings.inBodyEntries) || []).find(e => e.date === date) || null; },
    addInBodyEntry(entry) {
      const id = 'ib_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
      const e = { ...entry, id, createdAt: _nowISO(), updatedAt: _nowISO() };
      const list = [...((state.settings && state.settings.inBodyEntries) || []), e];
      let settings = { ...(state.settings || {}), inBodyEntries: list };
      // Safely add/update the weight log for that date.
      if (e.weight != null && e.date && !isNaN(+e.weight)) {
        settings = { ...settings, weights: { ...(settings.weights || {}), [e.date]: +e.weight } };
      }
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
      return e.id;
    },
    updateInBodyEntry(id, patch) {
      const list = ((state.settings && state.settings.inBodyEntries) || []).map(e => e.id === id ? { ...e, ...patch, updatedAt: _nowISO() } : e);
      const updated = list.find(e => e.id === id);
      let settings = { ...(state.settings || {}), inBodyEntries: list };
      if (updated && updated.weight != null && updated.date && !isNaN(+updated.weight)) {
        settings = { ...settings, weights: { ...(settings.weights || {}), [updated.date]: +updated.weight } };
      }
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    // Replace or merge an entry for the same date (used by the importer's duplicate dialog).
    saveInBodyForDate(date, entry, mode) {
      const existing = ((state.settings && state.settings.inBodyEntries) || []).find(e => e.date === date);
      if (!existing) return this.addInBodyEntry({ ...entry, date });
      if (mode === 'merge') {
        const merged = { ...entry };
        // keep existing non-empty values that the new parse left blank
        Object.keys(existing).forEach(k => { if (k === 'id' || k === 'createdAt') return; if ((merged[k] == null || merged[k] === '') && existing[k] != null && existing[k] !== '') merged[k] = existing[k]; });
        this.updateInBodyEntry(existing.id, merged);
      } else { // replace
        this.updateInBodyEntry(existing.id, { ...entry, date });
      }
      return existing.id;
    },
    deleteInBodyEntry(id) {
      const list = ((state.settings && state.settings.inBodyEntries) || []).filter(e => e.id !== id);
      const settings = { ...(state.settings || {}), inBodyEntries: list };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },

    // ── Bulk delete by date range (Cleanup tools). Only touches the targeted data. ──
    deleteMealsInRange(from, to) {
      const keep = (state.meals || []).filter(m => !((m.date || '') >= from && (m.date || '') <= to));
      const removed = (state.meals || []).length - keep.length;
      setState({ meals: keep });
      queueCloud('meals', () => window.Cloud.writeMeals(state.uid, state.meals));
      return removed;
    },
    deleteDayNotesInRange(from, to) {
      const notes = { ...((state.settings && state.settings.notes) || {}) };
      let n = 0; Object.keys(notes).forEach(d => { if (d >= from && d <= to) { delete notes[d]; n++; } });
      const settings = { ...(state.settings || {}), notes };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
      return n;
    },
    deleteWeightsInRange(from, to) {
      const weights = { ...((state.settings && state.settings.weights) || {}) };
      const wn = { ...((state.settings && state.settings.weightNotes) || {}) };
      let n = 0; Object.keys(weights).forEach(d => { if (d >= from && d <= to) { delete weights[d]; n++; } });
      Object.keys(wn).forEach(d => { if (d >= from && d <= to) delete wn[d]; });
      const settings = { ...(state.settings || {}), weights, weightNotes: wn };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
      return n;
    },
    deleteInBodyInRange(from, to) {
      const all = (state.settings && state.settings.inBodyEntries) || [];
      const keep = all.filter(e => !((e.date || '') >= from && (e.date || '') <= to));
      const settings = { ...(state.settings || {}), inBodyEntries: keep };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
      return all.length - keep.length;
    },
    // opts: { all, expense, income, transfer, fixedPaid }
    deleteFinanceInRange(from, to, opts) {
      const o = opts || {};
      const inRange = t => (t.date || '') >= from && (t.date || '') <= to;
      const match = t => {
        if (!inRange(t)) return false;
        if (o.all) return true;
        const kind = t.kind || 'expense';
        if (o.expense && kind === 'expense' && t.source !== 'fixed') return true;
        if (o.income && kind === 'income') return true;
        if (o.transfer && kind === 'transfer') return true;
        if (o.fixedPaid && t.source === 'fixed') return true;
        return false;
      };
      const removed = (state.finance || []).filter(match);
      const keep = (state.finance || []).filter(t => !match(t));
      // Keep fixed schedules consistent: un-mark paid dates whose paid expense we just deleted.
      const fixedRemovals = {};
      removed.forEach(t => { if (t.source === 'fixed' && t.fixedId && t.fixedDueDate) (fixedRemovals[t.fixedId] = fixedRemovals[t.fixedId] || []).push(t.fixedDueDate); });
      if (Object.keys(fixedRemovals).length) {
        const financeFixed = ((state.settings && state.settings.financeFixed) || []).map(fx => fixedRemovals[fx.id]
          ? { ...fx, paidDates: (fx.paidDates || []).filter(d => fixedRemovals[fx.id].indexOf(d) < 0), status: fx.status === 'completed' ? 'active' : (fx.status || 'active') }
          : fx);
        const settings = { ...(state.settings || {}), financeFixed };
        setState({ finance: keep, settings });
        queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
      } else {
        setState({ finance: keep });
      }
      queueCloud('finance', () => window.Cloud.writeFinance(state.uid, state.finance), 0);
      return removed.length;
    },

    // ── Local Food Library ──
    addLocalItem(item) {
      setState({ localLibrary: state.localLibrary.concat([item]) });
      queueCloud('local-library', () => window.Cloud.writeLocalLibrary(state.uid, state.localLibrary));
    },
    updateLocalItem(id, patch) {
      setState({ localLibrary: state.localLibrary.map(i => i.id === id ? { ...i, ...patch, updatedAt: new Date().toISOString() } : i) });
      queueCloud('local-library', () => window.Cloud.writeLocalLibrary(state.uid, state.localLibrary));
    },
    deleteLocalItem(id) {
      setState({ localLibrary: state.localLibrary.filter(i => i.id !== id) });
      queueCloud('local-library', () => window.Cloud.writeLocalLibrary(state.uid, state.localLibrary));
    },

    // ── Finance ──
    addTransactions(rows) {
      setState({ finance: state.finance.concat(rows) });
      queueCloud('finance', () => window.Cloud.writeFinance(state.uid, state.finance), 0);
    },
    deleteTransaction(id) {
      setState({ finance: state.finance.filter(t => t.id !== id) });
      queueCloud('finance', () => window.Cloud.writeFinance(state.uid, state.finance), 0);
    },
    updateTransaction(id, patch) {
      setState({ finance: state.finance.map(t => t.id === id ? { ...t, ...patch } : t) });
      queueCloud('finance', () => window.Cloud.writeFinance(state.uid, state.finance), 0);
    },
    // ── Fixing Data: adjust an account's actual balance via a visible finance record ──
    // Never overwrites a balance silently — creates a "Fixing Data" expense (balance ↓) or
    // income (balance ↑) so the recomputed balance matches the entered actual balance.
    addFinanceAdjustment(input) {
      const o = input || {};
      const num = v => (v != null && v !== '' && !isNaN(+v)) ? +v : 0;
      const from = num(o.fromBalance), to = num(o.toBalance);
      const diff = +(to - from).toFixed(2);
      if (!o.accountId || diff === 0) return { skipped: true, difference: 0 };
      // Ensure the Fixing Data expense category + income source exist (no duplicates).
      let settings = state.settings || {};
      const cats = settings.financeCategories;
      if (cats) {
        let next = cats; let changed = false;
        if (!next.some(c => c.id === 'fixing_data')) {
          next = next.concat([{ id: 'fixing_data', name: 'Fixing Data', icon: '🛠️', color: '#7A766C', spendingType: null, fixingData: true,
            subcategories: [{ id: 'fixing_data__adjust', name: 'Balance Adjustment' }] }]);
          changed = true;
        }
        const inc = next.find(c => c.id === 'income');
        if (inc && !(inc.subcategories || []).some(s => s.id === 'income_fixing_data')) {
          next = next.map(c => c.id === 'income' ? { ...c, subcategories: [...(c.subcategories || []), { id: 'income_fixing_data', name: 'Fixing Data' }] } : c);
          changed = true;
        }
        if (changed) settings = { ...settings, financeCategories: next };
      }
      const isExpense = diff < 0;
      const rec = {
        id: 'fin_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5),
        date: o.date || _todayKey(),
        kind: isExpense ? 'expense' : 'income',
        amount: Math.abs(diff),
        category: isExpense ? 'fixing_data' : 'income',
        subcategory: isExpense ? 'fixing_data__adjust' : 'income_fixing_data',
        title: 'Fixing Data',
        account: o.accountId,
        time: new Date().toTimeString().slice(0, 5),
        notes: (o.note && o.note.trim()) || ('Balance adjustment from ' + from + ' to ' + to),
        currency: o.currency || 'EGP',
        fixingData: true,
        adjustedFromBalance: from,
        adjustedToBalance: to,
        adjustmentDifference: diff,
        createdAt: _nowISO(),
      };
      setState({ settings, finance: (state.finance || []).concat([rec]) });
      queueCloud('finance', () => window.Cloud.writeFinance(state.uid, state.finance), 0);
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings), 0);
      return { created: true, kind: rec.kind, amount: rec.amount, difference: diff, id: rec.id };
    },

    // ── Finance: Categories & Accounts management ──
    financeCategories() {
      return (state.settings && state.settings.financeCategories) || null;
    },
    setFinanceCategories(list) {
      const settings = { ...(state.settings || {}), financeCategories: list };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    // One-time seed merge: unions the default seed into the user's categories
    // without deleting custom cats or breaking existing txn category links.
    ensureSeedCategories(seed, legacy) {
      const s = state.settings || {};
      if (s.financeCatsSeededV1) return;
      const existing = s.financeCategories || null;
      const hasTxns = (state.finance || []).length > 0;
      const base = existing ? existing : (hasTxns ? (legacy || []) : []);
      const merged = window.mergeFinanceCategories(base, seed || []);
      const settings = { ...s, financeCategories: merged, financeCatsSeededV1: true };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    financeAccounts() {
      return (state.settings && state.settings.financeAccounts) || null;
    },
    setFinanceAccounts(list) {
      // Strip transient computed fields — balance/income/expenses/egpBalance are derived at
      // render time (nutri-app.jsx) and must NEVER be persisted, or stored records get polluted
      // with stale numbers. Keep every other field (id/name/kind/icon/startingBalance/currency/notes/…).
      // NOTE: use explicit delete, NOT object-rest destructuring — babel-standalone's
      // `_objectWithoutProperties` transform does not strip reliably in this no-bundler app.
      const clean = (list || []).map(a => {
        const o = Object.assign({}, a);
        delete o.balance; delete o.egpBalance; delete o.income; delete o.expenses;
        return o;
      });
      const settings = { ...(state.settings || {}), financeAccounts: clean };
      setState({ settings });
      // Immediate write (delay 0): accounts are rare, important records that must survive a quick
      // refresh / app close. The 400ms debounce can be aborted before the write reaches Firestore.
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings), 0);
    },
    setUsdEgpRate(rate) {
      const updatedAt = new Date().toISOString();
      const settings = { ...(state.settings || {}), usdEgpRate: +rate, usdEgpRateUpdatedAt: updatedAt };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },

    // ── Gold Holdings ──
    goldHoldings() {
      return (state.settings && state.settings.goldHoldings) || [];
    },
    addGoldHolding(holding) {
      const holdings = [...((state.settings && state.settings.goldHoldings) || []), holding];
      const settings = { ...(state.settings || {}), goldHoldings: holdings };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    updateGoldHolding(id, patch) {
      const holdings = ((state.settings && state.settings.goldHoldings) || []).map(h => h.id === id ? { ...h, ...patch } : h);
      const settings = { ...(state.settings || {}), goldHoldings: holdings };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    deleteGoldHolding(id) {
      const holdings = ((state.settings && state.settings.goldHoldings) || []).filter(h => h.id !== id);
      const settings = { ...(state.settings || {}), goldHoldings: holdings };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    setGoldPrices(prices) {
      const updatedAt = new Date().toISOString();
      const settings = { ...(state.settings || {}), goldPrices: prices, goldPricesUpdatedAt: updatedAt };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },

    // ── Time filters (per-scope, user-specific, persisted in settings) ──
    timeFilterConfig(scope) {
      return ((state.settings && state.settings.timeFilters) || {})[scope] || {};
    },
    setTimeFilterPresets(scope, ids) {
      const all = { ...((state.settings && state.settings.timeFilters) || {}) };
      all[scope] = { ...(all[scope] || {}), presets: ids };
      const settings = { ...(state.settings || {}), timeFilters: all };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    addCustomTimeFilter(scope, f) {
      const all = { ...((state.settings && state.settings.timeFilters) || {}) };
      const cur = all[scope] || {};
      all[scope] = { ...cur, custom: [...(cur.custom || []), f] };
      const settings = { ...(state.settings || {}), timeFilters: all };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    updateCustomTimeFilter(scope, id, patch) {
      const all = { ...((state.settings && state.settings.timeFilters) || {}) };
      const cur = all[scope] || {};
      all[scope] = { ...cur, custom: (cur.custom || []).map(f => f.id === id ? { ...f, ...patch } : f) };
      const settings = { ...(state.settings || {}), timeFilters: all };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    deleteCustomTimeFilter(scope, id) {
      const all = { ...((state.settings && state.settings.timeFilters) || {}) };
      const cur = all[scope] || {};
      all[scope] = { ...cur, custom: (cur.custom || []).filter(f => f.id !== id) };
      const settings = { ...(state.settings || {}), timeFilters: all };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    resetTimeFilters(scope) {
      const all = { ...((state.settings && state.settings.timeFilters) || {}) };
      delete all[scope];
      const settings = { ...(state.settings || {}), timeFilters: all };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },

    // ── Chart-type preferences (per-chart bar/line/area) — synced via settings ──
    // (Previously localStorage-only; now cloud-backed so the choice follows the user across devices.)
    chartTypePref(key) { return ((state.settings && state.settings.chartTypes) || {})[key]; },
    setChartTypePref(key, val) {
      const chartTypes = { ...((state.settings && state.settings.chartTypes) || {}), [key]: val };
      const settings = { ...(state.settings || {}), chartTypes };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },

    // ── Paste-to-parse custom templates — synced via settings ──
    // (Previously localStorage-only keyed by uid; now cloud-backed. settings is already per-user.)
    pasteTemplates() { return (state.settings && state.settings.pasteTemplates) || {}; },
    setPasteTemplates(map) {
      const settings = { ...(state.settings || {}), pasteTemplates: map || {} };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },

    // ── Finance Projects ──
    financeProjects() {
      return (state.settings && state.settings.financeProjects) || [];
    },
    addFinanceProject(project) {
      const list = [...((state.settings && state.settings.financeProjects) || []), project];
      const settings = { ...(state.settings || {}), financeProjects: list };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    updateFinanceProject(id, patch) {
      const list = ((state.settings && state.settings.financeProjects) || []).map(p => p.id === id ? { ...p, ...patch } : p);
      const settings = { ...(state.settings || {}), financeProjects: list };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    deleteFinanceProject(id) {
      const list = ((state.settings && state.settings.financeProjects) || []).filter(p => p.id !== id);
      const settings = { ...(state.settings || {}), financeProjects: list };
      // Unlink this project from expenses (single + multi-project) — keep the expenses themselves.
      const finance = (state.finance || []).map(t => {
        const ids = Array.isArray(t.projectIds) ? t.projectIds : (t.project ? [t.project] : []);
        if (ids.indexOf(id) < 0) return t;
        const next = ids.filter(x => x !== id);
        const o = { ...t };
        if (next.length) o.projectIds = next; else delete o.projectIds;
        if (o.project === id) o.project = '';
        return o;
      });
      setState({ settings, finance });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings), 0);
      queueCloud('finance', () => window.Cloud.writeFinance(state.uid, state.finance), 0);
    },

    // ── Finance Fixed Expenses / Installments ──
    financeFixed() {
      return (state.settings && state.settings.financeFixed) || [];
    },
    addFinanceFixed(fx) {
      const list = [...((state.settings && state.settings.financeFixed) || []), fx];
      const settings = { ...(state.settings || {}), financeFixed: list };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    updateFinanceFixed(id, patch) {
      const list = ((state.settings && state.settings.financeFixed) || []).map(f => f.id === id ? { ...f, ...patch } : f);
      const settings = { ...(state.settings || {}), financeFixed: list };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    deleteFinanceFixed(id) {
      // Removes the schedule only — historical paid expense records are kept.
      const list = ((state.settings && state.settings.financeFixed) || []).filter(f => f.id !== id);
      const settings = { ...(state.settings || {}), financeFixed: list };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    // Mark one occurrence (dueDate) as paid → record date + create a linked expense.
    // Guards against double-counting the same dueDate.
    markFixedPaid(fixedId, dueDate, opts) {
      const o = opts || {};
      const list = (state.settings && state.settings.financeFixed) || [];
      const fx = list.find(f => f.id === fixedId);
      if (!fx) return { ok: false, reason: 'not-found' };
      const already = (fx.paidDates || []).indexOf(dueDate) >= 0
        || (state.finance || []).some(t => t.fixedId === fixedId && t.fixedDueDate === dueDate);
      if (already) return { ok: false, reason: 'already-paid' };
      const paidDates = [...(fx.paidDates || []), dueDate].sort();
      const newList = list.map(f => {
        if (f.id !== fixedId) return f;
        const next = { ...f, paidDates };
        if (f.installmentsTotal && paidDates.length >= +f.installmentsTotal) next.status = 'completed';
        return next;
      });
      const payDate = o.date || dueDate;
      const account = o.account || fx.account || ((window.NF_ACCOUNTS && window.NF_ACCOUNTS[0] && window.NF_ACCOUNTS[0].id) || 'cash');
      const id = 'fin_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
      const expense = {
        id, date: payDate, kind: 'expense',
        amount: Math.abs(+fx.amount || 0),
        category: fx.category || '', subcategory: fx.subcategory || '',
        title: fx.name || 'Fixed expense',
        account, time: new Date().toTimeString().slice(0, 5),
        notes: o.notes || fx.notes || '',
        currency: fx.currency || 'EGP',
        ...(o.exchangeRate ? { exchangeRate: o.exchangeRate } : {}),
        ...(o.egpEquivalent != null ? { egpEquivalent: o.egpEquivalent } : {}),
        ...(fx.project ? { project: fx.project } : {}),
        fixedId, fixedDueDate: dueDate, source: 'fixed',
      };
      const settings = { ...(state.settings || {}), financeFixed: newList };
      setState({ settings, finance: (state.finance || []).concat([expense]) });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings), 0);
      queueCloud('finance', () => window.Cloud.writeFinance(state.uid, state.finance), 0);
      return { ok: true, expense };
    },
    // Undo a paid occurrence — removes the paid flag and the linked expense.
    unmarkFixedPaid(fixedId, dueDate) {
      const list = (state.settings && state.settings.financeFixed) || [];
      const newList = list.map(f => f.id === fixedId
        ? { ...f, paidDates: (f.paidDates || []).filter(d => d !== dueDate),
            status: f.status === 'completed' ? 'active' : (f.status || 'active') }
        : f);
      const finance = (state.finance || []).filter(t => !(t.fixedId === fixedId && t.fixedDueDate === dueDate));
      const settings = { ...(state.settings || {}), financeFixed: newList };
      setState({ settings, finance });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings), 0);
      queueCloud('finance', () => window.Cloud.writeFinance(state.uid, state.finance), 0);
    },

    // ── Goals ──
    setGoals(goals) {
      setState({ goals });
      queueCloud('goals', () => window.Cloud.writeGoals(state.uid, state.goals));
    },
    addGoals(rows) {
      setState({ goals: state.goals.concat(rows) });
      queueCloud('goals', () => window.Cloud.writeGoals(state.uid, state.goals));
    },
    deleteGoal(id) {
      setState({ goals: state.goals.filter(g => g.id !== id) });
      queueCloud('goals', () => window.Cloud.writeGoals(state.uid, state.goals));
    },

    // ── Diary ──
    addDiaryEntries(rows) {
      setState({ diary: state.diary.concat(rows) });
      queueCloud('diary', () => window.Cloud.writeDiary(state.uid, state.diary));
    },
    deleteDiaryEntry(id) {
      setState({ diary: state.diary.filter(d => d.id !== id) });
      queueCloud('diary', () => window.Cloud.writeDiary(state.uid, state.diary));
    },
    setDiaryCustomTags(tags) {
      const settings = { ...(state.settings || {}), diaryCustomTags: Array.isArray(tags) ? tags : [] };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },

    // ── Notification settings (persisted per-user) ──
    notificationSettings() {
      const s = (state.settings && state.settings.notifications) || {};
      const d = s.diary || {}, g = s.goals || {};
      return {
        diary: { enabled: !!d.enabled, time: d.time || '07:00', sendEmpty: !!d.sendEmpty },
        goals: { enabled: !!g.enabled, time: g.time || '22:00', sendDone: !!g.sendDone },
      };
    },
    updateNotificationSetting(section, key, value) {
      const cur = (state.settings && state.settings.notifications) || {};
      const sec = { ...(cur[section] || {}), [key]: value };
      const settings = { ...(state.settings || {}), notifications: { ...cur, [section]: sec } };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },

    // ── Movie Night: credentials (user-specific, never hardcoded) ──
    movieCredentials() {
      return (state.settings && state.settings.movieNight) || {};
    },
    setMovieCredentials(creds) {
      const mn = { ...((state.settings && state.settings.movieNight) || {}) };
      if (creds && creds.apiKey !== undefined)     mn.apiKey     = (creds.apiKey     || '').trim();
      if (creds && creds.readToken !== undefined)  mn.readToken  = (creds.readToken  || '').trim();
      if (creds && creds.omdbApiKey !== undefined) mn.omdbApiKey = (creds.omdbApiKey || '').trim();
      mn.updatedAt = _nowISO();
      const settings = { ...(state.settings || {}), movieNight: mn };
      setState({ settings });
      // Immediate write (delay 0): the OMDb/TMDb key must survive a quick refresh — a debounced
      // write can be aborted on close, which is why the key kept "disappearing".
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings), 0);
    },
    clearMovieCredentials() {
      const settings = { ...(state.settings || {}), movieNight: {} };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },

    // ── Movie Night: custom lists ──
    movieLists() {
      return (state.settings && state.settings.movieLists) || [];
    },
    addMovieList(name) {
      const id = 'mlist_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
      const list = { id, name: (name || 'New list').trim(), createdAt: _nowISO(), archived: false };
      const lists = [...((state.settings && state.settings.movieLists) || []), list];
      const settings = { ...(state.settings || {}), movieLists: lists };
      setState({ settings });
      // delay=0: write immediately — list definitions must not be lost in the debounce window
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings), 0);
      return id;
    },
    renameMovieList(id, name) {
      const lists = ((state.settings && state.settings.movieLists) || []).map(l => l.id === id ? { ...l, name: (name || '').trim() || l.name } : l);
      const settings = { ...(state.settings || {}), movieLists: lists };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    setMovieListArchived(id, archived) {
      const lists = ((state.settings && state.settings.movieLists) || []).map(l => l.id === id ? { ...l, archived: !!archived } : l);
      const settings = { ...(state.settings || {}), movieLists: lists };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },
    deleteMovieList(id) {
      const lists = ((state.settings && state.settings.movieLists) || []).filter(l => l.id !== id);
      const settings = { ...(state.settings || {}), movieLists: lists };
      // Keep the movies; just unlink this list from each (archive-safe delete).
      const movies = (state.movies || []).map(m => (m.lists || []).indexOf(id) >= 0
        ? { ...m, lists: (m.lists || []).filter(x => x !== id), updatedAt: _nowISO() } : m);
      setState({ settings, movies });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings), 0);
      queueCloud('movies', () => window.Cloud.writeMovies(state.uid, state.movies), 0);
    },

    // ── Movie Night: library ──
    // Robust lookup: tmdbId → imdbId → normalized title+year (±1). Returns record or null.
    findMovie(meta) {
      return _findMovieRecord(meta, state.movies || []);
    },
    // Ensure a movie is in the library (dedupe + safe metadata merge); returns local id.
    ensureMovie(meta, source) {
      const movies = state.movies || [];
      const found = _findMovieRecord(meta, movies);
      if (found) {
        _saveMovies(movies.map(m => m.id === found.id ? _mergeMovieMeta(m, meta) : m));
        return found.id;
      }
      const rec = _newMovieRecord(meta, { importedFrom: source || 'browse' });
      _saveMovies([...movies, rec]);
      return rec.id;
    },
    updateMovie(id, patch) {
      _saveMovies((state.movies || []).map(m => m.id === id ? { ...m, ...patch, updatedAt: _nowISO() } : m));
    },
    deleteMovie(id) {
      _saveMovies((state.movies || []).filter(m => m.id !== id));
    },
    // Absolute disposition setter (manual-add). Sets liked/disliked/watchlist flags; leaves
    // watchedDate/rating untouched. 'new'/'' clears all three.
    setMovieStatus(id, status) {
      _saveMovies((state.movies || []).map(m => m.id !== id ? m : {
        ...m,
        liked: status === 'liked',
        disliked: status === 'disliked',
        watchlist: status === 'watchlist',
        updatedAt: _nowISO(),
      }));
    },
    toggleMovieLiked(id) {
      _saveMovies((state.movies || []).map(m => {
        if (m.id !== id) return m;
        const liked = !_movieLiked(m);
        return { ...m, liked, disliked: liked ? false : m.disliked, updatedAt: _nowISO() };
      }));
    },
    toggleMovieDisliked(id) {
      _saveMovies((state.movies || []).map(m => {
        if (m.id !== id) return m;
        const disliked = !m.disliked;
        return { ...m, disliked, liked: disliked ? false : m.liked, watchlist: disliked ? false : m.watchlist, updatedAt: _nowISO() };
      }));
    },
    toggleMovieWatchlist(id) {
      _saveMovies((state.movies || []).map(m => m.id === id ? { ...m, watchlist: !m.watchlist, updatedAt: _nowISO() } : m));
    },
    setMovieRating(id, rating) {
      const r = rating === '' || rating == null ? null : +rating;   // stored 1–5
      _saveMovies((state.movies || []).map(m => m.id === id ? { ...m, userRating: r, updatedAt: _nowISO() } : m));
    },
    setMovieNotes(id, notes) {
      _saveMovies((state.movies || []).map(m => m.id === id ? { ...m, userNotes: notes || '', updatedAt: _nowISO() } : m));
    },
    addMovieToList(id, listId) {
      const movies = (state.movies || []).map(m => m.id === id
        ? { ...m, lists: Array.from(new Set([...(m.lists || []), listId])), updatedAt: _nowISO() } : m);
      setState({ movies: movies.map(_normMovie) });
      // Immediate write (delay=0) so the membership never sits in the 400ms buffer
      queueCloud('movies', () => window.Cloud.writeMovies(state.uid, state.movies), 0);
    },
    removeMovieFromList(id, listId) {
      const movies = (state.movies || []).map(m => m.id === id
        ? { ...m, lists: (m.lists || []).filter(x => x !== listId), updatedAt: _nowISO() } : m);
      setState({ movies: movies.map(_normMovie) });
      queueCloud('movies', () => window.Cloud.writeMovies(state.uid, state.movies), 0);
    },

    // ── Bulk operations (spec §5) — preserve ALL metadata; one save; sync to Firebase ──
    // Each operates on a set of local ids and never deletes movie metadata.
    addMoviesToList(ids, listId) {
      if (!listId) return { count: 0 };
      const set = new Set(ids || []); let n = 0;
      _saveMovies((state.movies || []).map(m => {
        if (!set.has(m.id)) return m;
        if ((m.lists || []).indexOf(listId) >= 0) return m; // no duplicate membership
        n++;
        return { ...m, lists: Array.from(new Set([...(m.lists || []), listId])), updatedAt: _nowISO() };
      }), true);
      return { count: set.size, added: n };
    },
    moveMoviesToList(ids, fromListId, toListId) {
      if (!toListId) return { count: 0 };
      const set = new Set(ids || []);
      _saveMovies((state.movies || []).map(m => {
        if (!set.has(m.id)) return m;
        let lists = Array.from(new Set([...(m.lists || []), toListId]));
        if (fromListId) lists = lists.filter(x => x !== fromListId);
        return { ...m, lists, updatedAt: _nowISO() };
      }), true);
      return { count: set.size };
    },
    addMoviesToWatchlist(ids) {
      const set = new Set(ids || []); let n = 0;
      _saveMovies((state.movies || []).map(m => {
        if (!set.has(m.id) || m.watchedDate) return m; // watched movies aren't "to watch"
        if (m.watchlist) return m;
        n++;
        return { ...m, watchlist: true, updatedAt: _nowISO() };
      }), true);
      return { count: set.size, added: n };
    },
    removeMoviesFromWatchlist(ids) {
      const set = new Set(ids || []);
      _saveMovies((state.movies || []).map(m => set.has(m.id) ? { ...m, watchlist: false, updatedAt: _nowISO() } : m), true);
      return { count: set.size };
    },
    markMoviesWatched(ids) {
      const set = new Set(ids || []); const today = _todayKey(); const diaryMap = _diaryDateKeyMap();
      _saveMovies((state.movies || []).map(m => {
        if (!set.has(m.id)) return m;
        const d = m.watchedDate || today;                 // never overwrite an existing watchedDate
        const upd = { ...m, watchedDate: d, watchlist: false, systemDateAdded: m.watchedDate ? m.systemDateAdded : true, updatedAt: _nowISO() };
        upd.linkedDiaryMemoryIds = _computeMovieDiaryLinks(upd, diaryMap);
        return upd;
      }), true);
      return { count: set.size };
    },
    removeMoviesFromList(ids, listId) {
      const set = new Set(ids || []);
      _saveMovies((state.movies || []).map(m => set.has(m.id)
        ? { ...m, lists: (m.lists || []).filter(x => x !== listId), updatedAt: _nowISO() } : m), true);
      return { count: set.size };
    },
    // Mark watched. date omitted → today (flagged system-added). Clears watchlist; keeps
    // liked/disliked/rating. _saveMovies sets userStatus='watched'. Links diary by date.
    markWatched(id, date) {
      const diaryMap = _diaryDateKeyMap();
      const d = date || _todayKey();
      const sys = !date;
      _saveMovies((state.movies || []).map(m => {
        if (m.id !== id) return m;
        const upd = { ...m, watchedDate: d, watchlist: false, systemDateAdded: m.systemDateAdded || sys, updatedAt: _nowISO() };
        upd.linkedDiaryMemoryIds = _computeMovieDiaryLinks(upd, diaryMap);
        return upd;
      }));
    },
    setWatchedDate(id, date) { // manual edit — overwrite allowed
      const diaryMap = _diaryDateKeyMap();
      _saveMovies((state.movies || []).map(m => {
        if (m.id !== id) return m;
        const upd = { ...m, watchedDate: date || null, systemDateAdded: false, updatedAt: _nowISO() };
        upd.linkedDiaryMemoryIds = _computeMovieDiaryLinks(upd, diaryMap);
        return upd;
      }));
    },
    unmarkWatched(id) {
      _saveMovies((state.movies || []).map(m => m.id === id
        ? { ...m, watchedDate: null, linkedDiaryMemoryIds: [], updatedAt: _nowISO() } : m));
    },
    // Live diary links for a movie (always accurate even if diary changed). Read-only.
    movieDiaryLinks(movie) {
      return _computeMovieDiaryLinks(movie, _diaryDateKeyMap());
    },
    // Recompute & persist diary links across the whole library (e.g. after a CSV import).
    relinkMoviesToDiary() {
      const diaryMap = _diaryDateKeyMap();
      _saveMovies((state.movies || []).map(m => ({ ...m, linkedDiaryMemoryIds: _computeMovieDiaryLinks(m, diaryMap) })));
    },
    // Batch import (CSV) — dedupes by tmdbId/imdbId/title+year, additive on user fields, one re-render.
    importMovies(records) {
      let movies = (state.movies || []).slice();
      const byTmdb = {}, byImdb = {}, byTY = {};
      const reg = (m, i) => {
        if (m.tmdbId != null) byTmdb[m.tmdbId] = i;
        if (m.imdbId) byImdb[m.imdbId] = i;
        const nt = _normTitle(m.title); if (nt && m.year != null) byTY[nt + '|' + m.year] = i;
      };
      movies.forEach(reg);
      const findIdx = r => {
        if (r.tmdbId != null && byTmdb[r.tmdbId] != null) return byTmdb[r.tmdbId];
        if (r.imdbId && byImdb[r.imdbId] != null) return byImdb[r.imdbId];
        const nt = _normTitle(r.title); if (!nt) return -1;
        for (const y of [r.year, r.year - 1, r.year + 1]) { if (y != null && byTY[nt + '|' + y] != null) return byTY[nt + '|' + y]; }
        return -1;
      };
      const flagsFrom = r => ({
        liked: !!r.liked || r.userStatus === 'liked',
        disliked: !!r.disliked || r.userStatus === 'disliked',
        watchlist: !!r.watchlist || r.userStatus === 'watchlist',
      });
      const diaryMap = _diaryDateKeyMap();
      let added = 0, mergedCount = 0;
      (records || []).forEach(r => {
        const idx = findIdx(r);
        const f = flagsFrom(r);
        if (idx >= 0) {
          const mg = _mergeMovieMeta(movies[idx], r);
          if (!mg.watchedDate && r.watchedDate) mg.watchedDate = r.watchedDate;
          mg.liked = mg.liked || f.liked;
          mg.disliked = mg.disliked || f.disliked;
          mg.watchlist = (mg.watchlist || f.watchlist) && !mg.watchedDate;
          if (!mg.userNotes && r.userNotes) mg.userNotes = r.userNotes;
          if (mg.userRating == null && r.userRating != null) mg.userRating = r.userRating;
          if (!mg.importedDate && r.importedDate) mg.importedDate = r.importedDate;
          if (!mg.sourceAddedDateFromCSV && r.sourceAddedDateFromCSV) mg.sourceAddedDateFromCSV = r.sourceAddedDateFromCSV;
          if (mg.importOrder == null && r.importOrder != null) mg.importOrder = r.importOrder;
          mg.lists = Array.from(new Set([...(mg.lists || []), ...((r.lists) || [])]));
          mg.importedFrom = mg.importedFrom || r.importedFrom || 'csv';
          mg.linkedDiaryMemoryIds = _computeMovieDiaryLinks(mg, diaryMap);
          movies[idx] = mg; reg(mg, idx); mergedCount++;
        } else {
          const rec = _newMovieRecord(r, {
            importedFrom: r.importedFrom || 'csv', addedDate: r.addedDate,
            importedDate: r.importedDate, sourceAddedDateFromCSV: r.sourceAddedDateFromCSV,
            systemDateAdded: r.systemDateAdded, importOrder: r.importOrder,
          });
          rec.watchedDate = r.watchedDate || null;
          rec.liked = f.liked; rec.disliked = f.disliked; rec.watchlist = f.watchlist && !rec.watchedDate;
          rec.userNotes = r.userNotes || '';
          rec.userRating = r.userRating != null ? r.userRating : null;
          rec.lists = Array.isArray(r.lists) ? r.lists.slice() : [];
          rec.linkedDiaryMemoryIds = _computeMovieDiaryLinks(rec, diaryMap);
          movies.push(rec); reg(rec, movies.length - 1);
          added++;
        }
      });
      _saveMovies(movies);
      return { added, merged: mergedCount, total: movies.length };
    },
    // Repair: merge duplicate records + normalize statuses (watched/liked/etc). Idempotent,
    // preserves all user data. Fixes CSV-imported watched movies stuck at userStatus 'new'.
    repairMovies() {
      const movies = (state.movies || []).slice();
      const groups = []; const keyToGroup = {};
      const keysOf = m => {
        const ks = [];
        if (m.tmdbId != null) ks.push('t:' + m.tmdbId);
        if (m.imdbId) ks.push('i:' + m.imdbId);
        const nt = _normTitle(m.title); if (nt && m.year != null) ks.push('n:' + nt + '|' + m.year);
        return ks;
      };
      movies.forEach(m => {
        const ks = keysOf(m);
        let g = null;
        for (const k of ks) if (keyToGroup[k] != null) { g = keyToGroup[k]; break; }
        if (g == null) { g = groups.length; groups.push([]); }
        groups[g].push(m);
        ks.forEach(k => { if (keyToGroup[k] == null) keyToGroup[k] = g; });
      });
      const before = movies.length;
      const merged = groups.filter(g => g.length).map(_mergeMovieGroup);
      _saveMovies(merged);   // _normMovie inside sets userStatus + maps legacy flags
      return { before, after: merged.length, mergedDuplicates: before - merged.length };
    },
    // Run the watched/dedupe repair once per user (migration), tracked in settings.
    ensureMoviesRepaired() {
      if (!state.uid) return;
      if ((state.settings || {}).moviesRepairedV1) return;
      if (!(state.movies || []).length) {
        const settings0 = { ...(state.settings || {}), moviesRepairedV1: true };
        setState({ settings: settings0 });
        queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
        return;
      }
      this.repairMovies();
      const settings = { ...(state.settings || {}), moviesRepairedV1: true };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },

    // ── Auto-heal orphaned list refs on every attach (no version gate) ──────
    // Movie list DEFINITIONS live in settings.movieLists; MEMBERSHIPS live in movies[].lists.
    // If a settings write was lost (tab closed during the 400ms debounce, cross-device overwrite,
    // network failure) the definitions disappear while memberships survive. This runs silently on
    // every attach and fixes the inconsistency without prompting the user.
    ensureListsConsistent() {
      if (!state.uid) return { ok: true, recovered: 0 };
      const movies = state.movies || [];
      const defs = (state.settings && state.settings.movieLists) || [];
      const known = new Set(defs.map(l => l.id));
      const referenced = new Set();
      movies.forEach(m => (m.lists || []).forEach(id => { if (id) referenced.add(id); }));
      const missing = Array.from(referenced).filter(id => id && !known.has(id));
      if (!missing.length) return { ok: true, recovered: 0 };
      const recovered = missing.map((id, i) => ({
        id, name: 'Recovered list ' + (defs.length + i + 1),
        createdAt: _nowISO(), archived: false, recovered: true,
      }));
      const newDefs = [...defs, ...recovered];
      const settings = { ...(state.settings || {}), movieLists: newDefs };
      setState({ settings });
      // Immediate write (delay 0) so the repair persists even on a quick session.
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings), 0);
      if (window.NutriLogger) NutriLogger.warn('firebase', 'ensureListsConsistent', 'Auto-recovered ' + missing.length + ' orphaned list(s)');
      return { ok: true, recovered: missing.length };
    },

    // ── Movie Night: real IMDb ratings (via OMDb) ──────────────────────────
    // Save the real IMDb rating for one movie (kept SEPARATE from tmdbRating/userRating).
    setMovieImdbRating(id, data) {
      const d = data || {};
      _saveMovies((state.movies || []).map(m => m.id !== id ? m : {
        ...m,
        imdbId: d.imdbId || m.imdbId,
        imdbRating: d.imdbRating != null ? d.imdbRating : m.imdbRating,
        imdbVotes: d.imdbVotes != null ? d.imdbVotes : m.imdbVotes,
        imdbRatingSource: 'OMDb', imdbRatingUpdatedAt: _nowISO(), updatedAt: _nowISO(),
      }));
    },
    // Batch-sync IMDb ratings from OMDb. Ensures imdbId (via TMDb external ids) first.
    // opts: { force, onProgress(stats), shouldStop(), delayMs }. Throttled + chunk-flushed.
    async syncImdbRatings(opts) {
      if (!state.uid) return { error: 'Not signed in' };
      const o = opts || {};
      const onProgress = o.onProgress || function () {};
      let work = (state.movies || []).slice();
      const byId = {}; work.forEach((m, i) => { byId[m.id] = i; });
      const targets = work.filter(m => o.force ? !!(m.imdbId || m.tmdbId != null) : (m.imdbRating == null));
      const stats = { total: work.length, target: targets.length, processed: 0, fetched: 0, failed: 0, noImdb: 0 };
      const hasOmdb = !!(window.OMDB && window.OMDB.hasKey());
      const hasTmdb = !!(window.TMDB && window.TMDB.hasCredentials());
      if (!hasOmdb) return { ...stats, error: 'no-omdb-key' };
      let dirty = false, since = 0;
      const flush = () => { if (dirty) { _saveMovies(work); work = (state.movies || []).slice(); work.forEach((m, i) => { byId[m.id] = i; }); dirty = false; since = 0; } };
      for (const t of targets) {
        if (o.shouldStop && o.shouldStop()) { stats._stopped = 'user'; break; }
        const idx = byId[t.id]; if (idx == null) continue;
        let m = work[idx];
        let imdbId = m.imdbId;
        if (!imdbId && m.tmdbId != null && hasTmdb) {
          try { const ext = await window.TMDB.externalIds(m.tmdbId); if (ext && ext.imdbId) { imdbId = ext.imdbId; work[idx] = { ...m, imdbId, updatedAt: _nowISO() }; m = work[idx]; dirty = true; } } catch (_) {}
        }
        if (!imdbId) { stats.noImdb++; stats.processed++; onProgress({ ...stats }); continue; }
        try {
          const r = await window.OMDB.byImdb(imdbId);
          if (r.imdbRating != null) {
            work[idx] = { ...m, imdbId, imdbRating: r.imdbRating, imdbVotes: r.imdbVotes, imdbRatingSource: 'OMDb', imdbRatingUpdatedAt: _nowISO(), updatedAt: _nowISO() };
            dirty = true; stats.fetched++;
          } else stats.noImdb++;
        } catch (e) {
          stats.failed++;
          if (e && (e.code === 'auth' || e.code === 'rate-limit')) { stats._stopped = e.code; stats.processed++; onProgress({ ...stats }); break; }
        }
        stats.processed++; since++;
        onProgress({ ...stats });
        if (since >= 8) flush();
        await new Promise(res => setTimeout(res, o.delayMs != null ? o.delayMs : 130));
      }
      flush();
      const mn = { ...((state.settings && state.settings.movieNight) || {}), imdbSyncedAt: _nowISO() };
      setState({ settings: { ...(state.settings || {}), movieNight: mn } });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
      return stats;
    },
    // Repair movies with no poster: resolve via TMDb (by tmdbId, or imdbId→find) and store
    // posterPath/posterUrl. Never overwrites user data. opts: { onProgress, shouldStop, delayMs }.
    async repairMoviePosters(opts) {
      if (!state.uid) return { error: 'Not signed in' };
      const o = opts || {};
      const onProgress = o.onProgress || function () {};
      let work = (state.movies || []).slice();
      const byId = {}; work.forEach((m, i) => { byId[m.id] = i; });
      const missing = m => (!m.posterUrl && !m.posterPath);
      const targets = work.filter(m => missing(m) && (m.tmdbId != null || m.imdbId));
      const stats = { total: work.length, target: targets.length, processed: 0, repaired: 0, failed: 0, stillMissing: 0 };
      if (!(window.TMDB && window.TMDB.hasCredentials())) return { ...stats, error: 'no-tmdb-creds' };
      let dirty = false, since = 0;
      const flush = () => { if (dirty) { _saveMovies(work); work = (state.movies || []).slice(); work.forEach((m, i) => { byId[m.id] = i; }); dirty = false; since = 0; } };
      for (const t of targets) {
        if (o.shouldStop && o.shouldStop()) { stats._stopped = 'user'; break; }
        const idx = byId[t.id]; if (idx == null) continue;
        const m = work[idx];
        let tmdbId = m.tmdbId; const patch = {};
        try {
          if (tmdbId == null && m.imdbId) {
            const f = await window.TMDB.findByImdb(m.imdbId);
            if (f && f.tmdbId != null) {
              tmdbId = f.tmdbId; patch.tmdbId = f.tmdbId;
              if (f.posterPath) patch.posterPath = f.posterPath;
              if (f.posterUrl) patch.posterUrl = f.posterUrl;
              if (f.backdropPath) patch.backdropPath = f.backdropPath;
              if (f.backdropUrl) patch.backdropUrl = f.backdropUrl;
              if (!m.overview && f.overview) patch.overview = f.overview;
              if ((!m.genres || !m.genres.length) && f.genres) patch.genres = f.genres;
              if (!m.year && f.year) patch.year = f.year;
              if (m.tmdbRating == null && f.tmdbRating != null) patch.tmdbRating = f.tmdbRating;
            }
          }
          if (tmdbId != null && !patch.posterUrl) {
            const d = await window.TMDB.details(tmdbId);
            if (d) {
              if (d.posterPath) patch.posterPath = d.posterPath;
              if (d.posterUrl) patch.posterUrl = d.posterUrl;
              if (d.backdropPath) patch.backdropPath = d.backdropPath;
              if (d.backdropUrl) patch.backdropUrl = d.backdropUrl;
              if (patch.tmdbId == null && m.tmdbId == null) patch.tmdbId = tmdbId;
            }
          }
        } catch (e) { stats.failed++; }
        if (patch.posterUrl || patch.posterPath) { work[idx] = { ...m, ...patch, updatedAt: _nowISO() }; dirty = true; stats.repaired++; }
        else stats.stillMissing++;
        stats.processed++; since++;
        onProgress({ ...stats });
        if (since >= 8) flush();
        await new Promise(res => setTimeout(res, o.delayMs != null ? o.delayMs : 110));
      }
      flush();
      const mn = { ...((state.settings && state.settings.movieNight) || {}), postersRepairedAt: _nowISO() };
      setState({ settings: { ...(state.settings || {}), movieNight: mn } });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
      return stats;
    },
    // Recover list DEFINITIONS that were lost from settings while movies still reference
    // them (the §10 partial-save bug: memberships saved, the list registry didn't). Purely
    // additive — never deletes a list or a membership. Returns how many were recovered.
    repairMovieLists() {
      const movies = state.movies || [];
      const defs = (state.settings && state.settings.movieLists) || [];
      const known = new Set(defs.map(l => l.id));
      const referenced = new Set();
      movies.forEach(m => (m.lists || []).forEach(id => { if (id) referenced.add(id); }));
      const missing = Array.from(referenced).filter(id => id && !known.has(id));
      if (!missing.length) return { recovered: 0, lists: defs.length };
      const recovered = missing.map((id, i) => ({ id, name: 'Recovered list ' + (i + 1), createdAt: _nowISO(), archived: false, recovered: true }));
      const newDefs = [...defs, ...recovered];
      const settings = { ...(state.settings || {}), movieLists: newDefs };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
      return { recovered: recovered.length, lists: newDefs.length };
    },
    // Health-check counts for the Data Sync / Repair settings panel.
    movieSyncStats() {
      const lib = state.movies || [];
      const mn = (state.settings && state.settings.movieNight) || {};
      const known = new Set(((state.settings && state.settings.movieLists) || []).map(l => l.id));
      const refd = new Set(); lib.forEach(m => (m.lists || []).forEach(id => { if (id) refd.add(id); }));
      const orphanedLists = Array.from(refd).filter(id => !known.has(id)).length;
      return {
        total: lib.length,
        watched: lib.filter(m => m.watchedDate).length,
        liked: lib.filter(m => m.liked || _movieStar(m.userRating) === 5).length,
        watchlist: lib.filter(m => m.watchlist && !m.watchedDate).length,
        disliked: lib.filter(m => m.disliked).length,
        lists: ((state.settings && state.settings.movieLists) || []).length,
        orphanedLists,
        missingPoster: lib.filter(m => !m.posterUrl && !m.posterPath).length,
        withImdbId: lib.filter(m => m.imdbId).length,
        withImdbRating: lib.filter(m => m.imdbRating != null).length,
        missingImdbRating: lib.filter(m => m.imdbRating == null).length,
        imdbSyncedAt: mn.imdbSyncedAt || null,
        postersRepairedAt: mn.postersRepairedAt || null,
        lastSyncAt: state.lastSyncAt || null,
      };
    },
    // Reload Movie Night data from Firebase (recovery when a device is out of sync).
    async reloadAllFromCloud() {
      if (!state.uid) return { ok: false, error: 'Not signed in' };
      try {
        setState({ syncStatus: 'loading' });
        const cloud = await window.Cloud.loadAll(state.uid);
        setState({
          finance:      cloud ? (cloud.finance      || [])                                : state.finance,
          meals:        cloud ? (cloud.meals        || [])                                : state.meals,
          goals:        cloud ? (cloud.goals        || [])                                : state.goals,
          diary:        cloud ? (cloud.diary        || [])                                : state.diary,
          movies:       cloud ? (cloud.movies       || [])                                : state.movies,
          settings:     cloud ? (cloud.settings     || state.settings)                   : state.settings,
          lastSyncAt: new Date().toISOString(), syncStatus: 'idle', syncError: null,
        });
        return { ok: true };
      } catch (e) {
        setState({ syncStatus: navigator.onLine ? 'error' : 'offline', syncError: e && e.message });
        return { ok: false, error: e && e.message };
      }
    },
    async reloadMoviesFromCloud() {
      if (!state.uid) return { ok: false, error: 'Not signed in' };
      try {
        setState({ syncStatus: 'loading' });
        const cloud = await window.Cloud.loadAll(state.uid);
        setState({
          movies: (cloud && cloud.movies) || [],
          settings: (cloud && cloud.settings) || state.settings,
          lastSyncAt: new Date().toISOString(), syncStatus: 'idle', syncError: null,
        });
        return { ok: true, movies: ((cloud && cloud.movies) || []).length };
      } catch (e) {
        setState({ syncStatus: navigator.onLine ? 'error' : 'offline', syncError: e && e.message });
        return { ok: false, error: e && e.message };
      }
    },
    // Force-flush Movie Night writes now (used by the "Could not save — retry" banner).
    retryMovieSync() {
      if (!state.uid) return Promise.resolve();
      setState({ syncStatus: 'syncing' });
      return Promise.all([
        window.Cloud.writeMovies(state.uid, state.movies),
        window.Cloud.writeSettings(state.uid, state.settings),
      ]).then(() => setState({ syncStatus: 'idle', syncError: null, lastSyncAt: new Date().toISOString() }))
        .catch(e => setState({ syncStatus: !navigator.onLine ? 'offline' : 'error', syncError: e && e.message ? e.message : String(e) }));
    },
    // Watchlist sort preference (persisted per-user so it survives refresh + syncs).
    movieWatchlistSort() { return (state.settings && state.settings.movieWatchlistSort) || 'added_desc'; },
    setMovieWatchlistSort(v) {
      const settings = { ...(state.settings || {}), movieWatchlistSort: v };
      setState({ settings });
      queueCloud('settings', () => window.Cloud.writeSettings(state.uid, state.settings));
    },

    // ── Assistant chat ──
    appendAssistantChat(msg) {
      const next = state.assistantChat.concat([msg]);
      if (next.length > 200) next.splice(0, next.length - 200);
      setState({ assistantChat: next });
      queueCloud('assistant', () => window.Cloud.writeAssistant(state.uid, state.assistantChat));
    },
    clearAssistantChat() {
      setState({ assistantChat: [] });
      queueCloud('assistant', () => window.Cloud.writeAssistant(state.uid, []));
    },

    // ── Backup / Import / Migration ──
    // Export everything under the current uid.
    exportBackup() {
      if (!state.uid) { alert('Sign in first to export your data.'); return false; }
      const payload = {
        _v: 2,
        _exportedAt: new Date().toISOString(),
        uid: state.uid,
        profile: state.profile,
        macroGoals: state.macroGoals,
        meals: state.meals,
        finance: state.finance,
        goals: state.goals,
        diary: state.diary,
        assistantChat: state.assistantChat,
        settings: state.settings,
        movies: state.movies,
      };
      const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
      a.href = url; a.download = `nutri-backup-${ts}.json`;
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 1000);
      // optional: also write a snapshot to Firestore
      try { window.Cloud && window.Cloud.pushBackupSnapshot(state.uid, payload); } catch(_) {}
      return true;
    },
    // Replace local state from a Nutri or legacy Calories Analysis backup
    async importBackup(payload) {
      if (!state.uid) return { ok: false, message: 'Sign in first.' };
      if (!payload || typeof payload !== 'object') return { ok: false, message: 'Invalid file.' };
      try {
        const data = normalizeBackup(payload);
        if (!data) return { ok: false, message: "Couldn't read that file — not a recognized Nutri backup." };
        const next = {
          macroGoals:    data.macroGoals    || state.macroGoals,
          meals:         data.meals         || [],
          finance:       data.finance       || [],
          goals:         data.goals         || [],
          diary:         data.diary         || [],
          assistantChat: data.assistantChat || [],
          settings:      data.settings      || state.settings,
          localLibrary:  data.localLibrary  || [],
          movies:        data.movies        || [],
        };
        const uid = state.uid;
        // Apply locally immediately so UI updates right away
        setState({ ...next, syncStatus: 'syncing', syncError: null });
        // Push all slices to Firestore
        try {
          await Promise.all([
            window.Cloud.writeMeals(uid,         next.meals),
            window.Cloud.writeFinance(uid,        next.finance),
            window.Cloud.writeGoals(uid,          next.goals),
            window.Cloud.writeDiary(uid,          next.diary),
            window.Cloud.writeMacro(uid,          next.macroGoals),
            window.Cloud.writeAssistant(uid,      next.assistantChat),
            window.Cloud.writeSettings(uid,       next.settings),
            window.Cloud.writeLocalLibrary(uid,   next.localLibrary),
            window.Cloud.writeMovies(uid,         next.movies),
          ]);
          setState({ syncStatus: 'idle', syncError: null, lastSyncAt: new Date().toISOString() });
        } catch (cloudErr) {
          const offline = !navigator.onLine || /offline|network/i.test((cloudErr && cloudErr.message) || '');
          setState({ syncStatus: offline ? 'offline' : 'error', syncError: cloudErr && cloudErr.message ? cloudErr.message : String(cloudErr) });
        }
        return {
          ok: true,
          message: 'Backup imported.',
          stats: {
            meals:   next.meals.length,
            finance: next.finance.length,
            goals:   next.goals.length,
            diary:   next.diary.length,
          },
        };
      } catch (e) {
        return { ok: false, message: 'Could not parse backup: ' + (e && e.message ? e.message : e) };
      }
    },
    async migrateFromCaloriesAnalysis(payload) {
      // Same normalizer handles both formats — _v 1 (Calories Analysis multi-user)
      // gets squashed into the current uid as a single set of data.
      return this.importBackup(payload);
    },
    resetAllData() {
      if (!state.uid) return;
      const cleared = {
        meals: [], finance: [], goals: [], diary: [], assistantChat: [], movies: [],
        settings: { notes: {}, menu: [], weights: {} },
      };
      setState(cleared);
      const uid = state.uid;
      Promise.all([
        window.Cloud.writeMeals(uid, []),
        window.Cloud.writeFinance(uid, []),
        window.Cloud.writeGoals(uid, []),
        window.Cloud.writeDiary(uid, []),
        window.Cloud.writeAssistant(uid, []),
        window.Cloud.writeMovies(uid, []),
        window.Cloud.writeSettings(uid, cleared.settings),
      ]).catch(()=>{});
    },
  };

  // ── normalize legacy/new backups → flat single-user shape ──
  function normalizeBackup(payload) {
    // New Nutri v2 backup
    if (payload._v === 2) {
      return {
        macroGoals: payload.macroGoals,
        meals: payload.meals || [],
        finance: payload.finance || [],
        goals: payload.goals || [],
        diary: payload.diary || [],
        assistantChat: payload.assistantChat || [],
        settings: payload.settings,
        movies: payload.movies || [],
      };
    }
    // Legacy v1 Nutri backup OR a raw multi-user state
    const stateBlob = payload.state && typeof payload.state === 'object' ? payload.state : payload;
    if (!stateBlob.projects) return null;
    // pick the FIRST diet/finance/goal/diary project we find (single-user mode collapses them)
    const projects = stateBlob.projects;
    const projectMeta = stateBlob.projectMeta || [];
    const findFirst = type => {
      const meta = projectMeta.find(m => m.type === type);
      const proj = meta ? projects[meta.id] : Object.values(projects).find(p => p && p.type === type);
      return proj || null;
    };
    const diet    = findFirst('diet')    || {};
    const finance = findFirst('finance') || {};
    const goal    = findFirst('goal')    || {};
    const diary   = findFirst('diary')   || {};

    return {
      macroGoals: diet.goals || null,
      meals: Array.isArray(diet.entries) ? diet.entries : [],
      finance: Array.isArray(finance.entries) ? finance.entries : [],
      goals: Array.isArray(goal.goals) ? goal.goals : [],
      diary: Array.isArray(diary.entries) ? diary.entries : [],
      assistantChat: [],
      settings: {
        notes: diet.notes || {},
        menu: diet.menu || [],
        weights: diet.weights || {},
      },
    };
  }

  // ── React hook ──
  function useStore() {
    const [, force] = React.useReducer(x => x + 1, 0);
    React.useEffect(() => Store.subscribe(force), []);
    return Store;
  }

  // ============================================================
  //  AssistantAdapter — universal record producer (read-only)
  //  Now returns records for the single signed-in user.
  // ============================================================
  const _isISO = s => /^\d{4}-\d{2}-\d{2}$/.test(s||'');
  const _num = v => (v !== '' && v != null && !isNaN(+v)) ? +v : 0;
  const _eff = (e, k, items) => {
    if (e[k] !== '' && e[k] != null && !isNaN(+e[k])) return +e[k];
    return (items||[]).reduce((s, it) => s + (it[k] != null && !isNaN(+it[k]) ? +it[k] : 0), 0);
  };

  function adaptMeals() {
    const out = [];
    const uid = state.uid;
    state.meals.forEach(e => {
      const items = Array.isArray(e.items) ? e.items : [];
      out.push({
        projectId: 'meals', projectName: 'Calories', projectType: 'diet', userId: uid,
        recordId: e.id || ('meals:' + (e.date||'') + ':' + (e.meal||'')),
        recordType: 'meal',
        date: e.date || '', dateRange: null,
        title: e.name || e.meal || 'Meal',
        text: items.map(it => (it.name||'').trim()).filter(Boolean).join(', '),
        category: e.meal || '',
        tags: items.map(it => (it.name||'').trim()).filter(Boolean),
        amount: null,
        metrics: {
          calories: _num(e.calories),
          carbs_g:  _eff(e, 'carbs_g', items),
          protein_g:_eff(e, 'protein_g', items),
          fat_g:    _eff(e, 'fat_g', items),
        },
        sourcePath: 'meals/' + (e.id||''), rawData: e,
      });
    });
    const notes = (state.settings && state.settings.notes) || {};
    Object.entries(notes).forEach(([date, text]) => {
      const t = (text||'').trim(); if (!t) return;
      out.push({
        projectId: 'meals', projectName: 'Calories', projectType: 'diet', userId: uid,
        recordId: 'meals:note:' + date, recordType: 'note',
        date, dateRange: null,
        title: 'Day note', text: t, category: 'note', tags: [], amount: null, metrics: {},
        sourcePath: 'notes/' + date, rawData: { date, text: t },
      });
    });
    return out;
  }
  function adaptFinance() {
    const out = [];
    const uid = state.uid;
    const finCats  = (state.settings && state.settings.financeCategories)  || [];
    const finAccts = (state.settings && state.settings.financeAccounts) || [];
    const catMap = {}, subcatMap = {}, acctMap = {};
    finCats.forEach(c => {
      catMap[c.id] = c;
      (c.subcategories || []).forEach(s => { subcatMap[s.id] = { name: s.name, catId: c.id }; });
    });
    finAccts.forEach(a => { acctMap[a.id] = a; });

    state.finance.forEach(e => {
      const kind     = (e.kind || 'expense').toLowerCase();
      const catObj   = catMap[e.category]   || null;
      const subcatObj= e.subcategory ? (subcatMap[e.subcategory] || null) : null;
      const acctObj  = e.account    ? (acctMap[e.account]    || null) : null;
      out.push({
        projectId: 'finance', projectName: 'Finance', projectType: 'finance', userId: uid,
        recordId: e.id || ('finance:' + (e.date||'') + ':' + (e.amount||'') + ':' + (e.title || e.category||'')),
        recordType: kind,
        date: e.date || '', dateRange: null,
        title: e.title || e.label || e.name || e.category || (kind.charAt(0).toUpperCase() + kind.slice(1)),
        text: (e.notes || e.note || e.description || '').toString(),
        category:      e.category || '',
        categoryName:  catObj   ? catObj.name   : (e.category || ''),
        subcategory:   e.subcategory || '',
        subcategoryName: subcatObj ? subcatObj.name : (e.subcategory || ''),
        spendingType:  catObj   ? (catObj.spendingType || '') : '',
        account:       e.account || '',
        accountName:   acctObj  ? acctObj.name  : (e.account || ''),
        tags: [e.account, e.payee, e.fixingData ? 'fixing-data' : null].filter(Boolean),
        amount: _num(e.amount),
        fixingData: !!e.fixingData,
        metrics: { amount: _num(e.amount), ...(e.fixingData ? { adjustedFromBalance: _num(e.adjustedFromBalance), adjustedToBalance: _num(e.adjustedToBalance), adjustmentDifference: _num(e.adjustmentDifference) } : {}) },
        sourcePath: 'finance/' + (e.id||''), rawData: e,
      });
    });

    // Account balance records (current balance = startingBalance + all-time net flow)
    const today = (() => { const d = new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); })();
    finAccts.forEach(a => {
      const netFlow = state.finance.reduce((s, e) => {
        const kind = (e.kind||'expense');
        if (kind === 'income'   && e.account === a.id) return s + _num(e.amount);
        if (kind === 'expense'  && e.account === a.id) return s - Math.abs(_num(e.amount));
        if (kind === 'transfer') {
          if (e.fromAccount === a.id) return s - Math.abs(_num(e.amount));
          if (e.toAccount   === a.id) return s + Math.abs(_num(e.convertedAmount || e.amount));
        }
        return s;
      }, 0);
      const balance = _num(a.startingBalance) + netFlow;
      out.push({
        projectId: 'finance', projectName: 'Finance', projectType: 'finance', userId: uid,
        recordId: 'finance:account:' + (a.id||''),
        recordType: 'account',
        date: today, dateRange: null,
        title: (a.name || a.id) + ' balance',
        text: 'Current balance: ' + balance.toFixed(2),
        category: 'account', categoryName: 'Account',
        subcategory: '', subcategoryName: '',
        spendingType: '',
        account: a.id, accountName: a.name || a.id,
        tags: [a.kind].filter(Boolean),
        amount: balance,
        metrics: { balance, startingBalance: _num(a.startingBalance), netFlow },
        sourcePath: 'finance/accounts/' + (a.id||''), rawData: a,
      });
    });

    return out;
  }
  function adaptDiary() {
    const out = [];
    const uid = state.uid;
    state.diary.forEach(e => {
      const pad = n => String(n).padStart(2, '0');
      const date = e.dateKey
        || (typeof e.day === 'number' && typeof e.month === 'number' && typeof e.year === 'number'
              ? `${e.year}-${pad(e.month)}-${pad(e.day)}`
              : (e.date || ''));
      const text = (e.eventText || e.text || e.body || e.content || e.note || e.notes || '').toString();
      const mood = (e.mood || '').toString();
      const entryMode = e.entryMode || (text.trim() ? 'full' : 'quick');
      const recordType = entryMode === 'quick' ? 'mood_tag_only' : 'full_entry';
      out.push({
        projectId: 'diary', projectName: 'Diary', projectType: 'diary', userId: uid,
        recordId: e.id || ('diary:' + date),
        recordType,
        date, dateRange: null,
        title: (e.title || 'Entry').toString(),
        text,
        mood,
        category: (e.category || mood).toString(),
        tags: Array.isArray(e.tags) ? e.tags.slice() : [],
        amount: null, metrics: {},
        sourcePath: 'diary/' + (e.id||''), rawData: e,
      });
    });
    return out;
  }
  function adaptGoals() {
    const out = [];
    const uid = state.uid;
    // compute streak for a goal
    function _streak(g) {
      const today = (() => { const d = new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); })();
      const log = (g.log && typeof g.log === 'object') ? g.log : {};
      let streak = 0;
      for (let i = 0; i < 100; i++) {
        const d2 = new Date(today + 'T00:00:00'); d2.setDate(d2.getDate() - i);
        const iso = d2.getFullYear()+'-'+String(d2.getMonth()+1).padStart(2,'0')+'-'+String(d2.getDate()).padStart(2,'0');
        const arr = Array.isArray(log[iso]) ? log[iso] : [];
        if (!arr.length) continue; // not scheduled or no data — skip
        const done = arr.filter(s => s === 'done').length;
        const tpd  = _num(g.timesPerDay) || 1;
        if (done >= tpd) streak++;
        else break;
      }
      return streak;
    }
    state.goals.forEach(g => {
      const streak = _streak(g);
      const wds = Array.isArray(g.weekdays) ? g.weekdays : [0,1,2,3,4,5,6];
      const scheduleDesc = (g.repeatType === 'interval' && g.repeatInterval > 0)
        ? `every ${g.repeatInterval} days`
        : wds.length === 7 ? 'daily'
        : wds.length === 5 && !wds.includes(0) && !wds.includes(6) ? 'weekdays'
        : wds.length === 2 && wds.includes(0) && wds.includes(6) ? 'weekends'
        : 'custom (' + wds.map(d => ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d]).join(', ') + ')';
      out.push({
        projectId: 'goals', projectName: 'Goals', projectType: 'goal', userId: uid,
        recordId: 'goals:goal:' + (g.id||''),
        recordType: 'goal-def',
        date: g.startDate || '', dateRange: null,
        title: g.name || 'Goal',
        text: `Schedule: ${scheduleDesc}. Target: ${_num(g.timesPerDay)||1} ${g.unit||'session'}/day. Streak: ${streak} days.`,
        category: 'goal', tags: [], amount: null,
        metrics: { timesPerDay: _num(g.timesPerDay) || 1, durationDays: _num(g.durationDays), streak },
        sourcePath: 'goals/' + (g.id||''), rawData: g,
      });
      const log = (g.log && typeof g.log === 'object') ? g.log : {};
      Object.entries(log).forEach(([date, slots]) => {
        const arr = Array.isArray(slots) ? slots : [];
        const done   = arr.filter(s => s === 'done').length;
        const miss   = arr.filter(s => s === 'miss').length;
        const skip   = arr.filter(s => s === 'skip').length;
        const total  = _num(g.timesPerDay) || arr.length || 0;
        const isSkip = skip > 0 && done === 0 && miss === 0;
        if (done === 0 && miss === 0 && !isSkip) return;
        const status = isSkip ? 'skipped' : done >= total ? 'completed' : done > 0 ? 'partial' : 'missed';
        out.push({
          projectId: 'goals', projectName: 'Goals', projectType: 'goal', userId: uid,
          recordId: 'goals:glog:' + (g.id||'') + ':' + date,
          recordType: 'goal-log',
          date, dateRange: null,
          title: g.name || 'Goal',
          text: done + '/' + total + ' done' + (miss ? ', ' + miss + ' missed' : '') + (isSkip ? ' (skipped)' : ''),
          category: status, tags: [], amount: null,
          metrics: { done, miss, skip, total, status },
          sourcePath: 'goals/' + (g.id||'') + '/log/' + date,
          rawData: { goalId: g.id, date, slots: arr },
        });
      });
    });
    return out;
  }
  function adaptMovies() {
    const out = [];
    const uid = state.uid;
    (state.movies || []).forEach(m => {
      const watched = !!m.watchedDate;
      const liked = _movieLiked(m);
      const star = _movieStar(m.userRating);   // 1–5 (interprets legacy 1–10)
      const date = m.watchedDate || m.addedDate || m.importedDate || '';
      out.push({
        projectId: 'movies', projectName: 'Movie Night', projectType: 'movies', userId: uid,
        recordId: m.id || ('movies:' + (m.tmdbId || m.title || '')),
        recordType: 'movie',
        date, dateRange: null,
        title: m.title || 'Movie',
        text: m.overview || '',
        category: m.userStatus || _deriveMovieStatus(m),
        tags: Array.isArray(m.genres) ? m.genres.slice() : [],
        amount: null,
        metrics: {
          tmdbRating: m.tmdbRating != null ? m.tmdbRating : null,
          userRating: star || null,          // user's personal rating on a 1–5 scale
          year: m.year != null ? m.year : null,
          runtime: m.runtime != null ? m.runtime : null,
          watched: watched ? 1 : 0,
          liked: liked ? 1 : 0,
          disliked: m.disliked ? 1 : 0,
          rated: star ? 1 : 0,
        },
        sourcePath: 'movies/' + (m.id || ''), rawData: m,
      });
    });
    return out;
  }
  const AssistantAdapter = {
    // Kept for backward compat with the existing assistant query engine.
    getRecordsForUser(_uidIgnored) {
      return [].concat(adaptMeals(), adaptFinance(), adaptDiary(), adaptGoals(), adaptMovies());
    },
  };

  // Try to load any locally-cached state before the user logs in
  const cached = loadLocal();
  if (cached) state = Object.assign(blankState(), cached);

  Object.assign(window, { Store, AssistantAdapter, useStore });
})();
