# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Utility code for testing views."""

import io
import re
from collections.abc import Callable
from datetime import datetime
from typing import Any, TypeVar, cast
from unittest import mock

import lxml.etree
import lxml.objectify
from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest
from django.test import RequestFactory
from django.urls import resolve, reverse
from django.utils.formats import date_format as django_date_format
from django.views.generic.base import View
from rest_framework import status

from debusine.db.context import ContextConsistencyError
from debusine.db.models import User, WorkRequest, WorkflowTemplate, Workspace
from debusine.db.models.permissions import PermissionUser
from debusine.test.django import (
    AllowAll,
    BaseDjangoTestCase,
    DenyAll,
    TestResponseType,
    override_permission,
)
from debusine.web.utils import ui_prototype

ViewClass = TypeVar("ViewClass", bound=View)


class ViewTestMixin(BaseDjangoTestCase):
    """TestCase functions used to test Debusine views."""

    # LXML does not seem to know about HTML5 structural tags
    re_lxml_false_positive_tags = re.compile(
        r"Tag (?:nav|footer|header|article) invalid"
    )

    def make_request(self, url: str) -> HttpRequest:
        """Create a request that can be manipulated before invoking a view."""
        factory = RequestFactory()
        request = factory.get(url)
        request.user = AnonymousUser()
        return request

    def instantiate_view_class(
        self,
        view_class: type[ViewClass],
        request_or_url: HttpRequest | str,
        **kwargs: Any,
    ) -> ViewClass:
        """
        Instantiate a View subclass with the given request.

        For convenience, if request is a string it will be passed to
        make_request.
        """
        request: HttpRequest
        if isinstance(request_or_url, str):
            request = self.make_request(request_or_url)
        else:
            request = request_or_url
        view = view_class()
        view.setup(request, **kwargs)
        return view

    def _filter_parser_error_log(
        self, error_log: lxml.etree._ListErrorLog
    ) -> list[str]:
        """Filter lxml parser error log for known false positives."""
        # _LogEntry documentation:
        # https://lxml.de/apidoc/lxml.etree.html#lxml.etree._LogEntry
        Domains = lxml.etree.ErrorDomains

        errors: list[str] = []
        for error in error_log:
            match (error.domain, error.type_name):
                case Domains.HTML, "HTML_UNKNOWN_TAG":
                    if self.re_lxml_false_positive_tags.match(error.message):
                        continue
                    # Without this pass, python coverage is currently unable to
                    # detect that code does flow through here
                    pass

            errors.append(f"{error.line}:{error.type_name}:{error.message}")
        return errors

    def assertHTMLValid(
        self, response: TestResponseType
    ) -> lxml.objectify.ObjectifiedElement:
        """
        Parse the response contents as HTML and ensure it is valid.

        Returns the parsed tree.
        """
        parser = lxml.etree.HTMLParser(remove_blank_text=True)
        parser.set_element_class_lookup(
            lxml.objectify.ObjectifyElementClassLookup()
        )
        with io.BytesIO(response.content) as fd:
            root = lxml.etree.parse(fd, parser)
        errors = self._filter_parser_error_log(parser.error_log)
        self.assertEqual(errors, [])
        return cast(lxml.objectify.ObjectifiedElement, root)

    def assertHasElement(
        self, tree: lxml.objectify.ObjectifiedElement, xpath: str
    ) -> lxml.objectify.ObjectifiedElement:
        """
        Ensure that the tree has strictly one matching element.

        :param tree: tree to search
        :param xpath: XPath query to match
        :returns: resulting element
        """
        el = tree.xpath(xpath)
        if not el:
            self.fail(f"{xpath!r} not found in tree")
        if len(el) > 1:
            self.fail(f"{xpath!r} matched {len(el)} elements instead of one")
        assert isinstance(el[0], lxml.objectify.ObjectifiedElement)
        return el[0]

    @staticmethod
    def _normalize_node(node: lxml.objectify.ObjectifiedElement) -> str:
        """Normalize the HTML to ignore spaces and new lines."""

        def remove_new_lines_blanks(s: str) -> str:
            # Multiple spaces to single space
            s = re.sub(r"\s+", " ", s)

            # New lines and trailing spaces are removed
            return s.replace("\n", "").strip()

        root = lxml.etree.fromstring(lxml.etree.tostring(node))
        for element in root.iter():
            if element.text:
                element.text = remove_new_lines_blanks(element.text)
            if element.tail:
                element.tail = remove_new_lines_blanks(element.tail)
        return lxml.etree.tostring(root, method="html", encoding="unicode")

    def assertHTMLContentsEquivalent(
        self,
        node: lxml.objectify.ObjectifiedElement,
        expected: str,
    ) -> None:
        """Ensure that node's HTML is equivalent to expected_html."""
        expected_element = lxml.objectify.fromstring(expected)

        normalized_html_node = self._normalize_node(node)
        normalized_html_expected = self._normalize_node(expected_element)

        self.assertEqual(normalized_html_node, normalized_html_expected)

    def get_node_text_normalized(
        self, node: lxml.objectify.ObjectifiedElement
    ) -> str:
        """Get the node text contents, with spaces normalized."""
        # itertext will iterate on whitespace-only blocks, so it needs two
        # passes: one to reconstruct the text, and one to normalise whitespace
        sample = "".join(node.itertext())
        return " ".join(sample.strip().split())

    def assertTextContentEqual(
        self,
        node: lxml.objectify.ObjectifiedElement,
        text: str,
    ) -> None:
        """
        Ensure that node.text matches the given text.

        Both expected and actual text are normalised so that consecutive
        whitespace and newlines become a single space, to simplify dealing with
        the way HTML collapses whitespace.
        """
        sample = self.get_node_text_normalized(node)
        text = " ".join(text.strip().split())
        self.assertEqual(sample, text)

    def assertWorkRequestRow(
        self, tr: lxml.objectify.ObjectifiedElement, work_request: WorkRequest
    ) -> None:
        """Ensure the row shows the given work request."""
        work_request_url = work_request.get_absolute_url()
        self.assertTextContentEqual(tr.td[0], str(work_request.id))
        self.assertEqual(tr.td[0].a.get("href"), work_request_url)
        self.assertEqual(
            tr.td[1].get("title"),
            django_date_format(work_request.created_at, "DATETIME_FORMAT"),
        )
        self.assertTextContentEqual(tr.td[2], work_request.task_type)
        self.assertTextContentEqual(tr.td[3], work_request.get_label())
        self.assertTextContentEqual(tr.td[4], work_request.status.capitalize())
        self.assertTextContentEqual(tr.td[5], work_request.result.capitalize())

    def workspace_list_table_rows(
        self, tree: lxml.etree._Element
    ) -> list[lxml.objectify.ObjectifiedElement]:
        """Find the workspace list table in the page and return it."""
        table = tree.xpath("//table[@id='workspace-list-table']")
        if not table:
            self.fail("page has no workspace list table")
        return list(table[0].tbody.tr)

    @ui_prototype
    def collection_list_table_rows(
        self, tree: lxml.etree._Element
    ) -> list[lxml.objectify.ObjectifiedElement]:
        """Find the collection list table in the page and return it."""
        table = tree.xpath("//table[@id='collection-list-table']")
        if not table:
            self.fail("page has no collection list table")
        return list(table[0].tbody.tr)

    def assertSetsCurrentWorkspace(
        self,
        workspace: Workspace,
        url: str,
        method: str = "get",
        **kwargs: Any,
    ) -> None:
        """
        Check that the view sets the current workspace correctly.

        :param workspace: Workspace that is supposed to get set
        :param url: URL of the view to call
        :param method: HTTP method to use
        :param kwargs: passed to ``client.{method}``
        """

        class Reached(BaseException):
            """Thrown when the target code is reached."""

        with mock.patch(
            "debusine.db.models.Workspace.set_current",
            side_effect=ContextConsistencyError("expected fail"),
        ):
            response = getattr(self.client, method)(url, **kwargs)
            self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
            self.assertRegex(
                response.context["exception"],
                r"Workspace .+ not found in scope .+",
            )

        with (
            self.assertRaises(Reached),
            mock.patch(
                "debusine.db.models.Workspace.set_current",
                autospec=True,
                side_effect=Reached(),
            ) as set_current,
        ):
            response = getattr(self.client, method)(url, **kwargs)
        set_current.assert_called_once_with(workspace)

    def assertEnforcesPermission(
        self,
        predicate: Callable[[PermissionUser], bool],
        url: str,
        target_name: str,
        method: str = "get",
        **kwargs: Any,
    ) -> None:
        """
        Check that the view enforces the given permission.

        :param predicate: predicate to check
        :param url: URL of the view to call
        :param target_name: name (to be used with mock.patch) of the view
                       method that gets called after permissions are checked.
                       If it does not contain any dot, it is intended as a
                       method of the view class used for ``url``
        :param method: HTTP method to use
        :param kwargs: passed to ``client.{method}``

        """

        class Reached(BaseException):
            """Thrown when the target code is reached."""

        if "." not in target_name:
            view_class = getattr(resolve(url).func, "view_class")
            target_name = (
                f"{view_class.__module__}.{view_class.__qualname__}"
                f".{target_name}"
            )

        with (
            mock.patch(target_name, side_effect=Reached()) as target,
            override_permission(
                getattr(predicate, "__self__").__class__,
                predicate.__name__,
                AllowAll,
            ),
            self.assertRaises(Reached),
        ):
            response = getattr(self.client, method)(url, **kwargs)
        target.assert_called()

        with (
            mock.patch(target_name, side_effect=Reached()) as target,
            override_permission(
                getattr(predicate, "__self__").__class__,
                predicate.__name__,
                DenyAll,
            ),
        ):
            response = getattr(self.client, method)(url, **kwargs)
            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
        target.assert_not_called()

    def assertNavCommonElements(
        self, tree: lxml.objectify.ObjectifiedElement, is_homepage: bool = False
    ) -> None:
        """Check that the common elements in the navbar are present."""
        el = self.assertHasElement(tree, "//a[@class='navbar-brand']")

        if is_homepage:
            self.assertEqual(el.get("href"), reverse("homepage:homepage"))
        else:
            self.assertEqual(el.get("href"), reverse("scopes:detail"))

    def assertNavNoWorkspaces(
        self, tree: lxml.objectify.ObjectifiedElement
    ) -> None:
        """Check that the page has no workspaces in the navbar."""
        self.assertFalse(tree.xpath("//li[@class='nav-workspaces']"))

    def assertNavHasWorkspaces(
        self,
        tree: lxml.objectify.ObjectifiedElement,
        current: Workspace,
        others: list[Workspace],
    ) -> None:
        """Check that the navbar has the given current and other workspaces."""
        el = self.assertHasElement(tree, "//li[@id='nav-workspaces']")
        if others:
            self.assertEqual(
                el.div.a[0].get("href"), current.get_absolute_url()
            )
            expected = [li.a.get("href") for li in el.div.ul.li]
            actual = [ws.get_absolute_url() for ws in others]
            self.assertEqual(expected, actual)
        else:
            self.assertEqual(el.a.get("href"), current.get_absolute_url())
            self.assertTextContentEqual(el.a, current.name)

    def assertNavNoWorkflows(
        self, tree: lxml.objectify.ObjectifiedElement
    ) -> None:
        """Check that the page has no collections in the navbar."""
        self.assertFalse(tree.xpath("//li[@class='nav-workflows']"))

    def assertNavHasWorkflows(
        self,
        tree: lxml.objectify.ObjectifiedElement,
        workspace: Workspace,
        workflow_templates: list[WorkflowTemplate],
    ) -> None:
        """Check that the page has the given collections in the navbar."""
        el = self.assertHasElement(tree, "//li[@id='nav-workflows']")
        if not workflow_templates:
            workflows_a = self.assertHasElement(el, "a")
            self.assertFalse(el.xpath("div"))
        else:
            workflows_a = self.assertHasElement(el, "div/a[@href!='#']")

        list_url = reverse(
            "workspaces:workflows:list", kwargs={"wname": workspace.name}
        )
        self.assertEqual(workflows_a.get("href"), list_url)
        self.assertTextContentEqual(workflows_a, "Workflows")

        if not workflow_templates:
            return

        expected: list[tuple[str, str]] = [
            (list_url + f"?workflow_templates={wt.name}", wt.name)
            for wt in workflow_templates
        ]
        actual: list[tuple[str, str]] = []
        for li in el.div.ul.li:
            actual.append((li.a.get("href") or "", li.a.text or ""))
        self.assertEqual(expected, actual)

    def assertNavNoCollections(
        self, tree: lxml.objectify.ObjectifiedElement
    ) -> None:
        """Check that the page has no collections in the navbar."""
        self.assertFalse(tree.xpath("//li[@class='nav-collections']"))

    def assertNavHasCollections(
        self,
        tree: lxml.objectify.ObjectifiedElement,
        workspace: Workspace,
    ) -> None:
        """Check that the page has the given collections in the navbar."""
        el = self.assertHasElement(tree, "//li[@id='nav-collections']")
        self.assertEqual(
            el.div.a[0].get("href"),
            reverse(
                "workspaces:collections:list",
                kwargs={"wname": workspace.name},
            ),
        )
        self.assertTextContentEqual(el.div.a[0], "Collections")

        expected = {
            (c.get_absolute_url(), str(c)) for c in workspace.collections.all()
        }
        actual = set()
        for li in el.div.ul.li:
            actual.add((li.a.get("href"), li.a.text))
        self.assertEqual(expected, actual)

    def assertNavNoUser(self, tree: lxml.objectify.ObjectifiedElement) -> None:
        """Check that the page has no user in the navbar."""
        ul = self.assertHasElement(tree, "//ul[@id='navbar-right']")
        login_url = ul.li.a.get("href")
        assert login_url is not None
        self.assertEqual(login_url.split("?")[0], reverse("login"))

        self.assertFalse(tree.xpath("//ul[@id='navbar-user-actions']"))

    def assertNavHasUser(
        self, tree: lxml.objectify.ObjectifiedElement, user: User
    ) -> None:
        """Check that the page navbar has a user menu for the given user."""
        ul = self.assertHasElement(tree, "//ul[@id='navbar-right']")
        self.assertTextContentEqual(ul.li.a, user.username)

        ul = self.assertHasElement(tree, "//ul[@id='navbar-user-actions']")
        self.assertEqual(
            ul.li[0].a.get("href"),
            reverse("user:detail", kwargs={"username": user.username}),
        )
        self.assertEqual(
            ul.li[1].a.get("href"),
            reverse("user:token-list", kwargs={"username": user.username}),
        )
        self.assertEqual(ul.li[-1].form.get("action"), reverse("logout"))


def html_check_icon(value: bool) -> str:
    """Return HTML for check icon."""
    if value:
        return '<i style="color:green;" class="bi bi-check2"></i>'
    else:
        return '<i style="color:red;" class="bi bi-x"></i>'


def date_format(dt: datetime) -> str:
    """Return dt datetime formatted with the Django template format."""
    return django_date_format(dt, "DATETIME_FORMAT")
