'use strict'

const fs = require('fs')
const rpc = require('pm2-axon-rpc')
const axon = require('pm2-axon')
const log = require('debug')('interactor:daemon')
const os = require('os')
const cst = require('../constants.js')
const ReverseInteractor = require('./reverse/ReverseInteractor.js')
const PushInteractor = require('./push/PushInteractor.js')
const Utility = require('./Utility.js')
const PM2Client = require('./PM2Client.js')
const TransporterInterface = require('./TransporterInterface.js')
const domain = require('domain') // eslint-disable-line
const WatchDog = require('./WatchDog')
const InteractorClient = require('./InteractorClient')
const semver = require('semver')
const path = require('path')
const pkg = require('../package.json')

global._logs = false

const InteractorDaemon = module.exports = class InteractorDaemon {
  constructor () {
    this.opts = this.retrieveConf()

    log(`MACHINE_NAME=${this.opts.MACHINE_NAME}`)
    log(`PUBLIC_KEY=${this.opts.PUBLIC_KEY}`)
    log(`WEBSOCKET_ENABLED=${process.env.AGENT_TRANSPORT_WEBSOCKET}`)
    log(`AXON_ENABLED=${process.env.AGENT_TRANSPORT_AXON}`)
    log(`ROOT_URL=${cst.KEYMETRICS_ROOT_URL}`)

    this.DAEMON_ACTIVE = false
    this.transport = new TransporterInterface(this.opts, this)
      .bind('axon')
      .bind('websocket')
    this.transport.on('error', (err) => {
      return console.error('[NETWORK] Error : ' + err.message || err)
    })
    this.httpClient = new Utility.HTTPClient()
    this._online = true

    this._internalDebugger()
  }

  /**
   * Use process.send() if connected
   * @param {Object} data
   */
  sendToParent (data) {
    if (!process.connected || !process.send) return console.log('Could not send data to parent')

    try {
      process.send(data)
    } catch (e) {
      console.trace('Parent process disconnected')
    }
  }

  /**
   * Get an interface for communicating with PM2 daemon
   * @private
   * @return {PM2Client}
   */
  getPM2Client () {
    if (!this._ipm2) {
      this._ipm2 = new PM2Client()
    }
    return this._ipm2
  }

  /**
   * Terminate aconnections and exit
   * @param {cb} callback called at the end
   */
  exit (err, cb) {
    log('Exiting Interactor')
    // clear workers
    if (this._workerEndpoint) clearInterval(this._workerEndpoint)

    // stop interactors
    if (this.reverse) this.reverse.stop()
    if (this.push) this.push.stop()

    if (this._ipm2) this._ipm2.disconnect()
    if (this.watchDog) this.watchDog.stop()
    // stop transport
    if (this.transport) this.transport.disconnect()

    if (!err) {
      try {
        fs.unlinkSync(cst.INTERACTOR_RPC_PORT)
        fs.unlinkSync(cst.INTERACTOR_PID_PATH)
      } catch (err) {}
    }

    if (!this._rpc || !this._rpc.sock) {
      return process.exit(cst.ERROR_EXIT)
    }

    if (typeof cb === 'function') {
      cb()
    }

    setTimeout(() => {
      this._rpc.sock.close(() => {
        log('RPC server closed')
        process.exit(err ? cst.ERROR_EXIT : cst.SUCCESS_EXIT)
      })
    }, 10)
  }

  /**
   * Start a RPC server and expose it throught a socket file
   */
  startRPC (opts) {
    log('Launching Interactor RPC server (bind to %s)', cst.INTERACTOR_RPC_PORT)
    const rep = axon.socket('rep')
    const rpcServer = new rpc.Server(rep)
    const self = this
    rep.bind(cst.INTERACTOR_RPC_PORT)

    rpcServer.expose({
      kill: function (cb) {
        log('Shutdown request received via RPC')
        return self.exit(null, cb)
      },
      getInfos: function (cb) {
        if (self.opts && self.DAEMON_ACTIVE === true) {
          return cb(null, {
            machine_name: self.opts.MACHINE_NAME,
            public_key: self.opts.PUBLIC_KEY,
            secret_key: self.opts.SECRET_KEY,
            remote_host: self.km_data.endpoints.web,
            connected: self.transport.isConnected(),
            transporters: self.transport.getActiveTransporters(),
            socket_path: cst.INTERACTOR_RPC_PORT,
            pm2_home_monitored: cst.PM2_HOME
          })
        } else {
          return cb(null)
        }
      }
    })
    return rpcServer
  }

  /**
   * Handle specific signals to launch memory / cpu profiling
   * if available in node
   */
  _internalDebugger () {
    // inspector isn't available under node 8
    if (semver.satisfies(process.version, '<8')) return

    const inspector = require('inspector')
    const state = {
      heap: false,
      cpu: false,
      session: null
    }
    const commands = {
      heap: {
        start: 'HeapProfiler.startSampling',
        stop: 'HeapProfiler.stopSampling'
      },
      cpu: {
        start: 'Profiler.start',
        stop: 'Profiler.stop'
      }
    }

    const handleSignal = type => {
      return _ => {
        if (state.session === null) {
          state.session = new inspector.Session()
          state.session.connect()
        }

        const isAlreadyEnabled = state[type]
        const debuggerCommands = commands[type]
        const profilerDomain = type === 'cpu' ? 'Profiler' : 'HeapProfiler'
        const fileExt = type === 'heap' ? '.heapprofile' : '.cpuprofile'

        if (isAlreadyEnabled) {
          // stopping the profiling and writting it to disk if its running
          console.log(`[DEBUG] Stopping ${type.toUpperCase()} Profiling`)
          state.session.post(debuggerCommands.stop, (err, data) => {
            const profile = data.profile
            if (err) return console.error(err)
            const randomId = Math.random().toString(36)
            const profilePath = path.resolve(os.tmpdir(), `${type}-${randomId}${fileExt}`)

            fs.writeFileSync(profilePath, JSON.stringify(profile))
            console.log(`[DEBUG] Writing file in ${profilePath}`)
            state[type] = false
            state.session.post(`${profilerDomain}.disable`)
          })
        } else {
          // start the profiling otherwise
          console.log(`[DEBUG] Starting ${type.toUpperCase()} Profiling`)
          state.session.post(`${profilerDomain}.enable`, _ => {
            state.session.post(debuggerCommands.start)
            state[type] = true
          })
        }
      }
    }

    // use hook
    process.on('SIGUSR1', handleSignal('cpu'))
    process.on('SIGUSR2', handleSignal('heap'))
  }

  /**
   * Retrieve metadata about the system
   */
  getSystemMetadata () {
    return {
      MACHINE_NAME: this.opts.MACHINE_NAME,
      PUBLIC_KEY: this.opts.PUBLIC_KEY,
      RECYCLE: this.opts.RECYCLE || false,
      PM2_VERSION: process.env.PM2_VERSION,
      MEMORY: os.totalmem() / 1000 / 1000,
      HOSTNAME: os.hostname(),
      CPUS: os.cpus()
    }
  }

  /**
   * Ping root url to retrieve node info
   * @private
   * @param {Function} cb invoked with <Error, Object> where Object is the response sended by the server
   */
  _pingRoot (cb) {
    const data = this.getSystemMetadata()

    this.httpClient.open({
      url: this.opts.ROOT_URL + '/api/node/verifyPM2',
      method: 'POST',
      data: {
        public_id: this.opts.PUBLIC_KEY,
        private_id: this.opts.SECRET_KEY,
        data: data
      },
      headers: {
        'User-Agent': `PM2 Agent v${pkg.version}`
      }
    }, cb)
  }

  /**
   * Ping root to verify retrieve and connect to the km endpoint
   * @private
   * @param {Function} cb invoked with <Error, Boolean>
   */
  _verifyEndpoint (cb) {
    if (typeof cb !== 'function') cb = function () {}

    this._pingRoot((err, data) => {
      if (err) {
        log('Got an a error on ping root', err)
        return cb(err)
      }

      this.km_data = data

      // Verify data integrity
      if (data.disabled === true || data.pending === true) {
        log('Interactor is disabled by admins')
        return cb(new Error('Connection refused, you might have hit the limit of agents you can connect (send email at contact@keymetrics.io for more infos)'))
      }
      if (data.active === false) {
        log('Interactor not active: %s', data.msg || 'no message')
        return cb(null, data)
      }
      if (!data.endpoints) {
        return cb(new Error(`Endpoints field not present (${JSON.stringify(data)})`))
      }

      this.DAEMON_ACTIVE = true
      this.transport.connect(data.endpoints, cb)
    })
  }

  /**
   * Retrieve configuration from environnement
   */
  retrieveConf () {
    let opts = {}

    opts.MACHINE_NAME = process.env.PM2_MACHINE_NAME
    opts.PUBLIC_KEY = process.env.PM2_PUBLIC_KEY
    opts.SECRET_KEY = process.env.PM2_SECRET_KEY
    opts.RECYCLE = process.env.KM_RECYCLE ? JSON.parse(process.env.KM_RECYCLE) : false
    opts.PM2_VERSION = process.env.PM2_VERSION || '0.0.0'
    opts.AGENT_TRANSPORT_AXON = process.env.AGENT_TRANSPORT_AXON
    opts.AGENT_TRANSPORT_WEBSOCKET = process.env.AGENT_TRANSPORT_WEBSOCKET
    opts.internal_ip = Utility.network.v4

    opts.PM2_REMOTE_METHOD_ALLOWED = [
      'restart',
      'reload',
      'reset',
      'scale',
      'startLogging',
      'stopLogging',
      'ping'
    ]

    if (!opts.MACHINE_NAME) {
      console.error('You must provide a PM2_MACHINE_NAME environment variable')
      process.exit(cst.ERROR_EXIT)
    } else if (!opts.PUBLIC_KEY) {
      console.error('You must provide a PM2_PUBLIC_KEY environment variable')
      process.exit(cst.ERROR_EXIT)
    } else if (!opts.SECRET_KEY) {
      console.error('You must provide a PM2_SECRET_KEY environment variable')
      process.exit(cst.ERROR_EXIT)
    }
    return opts
  }

  /**
   * Ping root url to retrieve node info
   * @private
   * @param {Function} cb invoked with <Error> [optional]
   */
  start (cb) {
    let retries = 0
    this._rpc = this.startRPC()
    this.opts.ROOT_URL = cst.KEYMETRICS_ROOT_URL

    const verifyEndpointCallback = (err, result) => {
      if (err) {
        log('Error while trying to retrieve endpoints : ' + (err.message || err))
        if (retries++ < 30 && process.env.NODE_ENV !== 'test') {
          log('Retrying to retrieve endpoints...')
          return setTimeout(_ => {
            return this._verifyEndpoint(verifyEndpointCallback)
          }, 200 * retries)
        }
        this.sendToParent({ error: true, msg: err.message || err })
        return this.exit(new Error('Error retrieving endpoints'))
      }
      if (result === false) {
        log('False returned while trying to retrieve endpoints')
        return this.exit(new Error('Error retrieving endpoints'))
      }

      // send data over IPC for CLI feedback
      this.sendToParent({
        error: false,
        km_data: this.km_data,
        online: true,
        pid: process.pid,
        machine_name: this.opts.MACHINE_NAME,
        public_key: this.opts.PUBLIC_KEY,
        secret_key: this.opts.SECRET_KEY,
        reverse_interaction: this.opts.REVERSE_INTERACT
      })

      if (result && typeof result === 'object' &&
          result.error === true && result.active === false) {
        log(`Error when connecting: ${result.msg}`)
        return this.exit(new Error(`Error when connecting: ${result.msg}`))
      }

      // start workers
      this._workerEndpoint = setInterval(this._verifyEndpoint.bind(this, (err, result) => {
        if (err) return
        // We need to exit agent if bucket is disabled (trialing)
        if (result && typeof result === 'object' && result.error === true && result.active === false) {
          log(`Error when connecting: ${result.msg}, disconnecting transporters`)
          return this.transport.disconnect()
        }
      }), 60000)
      // start interactors
      this.watchDog = WatchDog

      setTimeout(() => {
        log('PM2 Watchdog started')
        this.watchDog.start({
          conf: {
            ipm2: this.getPM2Client()
          }
        })
      }, 1000 * 60 * 3)

      this.push = new PushInteractor(this.opts, this.getPM2Client(), this.transport)
      this.reverse = new ReverseInteractor(this.opts, this.getPM2Client(), this.transport)
      this.push.start()
      this.reverse.start()
      log('Interactor daemon started')
      if (cb) {
        setTimeout(cb, 20)
      }
    }
    return this._verifyEndpoint(verifyEndpointCallback)
  }
}

// If its the entry file launch the daemon
// otherwise we just required it to use a function
if (require.main === module) {
  const d = domain.create()
  let daemon = null

  d.on('error', function (err) {
    console.error('-- FATAL EXCEPTION happened --')
    console.error(new Date())
    console.error(err.stack)
    console.log('Re-initiating Agent')

    InteractorClient.getOrSetConf(cst, null, (err, infos) => {
      if (err || !infos) {
        if (err) {
          console.error('[PM2 Agent] Failed to rescue agent :')
          console.error(err || new Error(`Cannot find configuration to connect to backend`))
          return process.exit(1)
        }
      }
      console.log(`[PM2 Agent] Using (Public key: ${infos.public_key}) (Private key: ${infos.secret_key}) (Info node: ${infos.info_node})`)

      // Exit anyway the errored agent
      var timeout = setTimeout(_ => {
        console.error('Daemonization of failsafe agent did not worked')
        daemon.exit(err)
      }, 2000)

      InteractorClient.daemonize(cst, infos, (err) => {
        if (err) {
          log('[PM2 Agent] Failed to rescue agent :')
          log(err)
        } else {
          log(`Succesfully launched new agent`)
        }
        clearTimeout(timeout)
        daemon.exit(err)
      })
    })
  })

  d.run(_ => {
    daemon = new InteractorDaemon()

    process.title = `PM2 Agent v${pkg.version}: (${cst.PM2_HOME})`

    log('[PM2 Agent] Launching agent')
    daemon.start()
  })
}
