"""Tests for Dependency Vulnerability Scanner."""

from __future__ import annotations

import json
import os
import tempfile
from pathlib import Path

import pytest

from mcpaudit.dep_vuln import (
    DepClassification,
    RiskLevel,
    RiskyPackage,
    VulnReport,
    _classify_dep,
    _compute_risk_score,
    _detect_manifest_type,
    _normalize_name,
    _parse_package_json,
    _parse_pyproject_toml,
    _parse_requirements_txt,
    format_vuln_json,
    format_vuln_text,
    get_risk_categories,
    get_risky_packages,
    scan_deps,
)


# --- Data Models ---

class TestRiskLevel:
    def test_safe_constant(self):
        assert RiskLevel.SAFE == "safe"

    def test_caution_constant(self):
        assert RiskLevel.CAUTION == "caution"

    def test_dangerous_constant(self):
        assert RiskLevel.DANGEROUS == "dangerous"


class TestRiskyPackage:
    def test_to_dict(self):
        pkg = RiskyPackage("sh", "dangerous", "Shell wrapper", "execution")
        d = pkg.to_dict()
        assert d["name"] == "sh"
        assert d["risk"] == "dangerous"
        assert d["reason"] == "Shell wrapper"
        assert d["category"] == "execution"


class TestDepClassification:
    def test_to_dict(self):
        c = DepClassification("flask", "2.0.0", "safe", "No issues", "general")
        d = c.to_dict()
        assert d["package"] == "flask"
        assert d["version"] == "2.0.0"
        assert d["risk"] == "safe"

    def test_unpinned_version(self):
        c = DepClassification("flask", "unpinned", "safe", "No issues", "general")
        assert c.version == "unpinned"


class TestVulnReport:
    def test_empty_report(self):
        r = VulnReport(path="/tmp")
        assert r.total_deps == 0
        assert r.risky_deps == 0
        assert r.risk_score == 0
        assert r.safe_count == 0
        assert r.caution_count == 0
        assert r.dangerous_count == 0

    def test_counts(self):
        r = VulnReport(path="/tmp", total_deps=3, classifications=[
            DepClassification("a", "1.0", RiskLevel.SAFE, "", "general"),
            DepClassification("b", "1.0", RiskLevel.CAUTION, "", "network"),
            DepClassification("c", "1.0", RiskLevel.DANGEROUS, "", "execution"),
        ])
        assert r.safe_count == 1
        assert r.caution_count == 1
        assert r.dangerous_count == 1

    def test_to_dict(self):
        r = VulnReport(path="/test", total_deps=1, risky_deps=0, risk_score=0)
        d = r.to_dict()
        assert d["path"] == "/test"
        assert d["total_deps"] == 1
        assert "classifications" in d
        assert "errors" in d


# --- Manifest Parsers ---

class TestParseRequirementsTxt:
    def test_simple(self):
        content = "flask==2.0.0\nrequests>=2.28.0\n"
        deps = _parse_requirements_txt(content)
        assert len(deps) == 2
        assert deps[0] == ("flask", "2.0.0")
        assert deps[1] == ("requests", "2.28.0")

    def test_no_version(self):
        content = "flask\nrequests\n"
        deps = _parse_requirements_txt(content)
        assert len(deps) == 2
        assert deps[0] == ("flask", "")
        assert deps[1] == ("requests", "")

    def test_comments(self):
        content = "# comment\nflask==2.0.0  # inline comment\n"
        deps = _parse_requirements_txt(content)
        assert len(deps) == 1
        assert deps[0] == ("flask", "2.0.0")

    def test_empty(self):
        deps = _parse_requirements_txt("")
        assert deps == []

    def test_git_url_skipped(self):
        content = "git+https://github.com/foo/bar.git\nflask==2.0.0\n"
        deps = _parse_requirements_txt(content)
        assert len(deps) == 1

    def test_dash_option_skipped(self):
        content = "-r base.txt\nflask==2.0.0\n"
        deps = _parse_requirements_txt(content)
        assert len(deps) == 1

    def test_tilde_operator(self):
        content = "flask~=2.0.0\n"
        deps = _parse_requirements_txt(content)
        assert deps[0] == ("flask", "2.0.0")

    def test_not_equal_operator(self):
        content = "flask!=1.0.0\n"
        deps = _parse_requirements_txt(content)
        assert deps[0] == ("flask", "1.0.0")

    def test_case_insensitive(self):
        content = "Flask==2.0.0\n"
        deps = _parse_requirements_txt(content)
        assert deps[0][0] == "flask"


class TestParsePyprojectToml:
    def test_inline_deps(self):
        content = 'dependencies = ["flask>=2.0", "requests"]\n'
        deps = _parse_pyproject_toml(content)
        assert len(deps) == 2
        assert deps[0][0] == "flask"
        assert deps[1][0] == "requests"

    def test_empty(self):
        deps = _parse_pyproject_toml("")
        assert deps == []

    def test_comment_lines_skipped(self):
        content = "# This is a comment\ndependencies = []\n"
        deps = _parse_pyproject_toml(content)
        assert deps == []


class TestParsePackageJson:
    def test_simple(self):
        content = json.dumps({
            "dependencies": {"express": "^4.18.0", "lodash": "4.17.21"},
        })
        deps = _parse_package_json(content)
        assert len(deps) == 2

    def test_dev_deps(self):
        content = json.dumps({
            "dependencies": {"express": "^4.18.0"},
            "devDependencies": {"jest": "^29.0.0"},
        })
        deps = _parse_package_json(content)
        assert len(deps) == 2

    def test_peer_deps(self):
        content = json.dumps({
            "peerDependencies": {"react": "^18.0.0"},
        })
        deps = _parse_package_json(content)
        assert len(deps) == 1

    def test_invalid_json(self):
        deps = _parse_package_json("not json")
        assert deps == []

    def test_empty_json(self):
        deps = _parse_package_json("{}")
        assert deps == []

    def test_non_string_version(self):
        content = json.dumps({
            "dependencies": {"express": 123},
        })
        deps = _parse_package_json(content)
        assert deps == []


# --- Manifest Detection ---

class TestDetectManifestType:
    def test_requirements_txt(self):
        assert _detect_manifest_type(Path("requirements.txt")) == "requirements"

    def test_requirements_dev(self):
        assert _detect_manifest_type(Path("requirements-dev.txt")) == "requirements"

    def test_pyproject_toml(self):
        assert _detect_manifest_type(Path("pyproject.toml")) == "pyproject"

    def test_package_json(self):
        assert _detect_manifest_type(Path("package.json")) == "package_json"

    def test_unknown(self):
        assert _detect_manifest_type(Path("Makefile")) == "unknown"


# --- Name Normalization ---

class TestNormalizeName:
    def test_lowercase(self):
        assert _normalize_name("Flask") == "flask"

    def test_dashes_to_underscores(self):
        assert _normalize_name("my-package") == "my_package"

    def test_dots_to_underscores(self):
        assert _normalize_name("ruamel.yaml") == "ruamel_yaml"


# --- Classification ---

class TestClassifyDep:
    def test_dangerous_package(self):
        c = _classify_dep("sh", "1.0.0")
        assert c.risk == RiskLevel.DANGEROUS
        assert c.category == "execution"

    def test_caution_package(self):
        c = _classify_dep("requests", "2.28.0")
        assert c.risk == RiskLevel.CAUTION
        assert c.category == "network"

    def test_safe_package(self):
        c = _classify_dep("some-unknown-package", "1.0.0")
        assert c.risk == RiskLevel.SAFE

    def test_unpinned_version(self):
        c = _classify_dep("sh", "")
        assert c.version == "unpinned"

    def test_normalized_lookup(self):
        c = _classify_dep("ruamel.yaml", "0.17.0")
        assert c.risk == RiskLevel.CAUTION

    def test_paramiko(self):
        c = _classify_dep("paramiko", "3.0.0")
        assert c.risk == RiskLevel.DANGEROUS

    def test_shelljs(self):
        c = _classify_dep("shelljs", "0.8.0")
        assert c.risk == RiskLevel.DANGEROUS


# --- Risk Score ---

class TestComputeRiskScore:
    def test_empty(self):
        assert _compute_risk_score([]) == 0

    def test_all_safe(self):
        cls = [DepClassification("a", "1.0", RiskLevel.SAFE, "", "general")]
        assert _compute_risk_score(cls) == 0

    def test_one_dangerous(self):
        cls = [DepClassification("a", "1.0", RiskLevel.DANGEROUS, "", "execution")]
        assert _compute_risk_score(cls) == 15

    def test_one_caution(self):
        cls = [DepClassification("a", "1.0", RiskLevel.CAUTION, "", "network")]
        assert _compute_risk_score(cls) == 5

    def test_capped_at_100(self):
        cls = [DepClassification(f"p{i}", "1.0", RiskLevel.DANGEROUS, "", "x") for i in range(10)]
        assert _compute_risk_score(cls) == 100

    def test_mixed(self):
        cls = [
            DepClassification("a", "1.0", RiskLevel.DANGEROUS, "", "x"),
            DepClassification("b", "1.0", RiskLevel.CAUTION, "", "x"),
            DepClassification("c", "1.0", RiskLevel.SAFE, "", "x"),
        ]
        assert _compute_risk_score(cls) == 20


# --- Full Scan (with temp files) ---

class TestScanDeps:
    def test_scan_requirements_file(self, tmp_path):
        req = tmp_path / "requirements.txt"
        req.write_text("flask==2.0.0\nsh==1.14.0\nrequests==2.28.0\nsome-lib==1.0.0\n")
        report = scan_deps(req)
        assert report.total_deps == 4
        assert report.risky_deps >= 2  # sh=dangerous, requests=caution
        assert report.risk_score > 0

    def test_scan_package_json(self, tmp_path):
        pkg = tmp_path / "package.json"
        pkg.write_text(json.dumps({
            "dependencies": {
                "express": "^4.18.0",
                "shelljs": "^0.8.0",
                "lodash": "^4.17.21",
            }
        }))
        report = scan_deps(pkg)
        assert report.total_deps == 3
        assert report.risky_deps >= 1  # shelljs=dangerous

    def test_scan_directory(self, tmp_path):
        req = tmp_path / "requirements.txt"
        req.write_text("flask==2.0.0\nsh==1.14.0\n")
        report = scan_deps(tmp_path)
        assert report.total_deps >= 2

    def test_scan_nonexistent_path(self):
        report = scan_deps(Path("/nonexistent/path"))
        assert len(report.errors) > 0

    def test_scan_empty_directory(self, tmp_path):
        report = scan_deps(tmp_path)
        assert report.total_deps == 0
        assert report.risk_score == 0

    def test_scan_deduplicates(self, tmp_path):
        req1 = tmp_path / "requirements.txt"
        req1.write_text("flask==2.0.0\n")
        sub = tmp_path / "sub"
        sub.mkdir()
        req2 = sub / "requirements.txt"
        req2.write_text("flask==2.0.0\n")
        report = scan_deps(tmp_path)
        # Should deduplicate flask
        assert report.total_deps == 1

    def test_scan_unknown_manifest(self, tmp_path):
        f = tmp_path / "Makefile"
        f.write_text("all: build")
        report = scan_deps(f)
        assert len(report.errors) > 0


# --- Formatters ---

class TestFormatVulnText:
    def test_empty_report(self):
        r = VulnReport(path="/test")
        text = format_vuln_text(r)
        assert "Dependency Vulnerability Scan" in text
        assert "safe for MCP context" in text

    def test_with_dangerous(self):
        r = VulnReport(path="/test", total_deps=1, risky_deps=1, risk_score=15,
                        classifications=[
                            DepClassification("sh", "1.0", RiskLevel.DANGEROUS,
                                              "Shell wrapper", "execution"),
                        ])
        text = format_vuln_text(r)
        assert "DANGEROUS" in text
        assert "sh" in text

    def test_with_caution(self):
        r = VulnReport(path="/test", total_deps=1, risky_deps=1, risk_score=5,
                        classifications=[
                            DepClassification("requests", "2.28", RiskLevel.CAUTION,
                                              "HTTP client", "network"),
                        ])
        text = format_vuln_text(r)
        assert "CAUTION" in text

    def test_with_errors(self):
        r = VulnReport(path="/test", errors=["Something went wrong"])
        text = format_vuln_text(r)
        assert "Error:" in text


class TestFormatVulnJson:
    def test_valid_json(self):
        r = VulnReport(path="/test", total_deps=2, risky_deps=1, risk_score=15,
                        classifications=[
                            DepClassification("sh", "1.0", RiskLevel.DANGEROUS,
                                              "Shell wrapper", "execution"),
                            DepClassification("flask", "2.0", RiskLevel.SAFE,
                                              "No issues", "general"),
                        ])
        text = format_vuln_json(r)
        data = json.loads(text)
        assert data["total_deps"] == 2
        assert data["risky_deps"] == 1
        assert len(data["classifications"]) == 2


# --- Public API ---

class TestPublicAPI:
    def test_get_risky_packages(self):
        pkgs = get_risky_packages()
        assert len(pkgs) > 30
        names = {p.name for p in pkgs}
        assert "sh" in names
        assert "paramiko" in names

    def test_get_risk_categories(self):
        cats = get_risk_categories()
        assert "execution" in cats
        assert "serialization" in cats
        assert "network" in cats
