/**
 * What posthog-js is allowed to put on the wire from a browser.
 *
 * The server sanitizer never sees this traffic. Session replay, `identify` and
 * every manual capture go straight from the browser to the ingest proxy, and
 * posthog-js attaches its own page metadata to all of them: `$current_url`,
 * `$referrer`, `$pathname`, and the same values again under `$set`/`$set_once`
 * as "initial" properties that then live on the person.
 *
 * `capture_pageview: false` does not help. It suppresses the automatic pageview
 * EVENT; it does not stop the URL metadata riding along on everything else. A
 * storefront URL carrying a discount code, a search term or an email from a
 * newsletter link — or an embedded admin URL carrying `host`, `id_token` and
 * `session` — would reach PostHog in full, against a product rule that says
 * URLs are reduced to origin + pathname everywhere.
 *
 * `before_send` is posthog-js's supported hook over every outgoing event,
 * `$snapshot` included. It is the only place this rule can be enforced in the
 * browser, so it is enforced here and nowhere else.
 *
 * Two properties this file must have, because it runs on every event on a
 * merchant's storefront:
 *
 *   - it must never throw. A `before_send` that throws breaks capture entirely,
 *     and on some paths breaks the page;
 *   - it must FAIL CLOSED. An event that could not be cleaned is dropped, not
 *     sent uncleaned. Losing one event is a rounding error; leaking one URL is
 *     the thing this exists to prevent.
 *
 * SCOPE. It reduces URL-VALUED properties and the page address recorded in
 * rrweb's meta event. It deliberately does not rewrite hrefs inside the
 * captured DOM: the recording is already confined to our own modal subtree, so
 * that DOM is ours, and rewriting a page's links would change what the replay
 * shows rather than what it discloses.
 */

/** How deep to walk before giving up. Real events are 3–4 levels. */
const MAX_DEPTH = 6;

/**
 * A URL-ish string reduced to origin + pathname.
 *
 * Also handles a bare path, because `$pathname` with a query on it is still a
 * query and the rule is about the value, not the property name.
 *
 * @param value - The candidate value
 * @returns The reduced value, or the input unchanged when it is not a URL
 */
export function reduceUrlValue(value: string): string {
  if (!value) return value;

  // A bare path: no scheme, but it can still carry a query or a fragment.
  if (value.startsWith('/')) {
    const cut = value.search(/[?#]/);
    return cut === -1 ? value : value.slice(0, cut);
  }

  // Anything with a scheme. Only http(s) is a page address; a data: or
  // javascript: value is a payload, and there is no reduced form of it worth
  // sending.
  if (!/^[a-z][a-z0-9+.-]*:/i.test(value)) return value;
  try {
    const url = new URL(value);
    if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
    // `origin` drops any embedded credentials, which is why it is used rather
    // than reassembling from protocol + host.
    return `${url.origin}${url.pathname}`;
  } catch {
    return '';
  }
}

/**
 * Reduce every URL-valued string reachable from this value.
 *
 * @param value - Anything from the event
 * @param depth - Remaining depth budget
 * @param seen - Objects already visited, so a cycle cannot loop
 */
function reduceDeep(value: unknown, depth: number, seen: WeakSet<object>): unknown {
  if (typeof value === 'string') return reduceUrlValue(value);
  if (value === null || typeof value !== 'object') return value;
  if (depth <= 0) return value;
  if (seen.has(value as object)) return value;
  seen.add(value as object);

  if (Array.isArray(value)) {
    for (let i = 0; i < value.length; i += 1) {
      value[i] = reduceDeep(value[i], depth - 1, seen);
    }
    return value;
  }

  const record = value as Record<string, unknown>;
  for (const key of Object.keys(record)) {
    record[key] = reduceDeep(record[key], depth - 1, seen);
  }
  return record;
}

/** The URL metadata posthog-js attaches, deleted when all else fails. */
const URL_PROPERTY_KEYS = [
  '$current_url',
  '$referrer',
  '$pathname',
  '$host',
  '$initial_current_url',
  '$initial_referrer',
  '$initial_pathname',
  '$initial_host',
];

/** Remove the known URL properties outright. The last resort before dropping. */
function stripKnownUrlKeys(event: Record<string, unknown>): void {
  for (const container of ['properties', '$set', '$set_once']) {
    const bag = event[container];
    if (bag && typeof bag === 'object') {
      for (const key of URL_PROPERTY_KEYS) delete (bag as Record<string, unknown>)[key];
    }
  }
}

/**
 * posthog-js's `before_send` callback.
 *
 * @param event - The outgoing event, or null when something upstream dropped it
 * @returns The event with every URL reduced, or null to drop it
 */
export function sanitizeBrowserEvent<T>(event: T): T | null {
  if (!event || typeof event !== 'object') return null;

  try {
    reduceDeep(event, MAX_DEPTH, new WeakSet<object>());
    return event;
  } catch {
    // Something in the shape defeated the walk. Do not send it as it is.
    try {
      stripKnownUrlKeys(event as Record<string, unknown>);
      return event;
    } catch {
      return null;
    }
  }
}
