import { MessageInterface } from '../communication-interface/MessageInterface';
import { timeout } from '../helper';

declare global {
    interface ErrorConstructor {
        new(message?: string, options?: ErrorOptions): Error;
    }

    interface ErrorOptions {
        cause?: any;
    }
}

export type ExpectedCall<K> = {
    functionName: K,
    timeout?: number
}

type ExpectedCallStruct = {
    from: string,
    stack: string,
    resolver: (r: Promise<void> | void) => void
}

export class LongAsyncRemoteFunction<T> {

    constructor(private messageInterface: MessageInterface, private namespace = '') {
        this.messageInterface = messageInterface;
        this.messageInterface.addMessageListener(this._messageRecieved.bind(this));
        this.namespace = namespace;
    }

    public async messageRecieved(from: string, payload: string): Promise<boolean> {
        try {
            JSON.parse(payload);
        } catch (err) {
            return false;
        }
        const message = JSON.parse(payload);
        if (message.type === 'call') {
            const funcBundle = this.registeredFunctions.get(String(message.name));
            if (funcBundle) {
                console.info(`Received call to ${String(message.name)} from ${from}`);
                await this._messageRecieved(from, payload);
                return true;
            }
        }
        return false;
    }

    private async _messageRecieved(from: string, payload: string) {
        try {
            JSON.parse(payload);
        } catch (err) {
            console.warn(err);
            return;
        }
        const message = JSON.parse(payload);
        if (message.type === 'call') {
            await this._invokeLocal(from, message.name, message.argObject);
        }
    }


    registeredFunctions = new Map<string, { func: (from: string, param: any) => void | Promise<void>, expectedInvocations: Array<ExpectedCallStruct> }>();

    /**
     * Registers a function to be called on a remote system.
     * The functions are not allowed to return a value.
     * Instead, return values should invoke a different function.
     * @param name a name to identify the function (similar to REST routes)
     * @param func the function to be executed remotely
     */
    register<K extends keyof T>(name: K, func: (from: string, param: T[K]) => void | Promise<void>): void {
        if (!func.name.includes('_r_') && !(func.name.length === 0)) {
            throw new Error(`Remote functions must be prefixed with _r_ but got ${func.name}`);
        }
        this.registeredFunctions.set(this.namespace + String(name), { func, expectedInvocations: [] });
    }

    getCallCode<K extends keyof T>(name: K, targetUserId: string, argObject: T[K]): string {
        const func = this.registeredFunctions.get(this.namespace + String(name));
        if (!func) {
            throw new Error(`function ${String(name)} not registered`);
        }
        return JSON.stringify({ type: 'call', name: this.namespace + String(name), argObject });
    }

    async expectCallInvocation<K extends keyof T>(ec: ExpectedCall<K>, from: string): Promise<void> {
        const stack = new Error().stack!;
        const func = this.registeredFunctions.get(this.namespace + String(ec.functionName));
        if (!func) {
            throw new Error(`function ${String(ec.functionName)} not registered`);
        }
        const invocationPromise = new Promise<void>(resolve => {
            func.expectedInvocations.push({ from, stack, resolver: resolve });
        });
        await Promise.race([invocationPromise, timeout(ec.timeout || 60000)]);
        if (func.expectedInvocations.find(ei => ei.from === from)) {
            throw new Error(`Expected call to ${String(ec.functionName)} from ${from} timed out`);
        }
    }

    async call<K extends keyof T>(name: K, targetUserId: string, argObject: T[K], ec?: ExpectedCall<K>): Promise<void> {
        console.info(`Calling ${String(name)} on ${targetUserId}`);
        const func = this.registeredFunctions.get(this.namespace + String(name));
        if (!func) {
            throw new Error(`function ${String(name)} not registered`);
        }
        if (ec) {
            const expectedCallPromise = this.expectCallInvocation(ec, targetUserId);
            let sent = false;
            const miPromise = this.messageInterface.send(targetUserId, this.getCallCode(name, targetUserId, argObject)).then(() => sent = true);
            const to = timeout(ec.timeout || 15000).then(() => { throw new Error(`sending call to ${String(name)} timed out`) });
            await Promise.race([miPromise, to]);
            if (!sent) {
                throw new Error(`sending call to ${String(name)} failed`);
            }
            return await expectedCallPromise;
        }
        return await this.messageInterface.send(targetUserId, this.getCallCode(name, targetUserId, argObject));
    }

    private async _invokeLocal(from: string, name: string, argObject: any): Promise<void> {
        const funcBundle = this.registeredFunctions.get(String(name));
        if (!funcBundle) {
            console.warn(`${this.namespace}: function ${String(name)} not registered`);
            return;
        }
        const expectedCall = funcBundle.expectedInvocations.find(ei => ei.from === from);
        try {
            if (expectedCall) {
                expectedCall.resolver(funcBundle.func(from, argObject));
                funcBundle.expectedInvocations.splice(funcBundle.expectedInvocations.indexOf(expectedCall), 1);
            } else {
                await funcBundle.func(from, argObject);
            }
        } catch (e: any) {
            if (!expectedCall) {
                throw e;
            }
            else {
                throw new Error(`Error in expected call to ${String(name)} on ${from}: ${e}\n
                CurrentStack:\n${e.stack!}\nd
                Invocation Stack:\n${expectedCall.stack}`, { cause: e })
            }
        }
    }
}