'use strict';

/**
 * @fileoverview A utility for retrying failed async method calls.
 */

/* global setTimeout, clearTimeout */

//-----------------------------------------------------------------------------
// Constants
//-----------------------------------------------------------------------------

const MAX_TASK_TIMEOUT = 60000;
const MAX_TASK_DELAY = 100;
const MAX_CONCURRENCY = 1000;

//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------

/**
 * Logs a message to the console if the DEBUG environment variable is set.
 * @param {string} message The message to log.
 * @returns {void}
 */
function debug(message) {
    if (globalThis?.process?.env.DEBUG === "@hwc/retry") {
        console.debug(message);
    }
}

/*
 * The following logic has been extracted from graceful-fs.
 *
 * The ISC License
 *
 * Copyright (c) 2011-2023 Isaac Z. Schlueter, Ben Noordhuis, and Contributors
 *
 * Permission to use, copy, modify, and/or distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
 * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

/**
 * Checks if it is time to retry a task based on the timestamp and last attempt time.
 * @param {RetryTask} task The task to check.
 * @param {number} maxDelay The maximum delay for the queue.
 * @returns {boolean} true if it is time to retry, false otherwise.
 */
function isTimeToRetry(task, maxDelay) {
    const timeSinceLastAttempt = Date.now() - task.lastAttempt;
    const timeSinceStart = Math.max(task.lastAttempt - task.timestamp, 1);
    const desiredDelay = Math.min(timeSinceStart * 1.2, maxDelay);

    return timeSinceLastAttempt >= desiredDelay;
}

/**
 * Checks if it is time to bail out based on the given timestamp.
 * @param {RetryTask} task The task to check.
 * @param {number} timeout The timeout for the queue.
 * @returns {boolean} true if it is time to bail, false otherwise.
 */
function isTimeToBail(task, timeout) {
    return task.age > timeout;
}

/**
 * Creates a new promise with resolve and reject functions.
 * @returns {{promise:Promise<any>, resolve:(value:any) => any, reject: (value:any) => any}} A new promise.
 */
function createPromise() {
    if (Promise.withResolvers) {
        return Promise.withResolvers();
    }

    let resolve, reject;

    const promise = new Promise((res, rej) => {
        resolve = res;
        reject = rej;
    });

    if (resolve === undefined || reject === undefined) {
        throw new Error("Promise executor did not initialize resolve or reject.");
    }

    return { promise, resolve, reject };
}


/**
 * A class to represent a task in the retry queue.
 */
class RetryTask {

    /**
     * The unique ID for the task.
     * @type {string}
     */
    id = Math.random().toString(36).slice(2);

    /**
     * The function to call.
     * @type {Function}
     */
    fn;

    /**
     * The error that was thrown.
     * @type {Error}
     */
    error;
    
    /**
     * The timestamp of the task.
     * @type {number}
     */
    timestamp = Date.now();

    /**
     * The timestamp of the last attempt.
     * @type {number}
     */
    lastAttempt = this.timestamp;

    /**
     * The resolve function for the promise.
     * @type {Function}
     */
    resolve;

    /**
     * The reject function for the promise.
     * @type {Function}
     */
    reject;

    /**
     * The AbortSignal to monitor for cancellation.
     * @type {AbortSignal|undefined}
     */
    signal;

    /**
     * Creates a new instance.
     * @param {Function} fn The function to call.
     * @param {Error} error The error that was thrown.
     * @param {Function} resolve The resolve function for the promise.
     * @param {Function} reject The reject function for the promise.
     * @param {AbortSignal|undefined} signal The AbortSignal to monitor for cancellation.
     */
    constructor(fn, error, resolve, reject, signal) {
        this.fn = fn;
        this.error = error;
        this.timestamp = Date.now();
        this.lastAttempt = Date.now();
        this.resolve = resolve;
        this.reject = reject;
        this.signal = signal;
    }
    
    /**
     * Gets the age of the task.
     * @returns {number} The age of the task in milliseconds.
     * @readonly
     */
    get age() {
        return Date.now() - this.timestamp;
    }
}

//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------

/**
 * A class that manages a queue of retry jobs.
 */
class Retrier {

    /**
     * Represents the queue for processing tasks.
     * @type {Array<RetryTask>}
     */
    #retrying = [];

    /**
     * Represents the queue for pending tasks.
     * @type {Array<Function>}
     */
    #pending = [];

    /**
     * The number of tasks currently being processed.
     * @type {number}
     */
    #working = 0;

    /**
     * The timeout for the queue.
     * @type {number}
     */
    #timeout;

    /**
     * The maximum delay for the queue.
     * @type {number}
     */
    #maxDelay;

    /**
     * The setTimeout() timer ID.
     * @type {NodeJS.Timeout|undefined}
     */
    #timerId;

    /**
     * The function to call.
     * @type {Function}
     */
    #check;

    /**
     * The maximum number of concurrent tasks.
     * @type {number}
     */
    #concurrency;

    /**
     * Creates a new instance.
     * @param {Function} check The function to call.
     * @param {object} [options] The options for the instance.
     * @param {number} [options.timeout] The timeout for the queue.
     * @param {number} [options.maxDelay] The maximum delay for the queue.
     * @param {number} [options.concurrency] The maximum number of concurrent tasks.
     */
    constructor(check, { timeout = MAX_TASK_TIMEOUT, maxDelay = MAX_TASK_DELAY, concurrency = MAX_CONCURRENCY } = {}) {

        if (typeof check !== "function") {
            throw new Error("Missing function to check errors");
        }

        this.#check = check;
        this.#timeout = timeout;
        this.#maxDelay = maxDelay;
        this.#concurrency = concurrency;
    }

    /**
     * Gets the number of tasks waiting to be retried.
     * @returns {number} The number of tasks in the retry queue.
     */
    get retrying() {
        return this.#retrying.length;
    }

    /**
     * Gets the number of tasks waiting to be processed in the pending queue.
     * @returns {number} The number of tasks in the pending queue.
     */
    get pending() {
        return this.#pending.length;
    }

    /**
     * Gets the number of tasks currently being processed.
     * @returns {number} The number of tasks currently being processed.
     */
    get working() {
        return this.#working;
    }

    /**
     * Calls the function and retries if it fails.
     * @param {Function} fn The function to call.
     * @param {Object} options The options for the job.
     * @param {AbortSignal} [options.signal] The AbortSignal to monitor for cancellation.
     * @param {Promise<any>} options.promise The promise to return when the function settles.
     * @param {Function} options.resolve The resolve function for the promise.
     * @param {Function} options.reject The reject function for the promise.
     * @returns {Promise<any>} A promise that resolves when the function is
     * called successfully.
     */
    #call(fn, { signal, promise, resolve, reject }) {

        let result;

        try {
            result = fn();
        } catch (/** @type {any} */ error) {
            reject(new Error(`Synchronous error: ${error.message}`, { cause: error }));
            return promise;
        }

        // if the result is not a promise then reject an error
        if (!result || typeof result.then !== "function") {
            reject(new Error("Result is not a promise."));
            return promise;
        }

        this.#working++;
        promise.finally(() => {
            this.#working--;
            this.#processPending();
        });

        // call the original function and catch any ENFILE or EMFILE errors
        // @ts-ignore because we know it's any
        return Promise.resolve(result)
            .then(value => {
                debug("Function called successfully without retry.");
                resolve(value);
                return promise;
            })
            .catch(error => {
                if (!this.#check(error)) {
                    reject(error);
                    return promise;
                }

                const task = new RetryTask(fn, error, resolve, reject, signal);
                
                debug(`Function failed, queuing for retry with task ${task.id}.`);
                this.#retrying.push(task);

                signal?.addEventListener("abort", () => {
                    debug(`Task ${task.id} was aborted due to AbortSignal.`);
                    reject(signal.reason);
                });

                this.#processQueue();

                return promise;
            });
    }

    /**
     * Adds a new retry job to the queue.
     * @param {Function} fn The function to call.
     * @param {object} [options] The options for the job.
     * @param {AbortSignal} [options.signal] The AbortSignal to monitor for cancellation.
     * @returns {Promise<any>} A promise that resolves when the queue is
     *  processed.
     */
    retry(fn, { signal } = {}) {

        signal?.throwIfAborted();

        const { promise, resolve, reject } = createPromise();

        this.#pending.push(() => this.#call(fn, { signal, promise, resolve, reject }));
        this.#processPending();
        
        return promise;
    }


    /**
     * Processes the pending queue and the retry queue.
     * @returns {void}
     */
    #processAll() {
        if (this.pending) {
            this.#processPending();
        }

        if (this.retrying) {
            this.#processQueue();
        }
    }

    /**
     * Processes the pending queue to see which tasks can be started.
     * @returns {void}
     */
    #processPending() {

        debug(`Processing pending tasks: ${this.pending} pending, ${this.working} working.`);

        const available = this.#concurrency - this.working;

        if (available <= 0) {
            return;
        }

        const count = Math.min(this.pending, available);

        for (let i = 0; i < count; i++) {
            const task = this.#pending.shift();
            task?.();
        }

        debug(`Processed pending tasks: ${this.pending} pending, ${this.working} working.`);
    }

    /**
     * Processes the queue.
     * @returns {void}
     */
    #processQueue() {
        // clear any timer because we're going to check right now
        clearTimeout(this.#timerId);
        this.#timerId = undefined;

        debug(`Processing retry queue: ${this.retrying} retrying, ${this.working} working.`);

        const processAgain = () => {
            this.#timerId = setTimeout(() => this.#processAll(), 0);
        };

        // if there's nothing in the queue, we're done
        const task = this.#retrying.shift();
        if (!task) {
            debug("Queue is empty, exiting.");

            if (this.pending) {
                processAgain();
            }
            return;
        }

        // if it's time to bail, then bail
        if (isTimeToBail(task, this.#timeout)) {
            debug(`Task ${task.id} was abandoned due to timeout.`);
            task.reject(task.error);
            processAgain();
            return;
        }

        // if it's not time to retry, then wait and try again
        if (!isTimeToRetry(task, this.#maxDelay)) {
            debug(`Task ${task.id} is not ready to retry, skipping.`);
            this.#retrying.push(task);
            processAgain();
            return;
        }

        // otherwise, try again
        task.lastAttempt = Date.now();
        
        // Promise.resolve needed in case it's a thenable but not a Promise
        Promise.resolve(task.fn())
            // @ts-ignore because we know it's any
            .then(result => {
                debug(`Task ${task.id} succeeded after ${task.age}ms.`);
                task.resolve(result);
            })

            // @ts-ignore because we know it's any
            .catch(error => {
                if (!this.#check(error)) {
                    debug(`Task ${task.id} failed with non-retryable error: ${error.message}.`);
                    task.reject(error);
                    return;
                }

                // update the task timestamp and push to back of queue to try again
                task.lastAttempt = Date.now();
                this.#retrying.push(task);
                debug(`Task ${task.id} failed, requeueing to try again.`);
            })
            .finally(() => {
                this.#processAll();
            });
    }
}

exports.Retrier = Retrier;
