import copy
import re
from abc import ABC, abstractmethod
from typing import Dict, Optional, Type

from anchore_engine.db import (
    CpeV2Vulnerability,
    FixedArtifact,
    GemMetadata,
    GenericFeedDataRecord,
    NpmMetadata,
    NvdV2Metadata,
    VulnDBCpe,
    VulnDBMetadata,
    Vulnerability,
)
from anchore_engine.subsys import logger
from anchore_engine.utils import CPE


class FeedDataMapper(object):
    """
    Base interface for mapping feed records into the db
    """

    def __init__(self, feed_name: str, group_name: str, key_name: str):
        self.feed = feed_name
        self.group = group_name
        self.key_item_name = key_name

    def map(self, record_json: dict):
        """
        Entrypoint to do the mapping, a wrapper for the inner map() function to pre-process the json.

        :param record_json: data record deserialized from json (dict) to map
        :return: a DB entity that can be added to a session/persisted
        """
        raise NotImplementedError()


class KeyIDFeedDataMapper(FeedDataMapper):
    """
    A mapper for handling the case where each data item is a single key that is the id itself, without a field identifier.

    E.g. { 'my_id1': { 'data1': 'value1'} } -> key='my_id1', data={'data1':'value1'}

    """

    def map(self, record_json):
        if len(list(record_json.keys())) == 1:
            key, value = list(record_json.items())[0]
            return self.map_inner(key, value)

    def map_inner(self, key, data):
        raise NotImplementedError()


class GenericFeedDataMapper(KeyIDFeedDataMapper):
    """
    A generic mapping class to consume feed json and return db objects
    """

    def map_inner(self, key, data):
        """
        Map a single data feed record from msg to db format
        :param data: data record deserialized from json (dict) to map
        :return: a DB entity that can be added to a session/persisted
        """

        db_rec = GenericFeedDataRecord()
        db_rec.feed = self.feed
        db_rec.group = self.group
        db_rec.id = key
        db_rec.data = data
        return db_rec


class MapperFactory(ABC):
    @abstractmethod
    def __getitem__(self, item: str) -> FeedDataMapper:
        ...

    @abstractmethod
    def get(self, item: str) -> FeedDataMapper:
        ...


class MultiTypeMapperFactory(MapperFactory):
    def __init__(
        self,
        feed_name: Optional[str],
        mappers_dict: Dict[str, Type[FeedDataMapper]],
        common_key: Optional[str] = None,
    ):
        if feed_name is None:
            raise TypeError("feed_name must be a string and cannot be type(None)")
        self.feed_name: str = feed_name
        self.mapper_store = mappers_dict
        self.common_key: Optional[str] = common_key

    def __getitem__(self, item: str) -> FeedDataMapper:
        if item in self.mapper_store:
            return self.mapper_store[item](self.feed_name, item, self.common_key)
        return GenericFeedDataMapper(self.feed_name, item, self.common_key)

    def get(self, item: str) -> FeedDataMapper:
        return self.__getitem__(item)


class SingleTypeMapperFactory(MapperFactory):
    def __init__(
        self,
        feed_name: str,
        mapper_clazz: Type[FeedDataMapper],
        common_key: Optional[str] = None,
    ):
        """
        Create a single-type mapper factory that returns mappers of type <mapper_clazz>

        :param feed_name: name of the feed to configure into the mapper
        :param mapper_clazz: the class to instantiate when requested
        :param common_key: the data key to look for in items if all groups use the same key name for data items
        """

        self.feed: str = feed_name
        self.mapper_clazz: Type[FeedDataMapper] = mapper_clazz
        self.common_key: str = common_key

    def __getitem__(self, item: str) -> FeedDataMapper:
        return self.mapper_clazz(self.feed, item, self.common_key)

    def get(self, item: str) -> FeedDataMapper:
        return self.__getitem__(item)


class GemPackageDataMapper(KeyIDFeedDataMapper):
    """
    Maps a Gem package feed record to a db record
    """

    def map_inner(self, key, data):
        db_rec = GemMetadata()
        db_rec.name = key[:255]
        db_rec.id = int(data.get("id")) if data.get("id") else -1
        db_rec.authors_json = data.get("authors")
        db_rec.versions_json = data.get("versions")
        db_rec.licenses_json = data.get("licenses")
        db_rec.latest = data.get("latest")[:255] if data.get("latest") else None
        return db_rec


class NpmPackageDataMapper(KeyIDFeedDataMapper):
    """
    Maps a NPM package record to the db record
    """

    def map_inner(self, key, data):
        db_rec = NpmMetadata()
        db_rec.name = key[:255]
        db_rec.versions_json = data.get("versions")
        db_rec.latest = data.get("latest")[:255] if data.get("latest") else None
        db_rec.sourcepkg = (
            data.get("sourcepkg")[:255] if data.get("sourcepkg") else None
        )
        db_rec.origins_json = data.get("origins")
        db_rec.lics_json = data.get("lics")
        return db_rec


class NvdV2FeedDataMapper(FeedDataMapper):
    """
    Maps an NVD record into an NvdMetadata ORM object
    """

    def map(self, record_json):
        # log.debug("V2 DBREC: {}".format(json.dumps(record_json)))

        # Copy it to ensure no lingering refs to source json doc
        record_json = copy.deepcopy(record_json)

        db_rec = NvdV2Metadata()
        db_rec.name = (
            record_json.get("cve", {}).get("CVE_data_meta", {}).get("ID", None)
        )
        db_rec.namespace_name = self.group
        db_rec.description = (
            record_json.get("cve", {})
            .get("description", {})
            .get("description_data", [{}])[0]
            .get("value", "")
        )
        db_rec.cvss_v2 = record_json.get("cvss_v2", None)
        db_rec.cvss_v3 = record_json.get("cvss_v3", None)
        db_rec.severity = (
            record_json.get("severity")
            if record_json.get("severity", None)
            else "Unknown"
        )
        db_rec.link = "https://nvd.nist.gov/vuln/detail/{}".format(db_rec.name)
        db_rec.references = record_json.get("external_references", [])

        db_rec.vulnerable_cpes = []
        for input_cpe in record_json.get("vulnerable_cpes", []):
            try:
                # "cpe:2.3:a:openssl:openssl:-:*:*:*:*:*:*:*",
                # TODO - handle cpe inputs with escaped characters
                # cpetoks = input_cpe.split(":")
                cpe_obj = CPE.from_cpe23_fs(input_cpe)
                newcpe = CpeV2Vulnerability()
                newcpe.feed_name = self.feed
                newcpe.part = cpe_obj.part
                newcpe.vendor = cpe_obj.vendor
                newcpe.product = cpe_obj.product
                newcpe.version = cpe_obj.version
                newcpe.update = cpe_obj.update
                newcpe.edition = cpe_obj.edition
                newcpe.language = cpe_obj.language
                newcpe.sw_edition = cpe_obj.sw_edition
                newcpe.target_sw = cpe_obj.target_sw
                newcpe.target_hw = cpe_obj.target_hw
                newcpe.other = cpe_obj.other
                db_rec.vulnerable_cpes.append(newcpe)
            except Exception as err:
                logger.warn(
                    "failed to convert vulnerable-software-list into database CPEV2 record - exception: "
                    + str(err)
                )

        return db_rec


class VulnDBFeedDataMapper(FeedDataMapper):
    """
    Maps an VulnDB record into an ORM object
    """

    def map(self, record_json):
        # log.debug("V2 DBREC: {}".format(json.dumps(record_json)))
        db_rec = VulnDBMetadata()
        db_rec.name = record_json.get("id")
        db_rec.namespace_name = self.group
        db_rec.title = record_json.get("title", None)
        db_rec.description = record_json.get("description", None)
        db_rec.solution = record_json.get("solution", None)
        db_rec.vendor_product_info = record_json.get("vendor_product_info", [])
        db_rec.references = record_json.get("external_references", [])
        db_rec.vulnerable_packages = record_json.get("vulnerable_packages", [])
        db_rec.vulnerable_libraries = record_json.get("vulnerable_libraries", [])
        db_rec.vendor_cvss_v2 = record_json.get("vendor_cvss_v2", [])
        db_rec.vendor_cvss_v3 = record_json.get("vendor_cvss_v3", [])
        db_rec.nvd = record_json.get("nvd", [])
        db_rec.vuln_metadata = record_json.get("metadata", {})
        db_rec.severity = (
            record_json.get("severity")
            if record_json.get("severity", None)
            else "Unknown"
        )

        db_rec.cpes = []
        for input_cpe in record_json.get("vulnerable_cpes", []):
            try:
                # "cpe:2.3:a:openssl:openssl:-:*:*:*:*:*:*:*",
                cpe_obj = CPE.from_cpe23_fs(input_cpe)
                newcpe = VulnDBCpe()
                newcpe.feed_name = self.feed
                # newcpe.severity = db_rec.severity  # todo ugh! get this from the parent!
                newcpe.part = cpe_obj.part
                newcpe.vendor = cpe_obj.vendor.replace("\\", "")
                newcpe.product = cpe_obj.product.replace("\\", "")
                newcpe.version = cpe_obj.version.replace("\\", "")
                newcpe.update = cpe_obj.update.replace("\\", "")
                newcpe.edition = cpe_obj.edition.replace("\\", "")
                newcpe.language = cpe_obj.language.replace("\\", "")
                newcpe.sw_edition = cpe_obj.sw_edition.replace("\\", "")
                newcpe.target_sw = cpe_obj.target_sw.replace("\\", "")
                newcpe.target_hw = cpe_obj.target_hw.replace("\\", "")
                newcpe.other = cpe_obj.other.replace("\\", "")
                newcpe.is_affected = True

                db_rec.cpes.append(newcpe)
            except Exception as err:
                logger.warn(
                    "failed to convert vendor_product_info into database VulnDBCpe record - exception: "
                    + str(err)
                )

        for input_cpe in record_json.get("unaffected_cpes", []):
            try:
                # "cpe:2.3:a:openssl:openssl:-:*:*:*:*:*:*:*",
                cpe_obj = CPE.from_cpe23_fs(input_cpe)
                newcpe = VulnDBCpe()
                newcpe.feed_name = self.feed
                # newcpe.severity = db_rec.severity  # todo ugh! get this from the parent!
                newcpe.part = cpe_obj.part
                newcpe.vendor = cpe_obj.vendor.replace("\\", "")
                newcpe.product = cpe_obj.product.replace("\\", "")
                newcpe.version = cpe_obj.version.replace("\\", "")
                newcpe.update = cpe_obj.update.replace("\\", "")
                newcpe.edition = cpe_obj.edition.replace("\\", "")
                newcpe.language = cpe_obj.language.replace("\\", "")
                newcpe.sw_edition = cpe_obj.sw_edition.replace("\\", "")
                newcpe.target_sw = cpe_obj.target_sw.replace("\\", "")
                newcpe.target_hw = cpe_obj.target_hw.replace("\\", "")
                newcpe.other = cpe_obj.other.replace("\\", "")
                newcpe.is_affected = False

                db_rec.cpes.append(newcpe)
            except Exception as err:
                logger.warn(
                    "failed to convert vendor_product_info into database VulnDBCpe record - exception: "
                    + str(err)
                )

        return db_rec


class VulnerabilityFeedDataMapper(FeedDataMapper):
    """
    Maps a Vulnerability record:

    Example:
    {
        'Vulnerability': {
            'Description': 'Async Http Client (aka AHC or async-http-client) before 1.9.0 skips X.509 certificate verification unless both a keyStore location and a trustStore location are explicitly set, which allows man-in-the-middle attackers to spoof HTTPS servers by presenting an arbitrary certificate during use of a typical AHC configuration, as demonstrated by a configuration that does not send client certificates.',
            'FixedIn': [
                {
                    'Name': 'async-http-client',
                    'NamespaceName': 'debian:9',
                    'Version': '1.6.5-3',
                    'VersionFormat': 'dpkg',
                    'VendorAdvisory': {
                        'NoAdvisory': False,
                        'AdvisorySummary': [
                            {
                                'ID': 'DSA-0000-0',
                                'Link': 'https://security-tracker.debian.org/tracker/DSA-0000-0'
                            }
                        ]
                    }
                }
            ],
            'Link': 'https://security-tracker.debian.org/tracker/CVE-2013-7397',
            'Metadata': {
                'NVD': {
                    'CVSSv2': {
                        'Score': 4.3,
                        'Vectors': u'AV:N/AC:M/Au:N/C:N/I:P'
                    }
                }
            },
            'Name': 'CVE-2013-7397',
            'NamespaceName': 'debian:9',
            'Severity': 'Medium'}
    }
    """

    defaults = {"Severity": "Unknown", "Link": None, "Description": None}

    MAX_STR_LEN = 1024 * 64 - 4

    def map(self, record_json):
        if not record_json:
            return None

        # Handle a 'Vulnerability' wrapper around the specific record. If not present, assume a direct record
        if len(list(record_json.keys())) == 1 and record_json.get("Vulnerability"):
            vuln = record_json["Vulnerability"]
        else:
            vuln = record_json

        db_rec = Vulnerability()
        db_rec.id = vuln["Name"]
        db_rec.namespace_name = self.group
        db_rec.severity = vuln.get("Severity", "Unknown")
        db_rec.link = vuln.get("Link")
        description = vuln.get("Description", "")
        if description:
            db_rec.description = (
                vuln.get("Description", "")
                if len(vuln.get("Description", "")) < self.MAX_STR_LEN
                else (vuln.get("Description")[: self.MAX_STR_LEN - 8] + "...")
            )
        else:
            db_rec.description = ""
        db_rec.fixed_in = []
        # db_rec.vulnerable_in = []

        # db_rec.metadata_json = json.dumps(vuln.get('Metadata')) if 'Metadata' in vuln else None
        db_rec.additional_metadata = vuln.get("Metadata", {})
        cvss_data = vuln.get("Metadata", {}).get("NVD", {}).get("CVSSv2")
        if cvss_data:
            db_rec.cvss2_vectors = cvss_data.get("Vectors")
            db_rec.cvss2_score = cvss_data.get("Score")

        # Process Fixes
        if "FixedIn" in vuln:
            for f in vuln["FixedIn"]:
                fix = FixedArtifact()
                fix.name = f["Name"]
                fix.version = f["Version"]
                fix.version_format = f["VersionFormat"]
                fix.epochless_version = re.sub(r"^[0-9]*:", "", f["Version"])
                fix.vulnerability_id = db_rec.id
                fix.namespace_name = self.group
                fix.vendor_no_advisory = f.get("VendorAdvisory", {}).get(
                    "NoAdvisory", False
                )
                fix.fix_metadata = (
                    {"VendorAdvisorySummary": f["VendorAdvisory"]["AdvisorySummary"]}
                    if f.get("VendorAdvisory", {}).get("AdvisorySummary", [])
                    else None
                )

                db_rec.fixed_in.append(fix)

        #        if 'VulnerableIn' in vuln:
        #            for v in vuln['VulnerableIn']:
        #                v_in = VulnerableArtifact()
        #                v_in.name = v['Name']
        #                v_in.version = v['Version']
        #                v_in.version_format = v['VersionFormat']
        #                v_in.epochless_version = re.sub(r'^[0-9]*:', '', v['Version'])
        #                v_in.vulnerability_id = db_rec.id
        #                v_in.namespace_name = self.group
        #
        #                db_rec.vulnerable_in.append(v_in)

        return db_rec


class GithubFeedDataMapper(FeedDataMapper):
    """
    Maps a Github record into an GithubMetadata ORM object

    JSON Record is structured like::

        {'key': 'GHSA-73m2-3pwg-5fgc',
        'namespace': 'github:python',
        'payload': {'Vulnerability': {}, 'Advisory': advisory}

    And the Advisory itself::

        {'CVE': ['GHSA-73m2-3pwg-5fgc', 'CVE-2020-5236'],
         'FixedIn': [{'ecosystem': 'python',
                      'fixedin': None,
                      'identifier': '1.4.3',
                      'name': 'waitress'},
                     {'ecosystem': 'os',
                      'fixedin': None,
                      'identifier': None,
                      'name': 'waitress'}],
         'Metadata': {'CVE': ['GHSA-73m2-3pwg-5fgc', 'CVE-2020-5236']},
         'Severity': 'Critical',
         'Summary': 'Critical severity vulnerability that affects waitress',
         'url': 'https://github.com/advisories/GHSA-73m2-3pwg-5fgc',
         'withdrawn': None}

    """

    def map(self, record_json):
        advisory = record_json["Advisory"]

        db_rec = Vulnerability()
        db_rec.id = advisory["ghsaId"]
        db_rec.name = advisory["ghsaId"]
        db_rec.namespace_name = advisory["namespace"]
        db_rec.description = advisory["Summary"]
        db_rec.severity = advisory.get("Severity", "Unknown") or "Unknown"
        db_rec.link = advisory["url"]
        db_rec.metadata_json = advisory["Metadata"]
        references = [
            "https://nvd.nist.gov/vuln/detail/{}".format(i) for i in advisory["CVE"]
        ]
        db_rec.references = references

        # Set the `FixedArtifact` to an empty list so that a cascade deletion
        # gets rid of the associated fixes. If the advisory has been withdrawn,
        # this field will a string with a date.
        if advisory["withdrawn"] is not None:
            db_rec.fixed_in = []
            return db_rec

        for f in advisory["FixedIn"]:
            fix = FixedArtifact()
            fix.name = f["name"]
            # this is an unfortunate lie, 'version' has to be a range in order
            # to be processed correctly. If there is a real fix version, it
            # will be set in the `fix_metadata`.
            fix.version = f.get("range", "None")
            fix.version_format = "semver"
            fix.vulnerability_id = db_rec.id
            fix.namespace_name = f["namespace"]
            fix.vendor_no_advisory = False
            # the advisory summary is the same as db_rec.description, do we need to do this again?
            fix.fix_metadata = {"first_patched_version": f["identifier"]}

            db_rec.fixed_in.append(fix)

        return db_rec
