// Live data layer — loads the operator + venue catalogue, bookings, and per-
// venue availability from /api/* (which proxies the Bookable production API).
// If the API is unreachable the UI renders a disconnected state.
//
// Loaded after data.jsx so the empty arrays exist as the base shape.

window.__pp_live = { ready: false, error: null, dataLive: false, emptyCatalogue: false, authError: null };
window.__pp_slot_cache = window.__pp_slot_cache || new Map();
window.__pp_session = null;

// ── Debug timing — TEMPORARY SCAFFOLDING (slow-initial-load hunt) ────────────
// ppDebug(msg, extra) stamps a line with the ms elapsed since navigation start,
// writes it to the browser console AND queues it for POST /api/client-log,
// which echoes it into the `vercel dev` terminal — so the client boot story and
// the server-side [pp-timing] lines read as one log. Batched (1s / 50 lines per
// POST) so an availability fan-out doesn't turn into a request storm.
// Remove together with api/client-log.js when the bottleneck is found.
const ppDebugQueue = [];
let ppDebugFlushTimer = null;
function ppDebugFlush() {
  ppDebugFlushTimer = null;
  if (!ppDebugQueue.length) return;
  const lines = ppDebugQueue.splice(0, 50);
  try {
    fetch('/api/client-log', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ lines }),
      keepalive: true,
    }).catch(() => {});
  } catch (e) { /* debug only — never break the app */ }
  if (ppDebugQueue.length) ppDebugFlushTimer = setTimeout(ppDebugFlush, 0);
}
function ppDebug(msg, extra) {
  const line = `+${Math.round(performance.now())}ms ${msg}` + (extra ? ' ' + JSON.stringify(extra) : '');
  console.log('[pp-timing]', line);
  ppDebugQueue.push(line);
  if (!ppDebugFlushTimer) ppDebugFlushTimer = setTimeout(ppDebugFlush, 1000);
}
// Marks how long the CDN scripts + Babel in-browser compilation of the files
// BEFORE this one took (this is the 3rd of 16 text/babel scripts).
ppDebug('bookable-api.jsx evaluating (Babel has compiled tweaks-panel + data + this file)');
// One-shot page-load summary: network/DOM phases + the slowest resources, so a
// slow CDN script or font is visible next to the API timings. Babel executes
// this file after DOMContentLoaded, so `load` may already have fired — run
// immediately in that case.
function ppLogPageTimings() {
  try {
    const nav = performance.getEntriesByType('navigation')[0];
    if (nav) ppDebug('page load summary', {
      ttfbMs: Math.round(nav.responseStart),
      domContentLoadedMs: Math.round(nav.domContentLoadedEventStart),
      loadMs: Math.round(nav.loadEventStart),
    });
    const slow = performance.getEntriesByType('resource')
      .filter(r => r.duration >= 100)
      .sort((a, b) => b.duration - a.duration)
      .slice(0, 10);
    for (const r of slow) {
      ppDebug('slow resource', {
        url: r.name.replace(location.origin, ''),
        ms: Math.round(r.duration),
        kb: Math.round((r.transferSize || 0) / 1024),
      });
    }
  } catch (e) { /* debug only */ }
}
if (document.readyState === 'complete') setTimeout(ppLogPageTimings, 0);
else window.addEventListener('load', () => setTimeout(ppLogPageTimings, 0));
// Auth gate state, consumed by app.jsx to avoid painting the portal chrome
// before we know whether the user is signed in:
//   'pending'  — auth not yet resolved (initial)
//   'authed'   — signed in (or auth disabled server-side)
//   'anon'     — not signed in; a redirect to login is in flight
//   'failed'   — sign-in isn't completing; we stopped redirecting (see
//                ppSignInFailed) and show the reason instead of looping
window.__pp_auth = 'pending';
function ppSetAuth(state) {
  window.__pp_auth = state;
  try { window.dispatchEvent(new CustomEvent('pp:auth', { detail: { state } })); } catch (e) {}
}

function ppSlotKey(compositeId, date, guests) {
  return compositeId + '|' + date + '|' + guests;
}

// Sandbox mode — when on, every /api/* data call carries X-Bookable-Env:
// sandbox, so the proxies target the Bookable sandbox host (test bookings +
// test availability) instead of production. The flag lives in localStorage so
// it survives the reload we trigger on toggle.
function ppSandboxOn() {
  // __pp_sandbox_fallback: set at boot when the signed-in user's PRODUCTION
  // catalogue is empty but the sandbox has their (synthetic) data — see ppBoot.
  // Runtime-only (deliberately not persisted) so it self-heals: every boot
  // re-checks production first and only falls back while prod stays empty.
  if (window.__pp_sandbox_fallback) return true;
  try { return localStorage.getItem('pp_sandbox') === '1'; } catch (e) { return false; }
}
function ppSetSandbox(on) {
  try { localStorage.setItem('pp_sandbox', on ? '1' : '0'); } catch (e) {}
  // Catalogue + bookings load once at boot into global arrays against a single
  // host; a reload re-runs that boot against the newly selected environment.
  window.location.reload();
}

function ppCurrentPath() {
  try { return window.location.pathname + window.location.search; }
  catch (e) { return '/'; }
}

// Why a login attempt came back unauthenticated. /api/auth/callback redirects to
// /?auth_error=<reason> on every failure path (state_mismatch, token_exchange_403,
// callback_failed…) — read it instead of bouncing straight back into the same
// failure, which spun 50+ login→callback→401 cycles unseen in staging.
function ppAuthErrorParam() {
  try { return new URLSearchParams(window.location.search).get('auth_error') || ''; }
  catch (e) { return ''; }
}

// Give up after 2 login bounces in 30s. Backstop for callback failures that
// carry no auth_error (or a session that can't be stored at all).
function ppLoginLoopTripped() {
  try {
    const now = Date.now();
    const prev = JSON.parse(sessionStorage.getItem('pp_login_attempts') || 'null') || { n: 0, t: 0 };
    const n = (now - prev.t > 30000 ? 0 : prev.n) + 1;
    sessionStorage.setItem('pp_login_attempts', JSON.stringify({ n, t: now }));
    return n > 2;
  } catch (e) { return false; }
}

function ppSignInFailed(reason) {
  window.__pp_live.authError = reason || 'unknown';
  ppSetAuth('failed');
}

// Bounce the browser to Auth0 login, preserving where the user was headed.
let ppRedirectingToLogin = false;
function ppRedirectToLogin() {
  if (ppRedirectingToLogin) return;
  if (ppLoginLoopTripped()) return ppSignInFailed('login_loop');
  ppRedirectingToLogin = true;
  window.location.href = '/api/auth/login?returnTo=' + encodeURIComponent(ppCurrentPath());
}

async function ppFetchJSON(url, opts, cfg) {
  opts = opts || {};
  cfg = cfg || {};
  // forceSandbox lets the boot sandbox-probe target the sandbox env before the
  // sandbox flag is on (see ppBoot); otherwise we honour the normal mode.
  if (cfg.forceSandbox || ppSandboxOn()) {
    opts = { ...opts, headers: { ...(opts.headers || {}), 'X-Bookable-Env': 'sandbox' } };
  }
  const t0 = performance.now();
  const res = await fetch(url, opts);
  const firstByteMs = Math.round(performance.now() - t0);
  // A 401 anywhere means the session is missing or expired (e.g. it lapsed
  // mid-session) — send the user back through login rather than surfacing a
  // raw error into the UI. The boot sandbox-probe passes noAuthRedirect so a
  // sandbox that rejects the forwarded prod token falls through to the normal
  // disconnected splash instead of bouncing the user into a login loop.
  if (res.status === 401) {
    ppDebug(`fetch ${opts.method || 'GET'} ${url} → 401`, { firstByteMs });
    if (!cfg.noAuthRedirect) ppRedirectToLogin();
    const err = new Error('unauthenticated');
    err.status = 401;
    throw err;
  }
  const text = await res.text();
  ppDebug(`fetch ${opts.method || 'GET'} ${url} → ${res.status}`, {
    firstByteMs,
    readMs: Math.round(performance.now() - t0) - firstByteMs,
    kb: Math.round(text.length / 1024),
  });
  let body; try { body = text ? JSON.parse(text) : null; } catch { body = null; }
  if (!res.ok) {
    const err = new Error('http_' + res.status);
    err.status = res.status; err.body = body;
    throw err;
  }
  return body;
}

async function ppLoadCatalogue(cfg) {
  // pagesHint: the upstream page count from this browser's previous boot (per
  // env). Upstream /venues costs ~13s per request regardless of page size, so
  // the hint lets the server fire every page in parallel instead of paying a
  // serial "page 1 first, learn totalPages" round trip on a cold instance.
  const hintKey = ((cfg && cfg.forceSandbox) || ppSandboxOn()) ? 'pp_pages_hint_sandbox' : 'pp_pages_hint';
  let hint = 0;
  try { hint = Number(localStorage.getItem(hintKey)) || 0; } catch (e) {}
  const data = await ppFetchJSON('/api/venues' + (hint ? `?pagesHint=${hint}` : ''), undefined, cfg);
  if (!data || !Array.isArray(data.operators) || !Array.isArray(data.venues)) return false;
  if (data.pages) { try { localStorage.setItem(hintKey, String(data.pages)); } catch (e) {} }
  if (!data.operators.length && !data.venues.length) return false;

  // Mutate the arrays in place so React closures keep working.
  window.OPERATORS.length = 0;
  data.operators.forEach(o => window.OPERATORS.push(o));
  window.VENUES.length = 0;
  data.venues.forEach(v => window.VENUES.push(v));

  // Rebuild CITIES from the venue list, alphabetical. No "All locations" sentinel:
  // the location filter is multi-select (BOO-644 QA), so an empty selection is
  // what "everywhere" means.
  const cities = new Set();
  data.venues.forEach(v => { if (v.city) cities.add(v.city); });
  window.CITIES.length = 0;
  [...cities].sort((a, b) => a.localeCompare(b, 'en-GB')).forEach(c => window.CITIES.push(c));

  return true;
}

async function ppLoadBookings() {
  const data = await ppFetchJSON('/api/bookings');
  if (!data || !Array.isArray(data.bookings)) return null;
  return data.bookings;
}

// Lazy pre-order catalogue for one venue. The boot catalogue no longer inlines
// venue.preorders (the menus/packages were most of a ~67MB payload) — the
// booking drawer pulls them here on open instead. Cached per venue for the
// session, and the venue object is patched in place so anything else reading
// venue.preorders (e.g. results-screen dish search) picks the data up too.
window.__pp_preorder_cache = window.__pp_preorder_cache || new Map();
async function ppLoadVenuePreorders(venue) {
  const empty = { menus: [], packages: [] };
  if (!venue) return empty;
  // Already populated (a prior fetch patched it in) — nothing to load.
  const have = venue.preorders;
  if (have && ((have.menus && have.menus.length) || (have.packages && have.packages.length))) return have;
  if (!venue.upstreamId) return have || empty;
  if (!window.__pp_preorder_cache.has(venue.upstreamId)) {
    window.__pp_preorder_cache.set(venue.upstreamId, (async () => {
      try {
        const data = await ppFetchJSON('/api/venue-preorders?venueId=' + encodeURIComponent(venue.upstreamId));
        const pre = (data && data.preorders) || empty;
        venue.preorders = pre;
        return pre;
      } catch (e) {
        // Don't cache a failure — the next drawer open retries.
        window.__pp_preorder_cache.delete(venue.upstreamId);
        console.warn('[Bookable] venue pre-orders unavailable', e);
        return empty;
      }
    })());
  }
  return window.__pp_preorder_cache.get(venue.upstreamId);
}

// `live` forces the live Bookable availability path (?live=1) instead of the
// feed — used by the booking drawer to pull per-slot pre-order scoping the feed
// can't carry. Cached under a distinct key so it never collides with feed slots.
function ppLoadAvailability(compositeId, date, guests, live) {
  if (!compositeId) return Promise.resolve([]);
  const key = ppSlotKey(compositeId, date, guests) + (live ? '|live' : '');
  // Cache the PROMISE, not just the result: a searched page asks for the same
  // composite from several rows at once (and a product bucketed under more than
  // one category asked twice on its own), so 50 rows produced 147 requests for
  // 98 distinct composites. Resolved entries stay in the same map, so a later
  // read is still a plain cache hit.
  if (window.__pp_slot_cache.has(key)) {
    ppDebug(`availability cache hit ${key}`);
    return Promise.resolve(window.__pp_slot_cache.get(key));
  }

  const url = `/api/availability?compositeId=${encodeURIComponent(compositeId)}&date=${date}&partySize=${guests}` + (live ? '&live=1' : '');
  const inflight = ppFetchJSON(url)
    .then(data => {
      const slots = (data && Array.isArray(data.slots)) ? data.slots : [];
      window.__pp_slot_cache.set(key, slots);
      return slots;
    })
    .catch(() => {
      // Don't cache a failure. A 503 (feed unavailable) or a dropped request is
      // not "this venue has nothing" — caching [] froze the card as fully booked
      // for the rest of the session, so re-opening or re-searching could never
      // recover. Drop the in-flight entry so the next attempt refetches.
      window.__pp_slot_cache.delete(key);
      return [];
    });
  window.__pp_slot_cache.set(key, inflight);
  return inflight;
}

async function ppCreateBooking(compositeId, payload) {
  return ppFetchJSON('/api/bookings', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ compositeId, ...payload }),
  });
}

async function ppUpdateBooking(bookingId, patchOps) {
  return ppFetchJSON('/api/booking?id=' + encodeURIComponent(bookingId), {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(patchOps),
  });
}

async function ppCancelBooking(bookingId) {
  return ppFetchJSON('/api/booking?id=' + encodeURIComponent(bookingId), { method: 'DELETE' });
}

// ── Integration-partner API keys ────────────────────────────────────────────
// `env` ('production' | 'sandbox') selects which Bookable environment's keys to
// manage — both are real and hit their own host server-side. Defaults to
// production.
// A 401 here means BOOKABLE rejected the forwarded token for the KEYS call (e.g.
// wrong-env host, or a partner the token can't manage) — the portal session is
// fine. noAuthRedirect keeps the failure inside the Settings screen instead of
// bouncing the whole app to login and losing the page.
const PP_KEYS_CFG = { noAuthRedirect: true };

async function ppLoadPartnerKeys(env) {
  const data = await ppFetchJSON('/api/partner-keys?env=' + encodeURIComponent(env || 'production'), undefined, PP_KEYS_CFG);
  return (data && Array.isArray(data.keys)) ? data.keys : [];
}

async function ppCreatePartnerKey(payload, rotateOf, env) {
  const params = new URLSearchParams({ env: env || 'production' });
  if (rotateOf) params.set('rotate', rotateOf);
  return ppFetchJSON('/api/partner-keys?' + params.toString(), {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload || {}),
  }, PP_KEYS_CFG);
}

async function ppRenamePartnerKey(clientId, name, env) {
  const params = new URLSearchParams({ env: env || 'production', clientId });
  return ppFetchJSON('/api/partner-keys?' + params.toString(), {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name }),
  }, PP_KEYS_CFG);
}

async function ppDeletePartnerKey(clientId, env) {
  const params = new URLSearchParams({ env: env || 'production', clientId });
  return ppFetchJSON('/api/partner-keys?' + params.toString(), { method: 'DELETE' }, PP_KEYS_CFG);
}

// ── Listings catalogue feed (BOO-136) ───────────────────────────────────────
// Discovery list of the pre-generated availability feed files this partner can
// bulk-pull from GCS instead of calling the live availability API.
async function ppLoadCatalogueFeed() {
  return ppFetchJSON('/api/catalogue-feed');
}

// Natural-language search — POSTs the agent's query to /api/search which
// passes it through Claude and returns structured filters (+ resolved
// near-by coordinates for any UK postcode it found).
async function ppAiSearch(query) {
  const today = new Date().toISOString().slice(0, 10);
  return ppFetchJSON('/api/search', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, today }),
  });
}

// React hook — pulls live slots from /api/availability. Returns null while the
// request is in flight so the caller can render a loading state; returns [] if
// the venue × product has no availability for the date.
//
// Multiple Bookable products can collapse into a single portal bucket (e.g.
// "BOTTOMLESS BEFORE 2PM" + "BOTTOMLESS AFTER 2PM" → bottomless-brunch).
// Fetch every composite in the bucket in parallel and merge results so the
// card surfaces the union of available times, with each slot carrying the
// compositeId it came from for use when booking.
function usePpAvailability(venue, productId, date, guests) {
  const compositesList = (venue && venue.compositesByProduct && venue.compositesByProduct[productId]) || null;
  const fallbackComposite = venue && venue.composites && venue.composites[productId];
  const composites = compositesList && compositesList.length ? compositesList
                   : fallbackComposite ? [fallbackComposite]
                   : [];
  const key = composites.join('|');
  const [slots, setSlots] = React.useState(null);
  React.useEffect(() => {
    let cancelled = false;
    if (!composites.length) { setSlots([]); return; }
    setSlots(null);
    Promise.all(composites.map(cid => ppLoadAvailability(cid, date, guests)))
      .then(results => {
        if (cancelled) return;
        const byTime = new Map();
        for (let i = 0; i < results.length; i++) {
          const cid = composites[i];
          for (const slot of (results[i] || [])) {
            const stamped = slot.compositeId ? slot : { ...slot, compositeId: cid };
            const existing = byTime.get(stamped.time);
            // Prefer instant-book over request; otherwise keep first seen.
            if (!existing || (existing.type === 'request' && stamped.type !== 'request')) {
              byTime.set(stamped.time, stamped);
            }
          }
        }
        setSlots([...byTime.values()].sort((a, b) => a.time.localeCompare(b.time)));
      });
    return () => { cancelled = true; };
  }, [key, productId, date, guests, venue?.id]);
  return slots;
}

// Multi-product variant of usePpAvailability. Resolves slots for every product
// in `productIds` concurrently so a venue card can render a switcher (chip per
// product) without re-fetching when the selection changes. Returns an object
// keyed by productId; values are null while loading, [] when no slots.
function useAllProductsAvailability(venue, productIds, date, guests, enabled = true) {
  const ids = (productIds || []).filter(Boolean);
  const key = (enabled ? '1' : '0') + '|' + (venue && venue.id) + '|' + ids.join(',') + '|' + date + '|' + guests;
  const [byProduct, setByProduct] = React.useState(() =>
    ids.reduce((acc, pid) => { acc[pid] = null; return acc; }, {})
  );
  React.useEffect(() => {
    let cancelled = false;
    // No availability fetch until the partner explicitly searches — browsing a
    // filtered listing should show venues without hitting the slots API.
    if (!enabled) { setByProduct({}); return; }
    if (!venue || !ids.length) { setByProduct({}); return; }
    setByProduct(ids.reduce((acc, pid) => { acc[pid] = null; return acc; }, {}));
    ids.forEach(pid => {
      const compositesList = (venue.compositesByProduct && venue.compositesByProduct[pid]) || null;
      const fallback = venue.composites && venue.composites[pid];
      const composites = compositesList && compositesList.length ? compositesList
                       : fallback ? [fallback]
                       : [];
      if (!composites.length) {
        if (!cancelled) setByProduct(prev => ({ ...prev, [pid]: [] }));
        return;
      }
      Promise.all(composites.map(cid => ppLoadAvailability(cid, date, guests)))
        .then(results => {
          if (cancelled) return;
          const byTime = new Map();
          for (let i = 0; i < results.length; i++) {
            const cid = composites[i];
            for (const slot of (results[i] || [])) {
              const stamped = slot.compositeId ? slot : { ...slot, compositeId: cid };
              const existing = byTime.get(stamped.time);
              if (!existing || (existing.type === 'request' && stamped.type !== 'request')) {
                byTime.set(stamped.time, stamped);
              }
            }
          }
          const sorted = [...byTime.values()].sort((a, b) => a.time.localeCompare(b.time));
          setByProduct(prev => ({ ...prev, [pid]: sorted }));
        });
    });
    return () => { cancelled = true; };
  }, [key]);
  return byProduct;
}

// Confirm the session before loading any data. Returns false if we've kicked
// off a redirect to login (caller should abort boot). When auth is disabled
// server-side, this resolves true with a null session.
async function ppCheckAuth() {
  let res;
  const t0 = performance.now();
  try {
    res = await fetch('/api/auth/me', { headers: { Accept: 'application/json' } });
    ppDebug(`fetch GET /api/auth/me → ${res.status}`, { ms: Math.round(performance.now() - t0) });
  } catch (e) {
    ppDebug('fetch GET /api/auth/me FAILED (network)', { ms: Math.round(performance.now() - t0) });
    // Network blip reaching our own function — let boot continue and surface
    // the usual Disconnected state rather than trapping the user.
    ppSetAuth('authed');
    return true;
  }
  if (res.status === 401) {
    // Came back from a failed callback: another redirect just re-runs the same
    // failure, so surface the reason instead.
    const authErr = ppAuthErrorParam();
    if (authErr) {
      ppSignInFailed(authErr);
      return false;
    }
    // Not signed in — mark anon so the app stays on the boot splash (no portal
    // chrome) while we redirect to login.
    ppSetAuth('anon');
    ppRedirectToLogin();
    return false;
  }
  try {
    const me = await res.json();
    window.__pp_session = me && me.user ? me.user : null;
    // Whether this user may manage integration-partner API keys.
    window.__pp_keys_enabled = !!(me && me.keysEnabled);
    // BOO-412: false ⇒ DistributorBookingOnly (own bookings only) → hide the
    // whole "For developers" section. Absent ⇒ true (back-compat).
    window.__pp_can_manage_keys = !me || me.canManageKeys !== false;
  } catch (e) { window.__pp_session = null; window.__pp_keys_enabled = false; window.__pp_can_manage_keys = true; }
  // Session is good — reset the loop counter so a later lapse gets full retries.
  try { sessionStorage.removeItem('pp_login_attempts'); } catch (e) {}
  ppSetAuth('authed');
  return true;
}

// Boot — fire and forget. Front-end re-renders when the event fires.
(async function ppBoot() {
  const bootT0 = performance.now();
  const bootStep = (msg, extra) => ppDebug(`boot: ${msg}`, { ...(extra || {}), bootMs: Math.round(performance.now() - bootT0) });
  bootStep('start', { sandbox: ppSandboxOn() });
  try {
    const authed = await ppCheckAuth();
    bootStep('auth resolved', { authed });
    if (!authed) return; // redirecting to login; stop here

    // Bookings only need auth, not the catalogue — fetch them in parallel with
    // the venues call and await further down (the enrichment there needs
    // VENUES loaded, but the network round-trip doesn't).
    const bookingsPromise = ppLoadBookings();
    bookingsPromise.catch(() => {}); // consumed at the await below

    let catalogue = await ppLoadCatalogue();
    bootStep('catalogue loaded', { live: !!catalogue, operators: window.OPERATORS.length, venues: window.VENUES.length });
    // 200 with nothing in it: the API answered, this partner just has no venues
    // shared with them. Tracked separately so the UI stops calling that an
    // outage (a real transport failure throws instead and lands in the catch).
    if (!catalogue) window.__pp_live.emptyCatalogue = true;
    // A signed-in partner whose PRODUCTION catalogue is empty (no operators/
    // venues mapped to their identity) often has only synthetic data in the
    // sandbox — and booking-only users can't even see the manual sandbox toggle
    // (BOO-412 hides "For developers"). Probe the sandbox; if it has their
    // venues, switch the whole portal into sandbox mode so the rest of boot
    // (bookings, availability) follows — instead of showing a dead "Can't reach
    // Bookable" splash. Only when not already in sandbox.
    if (!catalogue && !ppSandboxOn()) {
      let sandboxCatalogue = false;
      try {
        sandboxCatalogue = await ppLoadCatalogue({ forceSandbox: true, noAuthRedirect: true });
      } catch (e) { sandboxCatalogue = false; }
      bootStep('sandbox probe (prod catalogue was empty)', { sandboxHasData: !!sandboxCatalogue });
      if (sandboxCatalogue) {
        // Runtime-only flag (not persisted) — every boot re-checks prod first.
        window.__pp_sandbox_fallback = true;
        catalogue = true; // sandbox venues are already loaded into the globals
        window.__pp_live.emptyCatalogue = false;
      }
    }
    if (catalogue) window.__pp_live.dataLive = true;

    // Build a compositeId → venue lookup for resolving booking venue names.
    // Index EVERY composite the venue publishes, not just `composites` — that
    // holds one composite per portal category bucket (the first product to claim
    // it), so a booking against any other product of the same category missed
    // and the row fell back to whatever /venues/bookings sent for the venue,
    // which can be the area ("City Centre") rather than a venue name.
    const compToVenue = new Map();
    for (const v of window.VENUES) {
      for (const list of Object.values(v.compositesByProduct || {})) {
        for (const cid of (list || [])) compToVenue.set(cid, v);
      }
      for (const p of (v.rawProducts || [])) {
        if (p && p.compositeId) compToVenue.set(p.compositeId, v);
      }
      for (const cid of Object.values(v.composites || {})) compToVenue.set(cid, v);
    }

    // The parallel fetch above targeted production; if the sandbox fallback
    // flipped the env mid-boot, refetch so bookings match the catalogue.
    const bookings = await (window.__pp_sandbox_fallback ? ppLoadBookings() : bookingsPromise);
    bootStep('bookings loaded', { bookings: Array.isArray(bookings) ? bookings.length : null });
    if (Array.isArray(bookings)) {
      const enriched = bookings.map(b => {
        if (!b.compositeId) return b;
        const v = compToVenue.get(b.compositeId);
        if (!v) return b;
        return {
          ...b,
          venue: v.name,
          venueId: v.id,
          venuePhoto: v.photos && v.photos[0],
          operator: v.operator || b.operator,
          city: v.city || b.city,
          area: v.area || b.area,
        };
      });
      window.SEED_BOOKINGS.length = 0;
      enriched.forEach(b => window.SEED_BOOKINGS.push(b));
      window.__pp_live.bookingsLoaded = true;
    }

    // Build the category list from the real `category` slugs present across the
    // partner's venues + bookings — so every category they actually have shows
    // up (no more fixed 8-bucket taxonomy). See ppRebuildCategories in data.jsx.
    if (typeof window.ppRebuildCategories === 'function') {
      const slugs = new Set();
      for (const v of window.VENUES) {
        for (const pid of (v.products || [])) slugs.add(pid);
      }
      for (const b of window.SEED_BOOKINGS) if (b.product) slugs.add(b.product);
      window.ppRebuildCategories([...slugs]);
    }
  } catch (e) {
    window.__pp_live.error = e?.message || String(e);
    // handleError's `detail` (e.g. "Set BOOKABLE_CLIENT_ID…") rides on err.body
    // and used to be dropped on the floor — it's the actionable half.
    window.__pp_live.errorDetail = (e && e.body && (e.body.detail || e.body.error)) || null;
    bootStep('FAILED', { error: window.__pp_live.error });
    console.error('[Bookable] live data unavailable', e);
  } finally {
    window.__pp_live.ready = true;
    bootStep('done — pp:data-loaded dispatched, UI leaves the loading splash', { live: window.__pp_live.dataLive });
    ppDebugFlush();
    window.dispatchEvent(new CustomEvent('pp:data-loaded', { detail: { live: window.__pp_live.dataLive } }));
  }
})();

Object.assign(window, {
  ppDebug, // debug scaffolding — see the timing block at the top of this file
  ppSandboxOn,
  ppSetSandbox,
  ppLoadAvailability,
  ppLoadVenuePreorders,
  ppCreateBooking,
  ppUpdateBooking,
  ppCancelBooking,
  ppLoadPartnerKeys,
  ppCreatePartnerKey,
  ppRenamePartnerKey,
  ppDeletePartnerKey,
  ppLoadCatalogueFeed,
  ppAiSearch,
  usePpAvailability,
  useAllProductsAvailability,
});
