import {
  injectable,
  inject,
  actionIdentifier,
  storeKey,
  Service,
  identifierKey,
} from 'reactant';
import { LastAction } from 'reactant-last-action';
import {
  loadFullStateActionName,
  SharedAppOptions,
  syncToClientsName,
  syncClientIdsFromClientsName,
  syncClientIdToServerName,
  removeClientIdToServerName,
} from '../constants';
import type {
  CallbackWithHook,
  ClientEvents,
  Port,
  PortApp,
  Transports,
  Transport,
  ISharedAppOptions,
  ProxyExecParams,
} from '../interfaces';
import { createId } from '../utils';

type OnClientDestroy = (clientId: string) => unknown;

/**
 * Port Detector
 *
 * It provides port detection and client/server port switching functions.
 */
@injectable()
export class PortDetector {
  protected portApp?: PortApp;

  protected lastHooks?: Set<ReturnType<CallbackWithHook>>;

  protected serverCallbacks = new Set<
    CallbackWithHook<Required<Transports>['server']>
  >();

  protected clientCallbacks = new Set<
    CallbackWithHook<Required<Transports>['server']>
  >();

  protected clientDestroyCallbacks = new Set<OnClientDestroy>();

  syncFullStatePromise?: ReturnType<
    ClientEvents[typeof loadFullStateActionName]
  >;

  /**
   * previous port
   */
  previousPort?: Port;

  /**
   * client id, it will be generated when the port is client, it is null in server port.
   */
  clientId: string | null = null;

  /**
   * allow Disable Sync
   */
  allowDisableSync = () => true;

  /**
   * client ids, it will collect all the client ids when the port is server, it is an empty array in client port.
   */
  clientIds: string[] = [];

  /**
   * server hooks for delegate(this, key, args, { _extra: { serverHook: '$hookName' } }) method
   */
  serverHooks: Record<string, (options: ProxyExecParams) => any> = {};

  constructor(
    @inject(SharedAppOptions) public sharedAppOptions: ISharedAppOptions,
    public lastAction: LastAction
  ) {
    this.onClient((transport) => {
      this.clientId = createId();
      this.clientIds = [];
      this.syncFullState({ forceSync: false });
      const disposeSyncToClients = transport.listen(
        syncToClientsName,
        async (fullState) => {
          if (!fullState) return;
          const store = (this as Service)[storeKey];
          store!.dispatch({
            type: `${actionIdentifier}_${loadFullStateActionName}`,
            state: this.getNextState(fullState),
            _reactant: actionIdentifier,
          });
          this.lastAction.sequence =
            fullState[this.lastAction.stateKey]._sequence;
        }
      );
      transport.emit(
        { name: syncClientIdToServerName, respond: false },
        this.clientId
      );
      const disposeSyncClientIds = transport.listen(
        syncClientIdsFromClientsName,
        async () => {
          if (this.clientId) {
            // for all clients send current client id to server
            transport.emit(
              { name: syncClientIdToServerName, respond: false },
              this.clientId
            );
          }
        }
      );
      const removeClientIdToServer = () => {
        transport.emit(
          { name: removeClientIdToServerName, respond: false },
          this.clientId!
        );
      };
      // do not use `unload` event
      // https://developer.chrome.com/docs/web-platform/deprecating-unload
      // the pagehide event is just only triggered in shared worker mode
      window.addEventListener('pagehide', removeClientIdToServer);
      return () => {
        this.previousPort = 'client';
        disposeSyncToClients?.();
        disposeSyncClientIds?.();
        window.removeEventListener('pagehide', removeClientIdToServer);
      };
    });

    this.onServer((transport) => {
      this.clientId = null;
      transport.emit({ name: syncClientIdsFromClientsName, respond: false });
      const disposeSyncClientId = transport.listen(
        syncClientIdToServerName,
        (clientId) => {
          if (!this.clientIds.includes(clientId)) {
            this.clientIds.push(clientId);
          }
        }
      );
      const disposeRemoveClientId = transport.listen(
        removeClientIdToServerName,
        (clientId) => {
          const index = this.clientIds.findIndex((id) => id === clientId);
          if (index !== -1) {
            this.clientIds.splice(index, 1);

            const callbacks = this.clientDestroyCallbacks;
            for (const callback of callbacks) {
              try {
                callback(clientId);
              } catch (e) {
                console.error(e);
              }
            }
          }
        }
      );
      return () => {
        this.previousPort = 'server';
        disposeSyncClientId?.();
        disposeRemoveClientId?.();
      };
    });
  }

  isolatedModules: Service[] = [];

  /**
   * all isolated instances state will not be sync to other clients or server.
   */
  disableShare(instance: object) {
    if (__DEV__) {
      if (!this.shared) {
        console.warn(`The app is not shared, so it cannot be isolated.`);
      }
      if (this.isolatedModules.includes(instance)) {
        console.warn(
          `This module "${instance.constructor.name}" has been disabled for state sharing.`
        );
      }
    }
    this.isolatedModules = this.isolatedModules.concat(instance);
  }

  protected lastIsolatedInstances?: Service[];

  protected lastIsolatedInstanceKeys?: (string | undefined)[];

  get isolatedInstanceKeys() {
    if (this.lastIsolatedInstances !== this.isolatedModules) {
      this.lastIsolatedInstanceKeys = this.isolatedModules.map(
        (instance) => instance[identifierKey]
      );
    }
    return this.lastIsolatedInstanceKeys ?? [];
  }

  hasIsolatedState(key: string) {
    return this.isolatedInstanceKeys.includes(key);
  }

  get id() {
    return this.clientId ?? '__SERVER__';
  }

  get shared() {
    return !!(this.sharedAppOptions.port && this.sharedAppOptions.type);
  }

  get name() {
    return this.sharedAppOptions.portName ?? 'default';
  }

  get disableSyncClient() {
    return (
      document.visibilityState === 'hidden' &&
      !this.sharedAppOptions.forcedSyncClient &&
      this.allowDisableSync()
    );
  }

  protected detectPort(port: Port) {
    return this.portApp?.[port];
  }

  /**
   * onServer
   *
   * When the port is server, this hook will execute.
   * And allow to return a function that will be executed when the current port is switched to client.
   */
  onServer = (callback: CallbackWithHook<Required<Transports>['server']>) => {
    if (typeof callback !== 'function') {
      throw new Error(`'onServer' argument should be a function.`);
    }
    this.serverCallbacks.add(callback);

    if (
      this.lastHooks &&
      this.lastHooks.size > 0 &&
      this.isServer &&
      this.transport
    ) {
      try {
        const hook = callback(this.transport);
        this.lastHooks.add(hook);
      } catch (e) {
        console.error(e);
      }
    }

    return () => {
      this.serverCallbacks.delete(callback);
    };
  };

  /**
   * onClient
   *
   * When the port is client, this hook will execute.
   * And allow to return a function that will be executed when the current port is switched to server.
   */
  onClient = (callback: CallbackWithHook<Required<Transports>['client']>) => {
    if (typeof callback !== 'function') {
      throw new Error(`'onClient' argument should be a function.`);
    }
    this.clientCallbacks.add(callback);

    if (
      this.lastHooks &&
      this.lastHooks.size > 0 &&
      this.isClient &&
      this.transport
    ) {
      try {
        const hook = callback(this.transport);
        this.lastHooks.add(hook);
      } catch (e) {
        console.error(e);
      }
    }

    return () => {
      this.clientCallbacks.delete(callback);
    };
  };

  /**
   * emit client destroy event with clientId
   */
  onClientDestroy = (callback: OnClientDestroy) => {
    if (typeof callback !== 'function') {
      throw new Error(`'onClientDestroy' argument should be a function.`);
    }

    this.clientDestroyCallbacks.add(callback);

    return () => {
      this.clientDestroyCallbacks.delete(callback);
    };
  };

  get isWorkerMode() {
    return this.sharedAppOptions.type === 'SharedWorker';
  }

  get isServerWorker() {
    return this.isWorkerMode && this.isServer;
  }

  get isServer() {
    return !!this.detectPort('server');
  }

  get isClient() {
    return !!this.detectPort('client');
  }

  get transports() {
    return this.sharedAppOptions.transports ?? {};
  }

  transport?: Transport;

  setPort(
    currentPortApp: PortApp,
    transport: Required<Transports>[keyof Transports]
  ) {
    this.transport = transport;
    if (this.lastHooks) {
      for (const hook of this.lastHooks) {
        try {
          hook?.();
        } catch (e) {
          console.error(e);
        }
      }
    }
    this.lastHooks = new Set();
    this.portApp = currentPortApp;
    const callbacks = this.isClient
      ? this.clientCallbacks
      : this.serverCallbacks;
    for (const callback of callbacks) {
      try {
        const hook = callback(transport);
        this.lastHooks.add(hook);
      } catch (e) {
        console.error(e);
      }
    }
  }

  syncToClients() {
    const store = (this as Service)[storeKey];
    if (this.transports.server) {
      this.transports.server?.emit(
        { name: syncToClientsName, respond: false },
        store!.getState()
      );
    } else {
      throw new Error(
        `Failed to 'syncToClients()', 'transports.server' does not exist.`
      );
    }
  }

  async syncFullState({ forceSync = true } = {}) {
    if (forceSync) {
      this.syncFullStatePromise = undefined;
    }
    if (this.syncFullStatePromise) {
      await this.syncFullStatePromise;
      return;
    }
    if (typeof this.transports.client === 'undefined') {
      throw new Error(`The current client transport does not exist.`);
    }
    this.syncFullStatePromise = this.transports.client.emit(
      loadFullStateActionName,
      !forceSync ? this.lastAction.sequence : -1
    );
    const fullState = await this.syncFullStatePromise;
    this.syncFullStatePromise = undefined;
    if (typeof fullState === 'undefined') {
      throw new Error(`Failed to sync full state from server port.`);
    }
    if (
      fullState === null ||
      (!forceSync &&
        this.lastAction.sequence >
          fullState[this.lastAction.stateKey]._sequence)
    )
      return;
    const store = (this as Service)[storeKey];
    store!.dispatch({
      type: `${actionIdentifier}_${loadFullStateActionName}`,
      state: this.getNextState(fullState),
      _reactant: actionIdentifier,
    });
    this.lastAction.sequence = fullState[this.lastAction.stateKey]._sequence;
  }

  /**
   * ignore router state and isolated state sync for last action
   */
  protected getNextState(fullState: Record<string, any>) {
    const store = (this as Service)[storeKey];
    const currentFullState = store!.getState();
    const nextState: Record<string, any> = {
      ...fullState,
      router: currentFullState.router,
    };
    if (this.isolatedInstanceKeys.length) {
      this.isolatedInstanceKeys.forEach((key) => {
        if (key) {
          nextState[key] = currentFullState[key];
        }
      });
    }
    return nextState;
  }

  /**
   * transform port with new transport
   */
  transform(port: Port, transport?: Transport) {
    if (port !== 'server' && port !== 'client') {
      throw new Error(`The port '${port}' is not supported.`);
    }
    this.sharedAppOptions.transports![port] =
      transport ?? this.sharedAppOptions.transports![port];
    this.sharedAppOptions.transform!(port);
  }
}
