/* global React */
const { useState, useMemo, useEffect, useRef } = React;

function cx(...xs) { return xs.filter(Boolean).join(' '); }

// ---- search / filter logic ----
// Returns { matchIds: Set, reasons: Record<id, Set<string>> }
function searchTables(tables, query, filters) {
  const q = query.trim().toLowerCase();
  const hasQ = q.length > 0;
  const { fandoms, niches, savedIds, visitedIds, notedIds, hasCatalog } = filters;
  const hasFandoms  = fandoms && fandoms.size > 0;
  const hasNiches   = niches  && niches.size  > 0;
  const hasSaved    = !!filters.savedOnly;
  const hasVisited  = !!filters.visitedOnly;
  const hasNotVisited = !!filters.notVisitedOnly;
  const hasNoted    = !!filters.notedOnly;
  const hasCatOnly  = !!hasCatalog;

  const anyFilter = hasQ || hasFandoms || hasNiches || hasSaved || hasVisited || hasNotVisited || hasNoted || hasCatOnly;

  const out = new Set();
  const reasons = {};

  for (const t of tables) {
    let queryHit = !hasQ;
    const tags = new Set();

    if (hasQ) {
      if (t.id.toLowerCase().includes(q)) { queryHit = true; tags.add('table'); }
      for (const a of t.artists) {
        if (a.name.toLowerCase().includes(q))   { queryHit = true; tags.add('name'); }
        if (a.handle.toLowerCase().includes(q)) { queryHit = true; tags.add('handle'); }
        for (const f of a.fandoms) if (f.toLowerCase().includes(q)) { queryHit = true; tags.add(f); }
        for (const n of a.niches)  if (n.toLowerCase().includes(q)) { queryHit = true; tags.add(n); }
        for (const s of Object.values(a.socials || {}))
          if (String(s).toLowerCase().includes(q)) { queryHit = true; tags.add('social'); }
      }
    }

    let fandomOk = !hasFandoms;
    if (hasFandoms) {
      for (const a of t.artists) {
        for (const f of a.fandoms) if (fandoms.has(f)) { fandomOk = true; tags.add(f); }
      }
    }

    let nicheOk = !hasNiches;
    if (hasNiches) {
      for (const a of t.artists) {
        for (const n of a.niches) if (niches.has(n)) { nicheOk = true; tags.add(n); }
      }
    }

    const savedOk   = !hasSaved   || (savedIds   && savedIds.has(t.id));
    const visitedOk = !hasVisited || (visitedIds && visitedIds.has(t.id));
    const notVisitedOk = !hasNotVisited || !visitedIds || !visitedIds.has(t.id);
    const notedOk   = !hasNoted   || (notedIds   && notedIds.has(t.id));
    const catalogOk = !hasCatOnly || t.hasCatalog;

    if (queryHit && fandomOk && nicheOk && savedOk && visitedOk && notVisitedOk && notedOk && catalogOk && anyFilter) {
      out.add(t.id);
      reasons[t.id] = tags;
    }
  }

  return { matchIds: out, reasons, anyFilter };
}

// ---- search input with typeahead ----
function SearchInput({ value, onChange, placeholder, tables, onPickTable }) {
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const rootRef = useRef(null);
  const inputRef = useRef(null);

  // Flat (artist, table) index. Rebuilt only when the dataset reference changes.
  const index = useMemo(() => {
    if (!Array.isArray(tables)) return [];
    const rows = [];
    for (const t of tables) for (const a of t.artists) rows.push({ artist: a, table: t });
    return rows;
  }, [tables]);

  // Score each artist against the query. Starts-with beats contains; ties
  // broken alphabetically so the order feels stable as the user types.
  const suggestions = useMemo(() => {
    const q = value.trim().toLowerCase();
    if (!q || !index.length) return [];
    const scored = [];
    for (const row of index) {
      const name = row.artist.name.toLowerCase();
      const tid  = row.table.id.toLowerCase();
      let score = -1;
      if (name.startsWith(q)) score = 0;
      else if (name.includes(q)) score = 1;
      else if (tid.startsWith(q)) score = 2;
      else if (tid.includes(q)) score = 3;
      if (score >= 0) scored.push({ ...row, score });
    }
    scored.sort((a, b) =>
      a.score - b.score || a.artist.name.localeCompare(b.artist.name, 'en', { sensitivity: 'base' })
    );
    return scored.slice(0, 8);
  }, [value, index]);

  // Clamp the active highlight whenever the suggestion list reshapes.
  useEffect(() => { setActive(0); }, [value]);

  // Dismiss on outside click.
  useEffect(() => {
    if (!open) return;
    const onDoc = (e) => {
      if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
    };
    document.addEventListener('pointerdown', onDoc);
    return () => document.removeEventListener('pointerdown', onDoc);
  }, [open]);

  const canSuggest = typeof onPickTable === 'function';
  const showList = open && canSuggest && suggestions.length > 0;

  const pick = (row) => {
    onPickTable(row.table.id);
    onChange('');
    setOpen(false);
    inputRef.current?.blur();
  };

  const onKey = (e) => {
    if (!showList) return;
    if (e.key === 'ArrowDown') { e.preventDefault(); setActive(i => (i + 1) % suggestions.length); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(i => (i - 1 + suggestions.length) % suggestions.length); }
    else if (e.key === 'Enter') { e.preventDefault(); pick(suggestions[active] || suggestions[0]); }
    else if (e.key === 'Escape') { setOpen(false); }
  };

  return (
    <div className="search-wrap" ref={rootRef}>
      <label className="search">
        <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
          <circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5"/>
          <path d="M11 11l3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
        </svg>
        <input
          ref={inputRef}
          type="text" value={value}
          onChange={(e) => { onChange(e.target.value); setOpen(true); }}
          onFocus={() => setOpen(true)}
          onKeyDown={onKey}
          placeholder={placeholder || 'search artists…'}
          autoComplete="off"
          autoCorrect="off"
          spellCheck="false"
          role="combobox"
          aria-autocomplete="list"
          aria-expanded={showList ? 'true' : 'false'}
        />
        {value && (
          <button type="button" className="clear" onClick={() => onChange('')} aria-label="clear search">×</button>
        )}
      </label>

      {showList && (
        <ul className="search-suggestions" role="listbox">
          {suggestions.map((row, i) => (
            <li
              key={`${row.table.id}-${row.artist.name}`}
              role="option"
              aria-selected={i === active ? 'true' : 'false'}
              className={cx('sug-row', i === active && 'is-active')}
              onMouseEnter={() => setActive(i)}
              onMouseDown={(e) => { e.preventDefault(); pick(row); }}
            >
              <span className="sug-name">{row.artist.name}</span>
              <span className="sug-table">{row.table.id}</span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

// ---- toggle chip ----
function Chip({ active, onClick, children, count, tone }) {
  return (
    <button
      type="button"
      className={cx('chip', active && 'is-active', tone && `tone-${tone}`)}
      aria-pressed={active ? 'true' : 'false'}
      onClick={onClick}
    >
      {children}
      {count !== undefined && <span className="chip-count">{count}</span>}
    </button>
  );
}

// ---- generic tag filter: inline scrollable list with search + counts ----
function TagFilter({ tables, value, onChange, kind, sortBy = 'count' }) {
  const [q, setQ] = useState('');

  const counts = useMemo(
    () => kind === 'niche' ? window.getNicheCounts(tables) : window.getFandomCounts(tables),
    [tables, kind]
  );
  const staticItems = kind === 'niche' ? (window.NICHES || []) : (window.FANDOMS || []);
  const allItems = useMemo(() => {
    const merged = [];
    const seen = new Set();
    const push = (item) => {
      const label = String(item || '').trim();
      if (!label) return;
      const key = label.toLowerCase();
      if (seen.has(key)) return;
      seen.add(key);
      merged.push(label);
    };
    staticItems.forEach(push);
    counts.forEach((_, label) => push(label));
    return merged;
  }, [staticItems, counts]);
  const searchPh = kind === 'niche' ? 'search products...' : 'search fandoms...';
  const emptyPh  = kind === 'niche' ? 'products' : 'fandoms';

  const toggle = (v) => {
    const next = new Set(value);
    if (next.has(v)) next.delete(v); else next.add(v);
    onChange(next);
  };

  const suggestions = useMemo(() => {
    const qnorm = q.trim().toLowerCase();
    const withCount = allItems.map(f => ({ f, c: counts.get(f) || 0 }));
    const rows = qnorm
      ? withCount.filter(({ f }) => f.toLowerCase().includes(qnorm))
      : withCount.filter(({ c }) => c > 0);
    return rows.sort((a, b) => {
      if (sortBy === 'alpha') return a.f.localeCompare(b.f, 'en', { sensitivity: 'base' });
      return b.c - a.c || a.f.localeCompare(b.f, 'en', { sensitivity: 'base' });
    });
  }, [q, counts, allItems, sortBy]);

  const selectedCount = value.size;

  return (
    <div className={cx('fandom-filter', `fandom-filter-${kind}`)} role="group" aria-label={`pick ${emptyPh}`}>
      <div className="fandom-search">
        <input
          type="text"
          value={q}
          onChange={(e) => setQ(e.target.value)}
          placeholder={searchPh}
        />
        {selectedCount > 0 && (
          <button type="button" className="link fandom-clear" onClick={() => onChange(new Set())}>
            clear ({selectedCount})
          </button>
        )}
      </div>
      <div className="fandom-list">
        {suggestions.map(({ f, c }) => (
          <label key={f} className={cx('pop-item', value.has(f) && 'is-checked', c === 0 && 'is-empty')}>
            <input
              type="checkbox"
              checked={value.has(f)}
              onChange={() => toggle(f)}
            />
            <span className="pop-name">{f}</span>
            <span className="pop-count">{c > 0 ? c : '0'}</span>
          </label>
        ))}
        {suggestions.length === 0 && <div className="pop-empty">no {emptyPh} match “{q}”</div>}
      </div>
    </div>
  );
}
// Kept for backwards-compat callers.
function FandomFilter(props) { return <TagFilter {...props} kind="fandom" />; }

// ---- filter bar ----
function FilterBar({ tables, query, onQuery, filters, setFilters, savedCount, visitedCount, notVisitedCount, notedCount, placeholderNote, layout, onPickTable }) {
  const vertical = layout === 'vertical';
  const setF = (patch) => setFilters({ ...filters, ...patch });
  const reset = () => setFilters({
    savedOnly: false, visitedOnly: false, notVisitedOnly: false, notedOnly: false,
    hasCatalog: false, fandoms: new Set(), niches: new Set(),
  });
  const anyFilter =
    filters.savedOnly || filters.visitedOnly || filters.notVisitedOnly || filters.notedOnly ||
    filters.hasCatalog || filters.fandoms.size > 0 || filters.niches.size > 0 || query;

  const scopeChips = (
    <>
      <Chip
        active={filters.savedOnly}
        tone="saved"
        onClick={() => setF({ savedOnly: !filters.savedOnly })}
      >
        saved <span className="chip-count">{savedCount}</span>
      </Chip>
      <Chip
        active={filters.visitedOnly}
        tone="visited"
        onClick={() => setF({ visitedOnly: !filters.visitedOnly, notVisitedOnly: false })}
      >
        visited <span className="chip-count">{visitedCount}</span>
      </Chip>
      <Chip
        active={filters.notVisitedOnly}
        onClick={() => setF({ notVisitedOnly: !filters.notVisitedOnly, visitedOnly: false })}
      >
        not visited <span className="chip-count">{notVisitedCount}</span>
      </Chip>
      <Chip
        active={filters.notedOnly}
        onClick={() => setF({ notedOnly: !filters.notedOnly })}
      >
        w/ notes <span className="chip-count">{notedCount}</span>
      </Chip>
      <Chip
        active={filters.hasCatalog}
        onClick={() => setF({ hasCatalog: !filters.hasCatalog })}
      >
        w/ catalog
      </Chip>
    </>
  );

  const fandomControl = (
    <TagFilter
      tables={tables}
      value={filters.fandoms}
      onChange={(next) => setF({ fandoms: next })}
      kind="fandom"
    />
  );

  const nicheControl = (
    <TagFilter
      tables={tables}
      value={filters.niches}
      onChange={(next) => setF({ niches: next })}
      kind="niche"
    />
  );

  if (vertical) {
    return (
      <div className="filter-bar filter-bar-vertical">
        <SearchInput value={query} onChange={onQuery} tables={tables} onPickTable={onPickTable} />

        <section className="filter-section">
          <div className="filter-section-label">quick filters</div>
          <div className="chip-row chip-row-grid">{scopeChips}</div>
        </section>

        <section className="filter-section">
          <div className="filter-section-label">by keyword(s)</div>
          <div className="chip-row fandom-chip-row">{fandomControl}</div>
        </section>

        <section className="filter-section">
          <div className="filter-section-label">by product(s)</div>
          <div className="chip-row fandom-chip-row">{nicheControl}</div>
        </section>

        <div className="clear-all-slot">
          {anyFilter && (
            <button type="button" className="link clear-all" onClick={() => { reset(); onQuery(''); }}>
              clear all
            </button>
          )}
        </div>
      </div>
    );
  }

  return (
    <div className="filter-bar">
      <SearchInput value={query} onChange={onQuery} tables={tables} onPickTable={onPickTable} />
      <div className="chip-row">
        {scopeChips}
        <span className="chip-divider" aria-hidden="true" />
      </div>
      <div className="chip-row fandom-chip-row">{fandomControl}</div>
      {anyFilter && (
        <button type="button" className="link clear-all" onClick={() => { reset(); onQuery(''); }}>
          clear all
        </button>
      )}
    </div>
  );
}

// ---- row in list ----
function TableRow({ table, selected, match, saved, visited, note,
                   onSelect, onToggleSave, onToggleVisited }) {
  const artistNames = table.artists.map(a => a.name).join(' · ');
  const allFandoms = [...new Set(table.artists.flatMap(a => a.fandoms))];
  const allNiches  = [...new Set(table.artists.flatMap(a => a.niches))];
  const noteText = (note || '').trim();
  const hasNote = !!noteText;
  const visibleNiches = allNiches.slice(0, 3);
  const visibleFandoms = allFandoms.slice(0, 3);
  return (
    <div
      className={cx('row', selected && 'is-selected', match && 'is-match', visited && 'is-visited')}
      onClick={() => onSelect(table.id)}
    >
      <div className="row-num">{table.id}</div>
      <div className="row-body">
        <div className="row-title-line">
          <div className="row-name">{artistNames}</div>
          <div className="row-state-tags">
            {table.shared && <span className="row-shared-tag">shared</span>}
            {hasNote && <span className="row-noted-tag">note</span>}
            {table.hasCatalog && <span className="row-catalog-tag">catalog</span>}
          </div>
        </div>
        <div className="row-meta">
          {visibleNiches.map(n => <span key={n} className="mini-tag niche">{n}</span>)}
          {allNiches.length > visibleNiches.length && <span className="mini-tag more">+{allNiches.length - visibleNiches.length}</span>}
        </div>
        <div className="row-tags">
          {visibleFandoms.map(f => <span key={f} className="mini-tag">{f}</span>)}
          {allFandoms.length > visibleFandoms.length && <span className="mini-tag more">+{allFandoms.length - visibleFandoms.length}</span>}
        </div>
        {hasNote && <div className="row-note">✎ {noteText}</div>}
      </div>
      <div className="row-actions">
        <button
          type="button"
          className={cx('icon-btn', 'save-btn', saved && 'is-saved')}
          onClick={(e) => { e.stopPropagation(); onToggleSave(table.id); }}
          aria-pressed={saved ? 'true' : 'false'}
          title={saved ? 'remove from saved' : 'save for later'}
        >
          {saved ? '♥' : '♡'}
        </button>
        {onToggleVisited && (
          <button
            type="button"
            className={cx('icon-btn', 'visit-btn', visited && 'is-visited')}
            onClick={(e) => { e.stopPropagation(); onToggleVisited(table.id); }}
            aria-pressed={visited ? 'true' : 'false'}
            title={visited ? 'mark unvisited' : 'mark visited'}
          >
            ✓
          </button>
        )}
      </div>
    </div>
  );
}

// ---- social icons ----
// Always renders three slots (X · Instagram · Bluesky). Linked handles become
// real anchors with brand tone; missing handles stay as muted placeholders so
// the row shape never changes between artists.
const SOCIAL_SLOTS = [
  { kind: 'x',    getHref: (v) => `https://x.com/${v}`,                label: (v) => `@${v} on X`,         glyph: '𝕏' },
  { kind: 'ig',   getHref: (v) => `https://instagram.com/${v}/`,       label: (v) => `@${v} on Instagram`, glyph: '◐' },
  { kind: 'bsky', getHref: (v) => `https://bsky.app/profile/${v}`,     label: (v) => `${v} on Bluesky`,    glyph: '☁' },
];
function SocialIcons({ socials, compact }) {
  return (
    <div className={cx('social-icons', compact && 'compact')}>
      {SOCIAL_SLOTS.map(slot => {
        const v = socials?.[slot.kind];
        if (!v) {
          return (
            <span
              key={slot.kind}
              className={cx('social-icon', 'social-empty', `social-${slot.kind}`)}
              aria-label={`${slot.kind} not linked`}
              aria-hidden="true"
            >
              {slot.glyph}
            </span>
          );
        }
        return (
          <a key={slot.kind}
             className={cx('social-icon', 'social-linked', `social-${slot.kind}`)}
             href={slot.getHref(v)} target="_blank" rel="noopener noreferrer"
             title={slot.label(v)} aria-label={slot.label(v)}>
            <span aria-hidden="true">{slot.glyph}</span>
          </a>
        );
      })}
    </div>
  );
}

// ---- twitter embed: loads widgets.js once, then scoped-hydrates a given node ----
// We track the script's load state so later mounts can call widgets.load()
// immediately rather than waiting for onload. Scoping the call to a specific
// element lets React-driven updates rebuild just that embed.
let _twttrState = 'idle'; // 'idle' | 'loading' | 'ready'
const _twttrQueue = [];
function _ensureTwttr() {
  return new Promise((resolve) => {
    if (_twttrState === 'ready' && window.twttr?.widgets) return resolve(window.twttr);
    _twttrQueue.push(resolve);
    if (_twttrState === 'loading') return;
    _twttrState = 'loading';
    const s = document.createElement('script');
    s.src = 'https://platform.twitter.com/widgets.js';
    s.async = true;
    s.charset = 'utf-8';
    s.onload = () => {
      _twttrState = 'ready';
      _twttrQueue.splice(0).forEach(fn => fn(window.twttr));
    };
    document.body.appendChild(s);
  });
}
function useTweetEmbed(containerRef, dep) {
  useEffect(() => {
    if (!dep || !containerRef.current) return;
    let cancelled = false;
    _ensureTwttr().then((twttr) => {
      if (cancelled || !containerRef.current) return;
      // widgets.load(el) scans only the subtree, avoiding redundant work
      // across every artist switch.
      twttr.widgets.load(containerRef.current);
    });
    return () => { cancelled = true; };
  }, [dep]);
}

// ---- detail panel ----
function DetailPanel({
  table, onClose, onPrev, onNext,
  saved, visited, note, onToggleSave, onToggleVisited, onSetNote,
  onFilterByTag,
  variant, // 'desktop' | 'mobile'
}) {
  if (!table) return null;
  const [localNote, setLocalNote] = useState(note || '');
  useEffect(() => { setLocalNote(note || ''); }, [table.id, note]);

  // debounced save of the note
  useEffect(() => {
    const h = setTimeout(() => {
      if ((note || '') !== localNote) onSetNote(table.id, localNote);
    }, 300);
    return () => clearTimeout(h);
  }, [localNote]); // eslint-disable-line

  const artistList = table.artists;
  const primary    = artistList[0];
  const allFandoms = [...new Set(artistList.flatMap(a => a.fandoms))];
  const allNiches  = [...new Set(artistList.flatMap(a => a.niches))];
  const noteText   = (note || '').trim();

  // Notes panel reveals in-place when the user toggles the "notes" pill or if
  // there's already a saved note waiting for them.
  const [notesOpen, setNotesOpen] = useState(!!noteText);
  useEffect(() => { setNotesOpen(!!noteText); }, [table.id]); // eslint-disable-line

  // Twitter embed: prefer a specific `catalogPostUrl` (pinned tweet) if the
  // table has one; fall back to a live timeline of the primary artist's X
  // account. Both are gated on hasCatalog + a handle so we don't render an
  // empty box for unclaimed tables. widgets.js accepts either domain but
  // canonicalizing to twitter.com avoids occasional redirect/parse issues.
  const embedHandle = (table.hasCatalog && primary.socials?.x) ? primary.socials.x : null;
  const rawPostUrl = (table.hasCatalog && table.catalogPostUrl) ? table.catalogPostUrl : null;
  const embedPostUrl = rawPostUrl ? rawPostUrl.replace('://x.com/', '://twitter.com/') : null;
  const hasEmbeddableCatalog = !!(table.hasCatalog && (embedPostUrl || embedHandle));
  const splitCatalog = variant !== 'mobile' && hasEmbeddableCatalog;
  const tweetTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
  const embedRef = useRef(null);
  useTweetEmbed(embedRef, embedPostUrl || embedHandle);

  return (
    <div className={cx('detail', variant && `detail-${variant}`, splitCatalog && 'has-catalog-layout')}>
      <div className="detail-nav">
        {variant === 'mobile' && onClose && (
          <button type="button" className="icon-btn close-btn" onClick={onClose} aria-label="close">×</button>
        )}
        {variant !== 'mobile' && onClose && (
          <button type="button" className="link back-btn" onClick={onClose}>← back to list</button>
        )}
        <div className="detail-nav-arrows" role="group" aria-label="previous/next table">
          <button
            type="button" className="icon-btn"
            onClick={onPrev} disabled={!onPrev}
            aria-label="previous table"
          >◀</button>
          <span className="detail-t-id">{table.id}</span>
          <button
            type="button" className="icon-btn"
            onClick={onNext} disabled={!onNext}
            aria-label="next table"
          >▶</button>
        </div>
      </div>
      <div className="detail-content">
      <div className="detail-head">
        <div className="detail-info">
        {/* Title gets the full row so long handles never have to fight icons
            for space. Socials sit on their own row below. */}
        <h1 className="detail-title">
          {table.shared ? artistList.map(a => a.name).join(' & ') : primary.name}
        </h1>
        {!table.shared && (
          <div className="detail-socials-row">
            <SocialIcons socials={primary.socials} compact />
          </div>
        )}

        {(table.type === 'exhibitor' || table.status !== 'official') && (
          <div className="detail-pills">
            {table.type === 'exhibitor' && <span className="pill pill-exhibitor">exhibitor</span>}
            {table.status !== 'official' && (
              <span className={cx('pill', `pill-${table.status}`)}>
                {table.status.replace('-', ' ')}
              </span>
            )}
          </div>
        )}

        {allFandoms.length > 0 && (
          <div className="tag-group">
            <div className="tag-group-label">fandoms and series:</div>
            <div className="tag-row tag-row-grid">
              {allFandoms.slice(0, 8).map(f => (
                onFilterByTag ? (
                  <button
                    key={f} type="button"
                    className="mini-tag mini-tag-btn"
                    onClick={() => onFilterByTag('fandom', f)}
                    title={`filter list by ${f}`}
                  ><span className="mini-tag-label">{f}</span></button>
                ) : <span key={f} className="mini-tag">{f}</span>
              ))}
            </div>
          </div>
        )}
        {allNiches.length > 0 && (
          <div className="tag-group">
            <div className="tag-group-label">products:</div>
            <div className="tag-row tag-row-grid">
              {allNiches.slice(0, 8).map(n => (
                onFilterByTag ? (
                  <button
                    key={n} type="button"
                    className="mini-tag mini-tag-btn niche"
                    onClick={() => onFilterByTag('niche', n)}
                    title={`filter list by ${n}`}
                  ><span className="mini-tag-label">{n}</span></button>
                ) : <span key={n} className="mini-tag niche">{n}</span>
              ))}
            </div>
          </div>
        )}

        {!table.shared && primary.notes && (
          <div className="artist-note detail-artist-note">"{primary.notes}"</div>
        )}
        </div>

        <div className="detail-controls">
        <div className="detail-actions">
          <button
            type="button"
            className={cx('pill-btn', saved && 'is-saved')}
            onClick={() => onToggleSave(table.id)}
            aria-pressed={saved ? 'true' : 'false'}
          >
            <span className="pill-btn-icon" aria-hidden="true">{saved ? '♥' : '♡'}</span>
            <span className="pill-btn-label">{saved ? 'saved' : 'save'}</span>
          </button>
          <button
            type="button"
            className={cx('pill-btn', visited && 'is-visited')}
            onClick={() => onToggleVisited(table.id)}
            aria-pressed={visited ? 'true' : 'false'}
          >
            <span className="pill-btn-icon" aria-hidden="true">{visited ? '✓' : '○'}</span>
            <span className="pill-btn-label">{visited ? 'visited' : 'visit'}</span>
          </button>
          <button
            type="button"
            className={cx('pill-btn', (notesOpen || noteText) && 'is-noted')}
            onClick={() => setNotesOpen(o => !o)}
            aria-expanded={notesOpen ? 'true' : 'false'}
          >
            <span className="pill-btn-icon" aria-hidden="true">✎</span>
            <span className="pill-btn-label">notes</span>
          </button>
        </div>

        {/* Notes editor — reveals directly beneath the action row instead of
            shoving the body down from a fixed location. */}
        <div className={cx('notes-drawer', notesOpen && 'is-open')}>
          <div className="notes-drawer-inner">
            <label className="notes-label">your notes <span className="notes-sub">(saved locally)</span></label>
            <textarea
              className="notes-input"
              rows={2}
              placeholder="e.g. picked up frieren charms, come back for restock @ 2pm"
              value={localNote}
              onChange={(e) => setLocalNote(e.target.value)}
            />
          </div>
        </div>
        </div>
      </div>

      <div className="detail-body">

        {/* Twitter embed — widgets.js hydrates any .twitter-tweet blockquote.
            If the table has a pinned catalogPostUrl we embed that single tweet;
            otherwise we embed a live timeline of the primary artist's account.
            We key on the embed source so switching artists rebuilds the node
            and widgets.js re-parses it. */}
        {(embedPostUrl || embedHandle) && (
          <div className="catalog-embed" ref={embedRef}>
            {embedPostUrl ? (
              <blockquote key={`${embedPostUrl}-${tweetTheme}`} className="twitter-tweet" data-theme={tweetTheme} data-dnt="true" data-conversation="none">
                <a href={embedPostUrl}>loading catalog post…</a>
              </blockquote>
            ) : (
              <blockquote key={`${embedHandle}-${tweetTheme}`} className="twitter-tweet" data-tweet-limit="3" data-theme={tweetTheme} data-dnt="true">
                <a href={`https://twitter.com/${embedHandle}`}>loading recent posts from @{embedHandle}…</a>
              </blockquote>
            )}
          </div>
        )}
        {!embedHandle && primary.socials?.ig && (
          <a className="catalog-link"
             href={`https://instagram.com/${primary.socials.ig}/`}
             target="_blank" rel="noreferrer">
            view catalog on instagram ↗
          </a>
        )}

        {/* Shared-table fallback: per-artist breakdown. (No shared tables in
            current data — left in place for when they're introduced.) */}
        {table.shared && (
          <div className="shared-artists">
            {artistList.map((a, i) => (
              <div className="artist-card" key={i}>
                <div className="artist-head">
                  <div className="artist-name">{a.name}</div>
                  <SocialIcons socials={a.socials} compact />
                </div>
                {a.fandoms.length > 0 && (
                  <div className="tag-row">
                    {a.fandoms.map(f => <span key={f} className="mini-tag">{f}</span>)}
                  </div>
                )}
                {a.niches.length > 0 && (
                  <div className="tag-row niches-row">
                    {a.niches.map(n => <span key={n} className="mini-tag niche">{n}</span>)}
                  </div>
                )}
                {a.notes && <div className="artist-note">"{a.notes}"</div>}
              </div>
            ))}
          </div>
        )}

      </div>
      </div>
    </div>
  );
}

// ---- side panel (desktop list + detail) ----
function SidePanel({
  mode, tables, selected, results, filtersActive,
  savedIds, visitedIds, notes,
  onSelect, onClose, onPrev, onNext,
  onToggleSave, onToggleVisited, onSetNote,
  onFilterByTag,
  detailVariant = 'desktop',
  collapsed, onCollapse,
}) {
  return (
    <aside className={cx('side-panel', collapsed && 'is-collapsed')} aria-hidden={collapsed ? 'true' : 'false'}>
      {!collapsed && (
        <div className="side-inner">
          {mode === 'detail' && selected ? (
            <DetailPanel
              variant={detailVariant}
              table={selected}
              onClose={onClose}
              onPrev={onPrev}
              onNext={onNext}
              saved={savedIds.has(selected.id)}
              visited={visitedIds.has(selected.id)}
              note={notes[selected.id] || ''}
              onToggleSave={onToggleSave}
              onToggleVisited={onToggleVisited}
              onSetNote={onSetNote}
              onFilterByTag={onFilterByTag}
            />
          ) : (
            <>
              <div className="side-head">
                <div className="side-eyebrow">
                  <span>{filtersActive ? 'search results' : 'the full list'}</span>
                  <span>{results.length} {results.length === 1 ? 'table' : 'tables'}</span>
                </div>
                <h2 className="side-title">
                  {filtersActive ? 'what matched' : 'artist alley and exhibitors'}
                </h2>
                <div className="side-sub">
                  {filtersActive
                    ? 'these are highlighted on the map too.'
                    : 'tap a booth on the map or a row below to peek at who\'s there.'}
                </div>
              </div>
              <div className="side-body">
                {results.length === 0 ? (
                  <div className="empty">
                    <div className="empty-big">nothing matches yet</div>
                    <div>try a different fandom or clear a filter</div>
                  </div>
                ) : (
                  <div className="row-list">
                    {results.map(t => (
                      <TableRow key={t.id}
                        table={t}
                        selected={selected?.id === t.id}
                        match={filtersActive}
                        saved={savedIds.has(t.id)}
                        visited={visitedIds.has(t.id)}
                        note={notes[t.id] || ''}
                        onSelect={onSelect}
                        onToggleSave={onToggleSave}
                        onToggleVisited={onToggleVisited}
                      />
                    ))}
                  </div>
                )}
              </div>
            </>
          )}
          {onCollapse && (
            <button
              type="button"
              className="side-collapse-inline"
              onClick={onCollapse}
              aria-label="collapse list"
              title="collapse list"
            >
              collapse ▶
            </button>
          )}
        </div>
      )}
    </aside>
  );
}

window.AS = { cx, SearchInput, Chip, TagFilter, FilterBar, TableRow, DetailPanel, SidePanel, searchTables };
