import { expose, caller } from 'postmsg-rpc';
import { deserializeError, serializeError } from './serializer';
import type { PostmsgRpcOptions } from 'postmsg-rpc';

export type RPCMessageBus = {
  postMessage: Function;
  addEventListener: Function;
  removeEventListener: Function;
};

type RPCMessage = {
  type: 'Message';
  payload: string;
};

type RPCError = {
  type: 'Error';
  payload: Error;
};

function isRPCError(data: any): data is RPCError {
  return data && typeof data === 'object' && data.type === 'Error';
}

function getRPCOptions(messageBus: RPCMessageBus): PostmsgRpcOptions {
  return {
    addListener: messageBus.addEventListener.bind(messageBus),
    removeListener: messageBus.removeEventListener.bind(messageBus),
    postMessage(data) {
      return messageBus.postMessage(data);
    },
    getMessageData(event) {
      return (event as { data: unknown }).data ?? event;
    },
  };
}

export const close = Symbol('@@rpc.close');

export const cancel = Symbol('@@rpc.cancel');

export type Exposed<T> = { [k in keyof T]: T[k] & { close(): void } } & {
  [close]: () => void;
};

export function exposeAll<O extends object>(
  obj: O,
  messageBus: RPCMessageBus
): Exposed<O> {
  Object.entries(obj as Record<string, any>).forEach(([key, val]) => {
    const { close } = expose(
      key,
      async (...args: unknown[]) => {
        try {
          return { type: 'Message', payload: await val(...args) };
        } catch (e: any) {
          // If server (whatever is executing the exposed method) throws during
          // the execution, we want to propagate error to the client (whatever
          // issued the call) and re-throw there. We will do this with a special
          // return type.
          return { type: 'Error', payload: serializeError(e) };
        }
      },
      getRPCOptions(messageBus)
    );
    val.close = close;
  });
  Object.defineProperty(obj, close, {
    enumerable: false,
    value() {
      Object.values(obj as Record<string, { close: () => void }>).forEach(
        (fn) => {
          fn.close();
        }
      );
    },
  });
  return obj as Exposed<O>;
}

export type Caller<
  Impl,
  Keys extends keyof Impl = keyof Impl
> = CancelableMethods<Pick<Impl, Keys>> & { [cancel]: () => void };

export function createCaller<Impl extends object>(
  methodNames: Extract<keyof Impl, string>[],
  messageBus: RPCMessageBus,
  processors: Partial<
    Record<(typeof methodNames)[number], (...input: any[]) => any[]>
  > = {}
): Caller<Impl, (typeof methodNames)[number]> {
  const obj = {};
  const inflight = new Set<CancelablePromise<unknown>>();
  methodNames.forEach((name) => {
    const c = caller(name as string, getRPCOptions(messageBus));
    (obj as any)[name] = async (...args: unknown[]) => {
      const processed =
        typeof processors[name] === 'function'
          ? processors[name]?.(...args)
          : args;
      const promise = c(...(processed as any[]));
      inflight.add(promise);
      const result = (await promise) as RPCError | RPCMessage;
      inflight.delete(promise);
      if (isRPCError(result)) throw deserializeError(result.payload);
      return result.payload;
    };
  });
  Object.defineProperty(obj, cancel, {
    enumerable: false,
    value() {
      for (const cancelable of inflight) {
        cancelable.cancel();
        inflight.delete(cancelable);
      }
    },
  });
  return obj as Caller<Impl, (typeof methodNames)[number]>;
}
