"""
Classes used to support string serialization of Parameters and
Parameterized objects.
"""

import json
import textwrap


class UnserializableException(Exception):
    pass


class UnsafeserializableException(Exception):
    pass


def JSONNullable(json_type):
    "Express a JSON schema type as nullable to easily support Parameters that allow_None"
    return {"anyOf": [json_type, {"type": "null"}]}


class Serialization(object):
    """
    Base class used to implement different types of serialization.
    """

    @classmethod
    def schema(cls, pobj, subset=None):
        raise NotImplementedError  # noqa: unimplemented method

    @classmethod
    def serialize_parameters(cls, pobj, subset=None):
        """
        Serialize the parameters on a Parameterized object into a
        single serialized object, e.g. a JSON string.
        """
        raise NotImplementedError  # noqa: unimplemented method

    @classmethod
    def deserialize_parameters(cls, pobj, serialized, subset=None):
        """
        Deserialize a serialized object representing one or
        more Parameters into a dictionary of parameter values.
        """
        raise NotImplementedError  # noqa: unimplemented method

    @classmethod
    def serialize_parameter_value(cls, pobj, pname):
        """
        Serialize a single parameter value.
        """
        raise NotImplementedError  # noqa: unimplemented method

    @classmethod
    def deserialize_parameter_value(cls, pobj, pname, value):
        """
        Deserialize a single parameter value.
        """
        raise NotImplementedError  # noqa: unimplemented method


class JSONSerialization(Serialization):
    """
    Class responsible for specifying JSON serialization, deserialization
    and JSON schemas for Parameters and Parameterized classes and
    objects.
    """

    unserializable_parameter_types = ["Callable"]

    json_schema_literal_types = {int: "integer", float: "number", str: "string", type(None): "null"}

    @classmethod
    def loads(cls, serialized):
        return json.loads(serialized)

    @classmethod
    def dumps(cls, obj):
        return json.dumps(obj)

    @classmethod
    def schema(cls, pobj, safe=False, subset=None):
        schema = {}
        for name, p in pobj.param.objects("existing").items():
            if subset is not None and name not in subset:
                continue
            schema[name] = p.schema(safe=safe)
            if p.doc:
                schema[name]["description"] = textwrap.dedent(p.doc).replace("\n", " ").strip()
            if p.label:
                schema[name]["title"] = p.label
        return schema

    @classmethod
    def serialize_parameters(cls, pobj, subset=None):
        # components = {}
        # for name, p in pobj.param.objects('existing').items():
        #     if subset is not None and name not in subset:
        #         continue
        #     value = pobj.param.get_value_generator(name)
        #     components[name] = p.serialize(value)
        # return cls.dumps(components)
        JSON = {}
        pobjtype = type(pobj)
        for key, param in pobj.parameters.objects().items():
            if subset is not None and key not in subset:
                pass
            else:
                value = param.__get__(pobj, pobjtype)
                value = param.serialize(value)
                JSON[key] = value
        return JSON

    @classmethod
    def deserialize_parameters(cls, pobj, serialization, subset=None):
        deserialized = cls.loads(serialization)
        components = {}
        for name, value in deserialized.items():
            if subset is not None and name not in subset:
                continue
            deserialized = pobj.param[name].deserialize(value)
            components[name] = deserialized
        return components

    # Parameter level methods

    @classmethod
    def _get_method(cls, ptype, suffix):
        "Returns specialized method if available, otherwise None"
        method_name = ptype.lower() + "_" + suffix
        return getattr(cls, method_name, None)

    @classmethod
    def param_schema(cls, ptype, p, safe=False, subset=None):
        if ptype in cls.unserializable_parameter_types:
            raise UnserializableException
        dispatch_method = cls._get_method(ptype, "schema")
        if dispatch_method:
            schema = dispatch_method(p, safe=safe)
        else:
            schema = {"type": ptype.lower()}
        return JSONNullable(schema) if p.allow_None else schema

    @classmethod
    def serialize_parameter_value(cls, pobj, pname):
        value = pobj.param.get_value_generator(pname)
        return cls.dumps(pobj.param[pname].serialize(value))

    @classmethod
    def deserialize_parameter_value(cls, pobj, pname, value):
        value = cls.loads(value)
        return pobj.param[pname].deserialize(value)

    # Custom Schemas

    @classmethod
    def class__schema(cls, class_, safe=False):
        from .parameterized import Parameterized

        if isinstance(class_, tuple):
            return {"anyOf": [cls.class__schema(cls_) for cls_ in class_]}
        elif class_ in cls.json_schema_literal_types:
            return {"type": cls.json_schema_literal_types[class_]}
        elif issubclass(class_, Parameterized):
            return {"type": "object", "properties": class_.param.schema(safe)}
        else:
            return {"type": "object"}

    @classmethod
    def array_schema(cls, p, safe=False):
        if safe is True:
            msg = "Array is not guaranteed to be safe for serialization as the dtype is unknown"
            raise UnsafeserializableException(msg)
        return {"type": "array"}

    @classmethod
    def classselector_schema(cls, p, safe=False):
        return cls.class__schema(p.class_, safe=safe)

    @classmethod
    def dict_schema(cls, p, safe=False):
        if safe is True:
            msg = "Dict is not guaranteed to be safe for serialization as the key and value types are unknown"
            raise UnsafeserializableException(msg)
        return {"type": "object"}

    @classmethod
    def date_schema(cls, p, safe=False):
        return {"type": "string", "format": "date-time"}

    @classmethod
    def calendardate_schema(cls, p, safe=False):
        return {"type": "string", "format": "date"}

    @classmethod
    def tuple_schema(cls, p, safe=False):
        schema = {"type": "array"}
        if p.length is not None:
            schema["minItems"] = p.length
            schema["maxItems"] = p.length
        return schema

    @classmethod
    def number_schema(cls, p, safe=False):
        schema = {"type": p.__class__.__name__.lower()}
        return cls.declare_numeric_bounds(schema, p.bounds, p.inclusive_bounds)

    @classmethod
    def declare_numeric_bounds(cls, schema, bounds, inclusive_bounds):
        "Given an applicable numeric schema, augment with bounds information"
        if bounds is not None:
            (low, high) = bounds
            if low is not None:
                key = "minimum" if inclusive_bounds[0] else "exclusiveMinimum"
                schema[key] = low
            if high is not None:
                key = "maximum" if inclusive_bounds[1] else "exclusiveMaximum"
                schema[key] = high
        return schema

    @classmethod
    def integer_schema(cls, p, safe=False):
        return cls.number_schema(p)

    @classmethod
    def numerictuple_schema(cls, p, safe=False):
        schema = cls.tuple_schema(p, safe=safe)
        schema["additionalItems"] = {"type": "number"}
        return schema

    @classmethod
    def xycoordinates_schema(cls, p, safe=False):
        return cls.numerictuple_schema(p, safe=safe)

    @classmethod
    def range_schema(cls, p, safe=False):
        schema = cls.tuple_schema(p, safe=safe)
        bounded_number = cls.declare_numeric_bounds({"type": "number"}, p.bounds, p.inclusive_bounds)
        schema["additionalItems"] = bounded_number
        return schema

    @classmethod
    def list_schema(cls, p, safe=False):
        schema = {"type": "array"}
        if safe is True and p.item_type is None:
            msg = "List without a class specified cannot be guaranteed to be safe for serialization"
            raise UnsafeserializableException(msg)
        if p.class_ is not None:
            schema["items"] = cls.class__schema(p.item_type, safe=safe)
        return schema

    @classmethod
    def objectselector_schema(cls, p, safe=False):
        try:
            allowed_types = [{"type": cls.json_schema_literal_types[type(obj)]} for obj in p.objects]
            schema = {"anyOf": allowed_types}
            schema["enum"] = p.objects
            return schema
        except:
            if safe is True:
                msg = (
                    "ObjectSelector cannot be guaranteed to be safe for "
                    "serialization due to unserializable type in objects"
                )
                raise UnsafeserializableException(msg)
            return {}

    @classmethod
    def selector_schema(cls, p, safe=False):
        try:
            allowed_types = [{"type": cls.json_schema_literal_types[type(obj)]} for obj in p.objects.values()]
            schema = {"anyOf": allowed_types}
            schema["enum"] = p.objects
            return schema
        except:
            if safe is True:
                msg = "Selector cannot be guaranteed to be safe for serialization due to unserializable type in objects"
                raise UnsafeserializableException(msg)
            return {}

    @classmethod
    def listselector_schema(cls, p, safe=False):
        if p.objects is None:
            if safe is True:
                msg = "ListSelector cannot be guaranteed to be safe for serialization as allowed objects unspecified"
            return {"type": "array"}
        for obj in p.objects:
            if type(obj) not in cls.json_schema_literal_types:
                msg = "ListSelector cannot serialize type %s" % type(obj)
                raise UnserializableException(msg)
        return {"type": "array", "items": {"enum": p.objects}}

    @classmethod
    def dataframe_schema(cls, p, safe=False):
        schema = {"type": "array"}
        if safe is True:
            msg = "DataFrame is not guaranteed to be safe for serialization as the column dtypes are unknown"
            raise UnsafeserializableException(msg)
        if p.columns is None:
            schema["items"] = {"type": "object"}
            return schema

        mincols, maxcols = None, None
        if isinstance(p.columns, int):
            mincols, maxcols = p.columns, p.columns
        elif isinstance(p.columns, tuple):
            mincols, maxcols = p.columns

        if isinstance(p.columns, int) or isinstance(p.columns, tuple):
            schema["items"] = {"type": "object", "minItems": mincols, "maxItems": maxcols}

        if isinstance(p.columns, list) or isinstance(p.columns, set):
            literal_types = [{"type": el} for el in cls.json_schema_literal_types.values()]
            allowable_types = {"anyOf": literal_types}
            properties = {name: allowable_types for name in p.columns}
            schema["items"] = {"type": "object", "properties": properties}

        minrows, maxrows = None, None
        if isinstance(p.rows, int):
            minrows, maxrows = p.rows, p.rows
        elif isinstance(p.rows, tuple):
            minrows, maxrows = p.rows

        if minrows is not None:
            schema["minItems"] = minrows
        if maxrows is not None:
            schema["maxItems"] = maxrows

        return schema


serializers = dict(json=JSONSerialization)
