// AI Sidebar — general-purpose assistant drawer.
//
// Features:
//   • General Q&A, product recs, list creation — model decides per-turn
//   • Markdown rendering (bold, italic, code, bullets) for AI text
//   • Inline product links <<p:id>>Name<<endp>> → clickable, opens detail
//   • Follow-up suggestion chips after every AI reply (one-tap to continue)
//   • Voice input (Web Speech API) — mic button next to send
//   • Thumbs up/down feedback on AI replies, stored locally
//   • "Add to list" picker — save productSuggestions or list to any existing list
//   • Page-aware context (current route / list)
//   • Long-term memory facts (persisted; AI can suggest additions)
//   • Conversation history + search
//   • Auto-title conversations

(function () {
  const e = React.createElement;
  const { useState, useEffect, useRef, useCallback, useMemo } = React;

  // ── localStorage keys (user-scoped) ────────────────────────────────────
  // SECURITY: each key is scoped by the current signed-in user_id (or
  // ':guest' when signed out) so chat history, "what I know about you"
  // memory, and per-response feedback NEVER leak across accounts on a
  // shared browser. This was the bug behind "I just signed up and see
  // another user's chats in the sidebar."
  //
  // We treat any unscoped legacy key (mr-ai-conversations, etc.) as
  // contaminated data and wipe it on first load after this fix ships
  // (gated by LS_LEGACY_WIPED so we only do it once per browser).
  const LS_LIST_BASE     = 'mr-ai-conversations';
  const LS_CURRENT_BASE  = 'mr-ai-current-id';
  const LS_MEMORY_BASE   = 'mr-ai-memory';
  const LS_FEEDBACK_BASE = 'mr-ai-feedback';
  const LS_LEGACY_WIPED  = 'mr-ai-legacy-wiped-v1';
  const MAX_CONVERSATIONS = 50;

  // Builds the storage key for a given namespace + user. Signed-out users
  // get their own ':guest' bucket — also isolated from anyone's signed-in
  // data, so a logged-out demo session doesn't bleed into an account that
  // later signs in.
  function k(base, userId) {
    return base + ':' + (userId || 'guest');
  }

  // One-time wipe of any pre-fix unscoped chat / memory / feedback data
  // sitting in localStorage. Removes the exact four legacy keys (and
  // nothing else) so a returning user sees an empty AI history rather
  // than someone else's. Safe to call multiple times — gated by a
  // sentinel that's set after the first successful wipe.
  function wipeLegacyAiKeysOnce() {
    try {
      if (localStorage.getItem(LS_LEGACY_WIPED) === '1') return;
      [LS_LIST_BASE, LS_CURRENT_BASE, LS_MEMORY_BASE, LS_FEEDBACK_BASE].forEach(key => {
        try { localStorage.removeItem(key); } catch {}
      });
      localStorage.setItem(LS_LEGACY_WIPED, '1');
    } catch {}
  }

  function loadConversations(userId) {
    try { const r = localStorage.getItem(k(LS_LIST_BASE, userId)); const a = r ? JSON.parse(r) : []; return Array.isArray(a) ? a : []; }
    catch { return []; }
  }
  function saveConversations(arr, userId) {
    try { localStorage.setItem(k(LS_LIST_BASE, userId), JSON.stringify(arr.slice(0, MAX_CONVERSATIONS))); } catch {}
  }
  function getCurrentId(userId) {
    try { return localStorage.getItem(k(LS_CURRENT_BASE, userId)); } catch { return null; }
  }
  function setCurrentId(id, userId) {
    try {
      if (id == null) localStorage.removeItem(k(LS_CURRENT_BASE, userId));
      else localStorage.setItem(k(LS_CURRENT_BASE, userId), id);
    } catch {}
  }
  function loadMemory(userId) {
    try { const r = localStorage.getItem(k(LS_MEMORY_BASE, userId)); const a = r ? JSON.parse(r) : []; return Array.isArray(a) ? a : []; }
    catch { return []; }
  }
  function saveMemory(arr, userId) {
    try { localStorage.setItem(k(LS_MEMORY_BASE, userId), JSON.stringify(arr.slice(0, 60))); } catch {}
  }
  function loadFeedback(userId) {
    try { const r = localStorage.getItem(k(LS_FEEDBACK_BASE, userId)); const o = r ? JSON.parse(r) : {}; return (o && typeof o === 'object') ? o : {}; }
    catch { return {}; }
  }
  function saveFeedback(obj, userId) {
    try { localStorage.setItem(k(LS_FEEDBACK_BASE, userId), JSON.stringify(obj)); } catch {}
  }

  // Read the current signed-in user_id synchronously from MR.user so the
  // very first render of the sidebar reads from the correct bucket. Falls
  // back to null (which keys into ':guest') for signed-out callers.
  function currentUserId() {
    try {
      const cur = window.MR && window.MR.user && typeof window.MR.user.current === 'function'
        ? window.MR.user.current()
        : null;
      return (cur && cur.session && cur.session.user && cur.session.user.id) || null;
    } catch { return null; }
  }

  // Wipe legacy keys at module-load time. Runs once per browser regardless
  // of whether the sidebar ever mounts, so even users who never open the
  // AI panel still get the cleanup. (No-op if the sentinel is already set
  // or if localStorage is unavailable.)
  wipeLegacyAiKeysOnce();

  function newId() { return 'c_' + Math.random().toString(36).slice(2, 10); }
  function titleFor(conv) {
    if (conv.title) return conv.title;
    const first = (conv.history || []).find(h => h.role === 'user');
    if (!first) return 'New conversation';
    const t = (first.content || '').trim();
    return t.length > 60 ? t.slice(0, 57) + '…' : (t || 'New conversation');
  }
  function timeAgo(ts) {
    if (!ts) return '';
    const s = Math.floor((Date.now() - ts) / 1000);
    if (s < 60)    return 'just now';
    if (s < 3600)  return Math.floor(s / 60)    + 'm ago';
    if (s < 86400) return Math.floor(s / 3600)  + 'h ago';
    const d = Math.floor(s / 86400);
    if (d < 7)  return d + 'd ago';
    return new Date(ts).toLocaleDateString();
  }

  // ── Page-aware context ──────────────────────────────────────────────────
  // Surfaces what page the user is on as a natural-language hint, plus
  // the specific list they're viewing if any.
  async function readPageContext(productMap) {
    let hint = '';
    try {
      const hash = (location.hash || '').replace(/^#/, '');
      const listMatch = hash.match(/^list\/([\w-]+)/);
      if (listMatch) {
        // Try to resolve the list name from the loaded tree
        if (window.MR && window.MR.nodes && typeof window.MR.nodes.fetchMyRoots === 'function') {
          const res = await window.MR.nodes.fetchMyRoots();
          const root = res && res.ok && Array.isArray(res.roots)
            ? res.roots.find(r => r.id === listMatch[1])
            : null;
          if (root) {
            const items = [];
            const walk = n => (n.children || []).forEach(c => {
              if (c.type === 'item') {
                const p = c.productId && productMap ? productMap[c.productId] : null;
                const name = (p && p.name) || (c.custom && c.custom.name) || c.name;
                if (name) items.push(name);
              } else walk(c);
            });
            walk(root);
            hint = `User is viewing their list "${root.name}". It contains ${items.length} items` +
              (items.length ? `: ${items.slice(0, 12).join(', ')}.` : '.');
          }
        }
      } else if (hash.startsWith('discover')) {
        hint = 'User is browsing the Discover page (catalog of curated products).';
      } else if (hash.startsWith('collections')) {
        hint = 'User is on Collections (their Loved / Getting / Have lists).';
      } else if (hash.startsWith('lists')) {
        hint = 'User is on their Lists overview.';
      } else if (hash.startsWith('lists-library')) {
        hint = 'User is on the Lists Library (templates to adopt).';
      } else if (!hash) {
        hint = 'User is on the home / Discover page.';
      }
    } catch {}
    return hint;
  }

  // ── User context collection ─────────────────────────────────────────────
  async function collectUserContext(productMap) {
    const ctx = { currentDate: new Date().toISOString().slice(0, 10) };
    // Read from the active user's scoped buckets — otherwise we'd send
    // the OG user's kids/loved/fit-prefs into a fresh account's chat
    // context. Same leak vector as the chat history; same fix.
    const _uid = currentUserId();
    const _bucket = _uid || 'guest';
    try {
      const raw = localStorage.getItem('parentstack-v1:' + _bucket);
      if (raw) {
        const ps = JSON.parse(raw) || {};
        const profile = ps.profile || {};
        if (Array.isArray(profile.kids) && profile.kids.length) {
          ctx.kids = profile.kids.map(k => ({
            name: k.name, birthday: k.birthday,
            interests: k.interests || [], notes: k.notes || '',
          }));
        }
        const lovedIds  = Array.isArray(ps.saved) ? ps.saved : [];
        const lovedCust = Array.isArray(ps.customLovedItems) ? ps.customLovedItems : [];
        const lovedNames = [
          ...lovedIds.map(id => (productMap && productMap[id]) ? `${productMap[id].brand || ''} ${productMap[id].name || ''}`.trim() : null).filter(Boolean),
          ...lovedCust.map(x => `${x.brand || ''} ${x.name || ''}`.trim()).filter(Boolean),
        ];
        if (lovedNames.length) ctx.loved = lovedNames.slice(0, 20);
      }
    } catch {}
    try {
      const fitRaw = localStorage.getItem('magicrascals-fit-prefs:' + _bucket);
      if (fitRaw) ctx.fit = JSON.parse(fitRaw) || {};
    } catch {}
    try {
      if (window.MR && window.MR.nodes && typeof window.MR.nodes.fetchMyRoots === 'function') {
        const res = await window.MR.nodes.fetchMyRoots();
        if (res && res.ok && Array.isArray(res.roots)) {
          ctx.lists = res.roots.slice(0, 10).map(r => {
            const samples = []; let itemCount = 0;
            const walk = (n) => {
              (n.children || []).forEach(c => {
                if (c.type === 'item') {
                  itemCount++;
                  if (samples.length < 6) {
                    const p = c.productId && productMap ? productMap[c.productId] : null;
                    const name = (p && p.name) || (c.custom && c.custom.name) || c.name;
                    if (name) samples.push(name);
                  }
                } else walk(c);
              });
            };
            walk(r);
            return { name: r.name || 'Untitled list', kind: r.kind || 'private', itemCount, sampleItems: samples };
          });
        }
      }
    } catch {}
    return ctx;
  }

  // ── Catalog packing ─────────────────────────────────────────────────────
  function packCatalog(products) {
    return (products || [])
      .filter(p => p && p.id && p.name)
      .slice(0, 400)
      .map(p => ({
        id:          p.id,
        brand:       p.brand || '',
        name:        p.name || '',
        price:       p.price != null ? Number(p.price) : null,
        category:    p.category || p.subcategory || '',
        ageMin:      p.ageMin != null ? p.ageMin : null,
        ageMax:      p.ageMax != null ? p.ageMax : null,
        description: (p.description || '').slice(0, 140),
      }));
  }

  // Get the Supabase session token so the server can verify Pro access.
  async function getAuthToken() {
    try {
      const c = window.MR && window.MR.supabase;
      if (!c) return '';
      const { data } = await c.auth.getSession();
      return (data && data.session && data.session.access_token) || '';
    } catch { return ''; }
  }

  // ── Non-streaming API call (used by What's-missing, empty-state, etc.) ─
  async function callAI({ messages, products, context, pageContext, memory }) {
    const token = await getAuthToken();
    const headers = { 'Content-Type': 'application/json' };
    if (token) headers['Authorization'] = 'Bearer ' + token;
    const r = await fetch('/api/ai-list', {
      method: 'POST',
      headers,
      body: JSON.stringify({ messages, catalog: packCatalog(products), context, pageContext, memory }),
    });
    let body = null;
    try { body = await r.json(); } catch { body = { ok: false, error: `Server returned ${r.status}` }; }
    // Friendlier message for 429s (rate limit) so it doesn't look like
    // a generic error.
    if (r.status === 429 && body && !body.ok) {
      body.rateLimited = true;
      const retry = body.retryAfter || Number(r.headers.get('Retry-After')) || 30;
      body.error = `Slow down — too many requests. Try again in ${retry}s.`;
    }
    return body || { ok: false, error: 'Empty response' };
  }

  // ── Streaming API call (used by the chat) ───────────────────────────────
  // onTextDelta(delta): called as text streams in
  // onReplaceText(t):   called if the server salvages the text (e.g. the
  //                     model emitted JSON; client should overwrite what
  //                     it streamed with this clean text)
  // onComplete(reply):  called with the full validated reply at the end
  // onError(msg):       called on any error
  async function streamAI({ messages, products, context, pageContext, memory, onTextDelta, onReplaceText, onComplete, onError }) {
    let resp;
    const token = await getAuthToken();
    const headers = { 'Content-Type': 'application/json' };
    if (token) headers['Authorization'] = 'Bearer ' + token;
    try {
      resp = await fetch('/api/ai-list', {
        method: 'POST',
        headers,
        body: JSON.stringify({
          messages, catalog: packCatalog(products), context, pageContext, memory,
          stream: true,
        }),
      });
    } catch (e) {
      onError && onError('Network error: ' + (e.message || e));
      return;
    }
    if (!resp.ok || !resp.body) {
      // Try to read a JSON error body. 429s carry a friendly retry hint.
      let errMsg = `Server returned ${resp.status}`;
      try {
        const body = await resp.json();
        if (resp.status === 429) {
          const retry = (body && body.retryAfter) || Number(resp.headers.get('Retry-After')) || 30;
          errMsg = `Slow down — too many requests. Try again in ${retry}s.`;
        } else if (body && body.error) {
          errMsg = body.error;
        }
      } catch {}
      onError && onError(errMsg);
      return;
    }
    const reader = resp.body.getReader();
    const decoder = new TextDecoder();
    let buf = '';
    let finalReply = null;
    let sawError = null;
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buf += decoder.decode(value, { stream: true });
      let nl;
      while ((nl = buf.indexOf('\n\n')) !== -1) {
        const evt = buf.slice(0, nl);
        buf = buf.slice(nl + 2);
        const lines = evt.split('\n');
        for (const ln of lines) {
          if (!ln.startsWith('data: ')) continue;
          const data = ln.slice(6);
          let obj;
          try { obj = JSON.parse(data); } catch { continue; }
          if (obj.type === 'text' && typeof obj.delta === 'string') {
            onTextDelta && onTextDelta(obj.delta);
          } else if (obj.type === 'replace_text' && typeof obj.text === 'string') {
            onReplaceText && onReplaceText(obj.text);
          } else if (obj.type === 'complete' && obj.reply) {
            finalReply = obj.reply;
          } else if (obj.type === 'error') {
            sawError = obj.error || 'Unknown error';
          }
        }
      }
    }
    if (sawError) { onError && onError(sawError); return; }
    if (finalReply) { onComplete && onComplete(finalReply); return; }
    onError && onError('Stream ended without a complete event');
  }

  // ── List builder ────────────────────────────────────────────────────────
  async function buildListFromSpec(spec) {
    if (!window.MR || !window.MR.nodes) return { ok: false, error: 'Data layer not ready' };
    const rootRes = await window.MR.nodes.createRoot({
      name: spec.name || 'New list', kind: 'private',
      description: spec.description || '',
    });
    if (!rootRes.ok) return { ok: false, error: rootRes.reason || 'Could not create list' };
    const root = rootRes.root;
    const insertChildren = async (parentId, items) => {
      for (let i = 0; i < items.length; i++) {
        const c = items[i] || {};
        if (c.kind === 'item') {
          if (c.productId) {
            await window.MR.nodes.addChild({
              parentId, rootId: root.id, type: 'item',
              productId: c.productId, name: c.name || '', position: i,
              picked: !!c.picked, status: c.status || 'want',
            });
          } else {
            await window.MR.nodes.addChild({
              parentId, rootId: root.id, type: 'item',
              name: c.name || 'Item', position: i,
              custom: {
                name: c.name || 'Item', brand: c.brand || '',
                price: c.price != null ? Number(c.price) : null, currency: 'AUD',
                description: c.description || '', placeholder: true,
              },
              picked: !!c.picked, status: c.status || 'want',
            });
          }
        } else {
          const inferredQty = c.kind === 'shortlist'
            ? (Number.isFinite(c.quantityNeeded) && c.quantityNeeded > 0 ? Math.floor(c.quantityNeeded) : 1)
            : 0;
          const r = await window.MR.nodes.addChild({
            parentId, rootId: root.id, type: 'slot',
            name: c.name || 'Group',
            slotKind: c.kind === 'shortlist' ? 'shortlist' : 'group',
            position: i, blurb: c.blurb || null,
            quantityNeeded: inferredQty,
          });
          if (r.ok && Array.isArray(c.children) && c.children.length) {
            await insertChildren(r.node.id, c.children);
          }
        }
      }
    };
    if (Array.isArray(spec.children)) await insertChildren(root.id, spec.children);
    return { ok: true, root };
  }

  // Add the AI's suggested products to an existing list at the root level.
  async function addProductsToList(rootId, productIds) {
    if (!window.MR || !window.MR.nodes) return { ok: false, error: 'Data layer not ready' };
    for (let i = 0; i < productIds.length; i++) {
      await window.MR.nodes.addChild({
        parentId: rootId, rootId, type: 'item',
        productId: productIds[i], position: 999 + i,
      });
    }
    return { ok: true };
  }

  function specStats(spec) {
    let items = 0, matched = 0, placeholders = 0, groups = 0, shortlists = 0;
    const walk = (arr) => (arr || []).forEach(c => {
      if (!c) return;
      if (c.kind === 'item') { items++; if (c.productId) matched++; else placeholders++; }
      else if (c.kind === 'group') { groups++; walk(c.children); }
      else if (c.kind === 'shortlist') { shortlists++; walk(c.children); }
    });
    walk(spec.children);
    return { items, matched, placeholders, groups, shortlists };
  }

  // ── Markdown renderer ───────────────────────────────────────────────────
  // Tiny custom parser. Supports paragraphs (split on \n\n), bold/italic/
  // code spans, bullet lists ("- " at line start), and inline product
  // links (<<p:id>>Name<<endp>>). Outputs React children — no innerHTML.
  function renderMarkdown(text, productMap, openProduct) {
    if (!text) return null;
    const blocks = text.split(/\n{2,}/);
    return blocks.map((block, bi) => {
      const lines = block.split('\n');
      // Bullet list block? (all lines start with - )
      if (lines.length && lines.every(l => /^\s*[-•]\s+/.test(l))) {
        return e('ul', { key: bi, className: 'ai-md-ul' },
          lines.map((l, li) => e('li', { key: li }, renderInline(l.replace(/^\s*[-•]\s+/, ''), productMap, openProduct, `${bi}-${li}`)))
        );
      }
      // Paragraph
      return e('p', { key: bi, className: 'ai-md-p' }, renderInline(block, productMap, openProduct, bi));
    });
  }

  function renderInline(text, productMap, openProduct, keyPrefix) {
    // Tokenize for: <<p:id>>Label<<endp>>, **bold**, *italic*, `code`, [link](url)
    const out = [];
    let rest = text;
    let i = 0;
    const PATTERNS = [
      { re: /^<<p:([^>]+?)>>(.*?)<<endp>>/,            type: 'product' },
      { re: /^\*\*([^*]+)\*\*/,                        type: 'bold' },
      { re: /^\*([^*\n]+)\*/,                          type: 'italic' },
      { re: /^`([^`\n]+)`/,                            type: 'code' },
      { re: /^\[([^\]]+)\]\(([^)]+)\)/,                type: 'link' },
    ];
    while (rest.length) {
      let matched = null;
      for (const pat of PATTERNS) {
        const m = rest.match(pat.re);
        if (m) { matched = { ...pat, match: m }; break; }
      }
      if (matched) {
        const m = matched.match;
        if (matched.type === 'product') {
          const id = m[1], label = m[2];
          const p = productMap && productMap[id];
          if (p) {
            out.push(e('button', {
              key: `${keyPrefix}-p${i++}`,
              type: 'button',
              className: 'ai-md-prod',
              onClick: () => openProduct(id),
              title: `${p.brand || ''} ${p.name || ''}`.trim(),
            }, label || p.name));
          } else {
            // Hallucinated id — render as plain text
            out.push(label || '');
          }
        } else if (matched.type === 'bold')  out.push(e('strong', { key: `${keyPrefix}-b${i++}` }, m[1]));
        else if (matched.type === 'italic') out.push(e('em',     { key: `${keyPrefix}-i${i++}` }, m[1]));
        else if (matched.type === 'code')   out.push(e('code',   { key: `${keyPrefix}-c${i++}` }, m[1]));
        else if (matched.type === 'link')   {
          // Sanitise the href to block javascript:/data:/vbscript: schemes.
          // If safeUrl rejects, render as plain text so we don't lose the
          // visible label.
          const href = window.MR && window.MR.safeUrl ? window.MR.safeUrl(m[2]) : null;
          if (href) out.push(e('a', { key: `${keyPrefix}-l${i++}`, href, target: '_blank', rel: 'noopener noreferrer' }, m[1]));
          else out.push(m[1]);
        }
        rest = rest.slice(m[0].length);
      } else {
        // Plain text — take chars up to the next inline marker (or end)
        const nextSpecial = rest.search(/[<*`\[]/);
        const chunk = nextSpecial === -1 ? rest : rest.slice(0, Math.max(1, nextSpecial));
        out.push(chunk);
        rest = rest.slice(chunk.length);
      }
    }
    return out;
  }

  // ── Voice input (Web Speech API) ────────────────────────────────────────
  function getSpeechRecognition() {
    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
    if (!SR) return null;
    try {
      const r = new SR();
      r.continuous = false;
      r.interimResults = true;
      r.lang = navigator.language || 'en-US';
      return r;
    } catch { return null; }
  }

  // ── Examples ────────────────────────────────────────────────────────────
  const EXAMPLES = [
    "What's the best stroller for the beach?",
    "Activities for a rainy day with a 3-year-old",
    "Compare 3 good bottles for breastfed babies",
    "Create a list of first-3-months baby clothes",
    "What am I missing for my hospital bag?",
    "Suggest 5 books for my toddler",
  ];

  // ── Sidebar component ───────────────────────────────────────────────────
  function AISidebar({ products, navigate, gated }) {
    const [open, setOpen] = useState(false);
    const [view, setView] = useState('chat'); // 'chat' | 'history' | 'memory'
    const [prompt, setPrompt] = useState('');
    // Track the active user_id so every persisted-state lookup is scoped
    // to the right bucket. Re-reads when the auth state changes via the
    // MR.user.subscribe effect below.
    const [userId, setUserId] = useState(() => currentUserId());
    const [conversations, setConversations] = useState(() => loadConversations(userId));
    const [currentId, setCurrent] = useState(() => getCurrentId(userId));
    const [memory, setMemory] = useState(() => loadMemory(userId));
    const [feedback, setFeedback] = useState(() => loadFeedback(userId));
    const [busy, setBusy] = useState(false);
    const [historyQ, setHistoryQ] = useState('');
    const [voiceListening, setVoiceListening] = useState(false);
    // Per-message UI states: which bubble has the "Add to list" picker open
    const [pickerOpenFor, setPickerOpenFor] = useState(null);
    const [myLists, setMyLists] = useState([]);

    const inputRef  = useRef(null);
    const scrollRef = useRef(null);
    const speechRef = useRef(null);

    const productMap = useMemo(() => {
      const m = {};
      (products || []).forEach(p => { if (p && p.id) m[p.id] = p; });
      return m;
    }, [products]);

    const currentConv = conversations.find(c => c.id === currentId) || null;
    const history = (currentConv && currentConv.history) || [];

    // Refresh list of user's lists whenever the drawer opens or someone
    // creates a new list — used by the "Add to list" picker.
    useEffect(() => {
      if (!open) return;
      (async () => {
        try {
          if (window.MR && window.MR.nodes && typeof window.MR.nodes.fetchMyRoots === 'function') {
            const res = await window.MR.nodes.fetchMyRoots();
            if (res && res.ok && Array.isArray(res.roots)) setMyLists(res.roots);
          }
        } catch {}
      })();
    }, [open, conversations.length]);

    // ── Auth-aware state reload ──────────────────────────────────────────
    // Subscribe to auth state so when the user signs in, signs out, or
    // switches accounts, the sidebar reads from the new user-scoped LS
    // bucket and discards in-memory state from the previous user. This is
    // the OTHER half of the leak fix: scoping the keys would still leak
    // if we kept reading stale state across an auth flip.
    useEffect(() => {
      if (!window.MR || !window.MR.user || typeof window.MR.user.subscribe !== 'function') return;
      const unsub = window.MR.user.subscribe((state) => {
        const nextId = (state && state.session && state.session.user && state.session.user.id) || null;
        setUserId(prev => {
          if (prev === nextId) return prev;
          // Auth flipped — reload every piece of persisted state from the
          // new bucket. The view also resets to the chat tab so we don't
          // strand the user on the History panel of the previous account.
          setConversations(loadConversations(nextId));
          setCurrent(getCurrentId(nextId));
          setMemory(loadMemory(nextId));
          setFeedback(loadFeedback(nextId));
          setView('chat');
          setPrompt('');
          setHistoryQ('');
          return nextId;
        });
      });
      return unsub;
    }, []);

    useEffect(() => {
      const onKey = (ev) => {
        if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 'j') {
          ev.preventDefault();
          setOpen(o => !o);
        }
        if (ev.key === 'Escape' && open) setOpen(false);
      };
      window.addEventListener('keydown', onKey);
      return () => window.removeEventListener('keydown', onKey);
    }, [open]);

    useEffect(() => {
      if (open && view === 'chat' && inputRef.current) {
        setTimeout(() => inputRef.current && inputRef.current.focus(), 80);
      }
    }, [open, view, currentId]);

    useEffect(() => {
      if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }, [history.length, busy, view]);

    // Expose an imperative API so other parts of the app can drive the
    // sidebar (e.g. "What's missing?" buttons on the list page open the
    // sidebar and pre-send a question). Re-registers whenever `send` is
    // re-created so the latest closure is always used.
    useEffect(() => {
      if (!window.MR) window.MR = {};
      window.MR.aiAssistant = {
        open: () => setOpen(true),
        close: () => setOpen(false),
        ask: (prompt) => {
          setOpen(true);
          setView('chat');
          // send() pulls the latest history via updateHistory's
          // setConversations callback; calling it directly here will
          // append to whatever chat is currently active.
          send(String(prompt || ''));
        },
      };
    }, [send]);

    // ── Conversation mutators ─────────────────────────────────────────────
    const updateHistory = useCallback((updater) => {
      setConversations(prev => {
        let id = currentId;
        let list = prev.slice();
        let conv = list.find(c => c.id === id);
        if (!conv) {
          id = newId();
          conv = { id, startedAt: Date.now(), updatedAt: Date.now(), history: [], title: null };
          list = [conv, ...list];
          setCurrent(id);
          setCurrentId(id, userId);
        }
        const nextHistory = typeof updater === 'function' ? updater(conv.history) : updater;
        // Auto-title once we have at least 1 user + 1 AI message and no title set
        let nextTitle = conv.title;
        if (!nextTitle && nextHistory.length >= 2) {
          const firstUser = nextHistory.find(h => h.role === 'user');
          if (firstUser) {
            const t = (firstUser.content || '').trim();
            nextTitle = t.length > 48 ? t.slice(0, 45) + '…' : t;
          }
        }
        list = list.map(c => c.id === id
          ? { ...c, history: nextHistory, updatedAt: Date.now(), title: nextTitle }
          : c
        );
        list.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
        saveConversations(list, userId);
        return list;
      });
    }, [currentId, userId]);

    const startNewChat = useCallback(() => {
      if (currentConv && currentConv.history.length === 0) { setView('chat'); return; }
      setCurrent(null);
      setCurrentId(null, userId);
      setView('chat');
      setPrompt('');
    }, [currentConv, userId]);

    const openConversation = useCallback((id) => {
      setCurrent(id);
      setCurrentId(id, userId);
      setView('chat');
    }, []);

    const deleteConversation = useCallback((id) => {
      setConversations(prev => {
        const next = prev.filter(c => c.id !== id);
        saveConversations(next, userId);
        return next;
      });
      if (id === currentId) {
        setCurrent(null);
        setCurrentId(null, userId);
      }
    }, [currentId, userId]);

    // ── Memory ────────────────────────────────────────────────────────────
    const acceptMemory = useCallback((newFacts) => {
      setMemory(prev => {
        const next = [...prev];
        newFacts.forEach(f => {
          const t = String(f || '').trim();
          if (t && !next.some(x => x.toLowerCase() === t.toLowerCase())) next.push(t);
        });
        saveMemory(next, userId);
        return next;
      });
    }, [userId]);
    const removeMemory = useCallback((idx) => {
      setMemory(prev => {
        const next = prev.slice();
        next.splice(idx, 1);
        saveMemory(next, userId);
        return next;
      });
    }, [userId]);
    const dismissMemorySuggestion = useCallback((bubbleIdx) => {
      updateHistory(h => h.map((x, i) => i === bubbleIdx ? { ...x, memoryUpdates: null } : x));
    }, [updateHistory]);

    // ── Feedback (thumbs) ─────────────────────────────────────────────────
    const setBubbleVote = useCallback((bubbleIdx, vote) => {
      const key = (currentId || 'unknown') + ':' + bubbleIdx;
      setFeedback(prev => {
        const next = { ...prev };
        if (next[key] === vote) delete next[key]; else next[key] = vote;
        saveFeedback(next, userId);
        return next;
      });
    }, [currentId, userId]);

    // ── Send prompt (streaming) ───────────────────────────────────────────
    // Adds the user msg + empty AI bubble. As text deltas arrive we look up
    // the AI bubble BY POSITION (last entry in history) rather than by a
    // captured index, since StrictMode in dev can double-run updaters and
    // shift indexes around.
    const send = useCallback(async (text) => {
      const q = (text || '').trim();
      if (!q || busy) return;
      setBusy(true);
      updateHistory(h => [...h, { role: 'user', content: q }, {
        role: 'ai', content: '', isStreaming: true,
      }]);
      setPrompt('');

      const sendable = [...history, { role: 'user', content: q }].map(h => ({
        role: h.role === 'ai' ? 'assistant' : h.role,
        content: h.content || '',
        list: h.list || null,
        productSuggestions: h.productSuggestions || null,
      }));
      const ctx = await collectUserContext(productMap);
      const pageContext = await readPageContext(productMap);

      // Find the AI bubble by position — it's ALWAYS the last entry we
      // just appended. This is more robust than capturing an index.
      const patchAIBubble = (patch) => {
        updateHistory(h => h.map((x, i) =>
          i === h.length - 1 && x.role === 'ai' ? { ...x, ...patch } : x
        ));
      };
      const appendToAIBubble = (delta) => {
        updateHistory(h => h.map((x, i) =>
          i === h.length - 1 && x.role === 'ai'
            ? { ...x, content: (x.content || '') + delta }
            : x
        ));
      };

      try {
        await streamAI({
          messages: sendable, products, context: ctx, pageContext, memory,
          onTextDelta: appendToAIBubble,
          onReplaceText: (text) => patchAIBubble({ content: text }),
          onComplete: (reply) => {
            patchAIBubble({
              isStreaming: false,
              content: reply.text || '',
              list: reply.list || null,
              productSuggestions: reply.productSuggestions || null,
              followUps: reply.followUps || null,
              memoryUpdates: reply.memoryUpdates || null,
              safetyNote: reply.safetyNote || null,
              listStatus: reply.list ? 'preview' : null,
            });
          },
          onError: (err) => {
            patchAIBubble({
              isStreaming: false, status: 'error',
              content: err || 'Something went wrong. Try again?',
            });
          },
        });
      } catch (ex) {
        patchAIBubble({
          isStreaming: false, status: 'error',
          content: 'Network error: ' + (ex.message || ex),
        });
      }
      setBusy(false);
    }, [busy, products, productMap, history, memory, updateHistory]);

    const createList = useCallback(async (idx) => {
      const entry = history[idx];
      if (!entry || !entry.list) return;
      updateHistory(h => h.map((x, i) => i === idx ? { ...x, listStatus: 'creating' } : x));
      const run = async () => {
        const result = await buildListFromSpec(entry.list);
        if (result.ok && result.root) {
          updateHistory(h => h.map((x, i) => i === idx ? { ...x, listStatus: 'created', createdRootId: result.root.id } : x));
          setTimeout(() => {
            try { location.hash = '#list/' + result.root.id; } catch {}
            setOpen(false);
          }, 600);
        } else {
          updateHistory(h => h.map((x, i) => i === idx ? { ...x, listStatus: 'preview', error: result.error || 'Failed' } : x));
        }
      };
      if (typeof gated === 'function') gated(run)();
      else run();
    }, [history, gated, updateHistory]);

    const discardList = useCallback((idx) => {
      updateHistory(h => h.map((x, i) => i === idx ? { ...x, listStatus: 'discarded' } : x));
    }, [updateHistory]);

    const addSuggestionsToList = useCallback(async (bubbleIdx, rootId) => {
      const entry = history[bubbleIdx];
      const ids = (entry && entry.productSuggestions) || [];
      if (!rootId || !ids.length) return;
      const run = async () => {
        const r = await addProductsToList(rootId, ids);
        if (r.ok) {
          updateHistory(h => h.map((x, i) => i === bubbleIdx ? { ...x, addedToListId: rootId } : x));
          setPickerOpenFor(null);
        }
      };
      if (typeof gated === 'function') gated(run)();
      else run();
    }, [history, gated, updateHistory]);

    const openProduct = useCallback((productId) => {
      try {
        const url = new URL(location.href);
        url.searchParams.set('p', productId);
        history.replaceState && history.replaceState(null, '', url.toString());
        window.dispatchEvent(new PopStateEvent('popstate'));
      } catch {}
    }, []);

    // ── Voice input ───────────────────────────────────────────────────────
    const toggleVoice = useCallback(() => {
      if (voiceListening && speechRef.current) {
        try { speechRef.current.stop(); } catch {}
        return;
      }
      const sr = getSpeechRecognition();
      if (!sr) {
        alert("Voice input isn't supported in this browser. Try Chrome on desktop or Safari on iOS.");
        return;
      }
      speechRef.current = sr;
      sr.onstart  = () => setVoiceListening(true);
      sr.onend    = () => { setVoiceListening(false); speechRef.current = null; };
      sr.onerror  = () => { setVoiceListening(false); speechRef.current = null; };
      sr.onresult = (ev) => {
        let interim = '', finalT = '';
        for (let i = 0; i < ev.results.length; i++) {
          const r = ev.results[i];
          if (r.isFinal) finalT += r[0].transcript;
          else interim += r[0].transcript;
        }
        setPrompt(prev => (finalT || interim).trim());
      };
      try { sr.start(); } catch {}
    }, [voiceListening]);

    const onKeyDown = (ev) => {
      if (ev.key === 'Enter' && !ev.shiftKey) {
        ev.preventDefault();
        send(prompt);
      }
    };

    const showExamples = view === 'chat' && history.length === 0 && !busy;

    // Pro gate — read once per render. The ProGate component itself
    // refreshes status on focus, so we don't need separate polling here.
    const proStatus = window.ProGate && window.ProGate.useStatus
      ? window.ProGate.useStatus().status
      : 'approved';
    const isPro = proStatus === 'approved';

    return e(React.Fragment, null,
      !open && e('button', {
        className: 'ai-fab', type: 'button',
        onClick: () => setOpen(true),
        title: 'AI assistant (⌘J)', 'aria-label': 'Open AI assistant',
      },
        e('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
          e('path', { d: 'M12 2 L13.8 8.2 L20 10 L13.8 11.8 L12 18 L10.2 11.8 L4 10 L10.2 8.2 Z' }),
          e('path', { d: 'M18 16 L19 19 L22 20 L19 21 L18 24 L17 21 L14 20 L17 19 Z', opacity: 0.65 })
        ),
        e('span', { className: 'ai-fab-label' }, 'Ask AI')
      ),

      e('div', { className: 'ai-sidebar' + (open ? ' is-open' : ''), 'aria-hidden': !open },
        e('div', { className: 'ai-sidebar-head' },
          e('div', { className: 'ai-sidebar-head-title' },
            e('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
              e('path', { d: 'M12 2 L13.8 8.2 L20 10 L13.8 11.8 L12 18 L10.2 11.8 L4 10 L10.2 8.2 Z' })
            ),
            e('span', null, view === 'history' ? 'Past chats' : view === 'memory' ? 'What I know about you' : 'AI Assistant')
          ),
          e('div', { className: 'ai-sidebar-head-actions' },
            view === 'chat' && e('button', {
              type: 'button', className: 'ai-iconbtn',
              onClick: () => setView('memory'), title: 'What I know about you',
              'aria-label': 'Memory',
            },
              e('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
                e('circle', { cx: 12, cy: 12, r: 9 }),
                e('path', { d: 'M9 11 L11 13 L15 9' })
              )
            ),
            view === 'chat' && e('button', {
              type: 'button', className: 'ai-iconbtn',
              onClick: () => setView('history'), title: 'Past chats',
              'aria-label': 'Past chats',
            },
              e('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
                e('circle', { cx: 12, cy: 12, r: 9 }),
                e('path', { d: 'M12 7 L12 12 L15.5 13.5' })
              )
            ),
            view === 'chat' && e('button', {
              type: 'button', className: 'ai-iconbtn',
              onClick: startNewChat, title: 'New chat', 'aria-label': 'New chat',
            },
              e('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
                e('path', { d: 'M12 5 L12 19 M5 12 L19 12' })
              )
            ),
            view !== 'chat' && e('button', {
              type: 'button', className: 'ai-iconbtn',
              onClick: () => setView('chat'), title: 'Back to chat', 'aria-label': 'Back',
            },
              e('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
                e('path', { d: 'M15 18 L9 12 L15 6' })
              )
            ),
            e('button', {
              type: 'button', className: 'ai-iconbtn',
              onClick: () => setOpen(false), 'aria-label': 'Close', title: 'Close',
            },
              e('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' },
                e('path', { d: 'M6 6 L18 18 M18 6 L6 18' })
              )
            )
          )
        ),

        e('div', { className: 'ai-sidebar-body', ref: scrollRef },
          // Pro gate — when the user isn't approved, show the upgrade
          // panel in place of the chat surface. History + Memory views
          // still work so the user can browse past chats they had before
          // restrictions kicked in.
          (!isPro && view === 'chat') ? e(window.ProGate.UpgradePanel, {
            status: proStatus,
            onRefresh: () => window.MR.pro && window.MR.pro.getMyStatus(),
            compact: true,
          })
          : view === 'history' ? renderHistoryView(conversations, currentId, openConversation, deleteConversation, startNewChat, historyQ, setHistoryQ)
          : view === 'memory' ? renderMemoryView(memory, removeMemory)
          : showExamples ? e('div', { className: 'ai-sidebar-empty' },
              e('h3', null, 'How can I help?'),
              e('p', null, 'Ask me anything — product recs, comparisons, activity ideas, or "create a list of …". I know about your kids, your lists, what you\'ve loved, and the page you\'re on, so I can tailor my answers.'),
              e('div', { className: 'ai-sidebar-examples' },
                EXAMPLES.map((ex, i) =>
                  e('button', {
                    key: i, type: 'button', className: 'ai-sidebar-example',
                    onClick: () => send(ex),
                  }, ex)
                )
              )
            )
          : null,

          view === 'chat' && history.map((entry, i) => renderBubble(entry, i, {
            productMap, openProduct, createList, discardList, setPrompt, inputRef,
            myLists, addSuggestionsToList, pickerOpenFor, setPickerOpenFor,
            send, feedback, setBubbleVote, currentId,
            acceptMemory, dismissMemorySuggestion,
          })),

          // No standalone thinking indicator — the streaming AI bubble
          // shows it inside itself while empty, so we only need one.
        ),

        view === 'chat' && isPro && e('div', { className: 'ai-sidebar-input' },
          e('div', { className: 'ai-input-wrap' + (voiceListening ? ' is-listening' : '') },
            e('textarea', {
              ref: inputRef,
              value: prompt,
              onChange: (ev) => setPrompt(ev.target.value),
              onKeyDown,
              placeholder: voiceListening ? 'Listening…' : 'Ask me anything…',
              rows: 2,
              disabled: busy,
            }),
            e('div', { className: 'ai-input-actions' },
              getSpeechRecognition() && e('button', {
                type: 'button',
                className: 'ai-mic' + (voiceListening ? ' is-on' : ''),
                onClick: toggleVoice,
                title: voiceListening ? 'Stop' : 'Voice input',
                'aria-label': 'Voice input',
              },
                e('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
                  e('rect', { x: 9, y: 3, width: 6, height: 12, rx: 3 }),
                  e('path', { d: 'M5 11 a7 7 0 0014 0 M12 18 L12 21' })
                )
              ),
              e('button', {
                type: 'button', className: 'ai-send',
                onClick: () => send(prompt),
                disabled: busy || !prompt.trim(),
                'aria-label': 'Send', title: 'Send (Enter)',
              },
                e('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'currentColor' },
                  e('path', { d: 'M3 11 L21 3 L13 21 L11 13 L3 11 Z' })
                )
              )
            )
          ),
          e('div', { className: 'ai-sidebar-input-hint' }, 'Enter to send · ⇧Enter for newline · Esc to close')
        )
      ),
      open && e('div', { className: 'ai-sidebar-scrim', onClick: () => setOpen(false), 'aria-hidden': true })
    );
  }

  // ── Bubble renderer ─────────────────────────────────────────────────────
  function renderBubble(entry, i, h) {
    const {
      productMap, openProduct, createList, discardList, setPrompt,
      myLists, addSuggestionsToList, pickerOpenFor, setPickerOpenFor,
      send, feedback, setBubbleVote, currentId,
      acceptMemory, dismissMemorySuggestion,
    } = h;

    if (entry.role === 'user') {
      return e('div', { key: i, className: 'ai-bubble ai-bubble--user' },
        e('div', { className: 'ai-bubble-text' }, entry.content)
      );
    }

    if (entry.status === 'error') {
      return e('div', { key: i, className: 'ai-bubble ai-bubble--ai ai-bubble--error' },
        e('div', { className: 'ai-bubble-text' }, '⚠ ' + entry.content)
      );
    }

    const children = [];

    if (entry.content) {
      children.push(e('div', { key: 't', className: 'ai-bubble-text ai-md' },
        renderMarkdown(entry.content, productMap, openProduct),
        // While streaming, a blinking caret sits at the end of the text
        // signalling content is still arriving.
        entry.isStreaming && e('span', { key: 'caret', className: 'ai-streaming-caret', 'aria-hidden': true })
      ));
    } else if (entry.isStreaming) {
      // Bubble exists but no text yet — the server may be buffering a
      // JSON-wrapped response. Show the thinking dots INSIDE this bubble
      // so the UI doesn't look frozen.
      children.push(e('div', { key: 't', className: 'ai-bubble-text ai-thinking' },
        e('span', { className: 'ai-thinking-dot' }), e('span', { className: 'ai-thinking-dot' }), e('span', { className: 'ai-thinking-dot' }),
        e('span', { style: { marginLeft: 8 } }, 'Thinking…')
      ));
    }

    // Safety note
    if (entry.safetyNote) {
      children.push(e('div', { key: 'sf', className: 'ai-safety' },
        e('svg', { width: 12, height: 12, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
          e('circle', { cx: 12, cy: 12, r: 9 }),
          e('path', { d: 'M12 8 L12 13 M12 16.2 L12 16.21', strokeWidth: 2.4 })
        ),
        e('span', null, entry.safetyNote)
      ));
    }

    // Product suggestion cards
    if (Array.isArray(entry.productSuggestions) && entry.productSuggestions.length > 0) {
      const cards = entry.productSuggestions.map(id => productMap[id]).filter(Boolean);
      if (cards.length > 0) {
        children.push(e('div', { key: 'p', className: 'ai-suggestions' },
          e('div', { className: 'ai-suggestions-head' },
            e('span', { className: 'ai-suggestions-label' }, cards.length === 1 ? 'Suggested' : `${cards.length} suggestions`),
            entry.addedToListId
              ? e('span', { className: 'ai-suggestions-added' }, '✓ Added to list')
              : myLists && myLists.length > 0 && e('div', { className: 'ai-pickerwrap' },
                  e('button', {
                    type: 'button', className: 'ai-link-btn',
                    // "Add also" reads naturally when the AI has surfaced
                    // suggestions during an ongoing conversation — the
                    // user thinks "yes, add THAT one too", not "save to
                    // a list" (which implies starting fresh).
                    onClick: () => setPickerOpenFor(pickerOpenFor === i ? null : i),
                  }, '+ Add also'),
                  pickerOpenFor === i && e('div', { className: 'ai-picker' },
                    myLists.slice(0, 12).map(l => e('button', {
                      key: l.id, type: 'button', className: 'ai-picker-opt',
                      onClick: () => addSuggestionsToList(i, l.id),
                    }, l.name || 'Untitled'))
                  )
                ),
          ),
          e('div', { className: 'ai-suggestions-grid' },
            cards.map(p => e('button', {
              key: p.id, type: 'button', className: 'ai-suggestion',
              onClick: () => openProduct(p.id),
              title: 'Open product details — or drag onto a list',
              // HTML5 drag so the user can pull a suggestion straight
              // onto the list tree (page-list-detail-v2.jsx listens for
              // 'application/x-mr-product' drops at the .list2-tree
              // level and routes the dropped productId through addItem).
              draggable: true,
              onDragStart: (ev) => {
                try {
                  const payload = JSON.stringify({ productId: p.id, name: p.name, brand: p.brand || '' });
                  ev.dataTransfer.setData('application/x-mr-product', payload);
                  ev.dataTransfer.setData('text/plain', `${p.brand || ''} ${p.name}`.trim());
                  ev.dataTransfer.effectAllowed = 'copy';
                } catch {}
              },
            },
              e('div', { className: 'ai-suggestion-thumb' },
                p.img ? e('img', { src: p.img, alt: '' }) : e('div', { className: 'ai-suggestion-empty' }, '—')
              ),
              e('div', { className: 'ai-suggestion-meta' },
                p.brand && e('div', { className: 'ai-suggestion-brand' }, p.brand),
                e('div', { className: 'ai-suggestion-name' }, p.name),
                p.price != null && e('div', { className: 'ai-suggestion-price' }, 'A$' + p.price),
              )
            ))
          )
        ));
      }
    }

    // List preview
    if (entry.list && entry.list.name) {
      const status = entry.listStatus || 'preview';
      const s = specStats(entry.list);
      children.push(e('div', { key: 'l', className: 'ai-listpreview' },
        e('div', { className: 'ai-listpreview-title' }, entry.list.name),
        entry.list.description && e('div', { className: 'ai-listpreview-desc' }, entry.list.description),
        e('div', { className: 'ai-listpreview-stats' },
          e('span', null, s.items + ' item' + (s.items === 1 ? '' : 's')),
          s.matched > 0 && e('span', { className: 'ai-stat-pill ai-stat-pill--matched' }, s.matched + ' from catalog'),
          s.placeholders > 0 && e('span', { className: 'ai-stat-pill ai-stat-pill--placeholder' }, s.placeholders + ' placeholder' + (s.placeholders === 1 ? '' : 's')),
          s.shortlists > 0 && e('span', { className: 'ai-stat-pill' }, s.shortlists + ' decision' + (s.shortlists === 1 ? '' : 's')),
        ),
        e('div', { className: 'ai-listpreview-tree' }, renderTree(entry.list.children, 0)),
        status === 'preview' && e('div', { className: 'ai-listpreview-actions' },
          e('button', { type: 'button', className: 'ai-btn ai-btn--primary', onClick: () => createList(i) }, 'Save as list'),
          e('button', { type: 'button', className: 'ai-btn', onClick: () => discardList(i) }, 'Discard'),
        ),
        status === 'creating' && e('div', { className: 'ai-listpreview-status' }, 'Creating list…'),
        status === 'created' && e('div', { className: 'ai-listpreview-status ai-listpreview-status--ok' }, '✓ Saved — opening it now'),
        status === 'discarded' && e('div', { className: 'ai-listpreview-status' }, 'Discarded'),
        entry.error && e('div', { className: 'ai-listpreview-status ai-listpreview-status--err' }, '⚠ ' + entry.error),
      ));
    }

    // Memory suggestion banner
    if (Array.isArray(entry.memoryUpdates) && entry.memoryUpdates.length > 0) {
      children.push(e('div', { key: 'mem', className: 'ai-memsugg' },
        e('div', { className: 'ai-memsugg-label' },
          e('svg', { width: 11, height: 11, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
            e('circle', { cx: 12, cy: 12, r: 9 }),
            e('path', { d: 'M9 11 L11 13 L15 9' })
          ),
          'Remember this for next time?'
        ),
        e('ul', { className: 'ai-memsugg-list' },
          entry.memoryUpdates.map((f, j) => e('li', { key: j }, f))
        ),
        e('div', { className: 'ai-memsugg-actions' },
          e('button', { type: 'button', className: 'ai-btn ai-btn--primary',
            onClick: () => { acceptMemory(entry.memoryUpdates); dismissMemorySuggestion(i); } }, 'Remember'),
          e('button', { type: 'button', className: 'ai-btn',
            onClick: () => dismissMemorySuggestion(i) }, 'Skip'),
        )
      ));
    }

    // Reply footer — thumbs + follow-up chips
    const voteKey = (currentId || 'unknown') + ':' + i;
    const currentVote = feedback[voteKey] || 0;
    children.push(e('div', { key: 'foot', className: 'ai-bubble-foot' },
      e('div', { className: 'ai-thumbs' },
        e('button', {
          type: 'button',
          className: 'ai-thumb' + (currentVote === 1 ? ' is-on' : ''),
          onClick: () => setBubbleVote(i, 1),
          title: 'Helpful', 'aria-label': 'Mark helpful',
        }, e('svg', { width: 12, height: 12, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
          e('path', { d: 'M7 22 L7 11 L11 4 L11 4 a2 2 0 014 2 L13 11 L19 11 a2 2 0 012 2 L20 21 a2 2 0 01-2 2 L7 22 Z' })
        )),
        e('button', {
          type: 'button',
          className: 'ai-thumb' + (currentVote === -1 ? ' is-on is-down' : ''),
          onClick: () => setBubbleVote(i, -1),
          title: 'Not helpful', 'aria-label': 'Mark not helpful',
        }, e('svg', { width: 12, height: 12, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
          e('path', { d: 'M17 2 L17 13 L13 20 L13 20 a2 2 0 01-4 -2 L11 13 L5 13 a2 2 0 01-2 -2 L4 3 a2 2 0 012 -2 L17 2 Z' })
        ))
      ),
      Array.isArray(entry.followUps) && entry.followUps.length > 0 && e('div', { className: 'ai-followups' },
        entry.followUps.map((q, j) => e('button', {
          key: j, type: 'button', className: 'ai-followup',
          onClick: () => send(q),
        }, q))
      )
    ));

    return e('div', { key: i, className: 'ai-bubble ai-bubble--ai' },
      e('div', { className: 'ai-bubble-card' }, ...children)
    );
  }

  function renderHistoryView(conversations, currentId, onOpen, onDelete, onNew, q, setQ) {
    const nonEmpty = conversations.filter(c => (c.history || []).some(h => h.role === 'user'));
    const lowerQ = (q || '').toLowerCase().trim();
    const filtered = lowerQ
      ? nonEmpty.filter(c => {
          if ((c.title || '').toLowerCase().includes(lowerQ)) return true;
          return (c.history || []).some(h => (h.content || '').toLowerCase().includes(lowerQ));
        })
      : nonEmpty;
    return e(React.Fragment, null,
      nonEmpty.length > 0 && e('div', { className: 'ai-history-search' },
        e('input', {
          type: 'text', placeholder: 'Search past chats…',
          value: q, onChange: (ev) => setQ(ev.target.value),
        })
      ),
      filtered.length === 0
        ? e('div', { className: 'ai-sidebar-empty' },
            e('h3', null, nonEmpty.length === 0 ? 'No past chats yet' : 'No matching chats'),
            e('p', null, nonEmpty.length === 0
              ? 'Conversations show up here so you can revisit them later.'
              : 'Try a different search.'),
            nonEmpty.length === 0 && e('button', { type: 'button', className: 'ai-btn ai-btn--primary', onClick: onNew, style: { marginTop: 8 } }, 'Start a new chat')
          )
        : e('div', { className: 'ai-history-list' },
            filtered.map(conv => {
              const title = titleFor(conv);
              const msgCount = (conv.history || []).length;
              return e('div', { key: conv.id, className: 'ai-history-item' + (conv.id === currentId ? ' is-current' : '') },
                e('button', {
                  type: 'button', className: 'ai-history-open',
                  onClick: () => onOpen(conv.id), title: 'Open',
                },
                  e('div', { className: 'ai-history-title' }, title),
                  e('div', { className: 'ai-history-meta' }, msgCount + ' message' + (msgCount === 1 ? '' : 's') + ' · ' + timeAgo(conv.updatedAt || conv.startedAt))
                ),
                e('button', {
                  type: 'button', className: 'ai-history-delete',
                  onClick: (ev) => { ev.stopPropagation(); if (confirm('Delete this conversation?')) onDelete(conv.id); },
                  title: 'Delete', 'aria-label': 'Delete conversation',
                },
                  e('svg', { width: 13, height: 13, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
                    e('path', { d: 'M4 7 L20 7' }),
                    e('path', { d: 'M9 7 L9 4 L15 4 L15 7' }),
                    e('path', { d: 'M6 7 L7 20 L17 20 L18 7' })
                  )
                )
              );
            })
          )
    );
  }

  function renderMemoryView(memory, removeMemory) {
    return e(React.Fragment, null,
      e('p', { className: 'ai-mem-intro' },
        'Things I remember about you across all chats. These help me personalize answers without you having to re-explain. Remove anything that\'s wrong.'
      ),
      memory.length === 0
        ? e('div', { className: 'ai-sidebar-empty' },
            e('p', null, 'Nothing here yet — I\'ll suggest things to remember as you chat.')
          )
        : e('ul', { className: 'ai-mem-list' },
            memory.map((m, i) => e('li', { key: i, className: 'ai-mem-item' },
              e('span', null, m),
              e('button', {
                type: 'button', className: 'ai-mem-del',
                onClick: () => removeMemory(i),
                title: 'Forget this',
              }, '×')
            ))
          )
    );
  }

  function renderTree(children, depth) {
    if (!Array.isArray(children) || children.length === 0) return null;
    return e('ul', { className: 'ai-tree ai-tree--d' + depth },
      children.map((c, i) => {
        if (!c) return null;
        if (c.kind === 'item') {
          return e('li', { key: i, className: 'ai-tree-item' + (c.productId ? ' ai-tree-item--matched' : ' ai-tree-item--placeholder') },
            e('span', { className: 'ai-tree-dot' }),
            e('span', { className: 'ai-tree-name' }, c.name || 'Item'),
            c.brand && e('span', { className: 'ai-tree-brand' }, c.brand),
            c.price != null && e('span', { className: 'ai-tree-price' }, 'A$' + c.price),
            !c.productId && e('span', { className: 'ai-tree-tag' }, 'placeholder'),
          );
        }
        return e('li', { key: i, className: 'ai-tree-group ai-tree-group--' + c.kind },
          e('div', { className: 'ai-tree-grouphead' },
            e('span', { className: 'ai-tree-groupicon' }, c.kind === 'shortlist' ? '◧' : '▸'),
            e('span', { className: 'ai-tree-groupname' }, c.name || (c.kind === 'shortlist' ? 'Pick one of' : 'Group')),
            c.kind === 'shortlist' && c.quantityNeeded && c.quantityNeeded > 1 && e('span', { className: 'ai-tree-tag ai-tree-tag--shortlist' }, 'pick ' + c.quantityNeeded),
            c.kind === 'shortlist' && (!c.quantityNeeded || c.quantityNeeded === 1) && e('span', { className: 'ai-tree-tag ai-tree-tag--shortlist' }, 'pick one'),
          ),
          renderTree(c.children, depth + 1)
        );
      })
    );
  }

  window.AISidebar = AISidebar;
})();
