# 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.

"""Implementation of JSON RPC for communication between API and conductors.

This module implements a subset of JSON RPC 2.0 as defined in
https://www.jsonrpc.org/specification. Main differences:
* No support for batched requests.
* No support for positional arguments passing.
* No JSON RPC 1.0 fallback.
"""

import json
import logging

from keystonemiddleware import auth_token
from oslo_config import cfg
import oslo_messaging
from oslo_utils import strutils
import webob

from ironic.common import auth_basic
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import wsgi_service
from ironic.conf import json_rpc


CONF = cfg.CONF
LOG = logging.getLogger(__name__)
_DENY_LIST = {'init_host', 'del_host', 'target', 'iter_nodes'}


def _build_method_map(manager):
    """Build mapping from method names to their bodies.

    :param manager: A conductor manager.
    :return: dict with mapping
    """
    result = {}
    for method in dir(manager):
        if method.startswith('_') or method in _DENY_LIST:
            continue
        func = getattr(manager, method)
        if not callable(func):
            continue
        LOG.debug('Adding RPC method %s', method)
        result[method] = func
    return result


class JsonRpcError(exception.IronicException):
    pass


class ParseError(JsonRpcError):
    code = -32700
    _msg_fmt = _("Invalid JSON received by RPC server")


class InvalidRequest(JsonRpcError):
    code = -32600
    _msg_fmt = _("Invalid request object received by RPC server")


class MethodNotFound(JsonRpcError):
    code = -32601
    _msg_fmt = _("Method %(name)s was not found")


class InvalidParams(JsonRpcError):
    code = -32602
    _msg_fmt = _("Params %(params)s are invalid for %(method)s: %(error)s")


class EmptyContext:

    request_id = None

    def __init__(self, src):
        self.__dict__.update(src)

    def to_dict(self):
        return self.__dict__.copy()


class WSGIService(wsgi_service.BaseWSGIService):
    """Provides ability to launch JSON RPC as a WSGI application."""

    def __init__(self, manager, serializer, context_class=EmptyContext,
                 conf_group: str = 'json_rpc'):
        """Create a JSON RPC service.

        :param manager: Object from which to expose methods.
        :param serializer: A serializer that supports calls serialize_entity
            and deserialize_entity.
        :param context_class: A context class - a callable accepting a dict
            received from network.
        :param conf_group: oslo.config group name to source settings from.
            Defaults to 'json_rpc'.
        """
        self.manager = manager
        self.serializer = serializer
        self.context_class = context_class
        self._method_map = _build_method_map(manager)
        self._conf_group = conf_group
        auth_strategy = json_rpc.auth_strategy(group=conf_group)
        if auth_strategy == 'keystone':
            conf = dict(CONF.keystone_authtoken)
            app = auth_token.AuthProtocol(self._application, conf)
        elif auth_strategy == 'http_basic':
            app = auth_basic.BasicAuthMiddleware(
                self._application,
                getattr(CONF, conf_group).http_basic_auth_user_file)
        else:
            app = self._application
        super().__init__('ironic-json-rpc', app, getattr(CONF, conf_group))
        self._debug = CONF.rpc_transport != 'none'

    def _application(self, environment, start_response):
        """WSGI application for conductor JSON RPC."""
        request = webob.Request(environment)
        if request.method != 'POST':
            body = {'error': {'code': 405,
                              'message': _('Only POST method can be used')}}
            return webob.Response(status_code=405, json_body=body)(
                environment, start_response)

        if json_rpc.auth_strategy(group=self._conf_group) == 'keystone':
            roles = (request.headers.get('X-Roles') or '').split(',')
            allowed_roles = getattr(CONF, self._conf_group).allowed_roles
            if set(roles).isdisjoint(allowed_roles):
                LOG.debug('Roles %s do not contain any of %s, rejecting '
                          'request', roles, allowed_roles)
                body = {'error': {'code': 403, 'message': _('Forbidden')}}
                return webob.Response(status_code=403, json_body=body)(
                    environment, start_response)

        result = self._call(request)
        if result is not None:
            response = webob.Response(content_type='application/json',
                                      charset='UTF-8',
                                      json_body=result)
        else:
            response = webob.Response(status_code=204)
        return response(environment, start_response)

    def _handle_error(self, exc, request_id=None):
        """Generate a JSON RPC 2.0 error body.

        :param exc: Exception object.
        :param request_id: ID of the request (if any).
        :return: dict with response body
        """
        if isinstance(exc, oslo_messaging.ExpectedException):
            exc = exc.exc_info[1]

        expected = isinstance(exc, exception.IronicException)
        cls = exc.__class__
        if expected:
            LOG.debug('RPC error %s: %s', cls.__name__, exc)
        else:
            LOG.exception('Unexpected RPC exception %s', cls.__name__)

        response = {
            "jsonrpc": "2.0",
            "id": request_id,
            "error": {
                "code": getattr(exc, 'code', 500),
                "message": str(exc),
            }
        }
        if expected and not isinstance(exc, JsonRpcError):
            # Allow de-serializing the correct class for expected errors.
            response['error']['data'] = {
                'class': '%s.%s' % (cls.__module__, cls.__name__)
            }
        return response

    def _call(self, request):
        """Process a JSON RPC request.

        :param request: ``webob.Request`` object.
        :return: dict with response body.
        """
        request_id = None
        try:
            try:
                body = json.loads(request.text)
            except ValueError:
                LOG.error('Cannot parse JSON RPC request as JSON')
                raise ParseError()

            if not isinstance(body, dict):
                LOG.error('JSON RPC request %s is not an object (batched '
                          'requests are not supported)', body)
                raise InvalidRequest()

            request_id = body.get('id')
            params = body.get('params', {})

            if (body.get('jsonrpc') != '2.0'
                    or not body.get('method')
                    or not isinstance(params, dict)):
                LOG.error('JSON RPC request %s is invalid', body)
                raise InvalidRequest()
        except Exception as exc:
            # We do not treat malformed requests as notifications and return
            # a response even when request_id is None. This seems in agreement
            # with the examples in the specification.
            return self._handle_error(exc, request_id)

        try:
            method = body['method']
            try:
                func = self._method_map[method]
            except KeyError:
                raise MethodNotFound(name=method)

            result = self._handle_requests(func, method, params)
            if request_id is not None:
                return {
                    "jsonrpc": "2.0",
                    "result": result,
                    "id": request_id
                }
        except Exception as exc:
            result = self._handle_error(exc, request_id)
            # We treat correctly formed requests without "id" as notifications
            # and do not return any errors.
            if request_id is not None:
                return result

    def _handle_requests(self, func, name, params):
        """Convert arguments and call a method.

        :param func: Callable object.
        :param name: RPC call name for logging.
        :param params: Keyword arguments.
        :return: call result as JSON.
        """
        # TODO(dtantsur): server-side version check?
        params.pop('rpc.version', None)
        logged_params = strutils.mask_dict_password(params)

        try:
            context = params.pop('context')
        except KeyError:
            context = None
        else:
            # A valid context is required for deserialization
            if not isinstance(context, dict):
                raise InvalidParams(
                    _("Context must be a dictionary, if provided"))

            context = self.context_class(context)
            params = {key: self.serializer.deserialize_entity(context, value)
                      for key, value in params.items()}
            params['context'] = context

        if self._debug:
            LOG.debug('RPC %s with %s', name, logged_params)

        try:
            result = func(**params)
        # FIXME(dtantsur): we could use the inspect module, but
        # oslo_messaging.expected_exceptions messes up signatures.
        except TypeError as exc:
            raise InvalidParams(params=', '.join(params),
                                method=name, error=exc)

        if context is not None:
            # Currently it seems that we can serialize even with invalid
            # context, but I'm not sure it's guaranteed to be the case.
            result = self.serializer.serialize_entity(context, result)

        if self._debug:
            LOG.debug('RPC %s returned %s', name,
                      strutils.mask_dict_password(result)
                      if isinstance(result, dict) else result)
        return result
