import { logger } from '@fiverr-private/obs';
import { ERRORS, ActionExecutionError, defaultErrorHandler, ActionErrorHandler } from './error';
import type { ActionStatus, ReadyAction, ReadyCallback, ActionsStatus, BeaconsMap, Beacon } from './types';

/**
 * The internal "ready" state to determine process readiness.
 */
let readyState = false;

export interface ReadinessManager {
    register: (name: string, action: ReadyAction, debug?: boolean) => void;
    run: () => ReadinessManager;
    onReady: (callback: ReadyCallback) => ReadinessManager;
    onActionReady: (name: string, callback: ReadyCallback) => ReadinessManager;
    onError: (errorHandler: ActionErrorHandler) => ReadinessManager;
    status: () => ActionsStatus;
    ready: boolean;
}

/**
 * The internal implementation of the ReadinessManager.
 * @example
 * const readinessManager = new ReadinessManager();
 * readinessManager.register('database', () => connectToDB());
 * readinessManager.register('redis', () => connectToRedis());
 * readinessManager.run();
 * readinessManager.onReady(() => console.log('App is ready! o/'));
 * @returns {ReadinessManager}
 */
class ReadinessManagerImpl implements ReadinessManager {
    #beacons: BeaconsMap;
    #callbacks: ReadyCallback[];
    #errorHandler: ActionErrorHandler;

    constructor() {
        this.#beacons = {};
        this.#callbacks = [];
        this.#errorHandler = defaultErrorHandler;
    }

    /**
     * Returns `true` if all the registered actions has been ran without exceptions/rejections.
     */
    get ready(): boolean {
        return readyState;
    }

    /**
     * Returns current registered actions status
     */
    status(): ActionsStatus {
        return Object.values(this.#beacons).reduce((acc, { name, status }) => {
            const current = acc[status] || [];
            return Object.assign(acc, { [status]: [...current, name] });
        }, {} as ActionsStatus);
    }

    /**
     * Registers a given name and action as beacon under manager.
     * registered actions will be observed once ran in order to determine the process readiness state.
     * @param name - The operation name to register with.
     * @param action - The ready emitter to condition the process readiness with.
     * @param debug - (Optional) flag to enable timing logs (default: `false`).
     * @throws {Error} If the action name is already registered.
     */
    register(name: string, action: ReadyAction, debug = false): void {
        if (this.ready) {
            return;
        }

        if (this.#beacons[name]) {
            throw new Error(ERRORS.BEACON_ALREADY_EXISTS);
        }

        const beacon: Beacon = {
            name,
            action,
            callbacks: [],
            status: 'not_started',
            debug,
        };

        Object.assign(this.#beacons, { [name]: beacon });
    }

    /**
     *
     * Runs all registered actions. Once all actions are resolved successfully, your app state will be determined as `ready`.
     * @returns {ReadinessManager}
     */
    run(): ReadinessManager {
        readyState = false;

        Object.values(this.#beacons).forEach((beacon) => this.#execute(beacon));

        return this;
    }

    /**
     * Registers a given callback on the global manager ready event.
     * @param callback - The callback to run upon manager ready event.
     * @returns {ReadinessManager}
     */
    onReady(callback: ReadyCallback): ReadinessManager {
        if (readyState) {
            // Invokes immediately given callback if process is already ready.
            callback();
            return this;
        }

        this.#callbacks.push(callback);
        return this;
    }

    /**
     * Registers a given callback on a specific action resolved.
     * @param name - The name of the action the callback should be attached to.
     * @param callback - The callback to run upon beacon resolved.
     * @returns {ReadinessManager}
     * @throws {Error} If the action name does not exists.
     */
    onActionReady(name: string, callback: ReadyCallback): ReadinessManager {
        const beacon = this.#beacons[name];
        if (!beacon) {
            throw new Error(ERRORS.BEACON_DOES_NOT_EXISTS);
        }

        if (beacon.status === 'resolved') {
            // Invokes immediately given callback if beacon status is already resolved.
            callback();
            return this;
        }

        this.#beacons[name].callbacks.push(callback);
        return this;
    }

    /**
     * Registers a given error handler under manager errors.
     * @param errorHandler - The handler to trigger upon action errors.
     */
    onError(errorHandler: ActionErrorHandler): ReadinessManager {
        this.#errorHandler = errorHandler;
        return this;
    }

    /**
     * Tracks a given beacon action.
     * @param beacon - The beacon to execute.
     * @param attempt - The attempt number of current beacon execution.
     * @throws {ActionExecutionError}
     * @private
     */
    async #execute(beacon: Beacon, attempt = 1): Promise<void> {
        const { name, action, debug } = beacon;

        const update = (status: ActionStatus) => this.#updateBeacon(name, status);

        update('pending');

        const startTime = debug ? Date.now() : null;

        try {
            // The actual beacon execution we want to keep track on.
            const result = action();

            if (result instanceof Promise) {
                await result;
            }

            if (debug && startTime !== null) {
                const duration = Date.now() - startTime;
                logger.debug(`[ReadinessManager] Action "${name}" completed in ${duration}ms`);
            }

            update('resolved');
        } catch (error) {
            if (debug && startTime !== null) {
                const duration = Date.now() - startTime;
                logger.debug(`[ReadinessManager] Action "${name}" failed after ${duration}ms`);
            }

            update('rejected');

            // Invokes consumer error hook with a retry method and the beacon error.
            this.#errorHandler(new ActionExecutionError(name, attempt, error as Error), () =>
                this.#execute(beacon, attempt + 1)
            );
        }
    }

    /**
     * Updates a given beacon name with a given status.
     * @param name - The beacon name to update.
     * @param status - The beacon status to set.
     * @private
     */
    #updateBeacon(name: string, status: ActionStatus): void {
        Object.assign(this.#beacons[name], { status });

        // Resolves specific beacon ready listeners.
        if (status === 'resolved') {
            this.#beacons[name].callbacks.forEach((callback) => callback());
        }

        this.#trackBeaconUpdate();
    }

    /**
     * Tracks a beacon update event, checks whether process is "ready".
     * If so, will trigger registered `onReady` callbacks.
     * @private
     */
    #trackBeaconUpdate(): void {
        if (readyState) {
            return;
        }

        readyState = Object.values(this.#beacons).every((beacon) => beacon.status === 'resolved');

        if (readyState) {
            this.#callbacks.forEach((callback) => callback());
        }
    }
}

const manager: ReadinessManager = new ReadinessManagerImpl();

export default manager;
export { ActionExecutionError, ActionErrorHandler };
