# Copyright 2024 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
import unittest
from datetime import datetime
from typing import Any, TYPE_CHECKING, TypeVar, cast

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 reverse
from django.utils.formats import date_format as django_date_format
from django.views.generic.base import View

from debusine.db.models import WorkRequest
from debusine.web.utils import ui_prototype

if TYPE_CHECKING:
    from django.test.client import _MonkeyPatchedWSGIResponse

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


class ViewTestMixin(unittest.TestCase):
    """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: "_MonkeyPatchedWSGIResponse"
    ) -> 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)

    @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 = reverse(
            "work-requests:detail", kwargs={"pk": work_request.id}
        )

        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 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 table[0].tbody.tr


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")
