"""Dependency Scanner for MCP servers.

Scans dependency manifests (requirements.txt, package.json, go.mod,
Cargo.toml) for known vulnerable package versions.
Uses an embedded database of common vulnerable packages.
Zero external dependencies.
"""

from __future__ import annotations

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


@dataclass
class VulnerableDep:
    """A vulnerable dependency found in a manifest."""

    package: str
    current_version: str
    safe_version: str
    severity: str  # CRITICAL, HIGH, MEDIUM, LOW
    advisory: str
    manifest_file: str = ""
    ecosystem: str = ""  # python, node, go, rust

    def to_dict(self) -> dict[str, Any]:
        return {
            "package": self.package,
            "current_version": self.current_version,
            "safe_version": self.safe_version,
            "severity": self.severity,
            "advisory": self.advisory,
            "manifest_file": self.manifest_file,
            "ecosystem": self.ecosystem,
        }


@dataclass
class DepScanResult:
    """Result of a dependency scan."""

    manifest_file: str
    ecosystem: str
    total_deps: int = 0
    vulnerable: list[VulnerableDep] = field(default_factory=list)
    scan_error: str = ""

    @property
    def vuln_count(self) -> int:
        return len(self.vulnerable)

    @property
    def severity_counts(self) -> dict[str, int]:
        counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
        for v in self.vulnerable:
            if v.severity in counts:
                counts[v.severity] += 1
        return counts

    @property
    def exit_code(self) -> int:
        sc = self.severity_counts
        if sc["CRITICAL"] > 0:
            return 2
        if sc["HIGH"] > 0:
            return 1
        return 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "manifest_file": self.manifest_file,
            "ecosystem": self.ecosystem,
            "total_deps": self.total_deps,
            "vulnerable_count": self.vuln_count,
            "severity_counts": self.severity_counts,
            "vulnerable": [v.to_dict() for v in self.vulnerable],
        }


# ---------- Embedded Vulnerability Database ----------

# Format: (package_name, vulnerable_below_version, safe_version, severity, advisory)

PYTHON_VULNS: list[tuple[str, str, str, str, str]] = [
    ("flask", "2.0.0", "2.0.0", "HIGH",
     "Flask <2.0 has CSRF and session security issues"),
    ("django", "3.2.0", "3.2.0", "CRITICAL",
     "Django <3.2 has multiple known CVEs including SQL injection"),
    ("requests", "2.28.0", "2.28.0", "MEDIUM",
     "Requests <2.28 has proxy credential leakage (CVE-2023-32681)"),
    ("pyyaml", "6.0.0", "6.0.0", "CRITICAL",
     "PyYAML <6.0 allows arbitrary code execution via yaml.load()"),
    ("jinja2", "3.1.0", "3.1.0", "HIGH",
     "Jinja2 <3.1 has sandbox escape vulnerabilities"),
    ("cryptography", "39.0.0", "39.0.0", "HIGH",
     "Cryptography <39.0 has OpenSSL security fixes"),
    ("paramiko", "3.0.0", "3.0.0", "HIGH",
     "Paramiko <3.0 has authentication bypass vulnerability"),
    ("urllib3", "2.0.0", "2.0.0", "MEDIUM",
     "urllib3 <2.0 has CRLF injection in request headers"),
    ("pillow", "10.0.0", "10.0.0", "HIGH",
     "Pillow <10.0 has multiple buffer overflow CVEs"),
    ("numpy", "1.24.0", "1.24.0", "LOW",
     "NumPy <1.24 has denial-of-service via crafted array"),
]

NODE_VULNS: list[tuple[str, str, str, str, str]] = [
    ("express", "4.18.0", "4.18.0", "HIGH",
     "Express <4.18 has open redirect and path traversal issues"),
    ("axios", "1.3.0", "1.3.0", "HIGH",
     "Axios <1.3 has SSRF and prototype pollution (CVE-2023-45857)"),
    ("lodash", "4.17.21", "4.17.21", "CRITICAL",
     "Lodash <4.17.21 has prototype pollution (CVE-2021-23337)"),
    ("minimist", "1.2.7", "1.2.7", "CRITICAL",
     "Minimist <1.2.7 has prototype pollution (CVE-2021-44906)"),
    ("jsonwebtoken", "9.0.0", "9.0.0", "HIGH",
     "jsonwebtoken <9.0 has algorithm confusion vulnerability"),
    ("helmet", "6.0.0", "6.0.0", "MEDIUM",
     "Helmet <6.0 has missing security headers"),
    ("cors", "2.8.5", "2.8.5", "MEDIUM",
     "CORS <2.8.5 has misconfigured origin validation"),
    ("dotenv", "16.1.0", "16.1.0", "LOW",
     "Dotenv <16.1 has path traversal in config loading"),
    ("ws", "8.13.0", "8.13.0", "HIGH",
     "WS <8.13 has ReDoS vulnerability (CVE-2024-37890)"),
    ("body-parser", "1.20.0", "1.20.0", "MEDIUM",
     "body-parser <1.20 has denial-of-service via large payloads"),
]

# All vuln databases indexed by ecosystem
_VULN_DB: dict[str, list[tuple[str, str, str, str, str]]] = {
    "python": PYTHON_VULNS,
    "node": NODE_VULNS,
}


def _parse_version(version_str: str) -> tuple[int, ...]:
    """Parse a version string into a comparable tuple.

    Handles formats like '1.2.3', '>=1.2.3', '~1.2.3', '^1.2.3'.
    Returns (0, 0, 0) if unparseable.
    """
    # Strip operators and whitespace
    clean = re.sub(r'^[>=<~^!\s]+', '', version_str.strip())
    # Remove pre-release/build suffixes
    clean = re.split(r'[-+]', clean)[0]
    # Remove any trailing non-digit chars
    clean = re.sub(r'[^0-9.]', '', clean)
    parts = clean.split(".")
    result: list[int] = []
    for p in parts[:4]:
        try:
            result.append(int(p))
        except ValueError:
            break
    if not result:
        return (0, 0, 0)
    # Pad to 3 components
    while len(result) < 3:
        result.append(0)
    return tuple(result)


def _version_lt(current: str, threshold: str) -> bool:
    """Check if current version is less than threshold."""
    return _parse_version(current) < _parse_version(threshold)


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

    Returns: 'python', 'node', 'go', 'rust', or 'unknown'.
    """
    name = filepath.name.lower()
    if name in ("requirements.txt", "requirements-dev.txt",
                "requirements_dev.txt", "constraints.txt"):
        return "python"
    if name in ("setup.cfg", "pyproject.toml") and filepath.suffix == ".toml":
        # Could be Python (pyproject.toml) or Rust (Cargo.toml)
        if "cargo" in name:
            return "rust"
        return "python"
    if name == "pipfile":
        return "python"
    if name in ("package.json", "package-lock.json"):
        return "node"
    if name == "go.mod":
        return "go"
    if name == "cargo.toml":
        return "rust"
    return "unknown"


def _parse_requirements_txt(content: str) -> list[tuple[str, str]]:
    """Parse requirements.txt and return list of (package, version).

    Handles formats:
        flask==2.0.1
        django>=3.2
        requests
        package==1.0.0  # comment
    """
    deps: list[tuple[str, str]] = []
    for raw_line in content.splitlines():
        line = raw_line.strip()
        # Remove comments
        if "#" in line:
            line = line[:line.index("#")].strip()
        if not line or line.startswith("-") or line.startswith("git+"):
            continue
        # Parse package==version or package>=version
        m = re.match(r'^([a-zA-Z0-9_.-]+)\s*([><=!~]+)\s*([^\s,;]+)', line)
        if m:
            pkg = m.group(1).lower().replace("-", "_").replace(".", "_")
            # Normalize common name variants
            pkg_normalized = pkg.replace("_", "-").replace(".", "-")
            version = m.group(3)
            deps.append((pkg_normalized, version))
        else:
            # Package without version
            m2 = re.match(r'^([a-zA-Z0-9_.-]+)', line)
            if m2:
                pkg = m2.group(1).lower().replace("_", "-")
                deps.append((pkg, ""))
    return deps


def _parse_package_json(content: str) -> list[tuple[str, str]]:
    """Parse package.json and return list of (package, version).

    Reads both dependencies and devDependencies.
    """
    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 _parse_go_mod(content: str) -> list[tuple[str, str]]:
    """Parse go.mod and return list of (module, version).

    Handles:
        require (
            github.com/foo/bar v1.2.3
        )
        require github.com/baz/qux v0.1.0
    """
    deps: list[tuple[str, str]] = []
    in_require_block = False
    for raw_line in content.splitlines():
        line = raw_line.strip()
        if line.startswith("//"):
            continue
        if line.startswith("require ("):
            in_require_block = True
            continue
        if in_require_block:
            if line == ")":
                in_require_block = False
                continue
            # Parse "module version"
            parts = line.split()
            if len(parts) >= 2:
                module = parts[0]
                version = parts[1].lstrip("v")
                deps.append((module, version))
        elif line.startswith("require "):
            parts = line.split()
            if len(parts) >= 3:
                module = parts[1]
                version = parts[2].lstrip("v")
                deps.append((module, version))
    return deps


def _parse_cargo_toml(content: str) -> list[tuple[str, str]]:
    """Parse Cargo.toml [dependencies] section.

    Simple TOML parser for dependency entries:
        [dependencies]
        serde = "1.0"
        tokio = { version = "1.0", features = ["full"] }
    """
    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'\[(?:dev-)?dependencies\]', line):
            in_deps = True
            continue
        if line.startswith("[") and in_deps:
            in_deps = False
            continue
        if in_deps and "=" in line:
            key, _, value = line.partition("=")
            pkg = key.strip()
            value = value.strip()
            # Simple string version
            m = re.match(r'^["\']([^"\']+)["\']', value)
            if m:
                deps.append((pkg, m.group(1)))
                continue
            # Table format: { version = "1.0", ... }
            m = re.search(r'version\s*=\s*["\']([^"\']+)["\']', value)
            if m:
                deps.append((pkg, m.group(1)))
    return deps


class DependencyScanner:
    """Scans dependency manifests for known vulnerable packages."""

    def __init__(
        self,
        extra_vulns: dict[str, list[tuple[str, str, str, str, str]]] | None = None,
    ) -> None:
        """Initialize with optional extra vulnerability entries.

        Args:
            extra_vulns: Additional vulnerability entries by ecosystem.
        """
        self._vuln_db = dict(_VULN_DB)
        if extra_vulns:
            for ecosystem, entries in extra_vulns.items():
                existing = self._vuln_db.get(ecosystem, [])
                self._vuln_db[ecosystem] = existing + entries

    def scan_manifest(self, filepath: Path) -> DepScanResult:
        """Scan a single dependency manifest file.

        Args:
            filepath: Path to the manifest file.

        Returns:
            DepScanResult with found vulnerabilities.
        """
        ecosystem = _detect_manifest_type(filepath)
        result = DepScanResult(
            manifest_file=str(filepath),
            ecosystem=ecosystem,
        )

        if ecosystem == "unknown":
            result.scan_error = f"Unknown manifest type: {filepath.name}"
            return result

        try:
            content = filepath.read_text(encoding="utf-8", errors="replace")
        except (OSError, IOError) as e:
            result.scan_error = str(e)
            return result

        # Parse dependencies
        deps: list[tuple[str, str]] = []
        if ecosystem == "python":
            if filepath.name.lower() in ("requirements.txt",
                                         "requirements-dev.txt",
                                         "requirements_dev.txt",
                                         "constraints.txt"):
                deps = _parse_requirements_txt(content)
            else:
                # pyproject.toml — simple extraction
                deps = _parse_requirements_txt(content)
        elif ecosystem == "node":
            deps = _parse_package_json(content)
        elif ecosystem == "go":
            deps = _parse_go_mod(content)
        elif ecosystem == "rust":
            deps = _parse_cargo_toml(content)

        result.total_deps = len(deps)

        # Check against vulnerability database
        vuln_entries = self._vuln_db.get(ecosystem, [])
        vuln_map: dict[str, tuple[str, str, str, str]] = {}
        for pkg, vuln_below, safe, sev, adv in vuln_entries:
            vuln_map[pkg.lower()] = (vuln_below, safe, sev, adv)

        for pkg_name, pkg_version in deps:
            normalized = pkg_name.lower()
            if normalized in vuln_map:
                vuln_below, safe, sev, adv = vuln_map[normalized]
                if not pkg_version:
                    # No version pinned — potentially vulnerable
                    result.vulnerable.append(VulnerableDep(
                        package=pkg_name,
                        current_version="unpinned",
                        safe_version=safe,
                        severity=sev,
                        advisory=f"{adv} (version not pinned)",
                        manifest_file=str(filepath),
                        ecosystem=ecosystem,
                    ))
                elif _version_lt(pkg_version, vuln_below):
                    result.vulnerable.append(VulnerableDep(
                        package=pkg_name,
                        current_version=pkg_version,
                        safe_version=safe,
                        severity=sev,
                        advisory=adv,
                        manifest_file=str(filepath),
                        ecosystem=ecosystem,
                    ))

        return result

    def scan_directory(self, dirpath: Path) -> list[DepScanResult]:
        """Scan a directory for all manifest files.

        Args:
            dirpath: Directory to scan.

        Returns:
            List of DepScanResult.
        """
        manifests = [
            "requirements.txt", "requirements-dev.txt",
            "package.json", "go.mod", "Cargo.toml",
        ]
        results: list[DepScanResult] = []
        for name in manifests:
            for manifest in sorted(dirpath.rglob(name)):
                # Skip vendor/node_modules
                parts = manifest.parts
                if "node_modules" in parts or "vendor" in parts:
                    continue
                results.append(self.scan_manifest(manifest))
        return results


def format_dep_scan_text(result: DepScanResult) -> str:
    """Format a dependency scan result as text."""
    lines: list[str] = []
    lines.append(f"\n  Dependency Scan: {result.manifest_file}")
    lines.append(f"  Ecosystem: {result.ecosystem}")
    lines.append(f"  Dependencies: {result.total_deps}")
    lines.append(f"  Vulnerable: {result.vuln_count}")

    if result.scan_error:
        lines.append(f"  Error: {result.scan_error}")
        return "\n".join(lines)

    if not result.vulnerable:
        lines.append("  Status: No known vulnerabilities found")
        return "\n".join(lines)

    lines.append("")
    lines.append(f"  {'Package':<20} {'Current':<12} {'Safe':<12} {'Severity':<10} {'Advisory'}")
    lines.append(f"  {'─' * 20} {'─' * 12} {'─' * 12} {'─' * 10} {'─' * 40}")

    for v in sorted(result.vulnerable, key=lambda x: (
        {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}.get(x.severity, 4)
    )):
        lines.append(
            f"  {v.package:<20} {v.current_version:<12} {v.safe_version:<12} "
            f"{v.severity:<10} {v.advisory}"
        )

    lines.append("")
    return "\n".join(lines)


def format_dep_scan_json(result: DepScanResult) -> str:
    """Format a dependency scan result as JSON."""
    return json.dumps(result.to_dict(), indent=2, ensure_ascii=False)
