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

import json
import re

from django.utils.six.moves.urllib.parse import urlencode
from django.utils.six import text_type

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 import importlib
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

throttle = None
if getattr(app_settings, 'THROTTLE', ''):
    def _import_from_string(val):
        parts = val.split('.')
        module_path, class_name = '.'.join(parts[:-1]), parts[-1]
        module = importlib.import_module(module_path)
        return getattr(module, class_name)
    throttle = _import_from_string(app_settings.THROTTLE)()

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):
        if throttle is not None:
            if not throttle.allow_request(request, self):
                msg = ("You've exceeded the request limit."
                    " Try again in %d seconds." % throttle.wait() +
                    " Contact us if you need to be able to exceed the default limit.")
                return HttpResponse(msg,
                    content_type='text/plain',
                    status=503)

        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 '%s' provided." % 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 %d resources; this query would return %s. Please filter your query."
                % (app_settings.MAX_GEO_LIST_RESULTS, 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))), mimetype="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),
                mimetype="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, mimetype="text/plain")
        elif format == 'kml':
            resp = HttpResponse(
                kml.generate_kml_document([kml.generate_placemark(name, geom)]),
                mimetype="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 '%s' provided. Please provide a positive integer." % limit)

        if limit < 0:
            raise BadRequest("Invalid limit '%s' provided. Please provide a positive integer >= 0." % 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 '%s' provided. Please provide an integer." % offset)

        if offset < 0:
            raise BadRequest("Invalid offset '%s' provided. Please provide a positive integer >= 0." % 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,
        }
