from __future__ import unicode_literals

import inspect
import logging

from furl import furl

from django.apps import apps
from django.conf import settings
from django.contrib.admin.utils import label_for_field
from django.core.exceptions import PermissionDenied
from django.shortcuts import resolve_url
from django.template import VariableDoesNotExist, Variable
from django.template.defaulttags import URLNode
from django.urls import Resolver404, resolve
from django.utils.encoding import force_str, force_text

from mayan.apps.common.utils import return_attrib
from mayan.apps.permissions import Permission

logger = logging.getLogger(__name__)


class ResolvedLink(object):
    def __init__(self, link, current_view):
        self.current_view = current_view
        self.disabled = False
        self.link = link
        self.url = '#'
        self.context = None
        self.request = None

    @property
    def active(self):
        return self.link.view == self.current_view

    @property
    def description(self):
        return self.link.description

    @property
    def html_data(self):
        return self.link.html_data

    @property
    def html_extra_classes(self):
        return self.link.html_extra_classes

    @property
    def icon(self):
        return self.link.icon

    @property
    def icon_class(self):
        return self.link.icon_class

    @property
    def tags(self):
        return self.link.tags

    @property
    def text(self):
        try:
            return self.link.text(context=self.context)
        except TypeError:
            return self.link.text


class Menu(object):
    _registry = {}

    @classmethod
    def get(cls, name):
        return cls._registry[name]

    @classmethod
    def remove(cls, name):
        del cls._registry[name]

    def __init__(self, name, condition=None, icon=None, icon_class=None, label=None, non_sorted_sources=None):
        if name in self.__class__._registry:
            raise Exception('A menu with this name already exists')

        self.condition = condition
        self.icon = icon
        self.icon_class = icon_class
        self.name = name
        self.label = label
        self.bound_links = {}
        self.unbound_links = {}
        self.link_positions = {}
        self.__class__._registry[name] = self
        self.non_sorted_sources = non_sorted_sources or []

    def _map_links_to_source(self, links, source, map_variable='bound_links', position=None):
        source_links = getattr(self, map_variable).setdefault(source, [])

        for link in links:
            source_links.append(link)
            self.link_positions[link] = position

    def add_unsorted_source(self, source):
        self.non_sorted_sources.append(source)

    def check_condition(self, context):
        """
        Check to see if menu has a conditional display function and return
        the result of the condition function against the context.
        """
        if self.condition:
            return self.condition(context=context)
        else:
            return True

    def bind_links(self, links, sources=None, position=None):
        """
        Associate a link to a model, a view inside this menu
        """
        try:
            for source in sources:
                self._map_links_to_source(
                    links=links, position=position, source=source
                )
        except TypeError:
            # Unsourced links display always
            self._map_links_to_source(
                links=links, position=position, source=sources
            )

    def get_resolved_navigation_object_list(self, context, source):
        resolved_navigation_object_list = []

        if source:
            resolved_navigation_object_list = [source]
        else:
            navigation_object_list = context.get(
                'navigation_object_list', ('object',)
            )

            logger.debug('navigation_object_list: %s', navigation_object_list)

            # Multiple objects
            for navigation_object in navigation_object_list:
                try:
                    resolved_navigation_object_list.append(
                        Variable(navigation_object).resolve(context)
                    )
                except VariableDoesNotExist:
                    pass

        logger.debug(
            'resolved_navigation_object_list: %s',
            force_text(resolved_navigation_object_list)
        )
        return resolved_navigation_object_list

    def resolve(self, context, source=None, sort_results=False):
        if not self.check_condition(context=context):
            return []

        result = []

        try:
            request = context.request
        except AttributeError:
            # Simple request extraction failed. Might not be a view context.
            # Try alternate method.
            try:
                request = Variable('request').resolve(context)
            except VariableDoesNotExist:
                # There is no request variable, most probable a 500 in a test
                # view. Don't return any resolved links then.
                logger.warning('No request variable, aborting menu resolution')
                return ()

        current_path = request.META['PATH_INFO']

        # Get sources: view name, view objects
        try:
            current_view = resolve(current_path).view_name
        except Resolver404:
            # Can't figure out which view corresponds to this URL.
            # Most likely it is an invalid URL.
            logger.warning('Can\'t figure out which view corresponds to this URL: %s; aborting menu resolution.', current_path)
            return ()

        resolved_navigation_object_list = self.get_resolved_navigation_object_list(
            context=context, source=source
        )

        for resolved_navigation_object in resolved_navigation_object_list:
            resolved_links = []

            for bound_source, links in self.bound_links.items():
                try:
                    if inspect.isclass(bound_source):
                        if type(resolved_navigation_object) == bound_source:
                            for link in links:
                                resolved_link = link.resolve(
                                    context=context,
                                    resolved_object=resolved_navigation_object
                                )
                                if resolved_link:
                                    if resolved_link.link not in self.unbound_links.get(bound_source, ()):
                                        resolved_links.append(resolved_link)
                            # No need for further content object match testing
                            break
                        elif hasattr(resolved_navigation_object, 'get_deferred_fields') and resolved_navigation_object.get_deferred_fields() and isinstance(resolved_navigation_object, bound_source):
                            # Second try for objects using .defer() or .only()
                            for link in links:
                                resolved_link = link.resolve(
                                    context=context,
                                    resolved_object=resolved_navigation_object
                                )
                                if resolved_link:
                                    if resolved_link.link not in self.unbound_links.get(bound_source, ()):
                                        resolved_links.append(resolved_link)
                            # No need for further content object match testing
                            break
                except TypeError:
                    # When source is a dictionary
                    pass

            if resolved_links:
                result.append(resolved_links)

        resolved_links = []
        # View links
        for link in self.bound_links.get(current_view, []):
            resolved_link = link.resolve(context=context)
            if resolved_link:
                if resolved_link.link not in self.unbound_links.get(current_view, ()):
                    resolved_links.append(resolved_link)

        if resolved_links:
            result.append(resolved_links)

        resolved_links = []

        # Main menu links
        for link in self.bound_links.get(None, []):
            if isinstance(link, Menu):
                condition = link.check_condition(context=context)
                if condition and link not in self.unbound_links.get(None, ()):
                    resolved_links.append(link)
            else:
                # "Always show" links
                resolved_link = link.resolve(context=context)
                if resolved_link:
                    if resolved_link.link not in self.unbound_links.get(None, ()):
                        resolved_links.append(resolved_link)

        if resolved_links:
            result.append(resolved_links)

        if result:
            unsorted_source = False
            for resolved_navigation_object in resolved_navigation_object_list:
                for source in self.non_sorted_sources:
                    if isinstance(resolved_navigation_object, source):
                        unsorted_source = True
                        break

            if sort_results and not unsorted_source:
                result[0] = sorted(
                    result[0], key=lambda item: (
                        item.link.text if isinstance(item, ResolvedLink) else item.label
                    )
                )
            else:
                # Sort links by position value passed during bind
                result[0] = sorted(
                    result[0], key=lambda item: (self.link_positions.get(item.link) or 0) if isinstance(item, ResolvedLink) else (self.link_positions.get(item) or 0)
                )

        return result

    def unbind_links(self, links, sources=None):
        """
        Allow unbinding links from sources, used to allow 3rd party apps to
        change the link binding of core apps
        """
        try:
            for source in sources:
                self._map_links_to_source(
                    links=links, source=source, map_variable='unbound_links'
                )
        except TypeError:
            # Unsourced links display always
            self._map_links_to_source(
                links=links, source=sources, map_variable='unbound_links'
            )


class Link(object):
    _registry = {}

    @classmethod
    def get(cls, name):
        return cls._registry[name]

    @classmethod
    def remove(cls, name):
        del cls._registry[name]

    def __init__(self, text, view=None, args=None, condition=None,
                 conditional_disable=None, description=None, html_data=None,
                 html_extra_classes=None, icon=None, icon_class=None,
                 keep_query=False, kwargs=None, name=None, permissions=None,
                 permissions_related=None, remove_from_query=None, tags=None,
                 url=None):

        self.args = args or []
        self.condition = condition
        self.conditional_disable = conditional_disable
        self.description = description
        self.html_data = html_data
        self.html_extra_classes = html_extra_classes
        self.icon = icon
        self.icon_class = icon_class
        self.keep_query = keep_query
        self.kwargs = kwargs or {}
        self.name = name
        self.permissions = permissions or []
        self.permissions_related = permissions_related
        self.remove_from_query = remove_from_query or []
        self.tags = tags
        self.text = text
        self.view = view
        self.url = url

        if name:
            self.__class__._registry[name] = self

    def resolve(self, context, resolved_object=None):
        AccessControlList = apps.get_model(
            app_label='acls', model_name='AccessControlList'
        )

        # Try to get the request object the faster way and fallback to the
        # slower method.
        try:
            request = context.request
        except AttributeError:
            request = Variable('request').resolve(context)

        current_path = request.META['PATH_INFO']
        current_view = resolve(current_path).view_name

        # ACL is tested agains the resolved_object or just {{ object }} if not
        if not resolved_object:
            try:
                resolved_object = Variable('object').resolve(context=context)
            except VariableDoesNotExist:
                pass

        # If this link has a required permission check that the user has it
        # too
        if self.permissions:
            if resolved_object:
                try:
                    AccessControlList.objects.check_access(
                        permissions=self.permissions, user=request.user,
                        obj=resolved_object, related=self.permissions_related
                    )
                except PermissionDenied:
                    return None
            else:
                try:
                    Permission.check_permissions(
                        requester=request.user, permissions=self.permissions
                    )
                except PermissionDenied:
                    return None

        # Check to see if link has conditional display function and only
        # display it if the result of the conditional display function is
        # True
        if self.condition:
            if not self.condition(context):
                return None

        resolved_link = ResolvedLink(current_view=current_view, link=self)

        if self.view:
            view_name = Variable('"{}"'.format(self.view))
            if isinstance(self.args, list) or isinstance(self.args, tuple):
                # TODO: Don't check for instance check for iterable in try/except
                # block. This update required changing all 'args' argument in
                # links.py files to be iterables and not just strings.
                args = [Variable(arg) for arg in self.args]
            else:
                args = [Variable(self.args)]

            # If we were passed an instance of the view context object we are
            # resolving, inject it into the context. This help resolve links for
            # object lists.
            if resolved_object:
                context['resolved_object'] = resolved_object

            try:
                kwargs = self.kwargs(context)
            except TypeError:
                # Is not a callable
                kwargs = self.kwargs

            kwargs = {key: Variable(value) for key, value in kwargs.items()}

            # Use Django's exact {% url %} code to resolve the link
            node = URLNode(
                view_name=view_name, args=args, kwargs=kwargs, asvar=None
            )
            try:
                resolved_link.url = node.render(context)
            except Exception as exception:
                logger.error(
                    'Error resolving link "%s" URL; %s', self.text, exception
                )
        elif self.url:
            resolved_link.url = self.url

        # This is for links that should be displayed but that are not clickable
        if self.conditional_disable:
            resolved_link.disabled = self.conditional_disable(context)
        else:
            resolved_link.disabled = False

        # Lets a new link keep the same URL query string of the current URL
        if self.keep_query:
            # Sometimes we are required to remove a key from the URL QS
            parsed_url = furl(
                force_str(
                    request.get_full_path() or request.META.get(
                        'HTTP_REFERER', resolve_url(settings.LOGIN_REDIRECT_URL)
                    )
                )
            )

            for key in self.remove_from_query:
                try:
                    parsed_url.query.remove(key)
                except KeyError:
                    pass

            # Use the link's URL but with the previous URL querystring
            new_url = furl(resolved_link.url)
            new_url.args = parsed_url.querystr
            resolved_link.url = new_url.url

        resolved_link.context = context
        return resolved_link


class Separator(Link):
    """
    Menu separator. Renders to an <hr> tag
    """
    def __init__(self, *args, **kwargs):
        self.icon = None
        self.text = None
        self.view = None

    def resolve(self, *args, **kwargs):
        result = ResolvedLink(current_view=None, link=self)
        result.separator = True
        return result


class SourceColumn(object):
    _registry = {}

    @staticmethod
    def sort(columns):
        return sorted(columns, key=lambda x: x.order)

    @classmethod
    def get_for_source(cls, source):
        try:
            return SourceColumn.sort(columns=cls._registry[source])
        except KeyError:
            try:
                # Try it as a queryset
                return SourceColumn.sort(columns=cls._registry[source.model])
            except AttributeError:
                try:
                    # It seems to be an instance, try its class
                    return SourceColumn.sort(columns=cls._registry[source.__class__])
                except KeyError:
                    try:
                        # Special case for queryset items produced from
                        # .defer() or .only() optimizations
                        return SourceColumn.sort(columns=cls._registry[list(source._meta.parents.items())[0][0]])
                    except (AttributeError, KeyError, IndexError):
                        return ()
        except TypeError:
            # unhashable type: list
            return ()

    def __init__(self, source, label=None, attribute=None, func=None, order=None):
        self.source = source
        self._label = label
        self.attribute = attribute
        self.func = func
        self.order = order or 0
        self.__class__._registry.setdefault(source, [])
        self.__class__._registry[source].append(self)

    @property
    def label(self):
        # TODO: Add support for related fields (dotted or double underscore attributes)
        if not self._label:
            if self.attribute:
                self._label = label_for_field(
                    name=self.attribute, model=self.source._meta.model
                )
            else:
                self._label = 'Function'

        return self._label

    def resolve(self, context):
        if self.attribute:
            result = return_attrib(context['object'], self.attribute)
        elif self.func:
            result = self.func(context=context)

        return result


class Text(Link):
    """
    Menu text. Renders to a plain <li> tag
    """
    def __init__(self, *args, **kwargs):
        self.icon = None
        self.text = kwargs.get('text')
        self.view = None

    def resolve(self, *args, **kwargs):
        result = ResolvedLink(current_view=None, link=self)
        result.context = kwargs.get('context')
        result.text_span = True
        return result
