import asyncio

from pydantic import BaseModel, Field, FilePath, field_validator, model_validator

from vulnsearch.cli.render import ScanUI
from vulnsearch.consolidation import consolidate_vulnerabilities
from vulnsearch.exceptions import UtilPathError
from vulnsearch.models.vulnerability import Package, Vulnerability
from vulnsearch.search import nvd_search_by_cpe_async, osv_search_by_purl_async

# Max of concurrent package scan operations
MAX_CONCURRENCY = 20


class SBOMPackage(BaseModel):
    type: str | None = None
    name: str | None = None
    version: str | None = None
    group: str | None = None
    purl: str | None = None

    @model_validator(mode="after")
    def apply_group(self):
        if self.name and self.group:
            self.name = f"{self.group}/{self.name}"
        return self


class SBOM(BaseModel):
    bomFormat: str
    components: list[SBOMPackage] = Field(default_factory=list)

    @field_validator("bomFormat")
    @classmethod
    def check_bom_format(cls, v: str) -> str:
        if v != "CycloneDX":
            raise ValueError("Unsupported SBOM format. Expected 'CycloneDX'.")
        return v

    @classmethod
    def load_from(cls, file: FilePath) -> "SBOM":
        try:
            return cls.model_validate_json(file.read_bytes())
        except FileNotFoundError:
            raise UtilPathError(f"file not found at '{file}'") from None
        except PermissionError:
            raise UtilPathError(f"permission denied for '{file}'") from None


async def _search_for_package(
    purl: str, name: str, version: str | None, include_nvd: bool = False
) -> Package | None:
    """
    Search vulnerabilities for one SBOM package (OSV always, NVD optionally)
    """
    tasks = []

    if purl:
        tasks.append(osv_search_by_purl_async(purl))
    if include_nvd and name and version:
        tasks.append(nvd_search_by_cpe_async(name, version))

    if not tasks:
        return None

    results = await asyncio.gather(*tasks, return_exceptions=True)

    vulnerabilities: list[Vulnerability] = []
    for r in results:
        if isinstance(r, Exception):
            continue
        vulnerabilities.extend(r)

    if not vulnerabilities:
        return None

    return consolidate_vulnerabilities(
        name=name, version=version, raw_vulnerabilities=vulnerabilities
    )


async def scan_packages(
    packages: list[SBOMPackage],
    include_nvd: bool = False,
) -> list[Package]:
    """Scan a list of SBOM packages concurrently and show progress UI."""
    semaphore = asyncio.Semaphore(MAX_CONCURRENCY)

    async def scan_one(package: SBOMPackage) -> Package | None:
        async with semaphore:
            return await _search_for_package(
                package.purl, package.name, package.version, include_nvd=include_nvd
            )

    tasks = [asyncio.create_task(scan_one(package)) for package in packages]
    results: list[Package] = []

    # Process async scan tasks and update progress UI for each completed result.
    with ScanUI(total=len(tasks)) as ui:
        for coro in asyncio.as_completed(tasks):
            result = await coro

            if result:
                results.append(result)
                ui.advance(is_vulnerable=True)
            else:
                ui.advance(is_vulnerable=False)

    return results


async def scan_sbom_file(file: FilePath, include_nvd: bool = False) -> list[Package]:
    """Load SBOM from file and scan its relevant components."""
    sbom = SBOM.load_from(file)

    components: list[SBOMPackage] = [c for c in sbom.components if c.purl or c.name]

    return await scan_packages(
        components,
        include_nvd=include_nvd,
    )


def search_vuln_in_sbom(file: FilePath, include_nvd: bool = False) -> list[Package]:
    """Sync wrapper around SBOM scanning"""
    return asyncio.run(scan_sbom_file(file, include_nvd=include_nvd))
