import randomstring from 'randomstring';
import * as lrap from '../long-running-async-process';
import StickySocketConnection from './stickySocketConnection';
import * as errors from '../../clients/errorHandler';
import {destroyInternalClientSocket} from './common.utils';
import {msToSeconds} from '../../helpers/convert/time';
import EventEmitter from '../../tools/eventEmitter';
import LoggerManager from '../../logger';
import Queue from '../../tools/queue';

/**
 * Client sticky socket
 */
class ClientStickySocket extends EventEmitter<ClientStickySocket.Events> {

  private _logger = LoggerManager.getLogger('ClientStickySocket');
  private _pool: lrap.RootPool<StickySocketConnection>;
  private _stickyConnectionWaitTimeout?: NodeJS.Timeout;
  private _id = randomstring.generate(16);
  private _label: string;
  private _stopped = false;
  private _connected = false;

  /**
   * Constructs instance
   * @param url URL
   * @param options options
   */
  constructor(url: string, options?: ClientStickySocket.Options) {
    super();
    this._label = `${options?.label || 'connection'}:${this._id}`;
    
    const stickyConnectionTtlInMs = 1000 * (options?.stickyConnectionTtlInSeconds ?? 10);
    let internalEvents = new EventEmitter<StickySocketConnection.SharedEvents>();
    internalEvents.once('connect', () => {
      this._connected = true;
      this.emit('connect');
    });
    internalEvents.on('connect', () => clearTimeout(this._stickyConnectionWaitTimeout));
    internalEvents.on('fail', err => {
      if (options?.useNativeSocketIoServer) {
        this.disconnect(err);
        return;
      }
      if (!this._stopped) {
        this._stickyConnectionWaitTimeout = setTimeout(() => {
          this._logger.warn(`${this._label}: failed to restore sticky connection`);
          this.disconnect(err);
        }, stickyConnectionTtlInMs);
      }
    });
    
    this._pool = new lrap.RootPool(StickySocketConnection, {
      dependencies: [this, internalEvents],
      label: `clientStickySocket:${this._label}`,
    });
    this._pool.scheduleProcess('connection', {
      args: [
        url, this._id,
        {
          startCount: 0,
          emitHistory: new Queue(),
          lastSentIndex: -1,
          lastReceivedIndex: -1
        },
        {
          label: this._label,
          connection: options?.connection,
          useNativeSocketIoServer: options?.useNativeSocketIoServer,
          emitHistoryTtlInSeconds: options?.emitHistoryTtlInSeconds ?? msToSeconds(stickyConnectionTtlInMs)
        }
      ],
      failoverThrottleDelay: {
        mode: 'exponential',
        resetDelayInMs: 0,
        minDelayInMs: options?.reconnectionDelayInMs ?? 1000,
        maxDelayInMs: options?.reconnectionDelayMaxInMs ?? 10000,
        randomizationFactor: options?.reconnectionRandomizationFactor ?? 0.5
      }
    });
  }

  /**
   * Returns socket ID
   * @returns socket ID
   */
  get id(): string {
    return this._id;
  }

  /**
   * Returns current native socket
   * @returns socket.io socket
   * @internal intended for tests only
   */
  get socket(): SocketIOClient.Socket {
    return this._pool.getProcess('connection')?.socket;
  }

  /**
   * Returns current transport name, using socket.io internals
   * @returns transport name, e.g. "websocket"
   * @internal intended for tests only
   */
  get transportName(): string {
    return this._pool.getProcess('connection')?.transportName;
  }
  
  /**
   * Returns first present packet index in last emit history
   * @returns first history index
   * @internal intended for tests only
   */
  get firstHistoryIndex(): number | undefined {
    return this._pool.getProcess('connection')?.firstHistoryIndex;
  }

  /**
   * Returns whether socket connected
   * @returns is connected
   */
  get connected(): boolean {
    return this._connected;
  }

  /**
   * @inheritdoc
   * @remarks Socket events `ClientStickySockets.INTERNAL_EVENTS` cannot be subscribed to
   * @throws `ValidationError` if attempting to subscribe to an internal event
   */
  on<U extends EventEmitter.Event<ClientStickySocket.Events>>(event: U, callback: ClientStickySocket.Events[U]) {
    if (ClientStickySocket.INTERNAL_EVENTS.includes(event)) {
      throw new errors.ValidationError('Cannot subscribe to an internal event');
    }
    super.on(event, callback);
  }

  /**
   * 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[]) {
    this._pool.getProcess('connection')?.send(event, ...args);
  }

  /**
   * Disconnects socket
   * @param err error if disconnecting with error
   * @returns promise resolving when disconnected
   */
  async disconnect(err?: Error) {
    clearTimeout(this._stickyConnectionWaitTimeout);
    let wasStopped = this._stopped;
    this._stopped = true;
    await this._pool.stop();
    if (!wasStopped) {
      this._connected = false;
      this.emit('disconnect', err);
    }
  }

  /**
   * Destroys underlying socket, using socket.io internals. The socket must have `websocket` transport
   * @param err optional error
   * @internal intended for tests only, e.g. to test connection loss
   */
  destroyInternalSocket(err?: Error) {
    destroyInternalClientSocket(this._pool.getProcess('connection').socket, err);
  }

  /**
   * Removes expired emit history
   * @internal intended for tests only
   */
  removeExpiredEmitHistory() {
    this._pool.getProcess('connection')?.removeExpiredEmitHistory();
  }
}

namespace ClientStickySocket {

  /** Constructing options */
  export type Options = Pick<StickySocketConnection.Options, 'connection' | 'label' | 'useNativeSocketIoServer'> & {
    /**
     * Emit history buffer TTL. Defaults to `stickyConnectionTtlInSeconds` + `reconnectThrottleDelayInMs`
     * (converted to seconds)
     */
    emitHistoryTtlInSeconds?: number;
    /** Timeout within of which a connection can be restored as previous one. Defaults to `10` */
    stickyConnectionTtlInSeconds?: number;
    /** Reconnection delay. The delay will be increased by 2x each failed connection attempt. Defaults to `1000` */
    reconnectionDelayInMs?: number;
    /** Max reconnection delay. Defaults to `10000` */
    reconnectionDelayMaxInMs?: number;
    /** Reconnection delay randomization factor in range [0; 1]. Defaults to `0.5` */
    reconnectionRandomizationFactor?: number;
  };

  /** Event listeners */
  export type Events = {
    /** Fired only once upon first connection */
    connect: () => void;
    /** Fired only once upon the last disconnection. Will be fired anyway, even if was never connected */
    disconnect: (err?: Error) => void;
    /** Custom subscription events */
    [event: string]: (...args: any[]) => any;
  };

  /** Connection events */
  export const CONNECTION_EVENTS = StickySocketConnection.CONNECTION_EVENTS;
  /** Internal events */
  export const INTERNAL_EVENTS = StickySocketConnection.INTERNAL_EVENTS;
}

export default ClientStickySocket;
