"""
osv to NVD CVE converter

This module fetches the vulnerability data from osv.dev and stores them in NVD CVE 1.1 json format.
"""

from os import getenv
from zipfile import ZipFile

import httpx
import orjson
from semver import Version

from vdb.lib import CustomNamedTemporaryFile, config
from vdb.lib.nvd import NvdSource
from vdb.lib.utils import (
    compress_str,
    convert_score_severity,
    get_cvss3_from_vector,
    get_cvss4_from_vector,
    get_default_cve_data,
    parse_purl,
)
from vdb.lib.gha import GitHubSource

OSV_PER_VERSION_LIMIT = 10

# Size of the stream to read and write to the file
DOWNLOAD_CHUNK_SIZE = 4096

vendor_overrides = {
    "apk": "alpine",
    "deb": "debian",
    "go": "golang",
    "crates.io": "cargo",
    "swifturl": "swift",
    "github actions": "github",
}


def functions_to_modules(affected_functions):
    affected_modules = set()
    for af in affected_functions:
        if "::" in af:
            tmp_parts = af.split("::")
            affected_modules.add("::".join(tmp_parts[:-1]))
        elif "/" in af:
            tmp_parts = af.split("/")
            affected_modules.add("/".join(tmp_parts[:-1]))
    return affected_modules


class OSVSource(NvdSource):
    """OSV CVE source"""

    def download_all(self):
        """Download all cve data"""
        # For performance do not retain the whole data in-memory
        # See: https://github.com/AppThreat/vulnerability-db/issues/27
        data_list = []
        for _, url in config.OSV_URL_DICT.items():
            data = self.fetch(url)
            if data:
                self.store(data)
        return data_list

    def download_recent(self):
        pass

    def fetch(self, url):
        ret_data = []
        client = httpx.Client(http2=True, follow_redirects=True, timeout=180)
        with CustomNamedTemporaryFile() as tf:
            try:
                with client.stream("GET", url) as r:
                    for chunk in r.iter_bytes(chunk_size=DOWNLOAD_CHUNK_SIZE):
                        tf.write(chunk)
                    tf.flush()
            except Exception:
                return ret_data
            with ZipFile(tf.name, "r") as zipfp:
                for zf in zipfp.namelist():
                    if zf.endswith(".json"):
                        with zipfp.open(zf) as jsonf:
                            cve_data = jsonf.read()
                            try:
                                json_data = orjson.loads(cve_data)
                                ret_data += self.convert(json_data)
                            except Exception:
                                pass
        return ret_data

    def convert(self, cve_data):
        if cve_data.get("withdrawn"):
            return []
        return self.to_vuln(cve_data)

    @staticmethod
    def to_vuln(cve_data):
        ret_data = []
        if not cve_data.get("affected"):
            return ret_data
        cve_id = cve_data.get("id")
        # Remove unwanted DEBIAN- and ALPINE- prefixes
        if "CVE-" in cve_id and not cve_id.startswith("CVE-"):
            cve_id = cve_id[cve_id.find("CVE-") :]
        cwe_id = ""
        edition = "*"
        cve_references = cve_data.get("references", [])
        aliases = cve_data.get("aliases", [])
        # Related list in OSV is full of self-promoting ids from commercial companies
        related = [f for r in cve_data.get("related", []) if r.startswith("CVE-") or r.startswith("GHSA-")]
        aliases_block = ""
        if aliases and len(aliases) > 1:
            aliases_block = f"""
## Aliases
{", ".join(aliases)}
"""
        if related:
            aliases_block += f"""
## Related
{", ".join(related)}
"""
        description = f"""# {cve_data.get("summary", "Summary")}
{cve_data.get("details", "")}
{aliases_block}
"""
        if "** DISPUTED **" in description or "** REJECT **" in description:
            return ret_data
        references = []
        # Change the key from type to name in references
        for aref in cve_references:
            references.append({"name": aref.get("type", "url"), "url": aref.get("url")})
        # Red Hat can have a lot of references for some reason
        if len(references) > 512:
            references = references[:512]
        references = orjson.dumps(references, option=orjson.OPT_NAIVE_UTC)
        if isinstance(references, bytes):
            references = references.decode("utf-8", "ignore")
        # Offer an option to ignore malware data to keep the db size small
        if getenv("OSV_EXCLUDE_MALWARE") and cve_id.startswith("MAL"):
            return ret_data
        # Quality of PYSEC and GO data are quite low missing both severity and score
        # Where a PYSEC or GO feed also reference a github id, let's ignore it
        # Fixes #99
        if cve_id.startswith("PYSEC") or cve_id.startswith("GO-"):
            for i in aliases:
                if i.startswith("GHSA"):
                    return ret_data
        # Try to locate the CVE id from the aliases section
        if not cve_id.startswith("CVE") and not cve_id.startswith("RUSTSEC"):
            for i in aliases:
                if i.startswith("CVE"):
                    cve_id = i
                    break
        assigner = "google"
        root_vector_string = ""
        root_cvss4_vector_string = ""
        root_severity = "LOW"
        if cve_id.startswith("GHSA"):
            assigner = "github_m"
        elif cve_id.startswith("CVE"):
            assigner = "mitre"
        elif cve_id.startswith("NPM"):
            assigner = "github_m"
        # For malwares, default to critical
        if cve_id.startswith("MAL"):
            root_severity = "CRITICAL"

        if cve_data.get("severity"):
            severity_list = cve_data.get("severity")
            for sv in severity_list:
                if sv["type"] == "CVSS_V3":
                    root_vector_string = sv["score"]
                elif sv["type"] == "CVSS_V4":
                    root_cvss4_vector_string = sv["score"]
                elif sv["type"] == "CVSS_V2" and not root_vector_string:
                    root_vector_string = sv["score"]

        cve_database_specific = cve_data.get("database_specific")
        cve_ecosystem_specific = cve_data.get("ecosystem_specific")
        # Support for collecting affected functions
        affected_functions = set()
        affected_modules = set()
        if cve_database_specific:
            if cve_database_specific.get("severity"):
                root_severity = cve_database_specific.get("severity")
            if cve_database_specific.get("cwe_ids"):
                cwes = cve_database_specific.get("cwe_ids")
                if isinstance(cwes, list):
                    cwe_id = ",".join(cwes)
        if cve_ecosystem_specific and cve_ecosystem_specific.get("severity"):
            root_severity = cve_ecosystem_specific.get("severity")
        for pkg_data in cve_data.get("affected"):
            min_ver = None
            max_ver = None
            # Database specific values. Useful to override.
            ds_version_start_including = ""
            ds_version_end_including = ""
            ds_version_start_excluding = ""
            ds_version_end_excluding = ""
            severity = root_severity
            vector_string = root_vector_string
            cvss4_vector_string = root_cvss4_vector_string
            if pkg_data.get("severity"):
                for sv in pkg_data.get("severity"):
                    if sv["type"] == "CVSS_V4":
                        cvss4_vector_string = sv["score"]
                    elif sv["type"] == "CVSS_V3":
                        vector_string = sv["score"]
                    elif sv["type"] == "CVSS_V2" and not vector_string:
                        vector_string = sv["score"]
            if pkg_data.get("ecosystem_specific"):
                ecosystem_specific = pkg_data.get("ecosystem_specific")
                if ecosystem_specific.get("severity"):
                    severity = ecosystem_specific.get("severity")
                if ecosystem_specific.get("affected_functions"):
                    affected_functions = affected_functions.union(
                        ecosystem_specific.get("affected_functions")
                    )
                if ecosystem_specific.get("affects", {}).get("functions"):
                    affected_functions = affected_functions.union(
                        ecosystem_specific.get("affects").get("functions")
                    )
                for aimp in ecosystem_specific.get("imports", []):
                    if aimp.get("path"):
                        affected_modules.add(aimp.get("path"))
                    if aimp.get("symbols"):
                        affected_functions = affected_functions.union(
                            aimp.get("symbols")
                        )
            if pkg_data.get("database_specific"):
                database_specific = pkg_data.get("database_specific")
                if database_specific.get("cwes"):
                    cwes = database_specific.get("cwes")
                    if isinstance(cwes, list):
                        cwe_id = cwes[0].get("cweId")
                if database_specific.get("cvss"):
                    cvss = database_specific.get("cvss")
                    if isinstance(cvss, dict):
                        if cvss.get("severity"):
                            severity = cvss.get("severity", "").upper()
                        if not vector_string and cvss.get("vectorString"):
                            vector_string = cvss.get("vectorString")
                        if cvss.get("score"):
                            score = cvss.get("score")
                            severity = convert_score_severity(score)
                if database_specific.get("last_known_affected_version_range"):
                    (
                        ds_version_start_including,
                        ds_version_end_including,
                        ds_version_start_excluding,
                        ds_version_end_excluding,
                    ) = GitHubSource.get_version_range(
                        database_specific.get("last_known_affected_version_range")
                    )
                    min_ver = ds_version_start_including
                    max_ver = ds_version_end_including
            user_interaction = "REQUIRED"
            score = 0.0
            exploitability_score = 0.0
            attack_complexity = "LOW"
            # We give importance to cvss4 which always seems to be lower compared to cvss3
            if cvss4_vector_string:
                cvss4_obj = get_cvss4_from_vector(cvss4_vector_string)
                score = cvss4_obj.get("baseScore")
                severity = cvss4_obj.get("baseSeverity")
                exploitability_score = score
                attack_complexity = cvss4_obj.get("attackComplexity")
                # CVSS 3.1 only supports NONE or REQUIRED for userInteraction
                user_interaction = (
                    "REQUIRED"
                    if cvss4_obj.get("userInteraction", "") != "NONE"
                    else "NONE"
                )
            if vector_string:
                cvss3_obj = get_cvss3_from_vector(vector_string)
                # Fallback to CVSS 3.1 only if CVSS 4 is absent
                if not cvss4_vector_string:
                    score = cvss3_obj.get("baseScore")
                    severity = cvss3_obj.get("baseSeverity")
                    exploitability_score = cvss3_obj.get("temporalScore")
                    attack_complexity = cvss3_obj.get("attackComplexity")
                    user_interaction = cvss3_obj.get("userInteraction")
            if not vector_string and not cvss4_vector_string:
                (
                    score,
                    severity,
                    dvector_string,
                    attack_complexity,
                ) = get_default_cve_data(severity)
                # Override the score for malware
                if cve_id.startswith("MAL"):
                    score = 10.0
                    user_interaction = "NONE"
                # Set the default vector string only if unavailable
                if not vector_string and dvector_string:
                    vector_string = dvector_string
                exploitability_score = score
            ranges = pkg_data.get("ranges", [])
            versions_list = pkg_data.get("versions", [])
            vendor_ecosystem = pkg_data.get("package", {}).get("ecosystem", "").lower()
            vendor = vendor_ecosystem
            pkg_name = pkg_data.get("package", {}).get("name", "")
            pkg_name_list = []
            purl_prefix = pkg_data.get("package", {}).get("purl", "")
            purl = parse_purl(purl_prefix)
            if purl and vendor_ecosystem != "npm":
                if purl.get("type"):
                    vendor = purl["type"]
                if purl.get("namespace") and purl.get("name"):
                    pkg_name = f"{purl['namespace']}/{purl['name']}"
                elif purl.get("name"):
                    pkg_name = purl["name"]
            if ":" in pkg_name and vendor.lower() not in (
                "swift",
                "swifturl",
                "github",
                "github actions",
            ):
                # Example: commons-fileupload:commons-fileupload
                # org.apache.tomcat:tomcat
                tmp_a = pkg_name.split(":")
                if len(tmp_a) == 2:
                    pkg_name = f"{tmp_a[0]}/{tmp_a[-1]}"
            # In case of swift, we need to remove any https protocol from the name
            if pkg_name.startswith("https://"):
                pkg_name = pkg_name.removeprefix("https://")
            if pkg_name:
                pkg_name_list.append(pkg_name)
            # Substitute alpine for apk and debian for deb
            if vendor_overrides.get(vendor):
                vendor = vendor_overrides.get(vendor)
            # Since swift allows both url and local based lookups, we store both the variations
            if vendor in (
                "swift",
                "swifturl",
                "github",
                "github actions",
            ) and pkg_name.startswith("github.com"):
                pkg_name_list.append(pkg_name.removeprefix("github.com/"))
            # For OS packages, such as alpine OSV appends the os version to the vendor
            # Let's remove it and add it to package name
            if ":" in vendor_ecosystem and (
                "alpine" in vendor
                or "apk" in vendor
                or "deb" in vendor
                or "debian" in vendor
                or "almalinux" in vendor
                or "rocky" in vendor
            ):
                tmp_v = vendor_ecosystem.split(":")
                vendor = tmp_v[0].lower().replace(" ", "").replace("-", "")
                vdistro = tmp_v[1]
                if vendor in ("alpine", "apk"):
                    vdistro = vdistro.replace("v", "")
                # In os-release, ID for rockylinux is simply rocky
                if "rocky" in vendor:
                    vendor = vendor.replace("linux", "")
                edition = f"{vendor}-{vdistro}"
                # Only use the precise version for os packages
                if (
                    "debian" in vendor
                    or "deb" in vendor
                    or "alpine" in vendor
                    or "apk" in vendor
                    or "almalinux" in vendor
                    or "rocky" in vendor
                ):
                    pkg_name_list = [
                        f"{vendor}/{edition}/{pkg_name.removeprefix(vendor + '/')}"
                    ]
                else:
                    pkg_name_list.append(f"{edition}/{pkg_name}")
            # For some ecosystem, osv provides a full list of versions with partial events. See osv-pypi2.json for an example
            needs_version_backup = len(ranges) == 0
            possible_fix_version = ""
            for r in ranges:
                events = r.get("events")
                for ev in events:
                    if ev.get("introduced", "") in (0, "0", "0.0.0"):
                        needs_version_backup = True
                    if ev.get("fixed"):
                        possible_fix_version = ev.get("fixed")
                    if ev.get("limit") and not possible_fix_version:
                        possible_fix_version = ev.get("limit")

            if needs_version_backup and len(versions_list):
                # For smaller lists, we track them all
                if len(versions_list) < OSV_PER_VERSION_LIMIT:
                    for each_version in versions_list:
                        for full_pkg in pkg_name_list:
                            tdata = config.CVE_TPL % dict(
                                cve_id=cve_id,
                                cwe_id=cwe_id,
                                assigner=assigner,
                                references=references,
                                description="",
                                vectorString=vector_string,
                                vendor=vendor,
                                product=full_pkg,
                                version="*",
                                edition=edition,
                                version_start_including=each_version,
                                version_end_including=each_version,
                                version_start_excluding="",
                                version_end_excluding="",
                                fix_version_start_including=possible_fix_version,
                                fix_version_end_including=possible_fix_version,
                                fix_version_start_excluding="",
                                fix_version_end_excluding="",
                                severity=severity,
                                attackComplexity=attack_complexity,
                                score=score,
                                userInteraction=user_interaction,
                                exploitabilityScore=exploitability_score,
                                publishedDate=cve_data.get("published"),
                                lastModifiedDate=cve_data.get("modified"),
                            )
                            try:
                                vuln = NvdSource.convert_vuln(orjson.loads(tdata))
                                if vuln is None:
                                    continue
                                vuln.description = compress_str(description)
                                if affected_functions:
                                    vuln.affects = {
                                        "affected_functions": sorted(list(affected_functions)),
                                        "affected_modules": sorted(
                                            list(
                                                affected_modules
                                                | functions_to_modules(affected_functions)
                                            )
                                        ),
                                    }
                                if cvss4_vector_string:
                                    vuln.cvss4_vector_string = cvss4_vector_string
                                if purl_prefix:
                                    vuln.purl_prefix = purl_prefix
                                ret_data.append(vuln)
                            except Exception:
                                pass
                else:
                    try:
                        min_ver = min(
                            versions_list,
                            key=lambda x: Version.parse(x, optional_minor_and_patch=True),
                        )
                        max_ver = max(
                            versions_list,
                            key=lambda x: Version.parse(x, optional_minor_and_patch=True),
                        )
                    except Exception:
                        min_ver = versions_list[0]
                        max_ver = versions_list[-1]
                    for full_pkg in pkg_name_list:
                        tdata = config.CVE_TPL % dict(
                            cve_id=cve_id,
                            cwe_id=cwe_id,
                            assigner=assigner,
                            references=references,
                            description="",
                            vectorString=vector_string,
                            vendor=vendor,
                            product=full_pkg,
                            version="*",
                            edition=edition,
                            version_start_including=min_ver,
                            version_end_including=max_ver,
                            version_start_excluding="",
                            version_end_excluding=possible_fix_version,
                            fix_version_start_including=possible_fix_version,
                            fix_version_end_including=possible_fix_version,
                            fix_version_start_excluding=max_ver,
                            fix_version_end_excluding="",
                            severity=severity,
                            attackComplexity=attack_complexity,
                            score=score,
                            userInteraction=user_interaction,
                            exploitabilityScore=exploitability_score,
                            publishedDate=cve_data.get("published"),
                            lastModifiedDate=cve_data.get("modified"),
                        )
                        try:
                            vuln = NvdSource.convert_vuln(orjson.loads(tdata))
                            if vuln is None:
                                continue
                            vuln.description = compress_str(description)
                            if affected_functions:
                                vuln.affects = {
                                    "affected_functions": sorted(list(affected_functions)),
                                    "affected_modules": sorted(
                                        list(
                                            affected_modules
                                            | functions_to_modules(affected_functions)
                                        )
                                    ),
                                }
                            if cvss4_vector_string:
                                vuln.cvss4_vector_string = cvss4_vector_string
                            if purl_prefix:
                                vuln.purl_prefix = purl_prefix
                            ret_data.append(vuln)
                        except Exception:
                            pass
            # Normal flow with version ranges
            for r in ranges:
                if r.get("type") == "GIT" and r.get("repo"):
                    vendor = "generic"
                    repo_name = (
                        r.get("repo")
                        .removeprefix("http://")
                        .removeprefix("https://")
                        .removeprefix("git://")
                        .removesuffix("/")
                        .removesuffix(".git")
                        .lower()
                    )
                    # See #112
                    for special_type in ("github.com", "gitlab.com"):
                        if repo_name.startswith(special_type):
                            vendor = special_type.removesuffix(".com")
                            repo_name = repo_name.replace(f"{special_type}/", "")
                    pkg_name_list.append(repo_name)
                events = r.get("events")
                rversions_list = r.get("versions", [])
                version_start_including = ds_version_start_including or min_ver
                version_end_including = ds_version_end_including or max_ver
                version_start_excluding = ds_version_start_excluding
                version_end_excluding = ds_version_end_excluding
                fix_version_start_including = ""
                fix_version_end_including = ""
                fix_version_start_excluding = ""
                fix_version_end_excluding = ""

                if rversions_list:
                    version_start_including = rversions_list[0]
                    if (
                        len(rversions_list) > 1
                        and version_start_including != rversions_list[-1]
                    ):
                        version_end_including = rversions_list[-1]
                fixed_version_in_events = (
                    len(
                        [
                            e
                            for e in events
                            if e.get("fixed")
                            or e.get("last_affected")
                            or e.get("limit")
                        ]
                    )
                    > 0
                )
                for ev in events:
                    # Reset all versions for introduced event
                    if ev.get("introduced") is not None:
                        version_end_including = ds_version_end_including
                        version_start_excluding = ds_version_start_excluding
                        version_end_excluding = ds_version_end_excluding
                        fix_version_start_including = version_end_excluding
                        fix_version_end_including = ""
                        fix_version_start_excluding = ""
                        fix_version_end_excluding = ""
                        intr = ev.get("introduced")
                        if (
                            ":" in str(intr)
                            and "debian" not in vendor
                            and "alpine" not in vendor
                        ):
                            version_start_including = str(intr).split(":")[-1]
                        else:
                            version_start_including = str(intr)

                        if version_start_including in (0, "0", "0.0.0"):
                            if min_ver:
                                version_start_including = min_ver
                            else:
                                version_start_including = "0.0.0"
                    if ev.get("fixed"):
                        fixed_val = ev.get("fixed")
                        if (
                            ":" in str(fixed_val)
                            and "debian" not in vendor
                            and "alpine" not in vendor
                        ):
                            version_end_excluding = str(fixed_val).split(":")[-1]
                        else:
                            version_end_excluding = str(fixed_val)
                        fix_version_start_including = version_end_excluding
                        fix_version_end_including = version_end_excluding
                        if not version_end_including and max_ver:
                            version_end_including = max_ver
                    if ev.get("last_affected"):
                        la_val = ev.get("last_affected")
                        if (
                            ":" in str(la_val)
                            and "debian" not in vendor
                            and "alpine" not in vendor
                        ):
                            version_end_including = str(la_val).split(":")[-1]
                        else:
                            version_end_including = str(la_val)
                        fix_version_start_excluding = version_end_including
                    if ev.get("limit"):
                        limit_val = ev.get("limit")
                        if (
                            ":" in str(limit_val)
                            and "debian" not in vendor
                            and "alpine" not in vendor
                        ):
                            version_end_excluding = str(limit_val).split(":")[-1]
                        else:
                            version_end_excluding = str(limit_val)
                    if version_start_including and (
                        (fix_version_start_including or fix_version_start_excluding)
                        or (not fixed_version_in_events and version_end_including)
                        or version_end_excluding
                        or (not fixed_version_in_events and not versions_list)
                    ):
                        for full_pkg in pkg_name_list:
                            tdata = config.CVE_TPL % dict(
                                cve_id=cve_id,
                                cwe_id=cwe_id,
                                assigner=assigner,
                                references=references,
                                description="",
                                vectorString=vector_string,
                                vendor=vendor,
                                product=full_pkg,
                                version="*",
                                edition=edition,
                                version_start_including=version_start_including,
                                version_end_including=version_end_including,
                                version_start_excluding=version_start_excluding,
                                version_end_excluding=version_end_excluding,
                                fix_version_start_including=fix_version_start_including,
                                fix_version_end_including=fix_version_end_including,
                                fix_version_start_excluding=fix_version_start_excluding,
                                fix_version_end_excluding=fix_version_end_excluding,
                                severity=severity,
                                attackComplexity=attack_complexity,
                                score=score,
                                userInteraction=user_interaction,
                                exploitabilityScore=exploitability_score,
                                publishedDate=cve_data.get("published"),
                                lastModifiedDate=cve_data.get("modified"),
                            )
                            try:
                                vuln = NvdSource.convert_vuln(orjson.loads(tdata))
                                if vuln is None:
                                    continue
                                vuln.description = compress_str(description)
                                if affected_functions:
                                    vuln.affects = {
                                        "affected_functions": sorted(
                                            affected_functions
                                        ),
                                        "affected_modules": sorted(
                                            list(
                                                affected_modules
                                                | functions_to_modules(
                                                    affected_functions
                                                )
                                            )
                                        ),
                                    }
                                if cvss4_vector_string:
                                    vuln.cvss4_vector_string = cvss4_vector_string
                                if purl_prefix:
                                    vuln.purl_prefix = purl_prefix
                                ret_data.append(vuln)
                            except Exception:
                                pass
        return ret_data
