from __future__ import absolute_import
from builtins import object

import inspect
from collections import defaultdict
from functools import wraps

from flask.views import View, with_metaclass
from flask import request
from werkzeug.exceptions import MethodNotAllowed
from future.utils import string_types


def methodview(methods=(), ifnset=None, ifset=None):
    """ Decorator to mark a method as a view.

    NOTE: This should be a top-level decorator!

    :param methods: List of HTTP verbs it works with
    :type methods: str|Iterable[str]
    :param ifnset: Conditional matching: only if the route param is not set (or is None)
    :type  ifnset: str|Iterable[str]|None
    :param ifset:  Conditional matching: only if the route param is     set (and is not None)
    :type  ifset:  str|Iterable[str]|None
    """
    return _MethodViewInfo(methods, ifnset, ifset).decorator


class _MethodViewInfo(object):
    """ Method view info object """

    def decorator(self, func):
        """ Wrapper function to decorate a function """
        # This wrapper seems useless, but in fact is serves the purpose
        # of being a clean namespace for setting custom attributes
        @wraps(func)
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)

        # Put the sign
        wrapper._methodview = self

        return wrapper

    @classmethod
    def get_info(cls, func):
        """ :rtype: _MethodViewInfo|None """
        try: return func._methodview
        except AttributeError: return None

    def __init__(self, methods=None, ifnset=None, ifset=None):
        if isinstance(methods, string_types):
            methods = (methods,)
        if isinstance(ifnset, string_types):
            ifnset = (ifnset,)
        if isinstance(ifset, string_types):
            ifset = (ifset,)

        #: Method verbs, uppercase
        self.methods = frozenset([m.upper() for m in methods]) if methods else None

        #: Conditional matching: route params that should not be set
        self.ifnset = frozenset(ifnset) if ifnset else None

        # : Conditional matching: route params that should be set
        self.ifset  = frozenset(ifset ) if ifset  else None

    def matches(self, verb, params):
        """ Test if the method matches the provided set of arguments

        :param verb: HTTP verb. Uppercase
        :type verb: str
        :param params: Existing route parameters
        :type params: set
        :returns: Whether this view matches
        :rtype: bool
        """
        return (self.ifset   is None or self.ifset          <= params) and \
               (self.ifnset  is None or self.ifnset.isdisjoint(params)) and \
               (self.methods is None or verb in self.methods)

    def __repr__(self):
        return '<{cls}: methods={methods} ifset={ifset} ifnset={ifnset}>'.format(
            cls=self.__class__.__name__,
            methods=set(self.methods),
            ifset=set(self.ifset) if self.ifset else '-',
            ifnset=set(self.ifnset) if self.ifnset else '-',
        )

class MethodViewType(type):
    """ Metaclass that collects methods decorated with @methodview """

    def __init__(cls, name, bases, d):
        # Prepare
        methods = set(cls.methods or [])
        methods_map = defaultdict(dict)
        # Methods
        for view_name, func in inspect.getmembers(cls):
            # Collect methods decorated with methodview()
            info = _MethodViewInfo.get_info(func)
            if info is not None:
                # @methodview-decorated view
                for method in info.methods:
                    methods_map[method][view_name] = info
                    methods.add(method)

        # Finish
        cls.methods = tuple(sorted(methods_map.keys()))  # ('GET', ... )
        cls.methods_map = dict(methods_map)  # { 'GET': {'get': _MethodViewInfo } }
        super(MethodViewType, cls).__init__(name, bases, d)


class MethodView(with_metaclass(MethodViewType, View)):
    """ Class-based view that dispatches requests to methods decorated with @methodview """

    def _match_view(self, method, route_params):
        """ Detect a view matching the query

        :param method: HTTP method
        :param route_params: Route parameters dict
        :return: Method
        :rtype: Callable|None
        """
        method = method.upper()
        route_params = frozenset(k for k, v in route_params.items() if v is not None)

        for view_name, info in self.methods_map[method].items():
            if info.matches(method, route_params):
                return getattr(self, view_name)
        else:
            return None

    def dispatch_request(self, *args, **kwargs):
        view = self._match_view(request.method, kwargs)
        if view is None:
            raise MethodNotAllowed(description='No view implemented for {}({})'.format(request.method, ', '.join(kwargs.keys())))
        return view(*args, **kwargs)

    @classmethod
    def route_as_view(cls, app, name, rules, *class_args, **class_kwargs):
        """ Register the view with an URL route
        :param app: Flask application
        :type app: flask.Flask|flask.Blueprint
        :param name: Unique view name
        :type name: str
        :param rules: List of route rules to use
        :type rules: Iterable[str|werkzeug.routing.Rule]
        :param class_args: Args to pass to object constructor
        :param class_kwargs: KwArgs to pass to object constructor
        :return: View callable
        :rtype: Callable
        """
        view = super(MethodView, cls).as_view(name, *class_args, **class_kwargs)
        for rule in rules:
            app.add_url_rule(rule, view_func=view)
        return view


class RestfulViewType(MethodViewType):
    """ Metaclass that automatically defines REST methods """
    methods_map = {
        # view-name: (needs-primary-key, http-method)
        # Collection methods
        'list':    (False, 'GET'),
        'create':  (False, 'POST'),
        # Item methods
        'get':     (True,  'GET'),
        'replace': (True,  'PUT'),
        'update':  (True,  'POST'),
        'delete':  (True,  'DELETE'),
    }

    def __init__(cls, name, bases, d):
        pk = getattr(cls, 'primary_key', ())
        mcs = type(cls)

        # Do not do anything with this class unless the primary key is set
        if pk:
            # Walk through known REST methods
            # list() is used to make sure we have a copy and do not re-wrap the same method twice
            for view_name, (needs_pk, method) in list(mcs.methods_map.items()):
                # Get the view func
                view = getattr(cls, view_name, None)
                if callable(view):  # method exists and is callable
                    # Automatically decorate it with @methodview() and conditions on PK
                    view = methodview(
                        method,
                        ifnset=None if needs_pk else pk,
                        ifset=pk if needs_pk else None,
                    )(view)
                    setattr(cls, view_name, view)

        # Proceed
        super(RestfulViewType, cls).__init__(name, bases, d)


class RestfulView(with_metaclass(RestfulViewType, MethodView)):
    """ Method View that automatically defines the following methods:

        Collection:
            GET /    -> list()
            POST /   -> create()
        Individual item:
            GET /<pk>     -> get()
            PUT /<pk>     -> replace()
            POST /<pk>    -> update()
            DELETE /<pk>  -> delete()

        You just need to specify PK fields
    """

    #: List of route parameters used as a primary key.
    #: If specified -- then we're working with an individual entry, and if not -- with the whole collection
    primary_key = ()


__all__ = ('methodview', 'MethodView', 'RestfulView')