# Copyright (C) 2019 NTT DATA
# All Rights Reserved.
#
#    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.

from copy import deepcopy
import hashlib
import os
import re
import shutil
from urllib.parse import urlparse
from urllib import request as urllib2
import yaml
import zipfile

from oslo_log import log as logging
from oslo_utils import encodeutils
from oslo_utils import excutils
from tacker.common import utils
from toscaparser.prereq.csar import CSAR
from toscaparser.tosca_template import ToscaTemplate

from tacker.common import exceptions
import tacker.conf
from tacker.extensions import vnfm


HASH_DICT = {
    'sha-224': hashlib.sha224,
    'sha-256': hashlib.sha256,
    'sha-384': hashlib.sha384,
    'sha-512': hashlib.sha512
}
CONF = tacker.conf.CONF
LOG = logging.getLogger(__name__)
TOSCA_META = 'TOSCA-Metadata/TOSCA.meta'
ARTIFACT_KEYS = ['Source', 'Algorithm', 'Hash']
IMAGE_FORMAT_LIST = ['raw', 'vhd', 'vhdx', 'vmdk', 'vdi', 'iso', 'ploop',
                   'qcow2', 'aki', 'ari', 'ami', 'img']


def _check_type(custom_def, node_type, type_list):
    for node_data_type, node_data_type_value in custom_def.items():
        if node_data_type == node_type and node_type in type_list:
            return True, node_data_type_value
        for k, v in node_data_type_value.items():
            if k == 'derived_from':
                if v in type_list and node_type == node_data_type:
                    return True, node_data_type_value
    return False, None


def _get_sw_image_artifact(artifacts):
    if not artifacts:
        return

    for artifact_value in artifacts.values():
        if isinstance(artifact_value, dict):
            if artifact_value.get('type') == 'tosca.artifacts.nfv.SwImage':
                return artifact_value
        elif isinstance(artifact_value, str):
            return {'file': artifact_value}


def _update_default_vnfd_data(node_value, node_type_value):
    vnf_properties = node_value['properties']
    type_properties = node_type_value['properties']
    for property_key, property_value in type_properties.items():
        if property_key == 'descriptor_id':
            # if descriptor_id is parameterized, then get the value from the
            # default property and set it in the vnf_properties.
            if vnf_properties and isinstance(
                    vnf_properties.get('descriptor_id'), dict):
                vnf_properties['descriptor_id'] = property_value.get("default")
    return vnf_properties


def _get_vnf_data(nodetemplates):
    type_list = ['tosca.nodes.nfv.VNF']
    for nt in nodetemplates:
        for node_name, node_value in nt.templates.items():
            type_status, node_type_value = _check_type(nt.custom_def,
                                            node_value['type'], type_list)
            if type_status and node_type_value:
                return _update_default_vnfd_data(node_value, node_type_value)


def _get_instantiation_levels(policies):
    if policies:
        for policy in policies:
            if policy.type_definition.type == \
                    'tosca.policies.nfv.InstantiationLevels':
                return policy.properties


def _update_flavour_data_from_vnf(custom_defs, node_tpl, flavour):
    type_list = ['tosca.nodes.nfv.VNF']

    type_status, _ = _check_type(custom_defs, node_tpl['type'], type_list)
    if type_status and node_tpl['properties']:
        vnf_properties = node_tpl['properties']
        if 'flavour_description' in vnf_properties:
            flavour.update(
                {'flavour_description': vnf_properties[
                    'flavour_description']})

        if 'flavour_id' in vnf_properties and isinstance(
                vnf_properties['flavour_id'], str):
            flavour.update({'flavour_id': vnf_properties['flavour_id']})


def _get_software_image(custom_defs, nodetemplate_name, node_tpl):
    type_list = ['tosca.nodes.nfv.Vdu.Compute',
                 'tosca.nodes.nfv.Vdu.VirtualBlockStorage']
    type_status, _ = _check_type(custom_defs, node_tpl['type'], type_list)
    if type_status:
        properties = node_tpl['properties']
        sw_image_artifact = _get_sw_image_artifact(node_tpl.get('artifacts'))
        if sw_image_artifact:
            image_path = sw_image_artifact['file'].lstrip("./")
            properties['sw_image_data'].update(
                {'software_image_id': nodetemplate_name,
                'image_path': image_path})
            sw_image_data = properties['sw_image_data']
            _convert_software_images_prop_to_fixed_unit(sw_image_data)

            if 'metadata' in sw_image_artifact:
                sw_image_data.update({'metadata':
                    sw_image_artifact['metadata']})
            return sw_image_data


def _convert_software_images_prop_to_fixed_unit(sw_image_data):
    """Update values of 'min_disk', 'min_ram' and 'size' to Bytes.

    Since, the units like MB/MiB/GB/GiB is not stored in the database, we need
    to convert 'min_disk', 'min_ram' and 'size' values to fixed unit before
    saving it in database. Here its converting 'min_disk', 'min_ram', and
    'size' values to Bytes.
    """
    for attribute in ['min_disk', 'min_ram', 'size']:
        if sw_image_data.get(attribute):
            updated_value = utils.MemoryUnit.convert_unit_size_to_num(
                sw_image_data.get(attribute),
                unit='B')
            sw_image_data[attribute] = updated_value


def _populate_flavour_data(tosca):
    flavours = []
    if tosca.nested_tosca_templates_with_topology:
        for tp in tosca.nested_tosca_templates_with_topology:
            sw_image_list = []

            # Setting up flavour data
            flavour_id = tp.substitution_mappings.properties.get('flavour_id')
            if flavour_id:
                flavour = {'flavour_id': flavour_id}
                tpl_dict = dict()

                # get from top-vnfd data
                for key, value in tosca.tpl.items():
                    if key in CONF.vnf_package.get_top_list:
                        tpl_dict[key] = value

                # get from lower-vnfd data
                tpl_dict['topology_template'] = dict()
                tpl_dict['topology_template']['groups'] = \
                    tp.tpl.get('groups')
                tpl_dict['topology_template']['policies'] = \
                    tp.tpl.get('policies')
                tpl_dict['topology_template']['node_templates'] = \
                    deepcopy(tp.tpl.get('node_templates'))
                for e_node in CONF.vnf_package.exclude_node:
                    if tpl_dict['topology_template']['node_templates'].\
                            get(e_node):
                        del (tpl_dict['topology_template']
                            ['node_templates'][e_node])
                tpl_dict['topology_template']['inputs'] = \
                    deepcopy(tp.tpl.get('inputs'))
                for del_input in CONF.vnf_package.del_input_list:
                    if tpl_dict['topology_template']['inputs'].get(del_input):
                        del tpl_dict['topology_template']['inputs'][del_input]
                if len(tpl_dict['topology_template']['inputs']) < 1:
                    del tpl_dict['topology_template']['inputs']

                flavour.update({'tpl_dict': tpl_dict})

                instantiation_levels = _get_instantiation_levels(tp.policies)
                if instantiation_levels:
                    flavour.update(
                        {'instantiation_levels': instantiation_levels})

                mgmt_driver = None
                for template_name, node_tpl in \
                        tp.tpl.get('node_templates').items():
                    # check the flavour property in vnf data
                    _update_flavour_data_from_vnf(
                        tp.custom_defs, node_tpl, flavour)
                    if node_tpl['type'] in CONF.vnf_package.get_lower_list:
                        if node_tpl['type'] == "tosca.nodes.nfv.VDU.Tacker":
                            # get mgmt_driver
                            mgmt_driver_flavour = \
                                node_tpl['properties'].get('mgmt_driver')
                            if mgmt_driver_flavour:
                                if mgmt_driver and \
                                        mgmt_driver_flavour != mgmt_driver:
                                    raise vnfm.MultipleMGMTDriversSpecified()
                                mgmt_driver = mgmt_driver_flavour
                                flavour.update({'mgmt_driver': mgmt_driver})

                for template_name, node_tpl in \
                        tp.tpl.get('node_templates').items():
                    # Update the software image data
                    sw_image = _get_software_image(tp.custom_defs,
                                                   template_name,
                                                   node_tpl)
                    if sw_image:
                        sw_image_list.append(sw_image)

                # Add software images for flavour
                if sw_image_list:
                    flavour.update({'sw_images': sw_image_list})

                if flavour:
                    flavours.append(flavour)

    else:
        _get_flavour_data(tosca.topology_template, flavours)

    return flavours


def _get_flavour_data(tp, flavours):
    sw_image_list = []

    # Setting up flavour data
    flavour_id = tp.substitution_mappings.properties.get('flavour_id')
    if flavour_id:
        if isinstance(flavour_id, dict):
            error_msg = "flavour_id should be string and given" \
                        " {}".format(flavour_id)
            raise exceptions.InvalidCSAR(error_msg)
        flavour = {'flavour_id': flavour_id}
    else:
        flavour = {}
    instantiation_levels = _get_instantiation_levels(tp.policies)
    if instantiation_levels:
        flavour.update({'instantiation_levels': instantiation_levels})
    for template_name, node_tpl in tp.tpl.get('node_templates').items():
        # check the flavour property in vnf data
        _update_flavour_data_from_vnf(tp.custom_defs, node_tpl, flavour)

        # Update the software image data
        sw_image = _get_software_image(tp.custom_defs, template_name,
                                       node_tpl)
        if sw_image:
            sw_image_list.append(sw_image)

    # Add software images for flavour
    if sw_image_list:
        flavour.update({'sw_images': sw_image_list})

    if flavour:
        flavours.append(flavour)


def _get_instantiation_levels_from_policy(tpl_policies):
    """Get defined instantiation levels

    Getting instantiation levels defined under policy type
    'tosca.policies.nfv.InstantiationLevels'.
    """

    levels = []
    for policy in tpl_policies:
        for key, value in policy.items():
            if value.get('type') == 'tosca.policies.nfv.InstantiationLevels'\
                    and value.get('properties', {}).get('levels', {}):
                levels = value.get('properties').get('levels').keys()
                default_level = value.get(
                    'properties').get('default_level')

                if default_level and default_level not in levels:
                    error_msg = "Level {} not found in defined levels" \
                                " {}".format(default_level,
                                             ",".join(sorted(levels)))
                    raise exceptions.InvalidCSAR(error_msg)
    return levels


def _validate_instantiation_levels(policy, instantiation_levels):
    expected_policy_type = ['tosca.policies.nfv.VduInstantiationLevels',
                            'tosca.policies.nfv.'
                            'VirtualLinkInstantiationLevels']
    for policy_name, policy_tpl in policy.items():
        if policy_tpl.get('type') not in expected_policy_type:
            return

        if not instantiation_levels:
            msg = ('Policy of type'
                   ' "tosca.policies.nfv.InstantiationLevels is not defined.')
            raise exceptions.InvalidCSAR(msg)
        if policy_tpl.get('properties'):
            levels_in_policy = policy_tpl.get(
                'properties').get('levels')

            if levels_in_policy:
                invalid_levels = set(levels_in_policy.keys()) - set(
                    instantiation_levels)
            else:
                invalid_levels = set()

            if invalid_levels:
                error_msg = "Level(s) {} not found in defined levels" \
                            " {}".format(",".join(sorted(invalid_levels)),
                                         ",".join(sorted(instantiation_levels)
                                                  ))
                raise exceptions.InvalidCSAR(error_msg)


def _validate_sw_image_data_for_artifact(node_tpl, template_name):
    artifact_names = []
    artifacts = node_tpl.get('artifacts')
    if not artifacts:
        return

    for key, value in artifacts.items():
        if isinstance(value, dict):
            if value.get('type') == 'tosca.artifacts.nfv.SwImage':
                artifact_names.append(key)
        elif isinstance(value, str):
            artifact_names.append(key)

    if len(artifact_names) > 1:
        error_msg = ('artifacts of type "tosca.artifacts.nfv.SwImage"'
                     ' is added more than one time for'
                     ' node %(node)s.') % {'node': template_name}
        raise exceptions.InvalidCSAR(error_msg)

    if artifact_names and node_tpl.get('properties'):
        if not node_tpl.get('properties').get('sw_image_data'):
            error_msg = ('Node property "sw_image_data" is missing for'
                         ' artifact %(artifact_name)s for node %(node)s.') % {
                'artifact_name': artifact_names[0], 'node': template_name}
            raise exceptions.InvalidCSAR(error_msg)


def _validate_sw_image_data_for_artifacts(tosca):
    for tp in tosca.nested_tosca_templates_with_topology:
        for template_name, node_tpl in tp.tpl.get('node_templates').items():
            _validate_sw_image_data_for_artifact(node_tpl, template_name)

    for template in tosca.nodetemplates:
        _validate_sw_image_data_for_artifact(
            template.entity_tpl, template.name)


def _get_data_from_csar(tosca, context, id):
    for tp in tosca.nested_tosca_templates_with_topology:
        policies = tp.tpl.get("policies")
        if policies:
            levels = _get_instantiation_levels_from_policy(policies)
            for policy_tpl in policies:
                _validate_instantiation_levels(policy_tpl, levels)

    _validate_sw_image_data_for_artifacts(tosca)
    vnf_data = _get_vnf_data(tosca.nodetemplates)
    if not vnf_data:
        error_msg = "VNF properties are mandatory"
        raise exceptions.InvalidCSAR(error_msg)

    flavours = _populate_flavour_data(tosca)
    if not flavours:
        error_msg = "No VNF flavours are available"
        raise exceptions.InvalidCSAR(error_msg)

    csar = CSAR(tosca.input_path, tosca.a_file)
    vnf_artifacts = []
    if csar.validate():
        vnf_artifacts = _get_vnf_artifacts(csar)

    return vnf_data, flavours, vnf_artifacts


def _get_vnf_artifacts(csar):
    vnf_artifacts = []
    if csar.is_tosca_metadata:
        if csar._get_metadata("ETSI-Entry-Manifest"):
            manifest_path = csar._get_metadata("ETSI-Entry-Manifest")
            if manifest_path.lower().endswith(".mf"):
                manifest_data = csar.zfile.read(manifest_path)
                vnf_artifacts = _convert_artifacts(
                    vnf_artifacts, manifest_data, csar)
            else:
                invalid_manifest_err_msg = (
                    ('The file "%(manifest)s" in the CSAR "%(csar)s" does not '
                     'contain valid manifest.') %
                    {'manifest': manifest_path, 'csar': csar.path})
                raise exceptions.InvalidCSAR(invalid_manifest_err_msg)
        tosca_data = csar.zfile.read(TOSCA_META)
        vnf_artifacts = _convert_artifacts(vnf_artifacts, tosca_data, csar)

    else:
        filelist = csar.zfile.namelist()
        main_template_file_name = os.path.splitext(
            csar.main_template_file_name)[0]
        for path in filelist:
            if path.lower().endswith(".mf"):
                manifest_file_name = os.path.splitext(path)[0]
                if manifest_file_name == main_template_file_name:
                    manifest_data = csar.zfile.read(path)
                    vnf_artifacts = _convert_artifacts(
                        vnf_artifacts, manifest_data, csar)
                else:
                    invalid_manifest_err_msg = \
                        (('The filename "%(manifest)s" is an invalid name.'
                          'The name must be the same as the main template '
                          'file name.') %
                        {'manifest': path})
                    raise exceptions.InvalidCSAR(invalid_manifest_err_msg)
    # Deduplication
    vnf_artifacts = [dict(t) for t in set([tuple(d.items())
                          for d in vnf_artifacts])]
    return vnf_artifacts


def _convert_artifacts(vnf_artifacts, artifacts_data, csar):
    artifacts_data_split = re.split(b'\n\n+', artifacts_data)

    for data in artifacts_data_split:
        if re.findall(b'.?Name:.?|.?Source:.?|', data):
            # validate key's existence
            if re.findall(b'.?Algorithm:.?|.?Hash:.?', data):
                artifact_data_dict = yaml.safe_load(data)
                if 'Name' in artifact_data_dict.keys():
                    artifact_data_dict.update(
                        {"Source": artifact_data_dict.pop("Name")})
                if 'Content-Type' in artifact_data_dict.keys():
                    del artifact_data_dict['Content-Type']
                if sorted(ARTIFACT_KEYS) != sorted(artifact_data_dict.keys()):
                    missing_key = list(set(ARTIFACT_KEYS) ^
                                       set(artifact_data_dict.keys()))
                    missing_key = sorted(missing_key)
                    invalid_artifact_err_msg = \
                        (('One of the artifact information '
                          'may not have the key("%(key)s")') %
                         {'key': missing_key})
                    raise exceptions.InvalidCSAR(invalid_artifact_err_msg)
                # validate value's existence
                for key, value in artifact_data_dict.items():
                    if not value:
                        invalid_artifact_err_msg = \
                            (('One of the artifact information may not have '
                              'the key value("%(key)s")') % {'key': key})
                        raise exceptions.InvalidCSAR(invalid_artifact_err_msg)
                artifact_path = artifact_data_dict.get('Source')
                if os.path.splitext(artifact_path)[-1][1:] \
                        in IMAGE_FORMAT_LIST:
                    continue
                else:
                    algorithm = artifact_data_dict.get('Algorithm')
                    hash_code = artifact_data_dict.get('Hash')
                    result = _validate_hash(algorithm, hash_code,
                                            csar, artifact_path)
                    if result:
                        vnf_artifacts.append(artifact_data_dict)
                    else:
                        invalid_artifact_err_msg = \
                            (('The hash "%(hash)s" of artifact file '
                              '"%(artifact)s" is an invalid value.') %
                             {'hash': hash_code, 'artifact': artifact_path})
                        raise exceptions.InvalidCSAR(invalid_artifact_err_msg)

    return vnf_artifacts


def _validate_hash(algorithm, hash_code, csar, artifact_path):
    z = zipfile.ZipFile(csar.path)
    algorithm = algorithm.lower()

    # validate Algorithm's value
    if algorithm in HASH_DICT.keys():
        hash_obj = HASH_DICT[algorithm]()
    else:
        invalid_artifact_err_msg = (('The algorithm("%(algorithm)s") of '
                                     'artifact("%(artifact_path)s") is '
                                     'an invalid value.') %
                                    {'algorithm': algorithm,
                                     'artifact_path': artifact_path})
        raise exceptions.InvalidCSAR(invalid_artifact_err_msg)
    filelist = csar.zfile.namelist()

    # validate Source's value
    if artifact_path in filelist:
        hash_obj.update(z.read(artifact_path))
    elif ((urlparse(artifact_path).scheme == 'file') or
          (bool(urlparse(artifact_path).scheme) and
           bool(urlparse(artifact_path).netloc))):
        hash_obj.update(urllib2.urlopen(artifact_path).read())
    else:
        invalid_artifact_err_msg = (('The path("%(artifact_path)s") of '
                                     'artifact Source is an invalid value.') %
                                    {'artifact_path': artifact_path})
        raise exceptions.InvalidCSAR(invalid_artifact_err_msg)

    # validate Hash's value
    if hash_code == hash_obj.hexdigest():
        return True
    else:
        return False


def extract_csar_zip_file(file_path, extract_path):
    try:
        with PreserveZipFilePermissions(file_path, 'r') as zf:
            zf.extractall(extract_path)
    except (RuntimeError, zipfile.BadZipfile) as exp:
        with excutils.save_and_reraise_exception():
            LOG.error("Error encountered while extracting "
                      "csar zip file %(path)s. Error: %(error)s.",
                      {'path': file_path,
                      'error': encodeutils.exception_to_unicode(exp)})
            exp.reraise = False
            raise exceptions.InvalidZipFile(path=file_path)


def load_csar_data(context, package_uuid, zip_path):

    extract_zip_path = os.path.join(CONF.vnf_package.vnf_package_csar_path,
                                    package_uuid)
    extract_csar_zip_file(zip_path, extract_zip_path)

    try:
        tosca = ToscaTemplate(zip_path, None, True)
        return _get_data_from_csar(tosca, context, package_uuid)
    except exceptions.InvalidCSAR as exp:
        with excutils.save_and_reraise_exception():
            LOG.error("Error processing CSAR file %(path)s for vnf package"
                      " %(uuid)s: Error: %(error)s. ",
                      {'path': zip_path, 'uuid': package_uuid,
                    'error': encodeutils.exception_to_unicode(exp)})
    except Exception as exp:
        with excutils.save_and_reraise_exception():
            LOG.error("Tosca parser failed for vnf package %(uuid)s: "
                      "Error: %(error)s. ", {'uuid': package_uuid,
                      'error': encodeutils.exception_to_unicode(exp)})
            exp.reraise = False
            raise exceptions.InvalidCSAR(encodeutils.exception_to_unicode
                                         (exp))


def delete_csar_data(package_uuid):
    # Remove zip and folder from the vnf_package_csar_path
    csar_zip_temp_path = os.path.join(CONF.vnf_package.vnf_package_csar_path,
                                      package_uuid)
    csar_path = os.path.join(CONF.vnf_package.vnf_package_csar_path,
                 package_uuid + ".zip")

    try:
        shutil.rmtree(csar_zip_temp_path)
        os.remove(csar_path)
    except OSError as exc:
        exc_message = encodeutils.exception_to_unicode(exc)
        msg = _('Failed to delete csar folder: '
                '%(csar_path)s, Error: %(exc)s')
        LOG.warning(msg, {'csar_path': csar_path, 'exc': exc_message})


class PreserveZipFilePermissions(zipfile.ZipFile):
    """Patched _extract_member function of zipFile.

    zipfile.ZipFile.extractall function internally calls
    _extract_member function.
    Here _extract_member function is patched to retain the
    file permissions using member.external_attr >> 16.

    Note: First 16 bits of external_attr store permission details.
    """
    def _extract_member(self, member, targetpath, pwd):
        if not isinstance(member, zipfile.ZipInfo):
            member = self.getinfo(member)

        targetpath = super()._extract_member(member, targetpath, pwd)

        attr = member.external_attr >> 16
        if attr != 0:
            os.chmod(targetpath, attr)
        return targetpath
