const fs = require('fs')
const path = require('path')
const {
  chalk,
  execa,
  semver,

  log,
  done,
  logWithSpinner,
  stopSpinner,

  isPlugin,
  resolvePluginId,

  loadModule,
  resolveModule
} = require('@vue/cli-shared-utils')

const tryGetNewerRange = require('./util/tryGetNewerRange')
const getPkg = require('./util/getPkg')
const PackageManager = require('./util/ProjectPackageManager')

function clearRequireCache () {
  Object.keys(require.cache).forEach(key => delete require.cache[key])
}
module.exports = class Upgrader {
  constructor (context = process.cwd()) {
    this.context = context
    this.pkg = getPkg(this.context)
    this.pm = new PackageManager({ context })
  }

  async upgradeAll (includeNext) {
    // TODO: should confirm for major version upgrades
    // for patch & minor versions, upgrade directly
    // for major versions, prompt before upgrading
    const upgradable = await this.getUpgradable(includeNext)

    if (!upgradable.length) {
      done('Seems all plugins are up to date. Good work!')
      return
    }

    for (const p of upgradable) {
      // reread to avoid accidentally writing outdated package.json back
      this.pkg = getPkg(this.context)
      await this.upgrade(p.name, { to: p.latest })
    }

    done('All plugins are up to date!')
  }

  async upgrade (pluginId, options) {
    const packageName = resolvePluginId(pluginId)

    let depEntry, required
    for (const depType of ['dependencies', 'devDependencies', 'optionalDependencies']) {
      if (this.pkg[depType] && this.pkg[depType][packageName]) {
        depEntry = depType
        required = this.pkg[depType][packageName]
        break
      }
    }
    if (!required) {
      throw new Error(`Can't find ${chalk.yellow(packageName)} in ${chalk.yellow('package.json')}`)
    }

    const installed = options.from || this.pm.getInstalledVersion(packageName)
    if (!installed) {
      throw new Error(
        `Can't find ${chalk.yellow(packageName)} in ${chalk.yellow('node_modules')}. Please install the dependencies first.\n` +
        `Or to force upgrade, you can specify your current plugin version with the ${chalk.cyan('--from')} option`
      )
    }

    let targetVersion = options.to || 'latest'
    // if the targetVersion is not an exact version
    if (!/\d+\.\d+\.\d+/.test(targetVersion)) {
      if (targetVersion === 'latest') {
        logWithSpinner(`Getting latest version of ${packageName}`)
      } else {
        logWithSpinner(`Getting max satisfying version of ${packageName}@${options.to}`)
      }

      targetVersion = await this.pm.getRemoteVersion(packageName, targetVersion)
      if (!options.to && options.next) {
        const next = await this.pm.getRemoteVersion(packageName, 'next')
        if (next) {
          targetVersion = semver.gte(targetVersion, next) ? targetVersion : next
        }
      }
      stopSpinner()
    }

    if (targetVersion === installed) {
      log(`Already installed ${packageName}@${targetVersion}`)

      const newRange = tryGetNewerRange(`~${targetVersion}`, required)
      if (newRange !== required) {
        this.pkg[depEntry][packageName] = newRange
        fs.writeFileSync(path.resolve(this.context, 'package.json'), JSON.stringify(this.pkg, null, 2))
        log(`${chalk.green('✔')}  Updated version range in ${chalk.yellow('package.json')}`)
      }
      return
    }

    log(`Upgrading ${packageName} from ${installed} to ${targetVersion}`)
    await this.pm.upgrade(`${packageName}@~${targetVersion}`)

    // The cached `pkg` field won't automatically update after running `this.pm.upgrade`.
    // Also, `npm install pkg@~version` won't replace the original `"pkg": "^version"` field.
    // So we have to manually update `this.pkg` and write to the file system in `runMigrator`
    this.pkg[depEntry][packageName] = `~${targetVersion}`

    const resolvedPluginMigrator =
      resolveModule(`${packageName}/migrator`, this.context)

    if (resolvedPluginMigrator) {
      // for unit tests, need to run migrator in the same process for mocks to work
      // TODO: fix the tests and remove this special case
      if (process.env.VUE_CLI_TEST) {
        clearRequireCache()
        await require('./migrate').runMigrator(
          this.context,
          {
            id: packageName,
            apply: loadModule(`${packageName}/migrator`, this.context),
            baseVersion: installed
          },
          this.pkg
        )
        return
      }

      const cliBin = path.resolve(__dirname, '../bin/vue.js')
      // Run migrator in a separate process to avoid all kinds of require cache issues
      await execa('node', [cliBin, 'migrate', packageName, '--from', installed], {
        cwd: this.context,
        stdio: 'inherit'
      })
    }
  }

  async getUpgradable (includeNext) {
    const upgradable = []

    // get current deps
    // filter @vue/cli-service, @vue/cli-plugin-* & vue-cli-plugin-*
    for (const depType of ['dependencies', 'devDependencies', 'optionalDependencies']) {
      for (const [name, range] of Object.entries(this.pkg[depType] || {})) {
        if (name !== '@vue/cli-service' && !isPlugin(name)) {
          continue
        }

        const installed = await this.pm.getInstalledVersion(name)
        const wanted = await this.pm.getRemoteVersion(name, range)

        if (!installed) {
          throw new Error(`At least one dependency can't be found. Please install the dependencies before trying to upgrade`)
        }

        let latest = await this.pm.getRemoteVersion(name)
        if (includeNext) {
          const next = await this.pm.getRemoteVersion(name, 'next')
          if (next) {
            latest = semver.gte(latest, next) ? latest : next
          }
        }

        if (semver.lt(installed, latest)) {
          // always list @vue/cli-service as the first one
          // as it's depended by all other plugins
          if (name === '@vue/cli-service') {
            upgradable.unshift({ name, installed, wanted, latest })
          } else {
            upgradable.push({ name, installed, wanted, latest })
          }
        }
      }
    }

    return upgradable
  }

  async checkForUpdates (includeNext) {
    logWithSpinner('Gathering package information...')
    const upgradable = await this.getUpgradable(includeNext)
    stopSpinner()

    if (!upgradable.length) {
      done('Seems all plugins are up to date. Good work!')
      return
    }

    // format the output
    // adapted from @angular/cli
    const names = upgradable.map(dep => dep.name)
    let namePad = Math.max(...names.map(x => x.length)) + 2
    if (!Number.isFinite(namePad)) {
      namePad = 30
    }
    const pads = [namePad, 16, 16, 16, 0]
    console.log(
      '  ' +
      ['Name', 'Installed', 'Wanted', 'Latest', 'Command to upgrade'].map(
        (x, i) => chalk.underline(x.padEnd(pads[i]))
      ).join('')
    )
    for (const p of upgradable) {
      const fields = [
        p.name,
        p.installed || 'N/A',
        p.wanted,
        p.latest,
        `vue upgrade ${p.name}${includeNext ? ' --next' : ''}`
      ]
      // TODO: highlight the diff part, like in `yarn outdated`
      console.log('  ' + fields.map((x, i) => x.padEnd(pads[i])).join(''))
    }

    return upgradable
  }
}
