// Spreadsheet view for a list — a third alternative to the list/grid
// modes. Renders every item in the tree (flattened, group headers
// interspersed) as rows in a scrollable table. Each field becomes a
// column, columns can be hidden/reordered, and the user can add their
// own custom text/number/url columns that persist per-list.
//
// Data flow:
//   - Read the same tree the grid/list views use (walks parent.children).
//   - Filter + sort using the same listControls the other views honour
//     (search, status filter, price ceiling, sort key).
//   - Edits to built-in fields land on node.status / node.custom.name /
//     node.custom.price / etc via onUpdate.
//   - Custom column values live on node.custom.customCols[colId].
//   - Column visibility + custom column definitions persist in
//     localStorage keyed by root id (mr-list2-sheet:{rootId}).

const { useState: _sh_s, useEffect: _sh_e, useMemo: _sh_m, useRef: _sh_r } = React;

// Built-in column defs. `render(item, ctx)` returns display JSX; `sortKey`
// returns a sortable value; `matchSearch` returns text used for the free
// search. `edit` is set on the columns that support inline editing.
const SHEET_BUILTIN_COLS = [
  // Drag handle — always on. Clicking anywhere in the cell starts a
  // pointer-drag reorder. Very narrow so it doesn't eat much room.
  { id: 'drag',     label: '',         defaultVisible: true,  width: 28,  align: 'center', alwaysOn: true },
  { id: 'image',    label: '',         defaultVisible: true,  width: 56,  align: 'center', alwaysOn: true },
  // Item = your label for the need ("Bath"); Product = the specific thing
  // found for it ("Shnuggle Baby Bath…"). Pasting a link fills Product only.
  { id: 'name',     label: 'Item',     defaultVisible: true,  width: 220, sticky: true, alwaysOn: true },
  { id: 'product',  label: 'Product',  defaultVisible: true,  width: 240 },
  // Link — always on, right next to the name. Paste a URL into a
  // placeholder row and it runs the same extractor as "Add by link"
  // (name / brand / image / price / gallery) and fills the row in place.
  { id: 'source',   label: 'Link',     defaultVisible: true,  width: 190 },
  { id: 'brand',    label: 'Brand',    defaultVisible: true,  width: 140 },
  { id: 'status',   label: 'Status',   defaultVisible: true,  width: 140, align: 'center' },
  { id: 'loved',    label: '♥ Loved',  defaultVisible: true,  width: 90,  align: 'center' },
  { id: 'price',    label: 'Price',    defaultVisible: true,  width: 100, align: 'right' },
  { id: 'age',      label: 'Age',      defaultVisible: true,  width: 100 },
  { id: 'notes',    label: 'Notes',    defaultVisible: true,  width: 260 },  // now defaults on
  // Planner-imported metadata columns. `defaultVisible: 'auto'` means
  // "on if any row in this list actually has a value for it" — keeps
  // vanilla lists uncluttered while planner lists (from the Excel
  // importer) light up all the columns automatically.
  //
  // `kind` drives the cell editor: 'enum' → dropdown seeded with `options`
  // (the exact vocab from the planner spreadsheet) plus any value already
  // used in the list plus "Other…"; 'date' → native date picker; 'number'
  // → numeric input; 'text' → free text; 'computed' → read-only.
  // `autoKey` is the metadata key whose presence turns the column on.
  { id: 'category',       label: 'Category',      defaultVisible: 'auto', width: 130, metaKey: 'category',       kind: 'enum', options: [] },
  { id: 'priority',       label: 'Priority',      defaultVisible: 'auto', width: 110, metaKey: 'priority',       kind: 'enum', options: ['essential', 'useful', 'bonus'] },
  { id: 'owner',          label: 'Owner',         defaultVisible: 'auto', width: 100, metaKey: 'owner',          kind: 'enum', options: ['lucie', 'simon', 'both'] },
  { id: 'when_needed',    label: 'When',          defaultVisible: 'auto', width: 130, metaKey: 'when_needed',    kind: 'enum', options: ['Before birth', '0–1 month', '1–3 months', 'After birth', 'Later'] },
  { id: 'target_date',    label: 'Target date',   defaultVisible: 'auto', width: 130, metaKey: 'target_date',    kind: 'date' },
  { id: 'qty_target',     label: 'Qty target',    defaultVisible: 'auto', width: 90,  align: 'right', metaKey: 'qty_target', kind: 'number' },
  { id: 'qty_owned',      label: 'Qty owned',     defaultVisible: 'auto', width: 90,  align: 'right', metaKey: 'qty_owned',  kind: 'number' },
  { id: 'qty_remaining',  label: 'Remaining',     defaultVisible: 'auto', width: 90,  align: 'right', autoKey: 'qty_target', kind: 'computed' },
  { id: 'purchase_route', label: 'How to get',    defaultVisible: 'auto', width: 130, metaKey: 'purchase_route', kind: 'enum', options: ['New', 'Second-hand', 'Gifted', 'Borrowed', 'Already own'] },
  { id: 'actual_cost',    label: 'Actual cost',   defaultVisible: 'auto', width: 110, align: 'right', metaKey: 'actual_cost', kind: 'number', prefix: 'A$' },
  { id: 'size',           label: 'Size',          defaultVisible: 'auto', width: 130, metaKey: 'size',           kind: 'enum', options: ['00000 / Tiny', '0000 / Newborn', '000 / 0-3m', '00 / 3-6m', '0 / 6-12m', '1 / 12-18m'] },
  // Freezer-meal sheet
  { id: 'servings_per_batch', label: 'Servings / batch', defaultVisible: 'auto', width: 110, align: 'right', metaKey: 'servings_per_batch', kind: 'number' },
  { id: 'batches_planned',    label: 'Batches planned',  defaultVisible: 'auto', width: 110, align: 'right', metaKey: 'batches_planned',    kind: 'number' },
  { id: 'batches_made',       label: 'Batches made',     defaultVisible: 'auto', width: 110, align: 'right', metaKey: 'batches_made',       kind: 'number' },
  { id: 'date_cooked',        label: 'Date cooked',      defaultVisible: 'auto', width: 130, metaKey: 'date_cooked', kind: 'date' },
  { id: 'use_by',             label: 'Use by',           defaultVisible: 'auto', width: 130, metaKey: 'use_by',      kind: 'date' },
  { id: 'materials',label: 'Materials',defaultVisible: false, width: 220 }, // now defaults off
  { id: 'certs',    label: 'Certs',    defaultVisible: false, width: 180 },
  { id: 'dims',     label: 'Dimensions', defaultVisible: false, width: 160 },
  { id: 'madeIn',   label: 'Made in',  defaultVisible: false, width: 120 },
  { id: 'group',    label: 'Group',    defaultVisible: false, width: 140 }, // less needed now that we render group headers inline
  { id: 'menu',     label: '',         defaultVisible: true,  width: 36,  align: 'center', alwaysOn: true, kind: 'computed' },
];

function openItemMenuAt(itemId, x, y) {
  try { window.dispatchEvent(new CustomEvent('mr-item-menu', { detail: { itemId, x, y } })); } catch {}
}

// Display labels for enum values stored in a normalised (lowercase) form.
const ENUM_LABELS = {
  status:   { need: 'Chosen', ordered: 'Ordered', have: 'Done', want: 'Loved' },
  priority: { essential: 'Essential', useful: 'Useful', bonus: 'Bonus', nice: 'Bonus' },
  owner:    { lucie: 'Lucie', simon: 'Simon', both: 'Both' },
};
// Keys whose values we normalise to lowercase on write (so chips colour
// consistently). Everything else keeps the user's casing.
const ENUM_LOWERCASE_KEYS = new Set(['priority', 'owner']);
const NUMERIC_META_KEYS = new Set(['qty_target', 'qty_owned', 'actual_cost', 'price', 'servings_per_batch', 'batches_planned', 'batches_made']);

function enumLabel(key, v) {
  if (v == null || v === '') return '';
  const m = ENUM_LABELS[key];
  return (m && m[String(v).toLowerCase()]) || String(v);
}
function fmtDate(iso) {
  if (!iso) return '';
  const d = new Date(iso + (String(iso).length === 10 ? 'T00:00:00' : ''));
  if (isNaN(d)) return String(iso);
  return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
}

// Cycle order for one-click status changes on the status cell.
const STATUS_CYCLE = { want: 'need', need: 'have', have: 'want' };

const STATUS_LABEL = { need: 'Chosen', ordered: 'Ordered', have: 'Done', want: 'Loved' };
const STATUS_ORDER = { need: 0, ordered: 1, have: 2, want: 3 };

// Tiny, safe markdown-ish renderer for user notes. Escapes HTML first,
// then applies four minimal rules:
//   **bold**   → <strong>
//   _italic_   → <em>            (single-underscore, word-bounded)
//   http(s)://…→ <a> (auto-link)
//   \n         → preserved by white-space: pre-wrap on the container
// Deliberately NOT full markdown — keeps the parser predictable and
// avoids surprising the user with side-effect syntax like `#` headings.
function escapeHtml(s) {
  return String(s || '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}
function renderNotesRich(text) {
  let s = escapeHtml(text || '');
  // Bold first (double asterisk, non-greedy, no linebreak).
  s = s.replace(/\*\*([^*\n][^*\n]*?)\*\*/g, '<strong>$1</strong>');
  // Italic — single underscore, must be word-bounded so file_names_
  // don't italicise. Match _foo_ or _foo bar_ but stops before \n.
  s = s.replace(/(^|[\s(])_([^_\n]+?)_(?=$|[\s.,;:!?)])/g, '$1<em>$2</em>');
  // Auto-link. Keep the link visible + open in new tab. No markdown-
  // style [text](href) — just bare URLs so pasted links "just work".
  s = s.replace(/(https?:\/\/[^\s<]+[^\s<.,;:!?)])/g, (m) => {
    const safe = window.MR && window.MR.safeUrl ? window.MR.safeUrl(m) : m;
    if (!safe) return m;
    return `<a href="${safe}" target="_blank" rel="noopener noreferrer" class="notes-link">${m}</a>`;
  });
  return s;
}
window.renderNotesRich = renderNotesRich;

function ageLabelFrom(n) {
  const min = n.ageMin, max = n.ageMax;
  if (min == null && max == null) return '';
  if (min != null && max != null) {
    if (min < 12 && max <= 24) return `${min}–${max} mo`;
    return `${Math.round(min/12)}–${Math.round(max/12)} yr`;
  }
  if (min != null) return `${min < 12 ? min + ' mo+' : Math.round(min/12) + ' yr+'}`;
  return '';
}

// Bump this when the column list changes shape (new/removed built-in
// cols, changed defaults) so previously-saved visibility state gets
// thrown away and users see the new defaults. Custom columns survive
// intact — we only wipe the built-in visibility overrides.
const SHEET_CONFIG_VERSION = 6;

function readSheetConfig(rootId) {
  try {
    const raw = localStorage.getItem(`mr-list2-sheet:${rootId}`);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    if (!parsed || typeof parsed !== 'object') return null;
    // Version mismatch → keep the user's custom columns, drop stale
    // visibility + order overrides so they pick up the current defaults.
    if (parsed.__v !== SHEET_CONFIG_VERSION) {
      return {
        __v: SHEET_CONFIG_VERSION,
        visible: {},
        order: null,
        customCols: Array.isArray(parsed.customCols) ? parsed.customCols : [],
        widths: parsed.widths && typeof parsed.widths === 'object' ? parsed.widths : {},
      };
    }
    return parsed;
  } catch { return null; }
}
function writeSheetConfig(rootId, config) {
  try {
    localStorage.setItem(
      `mr-list2-sheet:${rootId}`,
      JSON.stringify({ ...config, __v: SHEET_CONFIG_VERSION })
    );
  } catch {}
}

// Flatten the tree the same way NodeChildren visits — items only, in
// display order. Also records the parent slot name / id + the item's
// index within its parent (needed for drag-reorder). Uses the ROOT's
// own name as the group label for top-level items so the group header
// reads sensibly ("Top of list") without special-casing later.
function flattenItemsWithParent(root) {
  const out = [];
  const ROOT_LABEL = 'Top of list';
  const visit = (n, parentName, parentId, isRoot) => {
    const items = (n.children || []);
    items.forEach((c, idx) => {
      if (c.type === 'item') {
        out.push({
          node: c,
          groupName: isRoot ? ROOT_LABEL : (parentName || ROOT_LABEL),
          groupId: parentId,
          isRootGroup: isRoot,
          groupNode: isRoot ? null : n,
          indexInParent: idx,
        });
      } else if (c.type === 'slot') {
        visit(c, c.name || '', c.id, false);
      }
    });
  };
  if (root) visit(root, root.name || '', root.id, true);
  return out;
}

function SpreadsheetView({
  root,
  productMap,
  onOpenItem,
  onUpdate,
  onDelete,
  onMoveNode,     // (nodeId, newParentId, targetIndex) — same signature as list/grid drag
  listControls,
  hideToolbar = false,
  gridSize = 'md',
  votes = [],
  myUserId = null,
  onVote = null,
}) {
  const rootId = root && root.id;

  // Column state — merges built-ins with any custom columns the user
  // has defined, applies persisted visibility, preserves user order.
  const [config, setConfig] = _sh_s(() => {
    const saved = readSheetConfig(rootId) || {};
    return {
      // { [colId]: boolean } — undefined means use column's defaultVisible
      visible: saved.visible || {},
      // Ordered array of custom column defs
      customCols: Array.isArray(saved.customCols) ? saved.customCols : [],
      // Ordered array of ALL column ids (built-in + custom) — sets column order
      order: Array.isArray(saved.order) ? saved.order : null,
      // { [colId]: px } — user-dragged column widths
      widths: saved.widths && typeof saved.widths === 'object' ? saved.widths : {},
    };
  });
  // Persist config whenever it changes.
  _sh_e(() => { if (rootId) writeSheetConfig(rootId, config); }, [rootId, config]);
  // If we swap to a different list, re-read config.
  _sh_e(() => {
    const saved = readSheetConfig(rootId) || {};
    setConfig({
      visible: saved.visible || {},
      customCols: Array.isArray(saved.customCols) ? saved.customCols : [],
      order: Array.isArray(saved.order) ? saved.order : null,
      widths: saved.widths && typeof saved.widths === 'object' ? saved.widths : {},
    });
  }, [rootId]);

  const [colsMenuOpen, setColsMenuOpen] = _sh_s(false);
  // When the toolbar is hidden (Sheets command bar), the Columns button
  // lives elsewhere and asks us to open the menu at its own position.
  const [colsMenuPos, setColsMenuPos] = _sh_s(null);
  _sh_e(() => {
    const onAsk = (ev) => { const d = ev && ev.detail; setColsMenuPos(d && d.x != null ? { x: d.x, y: d.y } : null); setColsMenuOpen(v => !v); };
    window.addEventListener('mr-sheet-columns', onAsk);
    return () => window.removeEventListener('mr-sheet-columns', onAsk);
  }, []);
  const [sortCol, setSortCol] = _sh_s(null); // { id, dir: 'asc'|'desc' }
  // Header drag-reorder — { dragId, overId, overPos: 'before'|'after' }.
  const [colDrag, setColDrag] = _sh_s(null);
  const FIXED_FIRST = 'drag', FIXED_LAST = 'menu';
  const colIsMovable = (c) => c.id !== FIXED_FIRST && c.id !== FIXED_LAST;
  // Full column id list (visible + hidden) in current effective order.
  const effectiveOrderIds = () => {
    const all = [...SHEET_BUILTIN_COLS, ...config.customCols];
    let ordered = all;
    if (config.order) {
      const idxMap = new Map(config.order.map((id, i) => [id, i]));
      ordered = [...all].sort((a, b) => {
        const ai = idxMap.has(a.id) ? idxMap.get(a.id) : 999 + all.indexOf(a);
        const bi = idxMap.has(b.id) ? idxMap.get(b.id) : 999 + all.indexOf(b);
        return ai - bi;
      });
    }
    return ordered.map(c => c.id);
  };
  const onColDragStart = (e, col) => {
    if (!colIsMovable(col)) { e.preventDefault(); return; }
    try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', 'col:' + col.id); } catch {}
    setColDrag({ dragId: col.id, overId: null, overPos: null });
  };
  const onColDragOver = (e, col) => {
    if (!colDrag || colDrag.dragId === col.id || !colIsMovable(col)) return;
    e.preventDefault();
    try { e.dataTransfer.dropEffect = 'move'; } catch {}
    const r = e.currentTarget.getBoundingClientRect();
    const overPos = (e.clientX - r.left) < r.width / 2 ? 'before' : 'after';
    if (colDrag.overId !== col.id || colDrag.overPos !== overPos) setColDrag({ ...colDrag, overId: col.id, overPos });
  };
  const onColDrop = (e, col) => {
    if (!colDrag || colDrag.dragId === col.id || !colIsMovable(col)) { setColDrag(null); return; }
    e.preventDefault();
    const ids = effectiveOrderIds().filter(id => id !== colDrag.dragId);
    let at = ids.indexOf(col.id);
    if (at < 0) { setColDrag(null); return; }
    if (colDrag.overPos === 'after') at += 1;
    ids.splice(at, 0, colDrag.dragId);
    // Pin the handle first and the ⋯ menu last regardless.
    const pinned = ids.filter(id => id !== FIXED_FIRST && id !== FIXED_LAST);
    setConfig(c => ({ ...c, order: [FIXED_FIRST, ...pinned, FIXED_LAST] }));
    setColDrag(null);
  };
  const onColDragEnd = () => setColDrag(null);
  // Column resize — drag the handle on a header's right edge.
  const resizingRef = _sh_r(null);
  const onResizeStart = (e, col) => {
    e.preventDefault(); e.stopPropagation();
    const startX = e.clientX, startW = col.width || 120;
    resizingRef.current = { id: col.id, moved: false };
    const onMove = (ev) => {
      const w = Math.max(44, Math.round(startW + (ev.clientX - startX)));
      resizingRef.current.moved = true;
      setConfig(c => ({ ...c, widths: { ...(c.widths || {}), [col.id]: w } }));
    };
    const onUp = () => {
      window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp);
      setTimeout(() => { resizingRef.current = null; }, 0);
    };
    window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp);
  };
  const resetWidth = (col) => setConfig(c => { const w = { ...(c.widths || {}) }; delete w[col.id]; return { ...c, widths: w }; });
  const colsBtnRef = _sh_r(null);
  // Editing state — { nodeId, colId } for cell being edited.
  const [editing, setEditing] = _sh_s(null);
  const [editVal, setEditVal] = _sh_s('');
  // Rows currently being enriched from a pasted link.
  const [enrichingIds, setEnrichingIds] = _sh_s(() => new Set());

  // Flatten the tree once per root change / mutation. Declared BEFORE
  // `cols` because the column-visibility memo depends on the metadata
  // keys present in these rows.
  const rowsAll = _sh_m(() => flattenItemsWithParent(root), [root]);

  // Metadata keys that appear at least once in the flattened rows —
  // used to resolve `defaultVisible: 'auto'` (only show the column
  // when some row actually carries a value for it).
  const populatedMetaKeys = _sh_m(() => {
    const s = new Set();
    for (const r of rowsAll) {
      const m = (r.node && r.node.metadata) || {};
      for (const k in m) {
        if (m[k] !== null && m[k] !== undefined && m[k] !== '') s.add(k);
      }
      const c = (r.node && r.node.custom) || {};
      if (c.sourceUrl || c.source_url) s.add('__source');
    }
    return s;
  }, [rowsAll]);

  // Distinct values already used in this list per enum column — merged
  // with the column's seed options so the dropdown always offers both
  // the planner vocabulary and anything the user has typed in.
  const enumValuesByKey = _sh_m(() => {
    const out = {};
    for (const r of rowsAll) {
      const m = (r.node && r.node.metadata) || {};
      for (const k in m) {
        const v = m[k];
        if (typeof v !== 'string' || !v.trim()) continue;
        (out[k] = out[k] || new Map());
        const key = v.trim().toLowerCase();
        if (!out[k].has(key)) out[k].set(key, v.trim());
      }
    }
    return out;
  }, [rowsAll]);
  const enumOptions = (col) => {
    const seen = new Map();
    (col.options || []).forEach(o => seen.set(String(o).toLowerCase(), o));
    const used = enumValuesByKey[col.metaKey];
    if (used) used.forEach((v, k) => { if (!seen.has(k)) seen.set(k, v); });
    return Array.from(seen.values());
  };

  // Compose the final column list (in order, with visibility applied).
  const cols = _sh_m(() => {
    const all = [
      ...SHEET_BUILTIN_COLS,
      ...config.customCols.map(c => ({
        ...c,
        defaultVisible: true,
        kind: 'custom',
        width: c.width || 160,
      })),
    ];
    // Apply saved order if present, keeping any new-since-save cols at end.
    let ordered = all;
    if (config.order) {
      const idxMap = new Map(config.order.map((id, i) => [id, i]));
      ordered = [...all].sort((a, b) => {
        const ai = idxMap.has(a.id) ? idxMap.get(a.id) : 999 + all.indexOf(a);
        const bi = idxMap.has(b.id) ? idxMap.get(b.id) : 999 + all.indexOf(b);
        return ai - bi;
      });
    }
    // User-dragged widths override the defaults.
    const widths = config.widths || {};
    ordered = ordered.map(c => widths[c.id] ? { ...c, width: widths[c.id] } : c);
    // Filter to visible. `defaultVisible: 'auto'` resolves against the
    // set of metadata keys that any row in this list actually carries.
    return ordered.filter(c => {
      if (c.alwaysOn) return true;
      const v = config.visible[c.id];
      if (v !== undefined) return v;
      if (c.defaultVisible === 'auto') {
        const k = c.autoKey || c.metaKey;
        return k ? populatedMetaKeys.has(k) : false;
      }
      return c.defaultVisible;
    });
  }, [config, populatedMetaKeys]);

  // All columns (visible + hidden) for the visibility picker.
  const allCols = _sh_m(() => [
    ...SHEET_BUILTIN_COLS,
    ...config.customCols.map(c => ({ ...c, kind: 'custom' })),
  ], [config.customCols]);

  // Apply the same listControls that the grid/list views use.
  const rows = _sh_m(() => {
    let r = rowsAll;
    const q = (listControls && listControls.searchQ || '').trim().toLowerCase();
    if (q) {
      r = r.filter(({ node }) => {
        const label = (node.name || '').toLowerCase();
        const prod = ((node.custom && node.custom.name) || '').toLowerCase();
        const brand = ((node.custom && node.custom.brand) || '').toLowerCase();
        return label.includes(q) || prod.includes(q) || brand.includes(q);
      });
    }
    const fs = listControls && listControls.filterStatus;
    if (fs && fs !== 'all') {
      r = r.filter(({ node }) => node.status === fs);
    }
    const fl = listControls && listControls.filterLove;
    if (fl && window.mrLoveMatch) {
      r = r.filter(({ node }) => window.mrLoveMatch(fl, (votes || []).filter(v => v.node_id === node.id)));
    }
    const maxP = listControls && listControls.filterMaxPrice;
    if (maxP != null && maxP > 0) {
      r = r.filter(({ node }) => {
        const p = node.productId && productMap ? productMap[node.productId] : null;
        const price = p ? p.price : (node.custom && node.custom.price);
        return price == null || price <= maxP;
      });
    }
    // Local sort overrides listControls.sortBy when the user has clicked
    // a column header — column sorts are more discoverable in a sheet.
    if (sortCol) {
      const dir = sortCol.dir === 'desc' ? -1 : 1;
      r = [...r].sort((a, b) => cellSortKey(a, sortCol.id, productMap, { votes }) > cellSortKey(b, sortCol.id, productMap, { votes }) ? dir : -dir);
    }
    return r;
  }, [rowsAll, listControls, sortCol, productMap, votes]);

  // Click outside for the columns menu.
  _sh_e(() => {
    if (!colsMenuOpen) return;
    const onClick = (e) => {
      if (colsBtnRef.current && colsBtnRef.current.contains(e.target)) return;
      if (e.target && e.target.closest && e.target.closest('.list2-sheet-colsmenu')) return;
      setColsMenuOpen(false);
    };
    setTimeout(() => document.addEventListener('mousedown', onClick), 40);
    return () => document.removeEventListener('mousedown', onClick);
  }, [colsMenuOpen]);

  const toggleColVisible = (id, alwaysOn) => {
    if (alwaysOn) return;
    setConfig(c => ({ ...c, visible: { ...c.visible, [id]: !isColVisible(c, id) } }));
  };
  const isColVisible = (c, id) => {
    const def = SHEET_BUILTIN_COLS.find(x => x.id === id);
    if (def && def.alwaysOn) return true;
    if (c.visible[id] !== undefined) return c.visible[id];
    if (def) {
      if (def.defaultVisible === 'auto') {
        const k = def.autoKey || def.metaKey;
        return k ? populatedMetaKeys.has(k) : false;
      }
      return def.defaultVisible;
    }
    return true; // custom cols default to visible
  };

  const addCustomColumn = () => {
    const label = window.prompt('Column name:', '');
    if (!label || !label.trim()) return;
    const type = window.prompt('Column type — text / number / url ?', 'text');
    const cleanType = (type && ['text','number','url'].includes(type.trim().toLowerCase())) ? type.trim().toLowerCase() : 'text';
    const newCol = {
      id: 'c_' + Math.random().toString(36).slice(2, 8),
      label: label.trim(),
      type: cleanType,
    };
    setConfig(c => ({ ...c, customCols: [...c.customCols, newCol] }));
  };

  const deleteCustomColumn = (id) => {
    if (!window.confirm('Remove this column? Values will be dropped from every row.')) return;
    setConfig(c => ({ ...c, customCols: c.customCols.filter(x => x.id !== id) }));
  };

  const onHeaderClick = (col) => {
    if (col.id === 'image' || col.id === 'menu' || col.id === 'drag') return;
    setSortCol(prev => {
      if (!prev || prev.id !== col.id) return { id: col.id, dir: 'asc' };
      if (prev.dir === 'asc') return { id: col.id, dir: 'desc' };
      return null;
    });
  };

  const startEdit = (nodeId, colId, currentValue) => {
    setEditing({ nodeId, colId });
    setEditVal(currentValue == null ? '' : String(currentValue));
  };

  // Build a `{ metadata }` patch that sets/clears one key on a node.
  // Numeric keys are coerced; enum keys with a canonical lowercase form
  // are normalised; empty clears the key.
  const metaPatchFor = (node, key, raw) => {
    const curMeta = node.metadata || {};
    let v = raw == null ? '' : String(raw).trim();
    if (NUMERIC_META_KEYS.has(key)) {
      const n = parseFloat(v);
      v = Number.isFinite(n) ? n : null;
    } else if (ENUM_LOWERCASE_KEYS.has(key) && v) {
      v = v.toLowerCase();
    }
    if (v === '' || v == null) {
      if (!(key in curMeta)) return null;
      const nextMeta = { ...curMeta };
      delete nextMeta[key];
      return { metadata: nextMeta };
    }
    if (curMeta[key] === v) return null;
    return { metadata: { ...curMeta, [key]: v } };
  };
  // Direct commit used by the dropdown / date editors (no editVal round
  // trip — the picker already holds the final value).
  const commitMetaValue = (nodeId, key, value) => {
    const row = rows.find(r => r.node.id === nodeId);
    if (row) {
      const mp = metaPatchFor(row.node, key, value);
      if (mp) onUpdate(nodeId, mp);
    }
    setEditing(null); setEditVal('');
  };

  // Link cell commit. Saves the URL; for placeholders (or custom items
  // that have no image yet) it also runs the og-fetch extractor and
  // fills name / brand / image / price / description in place.
  const linkLockRef = _sh_r(null);
  const commitLink = async (nodeId, raw) => {
    // Paste commits immediately and unmounts the input, whose blur then
    // fires a second commit with stale text — swallow that echo.
    if (linkLockRef.current === nodeId) return;
    linkLockRef.current = nodeId;
    setTimeout(() => { if (linkLockRef.current === nodeId) linkLockRef.current = null; }, 400);
    setEditing(null); setEditVal('');
    const row = rows.find(r => r.node.id === nodeId);
    if (!row) return;
    const node = row.node;
    if (node.productId) return;                       // catalog items own their links
    const cur = node.custom || {};
    let url = String(raw || '').trim();
    if (!url) {
      if (cur.sourceUrl) onUpdate(nodeId, { custom: { ...cur, sourceUrl: '' } });
      return;
    }
    if (!/^https?:\/\//i.test(url)) url = 'https://' + url.replace(/^\/+/, '');
    try { new URL(url); } catch {
      window.__mr_showToast && window.__mr_showToast("That doesn't look like a link");
      return;
    }
    const shouldExtract = !!cur.placeholder || !cur.image;
    if (!shouldExtract || !window.MR || !window.MR.enrich || typeof window.MR.enrich.enrichItem !== 'function') {
      onUpdate(nodeId, { custom: { ...cur, sourceUrl: url } });
      return;
    }
    setEnrichingIds(s => new Set([...s, nodeId]));
    try {
      // For placeholders let the page title win over the placeholder
      // name ("Bath" → "Shnuggle Baby Bath"); real custom items keep theirs.
      const probe = { ...node, custom: { ...cur, sourceUrl: url, name: cur.placeholder ? '' : cur.name } };
      const res = await window.MR.enrich.enrichItem(probe);
      if (res && res.ok && res.data && !res.partial) {
        const next = { ...res.data, sourceUrl: url };
        delete next.placeholder;
        // Keep the item label ("Bath"); the product name lives in custom.name.
        onUpdate(nodeId, { custom: next });
        window.__mr_showToast && window.__mr_showToast(`✓ ${node.name || 'Item'} → ${next.name || 'product'}`);
      } else if (res && res.partial && res.data && res.data.name) {
        // Site blocked every reader; we got a name from the URL only.
        // Keep it a placeholder so it still reads as "to fill in".
        onUpdate(nodeId, { custom: { ...cur, sourceUrl: url, name: cur.placeholder ? res.data.name : cur.name, placeholder: !!cur.placeholder } });
        window.__mr_showToast && window.__mr_showToast(`${new URL(url).hostname.replace(/^www\./, '')} blocks readers — named it from the link; add the image/price by hand`);
      } else {
        onUpdate(nodeId, { custom: { ...cur, sourceUrl: url } });
        window.__mr_showToast && window.__mr_showToast("Couldn't read that page — link saved, fill the rest in by hand");
      }
    } catch (err) {
      console.warn('[sheet] link enrich failed', err);
      onUpdate(nodeId, { custom: { ...cur, sourceUrl: url } });
    } finally {
      setEnrichingIds(s => { const n = new Set(s); n.delete(nodeId); return n; });
    }
  };
  const commitEdit = () => {
    if (!editing) return;
    const { nodeId, colId } = editing;
    const row = rows.find(r => r.node.id === nodeId);
    if (row) {
      const node = row.node;
      const col = cols.find(c => c.id === colId);
      const cur = node.custom || {};
      const patch = {};
      if (col && col.kind === 'custom') {
        const nextCustomCols = { ...(cur.customCols || {}), [colId]: editVal.trim() };
        patch.custom = { ...cur, customCols: nextCustomCols };
      } else if (col && col.metaKey) {
        const mp = metaPatchFor(node, col.metaKey, editVal);
        if (mp) patch.metadata = mp.metadata;
      } else {
        // Built-in inline-editable fields.
        if (colId === 'name') {
          patch.name = editVal.trim();
        } else if (colId === 'product') {
          if (!node.productId) patch.custom = { ...cur, name: editVal.trim() };
        } else if (colId === 'brand') {
          patch.custom = { ...cur, brand: editVal.trim() };
        } else if (colId === 'price') {
          const n = parseFloat(editVal);
          patch.custom = { ...cur, price: Number.isFinite(n) ? n : null };
        } else if (colId === 'notes') {
          // Notes lives directly on the node, not inside custom.
          patch.notes = editVal;
        }
      }
      if (Object.keys(patch).length) onUpdate(node.id, patch);
    }
    setEditing(null); setEditVal('');
  };
  const cancelEdit = () => { setEditing(null); setEditVal(''); };
  const onEditKey = (e) => {
    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitEdit(); }
    else if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); }
  };

  // ── Status quick-cycle ─────────────────────────────────────────
  // Click the status cell to walk Loved → Getting → Have → Loved
  // without needing to open the item's full detail panel. Guarded
  // against click-bubble opening the item modal at the same time.
  const onCycleStatus = (nodeId, currentStatus) => {
    const next = STATUS_CYCLE[currentStatus || 'want'] || 'want';
    onUpdate(nodeId, { status: next });
  };

  // ── HTML5 drag reorder ─────────────────────────────────────────
  // Rows are draggable; dropping onto another row inserts BEFORE or
  // AFTER depending on which half of the row the cursor was over.
  // Cross-group drops move the item into the target group. Fires the
  // onMoveNode callback with (nodeId, newParentId, targetIndex) which
  // is the same signature the list/grid drag flow uses upstream.
  const [dragState, setDragState] = _sh_s(null);  // { dragId, overId, overPos: 'above'|'below' }
  const onRowDragStart = (e, row) => {
    if (!onMoveNode) return;
    try {
      e.dataTransfer.effectAllowed = 'move';
      e.dataTransfer.setData('text/plain', row.node.id);
    } catch {}
    setDragState({ dragId: row.node.id, overId: null, overPos: null });
  };
  const onRowDragOver = (e, row) => {
    if (!dragState || dragState.dragId === row.node.id) return;
    e.preventDefault();
    try { e.dataTransfer.dropEffect = 'move'; } catch {}
    const rect = e.currentTarget.getBoundingClientRect();
    const overPos = (e.clientY - rect.top) < rect.height / 2 ? 'above' : 'below';
    if (dragState.overId !== row.node.id || dragState.overPos !== overPos) {
      setDragState({ ...dragState, overId: row.node.id, overPos });
    }
  };
  const onRowDragLeave = (row) => {
    // Only clear if we were pointing at THIS row — dragover on the
    // next row will overwrite anyway.
    if (dragState && dragState.overId === row.node.id) {
      setDragState({ ...dragState, overId: null, overPos: null });
    }
  };
  const onRowDrop = (e, row) => {
    e.preventDefault();
    if (!dragState || !onMoveNode) { setDragState(null); return; }
    const srcId = dragState.dragId;
    const dst = row;
    setDragState(null);
    if (!srcId || srcId === dst.node.id) return;
    // Compute target index: within the destination row's parent, insert
    // at (dst.indexInParent + 1) if dropped below, or (dst.indexInParent)
    // if above. The upstream moveNode handles the "same-parent shuffle"
    // vs "cross-parent move" mechanics.
    const rect = e.currentTarget.getBoundingClientRect();
    const overPos = (e.clientY - rect.top) < rect.height / 2 ? 'above' : 'below';
    const targetIdx = dst.indexInParent + (overPos === 'below' ? 1 : 0);
    onMoveNode(srcId, dst.groupId, targetIdx);
  };
  const onRowDragEnd = () => setDragState(null);

  return (
    <div className="list2-sheet-wrap" data-size={gridSize || 'md'}>
      <div className="list2-sheet-toolbar" style={hideToolbar ? { display: 'none' } : undefined}>
        <div className="list2-sheet-meta">{rows.length} of {rowsAll.length} items</div>
        <div className="list2-sheet-actions">
          <button
            type="button"
            ref={colsBtnRef}
            className="list2-sheet-btn"
            onClick={() => setColsMenuOpen(v => !v)}
            aria-haspopup="menu"
            aria-expanded={colsMenuOpen}
          >
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="18"/><rect x="14" y="3" width="7" height="18"/></svg>
            <span>Columns ({cols.length})</span>
          </button>
          <button
            type="button"
            className="list2-sheet-btn"
            onClick={addCustomColumn}
            title="Add a custom column — text, number, or URL"
          >
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
            <span>Column</span>
          </button>
        </div>
      </div>
      {colsMenuOpen && (
            <div className={`list2-sheet-colsmenu${colsMenuPos ? ' is-floating' : ''}`} role="menu" style={colsMenuPos ? { position: 'fixed', left: Math.max(8, Math.min(colsMenuPos.x, window.innerWidth - 268)), top: colsMenuPos.y, right: 'auto' } : undefined}>
              <div className="list2-sheet-colsmenu-h">Show columns <span className="list2-sheet-colsmenu-count">{rows.length}/{rowsAll.length} rows</span></div>
              {allCols.map(col => (
                <label key={col.id} className={`list2-sheet-colsmenu-item${col.alwaysOn ? ' is-locked' : ''}`}>
                  <input
                    type="checkbox"
                    checked={isColVisible(config, col.id)}
                    disabled={!!col.alwaysOn}
                    onChange={() => toggleColVisible(col.id, col.alwaysOn)}
                  />
                  <span>{col.label || col.id}</span>
                  {col.kind === 'custom' && (
                    <button
                      type="button"
                      className="list2-sheet-colsmenu-del"
                      onClick={(e) => { e.preventDefault(); e.stopPropagation(); deleteCustomColumn(col.id); }}
                      title="Remove this column"
                      aria-label={`Remove ${col.label}`}
                    >×</button>
                  )}
                </label>
              ))}
              <button type="button" className="list2-sheet-colsmenu-add" onClick={() => { setColsMenuOpen(false); addCustomColumn(); }}>＋ Add custom column…</button>
            </div>
      )}

      <div className="list2-sheet-scroll">
        <table className="list2-sheet">
          <thead>
            <tr>
              {cols.map(col => (
                <th
                  key={col.id}
                  className={
                    `list2-sheet-th${col.sticky ? ' is-sticky' : ''}${col.align ? ' align-' + col.align : ''}`
                    + (colIsMovable(col) ? ' is-movable' : '')
                    + (colDrag && colDrag.dragId === col.id ? ' is-coldragging' : '')
                    + (colDrag && colDrag.overId === col.id ? ' is-colover-' + (colDrag.overPos || 'after') : '')
                  }
                  style={{ width: col.width, minWidth: col.width }}
                  onClick={() => { if (resizingRef.current) return; onHeaderClick(col); }}
                  draggable={colIsMovable(col)}
                  onDragStart={(e) => { if (resizingRef.current) { e.preventDefault(); return; } onColDragStart(e, col); }}
                  onDragOver={(e) => onColDragOver(e, col)}
                  onDrop={(e) => onColDrop(e, col)}
                  onDragEnd={onColDragEnd}
                  title={colIsMovable(col) ? 'Click to sort · drag to reorder' : undefined}
                >
                  <span>{col.label}</span>
                  {sortCol && sortCol.id === col.id && (
                    <span className="list2-sheet-sort">{sortCol.dir === 'asc' ? '↑' : '↓'}</span>
                  )}
                  {col.id !== 'drag' && col.id !== 'menu' && (
                    <span
                      className="list2-sheet-resizer"
                      title="Drag to resize · double-click to reset"
                      draggable={false}
                      onMouseDown={(e) => onResizeStart(e, col)}
                      onDoubleClick={(e) => { e.stopPropagation(); resetWidth(col); }}
                      onClick={(e) => e.stopPropagation()}
                    />
                  )}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {rows.length === 0 && (
              <tr>
                <td colSpan={cols.length} className="list2-sheet-empty">
                  {rowsAll.length === 0
                    ? 'No items yet — add some from the toolbar above.'
                    : 'No items match your filters.'}
                </td>
              </tr>
            )}
            {(() => {
              // Build the list of rows to render, interleaving group
              // header rows whenever the group changes. Group headers
              // are inserted BEFORE the first item of each group and
              // span the full column count. Only visible when the sort
              // is unclobbered — a manual column-sort blurs group
              // boundaries, so we hide the headers then.
              const out = [];
              let lastGroupId = undefined;
              rows.forEach((row, i) => {
                if (!sortCol && row.groupId !== lastGroupId) {
                  lastGroupId = row.groupId;
                  out.push(
                    (() => {
                      const g = row.groupNode;
                      const kids = g ? (g.children || []).filter(c => c.type === 'item') : [];
                      const isShort = !!(g && g.slotKind === 'shortlist');
                      const need = g ? (g.quantityNeeded || 1) : 0;
                      const picked = kids.filter(c => c.picked).length;
                      const decided = isShort && picked >= need;
                      return (
                        <tr key={`grp-${row.groupId || 'root'}-${i}`} className={`list2-sheet-grouprow${isShort ? ' is-shortlist' : ''}${decided ? ' is-decided' : ''}`}>
                          <td colSpan={cols.length} className="list2-sheet-groupcell">
                            <span className="list2-sheet-groupchip" aria-hidden="true">▸</span>
                            <span>{row.groupName || 'Top of list'}</span>
                            {isShort ? (
                              <span className={`list2-sheet-pickbadge${decided ? ' is-decided' : ''}`} title={decided ? 'Decided' : `Choose ${need} of these ${kids.length}`}>
                                {decided ? '✓ ' : ''}Pick {need} of {kids.length}{picked ? ` · ${picked} picked` : ''}
                              </span>
                            ) : (g ? <span className="list2-sheet-groupcount">{kids.length} {kids.length === 1 ? 'item' : 'items'}</span> : null)}
                          </td>
                        </tr>
                      );
                    })()
                  );
                }
                out.push(
                  <tr
                    key={row.node.id}
                    data-drop-item={row.node.id}
                    className={
                      'list2-sheet-row'
                      + (row.node.custom && row.node.custom.placeholder ? ' is-placeholder' : '')
                      + (dragState && dragState.dragId === row.node.id ? ' is-dragging' : '')
                      + (dragState && dragState.overId === row.node.id ? ' is-dragover-' + (dragState.overPos || 'below') : '')
                    }
                    draggable
                    onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); openItemMenuAt(row.node.id, e.clientX, e.clientY); }}
                    onDragStart={(e) => onRowDragStart(e, row)}
                    onDragOver={(e) => onRowDragOver(e, row)}
                    onDragLeave={() => onRowDragLeave(row)}
                    onDrop={(e) => onRowDrop(e, row)}
                    onDragEnd={onRowDragEnd}
                    onClick={(e) => {
                      if (e.target.closest('input, button, a, [contenteditable], .list2-sheet-drag, .list2-sheet-status')) return;
                      onOpenItem && onOpenItem(row.node.id);
                    }}
                  >
                    {cols.map(col => (
                      <td
                        key={col.id}
                        className={`list2-sheet-td${col.sticky ? ' is-sticky' : ''}${col.align ? ' align-' + col.align : ''}${col.id === 'drag' ? ' list2-sheet-td--drag' : ''}`}
                        style={{ width: col.width, minWidth: col.width }}
                      >
                        {renderCell(row, col, {
                          productMap,
                          editing,
                          editVal,
                          setEditVal,
                          startEdit,
                          commitEdit,
                          cancelEdit,
                          onEditKey,
                          onCycleStatus,
                          onUpdate,
                          commitMetaValue,
                          enumOptions,
                          commitLink,
                          enrichingIds,
                          votes,
                          myUserId,
                          onVote,
                        })}
                      </td>
                    ))}
                  </tr>
                );
              });
              return out;
            })()}
          </tbody>
        </table>
      </div>
    </div>
  );
}

// Sort key extractor — mirrors renderCell display but returns a
// comparable primitive. Numbers stay numeric; strings lowercased.
function cellSortKey({ node, groupName }, colId, productMap, extra) {
  const catalog = node.productId && productMap ? productMap[node.productId] : null;
  const custom = node.custom || {};
  const meta = node.metadata || {};
  switch (colId) {
    case 'name':  return String(node.name || custom.name || (catalog && catalog.name) || '').toLowerCase();
    case 'product': return String((catalog && catalog.name) || custom.name || '').toLowerCase();
    case 'brand': return String((catalog && catalog.brand) || custom.brand || '').toLowerCase();
    case 'status':return node.status ? (STATUS_ORDER[node.status] ?? 3) : 4;
    case 'loved': return -1 * ((extra && extra.votes) || []).filter(v => v.node_id === node.id && v.vote === 'up').length;
    case 'price': return Number((catalog ? catalog.price : custom.price) || 0);
    case 'age':   return node.ageMin != null ? node.ageMin : 999;
    case 'materials': return String(custom.materials || '').toLowerCase();
    case 'certs': return (custom.certifications || []).length;
    case 'dims':  return String((custom.dimensions && (custom.dimensions.size || custom.dimensions.width)) || '').toLowerCase();
    case 'madeIn': return String(custom.madeIn || '').toLowerCase();
    case 'source': return String(custom.sourceUrl || custom.source_url || custom.url || '').toLowerCase();
    case 'group': return String(groupName || '').toLowerCase();
    case 'notes': return String(node.notes || '').toLowerCase();
    // Planner metadata columns.
    case 'priority':       return ({essential:0, useful:1, bonus:2, nice:2})[String(meta.priority || '').toLowerCase()] ?? 3;
    case 'owner':          return String(meta.owner || '').toLowerCase();
    case 'category':       return String(meta.category || '').toLowerCase();
    case 'when_needed':    return ['before birth','0–1 month','1–3 months','after birth','later'].indexOf(String(meta.when_needed || '').toLowerCase()) + 1 || 99;
    case 'target_date':    return String(meta.target_date || '9999-12-31');
    case 'date_cooked':    return String(meta.date_cooked || '9999-12-31');
    case 'use_by':         return String(meta.use_by || '9999-12-31');
    case 'qty_target':     return Number(meta.qty_target) || 0;
    case 'qty_owned':      return Number(meta.qty_owned) || 0;
    case 'qty_remaining':  return (Number(meta.qty_target) || 0) - (Number(meta.qty_owned) || 0);
    case 'purchase_route': return String(meta.purchase_route || '').toLowerCase();
    case 'actual_cost':    return Number(meta.actual_cost) || 0;
    case 'size':           return String(meta.size || '').toLowerCase();
    case 'servings_per_batch': return Number(meta.servings_per_batch) || 0;
    case 'batches_planned':    return Number(meta.batches_planned) || 0;
    case 'batches_made':       return Number(meta.batches_made) || 0;
    default:
      // Custom col
      return String((custom.customCols && custom.customCols[colId]) || '').toLowerCase();
  }
}


// Popover picker for tag-style columns. Shows every option (seed vocab +
// anything already used in the list) as chips, type-to-filter, and a
// "Create" row when what you've typed isn't an option yet.
function SheetEnumPicker({ col, value, options, current, onPick, onClose, children, allowCreate = true, clearLabel = 'Clear' }) {
  const anchorRef = _sh_r(null);
  const inputRef = _sh_r(null);
  const [q, setQ] = _sh_s('');
  const [hi, setHi] = _sh_s(0);
  const [pos, setPos] = _sh_s(null);
  _sh_e(() => {
    const place = () => {
      const a = anchorRef.current; if (!a) return;
      const r = a.getBoundingClientRect();
      const W = 240, H = 300;
      const left = Math.max(8, Math.min(r.left, window.innerWidth - W - 8));
      const below = r.bottom + 6, above = r.top - 6 - H;
      const top = below + H > window.innerHeight - 8 && above > 8 ? above : below;
      setPos({ left, top, width: W });
    };
    place();
    window.addEventListener('resize', place);
    const t = setTimeout(() => inputRef.current && inputRef.current.focus(), 10);
    const onDown = (e) => {
      if (anchorRef.current && anchorRef.current.contains(e.target)) return;
      if (e.target.closest && e.target.closest('.list2-sheet-picker')) return;
      onClose();
    };
    setTimeout(() => document.addEventListener('mousedown', onDown), 0);
    return () => { window.removeEventListener('resize', place); document.removeEventListener('mousedown', onDown); clearTimeout(t); };
  }, []);
  const s = q.trim().toLowerCase();
  const filtered = options.filter(o => !s || String(o).toLowerCase().includes(s) || enumLabel(col.metaKey, o).toLowerCase().includes(s));
  const exact = options.some(o => String(o).toLowerCase() === s || enumLabel(col.metaKey, o).toLowerCase() === s);
  const canCreate = allowCreate && !!s && !exact;
  const rows = [
    ...filtered.map(o => ({ kind: 'opt', v: o })),
    ...(canCreate ? [{ kind: 'create', v: q.trim() }] : []),
    ...(current ? [{ kind: 'clear' }] : []),
  ];
  const act = (row) => {
    if (!row) return;
    if (row.kind === 'clear') onPick('');
    else onPick(row.v);
  };
  const onKey = (e) => {
    if (e.key === 'Escape') { e.preventDefault(); onClose(); }
    else if (e.key === 'ArrowDown') { e.preventDefault(); setHi(h => Math.min(rows.length - 1, h + 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setHi(h => Math.max(0, h - 1)); }
    else if (e.key === 'Enter') { e.preventDefault(); act(rows[Math.min(hi, rows.length - 1)]); }
    else if (e.key === 'Tab') { onClose(); }
  };
  const slug = (v) => String(v || '').toLowerCase().replace(/[^a-z0-9]/g, '');
  return (
    <span ref={anchorRef} className="list2-sheet-pickanchor">
      {children}
      {pos && (
        <div className="list2-sheet-picker" style={{ left: pos.left, top: pos.top, width: pos.width }} role="listbox" onMouseDown={(e) => e.stopPropagation()}>
          <input
            ref={inputRef}
            className="list2-sheet-picker-q"
            placeholder={allowCreate ? (options.length ? `Search or add ${col.label.toLowerCase()}…` : `New ${col.label.toLowerCase()}…`) : `Pick a ${col.label.toLowerCase()}…`}
            value={q}
            onChange={(e) => { setQ(e.target.value); setHi(0); }}
            onKeyDown={onKey}
          />
          <div className="list2-sheet-picker-list">
            {rows.map((row, i) => {
              const on = row.kind === 'opt' && current && String(row.v).toLowerCase() === String(current).toLowerCase();
              return (
                <button
                  key={row.kind + ':' + (row.v || '')}
                  type="button"
                  role="option"
                  aria-selected={on}
                  className={`list2-sheet-picker-row${i === hi ? ' is-hi' : ''}${on ? ' is-on' : ''}${row.kind !== 'opt' ? ' is-action' : ''}`}
                  onMouseEnter={() => setHi(i)}
                  onClick={() => act(row)}
                >
                  {row.kind === 'opt' && <span className={`list2-sheet-chip list2-sheet-chip--${col.id}-${slug(row.v)}`}>{enumLabel(col.metaKey, row.v)}</span>}
                  {row.kind === 'create' && <span>＋ Create “{row.v}”</span>}
                  {row.kind === 'clear' && <span>{clearLabel}</span>}
                  {on && <span className="list2-sheet-picker-tick">✓</span>}
                </button>
              );
            })}
            {rows.length === 0 && <div className="list2-sheet-picker-empty">Type to add the first option</div>}
          </div>
        </div>
      )}
    </span>
  );
}

function renderCell({ node, groupName }, col, ctx) {
  const catalog = node.productId && ctx.productMap ? ctx.productMap[node.productId] : null;
  const custom = node.custom || {};
  const meta = node.metadata || {};
  const isEditing = ctx.editing && ctx.editing.nodeId === node.id && ctx.editing.colId === col.id;

  // ── Reusable renderers for the planner metadata columns ────────────
  const renderMetaText = (metaKey, placeholder = 'Click to add') => {
    const v = meta[metaKey];
    if (isEditing) {
      return (
        <input
          className="list2-sheet-editin"
          autoFocus
          value={ctx.editVal}
          onChange={(e) => ctx.setEditVal(e.target.value)}
          onBlur={ctx.commitEdit}
          onKeyDown={ctx.onEditKey}
        />
      );
    }
    return (
      <span
        onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, col.id, v || ''); }}
        title={v ? String(v) : placeholder}
      >{v ? <span className="list2-sheet-truncate">{v}</span> : <span className="list2-sheet-muted">—</span>}</span>
    );
  };
  const renderMetaNumber = (metaKey, prefix = '') => {
    const v = meta[metaKey];
    if (isEditing) {
      return (
        <input
          className="list2-sheet-editin"
          autoFocus
          type="text"
          value={ctx.editVal}
          onChange={(e) => ctx.setEditVal(e.target.value.replace(/[^\d.\-]/g, ''))}
          onBlur={ctx.commitEdit}
          onKeyDown={ctx.onEditKey}
        />
      );
    }
    return (
      <span
        onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, col.id, v == null ? '' : String(v)); }}
      >{v != null && v !== '' ? `${prefix}${v}` : <span className="list2-sheet-muted">—</span>}</span>
    );
  };
  // Dropdown editor — options are the planner vocabulary + anything
  // already used in this list + "Other…" (free text). Selecting commits
  // immediately; Esc / blur cancels.
  const renderMetaEnum = (metaKey) => {
    const raw = meta[metaKey];
    const v = raw == null ? '' : String(raw);
    const label = enumLabel(metaKey, v);
    if (isEditing) {
      const opts = ctx.enumOptions(col);
      const chipSlug = v.toLowerCase().replace(/[^a-z0-9]/g, '');
      return (
        <SheetEnumPicker
          col={col}
          options={opts}
          current={v}
          onPick={(val) => ctx.commitMetaValue(node.id, metaKey, val)}
          onClose={ctx.cancelEdit}
        >
          {label
            ? <span className={`list2-sheet-chip list2-sheet-chip--${col.id}-${chipSlug}`}>{label}</span>
            : <span className="list2-sheet-muted">Pick…</span>}
          <span className="list2-sheet-caret is-open" aria-hidden="true">▴</span>
        </SheetEnumPicker>
      );
    }
    const slug = v.toLowerCase().replace(/[^a-z0-9]/g, '');
    return (
      <span
        className="list2-sheet-cell-pick"
        onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, col.id, v); }}
        title="Click to pick"
      >{label
        ? <span className={`list2-sheet-chip list2-sheet-chip--${col.id}-${slug}`}>{label}</span>
        : <span className="list2-sheet-muted">Pick…</span>}
        <span className="list2-sheet-caret" aria-hidden="true">▾</span>
      </span>
    );
  };
  // Native date picker; committing on change, cancel on blur / Esc.
  const renderMetaDate = (metaKey) => {
    const v = meta[metaKey] ? String(meta[metaKey]) : '';
    if (isEditing) {
      return (
        <input
          type="date"
          className="list2-sheet-editin list2-sheet-date"
          autoFocus
          defaultValue={v.slice(0, 10)}
          onChange={(e) => ctx.commitMetaValue(node.id, metaKey, e.target.value)}
          onBlur={ctx.cancelEdit}
          onKeyDown={(e) => {
            if (e.key === 'Escape') { e.preventDefault(); ctx.cancelEdit(); }
            if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); ctx.commitMetaValue(node.id, metaKey, ''); }
          }}
          onClick={(e) => e.stopPropagation()}
        />
      );
    }
    return (
      <span
        className="list2-sheet-cell-pick"
        onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, col.id, v); }}
        title="Click to pick a date"
      >{v ? <span>{fmtDate(v)}</span> : <span className="list2-sheet-muted">Pick…</span>}</span>
    );
  };

  // Metadata-backed columns dispatch on their editor kind; everything
  // else (built-ins + user custom columns) goes through the switch.
  if (col.metaKey && col.kind !== 'computed') {
    if (col.kind === 'enum')   return renderMetaEnum(col.metaKey);
    if (col.kind === 'date')   return renderMetaDate(col.metaKey);
    if (col.kind === 'number') return renderMetaNumber(col.metaKey, col.prefix || '');
    return renderMetaText(col.metaKey);
  }

  switch (col.id) {
    case 'drag': {
      // Row drag handle. The `draggable` attr is on the <tr> itself,
      // so this cell just draws the affordance — the actual drag
      // event bubbles up to the row's own onDragStart handler.
      return (
        <span className="list2-sheet-drag" title="Drag to reorder" aria-hidden="true">
          <svg width="12" height="14" viewBox="0 0 24 24" fill="currentColor">
            <circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/>
            <circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/>
            <circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/>
          </svg>
        </span>
      );
    }
    case 'image': {
      const img = (catalog && catalog.img) || custom.image;
      if (img) return <span className="list2-sheet-thumb"><img src={img} alt="" loading="lazy" /></span>;
      if (custom.placeholder) {
        return (
          <span className="list2-sheet-thumb list2-sheet-thumb--placeholder" title="Placeholder — click the row to pick a real product" aria-hidden="true">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
          </span>
        );
      }
      return <span className="list2-sheet-thumb list2-sheet-thumb--empty" aria-hidden="true">—</span>;
    }
    case 'product': {
      const prod = (catalog && catalog.name) || custom.name || '';
      const label = node.name || '';
      if (isEditing) {
        return (
          <input className="list2-sheet-editin" autoFocus value={ctx.editVal} onChange={(e) => ctx.setEditVal(e.target.value)} onBlur={ctx.commitEdit} onKeyDown={ctx.onEditKey} placeholder="Product name" />
        );
      }
      if (catalog) return <span className="list2-sheet-truncate" title={prod}>{prod}</span>;
      if (!prod || prod === label) {
        return <span className="list2-sheet-muted list2-sheet-cell-pick" onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, 'product', prod === label ? '' : prod); }} title="Set the specific product">{prod === label && prod ? '= item' : (custom.placeholder ? 'not chosen yet' : 'Add…')}</span>;
      }
      return <span className="list2-sheet-truncate" title={prod} onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, 'product', prod); }}>{prod}</span>;
    }
    case 'name': {
      const name = node.name || custom.name || (catalog && catalog.name) || 'Item';
      if (isEditing) {
        return (
          <input
            className="list2-sheet-editin"
            autoFocus
            value={ctx.editVal}
            onChange={(e) => ctx.setEditVal(e.target.value)}
            onBlur={ctx.commitEdit}
            onKeyDown={ctx.onEditKey}
          />
        );
      }
      return (
        <span
          className="list2-sheet-name"
          onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, 'name', name); }}
        >{name}{custom.placeholder && <span className="list2-sheet-ph-tag">Placeholder</span>}{node.picked && <span className="list2-sheet-picked-tag" title="Picked for this shortlist">✓ picked</span>}</span>
      );
    }
    case 'brand': {
      const brand = (catalog && catalog.brand) || custom.brand || '';
      if (isEditing) {
        return (
          <input
            className="list2-sheet-editin"
            autoFocus
            value={ctx.editVal}
            onChange={(e) => ctx.setEditVal(e.target.value)}
            onBlur={ctx.commitEdit}
            onKeyDown={ctx.onEditKey}
          />
        );
      }
      return (
        <span
          onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, 'brand', brand); }}
        >{brand || <span className="list2-sheet-muted">—</span>}</span>
      );
    }
    case 'status': {
      const s = node.status || '';
      const label = s ? (STATUS_LABEL[s] || s) : '';
      const chip = s
        ? <span className={`list2-sheet-status list2-sheet-status--${s}`}>{label}</span>
        : <span className="list2-sheet-muted">Not started</span>;
      if (isEditing) {
        return (
          <SheetEnumPicker
            col={{ id: 'status', label: 'Status', metaKey: 'status' }}
            options={['need', 'ordered', 'have']}
            current={s}
            allowCreate={false}
            clearLabel="Not started"
            onPick={(val) => { ctx.onUpdate(node.id, { status: val || null }); ctx.cancelEdit(); }}
            onClose={ctx.cancelEdit}
          >
            {chip}<span className="list2-sheet-caret is-open" aria-hidden="true">▴</span>
          </SheetEnumPicker>
        );
      }
      return (
        <span className="list2-sheet-cell-pick" onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, 'status', s); }} title="Click to set status">
          {chip}<span className="list2-sheet-caret" aria-hidden="true">▾</span>
        </span>
      );
    }
    case 'price': {
      const price = catalog ? catalog.price : custom.price;
      const cur = catalog ? (catalog.currency || 'AUD') : (custom.currency || 'AUD');
      if (isEditing) {
        return (
          <input
            className="list2-sheet-editin"
            type="text"
            autoFocus
            value={ctx.editVal}
            onChange={(e) => ctx.setEditVal(e.target.value.replace(/[^\d.]/g, ''))}
            onBlur={ctx.commitEdit}
            onKeyDown={ctx.onEditKey}
          />
        );
      }
      return (
        <span
          onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, 'price', price); }}
        >{price != null ? `${cur === 'AUD' ? 'A$' : '$'}${price}` : <span className="list2-sheet-muted">—</span>}</span>
      );
    }
    case 'age': {
      const s = ageLabelFrom(node);
      return s ? <span>{s}</span> : <span className="list2-sheet-muted">—</span>;
    }
    case 'materials': {
      const m = custom.materials;
      return m ? <span className="list2-sheet-truncate" title={m}>{m}</span> : <span className="list2-sheet-muted">—</span>;
    }
    case 'certs': {
      const cs = custom.certifications || [];
      if (!cs.length) return <span className="list2-sheet-muted">—</span>;
      return <span className="list2-sheet-truncate">{cs.join(' · ')}</span>;
    }
    case 'dims': {
      const d = custom.dimensions || {};
      const parts = [];
      if (d.size) parts.push(d.size);
      if (d.width) parts.push('W ' + d.width);
      if (d.height) parts.push('H ' + d.height);
      if (d.depth) parts.push('D ' + d.depth);
      if (d.diameter) parts.push('⌀ ' + d.diameter);
      if (d.weight) parts.push(d.weight);
      return parts.length ? <span className="list2-sheet-truncate" title={parts.join(' · ')}>{parts.join(' · ')}</span> : <span className="list2-sheet-muted">—</span>;
    }
    case 'madeIn': {
      const m = custom.madeIn;
      return m ? <span>{m}</span> : <span className="list2-sheet-muted">—</span>;
    }
    case 'source': {
      const isBusy = ctx.enrichingIds && ctx.enrichingIds.has(node.id);
      if (isBusy) {
        return <span className="list2-sheet-fetching"><span className="list2-sheet-spin" aria-hidden="true" /> Fetching details…</span>;
      }
      // Catalog items: link comes from the product record; not editable here.
      if (catalog) {
        const cu = catalog.primaryUrl || (Array.isArray(catalog.retailers) && catalog.retailers[0] && catalog.retailers[0].url) || null;
        const safeC = cu && window.MR && window.MR.safeUrl ? window.MR.safeUrl(cu) : null;
        if (!safeC) return <span className="list2-sheet-muted">catalog</span>;
        let hostC = ''; try { hostC = new URL(safeC).hostname.replace(/^www\./, ''); } catch {}
        return <a href={safeC} target="_blank" rel="noopener noreferrer" className="list2-sheet-link" onClick={(e) => e.stopPropagation()}>{hostC || 'shop'} ↗</a>;
      }
      const u = custom.sourceUrl || custom.source_url || custom.url || '';
      if (isEditing) {
        return (
          <input
            className="list2-sheet-editin list2-sheet-linkin"
            type="url"
            autoFocus
            placeholder="Paste a product link…"
            value={ctx.editVal}
            onChange={(e) => ctx.setEditVal(e.target.value)}
            onPaste={(e) => {
              // Fast path: paste = commit, no Enter needed.
              const txt = (e.clipboardData && e.clipboardData.getData('text')) || '';
              if (/^\s*(https?:\/\/|www\.)/i.test(txt)) {
                e.preventDefault();
                ctx.commitLink(node.id, txt);
              }
            }}
            onBlur={() => ctx.commitLink(node.id, ctx.editVal)}
            onKeyDown={(e) => {
              if (e.key === 'Enter') { e.preventDefault(); ctx.commitLink(node.id, ctx.editVal); }
              else if (e.key === 'Escape') { e.preventDefault(); ctx.cancelEdit(); }
            }}
            onClick={(e) => e.stopPropagation()}
          />
        );
      }
      const startLinkEdit = (e) => { e.stopPropagation(); ctx.startEdit(node.id, 'source', u); };
      if (!u) {
        return (
          <span className="list2-sheet-cell-pick list2-sheet-linkempty" onClick={startLinkEdit} title="Paste a product link — placeholders get filled in automatically">
            <span className="list2-sheet-muted">{custom.placeholder ? '+ Paste link to fill in' : 'Add link…'}</span>
          </span>
        );
      }
      let host = '';
      try { host = new URL(u).hostname.replace(/^www\./, ''); } catch {}
      const safe = window.MR && window.MR.safeUrl ? window.MR.safeUrl(u) : null;
      return (
        <span className="list2-sheet-linkcell">
          {safe
            ? <a href={safe} target="_blank" rel="noopener noreferrer" className="list2-sheet-link" onClick={(e) => e.stopPropagation()} title={u}>{host || 'source'} ↗</a>
            : <span className="list2-sheet-truncate">{host || 'link'}</span>}
          <button type="button" className="list2-sheet-linkedit" onClick={startLinkEdit} title="Change link" aria-label="Change link">✎</button>
        </span>
      );
    }
    case 'group': {
      return groupName ? <span className="list2-sheet-truncate">{groupName}</span> : <span className="list2-sheet-muted">—</span>;
    }
    case 'notes': {
      const n = node.notes || '';
      // Notes is the highest-traffic quick-entry field, so we let a
      // SINGLE click enter edit mode. Uses a textarea so users can
      // press Enter for line breaks (Cmd/Ctrl+Enter or blur saves;
      // Esc cancels). Auto-grows a bit while typing so multi-line
      // notes don't get scrolled to a tiny single-row window.
      if (isEditing) {
        return (
          <textarea
            className="list2-sheet-editin list2-sheet-editin--multi"
            autoFocus
            value={ctx.editVal}
            onChange={(e) => ctx.setEditVal(e.target.value)}
            onBlur={ctx.commitEdit}
            onKeyDown={(e) => {
              // Enter = newline (default textarea behaviour).
              // Cmd/Ctrl+Enter = save. Esc = cancel.
              if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
                e.preventDefault();
                ctx.commitEdit();
              } else if (e.key === 'Escape') {
                e.preventDefault();
                ctx.cancelEdit();
              }
              // Regular Enter: fall through so newline is inserted.
            }}
            placeholder="Add a note… **bold** _italic_ URLs auto-link · ⌘⏎ to save"
            rows={Math.max(2, Math.min(6, (ctx.editVal || '').split('\n').length + 1))}
          />
        );
      }
      // Render display with light markdown — preserves line breaks +
      // renders **bold** / _italic_ / auto-links. The container has
      // white-space: pre-wrap so newlines display without <br>s.
      return (
        <span
          className="list2-sheet-notes"
          onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, 'notes', n); }}
          title={n || 'Click to add a note'}
          dangerouslySetInnerHTML={n
            ? { __html: window.renderNotesRich ? window.renderNotesRich(n) : escapeHtml(n) }
            : undefined
          }
        >{n ? null : <span className="list2-sheet-muted">Add a note…</span>}</span>
      );
    }
    case 'menu': {
      return (
        <button
          type="button"
          className="list2-sheet-menubtn"
          title="More actions (or right-click the row)"
          aria-label="More actions"
          onClick={(e) => {
            e.stopPropagation();
            const r = e.currentTarget.getBoundingClientRect();
            openItemMenuAt(node.id, r.left, r.bottom + 4);
          }}
        >⋯</button>
      );
    }
    case 'loved': {
      const ups = (ctx.votes || []).filter(v => v.node_id === node.id && v.vote === 'up');
      const mine = !!(ctx.myUserId && ups.some(v => v.voter_id === ctx.myUserId));
      const others = ups.filter(v => v.voter_id !== ctx.myUserId).length;
      const both = mine && others > 0;
      const title = ups.length === 0 ? 'Nobody has loved this yet — click to love it'
        : both ? `Loved by both of you` : mine ? 'You love this' : `Loved by ${others} ${others === 1 ? 'person' : 'people'}`;
      return (
        <button
          type="button"
          className={`list2-sheet-love${mine ? ' is-on' : ''}${both ? ' is-both' : ''}`}
          title={title}
          onClick={(e) => { e.stopPropagation(); ctx.onVote && ctx.onVote(node.id, 'up'); }}
          disabled={!ctx.onVote}
        >
          <span aria-hidden="true">{ups.length >= 2 ? '♥♥' : mine || ups.length ? '♥' : '♡'}</span>
          {ups.length > 0 && <span className="list2-sheet-love-n">{ups.length}</span>}
        </button>
      );
    }
    case 'qty_remaining': {
      const t = Number(meta.qty_target), o = Number(meta.qty_owned) || 0;
      if (!Number.isFinite(t)) return <span className="list2-sheet-muted">—</span>;
      const rem = t - o;
      return <span className={rem <= 0 ? 'list2-sheet-ok' : ''}>{rem <= 0 ? '✓ 0' : rem}</span>;
    }
    default: {
      // Custom column
      const val = (custom.customCols && custom.customCols[col.id]) || '';
      if (isEditing) {
        return (
          <input
            className="list2-sheet-editin"
            autoFocus
            type={col.type === 'number' ? 'text' : (col.type === 'url' ? 'url' : 'text')}
            value={ctx.editVal}
            onChange={(e) => {
              const v = col.type === 'number' ? e.target.value.replace(/[^\d.\-]/g, '') : e.target.value;
              ctx.setEditVal(v);
            }}
            onBlur={ctx.commitEdit}
            onKeyDown={ctx.onEditKey}
            placeholder={col.type === 'url' ? 'https://…' : ''}
          />
        );
      }
      if (col.type === 'url' && val) {
        const safe = window.MR && window.MR.safeUrl ? window.MR.safeUrl(val) : null;
        if (safe) {
          let host = '';
          try { host = new URL(safe).hostname.replace(/^www\./, ''); } catch {}
          return (
            <a
              href={safe}
              target="_blank"
              rel="noopener noreferrer"
              className="list2-sheet-link"
              onClick={(e) => e.stopPropagation()}
              onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, col.id, val); }}
            >{host || val} ↗</a>
          );
        }
      }
      return (
        <span
          onClick={(e) => { e.stopPropagation(); ctx.startEdit(node.id, col.id, val); }}
        >{val || <span className="list2-sheet-muted">—</span>}</span>
      );
    }
  }
}

window.SpreadsheetView = SpreadsheetView;
