const { promisify } = require('util')
const read = promisify(require('read'))
const chalk = require('chalk')
const mkdirp = require('mkdirp-infer-owner')
const readPackageJson = require('read-package-json-fast')
const Arborist = require('@npmcli/arborist')
const runScript = require('@npmcli/run-script')
const { resolve, delimiter } = require('path')
const ciDetect = require('@npmcli/ci-detect')
const crypto = require('crypto')
const pacote = require('pacote')
const npa = require('npm-package-arg')
const fileExists = require('./utils/file-exists.js')
const PATH = require('./utils/path.js')
const BaseCommand = require('./base-command.js')
const getWorkspaces = require('./workspaces/get-workspaces.js')

// it's like this:
//
// npm x pkg@version <-- runs the bin named "pkg" or the only bin if only 1
//
// { name: 'pkg', bin: { pkg: 'pkg.js', foo: 'foo.js' }} <-- run pkg
// { name: 'pkg', bin: { foo: 'foo.js' }} <-- run foo?
//
// npm x -p pkg@version -- foo
//
// npm x -p pkg@version -- foo --registry=/dev/null
//
// const pkg = npm.config.get('package') || getPackageFrom(args[0])
// const cmd = getCommand(pkg, args[0])
// --> npm x -c 'cmd ...args.slice(1)'
//
// we've resolved cmd and args, and escaped them properly, and installed the
// relevant packages.
//
// Add the ${npx install prefix}/node_modules/.bin to PATH
//
// pkg = readPackageJson('./package.json')
// pkg.scripts.___npx = ${the -c arg}
// runScript({ pkg, event: 'npx', ... })
// process.env.npm_lifecycle_event = 'npx'

const nocolor = {
  reset: s => s,
  bold: s => s,
  dim: s => s,
  green: s => s,
}

class Exec extends BaseCommand {
  /* istanbul ignore next - see test/lib/load-all-commands.js */
  static get description () {
    return 'Run a command from a local or remote npm package'
  }

  /* istanbul ignore next - see test/lib/load-all-commands.js */
  static get params () {
    return ['workspace', 'workspaces']
  }

  /* istanbul ignore next - see test/lib/load-all-commands.js */
  static get name () {
    return 'exec'
  }

  /* istanbul ignore next - see test/lib/load-all-commands.js */
  static get usage () {
    return [
      '-- <pkg>[@<version>] [args...]',
      '--package=<pkg>[@<version>] -- <cmd> [args...]',
      '-c \'<cmd> [args...]\'',
      '--package=foo -c \'<cmd> [args...]\'',
    ]
  }

  exec (args, cb) {
    const path = this.npm.localPrefix
    const runPath = process.cwd()
    this._exec(args, { path, runPath }).then(() => cb()).catch(cb)
  }

  execWorkspaces (args, filters, cb) {
    this._execWorkspaces(args, filters).then(() => cb()).catch(cb)
  }

  // When commands go async and we can dump the boilerplate exec methods this
  // can be named correctly
  async _exec (_args, { locationMsg, path, runPath }) {
    const call = this.npm.config.get('call')
    const shell = this.npm.config.get('shell')
    // dereferenced because we manipulate it later
    const packages = [...this.npm.config.get('package')]

    if (call && _args.length)
      throw this.usage

    const args = [..._args]
    const pathArr = [...PATH]

    // nothing to maybe install, skip the arborist dance
    if (!call && !args.length && !packages.length) {
      return await this.run({
        args,
        call,
        locationMsg,
        shell,
        path,
        pathArr,
        runPath,
      })
    }

    const needPackageCommandSwap = args.length && !packages.length
    // if there's an argument and no package has been explicitly asked for
    // check the local and global bin paths for a binary named the same as
    // the argument and run it if it exists, otherwise fall through to
    // the behavior of treating the single argument as a package name
    if (needPackageCommandSwap) {
      let binExists = false
      if (await fileExists(`${this.npm.localBin}/${args[0]}`)) {
        pathArr.unshift(this.npm.localBin)
        binExists = true
      } else if (await fileExists(`${this.npm.globalBin}/${args[0]}`)) {
        pathArr.unshift(this.npm.globalBin)
        binExists = true
      }

      if (binExists) {
        return await this.run({
          args,
          call,
          locationMsg,
          path,
          pathArr,
          runPath,
          shell,
        })
      }

      packages.push(args[0])
    }

    // If we do `npm exec foo`, and have a `foo` locally, then we'll
    // always use that, so we don't really need to fetch the manifest.
    // So: run npa on each packages entry, and if it is a name with a
    // rawSpec==='', then try to readPackageJson at
    // node_modules/${name}/package.json, and only pacote fetch if
    // that fails.
    const manis = await Promise.all(packages.map(async p => {
      const spec = npa(p, path)
      if (spec.type === 'tag' && spec.rawSpec === '') {
        // fall through to the pacote.manifest() approach
        try {
          const pj = resolve(path, 'node_modules', spec.name)
          return await readPackageJson(pj)
        } catch (er) {}
      }
      // Force preferOnline to true so we are making sure to pull in the latest
      // This is especially useful if the user didn't give us a version, and
      // they expect to be running @latest
      return await pacote.manifest(p, {
        ...this.npm.flatOptions,
        preferOnline: true,
      })
    }))

    if (needPackageCommandSwap)
      args[0] = this.getBinFromManifest(manis[0])

    // figure out whether we need to install stuff, or if local is fine
    const localArb = new Arborist({
      ...this.npm.flatOptions,
      path,
    })
    const tree = await localArb.loadActual()

    // do we have all the packages in manifest list?
    const needInstall = manis.some(mani => this.manifestMissing(tree, mani))

    if (needInstall) {
      const installDir = this.cacheInstallDir(packages)
      await mkdirp(installDir)
      const arb = new Arborist({
        ...this.npm.flatOptions,
        log: this.npm.log,
        path: installDir,
      })
      const tree = await arb.loadActual()

      // at this point, we have to ensure that we get the exact same
      // version, because it's something that has only ever been installed
      // by npm exec in the cache install directory
      const add = manis.filter(mani => this.manifestMissing(tree, {
        ...mani,
        _from: `${mani.name}@${mani.version}`,
      }))
        .map(mani => mani._from)
        .sort((a, b) => a.localeCompare(b))

      // no need to install if already present
      if (add.length) {
        if (!this.npm.config.get('yes')) {
          // set -n to always say no
          if (this.npm.config.get('yes') === false)
            throw new Error('canceled')

          if (!process.stdin.isTTY || ciDetect()) {
            this.npm.log.warn('exec', `The following package${
            add.length === 1 ? ' was' : 's were'
          } not found and will be installed: ${
            add.map((pkg) => pkg.replace(/@$/, '')).join(', ')
          }`)
          } else {
            const addList = add.map(a => `  ${a.replace(/@$/, '')}`)
              .join('\n') + '\n'
            const prompt = `Need to install the following packages:\n${
            addList
          }Ok to proceed? `
            const confirm = await read({ prompt, default: 'y' })
            if (confirm.trim().toLowerCase().charAt(0) !== 'y')
              throw new Error('canceled')
          }
        }
        await arb.reify({
          ...this.npm.flatOptions,
          log: this.npm.log,
          add,
        })
      }
      pathArr.unshift(resolve(installDir, 'node_modules/.bin'))
    }

    return await this.run({
      args,
      call,
      locationMsg,
      path,
      pathArr,
      runPath,
      shell,
    })
  }

  async run ({ args, call, locationMsg, path, pathArr, runPath, shell }) {
    // turn list of args into command string
    const script = call || args.shift() || shell

    // do the fakey runScript dance
    // still should work if no package.json in cwd
    const realPkg = await readPackageJson(`${path}/package.json`)
      .catch(() => ({}))
    const pkg = {
      ...realPkg,
      scripts: {
        ...(realPkg.scripts || {}),
        npx: script,
      },
    }

    this.npm.log.disableProgress()
    try {
      if (script === shell) {
        if (process.stdin.isTTY) {
          if (ciDetect())
            return this.npm.log.warn('exec', 'Interactive mode disabled in CI environment')

          const color = this.npm.config.get('color')
          const colorize = color ? chalk : nocolor

          locationMsg = locationMsg || ` at location:\n${colorize.dim(runPath)}`

          this.npm.output(`${
            colorize.reset('\nEntering npm script environment')
          }${
            colorize.reset(locationMsg)
          }${
            colorize.bold('\nType \'exit\' or ^D when finished\n')
          }`)
        }
      }
      return await runScript({
        ...this.npm.flatOptions,
        pkg,
        banner: false,
        // we always run in cwd, not --prefix
        path: runPath,
        stdioString: true,
        event: 'npx',
        args,
        env: {
          PATH: pathArr.join(delimiter),
        },
        stdio: 'inherit',
      })
    } finally {
      this.npm.log.enableProgress()
    }
  }

  manifestMissing (tree, mani) {
    // if the tree doesn't have a child by that name/version, return true
    // true means we need to install it
    const child = tree.children.get(mani.name)
    // if no child, we have to load it
    if (!child)
      return true

    // if no version/tag specified, allow whatever's there
    if (mani._from === `${mani.name}@`)
      return false

    // otherwise the version has to match what we WOULD get
    return child.version !== mani.version
  }

  getBinFromManifest (mani) {
    // if we have a bin matching (unscoped portion of) packagename, use that
    // otherwise if there's 1 bin or all bin value is the same (alias), use
    // that, otherwise fail
    const bin = mani.bin || {}
    if (new Set(Object.values(bin)).size === 1)
      return Object.keys(bin)[0]

    // XXX probably a util to parse this better?
    const name = mani.name.replace(/^@[^/]+\//, '')
    if (bin[name])
      return name

    // XXX need better error message
    throw Object.assign(new Error('could not determine executable to run'), {
      pkgid: mani._id,
    })
  }

  cacheInstallDir (packages) {
    // only packages not found in ${prefix}/node_modules
    return resolve(this.npm.config.get('cache'), '_npx', this.getHash(packages))
  }

  getHash (packages) {
    return crypto.createHash('sha512')
      .update(packages.sort((a, b) => a.localeCompare(b)).join('\n'))
      .digest('hex')
      .slice(0, 16)
  }

  async _execWorkspaces (args, filters) {
    const workspaces =
      await getWorkspaces(filters, { path: this.npm.localPrefix })
    const getLocationMsg = async path => {
      const color = this.npm.config.get('color')
      const colorize = color ? chalk : nocolor
      const { _id } = await readPackageJson(`${path}/package.json`)
      return ` in workspace ${colorize.green(_id)} at location:\n${colorize.dim(path)}`
    }

    for (const workspacePath of workspaces.values()) {
      const locationMsg = await getLocationMsg(workspacePath)
      await this._exec(args, {
        locationMsg,
        path: workspacePath,
        runPath: workspacePath,
      })
    }
  }
}
module.exports = Exec
