"""Dependency Risk Analyzer.

Audits Python dependencies (from requirements.txt) for security and
maintenance risks including CVEs, stale packages, and typosquatting.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional

import requests


@dataclass
class DependencyRisk:
    """Risk assessment for a single dependency."""

    name: str
    version: Optional[str] = None
    risk_score: float = 0.0
    issues: List[str] = field(default_factory=list)
    cves: List[Dict[str, Any]] = field(default_factory=list)
    last_release_date: Optional[str] = None
    is_stale: bool = False
    typosquatting_warning: Optional[str] = None


@dataclass
class AuditResult:
    """Complete audit result for all dependencies."""

    total_packages: int = 0
    high_risk: int = 0
    medium_risk: int = 0
    low_risk: int = 0
    dependencies: List[DependencyRisk] = field(default_factory=list)

    @property
    def overall_score(self) -> float:
        if not self.dependencies:
            return 0.0
        return round(
            sum(d.risk_score for d in self.dependencies) / len(self.dependencies), 4
        )


# ---------------------------------------------------------------------------
# Levenshtein distance for typosquatting detection
# ---------------------------------------------------------------------------

def _levenshtein(s1: str, s2: str) -> int:
    """Compute Levenshtein edit distance between two strings."""
    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):
            insertions = prev_row[j + 1] + 1
            deletions = curr_row[j] + 1
            substitutions = prev_row[j] + (c1 != c2)
            curr_row.append(min(insertions, deletions, substitutions))
        prev_row = curr_row
    return prev_row[-1]


# Well-known popular packages for typosquatting comparison
_POPULAR_PACKAGES = [
    "requests", "flask", "django", "numpy", "pandas", "scipy",
    "tensorflow", "torch", "boto3", "openai", "anthropic",
    "fastapi", "uvicorn", "sqlalchemy", "celery", "redis",
    "pydantic", "httpx", "aiohttp", "beautifulsoup4", "scrapy",
    "pillow", "matplotlib", "scikit-learn", "pytest", "black",
    "cryptography", "paramiko", "pyyaml", "jinja2", "click",
]


def _check_typosquatting(name: str) -> Optional[str]:
    """Check if a package name is suspiciously similar to a popular one."""
    name_lower = name.lower().replace("-", "").replace("_", "")
    for popular in _POPULAR_PACKAGES:
        pop_normalized = popular.lower().replace("-", "").replace("_", "")
        if name_lower == pop_normalized:
            continue  # exact match — not typosquatting
        dist = _levenshtein(name_lower, pop_normalized)
        # Require edit distance 1 for short names (<=6), distance <=2 for longer ones
        max_dist = 1 if len(name_lower) <= 6 else 2
        if 0 < dist <= max_dist and len(name_lower) >= 4:
            return f"Suspiciously similar to '{popular}' (edit distance: {dist})"
    return None


def _parse_requirements(path: Path) -> list[tuple[str, Optional[str]]]:
    """Parse requirements.txt into (name, version_spec) tuples."""
    deps: list[tuple[str, Optional[str]]] = []
    if not path.exists():
        return deps

    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#") or line.startswith("-"):
                continue
            # Handle inline comments
            line = line.split("#")[0].strip()
            # Split on version specifiers
            match = re.match(r"^([a-zA-Z0-9._-]+)\s*(.*)?$", line)
            if match:
                name = match.group(1)
                version = match.group(2).strip() if match.group(2) else None
                deps.append((name, version))
    return deps


class DependencyAnalyzer:
    """Audits Python dependencies for security and maintenance risks.

    Parameters
    ----------
    requirements_path : str or Path
        Path to requirements.txt (default: ``"requirements.txt"``).
    check_cves : bool
        Query the OSV API for known vulnerabilities (default True).
    check_pypi : bool
        Check PyPI for release freshness (default True).
    check_typosquatting : bool
        Check for typosquatting risk (default True).
    stale_days : int
        Number of days without a release to consider stale (default 365).
    """

    def __init__(
        self,
        requirements_path: str | Path = "requirements.txt",
        check_cves: bool = True,
        check_pypi: bool = True,
        check_typosquatting: bool = True,
        stale_days: int = 365,
    ) -> None:
        self.requirements_path = Path(requirements_path)
        self.check_cves = check_cves
        self.check_pypi = check_pypi
        self.check_typosquatting = check_typosquatting
        self.stale_days = stale_days

    # ------------------------------------------------------------------
    # CVE checks via OSV API
    # ------------------------------------------------------------------

    def _query_osv(self, name: str, version: Optional[str] = None) -> list[dict]:
        """Query the OSV.dev API for known vulnerabilities."""
        try:
            payload: dict[str, Any] = {
                "package": {"name": name, "ecosystem": "PyPI"}
            }
            if version:
                clean_version = re.sub(r"^[><=!~]+", "", version).strip()
                if clean_version:
                    payload["version"] = clean_version

            resp = requests.post(
                "https://api.osv.dev/v1/query",
                json=payload,
                timeout=10,
            )
            if resp.status_code == 200:
                data = resp.json()
                return data.get("vulns", [])
        except Exception:
            pass
        return []

    # ------------------------------------------------------------------
    # PyPI freshness check
    # ------------------------------------------------------------------

    def _check_pypi_freshness(self, name: str) -> tuple[Optional[str], bool]:
        """Check when the package was last released on PyPI."""
        try:
            resp = requests.get(
                f"https://pypi.org/pypi/{name}/json",
                timeout=10,
            )
            if resp.status_code == 200:
                data = resp.json()
                releases = data.get("releases", {})
                if releases:
                    # Find the latest release date
                    latest_date = None
                    for ver, files in releases.items():
                        for f in files:
                            upload_time = f.get("upload_time", "")
                            if upload_time and (latest_date is None or upload_time > latest_date):
                                latest_date = upload_time

                    if latest_date:
                        from datetime import datetime

                        try:
                            release_dt = datetime.fromisoformat(
                                latest_date.replace("Z", "+00:00")
                            )
                            age_days = (datetime.now(release_dt.tzinfo or None) - release_dt).days
                            is_stale = age_days > self.stale_days
                            return latest_date[:10], is_stale
                        except Exception:
                            return latest_date[:10], False
        except Exception:
            pass
        return None, False

    # ------------------------------------------------------------------
    # Main audit
    # ------------------------------------------------------------------

    def audit(self) -> AuditResult:
        """Audit all dependencies and return an :class:`AuditResult`."""
        deps = _parse_requirements(self.requirements_path)
        if not deps:
            return AuditResult()

        results: list[DependencyRisk] = []

        for name, version in deps:
            risk = DependencyRisk(name=name, version=version)
            score = 0.0

            # CVE check
            if self.check_cves:
                vulns = self._query_osv(name, version)
                if vulns:
                    risk.cves = [
                        {
                            "id": v.get("id", "unknown"),
                            "summary": v.get("summary", ""),
                            "severity": v.get("database_specific", {}).get(
                                "severity", "UNKNOWN"
                            ),
                        }
                        for v in vulns[:10]
                    ]
                    risk.issues.append(f"{len(vulns)} known CVE(s)")
                    score += min(1.0, len(vulns) * 0.3)

            # PyPI freshness
            if self.check_pypi:
                last_release, is_stale = self._check_pypi_freshness(name)
                risk.last_release_date = last_release
                risk.is_stale = is_stale
                if is_stale:
                    risk.issues.append(f"Stale: last release {last_release}")
                    score += 0.2

            # Typosquatting
            if self.check_typosquatting:
                typo_warning = _check_typosquatting(name)
                if typo_warning:
                    risk.typosquatting_warning = typo_warning
                    risk.issues.append(typo_warning)
                    score += 0.4

            risk.risk_score = round(min(1.0, score), 4)
            results.append(risk)

        # Classify
        high = sum(1 for r in results if r.risk_score > 0.7)
        medium = sum(1 for r in results if 0.3 <= r.risk_score <= 0.7)
        low = sum(1 for r in results if r.risk_score < 0.3)

        return AuditResult(
            total_packages=len(results),
            high_risk=high,
            medium_risk=medium,
            low_risk=low,
            dependencies=results,
        )
