# -*- coding: utf-8 -*-

# Copyright 2015 Spanish National Research Council
# Copyright 2015 LIP - INDIGO-DataCloud
#
# 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 routes
import routes.middleware
import webob.dec

import ooi.api.compute
import ooi.api.network
import ooi.api.network_link
from ooi.api import query
import ooi.api.storage
import ooi.api.storage_link
from ooi import config
from ooi import exception
from ooi.log import log as logging
from ooi import utils
from ooi import version
from ooi.wsgi import parsers
from ooi.wsgi import serializers

LOG = logging.getLogger(__name__)

occi_opts = [
    config.cfg.StrOpt('ooi_listen',
                      default="0.0.0.0",
                      help='The IP address on which the OCCI (ooi) API '
                           'will listen.'),
    config.cfg.IntOpt('ooi_listen_port',
                      default=8787,
                      help='The port on which the OCCI (ooi) API '
                           'will listen.'),
    config.cfg.IntOpt('ooi_workers',
                      help='Number of workers for OCCI (ooi) API service. '
                           'The default will be equal to the number of CPUs '
                           'available.'),
    # NEUTRON
    config.cfg.StrOpt('neutron_ooi_endpoint',
                      default=None,
                      help='Neutron end point which access to'
                           ' the Neutron Restful API.'),
]

CONF = config.cfg.CONF
CONF.register_opts(occi_opts)


class Request(webob.Request):
    def should_have_body(self):
        return self.method in ("POST", "PUT")

    def get_content_type(self):
        """Determine content type of the request body."""
        if not self.content_type:
            return None

        if not self.should_have_body():
            return None

        content_types = self.content_type.split(",")

        for ct in content_types:
            if ct in parsers.get_supported_content_types():
                return ct

        LOG.debug("Unrecognized Content-Type provided in request")
        raise exception.InvalidContentType(content_type=self.content_type)

    def get_best_match_content_type(self, default_match=None):
        content_types = serializers.get_supported_content_types()
        content_type = self.accept.best_match(content_types,
                                              default_match=default_match)
        if not content_type:
            LOG.debug("Unrecognized Accept Content-type provided in request")
            raise exception.InvalidAccept(content_type=self.accept)
        return content_type

    def get_parser(self):
        mtype = parsers.get_media_map().get(self.get_content_type(), "header")
        return parsers.get_default_parsers()[mtype]


class OCCIMiddleware(object):

    occi_version = "1.1"
    occi_string = "OCCI/%s" % occi_version

    @classmethod
    def factory(cls, global_conf, **local_conf):
        """Factory method for paste.deploy."""
        def _factory(app):
            conf = global_conf.copy()
            conf.update(local_conf)
            return cls(app, **local_conf)
        return _factory

    def __init__(self, application, openstack_version="/v2.1",
                 neutron_ooi_endpoint=None):
        self.application = application
        self.openstack_version = openstack_version

        if CONF.neutron_ooi_endpoint:
            self.neutron_ooi_endpoint = CONF.neutron_ooi_endpoint
        elif neutron_ooi_endpoint:
            exception.raise_deprecation_message(
                "Configuration of neutron_ooi_endpoint"
                " in api-paste.ini file is deprecated,"
                " include it in nova.conf")
            self.neutron_ooi_endpoint = neutron_ooi_endpoint
        else:
            self.neutron_ooi_endpoint = None

        self.resources = {}
        self.mapper = routes.Mapper()
        self._setup_routes()

    def _create_resource(self, controller, neutron_ooi_endpoint=None):
        if neutron_ooi_endpoint:
            return Resource(controller(self.application,
                                       self.openstack_version,
                                       neutron_ooi_endpoint))
        return Resource(controller(self.application,
                                   self.openstack_version))

    def _create_resource_network(self, controller, neutron_ooi_endpoint=None):
        if neutron_ooi_endpoint:
            return Resource(controller(
                neutron_ooi_endpoint=neutron_ooi_endpoint)
            )
        else:
            return Resource(controller(self.application,
                                       self.openstack_version))

    def _setup_resource_routes(self, resource, controller):
        path = "/" + resource
        # These two could be removed for total OCCI compliance
        self.mapper.connect(resource, path, controller=controller,
                            action="index", conditions=dict(method=["GET"]))
        self.mapper.connect(resource, path, controller=controller,
                            action="create", conditions=dict(method=["POST"]))
        # OCCI states that paths must end with a "/" when operating on pahts,
        # that are not location pahts or resource instances
        self.mapper.connect(resource, path + "/", controller=controller,
                            action="index", conditions=dict(method=["GET"]))
        self.mapper.connect(resource, path + "/", controller=controller,
                            action="create", conditions=dict(method=["POST"]))
        self.mapper.connect(resource, path + "/{id}", controller=controller,
                            action="update", conditions=dict(method=["PUT"]))
        self.mapper.connect(resource, path + "/{id}", controller=controller,
                            action="delete",
                            conditions=dict(method=["DELETE"]))
        self.mapper.connect(resource, path + "/{id}", controller=controller,
                            action="show", conditions=dict(method=["GET"]))
        # OCCI specific, delete all resources
        self.mapper.connect(path + "/", controller=controller,
                            action="delete_all",
                            conditions=dict(method=["DELETE"]))
        # Actions
        self.mapper.connect(path + "/{id}", controller=controller,
                            action="run_action",
                            conditions=dict(method=["POST"]))

    def _setup_routes(self):
        """Setup the mapper routes.

        This method should populate the mapper with the Resources
        for each of the Controllers.

        For example, if ooi.api.query contains the following:

        .. code-block:: python

            class Controller(object):
                def index(self, *args, **kwargs):
                    # Currently we do not have anything to do here
                    return None

        This method could populate the mapper as follows:

        .. code-block:: python

            self.resources["query"] = self._create_resource(query.Controller)
            self.mapper.connect("query", "/-/",
                                controller=self.resources["query"],
                                action="index")

        or if the Controller has all the CRUD operations:

        .. code-block:: python

            self.resources["servers"] = self._create_resource(query.Controller)
            self.mapper.resource("server", "servers",
                                 controller=self.resources["servers"])

        """
        self.mapper.redirect("", "/")

        self.resources["query"] = self._create_resource(
            query.Controller,
            self.neutron_ooi_endpoint
        )
        self.mapper.connect("query", "/-/",
                            controller=self.resources["query"],
                            action="index")
        # RFC5785, OCCI section 3.6.7
        self.mapper.connect("query", "/.well-known/org/ogf/occi/-/",
                            controller=self.resources["query"],
                            action="index")

        self.resources["compute"] = self._create_resource(
            ooi.api.compute.Controller)
        self._setup_resource_routes("compute", self.resources["compute"])

        self.resources["storage"] = self._create_resource(
            ooi.api.storage.Controller)
        self._setup_resource_routes("storage", self.resources["storage"])

        self.resources["storagelink"] = self._create_resource(
            ooi.api.storage_link.Controller)
        self._setup_resource_routes("storagelink",
                                    self.resources["storagelink"])

        self.resources["networklink"] = self._create_resource(
            ooi.api.network_link.Controller)
        self._setup_resource_routes("networklink",
                                    self.resources["networklink"])

        self.resources["network"] = self._create_resource_network(
            ooi.api.network.Controller, self.neutron_ooi_endpoint)
        self._setup_resource_routes("network",
                                    self.resources["network"])

    @webob.dec.wsgify(RequestClass=Request)
    def __call__(self, req):
        response = self.process_request(req)
        if not response:
            response = req.get_response(self.application)

        return self.process_response(response)

    def process_request(self, req):
        if req.user_agent:
            # FIXME(aloga): review the regexp, since it will only match the
            # first string
            match = re.search(r"\bOCCI/\d\.\d\b", req.user_agent)
            if match and self.occi_string != match.group():
                return Fault(webob.exc.HTTPNotImplemented(
                    explanation="%s not supported" % match.group()))

        match = self.mapper.match(req.path_info, req.environ)
        if not match:
            return Fault(webob.exc.HTTPNotFound())
        method = match["controller"]
        return method(req, match)

    def process_response(self, response):
        """Process a response by adding our headers."""
        server_string = "ooi/%s %s" % (version.version_string,
                                       self.occi_string)

        headers = (("server", server_string),)
        if isinstance(response, Fault):
            for key, val in headers:
                response.wrapped_exc.headers.add(key, val)
        else:
            for key, val in headers:
                response.headers.add(key, val)
        return response


class Resource(object):
    def __init__(self, controller):
        self.controller = controller
        self.default_serializers = serializers.get_default_serializers()

    def get_action_args(self, args):
        """Parse dictionary created by routes library."""
        try:
            del args['controller']
        except KeyError:
            pass

        try:
            del args['format']
        except KeyError:
            pass

        return args

    def __call__(self, request, args):
        """Control the method dispatch."""
        action_args = self.get_action_args(args)
        action = action_args.pop('action', None)
        try:
            accept = request.get_best_match_content_type()
            content_type = request.get_content_type()
        except exception.InvalidContentType as e:
            msg = e.format_message()
            return Fault(webob.exc.HTTPNotAcceptable(explanation=msg))

        body = request.body

        # Get the implementing method
        try:
            method = self.get_method(request, action,
                                     content_type, body)
        except (AttributeError, TypeError):
            return Fault(webob.exc.HTTPNotFound())
        except KeyError as ex:
            msg = "There is no such action: %s" % ex.args[0]
            return Fault(webob.exc.HTTPBadRequest(explanation=msg))

        contents = {}
        if request.should_have_body():
            # allow empty body with PUT and POST
            if request.content_length == 0:
                contents = {'body': None}
            else:
                contents["body"] = body

        action_args.update(contents)

        response = None
        try:
            with ResourceExceptionHandler():
                action_result = self.dispatch(method, request, action_args)
        except Fault as ex:
            response = ex

        # No exceptions, so create a response
        # NOTE(aloga): if the middleware returns None, the pipeline will
        # continue, but we do not want to do so, so we convert the action
        # result to a ResponseObject.
        if not response:
            if isinstance(action_result, ResponseObject):
                resp_obj = action_result
            else:
                resp_obj = ResponseObject(action_result)

            response = resp_obj.serialize(request, accept,
                                          self.default_serializers)
        return response

    def get_method(self, request, action, content_type, body):
        """Look up the action-specific method and its extensions."""

        if not self.controller:
            meth = getattr(self, action)
        else:
            meth = getattr(self.controller, action)

        return meth

    def dispatch(self, method, request, action_args):
        """Dispatch a call to the action-specific method."""
        return method(req=request, **action_args)


class ResponseObject(object):
    def __init__(self, obj, code=None, headers=None, **serializers):
        self.obj = obj
        self.serializers = serializers
        self._default_code = 200
        self._code = code
        self._headers = headers or {}
        self.serializer = None
        self.media_type = None

    def get_serializer(self, content_type, default_serializers=None):
        """Returns the serializer for the wrapped object.

        Returns the serializer for the wrapped object subject to the
        indicated content type.  If no serializer matching the content
        type is attached, an appropriate serializer drawn from the
        default serializers will be used.  If no appropriate
        serializer is available, raises InvalidContentType.
        """

        default_serializers = default_serializers or {}
        try:
            if content_type is None:
                content_type = "text/plain"

            mtype = serializers.get_media_map().get(content_type,
                                                    content_type)
            if mtype in self.serializers:
                return mtype, self.serializers[mtype]
            else:
                return mtype, default_serializers[mtype]
        except (KeyError, TypeError):
            raise exception.InvalidContentType(content_type=content_type)

    def serialize(self, request, content_type, default_serializers=None):
        if self.serializer:
            serializer = self.serializer
        else:
            _mtype, _serializer = self.get_serializer(content_type,
                                                      default_serializers)
            env = {"application_url": request.application_url + "/"}
            serializer = _serializer(env)

        response = webob.Response()
        response.status_int = self.code
        for hdr, value in self._headers.items():
            response.headers[hdr] = utils.utf8(value)
        response.headers['Content-Type'] = content_type
        response.charset = 'utf8'
        if self.obj is not None:
            headers, body = serializer.serialize(self.obj)
            if headers is not None:
                for hdr in headers:
                    response.headers.add(*hdr)
            if body:
                response.body = body

            # 204 should be used if there is no content
            if (not (headers or body) and
                    response.status_int in [200, 201, 202]):
                response.status_int = 204

        return response

    @property
    def code(self):
        """Retrieve the response status."""

        return self._code or self._default_code

    @property
    def headers(self):
        """Retrieve the headers."""

        return self._headers.copy()


class ResourceExceptionHandler(object):
    """Context manager to handle Resource exceptions.

    Used when processing exceptions generated by API implementation
    methods (or their extensions).  Converts most exceptions to Fault
    exceptions, with the appropriate logging.
    """

    def __enter__(self):
        return None

    def __exit__(self, ex_type, ex_value, ex_traceback):
        if not ex_value:
            return True

        if isinstance(ex_value, exception.OCCIException):
            raise Fault(exception.ConvertedException(
                code=ex_value.code,
                explanation=ex_value.format_message()))
        elif isinstance(ex_value, exception.NotImplemented):
            raise Fault(exception.ConvertedException(
                code=ex_value.code,
                explanation=ex_value.format_message()))
        elif isinstance(ex_value, TypeError):
            exc_info = (ex_type, ex_value, ex_traceback)
            LOG.error('Exception handling resource: %s', ex_value,
                      exc_info=exc_info)
            raise Fault(webob.exc.HTTPBadRequest())
        elif isinstance(ex_value, Fault):
            LOG.info("Fault thrown: %s", ex_value)
            raise ex_value
        elif isinstance(ex_value, webob.exc.HTTPException):
            LOG.info("HTTP exception thrown: %s", ex_value)
            raise Fault(ex_value)
        else:
            LOG.exception("Unexpected exception: %s" % ex_value)
            raise Fault(webob.exc.HTTPInternalServerError())

        # We didn't handle the exception
        return False


class Fault(webob.exc.HTTPException):
    """Wrap webob.exc.HTTPException to provide API friendly response."""

    def __init__(self, exception):
        """Create a Fault for the given webob.exc.exception."""
        self.wrapped_exc = exception
        for key, value in self.wrapped_exc.headers.items():
            self.wrapped_exc.headers[key] = str(value)
        self.status_int = exception.status_int

    @webob.dec.wsgify(RequestClass=Request)
    def __call__(self, req):
        """Generate a WSGI response based on the exception passed to ctor."""

        # Replace the body with fault details.
        code = self.wrapped_exc.status_int
        explanation = self.wrapped_exc.explanation
        LOG.debug("Returning %(code)s to user: %(explanation)s",
                  {'code': code, 'explanation': explanation})

        def_ct = "text/plain"
        content_type = req.get_best_match_content_type(default_match=def_ct)
        mtype = serializers.get_media_map().get(content_type,
                                                "text")
        serializer = serializers.get_default_serializers()[mtype]
        env = {}
        serialized_exc = serializer(env).serialize(self.wrapped_exc)
        self.wrapped_exc.content_type = content_type
        self.wrapped_exc.body = serialized_exc[1]

        # We need to specify the HEAD req.method here to be HEAD because of the
        # way that webob.exc.WSGIHTTPException.__call__ generates the response.
        # The text/occi will not have a body since it is based on headers. We
        # cannot set this earlier in the middleware, since we are calling
        # OpenStack and it will fail because the responses won't contain a
        # body.
        if content_type == "text/occi":
            req.method = "HEAD"

        return self.wrapped_exc

    def __str__(self):
        return self.wrapped_exc.__str__()
