import * as lrap from '../long-running-async-process';
import ioClient from 'socket.io-client';
import ClientStickySocket from './clientStickySocket';
import socketWildcard from '../socket.io-wildcard';
import {
  BINARY_EVENT,
  ClientSocketDisconnectReason,
  EVENT,
  EmitHistoryPacket, EmitPacket, RESTORE_CONNECTION_EVENT, RestoreConnectionRequest, RestoreConnectionResponse,
  SocketOptions
} from './common.types';
import {getClientSocketIoTransportName, sendHistory} from './common.utils';
import EventEmitter from '../../tools/eventEmitter';
import LoggerManager from '../../logger';
import Queue from '../../tools/queue';

/**
 * A socket connection
 */
class StickySocketConnection extends lrap.RootProcess {
  
  private _options?: StickySocketConnection.Options;
  private _socket: SocketIOClient.Socket;
  private _client: ClientStickySocket;
  private _sharedState: StickySocketConnection.SharedState;
  private _internalEvents: EventEmitter<StickySocketConnection.SharedEvents>;
  private _disconnectReason?: ClientSocketDisconnectReason;
  private _logger = LoggerManager.getLogger(StickySocketConnection.name);
  private _prevStageListeners: {[event: string]: Set<(...args: any[]) => void>} = {};
  private _label: string;

  /**
   * Returns native socket
   * @returns socket.io socket
   */
  get socket(): SocketIOClient.Socket {
    return this._socket;
  }

  /**
   * Returns current transport name, using socket.io internals
   * @returns transport name, e.g. "websocket"
   */
  get transportName(): string {
    return getClientSocketIoTransportName(this._socket);
  }

  /**
   * Returns first present packet index in last emit history
   * @returns first history index
   * @internal intended for tests only
   */
  get firstHistoryIndex(): number | undefined {
    return this._sharedState.emitHistory.front()?.index;
  }

  /**
   * @inheritdoc
   * @param client parent client
   * @param internalEvents internal event emitter
   */
  inject(client: ClientStickySocket, internalEvents: EventEmitter<StickySocketConnection.SharedEvents>): void {
    this._client = client;
    this._internalEvents = internalEvents;
  }

  /**
   * @inheritdoc
   * @param url url to connect to
   * @param sessionId session ID
   * @param sharedState shared state
   * @param options additiona options
   */
  initialize(
    url: string, sessionId: string, sharedState: StickySocketConnection.SharedState,
    options?: StickySocketConnection.Options
  ) {
    this._options = options;
    this._label = options?.label || 'default';
    this._sharedState = sharedState;
    this._sharedState.startCount++;
    let query = {
      ...options?.connection?.query,
      stickySocketConnectionId: sessionId
    } as Record<string, any>;
    if (sharedState.startCount > 1) {
      let request: RestoreConnectionRequest = {
        lastReceivedIndex: sharedState.lastReceivedIndex,
        lastSentIndex: sharedState.lastSentIndex,
        firstHistoryIndex: this._sharedState.emitHistory.front()?.index,
        sessionId
      };
      this._logger.debug(`${this._label}: restoring session`, JSON.stringify(request));
      query.restoreStickyConnection = encodeURIComponent(JSON.stringify(request));
    }
    this._socket = ioClient(url, {
      ...options?.connection,
      reconnection: false,
      autoConnect: false,
      query
    });
    socketWildcard(ioClient.Manager)(this._socket);
    this._socket.on('*', packet => {
      if (packet.type === EVENT || packet.type === BINARY_EVENT) {
        let payload = packet.data[1];
        if (this._options?.useNativeSocketIoServer) {
          this._client.emit(packet.data[0], ...packet.data.slice(1));
          return;
        }
        if ('index' in payload) {
          this._logger.trace(() => `${this._label}: received packet ` + JSON.stringify({index: payload.index}));
          this._sharedState.lastReceivedIndex = Math.max(this._sharedState.lastReceivedIndex ?? -1, payload.index);
          this._client.emit(packet.data[0], ...payload.data);
        }
      }
    });
  }

  /**
   * Emits a data event. All events are buffered if no connection
   * @param event The event that we're emitting
   * @param args Optional arguments to send with the event
   */
  send(event: string, ...args: any[]) {
    if (this._options?.useNativeSocketIoServer) {
      this._socket.emit(event, ...args);
      return;
    }
    let packet: EmitHistoryPacket = {
      index: ++this._sharedState.lastSentIndex,
      event,
      data: args,
      time: new Date()
    };
    this._sharedState.emitHistory.push(packet);
    this._socket.emit(event, {
      index: packet.index,
      data: packet.data
    } satisfies EmitPacket);
  }

  /**
   * @inheritdoc
   */
  async start(stopPromise: lrap.HandlePromise<void>) {
    try {
      let connectPromise = this._connect(stopPromise);
      let restorePromise = this._restoreConnectionIfNeeded(stopPromise);
      let [_, restored] = await Promise.all([connectPromise, restorePromise]);
      if (restored) {
        this._logger.info(`${this._label}: restored connection session`);
      }
    } catch (err) {
      this._logger.warn(`${this._label}: failed to connect`, err);
      if (this._sharedState.startCount === 1 || err instanceof RestoreRejectError) {
        this._client.disconnect(err);
      }
      throw new lrap.ControlSignal({action: 'failover', severity: 'info'});
    }
  }

  private _connect(stopPromise: lrap.HandlePromise<void>) {
    this._socket.connect();
    return new Promise<void>((resolve, reject) => {
      stopPromise.then(() => reject(new Error('Stopped during connection')));
      this._setSocketStageListener('connect', () => {
        // Emitting connect immediately to guarantee no data events will be received until connect event emitted
        this._logger.debug(`${this._label}: internal socket connected`);
        this._internalEvents.emit('connect');
        resolve();
      });
      this._setSocketStageListener('disconnect', (reason: ClientSocketDisconnectReason) => {
        this._disconnectReason = reason;
        reject(new Error(`Disconnected when connecting due to ${reason}`));
      });
      this._setSocketStageListener('error', err => reject(err));
      this._setSocketStageListener('connect_error', err => reject(err));
      this._setSocketStageListener('connect_timeout', err => reject(err));
    });
  }

  private async _restoreConnectionIfNeeded(stopPromise: lrap.HandlePromise<void>): Promise<boolean> {
    if (this._sharedState.startCount === 1) {
      return false;
    }
    let sendSinceIndex = await new Promise<number>((resolve, reject) => {
      stopPromise.then(() => reject(new Error('Stopped during restoring connection')));
      this._setSocketStageListener(RESTORE_CONNECTION_EVENT, (event: RestoreConnectionResponse) => {
        event.restored ?
          resolve(event.sendSinceIndex) :
          reject(new RestoreRejectError('Cannot restore connection session'));
      });
      this._setSocketStageListener('disconnect', (reason: string) => {
        reject(new Error(`Disconnected when restoring connection due to ${reason}`));
      });
      this._setSocketStageListener('error', err => reject(err));
      this._setSocketStageListener('connect_error', err => reject(err));
      this._setSocketStageListener('connect_timeout', err => reject(err));
    });
    this._logger.debug(`${this._label}: sending history since ${sendSinceIndex} packet index`);
    sendHistory(this._socket, this._sharedState.emitHistory, sendSinceIndex);
    return true;
  }

  /**
   * @inheritdoc
   */
  async run(stopPromise: lrap.HandlePromise<void>): Promise<void> {
    const historyBufferTtlInMs = 1000 * (this._options?.emitHistoryTtlInSeconds ?? 10);
    let clearOldHistoryInterval = setInterval(() => this.removeExpiredEmitHistory(), historyBufferTtlInMs);
    try {
      if (this._disconnectReason) {
        if (!this._tryDisconnectClientGracefully(this._disconnectReason)) {
          throw new Error(`Disconnected due to ${this._disconnectReason}`);
        }
        return;
      }
      this._removePrevStageListeners();
      await Promise.race([stopPromise, new Promise<void>((resolve, reject) => {
        this._setSocketStageListener('disconnect', (reason: ClientSocketDisconnectReason) => {
          if (!this._tryDisconnectClientGracefully(reason)) {
            reject(new Error(`Disconnected due to ${reason}`));
          }
        });
        this._setSocketStageListener('error', reject);
      })]);
    } catch (err) {
      this._logger.warn(`${this._label}: lost connection`, err);
      this._internalEvents.emit('fail', err);
      throw new lrap.ControlSignal({action: 'failover', severity: 'info'});
    } finally {
      this._removePrevStageListeners();
      clearInterval(clearOldHistoryInterval);
    }
  }

  /**
   * Removes expired emit history
   */
  removeExpiredEmitHistory() {
    const historyBufferTtlInMs = 1000 * (this._options?.emitHistoryTtlInSeconds ?? 10);
    while (this._sharedState.emitHistory.length) {
      if (Date.now() - this._sharedState.emitHistory.front().time.getTime() > historyBufferTtlInMs) {
        this._sharedState.emitHistory.shift();
      } else {
        break;
      }
    }
  }

  private _tryDisconnectClientGracefully(reason: ClientSocketDisconnectReason): boolean {
    if (reason === 'io client disconnect' || reason === 'io server disconnect') {
      this._logger.info(`${this._label}: disconnecting client due to ${reason}`);
      this._client.disconnect();
      return true;
    }
    return false;
  }
  
  /**
   * @inheritdoc
   */
  async stop() {
    this._socket.disconnect();
  }

  private _setSocketStageListener<T extends (...args: any[]) => void>(event: string, listener: T): T {
    this._prevStageListeners[event] ||= new Set();
    this._prevStageListeners[event].add(listener);
    this._socket.on(event, listener);
    return listener;
  }

  private _removePrevStageListeners() {
    for (let [event, listeners] of Object.entries(this._prevStageListeners)) {
      for (let listener of listeners) {
        this._socket.off(event, listener);
      }
    }
  }
}

namespace StickySocketConnection {

  /** Options */
  export type Options = SocketOptions & {
    /** Logging label to identify this instance. Defaults to `default` */
    label?: string;
    /** Native socket IO options */
    connection?: Pick<
      SocketIOClient.ConnectOpts,
      'path' | 'timeout' | 'query'
    > & {
      /**
       * Headers that will be passed for each request to the server (via xhr-polling and via websockets). These values
       * then can be used during handshake or for special proxies
       */
      extraHeaders?: Record<string, string>;
    };
    /**
     * Adjusts compatibility to work in mode when a native socket.io server is used. In this mode the socket will not
     * try to restore connection and will send packets as is. Intended for tests where native socket.io server is used
     */
    useNativeSocketIoServer?: boolean;
  };

  /** Shared state */
  export type SharedState = {
    /** Count of times a connection is started */
    startCount: number;
    /** Emit */
    emitHistory: Queue<EmitHistoryPacket>;
    /** Last sent index. Starts from `0`. If no packets were sent yet, defaults to `-1` */
    lastSentIndex: number;
    /** Last received packet index. Starts from `0`. If no packets were received, defaults to `-1` */
    lastReceivedIndex: number;
  };

  /** Shared events for intermediate state */
  export type SharedEvents = {
    /** Connected event */
    connect: () => void;
    /** Lost connection event */
    fail: (err: Error) => void;
  };

  /** Connection events */
  export const CONNECTION_EVENTS = ['connect', 'disconnect'];
  /** Sticky connection protocol events */
  export const PROTOCOL_EVENTS = [RESTORE_CONNECTION_EVENT];
  /** Internal events */
  export const INTERNAL_EVENTS = [
    'error', 'connect_error', 'connect_timeout', 'reconnect', 'reconnect_attempt', 'reconnecting', 'reconnect_error',
    'reconnect_failed', 'ping', 'pong', ...PROTOCOL_EVENTS
  ];
}

export default StickySocketConnection;

/**
 * Error, meaning that the server rejected the restore request. E.g. server may have already
 * removed old emit history, required by the client, so the restoring cannot be done
 */
class RestoreRejectError extends Error {}
