import { EventEmitter } from 'events'
import fs from 'fs/promises'
import { request } from './client.mjs'
import HttpServer from './http.mjs'
import { defaults } from './defaults.mjs'

export default class Application extends EventEmitter {
  constructor(ctx, provider, name, opts = {}) {
    super()
    this.ctx = {
      db: ctx.db,
      util: ctx.util,
      log: ctx.log,
      core: ctx.core,
      app: this,
    }
    this.config = defaults({}, this.ctx.db.config[name])
    this.provider = provider
    this.name = name
    this.updating = false
    this.http = new HttpServer(this.config)
    this.module = null
    this.running = false

    // Fresh is used to indicate that when we run the application and it fails,
    // whether the environment we are in was fresh or not. An example would be
    // if we had previously run an older version. In that case, that older version
    // might have dirtied the runtime or left a port open or other stuff.
    // In which case, running the new version might fail even though it should
    // normally be fine. As such we have this flag here. to indicate we might
    // need a full restart before making another attempt.
    this.fresh = true

    // Apply defaults to config
    this.config.updateEvery = this.config.updateEvery != null ? this.config.updateEvery : 180
    this.config.startWaitUntilFail = this.config.startWaitUntilFail || (60 * 1000)
    this.config.heartbeatTimeout = this.config.heartbeatTimeout || (3 * 1000)
    this.config.heartbeatAttempts = this.config.heartbeatAttempts || 5
    this.config.heartbeatAttemptsWait = this.config.heartbeatAttemptsWait || (2 * 1000)
    this.config.heartbeatPath = this.config.heartbeatPath || '/'

    Object.assign(this, {
      setInterval: opts.setInterval || setInterval,
      fs: opts.fs || fs,
    })

    this.ctx.db.addApplication(name)
  }

  startAutoupdater() {
    if (this.provider.static) return
    if (this.config.updateEvery === 0) return

    let timer = this.setInterval(() => {
      this.update().then(
        () => {
          this.ctx.db.data.core[this.name].updater += 'Automatic update finished successfully. '
        },
        (err) => {
          this.ctx.db.data.core[this.name].updater += 'Error while running automatic update: ' + err.message + '. '
        }
      )
    }, this.config.updateEvery * 60 * 1000)
    timer.unref()
  }

  updateLog(message) {
    this.ctx.db.data.core[this.name].updater += message
    this.ctx.log.info(message)
    return message
  }

  msgStatic = 'Provider in question is static and so no update required, nothing to do.'

  update() {
    if (this.provider.static) {
      if (this.ctx.db.data.core[this.name].updater !== this.msgStatic) {
        this.ctx.db.data.core[this.name].updater = ''
        this.updateLog(this.msgStatic)
        return this.ctx.db.write().then(function() { return null })
      }
      return Promise.resolve(null)
    }

    if (this.updating) return null
    this.updating = true

    return this._update()
      .then((result) => {
        this.updating = false
        return this.ctx.db.write()
          .then(function() { return result })
      })
      .then((result) => {
        if (result) {
          this.emit('updated', result)
        }
        return result
      })
      .catch((err) => {
        this.updating = false
        return this.ctx.db.write()
          .then(function() { return Promise.reject(err) })
      })
  }

  logAddSeperator(log) {
    if (!log.endsWith('\n')) {
      log += '\n'
    }
    if (!log.endsWith('\n\n')) {
      log += '\n'
    }
    return log
  }

  async _update() {
    this.ctx.db.data.core[this.name].updater = ''
    let cleanup = true
    let folder = ''
    let log = ''
    let latest = null

    try {
      log += this.updateLog(`Checking for latest version at ${new Date().toISOString().replace('T', ' ').split('.')[0]}. `) + '\n'

      // Get the latest version from our provider
      latest = await this.provider.getLatestVersion()

      log += this.updateLog(`Found ${latest.version}. `) + '\n'

      // If the versino matches the latest installed, then there's nothing to do
      if (this.ctx.db.data.core[this.name].latestInstalled === latest.version) {
        this.updateLog('Already up to date, nothing to do. ')
        return null
      }
      
      // Make the id for the vesion the version number. Allows for easy lookup
      // among other nice and simple structure.
      latest.id = latest.version

      // check to see if we already have this version in our database.
      var found = this.ctx.db.get(this.ctx.db.data.core[this.name].versions, latest.id)
      if (found) {
        // Check if the existing version found was already installed.
        if (found.installed) {
          this.updateLog('Version was already installed, nothing to do. ')
          return null
        }

        // We found existing on, update the keys of the one in the databse with
        // the latest data we got from getLatestVersion(). This ensures that info
        // like link, filename and such get updated if these have changed since.
        Object.keys(latest).forEach(function(key) {
          found[key] = latest[key]
        })
        latest = found
        
        // Check to see if the existing one has failed too many times and
        // if so, we should skip them and consider those versions as black
        // listed and avoid at all cost.
        if (latest.failtodownload && latest.failtodownload > 3) {
          this.updateLog('Version failed to download too many times, skipping this version. ')
          return null
        }

        if (latest.failtoinstall && latest.failtoinstall > 3) {
          this.updateLog('Version failed to install too many times, skipping this version. ')
          return null
        }

        // Combine the logs
        log = latest.log + log
      } else {
        // This is a new version, mark it with stable tag of zero.
        latest.stable = 0
        this.ctx.db.upsertFirst(this.ctx.db.data.core[this.name].versions, latest)
      }

      // The target file for the archive and the target folder for new our version
      let target = this.ctx.util.getPathFromRoot(`./${this.name}/${latest.version}/file${this.ctx.util.getExtension(latest.filename)}`)
      folder = this.ctx.util.getPathFromRoot(`./${this.name}/${latest.version}`)

      // Create it in case it does not exist.
      await this.fs.mkdir(folder, { recursive: true })

      log += this.updateLog(`Downloading ${latest.link} to ${target}. `) + '\n'
      await this.ctx.db.write()

      // Download the latest version using the provider in question.
      await this.provider.downloadVersion(latest, target)
        .catch(function(err) {
          latest.failtodownload = (latest.failtodownload || 0) + 1
          return Promise.reject(err)
        })

      log += '\n' + this.updateLog(`Extracting ${target}. `) + '\n'
      await this.ctx.db.write()

      // Download was successful, extract the archived file that we downloaded
      await this.ctx.util.extractFile(target, function(msg) {
        log += msg
      }).catch(function(err) {
        latest.failtodownload = (latest.failtodownload || 0) + 1
        return Promise.reject(err)
      })

      // Remove the archived file since we're done using it.
      await this.fs.rm(target, { force: true }).catch(function() {})

      // The extracting process might not leave enough newlines for our
      // desired clean output for our logs so add them.
      log = this.logAddSeperator(log)

      // check if the version we downloaded had index.mjs. If this is
      // missing then either the extracting or download failed without erroring
      // or the archived is borked.
      await this.fs.stat(this.ctx.util.getPathFromRoot(`./${this.name}/${latest.version}/index.mjs`))
        .catch((err) => {
          latest.failtodownload = (latest.failtodownload || 0) + 1
          log += this.updateLog('Version did not include or was missing index.mjs. ') + '\n'
          return Promise.reject(err)
        })

      // If we reach here, then we don't wanna cleanup or remove existing files
      // in case more errors occured. The download was success and preliminary
      // checks indicate the version is valid. As such, we are gonna skip
      // clearing the folder even if something occurs later on.
      cleanup = false

      // Check if we have a package.json file. If we do, we need to run
      // npm install. If we don't then this application either has all the
      // required packages or it doesn't need them to run
      let packageStat = await this.fs.stat(this.ctx.util.getPathFromRoot(`./${this.name}/${latest.version}/package.json`))
        .catch(function() { return null })

      if (packageStat) {
        log += this.updateLog(`running npm install --production. `) + '\n'
        await this.ctx.db.write()

        // For some weird reason, --loglevel=notice is required otherwise
        // we get practically zero log output.
        await this.ctx.util.runCommand(
          'npm.cmd',
          ['install', '--production', '--no-optional', '--no-package-lock', '--no-audit', '--loglevel=notice'],
          folder,
          function(msg) { log += msg }
        ).catch(function(err) {
          latest.failtoinstall = (latest.failtoinstall || 0) + 1
          return Promise.reject(err)
        })
    
        log = this.logAddSeperator(log)
      } else {
        log += this.updateLog('Release did not contain package.json, skipping npm install. ') + '\n'
      }
    } catch (err) {
      log += this.updateLog(`Error: ${err.message}. `) + '\n'

      // Check if we have a folder and we need to do some cleanups. We do
      // this if the download process failed so we can have a fresh clean
      // tree for the next time update is run
      if (folder && cleanup) {
        await this.fs.rm(folder, { force: true, recursive: true }).catch((err) => {
          this.updateLog(`Error while cleaning up: ${err.message}. `)
        })
      }
      if (latest) {
        latest.log = log
      }
      return Promise.reject(err)
    }

    // If we reached here then everything went swimmingly. Mark the version
    // as being installed and attach the install log to it.
    log += this.updateLog(`Finished updating ${this.name} to version ${latest.version}.`) + '\n'
    this.ctx.db.data.core[this.name].latestInstalled = latest.version
    latest.installed = true
    latest.log = log
    return latest
  }

  registerModule(module, version = '') {
    if (module && typeof(module) === 'function') {
      return this.registerModule({ start: module })
    }
    if (!module || typeof(module) !== 'object' || typeof(module.start) !== 'function') {
      throw new Error(`Application ${this.name}${version ? ' version ' + version : '' } registerModule was called with a non module missing start function`)
    }

    this.module = module
  }

  async runVersion(version) {
    this.ctx.db.data.core[this.name].active = version
    await this.ctx.db.write()

    if (version !== 'static') {
      let indexPath = this.ctx.util.getPathFromRoot(`./${this.name}/${version}/index.mjs`)
      await this.fs.stat(indexPath).catch((err) => {
        return Promise.reject(new Error(`Version was missing index.mjs: ${err.message}`))
      })

      this.fresh = false
      let module = await import(this.ctx.util.getUrlFromRoot(`./${this.name}/${version}/index.mjs`))
      this.registerModule(module, version)
    } else {
      this.fresh = false
    }

    let errTimeout = new Error(`Version timed out (took over ${this.config.startWaitUntilFail}ms) while running start()`)

    await new Promise((res, rej) => {
      setTimeout(() => {
        rej(errTimeout)
      }, this.config.startWaitUntilFail)

      let startRes = this.module.start(this.http, this.config.port, this.ctx)
      if (startRes && startRes.then) {
        return startRes.then(res, rej)
      }
      res()
    })

    if (!this.http.active) {
      return Promise.reject(new Error(`Version did not call http.createServer()`))
    }

    let lastErr = null

    for (let i = 0; i < this.config.heartbeatAttempts; i++) {
      try {
        await request({ timeout: this.config.heartbeatAttemptsWait }, `http://localhost:${this.config.port}` + this.config.heartbeatPath, null, 0, true)
        return
      } catch (err) {
        lastErr = err
      }
    }

    return Promise.reject(new Error(`Version failed to start properly: ${lastErr.message}`))
  }

  closeServer() {
    return this.http.closeServer()
  }
}
