# 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
from typing import TYPE_CHECKING, cast

from django.test import TestCase

import lxml.etree
import lxml.objectify

from debusine.web.utils import ui_prototype

if TYPE_CHECKING:
    from django.test.client import _MonkeyPatchedWSGIResponse


class ViewTestMixin:
    """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 _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)
        cast(TestCase, self).assertEqual(errors, [])
        return cast(lxml.objectify.ObjectifiedElement, root)

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

        Multiple spaces in node.text are compacted like it would happen in a
        browser
        """
        sample = re.sub(r"\s+", " ", node.text or "").strip()
        cast(TestCase, self).assertEqual(sample, text)

    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:
            cast(TestCase, 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:
            cast(TestCase, self).fail("page has no collection list table")
        return table[0].tbody.tr
