// app/store.jsx — UI runtime. Talks to the backend ONLY through window.LCAdapter.
//
// Contains NO recipe data, NO seed content, NO domain rules — those live in the
// adapter (data) and domain.js (presentation). This file owns React state,
// navigation, and the device/cook-timer UI concerns that are prototype-side only.
//
// The useApp() surface below is the stable contract every screen consumes; it
// does not change when the data source flips from mock to live.
//
// Depends on: window.LCAdapter (adapter.js), window.LCDomain (domain.js)

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

const AppCtx = createContext(null);
const useApp = () => useContext(AppCtx);

// device → responsive layout mode + on-screen pixel size (prototype framing only)
const DEVICE_LAYOUT = { laptop: 'desktop', ipad: 'tablet', iphone: 'mobile' };

// In the SHIPPED app (live mode) there is no device picker — layout follows the
// real viewport. These breakpoints mirror the three prototype sizes.
const IS_LIVE = (typeof window !== 'undefined') && window.LC_ADAPTER_MODE === 'live';
function layoutForWidth(w) {
  if (w >= 1080) return 'desktop';
  if (w >= 700) return 'tablet';
  return 'mobile';
}
function useViewportLayout(active) {
  const get = () => layoutForWidth(typeof window !== 'undefined' ? window.innerWidth : 1440);
  const [vp, setVp] = useState(get);
  useEffect(() => {
    if (!active) return;
    const onResize = () => setVp(get());
    onResize();
    window.addEventListener('resize', onResize);
    window.addEventListener('orientationchange', onResize);
    return () => {
      window.removeEventListener('resize', onResize);
      window.removeEventListener('orientationchange', onResize);
    };
  }, [active]);
  return vp;
}
const DEVICE_SCREEN = {
  laptop: { w: 1440, h: 900 },
  ipad:   { w: 1180, h: 820 },
  iphone: { w: 390,  h: 844 },
};

// ── Session = UI runtime state (prototype-only; never sent to the backend) ──
const SESSION_KEY = 'luiscooks.session.v2';
const SESSION_DEFAULT = {
  device: null, loggedIn: false,
  screen: 'login', params: {}, history: [], navTab: 'recipes',
  search: '', filter: 'all', sort: 'recent',
  timers: [],
};
const SESSION_KEYS = Object.keys(SESSION_DEFAULT);

// ── Data = per-user state, sourced from the adapter (mock or live) ──
const DATA_DEFAULT = {
  recipes: [],
  favourites: [], notesByRecipe: {},
  checkedByRecipe: {}, cookStepByRecipe: {},
  plan: {}, shoppingRecipes: [], shoppingChecked: [], customRecipes: [],
  settings: { unitSystem: 'metric', defaultPlanningServings: 2 }, ingredientCosts: [],
  loginError: null, // transient; not persisted (lives outside SESSION_KEYS)
};

function loadSession() {
  try {
    const raw = localStorage.getItem(SESSION_KEY);
    const saved = raw ? JSON.parse(raw) : {};
    const out = { ...SESSION_DEFAULT };
    SESSION_KEYS.forEach((k) => { if (saved[k] !== undefined) out[k] = saved[k]; });
    return out;
  } catch (e) { return { ...SESSION_DEFAULT }; }
}
function saveSession(state) {
  try {
    const out = {};
    SESSION_KEYS.forEach((k) => { out[k] = state[k]; });
    localStorage.setItem(SESSION_KEY, JSON.stringify(out));
  } catch (e) {}
}

const upsertById = (list, item) => {
  const exists = (list || []).some((x) => x.id === item.id);
  return exists ? list.map((x) => (x.id === item.id ? item : x)) : [item, ...(list || [])];
};

function AppProvider({ children }) {
  const adapter = window.LCAdapter;
  const [state, setState] = useState(() => ({ ...DATA_DEFAULT, ...loadSession() }));
  const [ref, setRef] = useState({
    WEEK_DAYS: [], AI_INPUTS: [], PLAN_ALTERNATIVES: [], SCAN_ITEMS: [], SHARED_KITCHENS: [],
  });
  const [dataLoaded, setDataLoaded] = useState(false);

  // ── bootstrap the signed-in snapshot once on mount ──
  // A failed boot must NEVER trap the app on the <Booting/> splash. The live API
  // now returns an anonymous-safe empty shell (200) before login, so the common
  // pre-login case no longer 401s — but we still defend here: on any failure we
  // set dataLoaded so the app renders, and on a 401 we drop a stale/expired token
  // and fall back to the login screen instead of a broken signed-in state.
  const bootstrap = useCallback(async () => {
    let snap;
    try {
      snap = await adapter.bootstrap();
    } catch (e) {
      if (/\b401\b/.test(String((e && e.message) || ''))) {
        try { window.localStorage.removeItem('luiscooks.token'); } catch (_) {}
        setState((s) => ({ ...s, loggedIn: false, screen: 'login', params: {}, history: [] }));
      }
      setDataLoaded(true); // render the app (login screen) rather than hang on splash
      return;
    }
    setState((s) => ({
      ...s,
      recipes: snap.recipes || [],
      favourites: snap.favourites || [],
      notesByRecipe: snap.notes || {},
      checkedByRecipe: snap.checkedByRecipe || {},
      cookStepByRecipe: snap.cookStepByRecipe || {},
      plan: snap.plan || {},
      shoppingRecipes: snap.shoppingRecipes || [],
      shoppingChecked: snap.shoppingChecked || [],
      customRecipes: snap.customRecipes || [],
      settings: snap.settings || { unitSystem: 'metric', defaultPlanningServings: 2 },
      ingredientCosts: snap.ingredientCosts || [],
    }));
    setRef({
      WEEK_DAYS: snap.weekDays || [],
      AI_INPUTS: snap.planInputs || [],
      PLAN_ALTERNATIVES: snap.planAlternatives || [],
      SCAN_ITEMS: snap.scanItems || [],
      SHARED_KITCHENS: snap.sharedKitchens || [],
    });
    setDataLoaded(true);
  }, [adapter]);

  useEffect(() => { bootstrap(); }, [bootstrap]);

  // persist ONLY the session subset
  useEffect(() => { saveSession(state); },
    [state.device, state.loggedIn, state.screen, state.params, state.history,
     state.navTab, state.search, state.filter, state.sort, state.timers]);

  const set = useCallback((patch) => {
    setState((s) => ({ ...s, ...(typeof patch === 'function' ? patch(s) : patch) }));
  }, []);

  // ── recipes ──
  const allRecipes = state.recipes;
  const getRecipe = useCallback((id) =>
    (state.recipes || []).find((r) => r.id === id) || null, [state.recipes]);
  const photoFor = useCallback((id) => {
    const r = (state.recipes || []).find((x) => x.id === id);
    return r ? (r.photoUrl || null) : null;
  }, [state.recipes]);

  // ── navigation (pure UI) ──
  // Scroll handling: forward nav (go/openRecipe/setTab) starts the new screen at
  // the top; `back` restores the scroll position of the screen you're returning
  // to, so casually browsing the library and dipping into a recipe doesn't lose
  // your place. The outgoing scrollTop is stashed on the history entry and
  // re-applied on the way back.
  const historyRef = useRef(state.history);
  historyRef.current = state.history;
  const getScroll = () => {
    const sc = document.querySelector('[data-app-scroll]');
    return sc ? sc.scrollTop : 0;
  };
  const scrollTop = () => requestAnimationFrame(() => {
    const sc = document.querySelector('[data-app-scroll]');
    if (sc) sc.scrollTop = 0;
  });
  // `back` stashes the scroll position to restore here; a useLayoutEffect applies
  // it synchronously BEFORE the browser paints the destination screen, so there's
  // no flash of the top-of-list before it jumps to where you were.
  const pendingScroll = useRef(null);
  const go = useCallback((screen, params = {}) => {
    const scroll = getScroll();
    setState((s) => ({ ...s,
      history: [...s.history, { screen: s.screen, params: s.params, navTab: s.navTab, scroll }],
      screen, params }));
    scrollTop();
  }, []);
  const back = useCallback(() => {
    const hist = historyRef.current || [];
    if (!hist.length) return;
    const prev = hist[hist.length - 1];
    pendingScroll.current = prev.scroll || 0;
    setState((s) => ({ ...s, screen: prev.screen, params: prev.params,
      navTab: prev.navTab ?? s.navTab, history: s.history.slice(0, -1) }));
  }, []);
  const TAB_SCREEN = { recipes: 'library', cook: 'scan', plan: 'plan', groceries: 'shopping' };
  const setTab = useCallback((tab) => {
    setState((s) => ({ ...s, navTab: tab, screen: TAB_SCREEN[tab] || 'library', params: {}, history: [] }));
    scrollTop();
  }, []);
  const openRecipe = useCallback((id, opts = {}) => {
    const scroll = getScroll();
    setState((s) => ({ ...s,
      history: [...s.history, { screen: s.screen, params: s.params, navTab: s.navTab, scroll }],
      screen: 'recipe', params: { recipeId: id, ...opts } }));
    scrollTop();
  }, []);
  // Apply a pending restore before paint. Runs synchronously after the new
  // screen's DOM is committed; setting scrollTop here (vs a timeout/rAF) means
  // the very first painted frame is already at the right position — no flicker.
  // (Idempotent under StrictMode's double-invoke: it just sets the same value.)
  useLayoutEffect(() => {
    if (pendingScroll.current == null) return;
    const y = pendingScroll.current;
    pendingScroll.current = null;
    const sc = document.querySelector('[data-app-scroll]');
    if (sc) sc.scrollTop = y;
  }, [state.screen, state.params]);

  // ── device (prototype framing) ──
  const pickDevice = useCallback((device) => setState((s) => ({ ...s, device })), []);
  const exitDevice = useCallback(() => setState((s) => ({ ...s, device: null })), []);

  // ── auth ──
  // Bearer auth is enforced live: a wrong token makes adapter.login REJECT (the
  // 401 throws), so we must only mark loggedIn when the call RESOLVES. Gating on
  // "didn't throw / ok !== false" is the right signal in both modes — live throws
  // on a bad token; the mock always resolves, so the offline demo keeps working.
  const login = useCallback(async (token) => {
    let r = null;
    try { r = await adapter.login(token); } catch (e) { r = null; }
    if (!r || r.ok === false) {
      setState((s) => ({ ...s, loginError: "That token didn't work \u2014 check it and try again." }));
      return;
    }
    await bootstrap();
    setState((s) => ({ ...s, loggedIn: true, loginError: null, screen: 'library', navTab: 'recipes', params: {}, history: [] }));
  }, [adapter, bootstrap]);
  const logout = useCallback(async () => {
    try { await adapter.logout(); } catch (e) {}
    setState((s) => ({ ...s, loggedIn: false, screen: 'login', params: {}, history: [] }));
  }, [adapter]);

  // ── favourites ──
  const isFav = useCallback((id) => (state.favourites || []).includes(id), [state.favourites]);
  const toggleFav = useCallback((id) => {
    setState((s) => {
      const has = s.favourites.includes(id);
      const next = has ? s.favourites.filter((x) => x !== id) : [...s.favourites, id];
      adapter.setFavourite(id, !has);
      return { ...s, favourites: next };
    });
  }, [adapter]);

  // ── ratings ──
  // Rating is a single field on the recipe now (backend consolidated it onto
  // Recipe.rating; bootstrap.ratings is deprecated/empty — see
  // docs/design-handoff/ratings-single-field.md). setRating keeps the local
  // recipe fresh so the editor/planner reflect a just-set rating without a refresh.
  const ratingFor = useCallback((r) => (r && r.rating) || 0, []);
  const setRating = useCallback((id, v) => {
    setState((s) => ({ ...s,
      recipes: s.recipes.map((r) => (r.id === id ? { ...r, rating: v } : r)),
      customRecipes: s.customRecipes.map((r) => (r.id === id ? { ...r, rating: v } : r)) }));
    adapter.setRating(id, v);
  }, [adapter]);

  // ── notes ──
  const noteFor = useCallback((r) =>
    state.notesByRecipe[r.id] != null ? state.notesByRecipe[r.id] : r.notes, [state.notesByRecipe]);
  const setNote = useCallback((id, v) => {
    setState((s) => ({ ...s, notesByRecipe: { ...s.notesByRecipe, [id]: v } }));
    adapter.setNote(id, v);
  }, [adapter]);

  // ── cook-mode ingredient check ──
  const checkedFor = useCallback((id) => new Set(state.checkedByRecipe[id] || []), [state.checkedByRecipe]);
  const toggleChecked = useCallback((id, line) => {
    setState((s) => {
      const cur = new Set(s.checkedByRecipe[id] || []);
      cur.has(line) ? cur.delete(line) : cur.add(line);
      const lines = [...cur];
      adapter.setChecked(id, lines);
      return { ...s, checkedByRecipe: { ...s.checkedByRecipe, [id]: lines } };
    });
  }, [adapter]);
  const cookStep = useCallback((id) => state.cookStepByRecipe[id] || 0, [state.cookStepByRecipe]);
  const setCookStep = useCallback((id, idx) => {
    setState((s) => ({ ...s, cookStepByRecipe: { ...s.cookStepByRecipe, [id]: idx } }));
    adapter.setCookStep(id, idx);
  }, [adapter]);

  // ── timers (count-up · ephemeral UI, prototype-side only) ──
  const addTimer = useCallback((label) => {
    setState((s) => ({ ...s, timers: [...s.timers, {
      id: 't' + Date.now(), label: label || 'Timer', base: 0, startedAt: Date.now(), running: true,
    }] }));
  }, []);
  const toggleTimer = useCallback((id) => {
    setState((s) => ({ ...s, timers: s.timers.map((t) => {
      if (t.id !== id) return t;
      if (t.running) return { ...t, running: false, base: t.base + (Date.now() - t.startedAt) / 1000, startedAt: null };
      return { ...t, running: true, startedAt: Date.now() };
    }) }));
  }, []);
  const removeTimer = useCallback((id) => {
    setState((s) => ({ ...s, timers: s.timers.filter((t) => t.id !== id) }));
  }, []);

  // ── shopping ──
  const addRecipeToShopping = useCallback((id) => {
    setState((s) => s.shoppingRecipes.includes(id) ? s
      : { ...s, shoppingRecipes: [...s.shoppingRecipes, id] });
    adapter.addShoppingRecipe(id);
  }, [adapter]);
  const removeRecipeFromShopping = useCallback((id) => {
    setState((s) => ({ ...s, shoppingRecipes: s.shoppingRecipes.filter((x) => x !== id) }));
    adapter.removeShoppingRecipe(id);
  }, [adapter]);
  const toggleShopItem = useCallback((key) => {
    setState((s) => {
      const has = s.shoppingChecked.includes(key);
      const next = has ? s.shoppingChecked.filter((x) => x !== key) : [...s.shoppingChecked, key];
      adapter.setShoppingChecked(next);
      return { ...s, shoppingChecked: next };
    });
  }, [adapter]);

  // ── plan ──
  const setPlanDay = useCallback((day, entry) => {
    setState((s) => ({ ...s, plan: { ...s.plan, [day]: entry } }));
    adapter.setPlanDay(day, entry);
  }, [adapter]);
  const regeneratePlan = useCallback(async () => {
    const plan = await adapter.regeneratePlan();
    if (plan) setState((s) => ({ ...s, plan }));
  }, [adapter]);
  const autoSchedule = useCallback(async (opts) => {
    const res = await adapter.autoSchedule(opts);
    if (res && res.plan) setState((s) => ({ ...s, plan: res.plan }));
    return res;
  }, [adapter]);

  // ── recipe CRUD ──
  const saveRecipe = useCallback((recipe) => {
    // optimistic insert so the recipe screen finds it immediately
    setState((s) => ({ ...s,
      recipes: upsertById(s.recipes, recipe),
      customRecipes: upsertById(s.customRecipes, recipe) }));
    Promise.resolve(adapter.saveRecipe(recipe)).then((saved) => {
      if (saved) setState((s) => ({ ...s, recipes: upsertById(s.recipes, saved) }));
    }).catch(() => {});
  }, [adapter]);

  // ── photo enhancement (AI) ──
  // enhancePhoto kicks off the job and polls getRecipe() until the backend
  // reports done/failed; revertPhoto discards the AI photo. All state lives on
  // the recipe object (enhancementStatus / photoUrl / originalPhotoUrl), so the
  // hero re-renders automatically as status changes.
  const pollers = useRef({});
  const applyRecipe = useCallback((saved) => {
    if (saved) setState((s) => ({ ...s, recipes: upsertById(s.recipes, saved) }));
  }, []);
  const stopPoll = useCallback((id) => {
    if (pollers.current[id]) { clearInterval(pollers.current[id]); delete pollers.current[id]; }
  }, []);
  const pollEnhance = useCallback((id) => {
    if (pollers.current[id]) return;
    pollers.current[id] = setInterval(async () => {
      try {
        const r = await adapter.getRecipe(id);
        if (r) { applyRecipe(r); if (r.enhancementStatus !== 'enhancing') stopPoll(id); }
      } catch (e) {}
    }, 1200);
  }, [adapter, applyRecipe, stopPoll]);
  const enhancePhoto = useCallback(async (id, opts = {}) => {
    setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id
      ? { ...r, enhancementStatus: 'enhancing', enhancementError: null } : r) }));
    try {
      const r = await adapter.enhancePhoto(id, opts);
      if (r) applyRecipe(r);
      pollEnhance(id);
    } catch (e) {
      setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id
        ? { ...r, enhancementStatus: 'failed', enhancementError: 'Could not start — try again.' } : r) }));
    }
  }, [adapter, applyRecipe, pollEnhance]);
  const revertPhoto = useCallback(async (id) => {
    stopPoll(id);
    try { const r = await adapter.revertPhoto(id); if (r) applyRecipe(r); } catch (e) {}
  }, [adapter, applyRecipe, stopPoll]);
  useEffect(() => () => { Object.values(pollers.current).forEach(clearInterval); }, []);

  // ── recipe costing (AI parse + price DB) ──
  // calculateCost kicks off the job and polls getRecipe() until the backend
  // reports costed/needs_attention; mirrors the enhance flow. All state lives on
  // the recipe (costStatus / costPerServing / fullCost / costBreakdown), so the
  // cost UI re-renders as status changes.
  const costPollers = useRef({});
  const stopCostPoll = useCallback((id) => {
    if (costPollers.current[id]) { clearInterval(costPollers.current[id]); delete costPollers.current[id]; }
  }, []);
  const pollCost = useCallback((id) => {
    if (costPollers.current[id]) return;
    costPollers.current[id] = setInterval(async () => {
      try {
        const r = await adapter.getRecipe(id);
        if (r) { applyRecipe(r); if (r.costStatus !== 'costing') stopCostPoll(id); }
      } catch (e) {}
    }, 1000);
  }, [adapter, applyRecipe, stopCostPoll]);
  const calculateCost = useCallback(async (id, opts = {}) => {
    setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id
      ? { ...r, costStatus: 'costing' } : r) }));
    try {
      const r = await adapter.calculateCost(id, opts);
      if (r) applyRecipe(r);
      pollCost(id);
    } catch (e) {
      setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id
        ? { ...r, costStatus: r.costStatus === 'costing' ? 'none' : r.costStatus } : r) }));
    }
  }, [adapter, applyRecipe, pollCost]);
  useEffect(() => () => { Object.values(costPollers.current).forEach(clearInterval); }, []);

  // ── recipe effort estimate (on-demand AI) ──
  // estimateEffort kicks off the job and polls getRecipe() until effortStatus
  // leaves 'estimating'; mirrors the cost/enhance flow. State lives on the recipe
  // (effortStatus / effortMinutes / effort / effortEstimated).
  const effortPollers = useRef({});
  const stopEffortPoll = useCallback((id) => {
    if (effortPollers.current[id]) { clearInterval(effortPollers.current[id]); delete effortPollers.current[id]; }
  }, []);
  const pollEffort = useCallback((id) => {
    if (effortPollers.current[id]) return;
    effortPollers.current[id] = setInterval(async () => {
      try {
        const r = await adapter.getRecipe(id);
        if (r) { applyRecipe(r); if (r.effortStatus !== 'estimating') stopEffortPoll(id); }
      } catch (e) {}
    }, 1000);
  }, [adapter, applyRecipe, stopEffortPoll]);
  const estimateEffort = useCallback(async (id) => {
    setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id
      ? { ...r, effortStatus: 'estimating' } : r) }));
    try {
      const r = await adapter.estimateEffort(id);
      if (r) applyRecipe(r);
      pollEffort(id);
    } catch (e) {
      setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id
        ? { ...r, effortStatus: r.effortMinutes != null ? 'done' : 'none' } : r) }));
    }
  }, [adapter, applyRecipe, pollEffort]);
  useEffect(() => () => { Object.values(effortPollers.current).forEach(clearInterval); }, []);

  // ── settings ──
  const settings = state.settings || { unitSystem: 'metric', defaultPlanningServings: 2 };
  const setSettings = useCallback((patch) => {
    setState((s) => ({ ...s, settings: { ...s.settings, ...patch } }));
    adapter.setSettings(patch);
  }, [adapter]);

  // ── ingredient-price DB (Settings ▸ ingredient costs) ──
  const saveIngredientCost = useCallback(async (item) => {
    const saved = await adapter.saveIngredientCost(item);
    if (saved) setState((s) => {
      const exists = (s.ingredientCosts || []).some((x) => x.key === saved.key);
      return { ...s, ingredientCosts: exists
        ? s.ingredientCosts.map((x) => (x.key === saved.key ? saved : x))
        : [saved, ...(s.ingredientCosts || [])] };
    });
    return saved;
  }, [adapter]);
  const deleteIngredientCost = useCallback((key) => {
    setState((s) => ({ ...s, ingredientCosts: (s.ingredientCosts || []).filter((x) => x.key !== key) }));
    adapter.deleteIngredientCost(key);
  }, [adapter]);

  // ── plan: per-meal planned servings (Phase 2 UI) ──
  const setPlanServings = useCallback((day, n) => {
    setState((s) => {
      const e = s.plan[day];
      if (!e || e.skip || !e.recipeId) return s;
      const entry = { ...e, plannedServings: Math.max(1, n) };
      adapter.setPlanDay(day, entry);
      return { ...s, plan: { ...s.plan, [day]: entry } };
    });
  }, [adapter]);

  // ── fridge scan helpers (AI endpoints) ──
  const scanFridge = useCallback(() => adapter.scanFridge(), [adapter]);
  const matchRecipes = useCallback((items) => adapter.matchRecipes(items), [adapter]);

  // ── reset (prototype convenience) ──
  const resetAll = useCallback(async () => {
    try { await adapter.resetDemo(); } catch (e) {}
    try { localStorage.removeItem(SESSION_KEY); } catch (e) {}
    setState((s) => ({ ...DATA_DEFAULT, ...SESSION_DEFAULT, device: s.device }));
    await bootstrap();
  }, [adapter, bootstrap]);

  const viewportLayout = useViewportLayout(IS_LIVE);
  // Live (shipped): follow the real viewport. Prototype: follow the chosen device.
  const layout = IS_LIVE
    ? viewportLayout
    : (state.device ? DEVICE_LAYOUT[state.device] : 'desktop');

  const value = {
    state, set, layout, device: state.device, dataLoaded, isLive: IS_LIVE,
    DEVICE_SCREEN, DEVICE_LAYOUT,
    allRecipes, getRecipe, photoFor,
    go, back, setTab, openRecipe, pickDevice, exitDevice, login, logout,
    isFav, toggleFav, ratingFor, setRating, noteFor, setNote,
    checkedFor, toggleChecked, cookStep, setCookStep,
    addTimer, toggleTimer, removeTimer,
    addRecipeToShopping, removeRecipeFromShopping, toggleShopItem,
    setPlanDay, regeneratePlan, autoSchedule, saveRecipe, resetAll,
    enhancePhoto, revertPhoto,
    calculateCost, settings, setSettings, ingredientCosts: state.ingredientCosts,
    saveIngredientCost, deleteIngredientCost, setPlanServings,
    estimateEffort,
    scanFridge, matchRecipes,
    // reference data (from bootstrap)
    WEEK_DAYS: ref.WEEK_DAYS, AI_INPUTS: ref.AI_INPUTS, PLAN_ALTERNATIVES: ref.PLAN_ALTERNATIVES,
    SCAN_ITEMS: ref.SCAN_ITEMS, SHARED_KITCHENS: ref.SHARED_KITCHENS,
    // presentation helpers (ship)
    parseIngredientGroups: window.LCDomain.parseIngredientGroups,
    buildShoppingList: window.LCDomain.buildShoppingList,
    costInfo: window.LCDomain.costInfo,
    money: window.LCDomain.money,
    costForServings: window.LCDomain.costForServings,
    scaleQuantities: window.LCDomain.scaleQuantities,
  };

  return <AppCtx.Provider value={value}>{children}</AppCtx.Provider>;
}

// ── live clock hook: re-render every second (for ticking timers) ──
function useNow(active) {
  const [, force] = useState(0);
  useEffect(() => {
    if (!active) return;
    const id = setInterval(() => force((n) => n + 1), 1000);
    return () => clearInterval(id);
  }, [active]);
  return Date.now();
}
function timerElapsed(t, now) {
  const base = t.base || 0;
  return Math.floor(base + (t.running && t.startedAt ? (now - t.startedAt) / 1000 : 0));
}
function fmtTime(secs) {
  const m = Math.floor(secs / 60);
  const s = secs % 60;
  return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}

Object.assign(window, {
  AppProvider, useApp, useNow, timerElapsed, fmtTime,
  DEVICE_LAYOUT, DEVICE_SCREEN, IS_LIVE,
});
