import { assert } from "../../assert.js";
import { array_remove_first } from "../../collection/array/array_remove_first.js";

/**
 * @enum {string}
 */
export const WorkerStatus = Object.freeze({
    STOPPED: 'STOPPED',
    RUNNING: 'RUNNING',
    FAILED: 'FAILED',
});

/**
 *
 * @param {Worker} worker
 * @param {*} message
 * @return {boolean} true if sending was successful, false otherwise
 */
function trySendMessage(worker, message) {
    try {

        worker.postMessage(message);
        return true;
    } catch (e) {
        //request failed
        console.error("failed to send message: ", message, e);
        return false;
    }
}

function needsSerialization(value) {
    if (value === null) {
        return false;
    }

    if (typeof value !== "object") {
        return false;
    }

    if (value.hasOwnProperty('toJSON') && value.toJSON === "function") {
        return true;
    }

    return false;
}


class WorkerProxy {

    /**
     *
     * @type {Object<Array<{id:number, parameters:[], resolve: function, reject:function}>>}
     * @private
     */
    __pending = {};

    /**
     * @type {WorkerStatus}
     * @private
     */
    __status = WorkerStatus.STOPPED;

    /**
     * Error captured when the worker transitions to FAILED. Used to reject any subsequent requests.
     * @type {Error|null}
     * @private
     */
    __failure = null;

    /**
     *
     * @type {Worker|null}
     * @private
     */
    __worker = null;

    /**
     *
     * @type {number}
     * @private
     */
    __id_counter = 0;

    /**
     * Created worker will assume this name as well
     * Useful for debug purposes and reflection
     * @type {string}
     * @private
     */
    __name = "worker"

    /**
     *
     * @param {string} url
     * @param {Object} methods
     */
    constructor(url, methods) {
        this.url = url;
        this.methods = methods;

        this.__generateAPI();
    }

    /**
     * Invoke a given method on the worker, as defined by the `WorkerBuilder`
     * @template T
     * @param {string} name Method's name
     * @param {Array} args
     * @return {Promise<T>} eventual result of the invoked method
     */
    $submitRequest(name, args) {
        assert.isString(name, "name");

        if (this.__status === WorkerStatus.FAILED) {
            return Promise.reject(this.__failure);
        }

        const pending = this.__pending[name];

        const argumentCount = args.length;

        const parameters = new Array(argumentCount);

        for (let i = 0; i < argumentCount; i++) {
            const argument = args[i];
            if (needsSerialization(argument)) {
                //use toJSON method on an argument if possible
                parameters[i] = argument.toJSON();
            } else {
                parameters[i] = argument;
            }
        }

        const request_id = this.__id_counter++;

        return new Promise((resolve, reject) => {

            const request = {
                parameters: parameters,
                id: request_id,
                resolve: resolve,
                reject: reject
            };

            pending.push(request);

            if (this.isRunning()) {
                const message = {
                    methodName: name,
                    id: request.id,
                    parameters: parameters
                };

                if (!trySendMessage(this.__worker, message)) {
                    // failed to send the message
                    // drop the pending request
                    array_remove_first(pending, request);
                }
            }
        });
    }

    /**
     *
     * @param {string} name
     * @private
     */
    __makeMethod(name) {
        assert.isString(name, 'name');

        if (this.__pending.hasOwnProperty(name)) {
            throw new Error(`Method '${name}' already defined`);
        }

        // initialize the pending request queue for this method
        this.__pending[name] = [];

        const proxy = this;

        this[name] = function (...args) {
            return proxy.$submitRequest(name, args);
        };

    }

    /**
     *
     * @private
     */
    __generateAPI() {


        for (let methodName in this.methods) {
            if (this.methods.hasOwnProperty(methodName)) {
                this.__makeMethod(methodName);
            }
        }
    }

    /**
     *
     * @param {Event} event
     * @private
     */
    __handleMessage = (event) => {
        const pending = this.__pending;

        const data = event.data;

        const requestId = data.id;

        const methodName = data.methodName;
        //find pending request queue for method
        const requestQueue = pending[methodName];

        if (requestQueue === undefined) {
            throw new Error('Unexpected method \'' + methodName + '\'');
        } else {

            const n = requestQueue.length;

            for (let i = 0; i < n; i++) {
                const request = requestQueue[i];

                if (request.id === requestId) {
                    //found the right one

                    requestQueue.splice(i, 1);

                    if (data.hasOwnProperty('error')) {
                        request.reject(data.error);
                    } else {
                        request.resolve(data.result);
                    }

                    return;
                }
            }

            throw new Error(`Request ${requestId} not found in the request queue`);
        }
    }

    isRunning() {
        return this.__status === WorkerStatus.RUNNING;
    }

    /**
     * @returns {WorkerStatus}
     */
    getStatus() {
        return this.__status;
    }

    /**
     * Stop the worker.
     * If the worker is not running, this method does nothing.
     * A worker in FAILED state cannot be stopped (it has already been terminated).
     */
    stop() {
        if (this.__status !== WorkerStatus.RUNNING) {
            return;
        }
        this.__worker.terminate();
        this.__status = WorkerStatus.STOPPED;
    }

    /**
     *
     * @param {number} id
     * @param {string} method_name
     * @returns {boolean}
     */
    cancelRequest(id, method_name) {
        assert.isString(method_name, 'method_name');

        //find request
        const requestQueue = this.__pending[method_name];

        if (requestQueue === undefined) {
            throw new Error(`No request queue for method name '${method_name}'`);
        }

        const n = requestQueue.length;

        for (let i = 0; i < n; i++) {
            const request = requestQueue[i];

            if (request.id === id) {


                if (this.__status !== WorkerStatus.RUNNING) {
                    // not running, simply cut from the queue

                    requestQueue.splice(i, 1);

                    return true;
                } else {
                    // worker is running, send a termination request for this ID
                    throw new Error('Ability to cancel pending requests while worker is running is not implemented');
                }

            }
        }

    }

    sendPendingRequests() {
        for (let methodName in this.__pending) {
            if (this.__pending.hasOwnProperty(methodName)) {

                const pending = this.__pending[methodName];
                const n = pending.length;

                for (let i = 0; i < n; i++) {
                    const request = pending[i];
                    const message = {
                        methodName: methodName,
                        id: request.id,
                        parameters: request.parameters
                    };

                    trySendMessage(this.__worker, message);
                }
            }
        }
    }

    /**
     * Start the worker.
     * Any requests made while the worker is not running will be queued and sent once the worker is started.
     *
     * If the worker is already running, this method does nothing.
     *
     */
    start() {
        if (this.__status === WorkerStatus.RUNNING) {
            //already running
            return;
        }

        if (this.__status === WorkerStatus.FAILED) {
            // FAILED is terminal — refuse to restart. A re-spawn on the same URL would just hit the same load error.
            return;
        }

        this.__worker = new Worker(this.url,{
            name: this.__name
        });

        this.__worker.onmessage = this.__handleMessage;
        this.__worker.onerror = this.__handleError;

        this.__status = WorkerStatus.RUNNING;

        this.sendPendingRequests();
    }

    /**
     * Worker-level error handler. Fires when the worker script itself fails
     * (e.g. importScripts 404) or when an uncaught error escapes a handler.
     * Treats the worker as permanently failed: rejects all pending and future requests.
     *
     * @param {ErrorEvent} errorEvent
     * @private
     */
    __handleError = (errorEvent) => {
        const message = errorEvent.message || 'unknown error';
        const filename = errorEvent.filename || this.url;
        const lineno = errorEvent.lineno || 0;

        const err = new Error(`Worker '${this.__name}' failed: ${message} (${filename}:${lineno})`);

        // Set status BEFORE rejecting requests so any synchronous resubmission in a rejection
        // handler immediately fails fast instead of being queued against a dead worker.
        this.__status = WorkerStatus.FAILED;
        this.__failure = err;

        if (this.__worker !== null) {
            this.__worker.terminate();
        }

        this.__failAllPending(err);

        console.error('Worker error:', errorEvent);
    }

    /**
     * @param {Error} err
     * @private
     */
    __failAllPending(err) {
        for (const methodName in this.__pending) {
            if (!this.__pending.hasOwnProperty(methodName)) {
                continue;
            }

            const queue = this.__pending[methodName];
            const requests = queue.splice(0, queue.length);

            for (const request of requests) {
                request.reject(err);
            }
        }
    }
}

export default WorkerProxy;
