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

"""OSV.dev client for vulnerability lookups against SBOM components.

OSV (osv.dev) is the Google-run aggregator over NVD, GitHub Security Advisory,
PyPA, RustSec, and ecosystem-native databases. We use it because:

- One API for every ecosystem we parse (npm, PyPI, Go, Cargo, Maven, RubyGems,
  Packagist) instead of stitching together five different feeds.
- Free, no auth required, generous rate limits.
- Returns aliases (CVE / GHSA / OSV IDs) so the SBOM consumer can pivot to any
  database they prefer.

Design notes:

- Stdlib-only HTTP via :mod:`urllib.request` so the SBOM module stays
  dependency-free.
- Disk cache at ``~/.devseccode/cache/osv/`` keyed by ``ecosystem/name@version``
  hashed to a filename. Cache TTL is 24 hours by default — short enough that a
  freshly published advisory is picked up the next day, long enough that a CI
  job hitting OSV every commit doesn't pound the upstream.
- Offline fallback: if HTTP fails (network disabled, OSV down) and the cache
  has any entry — even an expired one — we serve that. Empty cache + offline =
  empty result, never a crash.
- Each ``query_batch`` call is bounded to 100 packages per request (OSV's
  documented batch limit). Larger inputs are chunked.

Public surface:

- :class:`OSVVulnerability` — typed view of a single advisory.
- :class:`OSVClient` — main entry point. ``query(component)`` returns a list
  of vulns for that one component; ``query_batch(components)`` is the
  efficient bulk form.
"""

from __future__ import annotations

import hashlib
import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path

from dsc.sbom.collector import Component


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_OSV_QUERY_URL = "https://api.osv.dev/v1/query"
_OSV_QUERYBATCH_URL = "https://api.osv.dev/v1/querybatch"
_OSV_VULN_URL = "https://api.osv.dev/v1/vulns/"

# OSV's batch endpoint accepts at most 1000 queries; we use 100 to keep
# request bodies small and avoid timeouts on slow links.
_BATCH_SIZE = 100

# Default disk cache root. Overridable via env or constructor for tests.
_DEFAULT_CACHE_ROOT = Path.home() / ".devseccode" / "cache" / "osv"

# Cache TTL: 24 hours. Advisories rarely change after publication, but
# severity ratings get backfilled and we don't want to miss those for
# more than a day.
_DEFAULT_TTL_SECONDS = 24 * 60 * 60

# HTTP timeout (seconds). OSV is usually <500ms; we cap at 10s so a
# misbehaving network can't stall a scan indefinitely.
_HTTP_TIMEOUT = 10.0

# Map our internal ecosystem identifiers (set by the manifest parsers) to
# the canonical names OSV expects. OSV is picky about case: ``"PyPI"`` and
# ``"crates.io"`` must be passed verbatim or no advisories come back.
_ECOSYSTEM_MAP = {
	"npm": "npm",
	"pypi": "PyPI",
	"go": "Go",
	"cargo": "crates.io",
	"maven": "Maven",
	"rubygems": "RubyGems",
	"composer": "Packagist",
}


# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class OSVVulnerability:
	"""A single advisory returned by OSV.

	We pull only the fields needed to emit findings and SBOM vulnerability
	references. Full advisory text (``details``, ``references``) is preserved
	via :attr:`raw` for callers that want to render it.
	"""

	id: str  # Primary OSV ID, e.g. "GHSA-xxx" or "PYSEC-2023-1"
	summary: str = ""
	severity: str = ""  # "critical" | "high" | "medium" | "low" | ""
	cvss_score: float | None = None
	cwes: tuple[str, ...] = field(default_factory=tuple)
	aliases: tuple[str, ...] = field(default_factory=tuple)
	cve_ids: tuple[str, ...] = field(default_factory=tuple)
	fixed_versions: tuple[str, ...] = field(default_factory=tuple)
	modified: str = ""  # ISO 8601 last-modified
	published: str = ""  # ISO 8601 publication
	raw: dict = field(default_factory=dict, compare=False, hash=False)


# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------


class OSVClient:
	"""Thin client wrapping the OSV.dev REST API.

	Parameters
	----------
	cache_dir
		Disk cache root. Defaults to ``~/.devseccode/cache/osv/``.
	ttl_seconds
		Max cache age before re-querying. Set to 0 to bypass cache.
	offline
		Skip all network calls and serve from cache only. Used by air-gapped
		CI and the ``--offline`` CLI flag.
	user_agent
		Sent as ``User-Agent`` so OSV's rate-limit metrics can attribute us.
	timeout
		HTTP timeout in seconds.
	"""

	def __init__(
		self,
		*,
		cache_dir: Path | None = None,
		ttl_seconds: int = _DEFAULT_TTL_SECONDS,
		offline: bool = False,
		user_agent: str = "deva-sbom/1.0",
		timeout: float = _HTTP_TIMEOUT,
	) -> None:
		self.cache_dir = cache_dir or _DEFAULT_CACHE_ROOT
		self.ttl_seconds = max(0, int(ttl_seconds))
		self.offline = bool(offline)
		self.user_agent = user_agent
		self.timeout = timeout

		try:
			self.cache_dir.mkdir(parents=True, exist_ok=True)
		except OSError:
			# A read-only home (CI sandbox) shouldn't crash the scanner.
			# In that case we silently degrade to a no-cache mode.
			self.ttl_seconds = 0

	# ------------------------------------------------------------------
	# Public API
	# ------------------------------------------------------------------

	def query(self, component: Component) -> list[OSVVulnerability]:
		"""Return advisories affecting *component*.

		Empty list when the component's ecosystem isn't supported by OSV
		or no advisories apply. Never raises on network errors.
		"""

		ecosystem = _ECOSYSTEM_MAP.get(component.ecosystem)
		if not ecosystem or not component.name or not component.version:
			return []

		key = self._cache_key(ecosystem, component.name, component.version)
		cached = self._read_cache(key)
		if cached is not None:
			return [_parse_vuln(v) for v in cached]

		if self.offline:
			# Cache miss in offline mode -> empty result.
			return []

		body = {
			"package": {"name": component.name, "ecosystem": ecosystem},
			"version": component.version,
		}
		response = self._post_json(_OSV_QUERY_URL, body)
		if response is None:
			# Network failure: serve a stale cache entry if one exists.
			stale = self._read_cache(key, ignore_ttl=True)
			if stale is not None:
				return [_parse_vuln(v) for v in stale]
			return []

		vulns = response.get("vulns") or []
		self._write_cache(key, vulns)
		return [_parse_vuln(v) for v in vulns]

	def query_batch(
		self, components: list[Component],
	) -> dict[tuple[str, str, str], list[OSVVulnerability]]:
		"""Bulk lookup. Returns a map of ``(ecosystem, name, version) -> [vulns]``.

		Components in unsupported ecosystems are silently dropped. The cache
		is consulted before issuing any HTTP request, so a re-run of the
		same SBOM is essentially free.
		"""

		results: dict[tuple[str, str, str], list[OSVVulnerability]] = {}
		# Components that need a network round-trip.
		todo: list[tuple[Component, str, str]] = []

		for c in components:
			ecosystem = _ECOSYSTEM_MAP.get(c.ecosystem)
			if not ecosystem or not c.name or not c.version:
				continue
			key = self._cache_key(ecosystem, c.name, c.version)
			cached = self._read_cache(key)
			if cached is not None:
				results[(c.ecosystem, c.name, c.version)] = [
					_parse_vuln(v) for v in cached
				]
				continue
			todo.append((c, ecosystem, key))

		if not todo or self.offline:
			# Offline: anything not in cache stays empty.
			for c, _ecosystem, _key in todo:
				results.setdefault((c.ecosystem, c.name, c.version), [])
			return results

		# Chunk into batches OSV will accept.
		for chunk_start in range(0, len(todo), _BATCH_SIZE):
			chunk = todo[chunk_start : chunk_start + _BATCH_SIZE]
			body = {
				"queries": [
					{
						"package": {"name": c.name, "ecosystem": ecosystem},
						"version": c.version,
					}
					for c, ecosystem, _key in chunk
				]
			}
			response = self._post_json(_OSV_QUERYBATCH_URL, body)
			if response is None:
				# Network failure on this chunk: serve stale cache where we have
				# it, otherwise fall back to empty.
				for c, _ecosystem, key in chunk:
					stale = self._read_cache(key, ignore_ttl=True) or []
					results.setdefault(
						(c.ecosystem, c.name, c.version),
						[_parse_vuln(v) for v in stale],
					)
				continue

			batch_results = response.get("results") or []
			# OSV returns one entry per query in the same order. Each entry
			# is ``{"vulns": [{"id": "...", "modified": "..."}]}`` — a stub
			# without full details. We have to look up each vuln by ID for
			# the full advisory.
			for (c, _ecosystem, key), entry in zip(chunk, batch_results):
				stubs = entry.get("vulns") or []
				if not stubs:
					self._write_cache(key, [])
					results[(c.ecosystem, c.name, c.version)] = []
					continue
				full: list[dict] = []
				for stub in stubs:
					vuln_id = stub.get("id") if isinstance(stub, dict) else None
					if not isinstance(vuln_id, str):
						continue
					detail = self._fetch_vuln(vuln_id)
					if detail is not None:
						full.append(detail)
				self._write_cache(key, full)
				results[(c.ecosystem, c.name, c.version)] = [
					_parse_vuln(v) for v in full
				]

		return results

	# ------------------------------------------------------------------
	# HTTP plumbing
	# ------------------------------------------------------------------

	def _post_json(self, url: str, body: dict) -> dict | None:
		"""POST JSON to *url* and return the parsed response, or ``None`` on failure."""

		data = json.dumps(body).encode("utf-8")
		req = urllib.request.Request(
			url,
			data=data,
			headers={
				"Content-Type": "application/json",
				"User-Agent": self.user_agent,
			},
			method="POST",
		)
		try:
			with urllib.request.urlopen(req, timeout=self.timeout) as resp:
				if resp.status != 200:
					return None
				return json.loads(resp.read().decode("utf-8"))
		except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError):
			return None

	def _fetch_vuln(self, vuln_id: str) -> dict | None:
		"""GET the full advisory record for *vuln_id*.

		Per-vuln cache lives alongside per-component cache so a single
		advisory affecting many packages is fetched only once.
		"""
		safe = vuln_id.replace("/", "_")
		cache_path = self.cache_dir / "vulns" / f"{safe}.json"
		if cache_path.exists() and self._is_fresh(cache_path):
			try:
				return json.loads(cache_path.read_text(encoding="utf-8"))
			except (OSError, json.JSONDecodeError):
				pass

		req = urllib.request.Request(
			_OSV_VULN_URL + vuln_id,
			headers={"User-Agent": self.user_agent},
		)
		try:
			with urllib.request.urlopen(req, timeout=self.timeout) as resp:
				if resp.status != 200:
					return None
				data = json.loads(resp.read().decode("utf-8"))
		except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError):
			# Fall back to a stale cache hit if we have one.
			if cache_path.exists():
				try:
					return json.loads(cache_path.read_text(encoding="utf-8"))
				except (OSError, json.JSONDecodeError):
					return None
			return None

		try:
			cache_path.parent.mkdir(parents=True, exist_ok=True)
			cache_path.write_text(json.dumps(data), encoding="utf-8")
		except OSError:
			pass
		return data

	# ------------------------------------------------------------------
	# Disk cache
	# ------------------------------------------------------------------

	def _cache_key(self, ecosystem: str, name: str, version: str) -> str:
		raw = f"{ecosystem}|{name}|{version}".encode("utf-8")
		return hashlib.sha256(raw).hexdigest()[:24]

	def _cache_path(self, key: str) -> Path:
		return self.cache_dir / "components" / f"{key}.json"

	def _is_fresh(self, path: Path) -> bool:
		if self.ttl_seconds == 0:
			return False
		try:
			age = time.time() - path.stat().st_mtime
		except OSError:
			return False
		return age < self.ttl_seconds

	def _read_cache(self, key: str, *, ignore_ttl: bool = False) -> list[dict] | None:
		path = self._cache_path(key)
		if not path.exists():
			return None
		if not ignore_ttl and not self._is_fresh(path):
			return None
		try:
			data = json.loads(path.read_text(encoding="utf-8"))
		except (OSError, json.JSONDecodeError):
			return None
		if not isinstance(data, list):
			return None
		return data

	def _write_cache(self, key: str, vulns: list) -> None:
		if self.ttl_seconds == 0:
			return
		path = self._cache_path(key)
		try:
			path.parent.mkdir(parents=True, exist_ok=True)
			# Atomic write: tmp + rename so a SIGINT mid-write doesn't leave a
			# corrupt cache file the next run will treat as authoritative.
			tmp = path.with_suffix(".json.tmp")
			tmp.write_text(json.dumps(vulns), encoding="utf-8")
			os.replace(tmp, path)
		except OSError:
			pass


# ---------------------------------------------------------------------------
# OSV record parser
# ---------------------------------------------------------------------------


def _parse_vuln(raw: dict) -> OSVVulnerability:
	"""Translate one OSV record into an :class:`OSVVulnerability`."""

	vuln_id = str(raw.get("id") or "")
	summary = str(raw.get("summary") or "")
	aliases = tuple(a for a in (raw.get("aliases") or []) if isinstance(a, str))
	cve_ids = tuple(a for a in aliases if a.startswith("CVE-"))

	# Severity: prefer the typed CVSS score from ``severity[]`` then fall
	# back to the database_specific.severity rating (used by GHSA).
	cvss_score: float | None = None
	severity_label = ""
	for sev in raw.get("severity") or []:
		if not isinstance(sev, dict):
			continue
		score = sev.get("score")
		if not isinstance(score, str):
			continue
		# Accept either "CVSS:3.1/..." (spec form) or "9.8 CVSS:3.1/..."
		# (some advisories prefix the numeric score before the vector).
		if score.startswith("CVSS:") or score[0:1].isdigit():
			cvss_score = _cvss_base_score(score) or cvss_score
	dbspec = raw.get("database_specific") or {}
	if isinstance(dbspec, dict):
		raw_label = dbspec.get("severity")
		if isinstance(raw_label, str):
			severity_label = raw_label.lower()
	if not severity_label and cvss_score is not None:
		severity_label = _cvss_to_label(cvss_score)

	# CWEs are usually under affected[].database_specific.cwe_ids or
	# database_specific.cwe_ids at the top level.
	cwes: list[str] = []
	for cwe in dbspec.get("cwe_ids") or []:
		if isinstance(cwe, str):
			cwes.append(cwe)
	for affected in raw.get("affected") or []:
		if not isinstance(affected, dict):
			continue
		aff_db = affected.get("database_specific") or {}
		for cwe in aff_db.get("cwe_ids") or []:
			if isinstance(cwe, str) and cwe not in cwes:
				cwes.append(cwe)

	# Fixed versions: walk ``affected[].ranges[].events[]`` and pull every
	# ``fixed`` event. Order matters less than completeness so we dedupe.
	fixed_versions: list[str] = []
	for affected in raw.get("affected") or []:
		if not isinstance(affected, dict):
			continue
		for rng in affected.get("ranges") or []:
			if not isinstance(rng, dict):
				continue
			for event in rng.get("events") or []:
				if not isinstance(event, dict):
					continue
				fix = event.get("fixed")
				if isinstance(fix, str) and fix not in fixed_versions:
					fixed_versions.append(fix)

	return OSVVulnerability(
		id=vuln_id,
		summary=summary,
		severity=severity_label,
		cvss_score=cvss_score,
		cwes=tuple(cwes),
		aliases=aliases,
		cve_ids=cve_ids,
		fixed_versions=tuple(fixed_versions),
		modified=str(raw.get("modified") or ""),
		published=str(raw.get("published") or ""),
		raw=raw,
	)


def _cvss_base_score(vector: str) -> float | None:
	"""Pull the numeric base score out of a CVSS vector string.

	OSV always includes the score as a header on the vector when populated,
	but some advisories embed only the vector. We don't reimplement the CVSS
	specification — for the SBOM use-case the numeric label is good enough
	to bucket findings by severity. If the score isn't preformatted we fall
	back to ``None``.
	"""
	# CVSS strings from OSV occasionally come prefixed with the score:
	# "9.8 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" — so check first.
	parts = vector.split(" ", 1)
	if parts and parts[0]:
		try:
			value = float(parts[0])
			if 0 <= value <= 10:
				return value
		except ValueError:
			pass
	return None


def _cvss_to_label(score: float) -> str:
	"""Map a numeric CVSS base score to a string severity bucket."""
	if score >= 9.0:
		return "critical"
	if score >= 7.0:
		return "high"
	if score >= 4.0:
		return "medium"
	if score > 0:
		return "low"
	return ""
