const crypto = require('node:crypto')
const parseLicense = require('spdx-expression-parse')
const PackageJson = require('@npmcli/package-json')
const npa = require('npm-package-arg')
const ssri = require('ssri')

const CYCLONEDX_SCHEMA = 'http://cyclonedx.org/schema/bom-1.5.schema.json'
const CYCLONEDX_FORMAT = 'CycloneDX'
const CYCLONEDX_SCHEMA_VERSION = '1.5'

const PROP_BUNDLED = 'cdx:npm:package:bundled'
const PROP_DEVELOPMENT = 'cdx:npm:package:development'
const PROP_EXTRANEOUS = 'cdx:npm:package:extraneous'
const PROP_PRIVATE = 'cdx:npm:package:private'

const REF_VCS = 'vcs'
const REF_WEBSITE = 'website'
const REF_ISSUE_TRACKER = 'issue-tracker'
const REF_DISTRIBUTION = 'distribution'

const ALGO_MAP = {
  sha1: 'SHA-1',
  sha256: 'SHA-256',
  sha384: 'SHA-384',
  sha512: 'SHA-512',
}

const cyclonedxOutput = ({ npm, nodes, packageType, packageLockOnly }) => {
  const rootNode = nodes.find(node => node.isRoot)
  const childNodes = nodes.filter(node => !node.isRoot && !node.isLink)
  const uuid = crypto.randomUUID()

  // Create list of child nodes w/ unique IDs
  const childNodeMap = new Map()
  for (const item of childNodes) {
    const id = toCyclonedxID(item)
    if (!childNodeMap.has(id)) {
      childNodeMap.set(id, item)
    }
  }
  const uniqueChildNodes = Array.from(childNodeMap.values())

  const deps = [rootNode, ...uniqueChildNodes]
    .map(node => toCyclonedxDependency(node, nodes))

  const bom = {
    $schema: CYCLONEDX_SCHEMA,
    bomFormat: CYCLONEDX_FORMAT,
    specVersion: CYCLONEDX_SCHEMA_VERSION,
    serialNumber: `urn:uuid:${uuid}`,
    version: 1,
    metadata: {
      timestamp: new Date().toISOString(),
      lifecycles: [
        { phase: packageLockOnly ? 'pre-build' : 'build' },
      ],
      tools: [
        {
          vendor: 'npm',
          name: 'cli',
          version: npm.version,
        },
      ],
      component: toCyclonedxItem(rootNode, { packageType }),
    },
    components: uniqueChildNodes.map(toCyclonedxItem),
    dependencies: deps,
  }

  return bom
}

const toCyclonedxItem = (node, { packageType }) => {
  packageType = packageType || 'library'

  // Calculate purl from package spec
  let spec = npa(node.pkgid)
  spec = (spec.type === 'alias') ? spec.subSpec : spec
  const purl = npa.toPurl(spec) + (isGitNode(node) ? `?vcs_url=${node.resolved}` : '')

  if (node.package) {
    const toNormalize = new PackageJson()
    toNormalize.fromContent(node.package).normalize({ steps: ['normalizeData'] })
    node.package = toNormalize.content
  }

  let license = node.package?.license
  if (license) {
    if (typeof license === 'object') {
      license = license.type
    }
  } else if (Array.isArray(node.package?.licenses)) {
    license = node.package.licenses
      .map(l => (typeof l === 'object' ? l.type : l))
      .filter(Boolean)
      .join(' OR ')
  }

  let parsedLicense
  try {
    parsedLicense = parseLicense(license)
  } catch {
    parsedLicense = null
  }

  const component = {
    'bom-ref': toCyclonedxID(node),
    type: packageType,
    name: node.packageName,
    version: node.version,
    scope: (node.optional || node.devOptional) ? 'optional' : 'required',
    author: (typeof node.package?.author === 'object')
      ? node.package.author.name
      : (node.package?.author || undefined),
    description: node.package?.description || undefined,
    purl: purl,
    properties: [],
    externalReferences: [],
  }

  if (node.integrity) {
    const integrity = ssri.parse(node.integrity, { single: true })
    component.hashes = [{
      alg: ALGO_MAP[integrity.algorithm] || /* istanbul ignore next */ 'SHA-512',
      content: integrity.hexDigest(),
    }]
  }

  if (node.dev === true) {
    component.properties.push(prop(PROP_DEVELOPMENT))
  }

  if (node.package?.private === true) {
    component.properties.push(prop(PROP_PRIVATE))
  }

  if (node.extraneous === true) {
    component.properties.push(prop(PROP_EXTRANEOUS))
  }

  if (node.inBundle === true) {
    component.properties.push(prop(PROP_BUNDLED))
  }

  if (!node.isLink && node.resolved) {
    component.externalReferences.push(extRef(REF_DISTRIBUTION, node.resolved))
  }

  if (node.package?.repository?.url) {
    component.externalReferences.push(extRef(REF_VCS, node.package.repository.url))
  }

  if (node.package?.homepage) {
    component.externalReferences.push(extRef(REF_WEBSITE, node.package.homepage))
  }

  if (node.package?.bugs?.url) {
    component.externalReferences.push(extRef(REF_ISSUE_TRACKER, node.package.bugs.url))
  }

  // If license is a single SPDX license, use the license field
  if (parsedLicense?.license) {
    component.licenses = [{ license: { id: parsedLicense.license } }]
    // If license is a conjunction, use the expression field
  } else if (parsedLicense?.conjunction) {
    component.licenses = [{ expression: license }]
  }

  return component
}

const toCyclonedxDependency = (node, nodes) => {
  // A node can have multiple outgoing edges resolving to the same
  // `name@version` (e.g. via npm aliases like `foo: npm:bar@1` alongside a
  // direct `bar: ^1` dep), which would produce duplicate entries in
  // `dependsOn`. CycloneDX 1.5 requires unique items, so dedupe by ref.
  const dependsOn = [...new Set(
    [...node.edgesOut.values()]
      // Filter out edges that are linking to nodes not in the list
      .filter(edge => nodes.find(n => n === edge.to))
      .map(edge => toCyclonedxID(edge.to))
      .filter(id => id)
  )]
  return {
    ref: toCyclonedxID(node),
    dependsOn,
  }
}

const toCyclonedxID = (node) => `${node.packageName}@${node.version}`

const prop = (name) => ({ name, value: 'true' })

const extRef = (type, url) => ({ type, url })

const isGitNode = (node) => {
  if (!node.resolved) {
    return
  }

  try {
    const { type } = npa(node.resolved)
    return type === 'git' || type === 'hosted'
  } catch {
    /* istanbul ignore next */
    return false
  }
}

module.exports = { cyclonedxOutput }
