import { EventEmitter } from 'events'
import { Logger, LoggerOptions } from 'node-log-it'
import { merge, filter, minBy, maxBy, random, find } from 'lodash'
import { Node } from './node'

const MODULE_NAME = 'Mesh'
const DEFAULT_OPTIONS: MeshOptions = {
  startBenchmarkOnInit: true,
  toFetchUserAgent: true,
  benchmarkIntervalMs: 2000,
  fetchMissingUserAgentIntervalMs: 5000,
  refreshUserAgentIntervalMs: 5 * 60 * 1000,
  minActiveNodesRequired: 2,
  pendingRequestsThreshold: 5,
  loggerOptions: {},
}

export interface MeshOptions {
  startBenchmarkOnInit?: boolean
  toFetchUserAgent?: boolean
  benchmarkIntervalMs?: number
  fetchMissingUserAgentIntervalMs?: number
  refreshUserAgentIntervalMs?: number
  minActiveNodesRequired?: number
  pendingRequestsThreshold?: number
  loggerOptions?: LoggerOptions
}

export class Mesh extends EventEmitter {
  nodes: Node[] // Ensure there's at least 1 item in the array

  private _isReady = false
  private benchmarkIntervalId?: NodeJS.Timer
  private fetchMissingUserAgentIntervalId?: NodeJS.Timer
  private refreshUserAgentIntervalId?: NodeJS.Timer
  private options: MeshOptions
  private logger: Logger

  constructor(nodes: Node[], options: MeshOptions = {}) {
    super()

    // Associate required properties
    this.nodes = nodes
    if (this.nodes.length === 0) {
      throw new Error('Mesh must have 1 or more nodes.')
    }

    // Associate optional properties
    this.options = merge({}, DEFAULT_OPTIONS, options)
    this.validateOptionalParameters()

    // Bootstrapping
    this.logger = new Logger(MODULE_NAME, this.options.loggerOptions)
    if (this.options.startBenchmarkOnInit) {
      this.startBenchmark()
    }

    this.logger.debug('constructor completes.')
  }

  isReady(): boolean {
    return this._isReady
  }

  startBenchmark() {
    this.logger.debug('startBenchmark triggered.')

    // Go through and ping all unknown nodes
    const unknownNodes = filter(this.nodes, (n: Node) => n.isActive === undefined)
    this.logger.debug('unknownNodes.length:', unknownNodes.length)
    unknownNodes.forEach((n: Node) => {
      n.getBlockCount()
        .then(() => {
          this.checkMeshReady()
        })
        .catch((err: any) => {
          this.logger.info('node.getBlockCount() failed, but to continue. Endpoint:', n.endpoint, 'Message:', err.message)
        })
    })

    // Fetch node version
    if (this.options.toFetchUserAgent) {
      unknownNodes.forEach((n: Node) => {
        n.getVersion().catch((err: any) => {
          this.logger.info('node.getVersion() failed, but to continue. Endpoint:', n.endpoint, 'Message:', err.message)
        })
      })

      this.fetchMissingUserAgentIntervalId = setInterval(() => this.performFetchMissingUserAgent(), this.options.fetchMissingUserAgentIntervalMs!)
      this.refreshUserAgentIntervalId = setInterval(() => this.performRefreshUserAgent(), this.options.refreshUserAgentIntervalMs!)
    }

    // Start timer
    this.benchmarkIntervalId = setInterval(() => this.performBenchmark(), this.options.benchmarkIntervalMs!)
  }

  stopBenchmark() {
    this.logger.debug('stopBenchmark triggered.')
    if (this.benchmarkIntervalId) {
      clearInterval(this.benchmarkIntervalId)
    }
    if (this.fetchMissingUserAgentIntervalId) {
      clearInterval(this.fetchMissingUserAgentIntervalId)
    }
    if (this.refreshUserAgentIntervalId) {
      clearInterval(this.refreshUserAgentIntervalId)
    }
  }

  close() {
    this.logger.debug('close triggered.')
    this.stopBenchmark()
    this.nodes.forEach((n: Node) => {
      n.close()
    })
  }

  getFastestNode(activeOnly = true): Node | undefined {
    this.logger.debug('getFastestNode triggered.')

    let nodePool = activeOnly ? this.listActiveNodes() : this.nodes
    if (nodePool.length === 0) {
      return undefined
    }

    nodePool = filter(nodePool, (n: Node) => n.latency !== undefined)
    if (nodePool.length === 0) {
      return undefined
    }

    return minBy(nodePool, 'latency')
  }

  getHighestNode(activeOnly = true): Node | undefined {
    this.logger.debug('getHighestNode triggered.')

    let nodePool = activeOnly ? this.listActiveNodes() : this.nodes
    if (nodePool.length === 0) {
      return undefined
    }

    nodePool = filter(nodePool, (n: Node) => n.blockHeight !== undefined)
    if (nodePool.length === 0) {
      return undefined
    }

    return maxBy(nodePool, 'blockHeight')
  }

  /**
   * @param activeOnly Toggle to only pick node that is determined to be active.
   */
  getRandomNode(activeOnly = true): Node | undefined {
    this.logger.debug('getRandomNode triggered.')

    const nodePool = activeOnly ? this.listActiveNodes() : this.nodes
    if (nodePool.length === 0) {
      return undefined
    }

    const randomIndex = random(0, nodePool.length - 1)
    return nodePool[randomIndex]
  }

  /**
   * An optimal node is defined as a fast node that has not exceed a pending threshold.
   */
  getOptimalNode(height: number, activeOnly = true): Node | undefined {
    this.logger.debug('getOptimalNode triggered.')

    const nodePool = activeOnly ? this.listActiveNodes() : this.nodes
    if (nodePool.length === 0) {
      return undefined
    }

    // Filter nodes that has the required height
    const qualifyHeightNodes = filter(this.nodes, (n: Node) => n.blockHeight !== undefined && n.blockHeight >= height)
    if (qualifyHeightNodes.length === 0) {
      return undefined
    }

    // Filter nodes that exceed pending threshold
    const qualifyPendingNodes = filter(qualifyHeightNodes, (n: Node) => !n.pendingRequests || n.pendingRequests <= this.options.pendingRequestsThreshold!)
    if (qualifyPendingNodes.length === 0) {
      // If all qualify nodes exceeded pending threshold, then just pick a random one from qualifyHeightNodes
      const randomIndex = random(0, qualifyHeightNodes.length - 1)
      return qualifyHeightNodes[randomIndex]
    }

    // Pick the fastest node from qualifyPendingNodes
    return minBy(qualifyPendingNodes, 'latency')
  }

  private validateOptionalParameters() {
    // TODO
  }

  private performBenchmark() {
    this.logger.debug('performBenchmark triggered.')
    const node = this.getNodeToBenchmark()
    if (node) {
      node.getBlockCount().catch((err: any) => {
        this.logger.info('node.getBlockCount error in performBenchmark(). Endpoint:', node.endpoint, 'Message:', err.message)
      })
    } else {
      this.logger.info('Unable to find a suitable node to perform benchmark.')
    }
  }

  private getNodeToBenchmark(): Node | undefined {
    this.logger.debug('getNodeToBenchmark triggered.')

    // Find nodes that's not currently running benchmarks
    const nodePool = filter(this.nodes, (n: Node) => !n.isBenchmarking)
    if (nodePool.length === 0) {
      return undefined
    }

    // Attempt to find a node that hasn't been benchmarked at all
    const unknownNode = find(nodePool, (n: Node) => n.lastPingTimestamp === undefined)
    if (unknownNode) {
      return unknownNode
    }

    // Attempt to find a node that last benchmark longest ago
    const targetNode = minBy(nodePool, (n: Node) => n.lastPingTimestamp)
    return targetNode
  }

  private performFetchMissingUserAgent() {
    this.logger.debug('performBenchmark triggered.')
    const nodePool = filter(this.nodes, (n: Node) => n.userAgent === undefined)
    nodePool.forEach((n: Node) => {
      n.getVersion().catch((err: any) => {
        this.logger.info('node.getVersion() failed, but to continue. Endpoint:', n.endpoint, 'Message:', err.message)
      })
    })
  }

  private performRefreshUserAgent() {
    this.logger.debug('performRefreshUserAgent triggered.')
    this.nodes.forEach((n: Node) => {
      n.getVersion().catch((err: any) => {
        this.logger.info('node.getVersion() failed, but to continue. Endpoint:', n.endpoint, 'Message:', err.message)
      })
    })
  }

  private checkMeshReady() {
    this.logger.debug('checkMeshReady triggered.')
    const activeNodes = this.listActiveNodes()
    if (!this.options.minActiveNodesRequired || activeNodes.length >= this.options.minActiveNodesRequired) {
      if (!this._isReady) {
        // First signal that mesh is considered as 'ready' state
        this.setReady()
        this.logger.debug('mesh is considered to be now ready.')
      }
    }
  }

  private setReady() {
    this._isReady = true
    this.emit('ready')
  }

  private listActiveNodes(): Node[] {
    return filter(this.nodes, { isActive: true })
  }
}
