"""Tests for Supply Chain Scanner."""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from mcpaudit.supply_chain import (
    COPYLEFT_LICENSES,
    DANGEROUS_NAME_PATTERNS,
    PERMISSIVE_LICENSES,
    POPULAR_NODE_PACKAGES,
    POPULAR_PYTHON_PACKAGES,
    SupplyChainFinding,
    SupplyChainResult,
    SupplyChainScanner,
    _check_license_risk,
    _detect_ecosystem,
    _detect_licenses_from_content,
    _levenshtein,
    _parse_manifest,
    _parse_package_json,
    _parse_pyproject_toml,
    _parse_requirements_txt,
    _parse_setup_cfg,
    _parse_setup_py,
    detect_typosquat,
    format_supply_chain_html,
    format_supply_chain_json,
    format_supply_chain_text,
)


# --- Levenshtein distance ---


class TestLevenshtein:
    def test_identical_strings(self):
        assert _levenshtein("abc", "abc") == 0

    def test_empty_strings(self):
        assert _levenshtein("", "") == 0

    def test_one_empty(self):
        assert _levenshtein("abc", "") == 3

    def test_other_empty(self):
        assert _levenshtein("", "xyz") == 3

    def test_single_insertion(self):
        assert _levenshtein("abc", "abcd") == 1

    def test_single_deletion(self):
        assert _levenshtein("abcd", "abc") == 1

    def test_single_substitution(self):
        assert _levenshtein("abc", "aXc") == 1

    def test_two_edits(self):
        assert _levenshtein("flask", "f1ask") == 1

    def test_longer_distance(self):
        assert _levenshtein("kitten", "sitting") == 3

    def test_symmetric(self):
        assert _levenshtein("abc", "xyz") == _levenshtein("xyz", "abc")


# --- Typosquatting detection ---


class TestTyposquatDetection:
    def test_exact_match_not_flagged(self):
        result = detect_typosquat("requests", POPULAR_PYTHON_PACKAGES)
        assert result is None

    def test_close_typo_detected(self):
        result = detect_typosquat("requets", POPULAR_PYTHON_PACKAGES)
        assert result == "requests"

    def test_flask_typo(self):
        result = detect_typosquat("f1ask", POPULAR_PYTHON_PACKAGES)
        # f1ask differs from flask by 1 edit
        assert result == "flask"

    def test_completely_different_not_flagged(self):
        result = detect_typosquat("zzzzzzz", POPULAR_PYTHON_PACKAGES)
        assert result is None

    def test_node_typo(self):
        result = detect_typosquat("expresss", POPULAR_NODE_PACKAGES)
        assert result == "express"

    def test_short_names_skipped(self):
        result = detect_typosquat("ab", POPULAR_PYTHON_PACKAGES)
        assert result is None

    def test_dash_underscore_normalized(self):
        result = detect_typosquat("scikit_learn", POPULAR_PYTHON_PACKAGES)
        assert result is None  # Exact match after normalization

    def test_numpy_typo(self):
        result = detect_typosquat("numpyy", POPULAR_PYTHON_PACKAGES)
        assert result == "numpy"


# --- Manifest parsers ---


class TestParseRequirementsTxt:
    def test_pinned_version(self):
        content = "flask==2.0.1\ndjango>=3.2\n"
        deps = _parse_requirements_txt(content)
        assert ("flask", "2.0.1") in deps
        assert ("django", "3.2") in deps

    def test_unpinned(self):
        content = "requests\nflask\n"
        deps = _parse_requirements_txt(content)
        assert ("requests", "") in deps

    def test_with_comments(self):
        content = "flask==2.0  # web framework\n# this is a comment\n"
        deps = _parse_requirements_txt(content)
        assert len(deps) == 1
        assert deps[0][0] == "flask"

    def test_git_urls_skipped(self):
        content = "git+https://github.com/user/repo.git\nflask==1.0\n"
        deps = _parse_requirements_txt(content)
        assert len(deps) == 1

    def test_flags_skipped(self):
        content = "-r other-requirements.txt\nflask==1.0\n"
        deps = _parse_requirements_txt(content)
        assert len(deps) == 1

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


class TestParsePackageJson:
    def test_basic_deps(self):
        content = json.dumps({
            "dependencies": {"express": "^4.18.0"},
            "devDependencies": {"jest": "^29.0.0"},
        })
        deps = _parse_package_json(content)
        assert ("express", "^4.18.0") in deps
        assert ("jest", "^29.0.0") in deps

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

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

    def test_peer_deps(self):
        content = json.dumps({
            "peerDependencies": {"react": ">=17.0.0"},
        })
        deps = _parse_package_json(content)
        assert ("react", ">=17.0.0") in deps

    def test_no_deps_key(self):
        content = json.dumps({"name": "test", "version": "1.0.0"})
        deps = _parse_package_json(content)
        assert deps == []


class TestParseSetupPy:
    def test_install_requires(self):
        content = """
from setuptools import setup
setup(
    name="test",
    install_requires=[
        "flask>=2.0",
        "requests==2.28.0",
    ],
)
"""
        deps = _parse_setup_py(content)
        assert len(deps) == 2
        assert deps[0][0] == "flask"
        assert deps[1][0] == "requests"

    def test_no_install_requires(self):
        content = "from setuptools import setup\nsetup(name='test')\n"
        deps = _parse_setup_py(content)
        assert deps == []


class TestParseSetupCfg:
    def test_install_requires(self):
        content = """[options]
install_requires =
    flask>=2.0
    requests==2.28.0
"""
        deps = _parse_setup_cfg(content)
        assert len(deps) == 2

    def test_empty_cfg(self):
        content = "[options]\nname = test\n"
        deps = _parse_setup_cfg(content)
        assert deps == []


class TestParsePyprojectToml:
    def test_dependencies(self):
        content = """
[project]
dependencies = [
    "flask>=2.0",
    "requests==2.28.0",
]
"""
        deps = _parse_pyproject_toml(content)
        assert len(deps) == 2

    def test_no_deps(self):
        content = "[project]\nname = 'test'\n"
        deps = _parse_pyproject_toml(content)
        assert deps == []


# --- Ecosystem detection ---


class TestEcosystemDetection:
    def test_requirements_txt(self):
        assert _detect_ecosystem(Path("requirements.txt")) == "python"

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

    def test_package_json(self):
        assert _detect_ecosystem(Path("package.json")) == "node"

    def test_setup_py(self):
        assert _detect_ecosystem(Path("setup.py")) == "python"

    def test_setup_cfg(self):
        assert _detect_ecosystem(Path("setup.cfg")) == "python"

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

    def test_unknown(self):
        assert _detect_ecosystem(Path("random.txt")) == "unknown"


# --- License detection ---


class TestLicenseDetection:
    def test_package_json_license(self):
        content = json.dumps({"license": "MIT"})
        licenses = _detect_licenses_from_content(content, Path("package.json"))
        assert "(project)" in licenses
        assert licenses["(project)"] == "MIT"

    def test_pyproject_toml_license(self):
        content = 'license = "Apache-2.0"\n'
        licenses = _detect_licenses_from_content(content, Path("pyproject.toml"))
        assert "(project)" in licenses
        assert "Apache" in licenses["(project)"]

    def test_no_license_found(self):
        content = "[project]\nname = 'test'\n"
        licenses = _detect_licenses_from_content(content, Path("pyproject.toml"))
        assert licenses == {}

    def test_copyleft_risk(self):
        finding = _check_license_risk("GPL-3.0", "some-pkg")
        assert finding is not None
        assert finding.category == "license"
        assert finding.severity == "MEDIUM"

    def test_permissive_no_risk(self):
        finding = _check_license_risk("MIT", "some-pkg")
        assert finding is None

    def test_agpl_risk(self):
        finding = _check_license_risk("AGPL-3.0", "some-pkg")
        assert finding is not None


# --- Data models ---


class TestSupplyChainFinding:
    def test_to_dict(self):
        f = SupplyChainFinding(
            category="typosquat",
            severity="CRITICAL",
            package="f1ask",
            description="looks like flask",
        )
        d = f.to_dict()
        assert d["category"] == "typosquat"
        assert d["severity"] == "CRITICAL"
        assert d["package"] == "f1ask"

    def test_default_fields(self):
        f = SupplyChainFinding(
            category="test", severity="LOW",
            package="pkg", description="desc",
        )
        assert f.recommendation == ""
        assert f.manifest_file == ""


class TestSupplyChainResult:
    def test_empty_result(self):
        r = SupplyChainResult(manifest_file="req.txt", ecosystem="python")
        assert r.finding_count == 0
        assert r.exit_code == 0

    def test_critical_exit_code(self):
        r = SupplyChainResult(manifest_file="req.txt", ecosystem="python")
        r.findings.append(SupplyChainFinding(
            category="test", severity="CRITICAL",
            package="pkg", description="desc",
        ))
        assert r.exit_code == 2

    def test_high_exit_code(self):
        r = SupplyChainResult(manifest_file="req.txt", ecosystem="python")
        r.findings.append(SupplyChainFinding(
            category="test", severity="HIGH",
            package="pkg", description="desc",
        ))
        assert r.exit_code == 1

    def test_severity_counts(self):
        r = SupplyChainResult(manifest_file="req.txt", ecosystem="python")
        r.findings.append(SupplyChainFinding(
            category="a", severity="HIGH", package="p1", description="d",
        ))
        r.findings.append(SupplyChainFinding(
            category="b", severity="HIGH", package="p2", description="d",
        ))
        r.findings.append(SupplyChainFinding(
            category="c", severity="LOW", package="p3", description="d",
        ))
        sc = r.severity_counts
        assert sc["HIGH"] == 2
        assert sc["LOW"] == 1

    def test_to_dict(self):
        r = SupplyChainResult(
            manifest_file="req.txt", ecosystem="python",
            total_deps=5,
        )
        d = r.to_dict()
        assert d["manifest_file"] == "req.txt"
        assert d["total_deps"] == 5
        assert "findings" in d


# --- Scanner integration ---


class TestSupplyChainScanner:
    def test_scan_clean_requirements(self, tmp_path):
        req = tmp_path / "requirements.txt"
        req.write_text("flask==2.0.1\nrequests==2.28.0\n")
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(req)
        assert result.ecosystem == "python"
        assert result.total_deps == 2
        # No dangerous names or typosquats in these clean deps
        dangerous = [f for f in result.findings if f.category == "dangerous_name"]
        typos = [f for f in result.findings if f.category == "typosquat"]
        assert len(dangerous) == 0
        assert len(typos) == 0

    def test_scan_dangerous_name(self, tmp_path):
        req = tmp_path / "requirements.txt"
        req.write_text("flask-eval-helper==1.0\n")
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(req)
        dangerous = [f for f in result.findings if f.category == "dangerous_name"]
        assert len(dangerous) >= 1

    def test_scan_typosquat(self, tmp_path):
        req = tmp_path / "requirements.txt"
        req.write_text("f1ask==2.0\n")
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(req)
        typos = [f for f in result.findings if f.category == "typosquat"]
        assert len(typos) >= 1
        assert typos[0].severity == "CRITICAL"

    def test_scan_excessive_deps(self, tmp_path):
        req = tmp_path / "requirements.txt"
        lines = [f"pkg-{i}==1.0.0" for i in range(105)]
        req.write_text("\n".join(lines))
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(req)
        dep_count = [f for f in result.findings if f.category == "dep_count"]
        assert len(dep_count) == 1
        assert dep_count[0].severity == "CRITICAL"

    def test_scan_medium_dep_count(self, tmp_path):
        req = tmp_path / "requirements.txt"
        lines = [f"pkg-{i}==1.0.0" for i in range(55)]
        req.write_text("\n".join(lines))
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(req)
        dep_count = [f for f in result.findings if f.category == "dep_count"]
        assert len(dep_count) == 1
        assert dep_count[0].severity == "MEDIUM"

    def test_scan_unpinned_deps(self, tmp_path):
        req = tmp_path / "requirements.txt"
        req.write_text("flask\ndjango\nrequests\n")
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(req)
        outdated = [f for f in result.findings if f.category == "outdated"]
        assert len(outdated) >= 1

    def test_scan_many_unpinned_deps(self, tmp_path):
        req = tmp_path / "requirements.txt"
        lines = [f"pkg{i}" for i in range(10)]
        req.write_text("\n".join(lines))
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(req)
        outdated = [f for f in result.findings if f.category == "outdated"]
        # Should trigger the "many unpinned" finding
        assert any("without version pins" in f.description for f in outdated)

    def test_scan_unknown_manifest(self, tmp_path):
        f = tmp_path / "unknown.xyz"
        f.write_text("content")
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(f)
        assert result.scan_error != ""

    def test_scan_package_json(self, tmp_path):
        pkg = tmp_path / "package.json"
        pkg.write_text(json.dumps({
            "dependencies": {"express": "^4.18.0"},
            "license": "MIT",
        }))
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(pkg)
        assert result.ecosystem == "node"
        assert result.total_deps == 1

    def test_scan_package_json_gpl(self, tmp_path):
        pkg = tmp_path / "package.json"
        pkg.write_text(json.dumps({
            "dependencies": {"express": "^4.18.0"},
            "license": "GPL-3.0",
        }))
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(pkg)
        license_findings = [f for f in result.findings if f.category == "license"]
        assert len(license_findings) >= 1

    def test_scan_directory(self, tmp_path):
        req = tmp_path / "requirements.txt"
        req.write_text("flask==2.0\n")
        pkg = tmp_path / "package.json"
        pkg.write_text(json.dumps({"dependencies": {"express": "^4.0"}}))
        scanner = SupplyChainScanner()
        results = scanner.scan_directory(tmp_path)
        assert len(results) >= 2

    def test_scan_setup_py(self, tmp_path):
        sp = tmp_path / "setup.py"
        sp.write_text("""
from setuptools import setup
setup(
    name="test",
    install_requires=["flask>=2.0", "requests"],
)
""")
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(sp)
        assert result.total_deps == 2

    def test_scan_pyproject_toml(self, tmp_path):
        pp = tmp_path / "pyproject.toml"
        pp.write_text("""
[project]
name = "test"
dependencies = ["flask>=2.0", "requests"]
""")
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(pp)
        assert result.total_deps == 2

    def test_node_typosquat(self, tmp_path):
        pkg = tmp_path / "package.json"
        pkg.write_text(json.dumps({
            "dependencies": {"expresss": "^4.18.0"},
        }))
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(pkg)
        typos = [f for f in result.findings if f.category == "typosquat"]
        assert len(typos) >= 1

    def test_manifest_file_set_on_findings(self, tmp_path):
        req = tmp_path / "requirements.txt"
        req.write_text("flask-eval-helper==1.0\n")
        scanner = SupplyChainScanner()
        result = scanner.scan_manifest(req)
        for f in result.findings:
            assert f.manifest_file == str(req)


# --- Formatters ---


class TestFormatters:
    def _make_result(self) -> SupplyChainResult:
        r = SupplyChainResult(
            manifest_file="requirements.txt",
            ecosystem="python",
            total_deps=5,
        )
        r.findings.append(SupplyChainFinding(
            category="typosquat", severity="CRITICAL",
            package="f1ask",
            description="Looks similar to flask",
            recommendation="Did you mean flask?",
        ))
        return r

    def test_format_text(self):
        r = self._make_result()
        text = format_supply_chain_text(r)
        assert "Supply Chain Scan" in text
        assert "requirements.txt" in text
        assert "CRITICAL" in text

    def test_format_text_clean(self):
        r = SupplyChainResult(
            manifest_file="req.txt", ecosystem="python", total_deps=3,
        )
        text = format_supply_chain_text(r)
        assert "No supply chain risks" in text

    def test_format_text_error(self):
        r = SupplyChainResult(
            manifest_file="req.txt", ecosystem="python",
            scan_error="file not found",
        )
        text = format_supply_chain_text(r)
        assert "Error" in text

    def test_format_json(self):
        r = self._make_result()
        j = format_supply_chain_json(r)
        data = json.loads(j)
        assert data["ecosystem"] == "python"
        assert len(data["findings"]) == 1

    def test_format_html(self):
        r = self._make_result()
        h = format_supply_chain_html(r)
        assert "<!DOCTYPE html>" in h
        assert "Supply Chain" in h
        assert "CRITICAL" in h

    def test_format_html_has_table(self):
        r = self._make_result()
        h = format_supply_chain_html(r)
        assert "<table" in h
        assert "f1ask" in h


# --- Constants ---


class TestConstants:
    def test_popular_python_packages_not_empty(self):
        assert len(POPULAR_PYTHON_PACKAGES) > 20

    def test_popular_node_packages_not_empty(self):
        assert len(POPULAR_NODE_PACKAGES) > 20

    def test_dangerous_patterns_not_empty(self):
        assert len(DANGEROUS_NAME_PATTERNS) > 5

    def test_copyleft_licenses_not_empty(self):
        assert len(COPYLEFT_LICENSES) > 3

    def test_permissive_licenses_not_empty(self):
        assert len(PERMISSIVE_LICENSES) > 3


# --- _parse_manifest dispatch ---


class TestParseManifestDispatch:
    def test_requirements_txt(self):
        deps = _parse_manifest(Path("requirements.txt"), "flask==2.0\n")
        assert len(deps) == 1

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

    def test_unknown_returns_empty(self):
        deps = _parse_manifest(Path("Makefile"), "all:")
        assert deps == []
