"""Tests for SBOM generation — SPDX 2.3 and CycloneDX 1.5 export."""

from __future__ import annotations

import json
import tempfile
from pathlib import Path

import pytest

from mcpaudit.sbom import (
    MCPSbom,
    RiskFactor,
    export_sbom_cyclonedx,
    export_sbom_json,
    export_sbom_spdx,
    export_sbom_text,
    generate_sbom,
    _detect_version_from_source,
    _extract_deps_from_content,
    _parse_cargo_deps,
    _parse_go_mod_deps,
    _parse_package_json_deps,
    _parse_pyproject_deps,
    _parse_requirements,
    _sanitize_id,
    _assess_risk_factors,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_sbom(**kwargs) -> MCPSbom:
    """Create a test MCPSbom with defaults."""
    defaults = {
        "server_name": "test-server",
        "version": "1.0.0",
        "language": "python",
        "framework": "FastMCP",
        "transport": "stdio",
        "tools": ["read_file", "write_file"],
        "resources": ["docs://readme"],
        "dependencies": [
            {"name": "fastmcp", "version": "0.3.0", "source": "requirements.txt"},
        ],
        "risk_factors": [],
        "capabilities": ["tools", "resources"],
        "file_path": "/tmp/server.py",
        "scan_timestamp": "2024-01-15T10:30:00Z",
        "generator": "mcpaudit",
        "generator_version": "1.4.0",
    }
    defaults.update(kwargs)
    risk_factors = defaults.pop("risk_factors")
    sbom = MCPSbom(**defaults)
    sbom.risk_factors = [
        RiskFactor(**rf) if isinstance(rf, dict) else rf
        for rf in risk_factors
    ]
    return sbom


def _write_py_server(tmp_path: Path, content: str | None = None) -> Path:
    """Write a Python MCP server file."""
    if content is None:
        content = '''
from mcp import Server
__version__ = "2.0.0"

server = Server("test-server")

@server.tool()
def hello(name: str):
    """Say hello."""
    return f"Hello {name}"
'''
    f = tmp_path / "server.py"
    f.write_text(content)
    return f


# ---------------------------------------------------------------------------
# MCPSbom data model
# ---------------------------------------------------------------------------

class TestMCPSbom:
    def test_default_values(self) -> None:
        sbom = MCPSbom()
        assert sbom.server_name == ""
        assert sbom.language == "unknown"
        assert sbom.tools == []
        assert sbom.dependencies == []

    def test_to_dict_complete(self) -> None:
        sbom = _make_sbom()
        d = sbom.to_dict()
        assert d["server_name"] == "test-server"
        assert d["version"] == "1.0.0"
        assert d["language"] == "python"
        assert d["framework"] == "FastMCP"
        assert d["transport"] == "stdio"
        assert len(d["tools"]) == 2
        assert len(d["dependencies"]) == 1
        assert d["generator"] == "mcpaudit"

    def test_to_dict_with_risk_factors(self) -> None:
        rf = RiskFactor(category="framework", description="Custom impl", severity="HIGH")
        sbom = _make_sbom(risk_factors=[rf])
        d = sbom.to_dict()
        assert len(d["risk_factors"]) == 1
        assert d["risk_factors"][0]["severity"] == "HIGH"

    def test_to_dict_empty(self) -> None:
        sbom = MCPSbom()
        d = sbom.to_dict()
        assert d["tools"] == []
        assert d["resources"] == []
        assert d["risk_factors"] == []


# ---------------------------------------------------------------------------
# RiskFactor
# ---------------------------------------------------------------------------

class TestRiskFactor:
    def test_to_dict(self) -> None:
        rf = RiskFactor(category="dependency", description="Unpinned", severity="MEDIUM")
        d = rf.to_dict()
        assert d["category"] == "dependency"
        assert d["description"] == "Unpinned"
        assert d["severity"] == "MEDIUM"

    def test_default_severity(self) -> None:
        rf = RiskFactor(category="test", description="test")
        assert rf.severity == "MEDIUM"


# ---------------------------------------------------------------------------
# Version detection
# ---------------------------------------------------------------------------

class TestDetectVersion:
    def test_dunder_version(self) -> None:
        assert _detect_version_from_source('__version__ = "3.2.1"') == "3.2.1"

    def test_json_version(self) -> None:
        assert _detect_version_from_source('"version": "1.0.0"') == "1.0.0"

    def test_equals_version(self) -> None:
        assert _detect_version_from_source("version = '0.5.0'") == "0.5.0"

    def test_no_version(self) -> None:
        assert _detect_version_from_source("print('hello')") == ""


# ---------------------------------------------------------------------------
# Dependency parsing
# ---------------------------------------------------------------------------

class TestParseRequirements:
    def test_pinned_versions(self) -> None:
        content = "requests==2.28.0\nflask>=2.0"
        deps = _parse_requirements(content)
        assert len(deps) == 2
        assert deps[0] == ("requests", "2.28.0")
        assert deps[1] == ("flask", "2.0")

    def test_comments_and_blanks(self) -> None:
        content = "# comment\nrequests\n\n  # another"
        deps = _parse_requirements(content)
        assert len(deps) == 1
        assert deps[0] == ("requests", "")

    def test_git_lines_skipped(self) -> None:
        content = "git+https://github.com/foo/bar.git\nflask"
        deps = _parse_requirements(content)
        assert len(deps) == 1

    def test_inline_comment(self) -> None:
        content = "numpy>=1.20  # for math"
        deps = _parse_requirements(content)
        assert deps[0] == ("numpy", "1.20")

    def test_options_skipped(self) -> None:
        content = "-r base.txt\nflask"
        deps = _parse_requirements(content)
        assert len(deps) == 1


class TestParsePackageJson:
    def test_dependencies(self) -> None:
        content = json.dumps({
            "dependencies": {"express": "^4.0.0", "lodash": "~4.17.0"},
            "devDependencies": {"jest": "^29.0.0"},
        })
        deps = _parse_package_json_deps(content)
        assert len(deps) == 3
        names = [d[0] for d in deps]
        assert "express" in names
        assert "jest" in names

    def test_invalid_json(self) -> None:
        deps = _parse_package_json_deps("not json")
        assert deps == []

    def test_empty_dependencies(self) -> None:
        content = json.dumps({"name": "test"})
        deps = _parse_package_json_deps(content)
        assert deps == []


class TestParseGoMod:
    def test_require_block(self) -> None:
        content = """module example.com/test

go 1.21

require (
    github.com/foo/bar v1.2.3
    github.com/baz/qux v0.5.0
)
"""
        deps = _parse_go_mod_deps(content)
        assert len(deps) == 2
        assert deps[0] == ("github.com/foo/bar", "1.2.3")

    def test_single_require(self) -> None:
        content = "require github.com/pkg/errors v0.9.1"
        deps = _parse_go_mod_deps(content)
        assert len(deps) == 1
        assert deps[0][1] == "0.9.1"

    def test_comments_skipped(self) -> None:
        content = "// comment\nrequire (\n    github.com/a/b v1.0.0\n)"
        deps = _parse_go_mod_deps(content)
        assert len(deps) == 1


class TestParseCargoDeps:
    def test_simple_deps(self) -> None:
        content = '[dependencies]\nserde = "1.0"\ntokio = { version = "1.0" }\n'
        deps = _parse_cargo_deps(content)
        assert len(deps) == 2
        assert ("serde", "1.0") in deps
        assert ("tokio", "1.0") in deps

    def test_section_boundary(self) -> None:
        content = '[dependencies]\nfoo = "1.0"\n\n[dev-dependencies]\nbar = "2.0"\n'
        deps = _parse_cargo_deps(content)
        assert len(deps) == 2


class TestParsePyproject:
    def test_dependencies(self) -> None:
        content = 'dependencies = [\n    "click>=8.0",\n    "rich",\n]\n'
        deps = _parse_pyproject_deps(content)
        assert len(deps) == 2
        assert deps[0][0] == "click"
        assert deps[1] == ("rich", "")


class TestExtractDepsFromContent:
    def test_requirements_txt(self) -> None:
        deps = _extract_deps_from_content("requirements.txt", "flask>=2.0")
        assert len(deps) == 1

    def test_package_json(self) -> None:
        content = json.dumps({"dependencies": {"express": "^4.0"}})
        deps = _extract_deps_from_content("package.json", content)
        assert len(deps) == 1

    def test_unknown_file(self) -> None:
        deps = _extract_deps_from_content("Makefile", "foo")
        assert deps == []

    def test_go_mod(self) -> None:
        deps = _extract_deps_from_content("go.mod", "require github.com/a/b v1.0.0")
        assert len(deps) == 1


# ---------------------------------------------------------------------------
# Sanitize ID
# ---------------------------------------------------------------------------

class TestSanitizeId:
    def test_alphanumeric(self) -> None:
        assert _sanitize_id("test-server") == "test-server"

    def test_special_chars(self) -> None:
        result = _sanitize_id("@scope/package")
        assert "/" not in result
        assert "@" not in result

    def test_empty(self) -> None:
        assert _sanitize_id("") == "unknown"

    def test_only_special(self) -> None:
        assert _sanitize_id("@@@") == "unknown"


# ---------------------------------------------------------------------------
# Risk assessment
# ---------------------------------------------------------------------------

class TestAssessRiskFactors:
    def test_custom_framework(self) -> None:
        from mcpaudit.fingerprint import MCPFingerprint
        fp = MCPFingerprint(language="python", framework="custom", transport="stdio")
        factors = _assess_risk_factors(fp, [])
        categories = [f.category for f in factors]
        assert "framework" in categories

    def test_high_tool_count(self) -> None:
        from mcpaudit.fingerprint import MCPFingerprint
        fp = MCPFingerprint(language="python", framework="FastMCP", transport="sse")
        fp.tool_count = 25
        factors = _assess_risk_factors(fp, [])
        descs = [f.description for f in factors]
        assert any("tool count" in d.lower() for d in descs)

    def test_unpinned_deps(self) -> None:
        from mcpaudit.fingerprint import MCPFingerprint
        fp = MCPFingerprint(language="python", framework="FastMCP", transport="stdio")
        deps = [{"name": "foo", "version": "unspecified"}]
        factors = _assess_risk_factors(fp, deps)
        assert any(f.category == "dependency" for f in factors)

    def test_no_risk_factors(self) -> None:
        from mcpaudit.fingerprint import MCPFingerprint
        fp = MCPFingerprint(language="python", framework="FastMCP", transport="sse")
        fp.tool_count = 5
        factors = _assess_risk_factors(fp, [{"name": "a", "version": "1.0"}])
        # No custom/unknown framework, reasonable tool count, pinned deps
        framework_factors = [f for f in factors if f.category == "framework"]
        assert len(framework_factors) == 0


# ---------------------------------------------------------------------------
# SBOM generation (generate_sbom)
# ---------------------------------------------------------------------------

class TestGenerateSbom:
    def test_generates_for_file(self, tmp_path: Path) -> None:
        f = _write_py_server(tmp_path)
        sbom = generate_sbom(f)
        assert sbom.server_name == "server"
        assert sbom.file_path == str(f)
        assert sbom.generator == "mcpaudit"
        assert sbom.generator_version == "2.9.0"

    def test_generates_for_directory(self, tmp_path: Path) -> None:
        _write_py_server(tmp_path)
        sbom = generate_sbom(tmp_path)
        assert sbom.server_name == tmp_path.name

    def test_nonexistent_path(self, tmp_path: Path) -> None:
        sbom = generate_sbom(tmp_path / "nonexistent")
        assert sbom.server_name == ""
        assert sbom.tools == []

    def test_detects_version_from_file(self, tmp_path: Path) -> None:
        f = _write_py_server(tmp_path)
        sbom = generate_sbom(f)
        assert sbom.version == "2.0.0"

    def test_scan_timestamp_set(self, tmp_path: Path) -> None:
        f = _write_py_server(tmp_path)
        sbom = generate_sbom(f)
        assert sbom.scan_timestamp != ""
        assert "T" in sbom.scan_timestamp

    def test_dependencies_from_requirements(self, tmp_path: Path) -> None:
        _write_py_server(tmp_path)
        req = tmp_path / "requirements.txt"
        req.write_text("flask>=2.0\nrequests==2.28.0\n")
        sbom = generate_sbom(tmp_path)
        assert len(sbom.dependencies) >= 2

    def test_empty_server(self, tmp_path: Path) -> None:
        f = tmp_path / "empty.py"
        f.write_text("# empty file\n")
        sbom = generate_sbom(f)
        assert sbom.server_name == "empty"
        assert sbom.tools == []


# ---------------------------------------------------------------------------
# SPDX 2.3 export
# ---------------------------------------------------------------------------

class TestExportSPDX:
    def test_valid_json(self) -> None:
        sbom = _make_sbom()
        result = export_sbom_spdx(sbom)
        data = json.loads(result)
        assert data["spdxVersion"] == "SPDX-2.3"

    def test_contains_packages(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_spdx(sbom))
        assert len(data["packages"]) >= 2  # main + 1 dep

    def test_contains_relationships(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_spdx(sbom))
        rel_types = [r["relationshipType"] for r in data["relationships"]]
        assert "DESCRIBES" in rel_types
        assert "DEPENDS_ON" in rel_types

    def test_creation_info(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_spdx(sbom))
        assert "creationInfo" in data
        assert "created" in data["creationInfo"]
        assert len(data["creationInfo"]["creators"]) > 0

    def test_document_namespace(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_spdx(sbom))
        assert "mcpaudit.dev" in data["documentNamespace"]

    def test_no_dependencies(self) -> None:
        sbom = _make_sbom(dependencies=[])
        data = json.loads(export_sbom_spdx(sbom))
        assert len(data["packages"]) == 1  # main only
        rel_types = [r["relationshipType"] for r in data["relationships"]]
        assert "DEPENDS_ON" not in rel_types

    def test_data_license(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_spdx(sbom))
        assert data["dataLicense"] == "CC0-1.0"

    def test_spdx_id_format(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_spdx(sbom))
        assert data["SPDXID"] == "SPDXRef-DOCUMENT"
        main_pkg = data["packages"][0]
        assert main_pkg["SPDXID"].startswith("SPDXRef-")


# ---------------------------------------------------------------------------
# CycloneDX 1.5 export
# ---------------------------------------------------------------------------

class TestExportCycloneDX:
    def test_valid_json(self) -> None:
        sbom = _make_sbom()
        result = export_sbom_cyclonedx(sbom)
        data = json.loads(result)
        assert data["bomFormat"] == "CycloneDX"
        assert data["specVersion"] == "1.5"

    def test_contains_components(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_cyclonedx(sbom))
        assert len(data["components"]) >= 2  # main + dep

    def test_main_component_type(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_cyclonedx(sbom))
        main = data["components"][0]
        assert main["type"] == "application"

    def test_dep_component_type(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_cyclonedx(sbom))
        dep = data["components"][1]
        assert dep["type"] == "library"

    def test_metadata_tools(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_cyclonedx(sbom))
        assert "metadata" in data
        assert data["metadata"]["tools"][0]["name"] == "mcpaudit"

    def test_dependencies_section(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_cyclonedx(sbom))
        assert len(data["dependencies"]) >= 1
        assert "dependsOn" in data["dependencies"][0]

    def test_risk_factors_as_vulnerabilities(self) -> None:
        rf = RiskFactor(category="framework", description="Custom impl", severity="HIGH")
        sbom = _make_sbom(risk_factors=[rf])
        data = json.loads(export_sbom_cyclonedx(sbom))
        assert "vulnerabilities" in data
        assert len(data["vulnerabilities"]) == 1
        assert data["vulnerabilities"][0]["id"].startswith("MCPAUDIT-RISK-")

    def test_no_vulnerabilities_when_no_risks(self) -> None:
        sbom = _make_sbom(risk_factors=[])
        data = json.loads(export_sbom_cyclonedx(sbom))
        assert "vulnerabilities" not in data

    def test_properties_include_framework(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_cyclonedx(sbom))
        main = data["components"][0]
        prop_names = [p["name"] for p in main["properties"]]
        assert "mcpaudit:framework" in prop_names
        assert "mcpaudit:language" in prop_names

    def test_serial_number(self) -> None:
        sbom = _make_sbom()
        data = json.loads(export_sbom_cyclonedx(sbom))
        assert data["serialNumber"].startswith("urn:uuid:mcpaudit-")


# ---------------------------------------------------------------------------
# Text export
# ---------------------------------------------------------------------------

class TestExportText:
    def test_contains_server_name(self) -> None:
        sbom = _make_sbom()
        text = export_sbom_text(sbom)
        assert "test-server" in text

    def test_contains_tools(self) -> None:
        sbom = _make_sbom()
        text = export_sbom_text(sbom)
        assert "read_file" in text
        assert "write_file" in text

    def test_contains_dependencies(self) -> None:
        sbom = _make_sbom()
        text = export_sbom_text(sbom)
        assert "fastmcp" in text

    def test_contains_risk_factors(self) -> None:
        rf = RiskFactor(category="test", description="Test risk", severity="HIGH")
        sbom = _make_sbom(risk_factors=[rf])
        text = export_sbom_text(sbom)
        assert "Test risk" in text

    def test_empty_sbom(self) -> None:
        sbom = MCPSbom()
        text = export_sbom_text(sbom)
        assert "SBOM" in text


# ---------------------------------------------------------------------------
# JSON export (native)
# ---------------------------------------------------------------------------

class TestExportJson:
    def test_valid_json(self) -> None:
        sbom = _make_sbom()
        result = export_sbom_json(sbom)
        data = json.loads(result)
        assert data["server_name"] == "test-server"

    def test_roundtrip(self) -> None:
        sbom = _make_sbom()
        result = export_sbom_json(sbom)
        data = json.loads(result)
        assert data == sbom.to_dict()


# ---------------------------------------------------------------------------
# CLI integration (via main)
# ---------------------------------------------------------------------------

class TestSbomCLI:
    def test_sbom_text(self, tmp_path: Path) -> None:
        _write_py_server(tmp_path)
        from mcpaudit.__main__ import main
        exit_code = main(["sbom", str(tmp_path)])
        assert exit_code == 0

    def test_sbom_spdx_output(self, tmp_path: Path) -> None:
        f = _write_py_server(tmp_path)
        out = tmp_path / "sbom.json"
        from mcpaudit.__main__ import main
        exit_code = main(["sbom", str(f), "--format", "spdx", "-o", str(out)])
        assert exit_code == 0
        data = json.loads(out.read_text())
        assert data["spdxVersion"] == "SPDX-2.3"

    def test_sbom_cyclonedx_output(self, tmp_path: Path) -> None:
        f = _write_py_server(tmp_path)
        out = tmp_path / "sbom.json"
        from mcpaudit.__main__ import main
        exit_code = main(["sbom", str(f), "--format", "cyclonedx", "-o", str(out)])
        assert exit_code == 0
        data = json.loads(out.read_text())
        assert data["bomFormat"] == "CycloneDX"

    def test_sbom_nonexistent(self) -> None:
        from mcpaudit.__main__ import main
        exit_code = main(["sbom", "/nonexistent/path"])
        assert exit_code == 1
