"""SBOM (Software Bill of Materials) parser for SPDX 2.x and CycloneDX JSON.

Extracts SHA-256 component hashes from standard SBOM formats for attestation
and verification via the CertiSigma API.  No external SBOM libraries required;
parsing uses the JSON stdlib only.

Supported formats:
  - SPDX 2.2 / 2.3 JSON  (ISO/IEC 5962)
  - CycloneDX 1.4 / 1.5 / 1.6 JSON  (OWASP / ECMA-424)
"""

from __future__ import annotations

import json
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

MAX_SBOM_FILE_BYTES = 100 * 1024 * 1024  # 100 MB

_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")


@dataclass
class SBOMComponent:
    """A single component extracted from an SBOM with its SHA-256 hash."""

    name: str
    version: str | None
    hash_hex: str
    purl: str | None = None
    component_type: str = "library"
    supplier: str | None = None


@dataclass
class SBOMDocument:
    """Parsed SBOM document with extracted components."""

    format: str
    spec_version: str
    name: str
    components: list[SBOMComponent] = field(default_factory=list)
    components_without_hash: int = 0
    creation_info: dict[str, Any] = field(default_factory=dict)


class SBOMParseError(Exception):
    """Raised when an SBOM file cannot be parsed."""


def _is_valid_sha256(value: str) -> bool:
    return bool(_SHA256_RE.match(value))


def _extract_sha256(
    checksums: list[dict[str, str]], alg_key: str, val_key: str
) -> str | None:
    """Extract SHA-256 value from a list of checksum dicts."""
    for cs in checksums:
        alg = cs.get(alg_key, "")
        if alg.upper().replace("-", "") == "SHA256":
            val = cs.get(val_key, "")
            if _is_valid_sha256(val):
                return val.lower()
    return None


def _parse_spdx(data: dict[str, Any]) -> SBOMDocument:
    """Parse an SPDX 2.x JSON document."""
    spec_version = data.get("spdxVersion", "unknown")
    doc_name = data.get("name", "unnamed")
    creation_info: dict[str, Any] = {}
    ci = data.get("creationInfo")
    if isinstance(ci, dict):
        creation_info = {
            "creators": ci.get("creators", []),
            "created": ci.get("created", ""),
        }

    components: list[SBOMComponent] = []
    skipped = 0

    for pkg in data.get("packages", []):
        if not isinstance(pkg, dict):
            continue
        name = pkg.get("name", "unknown")
        version = pkg.get("versionInfo")
        supplier_raw = pkg.get("supplier")
        supplier = supplier_raw if isinstance(supplier_raw, str) else None

        purl = None
        for ref in pkg.get("externalRefs", []):
            if isinstance(ref, dict) and ref.get("referenceType") == "purl":
                purl = ref.get("referenceLocator")
                break

        checksums = pkg.get("checksums", [])
        if not isinstance(checksums, list):
            checksums = []
        sha = _extract_sha256(checksums, "algorithm", "checksumValue")
        if sha:
            components.append(SBOMComponent(
                name=name,
                version=version,
                hash_hex=sha,
                purl=purl,
                component_type="package",
                supplier=supplier,
            ))
        else:
            skipped += 1

    for file_entry in data.get("files", []):
        if not isinstance(file_entry, dict):
            continue
        name = file_entry.get("fileName", "unknown")
        checksums = file_entry.get("checksums", [])
        if not isinstance(checksums, list):
            checksums = []
        sha = _extract_sha256(checksums, "algorithm", "checksumValue")
        if sha:
            components.append(SBOMComponent(
                name=name,
                version=None,
                hash_hex=sha,
                component_type="file",
            ))
        else:
            skipped += 1

    return SBOMDocument(
        format="spdx",
        spec_version=spec_version,
        name=doc_name,
        components=components,
        components_without_hash=skipped,
        creation_info=creation_info,
    )


def _parse_cyclonedx(data: dict[str, Any]) -> SBOMDocument:
    """Parse a CycloneDX JSON document."""
    spec_version = data.get("specVersion", "unknown")
    metadata = data.get("metadata", {})
    if not isinstance(metadata, dict):
        metadata = {}

    doc_name = "unnamed"
    mc = metadata.get("component")
    if isinstance(mc, dict):
        doc_name = mc.get("name", "unnamed")

    creation_info: dict[str, Any] = {}
    ts = metadata.get("timestamp")
    if ts:
        creation_info["timestamp"] = ts
    tools = metadata.get("tools")
    if tools:
        creation_info["tools"] = tools

    components: list[SBOMComponent] = []
    skipped = 0

    def _process_component(comp: dict[str, Any]) -> None:
        nonlocal skipped
        if not isinstance(comp, dict):
            return
        name = comp.get("name", "unknown")
        version = comp.get("version")
        comp_type = comp.get("type", "library")
        purl = comp.get("purl")
        supplier_obj = comp.get("supplier")
        supplier = None
        if isinstance(supplier_obj, dict):
            supplier = supplier_obj.get("name")
        elif isinstance(supplier_obj, str):
            supplier = supplier_obj

        hashes = comp.get("hashes", [])
        if not isinstance(hashes, list):
            hashes = []
        sha = _extract_sha256(hashes, "alg", "content")
        if sha:
            components.append(SBOMComponent(
                name=name,
                version=version,
                hash_hex=sha,
                purl=purl,
                component_type=comp_type,
                supplier=supplier,
            ))
        else:
            skipped += 1

    root_comp = metadata.get("component")
    if isinstance(root_comp, dict) and root_comp.get("hashes"):
        _process_component(root_comp)

    for comp in data.get("components", []):
        _process_component(comp)

    return SBOMDocument(
        format="cyclonedx",
        spec_version=spec_version,
        name=doc_name,
        components=components,
        components_without_hash=skipped,
        creation_info=creation_info,
    )


def detect_format(data: dict[str, Any]) -> str | None:
    """Auto-detect SBOM format from parsed JSON.

    Returns ``"spdx"``, ``"cyclonedx"``, or ``None`` if unrecognised.
    """
    if "spdxVersion" in data and "packages" in data:
        return "spdx"
    if str(data.get("bomFormat", "")).lower() == "cyclonedx":
        return "cyclonedx"
    if "specVersion" in data and "components" in data:
        return "cyclonedx"
    return None


def parse_sbom(
    path: Path, *, forced_format: str | None = None
) -> SBOMDocument:
    """Parse an SBOM file and return extracted components.

    Args:
        path: Path to the SBOM JSON file.
        forced_format: Force ``"spdx"`` or ``"cyclonedx"`` (skips auto-detection).

    Raises:
        SBOMParseError: If the file cannot be parsed.
        ValueError: If the file exceeds ``MAX_SBOM_FILE_BYTES``.
    """
    try:
        file_size = path.stat().st_size
    except OSError as exc:
        raise SBOMParseError(f"Cannot read SBOM file: {exc}") from exc

    if file_size > MAX_SBOM_FILE_BYTES:
        raise ValueError(
            f"SBOM file too large: {file_size:,} bytes "
            f"(max {MAX_SBOM_FILE_BYTES:,})"
        )

    try:
        raw = path.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError) as exc:
        raise SBOMParseError(f"Cannot read SBOM file: {exc}") from exc

    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise SBOMParseError(f"Invalid JSON: {exc}") from exc

    if not isinstance(data, dict):
        raise SBOMParseError("SBOM root must be a JSON object")

    fmt = forced_format or detect_format(data)
    if fmt is None:
        raise SBOMParseError(
            "Cannot auto-detect SBOM format. Use --format spdx or --format cyclonedx."
        )

    if fmt == "spdx":
        return _parse_spdx(data)
    if fmt == "cyclonedx":
        return _parse_cyclonedx(data)
    raise SBOMParseError(f"Unknown format: {fmt}")


def deduplicate_hashes(
    doc: SBOMDocument,
) -> tuple[list[str], dict[str, list[SBOMComponent]]]:
    """Deduplicate component hashes for batch API calls.

    Returns:
        Tuple of (unique hash list, mapping of hash -> components sharing it).
    """
    hash_map: dict[str, list[SBOMComponent]] = {}
    for comp in doc.components:
        hash_map.setdefault(comp.hash_hex, []).append(comp)
    return list(hash_map.keys()), hash_map
