from django.db import connections
from django.db.models.query import QuerySet, ValuesQuerySet, ValuesListQuerySet

from django.contrib.gis import memoryview
from django.contrib.gis.db.models import aggregates
from django.contrib.gis.db.models.fields import get_srid_info, PointField, LineStringField
from django.contrib.gis.db.models.sql import AreaField, DistanceField, GeomField, GeoQuery
from django.contrib.gis.geometry.backend import Geometry
from django.contrib.gis.measure import Area, Distance

from django.utils import six


class GeoQuerySet(QuerySet):
    "The Geographic QuerySet."

    ### Methods overloaded from QuerySet ###
    def __init__(self, model=None, query=None, using=None, hints=None):
        super(GeoQuerySet, self).__init__(model=model, query=query, using=using, hints=hints)
        self.query = query or GeoQuery(self.model)

    def values(self, *fields):
        return self._clone(klass=GeoValuesQuerySet, setup=True, _fields=fields)

    def values_list(self, *fields, **kwargs):
        flat = kwargs.pop('flat', False)
        if kwargs:
            raise TypeError('Unexpected keyword arguments to values_list: %s'
                    % (list(kwargs),))
        if flat and len(fields) > 1:
            raise TypeError("'flat' is not valid when values_list is called with more than one field.")
        return self._clone(klass=GeoValuesListQuerySet, setup=True, flat=flat,
                           _fields=fields)

    ### GeoQuerySet Methods ###
    def area(self, tolerance=0.05, **kwargs):
        """
        Returns the area of the geographic field in an `area` attribute on
        each element of this GeoQuerySet.
        """
        # Peforming setup here rather than in `_spatial_attribute` so that
        # we can get the units for `AreaField`.
        procedure_args, geo_field = self._spatial_setup('area', field_name=kwargs.get('field_name', None))
        s = {'procedure_args': procedure_args,
             'geo_field': geo_field,
             'setup': False,
             }
        connection = connections[self.db]
        backend = connection.ops
        if backend.oracle:
            s['procedure_fmt'] = '%(geo_col)s,%(tolerance)s'
            s['procedure_args']['tolerance'] = tolerance
            s['select_field'] = AreaField('sq_m')  # Oracle returns area in units of meters.
        elif backend.postgis or backend.spatialite:
            if backend.geography:
                # Geography fields support area calculation, returns square meters.
                s['select_field'] = AreaField('sq_m')
            elif not geo_field.geodetic(connection):
                # Getting the area units of the geographic field.
                s['select_field'] = AreaField(Area.unit_attname(geo_field.units_name(connection)))
            else:
                # TODO: Do we want to support raw number areas for geodetic fields?
                raise Exception('Area on geodetic coordinate systems not supported.')
        return self._spatial_attribute('area', s, **kwargs)

    def centroid(self, **kwargs):
        """
        Returns the centroid of the geographic field in a `centroid`
        attribute on each element of this GeoQuerySet.
        """
        return self._geom_attribute('centroid', **kwargs)

    def collect(self, **kwargs):
        """
        Performs an aggregate collect operation on the given geometry field.
        This is analagous to a union operation, but much faster because
        boundaries are not dissolved.
        """
        return self._spatial_aggregate(aggregates.Collect, **kwargs)

    def difference(self, geom, **kwargs):
        """
        Returns the spatial difference of the geographic field in a `difference`
        attribute on each element of this GeoQuerySet.
        """
        return self._geomset_attribute('difference', geom, **kwargs)

    def distance(self, geom, **kwargs):
        """
        Returns the distance from the given geographic field name to the
        given geometry in a `distance` attribute on each element of the
        GeoQuerySet.

        Keyword Arguments:
         `spheroid`  => If the geometry field is geodetic and PostGIS is
                        the spatial database, then the more accurate
                        spheroid calculation will be used instead of the
                        quicker sphere calculation.

         `tolerance` => Used only for Oracle. The tolerance is
                        in meters -- a default of 5 centimeters (0.05)
                        is used.
        """
        return self._distance_attribute('distance', geom, **kwargs)

    def envelope(self, **kwargs):
        """
        Returns a Geometry representing the bounding box of the
        Geometry field in an `envelope` attribute on each element of
        the GeoQuerySet.
        """
        return self._geom_attribute('envelope', **kwargs)

    def extent(self, **kwargs):
        """
        Returns the extent (aggregate) of the features in the GeoQuerySet.  The
        extent will be returned as a 4-tuple, consisting of (xmin, ymin, xmax, ymax).
        """
        return self._spatial_aggregate(aggregates.Extent, **kwargs)

    def extent3d(self, **kwargs):
        """
        Returns the aggregate extent, in 3D, of the features in the
        GeoQuerySet. It is returned as a 6-tuple, comprising:
          (xmin, ymin, zmin, xmax, ymax, zmax).
        """
        return self._spatial_aggregate(aggregates.Extent3D, **kwargs)

    def force_rhr(self, **kwargs):
        """
        Returns a modified version of the Polygon/MultiPolygon in which
        all of the vertices follow the Right-Hand-Rule.  By default,
        this is attached as the `force_rhr` attribute on each element
        of the GeoQuerySet.
        """
        return self._geom_attribute('force_rhr', **kwargs)

    def geojson(self, precision=8, crs=False, bbox=False, **kwargs):
        """
        Returns a GeoJSON representation of the geomtry field in a `geojson`
        attribute on each element of the GeoQuerySet.

        The `crs` and `bbox` keywords may be set to True if the user wants
        the coordinate reference system and the bounding box to be included
        in the GeoJSON representation of the geometry.
        """
        backend = connections[self.db].ops
        if not backend.geojson:
            raise NotImplementedError('Only PostGIS 1.3.4+ and SpatiaLite 3.0+ '
                                      'support GeoJSON serialization.')

        if not isinstance(precision, six.integer_types):
            raise TypeError('Precision keyword must be set with an integer.')

        # Setting the options flag -- which depends on which version of
        # PostGIS we're using. SpatiaLite only uses the first group of options.
        if backend.spatial_version >= (1, 4, 0):
            options = 0
            if crs and bbox:
                options = 3
            elif bbox:
                options = 1
            elif crs:
                options = 2
        else:
            options = 0
            if crs and bbox:
                options = 3
            elif crs:
                options = 1
            elif bbox:
                options = 2
        s = {'desc': 'GeoJSON',
             'procedure_args': {'precision': precision, 'options': options},
             'procedure_fmt': '%(geo_col)s,%(precision)s,%(options)s',
             }
        return self._spatial_attribute('geojson', s, **kwargs)

    def geohash(self, precision=20, **kwargs):
        """
        Returns a GeoHash representation of the given field in a `geohash`
        attribute on each element of the GeoQuerySet.

        The `precision` keyword may be used to custom the number of
        _characters_ used in the output GeoHash, the default is 20.
        """
        s = {'desc': 'GeoHash',
             'procedure_args': {'precision': precision},
             'procedure_fmt': '%(geo_col)s,%(precision)s',
             }
        return self._spatial_attribute('geohash', s, **kwargs)

    def gml(self, precision=8, version=2, **kwargs):
        """
        Returns GML representation of the given field in a `gml` attribute
        on each element of the GeoQuerySet.
        """
        backend = connections[self.db].ops
        s = {'desc': 'GML', 'procedure_args': {'precision': precision}}
        if backend.postgis:
            # PostGIS AsGML() aggregate function parameter order depends on the
            # version -- uggh.
            if backend.spatial_version > (1, 3, 1):
                s['procedure_fmt'] = '%(version)s,%(geo_col)s,%(precision)s'
            else:
                s['procedure_fmt'] = '%(geo_col)s,%(precision)s,%(version)s'
            s['procedure_args'] = {'precision': precision, 'version': version}

        return self._spatial_attribute('gml', s, **kwargs)

    def intersection(self, geom, **kwargs):
        """
        Returns the spatial intersection of the Geometry field in
        an `intersection` attribute on each element of this
        GeoQuerySet.
        """
        return self._geomset_attribute('intersection', geom, **kwargs)

    def kml(self, **kwargs):
        """
        Returns KML representation of the geometry field in a `kml`
        attribute on each element of this GeoQuerySet.
        """
        s = {'desc': 'KML',
             'procedure_fmt': '%(geo_col)s,%(precision)s',
             'procedure_args': {'precision': kwargs.pop('precision', 8)},
             }
        return self._spatial_attribute('kml', s, **kwargs)

    def length(self, **kwargs):
        """
        Returns the length of the geometry field as a `Distance` object
        stored in a `length` attribute on each element of this GeoQuerySet.
        """
        return self._distance_attribute('length', None, **kwargs)

    def make_line(self, **kwargs):
        """
        Creates a linestring from all of the PointField geometries in the
        this GeoQuerySet and returns it.  This is a spatial aggregate
        method, and thus returns a geometry rather than a GeoQuerySet.
        """
        return self._spatial_aggregate(aggregates.MakeLine, geo_field_type=PointField, **kwargs)

    def mem_size(self, **kwargs):
        """
        Returns the memory size (number of bytes) that the geometry field takes
        in a `mem_size` attribute  on each element of this GeoQuerySet.
        """
        return self._spatial_attribute('mem_size', {}, **kwargs)

    def num_geom(self, **kwargs):
        """
        Returns the number of geometries if the field is a
        GeometryCollection or Multi* Field in a `num_geom`
        attribute on each element of this GeoQuerySet; otherwise
        the sets with None.
        """
        return self._spatial_attribute('num_geom', {}, **kwargs)

    def num_points(self, **kwargs):
        """
        Returns the number of points in the first linestring in the
        Geometry field in a `num_points` attribute on each element of
        this GeoQuerySet; otherwise sets with None.
        """
        return self._spatial_attribute('num_points', {}, **kwargs)

    def perimeter(self, **kwargs):
        """
        Returns the perimeter of the geometry field as a `Distance` object
        stored in a `perimeter` attribute on each element of this GeoQuerySet.
        """
        return self._distance_attribute('perimeter', None, **kwargs)

    def point_on_surface(self, **kwargs):
        """
        Returns a Point geometry guaranteed to lie on the surface of the
        Geometry field in a `point_on_surface` attribute on each element
        of this GeoQuerySet; otherwise sets with None.
        """
        return self._geom_attribute('point_on_surface', **kwargs)

    def reverse_geom(self, **kwargs):
        """
        Reverses the coordinate order of the geometry, and attaches as a
        `reverse` attribute on each element of this GeoQuerySet.
        """
        s = {'select_field': GeomField()}
        kwargs.setdefault('model_att', 'reverse_geom')
        if connections[self.db].ops.oracle:
            s['geo_field_type'] = LineStringField
        return self._spatial_attribute('reverse', s, **kwargs)

    def scale(self, x, y, z=0.0, **kwargs):
        """
        Scales the geometry to a new size by multiplying the ordinates
        with the given x,y,z scale factors.
        """
        if connections[self.db].ops.spatialite:
            if z != 0.0:
                raise NotImplementedError('SpatiaLite does not support 3D scaling.')
            s = {'procedure_fmt': '%(geo_col)s,%(x)s,%(y)s',
                 'procedure_args': {'x': x, 'y': y},
                 'select_field': GeomField(),
                 }
        else:
            s = {'procedure_fmt': '%(geo_col)s,%(x)s,%(y)s,%(z)s',
                 'procedure_args': {'x': x, 'y': y, 'z': z},
                 'select_field': GeomField(),
                 }
        return self._spatial_attribute('scale', s, **kwargs)

    def snap_to_grid(self, *args, **kwargs):
        """
        Snap all points of the input geometry to the grid.  How the
        geometry is snapped to the grid depends on how many arguments
        were given:
          - 1 argument : A single size to snap both the X and Y grids to.
          - 2 arguments: X and Y sizes to snap the grid to.
          - 4 arguments: X, Y sizes and the X, Y origins.
        """
        if False in [isinstance(arg, (float,) + six.integer_types) for arg in args]:
            raise TypeError('Size argument(s) for the grid must be a float or integer values.')

        nargs = len(args)
        if nargs == 1:
            size = args[0]
            procedure_fmt = '%(geo_col)s,%(size)s'
            procedure_args = {'size': size}
        elif nargs == 2:
            xsize, ysize = args
            procedure_fmt = '%(geo_col)s,%(xsize)s,%(ysize)s'
            procedure_args = {'xsize': xsize, 'ysize': ysize}
        elif nargs == 4:
            xsize, ysize, xorigin, yorigin = args
            procedure_fmt = '%(geo_col)s,%(xorigin)s,%(yorigin)s,%(xsize)s,%(ysize)s'
            procedure_args = {'xsize': xsize, 'ysize': ysize,
                              'xorigin': xorigin, 'yorigin': yorigin}
        else:
            raise ValueError('Must provide 1, 2, or 4 arguments to `snap_to_grid`.')

        s = {'procedure_fmt': procedure_fmt,
             'procedure_args': procedure_args,
             'select_field': GeomField(),
             }

        return self._spatial_attribute('snap_to_grid', s, **kwargs)

    def svg(self, relative=False, precision=8, **kwargs):
        """
        Returns SVG representation of the geographic field in a `svg`
        attribute on each element of this GeoQuerySet.

        Keyword Arguments:
         `relative`  => If set to True, this will evaluate the path in
                        terms of relative moves (rather than absolute).

         `precision` => May be used to set the maximum number of decimal
                        digits used in output (defaults to 8).
        """
        relative = int(bool(relative))
        if not isinstance(precision, six.integer_types):
            raise TypeError('SVG precision keyword argument must be an integer.')
        s = {
            'desc': 'SVG',
            'procedure_fmt': '%(geo_col)s,%(rel)s,%(precision)s',
            'procedure_args': {
                'rel': relative,
                'precision': precision,
            }
        }
        return self._spatial_attribute('svg', s, **kwargs)

    def sym_difference(self, geom, **kwargs):
        """
        Returns the symmetric difference of the geographic field in a
        `sym_difference` attribute on each element of this GeoQuerySet.
        """
        return self._geomset_attribute('sym_difference', geom, **kwargs)

    def translate(self, x, y, z=0.0, **kwargs):
        """
        Translates the geometry to a new location using the given numeric
        parameters as offsets.
        """
        if connections[self.db].ops.spatialite:
            if z != 0.0:
                raise NotImplementedError('SpatiaLite does not support 3D translation.')
            s = {'procedure_fmt': '%(geo_col)s,%(x)s,%(y)s',
                 'procedure_args': {'x': x, 'y': y},
                 'select_field': GeomField(),
                 }
        else:
            s = {'procedure_fmt': '%(geo_col)s,%(x)s,%(y)s,%(z)s',
                 'procedure_args': {'x': x, 'y': y, 'z': z},
                 'select_field': GeomField(),
                 }
        return self._spatial_attribute('translate', s, **kwargs)

    def transform(self, srid=4326, **kwargs):
        """
        Transforms the given geometry field to the given SRID.  If no SRID is
        provided, the transformation will default to using 4326 (WGS84).
        """
        if not isinstance(srid, six.integer_types):
            raise TypeError('An integer SRID must be provided.')
        field_name = kwargs.get('field_name', None)
        tmp, geo_field = self._spatial_setup('transform', field_name=field_name)

        # Getting the selection SQL for the given geographic field.
        field_col = self._geocol_select(geo_field, field_name)

        # Why cascading substitutions? Because spatial backends like
        # Oracle and MySQL already require a function call to convert to text, thus
        # when there's also a transformation we need to cascade the substitutions.
        # For example, 'SDO_UTIL.TO_WKTGEOMETRY(SDO_CS.TRANSFORM( ... )'
        geo_col = self.query.custom_select.get(geo_field, field_col)

        # Setting the key for the field's column with the custom SELECT SQL to
        # override the geometry column returned from the database.
        custom_sel = '%s(%s, %s)' % (connections[self.db].ops.transform, geo_col, srid)
        # TODO: Should we have this as an alias?
        # custom_sel = '(%s(%s, %s)) AS %s' % (SpatialBackend.transform, geo_col, srid, qn(geo_field.name))
        self.query.transformed_srid = srid  # So other GeoQuerySet methods
        self.query.custom_select[geo_field] = custom_sel
        return self._clone()

    def union(self, geom, **kwargs):
        """
        Returns the union of the geographic field with the given
        Geometry in a `union` attribute on each element of this GeoQuerySet.
        """
        return self._geomset_attribute('union', geom, **kwargs)

    def unionagg(self, **kwargs):
        """
        Performs an aggregate union on the given geometry field.  Returns
        None if the GeoQuerySet is empty.  The `tolerance` keyword is for
        Oracle backends only.
        """
        return self._spatial_aggregate(aggregates.Union, **kwargs)

    ### Private API -- Abstracted DRY routines. ###
    def _spatial_setup(self, att, desc=None, field_name=None, geo_field_type=None):
        """
        Performs set up for executing the spatial function.
        """
        # Does the spatial backend support this?
        connection = connections[self.db]
        func = getattr(connection.ops, att, False)
        if desc is None:
            desc = att
        if not func:
            raise NotImplementedError('%s stored procedure not available on '
                                      'the %s backend.' %
                                      (desc, connection.ops.name))

        # Initializing the procedure arguments.
        procedure_args = {'function': func}

        # Is there a geographic field in the model to perform this
        # operation on?
        geo_field = self.query._geo_field(field_name)
        if not geo_field:
            raise TypeError('%s output only available on GeometryFields.' % func)

        # If the `geo_field_type` keyword was used, then enforce that
        # type limitation.
        if not geo_field_type is None and not isinstance(geo_field, geo_field_type):
            raise TypeError('"%s" stored procedures may only be called on %ss.' % (func, geo_field_type.__name__))

        # Setting the procedure args.
        procedure_args['geo_col'] = self._geocol_select(geo_field, field_name)

        return procedure_args, geo_field

    def _spatial_aggregate(self, aggregate, field_name=None,
                           geo_field_type=None, tolerance=0.05):
        """
        DRY routine for calling aggregate spatial stored procedures and
        returning their result to the caller of the function.
        """
        # Getting the field the geographic aggregate will be called on.
        geo_field = self.query._geo_field(field_name)
        if not geo_field:
            raise TypeError('%s aggregate only available on GeometryFields.' % aggregate.name)

        # Checking if there are any geo field type limitations on this
        # aggregate (e.g. ST_Makeline only operates on PointFields).
        if not geo_field_type is None and not isinstance(geo_field, geo_field_type):