"""Dependency Vulnerability Scanner for MCP servers.

Scans dependency manifests (requirements.txt, pyproject.toml, package.json)
from MCP servers and classifies packages by risk in an MCP context.
Identifies packages that enable dangerous patterns (deserialization,
subprocess wrappers, eval helpers, etc.) which are especially risky
when exposed via MCP tool calls.

Zero external dependencies — pure Python stdlib.
"""

from __future__ import annotations

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


# ---------- Risk Classification ----------

class RiskLevel:
    """Risk classification constants."""
    SAFE = "safe"
    CAUTION = "caution"
    DANGEROUS = "dangerous"


@dataclass
class RiskyPackage:
    """A package known to be risky in MCP context."""

    name: str
    risk: str  # safe, caution, dangerous
    reason: str
    category: str  # serialization, execution, network, filesystem, crypto

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


@dataclass
class DepClassification:
    """Classification result for a single dependency."""

    package: str
    version: str
    risk: str  # safe, caution, dangerous
    reason: str
    category: str

    def to_dict(self) -> dict[str, Any]:
        return {
            "package": self.package,
            "version": self.version,
            "risk": self.risk,
            "reason": self.reason,
            "category": self.category,
        }


@dataclass
class VulnReport:
    """Aggregated vulnerability report for a dependency scan."""

    path: str
    total_deps: int = 0
    risky_deps: int = 0
    risk_score: int = 0  # 0-100
    classifications: list[DepClassification] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)

    @property
    def safe_count(self) -> int:
        return sum(1 for c in self.classifications if c.risk == RiskLevel.SAFE)

    @property
    def caution_count(self) -> int:
        return sum(1 for c in self.classifications if c.risk == RiskLevel.CAUTION)

    @property
    def dangerous_count(self) -> int:
        return sum(1 for c in self.classifications if c.risk == RiskLevel.DANGEROUS)

    def to_dict(self) -> dict[str, Any]:
        return {
            "path": self.path,
            "total_deps": self.total_deps,
            "risky_deps": self.risky_deps,
            "risk_score": self.risk_score,
            "safe_count": self.safe_count,
            "caution_count": self.caution_count,
            "dangerous_count": self.dangerous_count,
            "classifications": [c.to_dict() for c in self.classifications],
            "errors": self.errors,
        }


# ---------- Risky Package Database ----------

# Packages known to be dangerous in MCP server context.
# These enable code execution, deserialization, or unrestricted I/O
# which can be exploited via MCP tool calls.

_DANGEROUS_PACKAGES: list[RiskyPackage] = [
    # Serialization risks (deserialization can lead to RCE)
    RiskyPackage("dill", RiskLevel.DANGEROUS,
                 "Extended serializer — can deserialize arbitrary objects including lambdas",
                 "serialization"),
    RiskyPackage("cloudpickle", RiskLevel.DANGEROUS,
                 "Serialization variant for distributed computing — allows arbitrary code execution",
                 "serialization"),
    RiskyPackage("jsonpickle", RiskLevel.DANGEROUS,
                 "JSON-based serialization that can deserialize arbitrary Python objects",
                 "serialization"),
    RiskyPackage("shelve", RiskLevel.DANGEROUS,
                 "Uses unsafe deserialization internally for object persistence",
                 "serialization"),
    RiskyPackage("pyaml", RiskLevel.DANGEROUS,
                 "YAML loader may enable arbitrary code execution via unsafe load()",
                 "serialization"),
    RiskyPackage("ruamel.yaml", RiskLevel.CAUTION,
                 "YAML library — safe if using safe_load, dangerous with load()",
                 "serialization"),
    RiskyPackage("pyyaml", RiskLevel.CAUTION,
                 "YAML library — safe if using safe_load, dangerous with load()",
                 "serialization"),
    # Execution risks (subprocess, shell, eval)
    RiskyPackage("sh", RiskLevel.DANGEROUS,
                 "Shell command wrapper — enables arbitrary command execution",
                 "execution"),
    RiskyPackage("plumbum", RiskLevel.DANGEROUS,
                 "Shell command wrapper with local/remote execution",
                 "execution"),
    RiskyPackage("invoke", RiskLevel.DANGEROUS,
                 "Task execution library — runs arbitrary shell commands",
                 "execution"),
    RiskyPackage("fabric", RiskLevel.DANGEROUS,
                 "Remote execution library — SSH command execution",
                 "execution"),
    RiskyPackage("paramiko", RiskLevel.DANGEROUS,
                 "SSH library — enables remote command execution",
                 "execution"),
    RiskyPackage("pexpect", RiskLevel.DANGEROUS,
                 "Spawns and controls child processes interactively",
                 "execution"),
    RiskyPackage("sarge", RiskLevel.DANGEROUS,
                 "Subprocess wrapper for pipeline execution",
                 "execution"),
    RiskyPackage("envoy", RiskLevel.DANGEROUS,
                 "Subprocess wrapper for command execution",
                 "execution"),
    RiskyPackage("asteval", RiskLevel.DANGEROUS,
                 "Evaluates Python expressions — can be used for code injection",
                 "execution"),
    RiskyPackage("simpleeval", RiskLevel.CAUTION,
                 "Simple expression evaluator — limited but can be abused",
                 "execution"),
    RiskyPackage("RestrictedPython", RiskLevel.CAUTION,
                 "Restricted Python execution — sandboxed but escape possible",
                 "execution"),
    # Network risks (SSRF, open connections)
    RiskyPackage("requests", RiskLevel.CAUTION,
                 "HTTP client — can be used for SSRF if user input reaches URLs",
                 "network"),
    RiskyPackage("httpx", RiskLevel.CAUTION,
                 "HTTP client — can be used for SSRF if user input reaches URLs",
                 "network"),
    RiskyPackage("aiohttp", RiskLevel.CAUTION,
                 "Async HTTP client — can be used for SSRF",
                 "network"),
    RiskyPackage("urllib3", RiskLevel.CAUTION,
                 "HTTP client — can be used for SSRF if user input reaches URLs",
                 "network"),
    RiskyPackage("pycurl", RiskLevel.CAUTION,
                 "Low-level HTTP client — can be used for SSRF",
                 "network"),
    RiskyPackage("twisted", RiskLevel.CAUTION,
                 "Network framework — powerful but risky if user input controls connections",
                 "network"),
    # Filesystem risks
    RiskyPackage("watchdog", RiskLevel.CAUTION,
                 "Filesystem watcher — can monitor sensitive directories",
                 "filesystem"),
    RiskyPackage("send2trash", RiskLevel.CAUTION,
                 "File deletion helper — can delete files if user input controls paths",
                 "filesystem"),
    RiskyPackage("pathtools", RiskLevel.SAFE,
                 "Path utilities — generally safe",
                 "filesystem"),
    # Crypto risks
    RiskyPackage("pycryptodome", RiskLevel.CAUTION,
                 "Crypto library — safe if used correctly, risky with weak algorithms",
                 "crypto"),
    RiskyPackage("cryptography", RiskLevel.CAUTION,
                 "Crypto library — safe if used correctly",
                 "crypto"),
    # Template engines (SSTI risk)
    RiskyPackage("jinja2", RiskLevel.CAUTION,
                 "Template engine — Server-Side Template Injection risk if user input in templates",
                 "execution"),
    RiskyPackage("mako", RiskLevel.DANGEROUS,
                 "Template engine with Python code execution in templates",
                 "execution"),
    RiskyPackage("chameleon", RiskLevel.CAUTION,
                 "Template engine — can execute Python expressions",
                 "execution"),
    # Database (injection risks)
    RiskyPackage("sqlalchemy", RiskLevel.CAUTION,
                 "ORM — SQL injection risk if user input in raw queries",
                 "network"),
    RiskyPackage("pymongo", RiskLevel.CAUTION,
                 "MongoDB driver — NoSQL injection risk with user input",
                 "network"),
    RiskyPackage("redis", RiskLevel.CAUTION,
                 "Redis client — can execute server commands if user input reaches client",
                 "network"),
    # Node.js dangerous packages
    RiskyPackage("shelljs", RiskLevel.DANGEROUS,
                 "Shell command execution in Node.js",
                 "execution"),
    RiskyPackage("execa", RiskLevel.DANGEROUS,
                 "Process execution wrapper — arbitrary command execution",
                 "execution"),
    RiskyPackage("node-pty", RiskLevel.DANGEROUS,
                 "Pseudo-terminal — enables interactive shell access",
                 "execution"),
    RiskyPackage("node-serialize", RiskLevel.DANGEROUS,
                 "Known RCE via deserialization (CVE-2017-5941)",
                 "serialization"),
    RiskyPackage("funcster", RiskLevel.DANGEROUS,
                 "Serializes/deserializes functions — RCE risk",
                 "serialization"),
    RiskyPackage("vm2", RiskLevel.DANGEROUS,
                 "Node.js VM sandbox — known sandbox escapes (CVE-2023-37466)",
                 "execution"),
    RiskyPackage("eval", RiskLevel.DANGEROUS,
                 "Code evaluation package — arbitrary code execution",
                 "execution"),
]

# Build lookup dict (normalized names)
_RISKY_DB: dict[str, RiskyPackage] = {}
for _pkg in _DANGEROUS_PACKAGES:
    _normalized = _pkg.name.lower().replace("-", "_").replace(".", "_")
    _RISKY_DB[_normalized] = _pkg
    # Also index by original name lowercase
    _RISKY_DB[_pkg.name.lower()] = _pkg


# ---------- Manifest Parsers ----------

def _parse_requirements_txt(content: str) -> list[tuple[str, str]]:
    """Parse requirements.txt into (package, version) pairs."""
    deps: list[tuple[str, str]] = []
    for raw_line in content.splitlines():
        line = raw_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:
            pkg = m.group(1).lower()
            version = m.group(3)
            deps.append((pkg, version))
        else:
            m2 = re.match(r'^([a-zA-Z0-9_.\-]+)', line)
            if m2:
                deps.append((m2.group(1).lower(), ""))
    return deps


def _parse_pyproject_toml(content: str) -> list[tuple[str, str]]:
    """Parse pyproject.toml dependencies section (simple TOML subset)."""
    deps: list[tuple[str, str]] = []
    in_deps = False
    for raw_line in content.splitlines():
        line = raw_line.strip()
        if line.startswith("#"):
            continue
        if re.match(r'^\[(?:project\.)?dependencies\]', line):
            in_deps = True
            continue
        # dependencies = [...] inline form
        if "dependencies" in line and "=" in line and "[" in line:
            bracket_content = re.search(r'\[([^\]]*)\]', line)
            if bracket_content:
                items = bracket_content.group(1).split(",")
                for item in items:
                    item = item.strip().strip('"').strip("'")
                    if item:
                        m = re.match(r'^([a-zA-Z0-9_.\-]+)\s*([><=!~]*)\s*(.*)', item)
                        if m:
                            deps.append((m.group(1).lower(), m.group(3).strip()))
            continue
        if in_deps:
            if line.startswith("[") or line.startswith("]"):
                in_deps = False
                continue
            item = line.strip().strip(",").strip('"').strip("'")
            if item:
                m = re.match(r'^([a-zA-Z0-9_.\-]+)\s*([><=!~]*)\s*(.*)', item)
                if m:
                    deps.append((m.group(1).lower(), m.group(3).strip()))
    return deps


def _parse_package_json(content: str) -> list[tuple[str, str]]:
    """Parse package.json dependencies into (package, version) pairs."""
    deps: list[tuple[str, str]] = []
    try:
        data = json.loads(content)
    except (json.JSONDecodeError, ValueError):
        return deps
    for dep_key in ("dependencies", "devDependencies", "peerDependencies"):
        dep_dict = data.get(dep_key, {})
        if isinstance(dep_dict, dict):
            for pkg, version in dep_dict.items():
                if isinstance(version, str):
                    deps.append((pkg.lower(), version))
    return deps


def _detect_manifest_type(filepath: Path) -> str:
    """Detect manifest type from filename.

    Returns: 'requirements', 'pyproject', 'package_json', or 'unknown'.
    """
    name = filepath.name.lower()
    if name in ("requirements.txt", "requirements-dev.txt",
                "requirements_dev.txt", "constraints.txt"):
        return "requirements"
    if name == "pyproject.toml":
        return "pyproject"
    if name in ("package.json", "package-lock.json"):
        return "package_json"
    return "unknown"


def _normalize_name(name: str) -> str:
    """Normalize package name for lookup."""
    return name.lower().replace("-", "_").replace(".", "_")


# ---------- Scanner ----------

def _classify_dep(pkg_name: str, version: str) -> DepClassification:
    """Classify a single dependency by MCP risk level."""
    normalized = _normalize_name(pkg_name)
    if normalized in _RISKY_DB:
        entry = _RISKY_DB[normalized]
        return DepClassification(
            package=pkg_name,
            version=version or "unpinned",
            risk=entry.risk,
            reason=entry.reason,
            category=entry.category,
        )
    lower = pkg_name.lower()
    if lower in _RISKY_DB:
        entry = _RISKY_DB[lower]
        return DepClassification(
            package=pkg_name,
            version=version or "unpinned",
            risk=entry.risk,
            reason=entry.reason,
            category=entry.category,
        )
    return DepClassification(
        package=pkg_name,
        version=version or "unpinned",
        risk=RiskLevel.SAFE,
        reason="No known MCP security risks",
        category="general",
    )


def _compute_risk_score(classifications: list[DepClassification]) -> int:
    """Compute aggregate risk score 0-100.

    Scoring: dangerous=+15, caution=+5, capped at 100.
    """
    if not classifications:
        return 0
    score = 0
    for c in classifications:
        if c.risk == RiskLevel.DANGEROUS:
            score += 15
        elif c.risk == RiskLevel.CAUTION:
            score += 5
    return min(score, 100)


def scan_deps(path: Path) -> VulnReport:
    """Scan a path for dependency manifests and classify by MCP risk.

    Args:
        path: File or directory to scan.

    Returns:
        VulnReport with all classifications.
    """
    report = VulnReport(path=str(path))

    if path.is_file():
        manifests = [path]
    elif path.is_dir():
        manifests = _find_manifests(path)
    else:
        report.errors.append(f"Path not found: {path}")
        return report

    all_deps: list[tuple[str, str]] = []
    for manifest in manifests:
        deps = _parse_manifest(manifest)
        if deps is None:
            report.errors.append(f"Unknown manifest type: {manifest.name}")
        else:
            all_deps.extend(deps)

    # Deduplicate by package name (keep first occurrence)
    seen: set[str] = set()
    unique_deps: list[tuple[str, str]] = []
    for pkg, ver in all_deps:
        key = _normalize_name(pkg)
        if key not in seen:
            seen.add(key)
            unique_deps.append((pkg, ver))

    report.total_deps = len(unique_deps)
    for pkg, ver in unique_deps:
        classification = _classify_dep(pkg, ver)
        report.classifications.append(classification)

    report.risky_deps = sum(
        1 for c in report.classifications if c.risk != RiskLevel.SAFE
    )
    report.risk_score = _compute_risk_score(report.classifications)
    return report


def _find_manifests(dirpath: Path) -> list[Path]:
    """Find all dependency manifests in a directory."""
    manifest_names = [
        "requirements.txt", "requirements-dev.txt",
        "pyproject.toml", "package.json",
    ]
    results: list[Path] = []
    for name in manifest_names:
        for f in sorted(dirpath.rglob(name)):
            parts = f.parts
            if "node_modules" in parts or "vendor" in parts or ".git" in parts:
                continue
            results.append(f)
    return results


def _parse_manifest(filepath: Path) -> list[tuple[str, str]] | None:
    """Parse a manifest file into (package, version) pairs.

    Returns None if the manifest type is unknown.
    """
    mtype = _detect_manifest_type(filepath)
    try:
        content = filepath.read_text(encoding="utf-8", errors="replace")
    except (OSError, IOError):
        return []

    if mtype == "requirements":
        return _parse_requirements_txt(content)
    if mtype == "pyproject":
        return _parse_pyproject_toml(content)
    if mtype == "package_json":
        return _parse_package_json(content)
    return None


# ---------- Formatters ----------

def format_vuln_text(report: VulnReport) -> str:
    """Format a VulnReport as human-readable text."""
    lines: list[str] = []
    lines.append(f"\n  Dependency Vulnerability Scan: {report.path}")
    lines.append(f"  Total dependencies: {report.total_deps}")
    lines.append(f"  Risky dependencies: {report.risky_deps}")
    lines.append(f"  Risk score: {report.risk_score}/100")
    lines.append("")

    if report.errors:
        for err in report.errors:
            lines.append(f"  Error: {err}")
        lines.append("")

    dangerous = [c for c in report.classifications if c.risk == RiskLevel.DANGEROUS]
    caution = [c for c in report.classifications if c.risk == RiskLevel.CAUTION]

    if dangerous:
        lines.append("  DANGEROUS dependencies:")
        for c in dangerous:
            lines.append(f"    [!] {c.package} ({c.version}) — {c.reason}")
        lines.append("")

    if caution:
        lines.append("  CAUTION dependencies:")
        for c in caution:
            lines.append(f"    [-] {c.package} ({c.version}) — {c.reason}")
        lines.append("")

    if not dangerous and not caution:
        lines.append("  All dependencies appear safe for MCP context.")
        lines.append("")

    return "\n".join(lines)


def format_vuln_json(report: VulnReport) -> str:
    """Format a VulnReport as JSON."""
    return json.dumps(report.to_dict(), indent=2, ensure_ascii=False)


# ---------- Public API ----------

def get_risky_packages() -> list[RiskyPackage]:
    """Return the full list of known risky packages."""
    return list(_DANGEROUS_PACKAGES)


def get_risk_categories() -> list[str]:
    """Return all risk categories."""
    return sorted({p.category for p in _DANGEROUS_PACKAGES})


# ==========================================================================
# v2.5.0 — Enhanced Dependency Vulnerability Scanner
# ==========================================================================
# New: deprecated packages DB, typosquatting detection, license compliance,
# known-vulnerable version ranges, and unified scan_dependencies() API.
# ==========================================================================


# ---------- Deprecated Packages Database ----------

@dataclass
class DeprecatedPackage:
    """A package known to be deprecated."""

    name: str
    replacement: str
    reason: str

    def to_dict(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "replacement": self.replacement,
            "reason": self.reason,
        }


_DEPRECATED_PACKAGES: list[DeprecatedPackage] = [
    DeprecatedPackage("nose", "pytest", "Unmaintained since 2015"),
    DeprecatedPackage("distribute", "setuptools", "Merged back into setuptools"),
    DeprecatedPackage("pep8", "pycodestyle", "Renamed to pycodestyle"),
    DeprecatedPackage("pyflakes", "flake8", "Use flake8 which bundles pyflakes"),
    DeprecatedPackage("optparse", "argparse", "Deprecated in Python 3"),
    DeprecatedPackage("imp", "importlib", "Deprecated since Python 3.4"),
    DeprecatedPackage("formatter", "", "Deprecated since Python 3.4"),
    DeprecatedPackage("cgi", "http.server", "Deprecated since Python 3.11"),
    DeprecatedPackage("cgitb", "", "Deprecated since Python 3.11"),
    DeprecatedPackage("aiodns", "aiohttp", "Often replaced by aiohttp built-in resolver"),
    DeprecatedPackage("trollius", "asyncio", "Replaced by Python 3 asyncio"),
    DeprecatedPackage("futures", "concurrent.futures", "Backport; use stdlib in Python 3"),
    DeprecatedPackage("pathlib2", "pathlib", "Backport; use stdlib pathlib in Python 3"),
    DeprecatedPackage("configparser2", "configparser", "Backport; use stdlib in Python 3"),
    DeprecatedPackage("mock", "unittest.mock", "Use stdlib unittest.mock in Python 3"),
    DeprecatedPackage("pylint-django", "pylint", "Often unnecessary with modern pylint"),
    DeprecatedPackage("fabric2", "fabric", "Use fabric >= 2.x directly"),
    DeprecatedPackage("python-dateutil", "dateutil", "Still maintained but name changed"),
    DeprecatedPackage("pycrypto", "pycryptodome", "Unmaintained; known vulnerabilities"),
    DeprecatedPackage("django-nose", "pytest-django", "Nose is unmaintained"),
    DeprecatedPackage("requests-mock", "responses", "Alternative: responses is more maintained"),
    DeprecatedPackage("suds", "zeep", "Unmaintained; use zeep for SOAP"),
    DeprecatedPackage("pylint-common", "pylint", "Merged into pylint core"),
]

_DEPRECATED_DB: dict[str, DeprecatedPackage] = {}
for _dp in _DEPRECATED_PACKAGES:
    _DEPRECATED_DB[_dp.name.lower().replace("-", "_").replace(".", "_")] = _dp
    _DEPRECATED_DB[_dp.name.lower()] = _dp


# ---------- Known Vulnerable Versions ----------

@dataclass
class VulnerableVersion:
    """A known vulnerable version range for a package."""

    package: str
    affected_versions: list[str]  # List of version prefixes/exact versions
    cve: str
    description: str
    severity: str = "HIGH"  # CRITICAL, HIGH, MEDIUM, LOW

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


_VULNERABLE_VERSIONS: list[VulnerableVersion] = [
    VulnerableVersion("django", ["1.", "2.0", "2.1", "2.2.0", "2.2.1", "2.2.2"],
                       "CVE-2019-14232", "ReDoS in django.utils.text.Truncator", "HIGH"),
    VulnerableVersion("flask", ["0."],
                       "CVE-2018-1000656", "Improper input validation in Flask < 1.0", "MEDIUM"),
    VulnerableVersion("requests", ["2.19.", "2.18.", "2.17.", "2.3."],
                       "CVE-2018-18074", "SSRF via redirect handling", "HIGH"),
    VulnerableVersion("urllib3", ["1.23", "1.22", "1.21"],
                       "CVE-2018-20060", "Cross-host redirect leak", "HIGH"),
    VulnerableVersion("jinja2", ["2.0", "2.1", "2.2", "2.3", "2.4", "2.5",
                                  "2.6", "2.7", "2.8", "2.9", "2.10.0", "2.10.1"],
                       "CVE-2020-28493", "Sandbox bypass via string formatting", "HIGH"),
    VulnerableVersion("pyyaml", ["3.", "4.", "5.0", "5.1", "5.2", "5.3.0"],
                       "CVE-2020-14343", "Arbitrary code execution via yaml.load()", "CRITICAL"),
    VulnerableVersion("pillow", ["5.", "6.", "7.0", "7.1", "8.0", "8.1.0", "8.1.1"],
                       "CVE-2021-25293", "Buffer overflow in TiffDecode", "HIGH"),
    VulnerableVersion("cryptography", ["2.", "3.0", "3.1", "3.2", "3.3.0", "3.3.1"],
                       "CVE-2023-23931", "Memory corruption in PKCS7 padding", "HIGH"),
    VulnerableVersion("paramiko", ["2.0", "2.1", "2.2", "2.3", "2.4.0", "2.4.1"],
                       "CVE-2018-1000805", "Auth bypass in Paramiko < 2.4.2", "CRITICAL"),
    VulnerableVersion("sqlalchemy", ["0.", "1.0", "1.1", "1.2.0", "1.2.1", "1.2.2"],
                       "CVE-2019-7164", "SQL injection in text() clause", "HIGH"),
    VulnerableVersion("werkzeug", ["0.", "1.0.0", "1.0.1"],
                       "CVE-2020-28724", "Open redirect via URL parsing", "MEDIUM"),
    VulnerableVersion("axios", ["0.18.", "0.17.", "0.16."],
                       "CVE-2019-10742", "SSRF via proxy setup", "HIGH"),
    VulnerableVersion("lodash", ["4.17.0", "4.17.1", "4.17.2", "4.17.3", "4.17.4",
                                  "4.17.5", "4.17.10", "4.17.11", "4.17.15"],
                       "CVE-2020-8203", "Prototype pollution in lodash", "HIGH"),
    VulnerableVersion("express", ["3.", "4.0", "4.1", "4.2", "4.3", "4.4",
                                   "4.5", "4.6", "4.7", "4.8", "4.9", "4.10",
                                   "4.11", "4.12", "4.13", "4.14", "4.15.0",
                                   "4.15.1", "4.15.2", "4.15.3", "4.15.4"],
                       "CVE-2022-24999", "Prototype pollution via qs", "HIGH"),
    VulnerableVersion("node-serialize", ["0."],
                       "CVE-2017-5941", "RCE via deserialization", "CRITICAL"),
    VulnerableVersion("vm2", ["3.0", "3.1", "3.2", "3.3", "3.4", "3.5",
                               "3.6", "3.7", "3.8", "3.9.0", "3.9.1",
                               "3.9.2", "3.9.3", "3.9.4", "3.9.5",
                               "3.9.6", "3.9.7", "3.9.8", "3.9.9",
                               "3.9.10", "3.9.11", "3.9.12", "3.9.13",
                               "3.9.14", "3.9.15", "3.9.16", "3.9.17",
                               "3.9.18", "3.9.19"],
                       "CVE-2023-37466", "Sandbox escape in vm2", "CRITICAL"),
]

_VULN_DB: dict[str, list[VulnerableVersion]] = {}
for _vv in _VULNERABLE_VERSIONS:
    key = _vv.package.lower().replace("-", "_").replace(".", "_")
    _VULN_DB.setdefault(key, []).append(_vv)
    _VULN_DB.setdefault(_vv.package.lower(), []).append(_vv)


# ---------- Typosquatting Detection ----------

# Common legitimate packages and their typosquat variants
_TYPOSQUAT_TARGETS: dict[str, list[str]] = {
    "requests": ["request", "reqests", "requets", "requsts", "requestes", "requestss"],
    "flask": ["flaask", "flaskk", "flaski"],
    "django": ["djnago", "djangoo", "djanogo"],
    "numpy": ["numpi", "numpyy", "nunpy"],
    "pandas": ["pandass", "pnadas", "pandsa"],
    "tensorflow": ["tenserflow", "tensorflw", "tensorfow"],
    "boto3": ["botto3", "botoo3", "boto33"],
    "httpx": ["httppx", "htpx", "httpxx"],
    "pyyaml": ["pyyyaml", "pyyml", "pyaml2"],
    "sqlalchemy": ["sqalchemy", "sqlalchmy", "sqlaclhemy"],
    "cryptography": ["cyptography", "crytography", "cryptograpy"],
    "paramiko": ["paraimko", "parmiko", "paramiiko"],
    "jinja2": ["jinja3", "jiinja2", "jnja2"],
    "aiohttp": ["aiohtp", "aiohtpp", "aioohttp"],
    "celery": ["celerry", "cellery", "celeery"],
    "redis": ["rediss", "reddis", "redsi"],
    "pymongo": ["pymogo", "pymongoo", "pymonggo"],
    "pillow": ["pilow", "pilllow", "pilw"],
    "setuptools": ["setuptool", "setuptoolss", "setptools"],
    "pytest": ["pyest", "pytst", "pytests"],
}

# Build reverse lookup: typo -> legitimate
_TYPOSQUAT_REVERSE: dict[str, str] = {}
for _legit, _typos in _TYPOSQUAT_TARGETS.items():
    for _t in _typos:
        _TYPOSQUAT_REVERSE[_t.lower()] = _legit


def _check_typosquat(pkg_name: str) -> str | None:
    """Check if a package name is a known typosquat.

    Returns the legitimate package name if typosquat detected, None otherwise.
    """
    normalized = pkg_name.lower().replace("-", "_").replace(".", "_")
    if normalized in _TYPOSQUAT_REVERSE:
        return _TYPOSQUAT_REVERSE[normalized]
    lowered = pkg_name.lower()
    if lowered in _TYPOSQUAT_REVERSE:
        return _TYPOSQUAT_REVERSE[lowered]
    return None


# ---------- License Compliance ----------

class LicenseRisk:
    """License risk classification."""
    OK = "ok"
    WARNING = "warning"
    COPYLEFT = "copyleft"
    UNKNOWN = "unknown"


@dataclass
class LicenseInfo:
    """License classification for a package."""

    package: str
    license_id: str
    risk: str  # ok, warning, copyleft, unknown
    note: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "package": self.package,
            "license_id": self.license_id,
            "risk": self.risk,
            "note": self.note,
        }


# Known license classifications
_LICENSE_MAP: dict[str, str] = {
    # Permissive (OK)
    "mit": LicenseRisk.OK,
    "apache-2.0": LicenseRisk.OK,
    "apache 2.0": LicenseRisk.OK,
    "apache": LicenseRisk.OK,
    "bsd-2-clause": LicenseRisk.OK,
    "bsd-3-clause": LicenseRisk.OK,
    "bsd": LicenseRisk.OK,
    "isc": LicenseRisk.OK,
    "unlicense": LicenseRisk.OK,
    "cc0": LicenseRisk.OK,
    "cc0-1.0": LicenseRisk.OK,
    "wtfpl": LicenseRisk.OK,
    "0bsd": LicenseRisk.OK,
    "zlib": LicenseRisk.OK,
    "psf": LicenseRisk.OK,
    "python-2.0": LicenseRisk.OK,
    "artistic-2.0": LicenseRisk.OK,
    # Copyleft (flag for review)
    "gpl-2.0": LicenseRisk.COPYLEFT,
    "gpl-3.0": LicenseRisk.COPYLEFT,
    "gpl": LicenseRisk.COPYLEFT,
    "agpl-3.0": LicenseRisk.COPYLEFT,
    "agpl": LicenseRisk.COPYLEFT,
    "lgpl-2.1": LicenseRisk.WARNING,
    "lgpl-3.0": LicenseRisk.WARNING,
    "lgpl": LicenseRisk.WARNING,
    "mpl-2.0": LicenseRisk.WARNING,
    "eupl-1.2": LicenseRisk.COPYLEFT,
    "cecill-2.1": LicenseRisk.COPYLEFT,
}


def classify_license(license_str: str) -> str:
    """Classify a license string by risk level.

    Returns LicenseRisk constant.
    """
    if not license_str:
        return LicenseRisk.UNKNOWN
    normalized = license_str.strip().lower()
    if normalized in _LICENSE_MAP:
        return _LICENSE_MAP[normalized]
    # Substring matching for common patterns
    if "mit" in normalized:
        return LicenseRisk.OK
    if "apache" in normalized:
        return LicenseRisk.OK
    if "bsd" in normalized:
        return LicenseRisk.OK
    if "gpl" in normalized and "lgpl" not in normalized:
        return LicenseRisk.COPYLEFT
    if "lgpl" in normalized:
        return LicenseRisk.WARNING
    if "agpl" in normalized:
        return LicenseRisk.COPYLEFT
    return LicenseRisk.UNKNOWN


# ---------- Enhanced Dependency Report ----------

@dataclass
class DepFinding:
    """A single finding from the enhanced dependency scanner."""

    package: str
    version: str
    finding_type: str  # "vulnerable", "deprecated", "typosquat", "license", "risky"
    severity: str  # CRITICAL, HIGH, MEDIUM, LOW, INFO
    description: str
    recommendation: str = ""
    cve: str = ""

    def to_dict(self) -> dict[str, Any]:
        result = {
            "package": self.package,
            "version": self.version,
            "finding_type": self.finding_type,
            "severity": self.severity,
            "description": self.description,
            "recommendation": self.recommendation,
        }
        if self.cve:
            result["cve"] = self.cve
        return result


@dataclass
class DependencyReport:
    """Full dependency analysis report from scan_dependencies."""

    source: str = ""
    total_deps: int = 0
    findings: list[DepFinding] = field(default_factory=list)
    licenses: list[LicenseInfo] = field(default_factory=list)
    risk_score: int = 0  # 0-100
    errors: list[str] = field(default_factory=list)

    @property
    def vulnerable_count(self) -> int:
        return sum(1 for f in self.findings if f.finding_type == "vulnerable")

    @property
    def deprecated_count(self) -> int:
        return sum(1 for f in self.findings if f.finding_type == "deprecated")

    @property
    def typosquat_count(self) -> int:
        return sum(1 for f in self.findings if f.finding_type == "typosquat")

    @property
    def license_issue_count(self) -> int:
        return sum(1 for f in self.findings if f.finding_type == "license")

    @property
    def risky_count(self) -> int:
        return sum(1 for f in self.findings if f.finding_type == "risky")

    @property
    def total_findings(self) -> int:
        return len(self.findings)

    @property
    def copyleft_licenses(self) -> list[LicenseInfo]:
        return [l for l in self.licenses if l.risk == LicenseRisk.COPYLEFT]

    def to_dict(self) -> dict[str, Any]:
        return {
            "source": self.source,
            "total_deps": self.total_deps,
            "total_findings": self.total_findings,
            "vulnerable_count": self.vulnerable_count,
            "deprecated_count": self.deprecated_count,
            "typosquat_count": self.typosquat_count,
            "license_issue_count": self.license_issue_count,
            "risky_count": self.risky_count,
            "risk_score": self.risk_score,
            "findings": [f.to_dict() for f in self.findings],
            "licenses": [l.to_dict() for l in self.licenses],
            "errors": self.errors,
        }


# ---------- Version Matching ----------

def _version_matches(version: str, affected: list[str]) -> bool:
    """Check if a version string matches any affected version prefix."""
    if not version or version == "unpinned":
        return False
    # Clean version string
    clean = version.lstrip("^~>=<!").strip()
    for prefix in affected:
        if clean == prefix:
            return True
        # If prefix ends with '.', it's an explicit prefix match (e.g. "0." matches "0.x.y")
        if prefix.endswith(".") and clean.startswith(prefix):
            return True
        # Otherwise, match prefix + dot boundary (e.g. "2.0" matches "2.0.1" but not "2.01")
        if clean.startswith(prefix) and len(clean) > len(prefix) and clean[len(prefix)] == ".":
            return True
    return False


# ---------- Enhanced Scanner ----------

def scan_dependencies(
    code_or_requirements: str,
    licenses: dict[str, str] | None = None,
) -> DependencyReport:
    """Scan dependencies for vulnerabilities, deprecation, typosquatting, and license issues.

    Args:
        code_or_requirements: Either a requirements.txt-style string,
            package.json content, or Python code with imports.
        licenses: Optional dict mapping package names to license identifiers.

    Returns:
        DependencyReport with all findings.
    """
    report = DependencyReport(source="inline")
    if licenses is None:
        licenses = {}

    if not code_or_requirements or not code_or_requirements.strip():
        report.errors.append("Empty input provided")
        return report

    # Parse dependencies
    deps = _parse_input(code_or_requirements)
    report.total_deps = len(deps)

    for pkg, ver in deps:
        # 1. Typosquatting check
        legit = _check_typosquat(pkg)
        if legit:
            report.findings.append(DepFinding(
                package=pkg,
                version=ver or "any",
                finding_type="typosquat",
                severity="CRITICAL",
                description=f"Possible typosquat of '{legit}'",
                recommendation=f"Did you mean '{legit}'? Remove '{pkg}' immediately.",
            ))

        # 2. Known vulnerable version check
        normalized = _normalize_name(pkg)
        if normalized in _VULN_DB:
            for vuln in _VULN_DB[normalized]:
                if ver and _version_matches(ver, vuln.affected_versions):
                    report.findings.append(DepFinding(
                        package=pkg,
                        version=ver,
                        finding_type="vulnerable",
                        severity=vuln.severity,
                        description=vuln.description,
                        recommendation=f"Upgrade {pkg} to latest version",
                        cve=vuln.cve,
                    ))

        # 3. Deprecated check
        if normalized in _DEPRECATED_DB:
            dp = _DEPRECATED_DB[normalized]
            rec = f"Replace with {dp.replacement}" if dp.replacement else "Remove dependency"
            report.findings.append(DepFinding(
                package=pkg,
                version=ver or "any",
                finding_type="deprecated",
                severity="MEDIUM",
                description=f"Deprecated: {dp.reason}",
                recommendation=rec,
            ))

        # 4. Risky package check (existing DB)
        if normalized in _RISKY_DB:
            entry = _RISKY_DB[normalized]
            if entry.risk == RiskLevel.DANGEROUS:
                report.findings.append(DepFinding(
                    package=pkg,
                    version=ver or "unpinned",
                    finding_type="risky",
                    severity="HIGH",
                    description=entry.reason,
                    recommendation=f"Review usage of {pkg}; consider safer alternatives",
                ))
            elif entry.risk == RiskLevel.CAUTION:
                report.findings.append(DepFinding(
                    package=pkg,
                    version=ver or "unpinned",
                    finding_type="risky",
                    severity="MEDIUM",
                    description=entry.reason,
                    recommendation=f"Use {pkg} with caution; validate inputs",
                ))
        elif pkg.lower() in _RISKY_DB:
            entry = _RISKY_DB[pkg.lower()]
            if entry.risk == RiskLevel.DANGEROUS:
                report.findings.append(DepFinding(
                    package=pkg,
                    version=ver or "unpinned",
                    finding_type="risky",
                    severity="HIGH",
                    description=entry.reason,
                    recommendation=f"Review usage of {pkg}; consider safer alternatives",
                ))
            elif entry.risk == RiskLevel.CAUTION:
                report.findings.append(DepFinding(
                    package=pkg,
                    version=ver or "unpinned",
                    finding_type="risky",
                    severity="MEDIUM",
                    description=entry.reason,
                    recommendation=f"Use {pkg} with caution; validate inputs",
                ))

        # 5. License compliance check
        lic_str = licenses.get(pkg, licenses.get(pkg.lower(), ""))
        if lic_str:
            risk = classify_license(lic_str)
            report.licenses.append(LicenseInfo(
                package=pkg,
                license_id=lic_str,
                risk=risk,
                note="Copyleft license requires disclosure" if risk == LicenseRisk.COPYLEFT else "",
            ))
            if risk == LicenseRisk.COPYLEFT:
                report.findings.append(DepFinding(
                    package=pkg,
                    version=ver or "any",
                    finding_type="license",
                    severity="HIGH",
                    description=f"Copyleft license ({lic_str}) requires source disclosure",
                    recommendation="Review license compatibility with your project",
                ))
            elif risk == LicenseRisk.WARNING:
                report.findings.append(DepFinding(
                    package=pkg,
                    version=ver or "any",
                    finding_type="license",
                    severity="MEDIUM",
                    description=f"License ({lic_str}) may have restrictions",
                    recommendation="Review license terms for your use case",
                ))

    # Compute risk score
    score = 0
    for f in report.findings:
        if f.severity == "CRITICAL":
            score += 20
        elif f.severity == "HIGH":
            score += 10
        elif f.severity == "MEDIUM":
            score += 5
        elif f.severity == "LOW":
            score += 2
    report.risk_score = min(score, 100)

    return report


def _parse_input(text: str) -> list[tuple[str, str]]:
    """Parse input text into (package, version) pairs.

    Tries: requirements.txt format, package.json format, then Python imports.
    """
    stripped = text.strip()

    # Try package.json
    if stripped.startswith("{"):
        try:
            deps = _parse_package_json(stripped)
            if deps:
                return deps
        except Exception:
            pass

    # Detect if this looks like Python code (has import/from/def statements)
    _is_python_code = any(
        line.strip().startswith(("import ", "from ", "def ", "class ", "async "))
        for line in stripped.splitlines()
        if line.strip() and not line.strip().startswith("#")
    )

    if _is_python_code:
        # Extract import statements from Python code
        _STDLIB = frozenset({
            "os", "sys", "re", "json", "typing", "collections", "functools",
            "itertools", "pathlib", "dataclasses", "enum", "abc", "math",
            "datetime", "hashlib", "hmac", "base64", "io", "copy", "string",
            "textwrap", "urllib", "http", "socket", "ssl", "subprocess",
            "shutil", "glob", "tempfile", "logging", "unittest", "contextlib",
            "__future__", "importlib", "inspect", "ast", "dis", "types",
            "warnings", "struct", "csv", "xml", "html", "email",
            "argparse", "configparser", "signal", "threading", "multiprocessing",
            "queue", "secrets", "platform", "traceback", "pdb",
        })
        imports: list[tuple[str, str]] = []
        for line in stripped.splitlines():
            line = line.strip()
            m = re.match(r'^(?:from|import)\s+([a-zA-Z0-9_]+)', line)
            if m:
                pkg = m.group(1).lower()
                if pkg not in _STDLIB:
                    imports.append((pkg, ""))
        return imports

    # Try requirements.txt format
    deps = _parse_requirements_txt(stripped)
    if deps:
        return deps

    return []


# ---------- Enhanced Formatters ----------

def format_dep_report_text(report: DependencyReport) -> str:
    """Format a DependencyReport as human-readable text."""
    lines: list[str] = []
    lines.append(f"\n  Dependency Analysis: {report.source}")
    lines.append(f"  Total dependencies: {report.total_deps}")
    lines.append(f"  Total findings: {report.total_findings}")
    lines.append(f"  Risk score: {report.risk_score}/100")
    lines.append("")

    if report.errors:
        for err in report.errors:
            lines.append(f"  Error: {err}")
        lines.append("")

    by_type: dict[str, list[DepFinding]] = {}
    for f in report.findings:
        by_type.setdefault(f.finding_type, []).append(f)

    type_labels = {
        "typosquat": "TYPOSQUATTING",
        "vulnerable": "KNOWN VULNERABILITIES",
        "deprecated": "DEPRECATED PACKAGES",
        "license": "LICENSE ISSUES",
        "risky": "RISKY PACKAGES",
    }

    for ftype in ("typosquat", "vulnerable", "deprecated", "license", "risky"):
        findings = by_type.get(ftype, [])
        if findings:
            label = type_labels.get(ftype, ftype.upper())
            lines.append(f"  {label}:")
            for f in findings:
                cve = f" ({f.cve})" if f.cve else ""
                lines.append(f"    [{f.severity}] {f.package} ({f.version}){cve} -- {f.description}")
            lines.append("")

    if not report.findings:
        lines.append("  No issues found.")
        lines.append("")

    return "\n".join(lines)


def format_dep_report_json(report: DependencyReport) -> str:
    """Format a DependencyReport as JSON."""
    return json.dumps(report.to_dict(), indent=2, ensure_ascii=False)


# ---------- Extended Public API ----------

def get_deprecated_packages() -> list[DeprecatedPackage]:
    """Return all known deprecated packages."""
    return list(_DEPRECATED_PACKAGES)


def get_vulnerable_versions() -> list[VulnerableVersion]:
    """Return all known vulnerable version entries."""
    return list(_VULNERABLE_VERSIONS)


def get_typosquat_targets() -> dict[str, list[str]]:
    """Return the typosquat target mapping."""
    return dict(_TYPOSQUAT_TARGETS)
