"""
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

# 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")
        cwe_id = ""
        edition = "*"
        cve_references = cve_data.get("references", [])
        aliases = cve_data.get("aliases", [])
        aliases_block = ""
        if aliases and len(aliases) > 1:
            aliases_block = f"""
## Related CVE(s)
{", ".join(aliases)}
"""
        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")})
        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"
        vector_string = ""
        cvss4_vector_string = ""
        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
        severity = "CRITICAL" if cve_id.startswith("MAL") else "LOW"
        if cve_data.get("severity"):
            severity_list = cve_data.get("severity")
            for sv in severity_list:
                if sv["type"] == "CVSS_V3":
                    vector_string = sv["score"]
                elif sv["type"] == "CVSS_V4":
                    cvss4_vector_string = sv["score"]
        # Issue 58
        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"):
                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"):
            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 = ""
            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)
                # Who said making sense of the various attributes in OSV json is easy?
                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"
            # 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 = parse_purl(pkg_data.get("package", {}).get("purl", ""))
            if purl:
                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
            # Problem 1: Storing each version as-is is slowing down vdb
            # Problem 2: The versions_list may be unsorted and trying to sort based on semantic versions can fail
            # Solution: We do our best to sort the versions_list. If it fails, we assume the input is sorted and store the first and last entry as min and max.
            # The assumption seems to be working as of today, but could result in false positives or false negatives in the future.
            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 needs_version_backup and len(versions_list):
                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
                        ret_data.append(vuln)
                    except Exception:
                        pass
            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 = ""
                version_start_including = ""
                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 = ""
                        version_start_including = ev.get("introduced").split(":")[-1]
                        # Sometimes, the quality of data will be so low we need
                        # workarounds like this to make the result searchable
                        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"):
                        fix_version_start_including = ev.get("fixed").split(":")[-1]
                        fix_version_end_including = ev.get("fixed").split(":")[-1]
                        version_end_excluding = ev.get("fixed").split(":")[-1]
                        if not version_end_including and max_ver:
                            version_end_including = max_ver
                    if ev.get("last_affected"):
                        version_end_including = ev.get("last_affected").split(":")[-1]
                        fix_version_start_excluding = version_end_including
                    if ev.get("limit"):
                        version_end_excluding = ev.get("limit").split(":")[-1]
                    # Create an entry for each introduced + fixed/limit event
                    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
                                ret_data.append(vuln)
                            except Exception:
                                pass
        return ret_data
