from __future__ import annotations

import copy
import glob
import logging
import os
import re
from collections import namedtuple
from typing import TYPE_CHECKING, Any

import orjson

from vunnel.result import SQLiteReader
from vunnel.tool import fixdate
from vunnel.utils import date, vulnerability
from vunnel.utils import http_wrapper as http

if TYPE_CHECKING:
    from types import TracebackType


DSAFixedInTuple = namedtuple("DSAFixedInTuple", ["dsa", "link", "distro", "pkg", "ver", "date"])
DSACollection = namedtuple("DSACollection", ["cves", "nocves"])


# Only releases presenting this mapping will be output by the driver, maintain it with new releases.
# Can also be extended via configuration.
debian_distro_map = {
    "duke": "15",
    "forky": "14",
    "trixie": "13",
    "bookworm": "12",
    "bullseye": "11",
    "buster": "10",
    "stretch": "9",
    "jessie": "8",
    "wheezy": "7",
    "sid": "unstable",
}


class Parser:
    _json_url_ = "https://security-tracker.debian.org/tracker/data/json"
    _dsa_url_ = "https://salsa.debian.org/security-tracker-team/security-tracker/raw/master/data/DSA/list"
    _json_file_ = "debian.json"
    _dsa_file_ = "DSA"

    _dsa_start_regex_ = re.compile(r"^\S+.*")
    _dsa_info_regex_ = re.compile(r"^\[(.*)\]\s+(DSA-[^\s]+)\s+([^\s]+)[-\s]+(.*)")
    _cve_list_regex_ = re.compile(r"^\s+\{(.*)\}")
    _fixed_in_regex_ = re.compile(r"^\s+\[(.*)\][-\s]+([^\s]*)(.*)")
    _fixed_in_note_regex_ = re.compile(r"^\s+NOTE:\s+\[(.*)\][-\s]+([^\s]*)(.*)")
    _base_dsa_id_regex_ = re.compile(r"(DSA-[^-]+).*")

    def __init__(self, workspace, fixdater: fixdate.Finder | None = None, download_timeout=125, logger=None, distro_map=None):
        self.workspace = workspace
        if not fixdater:
            fixdater = fixdate.default_finder(workspace)
        self.fixdater = fixdater
        self.download_timeout = download_timeout
        if not distro_map:
            distro_map = debian_distro_map
        self.debian_distro_map = distro_map
        self.json_file_path = os.path.join(workspace.input_path, self._json_file_)
        self.dsa_file_path = os.path.join(workspace.input_path, self._dsa_file_)
        self.legacy_records_path = os.path.join(self.workspace.input_path, "legacy")
        self.urls = [self._json_url_, self._dsa_url_]

        if not logger:
            logger = logging.getLogger(self.__class__.__name__)
        self.logger = logger

    def __enter__(self) -> Parser:
        self.fixdater.__enter__()
        return self

    def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> None:
        self.fixdater.__exit__(exc_type, exc_val, exc_tb)

    def _download_json(self):
        """
        Downloads debian json file
        :return:
        """
        try:
            r = http.get(self._json_url_, self.logger, timeout=self.download_timeout)
            orjson.loads(r.text)  # quick check if json is valid
            with open(self.json_file_path, "w", encoding="utf-8") as OFH:
                OFH.write(r.text)

        except Exception:
            self.logger.exception("Error downloading debian json file")
            raise

    def _download_dsa(self):
        """
        Downloads debian dsa file
        :return:
        """
        try:
            r = http.get(self._dsa_url_, self.logger, timeout=self.download_timeout)
            with open(self.dsa_file_path, "w", encoding="utf-8") as OFH:
                OFH.write(r.text)

        except Exception:
            self.logger.exception("error downloading debian DSA file")
            raise

    def _get_cve_to_dsalist(self, dsa):
        """

        :param dsa:
        :return:
        """
        ns_cve_dsalist = {}
        if dsa["cves"]:
            for fixedin in dsa["fixed_in"]:
                for cve in dsa["cves"]:
                    ns = fixedin["distro"]
                    if ns not in ns_cve_dsalist:
                        ns_cve_dsalist[ns] = {cve: []}
                    if cve not in ns_cve_dsalist[ns]:
                        ns_cve_dsalist[ns][cve] = []
                    ns_cve_dsalist[ns][cve].append(
                        DSAFixedInTuple(
                            dsa=dsa["id"],
                            link=dsa["link"],
                            distro=fixedin["distro"],
                            pkg=fixedin["pkg"],
                            ver=fixedin["ver"],
                            date=dsa.get("date"),
                        ),
                    )
        else:
            self.logger.debug(f"no CVEs found for {dsa['id']}")

        return ns_cve_dsalist

    def _parse_dsa_record(self, dsa_lines):  # noqa: C901
        """

        :param dsa_lines:
        :return:
        """
        try:
            dsa = {
                "cves": [],
                "fixed_in": [],
            }
            dsa_info_matched = False
            cve_list_matched = False

            for line in dsa_lines:
                if not dsa_info_matched:
                    dsa_info_match = re.match(self._dsa_info_regex_, line)
                    if dsa_info_match:
                        date, vuln_id, package, summary = dsa_info_match.groups()
                        summary = summary.strip() if summary else None
                        dsa["date"] = date
                        dsa["id"] = vuln_id
                        dsa["package"] = package
                        dsa["summary"] = summary
                        dsa["link"] = f"https://security-tracker.debian.org/tracker/{vuln_id}"
                        dsa_info_matched = True
                        continue

                if not cve_list_matched:
                    cve_list_match = re.match(self._cve_list_regex_, line)
                    if cve_list_match:
                        cves = cve_list_match.group(1).split()
                        dsa["cves"] = cves
                        cve_list_matched = True
                        continue

                fixed_in_match = re.match(self._fixed_in_regex_, line)
                if fixed_in_match:
                    distro, pkg, version = fixed_in_match.groups()
                    version = version.strip() if version else None
                    if not version:
                        self.logger.debug(
                            f"release version not included dsa: {dsa.get('id')}, distro: {distro}, pkg: {pkg}",
                        )
                    dsa["fixed_in"].append({"distro": distro, "pkg": pkg, "ver": version})
                    continue

                fixed_in_note_match = re.match(self._fixed_in_note_regex_, line)
                if fixed_in_note_match:
                    distro, pkg, version = fixed_in_note_match.groups()
                    version = version.strip() if version else None
                    if not version:
                        self.logger.debug(
                            f"release version not included dsa: {dsa.get('id')}, distro: {distro}, pkg: {pkg}",
                        )
                    dsa["fixed_in"].append({"distro": distro, "pkg": pkg, "ver": version})
                    continue

            return dsa
        except Exception:
            self.logger.exception("failed to parse dsa record")

    def _get_dsa_map(self):
        """

        :return:
        """

        if os.path.exists(self.dsa_file_path):
            dsa_map = {}

            with open(self.dsa_file_path, encoding="utf-8") as fp:
                dsa_rec = []
                line = fp.readline()

                def process_dsa_record():
                    # process bunch
                    dsa = self._parse_dsa_record(dsa_lines=dsa_rec)

                    # gather all DSAs with the same base DSA ID so the missing CVEs on some DSAs can be filled in
                    base_dsa_id_match = re.match(self._base_dsa_id_regex_, dsa["id"])
                    base_dsa_id = base_dsa_id_match.group(1) if base_dsa_id_match else dsa["id"]

                    if base_dsa_id not in dsa_map:
                        dsa_map[base_dsa_id] = DSACollection(cves=[], nocves=[])

                    if dsa["cves"]:
                        dsa_map[base_dsa_id].cves.append(dsa)
                    else:
                        dsa_map[base_dsa_id].nocves.append(dsa)
                    del dsa_rec[:]

                while line:
                    if re.match(self._dsa_start_regex_, line) and dsa_rec:
                        process_dsa_record()

                    dsa_rec.append(line)
                    line = fp.readline()

                if dsa_rec:
                    process_dsa_record()

            return dsa_map

        raise Exception(f"debian DSA source not found under {self.dsa_file_path}")

    def _normalize_dsa_list(self):
        ns_cve_dsalist = {}
        dsa_map = self._get_dsa_map()

        for dsa_collection in dsa_map.values():
            dsas_with_cves = dsa_collection.cves

            # Fill in the missing CVEs for some incremental DSAs if you can.
            # This also filters out DSAs that don't have neither CVEs nor fixed_in records
            if dsa_collection.nocves and dsas_with_cves:
                # get the cves from any dsa in the list
                cve_list = dsas_with_cves[0]["cves"]
                for dsa in dsa_collection.nocves:
                    dsa.update({"cves": copy.deepcopy(cve_list)})

                dsas_with_cves.extend(dsa_collection.nocves)

            # Invert the data and map DSAs to CVEs to namespaces
            for dsa in dsas_with_cves:
                for ns, cve_dsalist in self._get_cve_to_dsalist(dsa).items():
                    if ns not in ns_cve_dsalist:
                        ns_cve_dsalist[ns] = {}
                    for cve, dsalist in cve_dsalist.items():
                        if cve not in ns_cve_dsalist[ns]:
                            ns_cve_dsalist[ns][cve] = []
                        ns_cve_dsalist[ns][cve].extend(dsalist)

        dsa_map.clear()

        return ns_cve_dsalist

    def _normalize_json(self, ns_cve_dsalist=None):  # noqa: PLR0912,PLR0915,C901
        adv_mets = {}
        # all_matched_dsas = set()
        # all_dsas = set()

        # normalize DSA list
        # ns_cve_dsalist = normalize_dsa_list(dsaloc)

        # # metrics colleciton
        # for ns, cve_dsalist in ns_cve_dsalist.iteritems():
        #     if common.debian_distro_map.get(ns, None):
        #         all_dsas |= set([dsa_tup.dsa for dsalist in cve_dsalist.values() for dsa_tup in dsalist])

        if os.path.exists(self.json_file_path):
            with open(self.json_file_path, encoding="utf-8") as FH:
                data = orjson.loads(FH.read())
        else:
            raise Exception(f"debian json source not found under {self.json_file_path}")

        if ns_cve_dsalist is None:
            ns_cve_dsalist = {}

        vuln_records = {}

        for pkg in data:
            for vid in data[pkg]:
                # skip non CVE vids
                if not re.match("^CVE.*", vid):
                    continue

                # gather NVD data for this CVE. Pulling this logic out of the for loop as NVD data remains the same
                # regardless of the debian release.
                nvd_severity = None
                # if session:
                #     try:
                #         nvd_severity = nvd.get_severity(vid, session=session)
                #     except Exception:
                #         self.logger.exception("ignoring error processing nvdv2 record")

                vulnerability_data = data[pkg][vid]
                release_data = vulnerability_data.get("releases")
                if not isinstance(release_data, dict) or not release_data:
                    continue

                for rel, distro_record in release_data.items():
                    try:
                        relno = self.debian_distro_map.get(rel)
                        if not relno:
                            continue

                        # if relno not in retlists:
                        #    retlists[relno] = []

                        if relno not in vuln_records:
                            vuln_records[relno] = {}

                        complete = True
                        if distro_record.get("status", "") == "undetermined":
                            complete = False

                        ecosystem = "debian:" + str(relno)

                        if complete:
                            if vid not in vuln_records[relno]:
                                # create a new record
                                vuln_records[relno][vid] = copy.deepcopy(vulnerability.vulnerability_element)
                                vuln_record = vuln_records[relno][vid]

                                # populate the static information about the new vuln record
                                vuln_record["Vulnerability"]["Description"] = vulnerability_data.get("description", "")
                                vuln_record["Vulnerability"]["Name"] = str(vid)
                                vuln_record["Vulnerability"]["NamespaceName"] = ecosystem
                                vuln_record["Vulnerability"]["Link"] = "https://security-tracker.debian.org/tracker/" + str(vid)
                                vuln_record["Vulnerability"]["Severity"] = "Unknown"
                            else:
                                vuln_record = vuln_records[relno][vid]

                            # set severity
                            # from https://anonscm.debian.org/viewvc/secure-testing/bin/tracker_service.py
                            sev = None
                            if "urgency" in distro_record:
                                if distro_record["urgency"] in ["low", "low**"]:
                                    sev = "Low"
                                elif distro_record["urgency"] in ["medium", "medium**"]:
                                    sev = "Medium"
                                elif distro_record["urgency"] in ["high", "high**"]:
                                    sev = "High"
                                elif distro_record["urgency"] in [
                                    "unimportant",
                                ]:
                                    sev = "Negligible"
                                elif nvd_severity:  # no match to urgency found
                                    sev = nvd_severity  # fallback to nvd severity
                                else:
                                    sev = "Unknown"
                            elif nvd_severity:  # urgency element is not present
                                sev = nvd_severity  # fallback to nvd severity
                            else:
                                sev = "Unknown"

                            if sev and vulnerability.severity_order[sev] > vulnerability.severity_order[vuln_record["Vulnerability"]["Severity"]]:
                                vuln_record["Vulnerability"]["Severity"] = sev

                            # add fixedIn
                            skip_fixedin = False
                            fixed_el = {
                                "Name": pkg,
                                "NamespaceName": "debian:" + str(relno),
                                "VersionFormat": "dpkg",
                            }

                            if "fixed_version" in distro_record:
                                fixed_el["Version"] = distro_record["fixed_version"]
                                if distro_record["fixed_version"] == "0":
                                    # version == 0 should mean that the
                                    # package was determined to not be
                                    # vulnerable in the distro namespace
                                    # (from reviewing
                                    # https://security-tracker.debian.org/tracker/)
                                    skip_fixedin = True
                            else:
                                fixed_el["Version"] = "None"

                            if not skip_fixedin:
                                # collect metrics for vendor advisory
                                met_ns = vuln_record["Vulnerability"]["NamespaceName"]
                                met_sev = vuln_record["Vulnerability"]["Severity"]

                                if met_ns not in adv_mets:
                                    adv_mets[met_ns] = {
                                        met_sev: {
                                            "dsa": {"fixed": 0, "notfixed": 0},
                                            "nodsa": {"fixed": 0, "notfixed": 0},
                                            "neither": {"fixed": 0, "notfixed": 0},
                                        },
                                    }

                                if met_sev not in adv_mets[met_ns]:
                                    adv_mets[met_ns][met_sev] = {
                                        "dsa": {"fixed": 0, "notfixed": 0},
                                        "nodsa": {"fixed": 0, "notfixed": 0},
                                        "neither": {"fixed": 0, "notfixed": 0},
                                    }

                                # find DSAs associated with the CVE and package in the namespace
                                matched_dsas = [dsa for dsa in ns_cve_dsalist.get(rel, {}).get(vid, []) if dsa.pkg == pkg]

                                # add vendor advisory information to the fixed in record
                                if matched_dsas:
                                    fixed_el["VendorAdvisory"] = {
                                        "NoAdvisory": False,
                                        "AdvisorySummary": [{"ID": x.dsa, "Link": x.link} for x in matched_dsas],
                                    }
                                    # all_matched_dsas |= set([x.dsa for x in matched_dsas])
                                    adv_mets[met_ns][met_sev]["dsa"]["notfixed" if fixed_el["Version"] == "None" else "fixed"] += 1
                                elif "nodsa" in distro_record:
                                    fixed_el["VendorAdvisory"] = {"NoAdvisory": True}
                                    adv_mets[met_ns][met_sev]["nodsa"]["notfixed" if fixed_el["Version"] == "None" else "fixed"] += 1
                                else:
                                    fixed_el["VendorAdvisory"] = {
                                        "NoAdvisory": False,
                                        "AdvisorySummary": [],
                                    }
                                    adv_mets[met_ns][met_sev]["neither"]["notfixed" if fixed_el["Version"] == "None" else "fixed"] += 1

                                # add Available object if fix version exists and DSA date is available
                                advisory_fix_date = None
                                if fixed_el["Version"] != "None" and matched_dsas:
                                    # get the date from the first matched DSA (all should have same date for same advisory)
                                    advisory_fix_date = date.normalize_date(matched_dsas[0].date)

                                # getting the date information from the DSA is preferred, but is not reliable;
                                # we should fallback to other methods in these cases.
                                result = self.fixdater.best(
                                    vuln_id=str(vid),
                                    cpe_or_package=pkg,
                                    fix_version=fixed_el["Version"],
                                    ecosystem=ecosystem,
                                    candidates=[
                                        fixdate.Result(
                                            date=advisory_fix_date,
                                            kind="advisory",
                                            accurate=True,
                                        ),
                                    ],
                                )
                                if result:
                                    fixed_el["Available"] = {
                                        "Date": result.date.isoformat(),
                                        "Kind": result.kind,
                                    }

                                # append fixed in record to vulnerability
                                vuln_record["Vulnerability"]["FixedIn"].append(fixed_el)

                            # strip out any top level that is not set
                            final_record = {"Vulnerability": {}}
                            for k in vuln_record["Vulnerability"]:
                                if vuln_record["Vulnerability"][k]:
                                    final_record["Vulnerability"][k] = copy.deepcopy(vuln_record["Vulnerability"][k])

                            # retlists[relno].append(final_record)

                    except Exception:
                        self.logger.exception(f"ignoring error parsing vuln: {vid}, pkg: {pkg}, rel: {rel}")

        self.logger.debug(f"metrics for advisory information: {orjson.dumps(adv_mets).decode('utf-8')}")

        adv_mets.clear()
        # all_dsas.clear()
        # all_matched_dsas.clear()

        return vuln_records

    def _get_legacy_records(self) -> dict[str, dict[str, Any]]:
        legacy_records = self._get_legacy_records_from_results_db()

        fs_legacy_records = self._get_legacy_records_from_feed_service_datadrop()
        for relno, vuln_dict in fs_legacy_records.items():
            if relno not in legacy_records:
                legacy_records[relno] = {}
            for vid, vuln_record in vuln_dict.items():
                legacy_records[relno][vid] = self._patch_fix_date(vuln_record)

        if legacy_records:
            self.logger.info(f"found existing legacy data for the following releases: {list(legacy_records.keys())}")
        else:
            self.logger.info("no existing legacy data found")

        return legacy_records

    def _patch_fix_date(self, vuln_record: dict[str, Any]) -> dict[str, Any]:
        vid = vuln_record.get("Vulnerability", {}).get("Name", "")
        if not vid:
            return vuln_record

        fixed_in_list = vuln_record.get("Vulnerability", {}).get("FixedIn", [])
        for fixedin in fixed_in_list:
            if "Available" in fixedin and "Date" in fixedin["Available"]:
                continue
            if "Version" not in fixedin or fixedin["Version"] in ("None", "0"):
                continue

            result = self.fixdater.best(
                vuln_id=vid,
                cpe_or_package=fixedin.get("Name", ""),
                fix_version=fixedin["Version"],
                ecosystem=fixedin.get("NamespaceName", "").lower(),
            )
            if result:
                available = {
                    "Date": result.date.isoformat(),
                    "Kind": result.kind,
                }

                fixedin["Available"] = available
        return vuln_record

    def _get_legacy_records_from_results_db(self) -> dict[str, dict[str, Any]]:
        legacy_records = {}

        def process_result(file_path: str) -> None:
            self.logger.info(f"found existing legacy dataset: {file_path}")

            releases = set()
            records = 0
            with SQLiteReader(file_path) as db:
                envelopes = db.read_all()
                for envelope in envelopes:
                    relno = envelope.item["Vulnerability"]["NamespaceName"].split(":")[-1]
                    releases.add(relno)
                    vid = envelope.item["Vulnerability"]["Name"]
                    if relno not in legacy_records:
                        legacy_records[relno] = {}

                    records += 1
                    legacy_records[relno][vid] = self._patch_fix_date(envelope.item)

            self.logger.debug(f"legacy dataset {file_path} contains {len(releases)} releases with {records} records")

        result_files = glob.glob(os.path.join(self.legacy_records_path, "**", "results.db"), recursive=True)

        for file_path in result_files:
            process_result(file_path)

        return legacy_records

    def _get_legacy_records_from_feed_service_datadrop(self) -> dict[str, dict[str, Any]]:
        legacy_records = {}

        def process_file(contents: list[dict[str, Any]]) -> None:
            for record in contents:
                relno = record["Vulnerability"]["NamespaceName"].split(":")[-1]
                vid = record["Vulnerability"]["Name"]
                if relno not in legacy_records:
                    legacy_records[relno] = {}

                # ensure results are compliant with the current schema
                cvss_metadata = record["Vulnerability"].get("Metadata", {}).get("NVD", {}).get("CVSSv2", {})
                if cvss_metadata:
                    if cvss_metadata["Vectors"] is None:
                        del cvss_metadata["Vectors"]
                    record["Vulnerability"]["Metadata"]["NVD"]["CVSSv2"] = cvss_metadata

                # write the record back
                legacy_records[relno][vid] = record

        # read every json file in the legacy directory
        for root, _dirs, files in os.walk(self.legacy_records_path):
            for file in files:
                if file.endswith(".json") and file.startswith("vulnerabilities"):
                    with open(os.path.join(root, file)) as f:
                        process_file(orjson.loads(f.read()))

        if legacy_records:
            self.logger.info(f"found feed service legacy data for the following releases: {list(legacy_records.keys())}")

        return legacy_records

    def get(self):
        # download the files
        self._download_json()
        self._download_dsa()

        self.fixdater.download()

        # normalize dsa list first
        ns_cve_dsalist = self._normalize_dsa_list()

        # normalize json file
        vuln_records = self._normalize_json(ns_cve_dsalist=ns_cve_dsalist)

        # fetch records from legacy (if they exist)
        legacy_records = self._get_legacy_records()
        for relno, vuln_dict in legacy_records.items():
            if relno not in vuln_records:
                vuln_records[relno] = {}
            vuln_records[relno].update(vuln_dict)

        if vuln_records:
            for relno, vuln_dict in vuln_records.items():
                for vid, vuln_record in vuln_dict.items():
                    # HACK: when we can represent per-package severity or have a good mechanism
                    # for overriding upstream data, we should take this out.
                    severity_override = {"CVE-2020-35525", "CVE-2020-35527", "CVE-2023-4863", "CVE-2023-44487"}
                    if vid in severity_override:
                        self.logger.info(
                            f"clearing severity on {vid}, see https://github.com/anchore/grype-db/issues/108#issuecomment-1796301073",
                        )
                        vuln_record["Vulnerability"]["Severity"] = "Unknown"
                    yield relno, vid, vuln_record
        else:
            yield from ()
