# Copyright 2019 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import re

import sqlakeyset  # type: ignore

from sqlakeyset import get_page
from sqlakeyset.columns import OC  # type: ignore
from sqlakeyset.results import Paging  # type: ignore
from sqlalchemy import func, desc, or_, asc, exc
from sqlalchemy.orm import class_mapper, Mapper
from flask import request
from flask_sqlalchemy import Pagination  # type: ignore

from app.vulnerability.views.vulnerability import VulnerabilityView
from app.vulnerability.views.details import VulnerabilityDetails

from data.database import DEFAULT_DATABASE
from data.models.nvd import default_nvd_view_options
from data.models import (Description, Nvd, Vulnerability,
                         RepositoryFileComments, User, RepositoryFileMarkers)
from data.models.vulnerability import VulnerabilityState
from lib.utils import parse_pagination_param

db = DEFAULT_DATABASE


def custom_value_from_thing(thing, desc, ocol):  # pylint: disable=redefined-outer-name
    """
    Replacement function for the sqlakeyset value_from_thing method.
    This function supports auto aliasing as is required when sorting by
    columns that appear in subqueries.
    :param thing:
    :param desc:
    :param ocol:
    :return:
    """
    entity = desc['entity']
    expr = desc['expr']

    try:
        is_a_table = entity == expr
    except exc.ArgumentError:
        is_a_table = False

    if isinstance(expr, Mapper) and expr.class_ == entity:
        # Is a table mapper. Just treat as a table.
        is_a_table = True

    if is_a_table is True:  # is a table
        mapper = class_mapper(desc['type'])
        base_elem = ocol.element.base_columns
        if not base_elem:
            base_elem = ocol.element
        elif len(base_elem) == 1:
            base_elem = next(iter(base_elem))
        else:
            raise ValueError("Multiple columns")

        # If the OCOL is a hybrid attribute it won't belong to one table.
        if not hasattr(base_elem, 'table'):
            raise ValueError

        order_column_table = base_elem.table.name

        if entity.__table__.name == order_column_table:
            prop = mapper.get_property_by_column(base_elem)
            return getattr(thing, prop.key)
        raise ValueError

    # is an attribute
    if hasattr(expr, 'info'):
        mapper = expr.parent
        tname = mapper.local_table.description

        if ocol.table_name == tname and ocol.name == expr.name:
            return thing
        raise ValueError

    ocol_quoted_full_name = re.sub('[()]', '', ocol.quoted_full_name)
    # is an attribute with label
    if ocol_quoted_full_name == OC(expr).full_name:
        return thing
    raise ValueError


class VulncodeDB:
    def __init__(self):

        self.keyword = None
        self.top_contributors = []
        # TODO: Look into neabling this once public contributions are enabled.
        # self.fetch_top_contributors()

        has_annotations_col = Vulnerability.has_annotations
        vcdb_entries = db.session.query(Vulnerability, Nvd,
                                        has_annotations_col)
        vcdb_entries = vcdb_entries.filter(
            Vulnerability.state == VulnerabilityState.PUBLISHED)
        vcdb_entries = vcdb_entries.outerjoin(
            Nvd, Vulnerability.cve_id == Nvd.cve_id)
        vcdb_entries = vcdb_entries.options(default_nvd_view_options)
        vcdb_entries = vcdb_entries.from_self()
        vcdb_entries = vcdb_entries.order_by(desc(has_annotations_col),
                                             asc(Vulnerability.date_created),
                                             desc(Vulnerability.id))
        self.vcdb_entries = vcdb_entries

        nvd_entries = db.session.query(Nvd)
        nvd_entries = nvd_entries.outerjoin(Vulnerability,
                                            Nvd.cve_id == Vulnerability.cve_id)
        nvd_entries = nvd_entries.options(default_nvd_view_options)
        nvd_entries = nvd_entries.filter(Vulnerability.cve_id.is_(None))
        nvd_entries = nvd_entries.order_by(desc(Nvd.published_date),
                                           desc(Nvd.id))
        self.nvd_entries = nvd_entries

        self.keyword = request.args.get("keyword", None, type=str)

        apply_filter = None
        if self.keyword:
            # TODO: Make the filtering work with fulltext search as well.
            if VulnerabilityDetails.is_cve_id(self.keyword):
                apply_filter = or_(False, Nvd.cve_id == self.keyword)
            elif VulnerabilityDetails.is_vcdb_id(self.keyword):
                apply_filter = or_(False, Vulnerability.id == self.keyword)
            else:
                escaped_keyword = self.keyword.replace("%", "")
                # escaped_keyword = re.sub('[\W]+', ' ', self.keyword)
                # Attention: We can't use FullText search here because of some
                # buggy Mysql 5.7 behavior (using FullText on Join results seems
                # is doing bad things. We might need to apply the filter before
                # joining below.
                # apply_filter = or_(
                #     FullTextSearch(escaped_keyword, Nvd,
                #                    FullTextMode.BOOLEAN),
                #     FullTextSearch(escaped_keyword, Vulnerability,
                #                    FullTextMode.BOOLEAN))
                apply_filter = or_(
                    Nvd.descriptions.any(
                        Description.value.like("%" + escaped_keyword + "%")),
                    Vulnerability.comment.like("%" + escaped_keyword + "%"),
                )

            # TODO: add product search support.
            # apply_filter = or_(apply_filter, Cpe.product == keyword)

        if apply_filter is not None:
            self.vcdb_entries = self.vcdb_entries.filter(apply_filter)
            self.nvd_entries = self.nvd_entries.filter(apply_filter)

        per_page = 7
        vcdb_bookmarked_page = parse_pagination_param("vcdb_p")
        # Replace a sqlakeyset function to support our use case.
        # TODO: File a PR for this?
        sqlakeyset.paging.value_from_thing = custom_value_from_thing
        self.vcdb_pagination = get_page(self.vcdb_entries,
                                        per_page,
                                        page=vcdb_bookmarked_page)
        self.vcdb_pagination = VulnViewTypesetPaginationObjectWrapper(
            self.vcdb_pagination.paging)
        num_vuln_entries = db.session.query(func.count(
            Vulnerability.id)).scalar()
        self.vcdb_pagination.set_total(num_vuln_entries)

        nvd_bookmarked_page = parse_pagination_param("nvd_p")
        self.nvd_pagination = get_page(self.nvd_entries,
                                       per_page,
                                       page=nvd_bookmarked_page)
        self.nvd_pagination = VulnViewTypesetPaginationObjectWrapper(
            self.nvd_pagination.paging)
        num_nvd_entries = db.session.query(func.count(Nvd.id)).scalar()
        num_unique_nvd_estimate = num_nvd_entries - num_vuln_entries
        self.nvd_pagination.set_total(num_unique_nvd_estimate)

    def fetch_top_contributors(self):
        # TODO: count number of contributions to vulnerabilities instead of
        # single annotations
        num_comments = self.get_annotation_query(RepositoryFileComments)
        num_markers = self.get_annotation_query(RepositoryFileMarkers)
        num_both = num_comments.c.count + num_markers.c.count
        self.top_contributors = (db.session.query(
            User,
            func.coalesce(num_comments.c.count, 0).label("num_comments"),
            func.coalesce(num_markers.c.count, 0).label("num_markers"),
        ).outerjoin(
            num_comments, num_comments.c.creator_id == User.id).outerjoin(
                num_markers, num_markers.c.creator_id == User.id).filter(
                    num_both > 0).order_by(num_both.desc()).limit(10).all())

    @staticmethod
    def get_annotation_query(model):
        return (db.session.query(
            model.creator_id.label("creator_id"),
            func.count(1).label("count")).filter_by(active=True).group_by(
                model.creator_id).subquery())


def wrap_entries(target_entries):
    """
    Wraps all Vulnerability/Nvd entries into the VulnerabilityView class.
    :return:
    """
    new_entries = []
    if not target_entries:
        return

    first_element = target_entries[0]
    if isinstance(first_element, Nvd):
        for nvd in target_entries:
            vuln_view = VulnerabilityView(None, nvd, preview=True)
            new_entries.append(vuln_view)
    elif isinstance(first_element, Vulnerability):
        for vulnerability in target_entries:
            vuln_view = VulnerabilityView(vulnerability, None, preview=True)
            new_entries.append(vuln_view)
    else:
        for columns in target_entries:
            vulnerability = columns[0]
            nvd = columns[1]
            if len(columns) > 2 and isinstance(columns[2], bool):
                annotation_exists = columns[2]
                vulnerability.set_has_annotations(annotation_exists)

            vuln_view = VulnerabilityView(vulnerability, nvd, preview=True)
            new_entries.append(vuln_view)

    del target_entries[:]
    target_entries.extend(new_entries)


class VulnViewTypesetPaginationObjectWrapper(Paging):
    """
    An sqlakeyset Paging object wrapper class which wraps Vuln/Nvd rows
    inside a VulnView.
    """
    def __init__(self, paginationObject):  # pylint: disable=super-init-not-called
        """
        :param paginationObject: A Flask SQLalchemy Pagination object.
        """
        self.__class__ = type(
            paginationObject.__class__.__name__,
            (self.__class__, paginationObject.__class__),
            {},
        )
        self.__dict__ = paginationObject.__dict__
        self.total = 0
        wrap_entries(self.rows)

    def set_total(self, new_total):
        self.total = new_total


class VulnViewSqlalchemyPaginationObjectWrapper(Pagination):
    """
    A Flask SQLAlchemy Pagination object wrapper class which wraps Vuln/Nvd rows
    inside a VulnView.
    """
    def __init__(self, paginationObject):  # pylint: disable=super-init-not-called
        """
        :param paginationObject: A Flask SQLalchemy Pagination object.
        """
        self.__class__ = type(
            paginationObject.__class__.__name__,
            (self.__class__, paginationObject.__class__),
            {},
        )
        self.__dict__ = paginationObject.__dict__
        wrap_entries(self.items)
