import assert from "assert";
import { typeUtils } from "./typeUtils.class.js";

/*
 * Thenable implements a function chain that mimics the Promise instance API and can degrade to being backed by Promises
 * if necessary. If no Promises are encountered in the chain this will result in better performance because it chains functions
 * without pushing anything into a future tick in the microtask queue, i.e. it can be synchronous. When Promises go into the
 * same microtask queue they can be slowed down by things already ahead of them in the queue. Using Thenables allows us to
 * be synchronous when we can and degrade to being asynchronous (Promises) when we must.
 *
 * Note that Thenables are a notion from the Promise spec, see section 2.3.3 in https://promisesaplus.com/
 *
 * Note also that Promises are interoperable with the Thenable concept -- they have a construtor to convert a Thenable to a
 * Promise as well as a static method to contruct a Promise from a Thenable. See:
 * <pre><code>
 * new Promise((resolve, reject) => {resolve(thenable);}); // (constructor)
 * Promise.resolve(thenable); // (static)
 * </code></pre>
 *
 * Note that with this Promise interoperability, Thenable works with async/await. E.g. just like you can do the following with
 * Promises:
 * <pre><code>
 * async function asyncFunc() {
 *     var value = await Promise.resolve(1)
 *         .then(x => x * 3)
 *         .then(await Promise.resolve(x => x + 5))
 *         .then(x => x / 2);
 *     return value;
 * }
 * asyncFunc().then(x => {console.log(`x: ${x}`); return x;});
 * // log output: x: 4
 * // result: Promise {[[PromiseStatus]]: "resolved", [[PromiseValue]]: 4}
 * </code></pre>
 * you can also do the same thing with Thenable:
 * <pre><code>
 * async function asyncFunc() {
 *     var value = await Thenable.resolve(1)
 *         .then(x => x * 3)
 *         .then(await Thenable.resolve(x => x + 5))
 *         .then(x => x / 2);
 *     return value;
 * }
 * asyncFunc().then(x => {console.log(`x: ${x}`); return x;});
 * // log output: x: 4
 * // result: Promise {[[PromiseStatus]]: "resolved", [[PromiseValue]]: 4}
 * </code></pre>
 * While this interoperability with async/await may prove very handy when necessary, you should be careful using async/await
 * because once you do you will leave the synchronous chain of Thenables and start an asynchronous chain of Promises.
 */
export class Thenable {
    /*
     * Constructor.
     * @param value: any - The value that the thenable should resolve to.
     * @param rejectionReason: any - The rejection reason if the thenable rejects.
     */
    constructor(executor) {
        if (typeof executor != "function") {
            throw new Error("executor must be a function!");
        }

        this.value = undefined;
        this.rejectionReason = undefined;

        // This is expected to be synchronous.
        executor(this.resolver.bind(this), this.rejector.bind(this));
    }

    resolver(value) {
        this.value = value;
    }

    rejector(value) {
        this.rejectionReason = value;
    }

    /*
     * Returns a Thenable that is resolved with the provided value.
     * @param value: any - The value for the returned Thenable.
     * @returns Thenable<any> - Returns a Thenable that is resolved with the provided value.
     */
    static resolve(value) {
        return new Thenable((resolve) => {
            resolve(value);
        });
    }

    /*
     * Returns a Thenable that is rejected for the provided reason.
     * @param rejectionReason: any - The reason for the returned Thenable's rejection.
     * @returns Thenable<any> - Returns a Thenable that is rejected for the provided reason.
     */
    static reject(rejectionReason) {
        return new Thenable((resolve, reject) => {
            reject(rejectionReason);
        });
    }

    /*
     * Behaves like Promise.all() but uses Thenables synchronously if possible. If all Thenables in the chains are non-Promises,
     * then this returns a Thenable that resolves to an array of results. If a Promise is encountered, then a Promise is returned
     * that resolves to an array of results (same as Promise.all()).
     * @param thenables: Iterable<Thenable|Promise> - The iterable of Thenables or Promises for which to wait.
     * @returns Thenable<Array<any>>|Promise<Array<any>> - If all Thenables in the chains are non-Promises, then this returns a
     * Thenable that resolves to an array of results. If a Promise is encountered, then a Promise is returned that resolves to an
     * array of results (same as Promise.all()).
     */
    static all(thenables) {
        assert(thenables, `thenables was falsy -- should be defined as an iterable of Thenables: ${thenables}`);

        const thenableArray = [...thenables];
        const thenableResultsArray = [];

        while (thenableArray.length > 0) {
            if (typeUtils.isInstanceOf(thenableArray[0], Promise)) {
                break; // We need to degrade to Promise.all() for the remainder.
            }

            const thenable = thenableArray.shift();

            if (thenable.rejectionReason !== undefined) {
                return Thenable.reject(thenable.rejectionReason);
            }

            const thenableValue = thenable.value;
            // .then check is added to reduce the noise and limit the instanceOf check to thenables
            if ((thenableValue && thenableValue.then !== undefined) && (typeUtils.isInstanceOf(thenableValue, Thenable) || typeUtils.isInstanceOf(thenableValue, Promise))) {
                thenableArray.unshift(thenableValue);
            } else {
                thenableResultsArray.push(thenableValue);
            }
        }

        if (thenableArray.length > 0) {
            // We degraded to a Promise.all(). Merge whatever results we have so far.
            return Promise.all(thenableArray).then(promiseResultsArray => {
                thenableResultsArray.push.apply(thenableResultsArray, promiseResultsArray);

                return thenableResultsArray;
            });
        }

        return Thenable.resolve(thenableResultsArray);
    }

    /*
     * Returns a Thenable or a Promise based on the input value or rejectionReason. If the value is a Promise
     * then we have to let this convert to a Promise chain and return Promises from here on, otherwise it will
     * return a Thenable.
     * @param value: any - The value for the returned Thenable.
     * @param rejectionReason: any - The reason for the returned Thenable's rejection.
     * @returns Thenable<any> | Promise<any> - Returns a Thenable or a Promise based on the input value or rejectionReason.
     */
    static _resolveOrReject(value, rejectionReason) {
        if (value && value.then !== undefined) {
            if (typeUtils.isInstanceOf(value, Promise)) {
                // We encountered a Promise, so we have to convert to an async Promise chain from here on.
                return value;
            }
            if (value.rejectionReason) {
                return Thenable.reject(value.rejectionReason);
            }
            return Thenable.resolve(value.value);
        }

        if (rejectionReason) {
            return Thenable.reject(rejectionReason);
        }
        return Thenable.resolve(value);
    }

    /*
     * Appends fulfillment and rejection handlers to the Thenable, and returns a new Thenable resolving to the return value of
     * the called handler, or to its original settled value if the Thenable was not handled (i.e. if the relevant handler onFulfilled
     * or onRejected is not a function).
     *
     * This method mirrors its equivalent in the Promise API, but calls everything synchronously if it can. If it encounters a Promise
     * in the chain, this synchronous Thenable chain will convert to an asynchronous Promise chain at that point.
     * @param onFulfilled: function - A Function called if the Thenable is fulfilled. This function has one argument, the fulfillment value.
     * @param onRejected: function - A Function called if the Thenable is rejected. This function has one argument, the rejection reason.
     * @returns Thenable<any> | Promise<any> - Returns a Thenable or a Promise based on output of the onFulfilled or onRejected handler.
     */
    then(onFulfilled, onRejected) {
        let newValue;
        let newRejectionReason;

        try {
            if (this.value === this || this.rejectionReason === this) {
                throw new TypeError("Thenable cannot resolve to itself.");
            }

            if (this.rejectionReason) {
                if (typeof onRejected === "function") {
                    if (typeUtils.isInstanceOf(this.rejectionReason, Promise)) {
                        return this.rejectionReason.then(undefined, onRejected);
                    }

                    newValue = onRejected(this.rejectionReason);
                } else {
                    newRejectionReason = this.rejectionReason;
                }
            } else if (typeof onFulfilled === "function") {
                if (typeUtils.isInstanceOf(this.value, Promise)) {
                    return this.value.then(onFulfilled);
                }

                newValue = onFulfilled(this.value);
            }
        } catch (e) {
            newRejectionReason = e;
        }

        newValue = newValue || this.value;

        return Thenable._resolveOrReject(newValue, newRejectionReason);
    }

    /*
     * Appends a rejection handler callback to the Thenable, and returns a new Thenable resolving to the return value of the callback if it
     * is called, or to its original fulfillment value if the Thenable is instead fulfilled.
     *
     * This method mirrors its equivalent in the Promise API, but calls everything synchronously if it can. If it encounters a Promise
     * in the chain, this synchronous Thenable chain will convert to an asynchronous Promise chain at that point.
     * @param onRejected: function - A Function called if the Thenable is rejected. This function has one argument, the rejection reason.
     * @returns Thenable<any> | Promise<any> - Returns a Thenable or a Promise based on output of the onRejected handler.
     */
    catch(onRejected) {
        return this.then(undefined, onRejected);
    }
}
