/**
 * 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.
 *
 * @emails react-core
 * @jest-environment node
 */

/* eslint-disable no-for-of-loops/no-for-of-loops */

'use strict';

let Scheduler;
let runtime;
let performance;
let cancelCallback;
let scheduleCallback;
let requestPaint;
let shouldYield;
let NormalPriority;

// The Scheduler implementation uses browser APIs like `MessageChannel` and
// `setTimeout` to schedule work on the main thread. Most of our tests treat
// these as implementation details; however, the sequence and timing of these
// APIs are not precisely specified, and can vary across browsers.
//
// To prevent regressions, we need the ability to simulate specific edge cases
// that we may encounter in various browsers.
//
// This test suite mocks all browser methods used in our implementation. It
// assumes as little as possible about the order and timing of events.
describe('SchedulerBrowser', () => {
  beforeEach(() => {
    jest.resetModules();
    runtime = installMockBrowserRuntime();
    jest.unmock('scheduler');

    performance = global.performance;
    Scheduler = require('scheduler');
    cancelCallback = Scheduler.unstable_cancelCallback;
    scheduleCallback = Scheduler.unstable_scheduleCallback;
    NormalPriority = Scheduler.unstable_NormalPriority;
    requestPaint = Scheduler.unstable_requestPaint;
    shouldYield = Scheduler.unstable_shouldYield;
  });

  afterEach(() => {
    delete global.performance;

    if (!runtime.isLogEmpty()) {
      throw Error('Test exited without clearing log.');
    }
  });

  function installMockBrowserRuntime() {
    let hasPendingMessageEvent = false;
    let isFiringMessageEvent = false;
    let hasPendingDiscreteEvent = false;
    let hasPendingContinuousEvent = false;

    let timerIDCounter = 0;
    // let timerIDs = new Map();

    let eventLog = [];

    let currentTime = 0;

    global.performance = {
      now() {
        return currentTime;
      },
    };

    // Delete node provide setImmediate so we fall through to MessageChannel.
    delete global.setImmediate;

    global.setTimeout = (cb, delay) => {
      const id = timerIDCounter++;
      log(`Set Timer`);
      // TODO
      return id;
    };
    global.clearTimeout = id => {
      // TODO
    };

    const port1 = {};
    const port2 = {
      postMessage() {
        if (hasPendingMessageEvent) {
          throw Error('Message event already scheduled');
        }
        log('Post Message');
        hasPendingMessageEvent = true;
      },
    };
    global.MessageChannel = function MessageChannel() {
      this.port1 = port1;
      this.port2 = port2;
    };

    const scheduling = {
      isInputPending(options) {
        if (this !== scheduling) {
          throw new Error(
            'isInputPending called with incorrect `this` context',
          );
        }

        return (
          hasPendingDiscreteEvent ||
          (options && options.includeContinuous && hasPendingContinuousEvent)
        );
      },
    };

    global.navigator = {scheduling};

    function ensureLogIsEmpty() {
      if (eventLog.length !== 0) {
        throw Error('Log is not empty. Call assertLog before continuing.');
      }
    }
    function advanceTime(ms) {
      currentTime += ms;
    }
    function resetTime() {
      currentTime = 0;
    }
    function fireMessageEvent() {
      ensureLogIsEmpty();
      if (!hasPendingMessageEvent) {
        throw Error('No message event was scheduled');
      }
      hasPendingMessageEvent = false;
      const onMessage = port1.onmessage;
      log('Message Event');

      isFiringMessageEvent = true;
      try {
        onMessage();
      } finally {
        isFiringMessageEvent = false;
        if (hasPendingDiscreteEvent) {
          log('Discrete Event');
          hasPendingDiscreteEvent = false;
        }
        if (hasPendingContinuousEvent) {
          log('Continuous Event');
          hasPendingContinuousEvent = false;
        }
      }
    }
    function scheduleDiscreteEvent() {
      if (isFiringMessageEvent) {
        hasPendingDiscreteEvent = true;
      } else {
        log('Discrete Event');
      }
    }
    function scheduleContinuousEvent() {
      if (isFiringMessageEvent) {
        hasPendingContinuousEvent = true;
      } else {
        log('Continuous Event');
      }
    }
    function log(val) {
      eventLog.push(val);
    }
    function isLogEmpty() {
      return eventLog.length === 0;
    }
    function assertLog(expected) {
      const actual = eventLog;
      eventLog = [];
      expect(actual).toEqual(expected);
    }
    return {
      advanceTime,
      resetTime,
      fireMessageEvent,
      log,
      isLogEmpty,
      assertLog,
      scheduleDiscreteEvent,
      scheduleContinuousEvent,
    };
  }

  it('task that finishes before deadline', () => {
    scheduleCallback(NormalPriority, () => {
      runtime.log('Task');
    });
    runtime.assertLog(['Post Message']);
    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'Task']);
  });

  it('task with continuation', () => {
    scheduleCallback(NormalPriority, () => {
      runtime.log('Task');
      // Request paint so that we yield at the end of the frame interval
      requestPaint();
      while (!Scheduler.unstable_shouldYield()) {
        runtime.advanceTime(1);
      }
      runtime.log(`Yield at ${performance.now()}ms`);
      return () => {
        runtime.log('Continuation');
      };
    });
    runtime.assertLog(['Post Message']);

    runtime.fireMessageEvent();
    runtime.assertLog([
      'Message Event',
      'Task',
      'Yield at 5ms',
      'Post Message',
    ]);

    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'Continuation']);
  });

  it('multiple tasks', () => {
    scheduleCallback(NormalPriority, () => {
      runtime.log('A');
    });
    scheduleCallback(NormalPriority, () => {
      runtime.log('B');
    });
    runtime.assertLog(['Post Message']);
    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'A', 'B']);
  });

  it('multiple tasks with a yield in between', () => {
    scheduleCallback(NormalPriority, () => {
      runtime.log('A');
      runtime.advanceTime(4999);
    });
    scheduleCallback(NormalPriority, () => {
      runtime.log('B');
    });
    runtime.assertLog(['Post Message']);
    runtime.fireMessageEvent();
    runtime.assertLog([
      'Message Event',
      'A',
      // Ran out of time. Post a continuation event.
      'Post Message',
    ]);
    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'B']);
  });

  it('cancels tasks', () => {
    const task = scheduleCallback(NormalPriority, () => {
      runtime.log('Task');
    });
    runtime.assertLog(['Post Message']);
    cancelCallback(task);
    runtime.assertLog([]);
  });

  it('throws when a task errors then continues in a new event', () => {
    scheduleCallback(NormalPriority, () => {
      runtime.log('Oops!');
      throw Error('Oops!');
    });
    scheduleCallback(NormalPriority, () => {
      runtime.log('Yay');
    });
    runtime.assertLog(['Post Message']);

    expect(() => runtime.fireMessageEvent()).toThrow('Oops!');
    runtime.assertLog(['Message Event', 'Oops!', 'Post Message']);

    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'Yay']);
  });

  it('schedule new task after queue has emptied', () => {
    scheduleCallback(NormalPriority, () => {
      runtime.log('A');
    });

    runtime.assertLog(['Post Message']);
    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'A']);

    scheduleCallback(NormalPriority, () => {
      runtime.log('B');
    });
    runtime.assertLog(['Post Message']);
    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'B']);
  });

  it('schedule new task after a cancellation', () => {
    const handle = scheduleCallback(NormalPriority, () => {
      runtime.log('A');
    });

    runtime.assertLog(['Post Message']);
    cancelCallback(handle);

    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event']);

    scheduleCallback(NormalPriority, () => {
      runtime.log('B');
    });
    runtime.assertLog(['Post Message']);
    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'B']);
  });

  it('when isInputPending is available, we can wait longer before yielding', () => {
    function blockUntilSchedulerAsksToYield() {
      while (!Scheduler.unstable_shouldYield()) {
        runtime.advanceTime(1);
      }
      runtime.log(`Yield at ${performance.now()}ms`);
    }

    // First show what happens when we don't request a paint
    scheduleCallback(NormalPriority, () => {
      runtime.log('Task with no pending input');
      blockUntilSchedulerAsksToYield();
    });
    runtime.assertLog(['Post Message']);

    runtime.fireMessageEvent();
    runtime.assertLog([
      'Message Event',
      'Task with no pending input',
      // Even though there's no input, eventually Scheduler will yield
      // regardless in case there's a pending main thread task we don't know
      // about, like a network event.
      gate(flags =>
        flags.enableIsInputPending
          ? 'Yield at 10ms'
          : // When isInputPending is disabled, we always yield quickly
            'Yield at 5ms',
      ),
    ]);

    runtime.resetTime();

    // Now do the same thing, but while the task is running, simulate an
    // input event.
    scheduleCallback(NormalPriority, () => {
      runtime.log('Task with pending input');
      runtime.scheduleDiscreteEvent();
      blockUntilSchedulerAsksToYield();
    });
    runtime.assertLog(['Post Message']);

    runtime.fireMessageEvent();
    runtime.assertLog([
      'Message Event',
      'Task with pending input',
      // This time we yielded quickly to unblock the discrete event.
      'Yield at 5ms',
      'Discrete Event',
    ]);
  });

  it(
    'isInputPending will also check for continuous inputs, but after a ' +
      'slightly larger threshold',
    () => {
      function blockUntilSchedulerAsksToYield() {
        while (!Scheduler.unstable_shouldYield()) {
          runtime.advanceTime(1);
        }
        runtime.log(`Yield at ${performance.now()}ms`);
      }

      // First show what happens when we don't request a paint
      scheduleCallback(NormalPriority, () => {
        runtime.log('Task with no pending input');
        blockUntilSchedulerAsksToYield();
      });
      runtime.assertLog(['Post Message']);

      runtime.fireMessageEvent();
      runtime.assertLog([
        'Message Event',
        'Task with no pending input',
        // Even though there's no input, eventually Scheduler will yield
        // regardless in case there's a pending main thread task we don't know
        // about, like a network event.
        gate(flags =>
          flags.enableIsInputPending
            ? 'Yield at 10ms'
            : // When isInputPending is disabled, we always yield quickly
              'Yield at 5ms',
        ),
      ]);

      runtime.resetTime();

      // Now do the same thing, but while the task is running, simulate a
      // continuous input event.
      scheduleCallback(NormalPriority, () => {
        runtime.log('Task with continuous input');
        runtime.scheduleContinuousEvent();
        blockUntilSchedulerAsksToYield();
      });
      runtime.assertLog(['Post Message']);

      runtime.fireMessageEvent();
      runtime.assertLog([
        'Message Event',
        'Task with continuous input',
        // This time we yielded quickly to unblock the continuous event. But not
        // as quickly as for a discrete event.
        gate(flags =>
          flags.enableIsInputPending
            ? 'Yield at 10ms'
            : // When isInputPending is disabled, we always yield quickly
              'Yield at 5ms',
        ),
        'Continuous Event',
      ]);
    },
  );

  it('requestPaint forces a yield at the end of the next frame interval', () => {
    function blockUntilSchedulerAsksToYield() {
      while (!Scheduler.unstable_shouldYield()) {
        runtime.advanceTime(1);
      }
      runtime.log(`Yield at ${performance.now()}ms`);
    }

    // First show what happens when we don't request a paint
    scheduleCallback(NormalPriority, () => {
      runtime.log('Task with no paint');
      blockUntilSchedulerAsksToYield();
    });
    runtime.assertLog(['Post Message']);

    runtime.fireMessageEvent();
    runtime.assertLog([
      'Message Event',
      'Task with no paint',
      gate(flags =>
        flags.enableIsInputPending
          ? 'Yield at 10ms'
          : // When isInputPending is disabled, we always yield quickly
            'Yield at 5ms',
      ),
    ]);

    runtime.resetTime();

    // Now do the same thing, but call requestPaint inside the task
    scheduleCallback(NormalPriority, () => {
      runtime.log('Task with paint');
      requestPaint();
      blockUntilSchedulerAsksToYield();
    });
    runtime.assertLog(['Post Message']);

    runtime.fireMessageEvent();
    runtime.assertLog([
      'Message Event',
      'Task with paint',
      // This time we yielded quickly (5ms) because we requested a paint.
      'Yield at 5ms',
    ]);
  });

  it('yielding continues in a new task regardless of how much time is remaining', () => {
    scheduleCallback(NormalPriority, () => {
      runtime.log('Original Task');
      runtime.log('shouldYield: ' + shouldYield());
      runtime.log('Return a continuation');
      return () => {
        runtime.log('Continuation Task');
      };
    });
    runtime.assertLog(['Post Message']);

    runtime.fireMessageEvent();
    runtime.assertLog([
      'Message Event',
      'Original Task',
      // Immediately before returning a continuation, `shouldYield` returns
      // false, which means there must be time remaining in the frame.
      'shouldYield: false',
      'Return a continuation',

      // The continuation should be scheduled in a separate macrotask even
      // though there's time remaining.
      'Post Message',
    ]);

    // No time has elapsed
    expect(performance.now()).toBe(0);

    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'Continuation Task']);
  });
});
