'use strict'

const Bluebird = require('bluebird')

const audit = require('./install/audit.js')
const figgyPudding = require('figgy-pudding')
const fs = require('graceful-fs')
const Installer = require('./install.js').Installer
const lockVerify = require('lock-verify')
const log = require('npmlog')
const npa = require('libnpm/parse-arg')
const npm = require('./npm.js')
const npmConfig = require('./config/figgy-config.js')
const output = require('./utils/output.js')
const parseJson = require('json-parse-better-errors')

const readFile = Bluebird.promisify(fs.readFile)

const AuditConfig = figgyPudding({
  also: {},
  'audit-level': {},
  deepArgs: 'deep-args',
  'deep-args': {},
  dev: {},
  force: {},
  'dry-run': {},
  global: {},
  json: {},
  only: {},
  parseable: {},
  prod: {},
  production: {},
  registry: {},
  runId: {}
})

module.exports = auditCmd

const usage = require('./utils/usage')
auditCmd.usage = usage(
  'audit',
  '\nnpm audit [--json] [--production]' +
  '\nnpm audit fix ' +
  '[--force|--package-lock-only|--dry-run|--production|--only=(dev|prod)]'
)

auditCmd.completion = function (opts, cb) {
  const argv = opts.conf.argv.remain

  switch (argv[2]) {
    case 'audit':
      return cb(null, [])
    default:
      return cb(new Error(argv[2] + ' not recognized'))
  }
}

class Auditor extends Installer {
  constructor (where, dryrun, args, opts) {
    super(where, dryrun, args, opts)
    this.deepArgs = (opts && opts.deepArgs) || []
    this.runId = opts.runId || ''
    this.audit = false
  }

  loadAllDepsIntoIdealTree (cb) {
    Bluebird.fromNode(cb => super.loadAllDepsIntoIdealTree(cb)).then(() => {
      if (this.deepArgs && this.deepArgs.length) {
        this.deepArgs.forEach(arg => {
          arg.reduce((acc, child, ii) => {
            if (!acc) {
              // We might not always be able to find `target` through the given
              // path. If we can't we'll just ignore it.
              return
            }
            const spec = npa(child)
            const target = (
              acc.requires.find(n => n.package.name === spec.name) ||
              acc.requires.find(
                n => audit.scrub(n.package.name, this.runId) === spec.name
              )
            )
            if (target && ii === arg.length - 1) {
              target.loaded = false
              // This kills `hasModernMeta()` and forces a re-fetch
              target.package = {
                name: spec.name,
                version: spec.fetchSpec,
                _requested: target.package._requested
              }
              delete target.fakeChild
              let parent = target.parent
              while (parent) {
                parent.loaded = false
                parent = parent.parent
              }
              target.requiredBy.forEach(par => {
                par.loaded = false
                delete par.fakeChild
              })
            }
            return target
          }, this.idealTree)
        })
        return Bluebird.fromNode(cb => super.loadAllDepsIntoIdealTree(cb))
      }
    }).nodeify(cb)
  }

  // no top level lifecycles on audit
  runPreinstallTopLevelLifecycles (cb) { cb() }
  runPostinstallTopLevelLifecycles (cb) { cb() }
}

function maybeReadFile (name) {
  const file = `${npm.prefix}/${name}`
  return readFile(file)
    .then((data) => {
      try {
        return parseJson(data)
      } catch (ex) {
        ex.code = 'EJSONPARSE'
        throw ex
      }
    })
    .catch({code: 'ENOENT'}, () => null)
    .catch((ex) => {
      ex.file = file
      throw ex
    })
}

function filterEnv (action, opts) {
  const includeDev = opts.dev ||
    (!/^prod(uction)?$/.test(opts.only) && !opts.production) ||
    /^dev(elopment)?$/.test(opts.only) ||
    /^dev(elopment)?$/.test(opts.also)
  const includeProd = !/^dev(elopment)?$/.test(opts.only)
  const resolves = action.resolves.filter(({dev}) => {
    return (dev && includeDev) || (!dev && includeProd)
  })
  if (resolves.length) {
    return Object.assign({}, action, {resolves})
  }
}

function auditCmd (args, cb) {
  const opts = AuditConfig(npmConfig())
  if (opts.global) {
    const err = new Error('`npm audit` does not support testing globals')
    err.code = 'EAUDITGLOBAL'
    throw err
  }
  if (args.length && args[0] !== 'fix') {
    return cb(new Error('Invalid audit subcommand: `' + args[0] + '`\n\nUsage:\n' + auditCmd.usage))
  }
  return Bluebird.all([
    maybeReadFile('npm-shrinkwrap.json'),
    maybeReadFile('package-lock.json'),
    maybeReadFile('package.json')
  ]).spread((shrinkwrap, lockfile, pkgJson) => {
    const sw = shrinkwrap || lockfile
    if (!pkgJson) {
      const err = new Error('No package.json found: Cannot audit a project without a package.json')
      err.code = 'EAUDITNOPJSON'
      throw err
    }
    if (!sw) {
      const err = new Error('Neither npm-shrinkwrap.json nor package-lock.json found: Cannot audit a project without a lockfile')
      err.code = 'EAUDITNOLOCK'
      throw err
    } else if (shrinkwrap && lockfile) {
      log.warn('audit', 'Both npm-shrinkwrap.json and package-lock.json exist, using npm-shrinkwrap.json.')
    }
    const requires = Object.assign(
      {},
      (pkgJson && pkgJson.dependencies) || {},
      (!opts.production && pkgJson && pkgJson.devDependencies) || {}
    )
    return lockVerify(npm.prefix).then((result) => {
      if (result.status) return audit.generate(sw, requires)

      const lockFile = shrinkwrap ? 'npm-shrinkwrap.json' : 'package-lock.json'
      const err = new Error(`Errors were found in your ${lockFile}, run  npm install  to fix them.\n    ` +
        result.errors.join('\n    '))
      err.code = 'ELOCKVERIFY'
      throw err
    })
  }).then((auditReport) => {
    return audit.submitForFullReport(auditReport)
  }).catch((err) => {
    if (err.statusCode >= 400) {
      let msg
      if (err.statusCode === 401) {
        msg = `Either your login credentials are invalid or your registry (${opts.registry}) does not support audit.`
      } else if (err.statusCode === 404) {
        msg = `Your configured registry (${opts.registry}) does not support audit requests.`
      } else {
        msg = `Your configured registry (${opts.registry}) may not support audit requests, or the audit endpoint may be temporarily unavailable.`
      }
      if (err.body.length) {
        msg += '\nThe server said: ' + err.body
      }
      const ne = new Error(msg)
      ne.code = 'ENOAUDIT'
      ne.wrapped = err
      throw ne
    }
    throw err
  }).then((auditResult) => {
    if (args[0] === 'fix') {
      const actions = (auditResult.actions || []).reduce((acc, action) => {
        action = filterEnv(action, opts)
        if (!action) { return acc }
        if (action.isMajor) {
          acc.major.add(`${action.module}@${action.target}`)
          action.resolves.forEach(({id, path}) => acc.majorFixes.add(`${id}::${path}`))
        } else if (action.action === 'install') {
          acc.install.add(`${action.module}@${action.target}`)
          action.resolves.forEach(({id, path}) => acc.installFixes.add(`${id}::${path}`))
        } else if (action.action === 'update') {
          const name = action.module
          const version = action.target
          action.resolves.forEach(vuln => {
            acc.updateFixes.add(`${vuln.id}::${vuln.path}`)
            const modPath = vuln.path.split('>')
            const newPath = modPath.slice(
              0, modPath.indexOf(name)
            ).concat(`${name}@${version}`)
            if (newPath.length === 1) {
              acc.install.add(newPath[0])
            } else {
              acc.update.add(newPath.join('>'))
            }
          })
        } else if (action.action === 'review') {
          action.resolves.forEach(({id, path}) => acc.review.add(`${id}::${path}`))
        }
        return acc
      }, {
        install: new Set(),
        installFixes: new Set(),
        update: new Set(),
        updateFixes: new Set(),
        major: new Set(),
        majorFixes: new Set(),
        review: new Set()
      })
      return Bluebird.try(() => {
        const installMajor = opts.force
        const installCount = actions.install.size + (installMajor ? actions.major.size : 0) + actions.update.size
        const vulnFixCount = new Set([...actions.installFixes, ...actions.updateFixes, ...(installMajor ? actions.majorFixes : [])]).size
        const metavuln = auditResult.metadata.vulnerabilities
        const total = Object.keys(metavuln).reduce((acc, key) => acc + metavuln[key], 0)
        if (installCount) {
          log.verbose(
            'audit',
            'installing',
            [...actions.install, ...(installMajor ? actions.major : []), ...actions.update]
          )
        }
        return Bluebird.fromNode(cb => {
          new Auditor(
            npm.prefix,
            !!opts['dry-run'],
            [...actions.install, ...(installMajor ? actions.major : [])],
            opts.concat({
              runId: auditResult.runId,
              deepArgs: [...actions.update].map(u => u.split('>'))
            }).toJSON()
          ).run(cb)
        }).then(() => {
          const numScanned = auditResult.metadata.totalDependencies
          if (!opts.json && !opts.parseable) {
            output(`fixed ${vulnFixCount} of ${total} vulnerabilit${total === 1 ? 'y' : 'ies'} in ${numScanned} scanned package${numScanned === 1 ? '' : 's'}`)
            if (actions.review.size) {
              output(`  ${actions.review.size} vulnerabilit${actions.review.size === 1 ? 'y' : 'ies'} required manual review and could not be updated`)
            }
            if (actions.major.size) {
              output(`  ${actions.major.size} package update${actions.major.size === 1 ? '' : 's'} for ${actions.majorFixes.size} vulnerabilit${actions.majorFixes.size === 1 ? 'y' : 'ies'} involved breaking changes`)
              if (installMajor) {
                output('  (installed due to `--force` option)')
              } else {
                output('  (use `npm audit fix --force` to install breaking changes;' +
                       ' or refer to `npm audit` for steps to fix these manually)')
              }
            }
          }
        })
      })
    } else {
      const levels = ['low', 'moderate', 'high', 'critical']
      const minLevel = levels.indexOf(opts['audit-level'])
      const vulns = levels.reduce((count, level, i) => {
        return i < minLevel ? count : count + (auditResult.metadata.vulnerabilities[level] || 0)
      }, 0)
      if (vulns > 0) process.exitCode = 1
      if (opts.parseable) {
        return audit.printParseableReport(auditResult)
      } else {
        return audit.printFullReport(auditResult)
      }
    }
  }).asCallback(cb)
}
