// mixin providing the loadVirtual method
const localeCompare = require('@isaacs/string-locale-compare')('en')

const {resolve} = require('path')

const nameFromFolder = require('@npmcli/name-from-folder')
const consistentResolve = require('../consistent-resolve.js')
const Shrinkwrap = require('../shrinkwrap.js')
const Node = require('../node.js')
const Link = require('../link.js')
const relpath = require('../relpath.js')
const calcDepFlags = require('../calc-dep-flags.js')
const rpj = require('read-package-json-fast')
const treeCheck = require('../tree-check.js')

const loadFromShrinkwrap = Symbol('loadFromShrinkwrap')
const resolveNodes = Symbol('resolveNodes')
const resolveLinks = Symbol('resolveLinks')
const assignBundles = Symbol('assignBundles')
const loadRoot = Symbol('loadRoot')
const loadNode = Symbol('loadVirtualNode')
const loadLink = Symbol('loadVirtualLink')
const loadWorkspaces = Symbol.for('loadWorkspaces')
const loadWorkspacesVirtual = Symbol.for('loadWorkspacesVirtual')
const flagsSuspect = Symbol.for('flagsSuspect')
const reCalcDepFlags = Symbol('reCalcDepFlags')
const checkRootEdges = Symbol('checkRootEdges')
const rootOptionProvided = Symbol('rootOptionProvided')

const depsToEdges = (type, deps) =>
  Object.entries(deps).map(d => [type, ...d])

module.exports = cls => class VirtualLoader extends cls {
  constructor (options) {
    super(options)

    // the virtual tree we load from a shrinkwrap
    this.virtualTree = options.virtualTree
    this[flagsSuspect] = false
  }

  // public method
  async loadVirtual (options = {}) {
    if (this.virtualTree) {
      return this.virtualTree
    }

    // allow the user to set reify options on the ctor as well.
    // XXX: deprecate separate reify() options object.
    options = { ...this.options, ...options }

    if (options.root && options.root.meta) {
      await this[loadFromShrinkwrap](options.root.meta, options.root)
      return treeCheck(this.virtualTree)
    }

    const s = await Shrinkwrap.load({ path: this.path })
    if (!s.loadedFromDisk && !options.root) {
      const er = new Error('loadVirtual requires existing shrinkwrap file')
      throw Object.assign(er, { code: 'ENOLOCK' })
    }

    // when building the ideal tree, we pass in a root node to this function
    // otherwise, load it from the root package json or the lockfile
    const {
      root = await this[loadRoot](s),
    } = options

    this[rootOptionProvided] = options.root

    await this[loadFromShrinkwrap](s, root)
    return treeCheck(this.virtualTree)
  }

  async [loadRoot] (s) {
    const pj = this.path + '/package.json'
    const pkg = await rpj(pj).catch(() => s.data.packages['']) || {}
    return this[loadWorkspaces](this[loadNode]('', pkg))
  }

  async [loadFromShrinkwrap] (s, root) {
    if (!this[rootOptionProvided]) {
      // root is never any of these things, but might be a brand new
      // baby Node object that never had its dep flags calculated.
      root.extraneous = false
      root.dev = false
      root.optional = false
      root.devOptional = false
      root.peer = false
    } else {
      this[flagsSuspect] = true
    }

    this[checkRootEdges](s, root)
    root.meta = s
    this.virtualTree = root
    const {links, nodes} = this[resolveNodes](s, root)
    await this[resolveLinks](links, nodes)
    if (!(s.originalLockfileVersion >= 2)) {
      this[assignBundles](nodes)
    }
    if (this[flagsSuspect]) {
      this[reCalcDepFlags](nodes.values())
    }
    return root
  }

  [reCalcDepFlags] (nodes) {
    // reset all dep flags
    // can't use inventory here, because virtualTree might not be root
    for (const node of nodes) {
      if (node.isRoot || node === this[rootOptionProvided]) {
        continue
      }
      node.extraneous = true
      node.dev = true
      node.optional = true
      node.devOptional = true
      node.peer = true
    }
    calcDepFlags(this.virtualTree, !this[rootOptionProvided])
  }

  // check the lockfile deps, and see if they match.  if they do not
  // then we have to reset dep flags at the end.  for example, if the
  // user manually edits their package.json file, then we need to know
  // that the idealTree is no longer entirely trustworthy.
  [checkRootEdges] (s, root) {
    // loaded virtually from tree, no chance of being out of sync
    // ancient lockfiles are critically damaged by this process,
    // so we need to just hope for the best in those cases.
    if (!s.loadedFromDisk || s.ancientLockfile) {
      return
    }

    const lock = s.get('')
    const prod = lock.dependencies || {}
    const dev = lock.devDependencies || {}
    const optional = lock.optionalDependencies || {}
    const peer = lock.peerDependencies || {}
    const peerOptional = {}
    if (lock.peerDependenciesMeta) {
      for (const [name, meta] of Object.entries(lock.peerDependenciesMeta)) {
        if (meta.optional && peer[name] !== undefined) {
          peerOptional[name] = peer[name]
          delete peer[name]
        }
      }
    }
    for (const name of Object.keys(optional)) {
      delete prod[name]
    }

    const lockWS = []
    const workspaces = this[loadWorkspacesVirtual]({
      cwd: this.path,
      lockfile: s.data,
    })
    for (const [name, path] of workspaces.entries()) {
      lockWS.push(['workspace', name, `file:${path}`])
    }

    const lockEdges = [
      ...depsToEdges('prod', prod),
      ...depsToEdges('dev', dev),
      ...depsToEdges('optional', optional),
      ...depsToEdges('peer', peer),
      ...depsToEdges('peerOptional', peerOptional),
      ...lockWS,
    ].sort(([atype, aname], [btype, bname]) =>
      localeCompare(atype, btype) || localeCompare(aname, bname))

    const rootEdges = [...root.edgesOut.values()]
      .map(e => [e.type, e.name, e.spec])
      .sort(([atype, aname], [btype, bname]) =>
        localeCompare(atype, btype) || localeCompare(aname, bname))

    if (rootEdges.length !== lockEdges.length) {
      // something added or removed
      return this[flagsSuspect] = true
    }

    for (let i = 0; i < lockEdges.length; i++) {
      if (rootEdges[i][0] !== lockEdges[i][0] ||
          rootEdges[i][1] !== lockEdges[i][1] ||
          rootEdges[i][2] !== lockEdges[i][2]) {
        return this[flagsSuspect] = true
      }
    }
  }

  // separate out link metadatas, and create Node objects for nodes
  [resolveNodes] (s, root) {
    const links = new Map()
    const nodes = new Map([['', root]])
    for (const [location, meta] of Object.entries(s.data.packages)) {
      // skip the root because we already got it
      if (!location) {
        continue
      }

      if (meta.link) {
        links.set(location, meta)
      } else {
        nodes.set(location, this[loadNode](location, meta))
      }
    }
    return {links, nodes}
  }

  // links is the set of metadata, and nodes is the map of non-Link nodes
  // Set the targets to nodes in the set, if we have them (we might not)
  async [resolveLinks] (links, nodes) {
    for (const [location, meta] of links.entries()) {
      const targetPath = resolve(this.path, meta.resolved)
      const targetLoc = relpath(this.path, targetPath)
      const target = nodes.get(targetLoc)
      const link = this[loadLink](location, targetLoc, target, meta)
      nodes.set(location, link)
      nodes.set(targetLoc, link.target)

      // we always need to read the package.json for link targets
      // outside node_modules because they can be changed by the local user
      if (!link.target.parent) {
        const pj = link.realpath + '/package.json'
        const pkg = await rpj(pj).catch(() => null)
        if (pkg) {
          link.target.package = pkg
        }
      }
    }
  }

  [assignBundles] (nodes) {
    for (const [location, node] of nodes) {
      // Skip assignment of parentage for the root package
      if (!location || node.isLink && !node.target.location) {
        continue
      }
      const { name, parent, package: { inBundle }} = node

      if (!parent) {
        continue
      }

      // read inBundle from package because 'package' here is
      // actually a v2 lockfile metadata entry.
      // If the *parent* is also bundled, though, or if the parent has
      // no dependency on it, then we assume that it's being pulled in
      // just by virtue of its parent or a transitive dep being bundled.
      const { package: ppkg } = parent
      const { inBundle: parentBundled } = ppkg
      if (inBundle && !parentBundled && parent.edgesOut.has(node.name)) {
        if (!ppkg.bundleDependencies) {
          ppkg.bundleDependencies = [name]
        } else {
          ppkg.bundleDependencies.push(name)
        }
      }
    }
  }

  [loadNode] (location, sw) {
    const p = this.virtualTree ? this.virtualTree.realpath : this.path
    const path = resolve(p, location)
    // shrinkwrap doesn't include package name unless necessary
    if (!sw.name) {
      sw.name = nameFromFolder(path)
    }

    const dev = sw.dev
    const optional = sw.optional
    const devOptional = dev || optional || sw.devOptional
    const peer = sw.peer

    const node = new Node({
      legacyPeerDeps: this.legacyPeerDeps,
      root: this.virtualTree,
      path,
      realpath: path,
      integrity: sw.integrity,
      resolved: consistentResolve(sw.resolved, this.path, path),
      pkg: sw,
      hasShrinkwrap: sw.hasShrinkwrap,
      dev,
      optional,
      devOptional,
      peer,
    })
    // cast to boolean because they're undefined in the lock file when false
    node.extraneous = !!sw.extraneous
    node.devOptional = !!(sw.devOptional || sw.dev || sw.optional)
    node.peer = !!sw.peer
    node.optional = !!sw.optional
    node.dev = !!sw.dev
    return node
  }

  [loadLink] (location, targetLoc, target, meta) {
    const path = resolve(this.path, location)
    const link = new Link({
      legacyPeerDeps: this.legacyPeerDeps,
      path,
      realpath: resolve(this.path, targetLoc),
      target,
      pkg: target && target.package,
    })
    link.extraneous = target.extraneous
    link.devOptional = target.devOptional
    link.peer = target.peer
    link.optional = target.optional
    link.dev = target.dev
    return link
  }
}
