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

'use strict';

const infoLog = require('../Utilities/infoLog');
const invariant = require('invariant');

type SimpleTask = {
  name: string,
  run: () => void,
};
type PromiseTask = {
  name: string,
  gen: () => Promise<void>,
};
export type Task = SimpleTask | PromiseTask | (() => void);

const DEBUG: false = false;

/**
 * TaskQueue - A system for queueing and executing a mix of simple callbacks and
 * trees of dependent tasks based on Promises. No tasks are executed unless
 * `processNext` is called.
 *
 * `enqueue` takes a Task object with either a simple `run` callback, or a
 * `gen` function that returns a `Promise` and puts it in the queue.  If a gen
 * function is supplied, then the promise it returns will block execution of
 * tasks already in the queue until it resolves. This can be used to make sure
 * the first task is fully resolved (including asynchronous dependencies that
 * also schedule more tasks via `enqueue`) before starting on the next task.
 * The `onMoreTasks` constructor argument is used to inform the owner that an
 * async task has resolved and that the queue should be processed again.
 *
 * Note: Tasks are only actually executed with explicit calls to `processNext`.
 */
class TaskQueue {
  /**
   * TaskQueue instances are self contained and independent, so multiple tasks
   * of varying semantics and priority can operate together.
   *
   * `onMoreTasks` is invoked when `PromiseTask`s resolve if there are more
   * tasks to process.
   */
  constructor({onMoreTasks}: {onMoreTasks: () => void, ...}) {
    this._onMoreTasks = onMoreTasks;
    this._queueStack = [{tasks: [], popable: false}];
  }

  /**
   * Add a task to the queue.  It is recommended to name your tasks for easier
   * async debugging. Tasks will not be executed until `processNext` is called
   * explicitly.
   */
  enqueue(task: Task): void {
    this._getCurrentQueue().push(task);
  }

  enqueueTasks(tasks: Array<Task>): void {
    tasks.forEach(task => this.enqueue(task));
  }

  cancelTasks(tasksToCancel: Array<Task>): void {
    // search through all tasks and remove them.
    this._queueStack = this._queueStack
      .map(queue => ({
        ...queue,
        tasks: queue.tasks.filter(task => tasksToCancel.indexOf(task) === -1),
      }))
      .filter((queue, idx) => queue.tasks.length > 0 || idx === 0);
  }

  /**
   * Check to see if `processNext` should be called.
   *
   * @returns {boolean} Returns true if there are tasks that are ready to be
   * processed with `processNext`, or returns false if there are no more tasks
   * to be processed right now, although there may be tasks in the queue that
   * are blocked by earlier `PromiseTask`s that haven't resolved yet.
   * `onMoreTasks` will be called after each `PromiseTask` resolves if there are
   * tasks ready to run at that point.
   */
  hasTasksToProcess(): boolean {
    return this._getCurrentQueue().length > 0;
  }

  /**
   * Executes the next task in the queue.
   */
  processNext(): void {
    const queue = this._getCurrentQueue();
    if (queue.length) {
      const task = queue.shift();
      try {
        if (typeof task === 'object' && task.gen) {
          DEBUG && infoLog('TaskQueue: genPromise for task ' + task.name);
          this._genPromise(task);
        } else if (typeof task === 'object' && task.run) {
          DEBUG && infoLog('TaskQueue: run task ' + task.name);
          task.run();
        } else {
          invariant(
            typeof task === 'function',
            'Expected Function, SimpleTask, or PromiseTask, but got:\n' +
              JSON.stringify(task, null, 2),
          );
          DEBUG && infoLog('TaskQueue: run anonymous task');
          task();
        }
      } catch (e) {
        e.message =
          // $FlowFixMe[incompatible-type]
          'TaskQueue: Error with task ' + (task.name || '') + ': ' + e.message;
        throw e;
      }
    }
  }

  _queueStack: Array<{
    tasks: Array<Task>,
    popable: boolean,
    ...
  }>;
  _onMoreTasks: () => void;

  _getCurrentQueue(): Array<Task> {
    const stackIdx = this._queueStack.length - 1;
    const queue = this._queueStack[stackIdx];
    if (
      queue.popable &&
      queue.tasks.length === 0 &&
      this._queueStack.length > 1
    ) {
      this._queueStack.pop();
      DEBUG &&
        infoLog('TaskQueue: popped queue: ', {
          stackIdx,
          queueStackSize: this._queueStack.length,
        });
      return this._getCurrentQueue();
    } else {
      return queue.tasks;
    }
  }

  _genPromise(task: PromiseTask) {
    // Each async task pushes it's own queue onto the queue stack. This
    // effectively defers execution of previously queued tasks until the promise
    // resolves, at which point we allow the new queue to be popped, which
    // happens once it is fully processed.
    this._queueStack.push({tasks: [], popable: false});
    const stackIdx = this._queueStack.length - 1;
    const stackItem = this._queueStack[stackIdx];
    DEBUG && infoLog('TaskQueue: push new queue: ', {stackIdx});
    DEBUG && infoLog('TaskQueue: exec gen task ' + task.name);
    task
      .gen()
      .then(() => {
        DEBUG &&
          infoLog('TaskQueue: onThen for gen task ' + task.name, {
            stackIdx,
            queueStackSize: this._queueStack.length,
          });
        stackItem.popable = true;
        this.hasTasksToProcess() && this._onMoreTasks();
      })
      .catch(ex => {
        setTimeout(() => {
          ex.message = `TaskQueue: Error resolving Promise in task ${task.name}: ${ex.message}`;
          throw ex;
        }, 0);
      });
  }
}

module.exports = TaskQueue;
