#include uniqstr.js

const MamiWorker = function(url, eventTarget) {
    const timeOutMs = 30000;

    let worker, workerId;
    let connectTimeout;
    let pingId;
    let hasTimedout;

    const root = {};
    const objects = new Map;
    const pending = new Map;
    const clearObjects = () => {
        for(const [name, object] of objects)
            for(const method in object)
                delete object[method];
        objects.clear();
        objects.set('', root);
    };

    clearObjects();

    const broadcastTimeoutZone = body => {
        const localWorkerId = workerId;

        body(detail => {
            if(localWorkerId !== workerId || hasTimedout)
                return;
            hasTimedout = true;

            eventTarget.dispatch(':timeout', detail);
        });
    };

    const handlers = {};

    const handleMessage = ev => {
        if(typeof ev.data === 'object' && ev.data !== null && typeof ev.data.type === 'string') {
            if(ev.data.type in handlers)
                handlers[ev.data.type](ev.data.detail);
            return;
        }
    };

    const callObjectMethod = (objName, metName, ...args) => {
        return new Promise((resolve, reject) => {
            if(typeof objName !== 'string')
                throw 'objName must be a string';
            if(typeof metName !== 'string')
                throw 'metName must be a string';

            const id = MamiUniqueStr(8);
            const info = { id: id, resolve: resolve, reject: reject };
            pending.set(id, info);

            worker.postMessage({ type: 'metcall', detail: { id: id, object: objName, method: metName, args: args } });

            broadcastTimeoutZone(timeout => {
                info.timeOut = setTimeout(() => {
                    const reject = info.reject;
                    info.resolve = info.reject = undefined;

                    info.timeOut = undefined;
                    pending.delete(id);

                    timeout({ at: 'call', obj: objName, met: metName });

                    if(typeof reject === 'function')
                        reject('timeout');
                }, timeOutMs);
            });
        });
    };

    const defineObjectMethod = (object, objName, method) => object[method] = (...args) => callObjectMethod(objName, method, ...args);

    handlers['objdef'] = info => {
        let object = objects.get(info.object);
        if(object === undefined)
            objects.set(info.object, object = {});

        if(typeof info.eventPrefix === 'string') {
            const scopedTarget = eventTarget.scopeTo(info.eventPrefix);
            object.watch = scopedTarget.watch;
            object.unwatch = scopedTarget.unwatch;
        }

        for(const method of info.methods)
            defineObjectMethod(object, info.object, method);
    };

    handlers['objdel'] = info => {
        // this should never happen
        if(info.object === '') {
            console.error('Worker attempted to delete root object!!!!!');
            return;
        }

        const object = objects.get(info.object);
        if(object === undefined)
            return;

        objects.delete(info.object);

        const methods = Object.keys(object);
        for(const method of methods)
            delete object[method];
    };

    handlers['metdef'] = info => {
        const object = objects.get(info.object);
        if(object === undefined) {
            console.error('Worker attempted to define method on undefined object.');
            return;
        }

        defineObjectMethod(object, info.object, info.method);
    };

    handlers['metdel'] = info => {
        const object = objects.get(info.object);
        if(object === undefined) {
            console.error('Worker attempted to delete method on undefined object.');
            return;
        }

        delete object[info.method];
    };

    handlers['funcret'] = resp => {
        const info = pending.get(resp.id);
        if(info === undefined)
            return;

        pending.delete(info.id);

        if(info.timeOut !== undefined)
            clearTimeout(info.timeOut);

        const handler = resp.success ? info.resolve : info.reject;
        info.resolve = info.reject = undefined;

        if(handler !== undefined) {
            let result = resp.result;
            if(resp.object)
                result = objects.get(result);

            handler(result);
        }
    };

    handlers['evtdisp'] = resp => {
        eventTarget.dispatch(resp.name, resp.detail);
    };

    return {
        get root() { return root; },

        watch: eventTarget.watch,
        unwatch: eventTarget.unwatch,
        eventTarget: prefix => eventTarget.scopeTo(prefix),

        ping: () => {
            return new Promise((resolve, reject) => {
                if(worker === undefined)
                    throw 'no worker active';

                let pingTimeout;
                let localPingId = pingId;

                const pingHandleMessage = ev => {
                    if(typeof ev.data === 'string' && ev.data.startsWith('pong:') && ev.data.substring(5) === localPingId)
                        try {
                            reject = undefined;
                            pingId = undefined;

                            if(pingTimeout !== undefined)
                                clearTimeout(pingTimeout);

                            worker?.removeEventListener('message', pingHandleMessage);

                            if(typeof resolve === 'function')
                                resolve();
                        } finally {
                            resolve = undefined;
                        }
                };

                worker.addEventListener('message', pingHandleMessage);

                if(localPingId === undefined) {
                    pingId = localPingId = MamiUniqueStr(8);

                    broadcastTimeoutZone(timeout => {
                        pingTimeout = setTimeout(() => {
                            try {
                                resolve = undefined;

                                worker?.removeEventListener('message', pingHandleMessage);

                                timeout({ at: 'ping' });
                                if(typeof reject === 'function')
                                    reject('ping timeout');
                            } finally {
                                reject = undefined;
                            }
                        }, 200);
                    });

                    worker.postMessage(`ping:${localPingId}`);
                }
            });
        },

        sabotage: () => {
            worker?.terminate();
        },

        connect: () => {
            return new Promise((resolve, reject) => {
                const connectFinally = () => {
                    if(connectTimeout !== undefined) {
                        clearTimeout(connectTimeout);
                        connectTimeout = undefined;
                    }
                };

                const connectHandleMessage = ev => {
                    worker?.removeEventListener('message', connectHandleMessage);

                    if(typeof ev.data !== 'object' || ev.data === null || ev.data.type !== 'objdef'
                        || typeof ev.data.detail !== 'object' || ev.data.detail.object !== '') {
                        callReject('data');
                    } else
                        callResolve();
                };

                const callResolve = () => {
                    reject = undefined;
                    connectFinally();
                    try {
                        if(typeof resolve === 'function')
                            resolve(root);
                    } finally {
                        resolve = undefined;
                    }
                };

                const callReject = (...args) => {
                    resolve = undefined;
                    connectFinally();
                    try {
                        broadcastTimeoutZone(timeout => {
                            timeout({ at: 'connect' });
                        });

                        if(typeof reject === 'function')
                            reject(...args);
                    } finally {
                        reject = undefined;
                    }
                };

                if(worker !== undefined) {
                    worker.terminate();
                    workerId = worker = undefined;
                }

                hasTimedout = false;
                workerId = MamiUniqueStr(5);
                worker = new Worker(url);
                worker.addEventListener('message', handleMessage);
                worker.addEventListener('message', connectHandleMessage);

                connectTimeout = setTimeout(() => callReject('timeout'), timeOutMs);
                worker.postMessage({ type: 'init' });
            });
        },
    };
};
