from django import forms
from django.forms import MediaDefiningClass
from django.utils.functional import cached_property, Promise


DICT_RESERVED_KEYS = ['_type', '_args', '_dict', '_list', '_val', '_id', '_ref']
STRING_REF_MIN_LENGTH = 20  # do not turn strings shorter than this into references


class UnpackableTypeError(TypeError):
    pass


class Node:
    """
    Intermediate representation of a packed value. Subclasses represent a particular value
    type, and implement emit_verbose (returns a dict representation of a value that can have
    an _id attached) and emit_compact (returns a compact representation of the value, in any
    JSON-serialisable type).

    If this node is assigned an id, emit() will return the verbose representation with the
    id attached on first call, and a reference on subsequent calls. To disable this behaviour
    (e.g. for small primitive values where the reference representation adds unwanted overhead),
    set self.use_id = False.
    """
    def __init__(self):
        self.id = None
        self.seen = False
        self.use_id = True

    def emit(self):
        if self.use_id and self.seen and self.id is not None:
            # Have already emitted this value, so emit a reference instead
            return {'_ref': self.id}
        else:
            self.seen = True
            if self.use_id and self.id is not None:
                # emit this value in long form including an ID
                result = self.emit_verbose()
                result['_id'] = self.id
                return result
            else:
                return self.emit_compact()


class ValueNode(Node):
    """Represents a primitive value; int, bool etc"""
    def __init__(self, value):
        super().__init__()
        self.value = value
        self.use_id = False

    def emit_verbose(self):
        return {'_val': self.value}

    def emit_compact(self):
        return self.value


class StringNode(Node):
    def __init__(self, value):
        super().__init__()
        self.value = value
        self.use_id = len(value) >= STRING_REF_MIN_LENGTH

    def emit_verbose(self):
        return {'_val': self.value}

    def emit_compact(self):
        return self.value


class ListNode(Node):
    def __init__(self, value):
        super().__init__()
        self.value = value

    def emit_verbose(self):
        return {'_list': [item.emit() for item in self.value]}

    def emit_compact(self):
        return [item.emit() for item in self.value]


class DictNode(Node):
    def __init__(self, value):
        super().__init__()
        self.value = value

    def emit_verbose(self):
        return {'_dict': {key: val.emit() for key, val in self.value.items()}}

    def emit_compact(self):
        if any(reserved_key in self.value for reserved_key in DICT_RESERVED_KEYS):
            # compact representation is not valid as this dict contains reserved keys
            # that would clash with the verbose representation
            return self.emit_verbose()
        else:
            return {key: val.emit() for key, val in self.value.items()}


class ObjectNode(Node):
    def __init__(self, constructor, args):
        super().__init__()
        self.constructor = constructor
        self.args = args

    def emit_verbose(self):
        return {
            '_type': self.constructor,
            '_args': [arg.emit() for arg in self.args]
        }

    def emit_compact(self):
        # objects always use verbose representation
        return self.emit_verbose()


class BaseAdapter:
    """Handles serialisation of a specific object type"""
    def build_node(self, obj, context):
        """
        Translates obj into a node that we can call emit() on to obtain the final serialisable
        form. Any media declarations that will be required for deserialisation of the object should
        be passed to context.add_media().

        This base implementation handles simple JSON-serialisable values such as integers, and
        wraps them as a ValueNode.
        """
        return ValueNode(obj)


class StringAdapter(BaseAdapter):
    def build_node(self, obj, context):
        return StringNode(obj)


class DictAdapter(BaseAdapter):
    """Handles serialisation of dicts"""
    def build_node(self, obj, context):
        return DictNode({
            str(key): context.build_node(val)
            for key, val in obj.items()
        })


class Adapter(BaseAdapter, metaclass=MediaDefiningClass):
    """
    Handles serialisation of custom types.
    Subclasses should define:
    - js_constructor: namespaced identifier for the JS constructor function that will unpack this
        object
    - js_args(obj): returns a list of (telepath-packable) arguments to be passed to the constructor
    - get_media(obj) or class Media: media definitions necessary for unpacking

    The adapter should then be registered with register(adapter, cls).
    """

    def get_media(self, obj):
        return self.media

    def pack(self, obj, context):
        context.add_media(self.get_media(obj))
        return (self.js_constructor, self.js_args(obj))

    def build_node(self, obj, context):
        constructor, args = self.pack(obj, context)
        return ObjectNode(
            constructor, [context.build_node(arg) for arg in args]
        )


class AutoAdapter(Adapter):
    """
    Adapter for objects that define their own telepath_pack method that we can simply delegate to.
    """
    def pack(self, obj, context):
        return obj.telepath_pack(context)


class JSContextBase:
    """
    Base class for JSContext classes obtained through AdapterRegistry.js_context_class.
    Subclasses of this are assigned the following class attributes:
    registry - points to the associated AdapterRegistry
    telepath_js_path - path to telepath.js (as per standard Django staticfiles conventions)

    A JSContext handles packing a set of values to be used in the same request; calls to
    JSContext.pack will return the packed representation and also update the JSContext's media
    property to include all JS needed to unpack the values seen so far.
    """
    def __init__(self):
        self.media = self.base_media

        # Keep track of media declarations that have already added to self.media - ones that
        # exactly match a previous one can be ignored, as they will not affect the result
        self.media_fragments = set([str(self.media)])

    @property
    def base_media(self):
        return forms.Media(js=[self.telepath_js_path])

    def add_media(self, media=None, js=None, css=None):
        media_objects = []
        if media:
            media_objects.append(media)
        if js or css:
            if isinstance(js, str):
                # allow passing a single JS file name as equivalent to a singleton list
                js = [js]
            media_objects.append(forms.Media(js=js, css=css))

        for media_obj in media_objects:
            media_str = str(media_obj)
            if media_str not in self.media_fragments:
                self.media += media_obj
                self.media_fragments.add(media_str)

    def pack(self, obj):
        return ValueContext(self).build_node(obj).emit()


class AdapterRegistry:
    """
    Manages the mapping of Python types to their corresponding adapter implementations.
    """
    js_context_base_class = JSContextBase

    def __init__(self, telepath_js_path='telepath/js/telepath.js'):
        self.telepath_js_path = telepath_js_path
        self.adapters = {
            # Primitive value types that are unchanged on serialisation
            type(None): BaseAdapter(),
            bool: BaseAdapter(),
            int: BaseAdapter(),
            float: BaseAdapter(),
            str: StringAdapter(),

            # Container types to be serialised recursively
            dict: DictAdapter(),
            # Iterable types (list, tuple, odict_values...) do not have a reliably recognisable
            # superclass, so will be handled as a special case
        }

    def register(self, *args, **kwargs):
        if len(args) == 2 and not kwargs:
            # called as register(adapter, cls)
            adapter, cls = args
            if not isinstance(adapter, Adapter):
                raise TypeError("register expected an Adapter instance, got %r" % adapter)

            self.adapters[cls] = adapter

        elif not args:
            # called as a class decorator: @register() or @register(adapter=MyAdapter()) -
            # the return value here is the function that will receive the class definition
            adapter = kwargs.get('adapter') or AutoAdapter()
            if not isinstance(adapter, Adapter):
                raise TypeError("register expected an Adapter instance, got %r" % adapter)

            def wrapper(cls):
                # register the class and return it unchanged
                self.adapters[cls] = adapter
                return cls

            return wrapper

        elif len(args) == 1 and isinstance(args[0], type):
            # called as a class decorator @register without parentheses -
            # we are passed the class definition here
            cls = args[0]
            self.adapters[cls] = AutoAdapter()
            return cls

        else:
            raise TypeError(
                "register must be called as register(adapter, cls) or as a class decorator - "
                "@register or @register(adapter=MyAdapter())"
            )


    def find_adapter(self, cls):
        for base in cls.__mro__:
            adapter = self.adapters.get(base)
            if adapter is not None:
                return adapter

    @cached_property
    def js_context_class(self):
        return type('JSContext', (self.js_context_base_class,), {
            'registry': self,
            'telepath_js_path': self.telepath_js_path
        })


class ValueContext:
    """
    A context instantiated for each top-level value that JSContext.pack is called on. Results from
    this context's build_node method will be kept in a lookup table. If, over the course of
    building the node tree for the top level value, we encounter multiple references to the same
    value, a reference to the existing node will be generated rather than building it again. Calls
    to add_media are passed back to the parent context so that multiple calls to pack() will have
    their media combined in a single bundle.
    """
    def __init__(self, parent_context):
        self.parent_context = parent_context
        self.registry = parent_context.registry
        self.raw_values = {}
        self.nodes = {}
        self.next_id = 0

    def add_media(self, *args, **kwargs):
        self.parent_context.add_media(*args, **kwargs)

    def build_node(self, val):
        obj_id = id(val)
        try:
            existing_node = self.nodes[obj_id]
        except KeyError:
            # not seen this value before, so build a new node for it and store in self.nodes
            node = self._build_new_node(val)
            self.nodes[obj_id] = node
            # Also keep a reference to the original value to stop it from getting deallocated
            # and the ID being recycled
            self.raw_values[obj_id] = val

            return node

        if existing_node.id is None:
            # Assign existing_node an ID so that we can create references to it
            existing_node.id = self.next_id
            self.next_id += 1

        return existing_node

    def _build_new_node(self, obj):
        adapter = self.registry.find_adapter(type(obj))
        if adapter:
            return adapter.build_node(obj, self)

        # No adapter found; try special-case fallbacks

        if isinstance(obj, Promise) and obj._delegate_text:
            # object is a lazy translation object; handle as a string, translated to the currently
            # active locale
            return StringNode(str(obj))

        # try handling as an iterable
        try:
            items = iter(obj)
        except TypeError:  # obj is not iterable
            raise UnpackableTypeError("don't know how to pack object: %r" % obj)
        else:
            return ListNode([self.build_node(item) for item in items])


# define a default registry of adapters. Typically this will be the only instance of
# AdapterRegistry in use, although packages may define their own 'private' registry if they
# have a set of adapters customised for their own use (e.g. with a custom JS path).

registry = AdapterRegistry()
JSContext = registry.js_context_class
register = registry.register
