# authentic2 - versatile identity manager
# Copyright (C) 2010-2019 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import base64
import itertools
import json
import pickle

from django.contrib.messages.views import SuccessMessageMixin
from django.core import signing
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.forms import MediaDefiningClass
from django.http import Http404, HttpResponse
from django.urls import reverse, reverse_lazy
from django.utils.encoding import force_text
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.views.generic import CreateView, DeleteView, DetailView, FormView, TemplateView, UpdateView, View
from django.views.generic.base import ContextMixin
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormMixin
from django_select2.views import AutoResponseView
from django_tables2 import SingleTableMixin, SingleTableView
from gadjo.templatetags.gadjo import xstatic

from authentic2 import hooks
from authentic2.a2_rbac.models import OrganizationalUnit as OU
from authentic2.data_transfer import ImportContext, export_site, import_site
from authentic2.decorators import json as json_view
from authentic2.forms.profile import modelform_factory
from authentic2.utils.misc import batch_queryset, redirect
from django_rbac.utils import get_ou_model

from . import app_settings, forms, utils, widgets


class MediaMixinBase(MediaDefiningClass, FormMixin):
    pass


class MultipleOUMixin:
    '''Tell templates if there are multiple OU for adaptation in breadcrumbs for example'''

    def get_context_data(self, **kwargs):
        kwargs['multiple_ou'] = utils.get_ou_count() > 1
        return super().get_context_data(**kwargs)


class MediaMixin(metaclass=MediaMixinBase):
    '''Expose needed CSS and JS files as a media object'''

    class Media:
        js = (
            xstatic('jquery.js', 'jquery.min.js'),
            reverse_lazy('a2-manager-javascript-catalog'),
            xstatic('jquery-ui.js', 'jquery-ui.min.js'),
            'js/gadjo.js',
            'jquery/js/jquery.form.js',
            'admin/js/urlify.js',
            'authentic2/js/purl.js',
            'authentic2/manager/js/manager.js',
        )
        css = {'all': ('authentic2/manager/css/style.css',)}

    def get_context_data(self, **kwargs):
        kwargs['media'] = self.media
        ctx = super().get_context_data(**kwargs)
        if 'form' in ctx:
            ctx['media'] += ctx['form'].media
        return ctx


class PermissionMixin:
    '''Control access to views based on permissions'''

    permissions = None
    permissions_global = False

    def authorize(self, request, *args, **kwargs):
        if hasattr(self, 'model'):
            app_label = self.model._meta.app_label
            model_name = self.model._meta.model_name
            add_perm = '%s.add_%s' % (app_label, model_name)
            self.can_add = request.user.has_perm_any(add_perm)
            if hasattr(self, 'get_object') and (
                (hasattr(self, 'pk_url_kwarg') and self.pk_url_kwarg in self.kwargs)
                or (hasattr(self, 'slug_url_kwarg') and self.slug_url_kwarg in self.kwargs)
            ):
                self.object = self.get_object()
                permissions = ('view', 'change', 'delete', 'manage_members')
                for permission in permissions:
                    perm = '%s.%s_%s' % (app_label, permission, model_name)
                    setattr(self, 'can_' + permission, request.user.has_perm(perm, self.object))
                if self.permissions and not request.user.has_perms(self.permissions, self.object):
                    raise PermissionDenied
            elif self.permissions and not request.user.has_perm_any(self.permissions):
                raise PermissionDenied
        else:
            if self.permissions:
                if self.permissions_global and not request.user.has_perms(self.permissions):
                    raise PermissionDenied
                if not self.permissions_global and not request.user.has_perm_any(self.permissions):
                    raise PermissionDenied

    def dispatch(self, request, *args, **kwargs):
        response = self.authorize(request, *args, **kwargs)
        if response is not None:
            return response
        return super().dispatch(request, *args, **kwargs)


def filter_view(request, qs):
    model = qs.model
    perm = '%s.search_%s' % (model._meta.app_label, model._meta.model_name)
    return request.user.filter_by_perm(perm, qs)


class FilterQuerysetByPermMixin:
    def get_queryset(self):
        qs = super().get_queryset()
        return filter_view(self.request, qs)


class FilterTableQuerysetByPermMixin:
    def get_table_data(self):
        qs = super().get_table_data()
        if getattr(self, 'filter_table_by_perm', True):
            qs = filter_view(self.request, qs)
        return qs


class TableQuerysetMixin:
    def get_table_queryset(self):
        return self.get_queryset()

    def get_table_data(self):
        return self.get_table_queryset()


class SearchFormMixin:
    """Handle a search form on the current table view.

    The search form class must implement a .filter(qs) method returning a new queryset."""

    search_form_class = None

    def get_search_form_class(self):
        return self.search_form_class

    def get_search_form_kwargs(self):
        return {'request': self.request, 'data': self.request.GET}

    def get_search_form(self):
        form_class = self.get_search_form_class()
        if not form_class:
            return
        return form_class(**self.get_search_form_kwargs())

    def dispatch(self, request, *args, **kwargs):
        self.search_form = self.get_search_form()
        return super().dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        if self.search_form:
            ctx['search_form'] = self.search_form
        return ctx

    def filter_by_search(self, qs):
        if self.search_form and self.search_form.is_valid():
            qs = self.search_form.filter(qs)
        return qs

    def get_table_data(self):
        qs = super().get_table_data()
        qs = self.filter_by_search(qs)
        return qs


class FormatsContextData:
    '''Export list of supported formats in context'''

    formats = ['csv']

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx['formats'] = self.formats
        return ctx


class Action:
    '''Describe an action for view supporting multiples actions.'''

    name = None
    title = None
    confirm = None
    url_name = None
    url = None
    popup = True
    permission = None

    def __init__(
        self, name=None, title=None, confirm=None, url_name=None, url=None, popup=None, permission=None
    ):
        if name is not None:
            self.name = name
        if title is not None:
            self.title = title
        if confirm is not None:
            self.confirm = confirm
        if url_name is not None:
            self.url_name = url_name
        if url is not None:
            self.url = url
        if popup is not None:
            self.popup = popup
        if permission is not None:
            self.permission = permission

    def display(self, instance, request):
        if self.permission:
            return request.user.has_perm(self.permission, instance)
        return True


class AjaxFormViewMixin:
    '''Implement a JSON response for view which can be included in an AJAX popup'''

    success_url = '.'

    def dispatch(self, request, *args, **kwargs):
        response = super().dispatch(request, *args, **kwargs)
        return self.return_ajax_response(request, response)

    def return_ajax_response(self, request, response):
        if not request.is_ajax():
            return response
        data = {}
        if 'Location' in response:
            location = response['Location']
            # empty location means that the view can be used from anywhere
            # and so the redirect URL should not be used
            # otherwise compute an absolute URI from the relative URI
            if location and (
                not location.startswith('http://')
                or not location.startswith('https://')
                or not location.startswith('/')
            ):
                location = request.build_absolute_uri(location)
            data['location'] = location
        if hasattr(response, 'render'):
            response.render()
            data['content'] = force_text(response.content)
        return HttpResponse(json.dumps(data), content_type='application/json')


class TitleMixin:
    '''Mixin to provide a title to the view's template'''

    title = ''

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx['title'] = self.title
        ctx['manager_site_title'] = app_settings.SITE_TITLE
        return ctx


class ActionMixin:
    '''Describe the main action implementd by a view'''

    action = None

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        if self.action:
            ctx['action'] = self.action
        return ctx


class OtherActionsMixin:
    '''Describe secondary actions possible on a view'''

    other_actions = None

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx['other_actions'] = tuple(self.get_displayed_other_actions())
        return ctx

    def get_other_actions(self):
        return self.other_actions or []

    def get_displayed_other_actions(self):
        actions = []
        other_actions = list(self.get_other_actions())
        hooks.call_hooks('manager_modify_other_actions', self, other_actions)
        for action in other_actions:
            if callable(action.display) and not action.display(self.object, self.request):
                continue

            if action.display:
                actions.append(action)
        return actions

    def post(self, request, *args, **kwargs):
        self.object = self.get_object()
        for action in self.get_displayed_other_actions():
            if action.name in request.POST:
                response = None
                if hasattr(action, 'do'):
                    response = action.do(self, request, self.object)
                else:
                    method = getattr(self, 'action_' + action.name, None)
                    if method:
                        response = method(request, *args, **kwargs)
                hooks.call_hooks(
                    'event',
                    name='manager-action',
                    user=self.request.user,
                    action=action,
                    instance=self.object,
                )
                if response:
                    return response
                self.request.method = 'GET'
                return self.get(request, *args, **kwargs)
        parent = super()
        if hasattr(parent, 'post'):
            return parent.post(request, *args, **kwargs)
        return self.get(request, *args, **kwargs)


class ExportMixin:
    '''Help in implementd export views'''

    http_method_names = ['get', 'head', 'options']
    export_prefix = ''

    def get_export_prefix(self):
        return self.export_prefix

    def get_resource(self):
        return self.resource_class()

    def get_data(self):
        qs = self.get_table_data()
        return batch_queryset(qs)

    def get_dataset(self):
        return self.get_resource().export(self.get_data())

    def get(self, request, *args, **kwargs):
        export_format = kwargs['format'].lower()
        content_types = {
            'csv': 'text/csv',
        }
        if export_format not in content_types:
            raise Http404('unknown format')
        content = getattr(self.get_dataset(), export_format)
        content_type = content_types[export_format]
        return self.export_response(content, content_type, export_format)

    def export_response(self, content, content_type, export_format):
        response = HttpResponse(content, content_type=content_type)
        filename = '%s%s.%s' % (self.get_export_prefix(), now().strftime('%Y%m%d_%H%M%S'), export_format)
        response['Content-Disposition'] = 'attachment; filename="%s"' % filename
        return response


class FormNeedsRequest:
    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        if getattr(self.get_form_class(), 'need_request', False):
            kwargs['request'] = self.request
        return kwargs


class ModelNameMixin(MediaMixin):
    '''Mixin to provide a model name to view's template'''

    def get_model_name(self):
        return self.model._meta.verbose_name

    def get_instance_name(self):
        if hasattr(self, 'get_object'):
            return str(self.get_object())
        return ''

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx['model_name'] = self.get_model_name()
        return ctx


class TableHookMixin:
    '''Helper class for table views, hiding the OU column from tables if an OU filter exists'''

    def get_table(self, **kwargs):
        table = super().get_table(**kwargs)
        import copy

        table = copy.deepcopy(table)
        hooks.call_hooks('manager_modify_table', self, table)
        return table


class BaseTableView(
    MultipleOUMixin,
    TitleMixin,
    TableHookMixin,
    FormatsContextData,
    ModelNameMixin,
    PermissionMixin,
    SearchFormMixin,
    FilterQuerysetByPermMixin,
    TableQuerysetMixin,
    SingleTableView,
):
    '''Base class for views showing a table of objects'''

    pass


class SubTableViewMixin(
    TableHookMixin,
    FormatsContextData,
    ModelNameMixin,
    PermissionMixin,
    SearchFormMixin,
    FilterTableQuerysetByPermMixin,
    TableQuerysetMixin,
    SingleObjectMixin,
    SingleTableMixin,
    ContextMixin,
):
    '''Helper class for views showing a table of objects related to one object'''

    context_object_name = 'object'
    paginate_by = None


class SimpleSubTableView(TitleMixin, SubTableViewMixin, TemplateView):
    '''Base class for views showing a simple table of objects related to one object'''

    pass


class BaseSubTableView(MultipleOUMixin, TitleMixin, SubTableViewMixin, FormNeedsRequest, FormView):
    '''Base class for views showing a table of objects related to one object'''

    success_url = '.'


class BaseDeleteView(TitleMixin, ModelNameMixin, PermissionMixin, AjaxFormViewMixin, DeleteView):
    '''Base class for views implementing deletion of an object'''

    template_name = 'authentic2/manager/delete.html'
    context_object_name = 'object'

    @property
    def permissions(self):
        app_label = self.model._meta.app_label
        model_name = self.model._meta.model_name
        return ['%s.delete_%s' % (app_label, model_name)]

    @property
    def title(self):
        return _('Delete %s') % self.get_instance_name()

    def get_success_url(self):
        return '../../'


class ModelFormView(MediaMixin, FormNeedsRequest):
    '''Base class for views showing a form for a model'''

    fields = None
    form_class = None

    def get_fields(self):
        return self.fields

    def get_form_class(self):
        return modelform_factory(self.model, form=self.form_class, fields=self.get_fields())

    def get_form(self, form_class=None):
        form = super().get_form(form_class=form_class)
        hooks.call_hooks('manager_modify_form', self, form)
        return form


class BaseDetailView(MultipleOUMixin, TitleMixin, ModelNameMixin, PermissionMixin, ModelFormView, DetailView):
    context_object_name = 'object'
    form_class = None

    @property
    def permissions(self):
        app_label = self.model._meta.app_label
        model_name = self.model._meta.model_name
        return ['%s.view_%s' % (app_label, model_name)]

    def get_form(self):
        form_class = self.get_form_class()
        if getattr(form_class, 'need_request', False):
            form = form_class(request=self.request, instance=self.object)
        else:
            form = form_class(instance=self.object)
        for field in form.fields.values():
            widget = field.widget
            widget.attrs['disabled'] = ''
            if 'readonly' in widget.attrs:
                del widget.attrs['readonly']
        return form

    def get_context_data(self, **kwargs):
        form = self.get_form()
        hooks.call_hooks('manager_modify_form', self, form)
        kwargs['form'] = form
        ctx = super().get_context_data(**kwargs)
        return ctx


class BaseAddView(
    MultipleOUMixin, TitleMixin, ModelNameMixin, PermissionMixin, AjaxFormViewMixin, ModelFormView, CreateView
):
    '''Base class for views for adding an instance of a model'''

    template_name = 'authentic2/manager/form.html'
    success_view_name = None
    context_object_name = 'object'

    @property
    def permissions(self):
        app_label = self.model._meta.app_label
        model_name = self.model._meta.model_name
        return ['%s.add_%s' % (app_label, model_name)]

    @property
    def title(self):
        return ('Add %s') % super().get_model_name()

    def get_success_url(self):
        return reverse(self.success_view_name, kwargs={'pk': self.object.pk})


class BaseEditView(
    MultipleOUMixin,
    SuccessMessageMixin,
    TitleMixin,
    ModelNameMixin,
    PermissionMixin,
    AjaxFormViewMixin,
    ModelFormView,
    UpdateView,
):
    '''Base class for views for editing an instance of a model'''

    template_name = 'authentic2/manager/form.html'
    context_object_name = 'object'

    @property
    def permissions(self):
        app_label = self.model._meta.app_label
        model_name = self.model._meta.model_name
        return ['%s.change_%s' % (app_label, model_name)]

    @property
    def title(self):
        return _('Edit %s') % self.get_instance_name()

    def get_success_url(self):
        return '..'


class HomepageView(TitleMixin, PermissionMixin, MediaMixin, TemplateView):
    template_name = 'authentic2/manager/homepage.html'
    permissions = [
        'a2_rbac.search_role',
        'a2_rbac.search_organizationalunit',
        'auth.search_group',
        'custom_user.search_user',
    ]
    default_entries = [
        {
            'class': 'icon-organizational-units',
            'href': reverse_lazy('a2-manager-ous'),
            'label': _('Organizational units'),
            'order': -1,
            'permission': 'a2_rbac.search_organizationalunit',
            'slug': 'organizational-units',
        },
        {
            'class': 'icon-users',
            'href': reverse_lazy('a2-manager-users'),
            'label': _('Users'),
            'order': -1,
            'permission': 'custom_user.search_user',
            'slug': 'users',
        },
        {
            'class': 'icon-roles',
            'href': reverse_lazy('a2-manager-roles'),
            'label': _('Roles'),
            'order': -1,
            'permission': 'a2_rbac.search_role',
            'slug': 'roles',
        },
        {
            'class': 'icon-services',
            'href': reverse_lazy('a2-manager-services'),
            'label': _('Services'),
            'order': -1,
            'permission': 'authentic2.search_service',
            'slug': 'services',
        },
    ]

    def dispatch(self, request, *args, **kwargs):
        if app_settings.HOMEPAGE_URL:
            return redirect(request, app_settings.HOMEPAGE_URL)
        return super().dispatch(request, *args, **kwargs)

    def get_homepage_entries(self):
        entries = []
        for hook_entries in itertools.chain(
            self.default_entries, hooks.call_hooks('manager_homepage_entries', self)
        ):
            if not hasattr(hook_entries, 'append'):
                hook_entries = [hook_entries]
            for entry in hook_entries:
                permission = entry.get('permission')
                if permission == 'superuser' and not self.request.user.is_superuser:
                    continue
                elif permission and not self.request.user.has_perm_any(permission):
                    continue
                entries.append(entry)
        # use possible key order to sort
        # list.sort() is supposed to be a stable sort (already sorted entries
        # are kept in the same order)
        entries.sort(key=lambda d: d.get('order', 0))
        return entries

    def get_context_data(self, **kwargs):
        kwargs['entries'] = self.get_homepage_entries()
        kwargs['can_view_journal'] = self.request.user.has_perms(
            ['custom_user.view_user', 'a2_rbac.view_role']
        )
        return super().get_context_data(**kwargs)


homepage = HomepageView.as_view()


class MenuJson(HomepageView):
    def get(self, request, *args, **kwargs):
        menu_entries = []
        for entry in self.get_homepage_entries():
            if entry.get('slug') == 'journal':
                continue
            menu_entries.append(
                {
                    'label': str(entry['label']),
                    'slug': entry.get('slug', ''),
                    'url': request.build_absolute_uri(str(entry['href'])),
                }
            )
        return menu_entries


menu_json = json_view(MenuJson.as_view())


class HideOUColumnMixin:
    '''Helper class for table views, hiding the OU column from tables if an OU filter exists'''

    def get_table(self, **kwargs):
        OU = get_ou_model()
        exclude_ou = False
        if (
            hasattr(self, 'search_form')
            and self.search_form.is_valid()
            and self.search_form.cleaned_data.get('ou') is not None
        ):
            exclude_ou = True
        if OU.objects.count() < 2:
            exclude_ou = True
        if exclude_ou:
            exclude = kwargs.setdefault('exclude', [])
            if 'ou' not in exclude:
                exclude.append('ou')
        return super().get_table(**kwargs)


class Select2View(AutoResponseView):
    '''Overrided default django-select2 view to enforce security checks on Select2 AJAX requests.'''

    def get_widget_or_404(self):
        if not self.request.user.is_authenticated or not hasattr(self.request.user, 'filter_by_perm'):
            raise Http404('Invalid user')
        field_data = self.kwargs.get('field_id', self.request.GET.get('field_id', None))
        try:
            field_data = signing.loads(field_data)
        except (signing.SignatureExpired, signing.BadSignature):
            raise Http404('Invalid or expired signature.')
        widget_class = field_data.get('class')
        if not widget_class or not hasattr(widgets, widget_class):
            raise Http404('Missing or unknown widget class.')
        widget = getattr(widgets, widget_class)()
        if not isinstance(
            widget, (widgets.SimpleModelSelect2Widget, widgets.SimpleModelSelect2MultipleWidget)
        ):
            raise Http404('Reference to invalid widget class')
        qs = widget.get_queryset()
        qs.query.where = pickle.loads(base64.b64decode(field_data['where_clause']))
        # check permissions again as current user may not be the one who obtained the field_id
        perm = '%s.search_%s' % (qs.model._meta.app_label, qs.model._meta.model_name)
        qs = self.request.user.filter_by_perm(perm, qs)
        widget.queryset = qs
        widget.view = self
        return widget


select2 = Select2View.as_view()


class SiteExport(View):
    def get(self, request, *args, **kwargs):
        if not request.user.is_superuser:
            raise PermissionDenied
        return HttpResponse(json.dumps(export_site(), indent=4), content_type='application/json')


site_export = SiteExport.as_view()


class SiteImportView(MediaMixin, TitleMixin, FormView):
    form_class = forms.SiteImportForm
    template_name = 'authentic2/manager/import_form.html'
    success_url = reverse_lazy('a2-manager-homepage')
    title = _('Site Import')

    def form_valid(self, form):
        try:
            with transaction.atomic():
                import_site(form.cleaned_data['site_json'], ImportContext())
        except ValidationError as e:
            form.add_error('site_json', e)
            return self.form_invalid(form)

        return super().form_valid(form)

    def dispatch(self, request, *args, **kwargs):
        if not request.user.is_superuser:
            raise PermissionDenied
        return super().dispatch(request, *args, **kwargs)


site_import = SiteImportView.as_view()


class SearchOUMixin:
    @cached_property
    def ou(self):
        try:
            ou_id = int(self.request.GET['search-ou'])
        except (ValueError, KeyError):
            return None
        else:
            return OU.objects.filter(pk=ou_id).first()

    def get_context_data(self, **kwargs):
        return super().get_context_data(ou=self.ou, **kwargs)
