/**
 * 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
 */

import NativeTiming from './NativeTiming';

const BatchedBridge = require('../../BatchedBridge/BatchedBridge');
const Systrace = require('../../Performance/Systrace');
const invariant = require('invariant');

/**
 * JS implementation of timer functions. Must be completely driven by an
 * external clock signal, all that's stored here is timerID, timer type, and
 * callback.
 */

export type JSTimerType =
  | 'setTimeout'
  | 'setInterval'
  | 'requestAnimationFrame'
  | 'queueReactNativeMicrotask'
  | 'requestIdleCallback';

// These timing constants should be kept in sync with the ones in native ios and
// android `RCTTiming` module.
const FRAME_DURATION = 1000 / 60;
const IDLE_CALLBACK_FRAME_DEADLINE = 1;

// Parallel arrays
const callbacks: Array<?Function> = [];
const types: Array<?JSTimerType> = [];
const timerIDs: Array<?number> = [];
let reactNativeMicrotasks: Array<number> = [];
let requestIdleCallbacks: Array<number> = [];
const requestIdleCallbackTimeouts: {[number]: number, ...} = {};

let GUID = 1;
const errors: Array<Error> = [];

let hasEmittedTimeDriftWarning = false;

// Returns a free index if one is available, and the next consecutive index otherwise.
function _getFreeIndex(): number {
  let freeIndex = timerIDs.indexOf(null);
  if (freeIndex === -1) {
    freeIndex = timerIDs.length;
  }
  return freeIndex;
}

function _allocateCallback(func: Function, type: JSTimerType): number {
  const id = GUID++;
  const freeIndex = _getFreeIndex();
  timerIDs[freeIndex] = id;
  callbacks[freeIndex] = func;
  types[freeIndex] = type;
  return id;
}

/**
 * Calls the callback associated with the ID. Also unregister that callback
 * if it was a one time timer (setTimeout), and not unregister it if it was
 * recurring (setInterval).
 */
function _callTimer(timerID: number, frameTime: number, didTimeout: ?boolean) {
  if (timerID > GUID) {
    console.warn(
      'Tried to call timer with ID %s but no such timer exists.',
      timerID,
    );
  }

  // timerIndex of -1 means that no timer with that ID exists. There are
  // two situations when this happens, when a garbage timer ID was given
  // and when a previously existing timer was deleted before this callback
  // fired. In both cases we want to ignore the timer id, but in the former
  // case we warn as well.
  const timerIndex = timerIDs.indexOf(timerID);
  if (timerIndex === -1) {
    return;
  }

  const type = types[timerIndex];
  const callback = callbacks[timerIndex];
  if (!callback || !type) {
    console.error('No callback found for timerID ' + timerID);
    return;
  }

  if (__DEV__) {
    Systrace.beginEvent(type + ' [invoke]');
  }

  // Clear the metadata
  if (type !== 'setInterval') {
    _clearIndex(timerIndex);
  }

  try {
    if (
      type === 'setTimeout' ||
      type === 'setInterval' ||
      type === 'queueReactNativeMicrotask'
    ) {
      callback();
    } else if (type === 'requestAnimationFrame') {
      callback(global.performance.now());
    } else if (type === 'requestIdleCallback') {
      callback({
        timeRemaining: function () {
          // TODO: Optimisation: allow running for longer than one frame if
          // there are no pending JS calls on the bridge from native. This
          // would require a way to check the bridge queue synchronously.
          return Math.max(
            0,
            FRAME_DURATION - (global.performance.now() - frameTime),
          );
        },
        didTimeout: !!didTimeout,
      });
    } else {
      console.error('Tried to call a callback with invalid type: ' + type);
    }
  } catch (e) {
    // Don't rethrow so that we can run all timers.
    errors.push(e);
  }

  if (__DEV__) {
    Systrace.endEvent();
  }
}

/**
 * Performs a single pass over the enqueued reactNativeMicrotasks. Returns whether
 * more reactNativeMicrotasks are queued up (can be used as a condition a while loop).
 */
function _callReactNativeMicrotasksPass() {
  if (reactNativeMicrotasks.length === 0) {
    return false;
  }

  if (__DEV__) {
    Systrace.beginEvent('callReactNativeMicrotasksPass()');
  }

  // The main reason to extract a single pass is so that we can track
  // in the system trace
  const passReactNativeMicrotasks = reactNativeMicrotasks;
  reactNativeMicrotasks = [];

  // Use for loop rather than forEach as per @vjeux's advice
  // https://github.com/facebook/react-native/commit/c8fd9f7588ad02d2293cac7224715f4af7b0f352#commitcomment-14570051
  for (let i = 0; i < passReactNativeMicrotasks.length; ++i) {
    _callTimer(passReactNativeMicrotasks[i], 0);
  }

  if (__DEV__) {
    Systrace.endEvent();
  }
  return reactNativeMicrotasks.length > 0;
}

function _clearIndex(i: number) {
  timerIDs[i] = null;
  callbacks[i] = null;
  types[i] = null;
}

function _freeCallback(timerID: number) {
  // timerIDs contains nulls after timers have been removed;
  // ignore nulls upfront so indexOf doesn't find them
  if (timerID == null) {
    return;
  }

  const index = timerIDs.indexOf(timerID);
  // See corresponding comment in `callTimers` for reasoning behind this
  if (index !== -1) {
    const type = types[index];
    _clearIndex(index);
    if (
      type !== 'queueReactNativeMicrotask' &&
      type !== 'requestIdleCallback'
    ) {
      deleteTimer(timerID);
    }
  }
}

/**
 * JS implementation of timer functions. Must be completely driven by an
 * external clock signal, all that's stored here is timerID, timer type, and
 * callback.
 */
const JSTimers = {
  /**
   * @param {function} func Callback to be invoked after `duration` ms.
   * @param {number} duration Number of milliseconds.
   */
  setTimeout: function (
    func: Function,
    duration: number,
    ...args: any
  ): number {
    const id = _allocateCallback(
      () => func.apply(undefined, args),
      'setTimeout',
    );
    createTimer(id, duration || 0, Date.now(), /* recurring */ false);
    return id;
  },

  /**
   * @param {function} func Callback to be invoked every `duration` ms.
   * @param {number} duration Number of milliseconds.
   */
  setInterval: function (
    func: Function,
    duration: number,
    ...args: any
  ): number {
    const id = _allocateCallback(
      () => func.apply(undefined, args),
      'setInterval',
    );
    createTimer(id, duration || 0, Date.now(), /* recurring */ true);
    return id;
  },

  /**
   * The React Native microtask mechanism is used to back public APIs e.g.
   * `queueMicrotask`, `clearImmediate`, and `setImmediate` (which is used by
   * the Promise polyfill) when the JSVM microtask mechanism is not used.
   *
   * @param {function} func Callback to be invoked before the end of the
   * current JavaScript execution loop.
   */
  queueReactNativeMicrotask: function (func: Function, ...args: any): number {
    const id = _allocateCallback(
      () => func.apply(undefined, args),
      'queueReactNativeMicrotask',
    );
    reactNativeMicrotasks.push(id);
    return id;
  },

  /**
   * @param {function} func Callback to be invoked every frame.
   */
  requestAnimationFrame: function (func: Function): any | number {
    const id = _allocateCallback(func, 'requestAnimationFrame');
    createTimer(id, 1, Date.now(), /* recurring */ false);
    return id;
  },

  /**
   * @param {function} func Callback to be invoked every frame and provided
   * with time remaining in frame.
   * @param {?object} options
   */
  requestIdleCallback: function (
    func: Function,
    options: ?Object,
  ): any | number {
    if (requestIdleCallbacks.length === 0) {
      setSendIdleEvents(true);
    }

    const timeout = options && options.timeout;
    const id: number = _allocateCallback(
      timeout != null
        ? (deadline: any) => {
            const timeoutId: number = requestIdleCallbackTimeouts[id];
            if (timeoutId) {
              JSTimers.clearTimeout(timeoutId);
              delete requestIdleCallbackTimeouts[id];
            }
            return func(deadline);
          }
        : func,
      'requestIdleCallback',
    );
    requestIdleCallbacks.push(id);

    if (timeout != null) {
      const timeoutId: number = JSTimers.setTimeout(() => {
        const index: number = requestIdleCallbacks.indexOf(id);
        if (index > -1) {
          requestIdleCallbacks.splice(index, 1);
          _callTimer(id, global.performance.now(), true);
        }
        delete requestIdleCallbackTimeouts[id];
        if (requestIdleCallbacks.length === 0) {
          setSendIdleEvents(false);
        }
      }, timeout);
      requestIdleCallbackTimeouts[id] = timeoutId;
    }
    return id;
  },

  cancelIdleCallback: function (timerID: number) {
    _freeCallback(timerID);
    const index = requestIdleCallbacks.indexOf(timerID);
    if (index !== -1) {
      requestIdleCallbacks.splice(index, 1);
    }

    const timeoutId = requestIdleCallbackTimeouts[timerID];
    if (timeoutId) {
      JSTimers.clearTimeout(timeoutId);
      delete requestIdleCallbackTimeouts[timerID];
    }

    if (requestIdleCallbacks.length === 0) {
      setSendIdleEvents(false);
    }
  },

  clearTimeout: function (timerID: number) {
    _freeCallback(timerID);
  },

  clearInterval: function (timerID: number) {
    _freeCallback(timerID);
  },

  clearReactNativeMicrotask: function (timerID: number) {
    _freeCallback(timerID);
    const index = reactNativeMicrotasks.indexOf(timerID);
    if (index !== -1) {
      reactNativeMicrotasks.splice(index, 1);
    }
  },

  cancelAnimationFrame: function (timerID: number) {
    _freeCallback(timerID);
  },

  /**
   * This is called from the native side. We are passed an array of timerIDs,
   * and
   */
  callTimers: function (timersToCall: Array<number>): any | void {
    invariant(
      timersToCall.length !== 0,
      'Cannot call `callTimers` with an empty list of IDs.',
    );

    errors.length = 0;
    for (let i = 0; i < timersToCall.length; i++) {
      _callTimer(timersToCall[i], 0);
    }

    const errorCount = errors.length;
    if (errorCount > 0) {
      if (errorCount > 1) {
        // Throw all the other errors in a setTimeout, which will throw each
        // error one at a time
        for (let ii = 1; ii < errorCount; ii++) {
          JSTimers.setTimeout(
            ((error: Error) => {
              throw error;
            }).bind(null, errors[ii]),
            0,
          );
        }
      }
      throw errors[0];
    }
  },

  callIdleCallbacks: function (frameTime: number) {
    if (
      FRAME_DURATION - (Date.now() - frameTime) <
      IDLE_CALLBACK_FRAME_DEADLINE
    ) {
      return;
    }

    errors.length = 0;
    if (requestIdleCallbacks.length > 0) {
      const passIdleCallbacks = requestIdleCallbacks;
      requestIdleCallbacks = [];

      for (let i = 0; i < passIdleCallbacks.length; ++i) {
        _callTimer(passIdleCallbacks[i], frameTime);
      }
    }

    if (requestIdleCallbacks.length === 0) {
      setSendIdleEvents(false);
    }

    errors.forEach(error =>
      JSTimers.setTimeout(() => {
        throw error;
      }, 0),
    );
  },

  /**
   * This is called after we execute any command we receive from native but
   * before we hand control back to native.
   */
  callReactNativeMicrotasks() {
    errors.length = 0;
    while (_callReactNativeMicrotasksPass()) {}
    errors.forEach(error =>
      JSTimers.setTimeout(() => {
        throw error;
      }, 0),
    );
  },

  /**
   * Called from native (in development) when environment times are out-of-sync.
   */
  emitTimeDriftWarning(warningMessage: string) {
    if (hasEmittedTimeDriftWarning) {
      return;
    }
    hasEmittedTimeDriftWarning = true;
    console.warn(warningMessage);
  },
};

function createTimer(
  callbackID: number,
  duration: number,
  jsSchedulingTime: number,
  repeats: boolean,
): void {
  invariant(NativeTiming, 'NativeTiming is available');
  NativeTiming.createTimer(callbackID, duration, jsSchedulingTime, repeats);
}

function deleteTimer(timerID: number): void {
  invariant(NativeTiming, 'NativeTiming is available');
  NativeTiming.deleteTimer(timerID);
}

function setSendIdleEvents(sendIdleEvents: boolean): void {
  invariant(NativeTiming, 'NativeTiming is available');
  NativeTiming.setSendIdleEvents(sendIdleEvents);
}

let ExportedJSTimers: {|
  callIdleCallbacks: (frameTime: number) => any | void,
  callReactNativeMicrotasks: () => void,
  callTimers: (timersToCall: Array<number>) => any | void,
  cancelAnimationFrame: (timerID: number) => void,
  cancelIdleCallback: (timerID: number) => void,
  clearReactNativeMicrotask: (timerID: number) => void,
  clearInterval: (timerID: number) => void,
  clearTimeout: (timerID: number) => void,
  emitTimeDriftWarning: (warningMessage: string) => any | void,
  requestAnimationFrame: (func: any) => any | number,
  requestIdleCallback: (func: any, options: ?any) => any | number,
  queueReactNativeMicrotask: (func: any, ...args: any) => number,
  setInterval: (func: any, duration: number, ...args: any) => number,
  setTimeout: (func: any, duration: number, ...args: any) => number,
|};

if (!NativeTiming) {
  console.warn("Timing native module is not available, can't set timers.");
  // $FlowFixMe[prop-missing] : we can assume timers are generally available
  ExportedJSTimers = ({
    callReactNativeMicrotasks: JSTimers.callReactNativeMicrotasks,
    queueReactNativeMicrotask: JSTimers.queueReactNativeMicrotask,
  }: typeof JSTimers);
} else {
  ExportedJSTimers = JSTimers;
}

BatchedBridge.setReactNativeMicrotasksCallback(
  JSTimers.callReactNativeMicrotasks,
);

module.exports = ExportedJSTimers;
