import time
from datetime import datetime, timedelta
from functools import cached_property
from typing import Any, Dict, Optional

from linode_api4.objects.serializable import JSONObject

from .filtering import FilterableMetaclass

DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"


# The interval to reload volatile properties
volatile_refresh_timeout = timedelta(seconds=15)


class ExplicitNullValue:
    """
    An explicitly null value to set a property to.
    Instances of `NullValue` differ from None as they will be explicitly
    included in the resource PUT requests.
    """


class Property:
    def __init__(
        self,
        mutable=False,
        identifier=False,
        volatile=False,
        relationship=None,
        derived_class=None,
        is_datetime=False,
        id_relationship=False,
        slug_relationship=False,
        nullable=False,
        unordered=False,
        json_object=None,
        alias_of: Optional[str] = None,
    ):
        """
        A Property is an attribute returned from the API, and defines metadata
        about that value. These are expected to be used as the values of a
        class-level dict named 'properties' in subclasses of Base.

        :param mutable: This Property should be sent in a call to save()
        :type mutable: bool
        :param identifier: This Property identifies the object in the API
        :type identifier: bool
        :param volatile: Re-query for this Property if the local value is older than the
                         volatile refresh timeout
        :type volatile: bool
        :param relationship: The API Object this Property represents
        :type relationship: type or None
        :param derived_class: The sub-collection type this Property represents
        :type derived_class: type or None
        :param is_datetime: True if this Property should be parsed as a datetime.datetime
        :type is_datetime: bool
        :param id_relationship: This Property should create a relationship with this key as the ID
                                (This should be used on fields ending with '_id' only)
        :type id_relationship: type or None
        :param slug_relationship: This property is a slug related for a given type
        :type slug_relationship: type or None
        :param nullable: This property can be explicitly null on PUT requests
        :type nullable: bool
        :param unordered: The order of this property is not significant.
                          NOTE: This field is currently only for annotations purposes
                          and does not influence any update or decoding/encoding logic.
        :type unordered: bool
        :param json_object: The JSONObject class this property should be decoded into
        :type json_object: type or None
        :param alias_of: The original API attribute name when the property key is aliased.
                         This is useful when the API attribute name is a Python reserved word,
                         allowing you to use a different key while preserving the original name.
        :type alias_of: str or None
        """
        self.mutable = mutable
        self.identifier = identifier
        self.volatile = volatile
        self.relationship = relationship
        self.derived_class = derived_class
        self.is_datetime = is_datetime
        self.id_relationship = id_relationship
        self.slug_relationship = slug_relationship
        self.nullable = nullable
        self.unordered = unordered
        self.json_class = json_object
        self.alias_of = alias_of


class MappedObject:
    """
    Converts a dict into values accessible with the dot notation.

    object = {
        "this": "that"
    }

    becomes

    object.this # "that"
    """

    def __init__(self, **vals):
        self._expand_vals(self.__dict__, **vals)

    def _expand_vals(self, target, **vals):
        for v in vals:
            if type(vals[v]) is dict:
                vals[v] = MappedObject(**vals[v])
            elif type(vals[v]) is list:
                # oh mama
                vals[v] = [
                    MappedObject(**i) if type(i) is dict else i for i in vals[v]
                ]
        target.update(vals)

    def __repr__(self):
        return "Mapping containing {}".format(vars(self).keys())

    @staticmethod
    def _flatten_base_subclass(obj: "Base") -> Optional[Dict[str, Any]]:
        if obj is None:
            return None

        # If the object hasn't already been lazy-loaded,
        # manually refresh it
        if not getattr(obj, "_populated", False):
            obj._api_get()

        return obj._raw_json

    @property
    def dict(self):
        return self._serialize()

    def _serialize(self, is_put: bool = False) -> Dict[str, Any]:
        result = vars(self).copy()
        cls = type(self)

        for k, v in result.items():
            if isinstance(v, cls):
                result[k] = v.dict
            elif isinstance(v, list):
                result[k] = [
                    (
                        item._serialize(is_put=is_put)
                        if isinstance(item, (cls, JSONObject))
                        else (
                            self._flatten_base_subclass(item)
                            if isinstance(item, Base)
                            else item
                        )
                    )
                    for item in v
                ]
            elif isinstance(v, Base):
                result[k] = self._flatten_base_subclass(v)
            elif isinstance(v, JSONObject):
                result[k] = v._serialize(is_put=is_put)

        return result


class Base(object, metaclass=FilterableMetaclass):
    """
    The Base class knows how to look up api properties of a model, and lazy-load them.
    """

    properties = {}

    def __init__(self, client: object, id: object, json: object = {}) -> object:
        self._set("_populated", False)
        self._set("_last_updated", datetime.min)
        self._set("_client", client)
        self._set("_changed", False)

        #: self._raw_json is a copy of the json received from the API on population,
        #: and cannot be relied upon to be current.  Local changes to mutable fields
        #: that have not been saved will not be present, and volatile fields will not
        #: be updated on access.
        self._set("_raw_json", None)

        for k, v in type(self).properties.items():
            if v.identifier:
                continue

            self._set(k, None)

        self._set("id", id)
        if hasattr(type(self), "id_attribute"):
            self._set(getattr(type(self), "id_attribute"), id)

        self._populate(json)

    def __getattribute__(self, name):
        """
        Handles lazy-loading/refreshing an object from the server, and
        getting related objects, as defined in this object's 'properties'
        """
        if name in type(self).properties.keys():
            # We are accessing a Property
            if type(self).properties[name].identifier:
                pass  # don't load identifiers from the server, we have those
            elif (
                object.__getattribute__(self, name) is None
                and not self._populated
                or type(self).properties[name].derived_class
            ) or (
                type(self).properties[name].volatile
                and object.__getattribute__(self, "_last_updated")
                + volatile_refresh_timeout
                < datetime.now()
            ):
                # needs to be loaded from the server
                if type(self).properties[name].derived_class:
                    # load derived object(s)
                    self._set(
                        name,
                        type(self)
                        .properties[name]
                        .derived_class._api_get_derived(
                            self, getattr(self, "_client")
                        ),
                    )
                else:
                    self._api_get()
        elif "{}_id".format(name) in type(self).properties.keys():
            # possible id-based relationship
            related_type = (
                type(self).properties["{}_id".format(name)].id_relationship
            )
            if related_type:
                # no id, no related object
                if not getattr(self, "{}_id".format(name)):
                    return None
                # it is a relationship
                relcache_name = "_{}_relcache".format(name)
                if not hasattr(self, relcache_name):
                    self._set(
                        relcache_name,
                        related_type(
                            self._client, getattr(self, "{}_id".format(name))
                        ),
                    )
                return object.__getattribute__(self, relcache_name)

        return object.__getattribute__(self, name)

    def __repr__(self):
        """
        Returns a safe representation of this object without accessing the server
        """
        return "{}: {}".format(type(self).__name__, self.id)

    def __setattr__(self, name, value):
        """
        Enforces allowing editing of only Properties defined as mutable
        """

        if name in type(self).properties.keys():
            if not type(self).properties[name].mutable:
                raise AttributeError(
                    "'{}' is not a mutable field of '{}'".format(
                        name, type(self).__name__
                    )
                )

            self._changed = True

        self._set(name, value)

    @cached_property
    def properties_with_alias(self) -> dict[str, tuple[str, Property]]:
        """
        Gets a dictionary of aliased properties for this object.

        :returns: A dict mapping original API attribute names to their alias names and
                  corresponding Property instances.
        :rtype: dict[str, tuple[str, Property]]
        """
        return {
            prop.alias_of: (alias, prop)
            for alias, prop in type(self).properties.items()
            if prop.alias_of
        }

    def save(self, force=True) -> bool:
        """
        Send this object's mutable values to the server in a PUT request.

        :param force: If true, this method will always send a PUT request regardless of
                      whether the field has been explicitly updated. For optimization
                      purposes, this field should be set to false for typical update
                      operations. (Defaults to True)
        :type force: bool
        """
        if not force and not self._changed:
            return False

        data = None
        if not self._populated:
            data = {
                a: object.__getattribute__(self, a)
                for a in type(self).properties
                if type(self).properties[a].mutable
                and object.__getattribute__(self, a) is not None
            }

            for key, value in data.items():
                if (
                    isinstance(value, ExplicitNullValue)
                    or value == ExplicitNullValue
                ):
                    data[key] = None

            # Ensure we serialize any values that may not be already serialized
            data = _flatten_request_body_recursive(data, is_put=True)
        else:
            data = self._serialize(is_put=True)

        resp = self._client.put(type(self).api_endpoint, model=self, data=data)

        if "error" in resp:
            return False

        self._set("_changed", False)

        return True

    def delete(self):
        """
        Sends a DELETE request for this object
        """
        resp = self._client.delete(type(self).api_endpoint, model=self)

        if "error" in resp:
            return False
        self.invalidate()
        return True

    def invalidate(self):
        """
        Invalidates all non-identifier Properties this object has locally,
        causing the next access to re-fetch them from the server
        """
        for key in [
            k
            for k in type(self).properties.keys()
            if not type(self).properties[k].identifier
        ]:
            self._set(key, None)

        self._set("_populated", False)

    def _serialize(self, is_put: bool = False):
        """
        A helper method to build a dict of all mutable Properties of
        this object
        """

        result = {}

        # Aggregate mutable values into a dict
        for k, v in type(self).properties.items():
            if not v.mutable:
                continue

            value = getattr(self, k)

            if not v.nullable and (value is None or value == ""):
                continue

            # Let's allow explicit null values as both classes and instances
            if (
                isinstance(value, ExplicitNullValue)
                or value == ExplicitNullValue
            ):
                value = None

            api_key = k if not v.alias_of else v.alias_of
            result[api_key] = value

        # Resolve the underlying IDs of results
        for k, v in result.items():
            result[k] = _flatten_request_body_recursive(v, is_put=is_put)

        return result

    def _api_get(self):
        """
        A helper method to GET this object from the server
        """
        json = self._client.get(type(self).api_endpoint, model=self)
        self._populate(json)

    def _populate(self, json):
        """
        A helper method that, given a JSON object representing this object,
        assigns values based on the properties dict and the attributes of
        its Properties.
        """
        if not json:
            return

        # hide the raw JSON away in case someone needs it
        self._set("_raw_json", json)
        self._set("_updated", False)

        valid_keys = set(
            k
            for k, v in type(self).properties.items()
            if (not v.identifier) and (not v.alias_of)
        ) | set(self.properties_with_alias.keys())

        for api_key in json:
            if api_key in valid_keys:
                prop = type(self).properties.get(api_key)
                prop_key = api_key

                if prop is None:
                    prop_key, prop = self.properties_with_alias[api_key]

                if prop.relationship and json[api_key] is not None:
                    if isinstance(json[api_key], list):
                        objs = []
                        for d in json[api_key]:
                            if not "id" in d:
                                continue
                            new_class = prop.relationship
                            obj = new_class.make_instance(
                                d["id"], getattr(self, "_client")
                            )
                            if obj:
                                obj._populate(d)
                            objs.append(obj)
                        self._set(prop_key, objs)
                    else:
                        if isinstance(json[api_key], dict):
                            related_id = json[api_key]["id"]
                        else:
                            related_id = json[api_key]
                        new_class = prop.relationship
                        obj = new_class.make_instance(
                            related_id, getattr(self, "_client")
                        )
                        if obj and isinstance(json[api_key], dict):
                            obj._populate(json[api_key])
                        self._set(prop_key, obj)
                elif prop.slug_relationship and json[api_key] is not None:
                    # create an object of the expected type with the given slug
                    self._set(
                        prop_key,
                        prop.slug_relationship(self._client, json[api_key]),
                    )
                elif prop.json_class:
                    json_class = prop.json_class
                    json_value = json[api_key]

                    # build JSON object
                    if isinstance(json_value, list):
                        # We need special handling for list responses
                        value = [json_class.from_json(v) for v in json_value]
                    else:
                        value = json_class.from_json(json_value)

                    self._set(prop_key, value)
                elif type(json[api_key]) is dict:
                    self._set(prop_key, MappedObject(**json[api_key]))
                elif type(json[api_key]) is list:
                    # we're going to use MappedObject's behavior with lists to
                    # expand these, then grab the resulting value to set
                    mapping = MappedObject(_list=json[api_key])
                    self._set(
                        prop_key, mapping._list
                    )  # pylint: disable=no-member
                elif prop.is_datetime:
                    try:
                        t = time.strptime(json[api_key], DATE_FORMAT)
                        self._set(
                            prop_key, datetime.fromtimestamp(time.mktime(t))
                        )
                    except:
                        # if this came back, there's probably an issue with the
                        # python library; a field was marked as a datetime but
                        # wasn't in the expected format.
                        self._set(prop_key, json[api_key])
                else:
                    self._set(prop_key, json[api_key])

        self._set("_populated", True)
        self._set("_last_updated", datetime.now())

    def _set(self, name, value):
        """
        A helper method to set values of Properties without invoking
        the overloaded __setattr__
        """
        object.__setattr__(self, name, value)

    @classmethod
    def api_list(cls):
        """
        Returns a URL that will produce a list of JSON objects
        of this class' type
        """
        return "/".join(cls.api_endpoint.split("/")[:-1])

    @staticmethod
    def make(id, client, cls, parent_id=None, json=None):
        """
        Makes an api object based on an id and class.

        :param id: The id of the object to create
        :param client: The LinodeClient to give the new object
        :param cls: The class type to instantiate
        :param parent_id: The parent id for derived classes
        :param json: The JSON to use to populate the new class

        :returns: An instance of cls with the given id
        """
        from .dbase import DerivedBase  # pylint: disable-all

        if issubclass(cls, DerivedBase):
            return cls(client, id, parent_id, json)
        else:
            return cls(client, id, json)

    @classmethod
    def make_instance(cls, id, client, parent_id=None, json=None):
        """
        Makes an instance of the class this is called on and returns it.

        The intended usage is:
          instance = Linode.make_instance(123, client, json=response)

        :param cls: The class this was called on.
        :param id: The id of the instance to create
        :param client: The client to use for this instance
        :param parent_id: The parent id for derived classes
        :param json: The JSON to populate the instance with

        :returns: A new instance of this type, populated with json
        """
        return Base.make(id, client, cls, parent_id=parent_id, json=json)


def _flatten_request_body_recursive(data: Any, is_put: bool = False) -> Any:
    """
    This is a helper recursively flatten the given data for use in an API request body.

    NOTE: This helper does NOT raise an error if an attribute is
    not known to be JSON serializable.

    :param data: Arbitrary data to flatten.
    :return: The serialized data.
    """

    if isinstance(data, dict):
        return {
            k: _flatten_request_body_recursive(v, is_put=is_put)
            for k, v in data.items()
        }

    if isinstance(data, list):
        return [_flatten_request_body_recursive(v, is_put=is_put) for v in data]

    if isinstance(data, Base):
        return data.id

    if isinstance(data, ExplicitNullValue) or data == ExplicitNullValue:
        return None

    if isinstance(data, MappedObject) or issubclass(type(data), JSONObject):
        return data._serialize(is_put=is_put)

    return data
