const _makeIdealGraph = Symbol('makeIdealGraph')
const _createIsolatedTree = Symbol.for('createIsolatedTree')
const { mkdirSync } = require('node:fs')
const pacote = require('pacote')
const { join } = require('node:path')
const { depth } = require('treeverse')
const crypto = require('node:crypto')

// cache complicated function results
const memoize = (fn) => {
  const memo = new Map()
  return async function (arg) {
    const key = arg
    if (memo.has(key)) {
      return memo.get(key)
    }
    const result = {}
    memo.set(key, result)
    await fn(result, arg)
    return result
  }
}

module.exports = cls => class IsolatedReifier extends cls {
  /**
   * Create an ideal graph.
   *
   * An implementation of npm RFC-0042
   * https://github.com/npm/rfcs/blob/main/accepted/0042-isolated-mode.md
   *
   * This entire file should be considered technical debt that will be resolved
   * with an Arborist refactor or rewrite. Embedded logic in Nodes and Links,
   * and the incremental state of building trees and reifying contains too many
   * assumptions to do a linked mode properly.
   *
   * Instead, this approach takes a tree built from build-ideal-tree, and
   * returns a new tree-like structure without the embedded logic of Node and
   * Link classes.
   *
   * Since the RFC requires leaving the package-lock in place, this approach
   * temporarily replaces the tree state for a couple of steps of reifying.
   *
   **/
  async [_makeIdealGraph] (options) {
    /* Make sure that the ideal tree is build as the rest of
     * the algorithm depends on it.
     */
    const bitOpt = {
      ...options,
      complete: false,
    }
    await this.buildIdealTree(bitOpt)
    const idealTree = this.idealTree

    this.rootNode = {}
    const root = this.rootNode
    this.counter = 0

    // memoize to cache generating proxy Nodes
    this.externalProxyMemo = memoize(this.externalProxy.bind(this))
    this.workspaceProxyMemo = memoize(this.workspaceProxy.bind(this))

    root.external = []
    root.isProjectRoot = true
    root.localLocation = idealTree.location
    root.localPath = idealTree.path
    root.workspaces = await Promise.all(
      Array.from(idealTree.fsChildren.values(), this.workspaceProxyMemo))
    const processed = new Set()
    const queue = [idealTree, ...idealTree.fsChildren]
    while (queue.length !== 0) {
      const next = queue.pop()
      if (processed.has(next.location)) {
        continue
      }
      processed.add(next.location)
      next.edgesOut.forEach(e => {
        if (!e.to || (next.package.bundleDependencies || next.package.bundledDependencies || []).includes(e.to.name)) {
          return
        }
        queue.push(e.to)
      })
      if (!next.isProjectRoot && !next.isWorkspace && !next.inert) {
        root.external.push(await this.externalProxyMemo(next))
      }
    }

    await this.assignCommonProperties(idealTree, root)

    this.idealGraph = root
  }

  async workspaceProxy (result, node) {
    result.localLocation = node.location
    result.localPath = node.path
    result.isWorkspace = true
    result.resolved = node.resolved
    await this.assignCommonProperties(node, result)
  }

  async externalProxy (result, node) {
    await this.assignCommonProperties(node, result)
    if (node.hasShrinkwrap) {
      const dir = join(
        node.root.path,
        'node_modules',
        '.store',
        `${node.packageName}@${node.version}`
      )
      mkdirSync(dir, { recursive: true })
      // TODO this approach feels wrong
      // and shouldn't be necessary for shrinkwraps
      await pacote.extract(node.resolved, dir, {
        ...this.options,
        resolved: node.resolved,
        integrity: node.integrity,
      })
      const Arborist = this.constructor
      const arb = new Arborist({ ...this.options, path: dir })
      await arb[_makeIdealGraph]({ dev: false })
      this.rootNode.external.push(...arb.idealGraph.external)
      arb.idealGraph.external.forEach(e => {
        e.root = this.rootNode
        e.id = `${node.id}=>${e.id}`
      })
      result.localDependencies = []
      result.externalDependencies = arb.idealGraph.externalDependencies
      result.externalOptionalDependencies = arb.idealGraph.externalOptionalDependencies
      result.dependencies = [
        ...result.externalDependencies,
        ...result.localDependencies,
        ...result.externalOptionalDependencies,
      ]
    }
    result.optional = node.optional
    result.resolved = node.resolved
    result.version = node.version
  }

  async assignCommonProperties (node, result) {
    function validEdgesOut (node) {
      return [...node.edgesOut.values()].filter(e => e.to && e.to.target && !(node.package.bundledDependencies || node.package.bundleDependencies || []).includes(e.to.name))
    }
    const edges = validEdgesOut(node)
    const optionalDeps = edges.filter(e => e.optional).map(e => e.to.target)
    const nonOptionalDeps = edges.filter(e => !e.optional).map(e => e.to.target)

    // When legacyPeerDeps is enabled, peer dep edges are not created on the
    // node. Resolve them from the tree so they get symlinked in the store.
    const peerDeps = node.package.peerDependencies
    if (peerDeps && node.legacyPeerDeps) {
      const edgeNames = new Set(edges.map(e => e.name))
      for (const peerName of Object.keys(peerDeps)) {
        if (!edgeNames.has(peerName)) {
          const resolved = node.resolve(peerName)
          if (resolved && resolved !== node && !resolved.inert) {
            nonOptionalDeps.push(resolved)
          }
        }
      }
    }

    result.localDependencies = await Promise.all(nonOptionalDeps.filter(n => n.isWorkspace).map(this.workspaceProxyMemo))
    result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !n.isWorkspace && !n.inert).map(this.externalProxyMemo))
    result.externalOptionalDependencies = await Promise.all(optionalDeps.filter(n => !n.inert).map(this.externalProxyMemo))
    result.dependencies = [
      ...result.externalDependencies,
      ...result.localDependencies,
      ...result.externalOptionalDependencies,
    ]
    result.root = this.rootNode
    result.id = this.counter++
    /* istanbul ignore next - packageName is always set for real packages */
    result.name = result.isWorkspace ? (node.packageName || node.name) : node.name
    result.packageName = node.packageName || node.name
    result.package = { ...node.package }
    result.package.bundleDependencies = undefined
    result.hasInstallScript = node.hasInstallScript
  }

  async #createBundledTree () {
    // TODO: make sure that idealTree object exists
    const idealTree = this.idealTree
    // TODO: test workspaces having bundled deps
    const queue = []

    for (const [, edge] of idealTree.edgesOut) {
      if (edge.to && (idealTree.package.bundleDependencies || idealTree.package.bundledDependencies || []).includes(edge.to.name)) {
        queue.push({ from: idealTree, to: edge.to })
      }
    }
    for (const child of idealTree.fsChildren) {
      for (const [, edge] of child.edgesOut) {
        if (edge.to && (child.package.bundleDependencies || child.package.bundledDependencies || []).includes(edge.to.name)) {
          queue.push({ from: child, to: edge.to })
        }
      }
    }

    const processed = new Set()
    const nodes = new Map()
    const edges = []
    while (queue.length !== 0) {
      const nextEdge = queue.pop()
      const key = `${nextEdge.from.location}=>${nextEdge.to.location}`
      // should be impossible, unless bundled is duped
      /* istanbul ignore next */
      if (processed.has(key)) {
        continue
      }
      processed.add(key)
      const from = nextEdge.from
      if (!from.isRoot && !from.isWorkspace) {
        nodes.set(from.location, { location: from.location, resolved: from.resolved, name: from.name, optional: from.optional, pkg: { ...from.package, bundleDependencies: undefined } })
      }
      const to = nextEdge.to
      nodes.set(to.location, { location: to.location, resolved: to.resolved, name: to.name, optional: to.optional, pkg: { ...to.package, bundleDependencies: undefined } })
      edges.push({ from: from.isRoot ? 'root' : from.location, to: to.location })

      to.edgesOut.forEach(e => {
        // an edge out should always have a to
        /* istanbul ignore else */
        if (e.to) {
          queue.push({ from: e.from, to: e.to })
        }
      })
    }
    return { edges, nodes }
  }

  async [_createIsolatedTree] () {
    await this[_makeIdealGraph](this.options)

    const proxiedIdealTree = this.idealGraph

    const bundledTree = await this.#createBundledTree()

    const treeHash = (startNode) => {
      // generate short hash based on the dependency tree
      // starting at this node
      const deps = []
      const branch = []
      depth({
        tree: startNode,
        getChildren: node => node.dependencies,
        filter: node => node,
        visit: node => {
          branch.push(`${node.packageName}@${node.version}`)
          deps.push(`${branch.join('->')}::${node.resolved}`)
        },
        leave: () => {
          branch.pop()
        },
      })
      deps.sort()
      return crypto.createHash('shake256', { outputLength: 16 })
        .update(deps.join(','))
        .digest('base64')
        // Node v14 doesn't support base64url
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/m, '')
    }

    const getKey = (idealTreeNode) => {
      return `${idealTreeNode.packageName}@${idealTreeNode.version}-${treeHash(idealTreeNode)}`
    }

    const root = {
      fsChildren: [],
      integrity: null,
      inventory: new Map(),
      isLink: false,
      isRoot: true,
      binPaths: [],
      edgesIn: new Set(),
      edgesOut: new Map(),
      hasShrinkwrap: false,
      parent: null,
      // TODO: we should probably not reference this.idealTree
      resolved: this.idealTree.resolved,
      isTop: true,
      path: proxiedIdealTree.root.localPath,
      realpath: proxiedIdealTree.root.localPath,
      package: proxiedIdealTree.root.package,
      meta: { loadedFromDisk: false },
      global: false,
      isProjectRoot: true,
      children: [],
    }
    // root.inventory.set('', t)
    // root.meta = this.idealTree.meta
    // TODO We should mock better the inventory object because it is used by audit-report.js ... maybe
    root.inventory.query = () => {
      return []
    }
    const processed = new Set()
    proxiedIdealTree.workspaces.forEach(c => {
      const workspace = {
        edgesIn: new Set(),
        edgesOut: new Map(),
        children: [],
        hasInstallScript: c.hasInstallScript,
        binPaths: [],
        package: c.package,
        location: c.localLocation,
        path: c.localPath,
        realpath: c.localPath,
        resolved: c.resolved,
      }
      root.fsChildren.push(workspace)
      root.inventory.set(workspace.location, workspace)
    })
    const generateChild = (node, location, pkg, inStore) => {
      const newChild = {
        global: false,
        globalTop: false,
        isProjectRoot: false,
        isTop: false,
        location,
        name: node.packageName || node.name,
        optional: node.optional,
        top: { path: proxiedIdealTree.root.localPath },
        children: [],
        edgesIn: new Set(),
        edgesOut: new Map(),
        binPaths: [],
        fsChildren: [],
        /* istanbul ignore next -- emulate Node */
        getBundler () {
          return null
        },
        hasShrinkwrap: false,
        inDepBundle: false,
        integrity: null,
        isLink: false,
        isRoot: false,
        isInStore: inStore,
        path: join(proxiedIdealTree.root.localPath, location),
        realpath: join(proxiedIdealTree.root.localPath, location),
        resolved: node.resolved,
        version: pkg.version,
        package: pkg,
      }
      newChild.target = newChild
      root.children.push(newChild)
      root.inventory.set(newChild.location, newChild)
    }
    proxiedIdealTree.external.forEach(c => {
      const key = getKey(c)
      if (processed.has(key)) {
        return
      }
      processed.add(key)
      const location = join('node_modules', '.store', key, 'node_modules', c.packageName)
      generateChild(c, location, c.package, true)
    })
    bundledTree.nodes.forEach(node => {
      generateChild(node, node.location, node.pkg, false)
    })
    bundledTree.edges.forEach(e => {
      const from = e.from === 'root' ? root : root.inventory.get(e.from)
      const to = root.inventory.get(e.to)
      // Maybe optional should be propagated from the original edge
      const edge = { optional: false, from, to }
      from.edgesOut.set(to.name, edge)
      to.edgesIn.add(edge)
    })
    const memo = new Set()

    function processEdges (node, externalEdge) {
      externalEdge = !!externalEdge
      const key = getKey(node)
      if (memo.has(key)) {
        return
      }
      memo.add(key)

      let from, nmFolder
      if (externalEdge) {
        const fromLocation = join('node_modules', '.store', key, 'node_modules', node.packageName)
        from = root.children.find(c => c.location === fromLocation)
        nmFolder = join('node_modules', '.store', key, 'node_modules')
      } else {
        from = node.isProjectRoot ? root : root.fsChildren.find(c => c.location === node.localLocation)
        nmFolder = join(node.localLocation, 'node_modules')
      }
      /* istanbul ignore next - strict-peer-deps can exclude nodes from the tree */
      if (!from) {
        return
      }

      const processDeps = (dep, optional, external) => {
        optional = !!optional
        external = !!external

        const location = join(nmFolder, dep.name)
        const binNames = dep.package.bin && Object.keys(dep.package.bin) || []
        const toKey = getKey(dep)

        let target
        if (external) {
          const toLocation = join('node_modules', '.store', toKey, 'node_modules', dep.packageName)
          target = root.children.find(c => c.location === toLocation)
        } else {
          target = root.fsChildren.find(c => c.location === dep.localLocation)
        }
        // TODO: we should no-op is an edge has already been created with the same fromKey and toKey
        /* istanbul ignore next - strict-peer-deps can exclude nodes from the tree */
        if (!target) {
          return
        }

        binNames.forEach(bn => {
          target.binPaths.push(join(from.realpath, 'node_modules', '.bin', bn))
        })

        const link = {
          global: false,
          globalTop: false,
          isProjectRoot: false,
          edgesIn: new Set(),
          edgesOut: new Map(),
          binPaths: [],
          isTop: false,
          optional,
          location: location,
          path: join(dep.root.localPath, nmFolder, dep.name),
          realpath: target.path,
          name: toKey,
          resolved: dep.resolved,
          top: { path: dep.root.localPath },
          children: [],
          fsChildren: [],
          isLink: true,
          isStoreLink: true,
          isRoot: false,
          package: { _id: 'abc', bundleDependencies: undefined, deprecated: undefined, bin: target.package.bin, scripts: dep.package.scripts },
          target,
        }
        const newEdge1 = { optional, from, to: link }
        from.edgesOut.set(dep.name, newEdge1)
        link.edgesIn.add(newEdge1)
        const newEdge2 = { optional: false, from: link, to: target }
        link.edgesOut.set(dep.name, newEdge2)
        target.edgesIn.add(newEdge2)
        root.children.push(link)
      }

      for (const dep of node.localDependencies) {
        processEdges(dep, false)
        // nonOptional, local
        processDeps(dep, false, false)
      }
      for (const dep of node.externalDependencies) {
        processEdges(dep, true)
        // nonOptional, external
        processDeps(dep, false, true)
      }
      for (const dep of node.externalOptionalDependencies) {
        processEdges(dep, true)
        // optional, external
        processDeps(dep, true, true)
      }
    }

    processEdges(proxiedIdealTree, false)
    for (const node of proxiedIdealTree.workspaces) {
      processEdges(node, false)
    }
    root.children.forEach(c => c.parent = root)
    root.children.forEach(c => c.root = root)
    root.root = root
    root.target = root
    return root
  }
}
