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

import datetime as dt
from collections.abc import Iterable
from typing import Any, ClassVar, override
from unittest import mock

from django import forms
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.forms.fields import ChoiceField, IntegerField, InvalidJSONInput
from django.test import RequestFactory

from debusine.artifacts.models import (
    BareDataCategory,
    CollectionCategory,
    DebusineTaskConfiguration,
    TaskTypes,
)
from debusine.db.models import (
    Collection,
    CollectionRelation,
    Group,
    Token,
    User,
    Workspace,
)
from debusine.db.playground import scenarios
from debusine.test.django import TestCase
from debusine.web.forms import (
    BaseFormSetBase,
    BootstrapMixin,
    CollectionCreateForm,
    CollectionFormBase,
    CollectionRelationCreateForm,
    CollectionUpdateForm,
    DaysField,
    GroupAddUserForm,
    TaskConfigurationInspectorForm,
    TokenForm,
    WorkflowFilterForm,
    WorkspaceChoiceField,
    WorkspaceForm,
    WorkspaceInheritanceForm,
    WorkspaceInheritanceFormSet,
    YAMLField,
)
from debusine.web.views.ui.workspaces import WorkspaceUI


class BootstrapMixinForm(BootstrapMixin, forms.Form):
    """Class to test BootstrapMixinTestCase."""

    existing_class = "class1"
    char_field = forms.CharField()
    char_field_required = forms.CharField(required=True)
    char_field_required_with_suffix = forms.CharField(
        required=True, label_suffix="something"
    )
    char_field_extra_class = forms.CharField(
        widget=forms.TextInput(attrs={"class": existing_class})
    )
    choice_field = forms.ChoiceField(choices=[("1", "One"), ("2", "Two")])
    choice_field_extra_class = forms.ChoiceField(
        choices=[], widget=forms.Select(attrs={"class": existing_class})
    )
    datetime_field = forms.DateTimeField(
        widget=forms.DateTimeInput(attrs={"class": existing_class})
    )
    file_field = forms.FileField()

    date_field = forms.DateField(
        widget=forms.DateInput(attrs={"class": existing_class})
    )
    boolean_field = forms.BooleanField(
        widget=forms.CheckboxInput(attrs={"class": existing_class})
    )
    days_field = DaysField()
    integer_field = IntegerField()


class BootstrapMixinTests(TestCase):
    """Tests for BootstrapMixin."""

    form: ClassVar[BootstrapMixinForm]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.form = BootstrapMixinForm()

    def test_charfield_bootstrap_class(self) -> None:
        """CharField has the correct Bootstrap class."""  # noqa: D403
        char_widget_class = self.form.fields["char_field"].widget.attrs.get(
            "class"
        )
        self.assertEqual(char_widget_class, "form-control")

    def test_charfield_bootstrap_class_add(self) -> None:
        """CharField has the correct classes."""  # noqa: D403
        char_widget_class = self.form.fields[
            "char_field_extra_class"
        ].widget.attrs.get("class")
        self.assertEqual(
            char_widget_class,
            f"{BootstrapMixinForm.existing_class} form-control",
        )

    def test_choicefield_bootstrap_class(self) -> None:
        """ChoiceField has the correct Bootstrap class."""  # noqa: D403
        choice_widget_class = self.form.fields["choice_field"].widget.attrs.get(
            "class"
        )
        self.assertEqual(choice_widget_class, "form-select")

    def test_choicefield_bootstrap_class_add(self) -> None:
        """ChoiceField has the correct classes."""  # noqa: D403
        choice_widget_class = self.form.fields[
            "choice_field_extra_class"
        ].widget.attrs.get("class")
        self.assertEqual(
            choice_widget_class,
            f"{BootstrapMixinForm.existing_class} form-select",
        )

    def test_filefield_bootstrap_class(self) -> None:
        """FileField has the correct Bootstrap class."""  # noqa: D403
        file_widget_class = self.form.fields["file_field"].widget.attrs.get(
            "class"
        )
        self.assertEqual(file_widget_class, "form-control")

    def test_datetimefield_bootstrap_class(self) -> None:
        """Test DateField retains its original class."""  # noqa: D403
        date_widget_class = self.form.fields["datetime_field"].widget.attrs.get(
            "class"
        )
        self.assertEqual(
            date_widget_class,
            f"{BootstrapMixinForm.existing_class} form-control",
        )

    def test_datefield_retain_existing_class(self) -> None:
        """
        Test DateField retains its original class.

        DateField is not modified: nothing is added.
        """  # noqa: D403
        date_widget_class = self.form.fields["date_field"].widget.attrs.get(
            "class"
        )
        self.assertEqual(
            date_widget_class, f"{BootstrapMixinForm.existing_class}"
        )

    def test_daysfield_bootstrap_class(self) -> None:
        """Test DaysField has form-control."""
        days_widget_class = self.form.fields["days_field"].widget.attrs.get(
            "class"
        )
        self.assertEqual(days_widget_class, "form-control")

    def test_integerfield_bootstrap_class(self) -> None:
        integer_widget_class = self.form.fields[
            "integer_field"
        ].widget.attrs.get("class")
        self.assertEqual(integer_widget_class, "form-control")

    def test_booleanfield_bootstrap_class(self) -> None:
        """Test BooleanField has form-check-input."""
        boolean_widget_class = self.form.fields[
            "boolean_field"
        ].widget.attrs.get("class")
        self.assertEqual(
            boolean_widget_class,
            f"{BootstrapMixinForm.existing_class} form-check-input",
        )

    def test_asterisk_set_if_required(self) -> None:
        """Test asterisk is set if field is required."""
        char_field_required = self.form.fields["char_field_required"]
        self.assertEqual(char_field_required.label_suffix, " *")

    def test_asterisk_added_if_required(self) -> None:
        """Test asterisk is added if field is required."""
        char_field_required = self.form.fields[
            "char_field_required_with_suffix"
        ]
        self.assertEqual(char_field_required.label_suffix, "something *")


class YAMLFieldTests(TestCase):
    """Tests for :py:class:`YAMLField`."""

    def test_init_with_mutable(self) -> None:
        field = YAMLField(empty_value=3)
        self.assertEqual(field.empty_value, 3)
        field = YAMLField(empty_value=dict)
        self.assertEqual(field.empty_value, {})
        field = YAMLField(empty_value=list)
        self.assertEqual(field.empty_value, [])

    def test_to_python(self) -> None:
        field = YAMLField(empty_value=dict)
        for value, expected in (
            (None, {}),
            ("", {}),
            ("3", 3),
            ("test", "test"),
            ("test: 1", {"test": 1}),
        ):
            with self.subTest(value=value):
                self.assertEqual(field.to_python(value), expected)

    def test_to_python_disabled(self) -> None:
        field = YAMLField(empty_value=dict, disabled=True)
        for value in (None, "", "3", "test", "test: 1"):
            with self.subTest(value=value):
                self.assertEqual(field.to_python(value), value)

    def test_bound_data(self) -> None:
        field = YAMLField(empty_value=dict)
        for value, expected in (
            (None, None),
            ("", None),
            ("3", 3),
            ("test", "test"),
            ("test: 1", {"test": 1}),
            ("{", "{"),
        ):
            with self.subTest(value=value):
                self.assertEqual(field.bound_data(value, 42), expected)

    def test_bound_data_disabled(self) -> None:
        field = YAMLField(empty_value=dict, disabled=True)
        for value in (None, "", "3", "test", "test: 1"):
            with self.subTest(value=value):
                self.assertEqual(field.bound_data(value, 42), 42)

    def test_prepare_value(self) -> None:
        field = YAMLField(empty_value=dict)
        for value, expected in (
            (None, "{}\n"),
            ("", "{}\n"),
            (3, "3\n...\n"),
            ("test", "test\n...\n"),
            ({"test": 1}, "test: 1\n"),
            ("{", "'{'\n"),
            (InvalidJSONInput("{"), "{"),
        ):
            with self.subTest(value=repr(value)):
                self.assertEqual(field.prepare_value(value), expected)

    def test_has_changed(self) -> None:
        field = YAMLField(empty_value=dict)
        for initial, data, expected in (
            (None, None, True),
            (None, "", True),
            ([1, 2, 3], [1, 2, 3], False),
            ([1, 2, 3], [1, 2, 4], True),
            ({"one": 1, "two": 2}, {"two": 2, "one": 1}, False),
            ({"one": 1, "two": 2}, {"two": 3, "one": 1}, True),
        ):
            with self.subTest(initial=initial, data=data):
                self.assertEqual(field.has_changed(initial, data), expected)

    def test_invalid_yaml(self) -> None:
        field = YAMLField()
        invalid_yaml = "Task: : value"

        with self.assertRaises(ValidationError):
            field.to_python(invalid_yaml)


class TokenFormTests(TestCase):
    """Tests for TokenForm."""

    user: ClassVar[User]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass"
        )

    def test_form_initialization(self) -> None:
        """Form initialization set form.user."""
        form = TokenForm(user=self.user)
        self.assertEqual(form.user, self.user)
        self.assertTrue(form.fields["enabled"].initial)

    def test_form_save(self) -> None:
        """Form save the Token."""
        form = TokenForm({"comment": "Test Comment"}, user=self.user)
        self.assertTrue(form.is_valid())

        token = form.save()

        self.assertEqual(token.user, self.user)
        self.assertEqual(token.comment, "Test Comment")

    def test_form_save_commit_false(self) -> None:
        """Form does not save the token: commit=False."""
        form = TokenForm({"comment": "Test Comment"}, user=self.user)
        self.assertTrue(form.is_valid())

        token = form.save(commit=False)

        self.assertEqual(token.user, self.user)
        self.assertEqual(token.comment, "Test Comment")
        # Ensure token is not saved to the database yet
        with self.assertRaises(Token.DoesNotExist):
            Token.objects.get(comment="Test Comment")

    def test_form_validation(self) -> None:
        """Form return an error if the "comment" is not valid: too long."""
        assert Token.comment.field.max_length is not None
        form = TokenForm(
            {"comment": "x" * (Token.comment.field.max_length + 1)},
            user=self.user,
        )
        self.assertFalse(form.is_valid())
        self.assertIn("comment", form.errors)

    def test_comment_allowed_empty(self) -> None:
        """Form validates if comment is empty."""
        form = TokenForm({"comment": ""}, user=self.user)
        self.assertTrue(form.is_valid())


class WorkspaceChoiceFieldTests(TestCase):
    """Tests for WorkspaceChoiceField."""

    user: ClassVar[User]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass"
        )

    def test_label(self) -> None:
        """The label is the workspace name."""
        workspace_choice_field = WorkspaceChoiceField(user=self.user)

        labels = [
            label for value, label in workspace_choice_field.widget.choices
        ]

        self.assertEqual(
            labels,
            [
                workspace_choice_field.empty_label,
                Workspace.objects.earliest("id").name,
            ],
        )

    def test_order_by(self) -> None:
        """The field's queryset is ordered by name."""
        workspace_choice_field = WorkspaceChoiceField(user=None)

        assert workspace_choice_field.queryset is not None
        self.assertEqual(
            workspace_choice_field.queryset.query.order_by, ("name",)
        )

    def test_choices_no_user(self) -> None:
        """Choices for if no user: only the public workspaces."""
        default_workspace = Workspace.objects.earliest("id")
        default_workspace.public = False
        default_workspace.save()

        workspace = self.playground.create_workspace(
            name="It is public", public=True
        )

        workspace_choice_field = WorkspaceChoiceField(user=None)

        # Blank choice and public workspace
        self.assertEqual(
            list(workspace_choice_field.choices),  # type: ignore[arg-type]
            [
                ("", workspace_choice_field.empty_label),
                (workspace.id, workspace.name),
            ],
        )

    def test_choices_with_user(self) -> None:
        """Private workspace included in the choices: user has access."""
        workspace = Workspace.objects.earliest("id")
        workspace.public = False
        workspace.save()

        workspace_choice_field = WorkspaceChoiceField(user=self.user)

        self.assertEqual(
            list(workspace_choice_field.choices),  # type: ignore[arg-type]
            [
                ("", workspace_choice_field.empty_label),
                (workspace.pk, workspace.name),
            ],
        )


class WorkflowFilterFormTests(TestCase):
    """Tests for WorkflowFilterForm."""

    scenario = scenarios.DefaultContext(set_current=True)

    @override
    def setUp(self) -> None:
        super().setUp()

        self.form = WorkflowFilterForm()

    def test_statuses_field(self) -> None:
        """Test statuses field."""
        self.assertEqual(
            self.form.fields["statuses"].widget.choices,
            [
                ('pending', 'Pending'),
                (
                    'Running',
                    [
                        (
                            'running__needs_input',
                            'Running (children: Needs Input)',
                        ),
                        ('running__running', 'Running (children: Running)'),
                        ('running__waiting', 'Running (children: Waiting)'),
                        ('running__pending', 'Running (children: Pending)'),
                        ('running__aborted', 'Running (children: Aborted)'),
                        ('running__completed', 'Running (children: Completed)'),
                        ('running__blocked', 'Running (children: Blocked)'),
                        ('running__any', 'Running (children: Any)'),
                    ],
                ),
                ('completed', 'Completed'),
                ('aborted', 'Aborted'),
                ('blocked', 'Blocked'),
            ],
        )

    def test_runtime_statuses_field(self) -> None:
        """Test runtime_statuses field."""
        self.assertEqual(
            self.form.fields["runtime_statuses"].widget.choices,
            [
                ("needs_input", "Needs Input"),
                ("running", "Running"),
                ("waiting", "Waiting"),
                ("pending", "Pending"),
                ("aborted", "Aborted"),
                ("completed", "Completed"),
                ("blocked", "Blocked"),
            ],
        )

    def test_results_field(self) -> None:
        """Test results field."""
        self.assertEqual(
            self.form.fields["results"].widget.choices,
            [
                ("error", "Error"),
                ("failure", "Failure"),
                ("skipped", "Skipped"),
                ("success", "Success"),
            ],
        )

    def test_with_failed_work_requests_failed_field(self) -> None:
        """Test with_failed_work_requests field."""
        self.assertIn("with_failed_work_requests", self.form.fields)


class DaysFieldTests(TestCase):
    """Tests for DaysField."""

    def test_prepare_value(self) -> None:
        """Test DaysField.prepare_value."""
        field = DaysField()
        for orig, prepared in [
            ("0", "0"),
            (1, 1),
            (dt.timedelta(days=4), 4),
            (None, None),
        ]:
            with self.subTest(orig=repr(orig)):
                self.assertEqual(field.prepare_value(orig), prepared)

    def test_to_python(self) -> None:
        """Test DaysField.to_python."""
        field = DaysField()
        for orig, py in [
            ("0", dt.timedelta(days=0)),
            (1, dt.timedelta(days=1)),
            ("", None),
            (None, None),
        ]:
            with self.subTest(orig=orig):
                self.assertEqual(field.to_python(orig), py)


class WorkspaceFormTests(TestCase):
    """Tests for WorkspaceForm."""

    scenario = scenarios.DefaultContext(set_current=True)

    def test_labels(self) -> None:
        form = WorkspaceForm(instance=self.scenario.workspace)
        self.assertEqual(
            form["expiration_delay"].help_text,
            "If unset, workspace is permanent. Otherwise, the workspace gets "
            "removed after the given period of inactivity (expressed as a "
            "number of days after the completion of the last work request)",
        )
        self.assertEqual(
            form["default_expiration_delay"].help_text,
            "Number of days that new artifacts and work requests are kept "
            "in the workspace before being expired (0 means no expiration)",
        )

    def test_values_from_instances(self) -> None:
        """Test form field values."""
        form = WorkspaceForm(instance=self.scenario.workspace)
        self.assertTrue(form["public"].initial)
        self.assertIsNone(form["expiration_delay"].initial)
        self.assertEqual(
            form["default_expiration_delay"].initial, dt.timedelta(days=0)
        )

    def test_values_from_data(self) -> None:
        """Test setting form field values."""
        ws = self.scenario.workspace
        form = WorkspaceForm(
            data={
                "public": "false",
                "expiration_delay": "7",
                "default_expiration_delay": "14",
            },
            instance=ws,
        )
        self.assertTrue(form.is_valid())
        form.save()

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


class WorkspaceInheritanceFormTests(TestCase):
    """Tests for :py:class:`WorkspaceInheritanceForm`."""

    scenario = scenarios.DefaultContext(set_current=True)

    @override
    def setUp(self) -> None:
        """Set up a current request for tests."""
        super().setUp()
        factory = RequestFactory()
        self.request = factory.get("/")

    def get_helper(self, workspace: Workspace) -> WorkspaceUI:
        """Get the UI helper for a workspace."""
        return WorkspaceUI.get(self.request, workspace)

    def get_form(
        self, workspace: Workspace, **kwargs: Any
    ) -> WorkspaceInheritanceForm:
        """Instantiate a form from a workspace and form args."""
        return WorkspaceInheritanceForm(
            workspace_ui=self.get_helper(workspace), **kwargs
        )

    def test_choices(self) -> None:
        """``parent`` is a choice field with the expected workspaces."""
        ws1 = self.playground.create_workspace(name="ws1")
        ws2 = self.playground.create_workspace(name="ws2")
        self.get_helper(self.scenario.workspace).candidate_parents = [ws1, ws2]
        form = self.get_form(self.scenario.workspace)
        self.assertEqual(
            list(form.fields["parent"].widget.choices),
            [
                (None, "None"),
                (ws1.id, str(ws1)),
                (ws2.id, str(ws2)),
            ],
        )


class WorkspaceInheritanceFormSetTests(TestCase):
    """Tests for :py:class:`WorkspaceInheritanceFormSet`."""

    scenario = scenarios.DefaultContext(set_current=True)

    @override
    def setUp(self) -> None:
        """Set up a current request for tests."""
        super().setUp()
        factory = RequestFactory()
        self.request = factory.get("/")

    def get_helper(self, workspace: Workspace) -> WorkspaceUI:
        """Get the UI helper for a workspace."""
        return WorkspaceUI.get(self.request, workspace)

    def get_formset_factory(
        self, workspace: Workspace, **kwargs: Any
    ) -> type[BaseFormSetBase[WorkspaceInheritanceForm]]:
        """Instantiate a form from a workspace and form args."""
        return WorkspaceInheritanceFormSet.formset_factory(
            workspace_ui=self.get_helper(workspace), **kwargs
        )

    def test_formset_factory(self) -> None:
        formset_class = self.get_formset_factory(self.scenario.workspace)
        formset = formset_class()
        self.assertEqual(formset.initial_form_count(), 0)
        self.assertEqual(formset.total_form_count(), 1)
        self.assertTrue(formset.can_order)
        self.assertTrue(formset.can_delete)
        self.assertIsInstance(formset.forms[0], WorkspaceInheritanceForm)
        self.assertEqual(
            formset.forms[0].workspace_ui,
            self.get_helper(self.scenario.workspace),
        )


class GroupAddUserFormTests(TestCase):
    """Tests for GroupAddUserForm."""

    scenario = scenarios.DefaultContext(set_current=True)

    group: ClassVar[Group]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.group = cls.playground.create_group("test")

    def _form(self, username: Any) -> GroupAddUserForm:
        """Instantiate the form."""
        return GroupAddUserForm(group=self.group, data={"username": username})

    def test_username_required(self) -> None:
        """A valid username passes validation."""
        form = self._form(None)
        self.assertFalse(form.is_valid())
        self.assertEqual(form.errors, {"username": ["This field is required."]})

    def test_valid_username(self) -> None:
        """A valid username passes validation."""
        form = self._form(self.scenario.user.username)
        self.assertTrue(form.is_valid())
        self.assertEqual(
            form.cleaned_data["username"], self.scenario.user.username
        )

    def test_invalid_username(self) -> None:
        """Error on invalid usernames."""
        form = self._form("does-not-exist")
        self.assertFalse(form.is_valid())
        self.assertEqual(
            form.errors, {"username": ["User does-not-exist does not exist"]}
        )

    def test_user_already_member(self) -> None:
        """Existing members are not accepted."""
        self.group.add_user(self.scenario.user)
        form = self._form(self.scenario.user.username)
        self.assertFalse(form.is_valid())
        self.assertEqual(list(form.errors.keys()), ["username"])
        self.assertEqual(
            form.errors,
            {"username": ["User is already a member of debusine/test"]},
        )


class TaskConfigurationInspectorFormTests(TestCase):
    """Tests for :py:class:`TaskConfigurationInspectorForm`."""

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

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.collection = cls.scenario.workspace.collections.get(
            name="default", category=CollectionCategory.TASK_CONFIGURATION
        )

    @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 test_empty_collection(self) -> None:
        form = TaskConfigurationInspectorForm(collection=self.collection)
        unspecified = ('', '(unspecified)')
        self.assertEqual(form.fields["task"].widget.choices, [])
        self.assertEqual(form.fields["subject"].widget.choices, [unspecified])
        self.assertEqual(form.fields["context"].widget.choices, [unspecified])

    def test_choices(self) -> None:
        self.add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="noop"
            )
        )
        self.add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="sbuild"
            )
        )
        self.add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="noop", context="context1"
            )
        )
        self.add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                context="context2",
                subject="subject1",
            )
        )
        self.add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="sbuild",
                context="context1",
                subject="subject2",
            )
        )

        form = TaskConfigurationInspectorForm(collection=self.collection)
        unspecified = ('', '(unspecified)')
        self.assertEqual(
            form.fields["task"].widget.choices,
            [
                ('Worker:noop', 'Worker:noop'),
                ('Worker:sbuild', 'Worker:sbuild'),
            ],
        )
        self.assertEqual(
            form.fields["subject"].widget.choices,
            [unspecified, ('subject1', 'subject1'), ('subject2', 'subject2')],
        )
        self.assertEqual(
            form.fields["context"].widget.choices,
            [unspecified, ('context1', 'context1'), ('context2', 'context2')],
        )


class CollectionFormBaseTests(TestCase):
    """Tests for :py:class:`CollectionFormBase`."""

    scenario = scenarios.DefaultContext(set_current=True)
    sid: ClassVar[Collection]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.sid = cls.playground.create_collection(
            "sid", CollectionCategory.SUITE
        )

    def make_form_class(self, **meta: Any) -> type[CollectionFormBase]:
        """Create a concrete subclass of CollectionFormBase."""
        meta.setdefault(
            "fields",
            [
                "name",
                "category",
                "full_history_retention_period",
                "metadata_only_retention_period",
                "data",
            ],
        )
        meta.setdefault(
            "field_classes",
            {
                "full_history_retention_period": DaysField,
                "metadata_only_retention_period": DaysField,
            },
        )

        class ConcreteForm(CollectionFormBase):
            """Concrete version of CollectionFormBase used for testing."""

            Meta = type("Meta", (), {"model": Collection, **meta})

        return ConcreteForm

    def form(self, **meta: Any) -> CollectionFormBase:
        """Instantiate a test form."""
        form_class = self.make_form_class(**meta)
        form = form_class()
        # Simulate being inside full_clean
        form.cleaned_data = {}
        return form

    def test_check_timedelta_units(self) -> None:
        form = self.form()
        self.assertRegex(
            str(form.fields["full_history_retention_period"].help_text),
            r".+ \(in days\)$",
        )
        self.assertRegex(
            str(form.fields["metadata_only_retention_period"].help_text),
            r".+ \(in days\)$",
        )
        self.assertNotRegex(
            str(form.fields["name"].help_text), r".+ \(in days\)$"
        )

    def test_check_timedelta_units_without_fields(self) -> None:
        form = self.form(
            fields=["name", "category", "metadata_only_retention_period"]
        )
        self.assertNotIn("full_history_retention_period", form.fields)
        self.assertRegex(
            str(form.fields["metadata_only_retention_period"].help_text),
            r".+ \(in days\)$",
        )

    def test_check_name(self) -> None:
        form = self.form()
        form.check_name(
            self.scenario.workspace, "foo", CollectionCategory.SUITE
        )
        self.assertEqual(form.errors, {})

    def test_check_name_avoid_name_conflict(self) -> None:
        form = self.form()
        form.check_name(
            self.scenario.workspace, "sid", CollectionCategory.SUITE
        )
        self.assertEqual(
            form.errors,
            {
                "name": [
                    "A collection called sid@debian:suite"
                    f" already exists in workspace {self.scenario.workspace}",
                ]
            },
        )

    def test_check_name_avoid_name_conflict_spares_instance(self) -> None:
        form = self.form()
        form.check_name(
            self.scenario.workspace,
            "sid",
            CollectionCategory.SUITE,
            instance=self.sid,
        )
        self.assertEqual(form.errors, {})

    def test_check_name_wrong_name_for_singleton(self) -> None:
        form = self.form()
        form.check_name(
            self.scenario.workspace,
            "wrong",
            CollectionCategory.PACKAGE_BUILD_LOGS,
        )
        self.assertEqual(
            form.errors,
            {"name": ['A singleton collection needs to be named "_"']},
        )

    def test_check_name_wrong_name_for_non_singleton(self) -> None:
        for name in "_", "_wrong":
            with self.subTest(name=name):
                form = self.form()
                form.check_name(
                    self.scenario.workspace, name, CollectionCategory.SUITE
                )
                self.assertEqual(
                    form.errors,
                    {
                        "name": [
                            "The name of a non-singleton collection"
                            ' cannot start with "_"'
                        ]
                    },
                )


class CollectionCreateFormTests(TestCase):
    """Tests for :py:class:`CollectionCreateForm`."""

    scenario = scenarios.DefaultContext(set_current=True)

    sid: ClassVar[Collection]
    default_fields: ClassVar[dict[str, Any]]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.sid = cls.playground.create_collection(
            "sid", CollectionCategory.SUITE
        )
        cls.default_fields = {
            "full_history_retention_period": 0,
            "metadata_only_retention_period": 0,
            "data": {},
        }

    def form(self, **kwargs: Any) -> CollectionCreateForm:
        """Instantiate the form."""
        return CollectionCreateForm(data={**self.default_fields, **kwargs})

    def test_clean_without_category(self) -> None:
        form = self.form(name="foo", category="invalid")
        with mock.patch.object(form, "check_name") as check_name:
            self.assertFalse(form.is_valid())
        check_name.assert_not_called()

    def test_clean_checks_name(self) -> None:
        form = self.form(name="foo", category=CollectionCategory.SUITE)
        with mock.patch.object(form, "check_name") as check_name:
            self.assertTrue(form.is_valid())
        check_name.assert_called_once_with(
            self.scenario.workspace, "foo", CollectionCategory.SUITE
        )

    def test_save(self) -> None:
        form = self.form(name="foo", category=CollectionCategory.SUITE)
        self.assertTrue(form.is_valid())
        instance = form.save()
        assert isinstance(instance.pk, int)
        self.assertEqual(instance.workspace, self.scenario.workspace)
        self.assertEqual(
            instance.retains_artifacts, Collection.RetainsArtifacts.ALWAYS
        )

    def test_save_commit_false(self) -> None:
        form = self.form(name="foo", category=CollectionCategory.SUITE)
        self.assertTrue(form.is_valid())
        instance = form.save(commit=False)
        self.assertIsNone(instance.pk)


class CollectionUpdateFormTests(TestCase):
    """Tests for :py:class:`CollectionUpdateForm`."""

    scenario = scenarios.DefaultContext(set_current=True)

    sid: ClassVar[Collection]
    experimental: ClassVar[Collection]
    default_fields: ClassVar[dict[str, Any]]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.sid = cls.playground.create_collection(
            "sid", CollectionCategory.SUITE
        )
        cls.experimental = cls.playground.create_collection(
            "experimental", CollectionCategory.SUITE
        )
        cls.default_fields = {
            "full_history_retention_period": 0,
            "metadata_only_retention_period": 0,
            "data": {},
        }

    def form(self, instance: Collection, **kwargs: Any) -> CollectionUpdateForm:
        """Instantiate the form."""
        return CollectionUpdateForm(
            data={**self.default_fields, **kwargs}, instance=instance
        )

    def test_clean_checks_name(self) -> None:
        form = self.form(
            self.experimental, name="foo", category=CollectionCategory.SUITE
        )
        with mock.patch.object(form, "check_name") as check_name:
            self.assertTrue(form.is_valid())
        check_name.assert_called_once_with(
            self.scenario.workspace,
            "foo",
            CollectionCategory.SUITE,
            instance=self.experimental,
        )


class CollectionRelationCreateFormTests(TestCase):
    """Tests for CollectionRelationCreateForm."""

    trixie: ClassVar[Collection]
    trixie_backports: ClassVar[Collection]
    personal: ClassVar[Collection]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.trixie = cls.playground.create_collection(
            "trixie", CollectionCategory.SUITE
        )
        cls.trixie_backports = cls.playground.create_collection(
            "trixie-backports", CollectionCategory.SUITE
        )
        cls.personal = cls.playground.create_collection(
            "personal", CollectionCategory.SUITE
        )

    def test_form_all_types(self) -> None:
        form = CollectionRelationCreateForm(
            source_collection=self.personal,
            allowed_types=set(CollectionRelation.Types),
        )

        self.assertCountEqual(form.fields.keys(), ["type", "target"])
        type_field = form.fields["type"]

        assert isinstance(type_field, ChoiceField)
        assert isinstance(type_field.choices, Iterable)

        self.assertEqual(
            type_field.choices,
            [
                ("suite-based-on", "Based on"),
                ("suite-default-qa-results", "Default QA results"),
                ("suite-forked-from", "Forked from"),
                ("suite-requires", "Requires"),
                ("suite-targeting", "Targeting"),
            ],
        )

        target_field = form.fields["target"]

        assert hasattr(target_field, "queryset")
        self.assertTrue(target_field.queryset.ordered)

    def test_form_no_types(self) -> None:
        form = CollectionRelationCreateForm(
            source_collection=self.personal,
            allowed_types=set(),
        )

        type_field = form.fields["type"]

        assert isinstance(type_field, ChoiceField)
        assert isinstance(type_field.choices, Iterable)

        self.assertEqual(type_field.choices, [])
