# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""Vulnerability scanner orchestrator.

Coordinates manifest parsing and OSV querying to produce a complete
vulnerability scan result for a repository.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

from .manifest_parser import Dependency, ManifestParser
from .osv_client import OSVClient, Vulnerability

if TYPE_CHECKING:
    pass

logger = logging.getLogger(__name__)


@dataclass
class VulnerabilityFinding:
    """A vulnerability finding with context."""

    vulnerability: Vulnerability
    dependency: Dependency
    severity_level: str  # "error", "warning", "info" - mapped from CVE severity

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for serialization."""
        return {
            "vulnerability": self.vulnerability.to_dict(),
            "dependency": {
                "name": self.dependency.name,
                "version": self.dependency.version,
                "ecosystem": self.dependency.ecosystem.value,
                "source_file": self.dependency.source_file,
                "is_dev": self.dependency.is_dev,
            },
            "severity_level": self.severity_level,
        }


@dataclass
class ScanResult:
    """Result of a vulnerability scan."""

    scan_id: str
    timestamp: str
    repository: str
    ref: str = ""
    total_dependencies: int = 0
    total_vulnerabilities: int = 0
    critical_count: int = 0
    high_count: int = 0
    medium_count: int = 0
    low_count: int = 0
    findings: list[VulnerabilityFinding] = field(default_factory=list)
    manifests_scanned: list[str] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)

    @property
    def passed(self) -> bool:
        """Check if scan passed (no critical/high vulnerabilities)."""
        return self.critical_count == 0 and self.high_count == 0

    @property
    def has_vulnerabilities(self) -> bool:
        """Check if any vulnerabilities were found."""
        return self.total_vulnerabilities > 0

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for serialization."""
        return {
            "scan_id": self.scan_id,
            "timestamp": self.timestamp,
            "repository": self.repository,
            "ref": self.ref,
            "passed": self.passed,
            "total_dependencies": self.total_dependencies,
            "total_vulnerabilities": self.total_vulnerabilities,
            "severity_counts": {
                "critical": self.critical_count,
                "high": self.high_count,
                "medium": self.medium_count,
                "low": self.low_count,
            },
            "findings": [f.to_dict() for f in self.findings],
            "manifests_scanned": self.manifests_scanned,
            "errors": self.errors,
        }


class VulnerabilityScanner:
    """Orchestrates vulnerability scanning across manifests."""

    # Manifest files to look for in order of priority
    MANIFEST_FILES = [
        # npm
        "package-lock.json",
        "package.json",
        # pip
        "requirements.txt",
        "Pipfile",
        "pyproject.toml",
        # go
        "go.sum",
        "go.mod",
        # cargo
        "Cargo.lock",
        "Cargo.toml",
        # maven
        "pom.xml",
        # gem
        "Gemfile.lock",
        "Gemfile",
        # composer
        "composer.lock",
        "composer.json",
        # nuget
        "packages.config",
    ]

    def __init__(
        self,
        osv_client: OSVClient | None = None,
        manifest_parser: ManifestParser | None = None,
        scan_dev_dependencies: bool = False,
    ):
        """Initialize scanner.

        Args:
            osv_client: Optional OSV client instance
            manifest_parser: Optional manifest parser instance
            scan_dev_dependencies: Whether to scan dev dependencies
        """
        self._osv = osv_client or OSVClient()
        self._parser = manifest_parser or ManifestParser()
        self._scan_dev = scan_dev_dependencies

    def scan_files(
        self,
        files: dict[str, str],
        repository: str = "",
        ref: str = "",
        scan_id: str | None = None,
    ) -> ScanResult:
        """Scan multiple files for vulnerabilities.

        Args:
            files: Dictionary mapping filename to file content
            repository: Repository name for the scan result
            ref: Git ref (branch, tag, commit) being scanned
            scan_id: Optional scan ID, generated if not provided

        Returns:
            ScanResult with all findings
        """
        if scan_id is None:
            scan_id = self._generate_scan_id()

        result = ScanResult(
            scan_id=scan_id,
            timestamp=datetime.now(UTC).isoformat(),
            repository=repository,
            ref=ref,
        )

        # Collect all dependencies from manifest files
        all_deps: list[Dependency] = []
        seen_deps: set[tuple[str, str, str]] = set()  # (ecosystem, name, version)

        for filename, content in files.items():
            base_name = Path(filename).name

            # Check if this is a manifest file
            if not self._is_manifest_file(base_name):
                continue

            try:
                deps = self._parser.parse(content, filename)
                result.manifests_scanned.append(filename)

                for dep in deps:
                    # Skip dev dependencies if not configured to scan them
                    if dep.is_dev and not self._scan_dev:
                        continue

                    # Deduplicate
                    key = (dep.ecosystem.value, dep.name, dep.version)
                    if key not in seen_deps:
                        seen_deps.add(key)
                        all_deps.append(dep)

            except ValueError as e:
                result.errors.append(f"Error parsing {filename}: {e}")
            except Exception as e:
                logger.warning(f"Unexpected error parsing {filename}: {e}")
                result.errors.append(f"Unexpected error parsing {filename}: {str(e)}")

        result.total_dependencies = len(all_deps)

        if not all_deps:
            return result

        # Query OSV for vulnerabilities (batch query)
        try:
            vuln_map = self._osv.query_batch(all_deps)
        except Exception as e:
            logger.error(f"OSV query failed: {e}")
            result.errors.append(f"OSV query failed: {str(e)}")
            return result

        # Process vulnerabilities into findings
        for dep in all_deps:
            key = f"{dep.ecosystem.value}:{dep.name}:{dep.version}"
            vulns = vuln_map.get(key, [])

            for vuln in vulns:
                severity_level = self._map_severity(vuln.severity)
                finding = VulnerabilityFinding(
                    vulnerability=vuln,
                    dependency=dep,
                    severity_level=severity_level,
                )
                result.findings.append(finding)

                # Update counts
                result.total_vulnerabilities += 1
                if vuln.severity == "CRITICAL":
                    result.critical_count += 1
                elif vuln.severity == "HIGH":
                    result.high_count += 1
                elif vuln.severity == "MEDIUM":
                    result.medium_count += 1
                else:
                    result.low_count += 1

        # Sort findings by severity (critical first)
        severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "UNKNOWN": 4}
        result.findings.sort(key=lambda f: severity_order.get(f.vulnerability.severity, 5))

        return result

    def scan_directory(
        self,
        directory: str | Path,
        repository: str = "",
        ref: str = "",
        scan_id: str | None = None,
    ) -> ScanResult:
        """Scan a directory for manifest files and vulnerabilities.

        Args:
            directory: Path to directory to scan
            repository: Repository name for the scan result
            ref: Git ref being scanned
            scan_id: Optional scan ID

        Returns:
            ScanResult with all findings
        """
        directory = Path(directory)
        files: dict[str, str] = {}

        # Find all manifest files
        for manifest_name in self.MANIFEST_FILES:
            manifest_path = directory / manifest_name
            if manifest_path.exists():
                try:
                    files[str(manifest_path)] = manifest_path.read_text()
                except Exception as e:
                    logger.warning(f"Could not read {manifest_path}: {e}")

        # Also look for .csproj files
        for csproj in directory.glob("*.csproj"):
            try:
                files[str(csproj)] = csproj.read_text()
            except Exception as e:
                logger.warning(f"Could not read {csproj}: {e}")

        return self.scan_files(
            files, repository=repository or str(directory), ref=ref, scan_id=scan_id
        )

    def scan_dependency(self, dependency: Dependency) -> list[Vulnerability]:
        """Scan a single dependency for vulnerabilities.

        Args:
            dependency: The dependency to scan

        Returns:
            List of vulnerabilities affecting this dependency
        """
        return self._osv.query(dependency)

    def _is_manifest_file(self, filename: str) -> bool:
        """Check if filename is a recognized manifest file."""
        return filename in self.MANIFEST_FILES or filename.endswith(".csproj")

    def _map_severity(self, osv_severity: str) -> str:
        """Map OSV severity to our severity levels."""
        if osv_severity in ("CRITICAL", "HIGH"):
            return "error"
        if osv_severity == "MEDIUM":
            return "warning"
        return "info"

    def _generate_scan_id(self) -> str:
        """Generate a unique scan ID."""
        import uuid

        return f"scan_{uuid.uuid4().hex[:12]}"


def scan_repository(
    files: dict[str, str],
    repository: str = "",
    ref: str = "",
    include_dev: bool = False,
) -> ScanResult:
    """Convenience function to scan a repository.

    Args:
        files: Dictionary mapping filename to content
        repository: Repository name
        ref: Git ref being scanned
        include_dev: Whether to include dev dependencies

    Returns:
        ScanResult with all findings
    """
    scanner = VulnerabilityScanner(scan_dev_dependencies=include_dev)
    return scanner.scan_files(files, repository=repository, ref=ref)
