// Estado global de la app + componentes UI compartidos (Bubblegum)

const STORAGE_KEY = 'shoplist.v2';

function loadPersisted() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    // Sanea cantidades legacy ("1 kg", "2 L"…) a enteros la primera vez que
    // arranca esta versión. La función normalizeQty viene de data.jsx.
    if (parsed?.products && typeof normalizeQty === 'function') {
      for (const lid of Object.keys(parsed.products)) {
        const arr = parsed.products[lid];
        if (Array.isArray(arr)) {
          parsed.products[lid] = arr.map(it => ({ ...it, quantity: normalizeQty(it.quantity) }));
        }
      }
    }
    return parsed;
  } catch (e) { return null; }
}

function persist(state) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  } catch (e) { /* quota / private mode — fail silently */ }
}

// Viewport hook — un único breakpoint a 900px. Móvil = UI actual; desktop = layout web.
function useViewport() {
  const query = '(min-width: 900px)';
  const get = () => (typeof window !== 'undefined' && window.matchMedia(query).matches) ? 'desktop' : 'mobile';
  const [mode, setMode] = React.useState(get);
  React.useEffect(() => {
    const mql = window.matchMedia(query);
    const onChange = () => setMode(mql.matches ? 'desktop' : 'mobile');
    if (mql.addEventListener) mql.addEventListener('change', onChange);
    else mql.addListener(onChange);
    return () => {
      if (mql.removeEventListener) mql.removeEventListener('change', onChange);
      else mql.removeListener(onChange);
    };
  }, []);
  return { mode, isDesktop: mode === 'desktop' };
}

function useAppState() {
  const persisted = React.useMemo(loadPersisted, []);
  const { mode, isDesktop } = useViewport();

  const [lang, setLang]                 = React.useState(persisted?.lang || (navigator.language?.startsWith('es') ? 'es' : 'es'));
  const [dark, setDark]                 = React.useState(persisted?.dark ?? false);
  const [density, setDensity]           = React.useState(persisted?.density || 'comfy'); // compact | comfy | spacious
  const [cardStyle, setCardStyle]       = React.useState(persisted?.cardStyle || 'default'); // default | sticker | minimal
  const [animations, setAnimations]     = React.useState(persisted?.animations ?? true);
  const [shopLayout, setShopLayout]     = React.useState(persisted?.shopLayout || 'mega'); // mega | grid (sin swipe)
  const [autoCategory, setAutoCategory] = React.useState(persisted?.autoCategory ?? true);
  const [themeId, setThemeId]           = React.useState(persisted?.themeId || 'kraft');
  const [iconStyle, setIconStyle]       = React.useState(persisted?.iconStyle || 'material');
  const [sortMode, setSortMode]         = React.useState(persisted?.sortMode || 'added'); // 'added' | 'name'
  const [groupByCategory, setGroupByCategory] = React.useState(persisted?.groupByCategory ?? false);

  // Sincroniza el estilo elegido a window para que CatGlyph (en data.jsx)
  // pueda leerlo sin pasar prop. También carga las hojas de fuente externas
  // bajo demanda (Material Symbols solo si se elige material).
  React.useEffect(() => {
    if (typeof window !== 'undefined') window.__iconStyle = iconStyle;
    if (iconStyle === 'material' && !document.getElementById('sl-mat-symbols')) {
      const link = document.createElement('link');
      link.id = 'sl-mat-symbols';
      link.rel = 'stylesheet';
      link.href = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap';
      document.head.appendChild(link);
    }
  }, [iconStyle]);

  const [lists, setLists]                 = React.useState(persisted?.lists || SEED_LISTS);
  const [currentListId, setCurrentListId] = React.useState(persisted?.currentListId || null);
  const [products, setProducts]           = React.useState(persisted?.products || SEED_PRODUCTS);
  const [history, setHistory]             = React.useState(persisted?.history || SEED_HISTORY);
  const [tab, setTab]                     = React.useState(persisted?.tab || 'list');
  // Listas ocultas localmente: el usuario las borró del dispositivo pero
  // siguen existiendo en Supabase (no se propaga el delete remoto). Cualquier
  // pull/realtime las filtra para que no reaparezcan.
  const [hiddenListIds, setHiddenListIds] = React.useState(persisted?.hiddenListIds || []);

  // Snapshot del último item eliminado para mostrar la toast de "Deshacer".
  // No se persiste: si recargas la app pierde el undo (intencionado).
  const [recentlyDeleted, setRecentlyDeleted] = React.useState(null);
  const undoTimerRef = React.useRef(null);

  // Sesión Supabase. NO se persiste (Supabase ya guarda la sesión en
  // localStorage por su cuenta) — solo es un espejo en React para que la UI
  // pueda reaccionar a login/logout.
  const [user, setUser] = React.useState(null);
  const [authReady, setAuthReady] = React.useState(false);
  React.useEffect(() => {
    if (!window.SLAuth) { setAuthReady(true); return; }
    let mounted = true;
    window.SLAuth.getUser().then(u => {
      if (!mounted) return;
      setUser(u);
      setAuthReady(true);
    });
    const unsub = window.SLAuth.onChange(u => { if (mounted) setUser(u); });
    return () => { mounted = false; unsub(); };
  }, []);
  async function signInWithGoogle() {
    if (!window.SLAuth) return { error: new Error('Supabase no configurado') };
    return window.SLAuth.signInWithGoogle();
  }
  async function signInWithApple() {
    if (!window.SLAuth) return { error: new Error('Supabase no configurado') };
    return window.SLAuth.signInWithApple();
  }
  async function signOut() {
    if (!window.SLAuth) return;
    return window.SLAuth.signOut();
  }
  // Borrado de cuenta y datos (flujo A — usuario logueado).
  // Llama a la Edge Function `delete-account`, que valida el JWT, borra todos
  // los datos vía RPC delete_my_data y borra el auth.user. Luego limpiamos el
  // localStorage y forzamos signOut por si la sesión sigue viva en cliente.
  async function deleteAccount() {
    if (!window.SUPABASE) {
      return { error: new Error('Supabase no configurado') };
    }
    try {
      const { data, error } = await window.SUPABASE.functions.invoke('delete-account', {
        method: 'POST',
      });
      if (error) return { error };
      if (data?.error) return { error: new Error(data.error) };
      try { localStorage.removeItem('shoplist.v2'); } catch (_) { /* ignore */ }
      try { localStorage.removeItem('shoplist.tokens'); } catch (_) { /* ignore */ }
      await window.SLAuth.signOut();
      return { ok: true };
    } catch (err) {
      return { error: err };
    }
  }

  // Deep-link share: si la URL trae #/s/<token> (hash routing) o /s/<token>,
  // intentamos unirnos a esa lista. Esperamos a authReady para no pelearnos
  // con el initial pull de sync.jsx.
  const deepLinkRef = React.useRef(null);
  React.useEffect(() => {
    if (typeof window === 'undefined') return;
    if (deepLinkRef.current) return; // ya capturado
    // Regex laxa: cualquier cosa entre /s/ o /shared/ y un delimitador.
    // Soporta enlaces nuevos (/#/s/TOKEN) y antiguos (/shared/TOKEN, /s/TOKEN)
    // con tokens de varios formatos (xxx-xxx-xxx, hex, etc).
    const tokenRe = /\/(?:s|shared)\/([^/?#&\s]+)/i;
    const fromHash = window.location.hash.match(tokenRe);
    const fromPath = window.location.pathname.match(tokenRe);
    const m = fromHash || fromPath;
    if (!m) return;
    deepLinkRef.current = m[1].toLowerCase();
    // Limpia la URL para que recargas no re-disparen el join.
    window.history.replaceState({}, '', '/');
  }, []);

  React.useEffect(() => {
    const token = deepLinkRef.current;
    if (!token) return;
    if (!authReady) return;
    deepLinkRef.current = null; // procesa una sola vez
    (async () => {
      try {
        if (!window.SLDb?.findListByToken) {
          console.warn('[shoplist] deep-link: Supabase no configurado');
          return;
        }
        const row = await window.SLDb.findListByToken(token);
        if (!row) {
          console.warn('[shoplist] deep-link: lista no encontrada', token);
          return;
        }
        setHiddenListIds(prev => prev.filter(id => id !== row.id));
        const adapters = window.__slAdapters;
        const rows = await window.SLDb.getLists();
        const remoteLists = rows.map(r => adapters.listDbToLocal(r));
        setLists(remoteLists);
        setCurrentListId(row.id);
        console.info('[shoplist] deep-link: unido a lista', row.id);
      } catch (e) { console.error('[shoplist] deep-link join', e); }
    })();
  }, [authReady]);

  // Persistir cambios
  React.useEffect(() => {
    persist({
      lang, dark, density, cardStyle, animations, shopLayout, autoCategory, themeId, iconStyle,
      sortMode, groupByCategory,
      lists, currentListId, products, history, tab, hiddenListIds,
    });
  }, [lang, dark, density, cardStyle, animations, shopLayout, autoCategory, themeId, iconStyle,
      sortMode, groupByCategory,
      lists, currentListId, products, history, tab, hiddenListIds]);

  // Sincronizar el body class para fondo dark/light a nivel HTML (status bar)
  React.useEffect(() => {
    document.body.classList.toggle('sl-dark', !!dark);
    const meta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
    if (meta) meta.setAttribute('content', dark ? 'black-translucent' : 'default');
  }, [dark]);

  // Sincronizar variables CSS con el tema activo para que el scrollbar y
  // otros elementos puramente CSS reaccionen al cambio de tema.
  React.useEffect(() => {
    const t = window.getTheme ? window.getTheme(dark, themeId) : null;
    if (!t) return;
    const root = document.documentElement;
    // Pulgar del scrollbar = accent2 con baja opacidad (visible pero discreto).
    // Hex → rgba: usamos hex puro y dejamos que el navegador interprete con
    // alpha en formato 8-dígitos (#RRGGBBAA).
    const accent = t.c.accent2 || t.c.accent || '#888';
    root.style.setProperty('--sl-scroll-thumb', accent + '80');       // ~50% alpha
    root.style.setProperty('--sl-scroll-thumb-hover', accent + 'cc'); // ~80% alpha
  }, [dark, themeId]);

  const t = I18N[lang];
  // Si el id activo no existe, caemos a la primera lista disponible. Si no
  // hay ninguna, list = null y las pantallas que dependen de una lista activa
  // muestran un EmptyState con CTAs para crear/unirse.
  const list = lists.find(l => l.id === currentListId) || lists[0] || null;

  // Lista de productos frecuentes: si la lista activa tiene poco historial
  // (umbral configurable), usamos los defaults de FREQUENT por idioma. Si ya
  // hay suficientes registros (added/purchased), agregamos por nombre y
  // devolvemos los más usados de esa lista en concreto.
  const FREQUENT_THRESHOLD = 12;
  const FREQUENT_TOP = 15;
  const frequents = React.useMemo(() => {
    const logs = (history[currentListId] || [])
      .filter(l => l.action === 'added' || l.action === 'purchased');
    if (logs.length < FREQUENT_THRESHOLD) return FREQUENT[lang];
    const counts = new Map();
    for (const l of logs) {
      const name = (l.item_name || '').trim();
      if (!name) continue;
      // Normaliza por minúsculas para agrupar duplicados, pero conserva la
      // capitalización original más reciente para mostrar.
      const key = name.toLowerCase();
      const prev = counts.get(key) || { display: name, n: 0 };
      counts.set(key, { display: name, n: prev.n + 1 });
    }
    const sorted = Array.from(counts.values()).sort((a, b) => b.n - a.n);
    return sorted.slice(0, FREQUENT_TOP).map(e => e.display);
  }, [history, currentListId, lang]);
  const items = (list && products[list.id]) || [];
  const pending = items.filter(i => !i.is_purchased);
  const bought = items.filter(i => i.is_purchased);

  // Generadores de id: cuando Supabase está activo, usamos UUIDs reales para que
  // el id local coincida 1:1 con el de la BD; si no, mantenemos los locales.
  const newItemId    = () => (window.SLIds ? window.SLIds.uuid() : nextId());
  const newListId    = () => (window.SLIds ? window.SLIds.uuid() : ('l' + Date.now()));
  const newHistoryId = () => (window.SLIds ? window.SLIds.uuid() : ('h' + Date.now() + Math.random().toString(36).slice(2,6)));
  const newToken     = () => (window.SLIds ? window.SLIds.shortToken() :
                              Math.random().toString(36).slice(2,5) + '-' + Math.random().toString(36).slice(2,5) + '-' + Math.random().toString(36).slice(2,5));

  function pushHistory(action, item_name) {
    // Si hay sesión Google: nombre completo (o user del email como fallback).
    // Si no: null → en BD queda como anónimo.
    const who = user
      ? (user.user_metadata?.full_name || user.email?.split('@')[0] || null)
      : null;
    const avatar = user?.user_metadata?.avatar_url || null;
    const entry = {
      id: newHistoryId(), action, item_name,
      who, avatar, at: Date.now(),
    };
    setHistory(h => ({ ...h, [currentListId]: [entry, ...(h[currentListId] || [])] }));
    window.SLDb?.addHistory(currentListId, entry);
  }

  function addItem(name, qty, cat) {
    if (!name.trim()) return;
    if (!currentListId) return;
    const category = cat || (autoCategory ? autoCategoryFor(name) : 'otros');
    const item = {
      id: newItemId(), name: name.trim(), quantity: normalizeQty(qty),
      category, is_purchased: false, is_archived: false, created_at: Date.now(),
    };
    setProducts(p => ({ ...p, [currentListId]: [item, ...(p[currentListId] || [])] }));
    window.SLDb?.createProduct(currentListId, item);
    pushHistory('added', item.name);
    return item;
  }

  function togglePurchased(id) {
    const item = (products[currentListId] || []).find(i => i.id === id);
    const nextPurchased = item ? !item.is_purchased : true;
    setProducts(p => {
      const list = p[currentListId] || [];
      return { ...p, [currentListId]: list.map(i => i.id === id ? { ...i, is_purchased: !i.is_purchased } : i) };
    });
    window.SLDb?.updateProduct(id, { is_purchased: nextPurchased });
    if (item) pushHistory(item.is_purchased ? 'unpurchased' : 'purchased', item.name);
  }

  function updateItem(id, patch) {
    const cleanPatch = { ...patch };
    if ('quantity' in cleanPatch) cleanPatch.quantity = normalizeQty(cleanPatch.quantity);
    setProducts(p => ({
      ...p,
      [currentListId]: (p[currentListId] || []).map(i => i.id === id ? { ...i, ...cleanPatch } : i),
    }));
    window.SLDb?.updateProduct(id, cleanPatch);
    if (cleanPatch.name) {
      const old = (products[currentListId] || []).find(i => i.id === id);
      if (old) pushHistory('edited', cleanPatch.name || old.name);
    }
  }

  function deleteItem(id) {
    const item = (products[currentListId] || []).find(i => i.id === id);
    setProducts(p => ({ ...p, [currentListId]: (p[currentListId] || []).filter(i => i.id !== id) }));
    window.SLDb?.deleteProduct(id);
    if (item) {
      pushHistory('deleted', item.name);
      // Snapshot para deshacer. Limpia automáticamente tras 5s.
      const snapshot = { item, listId: currentListId, expiresAt: Date.now() + 5000 };
      setRecentlyDeleted(snapshot);
      clearTimeout(undoTimerRef.current);
      undoTimerRef.current = setTimeout(() => {
        setRecentlyDeleted(prev => (prev === snapshot ? null : prev));
      }, 5000);
    }
  }

  function restoreItem() {
    setRecentlyDeleted(curr => {
      if (!curr) return null;
      clearTimeout(undoTimerRef.current);
      const { item, listId } = curr;
      setProducts(p => {
        const list = p[listId] || [];
        // Si por carrera ya volvió (echo de realtime), no duplicar.
        if (list.some(i => i.id === item.id)) return p;
        return { ...p, [listId]: [item, ...list] };
      });
      // Re-crea en Supabase con el mismo id.
      window.SLDb?.createProduct(listId, item);
      pushHistory('added', item.name);
      return null;
    });
  }

  function dismissDelete() {
    clearTimeout(undoTimerRef.current);
    setRecentlyDeleted(null);
  }

  function clearBought() {
    const boughtItems = (products[currentListId] || []).filter(i => i.is_purchased);
    if (!boughtItems.length) return;
    setProducts(p => ({ ...p, [currentListId]: (p[currentListId] || []).filter(i => !i.is_purchased) }));
    window.SLDb?.deleteProductsByIds(boughtItems.map(i => i.id));
    pushHistory('archived', boughtItems.length + ' ' + (boughtItems.length === 1 ? t.item_one : t.items));
  }

  function reorderItems(newOrder) {
    setProducts(p => ({ ...p, [currentListId]: newOrder }));
    // El reorden no se persiste en BD por ahora — el esquema ya tiene sort_order
    // pero la UI nueva no lo expone explícitamente (queda como mejora futura).
  }

  function createList(name) {
    const id = newListId();
    const colors = ['cyan', 'pink', 'violet', 'orange', 'lime'];
    const newList = {
      id, name: { es: name, en: name },
      color: colors[lists.length % colors.length],
      members: 1, createdAt: Date.now(),
      token: newToken(),
    };
    setLists(ls => [...ls, newList]);
    setProducts(p => ({ ...p, [id]: [] }));
    setHistory(h => ({ ...h, [id]: [] }));
    setCurrentListId(id);
    window.SLDb?.createList(newList);
    return newList;
  }

  function renameList(id, newName) {
    const trimmed = (newName || '').trim();
    if (!trimmed) return;
    const patch = { name: { es: trimmed, en: trimmed } };
    setLists(ls => ls.map(l => l.id === id ? { ...l, ...patch } : l));
    window.SLDb?.updateList(id, patch);
  }

  function deleteList(id) {
    // Borrado solo local: marcamos la lista como oculta para que el sync no
    // la vuelva a traer desde Supabase y NO llamamos a SLDb.deleteList.
    setLists(ls => ls.filter(l => l.id !== id));
    setProducts(p => { const n = {...p}; delete n[id]; return n; });
    setHistory(h => { const n = {...h}; delete n[id]; return n; });
    setHiddenListIds(prev => prev.includes(id) ? prev : [...prev, id]);
    if (currentListId === id) {
      const fallback = lists.find(l => l.id !== id)?.id || null;
      setCurrentListId(fallback);
    }
  }

  const ret = {
    lang, setLang, dark, setDark,
    density, setDensity, cardStyle, setCardStyle,
    animations, setAnimations, shopLayout, setShopLayout,
    autoCategory, setAutoCategory,
    themeId, setThemeId,
    iconStyle, setIconStyle,
    sortMode, setSortMode,
    groupByCategory, setGroupByCategory,
    lists, currentListId, setCurrentListId, list, items, pending, bought,
    products, history, t, hiddenListIds, frequents,
    addItem, togglePurchased, updateItem, deleteItem, clearBought, reorderItems,
    createList, deleteList, renameList,
    recentlyDeleted, restoreItem, dismissDelete,
    tab, setTab,
    mode, isDesktop,
    // Sesión
    user, authReady, signInWithGoogle, signInWithApple, signOut, deleteAccount,
    // Setters internos para el hook de sync (uso exclusivo desde sync.jsx)
    __internal_setLists: setLists,
    __internal_setProducts: setProducts,
    __internal_setHistory: setHistory,
    __internal_setCurrentListId: setCurrentListId,
    __internal_setHiddenListIds: setHiddenListIds,
  };

  // Hook de sync: pull inicial + realtime. No-op si Supabase no está configurado.
  window.useSupabaseSync(ret);

  return ret;
}

// ─────────────────────────────────────────────────────────────
// UI compartido
// ─────────────────────────────────────────────────────────────
function Btn({ theme, variant = 'solid', size = 'md', icon, children, onClick, style, full, disabled }) {
  const c = theme.c;
  const sizes = {
    sm: { h: 36, px: 14, fs: 13, gap: 6 },
    md: { h: 48, px: 18, fs: 15, gap: 8 },
    lg: { h: 60, px: 24, fs: 17, gap: 10 },
    xl: { h: 80, px: 28, fs: 22, gap: 12 },
  };
  const s = sizes[size];
  const variants = {
    solid:  { bg: c.accent,  color: c.accentInk, border: 'none',                    shadow: c.shadowSoft },
    ghost:  { bg: 'transparent', color: c.ink,    border: `1.5px solid ${c.border}`, shadow: 'none' },
    dark:   { bg: c.ink,     color: c.bg,         border: 'none',                    shadow: 'none' },
    danger: { bg: c.danger,  color: '#fff',       border: 'none',                    shadow: c.shadowSoft },
    chip:   { bg: c.bgCard,  color: c.ink,        border: `1.5px solid ${c.border}`, shadow: 'none' },
  };
  const v = variants[variant];
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      style={{
        height: s.h, padding: `0 ${s.px}px`, fontSize: s.fs,
        fontWeight: 700, fontFamily: theme.fontDisplay,
        gap: s.gap, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        background: v.bg, color: v.color, border: v.border,
        borderRadius: theme.radius.pill,
        boxShadow: v.shadow, cursor: 'pointer', userSelect: 'none',
        width: full ? '100%' : 'auto', whiteSpace: 'nowrap',
        opacity: disabled ? 0.4 : 1,
        transition: 'transform .12s, box-shadow .12s',
        ...style,
      }}
      onTouchStart={e => { e.currentTarget.style.transform = 'scale(.97)'; }}
      onTouchEnd={e => { e.currentTarget.style.transform = ''; }}
      onMouseDown={e => { e.currentTarget.style.transform = 'scale(.97)'; }}
      onMouseUp={e => { e.currentTarget.style.transform = ''; }}
      onMouseLeave={e => { e.currentTarget.style.transform = ''; }}
    >
      {icon && <Icon name={icon} size={s.fs + 4} stroke={2.4} />}
      {children}
    </button>
  );
}

function ChipBadge({ theme, color, children, style }) {
  const c = theme.c;
  return (
    <span style={{
      display: 'inline-flex', alignItems: 'center', gap: 4,
      padding: '4px 10px', borderRadius: theme.radius.pill,
      background: color || c.accent, color: c.accentInk,
      fontSize: 11, fontWeight: 700, fontFamily: theme.fontDisplay,
      letterSpacing: 0.2,
      ...style,
    }}>{children}</span>
  );
}

function Avatar({ initials, color = '#A78BFA', size = 28, ring }) {
  return (
    <div style={{
      width: size, height: size, borderRadius: '50%',
      background: color, color: '#fff',
      display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
      fontSize: size * 0.42, fontWeight: 700,
      boxShadow: ring ? `0 0 0 2px ${ring}` : 'none',
      flexShrink: 0,
    }}>{initials}</div>
  );
}

// Nav — bottom bar en móvil (safe-area iOS/Android), sidebar vertical en desktop.
function Nav({ theme, tab, setTab, t, hasBought, mode = 'mobile' }) {
  const c = theme.c;
  const [hoverId, setHoverId] = React.useState(null);
  const tabs = [
    { id: 'list',     icon: 'list',    label: t.tabs.list },
    { id: 'shop',     icon: 'cart',    label: t.tabs.shop, badge: hasBought },
    { id: 'lists',    icon: 'stack',   label: t.tabs.lists },
    { id: 'history',  icon: 'history', label: t.tabs.history },
    { id: 'settings', icon: 'gear',    label: t.tabs.settings },
  ];

  if (mode === 'desktop') {
    return (
      <div style={{
        width: 240, flexShrink: 0,
        display: 'flex', flexDirection: 'column',
        background: c.bgAlt, borderRight: `1px solid ${c.border}`,
        padding: '24px 14px',
        height: '100%',
      }}>
        <div style={{
          fontSize: 22, fontWeight: 800, fontFamily: theme.fontDisplay,
          color: c.ink, letterSpacing: -0.5,
          padding: '0 12px 24px',
          display: 'flex', alignItems: 'center', gap: 8,
        }}>
          <span style={{
            width: 28, height: 28, borderRadius: 8,
            background: c.accent, color: c.accentInk,
            display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          }}>
            <Icon name="list" size={18} stroke={2.6} />
          </span>
          KomprApp
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
          {tabs.map(tb => {
            const active = tb.id === tab;
            return (
              <button
                key={tb.id}
                onClick={() => setTab(tb.id)}
                onMouseEnter={() => setHoverId(tb.id)}
                onMouseLeave={() => setHoverId(prev => prev === tb.id ? null : prev)}
                style={{
                  display: 'flex', alignItems: 'center', gap: 12,
                  padding: '12px 14px', borderRadius: theme.radius.md,
                  border: 'none', cursor: 'pointer', textAlign: 'left',
                  // Fondo controlado por React state (no por mutación directa
                  // del DOM) para que el cambio de tema actualice el color
                  // correctamente sin quedarse "pillado" con el bg viejo.
                  background: !active && hoverId === tb.id ? c.bg : 'transparent',
                  color: active ? c.accent : c.ink,
                  opacity: active ? 1 : 0.7,
                  fontFamily: theme.fontDisplay,
                  fontSize: 15, fontWeight: active ? 800 : 700,
                  position: 'relative',
                  transition: 'background .15s, color .18s, opacity .18s',
                }}
              >
                {/* Indicador lateral cuando está activo */}
                <span style={{
                  position: 'absolute', left: 0, top: '50%',
                  transform: `translateY(-50%) scaleY(${active ? 1 : 0})`,
                  width: 3, height: 22, borderRadius: '0 3px 3px 0',
                  background: c.accent,
                  transition: 'transform .22s cubic-bezier(.2,.9,.3,1)',
                  transformOrigin: 'center',
                }} />
                <div style={{ position: 'relative', display: 'inline-flex' }}>
                  <Icon name={tb.icon} size={20} stroke={active ? 2.6 : 2.2} />
                  {tb.badge && (
                    <span style={{
                      position: 'absolute', top: -2, right: -6, width: 8, height: 8,
                      borderRadius: '50%', background: c.accent2,
                      boxShadow: `0 0 0 2px ${c.bgAlt}`,
                    }} />
                  )}
                </div>
                <span>{tb.label}</span>
              </button>
            );
          })}
        </div>
      </div>
    );
  }

  return (
    <div style={{
      position: 'absolute', left: 0, right: 0, bottom: 0,
      paddingBottom: 'env(safe-area-inset-bottom, 12px)',
      background: c.navBg,
      borderTop: `1px solid ${c.border}`,
      backdropFilter: 'blur(20px) saturate(140%)',
      WebkitBackdropFilter: 'blur(20px) saturate(140%)',
      zIndex: 30,
    }}>
      <div style={{ display: 'flex', height: 64, padding: '0 8px' }}>
        {tabs.map(tb => {
          const active = tb.id === tab;
          return (
            <button
              key={tb.id}
              onClick={() => setTab(tb.id)}
              style={{
                flex: 1, background: 'none', border: 'none', cursor: 'pointer',
                display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
                gap: 3, padding: 0, position: 'relative',
                color: active ? c.accent : c.navInk,
                opacity: active ? 1 : 0.55,
                fontFamily: theme.fontDisplay,
                transition: 'color .18s ease, opacity .18s ease',
                WebkitTapHighlightColor: 'transparent',
              }}
            >
              {/* Indicador inferior cuando está activo. Antes estaba arriba
                  (top:0) y se solapaba visualmente con el SmartInput
                  flotante; lo movemos abajo dentro del nav, alineado bajo el
                  label, para no competir con el input. */}
              <span style={{
                position: 'absolute', bottom: 4, left: '50%',
                transform: `translateX(-50%) scaleX(${active ? 1 : 0})`,
                width: 22, height: 3, borderRadius: 999,
                background: c.accent,
                transition: 'transform .22s cubic-bezier(.2,.9,.3,1)',
                transformOrigin: 'center',
              }} />
              <div style={{ position: 'relative', display: 'inline-flex' }}>
                <Icon name={tb.icon} size={22} stroke={active ? 2.6 : 2} />
                {tb.badge && (
                  <span style={{
                    position: 'absolute', top: -2, right: -6, width: 8, height: 8,
                    borderRadius: '50%', background: c.accent2,
                    boxShadow: `0 0 0 2px ${c.navBg}`,
                  }} />
                )}
              </div>
              <span style={{
                fontSize: 10, fontWeight: active ? 800 : 700, letterSpacing: 0.2,
              }}>{tb.label}</span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// Compat alias — código viejo puede importar BottomNav.
const BottomNav = Nav;

function ScreenHeader({ theme, title, subtitle, right, accent }) {
  const c = theme.c;
  return (
    <div style={{
      padding: '14px 22px 12px',
      display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 12,
    }}>
      <div style={{ flex: 1, minWidth: 0 }}>
        {subtitle && (
          <div style={{
            fontSize: 12, fontWeight: 700, color: accent || c.inkSub,
            textTransform: 'uppercase', letterSpacing: 1.5,
            fontFamily: theme.fontMono,
            marginBottom: 2,
          }}>{subtitle}</div>
        )}
        <div style={{
          fontSize: 34, lineHeight: 1.02, fontWeight: 800,
          fontFamily: theme.fontDisplay, color: c.ink,
          letterSpacing: -1.2,
        }}>{title}</div>
      </div>
      {right}
    </div>
  );
}

function EmptyState({ theme, title, sub, big, action }) {
  const c = theme.c;
  return (
    <div style={{
      flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
      padding: '40px 24px', gap: 16, textAlign: 'center',
    }}>
      <div style={{
        fontSize: 64, lineHeight: 1, fontFamily: theme.fontDisplay,
        position: 'relative',
      }}>
        <div style={{
          position: 'absolute', inset: -12, borderRadius: '50%',
          background: c.accent, opacity: 0.25, filter: 'blur(8px)',
        }} />
        <span style={{ position: 'relative' }}>{big || '🛒'}</span>
      </div>
      <div>
        <div style={{ fontSize: 22, fontWeight: 800, fontFamily: theme.fontDisplay, color: c.ink, marginBottom: 4 }}>{title}</div>
        <div style={{ fontSize: 14, color: c.inkSub, fontFamily: theme.fontBody }}>{sub}</div>
      </div>
      {action}
    </div>
  );
}

// Hook compartido: devuelve píxeles que el teclado virtual tapa en la parte
// inferior del layout viewport. Útil para cualquier UI bottom-anchored (Sheet,
// SmartInput flotante). Cuando `enabled` es false, devuelve 0 sin escuchar.
//
// Combinamos varias señales porque ningún navegador/WebView las cubre todas:
//   - visualViewport.resize/scroll: iOS Safari y la mayoría de WebViews modernos.
//   - window.resize: Android Chrome con adjustResize, fallback útil.
//   - Capacitor Keyboard plugin (si está instalado): eventos nativos exactos
//     sobre iOS WKWebView donde visualViewport puede no reflejar el teclado.
function useKeyboardOffset(enabled = true) {
  const [off, setOff] = React.useState(0);
  React.useEffect(() => {
    if (!enabled) { setOff(0); return; }
    let lastNative = null; // cuando el plugin nativo manda altura, manda él.
    const vv = window.visualViewport;
    const recompute = () => {
      if (lastNative != null) { setOff(lastNative); return; }
      if (vv) {
        const o = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
        setOff(o);
      }
    };
    recompute();
    if (vv) {
      vv.addEventListener('resize', recompute);
      vv.addEventListener('scroll', recompute);
    }
    window.addEventListener('resize', recompute);
    window.addEventListener('orientationchange', recompute);

    // Capacitor Keyboard plugin (opcional): eventos nativos del teclado.
    // Si no está instalado, el bloque es no-op.
    const kb = (typeof window !== 'undefined' && window.Capacitor?.Plugins?.Keyboard) || null;
    const handles = [];
    if (kb?.addListener) {
      const sub = (evt, fn) => {
        try {
          const r = kb.addListener(evt, fn);
          if (r?.then) r.then(h => handles.push(h));
          else if (r) handles.push(r);
        } catch (_) {}
      };
      const onShow = (info) => {
        const h = info?.keyboardHeight;
        lastNative = (typeof h === 'number' && h > 0) ? h : (lastNative ?? 0);
        if (lastNative != null) setOff(lastNative);
      };
      const onHide = () => { lastNative = 0; setOff(0); };
      sub('keyboardWillShow', onShow);
      sub('keyboardDidShow',  onShow);
      sub('keyboardWillHide', onHide);
      sub('keyboardDidHide',  onHide);
    }

    return () => {
      if (vv) {
        vv.removeEventListener('resize', recompute);
        vv.removeEventListener('scroll', recompute);
      }
      window.removeEventListener('resize', recompute);
      window.removeEventListener('orientationchange', recompute);
      for (const h of handles) {
        try { h?.remove?.(); } catch (_) {}
      }
      setOff(0);
    };
  }, [enabled]);
  return off;
}

function Sheet({ theme, open, onClose, children, title, height = 'auto', mode = 'sheet' }) {
  const c = theme.c;
  const kbOffset = useKeyboardOffset(open);
  React.useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') onClose && onClose(); };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open, onClose]);
  // Drag-to-dismiss: solo aplica al modo bottom-sheet. Tracking via pointer
  // events para cubrir touch + mouse. Si el drag final supera threshold se
  // cierra; si no, snap back con transición.
  const [dragY, setDragY] = React.useState(0);
  const [dragging, setDragging] = React.useState(false);
  const dragRef = React.useRef({ startY: 0, active: false });
  const onHandlePointerDown = (e) => {
    if (mode !== 'sheet') return;
    dragRef.current.startY = e.clientY;
    dragRef.current.active = true;
    setDragging(true);
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {}
  };
  const onHandlePointerMove = (e) => {
    if (!dragRef.current.active) return;
    const dy = e.clientY - dragRef.current.startY;
    setDragY(Math.max(0, dy));
  };
  const onHandlePointerEnd = (e) => {
    if (!dragRef.current.active) return;
    const dy = e.clientY - dragRef.current.startY;
    dragRef.current.active = false;
    setDragging(false);
    if (dy > 80) {
      onClose && onClose();
    }
    setDragY(0);
  };
  if (!open) return null;

  if (mode === 'dialog') {
    return (
      <>
        <div onClick={onClose} style={{
          position: 'absolute', inset: 0, background: 'rgba(14,14,12,0.5)',
          zIndex: 50, animation: 'sl-fade .18s ease-out',
        }} />
        {/* Wrapper flex centra el diálogo. Mantenemos la animación (que usa
            transform: scale) en el hijo para que no pelee con un translate
            de centrado — antes el bounce sobreescribía translate(-50%,-50%)
            y la caja aparecía desplazada hasta acabar la animación. */}
        <div style={{
          position: 'absolute', inset: 0, zIndex: 51,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          pointerEvents: 'none',
          paddingBottom: kbOffset,
          transition: 'padding-bottom .15s ease-out',
        }}>
          <div style={{
            width: 'min(480px, calc(100% - 48px))',
            maxHeight: kbOffset > 0
              ? `calc(100dvh - ${kbOffset + 48}px)`
              : '80vh',
            background: c.bgSheet, color: c.ink,
            borderRadius: theme.radius.xl,
            border: `1px solid ${c.border}`,
            boxShadow: '0 30px 80px -20px rgba(0,0,0,0.45)',
            padding: '24px 24px 22px',
            animation: 'sl-bounce-in .22s cubic-bezier(.2,1.3,.4,1)',
            overflow: 'auto',
            pointerEvents: 'auto',
            transition: 'max-height .15s ease-out',
          }} className="sl-scroll">
            {title && <div style={{ fontSize: 22, fontWeight: 800, fontFamily: theme.fontDisplay, marginBottom: 18 }}>{title}</div>}
            {children}
          </div>
        </div>
      </>
    );
  }

  return (
    <>
      <div onClick={onClose} style={{
        position: 'absolute', inset: 0, background: 'rgba(14,14,12,0.5)',
        zIndex: 50, animation: 'sl-fade .18s ease-out',
      }} />
      <div style={{
        position: 'absolute', left: 0, right: 0, bottom: kbOffset,
        background: c.bgSheet, color: c.ink,
        borderTopLeftRadius: theme.radius.xl, borderTopRightRadius: theme.radius.xl,
        boxShadow: '0 -10px 40px rgba(0,0,0,0.2)',
        zIndex: 51,
        padding: kbOffset > 0
          ? '12px 22px 16px'
          : '12px 22px calc(28px + env(safe-area-inset-bottom, 0px))',
        animation: dragY === 0 && !dragging
          ? 'sl-slide-up .22s cubic-bezier(.2,.9,.3,1)'
          : 'none',
        maxHeight: kbOffset > 0
          ? `calc(100dvh - ${kbOffset + 24}px)`
          : '85%',
        overflow: 'auto',
        height: height,
        transform: `translateY(${dragY}px)`,
        transition: dragging
          ? 'none'
          : 'bottom .15s ease-out, max-height .15s ease-out, transform .2s cubic-bezier(.2,.9,.3,1)',
      }} className="sl-scroll">
        <div
          onPointerDown={onHandlePointerDown}
          onPointerMove={onHandlePointerMove}
          onPointerUp={onHandlePointerEnd}
          onPointerCancel={onHandlePointerEnd}
          style={{
            // Zona de agarre amplia para que sea fácil de coger en móvil:
            // padding extra alrededor del pill visible.
            padding: '6px 0 10px', margin: '-6px -22px 6px',
            cursor: 'grab', touchAction: 'none',
            display: 'flex', justifyContent: 'center',
          }}>
          <span style={{
            display: 'block', width: 38, height: 5,
            background: c.inkMuted, borderRadius: 3, opacity: 0.4,
          }} />
        </div>
        {title && <div style={{ fontSize: 22, fontWeight: 800, fontFamily: theme.fontDisplay, marginBottom: 16 }}>{title}</div>}
        {children}
      </div>
    </>
  );
}

// Empty state compartido cuando no hay lista activa: redirige al usuario al
// tab de Listas para crear una nueva o unirse con un código.
function NoListEmptyState({ app, theme }) {
  const c = theme.c;
  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: c.bg }}>
      <EmptyState theme={theme} big="📋"
        title={app.lang === 'es' ? 'Sin listas' : 'No lists yet'}
        sub={app.lang === 'es'
          ? 'Crea una nueva o únete con un código para empezar.'
          : 'Create one or join with a code to get started.'}
        action={
          <div style={{ display: 'flex', gap: 8, width: '100%', maxWidth: 360 }}>
            <Btn theme={theme} variant="solid" size="md" full icon="plus"
              onClick={() => app.setTab('lists')}>
              {app.lang === 'es' ? 'Ir a Listas' : 'Go to Lists'}
            </Btn>
          </div>
        } />
    </div>
  );
}

// Sheet para elegir categoría manualmente. Recibe `open`, `onClose`,
// `onPick(catKey)` y opcionalmente `selected` para resaltar la actual.
function CategoryPicker({ theme, app, open, onClose, onPick, selected }) {
  if (!open) return null;
  const c = theme.c;
  const t = app.t;
  const lang = app.lang;
  // Render todas las categorías no-alias en el orden definido en CATEGORIES.
  const entries = Object.entries(CATEGORIES).filter(([_, v]) => !v.alias);
  return (
    <Sheet theme={theme} mode={app.isDesktop ? 'dialog' : 'sheet'} open onClose={onClose}
      title={lang === 'es' ? 'Categoría' : 'Category'}>
      <div style={{
        display: 'grid',
        gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
        gap: 8, marginBottom: 8,
      }}>
        {entries.map(([key, cat]) => {
          const active = key === selected;
          const label = (t.cats && t.cats[key]) || key;
          return (
            <button key={key}
              onClick={() => { onPick(key); onClose(); }}
              style={{
                display: 'flex', alignItems: 'center', gap: 10,
                padding: '12px 12px',
                background: active ? c.accent : c.bgCard,
                color: active ? c.accentInk : c.ink,
                border: `1.5px solid ${active ? c.accent : c.border}`,
                borderRadius: theme.radius.md,
                cursor: 'pointer', textAlign: 'left',
                fontFamily: theme.fontDisplay,
                fontSize: 13, fontWeight: 700,
              }}>
              <CatGlyph cat={cat} size={18} stroke={2.2} style={{ color: active ? c.accentInk : c.ink, flexShrink: 0 }} />
              <span style={{
                flex: 1, minWidth: 0,
                overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
              }}>{label}</span>
            </button>
          );
        })}
      </div>
    </Sheet>
  );
}

Object.assign(window, { useAppState, useViewport, useKeyboardOffset, Btn, ChipBadge, Avatar, Nav, BottomNav, ScreenHeader, EmptyState, Sheet, NoListEmptyState, CategoryPicker });
