import json
import operator
import re

from django.apps import apps
from django.contrib.admin.utils import unquote
from django.core import signing
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.forms.fields import CharField
from django.http import (
    Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect, JsonResponse,
)
from django.shortcuts import get_object_or_404
from django.template import RequestContext
from django.urls import re_path, reverse
from django.utils.decorators import method_decorator
from django.utils.encoding import force_str
from django.utils.translation import gettext, override
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.http import require_POST

from cms.models import CMSPlugin, Page
from cms.utils import get_language_from_request

from .settings import TEXT_CHILDREN_ENABLED


try:
    from cms.models import PageContent
except ImportError:
    from cms.models import Title as PageContent

from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from cms.utils.placeholder import get_placeholder_conf
from cms.utils.urlutils import admin_reverse

from . import settings
from .forms import ActionTokenValidationForm, RenderPluginForm, TextForm
from .html import render_dynamic_attributes
from .models import Text
from .utils import (
    OBJ_ADMIN_WITH_CONTENT_RE_PATTERN, _plugin_tags_to_html, cms_placeholder_add_plugin, plugin_tags_to_admin_html,
    plugin_tags_to_id_list, plugin_tags_to_user_html, plugin_to_tag, random_comment_exempt, replace_plugin_tags,
)
from .widgets import TextEditorWidget, rte_config


def post_add_plugin(operation, **kwargs):
    from djangocms_history.actions import ADD_PLUGIN
    from djangocms_history.helpers import get_bound_plugins, get_plugin_data
    from djangocms_history.models import dump_json

    text_plugin = kwargs["plugin"]
    new_plugin_ids = set(text_plugin._get_inline_plugin_ids())

    if not new_plugin_ids:
        # User has not embedded any plugins on the text
        return

    new_plugins = CMSPlugin.objects.filter(pk__in=new_plugin_ids)
    new_plugins = get_bound_plugins(new_plugins)

    # Extend the recorded added plugins to include the inline plugins (if any)
    action = operation.actions.only("post_action_data").get(action=ADD_PLUGIN, order=1)
    post_data = json.loads(action.post_action_data)
    post_data["plugins"].extend(get_plugin_data(plugin) for plugin in new_plugins)
    action.post_action_data = dump_json(post_data)
    action.save(update_fields=["post_action_data"])


def pre_change_plugin(operation, **kwargs):
    from djangocms_history.actions import ADD_PLUGIN, DELETE_PLUGIN
    from djangocms_history.helpers import get_bound_plugins, get_plugin_data

    old_text_plugin = kwargs["old_plugin"]
    old_plugin_ids = set(old_text_plugin._get_inline_plugin_ids())

    new_text_plugin = kwargs["new_plugin"]
    new_plugin_ids = set(new_text_plugin._get_inline_plugin_ids())

    added_plugin_ids = new_plugin_ids.difference(old_plugin_ids)
    deleted_plugin_ids = old_plugin_ids.difference(new_plugin_ids)
    plugin_ids = added_plugin_ids | deleted_plugin_ids

    if added_plugin_ids == deleted_plugin_ids:
        # User has not added or removed embedded plugins
        return

    order = 1

    # This app is a special case.
    # We know the old and new tree orders because inline plugins
    # have already been set on the database when this pre operation
    # is executed.
    old_tree = (
        old_text_plugin.cmsplugin_set.filter(pk__in=old_plugin_ids)
        .order_by("position")
        .values_list("pk", flat=True)
    )
    old_tree = list(old_tree)

    new_tree = (
        new_text_plugin.cmsplugin_set.filter(pk__in=new_plugin_ids)
        .order_by("position")
        .values_list("pk", flat=True)
    )
    new_tree = list(new_tree)

    plugins = CMSPlugin.objects.filter(pk__in=plugin_ids)
    bound_plugins = list(get_bound_plugins(plugins))

    if added_plugin_ids:
        order += 1

        pre_action_data = {
            "order": old_tree,
            "parent_id": old_text_plugin.pk,
        }

        post_plugin_data = [
            get_plugin_data(plugin)
            for plugin in bound_plugins
            if plugin.pk in added_plugin_ids
        ]
        post_action_data = {
            "order": new_tree,
            "parent_id": old_text_plugin.pk,
            "plugins": post_plugin_data,
        }

        operation.create_action(
            action=ADD_PLUGIN,
            language=old_text_plugin.language,
            placeholder=kwargs["placeholder"],
            pre_data=pre_action_data,
            post_data=post_action_data,
            order=order,
        )

    if deleted_plugin_ids:
        order += 1
        deleted_plugins = [
            plugin for plugin in bound_plugins if plugin.pk in deleted_plugin_ids
        ]
        pre_plugin_data = [get_plugin_data(plugin) for plugin in deleted_plugins]
        pre_action_data = {
            "order": old_tree,
            "parent_id": old_text_plugin.pk,
            "plugins": pre_plugin_data,
        }

        post_plugin_data = [
            get_plugin_data(plugin, only_meta=True) for plugin in deleted_plugins
        ]
        post_action_data = {
            "order": new_tree,
            "parent_id": old_text_plugin.pk,
            "plugins": post_plugin_data,
        }

        operation.create_action(
            action=DELETE_PLUGIN,
            language=old_text_plugin.language,
            placeholder=kwargs["placeholder"],
            pre_data=pre_action_data,
            post_data=post_action_data,
            order=order,
        )


class TextPlugin(CMSPluginBase):
    model = Text
    name = settings.TEXT_PLUGIN_NAME
    module = settings.TEXT_PLUGIN_MODULE_NAME
    form = TextForm
    render_template = "cms/plugins/text.html"
    inline_editing_template = "cms/plugins/inline.html"
    editor_configuration = settings.TEXT_CONFIGURATION
    disable_child_plugins = True
    fieldsets = ((None, {"fields": ("body", "json")}),)

    class Media:
        css = {"all": ("djangocms_text/css/cms.normalize.css",)}

    # These are executed by the djangocms-history app
    # We use them to inject inline plugin data
    operation_handler_callbacks = {
        "post_add_plugin": post_add_plugin,
        "pre_change_plugin": pre_change_plugin,
    }

    @classmethod
    def do_post_copy(cls, instance, source_map):
        ids = plugin_tags_to_id_list(instance.body)
        ids_map = {pk: source_map[pk].pk for pk in ids if pk in source_map}
        new_text = replace_plugin_tags(instance.body, ids_map)
        cls.model.objects.filter(pk=instance.pk).update(body=new_text)

    @staticmethod
    def get_translation_export_content(field, plugin_data):
        def _render_plugin_with_content(obj, match):
            from djangocms_translations.utils import get_text_field_child_label

            field = get_text_field_child_label(obj.plugin_type)
            content = getattr(obj, field) if field else ""
            return plugin_to_tag(obj, content)

        content = _plugin_tags_to_html(
            plugin_data[field], output_func=_render_plugin_with_content
        )
        subplugins_within_this_content = plugin_tags_to_id_list(content)
        return content, subplugins_within_this_content

    @staticmethod
    def set_translation_import_content(content, plugin):
        data = [
            x.groups() for x in re.finditer(OBJ_ADMIN_WITH_CONTENT_RE_PATTERN, content)
        ]
        data = {int(pk): value for pk, value in data}

        return {
            subplugin_id: data[subplugin_id]
            for subplugin_id in plugin_tags_to_id_list(content)
        }

    def get_editor_widget(self, request, plugins, plugin):
        """
        Returns the Django form Widget to be used for
        the text area
        """
        cancel_url_name = self.get_admin_url_name("revert_on_cancel")
        cancel_url = reverse(f"admin:{cancel_url_name}")

        url_endpoint_name = self.get_admin_url_name("get_available_urls")
        url_endpoint = reverse(f"admin:{url_endpoint_name}")

        render_plugin_url_name = self.get_admin_url_name("render_plugin")
        render_plugin_url = reverse(f"admin:{render_plugin_url_name}")

        action_token = self.get_action_token(request, plugin)

        if plugin:
            widget = TextEditorWidget(
                installed_plugins=plugins,
                pk=plugin.pk,
                placeholder=plugin.placeholder,
                plugin_language=plugin.language,
                plugin_position=plugin.position,
                configuration=self.editor_configuration,
                render_plugin_url=render_plugin_url,
                cancel_url=cancel_url,
                url_endpoint=url_endpoint,
                action_token=action_token,
                revert_on_cancel=settings.TEXT_CHILDREN_ENABLED,
                body_css_classes=self._get_body_css_classes_from_parent_plugins(plugin),
            )
        else:
            widget = TextEditorWidget(
                installed_plugins=plugins,
                pk=None,
                placeholder=request.GET["placeholder_id"],
                plugin_language=request.GET["plugin_language"],
                plugin_position=request.GET["plugin_position"],
                configuration=self.editor_configuration,
                render_plugin_url=render_plugin_url,
                cancel_url=cancel_url,
                url_endpoint=url_endpoint,
                action_token=action_token,
                revert_on_cancel=False,
                body_css_classes="",
            )
        return widget

    def _get_body_css_classes_from_parent_plugins(
        self,
        plugin_instance: CMSPlugin,
        css_classes: str = "",
    ) -> str:
        """
        Recursion that collects CMSPluginBase.child_ckeditor_body_css_class attribute values,
        it allows to style content within WYSIWYG iframe <body> based on its parent plugins.
        """
        parent_current = plugin_instance.parent
        if parent_current:
            for plugin_name, plugin_class in plugin_pool.plugins.items():
                is_current_parent_found = plugin_name == parent_current.plugin_type
                if is_current_parent_found:
                    body_css_class = ""
                    if getattr(plugin_class, "child_ckeditor_body_css_class", False):
                        body_css_class = plugin_class.child_ckeditor_body_css_class
                    if getattr(
                        plugin_class, "get_child_ckeditor_body_css_class", False
                    ):
                        body_css_class = plugin_class.get_child_ckeditor_body_css_class(
                            parent_current
                        )

                    if body_css_class and (body_css_class not in css_classes):
                        css_classes += " " + body_css_class

            css_classes_collected = self._get_body_css_classes_from_parent_plugins(
                parent_current,
                css_classes,
            )
            if css_classes_collected not in css_classes:
                css_classes += css_classes_collected
        return css_classes

    def get_form_class(self, request, plugins, plugin):
        """
        Returns a subclass of Form to be used by this plugin
        """
        widget = self.get_editor_widget(
            request=request,
            plugins=plugins,
            plugin=plugin,
        )

        instance = plugin.get_plugin_instance()[0] if plugin else None

        if instance:
            context = RequestContext(request)
            context["request"] = request
            rendered_text = plugin_tags_to_admin_html(
                text=instance.body,
                context=context,
            )
        else:
            rendered_text = None

        # We avoid mutating the Form declared above by subclassing
        class TextPluginForm(self.form):
            body = CharField(widget=widget, required=False)

            def __init__(self, *args, **kwargs):
                initial = kwargs.pop("initial", {})

                if rendered_text:
                    initial["body"] = rendered_text
                super().__init__(*args, initial=initial, **kwargs)

        return TextPluginForm

    @staticmethod
    def _create_ghost_plugin(placeholder, plugin):
        """CMS version-save function to add a plugin to a placeholder"""
        if hasattr(placeholder, "add_plugin"):  # available as of CMS v4
            placeholder.add_plugin(plugin)
        else:  # CMS < v4
            plugin.save()

    @xframe_options_sameorigin
    def add_view(self, request, form_url="", extra_context=None):
        if "plugin" in request.GET:
            # CMS >= 3.4 compatibility
            self.cms_plugin_instance = self._get_plugin_or_404(request.GET["plugin"])

        if not settings.TEXT_CHILDREN_ENABLED or getattr(
            self, "cms_plugin_instance", None
        ):
            # This can happen if the user did not properly cancel the plugin
            # and so a "ghost" plugin instance is left over.
            # The instance is a record that points to the Text plugin
            # but is not a real text plugin instance.
            return super().add_view(
                request,
                form_url,
                extra_context,
            )

        if not self.has_add_permission(request):
            # this permission check is done by Django on the normal
            # workflow of adding a plugin.
            # This is NOT the normal workflow because we create a plugin
            # on GET request to the /add/ endpoint and so we bypass
            # django's add_view, thus bypassing permission check.
            message = gettext("You do not have permission to add a plugin.")
            return HttpResponseForbidden(force_str(message))

        _data = self._cms_initial_attributes
        data = {
            "plugin_language": _data["language"],
            "placeholder_id": _data["placeholder"],
            "parent": _data["parent"],
            "position": _data["position"],
            "plugin_type": _data["plugin_type"],
            "plugin_parent": _data["parent"],
        }

        # Sadly we have to create the CmsPlugin record on add GET request
        # because we need this record in order to allow the user to add
        # child plugins to the text (image, link, etc..)
        plugin = CMSPlugin(
            language=data["plugin_language"],
            plugin_type=data["plugin_type"],
            placeholder=data["placeholder_id"],
            position=data["position"],
            parent=data.get("plugin_parent"),
        )
        self._create_ghost_plugin(data["placeholder_id"], plugin)

        query = request.GET.copy()
        query["plugin"] = str(plugin.pk)

        success_url = admin_reverse(cms_placeholder_add_plugin)  # Version dependent
        # Because we've created the cmsplugin record
        # we need to delete the plugin when a user cancels.
        success_url += "?revert-on-cancel&" + query.urlencode()
        return HttpResponseRedirect(success_url)

    def get_plugin_urls(self):
        def pattern(regex, func):
            name = self.get_admin_url_name(func.__name__)
            return re_path(regex, func, name=name)

        url_patterns = [
            pattern(r"^render-plugin/$", self.render_plugin),
            pattern(r"^revert-on-cancel/$", self.revert_on_cancel),
            pattern(r"^urls/$", self.get_available_urls),
        ]
        return url_patterns

    def get_admin_url_name(self, name):
        plugin_type = self.__class__.__name__.lower()
        url_name = f"{self.model._meta.app_label}_{plugin_type}_{name}"
        return url_name

    def _get_text_plugin_from_request(self, request, data):
        if not (request.user.is_active and request.user.is_staff):
            raise PermissionDenied

        form = ActionTokenValidationForm(data)
        if form.is_valid():
            session_key = request.session.session_key
            text_plugin_id = form.get_id_from_token(session_key)

            if text_plugin_id:
                return self._get_plugin_or_404(text_plugin_id)

        message = gettext("Unable to process your request. Invalid token.")
        raise ValidationError(message=force_str(message))

    @random_comment_exempt
    @xframe_options_sameorigin
    def render_plugin(self, request):
        try:
            text_plugin = self._get_text_plugin_from_request(request, data=request.GET)
        except ValidationError as error:
            return HttpResponseBadRequest(error.message)

        form = RenderPluginForm(request.GET, text_plugin=text_plugin)
        if not form.is_valid():
            # plugin not found, inform CKEDITOR.plugins.insertPlugin to remove it
            return HttpResponse(status=204)

        plugin_class = text_plugin.get_plugin_class_instance()
        # The following is needed for permission checking
        plugin_class.opts = plugin_class.model._meta

        if not (
            plugin_class.has_change_permission(request, obj=text_plugin)
            and text_plugin.placeholder.has_change_permission(request.user)  # noqa
        ):
            raise PermissionDenied
        return HttpResponse(form.render_plugin(request))

    @method_decorator(require_POST)
    @xframe_options_sameorigin
    @transaction.atomic
    def revert_on_cancel(self, request):
        # This view is responsible for deleting a plugin
        # bypassing the delete permissions.
        try:
            text_plugin = self._get_text_plugin_from_request(request, data=request.POST)
        except (ValidationError, Http404):
            # Fail silently, since otherwise the user will see a 404 page in a message box
            return HttpResponse(status=204)

        plugin_class = text_plugin.get_plugin_class_instance()
        # The following is needed for permission checking
        plugin_class.opts = plugin_class.model._meta
        if not (
            plugin_class.has_add_permission(request)
            and text_plugin.placeholder.has_change_permission(request.user)  # noqa
        ):
            raise PermissionDenied

        downcasted_plugin, _ = text_plugin.get_plugin_instance()

        if downcasted_plugin:  # no ghost plugin?
            # This check prevents users from using a cancel token
            # to delete just any text plugin.
            # For already-saved plugins just run clean_plugins
            downcasted_plugin.clean_plugins()
        else:
            # Version-safe plugin delete method
            placeholder = text_plugin.placeholder
            if hasattr(placeholder, "delete_plugin"):  # since CMS v4
                placeholder.delete_plugin(text_plugin)
            else:  # up to CMS v3.11
                text_plugin.delete()

        # 204 -> request was successful but no response returned.
        return HttpResponse(status=204)

    def get_available_urls(self, request):
        if not (request.user.is_active and request.user.is_staff):
            raise PermissionDenied

        if request.GET.get("g"):
            # Get name of a reference
            try:
                model, pk = request.GET.get("g").split(":")
                app, model = model.split(".")
                model = apps.get_model(app, model)
                obj = model.objects.get(pk=pk)
                if isinstance(obj, Page):
                    obj = obj.pagecontent_set(manager="admin_manager").current_content().first()
                    return HttpResponse(json.dumps({"text": obj.title, "url": obj.get_absolute_url()}))
                return HttpResponse(json.dumps({"text": str(obj), "url": obj.get_absolute_url()}))
            except Exception as e:
                return HttpResponseBadRequest(json.dumps({"error": str(e)}))

        search = request.GET.get("q", "").strip("  ").lower()
        language = get_language_from_request(request)
        qs = (PageContent.admin_manager.filter(language=language, title__icontains=search)
              .current_content()
              .order_by("page__node__path"))
        urls = {
            "results": [
                {
                    "text": force_str(Page._meta.verbose_name_plural).capitalize(),
                    "children": [
                        {
                            "text": " " * (0 if search else len(page_content.page.node.path) // 4 - 1)
                                    + page_content.title,
                            "url": page_content.get_absolute_url(),
                            "id": f"cms.page:{page_content.page.pk}",
                            "verbose": page_content.title,
                        } for page_content in qs
                    ]
                }
            ]
        }
        return JsonResponse(urls)

    @classmethod
    def get_child_plugin_candidates(cls, slot, page):
        """

        This method is a class method that returns a list of child plugin candidates for a given slot and page.

        Parameters:
        - slot: The placeholder where the child plugins will be rendered.
        - page: The page object where the child plugins will be rendered.

        Returns:
        - A list of text-enabled plugins that can be used as child plugins for the given slot and page.
        """
        text_enabled_plugins = plugin_pool.get_text_enabled_plugins(
            placeholder=slot,
            page=page,
        )
        # Filter out plugins that are not in the whitelist if given
        if settings.TEXT_CHILDREN_WHITELIST is not None:
            text_enabled_plugins = [
                plugin
                for plugin in text_enabled_plugins
                if plugin.__name__ in settings.TEXT_CHILDREN_WHITELIST
            ]
        # Filter out plugins that are in the blacklist
        if settings.TEXT_CHILDREN_BLACKLIST:
            text_enabled_plugins = [
                plugin
                for plugin in text_enabled_plugins
                if plugin.__name__ not in settings.TEXT_CHILDREN_BLACKLIST
            ]
        return text_enabled_plugins

    def render_plugin_icon(self, plugin):
        icon = getattr(plugin, "text_icon", None)
        if icon is None:
            return
        if "cms-icon" in icon:
            return f'<span class="{icon}"></span>'
        return icon

    def get_plugins(self, obj=None):
        plugin = getattr(self, "cms_plugin_instance", None) or obj
        if not plugin or not TEXT_CHILDREN_ENABLED:
            return []
        get_plugin = plugin_pool.get_plugin
        child_plugin_types = self.get_child_classes(
            slot=plugin.placeholder.slot,
            page=self.page,
        )
        child_plugins = (get_plugin(name) for name in child_plugin_types)
        template = getattr(self.page, "template", None)

        modules = get_placeholder_conf(
            "plugin_modules", plugin.placeholder.slot, template, default={}
        )
        names = get_placeholder_conf(
            "plugin_labels", plugin.placeholder.slot, template, default={}
        )
        main_list = []

        # plugin.value points to the class name of the plugin
        # It's added on registration. TIL.
        for plugin in child_plugins:
            main_list.append(
                {
                    "value": plugin.value,
                    "name": names.get(plugin.value, plugin.name),
                    "icon": self.render_plugin_icon(plugin),
                    "module": modules.get(plugin.value, plugin.module),
                }
            )
        return sorted(main_list, key=operator.itemgetter("module"))

    def get_form(self, request, obj=None, **kwargs):
        plugin = getattr(self, "cms_plugin_instance", None) or obj
        plugins = self.get_plugins(obj)
        form = self.get_form_class(
            request=request,
            plugins=plugins,
            plugin=plugin,
        )
        kwargs["form"] = form  # override standard form
        return super().get_form(request, obj, **kwargs)

    def get_render_template(self, context, instance, placeholder):
        if self.inline_editing_active(context.get("request")):
            return self.inline_editing_template
        else:
            return self.render_template

    @staticmethod
    def inline_editing_active(request):
        return (
            settings.TEXT_INLINE_EDITING
            and hasattr(request, "toolbar")
            and request.toolbar.edit_mode_active
            and request.session.get("inline_editing", True)
        )

    def render(self, context, instance, placeholder):
        request = context.get("request")
        if self.inline_editing_active(request):
            with override(request.toolbar.toolbar_language):
                editor_settings = self.get_editor_widget(
                    context["request"], self.get_plugins(instance), instance
                ).get_editor_settings(request.toolbar.toolbar_language.split("-")[0])

            body = render_dynamic_attributes(
                instance.body, admin_objects=True, remove_attr=False
            )

            context.update(
                {
                    "body": plugin_tags_to_admin_html(body, context),
                    "placeholder": placeholder,
                    "object": instance,
                    "editor_settings": editor_settings,
                    "editor_settings_id": "cms-cfg-" + str(instance.pk),
                }
            )
        else:
            body = render_dynamic_attributes(
                instance.body, admin_objects=False, remove_attr=True
            )
            context.update(
                {
                    "body": plugin_tags_to_user_html(body, context),
                    "placeholder": placeholder,
                    "object": instance,
                }
            )
        return context

    def save_model(self, request, obj, form, change):
        if getattr(self, "cms_plugin_instance", None):
            # Because the plugin was created by manually
            # creating the CmsPlugin record, it's important
            # to assign all the values from the CmsPlugin record
            # to the real "non ghost" instance.
            fields = self.cms_plugin_instance._meta.fields

            for field in fields:
                # assign all the fields - we can do this, because object is
                # subclassing cms_plugin_instance (one to one relation)
                value = getattr(self.cms_plugin_instance, field.name)
                setattr(obj, field.name, value)
        obj.rte = rte_config.name
        super().save_model(request, obj, form, change)
        # This must come after calling save
        # If `clean_plugins()` deletes child plugins, django-treebeard will call
        # save() again on the Text instance (aka obj in this context) to update mptt values (numchild, etc).
        # See this ticket for details https://github.com/divio/djangocms-text-ckeditor/issues/212
        obj.clean_plugins()
        obj.copy_referenced_plugins()

    @staticmethod
    def get_action_token(request, obj):
        if not obj:
            return ""
        plugin_id = force_str(obj.pk)
        # salt is different for every user
        signer = signing.Signer(salt=request.session.session_key)
        return signer.sign(plugin_id)

    def _get_plugin_or_404(self, pk):
        plugin_type = self.__class__.__name__
        plugins = CMSPlugin.objects.select_related("placeholder", "parent").filter(
            plugin_type=plugin_type
        )

        field = self.model._meta.pk

        try:
            object_id = field.to_python(unquote(pk))
        except (ValidationError, ValueError):
            raise Http404("Invalid plugin id")
        return get_object_or_404(plugins, pk=object_id)


plugin_pool.register_plugin(TextPlugin)
