import { v1 } from 'uuid'
import WebSocket from 'isomorphic-ws'

export type RpcEventFunction = (e: Event) => void
export type RpcMessageEventFunction = (e: MessageEvent) => void
export type RpcCloseEventFunction = (e: CloseEvent) => void

export type RpcNotificationEvent = (data: IRpcNotification) => void
export type RpcRequestEvent = (data: IRpcRequest) => void
export type RpcSuccessResponseEvent = (data: IRpcSuccessResponse) => void
export type RpcErrorResponseEvent = (data: IRpcErrorResponse) => void

export enum RpcVersions {
  RPC_VERSION = '2.0',
}

export type RpcId = string | number

export interface IRpcData {
  method: string
  // TSES-lint: (any - unknown) Unexpected any. Specify a different type
  params?: unknown
}

export interface IRpcNotification extends IRpcData {
  jsonrpc: RpcVersions.RPC_VERSION
}

export interface IRpcRequest extends IRpcNotification {
  // if not included its notification
  id: RpcId
}

export interface IRpcResponse {
  id: RpcId
  jsonrpc: RpcVersions.RPC_VERSION
}

export interface IRpcSuccessResponse extends IRpcResponse {
  // if not included its notification
  // TSES-lint: (any - unknown) Unexpected any. Specify a different type
  result: unknown
}

export interface IRpcError {
  code: number
  message: string
  // TSES-lint: (any - unknown) Unexpected any. Specify a different type
  data?: unknown
}

export interface IRpcErrorResponse extends IRpcResponse {
  error: IRpcError
}

export interface IRpcWebSocketConfig {
  responseTimeout: number
}

export type RpcUnidentifiedMessage =
  | IRpcRequest
  | IRpcNotification
  | IRpcSuccessResponse
  | IRpcErrorResponse

export class RpcWebSocketClient {
  // native websocket
  public ws: WebSocket

  private idAwaiter: {
    // TSES-lint: (any - unknown) Unexpected any. Specify a different type
    [id: string]: (data?: unknown) => void
  } = {}

  private onOpenHandlers: RpcEventFunction[] = []
  private onAnyMessageHandlers: RpcMessageEventFunction[] = []

  private onNotification: RpcNotificationEvent[] = []
  private onRequest: RpcRequestEvent[] = []
  private onSuccessResponse: RpcSuccessResponseEvent[] = []
  private onErrorResponse: RpcErrorResponseEvent[] = []

  private onErrorHandlers: RpcEventFunction[] = []
  private onCloseHandlers: RpcCloseEventFunction[] = []

  private config = {
    responseTimeout: 10000,
  }

  // constructor
  /**
   * Does not start WebSocket connection!
   * You need to call connect() method first.
   * @memberof RpcWebSocketClient
   */
  public constructor() {
    // TSES-lint: (any - unknown) Unexpected any. Specify a different type

    this.ws = undefined as unknown
  }

  // public
  /**
   * Starts WebSocket connection. Returns Promise when connection is established.
   * @param {string} url
   * @param {(string | string[])} [protocols]
   * @memberof RpcWebSocketClient
   */
  public async connect(url: string, protocols?: string | string[]) {
    this.ws = new WebSocket(url, protocols)
    await this.listen()
  }

  // events
  public onOpen(fn: RpcEventFunction) {
    this.onOpenHandlers.push(fn)
  }

  /**
   * Native onMessage event. DO NOT USE THIS unless you really have to or for debugging purposes.
   * Proper RPC events are onRequest, onNotification, onSuccessResponse and onErrorResponse (or just awaiting response).
   * @param {RpcMessageEventFunction} fn
   * @memberof RpcWebSocketClient
   */
  public onAnyMessage(fn: RpcMessageEventFunction) {
    this.onAnyMessageHandlers.push(fn)
  }

  public onError(fn: RpcEventFunction) {
    this.onErrorHandlers.push(fn)
  }

  public onClose(fn: RpcCloseEventFunction) {
    this.onCloseHandlers.push(fn)
  }

  /**
   * Appends onmessage listener on native websocket with RPC handlers.
   * If onmessage function was already there, it will call it on beggining.
   * Useful if you want to use RPC WebSocket Client on already established WebSocket along with function changeSocket().
   * @memberof RpcWebSocketClient
   */
  public listenMessages() {
    let previousOnMessage: RpcEventFunction | undefined
    if (this.ws.onmessage) {
      previousOnMessage = this.ws.onmessage.bind(this.ws)
    }

    this.ws.onmessage = (e: MessageEvent) => {
      if (previousOnMessage) {
        previousOnMessage(e)
      }

      for (const handler of this.onAnyMessageHandlers) {
        handler(e)
      }

      const data: RpcUnidentifiedMessage = JSON.parse(e.data)
      if (this.isNotification(data)) {
        // notification
        for (const handler of this.onNotification) {
          handler(data)
        }
      } else if (this.isRequest(data)) {
        // request
        for (const handler of this.onRequest) {
          handler(data)
        }
        // responses
      } else if (this.isSuccessResponse(data)) {
        // success
        for (const handler of this.onSuccessResponse) {
          handler(data)
        }

        // resolve awaiting function
        this.idAwaiter[data.id](data.result)
      } else if (this.isErrorResponse(data)) {
        // error
        for (const handler of this.onErrorResponse) {
          handler(data)
        }

        // resolve awaiting function
        this.idAwaiter[data.id](data.error)
      }
    }
  }

  // communication

  /**
   * Creates and sends RPC request. Resolves when appropirate response is returned from server or after config.responseTimeout.
   * @param {string} method
   * @param {*} [params]
   * @returns
   * @memberof RpcWebSocketClient
   */
  // TSES-lint: (any - unknown) Unexpected any. Specify a different type

  public call(method: string, params?: unknown) {
    return new Promise((resolve, reject) => {
      const data = this.buildRequest(method, params)
      // give limited time for response
      let timeout: NodeJS.Timeout
      if (this.config.responseTimeout) {
        timeout = setTimeout(() => {
          // stop waiting for response
          delete this.idAwaiter[data.id]
          reject(
            `Awaiting response to: ${method} with id: ${data.id} timed out.`
          )
        }, this.config.responseTimeout)
      }
      // TSES-lint: (any - unknown) Unexpected any. Specify a different type
      // expect response
      this.idAwaiter[data.id] = (responseData?: unknown) => {
        // stop timeout
        clearInterval(timeout)
        // stop waiting for response
        delete this.idAwaiter[data.id]

        if (this.isRpcError(responseData)) {
          reject(responseData)
          return
        }

        resolve(responseData)
      }
      this.ws.send(JSON.stringify(data))
    })
  }
  // -------TODO: Unused Function Notify
  /**
   * Creates and sends RPC Notification.
   * @param {string} method
   * @param {*} [params]
   * @memberof RpcWebSocketClient
   */
  // public notify(method: string, params?: any) {
  //   this.ws.send(JSON.stringify(this.buildNotification(method, params)))
  // }

  // setup

  /**
   * You can provide custom id generation function to replace default uuid/v1.
   * @param {() => string} idFn
   * @memberof RpcWebSocketClient
   */
  public customId(idFn: () => string) {
    this.idFn = idFn
  }

  /**
   * Allows modifying configuration.
   * @param {RpcWebSocketConfig} options
   * @memberof RpcWebSocketClient
   */
  // TSES-lint: (any - unknown) Unexpected any. Specify a different type
  public configure(options: unknown) {
    Object.assign(this.config, options)
  }

  /**
   * Allows you to change used native WebSocket client to another one.
   * If you have already-connected WebSocket, use this with listenMessages().
   * @param {WebSocket} ws
   * @memberof RpcWebSocketClient
   */
  public changeSocket(ws: WebSocket) {
    this.ws = ws
  }

  // private

  // events
  private listen() {
    return new Promise<void>((resolve, reject) => {
      this.ws.onopen = (e: Event) => {
        for (const handler of this.onOpenHandlers) {
          handler(e)
        }
        resolve(void 0)
      }

      // listen for messages
      this.listenMessages()

      // called before onclose
      this.ws.onerror = (e: Event) => {
        for (const handler of this.onErrorHandlers) {
          handler(e)
        }
      }

      this.ws.onclose = (e: CloseEvent) => {
        for (const handler of this.onCloseHandlers) {
          handler(e)
        }
        reject()
      }
    })
  }
  // TSES-lint: (any - unknown) Unexpected any. Specify a different type

  // request
  private buildRequest(method: string, params?: unknown): IRpcRequest {
    const data = this.buildRequestBase(method, params)
    data.jsonrpc = RpcVersions.RPC_VERSION
    return data
  }
  // TSES-lint: (any - unknown) Unexpected any. Specify a different type

  // private buildRequestBase(method: string, params?: unknown): IRpcRequest {
  //   const data: IRpcRequest = {} as unknown
  //   data.id = this.idFn()
  //   data.method = method

  //   if (params) {
  //     data.params = params
  //   }

  //   return data
  // }

  private buildRequestBase(method: string, params?: unknown): IRpcRequest {
    const data: IRpcRequest = {
      id: this.idFn(),
      jsonrpc: RpcVersions.RPC_VERSION,
      method: method,
    }

    if (params) {
      data.params = params
    }
    return data
  }

  // -------TODO: Unused Function Notify
  // notification
  // private buildNotification(method: string, params?: any): IRpcNotification {
  //   const data = this.buildNotificationBase(method, params)
  //   data.jsonrpc = RpcVersions.RPC_VERSION
  //   return data
  // }

  // private buildNotificationBase(
  //   method: string,
  //   params?: any
  // ): IRpcNotification {
  //   const data: IRpcNotification = {} as any
  //   data.method = method

  //   if (params) {
  //     data.params = params
  //   }

  //   return data
  // }

  private idFn(): RpcId {
    return v1()
  }

  // tests
  private isNotification(
    data: RpcUnidentifiedMessage
  ): data is IRpcNotification {
    return !(data as any).id // eslint-disable-line @typescript-eslint/no-explicit-any
  }

  private isRequest(data: RpcUnidentifiedMessage): data is IRpcRequest {
    // TSES-lint: (any - unknown) Unexpected any. Specify a different type
    return (data as IRpcRequest).method !== undefined
    // return (data as any).method
  }

  private isSuccessResponse(
    data: RpcUnidentifiedMessage
  ): data is IRpcSuccessResponse {
    // TSES_lint : error  Do not access Object.prototype method 'hasOwnProperty' from target object
    // return data.hasOwnProperty(`result`)
    return Object.prototype.hasOwnProperty.call(data, 'result')
  }

  private isErrorResponse(
    data: RpcUnidentifiedMessage
  ): data is IRpcErrorResponse {
    // TSES_lint : error  Do not access Object.prototype method 'hasOwnProperty' from target object
    // return data.hasOwnProperty(`error`)
    return Object.prototype.hasOwnProperty.call(data, 'error')
  }
  // TSES-lint: (any - unknown) Unexpected any. Specify a different type

  private isRpcError(data: unknown): data is IRpcError {
    // return typeof (data as any).code !== 'undefined'
    return typeof (data as IRpcError).code !== 'undefined'
  }
}

export default RpcWebSocketClient
