# Luca de Alfaro, 2020
# BSD License

import datetime, pytz
import json, zlib, base64
from numbers import Real

def get_current_time():
    return datetime.datetime.now(datetime.timezone.utc)


class NQDBTypeError(Exception):
    """This is the error generated by incorrect assignments to properties."""
    pass

class Property(object):
    """A property that can be set."""

    __reserved__ = set()

    def __init__(self, name, indexed=True, repeated=False, default=None, required=False):
        """
        Intializes a property.
        @param name: name of the property.  Must coincide with the name of the class
            member to which the property is assigned.
        @param indexed: boolean, indicates whether the property is indexed.
        @param repeated: boolean, if true, the property is stored as a list.
        @param default: default value.
        @param required: if True, it cannot be left as None.
        """
        super().__init__()
        assert "." not in name
        assert " " not in name
        assert name not in Property.__reserved__
        assert not name.startswith("_")
        self._name = name
        self._indexed = indexed
        self._default = default
        self._repeated = repeated
        self._required = required

    # Assignment to property.  These translate into / out of the datastore
    # Entity, taking care of any desired type conversion.

    def __set__(self, ent, value):
        """Sets the entity field to a given value."""
        ent._entity[self._name] = self._typecheck(value)

    def __get__(self, ent, cls):
        """Returns the value of the entity."""
        # If ent is None, this is called to form a query, and we
        # need to return self to then process the comparisons.
        # Otherwise, we return the value of the entity.
        return self if ent is None else ent._entity.get(self._name, self._default)

    def _typecheck(self, value):
        """Type-checks a value before assigning it, returning the typechecked value."""
        return self._check_instance(value, True)

    def _check_instance(self, v, t):
        """Checks that the value v is an instance of a type t, or a list
        of objects of type t. t can either be a type, or a function, or True,
        in which case no check is performed."""
        if v is None:
            if self._required:
                raise NQDBTypeError("Value for {name} cannot be None".format(name=self._name))
            else:
                return v # None is accepted.
        if self._repeated:
            # We must return a list.
            vv = list(v) if (isinstance(v, list) or isinstance(v, tuple)) else [v]
            if isinstance(t, type):
                if not all(map(lambda x: isinstance(x, t), vv)):
                    raise NQDBTypeError("Type error: {name}".format(name=self._name))
            elif t is not True:
                if not all(map(t, vv)):
                    raise NQDBTypeError("Type error: {name}".format(name=self._name))
            return vv
        else:
            # Not a list.
            if isinstance(t, type):
                if not isinstance(v, t):
                    raise NQDBTypeError("Type error: {name} is {tp} instead of {tt}".format(name=self._name, tp=type(v), tt=t))
            elif t is not True:
                if not t(v):
                    raise NQDBTypeError("Type error: {name} is {tp}, fails check".format(name=self._name, tp=type(v)))
            return v

    # Support for serialization.
    def to_json(self, ent):
        v = ent._entity.get(self._name)
        return None if v is None else v

    def from_json(self, ent, s):
        ent._entity[self._name] = s

    # Sets the initial value.
    def init_hook(self, ent):
        if self._default is not None:
            if callable(self._default):
                ent._entity[self._name] = self._default()
            else:
                ent._entity[self._name] = self._default

    # Read/write hooks
    def read_hook(self, ent):
        pass

    def write_hook(self, ent):
        if ent._entity.get(self._name) is None and self._required:
            raise NQDBTypeError("Field {name} is required".format(name=self._name))

    # Support for building queries.
    def __eq__(self, other):
        return self._name, "=", other

    def __lt__(self, other):
        return self._name, "<", other

    def __le__(self, other):
        return self._name, "<=", other

    def __gt__(self, other):
        return self._name, ">", other

    def __ge__(self, other):
        return self._name, ">=", other

    # Support for building orderings.
    def __pos__(self):
        return self._name

    def __neg__(self):
        return "-" + self._name


class StringProperty(Property):
    def _typecheck(self, value):
        return self._check_instance(value, str)

class IntegerProperty(Property):
    def _typecheck(self, value):
        return self._check_instance(value, int)

class FloatProperty(Property):
    def _typecheck(self, value):
        return self._check_instance(value, Real)

class BooleanProperty(Property):
    def _typecheck(self, value):
        return self._check_instance(value, bool)


class DateTimeProperty(Property):
    def __init__(self, name, auto_now=False, auto_now_add=False,
                 default=None, indexed=True, repeated=False):
        """Initializes the property.
        :param default: default value.  If specified, takes the precedence after auto_now.
        :param auto_now: equivalent to update value.  If True, takes precedence.
        :param auto_now_add: If true, the default is to use current time."""
        super().__init__(name, indexed=indexed)
        self._auto_now = auto_now
        self._default = get_current_time if auto_now_add else default
        self._repeated = repeated

    def write_hook(self, ent):
        """Hooks that implements the auto-now properties."""
        if self._auto_now:
            ent._entity[self._name] = get_current_time()
            print("--Setting current time:", ent._entity[self._name].isoformat())
        super().write_hook(ent)

    def _typecheck(self, value):
        def is_utc(v):
            return isinstance(v, datetime.datetime) and (
                    v.tzinfo in [datetime.timezone.utc, pytz.UTC, pytz.utc])
        return self._check_instance(value, is_utc)

    def to_json(self, ent):
        """Translates the datetime to string."""
        # Gets the datetime d.
        d = ent._entity.get(self._name)
        if d is None:
            return None
        elif self._repeated:
            return list(x.isoformat() for x in d)
        else:
            return d.isoformat()

    def from_json(self, ent, s):
        if not s:
            tmp = None
        else:
            if self._repeated:
                tmp = [datetime.datetime.fromisoformat(x) for x in s]
            else:
                tmp = datetime.datetime.fromisoformat(s)
        ent._entity[self._name] = tmp


class TextProperty(Property):
    def __init__(self, name, default=None, compressed=False):
        super().__init__(name, indexed=False, default=default)
        self._compressed = compressed
        self._indexed = False

    def __set__(self, ent, value):
        """Sets the entity field to a given value."""
        vv = self._typecheck(value)
        v = zlib.compress(vv.encode('utf-8')) if self._compressed else vv
        ent._entity[self._name] = v

    def __get__(self, ent, cls):
        """Returns the value of the entity."""
        v = ent._entity.get(self._name)
        return zlib.decompress(v).decode('utf-8') if self._compressed else v

    def _typecheck(self, value):
        return self._check_instance(value, str)

    def to_json(self, ent):
        d = ent._entity.get(self._name)
        if self._compressed:
            return None if d is None else base64.b64encode(d).decode('utf-8')
        else:
            return d

    def from_json(self, ent, s):
        if self._compressed:
            if not s:
                tmp = None
            else:
                tmp = base64.b64decode(s.encode('utf-8'))
        else:
            tmp = s
        ent._entity[self._name] = tmp


class JsonProperty(Property):
    def __init__(self, name, encoder=json, default=None, compressed=True):
        super().__init__(name, indexed=False, default=default)
        self._compressed = compressed
        self._indexed = False
        self._encoder = encoder

    def __set__(self, ent, value):
        """Sets the entity field to a given value."""
        if value is None:
            s = None
        else:
            s = self._encoder.dumps(value).encode('utf-8')
            s = b"!" + zlib.compress(s) if self._compressed else s
        ent._entity[self._name] = s

    def __get__(self, ent, cls):
        """Reads the value.  We self-detect whether it's compressed due to the
        presence of the initial !."""
        v = ent._entity.get(self._name)
        if v is None:
            return None
        # The odd form for this test is due to the fact that v[0] is a byte,
        # and so, an integer, while v[0:1] is a byte string of 1 byte.
        if len(v) > 0 and v[0:1] == b"!":
            v = zlib.decompress(v[1:])
        return self._encoder.loads(v.decode('utf-8'))

    def to_json(self, ent):
        """Translates to string, noting that the value itself is a bytestring."""
        d = ent._entity.get(self._name)
        return d if d is None else base64.b64encode(d).decode('utf-8')

    def from_json(self, ent, s):
        if not s:
            tmp = None
        else:
            tmp = base64.b64decode(s.encode('utf-8'))
        ent._entity[self._name] = tmp


class BlobProperty(Property):
    """Unindexed byte blob."""

    def __init__(self, name, default=None, compressed=False):
        super().__init__(name, indexed=False, default=default)
        self._compressed = compressed
        self._indexed = False

    def __set__(self, ent, value):
        """Sets the entity field to a given value."""
        if value is None:
            v = None
        else:
            v = zlib.compress(value) if self._compressed else value
        ent._entity[self._name] = v

    def __get__(self, ent, cls):
        """Returns the value of the entity."""
        v = ent._entity.get(self._name)
        if v is None:
            return None
        return zlib.decompress(v) if self._compressed else v

    def _typecheck(self, value):
        return self._check_instance(value, bytes)

    def to_json(self, ent):
        """Translates to string, noting that the value itself is a bytestring."""
        d = ent._entity.get(self._name)
        return None if d is None else base64.b64encode(d).decode('utf-8')

    def from_json(self, ent, s):
        if not s:
            tmp = None
        else:
            tmp = base64.b64decode(s.encode('utf-8'))
        ent._entity[self._name] = tmp
