import json

from django import forms
from django.core.exceptions import ImproperlyConfigured
from django.forms import widgets
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _

from wagtail.admin.admin_url_finder import AdminURLFinder
from wagtail.admin.staticfiles import versioned_static
from wagtail.admin.telepath import register
from wagtail.admin.telepath.widgets import WidgetAdapter
from wagtail.coreutils import resolve_model_string
from wagtail.models import Page


class BaseChooser(widgets.Input):
    choose_one_text = _("Choose an item")
    choose_another_text = _("Choose another item")
    clear_choice_text = _("Clear choice")
    link_to_chosen_text = _("Edit this item")
    show_edit_link = True
    show_clear_link = True
    template_name = "wagtailadmin/widgets/chooser.html"
    display_title_key = (
        "title"  # key to use for the display title within the value data dict
    )
    icon = None
    classname = None
    model = None
    js_constructor = "Chooser"
    linked_fields = {}

    # when looping over form fields, this one should appear in visible_fields, not hidden_fields
    # despite the underlying input being type="hidden"
    input_type = "hidden"
    is_hidden = False

    def __init__(self, **kwargs):
        # allow attributes to be overridden by kwargs
        for var in [
            "choose_one_text",
            "choose_another_text",
            "clear_choice_text",
            "link_to_chosen_text",
            "show_edit_link",
            "show_clear_link",
            "icon",
            "linked_fields",
        ]:
            if var in kwargs:
                setattr(self, var, kwargs.pop(var))
        super().__init__(**kwargs)

    @cached_property
    def model_class(self):
        return resolve_model_string(self.model)

    def value_from_datadict(self, data, files, name):
        # treat the empty string as None
        result = super().value_from_datadict(data, files, name)
        if result == "":
            return None
        else:
            return result

    def get_hidden_input_context(self, name, value, attrs):
        """
        Return the context variables required to render the underlying hidden input element
        """
        return super().get_context(name, value, attrs)

    def render_hidden_input(self, name, value, attrs):
        """Render the HTML for the underlying hidden input element"""
        return self._render(
            "django/forms/widgets/input.html",
            self.get_hidden_input_context(name, value, attrs),
        )

    def get_chooser_modal_url(self):
        return reverse(self.chooser_modal_url_name)

    def get_context(self, name, value_data, attrs):
        original_field_html = self.render_hidden_input(
            name, value_data.get("id"), attrs
        )
        return {
            "widget": self,
            "original_field_html": original_field_html,
            "attrs": attrs,
            "value": bool(
                value_data
            ),  # only used by chooser.html to identify blank values
            "edit_url": value_data.get("edit_url", ""),
            "display_title": value_data.get(self.display_title_key, ""),
            "chooser_url": self.get_chooser_modal_url(),
            "icon": self.icon,
            "classname": self.classname,
        }

    def render_html(self, name, value_data, attrs):
        return render_to_string(
            self.template_name,
            self.get_context(name, value_data or {}, attrs),
        )

    def get_instance(self, value):
        """
        Given a value passed to this widget for rendering (which may be None, an id, or a model
        instance), return a model instance or None
        """
        if value is None:
            return None
        elif isinstance(value, self.model_class):
            return value
        else:  # assume instance ID
            try:
                return self.model_class.objects.get(pk=value)
            except self.model_class.DoesNotExist:
                return None

    def get_display_title(self, instance):
        """
        Return the text to display as the title for this instance
        """
        return str(instance)

    def get_value_data_from_instance(self, instance):
        """
        Given a model instance, return a value that we can pass to both the server-side template
        and the client-side rendering code (via telepath) that contains all the information needed
        for display. Typically this is a dict of id, title etc; it must be JSON-serialisable.
        """
        return {
            "id": instance.pk,
            "edit_url": AdminURLFinder().get_edit_url(instance),
            self.display_title_key: self.get_display_title(instance),
        }

    def get_value_data(self, value):
        """
        Given a value passed to this widget for rendering (which may be None, an id, or a model
        instance), return a value that we can pass to both the server-side template and the
        client-side rendering code (via telepath) that contains all the information needed
        for display. Typically this is a dict of id, title etc; it must be JSON-serialisable.
        """
        instance = self.get_instance(value)
        if instance:
            return self.get_value_data_from_instance(instance)

    def render(self, name, value, attrs=None, renderer=None):
        # no point trying to come up with sensible semantics for when 'id' is missing from attrs,
        # so let's make sure it fails early in the process
        try:
            id_ = attrs["id"]
        except (KeyError, TypeError):
            raise TypeError("BaseChooser cannot be rendered without an 'id' attribute")

        value_data = self.get_value_data(value)
        widget_html = self.render_html(name, value_data, attrs)

        js = self.render_js_init(id_, name, value_data)
        out = f"{widget_html}<script>{js}</script>"
        return mark_safe(out)

    @property
    def base_js_init_options(self):
        """The set of options to pass to the JS initialiser that are constant every time this widget
        instance is rendered (i.e. do not vary based on id / name / value)"""
        opts = {
            "modalUrl": self.get_chooser_modal_url(),
        }
        if self.linked_fields:
            opts["linkedFields"] = self.linked_fields
        return opts

    def get_js_init_options(self, id_, name, value_data):
        return {**self.base_js_init_options}

    def render_js_init(self, id_, name, value_data):
        opts = self.get_js_init_options(id_, name, value_data)
        return f"new {self.js_constructor}({json.dumps(id_)}, {json.dumps(opts)});"

    @cached_property
    def media(self):
        return forms.Media(
            js=[
                versioned_static("wagtailadmin/js/chooser-widget.js"),
            ]
        )


class BaseChooserAdapter(WidgetAdapter):
    js_constructor = "wagtail.admin.widgets.Chooser"

    def js_args(self, widget):
        return [
            widget.render_html("__NAME__", None, attrs={"id": "__ID__"}),
            widget.id_for_label("__ID__"),
            widget.base_js_init_options,
        ]

    @cached_property
    def media(self):
        return forms.Media(
            js=[
                versioned_static("wagtailadmin/js/chooser-widget-telepath.js"),
            ]
        )


register(BaseChooserAdapter(), BaseChooser)


class AdminPageChooser(BaseChooser):
    choose_one_text = _("Choose a page")
    choose_another_text = _("Choose another page")
    link_to_chosen_text = _("Edit this page")
    display_title_key = "display_title"
    chooser_modal_url_name = "wagtailadmin_choose_page"
    icon = "doc-empty-inverse"
    classname = "page-chooser"
    js_constructor = "PageChooser"

    def __init__(
        self, target_models=None, can_choose_root=False, user_perms=None, **kwargs
    ):
        super().__init__(**kwargs)

        if target_models:
            if not isinstance(target_models, (set, list, tuple)):
                # assume we've been passed a single instance; wrap it as a list
                target_models = [target_models]

            # normalise the list of target models to a list of page classes
            cleaned_target_models = []
            for model in target_models:
                try:
                    cleaned_target_models.append(resolve_model_string(model))
                except (ValueError, LookupError):
                    raise ImproperlyConfigured(
                        "Could not resolve %r into a model. "
                        "Model names should be in the form app_label.model_name"
                        % (model,)
                    )
        else:
            cleaned_target_models = [Page]

        if len(cleaned_target_models) == 1 and cleaned_target_models[0] is not Page:
            model_name = capfirst(cleaned_target_models[0]._meta.verbose_name)
            self.choose_one_text += " (" + model_name + ")"

        self.user_perms = user_perms
        self.target_models = cleaned_target_models
        if len(self.target_models) == 1:
            self.model = self.target_models[0]
        else:
            self.model = Page
        self.can_choose_root = bool(can_choose_root)

    @property
    def model_names(self):
        return [
            "{app}.{model}".format(
                app=model._meta.app_label, model=model._meta.model_name
            )
            for model in self.target_models
        ]

    @property
    def base_js_init_options(self):
        # a JSON-serializable representation of the configuration options needed for the
        # client-side behaviour of this widget
        return {
            "modelNames": self.model_names,
            "canChooseRoot": self.can_choose_root,
            "userPerms": self.user_perms,
            **super().base_js_init_options,
        }

    def get_instance(self, value):
        instance = super().get_instance(value)
        if instance:
            return instance.specific

    def get_display_title(self, instance):
        return instance.get_admin_display_title()

    def get_value_data_from_instance(self, instance):
        data = super().get_value_data_from_instance(instance)
        parent_page = instance.get_parent()
        data["parent_id"] = parent_page.pk if parent_page else None
        return data

    def get_js_init_options(self, id_, name, value_data):
        opts = super().get_js_init_options(id_, name, value_data)
        value_data = value_data or {}
        parent_id = value_data.get("parent_id")
        if parent_id is not None:
            opts["parentId"] = parent_id
        return opts

    @property
    def media(self):
        return forms.Media(
            js=[
                versioned_static("wagtailadmin/js/page-chooser-modal.js"),
                versioned_static("wagtailadmin/js/page-chooser.js"),
            ]
        )


class PageChooserAdapter(BaseChooserAdapter):
    js_constructor = "wagtail.widgets.PageChooser"

    @cached_property
    def media(self):
        return forms.Media(
            js=[
                versioned_static("wagtailadmin/js/page-chooser-modal.js"),
                versioned_static("wagtailadmin/js/page-chooser-telepath.js"),
            ]
        )


class AdminPageMoveChooser(AdminPageChooser):
    def __init__(
        self, target_models=None, can_choose_root=False, user_perms=None, **kwargs
    ):
        self.pages_to_move = kwargs.pop("pages_to_move", [])
        super().__init__(
            target_models=target_models,
            can_choose_root=can_choose_root,
            user_perms=user_perms,
            **kwargs,
        )

    @property
    def base_js_init_options(self):
        return {
            "targetPages": self.pages_to_move,
            "matchSubclass": False,
            **super().base_js_init_options,
        }


register(PageChooserAdapter(), AdminPageChooser)
