# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""SBOM generators for CycloneDX and SPDX formats.

Generates Software Bill of Materials in industry-standard formats:
- CycloneDX 1.5 (JSON and XML)
- SPDX 2.3 (JSON and tag-value)
"""

from __future__ import annotations

import hashlib
import json
import uuid
import xml.etree.ElementTree as ET
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any

from mergeguide.scanning import Dependency, Ecosystem, ManifestParser

if TYPE_CHECKING:
    pass


class SBOMFormat(Enum):
    """Supported SBOM output formats."""

    CYCLONEDX_JSON = "cyclonedx-json"
    CYCLONEDX_XML = "cyclonedx-xml"
    SPDX_JSON = "spdx-json"
    SPDX_TAG_VALUE = "spdx-tag-value"


@dataclass
class SBOMMetadata:
    """Metadata for the SBOM document."""

    name: str = ""
    version: str = ""
    description: str = ""
    author: str = "MergeGuide"
    timestamp: str = ""
    serial_number: str = ""

    def __post_init__(self) -> None:
        if not self.timestamp:
            self.timestamp = datetime.now(UTC).isoformat()
        if not self.serial_number:
            self.serial_number = f"urn:uuid:{uuid.uuid4()}"


@dataclass
class SBOM:
    """Software Bill of Materials container."""

    metadata: SBOMMetadata
    dependencies: list[Dependency] = field(default_factory=list)
    files_scanned: list[str] = field(default_factory=list)


class SBOMGenerator(ABC):
    """Abstract base class for SBOM generators."""

    @abstractmethod
    def generate(self, sbom: SBOM) -> str:
        """Generate SBOM output.

        Args:
            sbom: The SBOM data to format

        Returns:
            Formatted SBOM string
        """
        pass

    @staticmethod
    def _ecosystem_to_purl_type(ecosystem: Ecosystem) -> str:
        """Map ecosystem to Package URL type."""
        mapping = {
            Ecosystem.NPM: "npm",
            Ecosystem.PYPI: "pypi",
            Ecosystem.GO: "golang",
            Ecosystem.CARGO: "cargo",
            Ecosystem.MAVEN: "maven",
            Ecosystem.RUBYGEMS: "gem",
            Ecosystem.PACKAGIST: "composer",
            Ecosystem.NUGET: "nuget",
        }
        return mapping.get(ecosystem, "generic")

    @staticmethod
    def _generate_purl(dep: Dependency) -> str:
        """Generate Package URL (purl) for a dependency.

        Format: pkg:type/namespace/name@version
        """
        purl_type = SBOMGenerator._ecosystem_to_purl_type(dep.ecosystem)
        name = dep.name

        # Handle namespaced packages
        if dep.ecosystem == Ecosystem.MAVEN and ":" in name:
            # Maven: group:artifact -> pkg:maven/group/artifact@version
            parts = name.split(":")
            if len(parts) == 2:
                return f"pkg:{purl_type}/{parts[0]}/{parts[1]}@{dep.version}"

        if dep.ecosystem == Ecosystem.NPM and name.startswith("@"):
            # Scoped npm: @scope/name -> pkg:npm/%40scope/name@version
            return f"pkg:{purl_type}/{name.replace('@', '%40')}@{dep.version}"

        if dep.ecosystem == Ecosystem.PACKAGIST and "/" in name:
            # Composer: vendor/package -> pkg:composer/vendor/package@version
            return f"pkg:{purl_type}/{name}@{dep.version}"

        return f"pkg:{purl_type}/{name}@{dep.version}"

    @staticmethod
    def _generate_bom_ref(dep: Dependency) -> str:
        """Generate a unique reference ID for a component."""
        key = f"{dep.ecosystem.value}:{dep.name}:{dep.version}"
        return hashlib.sha256(key.encode()).hexdigest()[:16]


class CycloneDXGenerator(SBOMGenerator):
    """CycloneDX 1.5 format SBOM generator."""

    SPEC_VERSION = "1.5"
    SCHEMA_URL = "http://cyclonedx.org/schema/bom-1.5.schema.json"

    def generate(self, sbom: SBOM) -> str:
        """Generate CycloneDX JSON format."""
        bom = self._build_bom(sbom)
        return json.dumps(bom, indent=2, ensure_ascii=False)

    def generate_xml(self, sbom: SBOM) -> str:
        """Generate CycloneDX XML format."""
        root = self._build_xml_bom(sbom)
        # Use ElementTree to generate XML string
        ET.indent(root, space="  ")
        return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode")

    def _build_bom(self, sbom: SBOM) -> dict[str, Any]:
        """Build CycloneDX BOM structure."""
        return {
            "$schema": self.SCHEMA_URL,
            "bomFormat": "CycloneDX",
            "specVersion": self.SPEC_VERSION,
            "serialNumber": sbom.metadata.serial_number,
            "version": 1,
            "metadata": self._build_metadata(sbom.metadata),
            "components": [self._build_component(dep) for dep in sbom.dependencies],
        }

    def _build_metadata(self, meta: SBOMMetadata) -> dict[str, Any]:
        """Build metadata section."""
        result: dict[str, Any] = {
            "timestamp": meta.timestamp,
            "tools": [
                {
                    "vendor": "MergeGuide",
                    "name": "mergeguide",
                    "version": "0.2.0",
                }
            ],
        }

        if meta.name:
            result["component"] = {
                "type": "application",
                "name": meta.name,
                "version": meta.version or "0.0.0",
            }
            if meta.description:
                result["component"]["description"] = meta.description

        return result

    def _build_component(self, dep: Dependency) -> dict[str, Any]:
        """Build component entry for a dependency."""
        component: dict[str, Any] = {
            "type": "library",
            "bom-ref": self._generate_bom_ref(dep),
            "name": dep.name,
            "version": dep.version,
            "purl": self._generate_purl(dep),
        }

        # Add scope for dev dependencies
        if dep.is_dev:
            component["scope"] = "optional"

        # Add external references
        component["externalReferences"] = [
            {
                "type": "distribution",
                "url": self._get_registry_url(dep),
            }
        ]

        return component

    def _get_registry_url(self, dep: Dependency) -> str:
        """Get package registry URL for a dependency."""
        urls = {
            Ecosystem.NPM: f"https://www.npmjs.com/package/{dep.name}",
            Ecosystem.PYPI: f"https://pypi.org/project/{dep.name}/",
            Ecosystem.GO: f"https://pkg.go.dev/{dep.name}",
            Ecosystem.CARGO: f"https://crates.io/crates/{dep.name}",
            Ecosystem.MAVEN: f"https://search.maven.org/artifact/{dep.name.replace(':', '/')}",
            Ecosystem.RUBYGEMS: f"https://rubygems.org/gems/{dep.name}",
            Ecosystem.PACKAGIST: f"https://packagist.org/packages/{dep.name}",
            Ecosystem.NUGET: f"https://www.nuget.org/packages/{dep.name}",
        }
        return urls.get(dep.ecosystem, "")

    def _build_xml_bom(self, sbom: SBOM) -> ET.Element:
        """Build CycloneDX XML BOM structure."""
        ns = "http://cyclonedx.org/schema/bom/1.5"
        root = ET.Element(
            "bom",
            {
                "xmlns": ns,
                "serialNumber": sbom.metadata.serial_number,
                "version": "1",
            },
        )

        # Metadata
        metadata = ET.SubElement(root, "metadata")
        timestamp = ET.SubElement(metadata, "timestamp")
        timestamp.text = sbom.metadata.timestamp

        tools = ET.SubElement(metadata, "tools")
        tool = ET.SubElement(tools, "tool")
        vendor = ET.SubElement(tool, "vendor")
        vendor.text = "MergeGuide"
        name = ET.SubElement(tool, "name")
        name.text = "mergeguide"
        version = ET.SubElement(tool, "version")
        version.text = "0.2.0"

        if sbom.metadata.name:
            component = ET.SubElement(metadata, "component", {"type": "application"})
            comp_name = ET.SubElement(component, "name")
            comp_name.text = sbom.metadata.name
            comp_version = ET.SubElement(component, "version")
            comp_version.text = sbom.metadata.version or "0.0.0"

        # Components
        components = ET.SubElement(root, "components")
        for dep in sbom.dependencies:
            comp = ET.SubElement(
                components,
                "component",
                {
                    "type": "library",
                    "bom-ref": self._generate_bom_ref(dep),
                },
            )
            comp_name = ET.SubElement(comp, "name")
            comp_name.text = dep.name
            comp_version = ET.SubElement(comp, "version")
            comp_version.text = dep.version
            purl = ET.SubElement(comp, "purl")
            purl.text = self._generate_purl(dep)

            if dep.is_dev:
                scope = ET.SubElement(comp, "scope")
                scope.text = "optional"

        return root


class SPDXGenerator(SBOMGenerator):
    """SPDX 2.3 format SBOM generator."""

    SPDX_VERSION = "SPDX-2.3"
    DATA_LICENSE = "CC0-1.0"

    def generate(self, sbom: SBOM) -> str:
        """Generate SPDX JSON format."""
        doc = self._build_document(sbom)
        return json.dumps(doc, indent=2, ensure_ascii=False)

    def generate_tag_value(self, sbom: SBOM) -> str:
        """Generate SPDX tag-value format."""
        lines: list[str] = []

        # Document information
        doc_id = f"SPDXRef-DOCUMENT-{uuid.uuid4().hex[:8]}"
        lines.extend(
            [
                f"SPDXVersion: {self.SPDX_VERSION}",
                f"DataLicense: {self.DATA_LICENSE}",
                f"SPDXID: {doc_id}",
                f"DocumentName: {sbom.metadata.name or 'SBOM'}",
                f"DocumentNamespace: {sbom.metadata.serial_number}",
                "",
                "# Creator information",
                "Creator: Tool: mergeguide-0.2.0",
                f"Created: {sbom.metadata.timestamp}",
                "",
            ]
        )

        # Root package (if metadata.name is set)
        if sbom.metadata.name:
            lines.extend(
                [
                    "# Root Package",
                    f"PackageName: {sbom.metadata.name}",
                    "SPDXID: SPDXRef-Package-root",
                    f"PackageVersion: {sbom.metadata.version or '0.0.0'}",
                    "PackageDownloadLocation: NOASSERTION",
                    "FilesAnalyzed: false",
                    "",
                ]
            )

        # Dependencies as packages
        for _, dep in enumerate(sbom.dependencies):
            spdx_id = f"SPDXRef-Package-{self._generate_bom_ref(dep)}"
            purl = self._generate_purl(dep)

            lines.extend(
                [
                    f"# Package: {dep.name}",
                    f"PackageName: {dep.name}",
                    f"SPDXID: {spdx_id}",
                    f"PackageVersion: {dep.version}",
                    "PackageDownloadLocation: NOASSERTION",
                    "FilesAnalyzed: false",
                    f"ExternalRef: PACKAGE-MANAGER purl {purl}",
                    "",
                ]
            )

        # Relationships
        lines.append("# Relationships")
        if sbom.metadata.name:
            for dep in sbom.dependencies:
                spdx_id = f"SPDXRef-Package-{self._generate_bom_ref(dep)}"
                rel_type = "DEV_DEPENDENCY_OF" if dep.is_dev else "DEPENDENCY_OF"
                lines.append(f"Relationship: {spdx_id} {rel_type} SPDXRef-Package-root")

        lines.append(f"Relationship: {doc_id} DESCRIBES SPDXRef-Package-root")

        return "\n".join(lines)

    def _build_document(self, sbom: SBOM) -> dict[str, Any]:
        """Build SPDX JSON document structure."""
        doc_id = f"SPDXRef-DOCUMENT-{uuid.uuid4().hex[:8]}"

        document: dict[str, Any] = {
            "spdxVersion": self.SPDX_VERSION,
            "dataLicense": self.DATA_LICENSE,
            "SPDXID": doc_id,
            "name": sbom.metadata.name or "SBOM",
            "documentNamespace": sbom.metadata.serial_number,
            "creationInfo": {
                "created": sbom.metadata.timestamp,
                "creators": ["Tool: mergeguide-0.2.0"],
            },
            "packages": [],
            "relationships": [],
        }

        # Add root package if metadata.name is set
        root_spdx_id = None
        if sbom.metadata.name:
            root_spdx_id = "SPDXRef-Package-root"
            root_package = {
                "SPDXID": root_spdx_id,
                "name": sbom.metadata.name,
                "versionInfo": sbom.metadata.version or "0.0.0",
                "downloadLocation": "NOASSERTION",
                "filesAnalyzed": False,
            }
            if sbom.metadata.description:
                root_package["description"] = sbom.metadata.description
            document["packages"].append(root_package)

        # Add dependencies as packages
        for dep in sbom.dependencies:
            spdx_id = f"SPDXRef-Package-{self._generate_bom_ref(dep)}"
            package = {
                "SPDXID": spdx_id,
                "name": dep.name,
                "versionInfo": dep.version,
                "downloadLocation": "NOASSERTION",
                "filesAnalyzed": False,
                "externalRefs": [
                    {
                        "referenceCategory": "PACKAGE-MANAGER",
                        "referenceType": "purl",
                        "referenceLocator": self._generate_purl(dep),
                    }
                ],
            }
            document["packages"].append(package)

            # Add relationship to root package
            if root_spdx_id:
                rel_type = "DEV_DEPENDENCY_OF" if dep.is_dev else "DEPENDENCY_OF"
                document["relationships"].append(
                    {
                        "spdxElementId": spdx_id,
                        "relationshipType": rel_type,
                        "relatedSpdxElement": root_spdx_id,
                    }
                )

        # Document describes root package
        if root_spdx_id:
            document["relationships"].append(
                {
                    "spdxElementId": doc_id,
                    "relationshipType": "DESCRIBES",
                    "relatedSpdxElement": root_spdx_id,
                }
            )

        return document


def generate_sbom(
    files: dict[str, str],
    format: SBOMFormat = SBOMFormat.CYCLONEDX_JSON,
    name: str = "",
    version: str = "",
    description: str = "",
    include_dev: bool = False,
) -> str:
    """Generate SBOM from manifest files.

    Args:
        files: Dictionary mapping filename to content
        format: Output format
        name: Project/component name
        version: Project/component version
        description: Project description
        include_dev: Whether to include dev dependencies

    Returns:
        Formatted SBOM string
    """
    # Parse dependencies from manifests
    parser = ManifestParser()
    all_deps: list[Dependency] = []
    seen: set[tuple[str, str, str]] = set()
    files_scanned: list[str] = []

    for filename, content in files.items():
        try:
            deps = parser.parse(content, filename)
            files_scanned.append(filename)

            for dep in deps:
                # Skip dev dependencies if not included
                if dep.is_dev and not include_dev:
                    continue

                # Deduplicate
                key = (dep.ecosystem.value, dep.name, dep.version)
                if key not in seen:
                    seen.add(key)
                    all_deps.append(dep)
        except ValueError:
            # Unsupported manifest file, skip
            continue

    # Build SBOM
    metadata = SBOMMetadata(
        name=name,
        version=version,
        description=description,
    )
    sbom = SBOM(
        metadata=metadata,
        dependencies=all_deps,
        files_scanned=files_scanned,
    )

    # Generate output
    if format == SBOMFormat.CYCLONEDX_JSON:
        return CycloneDXGenerator().generate(sbom)
    elif format == SBOMFormat.CYCLONEDX_XML:
        return CycloneDXGenerator().generate_xml(sbom)
    elif format == SBOMFormat.SPDX_JSON:
        return SPDXGenerator().generate(sbom)
    elif format == SBOMFormat.SPDX_TAG_VALUE:
        return SPDXGenerator().generate_tag_value(sbom)
    else:
        raise ValueError(f"Unsupported format: {format}")


def generate_sbom_from_directory(
    directory: str | Path,
    format: SBOMFormat = SBOMFormat.CYCLONEDX_JSON,
    name: str = "",
    version: str = "",
    description: str = "",
    include_dev: bool = False,
) -> str:
    """Generate SBOM by scanning a directory for manifest files.

    Args:
        directory: Path to directory to scan
        format: Output format
        name: Project/component name (defaults to directory name)
        version: Project/component version
        description: Project description
        include_dev: Whether to include dev dependencies

    Returns:
        Formatted SBOM string
    """
    directory = Path(directory)

    # Default name to directory name
    if not name:
        name = directory.name

    # Find manifest files
    manifest_files = [
        "package.json",
        "package-lock.json",
        "requirements.txt",
        "Pipfile",
        "pyproject.toml",
        "go.mod",
        "go.sum",
        "Cargo.toml",
        "Cargo.lock",
        "pom.xml",
        "Gemfile",
        "Gemfile.lock",
        "composer.json",
        "composer.lock",
        "packages.config",
    ]

    files: dict[str, str] = {}

    for manifest_name in manifest_files:
        manifest_path = directory / manifest_name
        if manifest_path.exists():
            try:
                files[manifest_name] = manifest_path.read_text()
            except Exception:
                continue

    # Also look for .csproj files
    for csproj in directory.glob("*.csproj"):
        try:
            files[csproj.name] = csproj.read_text()
        except Exception:
            continue

    return generate_sbom(
        files=files,
        format=format,
        name=name,
        version=version,
        description=description,
        include_dev=include_dev,
    )
