# Modifications copyright (C) 2017 Pluralsight LLC

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you 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.
"""
Protocol implementation.
"""
import six
try:
    from hashlib import md5
except ImportError:
    from md5 import md5
try:
    import json
except ImportError:
    import simplejson as json
from spavro import schema

#
# Constants
#

# TODO(hammer): confirmed 'fixed' with Doug
VALID_TYPE_SCHEMA_TYPES = ('enum', 'record', 'error', 'fixed')

#
# Exceptions
#


class ProtocolParseException(schema.AvroException):
    pass

#
# Base Classes
#


class Protocol(object):
    """An application protocol."""
    def _parse_types(self, types, type_names):
        type_objects = []
        for type in types:
            type_object = schema.make_avsc_object(type, type_names)
            if type_object.type not in VALID_TYPE_SCHEMA_TYPES:
                fail_msg = 'Type %s not an enum, fixed, record, or error.' % type
                raise ProtocolParseException(fail_msg)
            type_objects.append(type_object)
        return type_objects

    def _parse_messages(self, messages, names):
        message_objects = {}
        for name, body in six.iteritems(messages):
            if name in message_objects:
                fail_msg = 'Message name "%s" repeated.' % name
                raise ProtocolParseException(fail_msg)
            elif not(hasattr(body, 'get') and callable(body.get)):
                fail_msg = 'Message name "%s" has non-object body %s.' % (name, body)
                raise ProtocolParseException(fail_msg)
            request = body.get('request')
            response = body.get('response')
            errors = body.get('errors')
            message_objects[name] = Message(name, request, response, errors, names)
        return message_objects

    def __init__(self, name, namespace=None, types=None, messages=None):
        # Ensure valid ctor args
        if not name:
            fail_msg = 'Protocols must have a non-empty name.'
            raise ProtocolParseException(fail_msg)
        elif not isinstance(name, six.string_types):
            fail_msg = 'The name property must be a string.'
            raise ProtocolParseException(fail_msg)
        elif namespace is not None and not isinstance(namespace, six.string_types):
            fail_msg = 'The namespace property must be a string.'
            raise ProtocolParseException(fail_msg)
        elif types is not None and not isinstance(types, list):
            fail_msg = 'The types property must be a list.'
            raise ProtocolParseException(fail_msg)
        elif (messages is not None and 
                    not(hasattr(messages, 'get') and callable(messages.get))):
            fail_msg = 'The messages property must be a JSON object.'
            raise ProtocolParseException(fail_msg)

        self._props = {}
        self.set_prop('name', name)
        type_names = schema.Names()
        if namespace is not None: 
            self.set_prop('namespace', namespace)
            type_names.default_namespace = namespace
        if types is not None:
            self.set_prop('types', self._parse_types(types, type_names))
        if messages is not None:
            self.set_prop('messages', self._parse_messages(messages, type_names))
        self._md5 = md5(str(self).encode('utf-8')).digest()

    # read-only properties
    name = property(lambda self: self.get_prop('name'))
    namespace = property(lambda self: self.get_prop('namespace'))
    fullname = property(lambda self: 
                                            schema.Name(self.name, self.namespace).fullname)
    types = property(lambda self: self.get_prop('types'))
    types_dict = property(lambda self: dict([(type.name, type)
                                                                                     for type in self.types]))
    messages = property(lambda self: self.get_prop('messages'))
    md5 = property(lambda self: self._md5)
    props = property(lambda self: self._props)

    # utility functions to manipulate properties dict
    def get_prop(self, key):
        return self.props.get(key)
    def set_prop(self, key, value):
        self.props[key] = value  

    def to_json(self):
        to_dump = {}
        to_dump['protocol'] = self.name
        names = schema.Names(default_namespace=self.namespace)
        if self.namespace: 
            to_dump['namespace'] = self.namespace
        if self.types:
            to_dump['types'] = [ t.to_json(names) for t in self.types ]
        if self.messages:
            messages_dict = {}
            for name, body in six.iteritems(self.messages):
                messages_dict[name] = body.to_json(names)
            to_dump['messages'] = messages_dict
        return to_dump

    def __str__(self):
        return json.dumps(self.to_json())

    def __eq__(self, that):
        to_cmp = json.loads(str(self))
        return to_cmp == json.loads(str(that))


class Message(object):
    """A Protocol message."""
    def _parse_request(self, request, names):
        if not isinstance(request, list):
            fail_msg = 'Request property not a list: %s' % request
            raise ProtocolParseException(fail_msg)
        return schema.RecordSchema(None, None, request, names, 'request')
    
    def _parse_response(self, response, names):
        if isinstance(response, six.string_types) and names.has_name(response, None):
            return names.get_name(response, None)
        else:
            return schema.make_avsc_object(response, names)

    def _parse_errors(self, errors, names):
        if not isinstance(errors, list):
            fail_msg = 'Errors property not a list: %s' % errors
            raise ProtocolParseException(fail_msg)
        errors_for_parsing = {'type': 'error_union', 'declared_errors': errors}
        return schema.make_avsc_object(errors_for_parsing, names)

    def __init__(self,  name, request, response, errors=None, names=None):
        self._name = name

        self._props = {}
        self.set_prop('request', self._parse_request(request, names))
        self.set_prop('response', self._parse_response(response, names))
        if errors is not None:
            self.set_prop('errors', self._parse_errors(errors, names))

    # read-only properties
    name = property(lambda self: self._name)
    request = property(lambda self: self.get_prop('request'))
    response = property(lambda self: self.get_prop('response'))
    errors = property(lambda self: self.get_prop('errors'))
    props = property(lambda self: self._props)

    # utility functions to manipulate properties dict
    def get_prop(self, key):
        return self.props.get(key)
    def set_prop(self, key, value):
        self.props[key] = value

    def __str__(self):
        return json.dumps(self.to_json())

    def to_json(self, names=None):
        if names is None:
            names = schema.Names()
        to_dump = {}
        to_dump['request'] = self.request.to_json(names)
        to_dump['response'] = self.response.to_json(names)
        if self.errors:
            to_dump['errors'] = self.errors.to_json(names)
        return to_dump

    def __eq__(self, that):
        return self.name == that.name and self.props == that.props


def make_avpr_object(json_data):
    """Build Avro Protocol from data parsed out of JSON string."""
    if hasattr(json_data, 'get') and callable(json_data.get):
        name = json_data.get('protocol')
        namespace = json_data.get('namespace')
        types = json_data.get('types')
        messages = json_data.get('messages')
        return Protocol(name, namespace, types, messages)
    else:
        raise ProtocolParseException('Not a JSON object: %s' % json_data)


def parse(json_string):
    """Constructs the Protocol from the JSON text."""
    try:
        json_data = json.loads(json_string)
    except:
        raise ProtocolParseException('Error parsing JSON: %s' % json_string)

    # construct the Avro Protocol object
    return make_avpr_object(json_data)

