"""Supply Chain Scanner for MCP servers.

Analyzes dependency manifests to detect supply chain risks including
typosquatting, license risks, excessive dependency counts, outdated
packages, and dangerous package names. Supports requirements.txt,
setup.py, setup.cfg, pyproject.toml, and package.json.

Zero external dependencies -- pure Python stdlib.
"""

from __future__ import annotations

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


# ---------------------------------------------------------------------------
# Popular package names (for typosquatting detection)
# ---------------------------------------------------------------------------

POPULAR_PYTHON_PACKAGES: tuple[str, ...] = (
    "requests", "flask", "django", "numpy", "pandas", "scipy",
    "cryptography", "pyyaml", "jinja2", "pillow", "boto3",
    "urllib3", "setuptools", "pip", "wheel", "pytest", "black",
    "mypy", "ruff", "httpx", "fastapi", "uvicorn", "gunicorn",
    "celery", "redis", "sqlalchemy", "pydantic", "aiohttp",
    "beautifulsoup4", "scrapy", "tensorflow", "torch", "keras",
    "matplotlib", "seaborn", "scikit-learn", "transformers",
)

POPULAR_NODE_PACKAGES: tuple[str, ...] = (
    "express", "react", "axios", "lodash", "moment", "webpack",
    "typescript", "next", "vue", "angular", "jquery", "cors",
    "dotenv", "helmet", "jsonwebtoken", "mongoose", "sequelize",
    "socket.io", "eslint", "prettier", "jest", "mocha", "chalk",
    "commander", "inquirer", "yargs", "uuid", "debug", "winston",
    "passport", "bcrypt", "sharp", "cheerio", "puppeteer",
)

# Dangerous name patterns (packages that contain risky names)
DANGEROUS_NAME_PATTERNS: tuple[str, ...] = (
    "eval", "exec", "subprocess", "shell",
    "exploit", "hack", "backdoor", "malware", "trojan",
    "keylogger", "rootkit", "payload", "reverse-shell",
)

# Copyleft licenses that may conflict with commercial use
COPYLEFT_LICENSES: tuple[str, ...] = (
    "gpl", "agpl", "lgpl", "gpl-2.0", "gpl-3.0",
    "agpl-3.0", "lgpl-2.1", "lgpl-3.0",
)

# Permissive licenses (safe for commercial)
PERMISSIVE_LICENSES: tuple[str, ...] = (
    "mit", "apache", "apache-2.0", "bsd", "bsd-2-clause",
    "bsd-3-clause", "isc", "unlicense", "cc0",
)


# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------

@dataclass
class SupplyChainFinding:
    """A single supply chain risk finding."""

    category: str  # typosquat, dangerous_name, license, dep_count, outdated
    severity: str  # CRITICAL, HIGH, MEDIUM, LOW, INFO
    package: str
    description: str
    recommendation: str = ""
    manifest_file: str = ""

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


@dataclass
class SupplyChainResult:
    """Result of a supply chain scan."""

    manifest_file: str
    ecosystem: str
    total_deps: int = 0
    findings: list[SupplyChainFinding] = field(default_factory=list)
    scan_error: str = ""
    licenses_detected: dict[str, str] = field(default_factory=dict)

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

    @property
    def severity_counts(self) -> dict[str, int]:
        counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0}
        for f in self.findings:
            if f.severity in counts:
                counts[f.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,
            "finding_count": self.finding_count,
            "severity_counts": self.severity_counts,
            "findings": [f.to_dict() for f in self.findings],
            "licenses_detected": self.licenses_detected,
        }


# ---------------------------------------------------------------------------
# Levenshtein distance (simplified for typosquatting)
# ---------------------------------------------------------------------------

def _levenshtein(s1: str, s2: str) -> int:
    """Compute Levenshtein edit distance between two strings.

    Args:
        s1: First string.
        s2: Second string.

    Returns:
        Integer edit distance.
    """
    if len(s1) < len(s2):
        return _levenshtein(s2, s1)

    if len(s2) == 0:
        return len(s1)

    prev_row = list(range(len(s2) + 1))

    for i, c1 in enumerate(s1):
        curr_row = [i + 1]
        for j, c2 in enumerate(s2):
            cost = 0 if c1 == c2 else 1
            curr_row.append(min(
                curr_row[j] + 1,
                prev_row[j + 1] + 1,
                prev_row[j] + cost,
            ))
        prev_row = curr_row

    return prev_row[-1]


def detect_typosquat(
    package_name: str,
    popular_packages: tuple[str, ...],
) -> str | None:
    """Detect if a package name looks like a typosquat of a popular package.

    Checks for edit distance <= 2 from a popular package name,
    but not an exact match.

    Args:
        package_name: Package name to check.
        popular_packages: Tuple of known popular package names.

    Returns:
        The popular package name it may be squatting, or None.
    """
    name = package_name.lower().replace("-", "").replace("_", "")

    for popular in popular_packages:
        canonical = popular.lower().replace("-", "").replace("_", "")
        if name == canonical:
            continue  # Exact match, not a typosquat

        if len(name) < 3 or len(canonical) < 3:
            continue

        distance = _levenshtein(name, canonical)
        # For short names (<6 chars), only flag distance 1 to reduce false positives
        max_dist = 1 if len(name) < 6 else 2
        if 0 < distance <= max_dist:
            return popular

    return None


# ---------------------------------------------------------------------------
# Manifest parsers
# ---------------------------------------------------------------------------

def _parse_requirements_txt(content: str) -> list[tuple[str, str]]:
    """Parse requirements.txt into (package, version) tuples."""
    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_package_json(content: str) -> list[tuple[str, str]]:
    """Parse package.json into (package, version) tuples."""
    deps: list[tuple[str, str]] = []
    try:
        data = json.loads(content)
    except (json.JSONDecodeError, ValueError):
        return deps
    for key in ("dependencies", "devDependencies", "peerDependencies"):
        dep_dict = data.get(key, {})
        if isinstance(dep_dict, dict):
            for pkg, ver in dep_dict.items():
                if isinstance(ver, str):
                    deps.append((pkg.lower(), ver))
    return deps


def _parse_setup_py(content: str) -> list[tuple[str, str]]:
    """Extract install_requires from setup.py content."""
    deps: list[tuple[str, str]] = []
    m = re.search(
        r'install_requires\s*=\s*\[([^\]]*)\]',
        content,
        re.DOTALL,
    )
    if not m:
        return deps
    block = m.group(1)
    for item in re.findall(r'''['"]([^'"]+)['"]''', block):
        m2 = re.match(r'^([a-zA-Z0-9_.\-]+)\s*([><=!~]*)\s*([\d.]*)', item)
        if m2:
            deps.append((m2.group(1).lower(), m2.group(3)))
    return deps


def _parse_setup_cfg(content: str) -> list[tuple[str, str]]:
    """Extract install_requires from setup.cfg content."""
    deps: list[tuple[str, str]] = []
    in_section = False
    for raw_line in content.splitlines():
        line = raw_line.strip()
        if line == "install_requires =":
            in_section = True
            continue
        if in_section:
            if not line:
                continue
            # New section or key-value pair at column 0 means end of deps
            if not raw_line[0].isspace() and not raw_line[0] == "\t":
                break
            m = re.match(
                r'^([a-zA-Z0-9_.\-]+)\s*([><=!~]*)\s*([\d.]*)',
                line,
            )
            if m:
                deps.append((m.group(1).lower(), m.group(3)))
    return deps


def _parse_pyproject_toml(content: str) -> list[tuple[str, str]]:
    """Extract dependencies from pyproject.toml content."""
    deps: list[tuple[str, str]] = []
    m = re.search(
        r'dependencies\s*=\s*\[([^\]]*)\]',
        content,
        re.DOTALL,
    )
    if not m:
        return deps
    block = m.group(1)
    for item in re.findall(r'''['"]([^'"]+)['"]''', block):
        m2 = re.match(r'^([a-zA-Z0-9_.\-]+)\s*([><=!~]*)\s*([\d.]*)', item)
        if m2:
            deps.append((m2.group(1).lower(), m2.group(3)))
    return deps


def _detect_ecosystem(filepath: Path) -> str:
    """Detect ecosystem from manifest filename."""
    name = filepath.name.lower()
    if name in (
        "requirements.txt", "requirements-dev.txt",
        "setup.py", "setup.cfg",
    ):
        return "python"
    if name == "pyproject.toml":
        return "python"
    if name in ("package.json", "package-lock.json"):
        return "node"
    return "unknown"


def _parse_manifest(filepath: Path, content: str) -> list[tuple[str, str]]:
    """Parse a manifest file into dependency tuples."""
    name = filepath.name.lower()
    if name == "requirements.txt" or name == "requirements-dev.txt":
        return _parse_requirements_txt(content)
    if name == "package.json":
        return _parse_package_json(content)
    if name == "setup.py":
        return _parse_setup_py(content)
    if name == "setup.cfg":
        return _parse_setup_cfg(content)
    if name == "pyproject.toml":
        return _parse_pyproject_toml(content)
    return []


# ---------------------------------------------------------------------------
# License detection (heuristic, from manifest content)
# ---------------------------------------------------------------------------

def _detect_licenses_from_content(
    content: str,
    filepath: Path,
) -> dict[str, str]:
    """Detect licenses mentioned in manifest content.

    Returns mapping of package -> license_type.
    """
    licenses: dict[str, str] = {}
    name = filepath.name.lower()

    if name == "package.json":
        try:
            data = json.loads(content)
            lic = data.get("license", "")
            if isinstance(lic, str) and lic:
                licenses["(project)"] = lic
        except (json.JSONDecodeError, ValueError):
            pass

    if name == "pyproject.toml":
        m = re.search(
            r'license\s*=\s*["\']([^"\']+)["\']',
            content,
        )
        if m:
            licenses["(project)"] = m.group(1)
        m2 = re.search(
            r'license\s*=\s*\{[^}]*text\s*=\s*["\']([^"\']+)["\']',
            content,
        )
        if m2:
            licenses["(project)"] = m2.group(1)

    return licenses


def _check_license_risk(
    license_str: str,
    package: str,
) -> SupplyChainFinding | None:
    """Check if a license has copyleft risk."""
    lower = license_str.lower()
    for copyleft in COPYLEFT_LICENSES:
        if copyleft in lower:
            return SupplyChainFinding(
                category="license",
                severity="MEDIUM",
                package=package,
                description=(
                    f"Copyleft license detected: {license_str}. "
                    f"May conflict with commercial use."
                ),
                recommendation=(
                    "Review license compatibility with your project. "
                    "Consider alternatives with permissive licenses."
                ),
            )
    return None


# ---------------------------------------------------------------------------
# Core scanner
# ---------------------------------------------------------------------------

class SupplyChainScanner:
    """Analyzes dependency manifests for supply chain risks."""

    def __init__(self) -> None:
        """Initialize the supply chain scanner."""
        self._python_popular = POPULAR_PYTHON_PACKAGES
        self._node_popular = POPULAR_NODE_PACKAGES

    def scan_manifest(self, filepath: Path) -> SupplyChainResult:
        """Scan a single manifest file for supply chain risks.

        Args:
            filepath: Path to the manifest file.

        Returns:
            SupplyChainResult with all detected risks.
        """
        ecosystem = _detect_ecosystem(filepath)
        result = SupplyChainResult(
            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

        deps = _parse_manifest(filepath, content)
        result.total_deps = len(deps)

        # License detection
        licenses = _detect_licenses_from_content(content, filepath)
        result.licenses_detected = licenses

        # Run all checks
        self._check_dangerous_names(deps, result)
        self._check_typosquatting(deps, ecosystem, result)
        self._check_dep_count(deps, result)
        self._check_license_risks(licenses, result)
        self._check_unpinned_deps(deps, result)

        # Set manifest_file on all findings
        for finding in result.findings:
            finding.manifest_file = str(filepath)

        return result

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

        Args:
            dirpath: Directory to scan.

        Returns:
            List of SupplyChainResult.
        """
        manifests = [
            "requirements.txt", "requirements-dev.txt",
            "package.json", "setup.py", "setup.cfg", "pyproject.toml",
        ]
        results: list[SupplyChainResult] = []
        for name in manifests:
            for manifest in sorted(dirpath.rglob(name)):
                parts = manifest.parts
                if "node_modules" in parts or "vendor" in parts:
                    continue
                results.append(self.scan_manifest(manifest))
        return results

    def _check_dangerous_names(
        self,
        deps: list[tuple[str, str]],
        result: SupplyChainResult,
    ) -> None:
        """Check for packages with dangerous names."""
        for pkg, _ in deps:
            name_lower = pkg.lower().replace("-", "_")
            for pattern in DANGEROUS_NAME_PATTERNS:
                if pattern.replace("-", "_") in name_lower:
                    result.findings.append(SupplyChainFinding(
                        category="dangerous_name",
                        severity="HIGH",
                        package=pkg,
                        description=(
                            f"Package name contains dangerous pattern "
                            f"'{pattern}': {pkg}"
                        ),
                        recommendation=(
                            f"Review package '{pkg}' carefully. "
                            f"Packages with names containing '{pattern}' "
                            f"may be malicious."
                        ),
                    ))
                    break  # One finding per package

    def _check_typosquatting(
        self,
        deps: list[tuple[str, str]],
        ecosystem: str,
        result: SupplyChainResult,
    ) -> None:
        """Check for potential typosquatting packages."""
        popular = (
            self._python_popular
            if ecosystem == "python"
            else self._node_popular
        )
        for pkg, _ in deps:
            similar = detect_typosquat(pkg, popular)
            if similar:
                result.findings.append(SupplyChainFinding(
                    category="typosquat",
                    severity="CRITICAL",
                    package=pkg,
                    description=(
                        f"Package '{pkg}' looks similar to popular package "
                        f"'{similar}' (possible typosquatting)"
                    ),
                    recommendation=(
                        f"Verify that '{pkg}' is the intended package. "
                        f"Did you mean '{similar}'?"
                    ),
                ))

    def _check_dep_count(
        self,
        deps: list[tuple[str, str]],
        result: SupplyChainResult,
    ) -> None:
        """Check for excessive dependency counts."""
        count = len(deps)
        if count > 100:
            result.findings.append(SupplyChainFinding(
                category="dep_count",
                severity="CRITICAL",
                package="(all)",
                description=(
                    f"Excessive dependency count: {count} dependencies. "
                    f"Large dependency trees increase supply chain risk."
                ),
                recommendation=(
                    "Audit dependencies and remove unused ones. "
                    "Consider bundling or replacing heavy dependencies."
                ),
            ))
        elif count > 50:
            result.findings.append(SupplyChainFinding(
                category="dep_count",
                severity="MEDIUM",
                package="(all)",
                description=(
                    f"High dependency count: {count} dependencies. "
                    f"Consider reducing to minimize attack surface."
                ),
                recommendation=(
                    "Review dependencies for unused or redundant packages."
                ),
            ))

    def _check_license_risks(
        self,
        licenses: dict[str, str],
        result: SupplyChainResult,
    ) -> None:
        """Check for license compatibility risks."""
        for pkg, lic in licenses.items():
            finding = _check_license_risk(lic, pkg)
            if finding:
                result.findings.append(finding)

    def _check_unpinned_deps(
        self,
        deps: list[tuple[str, str]],
        result: SupplyChainResult,
    ) -> None:
        """Check for unpinned dependencies."""
        unpinned = [pkg for pkg, ver in deps if not ver]
        if len(unpinned) > 5:
            result.findings.append(SupplyChainFinding(
                category="outdated",
                severity="MEDIUM",
                package="(multiple)",
                description=(
                    f"{len(unpinned)} dependencies without version pins: "
                    f"{', '.join(unpinned[:5])}..."
                ),
                recommendation=(
                    "Pin all dependency versions to ensure reproducible "
                    "builds and prevent supply chain attacks."
                ),
            ))
        elif unpinned:
            for pkg in unpinned:
                result.findings.append(SupplyChainFinding(
                    category="outdated",
                    severity="LOW",
                    package=pkg,
                    description=(
                        f"Package '{pkg}' has no version pin."
                    ),
                    recommendation=(
                        f"Pin '{pkg}' to a specific version."
                    ),
                ))


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

def format_supply_chain_text(result: SupplyChainResult) -> str:
    """Format supply chain result as text."""
    lines: list[str] = []
    lines.append(f"\n  Supply Chain Scan: {result.manifest_file}")
    lines.append(f"  Ecosystem: {result.ecosystem}")
    lines.append(f"  Dependencies: {result.total_deps}")
    lines.append(f"  Findings: {result.finding_count}")

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

    if not result.findings:
        lines.append("  Status: No supply chain risks detected")
        return "\n".join(lines)

    lines.append("")
    hdr = f"  {'Category':<16} {'Severity':<10} {'Package':<25} {'Description'}"
    lines.append(hdr)
    sep = f"  {'─' * 16} {'─' * 10} {'─' * 25} {'─' * 40}"
    lines.append(sep)

    sev_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
    for f in sorted(
        result.findings,
        key=lambda x: sev_order.get(x.severity, 5),
    ):
        desc = f.description[:60] + "..." if len(f.description) > 60 else f.description
        lines.append(
            f"  {f.category:<16} {f.severity:<10} {f.package:<25} {desc}"
        )

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


def format_supply_chain_json(result: SupplyChainResult) -> str:
    """Format supply chain result as JSON."""
    return json.dumps(result.to_dict(), indent=2, ensure_ascii=False)


def format_supply_chain_html(result: SupplyChainResult) -> str:
    """Format supply chain result as self-contained HTML."""
    sev_colors = {
        "CRITICAL": "#dc2626",
        "HIGH": "#f97316",
        "MEDIUM": "#eab308",
        "LOW": "#22c55e",
        "INFO": "#6b7280",
    }

    findings_rows = ""
    for f in result.findings:
        color = sev_colors.get(f.severity, "#6b7280")
        findings_rows += f"""
        <tr>
          <td><span style="color:{color};font-weight:bold;">{html_mod.escape(f.severity)}</span></td>
          <td>{html_mod.escape(f.category)}</td>
          <td><code>{html_mod.escape(f.package)}</code></td>
          <td>{html_mod.escape(f.description)}</td>
          <td>{html_mod.escape(f.recommendation)}</td>
        </tr>"""

    sc = result.severity_counts
    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Supply Chain Report - {html_mod.escape(result.manifest_file)}</title>
<style>
  body {{ font-family: -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; margin: 2em; }}
  h1 {{ color: #38bdf8; }}
  h2 {{ color: #94a3b8; border-bottom: 1px solid #334155; padding-bottom: 0.3em; }}
  table {{ border-collapse: collapse; width: 100%; margin: 1em 0; }}
  th, td {{ text-align: left; padding: 8px 12px; border-bottom: 1px solid #334155; }}
  th {{ background: #1e293b; color: #94a3b8; }}
  code {{ background: #1e293b; padding: 2px 6px; border-radius: 4px; }}
  .summary {{ display: flex; gap: 2em; margin: 1em 0; }}
  .stat {{ background: #1e293b; padding: 1em; border-radius: 8px; text-align: center; }}
  .stat-value {{ font-size: 2em; font-weight: bold; color: #38bdf8; }}
  .stat-label {{ color: #94a3b8; font-size: 0.9em; }}
</style>
</head>
<body>
<h1>Supply Chain Scan Report</h1>
<p>Manifest: <code>{html_mod.escape(result.manifest_file)}</code></p>
<p>Ecosystem: {html_mod.escape(result.ecosystem)}</p>

<div class="summary">
  <div class="stat"><div class="stat-value">{result.total_deps}</div><div class="stat-label">Dependencies</div></div>
  <div class="stat"><div class="stat-value">{result.finding_count}</div><div class="stat-label">Findings</div></div>
  <div class="stat"><div class="stat-value" style="color:#dc2626">{sc.get('CRITICAL', 0)}</div><div class="stat-label">Critical</div></div>
  <div class="stat"><div class="stat-value" style="color:#f97316">{sc.get('HIGH', 0)}</div><div class="stat-label">High</div></div>
</div>

<h2>Findings</h2>
<table>
<tr><th>Severity</th><th>Category</th><th>Package</th><th>Description</th><th>Recommendation</th></tr>
{findings_rows}
</table>
</body>
</html>"""
