// "Quick Add" — paste any product URL → og-fetch extracts metadata → smart
// match against the catalog → user picks list/status/quantity/age → save.

const { useEffect: _qe, useMemo: _qm, useRef: _qr, useState: _qs } = React;

// ─── Domain → category heuristics ──────────────────────────────────
// When a pasted URL isn't in our catalog, infer a category from its host.
// Saves the user a click and helps the catalog-submission queue land
// reasonable defaults. Substring match on hostname (lowercase).
const DOMAIN_CATEGORY_HINTS = [
  // Toys & developmental
  { match: ['lovevery.com'],                       category: 'developmental', label: 'Developmental toys' },
  { match: ['magnatiles.com','playmagnatiles.com'], category: 'developmental', label: 'STEM toys' },
  { match: ['melissaanddoug.com','melissaanddoug.com.au'], category: 'developmental', label: 'Wooden toys' },
  { match: ['tonies.com'],                         category: 'developmental', label: 'Audio toys' },
  { match: ['plantoys.com','grimms.eu'],           category: 'developmental', label: 'Wooden toys' },
  // Clothes
  { match: ['littlegreenradicals.com','hannaandersson.com','boden','jamiekaykids','minirodini'], category: 'clothes', label: 'Clothes' },
  { match: ['cotton-on.com','seedheritage.com','uniqlo.com/au/kids'], category: 'clothes', label: 'Clothes' },
  // Travel & gear
  { match: ['bugaboo.com','uppababy.com','nuna','silvercrossbaby','joolz.com','cybex-online.com','edwardsandco'], category: 'travel', label: 'Travel & gear' },
  // Nursery & sleep
  { match: ['boori.com','leander.com','snuzpod','pottery barn kids','potterybarnkids'], category: 'nursery', label: 'Nursery furniture' },
  { match: ['ergopouch.com','swaddleup.com','halo'], category: 'nursery', label: 'Sleepwear' },
  // Feeding
  { match: ['tommeetippee.com','medela.com','philipsavent','natursutten','haakaa','bibs'], category: 'feeding', label: 'Feeding' },
  // Books
  { match: ['penguinrandomhouse.com','readings.com.au','booktopia.com.au','allenandunwin.com'], category: 'books', label: 'Books' },
  // Generic baby retailers — skip (could be anything)
];
function categoryHintFromUrl(url) {
  if (!url) return null;
  let host = '';
  try { host = new URL(url).hostname.toLowerCase().replace(/^www\./, ''); } catch { return null; }
  for (const h of DOMAIN_CATEGORY_HINTS) {
    if (h.match.some(needle => host.includes(needle.toLowerCase()))) return h;
  }
  return null;
}

// Tiny inline pill segmented control reused for status + age buckets.
function PillRow({ value, options, onChange, ariaLabel }) {
  return (
    <div className="quick-add-pillrow" role="radiogroup" aria-label={ariaLabel}>
      {options.map(o => (
        <button
          key={o.value}
          type="button"
          role="radio"
          aria-checked={value === o.value}
          className={`quick-add-pill${value === o.value ? ' is-on' : ''}`}
          onClick={() => onChange(o.value)}
        >
          {o.label}
        </button>
      ))}
    </div>
  );
}

// Catalog matcher used by the bookmarklet / Quick Add review flow.
// Returns the best catalog candidate ONLY when both:
//   • the brand matches (or one side has no brand declared)
//   • there's at least 1 shared NAME token (i.e. distinct from the brand)
//     AND a Jaccard similarity ≥ 0.5 on those name tokens.
// The "at least one shared name token" guard is the key fix for the
// earlier "Cybex e-PRIAM Pram → matched to Cybex Cloud Q" bug — the old
// scorer awarded a +0.15 brand bonus that pushed brand-only overlap past
// the 0.6 threshold even when the products were completely unrelated.
// Brand-token overlap on its own is now never enough.
//
// We deliberately keep the matcher conservative — paste-link flows skip
// it entirely (a URL is an unambiguous reference and should never be
// silently swapped). This function is for the bookmarklet review screen
// where the user verifies the match before saving.
function quickFindCatalogMatch({ name, brand }) {
  const all = window.PRODUCTS || [];
  if (!all.length || !name) return null;
  const norm = s => (s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();

  const tBrand = norm(brand || '');
  const tBrandTokens = new Set(tBrand.split(' ').filter(t => t.length > 2));
  // Title tokens excluding the brand words — these are the distinctive
  // bits ("e priam pram", not "cybex").
  const tNameTokens = new Set(
    norm(name).split(' ')
      .filter(t => t.length > 2 && !tBrandTokens.has(t))
  );
  if (tNameTokens.size === 0) return null;

  let best = null, bestShared = 0, bestJaccard = 0;
  for (const p of all) {
    const pBrand = norm(p.brand || '');
    // Brand mismatch is an automatic skip — guards the common "same
    // brand, different product" wrong-match.
    if (tBrand && pBrand && tBrand !== pBrand) continue;

    const pBrandTokens = new Set(pBrand.split(' ').filter(t => t.length > 2));
    const pNameTokens = new Set(
      norm(p.name || '').split(' ')
        .filter(t => t.length > 2 && !pBrandTokens.has(t))
    );

    let shared = 0;
    tNameTokens.forEach(t => { if (pNameTokens.has(t)) shared++; });
    if (shared === 0) continue;  // brand-only overlap → reject

    const union = tNameTokens.size + pNameTokens.size - shared;
    const jaccard = union > 0 ? shared / union : 0;

    // Prefer more shared tokens; tie-break on Jaccard (favours the
    // catalog product whose name overlaps the target most precisely).
    if (shared > bestShared || (shared === bestShared && jaccard > bestJaccard)) {
      bestShared = shared;
      bestJaccard = jaccard;
      best = p;
    }
  }

  // Acceptance gate: at least 1 shared name token AND Jaccard ≥ 0.5,
  // OR 3+ shared name tokens (a strong signal even at lower Jaccard).
  if ((bestShared >= 1 && bestJaccard >= 0.5) || bestShared >= 3) {
    return { match: best, score: bestJaccard };
  }
  return null;
}

// Item status — the single axis that replaces the old "destination + list-
// status" two-tier picker. Three states a parent actually thinks in:
//   loved   — bookmarked, zero commitment ("ooh, cute")
//   getting — decision made, we're buying this (combines old want+need;
//             this is what goes on registries)
//   have    — already own it
const ITEM_STATUSES = [
  {
    value: 'loved',
    label: 'Loved',
    sub: 'Just saving it',
    icon: (
      <svg viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
        <path d="M12 21.3 C 12 21.3, 3.5 15.5, 3.5 9.5 C 3.5 6.2, 6 4, 8.6 4 C 10.5 4, 11.5 5.4, 12 6.5 C 12.5 5.4, 13.5 4, 15.4 4 C 18 4, 20.5 6.2, 20.5 9.5 C 20.5 15.5, 12 21.3, 12 21.3 Z"/>
      </svg>
    ),
  },
  {
    value: 'getting',
    label: 'Getting it',
    sub: "We're buying this",
    icon: (
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" width="22" height="22">
        <path d="M7 10V8a5 5 0 0110 0v2"/>
        <path d="M5 10h14l-1.2 9.5a2 2 0 01-2 1.5H8.2a2 2 0 01-2-1.5z"/>
        <path d="M9 14l2 2 4-4" strokeWidth="2"/>
      </svg>
    ),
  },
  {
    value: 'have',
    label: 'Have',
    sub: "Have it",
    icon: (
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" width="22" height="22">
        <rect x="3" y="3" width="18" height="18" rx="5.5"/>
        <path d="M7.5 12.4 L10.6 15.5 L16.8 8.8" strokeWidth="2.4"/>
      </svg>
    ),
  },
];

const AGE_BUCKETS = [
  { value: 'pre',     label: 'Pre-baby',  ageMin: -1, ageMax: 0   },
  { value: '0-3m',    label: '0–3 mo',    ageMin: 0,  ageMax: 3   },
  { value: '3-6m',    label: '3–6 mo',    ageMin: 3,  ageMax: 6   },
  { value: '6-12m',   label: '6–12 mo',   ageMin: 6,  ageMax: 12  },
  { value: '1-2y',    label: '1–2 yr',    ageMin: 12, ageMax: 24  },
  { value: '2-4y',    label: '2–4 yr',    ageMin: 24, ageMax: 48  },
  { value: '5+',      label: '5+ yr',     ageMin: 60, ageMax: 144 },
];

// Map an extracted (months, months) age range to the nearest bucket
// above. Used after og-fetch returns ageMin/ageMax so the QuickAdd
// form pre-fills the dropdown without the user having to click.
//
// Strategy: pick the bucket with the largest overlap with the
// extracted range; if the extracted upper bound is open (null) we
// treat it as `extractedMin + 24mo` so a "6m+" page picks 6-12m
// rather than landing on "5+ yr". Returns the bucket `value` or null
// when nothing overlaps (e.g. an out-of-our-range "12+ yr" product).
function pickAgeBucket(ageMin, ageMax) {
  const hasMin = ageMin != null && Number.isFinite(ageMin);
  const hasMax = ageMax != null && Number.isFinite(ageMax);
  if (!hasMin && !hasMax) return null;
  // Open-ended upper bound — clamp to a window so we don't bias toward
  // the "5+ yr" bucket every time. 24 months past the start is a
  // reasonable "this and the next couple of stages" window.
  const lo = hasMin ? ageMin : (hasMax ? ageMax - 12 : 0);
  const hi = hasMax ? ageMax : (hasMin ? ageMin + 24 : 0);
  let best = null;
  let bestScore = 0;
  for (const b of AGE_BUCKETS) {
    const ov = Math.max(0, Math.min(hi, b.ageMax) - Math.max(lo, b.ageMin));
    // Tie-break: prefer the narrower bucket so a "6m+" hit lands
    // on 6-12m, not 6-12m vs 1-2y vs 2-4y all tied at the same overlap.
    const score = ov + 1 / (b.ageMax - b.ageMin + 1) * 0.001;
    if (score > bestScore) { bestScore = score; best = b; }
  }
  return best && bestScore > 0 ? best.value : null;
}


function QuickAddPage({
  userLists,
  onCreateList,
  onAddItemToList,
  // NEW — invoked when the user picks a specific group/slot within a
  // list as the target destination. Receives (rootId, parentSlotId, item).
  // Implemented in index.html via window.MR.nodes.addChild so the item
  // lands at the right place in the tree, not the list root.
  onAddItemToNode,
  onAddToLoved,
  onAddToWishlist,
  onAddToOwned,
  onAddCustomOwned,
  onAddCustomLoved,
  onAddCustomWishlist,
  onNavigate,
  onRequireAuth,
  isSignedIn,
  onDone,
  // When the user opens the Add modal from a list-detail page, the
  // caller passes the list they're viewing so we can preselect it as
  // the target and rewrite the bottom CTA from the misleading
  // "Save to Loved" to a focused "Add to {list.name} →". The Loved/
  // Getting/Have status picker still works as a secondary axis.
  defaultListId,
  defaultListName,
}) {
  // Step 1: URL input + fetch
  const [url, setUrl] = _qs('');
  const [busy, setBusy] = _qs(false);
  const [err, setErr] = _qs('');
  const [fetched, setFetched] = _qs(null);  // { title, brand, price, image, description, sourceUrl, currency }
  const [identifyOpen, setIdentifyOpen] = _qs(false);
  const inputRef = _qr(null);

  // Step 2: form
  const [name, setName] = _qs('');
  const [brand, setBrand] = _qs('');
  const [price, setPrice] = _qs('');
  // Image selection — multi-select. selectedImages is kept ordered by
  // the og-fetch gallery order (so re-ordering is predictable: leftmost-
  // checked thumb is always the primary). The first entry is the
  // primary that shows on the card preview AND becomes gallery[0] on
  // save. setImageUrl is kept as a wrapper so resetForm / catalog
  // match / photo identify (which want "replace selection with this
  // single image") don't have to know about the array shape.
  const [selectedImages, setSelectedImages] = _qs([]);
  const imageUrl = selectedImages[0] || '';
  const setImageUrl = (val) => {
    if (typeof val === 'function') {
      setSelectedImages(prev => {
        const next = val(prev[0] || '');
        return next ? [next] : [];
      });
    } else {
      setSelectedImages(val ? [val] : []);
    }
  };
  const [description, setDescription] = _qs('');
  // Single status axis — 'loved' | 'getting' | 'have'. Default is 'loved'
  // because the bookmarklet path is high-volume / low-commitment.
  const [status, setStatus] = _qs('loved');
  const [quantity, setQuantity] = _qs(1);
  const [ageBucket, setAgeBucket] = _qs(null);
  const [inRegistry, setInRegistry] = _qs(false);
  // Ref on the primary submit button. Used to auto-focus it after a
  // paste-link fetch resolves OR after the form is opened pre-populated
  // from a list page — so the user can hit Enter to save without
  // hunting for the button.
  const submitBtnRef = _qr(null);
  // Image hovered in the picker — drives the floating preview popover
  // that shows the full-size image above the row, since 56px thumbs
  // are too small to read on busy product pages.
  const [previewImg, setPreviewImg] = _qs(null);
  // Optional first comment to post against the newly-created item.
  // Captures the user's "as I'm saving it" thought ("Saw this at the
  // store — looks great", "Sarah's friend recommended this"). Cleared
  // on resetForm. Posted via window.MR.nodes.addComment after the item
  // lands so it shows up in the same comment thread the rest of the
  // app reads from.
  const [noteOnAdd, setNoteOnAdd] = _qs('');
  // Read the user-id-scoped last-used target from localStorage. We try
  // BOTH the user-scoped key AND a fallback unscoped key — the read on
  // first render can race the Supabase user hydration, so if MR.user
  // isn't ready yet the scoped read returns null. The fallback unscoped
  // key catches that case (and rewrites correctly on the next submit).
  const LAST_USED_KEY = 'mr-quickadd-last-used';
  const _currentUid = () => {
    try {
      return (window.MR && window.MR.user && window.MR.user.current
        ? (window.MR.user.current().session && window.MR.user.current().session.user && window.MR.user.current().session.user.id) || null
        : null);
    } catch { return null; }
  };
  const _readLastUsed = () => {
    try {
      const uid = _currentUid();
      // 1. Scoped read (preferred — keeps two users on one browser separate)
      if (uid) {
        const raw = localStorage.getItem(LAST_USED_KEY + ':' + uid);
        if (raw) return JSON.parse(raw);
      }
      // 2. Fallback to unscoped read — covers the race where MR.user
      // isn't yet hydrated when the modal first mounts.
      const fallback = localStorage.getItem(LAST_USED_KEY);
      if (fallback) return JSON.parse(fallback);
      // 3. Final fallback to ':guest' for purely signed-out callers
      const guestRaw = localStorage.getItem(LAST_USED_KEY + ':guest');
      return guestRaw ? JSON.parse(guestRaw) : null;
    } catch { return null; }
  };
  const _writeLastUsed = (listId, slotId) => {
    try {
      const uid = _currentUid();
      const payload = JSON.stringify({
        listId: listId || null,
        slotId: slotId || null,
        ts: Date.now(),
      });
      // Write to BOTH the scoped key (correct per-user behavior) AND the
      // unscoped key (so the next mount can hit the unscoped fallback
      // before MR.user hydrates). The unscoped key gets overwritten with
      // the latest pick from any user — that's fine for a UI default.
      if (uid) localStorage.setItem(LAST_USED_KEY + ':' + uid, payload);
      localStorage.setItem(LAST_USED_KEY, payload);
    } catch {}
  };

  // Preselect order: defaultListId from the parent (modal opened from
  // a list page) wins; otherwise restore the LAST list the user added
  // to via this modal. The auto-pick effect handles the truly-cold
  // case (first ever add) when both are empty.
  const _lastUsed = _qm(() => _readLastUsed(), []);  // eslint-disable-line react-hooks/exhaustive-deps
  const [targetListId, setTargetListId] = _qs(
    defaultListId || (_lastUsed && _lastUsed.listId) || ''
  );
  // Optional sub-target: a specific slot/group WITHIN the chosen list.
  // Empty string = "Top of the list" (the root). When set, we route the
  // add through onAddItemToNode so it lands at the right place in the
  // tree — without this picker the bookmarklet always dumped items at
  // the root, even when the user had carefully built groups like
  // Carrier / Pram / Sleep inside their list. Also restored from the
  // last-used snapshot when the list matches (a slot id from list A
  // would be invalid against list B; the slots-reset effect below
  // clears it if the user picks a different list).
  const [targetSlotId, setTargetSlotId] = _qs(
    defaultListId ? '' : ((_lastUsed && _lastUsed.slotId) || '')
  );
  // Tree data for the user's lists — fetched once on mount via the new
  // Node API so we can show each list's groups in the sub-picker. Null
  // until loaded. Sub-picker hides itself if the chosen list has no
  // groups (most fresh lists won't).
  const [nodeRoots, setNodeRoots] = _qs(null);
  const [newListName, setNewListName] = _qs('');
  const [creatingList, setCreatingList] = _qs(false);
  // Inline subgroup creation — same pattern as new-list inline. When
  // creatingSlot is true we render an input for the subgroup name
  // instead of the slot dropdown. On submit the slot is created via
  // MR.nodes.addChild({type: 'slot'}) BEFORE the item add, then the
  // item lands inside the new slot.
  const [newSlotName, setNewSlotName] = _qs('');
  const [creatingSlot, setCreatingSlot] = _qs(false);

  // Fetch the full tree for the user's lists once. Cheap — fetchMyRoots
  // returns the whole tree in one round-trip and the user typically has
  // <10 lists. Refreshes are unnecessary while this modal is open.
  _qe(() => {
    if (!isSignedIn) return;
    if (!window.MR || !window.MR.nodes || typeof window.MR.nodes.fetchMyRoots !== 'function') return;
    let cancelled = false;
    (async () => {
      try {
        const res = await window.MR.nodes.fetchMyRoots();
        if (cancelled) return;
        if (res && res.ok && Array.isArray(res.roots)) setNodeRoots(res.roots);
      } catch {}
    })();
    return () => { cancelled = true; };
  }, [isSignedIn]);

  // Flatten the slots inside the currently-targeted list, indented for
  // nesting depth. Used to populate the "Where in the list?" picker.
  // Empty array when the list has no slots/groups (in which case we
  // hide the picker entirely).
  const targetSlots = _qm(() => {
    if (!targetListId || !Array.isArray(nodeRoots)) return [];
    const root = nodeRoots.find(r => r.id === targetListId);
    if (!root) return [];
    const out = [];
    const visit = (node, depth) => {
      (node.children || []).forEach(c => {
        if (c.type === 'slot') {
          out.push({
            id: c.id,
            name: c.name || 'Untitled group',
            depth,
            kind: c.slotKind === 'shortlist' ? 'shortlist' : 'group',
          });
          visit(c, depth + 1);
        }
      });
    };
    visit(root, 0);
    return out;
  }, [targetListId, nodeRoots]);

  // Reset slot pick whenever the list changes — a slot id from list A
  // would be meaningless against list B. Skips the FIRST render so the
  // last-used slot id restored from localStorage survives mount; only
  // user-driven list changes clear it.
  const _firstSlotResetRef = _qr(true);
  _qe(() => {
    if (_firstSlotResetRef.current) { _firstSlotResetRef.current = false; return; }
    setTargetSlotId('');
  }, [targetListId]);

  // Validate the restored list id against the loaded lists. If the
  // user deleted the last-used list, fall back to empty so the
  // auto-pick effect runs and grabs the first available list.
  _qe(() => {
    if (!targetListId) return;
    if (!Array.isArray(userLists) && nodeRoots === null) return; // still loading
    const exists =
      (Array.isArray(userLists) && userLists.some(l => l && l.id === targetListId))
      || (Array.isArray(nodeRoots) && nodeRoots.some(r => r && r.id === targetListId));
    if (!exists) {
      setTargetListId('');
      setTargetSlotId('');
    }
  }, [targetListId, userLists, nodeRoots]);

  // Validate the restored slot id against the loaded slots for the
  // current list. If the group was renamed/deleted between visits, we
  // silently drop to "Top of the list" instead of submitting a
  // dangling parentSlotId that would fail at the API.
  _qe(() => {
    if (!targetSlotId) return;
    if (nodeRoots === null) return;     // tree not yet loaded — wait
    if (targetSlots.length === 0) return; // empty list — nothing to validate against
    const exists = targetSlots.some(s => s.id === targetSlotId);
    if (!exists) setTargetSlotId('');
  }, [targetSlotId, targetSlots, nodeRoots]);

  // Effective list options for the dropdown. Defensive fallback: if the
  // parent's `userLists` prop is empty (e.g. the bookmarklet opened the
  // modal before App's hydration effect resolved fetchMyRoots, or the
  // user navigated cold from a non-list route) but our own nodeRoots
  // fetch DID land, derive the options from nodeRoots so the user
  // still sees their lists. This was the cause of the "Now my lists
  // aren't showing in the bookmarklet" report — there was a race
  // window where the modal rendered against an empty userLists.
  const effectiveLists = _qm(() => {
    if (Array.isArray(userLists) && userLists.length > 0) return userLists;
    if (Array.isArray(nodeRoots) && nodeRoots.length > 0) {
      return nodeRoots
        .filter(r => r.isMine !== false)
        .map(r => ({ id: r.id, name: r.name, isMine: r.isMine !== false }));
    }
    return userLists || [];
  }, [userLists, nodeRoots]);

  // Recently-added stream (this session only — feedback that things are landing)
  const [recent, setRecent] = _qs([]);

  // Catalog match suggestion
  const catalogMatch = _qm(() => {
    if (!name) return null;
    return quickFindCatalogMatch({ name, brand });
  }, [name, brand]);

  // After a paste-link fetch resolves and the form populates, move
  // focus to the submit button so the user can Enter/Space to save
  // without scrolling. Skipped when the form starts blank — focusing
  // submit there would steal the URL input's focus.
  _qe(() => {
    if (!fetched) return;
    // Defer to next frame so the form is rendered before we focus.
    const id = requestAnimationFrame(() => {
      if (submitBtnRef.current && !submitBtnRef.current.disabled) {
        submitBtnRef.current.focus({ preventScroll: true });
      }
    });
    return () => cancelAnimationFrame(id);
  }, [fetched && fetched.sourceUrl]); // re-focus whenever a NEW URL lands, not on every field edit

  _qe(() => {
    if (inputRef.current) inputRef.current.focus();
    // Read ?url= from the URL search params — set when the user arrives via
    // the "Add to MagicRascals" bookmarklet on another tab. Auto-fetch.
    try {
      const params = new URLSearchParams(window.location.search);
      const presetUrl = params.get('url');
      if (presetUrl) {
        setUrl(presetUrl);
        // Strip the param from the URL so refreshing doesn't re-trigger
        const clean = window.location.pathname + window.location.hash;
        window.history.replaceState({}, '', clean);
        // Auto-fetch after a tick so React renders the value first
        setTimeout(() => handleFetch(presetUrl), 100);
      }
    } catch {}
  }, []);

  const resetForm = () => {
    setUrl(''); setErr(''); setFetched(null);
    setName(''); setBrand(''); setPrice(''); setImageUrl(''); setDescription('');
    setStatus('loved'); setQuantity(1); setAgeBucket(null); setInRegistry(false);
    setTargetListId(''); setNewListName(''); setCreatingList(false);
    setNewSlotName(''); setCreatingSlot(false);
    setNoteOnAdd('');
  };

  const handleFetch = async (rawUrl) => {
    const u = (rawUrl || url || '').trim();
    if (!u) return;
    setErr('');
    setBusy(true);
    try {
      // deep=1 opts into the server-side AI fallback for materials +
      // certifications when the regex extractor comes up short. Adds
      // ~2-6s when triggered, on top of the standard fetch latency,
      // but only fires when the page genuinely doesn't expose its
      // composition info in any structured way. Worth the wait for
      // the user's "really catch the materials" requirement.
      const resp = await fetch(`/api/og-fetch?url=${encodeURIComponent(u)}&deep=1`);
      const json = await resp.json();
      if (!resp.ok || !json || !json.ok || !json.data) {
        throw new Error(json?.reason || 'Could not read that page. Try a different URL?');
      }
      const d = json.data;
      // Domain hint — infer category before we set fetched so the user
      // sees it in the resulting preview card.
      const hint = categoryHintFromUrl(u);
      // Confidence heuristic — if og-fetch returned neither a price nor an
      // image, the page probably isn't a product page (homepage, article,
      // category listing). We still load the form so the user can clean up
      // and save, but we surface a soft warning.
      //
      // `blocked` is a separate signal — set by og-fetch when the upstream
      // retailer (DJ, Macy's, etc.) served a bot-protection interstitial
      // to our server-side fetch. In that case the URL IS a real product
      // page; we just couldn't read it from a data-center IP. The bookmarklet
      // (which runs in the user's authenticated browser) handles those.
      const lowConfidence = !d.image && (d.price == null || Number.isNaN(Number(d.price)));
      setFetched({ ...d, sourceUrl: u, categoryHint: hint, lowConfidence, blocked: !!d.blocked });
      if (d.title)       setName(d.title);
      if (d.brand)       setBrand(d.brand);
      if (d.price != null && !Number.isNaN(Number(d.price))) setPrice(String(d.price));
      if (d.image)       setImageUrl(d.image);
      if (d.description) setDescription(d.description);
      // Auto-pick the age bucket when the page told us one. Only when
      // the user hasn't already picked one — preserves intent if they
      // edited the field between paste and fetch-land. The picker
      // returns null when the extracted range falls outside our
      // buckets (e.g. adult products), in which case we leave the
      // dropdown untouched.
      if (d.ageMin != null || d.ageMax != null) {
        const guess = pickAgeBucket(
          d.ageMin != null ? Number(d.ageMin) : null,
          d.ageMax != null ? Number(d.ageMax) : null
        );
        if (guess) setAgeBucket((curr) => curr || guess);
      }

      // Catalog growth: if this URL isn't an existing catalog match, queue
      // the extracted data as a `product_submissions` row so an admin can
      // promote it into the public catalog. Fire-and-forget; we don't want
      // submission failures to break the Quick Add flow.
      const localMatch = quickFindCatalogMatch({ name: d.title, brand: d.brand });
      if (!localMatch && window.MR && window.MR.supabase) {
        const session = window.MR.user && window.MR.user._session;
        const userId = session && session.user && session.user.id;
        if (userId) {
          window.MR.supabase.from('product_submissions').insert({
            submitted_by: userId,
            source_url: u,
            extracted: {
              title: d.title || null,
              brand: d.brand || null,
              price: d.price != null ? Number(d.price) : null,
              currency: d.currency || null,
              image: d.image || null,
              description: d.description || null,
              gallery: d.gallery || null,
            },
            matched_product_id: null,
            status: 'pending',
          }).then(({ error }) => {
            if (error && !/duplicate key|unique constraint/i.test(error.message || '')) {
              console.warn('[MR] submission queue failed:', error.message);
            }
          });
        }
      }
    } catch (e) {
      setErr(e.message || 'Could not read that page.');
    } finally {
      setBusy(false);
    }
  };

  // Auto-pick a list when the user hasn't set one and the data has loaded.
  // Prefers (in order): last-used list > last-used slot (re-applied) >
  // first existing list. If `defaultListId` was passed from a list page
  // we already set targetListId in the initial state, so this effect
  // becomes a no-op then.
  _qe(() => {
    if (!fetched) return;
    // Wait for at least one of the two sources to settle before deciding
    // "user has no lists, prompt to create one" — otherwise the
    // bookmarklet race condition would flip into "creating new list"
    // mode the moment the user finished og-fetching, even though their
    // existing lists were just about to arrive.
    const stillLoading = (!userLists || userLists.length === 0) && nodeRoots === null;
    if (stillLoading) return;
    if (!targetListId && effectiveLists.length > 0) {
      // Re-attempt the last-used read now that data has loaded — MR.user
      // is reliably ready by this point, so any race the initial render
      // hit is closed.
      const fresh = _readLastUsed();
      const restoredId = fresh && fresh.listId && effectiveLists.some(l => l.id === fresh.listId)
        ? fresh.listId
        : null;
      if (restoredId) {
        setTargetListId(restoredId);
        // Also re-apply the slot if it still exists in this list.
        if (fresh.slotId) {
          // Defer until targetSlots recomputes for the new list.
          setTimeout(() => setTargetSlotId(fresh.slotId), 0);
        }
      } else {
        setTargetListId(effectiveLists[0].id);
      }
    } else if (!targetListId && effectiveLists.length === 0) {
      setCreatingList(true);
      setNewListName('My list');
    }
  }, [fetched, effectiveLists, nodeRoots]);

  // Resolve the friendly list name for the bottom CTA. Prefers the live
  // list-by-id lookup so renames propagate; falls back to defaultListName
  // (passed when the modal was opened from a list page) so we still
  // render the right name on first paint before userLists settles.
  const _resolvedTargetName = _qm(() => {
    if (!targetListId) return null;
    const found = effectiveLists.find(l => l.id === targetListId);
    return (found && found.name) || (targetListId === defaultListId ? defaultListName : null);
  }, [targetListId, effectiveLists, defaultListId, defaultListName]);

  const handleUseCatalogMatch = () => {
    if (!catalogMatch || !catalogMatch.match) return;
    const m = catalogMatch.match;
    setName(m.name);
    setBrand(m.brand || '');
    if (m.price != null) setPrice(String(m.price));
    if (m.img) setImageUrl(m.img);
    if (m.description || m.why) setDescription(m.description || m.why);
    // Mark on the fetched data so we know it's a catalog product on save.
    setFetched(f => ({ ...(f || {}), catalogId: m.id, catalogMatched: true }));
  };

  // Is the pasted item a catalog match? Loved/Wishlist/Owned shortcuts only
  // make sense for catalog products (they store product IDs). For custom
  // items, those destinations route into a list with the appropriate status.
  const isCatalog = !!(fetched && fetched.catalogMatched && fetched.catalogId);

  const handleSave = async (e) => {
    if (e) e.preventDefault();
    if (!isSignedIn) {
      onRequireAuth();
      return;
    }
    if (!name.trim()) return;

    const productId = isCatalog ? fetched.catalogId : null;
    const product = productId ? (window.PRODUCTS || []).find(p => p.id === productId) : null;
    const bucket = AGE_BUCKETS.find(b => b.value === ageBucket);

    // 1. Update the global status bucket (Loved / Getting / Have). Catalog
    //    items toggle the global ID arrays; custom items feed the parallel
    //    customLoved / customWishlist / customOwned arrays so they surface in
    //    the matching tab of the Collections page.
    //    Internal storage names: lovedItems / wishlistIds (= Getting) / ownedIds (= Have).
    if (isCatalog) {
      if (status === 'loved'   && onAddToLoved)    onAddToLoved(productId);
      if (status === 'getting' && onAddToWishlist) onAddToWishlist(productId);
      if (status === 'have'    && onAddToOwned)    onAddToOwned(productId);
    } else {
      const customPayload = {
        name: name.trim(),
        brand: brand.trim(),
        price: price ? Number(price) : null,
        img: imageUrl,
        notes: description.trim(),
        source_url: fetched ? fetched.sourceUrl : null,
      };
      if (status === 'loved'   && onAddCustomLoved)    onAddCustomLoved(customPayload);
      if (status === 'getting' && onAddCustomWishlist) onAddCustomWishlist(customPayload);
      if (status === 'have'    && onAddCustomOwned)    onAddCustomOwned(customPayload);
    }

    // 2. Optionally also pin it to a named list. Lists are an orthogonal
    //    axis — same item can live in your "Newborn essentials" list and
    //    still carry its global Loved/Getting/Have status.
    let listId = targetListId;
    // Track lists we just created so the tree-aware route below treats
    // them correctly (nodeRoots is a snapshot from mount time and won't
    // know about a list created seconds ago).
    let justCreatedList = null;
    if (creatingList && (newListName || '').trim()) {
      const created = await onCreateList(newListName.trim());
      if (created && created.id) {
        listId = created.id;
        justCreatedList = created;
      }
    }
    if (listId) {
      // Map our 3-state status onto the legacy list_item status enum.
      const listStatus = status === 'have' ? 'have' : status === 'getting' ? 'need' : 'want';
      // Gallery payload: only the user's selected images, in gallery
      // order (which the picker enforces — leftmost selected thumb is
      // gallery[0]). When the user didn't go through the picker
      // (manual entry, photo identify, single-image page) we still
      // surface at least the primary so the detail panel has
      // something to render.
      const orderedGallery = selectedImages.length
        ? [...selectedImages]
        : (imageUrl ? [imageUrl] : []);
      const item = {
        id: 'li_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
        productId,
        custom: !isCatalog ? {
          name: name.trim(),
          brand: brand.trim(),
          image: imageUrl,
          gallery: orderedGallery,
          price: price ? Number(price) : null,
          currency: (fetched && fetched.currency) || 'AUD',
          description: description.trim(),
          source_url: fetched ? fetched.sourceUrl : null,
          // Structured spec fields captured at paste time. These power
          // the new Specs block in the item detail panel and let us
          // filter lists by material / dimension / cert later. All
          // optional — set only when og-fetch surfaced them.
          materials: (fetched && fetched.materials) || null,
          certifications: (fetched && Array.isArray(fetched.certifications)) ? fetched.certifications : [],
          dimensions: (fetched && fetched.dimensions) || null,
          // Country of origin — only the AI extractor surfaces this so
          // far. Useful as a filterable field ("show me only items
          // made in EU"); also shown in the Specs block.
          madeIn: (fetched && fetched.madeIn) || null,
          // 1-4 short feature highlights pulled by the AI extractor.
          // Rendered as a stand-alone "Highlights" chip row in the
          // detail panel so the user gets a structured at-a-glance
          // summary instead of a wall-of-text description.
          features: (fetched && Array.isArray(fetched.features)) ? fetched.features : [],
        } : null,
        status: listStatus,
        quantity_needed: Number(quantity) || 1,
        quantity_have: 0,
        in_registry: status === 'getting' && !!inRegistry,
        age_min: bucket ? bucket.ageMin : null,
        age_max: bucket ? bucket.ageMax : null,
        category: fetched && fetched.categoryHint ? fetched.categoryHint.category : null,
        notes: '',
        added_at: Date.now(),
      };
      // Route the save based on the list's schema:
      //   • Tree-aware lists (live in list_nodes — every list the user
      //     creates today is one of these) → window.MR.nodes path via
      //     onAddItemToNode. parentSlotId is either the picked slot OR
      //     the rootId itself (root is its own parent for top-level
      //     items, mirroring how ListDetailV2's toolbar does it).
      //   • Local-only / legacy lists → onAddItemToList (old schema).
      //
      // Detecting tree-aware: nodeRoots has this id. Previously we
      // only used the tree path when a slot was picked, so any add to
      // the "Top of the list" fell through to the legacy schema and
      // never appeared in ListDetailV2 (which reads the tree only).
      // Tree-aware = list lives in list_nodes. True when nodeRoots has
      // it, OR when we just created it via the tree-aware onCreateList
      // (the new list won't be in nodeRoots until the next mount). The
      // index.html createList wrapper now uses window.MR.nodes.createRoot
      // and stamps `_supabase: true` on the returned list to mark it.
      const isTreeAware =
        (Array.isArray(nodeRoots) && nodeRoots.some(r => r.id === listId))
        || !!(justCreatedList && justCreatedList._supabase);
      // Track the new item's id so we can scroll-and-flash it on the
      // list page after we navigate there. For the tree-aware path the
      // server returns the new node; for the legacy path we already
      // generated the id ourselves above.
      let newItemId = null;
      // Inline subgroup creation: if the user is creating a new group,
      // build it BEFORE the item add so the item can land inside. Skips
      // gracefully when the slot create fails — the item still lands at
      // the top of the list instead of disappearing.
      let resolvedSlotId = targetSlotId;
      if (creatingSlot && (newSlotName || '').trim() && isTreeAware && window.MR && window.MR.nodes && typeof window.MR.nodes.addChild === 'function') {
        try {
          const slotRes = await window.MR.nodes.addChild({
            parentId: listId,
            rootId: listId,
            type: 'slot',
            slotKind: 'group',
            name: newSlotName.trim(),
            quantityNeeded: 0,
          });
          if (slotRes && slotRes.ok && slotRes.node) {
            resolvedSlotId = slotRes.node.id;
          }
        } catch (err) {
          console.warn('[quick-add] slot create failed; falling back to top of list', err);
        }
      }

      if (isTreeAware && onAddItemToNode) {
        // Capture the new node — the wrapper returns it so we can
        // post the optional "as I'm saving it" note as a comment.
        // Fire-and-forget: if the comment fails (e.g. legacy table
        // missing) the item still lands, the user just doesn't see
        // their note. Better than blocking the add on a secondary write.
        const newNode = await onAddItemToNode(listId, resolvedSlotId || listId, {
          productId,
          custom: item.custom,
          name: product ? product.name : name.trim(),
          status: item.status,
          quantityNeeded: item.quantity_needed,
          inRegistry: item.in_registry,
          ageMin: item.age_min,
          ageMax: item.age_max,
          category: item.category,
        });
        newItemId = newNode && newNode.id;
        const noteBody = (noteOnAdd || '').trim();
        if (noteBody && newNode && newNode.id && window.MR && window.MR.nodes && typeof window.MR.nodes.addComment === 'function') {
          try {
            await window.MR.nodes.addComment({ nodeId: newNode.id, rootId: listId, body: noteBody });
          } catch (err) {
            console.warn('[quick-add] note attach failed', err);
          }
        }
      } else {
        // Legacy / local-only lists: dump the note into the item's
        // notes field instead. The legacy schema doesn't have a
        // comments table, so this is the closest thing.
        const noteBody = (noteOnAdd || '').trim();
        if (noteBody) item.notes = noteBody;
        onAddItemToList(listId, item);
        newItemId = item.id;
      }
      // Remember this list + group so the NEXT bookmarklet open
      // defaults to where the user was working. Skipped when the modal
      // was opened from a list page (defaultListId set) — that context
      // is one-shot and shouldn't pollute the user's persistent default.
      if (!defaultListId) _writeLastUsed(listId, resolvedSlotId);

      // Hand the list-detail page a "scroll-to-this-item" signal so when
      // we navigate over it knows where to scroll + which row to flash.
      // sessionStorage instead of a URL param so we don't pollute the
      // shareable list URL with item ids that mean nothing 20 seconds
      // later. The receiver clears the signal as soon as it acts on it.
      try {
        if (newItemId) {
          sessionStorage.setItem('mr-scroll-to-item', JSON.stringify({
            listId,
            itemId: newItemId,
            ts: Date.now(),
          }));
        }
      } catch {}

      // Navigate to the list. The list-detail mount will pick up the
      // sessionStorage signal, find the new item, scroll it into view
      // and flash it. If the user already had this list open behind the
      // modal, the hash assignment is still a state change because the
      // current hash is the QuickAdd route — hashchange fires, the
      // list page remounts/refocuses, and the signal handler runs.
      window.location.hash = 'list2/' + encodeURIComponent(listId);
    }

    // 3. Add a row to the "Just added" feed so the user sees confirmation.
    const listName = listId
      ? (effectiveLists.find(l => l.id === listId) || { name: newListName }).name
      : null;
    setRecent(prev => [{
      id: 'r_' + Date.now() + Math.random().toString(36).slice(2, 4),
      name: product ? product.name : name.trim(),
      brand: product ? product.brand : brand.trim(),
      image: product ? product.img : imageUrl,
      status,                              // 'loved' | 'getting' | 'have'
      quantity,
      listId,
      listName: listName || (status === 'loved' ? 'Loved' : status === 'getting' ? 'Getting' : 'Have'),
    }, ...prev].slice(0, 8));

    resetForm();
    setTimeout(() => { inputRef.current && inputRef.current.focus(); }, 50);
  };

  return (
    <main className="quick-add-page">
      <section className="quick-add-hero">
        <div className="quick-add-eyebrow">Add by link</div>
        <h1 className="quick-add-h">
          Paste a link — we'll do <em><Whimsy text="the rest" /></em>
        </h1>
        <p className="quick-add-sub">
          From any product page on the web. We pull the name, brand, image, price,
          description and dimensions, then drop it into one of your lists with a
          single tap. Works on Lovevery, Pottery Barn, IKEA, John Lewis,
          basically anywhere with a normal product page.
        </p>

        <form
          className="quick-add-form"
          onSubmit={(e) => { e.preventDefault(); handleFetch(); }}
        >
          <input
            ref={inputRef}
            type="url"
            className="quick-add-input"
            placeholder="https://www.lovevery.com/products/…"
            value={url}
            onChange={(e) => setUrl(e.target.value)}
            inputMode="url"
            autoComplete="off"
          />
          <button
            type="submit"
            className="btn quick-add-submit"
            disabled={!url.trim() || busy}
          >
            <span className="btn-row">{busy ? 'Reading…' : 'Read this link'}</span>
            <span className="arrow">→</span>
          </button>
        </form>

        {err && <div className="quick-add-err">{err}</div>}

        <div className="quick-add-or">
          <span className="quick-add-or-line" />
          <span className="quick-add-or-label">or</span>
          <span className="quick-add-or-line" />
        </div>
        {/* Two equal-weight alternate paths: identify by photo, or type it
            in manually. Both land on the same status-pick form below. */}
        <div className="quick-add-alts">
          <button
            type="button"
            className="quick-add-altpath"
            onClick={() => setIdentifyOpen(true)}
          >
            <span className="quick-add-altpath-icon" aria-hidden="true">
              <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7 h4 l2 -2 h6 l2 2 h4 v12 H3z"/><circle cx="12" cy="13" r="4"/></svg>
            </span>
            <span className="quick-add-altpath-body">
              <strong>Identify by photo</strong>
              <span className="quick-add-altpath-sub">Snap any baby thing</span>
            </span>
          </button>
          <button
            type="button"
            className="quick-add-altpath"
            onClick={() => {
              // Empty synthesized "fetched" object — opens the form for manual entry.
              setFetched({ title: '', sourceUrl: null, brand: '', image: '', price: '', description: '', currency: 'AUD' });
            }}
          >
            <span className="quick-add-altpath-icon" aria-hidden="true">
              <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4h10v16H3V4h5"/><path d="M9 8h6"/><path d="M9 12h6"/><path d="M9 16h4"/></svg>
            </span>
            <span className="quick-add-altpath-body">
              <strong>Add it manually</strong>
              <span className="quick-add-altpath-sub">Type the details in</span>
            </span>
          </button>
        </div>
      </section>

      {!fetched && (
        <p className="quick-add-tools-tip">
          ✨ Pro tip — set up the <a onClick={() => { window.location.hash = 'settings'; window.location.search = '?section=bookmarklet'; }} className="quick-add-tools-tip-link">browser bookmarklet</a> and add from any tab in one click.
        </p>
      )}

      <IdentifyByPhotoModal
        open={identifyOpen}
        onClose={() => setIdentifyOpen(false)}
        onIdentified={({ identification, imageUrl, catalogMatch: cm }) => {
          // Stash the extracted data into the same flow as og-fetch so the
          // user can review + tweak + save.
          const synth = {
            title: identification.name,
            brand: identification.brand,
            description: identification.description,
            image: imageUrl,
            currency: 'AUD',
            sourceUrl: '',
            categoryHint: identification.category ? {
              category: identification.category,
              label: identification.category.charAt(0).toUpperCase() + identification.category.slice(1),
            } : null,
            catalogMatched: !!cm,
            catalogId: cm ? cm.id : null,
          };
          setFetched(synth);
          if (identification.name)   setName(identification.name);
          if (identification.brand)  setBrand(identification.brand);
          if (identification.description) setDescription(identification.description);
          if (imageUrl)              setImageUrl(imageUrl);
          setIdentifyOpen(false);
        }}
      />

      {fetched && (
        <section className="quick-add-result">
          {(fetched.lowConfidence || fetched.blocked) && fetched.sourceUrl && (
            <div className="quick-add-warning">
              <span className="quick-add-warning-icon" aria-hidden="true">
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01"/><circle cx="12" cy="12" r="9"/></svg>
              </span>
              <span>
                {fetched.blocked ? (
                  <>
                    <strong>This retailer blocks automated fetches.</strong> Amazon, David Jones, Macy's, eBay,
                    Walmart and similar use bot protection that returns a challenge page instead of the
                    product. Easiest fix: open the page in a new tab, then use the <strong>"Save to Magic
                    Rascals" bookmarklet</strong> (in Settings) — it runs in your browser, so it sees the real
                    page. Or fill in the details below by hand.
                  </>
                ) : (
                  <>
                    <strong>This doesn't look like a product page.</strong> We couldn't find a price or image.
                    Either fill in the details below manually, or <a onClick={() => { setFetched(null); resetForm(); inputRef.current && inputRef.current.focus(); }} className="quick-add-warning-link">try a different URL</a>.
                  </>
                )}
              </span>
            </div>
          )}
          <div className="quick-add-card">
            <div className="quick-add-card-img">
              {imageUrl
                ? <SmartImage src={imageUrl} alt={name} fallbackLabel={brand || name} />
                : <div className="quick-add-card-imgempty">No image</div>}
            </div>
            <div className="quick-add-card-body">
              {/* Brand — was display-only, now inline-editable. Empty
                  field is fine on save; the small ghost-input styling
                  keeps the kicker feel when there's a real value. */}
              <input
                className="quick-add-card-brand quick-add-card-brand--edit"
                value={brand}
                onChange={(e) => setBrand(e.target.value)}
                placeholder="Brand (optional)"
                aria-label="Brand"
              />
              <input
                className="quick-add-card-name"
                value={name}
                onChange={(e) => setName(e.target.value)}
                placeholder="Product name"
              />
              {/* Price — inline-editable. Tightly-styled number input
                  with the currency prefix as an adornment, paired with
                  the source link on the same row so the card meta layout
                  is preserved. inputMode="decimal" so phones bring up
                  the number pad. */}
              <div className="quick-add-card-meta">
                <span className="quick-add-card-pricewrap">
                  <span className="quick-add-card-priceprefix" aria-hidden="true">
                    {(fetched.currency === 'AUD' || !fetched.currency) ? 'A$' : '$'}
                  </span>
                  <input
                    className="quick-add-card-priceinput"
                    value={price}
                    onChange={(e) => {
                      // Allow decimal numbers only — strip anything else
                      // so paste-with-symbols ("$45.99") still works.
                      const cleaned = e.target.value.replace(/[^\d.]/g, '');
                      // Single decimal point cap.
                      const parts = cleaned.split('.');
                      const sanitized = parts.length > 1
                        ? parts[0] + '.' + parts.slice(1).join('').slice(0, 2)
                        : cleaned;
                      setPrice(sanitized);
                    }}
                    placeholder="0"
                    inputMode="decimal"
                    aria-label="Price"
                  />
                </span>
                {(() => {
                  const safe = window.MR && window.MR.safeUrl ? window.MR.safeUrl(fetched.sourceUrl) : null;
                  return safe ? (
                    <a className="quick-add-card-source" href={safe} target="_blank" rel="noopener noreferrer">Source ↗</a>
                  ) : null;
                })()}
              </div>
              {/* Description — full textarea, auto-grows up to ~6 lines.
                  Replaces the old static <p> that truncated at 220 chars
                  and gave no way to fix typos / tighten what the
                  retailer dumped in. */}
              <textarea
                className="quick-add-card-desc quick-add-card-desc--edit"
                value={description}
                onChange={(e) => setDescription(e.target.value)}
                placeholder="Description (optional) — anything worth remembering about this one"
                rows={3}
                aria-label="Description"
              />
              {/* Extracted structured specs preview. Surfaces what the
                  deep-extractor pulled (materials, dimensions, country
                  of origin, certifications, feature highlights) so the
                  user can SEE the data was captured. Same data lands
                  on the saved item — this is just the visible signal
                  that the deep-fetch worked. Each section hides itself
                  when the field is empty. */}
              {fetched && (() => {
                const hasMaterials  = !!fetched.materials;
                const hasMadeIn     = !!fetched.madeIn;
                const dims = fetched.dimensions || {};
                const dimKeys = ['size','width','height','depth','diameter','length','weight'].filter(k => dims[k]);
                const hasDims       = dimKeys.length > 0;
                const certs = Array.isArray(fetched.certifications) ? fetched.certifications : [];
                const hasCerts      = certs.length > 0;
                const features = Array.isArray(fetched.features) ? fetched.features : [];
                const hasFeatures   = features.length > 0;
                if (!hasMaterials && !hasMadeIn && !hasDims && !hasCerts && !hasFeatures) return null;
                const CERT_LABELS = {
                  'gots':'GOTS organic','oeko-tex':'OEKO-TEX','organic':'Organic',
                  'recycled':'Recycled','fairtrade':'Fair Trade','bluesign':'bluesign',
                  'fsc':'FSC','b-corp':'B-Corp','cradle-to-cradle':'C2C','vegan':'Vegan',
                  'rds':'RDS down','rws':'RWS wool','pfas-free':'PFAS-free',
                  'phthalate-free':'Phthalate-free','bpa-free':'BPA-free',
                };
                // Split multi-component materials lines into rows.
                const compRe = /^(Shell|Outer|Lining|Filling|Fill|Stuffing|Inner|Body fabric|Body material|Cover|Outer material|Lining material|Material|Composition)\s*:\s*(.+)$/i;
                const matRows = (() => {
                  if (!hasMaterials) return [];
                  const segs = String(fetched.materials)
                    .split(/\s*[;|]\s+(?=[A-Z])/)
                    .map(s => s.trim()).filter(Boolean);
                  const parsed = segs.map(s => {
                    const m = s.match(compRe);
                    return m ? { label: m[1], value: m[2].trim() } : { label: null, value: s };
                  });
                  if (parsed.length > 1 && parsed.every(p => p.label)) {
                    const cap = s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
                    return parsed.map(p => ({ label: cap(p.label), value: p.value }));
                  }
                  return [{ label: 'Materials', value: fetched.materials }];
                })();
                const DIM_LABEL = {
                  size: 'Size', width: 'Width', height: 'Height', depth: 'Depth',
                  diameter: 'Diameter', length: 'Length', weight: 'Weight',
                };
                return (
                  <div className="quick-add-specs">
                    {hasFeatures && (
                      <ul className="quick-add-spec-features">
                        {features.map((f, i) => (
                          <li key={i}>
                            <span className="quick-add-spec-tick" aria-hidden="true">
                              <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12l5 5L20 7"/></svg>
                            </span>
                            <span>{f}</span>
                          </li>
                        ))}
                      </ul>
                    )}
                    {(matRows.length > 0 || hasDims || hasMadeIn) && (
                      <dl className="quick-add-spec-rows">
                        {matRows.map((r, i) => (
                          <div key={'m'+i} className="quick-add-spec-row">
                            <dt>{r.label}</dt><dd>{r.value}</dd>
                          </div>
                        ))}
                        {hasDims && dimKeys.map(k => (
                          <div key={'d'+k} className="quick-add-spec-row">
                            <dt>{DIM_LABEL[k]}</dt><dd>{dims[k]}</dd>
                          </div>
                        ))}
                        {hasMadeIn && (
                          <div className="quick-add-spec-row">
                            <dt>Made in</dt><dd>{fetched.madeIn}</dd>
                          </div>
                        )}
                      </dl>
                    )}
                    {hasCerts && (
                      <div className="quick-add-spec-certs">
                        {certs.map(t => (
                          <span key={t} className="quick-add-spec-cert">
                            <span className="quick-add-spec-tick" aria-hidden="true">
                              <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12l5 5L20 7"/></svg>
                            </span>
                            {CERT_LABELS[t] || t}
                          </span>
                        ))}
                      </div>
                    )}
                  </div>
                );
              })()}
              {fetched.categoryHint && (
                <div className="quick-add-cat-hint">
                  <span className="quick-add-cat-hint-dot" />
                  Looks like <strong>{fetched.categoryHint.label}</strong> — saved with this category.
                </div>
              )}
            </div>
          </div>

          {/* Multi-image picker. og-fetch returns up to 12 deduped
              candidates from JSON-LD, OG/Twitter meta, Shopify
              __NEXT_DATA__ blobs, and visible <img> tags. When the
              page's OG image is unhelpful (the site logo, a marketing
              banner) the right product photo is almost always somewhere
              in this list. Multi-select: click a thumb to toggle it
              in/out of the saved gallery. Position 1 is always the
              primary that shows on the card preview AND becomes
              gallery[0]. Reorder is purely by gallery position — the
              leftmost selected thumb is primary — so behaviour stays
              predictable when "Select all" is used. Hidden entirely
              when the page only exposed one image. */}
          {Array.isArray(fetched.gallery) && fetched.gallery.length > 1 && (() => {
            const gal = fetched.gallery;
            const selectedCount = selectedImages.length;
            const allSelected = selectedCount === gal.length;
            const toggleOne = (src) => {
              setSelectedImages(prev => {
                const setOf = new Set(prev);
                if (setOf.has(src)) setOf.delete(src); else setOf.add(src);
                // Re-sort by gallery order so the primary picker is
                // deterministic: it's whichever selected thumb comes
                // first in the gallery, regardless of click order.
                return gal.filter(u => setOf.has(u));
              });
            };
            const selectAll = () => setSelectedImages([...gal]);
            const clearAll = () => setSelectedImages([]);
            return (
              <div className="quick-add-imgpicker">
                <div className="quick-add-imgpicker-head">
                  <div className="quick-add-imgpicker-label">
                    {selectedCount === 0
                      ? `No images selected — pick one to save`
                      : selectedCount === 1
                        ? `1 image selected (it'll show on the card)`
                        : `${selectedCount} images selected · first one shows on the card`}
                  </div>
                  <div className="quick-add-imgpicker-actions">
                    <button
                      type="button"
                      className="quick-add-imgpicker-action"
                      onClick={allSelected ? clearAll : selectAll}
                    >
                      {allSelected ? 'Clear' : 'Select all'}
                    </button>
                  </div>
                </div>
                {/* Floating bigger-image preview. Anchored to the picker
                    container (bottom: 100%) so it floats above the row
                    without getting clipped by the modal's overflow.
                    Pointer-events: none so it never steals hover from
                    the thumbnail underneath. */}
                {previewImg && (
                  <div className="quick-add-imgpicker-preview" aria-hidden="true">
                    <img src={previewImg} alt="" />
                  </div>
                )}
                <div className="quick-add-imgpicker-row" role="group" aria-label="Choose product images">
                  {gal.map((src) => {
                    const idx = selectedImages.indexOf(src);
                    const isPicked = idx >= 0;
                    const isPrimary = idx === 0;
                    return (
                      <button
                        key={src}
                        type="button"
                        role="checkbox"
                        aria-checked={isPicked}
                        aria-label={isPrimary ? 'Primary image — click to remove' : (isPicked ? `Image ${idx + 1} of selection — click to remove` : 'Add this image to selection')}
                        className={
                          'quick-add-imgpicker-thumb'
                          + (isPicked ? ' is-picked' : '')
                          + (isPrimary ? ' is-primary' : '')
                        }
                        onClick={() => toggleOne(src)}
                        onMouseEnter={() => setPreviewImg(src)}
                        onMouseLeave={() => setPreviewImg(p => p === src ? null : p)}
                        onFocus={() => setPreviewImg(src)}
                        onBlur={() => setPreviewImg(p => p === src ? null : p)}
                      >
                        <img src={src} alt="" loading="lazy" />
                        {isPicked && (
                          <span className="quick-add-imgpicker-badge" aria-hidden="true">
                            {isPrimary ? '★' : idx + 1}
                          </span>
                        )}
                      </button>
                    );
                  })}
                </div>
              </div>
            );
          })()}

          {/* Catalog match suggestion */}
          {catalogMatch && !fetched.catalogMatched && (
            <div className="quick-add-match">
              <div className="quick-add-match-thumb">
                {catalogMatch.match.img && <SmartImage src={catalogMatch.match.img} alt="" fallbackLabel={catalogMatch.match.brand} />}
              </div>
              <div className="quick-add-match-body">
                <div className="quick-add-match-eyebrow">Already in our catalog</div>
                <div className="quick-add-match-name">{catalogMatch.match.brand} · {catalogMatch.match.name}</div>
                <div className="quick-add-match-sub">Using the catalog version gets you our hi-res images, retailer links, and full specs.</div>
              </div>
              <button type="button" className="btn" style={{ width: 'auto', padding: '8px 14px', flexShrink: 0 }} onClick={handleUseCatalogMatch}>
                <span>Use this</span>
              </button>
            </div>
          )}

          <form className="quick-add-fields" onSubmit={handleSave}>

            {/* The one and only axis: what's this item to you? */}
            <div className="quick-add-field">
              <span className="quick-add-lbl">What is this to you?</span>
              <div className="quick-add-statuses" role="radiogroup" aria-label="Status">
                {ITEM_STATUSES.map(s => (
                  <button
                    key={s.value}
                    type="button"
                    role="radio"
                    aria-checked={status === s.value}
                    title={s.sub}
                    className={`quick-add-status quick-add-status--${s.value}${status === s.value ? ' is-on' : ''}`}
                    onClick={() => setStatus(s.value)}
                  >
                    <span className="quick-add-status-icon">{s.icon}</span>
                    <span className="quick-add-status-label">{s.label}</span>
                    <span className="quick-add-status-sub">{s.sub}</span>
                  </button>
                ))}
              </div>
            </div>

            {/* Quantity + Brand row — quantity is only meaningful when you're
                actually buying or already own the thing. Loved skips it. */}
            <div className="quick-add-field quick-add-field--row">
              {status !== 'loved' && (
                <div style={{ flex: 1 }}>
                  <span className="quick-add-lbl">{status === 'have' ? 'Quantity you have' : 'Quantity needed'}</span>
                  <div className="quick-add-qtyrow">
                    <button type="button" className="quick-add-qty-btn" onClick={() => setQuantity(q => Math.max(1, Number(q) - 1))}>−</button>
                    <input
                      type="number" min="1" step="1"
                      className="quick-add-qty-input"
                      value={quantity}
                      onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value || '1', 10)))}
                    />
                    <button type="button" className="quick-add-qty-btn" onClick={() => setQuantity(q => Number(q) + 1)}>+</button>
                  </div>
                </div>
              )}
              <div style={{ flex: 1 }}>
                <span className="quick-add-lbl">Brand</span>
                <input
                  type="text"
                  className="quick-add-text"
                  value={brand}
                  onChange={(e) => setBrand(e.target.value)}
                  placeholder="Optional"
                />
              </div>
            </div>

            <div className="quick-add-field">
              <span className="quick-add-lbl">Age range <em className="quick-add-opt">(optional)</em></span>
              <PillRow
                value={ageBucket}
                options={[{ value: null, label: 'Any age' }, ...AGE_BUCKETS]}
                onChange={setAgeBucket}
                ariaLabel="Age range"
              />
            </div>

            {/* Optional list assignment. Orthogonal to status — an item can
                be Loved AND on the "Newborn essentials" list. */}
            <div className="quick-add-field">
              <span className="quick-add-lbl">Also save to a list <em className="quick-add-opt">(optional)</em></span>
              {creatingList ? (
                <div className="quick-add-qtyrow">
                  <input
                    type="text"
                    className="quick-add-text"
                    value={newListName}
                    onChange={(e) => setNewListName(e.target.value)}
                    placeholder="Name this list…"
                    style={{ flex: 1 }}
                    autoFocus
                  />
                  <button
                    type="button"
                    className="quick-add-qty-btn"
                    onClick={() => { setCreatingList(false); setNewListName(''); }}
                    style={{ width: 'auto', padding: '0 14px' }}
                  >
                    Cancel
                  </button>
                </div>
              ) : (
                <div className="quick-add-qtyrow">
                  <select
                    className="quick-add-text"
                    value={targetListId}
                    onChange={(e) => setTargetListId(e.target.value)}
                    style={{ flex: 1 }}
                  >
                    <option value="">No list — just save</option>
                    {effectiveLists.map(l => (
                      <option key={l.id} value={l.id}>{l.name}</option>
                    ))}
                  </select>
                  <button
                    type="button"
                    className="quick-add-qty-btn"
                    onClick={() => { setCreatingList(true); setNewListName(''); }}
                    style={{ width: 'auto', padding: '0 14px' }}
                  >
                    + New
                  </button>
                </div>
              )}
              {/* Sub-target picker — shows the groups / shortlists inside
                  the chosen list. Hidden during new-list creation (no
                  slots can exist yet). Shows the "+ New" inline create
                  affordance whenever a real list is selected, so users
                  can spin up a brand-new subgroup without leaving the
                  bookmarklet flow.
                  Indented options visually convey nesting. */}
              {!creatingList && targetListId && (
                creatingSlot ? (
                  <div className="quick-add-qtyrow" style={{ marginTop: 8 }}>
                    <input
                      type="text"
                      className="quick-add-text"
                      value={newSlotName}
                      onChange={(e) => setNewSlotName(e.target.value)}
                      placeholder="New group name — e.g. Doudou / Books / Sleepwear…"
                      style={{ flex: 1 }}
                      autoFocus
                      onKeyDown={(e) => {
                        // Esc cancels the inline create without losing the
                        // user's other form state.
                        if (e.key === 'Escape') {
                          e.preventDefault();
                          setCreatingSlot(false); setNewSlotName('');
                        }
                      }}
                    />
                    <button
                      type="button"
                      className="quick-add-qty-btn"
                      onClick={() => { setCreatingSlot(false); setNewSlotName(''); }}
                      style={{ width: 'auto', padding: '0 14px' }}
                    >
                      Cancel
                    </button>
                  </div>
                ) : targetSlots.length > 0 ? (
                  <div className="quick-add-qtyrow" style={{ marginTop: 8 }}>
                    <select
                      className="quick-add-text"
                      value={targetSlotId}
                      onChange={(e) => setTargetSlotId(e.target.value)}
                      style={{ flex: 1 }}
                      title="Pick a specific group inside this list"
                    >
                      <option value="">↳ Top of the list (no group)</option>
                      {targetSlots.map(s => (
                        <option key={s.id} value={s.id}>
                          {'　'.repeat(s.depth)}↳ {s.name}{s.kind === 'shortlist' ? '  (shortlist)' : ''}
                        </option>
                      ))}
                    </select>
                    <button
                      type="button"
                      className="quick-add-qty-btn"
                      onClick={() => { setCreatingSlot(true); setNewSlotName(''); setTargetSlotId(''); }}
                      title="Create a new group inside this list"
                      style={{ width: 'auto', padding: '0 14px' }}
                    >
                      + New
                    </button>
                  </div>
                ) : (
                  // Empty list — no slots yet. Show a quiet inline button
                  // so the user can still spin up the first group.
                  <button
                    type="button"
                    className="quick-add-slot-newcta"
                    onClick={() => { setCreatingSlot(true); setNewSlotName(''); }}
                    style={{ marginTop: 8 }}
                  >
                    + Create a group in this list
                  </button>
                )
              )}
            </div>

            {/* Registry only makes sense for things you're actively buying. */}
            {status === 'getting' && (
              <label className="quick-add-checkrow">
                <input
                  type="checkbox"
                  checked={inRegistry}
                  onChange={(e) => setInRegistry(e.target.checked)}
                />
                <span>
                  <strong>Also add to a registry</strong> — visible to anyone you share the registry link with.
                </span>
              </label>
            )}

            {/* Optional comment captured "as I'm saving it". Posted as
                the first comment on the new item once it lands. Skipped
                when blank. Compact placeholder copy nudges the user
                toward useful context without demanding it. */}
            <div className="quick-add-field">
              <span className="quick-add-lbl">Add a note <em className="quick-add-opt">(optional)</em></span>
              <textarea
                className="quick-add-text quick-add-note"
                value={noteOnAdd}
                onChange={(e) => setNoteOnAdd(e.target.value)}
                placeholder="Why this one? Tip for your partner? Anything you want to remember…"
                rows={2}
                maxLength={500}
              />
            </div>

            <div className="quick-add-actions">
              <button type="button" className="btn btn-ghost" onClick={resetForm} style={{ width: 'auto' }}>
                <span>Start over</span>
              </button>
              <button ref={submitBtnRef} type="submit" className="btn" disabled={!name.trim() || (creatingList && !newListName.trim()) || (creatingSlot && !newSlotName.trim())} style={{ flex: 1 }}>
                <span className="btn-row">
                  {!isSignedIn ? 'Sign in to save' :
                    // ORDER MATTERS. "Creating a new list" must win over the
                    // resolved-target-name fallback — otherwise clicking
                    // "+ New" while a previous list was selected leaves the
                    // button reading "Add to <old list>" while the user is
                    // typing a new list name in the input next to it, and
                    // the submit silently saves to the OLD list.
                    creatingList ? (
                      newListName.trim()
                        ? `Create "${newListName.trim()}" and add`
                        : 'Name the new list to continue'
                    ) :
                    // Otherwise, when a real list target is selected (modal
                    // was opened from a list page, or the user picked one),
                    // name the destination honestly. Old "Save to Loved"
                    // copy was a relic of the global-buckets flow and
                    // confused users mid-list.
                    _resolvedTargetName ? `Add to ${_resolvedTargetName}` :
                    status === 'loved'   ? 'Save to Loved' :
                    status === 'getting' ? "Let's get it" :
                                           'Mark as owned'}
                </span>
                <span className="arrow">→</span>
              </button>
            </div>
          </form>
        </section>
      )}

      {recent.length > 0 && (
        <section className="quick-add-recent">
          <h3 className="quick-add-recent-h">Just added</h3>
          <ul className="quick-add-recent-list">
            {recent.map(r => (
              <li key={r.id} className="quick-add-recent-item">
                <div className="quick-add-recent-thumb">
                  {r.image
                    ? <SmartImage src={r.image} alt={r.name} fallbackLabel={r.brand || r.name} />
                    : <span className="quick-add-card-imgempty">—</span>}
                </div>
                <div className="quick-add-recent-body">
                  <div className="quick-add-recent-brand">{r.brand || '—'}</div>
                  <div className="quick-add-recent-name">{r.name}</div>
                  <div className="quick-add-recent-meta">
                    <span className={`quick-add-status-pill quick-add-status-pill--${r.status}`}>{
                      r.status === 'loved' ? 'Loved' :
                      r.status === 'getting' ? 'Getting' :
                      r.status === 'have' ? 'Have' :
                      r.status
                    }</span>
                    {r.quantity > 1 && <span className="quick-add-recent-qty">×{r.quantity}</span>}
                    <span className="quick-add-recent-list-name">→ {r.listName}</span>
                  </div>
                </div>
              </li>
            ))}
          </ul>
          <div className="quick-add-recent-actions">
            <button
              type="button"
              className="btn btn-ghost"
              style={{ width: 'auto' }}
              onClick={() => onNavigate('lists')}
            >
              <span>See all my lists</span>
              <span className="arrow">→</span>
            </button>
            {onDone && (
              <button
                type="button"
                className="btn"
                style={{ width: 'auto' }}
                onClick={onDone}
              >
                <span>Done</span>
              </button>
            )}
          </div>
        </section>
      )}
    </main>
  );
}

window.QuickAddPage = QuickAddPage;
