import json
import trafaret as t

from django.http import HttpResponse
from django.conf.urls import url
from django.template import Template, Context

import logging
from .responses import ApiResponse, ApiError, CustomResponse
from staff.lib.json import JSONEncoder as CustomJSONEncoder

logger = logging.getLogger('debug')


class ConfigurationError(Exception):
    pass


MISSING = object()


class ApiJSONEncoder(CustomJSONEncoder):

    def iterencode(self, o, _one_shot=False):
        # Replace `null` with empty string to make our frontedner happy
        return ('""' if chunk == 'null' else chunk
                for chunk
                in super(ApiJSONEncoder, self).iterencode(o, _one_shot))


class InputParam(object):
    def __init__(self, help, validator, name=None, required=False,
                 multi=False, methods=None):
        self.help = help
        self.validator = validator
        self.name = name
        self.required = required
        self.methods = methods
        self.multi = multi

    def validate(self, val):
        if isinstance(self.validator, type) and issubclass(self.validator,
                                                           t.Trafaret):
            return self.validator().check(val)
        if isinstance(self.validator, t.Trafaret):
            return self.validator.check(val)
        else:
            return self.validator(val)


class AttributeDict(dict):
    def __getattr__(self, item):
        return self[item]

    def __setattr__(self, key, value):
        self[key] = value


class ApiMethodMeta(type):
    def __new__(mcs, clsname, bases, members):

        methods = [
            meth for meth in ['GET', 'POST', 'PUT', 'DELETE', 'RAPE', 'KILL']
            if meth in members
        ]

        if '__abstract__' in members:
            return type.__new__(mcs, clsname, bases, members)

        if 'help' not in members:
            raise ConfigurationError("%s is missing a help string" % clsname)
        if not methods:
            raise ConfigurationError("You need to define at least one method")

        inputs = {}
        resultmods = []
        for name, mem in list(members.items()):
            # Register input params
            if isinstance(mem, InputParam):
                if not mem.name:
                    mem.name = name
                if not mem.methods:
                    mem.methods = methods[:]
                # Store using attribute name, not input name
                inputs[name] = mem
                members.pop(name)
            elif isinstance(mem, ResultModifier):
                resultmods.append(mem)

        members['__inputs__'] = inputs
        members['__methods__'] = methods
        members['__resultmods__'] = resultmods

        return type.__new__(mcs, clsname, bases, members)


class ResultModifier(object):
    def __init__(self, key, methods=None):
        self.key = key
        self.methods = None

    def __call__(self, func):
        self.func = func
        return self


add_to_result = ResultModifier


class ApiMethod(metaclass=ApiMethodMeta):
    __abstract__ = True

    contenttypes = {
        'json': 'application/json; charset=utf-8'
    }

    def __init__(self, format, method, **kw):
        self.format = format
        self._method = method
        self.options = kw
        self.inputs = self.p = AttributeDict()

    @classmethod
    def handler_for(cls, format, **kw):
        """ Return handler to be used with django url machinery """

        def handler(request, *args, **kwargs):
            obj = cls(format=format, method=request.method, **kw)
            return obj.handle(request, *args,  **kwargs)

        return handler

    @classmethod
    def get_formats(cls):
        fmts = set(getattr(cls, 'formats', ()))
        fmts.update(getattr(cls.__collection__, 'formats', ()))
        fmts.add('doc')

        return fmts

    @classmethod
    def django_urls(cls):
        patterns = []
        for fmt in cls.get_formats():
            pattern = url(
                cls.url.format(format=fmt),
                cls.handler_for(fmt))

            patterns.append(pattern)
            if fmt == 'json':
                pattern = url(
                    cls.url.format(format='json.pretty'),
                    cls.handler_for(fmt, pretty=True)
                )
            patterns.append(pattern)
        return patterns

    def serialize_json(self, data):
        pretty = self.options.get('pretty', False)
        return json.dumps(data,
                          sort_keys=True,
                          indent=4 if pretty else None,
                          ensure_ascii=not pretty,
                          cls=ApiJSONEncoder)

    def _prepare_input_params(self, request):
        # Try to detect json posted into request body
        request.POST
        try:
            postdata = json.loads(request.read())
            assert isinstance(postdata, dict)
            postdata = dict((k, (v,)) for k, v in postdata.items())
        except (ValueError, AssertionError):
            if len(request.POST) == 1:
                key = next(iter(request.POST.keys()))

                if any(c in key for c in '[](){}"\''):
                    # This is probably invalid json
                    raise self.INVALID_PARAMS(
                        "Looks like invalid JSON (or not a dict)")

            postdata = dict(request.POST.lists())

        postdata.update(dict(request.FILES.lists()))

        return {
            'GET': dict(request.GET.lists()),
            'POST': postdata
        }

    @classmethod
    def inputs_for(cls, method):
        """ Get parameters list for a http method """
        return dict(
            (key, param) for key, param in cls.__inputs__.items()
            if method in param.methods
        )

    def validate_input(self, data):
        # Warning: this method actually destroys its input parameter.
        # This is intended behavior.
        validated = {}
        errors = {}

        def _collect_error(key, msg):
            errors[key] = msg

        data_cont = data['GET' if self._method == 'GET' else 'POST']

        for key, paramdesc in self.inputs_for(self._method).items():
            ikey = paramdesc.name  # input name
            rawval = data_cont.pop(ikey, MISSING)
            if paramdesc.required and rawval is MISSING:
                _collect_error(ikey, "Required parameter missing")
                continue

            if rawval is MISSING:
                validated[key] = None
                continue

            if not paramdesc.multi:
                # TODO
                if len(rawval) > 1:
                    _collect_error(ikey, "Multiple values not allowed")
                    continue
                rawval = rawval[0]
            try:
                val = paramdesc.validate(rawval)
            except t.DataError as exc:
                _collect_error(ikey, str(exc))
                continue
            validated[key] = val

        for method, d in data.items():
            errors.update(("%s:%s" % (method, key), "Unused parameter")
                          for key, val in d.items())
        return validated, errors

    def get_required_permissions(self):
        perms = list(self.__collection__.require_permissions)
        perms += getattr(self, 'require_permissions', [])
        return perms

    def missing_permissions(self, request):
        """
        Return required permission user does not have.
        """
        perms = self.get_required_permissions()
        missing_perms = [
            perm for perm in perms if not request.user.has_perm(perm)
        ]
        return missing_perms

    def handle(self, request, *args, **kwargs):
        """
        Main handling function - first function to be called after object is
        created.
        """
        if self.format == 'doc':
            return self.docview()

        try:
            return self.render(self._handle_and_raise(request))
        except ApiResponse as res:
            # result can be returned or thrown, either way works
            return self.render(res)

    def _handle_and_raise(self, request):
        logger.debug(request.get_full_path())
        if self._method not in self.__methods__:
            return ApiResponse(
                "Unsupported method: %s" % request.method,
                code=405,
                reason="METHOD_NOT_SUPPORTED")

        missing = self.missing_permissions(request)
        if missing:
            raise self.UNAUTHORIZED(data={'missing_permissions': missing})

        getdata, errors = self.validate_input(
            self._prepare_input_params(request)
        )

        if errors:
            raise self.INVALID_PARAMS(data=errors)

        self.inputs.update(getdata)
        self.request = request
        self._process_csrf()

        method_handler = getattr(self, self._method)

        resp = method_handler()

        if not isinstance(resp, ApiResponse):
            raise TypeError("Expected ApiResponse, not %s" % type(resp))

        return resp

    def render(self, apiresp):
        serializer = getattr(self, 'serialize_' + self.format)
        content_type = self.contenttypes[self.format]
        content = apiresp.gen_content()

        # Modify result according to @add_to_result decorators
        for rmod in self.__resultmods__:
            if rmod.methods is None or self._method in rmod.methods:
                content[rmod.key] = rmod.func(self)

        serialized = serializer(content)

        return HttpResponse(
            status=apiresp.code,
            content=serialized,
            content_type=content_type
        )

    class OK(ApiResponse):
        message = "OK"

    class NOT_FOUND(ApiError):
        code = 404
        message = "Object not found"

    class INVALID_PARAMS(ApiError):
        message = "Input validation failed"

        def gen_content(self):
            return {
                'errors': [{
                    'code': self.__class__.__name__,
                    'desc': self.message
                }],
                'content': {
                    field: [{'code': 'validation_error', 'desc': error}]
                    for field, error in (self.data or {}).items()
                }
            }

    class UNAUTHORIZED(ApiError):
        code = 403
        message = "You are not allowed to use this method"

    class CUSTOM_RESPONSE(CustomResponse):
        pass

    @classmethod
    def docstr(cls):
        """ Automatically generated docs as a string """
        inputs = {}
        for meth in cls.__methods__:
            inputs[meth] = cls.inputs_for(meth)
        return doc_template.render(Context(dict(
            help=cls.help,
            url=cls.url,
            inputs=inputs
        )))

    def docview(self):
        """ Self-documentation """
        return HttpResponse(
            self.docstr(),
            content_type="text/plain"
        )

    def _process_csrf(self):
        """Tell the middleware we want csrf cookie to be set"""
        self.request.META["CSRF_COOKIE_USED"] = True


doc_template_str = """
{% autoescape off %}
{{ help }}
Url: {{ url }}
{% for method, inputs in inputs.items %}{% if inputs %}
    {{ method }}
{% for _, input in inputs.items %}     {{ input.name }}: {{ input.help }}
{% endfor %}{% endif %}{% endfor %}{% endautoescape %}"""

doc_template = Template(doc_template_str)
