# MIT License

# Desert
# Copyright (c) 2019 Desert contributors

# With sections from marshmallow_dataclass
# Copyright (c) 2019 Ophir LOJKINE

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.


"""
This library allows the conversion of python 3.7's :mod:`dataclasses`
to :mod:`marshmallow` schemas.
It takes a python class, and generates a marshmallow schema for it.
Simple example::
    from marshmallow import Schema
    from marshmallow_dataclass import dataclass
    @dataclass
    class Point:
      x:float
      y:float
    point = Point(x=0, y=0)
    point_json = Point.Schema().dumps(point)
Full example::
    from marshmallow import Schema
    from dataclasses import field
    from marshmallow_dataclass import dataclass
    import datetime
    @dataclass
    class User:
      birth: datetime.date = field(metadata= {
        "required": True # A parameter to pass to marshmallow's field
      })
      website:str = field(metadata = {
        "marshmallow_field": marshmallow.fields.Url() # Custom marshmallow field
      })
      Schema: t.ClassVar[Type[Schema]] = Schema # For the type checker
"""

__all__ = ("schema_class", "schema")

import dataclasses
import datetime
import decimal
import enum
import inspect
import typing as t
import uuid

import attr
import marshmallow
import typing_inspect

import desert.exceptions


__all__ = ["dataclass", "add_schema", "class_schema", "field_for_schema"]

NoneType = type(None)
T = t.TypeVar("T")


def class_schema(
    clazz: type, meta: t.Dict[str, t.Any] = {}
) -> t.Type[marshmallow.Schema]:
    """
    Convert a class to a marshmallow schema
    :param clazz: A python class (may be a dataclass)
    :return: A marshmallow Schema corresponding to the dataclass
    .. note::
        All the arguments supported by marshmallow field classes are can
        be passed in the `metadata` dictionary of a field.
    If you want to use a custom marshmallow field
    (one that has no equivalent python type), you can pass it as the
    ``marshmallow_field`` key in the metadata dictionary.
    """

    fields: t.Union[t.Tuple[dataclasses.Field], t.Tuple[attr.Attribute]]

    if not isinstance(clazz, type):
        raise desert.exceptions.UnknownType(
            f"Desert failed to infer the field type for {clazz}.\n"
            + "Explicitly pass a Marshmallow field type."
        )
    if dataclasses.is_dataclass(clazz):
        fields = dataclasses.fields(clazz)
    elif attr.has(clazz):
        fields = attr.fields(clazz)
    elif issubclass(clazz, (list, dict)):
        raise desert.exceptions.UnknownType(
            "Use parametrized generics like t.List[int] or t.Dict[str, int] "
            f"instead of list and dict. Got {clazz}."
        )
    else:
        raise desert.exceptions.NotAnAttrsClassOrDataclass(clazz)

    # Copy all public members of the dataclass to the schema
    attributes = {k: v for k, v in inspect.getmembers(clazz) if not k.startswith("_")}
    # Update the schema members to contain marshmallow fields instead of dataclass fields

    hints = t.get_type_hints(clazz)
    for field in fields:
        if field.init:
            attributes[field.name] = field_for_schema(
                hints.get(field.name, field.type),
                _get_field_default(field),
                field.metadata,
            )

    cls_schema = type(
        clazz.__name__,
        (_base_schema(clazz),),
        {**attributes, "Meta": type("Meta", (), meta)},
    )

    return t.cast(t.Type[marshmallow.Schema], cls_schema)


_native_to_marshmallow: t.Dict[type, t.Type[marshmallow.fields.Field]] = {
    int: marshmallow.fields.Integer,
    float: marshmallow.fields.Float,
    str: marshmallow.fields.String,
    bool: marshmallow.fields.Boolean,
    datetime.datetime: marshmallow.fields.DateTime,
    datetime.time: marshmallow.fields.Time,
    datetime.timedelta: marshmallow.fields.TimeDelta,
    datetime.date: marshmallow.fields.Date,
    decimal.Decimal: marshmallow.fields.Decimal,
    uuid.UUID: marshmallow.fields.UUID,
    t.Any: marshmallow.fields.Raw,
}


class VariadicTuple(marshmallow.fields.List):
    """Homogenous tuple with variable number of entries."""

    def _deserialize(self, *args, **kwargs):
        return tuple(super()._deserialize(*args, **kwargs))


def only(items: t.Iterable[T]) -> T:
    """Return the only item in an iterable or raise ValueError."""
    [x] = items
    return x


def field_for_schema(
    typ: type, default=marshmallow.missing, metadata: t.Mapping[str, t.Any] = None
) -> marshmallow.fields.Field:
    """
    Get a marshmallow Field corresponding to the given python type.
    The metadata of the dataclass field is used as arguments to the marshmallow Field.
    >>> int_field = field_for_schema(int, default=9, metadata=dict(required=True))
    >>> int_field.__class__
    <class 'marshmallow.fields.Integer'>

    >>> int_field.default
    9
    >>> field_for_schema(t.Dict[str,str]).__class__
    <class 'marshmallow.fields.Dict'>
    >>> field_for_schema(t.Optional[str]).__class__
    <class 'marshmallow.fields.String'>
    >>> import marshmallow_enum
    >>> field_for_schema(enum.Enum("X", "a b c")).__class__
    <class 'marshmallow_enum.EnumField'>
    >>> import typing
    >>> field_for_schema(t.Union[int,str]).__class__
    <class 'marshmallow_union.Union'>
    >>> field_for_schema(t.NewType('UserId', int)).__class__
    <class 'marshmallow.fields.Integer'>
    >>> field_for_schema(t.NewType('UserId', int), default=0).default
    0
    >>> class Color(enum.Enum):
    ...   red = 1
    >>> field_for_schema(Color).__class__
    <class 'marshmallow_enum.EnumField'>
    >>> field_for_schema(t.Any).__class__
    <class 'marshmallow.fields.Raw'>
    """

    if metadata is None:
        metadata = {}
    else:
        metadata = dict(metadata)

    desert_metadata = dict(metadata).get(_DESERT_SENTINEL, {})
    metadata[_DESERT_SENTINEL] = desert_metadata

    if default is not marshmallow.missing:
        desert_metadata.setdefault("default", default)
        desert_metadata.setdefault("allow_none", True)
        if not desert_metadata.get(
            "required"
        ):  # 'missing' must not be set for required fields.
            desert_metadata.setdefault("missing", default)

    field = None

    # If the field was already defined by the user
    predefined_field = desert_metadata.get("marshmallow_field")

    if predefined_field:
        field = predefined_field
        field.metadata.update(metadata)
        return field

    # Base types
    if not field and typ in _native_to_marshmallow:
        field = _native_to_marshmallow[typ](default=default)

    # Generic types
    origin = typing_inspect.get_origin(typ)

    if origin:
        arguments = typing_inspect.get_args(typ, True)

        if origin in (list, t.List):
            field = marshmallow.fields.List(field_for_schema(arguments[0]))

        if origin in (tuple, t.Tuple) and Ellipsis not in arguments:
            field = marshmallow.fields.Tuple(
                tuple(field_for_schema(arg) for arg in arguments)
            )
        elif origin in (tuple, t.Tuple) and Ellipsis in arguments:

            field = VariadicTuple(
                field_for_schema(only(arg for arg in arguments if arg != Ellipsis))
            )
        elif origin in (dict, t.Dict):
            field = marshmallow.fields.Dict(
                keys=field_for_schema(arguments[0]),
                values=field_for_schema(arguments[1]),
            )
        elif typing_inspect.is_optional_type(typ):
            subtyp = next(t for t in arguments if t is not NoneType)
            # Treat optional types as types with a None default
            metadata[_DESERT_SENTINEL]["default"] = metadata.get("default", None)
            metadata[_DESERT_SENTINEL]["missing"] = metadata.get("missing", None)
            metadata[_DESERT_SENTINEL]["required"] = False

            field = field_for_schema(subtyp, metadata=metadata, default=None)
            field.default = None
            field.missing = None
            field.allow_none = True

        elif typing_inspect.is_union_type(typ):
            subfields = [field_for_schema(subtyp) for subtyp in arguments]
            import marshmallow_union

            field = marshmallow_union.Union(subfields)

    # t.NewType returns a function with a __supertype__ attribute
    newtype_supertype = getattr(typ, "__supertype__", None)
    if newtype_supertype and inspect.isfunction(typ):
        metadata.setdefault("description", typ.__name__)
        field = field_for_schema(newtype_supertype, default=default)

    # enumerations
    if type(typ) is enum.EnumMeta:
        import marshmallow_enum

        field = marshmallow_enum.EnumField(typ, metadata=metadata)

    # Nested dataclasses
    forward_reference = getattr(typ, "__forward_arg__", None)

    if field is None:
        nested = forward_reference or class_schema(typ)
        try:
            nested.help = typ.__doc__
        except AttributeError:
            # TODO need to handle the case where nested is a string forward reference.
            pass
        field = marshmallow.fields.Nested(nested)

    field.metadata.update(metadata)

    for key in ["default", "missing", "required", "marshmallow_field"]:
        if key in metadata.keys():
            metadata[_DESERT_SENTINEL][key] = metadata.pop(key)

    if field.default == field.missing == default == marshmallow.missing:
        field.required = True

    return field


def _base_schema(clazz: type) -> t.Type[marshmallow.Schema]:
    class BaseSchema(marshmallow.Schema):
        @marshmallow.post_load
        def make_data_class(self, data, **_):
            return clazz(**data)

    return BaseSchema


def _get_field_default(field: t.Union[dataclasses.Field, attr.Attribute]):
    """
    Return a marshmallow default value given a dataclass default value
    >>> _get_field_default(dataclasses.field())
    <marshmallow.missing>
    """
    if isinstance(field, dataclasses.Field):
        if field.default_factory != dataclasses.MISSING:
            return dataclasses.MISSING
        if field.default is dataclasses.MISSING:
            return marshmallow.missing
        return field.default
    elif isinstance(field, attr.Attribute):
        if field.default == attr.NOTHING:
            return marshmallow.missing
        if isinstance(field.default, attr.Factory):
            if field.default.takes_self:
                return attr.NOTHING
            return field.default.factory
        return field.default
    else:
        raise TypeError(field)


def sentinel(name):
    return attr.make_class(name, [], frozen=True)()


_DESERT_SENTINEL = sentinel("_DesertSentinel")
