from typing import Any
from unittest import mock

from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.http import Http404
from django.test import TestCase
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.text import slugify

from wagtail.coreutils import get_dummy_request
from wagtail.models import Page

from .form_data import querydict_from_html
from .wagtail_tests import WagtailTestUtils

AUTH_BACKEND = settings.AUTHENTICATION_BACKENDS[0]


class WagtailPageTestCase(WagtailTestUtils, TestCase):
    """
    A set of assertions to help write tests for custom Wagtail page types
    """

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.dummy_request = get_dummy_request()

    def _testCanCreateAt(self, parent_model, child_model):
        return child_model in parent_model.allowed_subpage_models()

    def assertCanCreateAt(self, parent_model, child_model, msg=None):
        """
        Assert a particular child Page type can be created under a parent
        Page type. ``parent_model`` and ``child_model`` should be the Page
        classes being tested.
        """
        if not self._testCanCreateAt(parent_model, child_model):
            msg = self._formatMessage(
                msg,
                "Can not create a %s.%s under a %s.%s"
                % (
                    child_model._meta.app_label,
                    child_model._meta.model_name,
                    parent_model._meta.app_label,
                    parent_model._meta.model_name,
                ),
            )
            raise self.failureException(msg)

    def assertCanNotCreateAt(self, parent_model, child_model, msg=None):
        """
        Assert a particular child Page type can not be created under a parent
        Page type. ``parent_model`` and ``child_model`` should be the Page
        classes being tested.
        """
        if self._testCanCreateAt(parent_model, child_model):
            msg = self._formatMessage(
                msg,
                "Can create a %s.%s under a %s.%s"
                % (
                    child_model._meta.app_label,
                    child_model._meta.model_name,
                    parent_model._meta.app_label,
                    parent_model._meta.model_name,
                ),
            )
            raise self.failureException(msg)

    def assertCanCreate(self, parent, child_model, data, msg=None, publish=True):
        """
        Assert that a child of the given Page type can be created under the
        parent, using the supplied POST data.

        ``parent`` should be a Page instance, and ``child_model`` should be a
        Page subclass. ``data`` should be a dict that will be POSTed at the
        Wagtail admin Page creation method.
        """
        self.assertCanCreateAt(parent.specific_class, child_model)

        if "slug" not in data and "title" in data:
            data["slug"] = slugify(data["title"])
        if publish:
            data["action-publish"] = "action-publish"

        add_url = reverse(
            "wagtailadmin_pages:add",
            args=[child_model._meta.app_label, child_model._meta.model_name, parent.pk],
        )
        response = self.client.post(add_url, data, follow=True)

        if response.status_code != 200:
            msg = self._formatMessage(
                msg,
                "Creating a %s.%s returned a %d"
                % (
                    child_model._meta.app_label,
                    child_model._meta.model_name,
                    response.status_code,
                ),
            )
            raise self.failureException(msg)

        if response.redirect_chain == []:
            if "form" not in response.context:
                msg = self._formatMessage(msg, "Creating a page failed unusually")
                raise self.failureException(msg)
            form = response.context["form"]
            if not form.errors:
                msg = self._formatMessage(
                    msg, "Creating a page failed for an unknown reason"
                )
                raise self.failureException(msg)

            errors = "\n".join(
                "  {}:\n    {}".format(field, "\n    ".join(errors))
                for field, errors in sorted(form.errors.items())
            )
            msg = self._formatMessage(
                msg,
                "Validation errors found when creating a %s.%s:\n%s"
                % (child_model._meta.app_label, child_model._meta.model_name, errors),
            )
            raise self.failureException(msg)

        if publish:
            expected_url = reverse("wagtailadmin_explore", args=[parent.pk])
        else:
            expected_url = reverse(
                "wagtailadmin_pages:edit", args=[Page.objects.order_by("pk").last().pk]
            )

        if response.redirect_chain != [(expected_url, 302)]:
            msg = self._formatMessage(
                msg,
                "Creating a page %s.%s didn't redirect the user to the expected page %s, but to %s"
                % (
                    child_model._meta.app_label,
                    child_model._meta.model_name,
                    expected_url,
                    response.redirect_chain,
                ),
            )
            raise self.failureException(msg)

    def assertAllowedSubpageTypes(self, parent_model, child_models, msg=None):
        """
        Test that the only page types that can be created under
        ``parent_model`` are ``child_models``.

        The list of allowed child models may differ from those set in
        ``Page.subpage_types``, if the child models have set
        ``Page.parent_page_types``.
        """
        self.assertEqual(
            set(parent_model.allowed_subpage_models()), set(child_models), msg=msg
        )

    def assertAllowedParentPageTypes(self, child_model, parent_models, msg=None):
        """
        Test that the only page types that ``child_model`` can be created under
        are ``parent_models``.

        The list of allowed parent models may differ from those set in
        ``Page.parent_page_types``, if the parent models have set
        ``Page.subpage_types``.
        """
        self.assertEqual(
            set(child_model.allowed_parent_page_models()), set(parent_models), msg=msg
        )

    def assertPageIsRoutable(
        self,
        page: Page,
        route_path: str | None = "/",
        msg: str | None = None,
    ):
        """
        Asserts that ``page`` can be routed to without raising a ``Http404`` error.

        For page types with multiple routes, you can use ``route_path`` to specify an alternate route to test.
        """
        path = page.get_url(self.dummy_request)
        if route_path != "/":
            path = path.rstrip("/") + "/" + route_path.lstrip("/")

        site = page.get_site()
        if site is None:
            msg = self._formatMessage(
                msg,
                'Failed to route to "%s" for %s "%s". The page does not belong to any sites.'
                % (type(page).__name__, route_path, page),
            )
            raise self.failureException(msg)

        path_components = [component for component in path.split("/") if component]
        try:
            page, args, kwargs = site.root_page.localized.specific.route(
                self.dummy_request, path_components
            )
        except Http404:
            msg = self._formatMessage(
                msg,
                'Failed to route to "%(route_path)s" for %(page_type)s "%(page)s". A Http404 was raised for path: "%(full_path)s".'
                % {
                    "route_path": route_path,
                    "page_type": type(page).__name__,
                    "page": page,
                    "full_path": path,
                },
            )
            raise self.failureException(msg)

    def assertPageIsRenderable(
        self,
        page: Page,
        route_path: str | None = "/",
        query_data: dict[str, Any] | None = None,
        post_data: dict[str, Any] | None = None,
        user: AbstractBaseUser | None = None,
        accept_404: bool | None = False,
        accept_redirect: bool | None = False,
        msg: str | None = None,
    ):
        """
        Asserts that ``page`` can be rendered without raising a fatal error.

        For page types with multiple routes, you can use ``route_path`` to specify an alternate route to test.

        When ``post_data`` is provided, the test makes a ``POST`` request with ``post_data`` in the request body. Otherwise, a ``GET`` request is made.

        When supplied, ``query_data`` is converted to a querystring and added to the request URL (regardless of whether ``post_data`` is provided).

        When ``user`` is provided, the test is conducted with them as the active user.

        By default, the assertion will fail if the request to the page URL results in a 301, 302 or 404 HTTP response. If you are testing a page/route
        where a 404 response is expected, you can use ``accept_404=True`` to indicate this, and the assertion will pass when encountering a 404. Likewise,
        if you are testing a page/route where a redirect response is expected, you can use `accept_redirect=True` to indicate this, and the assertion will
        pass when encountering 301 or 302.
        """
        if user:
            self.client.force_login(user, AUTH_BACKEND)

        path = page.get_url(self.dummy_request)
        if route_path != "/":
            path = path.rstrip("/") + "/" + route_path.lstrip("/")

        post_kwargs = {}
        if post_data is not None:
            post_kwargs = {"data": post_data}
            if query_data:
                post_kwargs["QUERYSTRING"] = urlencode(query_data, doseq=True)
        try:
            if post_data is None:
                resp = self.client.get(path, data=query_data)
            else:
                resp = self.client.post(path, **post_kwargs)
        except Exception as e:  # noqa: BLE001
            msg = self._formatMessage(
                msg,
                'Failed to render route "%(route_path)s" for %(page_type)s "%(page)s":\n%(exc)s'
                % {
                    "route_path": route_path,
                    "page_type": type(page).__name__,
                    "page": page,
                    "exc": e,
                },
            )
            raise self.failureException(msg)
        finally:
            if user:
                self.client.logout()

        if (
            resp.status_code == 200
            or (accept_404 and resp.status_code == 404)
            or (accept_redirect and resp.status_code in (301, 302))
            or isinstance(resp, mock.MagicMock)
        ):
            return

        msg = self._formatMessage(
            msg,
            'Failed to render route "%(route_path)s" for %(page_type)s "%(page)s":\nA HTTP %(code)s response was received for path: "%(full_path)s".'
            % {
                "route_path": route_path,
                "page_type": type(page).__name__,
                "page": page,
                "code": resp.status_code,
                "full_path": path,
            },
        )
        raise self.failureException(msg)

    def assertPageIsEditable(
        self,
        page: Page,
        post_data: dict[str, Any] | None = None,
        user: AbstractBaseUser | None = None,
        msg: str | None = None,
    ):
        """
        Asserts that the page edit view works for ``page`` without raising a fatal error.

        When ``user`` is provided, the test is conducted with them as the active user. Otherwise, a superuser is created and used for the test.

        After a successful ``GET`` request, a ``POST`` request is made with field data in the request body. If ``post_data`` is provided, that will be used for this purpose. If not, this data will be extracted from the ``GET`` response HTML.
        """
        if user:
            # rule out permission issues early on
            if not page.permissions_for_user(user).can_edit():
                self._formatMessage(
                    msg,
                    'Failed to load edit view for %(page_type)s "%(page)s":\nUser "%(user)s" have insufficient permissions.'
                    % {
                        "page_type": type(page).__name__,
                        "page": page,
                        "user": user,
                    },
                )
                raise self.failureException(msg)
        else:
            if not hasattr(self, "_pageiseditable_superuser"):
                self._pageiseditable_superuser = self.create_superuser(
                    "assertpageiseditable"
                )
            user = self._pageiseditable_superuser

        self.client.force_login(user, AUTH_BACKEND)

        path = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.id})
        try:
            response = self.client.get(path)
        except Exception as e:  # noqa: BLE001
            self.client.logout()
            msg = self._formatMessage(
                msg,
                'Failed to load edit view via GET for %(page_type)s "%(page)s":\n%(exc)s'
                % {"page_type": type(page).__name__, "page": page, "exc": e},
            )
            raise self.failureException(msg)
        if response.status_code != 200:
            self.client.logout()
            msg = self._formatMessage(
                msg,
                'Failed to load edit view via GET for %(page_type)s "%(page)s":\nReceived response with HTTP status code: %(code)s.'
                % {
                    "page_type": type(page).__name__,
                    "page": page,
                    "code": response.status_code,
                },
            )
            raise self.failureException(msg)

        if post_data is not None:
            data_to_post = post_data
        else:
            data_to_post = querydict_from_html(
                response.content.decode(), form_id="page-edit-form"
            )
            data_to_post["action-publish"] = ""

        try:
            self.client.post(path, data_to_post)
        except Exception as e:  # noqa: BLE001
            msg = self._formatMessage(
                msg,
                'Failed to load edit view via POST for %(page_type)s "%(page)s":\n%(exc)s'
                % {"page_type": type(page).__name__, "page": page, "exc": e},
            )
            raise self.failureException(msg)
        finally:
            page.save()  # undo any changes to page
            self.client.logout()

    def assertPageIsPreviewable(
        self,
        page: Page,
        mode: str | None = "",
        post_data: dict[str, Any] | None = None,
        user: AbstractBaseUser | None = None,
        msg: str | None = None,
    ):
        """
        Asserts that the page preview view can be loaded for ``page`` without raising a fatal error.

        For page types that support multiple preview modes, ``mode`` can be used to specify the preview mode to be tested.

        When ``user`` is provided, the test is conducted with them as the active user. Otherwise, a superuser is created and used for the test.

        To load the preview, the test client needs to make a ``POST`` request including all required field data in the request body.
        If ``post_data`` is provided, that will be used for this purpose. If not, the method will attempt to extract this data from the page edit view.
        """
        if not user:
            if not hasattr(self, "_pageispreviewable_superuser"):
                self._pageispreviewable_superuser = self.create_superuser(
                    "assertpageispreviewable"
                )
            user = self._pageispreviewable_superuser

        self.client.force_login(user, AUTH_BACKEND)

        if post_data is None:
            edit_path = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.id})
            html = self.client.get(edit_path).content.decode()
            post_data = querydict_from_html(html, form_id="page-edit-form")

        preview_path = reverse(
            "wagtailadmin_pages:preview_on_edit", kwargs={"page_id": page.id}
        )
        try:
            response = self.client.post(
                preview_path, data=post_data, QUERYSTRING=f"mode={mode}"
            )
            self.assertEqual(response.status_code, 200)
            self.assertJSONEqual(
                response.content.decode(),
                {"is_valid": True, "is_available": True},
            )
        except Exception as e:  # noqa: BLE001
            self.client.logout()
            msg = self._formatMessage(
                msg,
                'Failed to load preview for %(page_type)s "%(page)s" with mode="%(mode)s":\n%(exc)s'
                % {
                    "page_type": type(page).__name__,
                    "page": page,
                    "mode": mode,
                    "exc": e,
                },
            )
            raise self.failureException(msg)

        try:
            self.client.get(preview_path, data={"mode": mode})
        except Exception as e:  # noqa: BLE001
            msg = self._formatMessage(
                msg,
                'Failed to load preview for %(page_type)s "%(page)s" with mode="%(mode)s":\n%(exc)s'
                % {
                    "page_type": type(page).__name__,
                    "page": page,
                    "mode": mode,
                    "exc": e,
                },
            )
            raise self.failureException(msg)
        finally:
            self.client.logout()


class WagtailPageTests(WagtailPageTestCase):
    def setUp(self):
        super().setUp()
        self.login()
