# 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 the base views."""

from typing import cast
from unittest import mock

from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse
from django.template.response import TemplateResponse
from django.test import RequestFactory
from django.views.generic import TemplateView

from debusine.artifacts.models import CollectionCategory
from debusine.db.context import context
from debusine.db.models import Scope
from debusine.db.permissioncontext import PermissionContext
from debusine.db.playground import scenarios
from debusine.test.django import (
    AllowAll,
    DenyAll,
    TestCase,
    TestResponseType,
    override_permission,
)
from debusine.web.views.base import BaseUIView
from debusine.web.views.places import IndexPlace
from debusine.web.views.tests.utils import ViewTestMixin


class TemplateBaseUIView(BaseUIView, TemplateView):
    """BaseUIView and TemplateView to test base template rendering."""

    template_name = "web/_base_scoped.html"


class TestBaseUI(ViewTestMixin, TestCase):
    """Test the BaseUI view."""

    scenario = scenarios.DefaultContext()

    def render_base(self) -> TestResponseType:
        """Render the base template."""
        factory = RequestFactory()
        request = factory.get("/")
        assert context.user is not None
        request.user = context.user
        request.session = self.client.session
        view = TemplateBaseUIView.as_view()
        response = cast(TemplateResponse, view(request))
        response.render()
        # Django is somewhat inconsistent with naming: alias context_data to
        # context to maintain the API of test responses
        response.context = response.context_data  # type: ignore[assignment]
        return cast(TestResponseType, response)

    def test_enforce(self) -> None:
        """Test the enforce method."""
        scope = self.playground.get_default_scope()
        user = self.playground.get_default_user()
        context.set_permission_context_scoped(
            scope, PermissionContext.for_ui_user(user)
        )
        view = BaseUIView()
        with override_permission(Scope, "can_display", AllowAll):
            view.enforce(scope.can_display)
        with (
            override_permission(Scope, "can_display", DenyAll),
            self.assertRaisesRegex(
                PermissionDenied, r"playground cannot display scope debusine"
            ),
        ):
            view.enforce(scope.can_display)

    def test_set_current_workspace_wrong_name(self) -> None:
        """Test setting the current workspace to an invalid name."""
        context.set_permission_context_scoped(
            self.scenario.scope, self.scenario.context_ui()
        )
        view = BaseUIView()
        with self.assertRaises(Http404):
            view.set_current_workspace("does-not-exist")

    def _call_init_view(self, method: str, add_method: bool) -> bool:
        """Check if init_view is called for the given method."""
        factory = RequestFactory()
        request = getattr(factory, method)("/")
        request.session = self.client.session

        if add_method:
            view_cls = type(
                "TestView",
                (BaseUIView,),
                {method: lambda *args: HttpResponse("ok")},
            )
            view = view_cls.as_view()  # type: ignore[attr-defined]
        else:
            view = BaseUIView.as_view()

        with mock.patch(
            "debusine.web.views.base.BaseUIView.init_view"
        ) as init_view:
            view(request)

        return init_view.called

    def test_init_view(self) -> None:
        """Test init_view handling."""
        for method in ("get", "post", "put", "patch", "delete", "head"):
            with self.subTest(method):
                self.assertFalse(self._call_init_view(method, False))
                self.assertTrue(self._call_init_view(method, True))

        # options is always defined in views, but init_view is not called for
        # it
        self.assertFalse(self._call_init_view("options", False))

        # init_view is not called for trace
        self.assertFalse(self._call_init_view("trace", False))
        self.assertFalse(self._call_init_view("trace", True))

    def test_worker_token_denied(self) -> None:
        """Worker tokens cannot be used with web views."""
        token = self.playground.create_worker_token()

        context.set_permission_context_scoped(
            self.scenario.scope,
            PermissionContext.for_worker(token),
        )

        request = RequestFactory().get("/")
        request.session = self.client.session

        with self.assertRaisesRegex(
            PermissionDenied,
            "special purpose tokens cannot be used with web views",
        ):
            BaseUIView.as_view()(request)

    def test_work_request_token_denied(self) -> None:
        """Work request tokens cannot be used with web views."""
        token = self.playground.create_work_request_token()

        context.set_permission_context_scoped(
            self.scenario.scope,
            PermissionContext.for_work_request(token=token),
        )

        request = RequestFactory().get("/")
        request.session = self.client.session

        with self.assertRaisesRegex(
            PermissionDenied,
            "work request tokens cannot be used with web views",
        ):
            BaseUIView.as_view()(request)

    def test_get_context_data_no_workspace(self) -> None:
        """Test get_context_data with no current workspace."""
        context.set_permission_context_scoped(
            self.scenario.scope, self.scenario.context_ui()
        )
        view = BaseUIView()
        view.request = self.make_request("/")
        ctx = view.get_context_data()
        self.assertEqual(ctx["base_template"], "web/_base_scoped.html")
        place = ctx["place"]
        self.assertIsInstance(place, IndexPlace)
        self.assertEqual(place.url, "/")
        self.assertNotIn("workspace_collections", ctx)

    def test_get_context_data_with_workspace(self) -> None:
        """Test get_context_data with a current workspace."""
        self.scenario.set_current()

        self.playground.create_collection(
            name="testing", category=CollectionCategory.WORKFLOW_INTERNAL
        )

        self.playground.create_collection(
            name="bookworm", category=CollectionCategory.SUITE
        )

        view = BaseUIView()
        view.request = self.make_request("/")
        ctx = view.get_context_data()
        self.assertEqual(ctx["base_template"], "web/_base_scoped.html")
        place = ctx["place"]
        self.assertIsInstance(place, IndexPlace)
        self.assertEqual(place.url, "/")
        self.assertQuerySetEqual(
            ctx["workspace_collections"],
            list(
                self.scenario.workspace.collections.exclude(
                    category=CollectionCategory.WORKFLOW_INTERNAL
                ).order_by("name", "category")
            ),
        )

    def test_get_context_data_with_version(self) -> None:
        """get_context_data returns debusine's version if known."""
        self.scenario.set_current()
        view = BaseUIView()
        view.request = self.make_request("/")

        with mock.patch(
            "debusine.web.views.base.debusine_version", return_value="0.1.0"
        ):
            ctx = view.get_context_data()

        self.assertEqual(ctx["debusine_version"], "0.1.0")

    def test_get_context_data_without_version(self) -> None:
        """get_context_data tolerates not knowing debusine's version."""
        self.scenario.set_current()
        view = BaseUIView()
        view.request = self.make_request("/")

        with mock.patch(
            "debusine.web.views.base.debusine_version", return_value=None
        ):
            ctx = view.get_context_data()

        self.assertIsNone(ctx["debusine_version"])

    def test_navbar_not_logged_in(self) -> None:
        """Test the navbar with no user and no current workspace."""
        context.set_permission_context_scoped(
            self.scenario.scope, PermissionContext.for_ui_anonymous()
        )
        response = self.render_base()
        tree = self.assertResponseHTML(response)

        self.assertNavCommonElements(tree)
        self.assertNavNoUser(tree)
        self.assertNavNoWorkspaces(tree)
        self.assertNavNoWorkflows(tree)
        self.assertNavNoCollections(tree)
        self.assertNavNoPlumbing(tree)

    def test_navbar_no_workspace(self) -> None:
        """Test the navbar with no user and no current workspace."""
        context.set_permission_context_scoped(
            self.scenario.scope, self.scenario.context_ui()
        )

        response = self.render_base()
        tree = self.assertResponseHTML(response)

        self.assertNavCommonElements(tree)
        self.assertNavHasUser(tree, self.scenario.user)
        self.assertNavNoWorkspaces(tree)
        self.assertNavNoWorkflows(tree)
        self.assertNavNoCollections(tree)
        self.assertNavNoPlumbing(tree)

    def test_navbar_with_workspace(self) -> None:
        """Test the navbar with user and current workspace."""
        self.scenario.set_current()

        response = self.render_base()
        tree = self.assertResponseHTML(response)

        self.assertNavCommonElements(tree)
        self.assertNavHasCollections(tree, self.scenario.workspace)
        self.assertNavHasWorkflows(tree, self.scenario.workspace, [])
        self.assertNavHasWorkspaces(tree, self.scenario.workspace, [])
        self.assertNavHasUser(tree, self.scenario.user)
        self.assertNavHasPlumbing(tree)

    def test_navbar_with_workflow_templates(self) -> None:
        """Test the navbar with user and current workspace."""
        self.scenario.set_current()

        wt_foo = self.playground.create_workflow_template("foo", "noop")
        wt_bar = self.playground.create_workflow_template("bar", "noop")
        wt_baz = self.playground.create_workflow_template("baz", "noop")

        response = self.render_base()
        tree = self.assertResponseHTML(response)

        self.assertNavCommonElements(tree)
        self.assertNavHasCollections(tree, self.scenario.workspace)
        self.assertNavHasWorkflows(
            tree,
            self.scenario.workspace,
            [wt_bar, wt_baz, wt_foo],
        )
        self.assertNavHasWorkspaces(tree, self.scenario.workspace, [])
        self.assertNavHasUser(tree, self.scenario.user)

    def test_navbar_with_other_workspace(self) -> None:
        """Test the navbar with current plus other workspaces to list."""
        self.scenario.set_current()
        b = self.playground.create_workspace(name="b", public=True)
        a = self.playground.create_workspace(name="a", public=True)
        self.playground.create_workspace(name="c")

        response = self.render_base()
        tree = self.assertResponseHTML(response)
        self.assertEqual(response.context["other_workspaces"], [a, b])

        self.assertNavCommonElements(tree)
        self.assertNavHasCollections(tree, self.scenario.workspace)
        self.assertNavHasWorkspaces(tree, self.scenario.workspace, [a, b])
        self.assertNavHasUser(tree, self.scenario.user)

    def test_footer_with_version(self) -> None:
        """The footer shows debusine's version if known."""
        self.scenario.set_current()
        with mock.patch(
            "debusine.web.views.base.debusine_version", return_value="0.1.0"
        ):
            response = self.render_base()
        tree = self.assertResponseHTML(response)

        self.assertTextContentEqual(
            tree.xpath("//footer/div[@id='footer-software']")[0],
            "Debusine software project: Documentation • Bugs • Code • "
            "Contributing • 0.1.0",
        )

    def test_footer_without_version(self) -> None:
        """The footer skips debusine's version if not known."""
        self.scenario.set_current()
        with mock.patch(
            "debusine.web.views.base.debusine_version", return_value=None
        ):
            response = self.render_base()
        tree = self.assertResponseHTML(response)

        self.assertTextContentEqual(
            tree.xpath("//footer/div[@id='footer-software']")[0],
            "Debusine software project: Documentation • Bugs • Code • "
            "Contributing",
        )

    def test_footer_custom_replace(self) -> None:
        """Footer replaced by templates installed by site admins."""
        self.scenario.set_current()
        tree = self.assertResponseHTML(self.render_base())
        footer = self.assertHasElement(
            tree, "//footer/*/div[@id='footer-status']"
        )
        self.assertTextContentEqual(
            footer, "Status: Worker pools • Workers • Task status"
        )

        with self.custom_template("web/footer.html") as template:
            template.write_text("CUSTOM")
            tree = self.assertResponseHTML(self.render_base())
            footer = self.assertHasElement(tree, "//footer")
            self.assertTextContentEqual(footer, "CUSTOM")

    def test_footer_custom_extend(self) -> None:
        """Footer extended by templates installed by site admins."""
        self.scenario.set_current()
        tree = self.assertResponseHTML(self.render_base())
        footer = self.assertHasElement(
            tree, "//footer/*/div[@id='footer-status']"
        )
        self.assertTextContentEqual(
            footer, "Status: Worker pools • Workers • Task status"
        )

        with self.custom_template("web/footer.html") as template:
            template.write_text(
                "{% extends 'web/footer.html' %}"
                "{% block footer_status %}{{block.super}}"
                "<div id='footer-testing'>Testing</div>{% endblock %}"
            )
            tree = self.assertResponseHTML(self.render_base())
            footer = self.assertHasElement(
                tree, "//footer/div[@id='footer-utils']"
            )
            self.assertTextContentEqual(
                footer,
                "Status: Worker pools • Workers • Task status"
                " Testing"
                " Theme: dark • light • auto",
            )

    def test_theme_selection(self) -> None:
        self.scenario.set_current()
        view = BaseUIView()
        view.request = self.make_request("/")
        ctx = view.get_context_data()
        self.assertIsNone(ctx["theme"])
        view.request.COOKIES["theme"] = "foo"
        ctx = view.get_context_data()
        self.assertEqual(ctx["theme"], "foo")
