"""

    Data types and validators (PointProperty) for working with neo4j's spatial data types through neomodel.

    `spatial_properties` offers two classes: NeomodelPoint, PointProperty that marshal data to and from a neo4j dbms,
    with the added capability of being Shapely objects. Therefore, points retrieved with Neomodel can readily be used
    in further geometric (via Shapely) or geospatial (via PySAL) operations.

    * More information on Neo4J's spatial data types:
        * https://neo4j.com/docs/developer-manual/3.4/cypher/syntax/spatial/

    * More information on the Python driver's data types:
        * https://neo4j.com/docs/api/python-driver/1.7-preview/types.html#spatial-types

    * More information about Shapely's spatial data types:
        * http://toblerity.org/shapely/manual.html#geometric-objects

    * More information about PySAL through Shapely:
        * https://pysal.readthedocs.io/en/latest/users/tutorials/shapely.html
"""

__author__ = "Athanasios Anastasiou"

import neo4j.spatial

# If shapely is not installed, its import will fail and the spatial properties will not be available
try:
    from shapely.geometry import Point as ShapelyPoint
except ImportError:
    raise ImportError('NEOMODEL ERROR: Shapely not found. If required, you can install Shapely via '
                      '`pip install shapely`.')

from neomodel.properties import Property, validator

# Note: Depending on how Neo4J decides to handle the resolution of geographical points, these two
# private attributes might have to be updated in the future or removed altogether.
# They help in trying to resolve which class needs to be instantiated based on the data received from the dbms
# and doing it in a concise way that minimises errors.
#
# Acceptable Coordinate Reference Systems
ACCEPTABLE_CRS = ['cartesian', 'cartesian-3d', 'wgs-84', 'wgs-84-3d']
# A simple CRS to SRID mapping
SRID_TO_CRS = {7203: 'cartesian', 9157: 'cartesian-3d', 4326: 'wgs-84', 4979: 'wgs-84-3d'}


class NeomodelPoint(ShapelyPoint):
    """
    Abstracts the Point spatial data type of Neo4j.

    Note:
    At the time of writing, Neo4j supports 2 main variants of Point:
        1. A generic point defined over a Cartesian plane
            * The minimum data to define a point is x, y [,z] when crs is either "cartesian" or "cartesian-3d"
        2. A generic point defined over the WGS84 ellipsoid
            * The minimum data to define a point is longitude, latitude [,Height] and the crs is then assumed
              to be "wgs-84".
    """

    # def __init__(self, *args, crs=None, x=None, y=None, z=None, latitude=None, longitude=None, height=None, **kwargs):
    def __init__(self, *args, **kwargs):
        """
        Creates a NeomodelPoint.

        :param args: Positional arguments to emulate the behaviour of Shapely's Point (and specifically the copy
        constructor)
        :type args: list
        :param crs: Coordinate Reference System, must be one of ['cartesian', 'cartesian-3d', 'wgs-84', 'wgs-84-3d']
        :type crs: str
        :param x: x coordinate of point
        :type x: float
        :param y: y coordinate of point
        :type y: float
        :param z: z coordinate of point if the crs is cartesian-3d
        :type z: float
        :param latitude: Latitude of point
        :type latitude: float
        :param longitude: Longitude of point
        :type longitude: float
        :param height: Height of point if the crs is wgs-84-3d
        :type height: float
        :param kwargs: Dictionary of keyword arguments
        :type kwargs: dict
        """

        # Python2.7 Workaround for the order that the arguments get passed to the functions
        crs = kwargs.pop('crs', None)
        x = kwargs.pop('x', None)
        y = kwargs.pop('y', None)
        z = kwargs.pop('z', None)
        longitude = kwargs.pop('longitude', None)
        latitude = kwargs.pop('latitude', None)
        height = kwargs.pop('height', None)

        _x, _y, _z = None, None, None

        # CRS validity check is common to both types of constructors that follow
        if crs is not None and crs not in ACCEPTABLE_CRS:
            raise ValueError('Invalid CRS({}). Expected one of {}'.format(crs, ','.join(ACCEPTABLE_CRS)))
        self._crs = crs

        # If positional arguments have been supplied, then this is a possible call to the copy constructor or
        # initialisation by a coordinate iterable as per ShapelyPoint constructor.
        if len(args):
            # If a coordinate iterable was passed, emulate a call with x,y[,z] parameters
            if isinstance(args[0],(tuple, list)):
                # Check dimensionality of tuple
                if len(args[0]) < 2 or len(args[0]) > 3:
                    raise ValueError('Invalid vector dimensions. Expected 2 or 3, received {}'.format(len(args[0])))
                x = args[0][0]
                y = args[0][1]
                if len(args[0]) == 3:
                    z = args[0][2]
            # If another "Point" was passed, then this is a call to the copy constructor
            elif isinstance(args[0], ShapelyPoint):
                super(NeomodelPoint, self).__init__(args[0])
                # If the other Point was a NeomodelPoint then it bears the CRS that is used to
                # interpret the points and this has to be carried over.
                if isinstance(args[0], NeomodelPoint):
                    self._crs = args[0]._crs
                else:
                    # This allows NeomodelPoint((0,0),crs="wgs-84") which will interpret the tuple as
                    # (longitude,latitude) even though it was not specified as such with the named arguments.
                    #
                    # NOTE: Notice the indexing on the coordinates `args[0].coords[0]`. Coordinates are always an
                    # iterable which for Points has length of 1 but for other geometrical objects (e.g. boundaries)
                    # might be longer. This will come back to bite you, if you assume that `coords` is what it pretends
                    # to be (i.e. coords gives access to the actual coordinates NOT an iterable).
                    #
                    if len(args[0].coords[0]) == 2:
                        if crs is None:
                            self._crs = 'cartesian'
                    elif len(args[0].coords[0]) == 3:
                        if crs is None:
                            self._crs = 'cartesian-3d'
                    else:
                        raise ValueError('Invalid vector dimensions. '
                                         'Expected 2 or 3, received {}'.format(len(args[0].coords[0])))
                return
            else:
                raise TypeError('Invalid object passed to copy constructor. '
                                'Expected NeomodelPoint or shapely Point, received {}'.format(type(args[0])))

        # Initialisation is either via x,y[,z] XOR longitude,latitude[,height]. Specifying both leads to an error.
        if (x is not None or y is not None or z is not None) and \
                (latitude is not None or longitude is not None or height is not None):
            raise ValueError('Invalid instantiation via arguments. '
                             'A Point can be defined either by x,y,z coordinates OR latitude,longitude,height but not '
                             'a combination of these terms')

        # Specifying no initialisation argument at this point in the constructor is flagged as an error
        if x is None and y is None and z is None and longitude is None and latitude is None and height is None:
            raise ValueError('Invalid instantiation via no arguments. '
                             'A Point needs default values either in x,y,z or longitude, latitude, height coordinates')

        # Geographical Point Initialisation
        if latitude is not None and longitude is not None:
            if height is not None:
                if self._crs is None:
                    self._crs = 'wgs-84-3d'
                _z = height
            else:
                if self._crs is None:
                    self._crs = 'wgs-84'
            _x = longitude
            _y = latitude

        # Geometrical Point Initialisation
        if x is not None and y is not None:
            if z is not None:
                if self._crs is None:
                    self._crs = 'cartesian-3d'
                _z = z
            else:
                if self._crs is None:
                    self._crs = 'cartesian'
            _x = x
            _y = y

        if _z is None:
            if '-3d' not in self._crs:
                super(NeomodelPoint, self).__init__((float(_x),float(_y)), **kwargs)
            else:
                raise ValueError("Invalid vector dimensions(2) for given CRS({}).".format(self._crs))
        else:
            if '-3d' in self._crs:
                super(NeomodelPoint, self).__init__((float(_x), float(_y), float(_z)), **kwargs)
            else:
                raise ValueError("Invalid vector dimensions(3) for given CRS({}).".format(self._crs))

    @property
    def crs(self):
        return self._crs

    @property
    def x(self):
        if not self._crs.startswith('cartesian'):
            raise AttributeError('Invalid coordinate ("x") for points defined over {}'.format(self.crs))
        return super(NeomodelPoint, self).x

    @property
    def y(self):
        if not self._crs.startswith('cartesian'):
            raise AttributeError('Invalid coordinate ("y") for points defined over {}'.format(self.crs))
        return super(NeomodelPoint, self).y

    @property
    def z(self):
        if not self._crs == 'cartesian-3d':
            raise AttributeError('Invalid coordinate ("z") for points defined over {}'.format(self.crs))
        return super(NeomodelPoint, self).z

    @property
    def latitude(self):
        if not self._crs.startswith('wgs-84'):
            raise AttributeError('Invalid coordinate ("latitude") for points defined over {}'.format(self.crs))
        return super(NeomodelPoint, self).y

    @property
    def longitude(self):
        if not self._crs.startswith('wgs-84'):
            raise AttributeError('Invalid coordinate ("longitude") for points defined over {}'.format(self.crs))
        return super(NeomodelPoint, self).x

    @property
    def height(self):
        if not self._crs == 'wgs-84-3d':
            raise AttributeError('Invalid coordinate ("height") for points defined over {}'.format(self.crs))
        return super(NeomodelPoint, self).z

    # The following operations are necessary here due to the way queries (and more importantly their parameters) get
    # combined and evaluated in neomodel. Specifically, query expressions get duplicated with deep copies and any valid
    # datatype values should also implement these operations.
    def __copy__(self):
        return NeomodelPoint(self)

    def __deepcopy__(self, memo):
        return NeomodelPoint(self)


class PointProperty(Property):
    """
    Validates points which can participate in spatial queries.
    """

    form_field_class = 'PointField'
    # The CRS that this property is expected to be expressed in.
    _crs = None

    def __init__(self, *args, **kwargs):
        """
        A Point property that requires at least its CRS to be known to offer proper validation.

        :param crs: Coordinate Reference System
        :type crs: str
        :param kwargs: Dictionary of arguments
        :type kwargs: dict
        """
        if 'crs' in kwargs:
            crs = kwargs['crs']
            del(kwargs['crs'])
        else:
            crs = None

        if crs is None or (crs not in ACCEPTABLE_CRS):
            raise ValueError('Invalid CRS({}). '
                             'Point properties require CRS to be one of {}'.format(crs, ','.join(ACCEPTABLE_CRS)))

        # If a default value is passed and it is not a callable, then make sure it is in the right type
        if 'default' in kwargs:
            if not hasattr(kwargs['default'], "__call__"):
                if not isinstance(kwargs['default'], NeomodelPoint):
                    raise TypeError('Invalid default value. '
                                    'Expected NeomodelPoint, received {}'.format(type(kwargs['default'])))

        super(PointProperty, self).__init__(*args, **kwargs)
        self._crs = crs

    @validator
    def inflate(self, value):
        """
        Handles the marshalling from Neo4J POINT to NeomodelPoint

        :param value: Value returned from the database
        :type value: Neo4J POINT
        :return: NeomodelPoint
        """
        if not isinstance(value,neo4j.spatial.Point):
            raise TypeError('Invalid datatype to inflate. Expected POINT datatype, received {}'.format(type(value)))

        try:
            value_point_crs = SRID_TO_CRS[value.srid]
        except KeyError:
            raise ValueError('Invalid SRID to inflate. '
                             'Expected one of {}, received {}'.format(SRID_TO_CRS.keys(), value.srid))

        if self._crs != value_point_crs:
            raise ValueError('Invalid CRS. '
                             'Expected POINT defined over {}, received {}'.format(self._crs, value_point_crs))
        # cartesian
        if value.srid == 7203:
            return NeomodelPoint(x=value.x, y=value.y)
        # cartesian-3d
        elif value.srid == 9157:
            return NeomodelPoint(x=value.x, y=value.y, z=value.z)
        # wgs-84
        elif value.srid == 4326:
            return NeomodelPoint(longitude=value.longitude, latitude=value.latitude)
        # wgs-83-3d
        elif value.srid == 4979:
            return NeomodelPoint(longitude=value.longitude, latitude=value.latitude, height=value.height)

    @validator
    def deflate(self, value):
        """
        Handles the marshalling from NeomodelPoint to Neo4J POINT

        :param value: The point that was assigned as value to a property in the model
        :type value: NeomodelPoint
        :return: Neo4J POINT
        """
        if not isinstance(value, NeomodelPoint):
            raise TypeError('Invalid datatype to deflate. Expected NeomodelPoint, received {}'.format(type(value)))

        if not value.crs == self._crs:
            raise ValueError('Invalid CRS. '
                             'Expected NeomodelPoint defined over {}, '
                             'received NeomodelPoint defined over {}'.format(self._crs, value.crs))

        if value.crs == 'cartesian-3d':
            return neo4j.spatial.CartesianPoint((value.x, value.y,  value.z))
        elif value.crs == 'cartesian':
            return neo4j.spatial.CartesianPoint((value.x,value.y))
        elif value.crs == 'wgs-84':
            return neo4j.spatial.WGS84Point((value.longitude, value.latitude))
        elif value.crs == 'wgs-84-3d':
            return neo4j.spatial.WGS84Point((value.longitude, value.latitude, value.height))
