""" A mini API framework.
"""
from __future__ import unicode_literals

import json
import re

from django.conf import settings
from django.contrib.gis.measure import D
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpResponse, Http404, HttpResponseForbidden, HttpResponseBadRequest
from django.template import loader, RequestContext
from django.template.defaultfilters import escapejs
from django.utils.six import text_type
from django.utils.six.moves.urllib.parse import urlencode
from django.utils.translation import ugettext as _
from django.views.generic import View

from boundaries import kml
from boundaries.models import app_settings


class RawJSONResponse(object):

    """APIView subclasses can return these if they have
    already-serialized JSON to return"""

    def __init__(self, content):
        self.content = content


class BadRequest(Exception):
    pass


class APIView(View):

    """Base view class that serializes subclass responses to JSON.

    Subclasses should define get/post/etc. methods."""

    allow_jsonp = True
    content_type = 'application/json; charset=utf-8'

    def dispatch(self, request, *args, **kwargs):
        try:
            result = super(APIView, self).dispatch(request, *args, **kwargs)
        except BadRequest as e:
            return HttpResponseBadRequest(text_type(e), content_type='text/plain')
        if isinstance(result, HttpResponse):
            return result
        if request.GET.get('format') == 'apibrowser':
            return self.apibrowser_response(request, result)
        resp = HttpResponse(content_type=self.content_type)

        # CORS
        if request.method == 'GET' and app_settings.ALLOW_ORIGIN:
            resp['Access-Control-Allow-Origin'] = app_settings.ALLOW_ORIGIN

        # JSONP
        callback = ''
        if self.allow_jsonp and 'callback' in request.GET:
            callback = re.sub(r'[^a-zA-Z0-9_]', '', request.GET['callback'])
            resp.write(callback + '(')
        if isinstance(result, RawJSONResponse):
            resp.write(result.content)
        else:
            json.dump(result, resp, indent=(4 if request.GET.get('pretty') else None))
        if callback:
            resp.write(');')

        return resp

    def apibrowser_response(self, request, result):
        """If format=apibrowser, return a prettified HTML reponse."""
        if isinstance(result, RawJSONResponse):
            result = json.loads(result.content)
        jsonresult = json.dumps(result, indent=4)
        t = loader.get_template('boundaries/apibrowser.html')
        json_url = request.path
        params = dict([k, v.encode('utf-8')] for k, v in request.GET.items())
        params.pop('format')
        if params:
            json_url += '?' + urlencode(params)
        ctx = {
            'json': jsonresult,
            'resource_name': self.model.__name__,
            'is_list': isinstance(self, ModelListView),
            'json_url': json_url
        }
        if ctx['is_list']:
            ctx['title'] = self.model._meta.verbose_name_plural
        else:
            ctx['title'] = self.model._meta.verbose_name
        c = RequestContext(request, ctx)
        return HttpResponse(t.render(c))


class ModelListView(APIView):

    """Base API class for a list of resources.

    Subclasses should set the 'model' attribute to the appropriate model class.
    Set the filterable_fields attribute to a list of field names users should
    be able to filter on.

    Compatible model classes should define a static method called get_dicts that,
    given a list of objects, returns a list of dicts suitable for serialization.
    By default, those will be model objects, but the model can also define a static
    method called 'prepare_queryset_for_get_dicts' that accepts a queryset and returns
    a sliceable iterable of objects that will later be passed to get_dicts."""

    filter_types = ['exact', 'iexact', 'contains', 'icontains',
                    'startswith', 'istartswith', 'endswith', 'iendswith', 'isnull']

    def get_qs(self, request):
        return self.model.objects.all()

    def filter(self, request, qs):
        for (f, val) in request.GET.items():
            if '__' in f:
                (filter_field, filter_type) = f.split('__')
            else:
                (filter_field, filter_type) = (f, 'exact')
            if filter_field in getattr(self, 'filterable_fields', []) and filter_type in self.filter_types:
                if val in ['true', 'True']:
                    val = True
                elif val in ['false', 'False']:
                    val = False
                elif val in ['none', 'None']:
                    val = None
                qs = qs.filter(**{filter_field + '__' + filter_type: val})
        return qs

    def get_related_resources(self, request, qs, meta):
        return {}

    def get(self, request, **kwargs):
        qs = self.get_qs(request, **kwargs)
        try:
            qs = self.filter(request, qs)
        except ValueError:
            raise BadRequest(_("Invalid filter value"))
        if hasattr(self.model, 'prepare_queryset_for_get_dicts'):
            qs = self.model.prepare_queryset_for_get_dicts(qs)
        paginator = Paginator(request.GET, qs, resource_uri=request.path)
        result = paginator.page()
        result['objects'] = self.model.get_dicts(result['objects'])
        related = self.get_related_resources(request, qs, result['meta'])
        if related:
            result['meta']['related'] = related
        return result


class ModelGeoListView(ModelListView):

    """Adds geospatial support to ModelListView.

    Subclasses must set the 'allowed_geo_fields' attribute to a list
    of geospatial field names which we're allowed to provide.

    'name_field' should be the name of the field on objects that
     contains a name value

    To enable a couple of default geospatial filters, the
    default_geo_filter_field attribute should be set to the name
    of the geometry field to filter on.

    To access a geospatial field, the field name must be provided
    by the URLconf in the 'geo_field' keyword argument."""

    name_field = 'name'
    default_geo_filter_field = None

    def filter(self, request, qs):
        qs = super(ModelGeoListView, self).filter(request, qs)

        if self.default_geo_filter_field:
            if 'contains' in request.GET:
                try:
                    lat, lon = re.sub(r'[^\d.,-]', '', request.GET['contains']).split(',')
                    wkt_pt = 'POINT(%s %s)' % (lon, lat)
                    qs = qs.filter(**{self.default_geo_filter_field + "__contains": wkt_pt})
                except ValueError:
                    raise BadRequest(_("Invalid latitude,longitude '%(value)s' provided.") % {'value': request.GET['contains']})

            if 'near' in request.GET:
                lat, lon, range = request.GET['near'].split(',')
                wkt_pt = 'POINT(%s %s)' % (float(lon), float(lat))
                numeral = re.match('([0-9]+)', range).group(1)
                unit = range[len(numeral):]
                numeral = int(numeral)
                kwargs = {unit: numeral}
                qs = qs.filter(**{self.default_geo_filter_field + "__distance_lte": (wkt_pt, D(**kwargs))})

        return qs

    def get(self, request, **kwargs):
        if 'geo_field' not in kwargs:
            # If it's not a geo request, let ModelListView handle it.
            return super(ModelGeoListView, self).get(request, **kwargs)

        field = kwargs.pop('geo_field')
        if field not in self.allowed_geo_fields:
            raise Http404
        qs = self.get_qs(request, **kwargs)
        try:
            qs = self.filter(request, qs)
        except ValueError:
            raise BadRequest(_("Invalid filter value"))

        if qs.count() > app_settings.MAX_GEO_LIST_RESULTS:
            return HttpResponseForbidden(
                _("Spatial-list queries cannot return more than %(expected)d resources; this query would return %(actual)s. Please filter your query.")
                % {'expected': app_settings.MAX_GEO_LIST_RESULTS, 'actual': qs.count()})

        format = request.GET.get('format', 'json')

        if format in ('json', 'apibrowser'):
            strings = ['{"objects": [']
            strings.append(','.join(('{"name": "%s", "%s": %s}' % (escapejs(x[1]), field, x[0].geojson)
                                     for x in qs.values_list(field, self.name_field))))
            strings.append(']}')
            return RawJSONResponse(''.join(strings))
        elif format == 'wkt':
            return HttpResponse("\n".join((geom.wkt for geom in qs.values_list(field, flat=True))), content_type="text/plain")
        elif format == 'kml':
            placemarks = [kml.generate_placemark(x[1], x[0]) for x in qs.values_list(field, self.name_field)]
            resp = HttpResponse(
                kml.generate_kml_document(placemarks),
                content_type="application/vnd.google-earth.kml+xml")
            resp['Content-Disposition'] = 'attachment; filename="shape.kml"'
            return resp
        else:
            raise NotImplementedError


class ModelDetailView(APIView):

    """Return the API representation of a single object.

    Subclasses must set the 'model' attribute to the appropriate model class.
    Subclasses must define a 'get_object' method to return a single model
      object. Its argument will be the request, a QuerySet of objects from
      which to select, and any keyword arguments provided by the URLconf.

    Compatible model classes must define an as_dict instance method which
    returns a serializable dict of the object's data."""

    def __init__(self):
        super(ModelDetailView, self).__init__()
        self.base_qs = self.model.objects.all()

    def get(self, request, **kwargs):
        try:
            return self.get_object(request, self.base_qs, **kwargs).as_dict()
        except ObjectDoesNotExist:
            raise Http404


class ModelGeoDetailView(ModelDetailView):

    """Adds geospatial support to ModelDetailView

    Subclasses must set the 'allowed_geo_fields' attribute to a list
    of geospatial field names which we're allowed to provide.

    To access a geospatial field, the field name must be provided
    by the URLconf in the 'geo_field' keyword argument."""

    name_field = 'name'

    def get(self, request, **kwargs):
        if 'geo_field' not in kwargs:
            # If it's not a geo request, let ModelDetailView handle it.
            return super(ModelGeoDetailView, self).get(request, **kwargs)

        field = kwargs.pop('geo_field')
        if field not in self.allowed_geo_fields:
            raise Http404

        try:
            obj = self.get_object(request, self.base_qs.only(field, self.name_field), **kwargs)
        except ObjectDoesNotExist:
            raise Http404

        geom = getattr(obj, field)
        name = getattr(obj, self.name_field)
        format = request.GET.get('format', 'json')
        if format in ('json', 'apibrowser'):
            return RawJSONResponse(geom.geojson)
        elif format == 'wkt':
            return HttpResponse(geom.wkt, content_type="text/plain")
        elif format == 'kml':
            resp = HttpResponse(
                kml.generate_kml_document([kml.generate_placemark(name, geom)]),
                content_type="application/vnd.google-earth.kml+xml")
            resp['Content-Disposition'] = 'attachment; filename="shape.kml"'
            return resp
        else:
            raise NotImplementedError


class Paginator(object):

    """
    Taken from django-tastypie. Thanks!
    """

    def __init__(self, request_data, objects, resource_uri=None, limit=None, offset=0, max_limit=1000, collection_name='objects'):
        """
        Instantiates the ``Paginator`` and allows for some configuration.

        The ``request_data`` argument ought to be a dictionary-like object.
        May provide ``limit`` and/or ``offset`` to override the defaults.
        Commonly provided ``request.GET``. Required.

        The ``objects`` should be a list-like object of ``Resources``.
        This is typically a ``QuerySet`` but can be anything that
        implements slicing. Required.

        Optionally accepts a ``limit`` argument, which specifies how many
        items to show at a time. Defaults to ``None``, which is no limit.

        Optionally accepts an ``offset`` argument, which specifies where in
        the ``objects`` to start displaying results from. Defaults to 0.

        Optionally accepts a ``max_limit`` argument, which the upper bound
        limit. Defaults to ``1000``. If you set it to 0 or ``None``, no upper
        bound will be enforced.
        """
        self.request_data = request_data
        self.objects = objects
        self.limit = limit
        self.max_limit = max_limit
        self.offset = offset
        self.resource_uri = resource_uri
        self.collection_name = collection_name

    def get_limit(self):
        """
        Determines the proper maximum number of results to return.

        In order of importance, it will use:

            * The user-requested ``limit`` from the GET parameters, if specified.
            * The object-level ``limit`` if specified.
            * ``settings.API_LIMIT_PER_PAGE`` if specified.

        Default is 20 per page.
        """

        limit = self.request_data.get('limit', self.limit)
        if limit is None:
            limit = getattr(settings, 'API_LIMIT_PER_PAGE', 20)

        try:
            limit = int(limit)
        except ValueError:
            raise BadRequest(_("Invalid limit '%(value)s' provided. Please provide a positive integer.") % {'value': limit})

        if limit < 0:
            raise BadRequest(_("Invalid limit '%(value)s' provided. Please provide a positive integer >= 0.") % {'value': limit})

        if self.max_limit and (not limit or limit > self.max_limit):
            # If it's more than the max, we're only going to return the max.
            # This is to prevent excessive DB (or other) load.
            return self.max_limit

        return limit

    def get_offset(self):
        """
        Determines the proper starting offset of results to return.

        It attempst to use the user-provided ``offset`` from the GET parameters,
        if specified. Otherwise, it falls back to the object-level ``offset``.

        Default is 0.
        """
        offset = self.offset

        if 'offset' in self.request_data:
            offset = self.request_data['offset']

        try:
            offset = int(offset)
        except ValueError:
            raise BadRequest(_("Invalid offset '%(value)s' provided. Please provide a positive integer.") % {'value': offset})

        if offset < 0:
            raise BadRequest(_("Invalid offset '%(value)s' provided. Please provide a positive integer >= 0.") % {'value': offset})

        return offset

    def get_slice(self, limit, offset):
        """
        Slices the result set to the specified ``limit`` & ``offset``.
        """
        if limit == 0:
            return self.objects[offset:]

        return self.objects[offset:offset + limit]

    def get_count(self):
        """
        Returns a count of the total number of objects seen.
        """
        try:
            return self.objects.count()
        except (AttributeError, TypeError):
            # If it's not a QuerySet (or it's ilk), fallback to ``len``.
            return len(self.objects)

    def get_previous(self, limit, offset):
        """
        If a previous page is available, will generate a URL to request that
        page. If not available, this returns ``None``.
        """
        if offset - limit < 0:
            return None

        return self._generate_uri(limit, offset - limit)

    def get_next(self, limit, offset, count):
        """
        If a next page is available, will generate a URL to request that
        page. If not available, this returns ``None``.
        """
        if offset + limit >= count:
            return None

        return self._generate_uri(limit, offset + limit)

    def _generate_uri(self, limit, offset):
        if self.resource_uri is None:
            return None

        try:
            # QueryDict has a urlencode method that can handle multiple values for the same key
            request_params = self.request_data.copy()
            if 'limit' in request_params:
                del request_params['limit']
            if 'offset' in request_params:
                del request_params['offset']
            request_params.update({'limit': limit, 'offset': offset})
            encoded_params = request_params.urlencode()
        except AttributeError:
            request_params = {}

            for k, v in self.request_data.items():
                if isinstance(v, text_type):
                    request_params[k] = v.encode('utf-8')
                else:
                    request_params[k] = v

            if 'limit' in request_params:
                del request_params['limit']
            if 'offset' in request_params:
                del request_params['offset']
            request_params.update({'limit': limit, 'offset': offset})
            encoded_params = urlencode(request_params)

        return '%s?%s' % (self.resource_uri, encoded_params)

    def page(self):
        """
        Generates all pertinent data about the requested page.

        Handles getting the correct ``limit`` & ``offset``, then slices off
        the correct set of results and returns all pertinent metadata.
        """
        limit = self.get_limit()
        offset = self.get_offset()
        count = self.get_count()
        objects = self.get_slice(limit, offset)
        meta = {
            'offset': offset,
            'limit': limit,
            'total_count': count,
        }

        if limit:
            meta['previous'] = self.get_previous(limit, offset)
            meta['next'] = self.get_next(limit, offset, count)

        return {
            self.collection_name: objects,
            'meta': meta,
        }
