boot.tstypescript
import { bindAll, type Bindings } from './bindings';
import { browserOptions, browserStorage } from './dom/browser';
import { pageEnvironment } from './dom/environment';
import { runReadyQueue } from './dom/ready';
import type { BootConfig } from './config';
import { Engine } from './engine/engine';
import { envelope, PUBLIC, type SdkEvent } from './engine/events/bus';
import type { Logger } from './engine/runtime/log';
import { funnelFromPath, storageScope } from './engine/visit/scope';
import type { TrackingEvent, TrackingName } from './engine/events/analytics';
import type { EventMap, EventName, InternalEventName } from './engine/events/bus';
import type { EventContext } from './engine/events/context';
import type { Snapshot } from './engine/events/sdk';
import type { Campaign, Send } from './engine/types';
import type { NextActions } from './engine/actions';
import type { ModelType, Query } from './engine/display/query';
import { announceLegacyReady, legacyApi, type LegacyApi } from './compat/legacy/api';
import { legacyEvents, type LegacyEvents } from './compat/legacy/registry';
import {
  legacyAnalytics,
  nextConfig,
  readLegacyAnalytics,
  readLegacyI18n,
  removeLegacyPersonalData,
} from './compat/legacy/globals';
import { triedConfig, withTried } from './engine/visit/debug-config';
import { localeOf } from './engine/display/format';
import { trackingIgnored } from './engine/visit/params';
import { rulesSend, textsIn, type Texts } from './engine/display/texts';

declare const __VERSION__: string;

const ORDER_MARKUP =
  '[data-next-upsell], [data-next-order-items], [data-next-display^="order."], [data-next-show^="order."], [data-next-hide^="order."]';
/** 0.4.x and 0.5 markup the checkout and order chunks bind, beside what `bindAll` binds. */
const LEGACY_FORMS =
  '[data-next-checkout-field], [os-checkout-field], [data-next-upsell], [data-next-upsell-action], [data-next-order-items]';
const V2 =
  '[data-next-query],[data-next-each],[data-next-for],[data-next-selector],[data-next-option],[data-next-field],[data-next-action]:not([data-next-action=add-to-cart],[data-next-action=select-variants],[data-next-action=checkout])';

const v2In = (root: ParentNode) => !!root.querySelector(V2) || (root instanceof Element && root.matches(V2));

/** The names `next.on` takes: every event but the SDK's internal ones. */
export type PublicEventName = Exclude<EventName, InternalEventName>;

export interface NextApi extends LegacyApi {
  version: string;
  /**
   * Calls `fn` with every `name` event, or every event for `*`, starting with the ones this page
   * sent before the call. A 0.4.x name gets its 0.4.x payload, and only the events after the call
   * unless 0.4.x pages listen for it late (`campaign:loaded`, `checkout:started`, …).
   */
  on<K extends PublicEventName>(
    name: K | 'ready' | '*',
    fn: (data: EventMap[K], event: SdkEvent<K>) => void,
  ): () => void;
  analytics: {
    /** Calls `fn` with every `name` tracking event, or every one for `*`, starting with the ones this page already sent. */
    on(name: TrackingName | '*', fn: (event: TrackingEvent) => void): () => void;
  };
  /** What the page shows now: the campaign, cart, checkout, order and selectors, as the events carry them. */
  snapshot(): Snapshot;
  /** The SDK's internal state, for debugging; its shape changes between releases. Read `snapshot()` instead. */
  getState: Engine['getState'];
  /** The page, campaign, session, attribution and browser now, as on tracking events. */
  context(): EventContext;
  log: Pick<Logger, 'level' | 'entries' | 'stream'>;
  bind(root: ParentNode): void;
  /** Reads a path of the object model, as `data-next-query` does: `await next.query('cart.total')`. Its code loads on the first call. */
  query(path: string): Promise<unknown>;
  /** Calls `fn` with the path's value once its code has loaded, then each time it changes; returns an unsubscribe function. */
  watch(path: string, fn: (value: unknown) => void): () => void;
  /** Adds a field the page computes to every object of a type, read like the SDK's own: `next.define('Option', 'per_day', (o) => o.total / 30, { format: 'currency' })`. */
  define(type: ModelType, name: string, derive: (object: any) => unknown, options?: { format?: string }): void;
  /** Every action `data-next-action` names, taking one object of the model's field names and resolving to `{ ok, errors }`. */
  actions: NextActions;
}

/** @internal */
export function boot(config: BootConfig, campaign: Promise<Campaign>): NextApi {
  const params = new URLSearchParams(location.search);
  const funnel = config.funnel || funnelFromPath(location.pathname);
  const scope = storageScope(config.apiKey, funnel);
  const page = nextConfig();
  const tried = triedConfig(
    browserStorage(() => sessionStorage),
    config.debug,
  );
  if (tried) Object.assign(window, { nextConfig: withTried(page, tried) });
  const i18n = readLegacyI18n();
  const send = window.fetch.bind(window);
  const rules = rulesSend(send, config.apiKey);

  const engine = new Engine({
    environment: pageEnvironment,
    apiKey: config.apiKey,
    apiBase: config.apiBase,
    send,
    campaign,
    storagePrefix: `next:${scope}`,
    ...browserOptions(),
    currency: config.currency,
    locale: config.lang || i18n.locale,
    params: Object.fromEntries(params),
    funnel: config.funnel || undefined,
    reset: config.reset,
    debug: config.debug,
  });
  if (engine.debug) {
    engine.log.info(`storage scope ${scope} (funnel "${funnel || '(none)'}")`);
    const arrived = { lines: engine.cart.lines(), reset: config.reset };
    void import('./debug').then((m) =>
      m.startDebug(engine, {
        version: __VERSION__,
        apiBase: config.apiBase,
        scope,
        funnel,
        arrived,
        reset: () => location.assign(`${location.pathname}?reset=1`),
        off: () => location.assign(`${location.pathname}?debug=0`),
        send: rules,
        page,
        tried: !!tried,
        analytics: () => ({ ...readLegacyAnalytics(), ignored: trackingIgnored(engine.getState().visit.params) }),
      }),
    );
  }

  const paymentFailed = params.get('payment_failed') === 'true';
  if (config.reset || paymentFailed) {
    for (const name of ['reset', 'payment_failed', 'payment_method']) params.delete(name);
    history.replaceState(history.state, '', `${location.pathname}${params.size ? `?${params}` : ''}${location.hash}`);
  }

  const legacy = legacyEvents(engine, () => import('./compat/legacy'));
  const bindings = bindAll(document, engine);
  if (bindings.legacy || !v2In(document) || document.querySelector(LEGACY_FORMS)) void legacy.preload();
  const [next, queried, bindV2] = nextApi(engine, legacy, bindings);
  (window as unknown as { next: NextApi }).next = next;
  const orderParams = params.has('ref_id') || params.has('order_ref_id') || paymentFailed;
  const bindChunks = pageChunks(engine, legacy, queried, orderParams, rules, i18n.translations);
  revealWhenBound(engine, bindings, next, bindChunks, bindV2, config.apiKey);
  engine.on('sdk.error', (e) => {
    if (e.data.action === 'campaign.load') document.documentElement.setAttribute('data-next-error', 'campaign');
  });
  void engine.start();

  addEventListener('pageshow', (e) => {
    if (e.persisted) location.reload();
  });
  removeLegacyPersonalData();

  return next;
}

/** Loads the lazy chunks the page's markup needs; the function it returns binds them once the campaign has loaded. */
function pageChunks(
  engine: Engine,
  legacy: LegacyEvents,
  queried: () => Promise<Query>,
  orderParams: boolean,
  rules: Send,
  translations: Record<string, Texts>,
): (v2Page: boolean) => unknown[] {
  const form = document.querySelector<HTMLFormElement>('form[data-next-checkout]');
  const checkout = form ? import('./checkout') : undefined;
  if (form) {
    for (const href of ['https://payments.29next.com', 'https://core.spreedly.com']) {
      document.head.append(Object.assign(document.createElement('link'), { rel: 'preconnect', href, crossOrigin: '' }));
    }
  }
  const order = document.querySelector(ORDER_MARKUP) || orderParams ? import('./order') : undefined;
  const widgets = document.querySelector(
    '[data-next-accordion], [data-next-tooltip], [data-next-component="scroll-hint"], [data-next-timer]',
  );
  if (widgets)
    void import('./compat/widgets').then((m) =>
      m.bindWidgets(document, {
        timerStart: (id, ms) => engine.visit.timerStart(id, ms),
        log: engine.log,
        events: legacy.widgets,
      }),
    );
  const i18n = document.querySelector('[data-next-i18n]') ? import('./attributes/i18n') : undefined;
  const slots = document.querySelector('[data-next-bundle-slot-template-id], [data-next-bundle-slot-template]')
    ? import('./bindings/slots')
    : undefined;
  return (v2Page) => [
    form &&
      Promise.all([checkout!, v2Page && queried()]).then(([m, q]) =>
        m.bindCheckout(engine, form, {
          rules,
          legacyCheckoutStarted: legacy.checkoutStarted,
          query: q || undefined,
          translations,
        }),
      ),
    order?.then((m) => m.bindOrder(engine)),
    slots?.then((m) => m.bindSlots(engine)),
    i18n?.then((m) => {
      const lang = localeOf(engine.getState()) || 'en';
      void m.bindI18n(document, { send: rules, lang, page: textsIn(translations, lang), log: engine.log });
    }),
  ];
}

/** `window.next`, with the query and attributes v2 loaders it shares with the page's own binding. */
function nextApi(
  engine: Engine,
  legacy: LegacyEvents,
  bindings: Bindings,
): [NextApi, () => Promise<Query>, () => Promise<{ bind(root: ParentNode): void }>] {
  let query: Promise<Query> | undefined;
  const queried = () => (query ??= import('./engine/display/query').then((m) => new m.Query(engine)));
  let attributes: Promise<typeof import('./attributes')> | undefined;
  /** Every `next.actions` call loads the actions on first use; 0.5's positional calls work there too. */
  const actions = new Proxy({} as NextActions, {
    get:
      (_, name: string) =>
      (...args: unknown[]) =>
        (attributes ??= import('./attributes')).then((m) => m.runAction(engine, name, args)),
  });
  let v2: Promise<{ bind(root: ParentNode): void }> | undefined;
  const bindV2 = () =>
    (v2 ??= Promise.all([(attributes ??= import('./attributes')), queried()]).then(([m, q]) =>
      m.bindAttributes(document, engine, q, { deferred: bindings.deferred }),
    ));
  const next: NextApi = {
    version: __VERSION__,
    on: (name, fn) => {
      const legacyOff = legacy.on(name, (data, e) =>
        fn(data as never, (e && envelope(e, engine.sessionId())) as never),
      );
      if (legacyOff) return legacyOff;
      const event = (name === 'ready' ? 'sdk.ready' : name) as EventName | '*';
      if (event !== '*' && !PUBLIC.has(event)) {
        engine.log.warn(`next.on: unknown event "${name}"`);
        return () => {};
      }
      return engine.on(
        event,
        (e) => {
          if (event === '*' && !PUBLIC.has(e.name)) return;
          fn(e.data as never, envelope(e, engine.sessionId()) as never);
        },
        { replay: true },
      );
    },
    analytics: {
      on: (name, fn) =>
        engine.on(
          'analytics',
          (e) => {
            if (name === '*' || e.data.event_type === name) fn(e.data);
          },
          { replay: true },
        ),
    },
    snapshot: () => engine.snapshot(),
    getState: () => engine.getState(),
    context: () => engine.eventContext(),
    log: engine.log,
    bind: (root) => {
      bindings.bind(root);
      if (v2 || v2In(root)) void bindV2().then((b) => b.bind(root));
    },
    query: (path) => queried().then((q) => q.get(path)),
    watch: (path, fn) => {
      const off = queried().then((q) => q.watch(path, fn));
      return () => void off.then((stop) => stop());
    },
    define: (...args) => void queried().then((q) => q.define(...args)),
    actions,
    ...legacyApi(engine),
  };
  return [next, queried, bindV2];
}

/**
 * Once the campaign has loaded and the page's chunks are bound, shows the page, runs its ready callbacks and
 * starts analytics when the browser is idle.
 */
function revealWhenBound(
  engine: Engine,
  bindings: Bindings,
  next: NextApi,
  bindChunks: (v2Page: boolean) => unknown[],
  bindV2: () => Promise<unknown>,
  apiKey: string,
) {
  const reveal = () => {
    try {
      bindings.render();
    } finally {
      document.documentElement.setAttribute('data-next-ready', '');
      performance.mark?.('next:ready');
    }
    announceLegacyReady(next);
    runReadyQueue(next, engine.log);
    const analytics = legacyAnalytics(engine.log);
    const ignored = trackingIgnored(engine.getState().visit.params);
    if (ignored) engine.log.info('analytics off for this tab: ?ignore=true (?ignore=false turns it back on)');
    if (analytics.enabled && !ignored) {
      const start = () =>
        import('./analytics').then((m) =>
          m.startAnalytics(engine, {
            apiKey,
            version: next.version,
            nextCampaign: analytics.nextCampaign,
            legacy: analytics,
          }),
        );
      (window.requestIdleCallback ?? setTimeout)(() => void start());
    }
  };
  engine.on('sdk.ready', () => {
    const v2Page = v2In(document) || bindings.deferred.size > 0;
    const binding = [...bindChunks(v2Page), v2Page && bindV2(), bindings.ready].filter(Boolean);
    if (binding.length) void Promise.allSettled(binding).then(reveal);
    else reveal();
  });
}