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

import datetime as dt
import json
from typing import Any, ClassVar, NamedTuple, override
from unittest import mock

import lxml
import lxml.objectify
from django.contrib import messages
from django.db.models import Max
from django.template.response import SimpleTemplateResponse
from django.test import override_settings
from django.urls import reverse
from rest_framework import status

from debusine.artifacts.models import (
    BareDataCategory,
    CollectionCategory,
    DebusineTaskConfiguration,
    TaskTypes,
)
from debusine.db.models import Collection, Scope, WorkRequest, Workspace
from debusine.db.playground import scenarios
from debusine.test.django import (
    AllowAll,
    TestCase,
    TestResponseType,
    override_permission,
)
from debusine.web.views.tests.utils import ViewTestMixin


class WorkspaceDetailViewTests(ViewTestMixin, TestCase):
    """Tests for WorkspaceDetailView class."""

    scenario = scenarios.DefaultScopeUser()

    workspace_public: ClassVar[Workspace]
    workspace_private: ClassVar[Workspace]
    collection: ClassVar[Collection]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        """Set up a database layout for views."""
        super().setUpTestData()
        cls.workspace_public = cls.playground.create_workspace(
            name="Public",
            public=True,
        )
        cls.workspace_private = cls.playground.create_workspace(name="Private")

        cls.collection = cls.playground.create_collection(
            "Public",
            CollectionCategory.WORKFLOW_INTERNAL,
            workspace=cls.workspace_public,
        )

    def test_detail(self) -> None:
        """Test workspace detail view."""
        response = self.client.get(self.workspace_public.get_absolute_url())
        tree = self.assertResponseHTML(response)
        details = self.assertHasElement(
            tree, "//table[@id='workspace-details']"
        )
        self.assertHasElement(details, ".//tr[@id='workspace-details-public']")
        self.assertHasElement(
            details, ".//tr[@id='workspace-details-default-expiration']"
        )
        description = self.assertHasElement(
            tree,
            "//p[@id='no-description']",
        )
        self.assertTextContentEqual(
            description, "No description in the workspace."
        )

    def test_detail_with_description(self) -> None:
        self.workspace_public.description = "Workspace **description**"
        self.workspace_public.save()

        response = self.client.get(self.workspace_public.get_absolute_url())
        tree = self.assertResponseHTML(response)

        description_el = self.assertHasElement(
            tree,
            "//div[@id='description']",
        )

        description_html = lxml.etree.tostring(description_el)

        self.assertIn(
            b"<p>Workspace <strong>description</strong>", description_html
        )

    @override_settings(
        DEBUSINE_DEBIAN_ARCHIVE_URL_SCHEME="https",
        DEBUSINE_DEBIAN_ARCHIVE_PRIMARY_FQDN="deb.example.org",
    )
    def test_debian_archive_url(self) -> None:
        Collection.objects.create(
            workspace=self.workspace_public,
            category=CollectionCategory.ARCHIVE,
            name="_",
        )
        response = self.client.get(self.workspace_public.get_absolute_url())
        tree = self.assertResponseHTML(response)
        a = self.assertHasElement(
            tree,
            "//table[@id='workspace-details']"
            "//tr[@id='workspace-debian-archive']/td/a",
        )
        url = (
            f"https://deb.example.org/{self.scenario.scope.name}/"
            f"{self.workspace_public.name}/"
        )
        self.assertEqual(a.get("href"), url)
        self.assertTextContentEqual(a, url)

    @override_settings(DEBUSINE_DEBIAN_ARCHIVE_PRIMARY_FQDN="deb.example.org")
    def test_debian_archive_url_archive_not_present(self) -> None:
        Collection.objects.filter(
            workspace=self.workspace_public,
            category=CollectionCategory.ARCHIVE,
        ).delete()
        response = self.client.get(self.workspace_public.get_absolute_url())
        tree = self.assertResponseHTML(response)
        self.assertFalse(
            tree.xpath(
                "//table[@id='workspace-details']"
                "//tr[@id='workspace-debian-archive']"
            )
        )

    def test_permissions(self) -> None:
        self.assertSetsCurrentWorkspace(
            self.workspace_public,
            self.workspace_public.get_absolute_url(),
        )

    def test_no_expiration(self) -> None:
        """No expiration is shown on non-expiring workspaces."""
        self.client.force_login(self.scenario.user)
        response = self.client.get(self.workspace_public.get_absolute_url())
        tree = self.assertResponseHTML(response)
        details = self.assertHasElement(
            tree, "//table[@id='workspace-details']"
        )
        tr = self.assertHasElement(
            details, "//tr[@id='workspace-details-expiration']"
        )
        self.assertTextContentEqual(tr.th, "Expires")
        self.assertTextContentEqual(tr.td, "Never")

    def test_expiration(self) -> None:
        """Expiration is shown on expiring workspaces."""
        self.workspace_public.expiration_delay = dt.timedelta(days=7)
        self.workspace_public.save()

        self.client.force_login(self.scenario.user)
        response = self.client.get(self.workspace_public.get_absolute_url())
        tree = self.assertResponseHTML(response)
        details = self.assertHasElement(
            tree, "//table[@id='workspace-details']"
        )
        tr = self.assertHasElement(
            details, "//tr[@id='workspace-details-expiration']"
        )
        self.assertTextContentEqual(tr.th, "Expires")
        self.assertTextContentEqual(
            tr.td,
            (self.workspace_public.created_at + dt.timedelta(days=7)).strftime(
                "%Y-%m-%d"
            ),
        )

    def test_detail_parents(self) -> None:
        """Parent workspaces are shown when they exist."""
        workspace = self.playground.create_workspace(name="base", public=True)
        workspace.set_inheritance(
            [self.workspace_private, self.workspace_public]
        )

        response = self.client.get(workspace.get_absolute_url())
        tree = self.assertResponseHTML(response)
        details = self.assertHasElement(
            tree, "//table[@id='workspace-details']"
        )
        tr = self.assertHasElement(
            details, "*/tr[@id='workspace-details-parents']", dump_on_error=True
        )
        self.assertTextContentEqual(tr.th, "Inherits from")
        self.assertTextContentEqual(tr.td, str(self.workspace_public))

    def test_can_configure(self) -> None:
        """User is logged in and can create work_request."""
        self.client.force_login(self.scenario.user)
        with override_permission(Workspace, "can_configure", AllowAll):
            response = self.client.get(self.workspace_public.get_absolute_url())
        tree = self.assertResponseHTML(response)
        actions = self.assertHasElement(tree, "//div[@id='title-menu']")
        configure_url = self.workspace_public.get_absolute_url_configure()
        self.assertHasElement(
            actions, f".//a[@href='{configure_url}']", dump_on_error=True
        )

    def test_private(self) -> None:
        """Unauthenticated detail view cannot access private workspaces."""
        response = self.client.get(self.workspace_private.get_absolute_url())
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

        self.client.force_login(self.playground.get_default_user())
        response = self.client.get(self.workspace_private.get_absolute_url())
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

        self.playground.create_group_role(
            self.workspace_private,
            Workspace.Roles.OWNER,
            users=[self.playground.get_default_user()],
        )
        response = self.client.get(self.workspace_private.get_absolute_url())
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_private_external_user_token(self) -> None:
        """External user tokens do not grant access to private workspaces."""
        collection = self.playground.create_collection(
            "test", CollectionCategory.TEST, workspace=self.workspace_private
        )
        token = self.playground.create_external_user_token()
        collection.grant_role(
            token, Collection.Roles.VIEWER, created_by=self.scenario.user
        )

        response = self.client.get(
            self.workspace_private.get_absolute_url(), HTTP_TOKEN=token.key
        )

        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

    def test_name_duplicate(self) -> None:
        """Homonymous workspace in different scope does not trigger a dup."""
        scope = Scope.objects.create(name="tests")
        self.playground.create_workspace(
            name="Public", public=True, scope=scope
        )
        response = self.client.get(self.workspace_public.get_absolute_url())
        self.assertResponseHTML(response)

    def test_workflowtemplate_list_empty(self) -> None:
        """Test showing a workspace with no workflow templates."""
        response = self.client.get(self.workspace_public.get_absolute_url())
        tree = self.assertResponseHTML(response)
        div = self.assertHasElement(tree, "//div[@id='workflow-templates']")
        self.assertTextContentEqual(
            div, "No workflow templates configured for this workspace."
        )

    def test_workflowtemplate(self) -> None:
        """Test showing a workspace with one workflow templates."""
        template = self.playground.create_workflow_template(
            name="noop-template",
            task_name="noop",
            workspace=self.workspace_public,
        )

        # 3 completed workflow
        for _ in range(3):
            self.playground.create_workflow(
                task_name="noop", result=WorkRequest.Results.SUCCESS
            )

        # 2 running workflows
        for _ in range(2):
            self.playground.create_workflow(task_name="noop", mark_running=True)

        # 1 needs input workflow
        workflow_needs_input = self.playground.create_workflow(
            task_name="noop", mark_running=True
        )
        workflow_needs_input.workflow_runtime_status = (
            WorkRequest.RuntimeStatuses.NEEDS_INPUT
        )
        workflow_needs_input.save()

        response = self.client.get(self.workspace_public.get_absolute_url())
        tree = self.assertResponseHTML(response)

        self.assertTrue(response.context["workflow_templates"].ordered)

        div = self.assertHasElement(tree, "//div[@id='workflow-templates']")
        workflow_templates = div.table

        self.assertEqual(
            workflow_templates.attrib["id"], "workflow-templates-list"
        )

        self.assertEqual(
            workflow_templates.tbody.tr[0].td[0].a, "noop-template"
        )
        self.assertEqual(
            workflow_templates.tbody.tr[0].td[0].a.attrib["href"],
            template.get_absolute_url(),
        )
        self.assertEqual(workflow_templates.tbody.tr[0].td[1].a, "noop")
        self.assertEqual(
            workflow_templates.tbody.tr[0].td[1].a.attrib["href"],
            "https://freexian-team.pages.debian.net/debusine/"
            "reference/workflows/specs/noop.html",
        )

    def test_workflowtemplate_links(self) -> None:
        """Test links to workflow tables."""
        workflow_list_base_url = reverse(
            "workspaces:workflows:list",
            kwargs={"wname": self.workspace_public.name},
        )
        self.playground.create_workflow_template(
            name="noop-template",
            task_name="noop",
            workspace=self.workspace_public,
        )

        # 1 completed workflow
        self.playground.create_workflow(
            task_name="noop", result=WorkRequest.Results.SUCCESS
        )

        response = self.client.get(self.workspace_public.get_absolute_url())
        tree = self.assertResponseHTML(response)

        div = self.assertHasElement(tree, "//div[@id='workflow-templates']")
        tr = div.table.tbody.tr[0]

        # Running workflows
        url = tr.td[2].a.get("href")
        assert url is not None
        self.assertIn(workflow_list_base_url, url)
        response = self.client.get(url)
        paginator = response.context["paginator"]
        self.assertEqual(
            paginator.table.filters["workflow_templates"].value, "noop-template"
        )
        self.assertEqual(
            paginator.table.filters["statuses"].value, ["running__any"]
        )

        # Input needed workflows
        url = tr.td[3].a.get("href")
        assert url is not None
        self.assertIn(workflow_list_base_url, url)
        response = self.client.get(url)
        paginator = response.context["paginator"]
        self.assertEqual(
            paginator.table.filters["workflow_templates"].value, "noop-template"
        )
        self.assertEqual(
            paginator.table.filters["statuses"].value, ["running__needs_input"]
        )

        # Completed workflows
        url = tr.td[4].a.get("href")
        assert url is not None
        self.assertIn(workflow_list_base_url, url)
        response = self.client.get(url)
        paginator = response.context["paginator"]
        self.assertEqual(
            paginator.table.filters["workflow_templates"].value, "noop-template"
        )
        self.assertEqual(
            paginator.table.filters["statuses"].value, ["completed"]
        )


class WorkspaceUpdateViewTests(ViewTestMixin, TestCase):
    """Tests for WorkspaceUpdateView class."""

    scenario = scenarios.DefaultContext()

    def test_permissions(self) -> None:
        """Test permissions."""
        self.assertSetsCurrentWorkspace(
            self.scenario.workspace,
            self.scenario.workspace.get_absolute_url_configure(),
        )
        self.assertEnforcesPermission(
            self.scenario.workspace.can_configure,
            self.scenario.workspace.get_absolute_url_configure(),
            "get_context_data",
        )

    @override_permission(Workspace, "can_configure", AllowAll)
    def test_configure(self) -> None:
        """Test changing workspace configuration."""
        ws = self.scenario.workspace
        self.playground.create_group_role(
            ws, Workspace.Roles.OWNER, users=[self.scenario.user]
        )
        self.client.force_login(self.scenario.user)

        response = self.client.post(
            ws.get_absolute_url_configure(),
            {
                "public": False,
                "expiration_delay": 7,
                "default_expiration_delay": 14,
            },
        )
        self.assertRedirects(response, ws.get_absolute_url())

        ws.refresh_from_db()

        self.assertFalse(ws.public)
        self.assertEqual(ws.expiration_delay, dt.timedelta(days=7))
        self.assertEqual(ws.default_expiration_delay, dt.timedelta(days=14))

    @override_permission(Workspace, "can_configure", AllowAll)
    def test_initial_form(self) -> None:
        """Get request to ensure the form is displayed."""
        ws = self.scenario.workspace
        self.client.force_login(self.scenario.user)

        title_text = "System settings"
        response = self.client.get(ws.get_absolute_url_configure())
        tree = self.assertResponseHTML(response)
        title = self.assertHasElement(tree, "//head//title")
        self.assertTextContentEqual(title, f"debusine - System - {title_text}")
        h1 = self.assertHasElement(tree, "//body//h1")
        self.assertTextContentEqual(h1, title_text)

        assert isinstance(response, SimpleTemplateResponse)
        assert response.context_data is not None
        form = response.context_data["form"]
        self.assertTrue(form["public"].initial)
        self.assertIsNone(form["expiration_delay"].initial)
        self.assertEqual(
            form["default_expiration_delay"].initial, dt.timedelta(0)
        )


class TaskConfigInspectorViewTests(ViewTestMixin, TestCase):
    """Tests for :py:class:`TaskConfigInspectorView` class."""

    scenario = scenarios.DefaultContext()
    collection: ClassVar[Collection]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        """Set up a database layout for views."""
        super().setUpTestData()
        cls.collection = cls.scenario.default_task_configuration_collection

    @classmethod
    def add_config(cls, entry: DebusineTaskConfiguration) -> None:
        """Add a config entry to config_collection."""
        cls.collection.manager.add_bare_data(
            BareDataCategory.TASK_CONFIGURATION,
            user=cls.scenario.user,
            data=entry,
        )

    def get(
        self,
        collections: list[Collection] | None = None,
        **kwargs: str,
    ) -> TestResponseType:
        workspace = self.scenario.workspace
        if collections is None:
            collections = [self.collection]
        url = reverse(
            "workspaces:task_configuration_inspector",
            kwargs={"wname": workspace.name},
        )

        with mock.patch(
            "debusine.db.models.workspaces."
            "Workspace.list_accessible_collections",
            return_value=collections,
        ):
            return self.client.get(url, kwargs)

    def assertHasInputContainer(
        self, tree: lxml.objectify.ObjectifiedElement
    ) -> lxml.objectify.ObjectifiedElement:
        """Return the main user input container element."""
        return self.assertHasElement(tree, "//div[@id='user-input']")

    def assertHasCollectionSelector(
        self, tree: lxml.objectify.ObjectifiedElement
    ) -> lxml.objectify.ObjectifiedElement:
        """Return the collection selector root element."""
        input_container = self.assertHasInputContainer(tree)
        return self.assertHasElement(
            input_container, "//div[@id='collection-selector']"
        )

    def assertCollectionSelected(
        self,
        tree: lxml.objectify.ObjectifiedElement,
        collection: Collection,
        can_reselect: bool = False,
        is_empty: bool = False,
    ) -> None:
        """Ensure the given collection is currently selected."""
        input_container = self.assertHasInputContainer(tree)

        # Ensure the collection selector is not shown
        self.assertFalse(
            input_container.xpath("//div[@id='collection-selector']")
        )

        input_header = self.assertHasElement(
            input_container, "div[contains(@class, 'card-header')]"
        )
        input_title = self.assertHasElement(input_header, "*/h2")
        self.assertTextContentEqual(
            input_title,
            f"Collection {collection.name} in {collection.workspace}",
        )

        if can_reselect:
            reselect = self.assertHasElement(input_header, "a[@href='.']")
            self.assertEqual(reselect.get("title"), "select another collection")
        else:
            self.assertFalse(input_header.xpath("a[@href='.']"))

        input_body = self.assertHasElement(
            input_container, "div[contains(@class, 'card-body')]"
        )
        if is_empty:
            self.assertTextContentEqual(input_body, "This collection is empty")
        else:
            selected_collection = self.assertHasElement(
                input_body, "form/input[@name='collection']"
            )
            self.assertEqual(
                selected_collection.get("value"), str(collection.id)
            )

    def test_title(self) -> None:
        expected_title = "Task configuration inspector"

        response = self.get()
        tree = self.assertResponseHTML(response)
        title = self.assertHasElement(tree, "//head//title")
        self.assertTextContentEqual(
            title, f"debusine - System - {expected_title}"
        )
        h1 = self.assertHasElement(tree, "//body//h1")
        self.assertTextContentEqual(h1, expected_title)

    def test_collection_selector(self) -> None:
        ws_other = self.playground.create_workspace(name="Other", public=True)
        coll_other = self.playground.create_collection(
            "default", CollectionCategory.TASK_CONFIGURATION, workspace=ws_other
        )

        response = self.get(collections=[self.collection, coll_other])
        tree = self.assertResponseHTML(response)
        div = self.assertHasCollectionSelector(tree)
        self.assertEqual(
            div.a[0].get("href"), f"?collection={self.collection.pk}"
        )
        self.assertTextContentEqual(
            div.a[0], f"{self.collection.name} in {self.collection.workspace}"
        )
        self.assertEqual(div.a[1].get("href"), f"?collection={coll_other.pk}")
        self.assertTextContentEqual(
            div.a[1], f"{coll_other.name} in {coll_other.workspace}"
        )

    def test_collection_selected(self) -> None:
        ws_other = self.playground.create_workspace(name="Other", public=True)
        coll_other = self.playground.create_collection(
            "default", CollectionCategory.TASK_CONFIGURATION, workspace=ws_other
        )

        response = self.get(
            collections=[self.collection, coll_other],
            collection=str(coll_other.id),
        )
        tree = self.assertResponseHTML(response)
        self.assertCollectionSelected(
            tree, coll_other, can_reselect=True, is_empty=True
        )

    def test_collection_autoselect(self) -> None:
        response = self.get(collections=[self.collection])
        tree = self.assertResponseHTML(response)
        self.assertCollectionSelected(
            tree, self.collection, can_reselect=False, is_empty=True
        )

    def test_collection_lookup(self) -> None:
        item = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER, task_name="noop"
        )
        self.add_config(item)
        response = self.get(
            collections=[self.collection], task=f"{TaskTypes.WORKER}:noop"
        )
        tree = self.assertResponseHTML(response)
        self.assertCollectionSelected(
            tree, self.collection, can_reselect=False, is_empty=False
        )

        results = self.assertHasElement(tree, "//div[@id='results']")

        default_values = self.assertHasElement(
            results, "div[@id='default-values']"
        )
        self.assertYAMLContentEqual(default_values.div[1], {})
        override_values = self.assertHasElement(
            results, "div[@id='override-values']"
        )
        self.assertYAMLContentEqual(override_values.div[1], {})
        configuration_items = self.assertHasElement(
            results, "div[@id='configuration-items']"
        )
        self.assertTextContentEqual(
            configuration_items.div[0].div[0], item.name()
        )
        self.assertYAMLContentEqual(
            configuration_items.div[0].div[1], item.model_dump()
        )

    def test_collection_tags_provided(self) -> None:
        item = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER, task_name="noop"
        )
        self.add_config(item)
        with self.spy(
            "debusine.server.collections.debusine_task_configuration"
            ".ConfigurationLookup.set_tags_provided"
        ) as set_tp:
            self.assertResponseHTML(
                self.get(
                    collections=[self.collection],
                    task=f"{TaskTypes.WORKER}:noop",
                    tags_provided="a b c",
                )
            )
        set_tp.assert_called_once()
        self.assertEqual(set_tp.call_args.args[1], {"a", "b", "c"})

    def test_collection_tags_required(self) -> None:
        item = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER, task_name="noop"
        )
        self.add_config(item)
        with self.spy(
            "debusine.server.collections.debusine_task_configuration"
            ".ConfigurationLookup.set_tags_required"
        ) as set_tr:
            self.assertResponseHTML(
                self.get(
                    collections=[self.collection],
                    task=f"{TaskTypes.WORKER}:noop",
                    tags_required="a b c",
                )
            )
        set_tr.assert_called_once()
        self.assertEqual(set_tr.call_args.args[1], {"a", "b", "c"})

    def test_form_invalid(self) -> None:
        item = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER, task_name="noop"
        )
        self.add_config(item)
        response = self.get(
            collections=[self.collection], task=f"{TaskTypes.WORKER}:invalid"
        )
        tree = self.assertResponseHTML(response)
        self.assertCollectionSelected(
            tree, self.collection, can_reselect=False, is_empty=False
        )

        self.assertFalse(tree.xpath("//div[@id='results']"))

    def test_invalid_collection_parameter(self) -> None:
        """Invalid collection parameter returns correct status codes."""
        test_cases = [
            ("nonexistent", 999999999, status.HTTP_404_NOT_FOUND),
            ("non_numeric", "abc", status.HTTP_400_BAD_REQUEST),
            ("empty", "", status.HTTP_400_BAD_REQUEST),
            ("overflow", 10**100, status.HTTP_404_NOT_FOUND),
        ]

        for label, value, expected_status in test_cases:
            with self.subTest(label=label, value=value):
                response = self.get(collection=str(value))
                self.assertEqual(response.status_code, expected_status)


class WorkspaceUpdateInheritanceViewTests(ViewTestMixin, TestCase):
    """Tests for :py:class:`WorkspaceUpdateInheritanceView` class."""

    scenario = scenarios.DefaultContext()
    ws1: ClassVar[Workspace]
    ws2: ClassVar[Workspace]

    class PostForm(NamedTuple):
        pk: int
        order: int
        delete: bool = False

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        """Set up a database layout for views."""
        super().setUpTestData()
        cls.ws1 = cls.playground.create_workspace(name="ws1", public=True)
        cls.ws2 = cls.playground.create_workspace(name="ws2", public=True)

    def get(self, **kwargs: str) -> TestResponseType:
        workspace = self.scenario.workspace
        url = reverse(
            "workspaces:update_inheritance",
            kwargs={"wname": workspace.name},
        )
        return self.client.get(url, kwargs)

    def post(
        self,
        items: list[PostForm | Workspace] | None = None,
        initial_forms: int | None = None,
        save: str = "continue",
    ) -> TestResponseType:
        """
        Make a POST request.

        :param items: list of data for each individual formset form. If omitted
            it uses the empty list. If passed a Workspace it uses its ID and
            infers the rest.
        :param initial_forms: number of initial forms in the formset management
            form. If omitted the length of items is used.
        :param save: value of the ``save`` field.
        """
        post_data: dict[str, Any] = {}
        form_count = 0
        for item in items or []:
            form: WorkspaceUpdateInheritanceViewTests.PostForm
            if isinstance(item, Workspace):
                form = self.PostForm(item.pk, form_count, False)
            else:
                form = item
            post_data[f"form-{form_count}-parent"] = form.pk
            post_data[f"form-{form_count}-ORDER"] = form.order
            if form.delete:
                post_data[f"form-{form_count}-DELETE"] = "true"
            form_count += 1
        post_data[f"form-{form_count}-parent"] = ""
        post_data[f"form-{form_count}-ORDER"] = ""
        if initial_forms is None:
            initial_forms = len(items) if items is not None else 0
        post_data["form-TOTAL_FORMS"] = max(form_count + 1, initial_forms)
        post_data["form-INITIAL_FORMS"] = initial_forms
        post_data["form-MIN_NUM_FORMS"] = 0
        post_data["form-MAX_NUM_FORMS"] = 1000
        post_data["save"] = save
        url = reverse(
            "workspaces:update_inheritance",
            kwargs={"wname": self.scenario.workspace.name},
        )
        return self.client.post(url, data=post_data)

    def assertParents(self, workspace: Workspace, names: list[str]) -> None:
        """Ensure the inheritance chain matches the listed workspace names."""
        self.assertQuerySetEqual(
            workspace.chain_parents.order_by("order").values_list(
                "parent__name", flat=True
            ),
            names,
        )

    def assertHasMessage(
        self, response: TestResponseType, level: int, text: str
    ) -> None:
        """Ensure the response sent the given Django message."""
        msgs = messages.get_messages(response.wsgi_request)
        assert [(m.level, m.message) for m in msgs] == [(level, text)]

    def assertHasNoMessages(self, response: TestResponseType) -> None:
        """Ensure the response did not send any Django message."""
        msgs = messages.get_messages(response.wsgi_request)
        assert [m.message for m in msgs] == []

    def test_permissions(self) -> None:
        """Test permissions."""
        self.assertSetsCurrentWorkspace(
            self.scenario.workspace,
            self.scenario.workspace.get_absolute_url_configure_inheritance(),
        )
        self.assertSetsCurrentWorkspace(
            self.scenario.workspace,
            self.scenario.workspace.get_absolute_url_configure_inheritance(),
            method="post",
        )
        self.assertEnforcesPermission(
            self.scenario.workspace.can_configure,
            self.scenario.workspace.get_absolute_url_configure_inheritance(),
            "get_object",
        )
        self.assertEnforcesPermission(
            self.scenario.workspace.can_configure,
            self.scenario.workspace.get_absolute_url_configure_inheritance(),
            "get_object",
            method="post",
        )

    @override_permission(Workspace, "can_configure", AllowAll)
    def test_title(self) -> None:
        expected_title = "System inheritance"

        response = self.get()
        tree = self.assertResponseHTML(response)
        title = self.assertHasElement(tree, "//head//title")
        self.assertTextContentEqual(
            title, f"debusine - System - {expected_title}"
        )
        h1 = self.assertHasElement(tree, "//body//h1")
        self.assertTextContentEqual(h1, expected_title)

    @override_permission(Workspace, "can_configure", AllowAll)
    def test_get(self) -> None:
        response = self.get()
        self.assertEqual(
            json.loads(response.context["available"]),
            [[self.ws1.pk, str(self.ws1)], [self.ws2.pk, str(self.ws2)]],
        )
        self.assertEqual(json.loads(response.context["current"]), [])

    @override_permission(Workspace, "can_configure", AllowAll)
    def test_get_with_current_chain(self) -> None:
        self.scenario.workspace.set_inheritance([self.ws2, self.ws1])
        response = self.get()
        self.assertEqual(
            json.loads(response.context["available"]),
            [[self.ws1.pk, str(self.ws1)], [self.ws2.pk, str(self.ws2)]],
        )
        self.assertEqual(
            json.loads(response.context["current"]),
            [[self.ws2.pk, str(self.ws2)], [self.ws1.pk, str(self.ws1)]],
        )

    @override_permission(Workspace, "can_configure", AllowAll)
    def test_post_empty(self) -> None:
        response = self.post()
        self.assertRedirects(
            response,
            self.scenario.workspace.get_absolute_url_configure_inheritance(),
        )
        self.scenario.workspace.refresh_from_db()
        self.assertParents(self.scenario.workspace, [])
        self.assertHasMessage(
            response, messages.SUCCESS, "Inheritance chain saved"
        )

    @override_permission(Workspace, "can_configure", AllowAll)
    def test_post_add(self) -> None:
        self.scenario.workspace.set_inheritance([self.ws2])
        response = self.post([self.ws1, self.ws2])
        self.assertRedirects(
            response,
            self.scenario.workspace.get_absolute_url_configure_inheritance(),
        )
        self.scenario.workspace.refresh_from_db()
        self.assertParents(self.scenario.workspace, ["ws1", "ws2"])
        self.assertHasMessage(
            response, messages.SUCCESS, "Inheritance chain saved"
        )

    @override_permission(Workspace, "can_configure", AllowAll)
    def test_post_add_save_done(self) -> None:
        self.scenario.workspace.set_inheritance([self.ws2, self.ws1])
        response = self.post([self.ws1, self.ws2], save="done")
        self.assertRedirects(
            response,
            self.scenario.workspace.get_absolute_url(),
        )
        self.scenario.workspace.refresh_from_db()
        self.assertParents(self.scenario.workspace, ["ws1", "ws2"])
        self.assertHasMessage(
            response, messages.SUCCESS, "Inheritance chain saved"
        )

    @override_permission(Workspace, "can_configure", AllowAll)
    def test_post_delete(self) -> None:
        self.scenario.workspace.set_inheritance([self.ws2, self.ws1])
        response = self.post([self.PostForm(self.ws1.pk, 10, True), self.ws2])
        self.assertRedirects(
            response,
            self.scenario.workspace.get_absolute_url_configure_inheritance(),
        )
        self.scenario.workspace.refresh_from_db()
        self.assertParents(self.scenario.workspace, ["ws2"])
        self.assertHasMessage(
            response, messages.SUCCESS, "Inheritance chain saved"
        )

    @override_permission(Workspace, "can_configure", AllowAll)
    def test_post_invalid_empty(self) -> None:
        self.scenario.workspace.set_inheritance([self.ws2, self.ws1])
        response = self.post(initial_forms=2)
        self.assertResponseHTML(response)
        self.assertEqual(
            response.context["form"].errors,
            [
                {"parent": ["This field is required."]},
                {"parent": ["This field is required."]},
            ],
        )
        self.assertHasNoMessages(response)

    @override_permission(Workspace, "can_configure", AllowAll)
    def test_post_invalid_id(self) -> None:
        invalid_id = Workspace.objects.aggregate(id_max=Max("id"))["id_max"] + 1
        response = self.post([self.PostForm(invalid_id, 1)])
        self.assertResponseHTML(response)
        self.assertEqual(
            response.context["form"].errors,
            [
                {
                    "parent": [
                        "Select a valid choice."
                        f" {invalid_id} is not one of the available choices."
                    ]
                },
                {},
            ],
        )
        self.assertHasNoMessages(response)

    @override_permission(Workspace, "can_configure", AllowAll)
    def test_post_invalid_duplicate(self) -> None:
        response = self.post([self.ws1, self.ws1])
        self.assertRedirects(
            response,
            self.scenario.workspace.get_absolute_url_configure_inheritance(),
        )
        self.assertHasMessage(
            response,
            messages.ERROR,
            "duplicate workspace 'ws1' in inheritance chain",
        )
