// Inventory listing — the partner's landing screen (BOO-644). List / Grid / Map,
// list by default, one page of venues at a time.
//
// Search and filtering run over the WHOLE in-memory catalogue (window.VENUES is
// fully loaded before __pp_live.ready flips) and only the surviving page is
// rendered — so a match on the 400th venue still surfaces on page 1 of results,
// and only a page's worth of /api/availability calls go out.

const VENUES_PAGE_SIZE = 50;

const PRE_ORDER_LABELS = {
  none: null,
  optional:       { short: 'Pre-order available',            title: 'This venue publishes a menu — you can add guest selections during booking.' },
  menu:           { short: 'Menu pre-order required',         title: 'Guests must choose menu items before this booking can be submitted.' },
  package:        { short: 'Package required',                title: 'A package must be added to this booking before it can be submitted.' },
  packageAndMenu: { short: 'Pre-order required', title: 'Either a package or menu selections must be added before this booking can be submitted.' },
};

// ── Pure helpers (kept top-level so tests/inventory-list.test.js can lift them
// out of this browser-global file — see tests/booking-window.test.js) ─────────

// Which slice of `total` items page `page` shows, with the page clamped into
// range. A filter that shrinks the list must not leave you stranded on an empty
// page, so callers render `page` back rather than their own state.
function ppPageWindow(total, pageSize, page) {
  const totalPages = Math.max(1, Math.ceil(total / pageSize));
  const safePage = Math.min(Math.max(0, page || 0), totalPages - 1);
  const from = safePage * pageSize;
  return { page: safePage, totalPages, from, to: Math.min(total, from + pageSize) };
}

const PP_SOFT_STOP = new Set(['and', 'the', 'with', 'for', 'a', 'an', 'of', 'in', 'on', 'at', 'to', 'that', 'does', 'do']);

// Needle matches when every significant token appears somewhere in the hay.
function ppTokenHit(hay, needle) {
  const norm = (typeof ppNorm === 'function')
    ? ppNorm
    : (s) => (s || '').toString().toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
  const toks = norm(needle).split(' ').filter(t => t && !PP_SOFT_STOP.has(t));
  if (!toks.length) return true;
  return toks.every(t => hay.includes(t));
}

// The keyword is applied on top of the hard filters, and dropped rather than
// allowed to blank the page — a partner who typed a dish name nothing matches is
// better served the filtered list than an empty one.
//
// Takes venues, not pre-built haystacks: with no keyword (the common case) not a
// single haystack gets built.
function ppSoftTiers(venues, { text }) {
  if (!text) return { rows: venues, dropped: null };
  const hit = venues.filter(v => ppTokenHit(ppVenueHay(v), text));
  return hit.length ? { rows: hit, dropped: null } : { rows: venues, dropped: 'text' };
}

function ResultsScreen({ query, setQuery, onSearch, onBook, onOpenListing }) {
  const [view, setView] = ppUsePref('pp_view', 'list');
  const [page, setPage] = React.useState(0);
  const listTop = React.useRef(null);
  // Paging 50 rows leaves you wherever you were vertically — which is the bottom
  // of the previous page. Put the top of the new page back under the eye.
  const goToPage = (p) => {
    setPage(p);
    const el = listTop.current;
    if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
  };

  // Any change to what's being filtered starts again at page 1.
  React.useEffect(() => { setPage(0); }, [
    query.text, (query.locations || []).join(','), (query.productTypes || []).join(','),
    (query.operatorIds || []).join(','), query.venueId, query.date, query.guests,
  ]);

  const text = (query.text || '').trim().toLowerCase();
  const types = query.productTypes || [];
  const locations = query.locations || [];
  const operators = query.operatorIds || [];

  // Hard signals — city / operator / products / venueId — must always pass. The
  // keyword is soft: it narrows when it matches and is dropped rather than
  // blanking the page (see ppSoftTiers).
  const matchesHard = (v) => {
    if (query.venueId && v.id !== query.venueId) return false;
    // Each multi-select is a union within itself and an AND across facets:
    // "(London or Leeds) and (Dinner or Karaoke)".
    if (locations.length && !locations.includes(v.city)) return false;
    if (operators.length && !operators.includes(v.operator)) return false;
    // Multi-select is a union: "Dinner or Karaoke", matching the prototype's
    // checkbox popover. Matches a product's PRIMARY category only, so every
    // venue in the results has the filtered product to show and to book.
    if (types.length && !types.some(t => v.products.includes(t))) return false;
    return true;
  };
  // Searchable text per venue, built ONLY when a keyword is actually being
  // matched and cached per venue thereafter. It folds in the product taxonomy
  // names (the closest thing to an event name) and the pre-order menu/package
  // data so dish-level terms like "fish and chips" match — which on the real
  // Stonegate catalogue is ~930KB of text across 939 venues, far too much to
  // normalise on every render. See ppVenueHay in data.jsx.

  const hardMatches = VENUES.filter(matchesHard);
  const { rows: ordered, dropped } = ppSoftTiers(hardMatches, { text });
  const droppedKeyword = dropped ? query.text : null;

  // One card per venue. The card carries an inline product switcher so the
  // partner can pick which of the venue's products to view times for — fixes
  // "click an operator, see a random product" by surfacing every product the
  // venue offers as a chip.
  const cards = ordered
    .map(v => {
      const products = (v.products || []).filter(Boolean);
      if (!products.length) return null;
      // Show the product the partner filtered on, which a filtered venue always
      // has now that filtering is primary-category only.
      const initialProductId = types.find(t => products.includes(t)) || products[0];
      return { venue: v, productId: initialProductId };
    })
    .filter(Boolean);

  const win = ppPageWindow(cards.length, VENUES_PAGE_SIZE, page);
  const pageCards = cards.slice(win.from, win.to);
  const dateLabel = new Date(query.date + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' });
  // Only name a total when a FILTER narrowed things. The denominator is venues
  // that have a product at all: on the real Stonegate catalogue 208 of 939 publish
  // none, and counting those made an unfiltered listing read as "731 of 939" —
  // as though the partner's own filters had hidden 208 bookable venues.
  const bookableTotal = VENUES.reduce((n, v) => n + ((v.products || []).filter(Boolean).length ? 1 : 0), 0);
  const shown = cards.length === bookableTotal
    ? `${cards.length} listings`
    : `${cards.length} of ${bookableTotal} listings`;
  const metaLine = query.searched
    ? `${shown} · times on ${dateLabel} · ${query.guests} ${query.guests === 1 ? 'guest' : 'guests'}`
    : `${shown} in your inventory — set date, guests and products, then search for live times`;

  // There was no route back to the un-searched inventory: `searched` never went
  // false again and no control reset the filters, so a keyword search was a
  // one-way door. This is the listing exactly as it looks on arrival.
  const filtersApplied = !!(text || types.length || locations.length || operators.length || query.venueId);
  const resetSearch = () => setQuery({
    ...query, text: '', locations: [], productTypes: [], operatorIds: [],
    venueId: null, searched: false,
  });

  return (
    <div className="pp-screen-results">
      <InstallBanner/>
      {/* Full-bleed band under the top bar, as in the prototype: every control
          on one row with the view toggle, then the selected-product chips. */}
      <div className="pp-searchband" ref={listTop}>
        <SearchBand committed={query} onSearch={onSearch} view={view} setView={setView}/>
      </div>

      <div className="pp-listing-meta">
        <span>
          {metaLine}
          {(filtersApplied || query.searched) && (
            <button type="button" className="pp-listing-reset" onClick={resetSearch}>
              <IconClose size={10}/>
              <span>{filtersApplied ? 'Clear search' : 'Back to full inventory'}</span>
            </button>
          )}
        </span>
        {query.searched && (
          <span className="pp-slot-legend-row">
            <span className="pp-slot-legend pp-slot-legend--book"><span className="pp-slot-legend-sw"/>Instant</span>
            <span className="pp-slot-legend pp-slot-legend--request"><span className="pp-slot-legend-sw"/>Enquiry</span>
          </span>
        )}
      </div>

      {droppedKeyword && (
        <div className="pp-results-widen" role="status">
          <IconSearch size={13}/>
          <span>Nothing matches <strong>{droppedKeyword}</strong>. Showing everything else your filters allow — or <button type="button" className="pp-inline-link" onClick={resetSearch}>clear the search</button>.</span>
        </div>
      )}

      {cards.length === 0 ? (
        <EmptyResults onClear={resetSearch}/>
      ) : view === 'list' ? (
        <VenueList cards={pageCards} query={query} onBook={onBook} onOpen={onOpenListing}/>
      ) : view === 'grid' ? (
        <div className="pp-card-grid">
          {pageCards.map(c => (
            <VenueCard key={c.venue.id}
                       venue={c.venue}
                       defaultProductId={c.productId}
                       query={query}
                       onBook={onBook}
                       onOpen={onOpenListing}/>
          ))}
        </div>
      ) : (
        // The map plots exactly the rows the table is showing — narrowing the
        // search or paging moves the pins with it.
        <MapView cards={pageCards} query={query} onBook={onBook} onOpen={onOpenListing}/>
      )}

      {cards.length > VENUES_PAGE_SIZE && (
        <Pagination total={cards.length}
                    pageStart={win.from}
                    pageEnd={win.to}
                    page={win.page}
                    totalPages={win.totalPages}
                    onPage={goToPage}/>
      )}
    </div>
  );
}

// Everything a venue row/card needs to show times for one of its products.
// Shared by the list and the grid so the min-party gating, availability fan-out
// and pre-order badge derivation exist once.
function useVenueSlots(venue, products, defaultProductId, query) {
  const [sel, setSel] = React.useState(defaultProductId);
  React.useEffect(() => { setSel(defaultProductId); }, [defaultProductId]);

  // Per-product minimum party size (from the Bookable weekly rules, see #60).
  // A product whose minimum exceeds the searched party size can never return a
  // slot, so we skip its availability call entirely and tell the partner the
  // minimum instead of showing a misleading "no times".
  const minByProduct = {};
  (venue.rawProducts || []).forEach(rp => {
    const n = Number(rp.minPartySize);
    if (rp.portalProduct && n > 0) minByProduct[rp.portalProduct] = n;
  });
  const belowMinFor = (pid) => minByProduct[pid] > 0 && query.guests < minByProduct[pid];
  const eligible = products.filter(pid => !belowMinFor(pid));

  const byProduct = useAllProductsAvailability(venue, eligible, query.date, query.guests, query.searched);
  const productId = sel || defaultProductId;
  const belowMin = belowMinFor(productId) ? minByProduct[productId] : null;
  const slots = byProduct[productId];
  const loading = query.searched && !belowMin && (slots === null || slots === undefined);
  const product = productById(productId) || { name: (venue.productNames && venue.productNames[productId]) || productId };
  const priceList = (slots || []).map(s => s.price).filter(p => typeof p === 'number' && p > 0);
  const minPrice = priceList.length ? Math.min(...priceList) : null;
  const rawProduct = (venue.rawProducts || []).find(rp => rp.portalProduct === productId);
  // Pre-order menus/packages live at the venue level but each product publishes
  // an allow-list (preorderMenuIds / preorderPackageIds). Only surface the badge
  // when this product is actually nested onto something.
  const allowedMenuIds = (rawProduct && rawProduct.preorderMenuIds) || [];
  const allowedPackageIds = (rawProduct && rawProduct.preorderPackageIds) || [];
  const venueMenus = (venue.preorders && venue.preorders.menus) || [];
  const venuePackages = (venue.preorders && venue.preorders.packages) || [];
  const hasPreorderCatalogue = (allowedMenuIds.length
      && venueMenus.some(m => allowedMenuIds.includes(String(m.id)) || (m.slug && allowedMenuIds.includes(m.slug))))
    || (allowedPackageIds.length
      && venuePackages.some(p => allowedPackageIds.includes(String(p.id)) || (p.slug && allowedPackageIds.includes(p.slug))));
  const preOrderRequired = !!(rawProduct && rawProduct.preOrderRequired);
  const preOrderType = preOrderRequired ? (rawProduct.preOrderRequiredType || 'menu')
                     : hasPreorderCatalogue ? 'optional'
                     : 'none';
  const minParty = rawProduct && Number(rawProduct.minPartySize) > 0 ? Number(rawProduct.minPartySize) : null;

  return {
    productId, pick: setSel, byProduct, slots, loading, belowMin, product, minPrice,
    minParty, preOrderLabel: PRE_ORDER_LABELS[preOrderType],
  };
}

// One product switcher chip per product the venue offers. `cap` limits how many
// render before a "+N" expander — a Stonegate venue publishes nine products,
// which wrapped to seven lines and pushed a single list row past 300px, so only
// two listings fitted above the fold. The selected product always stays visible:
// it is the one whose times the row is showing, and it is often not in the first
// `cap` of the catalogue order.
function ProductSwitch({ venue, products, productId, byProduct, onPick, cap }) {
  const [expanded, setExpanded] = React.useState(false);
  React.useEffect(() => { setExpanded(false); }, [products.length]);

  let visible = products;
  if (cap && !expanded && products.length > cap) {
    visible = products.slice(0, cap);
    if (!visible.includes(productId)) visible = [...visible.slice(0, cap - 1), productId];
  }
  const hidden = products.length - visible.length;

  return (
    <div className="pp-prod-switch" role="tablist" aria-label="Choose a product">
      {visible.map(pid => {
        const PG = glyphFor(pid);
        const p = productById(pid) || { name: (venue.productNames && venue.productNames[pid]) || pid };
        const raw = (venue.rawProducts || []).find(rp => rp.portalProduct === pid);
        const pSlots = byProduct[pid];
        const isSel = pid === productId;
        const none = Array.isArray(pSlots) && pSlots.length === 0;
        return (
          <button key={pid} type="button" role="tab" aria-selected={isSel}
                  className={"pp-prod-tab" + (isSel ? " is-active" : "") + (none ? " is-empty" : "")}
                  title={[raw && raw.name ? raw.name : p.name,
                          none ? 'no times for this date' : null].filter(Boolean).join(' — ')}
                  onClick={() => onPick(pid)}>
            <PG size={12}/>
            <span>{p.name}</span>
            {none && <span className="pp-prod-tab-x" aria-hidden="true">—</span>}
          </button>
        );
      })}
      {hidden > 0 && (
        <button type="button" className="pp-prod-tab pp-prod-tab--more"
                title={'Show ' + hidden + ' more product' + (hidden === 1 ? '' : 's')}
                onClick={() => setExpanded(true)}>
          +{hidden}
        </button>
      )}
    </div>
  );
}

// The time chips for one venue × product. `cap` limits how many render before a
// "+N more" expander (the grid wants that; the list scrolls instead).
function TimeChips({ venue, productId, query, slots, loading, belowMin, onBook, cap }) {
  const [expanded, setExpanded] = React.useState(false);
  React.useEffect(() => { setExpanded(false); }, [productId, query.date, query.guests]);

  if (!query.searched) return <div className="pp-vcard-no-times">Pick a date and search to see times</div>;
  if (belowMin) return <div className="pp-vcard-no-times">Minimum {belowMin} guests for this product</div>;
  if (loading) {
    return (
      <React.Fragment>
        {Array.from({ length: 5 }).map((_, i) => (
          <span key={i} className="pp-time-chip-skel pp-shimmer" aria-hidden="true"/>
        ))}
      </React.Fragment>
    );
  }
  const all = slots || [];
  if (all.length === 0) return <div className="pp-vcard-no-times">No times for this date</div>;

  const visible = (!cap || expanded) ? all : all.slice(0, cap);
  const hidden = all.length - visible.length;
  return (
    <React.Fragment>
      {visible.map(s => {
        const isRequest = s.type === 'request';
        return (
          <button key={s.time + '-' + (s.type || 'book')}
                  type="button"
                  className={"pp-time-chip" + (isRequest ? " pp-time-chip--request" : "")}
                  title={(isRequest ? 'Enquiry — operator must approve.' : 'Instant confirmation.') + (s.price ? ' £' + s.price + ' per person' : '')}
                  onClick={() => onBook({ venueId: venue.id, productId, date: query.date, time: s.time, guests: query.guests, price: s.price, type: s.type, compositeId: s.compositeId, preOrderItems: s.preOrderItems })}>
            {s.time}
          </button>
        );
      })}
      {hidden > 0 && (
        <button type="button" className="pp-time-chip pp-time-chip--more" onClick={() => setExpanded(true)}>
          +{hidden} more
        </button>
      )}
    </React.Fragment>
  );
}

// ── List view — the default. A dense table: many more venues above the fold
// than the photo grid manages, and the same product switcher + time chips. ────
function VenueList({ cards, query, onBook, onOpen }) {
  return (
    <div className="pp-vlist">
      <div className="pp-vlist-head" aria-hidden="true">
        <div>Listing</div>
        <div>{query.searched ? 'Products' : 'Inventory'}</div>
        <div>{query.searched ? 'Times' : ''}</div>
      </div>
      {cards.map(c => (
        <VenueRow key={c.venue.id}
                  venue={c.venue}
                  defaultProductId={c.productId}
                  query={query}
                  onBook={onBook}
                  onOpen={onOpen}/>
      ))}
    </div>
  );
}

const VenueRow = React.memo(function VenueRow({ venue, defaultProductId, query, onBook, onOpen }) {
  const products = (venue.products || []).filter(Boolean);
  const s = useVenueSlots(venue, products, defaultProductId, query);
  const op = operatorById(venue.operator);
  const meta = [venue.city, ppArea(venue.area), op && op.name].filter(Boolean).join(' · ');
  return (
    <div className="pp-vrow">
      <button type="button" className="pp-vrow-listing" onClick={() => onOpen && onOpen(venue.id)}
              title={'Open ' + venue.name}>
        {venue.photos && venue.photos[0] ? (
          <img className={"pp-vrow-thumb pp-cover--" + (venue.tone || 'sage')}
               src={venue.photos[0]} alt="" loading="lazy" decoding="async"/>
        ) : (
          <span className={"pp-vrow-thumb pp-vrow-thumb--mono pp-cover--" + (venue.tone || 'sage')}>{monogramOf(venue.name)}</span>
        )}
        <span className="pp-vrow-listing-text">
          <span className="pp-vrow-name">{venue.name}</span>
          <span className="pp-vrow-meta">{meta}</span>
        </span>
      </button>

      <div className="pp-vrow-products">
        {query.searched ? (
          <ProductSwitch venue={venue} products={products} productId={s.productId}
                         byProduct={s.byProduct} onPick={s.pick} cap={4}/>
        ) : (
          <span className="pp-vrow-inventory">
            {products.map(pid => (productById(pid) || { name: pid }).name).join(', ')}
          </span>
        )}
        {s.preOrderLabel && (
          <span className="pp-vrow-preorder" title={s.preOrderLabel.title}>
            <IconBox size={11}/><span>{s.preOrderLabel.short}</span>
          </span>
        )}
      </div>

      <div className="pp-vrow-times">
        {query.searched ? (
          <React.Fragment>
            <div className="pp-vrow-chiprail">
              <TimeChips venue={venue} productId={s.productId} query={query}
                         slots={s.slots} loading={s.loading} belowMin={s.belowMin} onBook={onBook}/>
            </div>
            {s.minPrice ? <span className="pp-vcard-from">from £{s.minPrice}/pp</span> : null}
          </React.Fragment>
        ) : (
          <span className="pp-vrow-idle">Search for live times</span>
        )}
      </div>
    </div>
  );
});

// ── Grid view — photo cards. ─────────────────────────────────────────────────
const VenueCard = React.memo(function VenueCard({ venue, defaultProductId, query, onBook, onOpen }) {
  const products = (venue.products || []).filter(Boolean);
  const [saved, setSaved] = React.useState(false);
  const s = useVenueSlots(venue, products, defaultProductId, query);
  const G = glyphFor(s.productId);
  return (
    <article className="pp-vcard">
      <div className={"pp-vcard-cover pp-cover--" + (venue.tone || 'sage') + (venue.photos && venue.photos[0] ? ' has-photo' : '')}
           role="button" tabIndex={0} title={'Open ' + venue.name}
           onClick={() => onOpen && onOpen(venue.id)}
           onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen && onOpen(venue.id); } }}>
        {venue.photos && venue.photos[0] && (
          <img className="pp-vcard-cover-img"
               src={venue.photos[0]}
               alt=""
               decoding="async"
               loading="lazy"
               onError={(e) => { e.currentTarget.style.display = 'none'; e.currentTarget.parentNode.classList.remove('has-photo'); }}/>
        )}
        <button className={"pp-vcard-heart" + (saved ? " is-on" : "")} onClick={(e) => { e.stopPropagation(); setSaved(v => !v); }}
                aria-label={saved ? 'Unsave' : 'Save'} aria-pressed={saved}>
          <HeartIcon filled={saved}/>
        </button>
        {(!venue.photos || !venue.photos[0]) && (
          <div className="pp-vcard-cover-mark" aria-hidden="true">
            <span className="pp-vcard-cover-mono">{monogramOf(venue.name)}</span>
          </div>
        )}
        <span className="pp-vcard-cover-tag"><OperatorTag id={venue.operator} size="sm"/></span>
      </div>
      <div className="pp-vcard-body">
        <div className="pp-vcard-head">
          <div className="pp-vcard-title-wrap">
            <h3 className="pp-vcard-title">
              <button type="button" onClick={() => onOpen && onOpen(venue.id)}>{venue.name}</button>
            </h3>
            <div className="pp-vcard-loc"><IconPin size={11}/> {venue.city}</div>
          </div>
          <button className="pp-vcard-info" aria-label="Venue details" title="Venue details"
                  onClick={() => onOpen && onOpen(venue.id)}>
            <InfoIcon/>
          </button>
        </div>
        <div className="pp-vcard-section-label">
          Products <span className="pp-vcard-section-count">{products.length}</span>
        </div>
        <ProductSwitch venue={venue} products={products} productId={s.productId}
                       byProduct={s.byProduct} onPick={s.pick}/>
        {s.preOrderLabel && (
          <div className={"pp-vcard-preorder" + (s.preOrderLabel === PRE_ORDER_LABELS.optional ? ' pp-vcard-preorder--soft' : '')} title={s.preOrderLabel.title}>
            <IconBox size={12}/>
            <span>{s.preOrderLabel.short}</span>
          </div>
        )}
        <div className="pp-vcard-times-head">
          <span className="pp-vcard-section-label">
            <G size={12}/> {s.product.name}
            {s.minParty ? <span className="pp-vcard-minparty" title={'Minimum ' + s.minParty + (s.minParty === 1 ? ' guest' : ' guests') + ' for this product'}>min {s.minParty} {s.minParty === 1 ? 'guest' : 'guests'}</span> : null}
          </span>
          {s.minPrice ? <span className="pp-vcard-from">from £{s.minPrice}/pp</span> : null}
        </div>
        <div className="pp-time-chips">
          <TimeChips venue={venue} productId={s.productId} query={query}
                     slots={s.slots} loading={s.loading} belowMin={s.belowMin} onBook={onBook} cap={8}/>
        </div>
      </div>
    </article>
  );
});

function monogramOf(name) {
  return name.replace(/[^A-Za-z\s]/g, '').split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase();
}

function HeartIcon({ filled }) {
  return (
    <svg width="14" height="14" viewBox="0 0 24 24" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round">
      <path d="M12 21s-7-4.5-9.3-9.1A5.3 5.3 0 0 1 12 5.3a5.3 5.3 0 0 1 9.3 6.6C19 16.5 12 21 12 21Z"/>
    </svg>
  );
}

function InfoIcon() {
  return (
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
      <circle cx="12" cy="12" r="9"/>
      <path d="M12 11v5M12 8v.01" strokeLinecap="round"/>
    </svg>
  );
}

function EmptyResults({ onClear }) {
  return (
    <div className="pp-empty">
      <div className="pp-empty-glyph"><IconSearch size={28}/></div>
      <div className="pp-empty-title">No inventory matches</div>
      <div className="pp-empty-sub">Try a different date, loosen the product type, or broaden the location.</div>
      <button className="pp-btn pp-btn--ghost" onClick={onClear}>Clear filters</button>
    </div>
  );
}

// Map view — Leaflet split layout. List of venues on the left, real map with
// per-venue markers on the right. Hovering a list row pans to the marker;
// clicking either opens a popup with photo + name + city + a Book button.
function MapView({ cards, query, onBook, onOpen }) {
  const mapRef = React.useRef(null);
  const mapInstanceRef = React.useRef(null);
  const markersRef = React.useRef(new Map()); // venueId → L.marker
  const [activeId, setActiveId] = React.useState(null);

  const geo = React.useMemo(
    () => cards.filter(c => Number.isFinite(c.venue.lat) && Number.isFinite(c.venue.lng)),
    [cards]
  );

  // Init the map once.
  React.useEffect(() => {
    if (!mapRef.current || mapInstanceRef.current || !window.L) return;
    const map = window.L.map(mapRef.current, {
      zoomControl: true,
      attributionControl: true,
      worldCopyJump: true,
    }).setView([54.5, -2.5], 6);
    window.L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
      subdomains: 'abcd',
      maxZoom: 19,
    }).addTo(map);
    mapInstanceRef.current = map;
    return () => { map.remove(); mapInstanceRef.current = null; markersRef.current.clear(); };
  }, []);

  // Sync markers whenever the visible cards change.
  React.useEffect(() => {
    const map = mapInstanceRef.current;
    if (!map || !window.L) return;

    // Drop stale markers
    for (const [id, m] of markersRef.current) {
      if (!geo.find(c => c.venue.id === id)) { m.remove(); markersRef.current.delete(id); }
    }

    // Add new markers
    for (const c of geo) {
      if (markersRef.current.has(c.venue.id)) continue;
      const v = c.venue;
      const icon = window.L.divIcon({
        className: 'pp-pin-wrap',
        html: '<div class="pp-pin"><div class="pp-pin-inner"></div></div>',
        iconSize: [30, 30],
        iconAnchor: [15, 30],
        popupAnchor: [0, -28],
      });
      const marker = window.L.marker([v.lat, v.lng], { icon, riseOnHover: true })
        .addTo(map)
        .bindPopup(popupHTML(v, c.productId), { closeButton: true, autoClose: true, className: 'pp-popup-wrap' });
      marker.on('click', () => setActiveId(v.id));
      marker.on('popupopen', () => setActiveId(v.id));
      marker.on('popupclose', () => setActiveId(prev => prev === v.id ? null : prev));
      marker.getElement()?.querySelector('.pp-pin')?.setAttribute('data-vid', v.id);
      markersRef.current.set(v.id, marker);
    }

    // Wire popup CTA via event delegation on the map container.
    const onClick = (e) => {
      const cta = e.target.closest && e.target.closest('.pp-popup-cta');
      if (!cta) return;
      const vid = cta.getAttribute('data-vid');
      const pid = cta.getAttribute('data-pid');
      const c = geo.find(c => c.venue.id === vid);
      if (c) onBook({ venueId: vid, productId: pid || c.productId, date: query.date, guests: query.guests });
    };
    mapRef.current.addEventListener('click', onClick);

    // Fit bounds.
    if (geo.length) {
      const bounds = window.L.latLngBounds(geo.map(c => [c.venue.lat, c.venue.lng]));
      map.fitBounds(bounds, { padding: [40, 40], maxZoom: 13 });
    }

    return () => { mapRef.current && mapRef.current.removeEventListener('click', onClick); };
  }, [geo, query.date, query.guests, onBook]);

  // Highlight active marker.
  React.useEffect(() => {
    const allPins = mapRef.current?.querySelectorAll('.pp-pin') || [];
    allPins.forEach(p => p.classList.toggle('is-active', p.getAttribute('data-vid') === activeId));
  }, [activeId]);

  const focusVenue = (c) => {
    const map = mapInstanceRef.current;
    const marker = markersRef.current.get(c.venue.id);
    if (!map || !marker) return;
    map.setView([c.venue.lat, c.venue.lng], Math.max(map.getZoom(), 14), { animate: true });
    marker.openPopup();
    setActiveId(c.venue.id);
  };

  const missing = cards.length - geo.length;

  return (
    <div className="pp-mapview">
      <div className="pp-mapview-list">
        <div className="pp-mapview-list-head">
          <span><span className="pp-mapview-list-count">{geo.length}</span> on map</span>
          {missing > 0 && <span title="No coordinates from Bookable">{missing} unmapped</span>}
        </div>
        {geo.map(c => {
          const v = c.venue;
          const op = operatorById(v.operator);
          const product = productById(c.productId) || { name: (v.productNames && v.productNames[c.productId]) || c.productId };
          return (
            <button key={v.id}
                    className={"pp-mapview-row" + (activeId === v.id ? ' is-active' : '')}
                    onMouseEnter={() => setActiveId(v.id)}
                    onDoubleClick={() => onOpen && onOpen(v.id)}
                    onClick={() => focusVenue(c)}>
              {v.photos && v.photos[0] ? (
                <img className="pp-mapview-row-thumb" src={v.photos[0]} alt="" loading="lazy"/>
              ) : (
                <span className="pp-mapview-row-thumb-placeholder">{monogramOf(v.name)}</span>
              )}
              <span className="pp-mapview-row-body">
                <span className="pp-mapview-row-name">{v.name}</span>
                <span className="pp-mapview-row-meta">
                  <span><IconPin size={10}/> {v.city}{v.area ? ' · ' + v.area : ''}</span>
                </span>
                <span className="pp-mapview-row-meta">
                  {op && <OperatorTag id={v.operator} size="sm"/>}
                  <span className="pp-mapview-row-product">{product.name}</span>
                </span>
              </span>
            </button>
          );
        })}
        {geo.length === 0 && (
          <div className="pp-mapview-empty">No mappable venues for these filters.</div>
        )}
      </div>
      <div className="pp-mapview-mapwrap">
        <div ref={mapRef} className="pp-mapview-map"/>
      </div>
    </div>
  );
}

function popupHTML(v, productId) {
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
  const img = v.photos && v.photos[0] ? `<img class="pp-popup-img" src="${esc(v.photos[0])}" alt=""/>` : '';
  const productName = (v.productNames && v.productNames[productId]) || productId;
  return [
    '<div class="pp-popup">',
    img,
    '<div class="pp-popup-body">',
    `<h3 class="pp-popup-name">${esc(v.name)}</h3>`,
    `<div class="pp-popup-meta">${esc(v.city)}${v.area ? ' · ' + esc(v.area) : ''}</div>`,
    `<div class="pp-popup-meta">${esc(productName)}</div>`,
    `<button class="pp-popup-cta" data-vid="${esc(v.id)}" data-pid="${esc(productId)}">Book ${esc(productName)}</button>`,
    '</div></div>',
  ].join('');
}

function ppHaversineKm(lat1, lng1, lat2, lng2) {
  const R = 6371;
  const toRad = (d) => (d * Math.PI) / 180;
  const dLat = toRad(lat2 - lat1);
  const dLng = toRad(lng2 - lng1);
  const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

Object.assign(window, { ResultsScreen, ppHaversineKm, ppPageWindow, ppSoftTiers, ppTokenHit });
