const { delimiter, dirname, resolve } = require('path')
const { promisify } = require('util')
const read = promisify(require('read'))

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

const cacheInstallDir = require('./cache-install-dir.js')
const { fileExists, localFileExists } = require('./file-exists.js')
const getBinFromManifest = require('./get-bin-from-manifest.js')
const noTTY = require('./no-tty.js')
const runScript = require('./run-script.js')
const isWindows = require('./is-windows.js')
const _localManifest = Symbol('localManifest')

/* 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 = resolve('./node_modules/.bin'),
    locationMsg = undefined,
    globalBin = '',
    output,
    packages: _packages = [],
    path = '.',
    runPath = '.',
    scriptShell = isWindows ? process.env.ComSpec || 'cmd' : 'sh',
    yes = undefined,
    ...flatOptions
  } = opts

  // dereferences values because we manipulate it later
  const packages = [..._packages]
  const pathArr = [...PATH]
  const _run = () => runScript({
    args,
    call,
    color,
    flatOptions,
    locationMsg,
    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
    const dir = dirname(dirname(localBin))
    const localBinPath = await localFileExists(dir, args[0])
    if (localBinPath) {
      pathArr.unshift(localBinPath)
      binExists = true
    } else if (await fileExists(`${globalBin}/${args[0]}`)) {
      pathArr.unshift(globalBin)
      binExists = true
    }

    if (binExists) {
      return await _run()
    }

    packages.push(args[0])
  }

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

  const getLocalManifest = ({ tree, name }) => {
    // look up the package name in the current tree inventory,
    // if it's found then return that normalized pkg data
    const [node] = tree.inventory.query('packageName', name)

    if (node) {
      return {
        _id: node.pkgid,
        ...node.package,
        [_localManifest]: true,
      }
    }
  }

  // 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 find that node name in the tree inventory
  // 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 === '') {
      const localManifest = getLocalManifest({
        tree: localTree,
        name: spec.name,
      })
      if (localManifest) {
        return localManifest
      }
    }
    // 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])
  }

  // are all packages from the manifest list installed?
  const needInstall =
    manis.some(manifest => !manifest[_localManifest])

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

    // inspect the npx-space installed tree to check if the package is already
    // there, if that's the case also check that it's version matches the same
    // version expected by the user requested pkg returned by pacote.manifest
    const filterMissingPackagesFromInstallDir = (mani) => {
      const localManifest = getLocalManifest({ tree, name: mani.name })
      if (localManifest) {
        return localManifest.version !== mani.version
      }
      return true
    }

    // 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 => !mani[_localManifest])
      .filter(filterMissingPackagesFromInstallDir)
      .map(mani => mani._from)
      .sort((a, b) => a.localeCompare(b, 'en'))

    // 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? `
          npmlog.clearProgress()
          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
