"""SBOM Generation for MCP Server Ecosystems.

Generates a Software Bill of Materials (SBOM) for MCP servers,
supporting SPDX 2.3 and CycloneDX 1.5 export formats.
Integrates with fingerprint.py and dep_scanner.py for comprehensive
server profiling.

Zero external dependencies. Python 3.11+ stdlib only.
"""

from __future__ import annotations

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

from .fingerprint import (
    MCPFingerprint,
    fingerprint_path,
    fingerprint_server,
)
from .dep_scanner import DependencyScanner


# ---------------------------------------------------------------------------
# Data Models
# ---------------------------------------------------------------------------

@dataclass
class RiskFactor:
    """A risk factor identified for an MCP server component."""

    category: str  # e.g. "dependency", "framework", "configuration"
    description: str
    severity: str = "MEDIUM"  # CRITICAL, HIGH, MEDIUM, LOW, INFO

    def to_dict(self) -> dict[str, Any]:
        return {
            "category": self.category,
            "description": self.description,
            "severity": self.severity,
        }


@dataclass
class MCPSbom:
    """Software Bill of Materials for an MCP server.

    Captures server identity, framework, language, tools, resources,
    dependencies, and associated risk factors.
    """

    server_name: str = ""
    version: str = ""
    language: str = "unknown"
    framework: str = "unknown"
    transport: str = "unknown"
    tools: list[str] = field(default_factory=list)
    resources: list[str] = field(default_factory=list)
    dependencies: list[dict[str, str]] = field(default_factory=list)
    risk_factors: list[RiskFactor] = field(default_factory=list)
    capabilities: list[str] = field(default_factory=list)
    file_path: str = ""
    scan_timestamp: str = ""
    generator: str = "mcpaudit"
    generator_version: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "server_name": self.server_name,
            "version": self.version,
            "language": self.language,
            "framework": self.framework,
            "transport": self.transport,
            "tools": self.tools,
            "resources": self.resources,
            "dependencies": self.dependencies,
            "risk_factors": [rf.to_dict() for rf in self.risk_factors],
            "capabilities": self.capabilities,
            "file_path": self.file_path,
            "scan_timestamp": self.scan_timestamp,
            "generator": self.generator,
            "generator_version": self.generator_version,
        }


# ---------------------------------------------------------------------------
# SBOM Generation
# ---------------------------------------------------------------------------

def _detect_server_name(path: Path, fp: MCPFingerprint) -> str:
    """Derive a server name from the path or fingerprint."""
    if path.is_file():
        return path.stem
    return path.name


def _detect_version_from_source(source: str) -> str:
    """Try to extract a version string from source code."""
    patterns = [
        re.compile(r'__version__\s*=\s*["\']([^"\']+)["\']'),
        re.compile(r'"version"\s*:\s*"([^"]+)"'),
        re.compile(r"version\s*=\s*['\"]([^'\"]+)['\"]"),
    ]
    for pat in patterns:
        m = pat.search(source)
        if m:
            return m.group(1)
    return ""


def _scan_dependencies(path: Path) -> list[dict[str, str]]:
    """Scan for dependencies in manifest files near the path."""
    scanner = DependencyScanner()
    deps: list[dict[str, str]] = []

    search_dir = path if path.is_dir() else path.parent

    manifest_names = [
        "requirements.txt", "package.json", "go.mod", "Cargo.toml",
        "pyproject.toml", "setup.cfg",
    ]

    for mname in manifest_names:
        for manifest in sorted(search_dir.rglob(mname)):
            parts = manifest.parts
            if "node_modules" in parts or "vendor" in parts or ".venv" in parts:
                continue
            try:
                content = manifest.read_text(encoding="utf-8", errors="replace")
            except (OSError, PermissionError):
                continue

            # Extract deps from manifest content
            extracted = _extract_deps_from_content(manifest.name, content)
            for pkg, ver in extracted:
                deps.append({
                    "name": pkg,
                    "version": ver or "unspecified",
                    "source": str(manifest),
                })

    return deps


def _extract_deps_from_content(
    filename: str, content: str
) -> list[tuple[str, str]]:
    """Extract (package, version) pairs from manifest content."""
    fname = filename.lower()

    if fname in ("requirements.txt", "requirements-dev.txt", "constraints.txt"):
        return _parse_requirements(content)
    if fname == "package.json":
        return _parse_package_json_deps(content)
    if fname == "go.mod":
        return _parse_go_mod_deps(content)
    if fname == "cargo.toml":
        return _parse_cargo_deps(content)
    if fname == "pyproject.toml":
        return _parse_pyproject_deps(content)

    return []


def _parse_requirements(content: str) -> list[tuple[str, str]]:
    """Parse requirements.txt format."""
    deps: list[tuple[str, str]] = []
    for line in content.splitlines():
        line = line.strip()
        if "#" in line:
            line = line[:line.index("#")].strip()
        if not line or line.startswith("-") or line.startswith("git+"):
            continue
        m = re.match(r'^([a-zA-Z0-9_.-]+)\s*([><=!~]+)\s*([^\s,;]+)', line)
        if m:
            deps.append((m.group(1), m.group(3)))
        else:
            m2 = re.match(r'^([a-zA-Z0-9_.-]+)', line)
            if m2:
                deps.append((m2.group(1), ""))
    return deps


def _parse_package_json_deps(content: str) -> list[tuple[str, str]]:
    """Parse package.json dependencies."""
    deps: list[tuple[str, str]] = []
    try:
        data = json.loads(content)
    except (json.JSONDecodeError, ValueError):
        return deps
    for key in ("dependencies", "devDependencies"):
        section = data.get(key, {})
        if isinstance(section, dict):
            for pkg, ver in section.items():
                if isinstance(ver, str):
                    deps.append((pkg, ver))
    return deps


def _parse_go_mod_deps(content: str) -> list[tuple[str, str]]:
    """Parse go.mod require entries."""
    deps: list[tuple[str, str]] = []
    in_block = False
    for line in content.splitlines():
        line = line.strip()
        if line.startswith("//"):
            continue
        if line.startswith("require ("):
            in_block = True
            continue
        if in_block:
            if line == ")":
                in_block = False
                continue
            parts = line.split()
            if len(parts) >= 2:
                deps.append((parts[0], parts[1].lstrip("v")))
        elif line.startswith("require "):
            parts = line.split()
            if len(parts) >= 3:
                deps.append((parts[1], parts[2].lstrip("v")))
    return deps


def _parse_cargo_deps(content: str) -> list[tuple[str, str]]:
    """Parse Cargo.toml dependencies."""
    deps: list[tuple[str, str]] = []
    in_deps = False
    for line in content.splitlines():
        line = line.strip()
        if line.startswith("#"):
            continue
        if re.match(r'\[(?:dev-)?dependencies\]', line):
            in_deps = True
            continue
        if line.startswith("[") and in_deps:
            in_deps = False
            continue
        if in_deps and "=" in line:
            key, _, value = line.partition("=")
            pkg = key.strip()
            value = value.strip()
            m = re.match(r'^["\']([^"\']+)["\']', value)
            if m:
                deps.append((pkg, m.group(1)))
                continue
            m = re.search(r'version\s*=\s*["\']([^"\']+)["\']', value)
            if m:
                deps.append((pkg, m.group(1)))
    return deps


def _parse_pyproject_deps(content: str) -> list[tuple[str, str]]:
    """Parse pyproject.toml dependencies (simple extraction)."""
    deps: list[tuple[str, str]] = []
    in_deps = False
    for line in content.splitlines():
        stripped = line.strip()
        if stripped == "dependencies = [":
            in_deps = True
            continue
        if in_deps:
            if stripped == "]":
                in_deps = False
                continue
            m = re.match(r'^\s*["\']([a-zA-Z0-9_.-]+)([><=!~]+[^"\']*)?["\']', stripped)
            if m:
                pkg = m.group(1)
                ver = m.group(2) or ""
                ver = re.sub(r'^[><=!~]+', '', ver).strip()
                deps.append((pkg, ver))
    return deps


def _assess_risk_factors(
    fp: MCPFingerprint, deps: list[dict[str, str]]
) -> list[RiskFactor]:
    """Assess risk factors based on fingerprint and dependencies."""
    factors: list[RiskFactor] = []

    # Framework-related risks
    if fp.framework == "custom":
        factors.append(RiskFactor(
            category="framework",
            description="Custom MCP implementation without standard security controls",
            severity="HIGH",
        ))
    if fp.framework == "unknown":
        factors.append(RiskFactor(
            category="framework",
            description="Could not detect MCP framework — manual review recommended",
            severity="MEDIUM",
        ))

    # Transport risks
    if fp.transport == "stdio":
        factors.append(RiskFactor(
            category="transport",
            description="stdio transport lacks built-in authentication",
            severity="MEDIUM",
        ))
    if fp.transport == "unknown":
        factors.append(RiskFactor(
            category="transport",
            description="Unknown transport — cannot verify security properties",
            severity="LOW",
        ))

    # Tool count risks
    if fp.tool_count > 20:
        factors.append(RiskFactor(
            category="surface_area",
            description=f"High tool count ({fp.tool_count}) increases attack surface",
            severity="MEDIUM",
        ))
    if fp.tool_count == 0:
        factors.append(RiskFactor(
            category="surface_area",
            description="No tools detected — server may not function correctly",
            severity="LOW",
        ))

    # Dependency risks
    unpinned = [d for d in deps if d.get("version") in ("", "unspecified")]
    if unpinned:
        factors.append(RiskFactor(
            category="dependency",
            description=f"{len(unpinned)} dependencies without pinned versions",
            severity="MEDIUM",
        ))

    if len(deps) > 50:
        factors.append(RiskFactor(
            category="dependency",
            description=f"Large dependency tree ({len(deps)} packages) increases supply chain risk",
            severity="LOW",
        ))

    return factors


def generate_sbom(path: str | Path) -> MCPSbom:
    """Generate an SBOM for an MCP server at the given path.

    Scans the path for MCP server characteristics using fingerprinting,
    enumerates dependencies from manifest files, and assesses risk factors.

    Args:
        path: Path to a source file or directory.

    Returns:
        MCPSbom with complete server profile.
    """
    from . import __version__

    target = Path(path)
    sbom = MCPSbom(
        file_path=str(target),
        scan_timestamp=datetime.now(timezone.utc).isoformat(),
        generator_version=__version__,
    )

    if not target.exists():
        return sbom

    # Fingerprint the server
    fp = fingerprint_path(target)
    sbom.server_name = _detect_server_name(target, fp)
    sbom.language = fp.language
    sbom.framework = fp.framework
    sbom.transport = fp.transport
    sbom.tools = list(fp.tools)
    sbom.resources = list(fp.resources)
    sbom.capabilities = list(fp.capabilities)

    # Try to detect version from source
    if target.is_file():
        try:
            source = target.read_text(encoding="utf-8", errors="replace")
            sbom.version = _detect_version_from_source(source)
        except (OSError, PermissionError):
            pass
    else:
        # Check common version files in directory
        for vfile in ("__init__.py", "package.json", "version.py"):
            vpath = target / vfile
            if vpath.exists():
                try:
                    source = vpath.read_text(encoding="utf-8", errors="replace")
                    ver = _detect_version_from_source(source)
                    if ver:
                        sbom.version = ver
                        break
                except (OSError, PermissionError):
                    continue

    # Scan dependencies
    sbom.dependencies = _scan_dependencies(target)

    # Assess risk factors
    sbom.risk_factors = _assess_risk_factors(fp, sbom.dependencies)

    return sbom


# ---------------------------------------------------------------------------
# Export: SPDX 2.3
# ---------------------------------------------------------------------------

def export_sbom_spdx(sbom: MCPSbom) -> str:
    """Export SBOM in SPDX 2.3 JSON format.

    Produces a JSON document conforming to the SPDX 2.3 specification
    for software composition analysis.

    Args:
        sbom: The MCPSbom to export.

    Returns:
        JSON string in SPDX 2.3 format.
    """
    spdx_id = f"SPDXRef-{_sanitize_id(sbom.server_name or 'mcp-server')}"

    packages: list[dict[str, Any]] = [
        {
            "SPDXID": spdx_id,
            "name": sbom.server_name or "mcp-server",
            "versionInfo": sbom.version or "NOASSERTION",
            "downloadLocation": "NOASSERTION",
            "filesAnalyzed": False,
            "supplier": "NOASSERTION",
            "primaryPackagePurpose": "APPLICATION",
            "description": f"MCP server ({sbom.framework} / {sbom.language})",
        }
    ]

    relationships: list[dict[str, str]] = [
        {
            "spdxElementId": "SPDXRef-DOCUMENT",
            "relatedSpdxElement": spdx_id,
            "relationshipType": "DESCRIBES",
        }
    ]

    for dep in sbom.dependencies:
        dep_id = f"SPDXRef-{_sanitize_id(dep['name'])}"
        packages.append({
            "SPDXID": dep_id,
            "name": dep["name"],
            "versionInfo": dep.get("version", "NOASSERTION"),
            "downloadLocation": "NOASSERTION",
            "filesAnalyzed": False,
            "supplier": "NOASSERTION",
        })
        relationships.append({
            "spdxElementId": spdx_id,
            "relatedSpdxElement": dep_id,
            "relationshipType": "DEPENDS_ON",
        })

    doc: dict[str, Any] = {
        "spdxVersion": "SPDX-2.3",
        "dataLicense": "CC0-1.0",
        "SPDXID": "SPDXRef-DOCUMENT",
        "name": f"mcpaudit-sbom-{sbom.server_name or 'mcp-server'}",
        "documentNamespace": f"https://mcpaudit.dev/sbom/{sbom.server_name or 'mcp-server'}/{sbom.scan_timestamp}",
        "creationInfo": {
            "created": sbom.scan_timestamp or datetime.now(timezone.utc).isoformat(),
            "creators": [
                f"Tool: {sbom.generator}-{sbom.generator_version}",
            ],
            "licenseListVersion": "3.22",
        },
        "packages": packages,
        "relationships": relationships,
    }

    return json.dumps(doc, indent=2, ensure_ascii=False)


# ---------------------------------------------------------------------------
# Export: CycloneDX 1.5
# ---------------------------------------------------------------------------

def export_sbom_cyclonedx(sbom: MCPSbom) -> str:
    """Export SBOM in CycloneDX 1.5 JSON format.

    Produces a JSON document conforming to the CycloneDX 1.5 specification
    for software composition analysis.

    Args:
        sbom: The MCPSbom to export.

    Returns:
        JSON string in CycloneDX 1.5 format.
    """
    components: list[dict[str, Any]] = []

    # Main component
    main_component: dict[str, Any] = {
        "type": "application",
        "bom-ref": _sanitize_id(sbom.server_name or "mcp-server"),
        "name": sbom.server_name or "mcp-server",
        "version": sbom.version or "0.0.0",
        "description": f"MCP server ({sbom.framework} / {sbom.language})",
        "properties": [
            {"name": "mcpaudit:framework", "value": sbom.framework},
            {"name": "mcpaudit:language", "value": sbom.language},
            {"name": "mcpaudit:transport", "value": sbom.transport},
            {"name": "mcpaudit:tool_count", "value": str(len(sbom.tools))},
            {"name": "mcpaudit:resource_count", "value": str(len(sbom.resources))},
        ],
    }
    components.append(main_component)

    # Dependencies as components
    dep_refs: list[str] = []
    for dep in sbom.dependencies:
        ref = _sanitize_id(dep["name"])
        dep_refs.append(ref)
        components.append({
            "type": "library",
            "bom-ref": ref,
            "name": dep["name"],
            "version": dep.get("version", ""),
        })

    # Vulnerability info from risk factors
    vulns: list[dict[str, Any]] = []
    for i, rf in enumerate(sbom.risk_factors):
        vulns.append({
            "id": f"MCPAUDIT-RISK-{i+1:03d}",
            "description": rf.description,
            "ratings": [
                {
                    "severity": rf.severity.lower(),
                    "method": "other",
                    "source": {"name": "mcpaudit"},
                }
            ],
        })

    doc: dict[str, Any] = {
        "bomFormat": "CycloneDX",
        "specVersion": "1.5",
        "serialNumber": f"urn:uuid:mcpaudit-{_sanitize_id(sbom.server_name or 'server')}-{sbom.scan_timestamp}",
        "version": 1,
        "metadata": {
            "timestamp": sbom.scan_timestamp or datetime.now(timezone.utc).isoformat(),
            "tools": [
                {
                    "vendor": "mcpaudit",
                    "name": sbom.generator,
                    "version": sbom.generator_version,
                }
            ],
            "component": main_component,
        },
        "components": components,
        "dependencies": [
            {
                "ref": _sanitize_id(sbom.server_name or "mcp-server"),
                "dependsOn": dep_refs,
            }
        ],
    }

    if vulns:
        doc["vulnerabilities"] = vulns

    return json.dumps(doc, indent=2, ensure_ascii=False)


# ---------------------------------------------------------------------------
# Export: Human-readable Text
# ---------------------------------------------------------------------------

def export_sbom_text(sbom: MCPSbom) -> str:
    """Export SBOM as human-readable text.

    Args:
        sbom: The MCPSbom to export.

    Returns:
        Formatted text string.
    """
    lines: list[str] = []
    lines.append(f"\n{'=' * 60}")
    lines.append("  mcpaudit -- Software Bill of Materials (SBOM)")
    lines.append(f"{'=' * 60}")
    lines.append(f"  Server:      {sbom.server_name or '(unknown)'}")
    lines.append(f"  Version:     {sbom.version or '(unknown)'}")
    lines.append(f"  Language:    {sbom.language}")
    lines.append(f"  Framework:   {sbom.framework}")
    lines.append(f"  Transport:   {sbom.transport}")
    lines.append(f"  Path:        {sbom.file_path}")
    lines.append(f"  Scanned:     {sbom.scan_timestamp}")
    lines.append(f"  Generator:   {sbom.generator} {sbom.generator_version}")

    if sbom.capabilities:
        lines.append(f"\n  Capabilities: {', '.join(sbom.capabilities)}")

    if sbom.tools:
        lines.append(f"\n  Tools ({len(sbom.tools)}):")
        for tool in sbom.tools:
            lines.append(f"    - {tool}")

    if sbom.resources:
        lines.append(f"\n  Resources ({len(sbom.resources)}):")
        for res in sbom.resources:
            lines.append(f"    - {res}")

    if sbom.dependencies:
        lines.append(f"\n  Dependencies ({len(sbom.dependencies)}):")
        lines.append(f"    {'Name':<30} {'Version':<15} {'Source'}")
        lines.append(f"    {'─' * 30} {'─' * 15} {'─' * 30}")
        for dep in sbom.dependencies:
            lines.append(
                f"    {dep['name']:<30} {dep.get('version', ''):<15} "
                f"{dep.get('source', '')}"
            )

    if sbom.risk_factors:
        lines.append(f"\n  Risk Factors ({len(sbom.risk_factors)}):")
        for rf in sbom.risk_factors:
            lines.append(f"    [{rf.severity}] {rf.category}: {rf.description}")

    lines.append(f"\n{'=' * 60}\n")
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Export: JSON (native mcpaudit format)
# ---------------------------------------------------------------------------

def export_sbom_json(sbom: MCPSbom) -> str:
    """Export SBOM as mcpaudit native JSON.

    Args:
        sbom: The MCPSbom to export.

    Returns:
        JSON string.
    """
    return json.dumps(sbom.to_dict(), indent=2, ensure_ascii=False)


# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------

def _sanitize_id(name: str) -> str:
    """Sanitize a name for use as an ID in SPDX/CycloneDX.

    Replaces non-alphanumeric characters with hyphens and strips leading
    hyphens. Returns 'unknown' if empty.
    """
    result = re.sub(r'[^a-zA-Z0-9_.-]', '-', name)
    result = result.strip('-')
    return result or "unknown"
