'use strict'

// Do a two-pass walk, first to get the list of packages that need to be
// bundled, then again to get the actual files and folders.
// Keep a cache of node_modules content and package.json data, so that the
// second walk doesn't have to re-do all the same work.

const bundleWalk = require('npm-bundled')
const BundleWalker = bundleWalk.BundleWalker

const ignoreWalk = require('ignore-walk')
const IgnoreWalker = ignoreWalk.Walker

const rootBuiltinRules = Symbol('root-builtin-rules')
const packageNecessaryRules = Symbol('package-necessary-rules')
const path = require('path')

const normalizePackageBin = require('npm-normalize-package-bin')

// Weird side-effect of this: a readme (etc) file will be included
// if it exists anywhere within a folder with a package.json file.
// The original intent was only to include these files in the root,
// but now users in the wild are dependent on that behavior for
// localized documentation and other use cases.  Adding a `/` to
// these rules, while tempting and arguably more "correct", is a
// significant change that will break existing use cases.
const packageMustHaveFileNames = 'readme|copying|license|licence'

const packageMustHaves = `@(${packageMustHaveFileNames}){,.*[^~$]}`
const packageMustHavesRE = new RegExp(`^(${packageMustHaveFileNames})(\\..*[^~$])?$`, 'i')

const fs = require('fs')
const glob = require('glob')
const globify = pattern => pattern.split('\\').join('/')

const readOutOfTreeIgnoreFiles = (root, rel, result = '') => {
  for (const file of ['.npmignore', '.gitignore']) {
    try {
      const ignoreContent = fs.readFileSync(path.join(root, file), { encoding: 'utf8' })
      result += ignoreContent + '\n'
      // break the loop immediately after concatting, this allows us to prioritize the
      // .npmignore and discard the .gitignore if one exists
      break
    } catch (err) {
      // we ignore ENOENT errors completely because we don't care if the file doesn't exist
      // but we throw everything else because failing to read a file that does exist is
      // something that the user likely wants to know about. we don't need to test this.
      /* istanbul ignore next */
      if (err.code !== 'ENOENT') {
        throw err
      }
    }
  }

  if (!rel) {
    return result
  }

  const firstRel = rel.split(path.sep)[0]
  const newRoot = path.join(root, firstRel)
  const newRel = path.relative(newRoot, path.join(root, rel))

  return readOutOfTreeIgnoreFiles(newRoot, newRel, result)
}

const pathHasPkg = (input) => {
  if (!input.startsWith('node_modules/')) {
    return false
  }

  const segments = input.slice('node_modules/'.length).split('/', 2)
  return segments[0].startsWith('@')
    ? segments.length === 2
    : true
}

const pkgFromPath = (input) => {
  const segments = input.slice('node_modules/'.length).split('/', 2)
  return segments[0].startsWith('@')
    ? segments.join('/')
    : segments[0]
}

const defaultRules = [
  '.npmignore',
  '.gitignore',
  '**/.git',
  '**/.svn',
  '**/.hg',
  '**/CVS',
  '**/.git/**',
  '**/.svn/**',
  '**/.hg/**',
  '**/CVS/**',
  '/.lock-wscript',
  '/.wafpickle-*',
  '/build/config.gypi',
  'npm-debug.log',
  '**/.npmrc',
  '.*.swp',
  '.DS_Store',
  '**/.DS_Store/**',
  '._*',
  '**/._*/**',
  '*.orig',
  '/package-lock.json',
  '/yarn.lock',
  '/pnpm-lock.yaml',
  '/archived-packages/**',
]

// There may be others, but :?|<> are handled by node-tar
const nameIsBadForWindows = file => /\*/.test(file)

class Walker extends IgnoreWalker {
  constructor (opt) {
    opt = opt || {}

    // the order in which rules are applied.
    opt.ignoreFiles = [
      rootBuiltinRules,
      'package.json',
      '.npmignore',
      '.gitignore',
      packageNecessaryRules,
    ]

    opt.includeEmpty = false
    opt.path = opt.path || process.cwd()

    // only follow links in the root node_modules folder, because if those
    // folders are included, it's because they're bundled, and bundles
    // should include the contents, not the symlinks themselves.
    // This regexp tests to see that we're either a node_modules folder,
    // or a @scope within a node_modules folder, in the root's node_modules
    // hierarchy (ie, not in test/foo/node_modules/ or something).
    const followRe = /^(?:\/node_modules\/(?:@[^/]+\/[^/]+|[^/]+)\/)*\/node_modules(?:\/@[^/]+)?$/
    const rootPath = opt.parent ? opt.parent.root : opt.path
    const followTestPath = opt.path.replace(/\\/g, '/').slice(rootPath.length)
    opt.follow = followRe.test(followTestPath)

    super(opt)

    // ignore a bunch of things by default at the root level.
    // also ignore anything in the main project node_modules hierarchy,
    // except bundled dependencies
    if (this.isProject) {
      this.bundled = opt.bundled || []
      this.bundledScopes = Array.from(new Set(
        this.bundled.filter(f => /^@/.test(f))
          .map(f => f.split('/')[0])))
      this.packageJsonCache = this.parent ? this.parent.packageJsonCache
        : (opt.packageJsonCache || new Map())
      let rules = defaultRules.join('\n') + '\n'

      if (opt.prefix && opt.workspaces) {
        const gPath = globify(opt.path)
        const gPrefix = globify(opt.prefix)
        const gWorkspaces = opt.workspaces.map((ws) => globify(ws))
        // if opt.path and opt.prefix are not the same directory, and opt.workspaces has opt.path
        // in it, then we know that opt.path is a workspace directory. in order to not drop ignore
        // rules from directories between the workspace root (opt.prefix) and the workspace itself
        // (opt.path), we need to find and read those now
        /* istanbul ignore else */
        if (gPath !== gPrefix && gWorkspaces.includes(gPath)) {
          // relpath is the relative path between the prefix and the parent of opt.path
          // we use the parent because ignore-walk will read the files in opt.path already
          const relpath = path.relative(opt.prefix, path.dirname(opt.path))
          rules += readOutOfTreeIgnoreFiles(opt.prefix, relpath)
        } else if (gPath === gPrefix) {
          // on the other hand, if the path and the prefix are the same, then we ignore workspaces
          // so that we don't pack workspaces inside of a root project
          rules += opt.workspaces.map((ws) => globify(path.relative(opt.path, ws))).join('\n')
        }
      }

      super.onReadIgnoreFile(rootBuiltinRules, rules, _ => _)
    } else {
      this.bundled = []
      this.bundledScopes = []
      this.packageJsonCache = this.parent.packageJsonCache
    }
  }

  get isProject () {
    return !this.parent || this.parent.follow && this.isSymbolicLink
  }

  onReaddir (entries) {
    if (this.isProject) {
      entries = entries.filter(e =>
        e !== '.git' &&
        !(e === 'node_modules' && this.bundled.length === 0)
      )
    }

    // if we have a package.json, then look in it for 'files'
    // we _only_ do this in the root project, not bundled deps
    // or other random folders.  Bundled deps are always assumed
    // to be in the state the user wants to include them, and
    // a package.json somewhere else might be a template or
    // test or something else entirely.
    if (!this.isProject || !entries.includes('package.json')) {
      return super.onReaddir(entries)
    }

    // when the cache has been seeded with the root manifest,
    // we must respect that (it may differ from the filesystem)
    const ig = path.resolve(this.path, 'package.json')

    if (this.packageJsonCache.has(ig)) {
      const pkg = this.packageJsonCache.get(ig)

      // fall back to filesystem when seeded manifest is invalid
      if (!pkg || typeof pkg !== 'object') {
        return this.readPackageJson(entries)
      }

      // feels wonky, but this ensures package bin is _always_
      // normalized, as well as guarding against invalid JSON
      return this.getPackageFiles(entries, JSON.stringify(pkg))
    }

    this.readPackageJson(entries)
  }

  onReadPackageJson (entries, er, pkg) {
    if (er) {
      this.emit('error', er)
    } else {
      this.getPackageFiles(entries, pkg)
    }
  }

  mustHaveFilesFromPackage (pkg) {
    const files = []
    if (pkg.browser) {
      files.push('/' + pkg.browser)
    }
    if (pkg.main) {
      files.push('/' + pkg.main)
    }
    if (pkg.bin) {
      // always an object because normalized already
      for (const key in pkg.bin) {
        files.push('/' + pkg.bin[key])
      }
    }
    files.push(
      '/package.json',
      '/npm-shrinkwrap.json',
      '!/package-lock.json',
      packageMustHaves
    )
    return files
  }

  getPackageFiles (entries, pkg) {
    try {
      // XXX this could be changed to use read-package-json-fast
      // which handles the normalizing of bins for us, and simplifies
      // the test for bundleDependencies and bundledDependencies later.
      // HOWEVER if we do this, we need to be sure that we're careful
      // about what we write back out since rpj-fast removes some fields
      // that the user likely wants to keep. it also would add a second
      // file read that we would want to optimize away.
      pkg = normalizePackageBin(JSON.parse(pkg.toString()))
    } catch (er) {
      // not actually a valid package.json
      return super.onReaddir(entries)
    }

    const ig = path.resolve(this.path, 'package.json')
    this.packageJsonCache.set(ig, pkg)

    // no files list, just return the normal readdir() result
    if (!Array.isArray(pkg.files)) {
      return super.onReaddir(entries)
    }

    pkg.files.push(...this.mustHaveFilesFromPackage(pkg))

    // If the package has a files list, then it's unlikely to include
    // node_modules, because why would you do that?  but since we use
    // the files list as the effective readdir result, that means it
    // looks like we don't have a node_modules folder at all unless we
    // include it here.
    if ((pkg.bundleDependencies || pkg.bundledDependencies) && entries.includes('node_modules')) {
      pkg.files.push('node_modules')
    }

    const patterns = Array.from(new Set(pkg.files)).reduce((set, pattern) => {
      const excl = pattern.match(/^!+/)
      if (excl) {
        pattern = pattern.slice(excl[0].length)
      }
      // strip off any / or ./ from the start of the pattern.  /foo => foo, ./foo => foo
      pattern = pattern.replace(/^\.?\/+/, '')
      // an odd number of ! means a negated pattern.  !!foo ==> foo
      const negate = excl && excl[0].length % 2 === 1
      set.push({ pattern, negate })
      return set
    }, [])

    let n = patterns.length
    const set = new Set()
    const negates = new Set()
    const results = []
    const then = (pattern, negate, er, fileList, i) => {
      if (er) {
        return this.emit('error', er)
      }

      results[i] = { negate, fileList }
      if (--n === 0) {
        processResults(results)
      }
    }
    const processResults = processed => {
      for (const { negate, fileList } of processed) {
        if (negate) {
          fileList.forEach(f => {
            f = f.replace(/\/+$/, '')
            set.delete(f)
            negates.add(f)
          })
        } else {
          fileList.forEach(f => {
            f = f.replace(/\/+$/, '')
            set.add(f)
            negates.delete(f)
          })
        }
      }

      const list = Array.from(set)
      // replace the files array with our computed explicit set
      pkg.files = list.concat(Array.from(negates).map(f => '!' + f))
      const rdResult = Array.from(new Set(
        list.map(f => f.replace(/^\/+/, ''))
      ))
      super.onReaddir(rdResult)
    }

    // maintain the index so that we process them in-order only once all
    // are completed, otherwise the parallelism messes things up, since a
    // glob like **/*.js will always be slower than a subsequent !foo.js
    patterns.forEach(({ pattern, negate }, i) =>
      this.globFiles(pattern, (er, res) => then(pattern, negate, er, res, i)))
  }

  filterEntry (entry, partial) {
    // get the partial path from the root of the walk
    const p = this.path.slice(this.root.length + 1)
    const { isProject } = this
    const pkg = isProject && pathHasPkg(entry)
      ? pkgFromPath(entry)
      : null
    const rootNM = isProject && entry === 'node_modules'
    const rootPJ = isProject && entry === 'package.json'

    return (
      // if we're in a bundled package, check with the parent.
      /^node_modules($|\/)/i.test(p) && !this.isProject ? this.parent.filterEntry(
        this.basename + '/' + entry, partial)

      // if package is bundled, all files included
      // also include @scope dirs for bundled scoped deps
      // they'll be ignored if no files end up in them.
      // However, this only matters if we're in the root.
      // node_modules folders elsewhere, like lib/node_modules,
      // should be included normally unless ignored.
      : pkg ? this.bundled.indexOf(pkg) !== -1 ||
        this.bundledScopes.indexOf(pkg) !== -1

      // only walk top node_modules if we want to bundle something
      : rootNM ? !!this.bundled.length

      // always include package.json at the root.
      : rootPJ ? true

      // always include readmes etc in any included dir
      : packageMustHavesRE.test(entry) ? true

      // npm-shrinkwrap and package.json always included in the root pkg
      : isProject && (entry === 'npm-shrinkwrap.json' || entry === 'package.json')
        ? true

      // package-lock never included
        : isProject && entry === 'package-lock.json' ? false

        // otherwise, follow ignore-walk's logic
        : super.filterEntry(entry, partial)
    )
  }

  filterEntries () {
    if (this.ignoreRules['.npmignore']) {
      this.ignoreRules['.gitignore'] = null
    }
    this.filterEntries = super.filterEntries
    super.filterEntries()
  }

  addIgnoreFile (file, then) {
    const ig = path.resolve(this.path, file)
    if (file === 'package.json' && !this.isProject) {
      then()
    } else if (this.packageJsonCache.has(ig)) {
      this.onPackageJson(ig, this.packageJsonCache.get(ig), then)
    } else {
      super.addIgnoreFile(file, then)
    }
  }

  onPackageJson (ig, pkg, then) {
    this.packageJsonCache.set(ig, pkg)

    if (Array.isArray(pkg.files)) {
      // in this case we already included all the must-haves
      super.onReadIgnoreFile('package.json', pkg.files.map(
        f => '!' + f
      ).join('\n') + '\n', then)
    } else {
      // if there's a bin, browser or main, make sure we don't ignore it
      // also, don't ignore the package.json itself, or any files that
      // must be included in the package.
      const rules = this.mustHaveFilesFromPackage(pkg).map(f => `!${f}`)
      const data = rules.join('\n') + '\n'
      super.onReadIgnoreFile(packageNecessaryRules, data, then)
    }
  }

  // override parent stat function to completely skip any filenames
  // that will break windows entirely.
  // XXX(isaacs) Next major version should make this an error instead.
  stat ({ entry, file, dir }, then) {
    if (nameIsBadForWindows(entry)) {
      then()
    } else {
      super.stat({ entry, file, dir }, then)
    }
  }

  // override parent onstat function to nix all symlinks, other than
  // those coming out of the followed bundled symlink deps
  onstat ({ st, entry, file, dir, isSymbolicLink }, then) {
    if (st.isSymbolicLink()) {
      then()
    } else {
      super.onstat({ st, entry, file, dir, isSymbolicLink }, then)
    }
  }

  onReadIgnoreFile (file, data, then) {
    if (file === 'package.json') {
      try {
        const ig = path.resolve(this.path, file)
        this.onPackageJson(ig, JSON.parse(data), then)
      } catch (er) {
        // ignore package.json files that are not json
        then()
      }
    } else {
      super.onReadIgnoreFile(file, data, then)
    }
  }

  sort (a, b) {
    // optimize for compressibility
    // extname, then basename, then locale alphabetically
    // https://twitter.com/isntitvacant/status/1131094910923231232
    const exta = path.extname(a).toLowerCase()
    const extb = path.extname(b).toLowerCase()
    const basea = path.basename(a).toLowerCase()
    const baseb = path.basename(b).toLowerCase()

    return exta.localeCompare(extb, 'en') ||
      basea.localeCompare(baseb, 'en') ||
      a.localeCompare(b, 'en')
  }

  globFiles (pattern, cb) {
    glob(globify(pattern), { dot: true, cwd: this.path, nocase: true }, cb)
  }

  readPackageJson (entries) {
    fs.readFile(this.path + '/package.json', (er, pkg) =>
      this.onReadPackageJson(entries, er, pkg))
  }

  walker (entry, opt, then) {
    new Walker(this.walkerOpt(entry, opt)).on('done', then).start()
  }
}

const walk = (options, callback) => {
  options = options || {}
  const p = new Promise((resolve, reject) => {
    const bw = new BundleWalker(options)
    bw.on('done', bundled => {
      options.bundled = bundled
      options.packageJsonCache = bw.packageJsonCache
      new Walker(options).on('done', resolve).on('error', reject).start()
    })
    bw.start()
  })
  return callback ? p.then(res => callback(null, res), callback) : p
}

module.exports = walk
walk.Walker = Walker
