# -------------------------------------------------------------------------------------
#  Copyright (c) DevSecCode. All rights reserved.
#  Licensed under the MIT License. See LICENSE.txt in the project root for license
#  information.
# -------------------------------------------------------------------------------------

"""CycloneDX 1.5 serializer.

Produces a CycloneDX JSON document (spec: https://cyclonedx.org/docs/1.5/json/)
suitable for distribution alongside a release or uploading to dependency-track,
Grype, Snyk, or any SLSA-compliant consumer.

We stay on the lean side of the spec -- components + metadata + top-level
bomFormat fields -- to keep the output deterministic and avoid fabricating
fields (hashes, signatures) we don't actually have.
"""

from __future__ import annotations

import json
import uuid
from datetime import datetime, timezone
from pathlib import Path

from dsc.sbom.collector import Component
from dsc.sbom.osv_client import OSVVulnerability
from dsc.version import __version__


def format_cyclonedx(
	components: list[Component],
	root: Path,
	*,
	vulnerabilities: dict[tuple[str, str, str], list[OSVVulnerability]] | None = None,
) -> str:
	"""Serialize *components* as a CycloneDX 1.5 JSON document.

	When *vulnerabilities* is supplied (mapping ``(ecosystem, name, version) ->
	[OSVVulnerability]``), a top-level ``vulnerabilities`` array is included
	per the CycloneDX 1.5 VEX-light shape so downstream consumers can read
	advisories without a separate scan.
	"""

	root = root.resolve()
	now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

	bom = {
		"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
		"bomFormat": "CycloneDX",
		"specVersion": "1.5",
		"serialNumber": f"urn:uuid:{uuid.uuid4()}",
		"version": 1,
		"metadata": {
			"timestamp": now_iso,
			"tools": [
				{
					"vendor": "DevSecCode",
					"name": "dsc",
					"version": __version__,
				}
			],
			"component": {
				"type": "application",
				"bom-ref": f"root-{root.name}",
				"name": root.name or "workspace",
			},
		},
		"components": [_component_entry(c) for c in components],
	}

	if vulnerabilities:
		bom["vulnerabilities"] = list(_iter_vuln_entries(components, vulnerabilities))

	return json.dumps(bom, indent=2, sort_keys=False) + "\n"


def _component_entry(c: Component) -> dict:
	entry: dict = {
		"type": "library",
		"bom-ref": c.purl or f"{c.ecosystem}/{c.name}@{c.version}",
		"name": c.name,
		"version": c.version,
		"purl": c.purl,
		"properties": [
			{"name": "dsc:ecosystem", "value": c.ecosystem},
			{"name": "dsc:manifest", "value": c.manifest},
			{"name": "dsc:relation", "value": "direct" if c.direct else "transitive"},
			{"name": "dsc:scope", "value": "dev" if c.dev_only else "runtime"},
			*([{"name": "dsc:manifest_section", "value": c.scope}] if c.scope else []),
		],
	}

	if c.license:
		entry["licenses"] = [_license_entry(c.license)]

	return entry


def _license_entry(raw_license: str) -> dict:
	"""Wrap a license string in CycloneDX's license choice structure.

	Spec allows either ``{"license": {"id": "<spdx>"}}`` or ``{"license": {"name": "..."}}``.
	We pick ``id`` when the value looks like a well-known SPDX identifier,
	otherwise fall back to ``name`` so downstream consumers don't choke on
	non-SPDX values.
	"""

	if _looks_like_spdx(raw_license):
		return {"license": {"id": raw_license}}
	return {"license": {"name": raw_license}}


# Rough-and-ready SPDX identifier set -- the most commonly-seen licenses in
# npm/PyPI/crates.  A user who needs the full SPDX list can add entries;
# we intentionally avoid pulling the ~500-line catalog at import time.
_KNOWN_SPDX_IDS = frozenset({
	"0BSD", "AGPL-3.0", "AGPL-3.0-only", "AGPL-3.0-or-later",
	"Apache-1.1", "Apache-2.0",
	"BSD-2-Clause", "BSD-3-Clause", "BSD-4-Clause",
	"BSL-1.0", "CC-BY-3.0", "CC-BY-4.0", "CC0-1.0",
	"CDDL-1.0", "CDDL-1.1",
	"EPL-1.0", "EPL-2.0",
	"GPL-2.0", "GPL-2.0-only", "GPL-2.0-or-later",
	"GPL-3.0", "GPL-3.0-only", "GPL-3.0-or-later",
	"ISC", "LGPL-2.1", "LGPL-2.1-only", "LGPL-2.1-or-later",
	"LGPL-3.0", "LGPL-3.0-only", "LGPL-3.0-or-later",
	"MIT", "MPL-1.1", "MPL-2.0",
	"Unlicense", "WTFPL", "Zlib",
})


def _looks_like_spdx(raw: str) -> bool:
	value = raw.strip()
	return value in _KNOWN_SPDX_IDS


# ---------------------------------------------------------------------------
# Vulnerability serialization (CycloneDX 1.5 vulnerabilities[] block)
# ---------------------------------------------------------------------------


def _iter_vuln_entries(
	components: list[Component],
	vulnerabilities: dict[tuple[str, str, str], list[OSVVulnerability]],
):
	"""Yield CycloneDX vulnerability entries for affected components.

	One entry per advisory; affected components are expressed via the
	``affects[].ref`` field which points back to the component's bom-ref.
	"""

	# Group OSV vulns by id so a single advisory affecting multiple
	# components becomes one CycloneDX entry with a multi-component
	# ``affects`` array.
	by_id: dict[str, dict] = {}

	# Quick lookup from (ecosystem, name, version) -> bom-ref string.
	bom_refs: dict[tuple[str, str, str], str] = {}
	for c in components:
		key = (c.ecosystem, c.name, c.version)
		bom_refs[key] = c.purl or f"{c.ecosystem}/{c.name}@{c.version}"

	for key, vulns in vulnerabilities.items():
		ref = bom_refs.get(key)
		if not ref:
			continue
		for v in vulns:
			entry = by_id.get(v.id)
			if entry is None:
				entry = {
					"bom-ref": v.id,
					"id": v.id,
					"source": {"name": "osv.dev"},
					"description": v.summary,
					"affects": [],
				}
				if v.cve_ids:
					entry["references"] = [
						{
							"id": cve,
							"source": {"name": "NVD", "url": f"https://nvd.nist.gov/vuln/detail/{cve}"},
						}
						for cve in v.cve_ids
					]
				if v.cwes:
					# CycloneDX takes integer CWE IDs (just the numeric part).
					cwe_ints: list[int] = []
					for cwe in v.cwes:
						numeric = cwe.replace("CWE-", "")
						if numeric.isdigit():
							cwe_ints.append(int(numeric))
					if cwe_ints:
						entry["cwes"] = cwe_ints
				if v.cvss_score is not None or v.severity:
					entry["ratings"] = [
						{
							"source": {"name": "osv.dev"},
							"severity": (v.severity or "unknown").lower(),
							**({"score": v.cvss_score} if v.cvss_score is not None else {}),
							**(
								{"method": "CVSSv3"}
								if v.cvss_score is not None
								else {}
							),
						}
					]
				if v.published:
					entry["published"] = v.published
				if v.modified:
					entry["updated"] = v.modified
				by_id[v.id] = entry
			entry["affects"].append({"ref": ref})

	# Stable order: by id so output is reproducible.
	for vuln_id in sorted(by_id.keys()):
		yield by_id[vuln_id]
