const { delimiter, resolve } = require('path')
const { promisify } = require('util')
const read = promisify(require('read'))
const stat = promisify(require('fs').stat)

const Arborist = require('@npmcli/arborist')
const ciDetect = require('@npmcli/ci-detect')
const logger = require('proc-log')
const mkdirp = require('mkdirp-infer-owner')
const npa = require('npm-package-arg')
const pacote = require('pacote')
const readPackageJson = require('read-package-json-fast')

const cacheInstallDir = require('./cache-install-dir.js')
const getBinFromManifest = require('./get-bin-from-manifest.js')
const manifestMissing = require('./manifest-missing.js')
const noTTY = require('./no-tty.js')
const runScript = require('./run-script.js')

const fileExists = (file) => stat(file)
  .then((stat) => stat.isFile())
  .catch(() => false)

/* istanbul ignore next */
const PATH = (
  process.env.PATH || process.env.Path || process.env.path
).split(delimiter)

const exec = async (opts) => {
  const {
    args = [],
    call = '',
    color = false,
    localBin = '',
    locationMsg = undefined,
    globalBin = '',
    output,
    packages: _packages = [],
    path = '.',
    runPath = '.',
    scriptShell = undefined,
    yes = undefined,
    ...flatOptions
  } = opts
  const log = flatOptions.log || logger

  // dereferences values because we manipulate it later
  const packages = [..._packages]
  const pathArr = [...PATH]
  const _run = () => runScript({
    args,
    call,
    color,
    flatOptions,
    locationMsg,
    log,
    output,
    path,
    pathArr,
    runPath,
    scriptShell,
  })

  // nothing to maybe install, skip the arborist dance
  if (!call && !args.length && !packages.length)
    return await _run()

  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(`${localBin}/${args[0]}`)) {
      pathArr.unshift(localBin)
      binExists = true
    } else if (await fileExists(`${globalBin}/${args[0]}`)) {
      pathArr.unshift(globalBin)
      binExists = true
    }

    if (binExists)
      return await _run()

    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, 'package.json')
        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, {
      ...flatOptions,
      preferOnline: true,
    })
  }))

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

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

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

  if (needInstall) {
    const { cache } = flatOptions
    const installDir = cacheInstallDir({ cache, packages })
    await mkdirp(installDir)
    const arb = new Arborist({
      ...flatOptions,
      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 => manifestMissing({
      tree,
      manifest: {
        ...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 (!yes) {
        // set -n to always say no
        if (yes === false)
          throw new Error('canceled')

        if (noTTY() || ciDetect()) {
          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({
        ...flatOptions,
        add,
      })
    }
    pathArr.unshift(resolve(installDir, 'node_modules/.bin'))
  }

  return await _run()
}

module.exports = exec
