#    Copyright (c) 2014 Mirantis, Inc.
#
#
#    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 cgi
import os
import tempfile

import jsonschema
from keystoneclient import exceptions as keystone_ex
from keystoneclient import service_catalog
from oslo_config import cfg
from oslo_db import exception as db_exc
from oslo_log import log as logging
from webob import exc

import murano.api.v1
from murano.api.v1 import validation_schemas
from murano.common import exceptions
from murano.common.i18n import _
from murano.common import policy
import murano.common.utils as murano_utils
from murano.common import wsgi
from murano.db.catalog import api as db_api
from murano.packages import exceptions as pkg_exc
from murano.packages import load_utils
from muranoclient.glance import client as glare_client


LOG = logging.getLogger(__name__)
CONF = cfg.CONF

SUPPORTED_PARAMS = murano.api.v1.SUPPORTED_PARAMS
LIST_PARAMS = murano.api.v1.LIST_PARAMS
ORDER_VALUES = murano.api.v1.ORDER_VALUES
PKG_PARAMS_MAP = murano.api.v1.PKG_PARAMS_MAP
OPERATOR_VALUES = murano.api.v1.OPERATOR_VALUES


def _check_content_type(req, content_type):
    try:
        req.get_content_type((content_type,))
    except exceptions.UnsupportedContentType:
        msg = _("Content-Type must be '{type}'").format(type=content_type)
        LOG.error(msg)
        raise exc.HTTPBadRequest(explanation=msg)


def _get_filters(query_params):
    filters = {}
    for param_pair in query_params:
        k, v = param_pair
        if k not in SUPPORTED_PARAMS:
            LOG.warning("Search by parameter '{name}' "
                        "is not supported. Skipping it.".format(name=k))
            continue

        if k in LIST_PARAMS:
            if v.startswith('in:') and k in OPERATOR_VALUES:
                in_value = v[len('in:'):]
                try:
                    filters[k] = murano_utils.split_for_quotes(in_value)
                except ValueError as err:
                    LOG.warning("Search by parameter '{name}' "
                                "caused an {message} error."
                                "Skipping it.".format(name=k,
                                                      message=err))
            else:
                filters.setdefault(k, []).append(v)
        else:
            filters[k] = v
        order_by = filters.get('order_by', [])
        for i in order_by[:]:
            if ORDER_VALUES and i not in ORDER_VALUES:
                filters['order_by'].remove(i)
                LOG.warning("Value of 'order_by' parameter is not valid. "
                            "Allowed values are: {values}. Skipping it."
                            .format(values=", ".join(ORDER_VALUES)))
    return filters


def _validate_body(body):
    """Check multipart/form-data has two parts

    Check multipart/form-data has two parts: text (which is json string and
    should parsed into dictionary in serializer) and file, which stores as
    cgi.FieldStorage instance. Also validate file size doesn't exceed
    the limit: seek to the end of the file, get the position of EOF and
    reset the file position to the beginning
    """
    def check_file_size(f):
        mb_limit = CONF.murano.package_size_limit
        pkg_size_limit = mb_limit * 1024 * 1024
        f.seek(0, 2)
        size = f.tell()
        f.seek(0)
        if size > pkg_size_limit:
            raise exc.HTTPBadRequest(explanation=_(
                'Uploading file is too large. '
                'The limit is {0} Mb').format(mb_limit))

    if len(body) > 2:
        msg = _("'multipart/form-data' request body should contain 1 or 2 "
                "parts: json string and zip archive. Current body consists "
                "of {amount} part(s)").format(amount=len(body.keys()))
        LOG.error(msg)
        raise exc.HTTPBadRequest(explanation=msg)

    file_obj = None
    package_meta = None
    for part in body.values():
        if isinstance(part, cgi.FieldStorage):
            file_obj = part
            check_file_size(file_obj.file)

        if isinstance(part, dict):
            package_meta = part
    if file_obj is None:
        msg = _('There is no file package with application description')
        LOG.error(msg)
        raise exc.HTTPBadRequest(explanation=msg)
    return file_obj, package_meta


class Controller(object):
    """WSGI controller for application catalog resource in Murano v1 API."""

    def _validate_limit(self, value):
        if value is None:
            return
        try:
            value = int(value)
        except ValueError:
            msg = _("Limit param must be an integer")
            LOG.error(msg)
            raise exc.HTTPBadRequest(explanation=msg)

        if value <= 0:
            msg = _("Limit param must be positive")
            LOG.error(msg)
            raise exc.HTTPBadRequest(explanation=msg)

        return value

    def update(self, req, body, package_id):
        """List of allowed changes

        List of allowed changes:
            { "op": "add", "path": "/tags", "value": [ "foo", "bar" ] }
            { "op": "add", "path": "/categories", "value": [ "foo", "bar" ] }
            { "op": "remove", "path": "/tags" }
            { "op": "remove", "path": "/categories" }
            { "op": "replace", "path": "/tags", "value": ["foo", "bar"] }
            { "op": "replace", "path": "/is_public", "value": true }
            { "op": "replace", "path": "/description",
                                "value":"New description" }
            { "op": "replace", "path": "/name", "value": "New name" }
        """
        policy.check("modify_package", req.context, {'package_id': package_id})

        pkg_to_update = db_api.package_get(package_id, req.context)
        if pkg_to_update.is_public:
            policy.check("manage_public_package", req.context)

        _check_content_type(req, 'application/murano-packages-json-patch')
        if not isinstance(body, list):
            msg = _('Request body must be a JSON array of operation objects.')
            LOG.error(msg)
            raise exc.HTTPBadRequest(explanation=msg)
        for change in body:
            if 'is_public' in change['path']:
                if change['value'] is True and not pkg_to_update.is_public:
                    policy.check('publicize_package', req.context)
            if 'name' in change['path']:
                if len(change['value']) > 80:
                    msg = _('Package name should be 80 characters maximum')
                    LOG.error(msg)
                    raise exc.HTTPBadRequest(explanation=msg)
        package = db_api.package_update(package_id, body, req.context)
        return package.to_dict()

    def get(self, req, package_id):
        policy.check("get_package", req.context, {'package_id': package_id})

        package = db_api.package_get(package_id, req.context)
        return package.to_dict()

    def search(self, req):
        policy.check("get_package", req.context)
        manage_public = True
        try:
            policy.check("manage_public_package", req.context)
        except exc.HTTPForbidden:
            manage_public = False

        filters = _get_filters(req.GET.items())

        limit = self._validate_limit(filters.get('limit'))
        if limit is None:
            limit = CONF.murano.limit_param_default
        limit = min(CONF.murano.api_limit_max, limit)

        result = {}

        catalog = req.GET.pop('catalog', '').lower() == 'true'
        packages = db_api.package_search(
            filters, req.context, manage_public, limit, catalog=catalog)
        if len(packages) == limit:
            result['next_marker'] = packages[-1].id
        result['packages'] = [package.to_dict() for package in packages]
        return result

    def upload(self, req, body=None):
        """Upload new file archive

        Upload new file archive for the new package
        together with package metadata.
        """
        policy.check("upload_package", req.context)

        _check_content_type(req, 'multipart/form-data')
        file_obj, package_meta = _validate_body(body)
        if package_meta:
            try:
                jsonschema.validate(package_meta,
                                    validation_schemas.PKG_UPLOAD_SCHEMA)
            except jsonschema.ValidationError as e:
                msg = _("Package schema is not valid: {reason}").format(
                    reason=e)
                LOG.exception(msg)
                raise exc.HTTPBadRequest(explanation=msg)
        else:
            package_meta = {}

        if package_meta.get('is_public'):
            policy.check('publicize_package', req.context)

        with tempfile.NamedTemporaryFile(delete=False) as tempf:
            LOG.debug("Storing package archive in a temporary file")
            content = file_obj.file.read()
            if not content:
                msg = _("Uploading file can't be empty")
                LOG.error(msg)
                raise exc.HTTPBadRequest(explanation=msg)
            tempf.write(content)
            package_meta['archive'] = content
        try:
            with load_utils.load_from_file(
                    tempf.name, target_dir=None,
                    drop_dir=True) as pkg_to_upload:
                # extend dictionary for update db
                for k, v in PKG_PARAMS_MAP.items():
                    if hasattr(pkg_to_upload, k):
                        if k == "tags" and package_meta.get(k):
                            package_meta[v] = list(set(
                                package_meta[v] + getattr(pkg_to_upload, k)))
                        else:
                            package_meta[v] = getattr(pkg_to_upload, k)
                if len(package_meta['name']) > 80:
                    msg = _('Package name should be 80 characters maximum')
                    LOG.error(msg)
                    raise exc.HTTPBadRequest(explanation=msg)
                try:
                    package = db_api.package_upload(
                        package_meta, req.context.tenant)
                except db_exc.DBDuplicateEntry:
                    msg = _('Package with specified full '
                            'name is already registered')
                    LOG.exception(msg)
                    raise exc.HTTPConflict(msg)
                return package.to_dict()
        except pkg_exc.PackageLoadError as e:
            msg = _("Couldn't load package from file: {reason}").format(
                reason=e)
            LOG.exception(msg)
            raise exc.HTTPBadRequest(explanation=msg)
        finally:
            LOG.debug("Deleting package archive temporary file")
            os.remove(tempf.name)

    def get_ui(self, req, package_id):
        if CONF.engine.packages_service == 'murano':
            target = {'package_id': package_id}
            policy.check("get_package", req.context, target)

            package = db_api.package_get(package_id, req.context)
            return package.ui_definition
        else:
            g_client = self._get_glare_client(req)
            blob_data = g_client.artifacts.download_blob(package_id, 'archive')
            with tempfile.NamedTemporaryFile() as tempf:
                for chunk in blob_data:
                    tempf.write(chunk)
                tempf.file.flush()
                os.fsync(tempf.file.fileno())
                with load_utils.load_from_file(tempf.name, target_dir=None,
                                               drop_dir=True) as pkg:
                    return pkg.ui

    def get_logo(self, req, package_id):
        target = {'package_id': package_id}
        policy.check("get_package", req.context, target)

        package = db_api.package_get(package_id, req.context)
        return package.logo

    def get_supplier_logo(self, req, package_id):
        package = db_api.package_get(package_id, req.context)
        return package.supplier_logo

    def download(self, req, package_id):
        target = {'package_id': package_id}
        policy.check("download_package", req.context, target)

        package = db_api.package_get(package_id, req.context)
        return package.archive

    def delete(self, req, package_id):
        target = {'package_id': package_id}
        policy.check("delete_package", req.context, target)

        package = db_api.package_get(package_id, req.context)
        if package.is_public:
            policy.check("manage_public_package", req.context, target)
        db_api.package_delete(package_id, req.context)

    def get_category(self, req, category_id):
        policy.check("get_category", req.context)
        category = db_api.category_get(category_id, packages=True)
        return category.to_dict()

    def list_categories(self, req):
        """List all categories

        List all categories with pagination and sorting
           Acceptable filter params:
           :param sort_keys: an array of fields used to sort the list
           :param sort_dir: the direction of the sort ('asc' or 'desc')
           :param limit: the number of categories to list
           :param marker: the ID of the last item in the previous page
        """
        def _get_category_filters(req):
            query_params = {}
            valid_query_params = ['sort_keys', 'sort_dir', 'limit', 'marker']
            for key, value in req.GET.items():
                if key not in valid_query_params:
                    raise exc.HTTPBadRequest(
                        _('Bad value passed to filter. '
                          'Got {key}, expected:{valid}').format(
                            key=key, valid=', '.join(valid_query_params)))
                if key == 'sort_keys':
                    available_sort_keys = ['name', 'created',
                                           'updated', 'package_count', 'id']
                    value = [v.strip() for v in value.split(',')]
                    for sort_key in value:
                        if sort_key not in available_sort_keys:
                            raise exc.HTTPBadRequest(
                                explanation=_('Invalid sort key: {sort_key}. '
                                              'Must be one of the following: '
                                              '{available}').format(
                                    sort_key=sort_key,
                                    available=', '.join(available_sort_keys)))
                if key == 'sort_dir':
                    if value not in ['asc', 'desc']:
                        msg = _('Invalid sort direction: {0}').format(value)
                        raise exc.HTTPBadRequest(explanation=msg)
                query_params[key] = value
            return query_params

        policy.check("get_category", req.context)

        filters = _get_category_filters(req)

        marker = filters.get('marker')
        limit = self._validate_limit(filters.get('limit'))

        result = {}
        categories = db_api.categories_list(filters,
                                            limit=limit,
                                            marker=marker)
        if len(categories) == limit:
            result['next_marker'] = categories[-1].id

        result['categories'] = [category.to_dict() for category in categories]
        return result

    def add_category(self, req, body=None):
        policy.check("add_category", req.context)
        category_name = body.get('name')
        if not category_name:
            raise exc.HTTPBadRequest(
                explanation='Please, specify a name of the category to create')
        if len(category_name) > 80:
            msg = _('Category name should be 80 characters maximum')
            LOG.error(msg)
            raise exc.HTTPBadRequest(explanation=msg)
        try:
            category = db_api.category_add(category_name)
        except db_exc.DBDuplicateEntry:
            msg = _('Category with specified name is already exist')
            LOG.error(msg)
            raise exc.HTTPConflict(explanation=msg)
        return category.to_dict()

    def delete_category(self, req, category_id):
        target = {'category_id': category_id}
        policy.check("delete_category", req.context, target)
        category = db_api.category_get(category_id, packages=True)
        if category.packages:
            msg = _("It's impossible to delete categories assigned "
                    "to the package, uploaded to the catalog")
            raise exc.HTTPForbidden(explanation=msg)
        db_api.category_delete(category_id)

    def _get_glare_client(self, request):
        glare_settings = CONF.glare
        token = request.context.auth_token
        url = glare_settings.url
        if not url:
            url = self._get_glare_url(request)
        # TODO(gyurco): use auth_utils.get_session_client_parameters
        client = glare_client.Client(
            endpoint=url, token=token, insecure=glare_settings.insecure,
            key_file=glare_settings.keyfile or None,
            ca_file=glare_settings.cafile or None,
            cert_file=glare_settings.certfile or None,
            type_name='murano',
            type_version=1)
        return client

    def _get_glare_url(self, request):
        sc = request.context.service_catalog
        token = request.context.auth_token
        try:
            return service_catalog.ServiceCatalogV2(
                {'serviceCatalog': sc}).url_for(
                service_type='artifact',
                endpoint_type=CONF.glare.endpoint_type,
                region_name=CONF.home_region)
        except keystone_ex.EndpointNotFound:
            return service_catalog.ServiceCatalogV3(
                token,
                {'catalog': sc}).url_for(
                    service_type='artifact',
                    endpoint_type=CONF.glare.endpoint_type,
                    region_name=CONF.home_region)


def create_resource():
    specific_content_types = {
        'get_ui': ['text/plain'],
        'download': ['application/octet-stream'],
        'get_logo': ['application/octet-stream'],
        'get_supplier_logo': ['application/octet-stream']}
    deserializer = wsgi.RequestDeserializer(
        specific_content_types=specific_content_types)
    return wsgi.Resource(Controller(), deserializer=deserializer)
