# 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.

"""Tests for debusine templatetags."""

import datetime as dt
import re
import textwrap
from typing import Any, ClassVar, cast, override
from unittest import mock

from django.contrib import messages
from django.contrib.auth.models import AnonymousUser
from django.contrib.messages.storage.base import Message
from django.db.models import Model
from django.template import TemplateSyntaxError, engines
from django.template.backends.django import Template as DjangoTemplate
from django.template.base import FilterExpression, Parser
from django.template.context import Context
from django.test import RequestFactory, override_settings
from django.utils.safestring import SafeString

from debusine.db.context import context
from debusine.db.models import Scope
from debusine.db.permissioncontext import PermissionContext, PermissionUser
from debusine.db.playground import scenarios
from debusine.server.middlewares.scopes import (
    TOKEN_LAST_SEEN_AT_UPDATE_INTERVAL,
)
from debusine.test.django import TestCase
from debusine.web.helps import HELPS
from debusine.web.icons import Icons
from debusine.web.templatetags.debusine import (
    DebusineNode,
    DebusineNodeRenderError,
    PlaceNode,
    UINode,
    WidgetNode,
    _help,
    as_yaml,
    has_perm,
    icon,
    markdown_as_html,
    message_toast_color_class,
    message_toast_header_icon,
    roles,
    sorted_,
    worker_last_seen,
)
from debusine.web.views.widgets import Widget, widget_method


class DebusineNodeTests(TestCase):
    """Tests for the :py:class:`DebusineNode` class."""

    def make_node(self) -> DebusineNode:
        """Instantiate a concrete subclass of DebusineNode."""

        class TestNode(DebusineNode):
            @override
            def render_checked(self, _: Context) -> str:
                raise NotImplementedError()

        return TestNode()

    @override_settings(TEST_MODE=False)
    def test_render_error_debug_or_tests(self) -> None:
        node = self.make_node()
        for setting in ("DEBUG", "TEST_MODE"):
            with (
                self.subTest(setting=setting),
                override_settings(**{setting: True}),
                self.assertRaisesRegex(ValueError, r"devel message"),
            ):
                node._render_error(
                    user_message="user message", devel_message="devel message"
                )

    @override_settings(TEST_MODE=False)
    def test_render_error_exception_debug_or_tests(self) -> None:
        node = self.make_node()
        self.enterContext(
            mock.patch.object(
                node,
                "render_checked",
                side_effect=RuntimeError("expected error"),
            )
        )

        for setting in ("DEBUG", "TEST_MODE"):
            with (
                self.subTest(setting=setting),
                override_settings(**{setting: True}),
            ):
                with self.assertRaises(RuntimeError) as exc:
                    node.render(Context())
                self.assertEqual(str(exc.exception), "expected error")
                self.assertRegex(
                    exc.exception.__notes__[0],
                    r"unhandled exception in render_checked",
                )

    @override_settings(TEST_MODE=False)
    def test_render_error_production(self) -> None:
        node = self.make_node()
        with self.assertLogs("debusine.web", level="WARNING") as log:
            rendered = node._render_error(
                user_message="user message", devel_message="devel message"
            )

        tree = self.assertHTMLValid(rendered, check_widget_errors=False)
        span = self.assertHasElement(tree, "body/span")
        self.assertEqual(span.get("data-role"), "debusine-template-error")
        _, actual_message = self.get_node_text_normalized(span).split(": ")
        self.assertEqual(actual_message, "user message")

        self.assertEqual(
            log.output,
            ["WARNING:debusine.web:TestNode rendering error: devel message"],
        )

    def test_resolve_filter(self) -> None:
        parser = Parser("")
        expr = FilterExpression("test", parser)
        node = self.make_node()
        self.assertEqual(
            node.resolve_filter(Context({"test": "test"}), expr, "test"), "test"
        )

    def test_resolve_filter_invalid_filter(self) -> None:
        parser = Parser("")
        expr = FilterExpression("expr", parser)
        node = self.make_node()
        with (
            self.assertRaises(DebusineNodeRenderError) as exc,
            mock.patch(
                "django.template.base.FilterExpression.resolve",
                side_effect=RuntimeError("expected error"),
            ),
        ):
            node.resolve_filter(Context({}), expr, "test")
        self.assertEqual(
            exc.exception.user_message, "template argument malformed"
        )
        self.assertEqual(
            exc.exception.devel_message,
            "Invalid test argument: 'expr'",
        )

    def test_resolve_filter_invalid_lookup(self) -> None:
        parser = Parser("")
        expr = FilterExpression("expr", parser)
        node = self.make_node()
        with self.assertRaises(DebusineNodeRenderError) as exc:
            node.resolve_filter(Context({}), expr, "test")
        self.assertEqual(
            exc.exception.user_message, "template argument lookup failed"
        )
        self.assertEqual(
            exc.exception.devel_message,
            "test argument 'expr' failed to resolve",
        )

    def test_render_checked_debusine_node_render_error(self) -> None:
        node = self.make_node()
        exception = RuntimeError("expected error")
        with (
            mock.patch.object(
                node,
                "render_checked",
                side_effect=DebusineNodeRenderError(
                    user_message="um", devel_message="dm", exception=exception
                ),
            ),
            mock.patch.object(
                node, "_render_error", return_value="result"
            ) as render_error,
        ):
            self.assertEqual(node.render(Context()), "result")

        render_error.assert_called_once_with(
            user_message="um", devel_message="dm", exception=exception
        )

    def test_render_checked_exception(self) -> None:
        node = self.make_node()
        exception = RuntimeError("expected error")
        with (
            mock.patch.object(
                node,
                "render_checked",
                side_effect=exception,
            ),
            mock.patch.object(
                node, "_render_error", return_value="result"
            ) as render_error,
        ):
            self.assertEqual(node.render(Context()), "result")

        render_error.assert_called_once_with(
            user_message="template rendering error",
            devel_message="unhandled exception in render_checked",
            exception=exception,
        )


class WidgetNodeTests(TestCase):
    """Tests for the :py:class:`WidgetNode` class."""

    def render(self, template_string: str, **kwargs: Any) -> str:
        request = RequestFactory().get("/")
        template = engines["django"].from_string(
            "{% load debusine %}" + template_string
        )
        return template.render(
            {"context": context, "request": request, **kwargs}
        )

    def test_tag_arg_validation(self) -> None:
        for arg, exc in (
            ("", "widget requires at least one argument"),
            ("foo bar", "received an invalid token: 'bar'"),
            ("foo bar baz", "received an invalid token: 'bar'"),
            ("foo bar=1 baz", "received an invalid token: 'baz'"),
        ):
            with (
                self.subTest(arg=arg),
                self.assertRaisesRegex(TemplateSyntaxError, re.escape(exc)),
            ):
                engines["django"].from_string(
                    "{% load debusine %}{% widget " + arg + " %}"
                )

    def test_render(self) -> None:
        request = RequestFactory().get("/")
        template = engines["django"].from_string(
            "{% load debusine %}{% widget 'value' %}"
        )
        self.assertEqual(template.render({}, request=request), "value")

    def test_string_autoescape(self) -> None:
        node = WidgetNode(cast(FilterExpression, None), {})
        with mock.patch.object(
            node, "resolve_filter", return_value="<resolved>"
        ):
            self.assertEqual(node.render_checked(Context()), "&lt;resolved&gt;")

    def test_string_autoescape_safestring(self) -> None:
        expr = mock.MagicMock()
        expr.token = "name"
        node = WidgetNode(expr, {})
        with mock.patch.object(
            node, "resolve_filter", return_value=SafeString("<resolved>")
        ):
            self.assertEqual(node.render_checked(Context()), "<resolved>")

    def test_string_noautoescape(self) -> None:
        node = WidgetNode(cast(FilterExpression, None), {})
        with mock.patch.object(
            node, "resolve_filter", return_value="<resolved>"
        ):
            self.assertEqual(
                node.render_checked(Context(autoescape=False)),
                "<resolved>",
            )

    def test_widget(self) -> None:
        class _Test(Widget):
            @override
            def render(self, _: Context) -> str:
                return "rendered"

        node = WidgetNode(cast(FilterExpression, None), {})
        with mock.patch.object(node, "resolve_filter", return_value=_Test()):
            self.assertEqual(node.render_checked(Context()), "rendered")

    def test_widget_with_args(self) -> None:
        class _Test(Widget):
            @override
            def render(self, _: Context, extra: str = "") -> str:
                return f"rendered{extra}"

        self.assertEqual(
            self.render("{% widget test extra='foo' %}", test=_Test()),
            "renderedfoo",
        )

    def test_callable(self) -> None:
        @widget_method()
        def _test() -> str:
            return "rendered"

        self.assertEqual(
            self.render("{% widget test %}", test=_test),
            "rendered",
        )

    def test_callable_with_args(self) -> None:
        @widget_method()
        def _test(extra: str = "") -> str:
            return f"rendered{extra}"

        self.assertEqual(
            self.render("{% widget test extra='foo' %}", test=_test),
            "renderedfoo",
        )

    def test_callable_with_context_and_args(self) -> None:
        @widget_method(takes_context=True)
        def _test(context: Context, extra: str = "") -> str:
            return f"rendered{context['arg']}{extra}"

        self.assertEqual(
            self.render("{% widget test extra='foo' %}", test=_test, arg="bar"),
            "renderedbarfoo",
        )

    @override_settings(TEST_MODE=False)
    def test_callable_not_tagged_as_widget(self) -> None:
        def _test() -> str:
            raise NotImplementedError()

        setattr(_test, "do_not_call_in_templates", True)

        with self.assertLogs("debusine.web", level="WARNING") as log:
            self.assertRegex(
                self.render("{% widget test %}", test=_test),
                "test is not tagged as a Debusine widget",
            )
        self.assertRegex(
            log.output[0],
            r"WARNING:debusine.web:WidgetNode rendering error:"
            r" Widget test \(<function .+_test.+\)"
            r" is not tagged as a Debusine widget",
        )

    @override_settings(TEST_MODE=False)
    def test_callable_raises(self) -> None:
        @widget_method()
        def _test() -> str:
            raise ValueError("invalid")

        with self.assertLogs("debusine.web", level="WARNING") as log:
            self.assertRegex(
                self.render("{% widget test %}", test=_test),
                "test failed to render",
            )
        self.assertRegex(
            log.output[0],
            r"(?s)WARNING:debusine.web:WidgetNode rendering error:"
            r" Widget test \(<function .+_test.+\)"
            r" failed to render.+ValueError: invalid",
        )

    def test_widget_raises_exception(self) -> None:
        exception = RuntimeError("expected error")

        class _Test(Widget):
            @override
            def render(self, _: Context) -> str:
                raise exception

        expr = mock.MagicMock()
        expr.token = "name"
        node = WidgetNode(expr, {})
        with (
            mock.patch.object(node, "resolve_filter", return_value=_Test()),
            self.assertRaises(DebusineNodeRenderError) as exc,
        ):
            self.assertEqual(node.render_checked(Context()), "error")
        self.assertEqual(exc.exception.user_message, "_Test failed to render")
        self.assertRegex(
            exc.exception.devel_message,
            r"Widget name \(.+\._Test object at .+\) failed to render",
        )
        self.assertEqual(exc.exception.exception, exception)

    def test_value_errors(self) -> None:
        """Test rendering with problematic values."""
        expr = mock.MagicMock()
        expr.token = "name"
        node = WidgetNode(expr, {})
        for value, user_message, devel_message in (
            (42, "invalid widget type", "widget name 42 has invalid type"),
            (3.14, "invalid widget type", "widget name 3.14 has invalid type"),
            ({}, "invalid widget type", "widget name {} has invalid type"),
        ):
            with (
                self.subTest(value=value),
                mock.patch.object(node, "resolve_filter", return_value=value),
            ):
                with self.assertRaises(DebusineNodeRenderError) as exc:
                    node.render_checked(Context())
                self.assertEqual(exc.exception.user_message, user_message)
                self.assertEqual(exc.exception.devel_message, devel_message)


class WithscopeTests(TestCase):
    """Tests for withscope tag."""

    scenario = scenarios.DefaultContext()
    scope2: ClassVar[Scope]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        """Set up a database layout for views."""
        super().setUpTestData()
        cls.scope2 = cls.playground.get_or_create_scope("scope2")

    def render(self, template_code: str) -> str:
        """Render a template from a string."""
        request = RequestFactory().get("/")
        template = engines["django"].from_string(template_code)
        return template.render({"context": context}, request=request)

    def test_context_accessors_defaults(self) -> None:
        """Test defaults for application context accessors."""
        self.assertEqual(self.render("{{scope.name}}"), "")
        self.assertEqual(self.render("{{workspace.name}}"), "")

    def test_context_accessors_populated(self) -> None:
        """Test application context accessors for populated contexts."""
        self.scenario.set_current()
        self.assertEqual(
            self.render("{{scope.name}}"), self.scenario.scope.name
        )
        self.assertEqual(
            self.render("{{context.user.username}}"),
            self.scenario.user.username,
        )
        self.assertEqual(
            self.render("{{workspace.name}}"), self.scenario.workspace.name
        )

    def test_withscope(self) -> None:
        """Test withscope template tag."""
        self.scenario.set_current()
        name = self.scenario.scope.name
        self.assertEqual(
            self.render(
                "{% load debusine %}{{scope}}"
                "{% withscope 'scope2' %}{{scope}}{% endwithscope %}"
                "{{scope}}"
            ),
            f"{name}scope2{name}",
        )

    def test_withscope_preserve_user(self) -> None:
        self.scenario.set_current()
        self.assertEqual(
            self.render(
                "{% load debusine %}"
                "{% withscope 'scope2' %}"
                "{{scope}}{{context.user}}"
                "{% endwithscope %}"
            ),
            f"scope2{self.scenario.user.username}",
        )

    def test_withscope_misspelled(self) -> None:
        """Test withscope with a misspelled variable."""
        self.scenario.set_current()
        self.assertEqual(
            self.render(
                "{% load debusine %}{{scope}}"
                "{% withscope misspelled_var %}{{scope}}{% endwithscope %}"
                "{{scope}}"
            ),
            self.scenario.scope.name * 3,
        )

    def test_withscope_wrongtype(self) -> None:
        """Test withscope with a scope of an inappropriate type."""
        self.scenario.set_current()
        self.assertEqual(
            self.render(
                "{% load debusine %}{{scope}}"
                "{% withscope 3 %}{{scope}}{% endwithscope %}"
                "{{scope}}"
            ),
            self.scenario.scope.name * 3,
        )

    def test_withscope_wrongscope(self) -> None:
        """Test withscope with a nonexistent scope."""
        self.scenario.set_current()
        self.assertEqual(
            self.render(
                "{% load debusine %}{{scope}}"
                "{% withscope 'wrongscope' %}{{scope}}{% endwithscope %}"
                "{{scope}}"
            ),
            self.scenario.scope.name * 3,
        )

    def test_withscope_noarg(self) -> None:
        """Test withscope without args."""
        with self.assertRaisesRegex(
            TemplateSyntaxError, "withscope requires exactly one argument"
        ):
            self.render(
                "{% load debusine %}{% withscope %}{{scope}}{% endwithscope %}"
            )

    def test_withscope_toomanyargs(self) -> None:
        """Test withscope with too many args."""
        with self.assertRaisesRegex(
            TemplateSyntaxError, "withscope requires exactly one argument"
        ):
            self.render(
                "{% load debusine %}"
                "{% withscope a b %}{{scope}}{% endwithscope %}"
            )


class MockResource:
    """Mock a permission predicate for has_perm tests."""

    user: PermissionUser | None

    def __init__(self, retval: bool) -> None:
        """Store the return value."""
        self.retval = retval

    def predicate(self, pc: PermissionContext) -> bool:
        """Store the argument for checking later."""
        self.user = pc.user
        return self.retval

    def get_roles(self, pc: PermissionContext) -> list[str]:
        """Get the roles of the user."""
        # Mock as roles the user name and an arbitrary name, since get_roles
        # can return multiple values
        return [str(pc.user), "a_test_role"]


class HaspermTests(TestCase):
    """Test the has_perm template filter."""

    scenario = scenarios.DefaultScopeUser()

    def test_context_worker_token(self) -> None:
        """Test using the default user."""
        token = self.playground.create_worker_token()
        context.set_permission_context_scoped(
            self.scenario.scope, PermissionContext.for_worker(token)
        )

        resource = MockResource(True)
        self.assertTrue(
            has_perm(resource, "predicate"),  # type: ignore[arg-type]
        )
        self.assertEqual(resource.user, AnonymousUser())

    def test_context_user_anonymous(self) -> None:
        """Test using the default user."""
        context.set_permission_context_scoped(
            self.scenario.scope, PermissionContext.for_ui_anonymous()
        )
        resource = MockResource(True)
        self.assertTrue(
            has_perm(resource, "predicate"),  # type: ignore[arg-type]
        )
        assert resource.user is not None
        self.assertFalse(resource.user.is_authenticated)

    def test_context_user(self) -> None:
        """Test using the default user."""
        self.scenario.set_current()
        resource = MockResource(False)
        self.assertFalse(
            has_perm(resource, "predicate"),  # type: ignore[arg-type]
        )
        assert resource.user is not None
        self.assertEqual(resource.user, self.scenario.user)


class RolesTests(TestCase):
    """Test the roles filter."""

    scenario = scenarios.DefaultScopeUser()

    def test_current(self) -> None:
        """Test with the default user."""
        self.scenario.set_current()

        resource = cast(Model, MockResource(False))
        self.assertEqual(roles(resource), ["a_test_role", "playground"])

    def test_explicit_anonymous(self) -> None:
        """Test passing an anonymous user explicitly."""
        self.scenario.set_current()
        resource = cast(Model, MockResource(False))
        self.assertEqual(
            roles(resource, AnonymousUser()), ["AnonymousUser", "a_test_role"]
        )

    def test_explicit(self) -> None:
        """Test passing a user explicitly."""
        self.scenario.set_current()
        user = self.playground.create_user("other")

        resource = cast(Model, MockResource(False))
        self.assertEqual(roles(resource, user), ["a_test_role", "other"])

    def test_response_without_role(self) -> None:
        self.scenario.set_current()
        resource = cast(Model, object())
        self.assertEqual(roles(resource), [])


class SortedTests(TestCase):
    """Test the sorted template filter."""

    def test_sorted(self) -> None:
        """Test sorting a sequence."""
        self.assertEqual(sorted_([1, 5, 2, 4, 3]), [1, 2, 3, 4, 5])


class HighlightAsYamlTests(TestCase):
    """Test the highlight_as_yaml filter."""

    def test_highlight_as_yaml(self) -> None:
        tree = self.assertHTMLValid(as_yaml(42))

        div = self.assertHasElement(tree, "body/div")
        self.assertEqual(div.get("class"), "formatted-content")
        formatted = div.xpath("string(pre)")
        self.assertEqual(formatted, "42\n...\n")

    def test_highlight_as_yaml_sorted(self) -> None:
        tree = self.assertHTMLValid(as_yaml({"b": 2, "a": 1}))
        formatted = tree.xpath("string(body/div/pre)")
        self.assertEqual(formatted, "a: 1\nb: 2\n")

    def test_highlight_as_yaml_unsorted(self) -> None:
        tree = self.assertHTMLValid(as_yaml({"b": 2, "a": 1}, "unsorted"))
        formatted = tree.xpath("string(body/div/pre)")
        self.assertEqual(formatted, "b: 2\na: 1\n")


class IconTests(TestCase):
    """Test the icon filter."""

    def test_icon(self) -> None:
        for name, expected in (
            ("USER", Icons.USER),
            ("CaTeGoRy", Icons.CATEGORY),
            ("created_at", Icons.CREATED_AT),
            ("does-not-exist", "square-fill"),
        ):
            with self.subTest(name=name):
                self.assertEqual(icon(name), "bi-" + expected)


class HelpTests(TestCase):
    """Test the "help" template tag."""

    def test_help(self) -> None:
        help_data = HELPS["dependencies"]

        html = _help("dependencies")

        tree = self.assertHTMLValid(html)

        self.assertEqual(tree.body.i.attrib["title"], help_data.title)
        self.assertIn(help_data.link, tree.body.i.attrib["data-bs-content"])
        self.assertIn(help_data.summary, tree.body.i.attrib["data-bs-content"])


class UINodeTests(TestCase):
    """Tests for the :py:class:`UINode` class."""

    scenario = scenarios.DefaultScopeUser()

    syntax_error = re.escape(
        "'ui' statement syntax is {% ui [object] as [variable] %}"
    )

    @override
    def setUp(self) -> None:
        super().setUp()
        factory = RequestFactory()
        self.request = factory.get("/")

    def compile(self, template_code: str) -> DjangoTemplate:
        """Compile a template snippet."""
        template = engines["django"].from_string(
            "{% load debusine %}" + template_code
        )
        assert isinstance(template, DjangoTemplate)
        return template

    def test_ui(self) -> None:
        self.compile("{% ui test as 'var' %}")

    def test_ui_no_args(self) -> None:
        with self.assertRaisesRegex(TemplateSyntaxError, self.syntax_error):
            self.compile("{% ui %}")

    def test_ui_no_as(self) -> None:
        with self.assertRaisesRegex(TemplateSyntaxError, self.syntax_error):
            self.compile("{% ui helper %}")

    def test_ui_no_target(self) -> None:
        with self.assertRaisesRegex(TemplateSyntaxError, self.syntax_error):
            self.compile("{% ui as %}")

    def test_ui_multiple_targets(self) -> None:
        with self.assertRaisesRegex(TemplateSyntaxError, self.syntax_error):
            self.compile("{% ui as a b %}")

    def test_lookup_and_set(self) -> None:
        node = UINode(cast(FilterExpression, None), "var")
        with (
            mock.patch.object(
                node, "resolve_filter", return_value=self.scenario.scope
            ),
            mock.patch(
                "debusine.web.views.ui.base.UI.for_instance",
                return_value="result",
            ) as for_instance,
        ):
            context = Context({"request": self.request})
            self.assertEqual(node.render_checked(context), "")
            for_instance.assert_called_once_with(
                self.request, self.scenario.scope
            )
            self.assertEqual(context["var"], "result")

    def test_value_errors(self) -> None:
        """Test lookup with problematic values."""
        node = UINode(cast(FilterExpression, None), "var")
        for value, devel_message in (
            ("value", "ui model instance None 'value' has invalid type"),
            (42, "ui model instance None 42 has invalid type"),
            (3.14, "ui model instance None 3.14 has invalid type"),
        ):
            with (
                self.subTest(value=value),
                mock.patch.object(node, "resolve_filter", return_value=value),
            ):
                context = Context()
                with self.assertRaises(DebusineNodeRenderError) as exc:
                    node.render_checked(context)
                self.assertIsNone(context["var"])

                self.assertEqual(
                    exc.exception.user_message,
                    "template argument lookup failed",
                )
                self.assertEqual(exc.exception.devel_message, devel_message)

    def test_model_without_helper(self) -> None:
        node = UINode(cast(FilterExpression, None), "var")
        exception = KeyError("not found")
        with (
            mock.patch.object(
                node, "resolve_filter", return_value=self.scenario.scope
            ),
            mock.patch(
                "debusine.web.views.ui.base.UI.for_instance",
                side_effect=exception,
            ) as for_instance,
        ):
            context = Context({"request": self.request})
            with self.assertRaises(DebusineNodeRenderError) as exc:
                node.render_checked(context)
            for_instance.assert_called_once_with(
                self.request, self.scenario.scope
            )
            self.assertIsNone(context["var"])

            self.assertEqual(
                exc.exception.user_message,
                "template argument lookup failed",
            )
            self.assertEqual(
                exc.exception.devel_message,
                "ui helper lookup for <Scope: debusine> failed",
            )
            self.assertEqual(exc.exception.exception, exception)


class PlaceNodeTests(TestCase):
    """Tests for the :py:class:`PlaceNode` class."""

    scenario = scenarios.DefaultScopeUser()

    usage = (
        "Usage: {% place {object} [place_type] as_{widget_type}"
        " [key=value, ...] %}"
    )

    @override
    def setUp(self) -> None:
        super().setUp()
        factory = RequestFactory()
        self.request = factory.get("/")

    def compile(self, template_code: str) -> DjangoTemplate:
        """Compile a template snippet."""
        template = engines["django"].from_string(
            "{% load debusine %}" + template_code
        )
        assert isinstance(template, DjangoTemplate)
        return template

    def get_node(self, template_str: str) -> PlaceNode:
        """Return the first PlaceNode in the compiled template."""
        template = self.compile(template_str)
        nodelist = template.template.nodelist
        for node in nodelist:
            if isinstance(node, PlaceNode):
                return node
        # This is currently never reached, but better have it as a safety net
        # than removing it to get to 100% coverage
        self.fail(
            f"{template}: nodelist does not contain a PlaceNode: {nodelist}"
        )  # pragma: no cover

    def assertCompileFails(self, template: str, error: str) -> None:
        with self.assertRaisesRegex(
            TemplateSyntaxError, re.escape(error + ". " + self.usage)
        ):
            self.compile(template)

    def test_tag_without_place_type(self) -> None:
        node = self.get_node("{% place test as_button %}")
        self.assertIsNone(node.place_type)
        self.assertEqual(node.widget_type, "as_button")

    def test_tag_with_place_type(self) -> None:
        node = self.get_node("{% place test foo as_button %}")
        self.assertEqual(node.place_type, "foo")
        self.assertEqual(node.widget_type, "as_button")

    def test_tag_without_place_type_with_kwargs(self) -> None:
        node = self.get_node("{% place test as_button k=3 %}")
        self.assertIsNone(node.place_type)
        self.assertEqual(node.widget_type, "as_button")
        self.assertEqual(node.kwargs[0][0], "k")

    def test_tag_with_place_type_with_kwargs(self) -> None:
        node = self.get_node("{% place test foo as_button k=3 v=5 %}")
        self.assertEqual(node.place_type, "foo")
        self.assertEqual(node.widget_type, "as_button")
        self.assertEqual(node.kwargs[0][0], "k")
        self.assertEqual(node.kwargs[1][0], "v")

    def test_tag_no_args(self) -> None:
        for template, error in (
            ("{% place %}", "object not provided"),
            ("{% place obj %}", "as_[widget] not provided"),
            ("{% place obj ptype %}", "as_[widget] not provided"),
            (
                "{% place obj ptype foo %}",
                "widget type does not begin with `as_`",
            ),
            ("{% place obj as_foo k %}", "missing = in key=value assignment"),
            ("{% place obj as_foo = %}", "empty key in key=value assignment"),
            (
                "{% place obj as_foo =foo %}",
                "empty key in key=value assignment",
            ),
        ):
            with self.subTest(template=template):
                self.assertCompileFails(template, error)

    def test_resolve_place(self) -> None:
        self.scenario.set_current()
        self.request.user = self.scenario.user
        scope = self.scenario.scope
        ui = scope.ui(self.request)
        for obj, place_type, expected in (
            (scope, None, ui.place),
            (ui, None, ui.place),
            (ui.place, None, ui.place),
            (scope, "group_list", ui.place_group_list),
            (ui, "group_list", ui.place_group_list),
            (ui.place, "group_list", ui.place),
        ):
            with self.subTest(obj=repr(obj), place_type=place_type):
                context = Context({"request": self.request})
                node = PlaceNode(
                    cast(FilterExpression, None), place_type, "as_button", []
                )
                with mock.patch.object(
                    node, "resolve_filter", return_value=obj
                ):
                    self.assertIs(node._resolve_place(context), expected)

    def test_resolve_place_bad_object_type(self) -> None:
        self.scenario.set_current()
        node = PlaceNode(cast(FilterExpression, None), None, "as_button", [])
        with (
            mock.patch.object(node, "resolve_filter", return_value=42),
            self.assertRaises(DebusineNodeRenderError) as exc,
        ):
            node._resolve_place(Context())
        self.assertEqual(exc.exception.user_message, "invalid object type")
        self.assertEqual(
            exc.exception.devel_message,
            "object None resolved to unsupported object type int (42)",
        )

    def test_resolve_place_model_without_helper(self) -> None:
        self.scenario.set_current()
        context = Context({"request": self.request})
        node = PlaceNode(cast(FilterExpression, None), None, "as_button", [])
        exception = KeyError("not found")
        with (
            mock.patch.object(
                node, "resolve_filter", return_value=self.scenario.scope
            ),
            mock.patch(
                "debusine.web.views.ui.base.UI.for_instance",
                side_effect=exception,
            ),
            self.assertRaises(DebusineNodeRenderError) as exc,
        ):
            node._resolve_place(context)
        self.assertEqual(exc.exception.user_message, "ui lookup failed")
        self.assertEqual(
            exc.exception.devel_message, "ui helper lookup for None failed"
        )
        self.assertEqual(exc.exception.exception, exception)

    def test_resolve_place_bad_place_type(self) -> None:
        self.scenario.set_current()
        context = Context({"request": self.request})
        ui = self.scenario.scope.ui(self.request)
        node = PlaceNode(
            cast(FilterExpression, None), "invalid", "as_button", []
        )
        with (
            mock.patch.object(node, "resolve_filter", return_value=ui),
            self.assertRaises(DebusineNodeRenderError) as exc,
        ):
            node._resolve_place(context)
        self.assertEqual(exc.exception.user_message, "place lookup failed")
        self.assertEqual(
            exc.exception.devel_message,
            f"place method 'place_invalid' for {ui!r} failed",
        )
        self.assertIsInstance(exc.exception.exception, AttributeError)

    def test_resolve_kwargs(self) -> None:
        context = Context()
        node = PlaceNode(
            cast(FilterExpression, None),
            None,
            "as_button",
            [
                ("key1", cast(FilterExpression, 1)),
                ("key2", cast(FilterExpression, 2)),
            ],
        )
        with mock.patch.object(
            node, "resolve_filter", return_value="value"
        ) as resolve_filter:
            self.assertEqual(
                node._resolve_kwargs(context),
                {"key1": "value", "key2": "value"},
            )
        resolve_filter.assert_has_calls(
            [
                mock.call(context, 1, "kwarg value"),
                mock.call(context, 2, "kwarg value"),
            ]
        )

    def test_render_no_kwargs(self) -> None:
        ui = self.scenario.scope.ui(self.request)
        node = PlaceNode(cast(FilterExpression, None), None, "as_button", [])
        with (
            mock.patch.object(node, "_resolve_place", return_value=ui.place),
            mock.patch.object(
                ui.place, "as_button", return_value="rendered"
            ) as renderer,
        ):
            self.assertEqual(node.render_checked(Context()), "rendered")

        renderer.assert_called_once_with()

    def test_render_with_kwargs(self) -> None:
        ui = self.scenario.scope.ui(self.request)
        node = PlaceNode(cast(FilterExpression, None), None, "as_button", [])
        with (
            mock.patch.object(node, "_resolve_place", return_value=ui.place),
            mock.patch.object(
                node, "_resolve_kwargs", return_value={"key": "value"}
            ),
            mock.patch.object(
                ui.place, "as_button", return_value="rendered"
            ) as renderer,
        ):
            self.assertEqual(node.render_checked(Context()), "rendered")

        renderer.assert_called_once_with(key="value")

    def test_render_invalid_widget_type(self) -> None:
        ui = self.scenario.scope.ui(self.request)
        node = PlaceNode(cast(FilterExpression, None), None, "as_invalid", [])
        with (
            mock.patch.object(node, "_resolve_place", return_value=ui.place),
            self.assertRaises(DebusineNodeRenderError) as exc,
        ):
            node.render_checked(Context())

        self.assertEqual(
            exc.exception.user_message, "invalid place render type"
        )
        self.assertEqual(
            exc.exception.devel_message,
            f"place {ui.place!r} does not have an as_invalid method",
        )
        self.assertIsNone(exc.exception.exception)

    def test_render_fails(self) -> None:
        exception = RuntimeError("expected error")
        ui = self.scenario.scope.ui(self.request)
        node = PlaceNode(cast(FilterExpression, None), None, "as_button", [])
        with (
            mock.patch.object(node, "_resolve_place", return_value=ui.place),
            mock.patch.object(ui.place, "as_button", side_effect=exception),
            self.assertRaises(DebusineNodeRenderError) as exc,
        ):
            node.render_checked(Context())

        self.assertEqual(exc.exception.user_message, "place rendering failed")
        self.assertEqual(
            exc.exception.devel_message,
            f"place {ui.place!r} as_button method failed",
        )
        self.assertIs(exc.exception.exception, exception)


class MessageToastTests(TestCase):
    """Test the message_toast* helper filters."""

    def test_color_class(self) -> None:
        for level, expected_class in (
            (messages.DEBUG, "light"),
            (messages.INFO, "light"),
            (messages.SUCCESS, "success"),
            (messages.WARNING, "warning"),
            (messages.ERROR, "danger"),
            (12345, "light"),
        ):
            with self.subTest(level=level):
                msg = Message(level, "test")
                self.assertEqual(message_toast_color_class(msg), expected_class)

    def test_header_icon(self) -> None:
        for level, expected_icon in (
            (messages.DEBUG, "bi bi-info-circle"),
            (messages.INFO, "bi bi-info-circle"),
            (messages.SUCCESS, "bi bi-info-circle"),
            (messages.WARNING, "bi bi-exclamation-triangle"),
            (messages.ERROR, "bi bi-exclamation-triangle"),
            (12345, "bi bi-info-circle"),
        ):
            with self.subTest(level=level):
                msg = Message(level, "test")
                self.assertEqual(message_toast_header_icon(msg), expected_icon)

    def test_worker_last_seen_never(self) -> None:
        self.assertEqual(worker_last_seen(None), "Never")

    def test_worker_last_seen_less_than_minutes_ago(self) -> None:
        actual = worker_last_seen(dt.datetime.now(dt.UTC))
        minutes = int(TOKEN_LAST_SEEN_AT_UPDATE_INTERVAL.total_seconds() / 60)
        self.assertEqual(actual, f"Less than {minutes} minutes ago")

    def test_worker_last_seen_ago(self) -> None:
        actual = worker_last_seen(
            dt.datetime.now(dt.UTC)
            - TOKEN_LAST_SEEN_AT_UPDATE_INTERVAL
            - dt.timedelta(seconds=1)
        )

        self.assertRegex(actual, " ago$")
        self.assertNotIn("Less than", actual)


class MarkdownAsHtmlTests(TestCase):
    """Tests for markdown_as_html() method."""

    def test_markdown_as_html(self) -> None:
        self.assertHTMLEqual(
            markdown_as_html(
                textwrap.dedent(
                    """
                # Test
                Information about *repository*
                See the [Salsa](https://salsa.debusine.org/ns/repo)
                <a href="javascript:alert(1)" title="bad">bad link</a>
                <a href="https://debian.org">Debian</a>
                <div>
                div tag is allowed
                </div>
                <span>
                """
                ).strip()
            ),
            """
            <h1>Test</h1>
            <p>Information about <em>repository</em>
            See the <a href="https://salsa.debusine.org/ns/repo" rel="noopener noreferrer nofollow">Salsa</a>
            <a title="bad" rel="noopener noreferrer nofollow">bad link</a>
            <a href="https://debian.org" rel="noopener noreferrer nofollow">Debian</a></p>
            <div>
            div tag is allowed
            </div>
            <p><span></span></p>
            """,  # noqa: E501
        )

    def test_markdown_as_html_no_relative_anchor(self) -> None:
        self.assertHTMLEqual(
            markdown_as_html(
                textwrap.dedent(
                    """
                Absolute [anchor](https://debian.org)
                Relative [anchor](something-relative/to-something)
                Invalid [protocol](ftp://example.org)
                Absolute [image](https://example.org/img1.png)
                Relative [image](img1.png)
                """
                ).strip()
            ),
            """
            <p>Absolute <a href="https://debian.org" rel="noopener noreferrer nofollow">anchor</a>
            Relative <a rel="noopener noreferrer nofollow">anchor</a>
            Invalid <a rel="noopener noreferrer nofollow">protocol</a>
            Absolute <a href="https://example.org/img1.png" rel="noopener noreferrer nofollow">image</a>
            Relative <a rel="noopener noreferrer nofollow">image</a></p>
            """,  # noqa: E501
        )
