/**
 * @module async/promise.js
 * @author Krzysztof Putyra
 * @date Apt 01, 2025
 * @description Utility functions for managing promises
 */

/*********************************************************
 * Error thrown by promises                              *
 *********************************************************/

/**
 * An error type returned by timedout promises
 */
export class TimeoutError extends Error {
    get name() { return 'TimeoutError'; }
}

/**
 * An error type returned by aborted promises
 */
export class AbortError extends Error {
    get name() { return 'AbortError'; }
}

/*********************************************************
 * Detecting the status of a promise                     *
 *********************************************************/

/**
 * @typedef {string} PromiseStatusEnum
 */

/**
 * The enum of possible states of promises
 * @enum {PromiseStatusEnum}
 */
export const PromiseStatus = Object.freeze({
    RESOLVED: 'resolved',
    REJECTED: 'rejected',
    PENDING: 'pending'
});

/**
 * A symbol used internally used for detecting the status of a promise
 */
const unsettledSymbol  = Symbol('unsettled');

/**
 * Checks if a promise has been resolved.
 * @function
 * @param {Promise} promise - the tested promise
 * @return {boolean}       - true if resolved and false otherwise
 */
export const isResolved = promise => Promise.race([promise, Promise.reject()]).then(
    value => true,
    error => false
)

/**
 * Checks if a promise has been rejected.
 * @function
 * @param {Promise} promise - the tested promise
 * @return {boolean}       - true if rejected and false otherwise
 */
export const isRejected = promise => Promise.race([promise, Promise.resolve()]).then(
    value => false,
    error => true
)

/**
 * Checks if a promise has been rejected.
 * @function
 * @param {Promise} promise - the tested promise
 * @return {boolean}       - true if settled (resolved or rejected) and false otherwise
 */
export const isSettled = promise => Promise.race([promise, unsettledSymbol]).then(
    value => value !== unsettledSymbol,
    error => true
)

/**
 * Returns a string with the status of the provided promise
 * @function
 * @param {Promise} promise - the tested promise
 * @return {PromiseStatusEnum}
 */
export const promiseStatus = promise => Promise.race([promise, unsettledSymbol]).then(
    value => value === unsettledSymbol ? PromiseStatus.PENDING : PromiseStatus.RESOLVED,
    error => PromiseStatus.REJECTED
);


/*********************************************************
 * Creating abortable promises                           *
 *********************************************************/

/**
 * Creates an abortable promise with an optional timeout.
 * 
 * The callback is given a third parameter of type AbortSignal that dispatches 'abort'
 * event when the promise is torn down. Note that the promise is already settled when
 * the event is dispatched. The target's reason field gives more information on why
 * the promise has been aborted and it can have the following values:
 * - 'resolved': the promise has been resolved by the callback
 * - 'rejected': the promise has been rejected by the callback
 * - 'timeout': it took too long to settle the promise
 * - 'abort': the abort signal passed to the promise has been fired
 * 
 * @function
 * @param {callback} callback
 * @param {AbortSignal} param1.signal    an optional abort signal
 * @param {number} param1.timeout        an optional timeout for the promise
 * @return {Promise}
 */
export const createPromise = (callback, { signal, timeout } = {}) =>
    new Promise((res, rej) => {
        
        const tearDown = (reason) => { tearDownSignal.abort(reason); clearTimeout(timerId); };
        const resolve = value => { tearDown('resolved'); res(value); };
        const reject = (error, reason) => { tearDown(reason || 'rejected'); rej(error); };

        // set up
        const tearDownSignal = new AbortController();
        const timerId = (timeout|0 > 0) && setTimeout(() => reject(new TimeoutError(), 'timeout'), timeout|0);
    
        if (signal instanceof AbortSignal) {
            if (signal.aborted) {
                reject(new AbortError(signal.reason), 'abort');
                return;
            } else {
                signal.addEventListener('abort',
                    evt => reject(new AbortError(evt.target.reason), 'abort'),
                    { signal: tearDownSignal.signal }
                );
            }
        }

        // run the callback
        callback(resolve, reject, tearDownSignal.signal);
    });


/**
 * Resolves after a specified time with the optionally specified value.
 * The promise can be aborted with a signal.
 * @function
 * @param {number} ms       the number of miliseconds to wait
 * @param {*} param1.value  the value to be returned
 * @param {AbortSignal} param1.signal  an optional abort signal
 * @return {Promise}
 */
export const wait = (ms, { value, signal } = {}) => createPromise((res, rej, tearDownSignal) => {
    const timer = setTimeout(() => res(value), ms);
    tearDownSignal.addEventListener('abort', () => clearTimeout(timer));
}, { signal });


/**
 * Waits for the promise to settle or rejects if the timeout has passed
 * @function
 * @param {Promise} promise            a wrapped promise
 * @param {number} timeout             the timeout in miliseconds
 * @param {AbortSignal} param2.signal  an optional abort signal
 * @return {Promise}
 */
export const waitFor = (promise, timeout, { signal } = {}) => Promise.race([
    promise,
    wait(timeout|0, { signal }).then(() => Promise.reject(new TimeoutError()))
]);


export const waitForCondition = (predicate, options, frequency=500) => createPromise((res,rej,tearDown) => {
        const timerId = setInterval(() => {
            if (predicate() || tearDown.aborted) {
                clearInterval(timerId);
                res();
            }
        }, frequency);
    }, options);


/**
 * Resolves when an event has been dispatched
 * @function
 * @param {EventTarget} target          An object with a property addEventListener
 * @param {string} event                 The names of an event
 * @param {AbortSignal} param1.signal    An optional signal to abort the promise
 * @param {number} param1.timeout        An optional timeout in miliseconds
 * @return {Promise<Event>}
 */
export const waitForEvent = (target, event, { timeout, signal } = {}) =>
    createPromise((res, rej, tearDownSignal) => {
        target.addEventListener(event, evt => res(evt), {
            signal: tearDownSignal,
            once: true
        })
    }, { timeout, signal });

/**
 * Resolves when any of the provided events has been dispatched
 * @function
 * @param {EventTarget} target          An object with a property addEventListener
 * @param {Iterable<string>} eventNames  The names of events
 * @param {AbortSignal} param1.signal    An optional signal to abort the promise
 * @param {number} param1.timeout        An optional timeout in miliseconds
 * @return {Promise<Event>}
 */
export const waitForEvents = (target, eventNames, { timeout, signal } = {}) =>
    createPromise((res, rej, tearDownSignal) => {
        for (const event of eventNames) {
            target.addEventListener(event, evt => res(evt), {
                signal: tearDownSignal,
                once: true
            })
        }
    }, { timeout, signal });


/*********************************************************
 * A manager of promises                                 *
 *********************************************************/

/**
 * @typedef PromiseDescriptor
 * @property {Promise} promise
 * @property {callback} resolve
 * @property {callback} reject
 */

/**
 * A class representing a collection of promises
 * @extends {Map<any,PromiseDescriptor>}
 */
export class PromiseManager extends Map
{
    /**
     * The number of pending promises.
     */
    get pending() { return this.size; }

    /**
     * Creates a promise with a given signature unless one already exists
     * @param {*} signature 
     * @return {Promise}
     */
    create(signature) {
        const promiseData = this.get(signature);
        if (!promiseData) {
            /** @type {PromiseDescriptor} */
            const descriptor = {};
            descriptor.promise = new Promise((res, rej) => {
                descriptor.resolve = res;
                descriptor.reject = rej;
            })
            this.set(signature, descriptor);
            return descriptor.promise;
        }
        return promiseData.promise;
    }

    /**
     * Resolves and removes a promise with a matching signature
     * @param {*} signature
     * @param {*} value         The value with which the promises are resolved
     * @return {boolean}       True if a promise has been found
     */
    resolve(signature, value) {
        const promise = this.get(signature);
        if (promise) {
            this.delete(signature);
            promise.resolve(value);
            return true;
        }
        return false;
    }

    /**
     * Rejects and removes a promise with a matching signature
     * @param {*} signature
     * @param {*} error         The value with which the promises are rejected
     * @return {boolean}       True if a promise has been found
     */
    reject(signature, error) {
        const promise = this.get(signature);
        if (promise) {
            this.delete(signature);
            promise.reject(error);
        }
        return !!promise;
    }

    /**
     * Rejects all promises and clears the list
     * @param {*} error         The value with which the promises are rejected
     * @return {void}
     */
    rejectAll(error) {
        for (const p of this.values())
            p.reject(error);
        this.clear();
    }
}
