const { resolve } = require('node:path')
const { stripVTControlCharacters } = require('node:util')
const pacote = require('pacote')
const table = require('text-table')
const npa = require('npm-package-arg')
const pickManifest = require('npm-pick-manifest')
const { output } = require('proc-log')
const localeCompare = require('@isaacs/string-locale-compare')('en')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')

const safeNpa = (spec) => {
  try {
    return npa(spec)
  } catch {
    return null
  }
}

// This string is load bearing and is shared with Arborist
const MISSING = 'MISSING'

class Outdated extends ArboristWorkspaceCmd {
  static description = 'Check for outdated packages'
  static name = 'outdated'
  static usage = ['[<package-spec> ...]']
  static params = [
    'all',
    'json',
    'long',
    'parseable',
    'global',
    'workspace',
  ]

  #tree
  #list = []
  #edges = new Set()
  #filterSet

  async exec (args) {
    const Arborist = require('@npmcli/arborist')
    const arb = new Arborist({
      ...this.npm.flatOptions,
      path: this.npm.global ? resolve(this.npm.globalDir, '..') : this.npm.prefix,
    })
    this.#tree = await arb.loadActual()

    if (this.workspaceNames?.length) {
      this.#filterSet = arb.workspaceDependencySet(
        this.#tree,
        this.workspaceNames,
        this.npm.flatOptions.includeWorkspaceRoot
      )
    } else if (!this.npm.flatOptions.workspacesEnabled) {
      this.#filterSet = arb.excludeWorkspacesDependencySet(this.#tree)
    }

    if (args.length) {
      for (const arg of args) {
        // specific deps
        this.#getEdges(this.#tree.inventory.query('name', arg), 'edgesIn')
      }
    } else {
      if (this.npm.config.get('all')) {
        // all deps in tree
        this.#getEdges(this.#tree.inventory.values(), 'edgesOut')
      }
      // top-level deps
      this.#getEdges()
    }

    await Promise.all([...this.#edges].map((e) => this.#getOutdatedInfo(e)))

    // sorts list alphabetically by name and then dependent
    const outdated = this.#list
      .sort((a, b) => localeCompare(a.name, b.name) || localeCompare(a.dependent, b.dependent))

    if (outdated.length) {
      process.exitCode = 1
    }

    if (this.npm.config.get('json')) {
      output.buffer(this.#json(outdated))
      return
    }

    const res = this.npm.config.get('parseable')
      ? this.#parseable(outdated)
      : this.#pretty(outdated)

    if (res) {
      output.standard(res)
    }
  }

  #getEdges (nodes, type) {
    // when no nodes are provided then it should only read direct deps
    // from the root node and its workspaces direct dependencies
    if (!nodes) {
      this.#getEdgesOut(this.#tree)
      this.#getWorkspacesEdges()
      return
    }

    for (const node of nodes) {
      if (type === 'edgesOut') {
        this.#getEdgesOut(node)
      } else {
        this.#getEdgesIn(node)
      }
    }
  }

  #getEdgesIn (node) {
    for (const edge of node.edgesIn) {
      this.#trackEdge(edge)
    }
  }

  #getEdgesOut (node) {
    // TODO: normalize usage of edges and avoid looping through nodes here
    const edges = this.npm.global ? node.children.values() : node.edgesOut.values()
    for (const edge of edges) {
      this.#trackEdge(edge)
    }
  }

  #trackEdge (edge) {
    if (edge.from && this.#filterSet?.size > 0 && !this.#filterSet.has(edge.from.target)) {
      return
    }
    this.#edges.add(edge)
  }

  #getWorkspacesEdges () {
    if (this.npm.global) {
      return
    }

    for (const edge of this.#tree.edgesOut.values()) {
      if (edge?.to?.target?.isWorkspace) {
        this.#getEdgesOut(edge.to.target)
      }
    }
  }

  async #getPackument (spec) {
    return pacote.packument(spec, {
      ...this.npm.flatOptions,
      fullMetadata: this.npm.config.get('long'),
      preferOnline: true,
    })
  }

  async #getOutdatedInfo (edge) {
    const alias = safeNpa(edge.spec)?.subSpec?.name
    const spec = npa(alias ?? edge.name)
    const node = edge.to || edge
    const { path, location, package: { version: current } = {} } = node

    const type = edge.optional ? 'optionalDependencies'
      : edge.peer ? 'peerDependencies'
      : edge.dev ? 'devDependencies'
      : 'dependencies'

    for (const omitType of this.npm.flatOptions.omit) {
      if (node[omitType]) {
        return
      }
    }

    // deps different from prod not currently
    // on disk are not included in the output
    if (edge.error === MISSING && type !== 'dependencies') {
      return
    }

    // if it's not a range, version, or tag, skip it
    if (!safeNpa(`${edge.name}@${edge.spec}`)?.registry) {
      return null
    }

    try {
      const packument = await this.#getPackument(spec)
      const expected = alias ? alias.fetchSpec : edge.spec
      const wanted = pickManifest(packument, expected, this.npm.flatOptions)
      const latest = pickManifest(packument, '*', this.npm.flatOptions)
      if (!current || current !== wanted.version || wanted.version !== latest.version) {
        this.#list.push({
          name: alias ? edge.spec.replace('npm', edge.name) : edge.name,
          path,
          type,
          current,
          location,
          wanted: wanted.version,
          latest: latest.version,
          workspaceDependent: edge.from?.isWorkspace ? edge.from.pkgid : null,
          dependent: edge.from?.name ?? 'global',
          homepage: packument.homepage,
        })
      }
    } catch (err) {
      // silently catch and ignore ETARGET, E403 &
      // E404 errors, deps are just skipped
      if (!['ETARGET', 'E404', 'E404'].includes(err.code)) {
        throw err
      }
    }
  }

  // formatting functions

  #pretty (list) {
    if (!list.length) {
      return
    }

    const long = this.npm.config.get('long')
    const { bold, yellow, red, cyan, blue } = this.npm.chalk

    return table([
      [
        'Package',
        'Current',
        'Wanted',
        'Latest',
        'Location',
        'Depended by',
        ...long ? ['Package Type', 'Homepage'] : [],
      ].map(h => bold.underline(h)),
      ...list.map((d) => [
        d.current === d.wanted ? yellow(d.name) : red(d.name),
        d.current ?? 'MISSING',
        cyan(d.wanted),
        blue(d.latest),
        d.location ?? '-',
        d.workspaceDependent ? blue(d.workspaceDependent) : d.dependent,
        ...long ? [d.type, blue(d.homepage ?? '')] : [],
      ]),
    ], {
      align: ['l', 'r', 'r', 'r', 'l'],
      stringLength: s => stripVTControlCharacters(s).length,
    })
  }

  // --parseable creates output like this:
  // <fullpath>:<name@wanted>:<name@installed>:<name@latest>:<dependedby>
  #parseable (list) {
    return list.map(d => [
      d.path,
      `${d.name}@${d.wanted}`,
      d.current ? `${d.name}@${d.current}` : 'MISSING',
      `${d.name}@${d.latest}`,
      d.dependent,
      ...this.npm.config.get('long') ? [d.type, d.homepage] : [],
    ].join(':')).join('\n')
  }

  #json (list) {
    // TODO(BREAKING_CHANGE): this should just return an array. It's a list and
    // turing it into an object with keys is lossy since multiple items in the
    // list could have the same key. For now we hack that by only changing
    // top level values into arrays if they have multiple outdated items
    return list.reduce((acc, d) => {
      const dep = {
        current: d.current,
        wanted: d.wanted,
        latest: d.latest,
        dependent: d.dependent,
        location: d.path,
        ...this.npm.config.get('long') ? { type: d.type, homepage: d.homepage } : {},
      }
      acc[d.name] = acc[d.name]
        // If this item alread has an outdated dep then we turn it into an array
        ? (Array.isArray(acc[d.name]) ? acc[d.name] : [acc[d.name]]).concat(dep)
        : dep
      return acc
    }, {})
  }
}

module.exports = Outdated
