/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @format
 * @flow strict
 */

import type {
  DOMHighResTimeStamp,
  PerformanceEntryType,
} from './PerformanceEntry';

import warnOnce from '../../../../Libraries/Utilities/warnOnce';
import {PerformanceEventTiming} from './EventTiming';
import {PerformanceEntry} from './PerformanceEntry';
import {
  performanceEntryTypeToRaw,
  rawToPerformanceEntry,
  rawToPerformanceEntryType,
} from './RawPerformanceEntry';
import NativePerformanceObserver from './specs/NativePerformanceObserver';

export type PerformanceEntryList = $ReadOnlyArray<PerformanceEntry>;

export {PerformanceEntry} from './PerformanceEntry';

export class PerformanceObserverEntryList {
  #entries: PerformanceEntryList;

  constructor(entries: PerformanceEntryList) {
    this.#entries = entries;
  }

  getEntries(): PerformanceEntryList {
    return this.#entries;
  }

  getEntriesByType(type: PerformanceEntryType): PerformanceEntryList {
    return this.#entries.filter(entry => entry.entryType === type);
  }

  getEntriesByName(
    name: string,
    type?: PerformanceEntryType,
  ): PerformanceEntryList {
    if (type === undefined) {
      return this.#entries.filter(entry => entry.name === name);
    } else {
      return this.#entries.filter(
        entry => entry.name === name && entry.entryType === type,
      );
    }
  }
}

export type PerformanceObserverCallback = (
  list: PerformanceObserverEntryList,
  observer: PerformanceObserver,
  // The number of buffered entries which got dropped from the buffer due to the buffer being full:
  droppedEntryCount?: number,
) => void;

export type PerformanceObserverInit =
  | {
      entryTypes: Array<PerformanceEntryType>,
    }
  | {
      type: PerformanceEntryType,
      durationThreshold?: DOMHighResTimeStamp,
    };

type PerformanceObserverConfig = {|
  callback: PerformanceObserverCallback,
  entryTypes: $ReadOnlySet<PerformanceEntryType>,
  durationThreshold: ?number,
|};

const observerCountPerEntryType: Map<PerformanceEntryType, number> = new Map();
const registeredObservers: Map<PerformanceObserver, PerformanceObserverConfig> =
  new Map();
let isOnPerformanceEntryCallbackSet: boolean = false;

// This is a callback that gets scheduled and periodically called from the native side
const onPerformanceEntry = () => {
  if (!NativePerformanceObserver) {
    return;
  }
  const entryResult = NativePerformanceObserver.popPendingEntries();
  const rawEntries = entryResult?.entries ?? [];
  const droppedEntriesCount = entryResult?.droppedEntriesCount;
  if (rawEntries.length === 0) {
    return;
  }
  const entries = rawEntries.map(rawToPerformanceEntry);
  for (const [observer, observerConfig] of registeredObservers.entries()) {
    const entriesForObserver: PerformanceEntryList = entries.filter(entry => {
      if (!observerConfig.entryTypes.has(entry.entryType)) {
        return false;
      }

      if (
        entry.entryType === 'event' &&
        observerConfig.durationThreshold != null
      ) {
        return entry.duration >= observerConfig.durationThreshold;
      }

      return true;
    });
    if (entriesForObserver.length !== 0) {
      try {
        observerConfig.callback(
          new PerformanceObserverEntryList(entriesForObserver),
          observer,
          droppedEntriesCount,
        );
      } catch (error) {
        console.error(error);
      }
    }
  }
};

export function warnNoNativePerformanceObserver() {
  warnOnce(
    'missing-native-performance-observer',
    'Missing native implementation of PerformanceObserver',
  );
}

function applyDurationThresholds() {
  const durationThresholds = Array.from(registeredObservers.values())
    .map(observerConfig => observerConfig.durationThreshold)
    .filter(Boolean);

  return Math.min(...durationThresholds);
}

function getSupportedPerformanceEntryTypes(): $ReadOnlyArray<PerformanceEntryType> {
  if (!NativePerformanceObserver) {
    return Object.freeze([]);
  }
  if (!NativePerformanceObserver.getSupportedPerformanceEntryTypes) {
    // fallback if getSupportedPerformanceEntryTypes is not defined on native side
    return Object.freeze(['mark', 'measure', 'event']);
  }
  return Object.freeze(
    NativePerformanceObserver.getSupportedPerformanceEntryTypes().map(
      rawToPerformanceEntryType,
    ),
  );
}

/**
 * Implementation of the PerformanceObserver interface for RN,
 * corresponding to the standard in https://www.w3.org/TR/performance-timeline/
 *
 * @example
 * const observer = new PerformanceObserver((list, _observer) => {
 *   const entries = list.getEntries();
 *   entries.forEach(entry => {
 *     reportEvent({
 *       eventName: entry.name,
 *       startTime: entry.startTime,
 *       endTime: entry.startTime + entry.duration,
 *       processingStart: entry.processingStart,
 *       processingEnd: entry.processingEnd,
 *       interactionId: entry.interactionId,
 *     });
 *   });
 * });
 * observer.observe({ type: "event" });
 */
export class PerformanceObserver {
  #callback: PerformanceObserverCallback;
  #type: 'single' | 'multiple' | void;

  constructor(callback: PerformanceObserverCallback) {
    this.#callback = callback;
  }

  observe(options: PerformanceObserverInit): void {
    if (!NativePerformanceObserver) {
      warnNoNativePerformanceObserver();
      return;
    }

    this.#validateObserveOptions(options);

    let requestedEntryTypes;

    if (options.entryTypes) {
      this.#type = 'multiple';
      requestedEntryTypes = new Set(options.entryTypes);
    } else {
      this.#type = 'single';
      requestedEntryTypes = new Set([options.type]);
    }

    // The same observer may receive multiple calls to "observe", so we need
    // to check what is new on this call vs. previous ones.
    const currentEntryTypes = registeredObservers.get(this)?.entryTypes;
    const nextEntryTypes = currentEntryTypes
      ? union(requestedEntryTypes, currentEntryTypes)
      : requestedEntryTypes;

    // This `observe` call is a no-op because there are no new things to observe.
    if (currentEntryTypes && currentEntryTypes.size === nextEntryTypes.size) {
      return;
    }

    registeredObservers.set(this, {
      callback: this.#callback,
      durationThreshold:
        options.type === 'event' ? options.durationThreshold : undefined,
      entryTypes: nextEntryTypes,
    });

    if (!isOnPerformanceEntryCallbackSet) {
      NativePerformanceObserver.setOnPerformanceEntryCallback(
        onPerformanceEntry,
      );
      isOnPerformanceEntryCallbackSet = true;
    }

    // We only need to start listenening to new entry types being observed in
    // this observer.
    const newEntryTypes = currentEntryTypes
      ? difference(
          new Set(requestedEntryTypes.keys()),
          new Set(currentEntryTypes.keys()),
        )
      : new Set(requestedEntryTypes.keys());
    for (const type of newEntryTypes) {
      if (!observerCountPerEntryType.has(type)) {
        const rawType = performanceEntryTypeToRaw(type);
        NativePerformanceObserver.startReporting(rawType);
      }
      observerCountPerEntryType.set(
        type,
        (observerCountPerEntryType.get(type) ?? 0) + 1,
      );
    }
    applyDurationThresholds();
  }

  disconnect(): void {
    if (!NativePerformanceObserver) {
      warnNoNativePerformanceObserver();
      return;
    }

    const observerConfig = registeredObservers.get(this);
    if (!observerConfig) {
      return;
    }

    // Disconnect this observer
    for (const type of observerConfig.entryTypes.keys()) {
      const numberOfObserversForThisType =
        observerCountPerEntryType.get(type) ?? 0;
      if (numberOfObserversForThisType === 1) {
        observerCountPerEntryType.delete(type);
        NativePerformanceObserver.stopReporting(
          performanceEntryTypeToRaw(type),
        );
      } else if (numberOfObserversForThisType !== 0) {
        observerCountPerEntryType.set(type, numberOfObserversForThisType - 1);
      }
    }

    // Disconnect all observers if this was the last one
    registeredObservers.delete(this);
    if (registeredObservers.size === 0) {
      NativePerformanceObserver.setOnPerformanceEntryCallback(undefined);
      isOnPerformanceEntryCallbackSet = false;
    }

    applyDurationThresholds();
  }

  #validateObserveOptions(options: PerformanceObserverInit): void {
    const {type, entryTypes, durationThreshold} = options;

    if (!type && !entryTypes) {
      throw new TypeError(
        "Failed to execute 'observe' on 'PerformanceObserver': An observe() call must not include both entryTypes and type arguments.",
      );
    }

    if (entryTypes && type) {
      throw new TypeError(
        "Failed to execute 'observe' on 'PerformanceObserver': An observe() call must include either entryTypes or type arguments.",
      );
    }

    if (this.#type === 'multiple' && type) {
      throw new Error(
        "Failed to execute 'observe' on 'PerformanceObserver': This observer has performed observe({entryTypes:...}, therefore it cannot perform observe({type:...})",
      );
    }

    if (this.#type === 'single' && entryTypes) {
      throw new Error(
        "Failed to execute 'observe' on 'PerformanceObserver': This PerformanceObserver has performed observe({type:...}, therefore it cannot perform observe({entryTypes:...})",
      );
    }

    if (entryTypes && durationThreshold !== undefined) {
      throw new TypeError(
        "Failed to execute 'observe' on 'PerformanceObserver': An observe() call must not include both entryTypes and durationThreshold arguments.",
      );
    }
  }

  static supportedEntryTypes: $ReadOnlyArray<PerformanceEntryType> =
    getSupportedPerformanceEntryTypes();
}

function union<T>(a: $ReadOnlySet<T>, b: $ReadOnlySet<T>): Set<T> {
  return new Set([...a, ...b]);
}

function difference<T>(a: $ReadOnlySet<T>, b: $ReadOnlySet<T>): Set<T> {
  return new Set([...a].filter(x => !b.has(x)));
}

export {PerformanceEventTiming};
