/**
 * Storefront session replay, confined to the modal.
 *
 * Three constraints shape everything here.
 *
 * **Bundle.** This widget builds as a single IIFE (see vite.config.ts), so there
 * is no code-splitting to lean on: importing posthog-js would put ~180KB of
 * recorder into the bundle, and the plugin's whole promise is that it is a thin
 * connector that keeps the merchant's store fast. So the SDK is fetched as a
 * script tag from our own ingest proxy, on demand, and the bundle grows by this
 * file alone.
 *
 * **Consent.** Nothing here runs before the merchant's CMP grants analytics AND
 * the shopper has opened the modal. Loading the SDK is itself a request to our
 * proxy, so "load it early and start later" would already be a request a
 * non-consenting shopper never agreed to. WordPress has no platform consent
 * API, so the answer comes from the bootstrap's CMP reader — the only code that
 * knows what the merchant's banner said.
 *
 * **Scope.** A replay of a WooCommerce product page would capture the theme's
 * newsletter field, whatever chat plugin the merchant runs, and every other
 * script on the page. Recording starts when the modal mounts and stops when it
 * closes — but that bounds it in time, not in space, because rrweb's first act
 * is a full-document snapshot. So everything outside our own overlay is blocked
 * outright (see BLOCK_SELECTOR), every input is masked, and every image is
 * blocked even inside the modal: the shopper's photo and their try-on result
 * are the entire reason this feature is sensitive.
 */
import { sanitizeBrowserEvent } from './browserSanitize';

export interface ReplayConfig {
  /** Browser project key. Empty means replay is off; nothing is requested. */
  key: string;
  /** Ingest proxy origin. Never posthog.com directly. */
  host: string;
  /** Server-side switch, so replay can be turned off without a plugin release. */
  enabled: boolean;
  environment: string;
  store: string;
}

/**
 * The only values posthog-js recognises for `person_profiles`.
 *
 * It does not validate this option: an unrecognised literal is ignored and the
 * SDK falls back to its default, so a typo — or a plausible-looking `never` —
 * reads in review as a privacy guarantee while meaning nothing at all. Typing
 * the option against this list makes that a compile error instead.
 */
export const SUPPORTED_PERSON_PROFILES = ['always', 'identified_only'] as const;
export type PersonProfiles = (typeof SUPPORTED_PERSON_PROFILES)[number];

/**
 * No person profile for an anonymous shopper.
 *
 * `identified_only` is how posthog-js spells that: a profile appears only once
 * `identify()` is called, and the storefront never calls it.
 */
const PERSON_PROFILES: PersonProfiles = 'identified_only';

interface PostHogLike {
  init: (key: string, options: Record<string, unknown>) => void;
  startSessionRecording?: () => void;
  stopSessionRecording?: () => void;
  register?: (properties: Record<string, unknown>) => void;
  opt_out_capturing?: () => void;
  opt_in_capturing?: () => void;
  __loaded?: boolean;
}

/** posthog-js's library object, whose `init` can mint a NAMED instance. */
interface PostHogLibrary extends PostHogLike {
  init: (key: string, options: Record<string, unknown>, name?: string) => PostHogLike | undefined;
  [instanceName: string]: unknown;
}

/**
 * The name of the instance Aisthetix owns, and the reason it exists.
 *
 * A WooCommerce merchant running their own PostHog plugin is ordinary, not
 * exotic. Their `window.posthog` is configured for THEIR project: their key,
 * their proxy, no masking, no block selector. Reusing it — which the loader
 * used to do whenever `__loaded` was set — would start a recorder on their
 * configuration, so our `before_send`, our memory persistence, our masks and
 * the modal `blockSelector` would never be installed. That is not a degraded
 * recording; it is a full-page recorder owned by another script, and it defeats
 * both the URL sanitation and the entire confinement guarantee at once.
 *
 * posthog-js supports naming an instance: `init(key, options, name)` creates or
 * returns one entirely separate from the default. We only ever touch ours.
 */
export const AISTHETIX_INSTANCE = 'aisthetixReplay';

/** The library object, if any script has loaded it. */
function posthogLibrary(): PostHogLibrary | undefined {
  return (window as unknown as { posthog?: PostHogLibrary }).posthog;
}

/**
 * OUR instance, held explicitly.
 *
 * A module reference rather than rediscovering `window.posthog` on every stop
 * and opt-in: the global can be replaced by a later plugin, and calling
 * `opt_out_capturing()` on whatever happens to be there would silently switch
 * off the merchant's own analytics.
 */
let instance: PostHogLike | null = null;

let loading: Promise<PostHogLike | null> | null = null;
let recording = false;
/** Whether a withdrawal has told the SDK to stop sending anything at all. */
let optedOut = false;

/** The class the widget puts on anything that must never be recorded. */
export const NO_CAPTURE_CLASS = 'ph-no-capture';

/**
 * The class on the one element a replay is allowed to contain.
 *
 * Exported and consumed by Modal.tsx rather than written out twice: the
 * recording's entire scope is "this element and its children", so a rename that
 * only reached one of the two files would silently widen it to the whole page.
 */
export const MODAL_OVERLAY_CLASS = 'aisthetix-modal-overlay';

/**
 * Whether the element a recording is confined to is actually in the DOM.
 *
 * The precondition for recording, stated as the thing it actually is. A React
 * ref set during render is not that: it still holds the previous render's value
 * inside the click handler that opened the modal, so a check written against it
 * answers "closed" at exactly the moment the shopper opened it — and for an
 * already-consented shopper, nothing ever asks again.
 *
 * Asking the DOM is also the stronger question. rrweb's first act is a
 * full-document snapshot; taken before the overlay mounts, every node matches
 * the block selector and the recording contains nothing at all, with no error
 * anywhere to notice.
 */
export function modalIsMounted(): boolean {
	try {
		return document.querySelector( `.${ MODAL_OVERLAY_CLASS }` ) !== null;
	} catch {
		// No document (SSR, a sandboxed context): not mounted, so not recording.
		return false;
	}
}

/**
 * What the recorder must refuse to look at.
 *
 * Starting the recorder when the modal opens bounds the recording in TIME, and
 * that is all it does: rrweb's first act is a full-document snapshot, so the
 * theme's newsletter field, the merchant's chat plugin and every other script
 * on the page would all be in frame one.
 *
 * So scope is stated positively: every direct child of `body` is blocked EXCEPT
 * our own overlay. That is an allowlist of one, and a WordPress plugin that
 * appends its own node to the body — which is how they all arrive — is blocked
 * by default rather than needing to be predicted here.
 *
 * Images, canvases and videos stay blocked on top of that, because they are
 * blocked INSIDE the modal too: the shopper's photo, the processing preview,
 * the result and the comparison are all `<img>`.
 */
export const BLOCK_SELECTOR = [
  `body > *:not(.${MODAL_OVERLAY_CLASS})`,
  'img',
  'canvas',
  'video',
  `.${NO_CAPTURE_CLASS}`,
].join(', ');

/**
 * Load the recorder from our own proxy, once.
 *
 * @param config - Where to load from and under which key
 * @returns The SDK, or null when it could not be loaded
 */
function loadRecorder(config: ReplayConfig): Promise<PostHogLike | null> {
  if (loading) return loading;

  loading = new Promise<PostHogLike | null>((resolve) => {
    try {
      // Already loaded by the merchant — by their own PostHog plugin, or by
      // ours on an earlier open. Either way the LIBRARY is here and only our
      // INSTANCE is missing, so there is nothing to fetch.
      const present = posthogLibrary();
      if (present?.__loaded) {
        resolve(initAisthetixInstance(present, config));
        return;
      }

      const script = document.createElement('script');
      script.src = `${config.host.replace(/\/$/, '')}/static/array.js`;
      script.async = true;
      // WP Rocket, LiteSpeed and Perfmatters rewrite and defer third-party
      // scripts. This one is already deferred by construction — it is injected
      // on modal open — and must be left alone, exactly like the bootstrap.
      script.setAttribute('data-no-optimize', '1');
      script.setAttribute('data-cfasync', 'false');
      script.onload = () => {
        const library = posthogLibrary();
        if (!library) {
          resolve(null);
          return;
        }
        resolve(initAisthetixInstance(library, config));
      };
      script.onerror = () => resolve(null);
      document.head.appendChild(script);
    } catch {
      resolve(null);
    }
  });

  return loading;
}

/**
 * Create (or retrieve) the instance Aisthetix records through.
 *
 * Never the default one. See AISTHETIX_INSTANCE for why that distinction is the
 * whole privacy guarantee rather than a tidiness preference.
 *
 * @param library - posthog-js, however it got here
 * @param config - Where to send and under which key
 * @returns Our instance, or null when it could not be created
 */
function initAisthetixInstance(library: PostHogLibrary, config: ReplayConfig): PostHogLike | null {
  try {
    const created = library.init(config.key, {
        api_host: config.host,
        // Memory only: no cookies, no localStorage. A shopper's device is
        // left exactly as it was found, and there is no identifier to
        // outlive the session.
        persistence: 'memory',
        autocapture: false,
        capture_pageview: false,
        capture_pageleave: false,
        // The only hook that sees every outgoing event, $snapshot included.
        // capture_pageview: false suppresses the automatic pageview EVENT;
        // it does not stop posthog-js attaching $current_url, $referrer and
        // their "initial" copies to everything else — and none of that
        // browser traffic passes through the brain's sanitizer. See
        // browserSanitize.ts.
        before_send: sanitizeBrowserEvent,
        disable_surveys: true,
        // Recording starts explicitly, when the modal mounts — never at init.
        disable_session_recording: true,
        // No person profile for an anonymous shopper. See PERSON_PROFILES.
        person_profiles: PERSON_PROFILES,
        session_recording: {
          maskAllInputs: true,
          maskTextSelector: '[data-ph-mask]',
          // Everything outside our own modal, plus every image inside it.
          // See BLOCK_SELECTOR — this is the whole confinement guarantee.
          blockSelector: BLOCK_SELECTOR,
          collectFonts: false,
          recordCrossOriginIframes: false,
        },
      },
      // The name is the whole point. Without it this is `init` on the DEFAULT
      // instance, which on a merchant storefront may be theirs.
      AISTHETIX_INSTANCE
    );
    // `init` returns the named instance; the property lookup is the documented
    // fallback for builds that return void.
    const named = created ?? (library[AISTHETIX_INSTANCE] as PostHogLike | undefined);
    if (!named) return null;

    named.register?.({
      environment: config.environment,
      store: config.store,
      surface: 'storefront',
      platform: 'woocommerce',
    });
    instance = named;
    return named;
  } catch {
    return null;
  }
}

/** Whether replay is configured to run at all. */
export function replayConfigured(config: ReplayConfig | null | undefined): boolean {
  return Boolean(config?.enabled && config.key && config.host);
}

/**
 * Begin recording, loading the SDK first if needed.
 *
 * Both gates are re-checked after the load resolves, because loading takes time
 * and a shopper can close the modal or withdraw consent while it is in flight.
 *
 * @param config - Replay configuration from the widget config
 * @param stillAllowed - Re-checked after the async load: consent and modal state
 */
export async function startReplay(
  config: ReplayConfig | null | undefined,
  stillAllowed: () => boolean
): Promise<void> {
  if (!replayConfigured(config) || !config) return;
  if (!stillAllowed()) return;
  if (recording) return;

  const sdk = await loadRecorder(config);
  if (!sdk) return;

  // The gate that matters: by now the shopper may have closed the modal or
  // withdrawn consent, and starting anyway would record neither of their wishes.
  if (!stillAllowed()) return;

  try {
    // Undo a previous withdrawal. `opt_out_capturing` is sticky for the page,
    // so without this a shopper who declined and then accepted again would get
    // a recorder that reports itself as running and sends nothing.
    if (optedOut) {
      sdk.opt_in_capturing?.();
      optedOut = false;
    }
    sdk.startSessionRecording?.();
    recording = true;
  } catch {
    recording = false;
  }
}

/**
 * React to the shopper changing their mind, whichever way it goes.
 *
 * The withdrawal half was the only half that existed, and it made the grant
 * half impossible to notice: a shopper who opened the modal before the CMP
 * banner was answered and then accepted was never recorded, because the only
 * code that could have started the recorder ran on modal open and had already
 * decided.
 *
 * @param granted - Whether product analytics are now allowed
 * @param config - Replay configuration from the widget config
 * @param stillAllowed - Re-checked after the async load: consent and modal state
 */
export async function applyConsentDecision(
  granted: boolean,
  config: ReplayConfig | null | undefined,
  stillAllowed: () => boolean
): Promise<void> {
  if (!granted) {
    revokeReplay();
    return;
  }
  await startReplay(config, stillAllowed);
}

/**
 * Stop recording.
 *
 * Called on modal close, on unmount, and on consent withdrawal. Safe to call
 * when nothing is recording — which is most of the time, and is why it must be.
 */
export function stopReplay(): void {
  if (!recording) return;
  try {
    instance?.stopSessionRecording?.();
  } catch {
    // Nothing to do; the flag below still stops us claiming we are recording.
  }
  recording = false;
}

/**
 * Stop recording and stop sending.
 *
 * The withdrawal path: `opt_out_capturing` tells the SDK to stop sending
 * anything at all, not just to pause the recorder. It is sticky, which is the
 * point — and also why `startReplay` has to undo it explicitly if the same
 * shopper accepts again on the same page.
 */
export function revokeReplay(): void {
  stopReplay();
  try {
    instance?.opt_out_capturing?.();
    optedOut = true;
  } catch {
    // Best-effort; recording has already stopped.
  }
}

/** Whether a recording is currently running. Exported for tests. */
export function isRecording(): boolean {
  return recording;
}

/** Reset module state. Tests only. */
export function resetReplayForTests(): void {
  loading = null;
  instance = null;
  recording = false;
  optedOut = false;
}
