GetOwnPropertyDescriptor,
  ObjectGetOwnPropertyDescriptors,
  Promise,
  Symbol,
  SymbolAsyncIterator,
  SymbolDispose,
  globalThis,
} = primordials;

const {
  validateAbortSignal,
  validateNumber,
  validateStringArray,
} = require('internal/validators');

const {
  AbortError,
  codes: {
    ERR_INVALID_ARG_VALUE,
    ERR_INVALID_STATE,
  },
} = require('internal/errors');

const { TIMEOUT_MAX } = require('internal/timers');

const PriorityQueue = require('internal/priority_queue');
const nodeTimers = require('timers');
const nodeTimersPromises = require('timers/promises');
const EventEmitter = require('events');

let kResistStopPropagation;
// Internal reference to the MockTimers class inside MockDate
let kMock;
// Initial epoch to which #now should be set to
const kInitialEpoch = 0;

function compareTimersLists(a, b) {
  return (a.runAt - b.runAt) || (a.id - b.id);
}

function setPosition(node, pos) {
  node.priorityQueuePosition = pos;
}

function abortIt(signal) {
  return new AbortError(undefined, { __proto__: null, cause: signal.reason });
}

/**
 * @enum {('setTimeout'|'setInterval'|'setImmediate'|'Date', 'scheduler.wait')[]} Supported timers
 */
const SUPPORTED_APIS = ['setTimeout', 'setInterval', 'setImmediate', 'Date', 'scheduler.wait'];
const TIMERS_DEFAULT_INTERVAL = {
  __proto__: null,
  setImmediate: -1,
};

class Timeout {
  constructor(opts) {
    this.id = opts.id;
    this.callback = opts.callback;
    this.runAt = opts.runAt;
    this.interval = opts.interval;
    this.args = opts.args;
  }

  hasRef() {
    return true;
  }

  ref() {
    return this;
  }

  unref() {
    return this;
  }

  refresh() {
    return this;
  }
}

class MockTimers {
  #realSetTimeout;
  #realClearTimeout;
  #realSetInterval;
  #realClearInterval;
  #realSetImmediate;
  #realClearImmediate;

  #realPromisifiedSetTimeout;
  #realPromisifiedSetInterval;
  #realTimersPromisifiedSchedulerWait;

  #realTimersSetTimeout;
  #realTimersClearTimeout;
  #realTimersSetInterval;
  #realTimersClearInterval;
  #realTimersSetImmediate;
  #realTimersClearImmediate;
  #realPromisifiedSetImmediate;

  #nativeDateDescriptor;

  #timersInContext = [];
  #isEnabled = false;
  #currentTimer = 1;
  #now = kInitialEpoch;

  #executionQueue = new PriorityQueue(compareTimersLists, setPosition);

  #setTimeout = FunctionPrototypeBind(this.#createTimer, this, false);
  #clearTimeout = FunctionPrototypeBind(this.#clearTimer, this);
  #setInterval = FunctionPrototypeBind(this.#createTimer, this, true);
  #clearInterval = FunctionPrototypeBind(this.#clearTimer, this);
  #clearImmediate = FunctionPrototypeBind(this.#clearTimer, this);

  #restoreSetImmediate() {
    ObjectDefineProperty(
      globalThis,
      'setImmediate',
      this.#realSetImmediate,
    );
    ObjectDefineProperty(
      globalThis,
      'clearImmediate',
      this.#realClearImmediate,
    );
    ObjectDefineProperty(
      nodeTimers,
      'setImmediate',
      this.#realTimersSetImmediate,
    );
    ObjectDefineProperty(
      nodeTimers,
      'clearImmediate',
      this.#realTimersClearImmediate,
    );
    ObjectDefineProperty(
      nodeTimersPromises,
      'setImmediate',
      this.#realPromisifiedSetImmediate,
    );
  }

  #restoreOriginalSetInterval() {
    ObjectDefineProperty(
      globalThis,
      'setInterval',
      this.#realSetInterval,
    );
    ObjectDefineProperty(
      globalThis,
      'clearInterval',
      this.#realClearInterval,
    );
    ObjectDefineProperty(
      nodeTimers,
      'setInterval',
      this.#realTimersSetInterval,
    );
    ObjectDefineProperty(
      nodeTimers,
      'clearInterval',
      this.#realTimersClearInterval,
    );
    ObjectDefineProperty(
      nodeTimersPromises,
      'setInterval',
      this.#realPromisifiedSetInterval,
    );
  }

  #restoreOriginalSchedulerWait() {
    nodeTimersPromises.scheduler.wait = FunctionPrototypeBind(
      this.#realTimersPromisifiedSchedulerWait,
      this,
    );
  }

  #restoreOriginalSetTimeout() {
    ObjectDefineProperty(
      globalThis,
      'setTimeout',
      this.#realSetTimeout,
    );
    ObjectDefineProperty(
      globalThis,
      'clearTimeout',
      this.#realClearTimeout,
    );
    ObjectDefineProperty(
      nodeTimers,
      'setTimeout',
      this.#realTimersSetTimeout,
    );
    ObjectDefineProperty(
      nodeTimers,
      'clearTimeout',
      this.#realTimersClearTimeout,
    );
    ObjectDefineProperty(
      nodeTimersPromises,
      'setTimeout',
      this.#realPromisifiedSetTimeout,
    );
  }

  #storeOriginalSetImmediate() {
    this.#realSetImmediate = ObjectGetOwnPropertyDescriptor(
      globalThis,
      'setImmediate',
    );
    this.#realClearImmediate = ObjectGetOwnPropertyDescriptor(
      globalThis,
      'clearImmediate',
    );
    this.#realTimersSetImmediate = ObjectGetOwnPropertyDescriptor(
      nodeTimers,
      'setImmediate',
    );
    this.#realTimersClearImmediate = ObjectGetOwnPropertyDescriptor(
      nodeTimers,
      'clearImmediate',
    );
    this.#realPromisifiedSetImmediate = ObjectGetOwnPropertyDescriptor(
      nodeTimersPromises,
      'setImmediate',
    );
  }

  #storeOriginalSetInterval() {
    this.#realSetInterval = ObjectGetOwnPropertyDescriptor(
      globalThis,
      'setInterval',
    );
    this.#realClearInterval = ObjectGetOwnPropertyDescriptor(
      globalThis,
      'clearInterval',
    );
    this.#realTimersSetInterval = ObjectGetOwnPropertyDescriptor(
      nodeTimers,
      'setInterval',
    );
    this.#realTimersClearInterval = ObjectGetOwnPropertyDescriptor(
      nodeTimers,
      'clearInterval',
    );
    this.#realPromisifiedSetInterval = ObjectGetOwnPropertyDescriptor(
      nodeTimersPromises,
      'setInterval',
    );
  }

  #storeOriginalSchedulerWait() {

    this.#realTimersPromisifiedSchedulerWait = FunctionPrototypeBind(
      nodeTimersPromises.scheduler.wait,
      this,
    );
  }

  #storeOriginalSetTimeout() {
    this.#realSetTimeout = ObjectGetOwnPropertyDescriptor(
      globalThis,
      'setTimeout',
    );
    this.#realClearTimeout = ObjectGetOwnPropertyDescriptor(
      globalThis,
      'clearTimeout',
    );
    this.#realTimersSetTimeout = ObjectGetOwnPropertyDescriptor(
      nodeTimers,
      'setTimeout',
    );
    this.#realTimersClearTimeout = ObjectGetOwnPropertyDescriptor(
      nodeTimers,
      'clearTimeout',
    );
    this.#realPromisifiedSetTimeout = ObjectGetOwnPropertyDescriptor(
      nodeTimersPromises,
      'setTimeout',
    );
  }

  #createTimer(isInterval, callback, delay, ...args) {
    if (delay > TIMEOUT_MAX) {
      delay = 1;
    }

    const timerId = this.#currentTimer++;
    const opts = {
      __proto__: null,
      id: timerId,
      callback,
      runAt: this.#now + delay,
      interval: isInterval ? delay : undefined,
      args,
    };

    const timer = new Timeout(opts);
    this.#executionQueue.insert(timer);
    return timer;
  }

  #clearTimer(timer) {
    if (timer?.priorityQueuePosition !== undefined) {
      this.#executionQueue.removeAt(timer.priorityQueuePosition);
      timer.priorityQueuePosition = undefined;
    }
  }

  #createDate() {
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
    if (NativeDateConstructor.isMock) {
      throw new ERR_INVALID_STATE('Date is already being mocked!');
    }
    /**
     * Function to mock the Date constructor, treats cases as per ECMA-262
     * and returns a Date object with a mocked implementation
     * @typedef {Date} MockDate
     * @returns {MockDate} a mocked Date object
     */
    function MockDate(year, month, date, hours, minutes, seconds, ms) {
      const mockTimersSource = MockDate[kMock];
      const nativeDate = mockTimersSource.#nativeDateDescriptor.value;

      // As of the fake-timers implementation for Sinon
      // ref https://github.com/sinonjs/fake-timers/blob/a4c757f80840829e45e0852ea1b17d87a998388e/src/fake-timers-src.js#L456
      // This covers the Date constructor called as a function ref.
      // ECMA-262 Edition 5.1 section 15.9.2.
      // and ECMA-262 Edition 14 Section 21.4.2.1
      // replaces 'this instanceof MockDate' with a more reliable check
      // from ECMA-262 Edition 14 Section 13.3.12.1 NewTarget
      if (!new.target) {
        return DatePrototypeToString(new nativeDate(mockTimersSource.#now));
      }

      // Cases where Date is called as a constructor
      // This is intended as a defensive implementation to avoid
      // having unexpected returns
      switch (arguments.length) {
        case 0:
          return new nativeDate(MockDate[kMock].#now);
        case 1:
          return new nativeDate(year);
        case 2:
          return new nativeDate(year, month);
        case 3:
          return new nativeDate(year, month, date);
        case 4:
          return new nativeDate(year, month, date, hours);
        case 5:
          return new nativeDate(year, month, date, hours, minutes);
        case 6:
          return new nativeDate(year, month, date, hours, minutes, seconds);
        default:
          return new nativeDate(year, month, date, hours, minutes, seconds, ms);
      }
    }

    // Prototype is read-only, and non assignable through Object.defineProperties
    // eslint-disable-next-line no-unused-vars -- used to get the prototype out of the object
    const { prototype, ...dateProps } = ObjectGetOwnPropertyDescriptors(NativeDateConstructor);

    // Binds all the properties of Date to the MockDate function
    ObjectDefineProperties(
      MockDate,
      dateProps,
    );

    MockDate.now = function now() {
      return MockDate[kMock].#now;
    };

    // This is just to print the function { native code } in the console
    // when the user prints the function and not the internal code
    MockDate.toString = function toString() {
      return FunctionPrototypeToString(MockDate[kMock].#nativeDateDescriptor.value);
    };

    // We need to pollute the prototype of this
    ObjectDefineProperties(MockDate, {
      __proto__: null,
      [kMock]: {
        __proto__: null,
        enumerable: false,
        configurable: false,
        writable: false,
        value: this,
      },

      isMock: {
        __proto__: null,
        enumerable: true,
        configurable: false,
        writable: false,
        value: true,
      },
    });

    MockDate.prototype = NativeDateConstructor.prototype;
    MockDate.parse = NativeDateConstructor.parse;
    MockDate.UTC = NativeDateConstructor.UTC;
    MockDate.prototype.toUTCString = NativeDateConstructor.prototype.toUTCString;
    return MockDate;
  }

  async * #setIntervalPromisified(interval, result, options) {
    const context = this;
    const emitter = new EventEmitter();
    if (options?.signal) {
      validateAbortSignal(options.signal, 'options.signal');

      if (options.signal.aborted) {
        throw abortIt(options.signal);
      }

      const onAbort = (reason) => {
        emitter.emit('data', { __proto__: null, aborted: true, reason });
      };

      kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
      options.signal.addEventListener('abort', onAbort, {
        __proto__: null,
        once: true,
        [kResistStopPropagation]: true,
      });
    }

    const eventIt = EventEmitter.on(emitter, 'data');
    const callback = () => {
      emitter.emit('data', result);
    };

    const timer = this.#createTimer(true, callback, interval, options);
    const clearListeners = () => {
      emitter.removeAllListeners();
      context.#clearTimer(timer);
    };
    const iterator = {
      __proto__: null,
      [SymbolAsyncIterator]() {
        return this;
      },
      async next() {
        const result = await eventIt.next();
        const value = ArrayPrototypeAt(result.value, 0);
        if (value?.aborted) {
          iterator.return();
          throw abortIt(options.signal);
        }

        return {
          __proto__: null,
          done: result.done,
          value,
        };
      },
      async return() {
        clearListeners();
        return eventIt.return();
      },
    };
    yield* iterator;
  }

  #setImmediate(callback, ...args) {
    return this.#createTimer(
      false,
      callback,
      TIMERS_DEFAULT_INTERVAL.setImmediate,
      ...args,
    );
  }

  #promisifyTimer({ timerFn, clearFn, ms, result, options }) {
    return new Promise((resolve, reject) => {
      if (options?.signal) {
        try {
          validateAbortSignal(options.signal, 'options.signal');
        } catch (err) {
          return reject(err);
        }

        if (options.signal.aborted) {
          return reject(abortIt(options.signal));
        }
      }

      const onabort = () => {
        clearFn(timer);
        return reject(abortIt(options.signal));
      };

      const timer = timerFn(() => {
        return resolve(result);
      }, ms);

      if (options?.signal) {
        kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
        options.signal.addEventListener('abort', onabort, {
          __proto__: null,
          once: true,
          [kResistStopPropagation]: true,
        });
      }
    });
  }

  #setImmediatePromisified(result, options) {
    return this.#promisifyTimer({
      __proto__: null,
      timerFn: FunctionPrototypeBind(this.#setImmediate, this),
      clearFn: FunctionPrototypeBind(this.#clearImmediate, this),
      ms: TIMERS_DEFAULT_INTERVAL.setImmediate,
      result,
      options,
    });
  }

  #setTimeoutPromisified(ms, result, options) {
    return this.#promisifyTimer({
      __proto__: null,
      timerFn: FunctionPrototypeBind(this.#setTimeout, this),
      clearFn: FunctionPrototypeBind(this.#clearTimeout, this),
      ms,
      result,
      options,
    });
  }

  #assertTimersAreEnabled() {
    if (!this.#isEnabled) {
      throw new ERR_INVALID_STATE(
        'You should enable MockTimers first by calling the .enable function',
      );
    }
  }

  #assertTimeArg(time) {
    if (time < 0) {
      throw new ERR_INVALID_ARG_VALUE('time', 'positive integer', time);
    }
  }

  #isValidDateWithGetTime(maybeDate) {
    // Validation inspired on https://github.com/inspect-js/is-date-object/blob/main/index.js#L3-L11
    try {
      DatePrototypeGetTime(maybeDate);
      return true;
    } catch {
      return false;
    }
  }

  #toggleEnableTimers(activate) {
    const options = {
      __proto__: null,
      toFake: {
        '__proto__': null,
        'scheduler.wait': () => {
          this.#storeOriginalSchedulerWait();

          nodeTimersPromises.scheduler.wait = (delay, options) =>
            this.#setTimeoutPromisified(delay, undefined, options);
        },
        'setTimeout': () => {
          this.#storeOriginalSetTimeout();

          globalThis.setTimeout = this.#setTimeout;
          globalThis.clearTimeout = this.#clearTimeout;

          nodeTimers.setTimeout = this.#setTimeout;
          nodeTimers.clearTimeout = this.#clearTimeout;

          nodeTimersPromises.setTimeout = FunctionPrototypeBind(
            this.#setTimeoutPromisified,
            this,
          );
        },
        'setInterval': () => {
          this.#storeOriginalSetInterval();

          globalThis.setInterval = this.#setInterval;
          globalThis.clearInterval = this.#clearInterval;

          nodeTimers.setInterval = this.#setInterval;
          nodeTimers.clearInterval = this.#clearInterval;

          nodeTimersPromises.setInterval = FunctionPrototypeBind(
            this.#setIntervalPromisified,
            this,
          );
        },
        'setImmediate': () => {
          this.#storeOriginalSetImmediate();

          // setImmediate functions needs to bind MockTimers
          // otherwise it will throw an error when called
          // "Receiver must be an instance of MockTimers"
          // because #setImmediate is the only function here
          // that calls #createTimer and it's not bound to MockTimers
          globalThis.setImmediate = FunctionPrototypeBind(
            this.#setImmediate,
            this,
          );
          globalThis.clearImmediate = this.#clearImmediate;

          nodeTimers.setImmediate = FunctionPrototypeBind(
            this.#setImmediate,
            this,
          );
          nodeTimers.clearImmediate = this.#clearImmediate;
          nodeTimersPromises.setImmediate = FunctionPrototypeBind(
            this.#setImmediatePromisified,
            this,
          );
        },
        'Date': () => {
          this.#nativeDateDescriptor = ObjectGetOwnPropertyDescriptor(globalThis, 'Date');
          globalThis.Date = this.#createDate();
        },
      },
      toReal: {
        '__proto__': null,
        'scheduler.wait': () => {
          this.#restoreOriginalSchedulerWait();
        },
        'setTimeout': () => {
          this.#restoreOriginalSetTimeout();
        },
        'setInterval': () => {
          this.#restoreOriginalSetInterval();
        },
        'setImmediate': () => {
          this.#restoreSetImmediate();
        },
        'Date': () => {
          ObjectDefineProperty(globalThis, 'Date', this.#nativeDateDescriptor);
        },
      },
    };

    const target = activate ? options.toFake : options.toReal;
    ArrayPrototypeForEach(this.#timersInContext, (timer) => target[timer]());
    this.#isEnabled = activate;
  }

  /**
   * Advances the virtual time of MockTimers by the specified duration (in milliseconds).
   * This method simulates the passage of time and triggers any scheduled timers that are due.
   * @param {number} [time=1] - The amount of time (in milliseconds) to advance the virtual time.
   * @throws {ERR_INVALID_STATE} If MockTimers are not enabled.
   * @throws {ERR_INVALID_ARG_VALUE} If a negative time value is provided.
   */
  tick(time = 1) {
    this.#assertTimersAreEnabled();
    this.#assertTimeArg(time);

    this.#now += time;
    let timer = this.#executionQueue.peek();
    while (timer) {
      if (timer.runAt > this.#now) break;
      FunctionPrototypeApply(timer.callback, undefined, timer.args);

      // Check if the timeout was cleared by calling clearTimeout inside its own callback
      const afterCallback = this.#executionQueue.peek();
      if (afterCallback?.id === timer.id) {
        this.#executionQueue.shift();
        timer.priorityQueuePosition = undefined;
      }

      if (timer.interval !== undefined) {
        timer.runAt += timer.interval;
        this.#executionQueue.insert(timer);
      }

      timer = this.#executionQueue.peek();
    }
  }

  /**
   * @typedef {{apis: SUPPORTED_APIS;now: number | Date;}} EnableOptions Options to enable the timers
   * @property {SUPPORTED_APIS} apis List of timers to enable, defaults to all
   * @property {number | Date} now The epoch to which the timers should be set to, defaults to 0
   */
  /**
   * Enables the MockTimers replacing the native timers with the fake ones.
   * @param {EnableOptions} [options]
   */
  enable(options = { __proto__: null, apis: SUPPORTED_APIS, now: 0 }) {
    const internalOptions = { __proto__: null, ...options };
    if (this.#isEnabled) {
      throw new ERR_INVALID_STATE('MockTimers is already enabled!');
    }

    if (NumberIsNaN(internalOptions.now)) {
      throw new ERR_INVALID_ARG_VALUE('now', internalOptions.now, `epoch must be a positive integer received ${internalOptions.now}`);
    }

    internalOptions.now ||= 0;

    internalOptions.apis ||= SUPPORTED_APIS;

    validateStringArray(internalOptions.apis, 'options.apis');
    // Check that the timers passed are supported
    ArrayPrototypeForEach(internalOptions.apis, (timer) => {
      if (!ArrayPrototypeIncludes(SUPPORTED_APIS, timer)) {
        throw new ERR_INVALID_ARG_VALUE(
          'options.apis',
          timer,
          `option ${timer} is not supported`,
        );
      }
    });
    this.#timersInContext = internalOptions.apis;

    // Checks if the second argument is the initial time
    if (this.#isValidDateWithGetTime(internalOptions.now)) {
      this.#now = DatePrototypeGetTime(internalOptions.now);
    } else if (validateNumber(internalOptions.now, 'initialTime') === undefined) {
      this.#assertTimeArg(internalOptions.now);
      this.#now = internalOptions.now;
    }

    this.#toggleEnableTimers(true);
  }

  /**
   * Sets the current time to the given epoch.
   * @param {number} time The epoch to set the current time to.
   */
  setTime(time = kInitialEpoch) {
    validateNumber(time, 'time');
    this.#assertTimeArg(time);
    this.#assertTimersAreEnabled();

    this.#now = time;
  }

  /**
   * An alias for `this.reset()`, allowing the disposal of the `MockTimers` instance.
   */
  [SymbolDispose]() {
    this.reset();
  }

  /**
   * Resets MockTimers, disabling any enabled timers and clearing the execution queue.
   * Does nothing if MockTimers are not enabled.
   */
  reset() {
    // Ignore if not enabled
    if (!this.#isEnabled) return;

    this.#toggleEnableTimers(false);
    this.#timersInContext = [];
    this.#now = kInitialEpoch;

    let timer = this.#executionQueue.peek();
    while (timer) {
      this.#executionQueue.shift();
      timer = this.#executionQueue.peek();
    }
  }

  /**
   * Runs all scheduled timers until there are no more pending timers.
   * @throws {ERR_INVALID_STATE} If MockTimers are not enabled.
   */
  runAll() {
    this.#assertTimersAreEnabled();
    const longestTimer = this.#executionQueue.peekBottom();
    if (!longestTimer) return;
    this.tick(longestTimer.runAt - this.#now);
  }
}

module.exports = { MockTimers };
