from persistent import Persistent
from .utils import compare_versions, haskeys
import functools
import re


@functools.total_ordering
class Vulnerability(Persistent):
    """Represents a single vulnerability.

    Note that this vulnerability can be attached to different products
    and versions, this is called 'configurations' in CPE lingo. We
    represent theses as `Node` objects.
    """

    cve_id = None
    nodes = None
    cvssv3 = 0.0
    cvssv2 = 0.0

    def __init__(self, cve_id, nodes=None, cvssv3=0.0, cvssv2=0.0):
        self.cve_id = cve_id
        self.nodes = nodes or []
        self.cvssv3 = float(cvssv3)
        self.cvssv2 = float(cvssv2)

    def __str__(self):
        return self.cve_id

    def __repr__(self):
        return '<Vulnerability {} (cvssv3: {})>'.format(
            self.cve_id, self.cvssv3)

    def __eq__(self, other):
        return self.cve_id == other.cve_id

    def __lt__(self, other):
        """Order CVEs numerically not lexicographically."""
        (_, s_year, s_id) = self.cve_id.split('-')
        (_, o_year, o_id) = other.cve_id.split('-')
        return (int(s_year), int(s_id)) < (int(o_year), int(o_id))

    def __hash__(self):
        return hash(self.cve_id)

    @classmethod
    def parse(cls, item):
        """Create Vulnerability object from NVD JSON snippet."""
        res = cls(item['cve']['CVE_data_meta']['ID'])
        if 'configurations' in item:
            res.nodes = Node.parse(item['configurations'].get('nodes', []))
        if haskeys(item, 'impact', 'baseMetricV3', 'cvssV3', 'baseScore'):
            res.cvssv3 = float(
                item['impact']['baseMetricV3']['cvssV3']['baseScore'])
        if haskeys(item, 'impact', 'baseMetricV2', 'cvssV2', 'baseScore'):
            res.cvssv2 = float(
                item['impact']['baseMetricV2']['cvssV2']['baseScore'])
        return res

    def match(self, pname, pvers):
        """Returns True if package version is covered by any node."""
        for n in self.nodes:
            if not n.product == pname:
                continue
            if isinstance(n.version, str) and n.version == pvers:
                return True
            elif (isinstance(n.version, list) and
                    all(in_range(pvers, v) for v in n.version)):
                return True
        return False


class Node:
    """CPE configuration item.

    Node uses a simplified matching logic:

    - Don't try to grok the AND/OR logic. NVD data seems to be illogical
      or incomplete in many cases. We accept false positives instead.

    - Currently, vendor strings are ignored completely while matching.
      This may change in a future version.
    """

    vendor = None
    product = None
    version = None

    def __init__(self, vendor, product, version=None):
        self.vendor = vendor
        self.product = product
        self.version = version or []

    @classmethod
    def parse(cls, nodes):
        res = []
        for node in nodes:
            res += cls.parse_matches(node.get('cpe_match', []))
            res += cls.parse(node.get('children', []))
        return res

    R_UNQUOTE = re.compile(r'(\\:)+')

    @classmethod
    def parse_matches(cls, cpe_match):
        nodes = []
        for expr in cpe_match:
            if expr.get('vulnerable') is not True:
                continue
            if 'cpe23Uri' not in expr:
                continue
            cpe23Uri = cls.R_UNQUOTE.sub('-', expr['cpe23Uri'])
            (cpe, cpevers, typ, vendor, product, vers, rev, _) = \
                cpe23Uri.split(':', 7)
            if cpe != 'cpe' or cpevers != '2.3' or typ != 'a':
                continue
            e = cls(vendor, product)
            if vers and vers != '*' and vers != '-':
                if rev and rev != '*' and rev != '-':
                    vers = vers + '-' + rev
                # Exact match: Change self.version to a string with a single
                # version. Doing this, future attempts to apppend() will fail.
                e.version = str(vers)
            else:
                if 'versionStartIncluding' in expr:
                    e.version.append('>=' + expr['versionStartIncluding'])
                if 'versionStartExcluding' in expr:
                    e.version.append('>' + expr['versionStartExcluding'])
                if 'versionEndIncluding' in expr:
                    e.version.append('<=' + expr['versionEndIncluding'])
                if 'versionEndExcluding' in expr:
                    e.version.append('<' + expr['versionEndExcluding'])
            if e.version:
                # no point adding an expr without any version match
                nodes.append(e)
        # dedup strings
        if len(nodes) > 1:
            first = nodes[0]
            for n in nodes[1:]:
                if n.vendor == first.vendor:
                    n.vendor = first.vendor
                if n.product == first.product:
                    n.product = first.product
        return nodes

    def __eq__(self, other):
        return (self.vendor == other.vendor and
                self.product == other.product and
                isinstance(self.version, type(other.version)) and
                self.version == other.version)

    def __repr__(self):
        return '<Node {}:{}, {}>'.format(
            self.vendor, self.product, self.version)


def in_range(pvers, spec):
    """Returns True if package version `pvers` complies with `spec`."""
    if spec[1] == '=':
        if pvers == spec[2:]:
            return True
        idx = 2
    else:
        idx = 1
    if spec[0] == '>':
        return compare_versions(pvers, spec[idx:]) == 1
    elif spec[0] == '<':
        return compare_versions(pvers, spec[idx:]) == -1
    raise ValueError('Invalid version spec', spec)
