import { createHash } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'

const root = resolve(new URL('..', import.meta.url).pathname)
const lockPath = resolve(root, 'package-lock.json')
const packagePath = resolve(root, 'package.json')
const outputPath = resolve(root, 'config/sbom.cdx.json')
const checkOnly = process.argv.includes('--check')

const readJson = async (path) => JSON.parse(await readFile(path, 'utf8'))
const packageNameFromPath = (packagePathName) => {
  const marker = 'node_modules/'
  const index = packagePathName.lastIndexOf(marker)
  const relative = packagePathName.slice(index + marker.length)
  const parts = relative.split('/')
  return parts[0].startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
}
const purlFor = (name, version) => `pkg:npm/${encodeURIComponent(name)}@${version}`
const manifestFor = async (packagePathName) => {
  try {
    return await readJson(resolve(root, packagePathName, 'package.json'))
  } catch {
    return undefined
  }
}
const licenseEntries = (packageManifest) => {
  const declared = packageManifest?.license ?? packageManifest?.licenses
  const values = Array.isArray(declared) ? declared : declared ? [declared] : []
  return values.flatMap((value) => {
    const text = typeof value === 'string' ? value : value?.type ?? value?.name
    if (!text) return []
    return /^[A-Za-z0-9.+-]+$/.test(text) ? [{ license: { id: text } }] : [{ license: { name: text } }]
  })
}

const lock = await readJson(lockPath)
const manifest = await readJson(packagePath)
if (lock.lockfileVersion !== 3) throw new Error(`SBOM requires npm lockfileVersion 3 (got ${lock.lockfileVersion}).`)
const lockPackages = lock.packages ?? {}
const rootRef = purlFor(manifest.name, manifest.version)
const packageEntries = Object.entries(lockPackages)
  .filter(([path, value]) => path.startsWith('node_modules/') && value?.version)
  .sort(([left], [right]) => left.localeCompare(right))

const components = []
const packageRefsByPath = new Map()
const metadataByPath = new Map()
for (const [packagePathName, lockEntry] of packageEntries) {
  const name = packageNameFromPath(packagePathName)
  if (name !== lockEntry.name && lockEntry.name) throw new Error(`SBOM package path/name mismatch: ${packagePathName} -> ${lockEntry.name}`)
  const ref = purlFor(name, lockEntry.version)
  packageRefsByPath.set(packagePathName, ref)
  const packageManifest = await manifestFor(packagePathName)
  const installed = Boolean(packageManifest)
  if (installed && packageManifest.version !== lockEntry.version) throw new Error(`SBOM installed version mismatch for ${name}: ${packageManifest.version} != ${lockEntry.version}`)
  const component = {
    type: 'library',
    'bom-ref': ref,
    name,
    version: lockEntry.version,
    scope: lockEntry.optional ? 'optional' : lockEntry.dev ? 'development' : 'required',
    purl: ref,
    hashes: [{ alg: 'SHA-512', content: lockEntry.integrity.replace(/^sha512-/, '') }],
    externalReferences: [{ type: 'distribution', url: lockEntry.resolved }],
  }
  const licenses = licenseEntries(packageManifest)
  if (licenses.length) component.licenses = licenses
  component.properties = [
    { name: 'bitbybit:installed', value: String(installed) },
    { name: 'bitbybit:license-status', value: licenses.length ? 'declared' : installed ? 'undisclosed' : 'not-installed-optional' },
  ]
  components.push(component)
  metadataByPath.set(packagePathName, { lockEntry, ref })
}

const resolveDependencyPath = (fromPath, dependencyName) => {
  const candidates = []
  let current = fromPath
  while (current) {
    candidates.push(`${current}/node_modules/${dependencyName}`)
    const parent = current.lastIndexOf('/node_modules/')
    if (parent < 0) break
    current = current.slice(0, parent)
  }
  candidates.push(`node_modules/${dependencyName}`)
  return candidates.find((candidate) => packageRefsByPath.has(candidate))
}
const dependencyRecords = [{ ref: rootRef, dependsOn: [] }]
for (const [packagePathName, { lockEntry, ref }] of metadataByPath) {
  const dependencies = { ...(lockEntry.dependencies ?? {}), ...(lockEntry.optionalDependencies ?? {}) }
  const dependsOn = Object.keys(dependencies).sort().flatMap((dependencyName) => {
    const resolvedPath = resolveDependencyPath(packagePathName, dependencyName)
    return resolvedPath ? [packageRefsByPath.get(resolvedPath)] : []
  })
  dependencyRecords.push({ ref, dependsOn: [...new Set(dependsOn)].sort() })
}
const directDependencies = [...new Set([
  ...Object.keys(manifest.dependencies ?? {}),
  ...Object.keys(manifest.devDependencies ?? {}),
])].sort()
for (const dependencyName of directDependencies) {
  const directPath = `node_modules/${dependencyName}`
  if (!packageRefsByPath.has(directPath)) throw new Error(`SBOM direct dependency is not locked: ${dependencyName}`)
  dependencyRecords[0].dependsOn.push(packageRefsByPath.get(directPath))
}
dependencyRecords[0].dependsOn = [...new Set(dependencyRecords[0].dependsOn)].sort()

const lockHash = createHash('sha256').update(await readFile(lockPath)).digest('hex')
const rootComponent = {
  type: 'application',
  'bom-ref': rootRef,
  name: manifest.name,
  version: manifest.version,
  purl: rootRef,
  properties: [{ name: 'bitbybit:license-status', value: manifest.license ? 'declared' : 'undisclosed' }],
}
const document = {
  bomFormat: 'CycloneDX',
  specVersion: '1.5',
  version: 1,
  metadata: {
    tools: [{ vendor: 'BitBybit Web CAD', name: 'generate-sbom.mjs', version: '1' }],
    component: rootComponent,
    properties: [
      { name: 'bitbybit:source', value: 'package-lock.json' },
      { name: 'bitbybit:lockfile-sha256', value: lockHash },
      { name: 'bitbybit:package-manager', value: 'npm lockfileVersion 3' },
    ],
  },
  components,
  dependencies: dependencyRecords.sort((left, right) => left.ref.localeCompare(right.ref)),
}
const serialized = `${JSON.stringify(document, null, 2)}\n`
if (checkOnly) {
  let existing
  try {
    existing = await readFile(outputPath, 'utf8')
  } catch {
    throw new Error(`SBOM artifact is missing: ${outputPath}`)
  }
  if (existing !== serialized) throw new Error('SBOM artifact is stale; run `npm run generate:sbom` and review the result.')
  const undisclosed = components.filter((component) => component.properties?.some((property) => property.name === 'bitbybit:license-status' && property.value === 'undisclosed'))
  if (undisclosed.length) throw new Error(`SBOM has ${undisclosed.length} installed packages without declared licenses.`)
  console.log(JSON.stringify({ status: 'sbom-pass', components: components.length, optionalNotInstalled: components.filter((component) => component.properties?.some((property) => property.value === 'not-installed-optional')).length, lockfileSha256: lockHash }, null, 2))
} else {
  await writeFile(outputPath, serialized)
  console.log(JSON.stringify({ status: 'sbom-generated', output: outputPath, components: components.length, lockfileSha256: lockHash }, null, 2))
}
