# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""OSV (Open Source Vulnerabilities) API client.

Queries the OSV database (https://osv.dev) to find known vulnerabilities
in package dependencies.
"""

from __future__ import annotations

import hashlib
import json
import logging
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from .manifest_parser import Dependency

logger = logging.getLogger(__name__)

# OSV API endpoints
OSV_API_BASE = "https://api.osv.dev/v1"
OSV_QUERY_URL = f"{OSV_API_BASE}/query"
OSV_BATCH_QUERY_URL = f"{OSV_API_BASE}/querybatch"
OSV_VULN_URL = f"{OSV_API_BASE}/vulns"


@dataclass
class Vulnerability:
    """A vulnerability record from OSV."""

    id: str
    summary: str
    details: str = ""
    aliases: list[str] = field(default_factory=list)
    severity: str = "UNKNOWN"  # CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN
    cvss_score: float | None = None
    affected_package: str = ""
    affected_ecosystem: str = ""
    affected_versions: list[str] = field(default_factory=list)
    fixed_versions: list[str] = field(default_factory=list)
    references: list[str] = field(default_factory=list)
    published: str = ""
    modified: str = ""

    @classmethod
    def from_osv_response(
        cls, data: dict[str, Any], package_name: str = "", ecosystem: str = ""
    ) -> Vulnerability:
        """Create from OSV API response."""
        vuln_id = data.get("id", "")
        summary = data.get("summary", "")
        details = data.get("details", "")
        aliases = data.get("aliases", [])
        published = data.get("published", "")
        modified = data.get("modified", "")

        # Extract severity from CVSS or severity field
        severity = "UNKNOWN"
        cvss_score = None
        for severity_entry in data.get("severity", []):
            if severity_entry.get("type") == "CVSS_V3":
                score_str = severity_entry.get("score", "")
                # Parse CVSS vector for score
                if score_str:
                    # Try to extract score from CVSS vector or direct score
                    if "/" in score_str:
                        # It's a CVSS vector string
                        pass
                    else:
                        try:
                            cvss_score = float(score_str)
                        except ValueError:
                            pass

        # Also check database_specific for severity
        db_specific = data.get("database_specific", {})
        if "severity" in db_specific:
            severity = db_specific["severity"].upper()
        elif cvss_score is not None:
            severity = cls._cvss_to_severity(cvss_score)

        # Extract affected versions and fixed versions
        affected_versions: list[str] = []
        fixed_versions: list[str] = []
        references: list[str] = []

        for affected in data.get("affected", []):
            pkg = affected.get("package", {})
            if pkg.get("name") == package_name or not package_name:
                for rng in affected.get("ranges", []):
                    for event in rng.get("events", []):
                        if "introduced" in event:
                            affected_versions.append(event["introduced"])
                        if "fixed" in event:
                            fixed_versions.append(event["fixed"])
                # Also check versions array
                affected_versions.extend(affected.get("versions", []))

        # Extract references
        for ref in data.get("references", []):
            url = ref.get("url", "")
            if url:
                references.append(url)

        return cls(
            id=vuln_id,
            summary=summary,
            details=details,
            aliases=aliases,
            severity=severity,
            cvss_score=cvss_score,
            affected_package=package_name,
            affected_ecosystem=ecosystem,
            affected_versions=affected_versions[:10],  # Limit for brevity
            fixed_versions=fixed_versions[:5],
            references=references[:5],
            published=published,
            modified=modified,
        )

    @staticmethod
    def _cvss_to_severity(score: float) -> str:
        """Convert CVSS score to severity level."""
        if score >= 9.0:
            return "CRITICAL"
        if score >= 7.0:
            return "HIGH"
        if score >= 4.0:
            return "MEDIUM"
        if score >= 0.1:
            return "LOW"
        return "UNKNOWN"

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for serialization."""
        return {
            "id": self.id,
            "summary": self.summary,
            "details": self.details,
            "aliases": self.aliases,
            "severity": self.severity,
            "cvss_score": self.cvss_score,
            "affected_package": self.affected_package,
            "affected_ecosystem": self.affected_ecosystem,
            "affected_versions": self.affected_versions,
            "fixed_versions": self.fixed_versions,
            "references": self.references,
            "published": self.published,
            "modified": self.modified,
        }


class OSVCache:
    """Simple in-memory cache for OSV responses."""

    def __init__(self, ttl_seconds: int = 3600):  # 1 hour default TTL
        self._cache: dict[str, tuple[datetime, list[Vulnerability]]] = {}
        self._ttl = timedelta(seconds=ttl_seconds)

    def _make_key(self, package: str, version: str, ecosystem: str) -> str:
        """Create cache key from query parameters."""
        key_str = f"{ecosystem}:{package}:{version}"
        return hashlib.sha256(key_str.encode()).hexdigest()[:16]

    def get(self, package: str, version: str, ecosystem: str) -> list[Vulnerability] | None:
        """Get cached vulnerabilities if not expired."""
        key = self._make_key(package, version, ecosystem)
        if key in self._cache:
            cached_time, vulns = self._cache[key]
            if datetime.now(UTC) - cached_time < self._ttl:
                return vulns
            # Expired, remove from cache
            del self._cache[key]
        return None

    def set(self, package: str, version: str, ecosystem: str, vulns: list[Vulnerability]) -> None:
        """Cache vulnerabilities."""
        key = self._make_key(package, version, ecosystem)
        self._cache[key] = (datetime.now(UTC), vulns)

    def clear(self) -> None:
        """Clear all cached entries."""
        self._cache.clear()


class OSVClient:
    """Client for querying the OSV vulnerability database."""

    def __init__(
        self,
        cache: OSVCache | None = None,
        timeout: int = 30,
    ):
        """Initialize OSV client.

        Args:
            cache: Optional cache instance for response caching
            timeout: HTTP request timeout in seconds
        """
        self._cache = cache or OSVCache()
        self._timeout = timeout

    def query(self, dependency: Dependency) -> list[Vulnerability]:
        """Query OSV for vulnerabilities affecting a single dependency.

        Args:
            dependency: The dependency to check

        Returns:
            List of vulnerabilities affecting this dependency
        """
        # Check cache first
        cached = self._cache.get(dependency.name, dependency.version, dependency.ecosystem.value)
        if cached is not None:
            return cached

        # Build query
        query = dependency.to_osv_query()

        try:
            vulns = self._query_osv(query, dependency.name, dependency.ecosystem.value)
            # Cache result
            self._cache.set(dependency.name, dependency.version, dependency.ecosystem.value, vulns)
            return vulns
        except (urllib.error.URLError, json.JSONDecodeError, OSError) as e:
            logger.warning(f"OSV query failed for {dependency.name}: {e}")
            return []

    def query_batch(self, dependencies: list[Dependency]) -> dict[str, list[Vulnerability]]:
        """Query OSV for multiple dependencies at once.

        Args:
            dependencies: List of dependencies to check

        Returns:
            Dictionary mapping "ecosystem:package:version" to vulnerabilities
        """
        results: dict[str, list[Vulnerability]] = {}
        uncached: list[tuple[int, Dependency]] = []

        # Check cache for each dependency
        for i, dep in enumerate(dependencies):
            key = f"{dep.ecosystem.value}:{dep.name}:{dep.version}"
            cached = self._cache.get(dep.name, dep.version, dep.ecosystem.value)
            if cached is not None:
                results[key] = cached
            else:
                uncached.append((i, dep))

        if not uncached:
            return results

        # Batch query uncached dependencies
        queries = [{"queries": [dep.to_osv_query() for _, dep in uncached]}]

        try:
            batch_results = self._batch_query_osv(queries[0])

            # Process results
            for (_, dep), result in zip(uncached, batch_results):
                key = f"{dep.ecosystem.value}:{dep.name}:{dep.version}"
                vulns = [
                    Vulnerability.from_osv_response(v, dep.name, dep.ecosystem.value)
                    for v in result.get("vulns", [])
                ]
                results[key] = vulns
                # Cache each result
                self._cache.set(dep.name, dep.version, dep.ecosystem.value, vulns)

        except (urllib.error.URLError, json.JSONDecodeError, OSError) as e:
            logger.warning(f"OSV batch query failed: {e}")

        return results

    def _query_osv(self, query: dict, package_name: str, ecosystem: str) -> list[Vulnerability]:
        """Execute single OSV query."""
        data = json.dumps(query).encode("utf-8")
        request = urllib.request.Request(
            OSV_QUERY_URL,
            data=data,
            headers={"Content-Type": "application/json"},
            method="POST",
        )

        with urllib.request.urlopen(request, timeout=self._timeout) as response:
            result = json.loads(response.read().decode("utf-8"))

        return [
            Vulnerability.from_osv_response(v, package_name, ecosystem)
            for v in result.get("vulns", [])
        ]

    def _batch_query_osv(self, queries: dict) -> list[dict]:
        """Execute batch OSV query."""
        data = json.dumps(queries).encode("utf-8")
        request = urllib.request.Request(
            OSV_BATCH_QUERY_URL,
            data=data,
            headers={"Content-Type": "application/json"},
            method="POST",
        )

        with urllib.request.urlopen(request, timeout=self._timeout) as response:
            result = json.loads(response.read().decode("utf-8"))

        return result.get("results", [])

    def get_vulnerability_details(self, vuln_id: str) -> Vulnerability | None:
        """Get full details for a specific vulnerability.

        Args:
            vuln_id: The vulnerability ID (e.g., "GHSA-xxxx-xxxx-xxxx")

        Returns:
            Full vulnerability details or None if not found
        """
        url = f"{OSV_VULN_URL}/{vuln_id}"
        request = urllib.request.Request(url, method="GET")

        try:
            with urllib.request.urlopen(request, timeout=self._timeout) as response:
                result = json.loads(response.read().decode("utf-8"))
            return Vulnerability.from_osv_response(result)
        except urllib.error.HTTPError as e:
            if e.code == 404:
                return None
            raise
