/**
 * Mimic the Timer class with external control of the "time" value, allowing tests to run more quickly and
 * reliably. Multiple instances of this class operate independently: they may report different time values, and
 * advancing one timer will not trigger timeouts set on another.
 */
class MockTimer {
    /**
     * Creates an instance of MockTimer.
     * @param {*} [nowObj=null] - alert the caller that this parameter, supported by Timer, is not supported here.
     * @memberof MockTimer
     */
    constructor (nowObj = null) {
        if (nowObj) {
            throw new Error('nowObj is not implemented in MockTimer');
        }

        /**
         * The fake "current time" value, in epoch milliseconds.
         * @type {number}
         */
        this._mockTime = 0;

        /**
         * Used to store the start time of a timer action.
         * Updated when calling `timer.start`.
         * @type {number}
         */
        this.startTime = 0;

        /**
         * The ID to use the next time `setTimeout` is called.
         * @type {number}
         */
        this._nextTimeoutId = 1;

        /**
         * Map of timeout ID to pending timeout callback info.
         * @type {Map.<Object>}
         * @property {number} time - the time at/after which this handler should run
         * @property {Function} handler - the handler to call when the time comes
         */
        this._timeouts = new Map();
    }

    /**
     * Advance this MockTimer's idea of "current time", running timeout handlers if appropriate.
     *
     * @param {number} milliseconds - the amount of time to add to the current mock time value, in milliseconds.
     * @memberof MockTimer
     */
    advanceMockTime (milliseconds) {
        if (milliseconds < 0) {
            throw new Error('Time may not move backward');
        }
        this._mockTime += milliseconds;
        this._runTimeouts();
    }

    /**
     * Advance this MockTimer's idea of "current time", running timeout handlers if appropriate.
     *
     * @param {number} milliseconds - the amount of time to add to the current mock time value, in milliseconds.
     * @returns {Promise} - promise which resolves after timeout handlers have had an opportunity to run.
     * @memberof MockTimer
     */
    advanceMockTimeAsync (milliseconds) {
        return new Promise(resolve => {
            this.advanceMockTime(milliseconds);
            global.setTimeout(resolve, 0);
        });
    }

    /**
     * @returns {number} - current mock time elapsed since 1 January 1970 00:00:00 UTC.
     * @memberof MockTimer
     */
    time () {
        return this._mockTime;
    }

    /**
     * Start a timer for measuring elapsed time.
     * @memberof MockTimer
     */
    start () {
        this.startTime = this._mockTime;
    }

    /**
     * @returns {number} - the time elapsed since `start()` was called.
     * @memberof MockTimer
     */
    timeElapsed () {
        return this._mockTime - this.startTime;
    }

    /**
     * Call a handler function after a specified amount of time has elapsed.
     * Guaranteed to happen in between "ticks" of JavaScript.
     * @param {function} handler - function to call after the timeout
     * @param {number} timeout - number of milliseconds to delay before calling the handler
     * @returns {number} - the ID of the new timeout.
     * @memberof MockTimer
     */
    setTimeout (handler, timeout) {
        const timeoutId = this._nextTimeoutId++;
        this._timeouts.set(timeoutId, {
            time: this._mockTime + timeout,
            handler
        });
        this._runTimeouts();
        return timeoutId;
    }

    /**
     * Clear a particular timeout from the pending timeout pool.
     * @param {number} timeoutId - the value returned from `setTimeout()`
     * @memberof MockTimer
     */
    clearTimeout (timeoutId) {
        this._timeouts.delete(timeoutId);
    }

    /**
     * WARNING: this method has no equivalent in `Timer`. Do not use this method outside of tests!
     * @returns {boolean} - true if there are any pending timeouts, false otherwise.
     * @memberof MockTimer
     */
    hasTimeouts () {
        return this._timeouts.size > 0;
    }

    /**
     * Run any timeout handlers whose timeouts have expired.
     * @memberof MockTimer
     */
    _runTimeouts () {
        const ready = [];

        this._timeouts.forEach((timeoutRecord, timeoutId) => {
            const isReady = timeoutRecord.time <= this._mockTime;
            if (isReady) {
                ready.push(timeoutRecord);
                this._timeouts.delete(timeoutId);
            }
        });

        // sort so that earlier timeouts run before later timeouts
        ready.sort((a, b) => a.time < b.time);

        // next tick, call everything that's ready
        global.setTimeout(() => {
            ready.forEach(o => o.handler());
        }, 0);
    }
}

module.exports = MockTimer;