export function forEachPromise<T>(array: T[], callback: (value: T, index: number, array: T[]) => Promise<any>, thisArg?: any): Promise<void> {
    return new Promise((resolve, reject) => {
        function next(index: number) {
            if (index < array.length) {
                callback
                    .call(thisArg, array[index] as T, index, array)
                    .then((result) => {
                        if (result === false) {
                            resolve();
                        } else {
                            next(index + 1);
                        }
                    })
                    .catch((error) => {
                        reject(error);
                    });
            } else {
                resolve();
            }
        }
        next(0);
    });
}

export function mapPromise<T, U>(array: T[], callback: (value: T, index: number, array: T[]) => Promise<U>, thisArg?: any): Promise<U[]> {
    return new Promise((resolve, reject) => {
        const results: U[] = [];

        function next(index: number) {
            if (index < array.length) {
                callback
                    .call(thisArg, array[index] as T, index, array)
                    .then((result) => {
                        results[index] = result;
                        next(index + 1);
                    })
                    .catch((error) => {
                        reject(error);
                    });
            } else {
                resolve(results);
            }
        }

        next(0);
    });
}

export async function mapValuesPromise<T extends object, U>(
    object: T,
    callback: (value: T[keyof T], key: string, object: T) => Promise<U>,
    thisArg?: any
): Promise<Record<string, U>> {
    const results: Record<string, U> = {};
    for (const [key, value] of Object.entries(object)) {
        results[key] = await callback.call(thisArg, value, key, object);
    }
    return results;
}

export function reducePromise<T, U>(
    array: T[],
    callback: (accumulator: U, currentValue: T, currentIndex: number, array: T[]) => Promise<U>,
    initialValue: U,
    thisArg?: any
): Promise<U> {
    return new Promise((resolve, reject) => {
        function next(index: number, accumulator: U) {
            if (index < array.length) {
                callback
                    .call(thisArg, accumulator, array[index] as T, index, array)
                    .then((result) => {
                        next(index + 1, result);
                    })
                    .catch((error) => {
                        reject(error);
                    });
            } else {
                resolve(accumulator);
            }
        }

        next(0, initialValue);
    });
}

export function findPromise<T>(array: T[], callback: (value: T, index: number, array: T[]) => Promise<boolean>, thisArg?: any): Promise<T | undefined> {
    return new Promise((resolve, reject) => {
        function next(index: number) {
            if (index < array.length) {
                callback
                    .call(thisArg, array[index] as T, index, array)
                    .then((result) => {
                        if (result) {
                            resolve(array[index]);
                        } else {
                            next(index + 1);
                        }
                    })
                    .catch((error) => {
                        reject(error);
                    });
            } else {
                resolve(undefined);
            }
        }
        next(0);
    });
}

export interface DeferredPromise<T> {
    resolve: (value: T | PromiseLike<T>) => void;
    reject: (reason?: any) => void;
    promise: Promise<T>;
}

export function deferredPromise<T>(): DeferredPromise<T> {
    let _resolve: (value: T | PromiseLike<T>) => void;
    let _reject: (reason?: any) => void;
    const promise = new Promise<T>((resolve, reject) => {
        _resolve = resolve;
        _reject = reject;
    });
    // The executor function is called before the Promise constructor returns:
    // https://262.ecma-international.org/6.0/#sec-promise-executor
    // so it is safe to use Non-null Assertion Operator "!"
    return {
        promise: promise,
        resolve: _resolve!,
        reject: _reject!
    };
}

/**
 * Creates a function that is restricted to invoking `func` serially. Subsequent
 * calls to the function while the previous `func` call is being resolved, defer
 * invoking the `func` until the previous call is resolved. The deferred `func`
 * is invoked with the last arguments provided to the created function. All
 * subsequent calls to the function are resolved simultaneously with the result
 * of the last `func` invocation.
 *
 * The `options.groupResolver` function allows separating deferred calls into
 * groups based on the arguments provided to the created function.
 *
 * The `options.argsResolver` function allows controlling the arguments passed
 * to the deferred `func`.
 *
 * @example
 * const defFunc = deferOnceWhileRunning(origFunc);
 *
 *                          defFunc(z)
 *              defFunc(y)      ↓
 *  defFunc(x)      ↓           o---------------------------●
 *      ↓           o---------------------------------------●
 *      o--------------------------------●                  ↑
 *      ↓                                ↑                  ↑
 * -----o================================●-o================●----->
 *      ↑                                  ↑
 *  origFunc(x)                        origFunc(z)
 */
export function deferWhileRunning<R, T extends (...args: any) => Promise<R>>(
    func: T,
    options: {
        thisArg?: any;
        groupResolver?: (...args: Parameters<T>) => string;
        argsResolver?: ({ nextArgs, prevArgs }: { nextArgs: Parameters<T>; prevArgs: Parameters<T> | null }) => Parameters<T>;
        debounceDelay?: number;
        debounceMaxDelay?: number;
    } = {}
): T {
    const groupResolver = options.groupResolver ?? (() => 'defaultGroup');
    const argsResolver = options.argsResolver ?? (({ nextArgs }) => nextArgs);
    const thisArg = options.thisArg;
    const debounceDelay = options.debounceDelay;
    const debounceMaxDelay = options.debounceMaxDelay;

    type DeferGroup = {
        startWaiting: number | null;
        timeout: NodeJS.Timeout | null;
        isInvoking: boolean;
        deferred: DeferredPromise<R> | null;
        nextArgs: Parameters<T> | null;
    };
    const deferGroups: Record<string, DeferGroup> = {};

    const invoke = async (group: DeferGroup, args: Parameters<T>): Promise<R> => {
        try {
            group.isInvoking = true;
            return await func.apply(thisArg, args);
        } finally {
            group.isInvoking = false;
            if (group.deferred !== null) {
                const nextArgsCopy = group.nextArgs!;
                const deferredCopy = group.deferred;
                group.nextArgs = null;
                group.deferred = null;
                try {
                    deferredCopy.resolve(invoke(group, nextArgsCopy));
                } catch (e) {
                    deferredCopy.reject(e);
                }
            }
        }
    };

    const debounce = (group: DeferGroup, args: Parameters<T>, debounceWait: number): Promise<R> => {
        const now = new Date().getTime();
        if (!group.deferred) {
            group.startWaiting = now;
            group.deferred = deferredPromise();
        }
        if (group.timeout) {
            clearTimeout(group.timeout);
        }
        group.nextArgs = argsResolver({ nextArgs: args, prevArgs: group.nextArgs });
        const wait = debounceMaxDelay ? Math.min(Math.max(0, debounceMaxDelay - (now - group.startWaiting!)), debounceWait) : debounceWait;
        group.timeout = setTimeout(() => {
            const nextArgsCopy = group.nextArgs!;
            const deferredCopy = group.deferred!;
            group.nextArgs = null;
            group.deferred = null;
            group.timeout = null;
            group.startWaiting = null;
            try {
                deferredCopy.resolve(invoke(group, nextArgsCopy));
            } catch (e) {
                deferredCopy.reject(e);
            }
        }, wait);
        return group.deferred.promise;
    };

    return (async (...args: Parameters<T>): Promise<R> => {
        const groupId = groupResolver(...args);
        let group: DeferGroup;

        if (groupId in deferGroups) {
            group = deferGroups[groupId]!;
        } else {
            group = {
                startWaiting: null,
                timeout: null,
                isInvoking: false,
                deferred: null,
                nextArgs: null
            };
            deferGroups[groupId] = group;
        }

        if (!group.isInvoking) {
            if (typeof debounceDelay !== 'undefined') {
                return debounce(group, args, debounceDelay);
            }
            return invoke(group, args);
        }

        if (!group.deferred) {
            group.deferred = deferredPromise();
        }

        group.nextArgs = argsResolver({ nextArgs: args, prevArgs: group.nextArgs });
        return group.deferred.promise;
    }) as T;
}
