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

'use strict';

let Scheduler;
let runWithPriority;
let ImmediatePriority;
let UserBlockingPriority;
let NormalPriority;
let LowPriority;
let IdlePriority;
let scheduleCallback;
let cancelCallback;
let wrapCallback;
let getCurrentPriorityLevel;
let shouldYield;
let waitForAll;
let assertLog;
let waitFor;
let waitForPaint;

describe('Scheduler', () => {
  beforeEach(() => {
    jest.resetModules();
    jest.mock('scheduler', () => require('scheduler/unstable_mock'));

    Scheduler = require('scheduler');

    runWithPriority = Scheduler.unstable_runWithPriority;
    ImmediatePriority = Scheduler.unstable_ImmediatePriority;
    UserBlockingPriority = Scheduler.unstable_UserBlockingPriority;
    NormalPriority = Scheduler.unstable_NormalPriority;
    LowPriority = Scheduler.unstable_LowPriority;
    IdlePriority = Scheduler.unstable_IdlePriority;
    scheduleCallback = Scheduler.unstable_scheduleCallback;
    cancelCallback = Scheduler.unstable_cancelCallback;
    wrapCallback = Scheduler.unstable_wrapCallback;
    getCurrentPriorityLevel = Scheduler.unstable_getCurrentPriorityLevel;
    shouldYield = Scheduler.unstable_shouldYield;

    const InternalTestUtils = require('internal-test-utils');
    waitForAll = InternalTestUtils.waitForAll;
    assertLog = InternalTestUtils.assertLog;
    waitFor = InternalTestUtils.waitFor;
    waitForPaint = InternalTestUtils.waitForPaint;
  });

  it('flushes work incrementally', async () => {
    scheduleCallback(NormalPriority, () => Scheduler.log('A'));
    scheduleCallback(NormalPriority, () => Scheduler.log('B'));
    scheduleCallback(NormalPriority, () => Scheduler.log('C'));
    scheduleCallback(NormalPriority, () => Scheduler.log('D'));

    await waitFor(['A', 'B']);
    await waitFor(['C']);
    await waitForAll(['D']);
  });

  it('cancels work', async () => {
    scheduleCallback(NormalPriority, () => Scheduler.log('A'));
    const callbackHandleB = scheduleCallback(NormalPriority, () =>
      Scheduler.log('B'),
    );
    scheduleCallback(NormalPriority, () => Scheduler.log('C'));

    cancelCallback(callbackHandleB);

    await waitForAll([
      'A',
      // B should have been cancelled
      'C',
    ]);
  });

  it('executes the highest priority callbacks first', async () => {
    scheduleCallback(NormalPriority, () => Scheduler.log('A'));
    scheduleCallback(NormalPriority, () => Scheduler.log('B'));

    // Yield before B is flushed
    await waitFor(['A']);

    scheduleCallback(UserBlockingPriority, () => Scheduler.log('C'));
    scheduleCallback(UserBlockingPriority, () => Scheduler.log('D'));

    // C and D should come first, because they are higher priority
    await waitForAll(['C', 'D', 'B']);
  });

  it('expires work', async () => {
    scheduleCallback(NormalPriority, didTimeout => {
      Scheduler.unstable_advanceTime(100);
      Scheduler.log(`A (did timeout: ${didTimeout})`);
    });
    scheduleCallback(UserBlockingPriority, didTimeout => {
      Scheduler.unstable_advanceTime(100);
      Scheduler.log(`B (did timeout: ${didTimeout})`);
    });
    scheduleCallback(UserBlockingPriority, didTimeout => {
      Scheduler.unstable_advanceTime(100);
      Scheduler.log(`C (did timeout: ${didTimeout})`);
    });

    // Advance time, but not by enough to expire any work
    Scheduler.unstable_advanceTime(249);
    assertLog([]);

    // Schedule a few more callbacks
    scheduleCallback(NormalPriority, didTimeout => {
      Scheduler.unstable_advanceTime(100);
      Scheduler.log(`D (did timeout: ${didTimeout})`);
    });
    scheduleCallback(NormalPriority, didTimeout => {
      Scheduler.unstable_advanceTime(100);
      Scheduler.log(`E (did timeout: ${didTimeout})`);
    });

    // Advance by just a bit more to expire the user blocking callbacks
    Scheduler.unstable_advanceTime(1);
    await waitFor(['B (did timeout: true)', 'C (did timeout: true)']);

    // Expire A
    Scheduler.unstable_advanceTime(4600);
    await waitFor(['A (did timeout: true)']);

    // Flush the rest without expiring
    await waitForAll(['D (did timeout: false)', 'E (did timeout: true)']);
  });

  it('has a default expiration of ~5 seconds', () => {
    scheduleCallback(NormalPriority, () => Scheduler.log('A'));

    Scheduler.unstable_advanceTime(4999);
    assertLog([]);

    Scheduler.unstable_advanceTime(1);
    Scheduler.unstable_flushExpired();
    assertLog(['A']);
  });

  it('continues working on same task after yielding', async () => {
    scheduleCallback(NormalPriority, () => {
      Scheduler.unstable_advanceTime(100);
      Scheduler.log('A');
    });
    scheduleCallback(NormalPriority, () => {
      Scheduler.unstable_advanceTime(100);
      Scheduler.log('B');
    });

    let didYield = false;
    const tasks = [
      ['C1', 100],
      ['C2', 100],
      ['C3', 100],
    ];
    const C = () => {
      while (tasks.length > 0) {
        const [label, ms] = tasks.shift();
        Scheduler.unstable_advanceTime(ms);
        Scheduler.log(label);
        if (shouldYield()) {
          didYield = true;
          return C;
        }
      }
    };

    scheduleCallback(NormalPriority, C);

    scheduleCallback(NormalPriority, () => {
      Scheduler.unstable_advanceTime(100);
      Scheduler.log('D');
    });
    scheduleCallback(NormalPriority, () => {
      Scheduler.unstable_advanceTime(100);
      Scheduler.log('E');
    });

    // Flush, then yield while in the middle of C.
    expect(didYield).toBe(false);
    await waitFor(['A', 'B', 'C1']);
    expect(didYield).toBe(true);

    // When we resume, we should continue working on C.
    await waitForAll(['C2', 'C3', 'D', 'E']);
  });

  it('continuation callbacks inherit the expiration of the previous callback', async () => {
    const tasks = [
      ['A', 125],
      ['B', 124],
      ['C', 100],
      ['D', 100],
    ];
    const work = () => {
      while (tasks.length > 0) {
        const [label, ms] = tasks.shift();
        Scheduler.unstable_advanceTime(ms);
        Scheduler.log(label);
        if (shouldYield()) {
          return work;
        }
      }
    };

    // Schedule a high priority callback
    scheduleCallback(UserBlockingPriority, work);

    // Flush until just before the expiration time
    await waitFor(['A', 'B']);

    // Advance time by just a bit more. This should expire all the remaining work.
    Scheduler.unstable_advanceTime(1);
    Scheduler.unstable_flushExpired();
    assertLog(['C', 'D']);
  });

  it('continuations are interrupted by higher priority work', async () => {
    const tasks = [
      ['A', 100],
      ['B', 100],
      ['C', 100],
      ['D', 100],
    ];
    const work = () => {
      while (tasks.length > 0) {
        const [label, ms] = tasks.shift();
        Scheduler.unstable_advanceTime(ms);
        Scheduler.log(label);
        if (tasks.length > 0 && shouldYield()) {
          return work;
        }
      }
    };
    scheduleCallback(NormalPriority, work);
    await waitFor(['A']);

    scheduleCallback(UserBlockingPriority, () => {
      Scheduler.unstable_advanceTime(100);
      Scheduler.log('High pri');
    });

    await waitForAll(['High pri', 'B', 'C', 'D']);
  });

  it(
    'continuations do not block higher priority work scheduled ' +
      'inside an executing callback',
    async () => {
      const tasks = [
        ['A', 100],
        ['B', 100],
        ['C', 100],
        ['D', 100],
      ];
      const work = () => {
        while (tasks.length > 0) {
          const task = tasks.shift();
          const [label, ms] = task;
          Scheduler.unstable_advanceTime(ms);
          Scheduler.log(label);
          if (label === 'B') {
            // Schedule high pri work from inside another callback
            Scheduler.log('Schedule high pri');
            scheduleCallback(UserBlockingPriority, () => {
              Scheduler.unstable_advanceTime(100);
              Scheduler.log('High pri');
            });
          }
          if (tasks.length > 0) {
            // Return a continuation
            return work;
          }
        }
      };
      scheduleCallback(NormalPriority, work);
      await waitForAll([
        'A',
        'B',
        'Schedule high pri',
        // The high pri callback should fire before the continuation of the
        // lower pri work
        'High pri',
        // Continue low pri work
        'C',
        'D',
      ]);
    },
  );

  it('cancelling a continuation', async () => {
    const task = scheduleCallback(NormalPriority, () => {
      Scheduler.log('Yield');
      return () => {
        Scheduler.log('Continuation');
      };
    });

    await waitFor(['Yield']);
    cancelCallback(task);
    await waitForAll([]);
  });

  it('top-level immediate callbacks fire in a subsequent task', () => {
    scheduleCallback(ImmediatePriority, () => Scheduler.log('A'));
    scheduleCallback(ImmediatePriority, () => Scheduler.log('B'));
    scheduleCallback(ImmediatePriority, () => Scheduler.log('C'));
    scheduleCallback(ImmediatePriority, () => Scheduler.log('D'));
    // Immediate callback hasn't fired, yet.
    assertLog([]);
    // They all flush immediately within the subsequent task.
    Scheduler.unstable_flushExpired();
    assertLog(['A', 'B', 'C', 'D']);
  });

  it('nested immediate callbacks are added to the queue of immediate callbacks', () => {
    scheduleCallback(ImmediatePriority, () => Scheduler.log('A'));
    scheduleCallback(ImmediatePriority, () => {
      Scheduler.log('B');
      // This callback should go to the end of the queue
      scheduleCallback(ImmediatePriority, () => Scheduler.log('C'));
    });
    scheduleCallback(ImmediatePriority, () => Scheduler.log('D'));
    assertLog([]);
    // C should flush at the end
    Scheduler.unstable_flushExpired();
    assertLog(['A', 'B', 'D', 'C']);
  });

  it('wrapped callbacks have same signature as original callback', () => {
    const wrappedCallback = wrapCallback((...args) => ({args}));
    expect(wrappedCallback('a', 'b')).toEqual({args: ['a', 'b']});
  });

  it('wrapped callbacks inherit the current priority', () => {
    const wrappedCallback = runWithPriority(NormalPriority, () =>
      wrapCallback(() => {
        Scheduler.log(getCurrentPriorityLevel());
      }),
    );

    const wrappedUserBlockingCallback = runWithPriority(
      UserBlockingPriority,
      () =>
        wrapCallback(() => {
          Scheduler.log(getCurrentPriorityLevel());
        }),
    );

    wrappedCallback();
    assertLog([NormalPriority]);

    wrappedUserBlockingCallback();
    assertLog([UserBlockingPriority]);
  });

  it('wrapped callbacks inherit the current priority even when nested', () => {
    let wrappedCallback;
    let wrappedUserBlockingCallback;

    runWithPriority(NormalPriority, () => {
      wrappedCallback = wrapCallback(() => {
        Scheduler.log(getCurrentPriorityLevel());
      });
      wrappedUserBlockingCallback = runWithPriority(UserBlockingPriority, () =>
        wrapCallback(() => {
          Scheduler.log(getCurrentPriorityLevel());
        }),
      );
    });

    wrappedCallback();
    assertLog([NormalPriority]);

    wrappedUserBlockingCallback();
    assertLog([UserBlockingPriority]);
  });

  it("immediate callbacks fire even if there's an error", () => {
    scheduleCallback(ImmediatePriority, () => {
      Scheduler.log('A');
      throw new Error('Oops A');
    });
    scheduleCallback(ImmediatePriority, () => {
      Scheduler.log('B');
    });
    scheduleCallback(ImmediatePriority, () => {
      Scheduler.log('C');
      throw new Error('Oops C');
    });

    expect(() => Scheduler.unstable_flushExpired()).toThrow('Oops A');
    assertLog(['A']);

    // B and C flush in a subsequent event. That way, the second error is not
    // swallowed.
    expect(() => Scheduler.unstable_flushExpired()).toThrow('Oops C');
    assertLog(['B', 'C']);
  });

  it('multiple immediate callbacks can throw and there will be an error for each one', () => {
    scheduleCallback(ImmediatePriority, () => {
      throw new Error('First error');
    });
    scheduleCallback(ImmediatePriority, () => {
      throw new Error('Second error');
    });
    expect(() => Scheduler.unstable_flushAll()).toThrow('First error');
    // The next error is thrown in the subsequent event
    expect(() => Scheduler.unstable_flushAll()).toThrow('Second error');
  });

  it('exposes the current priority level', () => {
    Scheduler.log(getCurrentPriorityLevel());
    runWithPriority(ImmediatePriority, () => {
      Scheduler.log(getCurrentPriorityLevel());
      runWithPriority(NormalPriority, () => {
        Scheduler.log(getCurrentPriorityLevel());
        runWithPriority(UserBlockingPriority, () => {
          Scheduler.log(getCurrentPriorityLevel());
        });
      });
      Scheduler.log(getCurrentPriorityLevel());
    });

    assertLog([
      NormalPriority,
      ImmediatePriority,
      NormalPriority,
      UserBlockingPriority,
      ImmediatePriority,
    ]);
  });

  if (__DEV__) {
    // Function names are minified in prod, though you could still infer the
    // priority if you have sourcemaps.
    // TODO: Feature temporarily disabled while we investigate a bug in one of
    // our minifiers.
    // eslint-disable-next-line jest/no-disabled-tests
    it.skip('adds extra function to the JS stack whose name includes the priority level', async () => {
      function inferPriorityFromCallstack() {
        try {
          throw Error();
        } catch (e) {
          const stack = e.stack;
          const lines = stack.split('\n');
          for (let i = lines.length - 1; i >= 0; i--) {
            const line = lines[i];
            const found = line.match(
              /scheduler_flushTaskAtPriority_([A-Za-z]+)/,
            );
            if (found !== null) {
              const priorityStr = found[1];
              switch (priorityStr) {
                case 'Immediate':
                  return ImmediatePriority;
                case 'UserBlocking':
                  return UserBlockingPriority;
                case 'Normal':
                  return NormalPriority;
                case 'Low':
                  return LowPriority;
                case 'Idle':
                  return IdlePriority;
              }
            }
          }
          return null;
        }
      }

      scheduleCallback(ImmediatePriority, () =>
        Scheduler.log('Immediate: ' + inferPriorityFromCallstack()),
      );
      scheduleCallback(UserBlockingPriority, () =>
        Scheduler.log('UserBlocking: ' + inferPriorityFromCallstack()),
      );
      scheduleCallback(NormalPriority, () =>
        Scheduler.log('Normal: ' + inferPriorityFromCallstack()),
      );
      scheduleCallback(LowPriority, () =>
        Scheduler.log('Low: ' + inferPriorityFromCallstack()),
      );
      scheduleCallback(IdlePriority, () =>
        Scheduler.log('Idle: ' + inferPriorityFromCallstack()),
      );

      await waitForAll([
        'Immediate: ' + ImmediatePriority,
        'UserBlocking: ' + UserBlockingPriority,
        'Normal: ' + NormalPriority,
        'Low: ' + LowPriority,
        'Idle: ' + IdlePriority,
      ]);
    });
  }

  describe('delayed tasks', () => {
    it('schedules a delayed task', async () => {
      scheduleCallback(NormalPriority, () => Scheduler.log('A'), {
        delay: 1000,
      });

      // Should flush nothing, because delay hasn't elapsed
      await waitForAll([]);

      // Advance time until right before the threshold
      Scheduler.unstable_advanceTime(999);
      // Still nothing
      await waitForAll([]);

      // Advance time past the threshold
      Scheduler.unstable_advanceTime(1);

      // Now it should flush like normal
      await waitForAll(['A']);
    });

    it('schedules multiple delayed tasks', async () => {
      scheduleCallback(NormalPriority, () => Scheduler.log('C'), {
        delay: 300,
      });

      scheduleCallback(NormalPriority, () => Scheduler.log('B'), {
        delay: 200,
      });

      scheduleCallback(NormalPriority, () => Scheduler.log('D'), {
        delay: 400,
      });

      scheduleCallback(NormalPriority, () => Scheduler.log('A'), {
        delay: 100,
      });

      // Should flush nothing, because delay hasn't elapsed
      await waitForAll([]);

      // Advance some time.
      Scheduler.unstable_advanceTime(200);
      // Both A and B are no longer delayed. They can now flush incrementally.
      await waitFor(['A']);
      await waitForAll(['B']);

      // Advance the rest
      Scheduler.unstable_advanceTime(200);
      await waitForAll(['C', 'D']);
    });

    it('interleaves normal tasks and delayed tasks', async () => {
      // Schedule some high priority callbacks with a delay. When their delay
      // elapses, they will be the most important callback in the queue.
      scheduleCallback(UserBlockingPriority, () => Scheduler.log('Timer 2'), {
        delay: 300,
      });
      scheduleCallback(UserBlockingPriority, () => Scheduler.log('Timer 1'), {
        delay: 100,
      });

      // Schedule some tasks at default priority.
      scheduleCallback(NormalPriority, () => {
        Scheduler.log('A');
        Scheduler.unstable_advanceTime(100);
      });
      scheduleCallback(NormalPriority, () => {
        Scheduler.log('B');
        Scheduler.unstable_advanceTime(100);
      });
      scheduleCallback(NormalPriority, () => {
        Scheduler.log('C');
        Scheduler.unstable_advanceTime(100);
      });
      scheduleCallback(NormalPriority, () => {
        Scheduler.log('D');
        Scheduler.unstable_advanceTime(100);
      });

      // Flush all the work. The timers should be interleaved with the
      // other tasks.
      await waitForAll(['A', 'Timer 1', 'B', 'C', 'Timer 2', 'D']);
    });

    it('interleaves delayed tasks with time-sliced tasks', async () => {
      // Schedule some high priority callbacks with a delay. When their delay
      // elapses, they will be the most important callback in the queue.
      scheduleCallback(UserBlockingPriority, () => Scheduler.log('Timer 2'), {
        delay: 300,
      });
      scheduleCallback(UserBlockingPriority, () => Scheduler.log('Timer 1'), {
        delay: 100,
      });

      // Schedule a time-sliced task at default priority.
      const tasks = [
        ['A', 100],
        ['B', 100],
        ['C', 100],
        ['D', 100],
      ];
      const work = () => {
        while (tasks.length > 0) {
          const task = tasks.shift();
          const [label, ms] = task;
          Scheduler.unstable_advanceTime(ms);
          Scheduler.log(label);
          if (tasks.length > 0) {
            return work;
          }
        }
      };
      scheduleCallback(NormalPriority, work);

      // Flush all the work. The timers should be interleaved with the
      // other tasks.
      await waitForAll(['A', 'Timer 1', 'B', 'C', 'Timer 2', 'D']);
    });

    it('cancels a delayed task', async () => {
      // Schedule several tasks with the same delay
      const options = {delay: 100};

      scheduleCallback(NormalPriority, () => Scheduler.log('A'), options);
      const taskB = scheduleCallback(
        NormalPriority,
        () => Scheduler.log('B'),
        options,
      );
      const taskC = scheduleCallback(
        NormalPriority,
        () => Scheduler.log('C'),
        options,
      );

      // Cancel B before its delay has elapsed
      await waitForAll([]);
      cancelCallback(taskB);

      // Cancel C after its delay has elapsed
      Scheduler.unstable_advanceTime(500);
      cancelCallback(taskC);

      // Only A should flush
      await waitForAll(['A']);
    });

    it('gracefully handles scheduled tasks that are not a function', async () => {
      scheduleCallback(ImmediatePriority, null);
      await waitForAll([]);

      scheduleCallback(ImmediatePriority, undefined);
      await waitForAll([]);

      scheduleCallback(ImmediatePriority, {});
      await waitForAll([]);

      scheduleCallback(ImmediatePriority, 42);
      await waitForAll([]);
    });

    it('toFlushUntilNextPaint stops if a continuation is returned', async () => {
      scheduleCallback(NormalPriority, () => {
        Scheduler.log('Original Task');
        Scheduler.log('shouldYield: ' + shouldYield());
        Scheduler.log('Return a continuation');
        return () => {
          Scheduler.log('Continuation Task');
        };
      });

      await waitForPaint([
        '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 not flush yet.
      ]);

      // No time has elapsed
      expect(Scheduler.unstable_now()).toBe(0);

      // Continue the task
      await waitForAll(['Continuation Task']);
    });

    it("toFlushAndYield keeps flushing even if there's a continuation", async () => {
      scheduleCallback(NormalPriority, () => {
        Scheduler.log('Original Task');
        Scheduler.log('shouldYield: ' + shouldYield());
        Scheduler.log('Return a continuation');
        return () => {
          Scheduler.log('Continuation Task');
        };
      });

      await waitForAll([
        '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 flush immediately, even though the task
        // yielded a continuation.
        'Continuation Task',
      ]);
    });
  });
});