# Copyright 2023 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 textwrap
from datetime import timedelta
from pathlib import Path
from typing import Any, ClassVar

from django import forms
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.utils.datastructures import MultiValueDict

try:
    import pydantic.v1 as pydantic
except ImportError:
    import pydantic  # type: ignore

import yaml

from debusine.artifacts import PackageBuildLog
from debusine.artifacts.models import ArtifactCategory
from debusine.db.models import Artifact, Token, User, Workspace
from debusine.tasks import BaseTask
from debusine.tasks.models import TaskTypes
from debusine.test import TestHelpersMixin
from debusine.web.forms import (
    ArtifactForm,
    BootstrapMixin,
    TokenForm,
    WorkRequestForm,
    WorkspaceChoiceField,
)


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


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

    form: ClassVar[BootstrapMixinForm]

    @classmethod
    def setUpTestData(cls):
        """Set up common data for tests."""
        super().setUpTestData()
        cls.form = BootstrapMixinForm()

    def test_charfield_bootstrap_class(self):
        """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):
        """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):
        """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):
        """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):
        """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):
        """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):
        """
        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_asterisk_set_if_required(self):
        """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):
        """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 TokenFormTests(TestCase):
    """Tests for TokenForm."""

    user: ClassVar[User]

    @classmethod
    def setUpTestData(cls):
        """Initialize test objects."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass"
        )

    def test_form_initialization(self):
        """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):
        """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):
        """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):
        """Form return an error if the "comment" is not valid: too long."""
        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):
        """Form validates if comment is empty."""
        form = TokenForm({"comment": ""}, user=self.user)
        self.assertTrue(form.is_valid())


class WorkRequestFormTests(TestCase):
    """Tests for WorkRequestForm."""

    user: ClassVar[User]
    workspace: ClassVar[Workspace]

    @classmethod
    def setUpTestData(cls):
        """Initialize test objects."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass"
        )
        cls.workspace = Workspace.objects.first()

    def test_form_initialization(self):
        """Form initialization set form.user."""
        form = WorkRequestForm(user=self.user)
        self.assertEqual(form.user, self.user)

    def test_form_save_no_commit(self):
        """Save method is called without committing to the DB."""
        task_name = "sbuild"
        task_data = textwrap.dedent(
            """
        build_components:
        - any
        - all
        distribution: stable
        host_architecture: amd64
        backend: schroot
        input:
            source_artifact: 5
        """  # noqa: E501
        )

        form = WorkRequestForm(
            {
                "workspace": self.workspace,
                "task_name": task_name,
                "task_data": task_data,
            },
            user=self.user,
        )
        self.assertTrue(form.is_valid())

        work_request = form.save(commit=False)

        self.assertEqual(work_request.workspace, self.workspace)
        self.assertEqual(work_request.created_by, self.user)
        self.assertEqual(work_request.task_name, task_name)
        self.assertEqual(work_request.task_data, yaml.safe_load(task_data))
        self.assertIsNone(work_request.id)

    def test_form_save_invalid_yaml(self):
        """Try to save the work request, YAML is invalid."""
        form = WorkRequestForm(
            {
                "workspace": self.workspace,
                "task_data": "sbuild",
                "data_yaml": ":",
            },
            user=self.user,
        )

        with self.assertRaises(ValueError):
            # The WorkRequest could not be created: data_yaml is invalid
            form.save(commit=False)

    def test_clean_data_yaml_raise_validation_error(self):
        """Raise forms.ValidationError: invalid data."""
        form = WorkRequestForm({"task_data": ":"}, user=self.user)

        self.assertFalse(form.is_valid())
        self.assertRegex(form.errors["task_data"][0], "^Invalid YAML")

    def test_clean_task_name_must_be_valid(self):
        """Raise forms.ValidationError: invalid task name."""
        form = WorkRequestForm(
            {
                "task_name": "does-not-exist",
                "task_data": "",
            },
            user=self.user,
        )

        self.assertFalse(form.is_valid())
        self.assertRegex(
            form.errors["task_name"][0],
            "does-not-exist is not one of the available choices.",
        )

    def test_clean_data_task_data_must_be_dict(self):
        """Raise forms.ValidationError: task data not a dict."""
        form = WorkRequestForm(
            {
                "task_name": "sbuild",
                "task_data": "[]",
            },
            user=self.user,
        )

        self.assertFalse(form.is_valid())
        self.assertRegex(
            form.errors["task_data"][0],
            "task data must be a dictionary",
        )

    def test_clean_data_task_data_raise_validation_error(self):
        """Raise forms.ValidationError: invalid task data."""
        task_data_yaml = textwrap.dedent(
            """
        build_components:
        - any
        - all
        host_architecture: amd64
        input:
          source_artifact: 5
        """  # noqa: E501
        )

        form = WorkRequestForm(
            {
                "task_name": "sbuild",
                "task_data": task_data_yaml,
            },
            user=self.user,
        )

        self.assertFalse(form.is_valid())
        self.assertRegex(
            form.errors["task_data"][0],
            'The backend "auto" requires "environment" to be set',
        )

    def test_clean_data_yaml_return_dictionary(self):
        """clean_data_yaml return parsed YAML in a dictionary."""
        task_data = {
            "input": {
                "source_artifact": 1,
            },
            "host_architecture": "amd64",
            "environment": "debian/match:codename=bookworm",
            "build_components": ["all"],
        }
        form = WorkRequestForm(
            {
                "task_data": yaml.safe_dump(task_data),
                "task_name": "sbuild",
            },
            user=self.user,
        )

        form.is_valid()

        self.assertNotIn("task_data", form.errors)

        self.assertEqual(form.cleaned_data["task_data"], task_data)

    def test_clean_task_data_return_empty_dictionary(self):
        """clean_data_yaml return empty dictionary."""
        task_data = ""
        form = WorkRequestForm(
            {"task_name": "noop", "task_data": task_data}, user=self.user
        )

        form.is_valid()
        self.assertEqual(form.cleaned_data["task_data"], {})

    def test_form_use_workspace_choice_field(self):
        """
        Workspace field is of instance WorkspaceChoiceField.

        Sorting, label, etc. are tested in WorkspaceChoiceFieldTests.
        """
        form = ArtifactForm(user=self.user)
        self.assertIsInstance(form.fields["workspace"], WorkspaceChoiceField)

    def test_task_name(self):
        """The field "task_name" is a choice field with the expected names."""
        form = WorkRequestForm(user=self.user)

        task_names = [
            [name, name]
            for name in sorted(BaseTask.worker_task_names())
            if "internal" not in name
        ]

        self.assertEqual(
            list(form.fields["task_name"].widget.choices), task_names
        )

    def test_task_name_superuser(self):
        """The field "task_name" is a choice field with the expected names."""
        user = get_user_model().objects.create_superuser(
            username="testsuperuser",
            email="superuser@mail.none",
            password="testsuperpass",
        )
        form = WorkRequestForm(user=user)

        task_names = [
            [name, name]
            for name in sorted(
                BaseTask.task_names(TaskTypes.WORKER)
                + BaseTask.task_names(TaskTypes.SERVER)
            )
        ]

        self.assertEqual(
            list(form.fields["task_name"].widget.choices), task_names
        )


class ArtifactFormTests(TestHelpersMixin, TestCase):
    """Tests for ArtifactForm."""

    user: ClassVar[User]
    artifact_category: ClassVar[ArtifactCategory]
    form_data: ClassVar[dict[str, Any]]

    @classmethod
    def setUpTestData(cls):
        """Initialize test objects."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass"
        )
        cls.artifact_category = ArtifactCategory.WORK_REQUEST_DEBUG_LOGS
        cls.form_data = {
            "category": cls.artifact_category,
            "workspace": Workspace.objects.first(),
        }

    def test_form_initialization(self):
        """Form initialization set form.user."""
        form = ArtifactForm(user=self.user)
        self.assertEqual(form.user, self.user)

    def test_form_save_set_created_by(self):
        """Form save(commit=False) set created_by."""
        form = ArtifactForm(data=self.form_data, user=self.user)
        form.is_valid()
        instance = form.save(commit=False)

        self.assertEqual(instance.created_by, self.user)

    def test_form_save_commit(self):
        """Form save(commit=True) set created_by and saves into the DB."""
        category = self.artifact_category
        workspace = Workspace.objects.first()

        files_to_upload = {"file1.txt": b"contents1", "file2.txt": b"contents2"}

        simple_uploaded_files = []
        for name, contents in files_to_upload.items():
            simple_uploaded_files.append(SimpleUploadedFile(name, contents))

        form_data = {
            "data": {
                "category": category,
                "workspace": workspace.id,
                "data": {},
                "expiration_delay_in_days": 1,
            },
            "files": MultiValueDict({"files": simple_uploaded_files}),
        }

        form = ArtifactForm(**form_data, user=self.user)

        self.assertTrue(form.is_valid())

        artifact = form.save(commit=True)

        self.assertEqual(Artifact.objects.get(id=artifact.id), artifact)

        self.assertEqual(artifact.category, category)
        self.assertEqual(artifact.workspace, workspace)

        self.assertEqual(
            artifact.fileinartifact_set.count(), len(files_to_upload)
        )
        self.assertEqual(artifact.expiration_delay, timedelta(days=1))

        file_backend = workspace.default_file_store.get_backend_object()

        for file_in_artifact, file_uploaded in zip(
            artifact.fileinartifact_set.order_by("id"), files_to_upload
        ):
            self.assertEqual(file_in_artifact.path, file_uploaded)

            with file_backend.get_stream(file_in_artifact.file) as file:
                self.assertEqual(file.read(), files_to_upload[file_uploaded])

    def test_form_save_no_commit(self):
        """Form save(commit=False): not saved to the DB."""
        workspace = Workspace.objects.first()

        files_to_upload = {"file1.txt": b"contents1", "file2.txt": b"contents2"}

        simple_uploaded_files = []
        for name, contents in files_to_upload.items():
            simple_uploaded_files.append(SimpleUploadedFile(name, contents))

        form_data = {
            "data": {
                "category": self.artifact_category,
                "workspace": workspace.id,
                "data": {},
            },
            "files": MultiValueDict({"files": simple_uploaded_files}),
        }

        form = ArtifactForm(**form_data, user=self.user)

        self.assertTrue(form.is_valid())

        artifact = form.save(commit=False)

        # Returned artifact is not saved
        self.assertIsNone(artifact.id)

        # Nothing is saved
        self.assertEqual(Artifact.objects.count(), 0)

    def test_form_use_workspace_choice_field(self):
        """
        Workspace field is of instance WorkspaceChoiceField.

        Sorting, label, etc. are tested in WorkspaceChoiceFieldTests.
        """
        form = ArtifactForm(user=self.user)
        self.assertIsInstance(form.fields["workspace"], WorkspaceChoiceField)

    def test_form_expire_at_past_error(self):
        """Field expire_at is in the past: return an error."""
        form_data = {
            "data": {
                "category": self.artifact_category,
                "workspace": Workspace.objects.first().id,
                "data": {},
                "expiration_delay_in_days": -1,
            },
            "files": {
                "files": [SimpleUploadedFile("file.txt", b"content.txt")]
            },
        }

        form = ArtifactForm(**form_data, user=self.user)
        self.assertFalse(form.is_valid())
        self.assertEqual(
            form.errors,
            {
                "expiration_delay_in_days": [
                    "Ensure this value is greater than or equal to 0."
                ]
            },
        )

    def test_categories(self):
        """Check expected category choices."""
        form = ArtifactForm(user=self.user)
        choices = []
        for category in sorted(ArtifactCategory):
            if category != ArtifactCategory.TEST:
                choices.append((str(category), str(category)))

        self.assertEqual(form.fields["category"].choices, choices)

    def test_file_zero_bytes(self):
        """The form is valid with a file of zero bytes."""
        category = ArtifactCategory.WORK_REQUEST_DEBUG_LOGS
        file_name = "build.txt"

        form_data = {
            "data": {
                "category": category,
                "workspace": Workspace.objects.first().id,
                "data": {},
                "expire_at": None,
            },
            "files": {"files": [SimpleUploadedFile(file_name, b"")]},
        }

        form = ArtifactForm(**form_data, user=self.user)
        self.assertTrue(form.is_valid())

    def test_files_invalid(self):
        """
        User tries to create an Artifact with invalid data.

        The data is YAML valid but the LocalArtifact does not validate.
        LocalArtifact checks the data with the contents of the files.
        """
        category = ArtifactCategory.PACKAGE_BUILD_LOG
        data = {
            "source": "test-source",
            "version": "1.2.3",
            "filename": "test",
        }
        file_name = "build.txt"

        # Try to create a PackageBuildLog. The file should end in .build
        # but it ends in .txt: invalid.
        form_data = {
            "data": {
                "category": category,
                "workspace": Workspace.objects.first().id,
                "data": data,
                "expire_at": None,
            },
            "files": {"files": [SimpleUploadedFile(file_name, b"content.txt")]},
        }

        form = ArtifactForm(**form_data, user=self.user)
        self.assertFalse(form.is_valid())

        expected_error = None
        try:
            PackageBuildLog(
                category=category,
                data=data,
                files={file_name: Path("not-used.txt")},
            )
        except pydantic.ValidationError as exc:
            expected_error = exc

        self.assertEqual(
            form.errors, {"files": [str(expected_error.args[0][0].exc)]}
        )

    def test_data_invalid(self):
        """
        User tries to create an Artifact with invalid data.

        The data is YAML valid but the LocalArtifact does not validate.
        LocalArtifact checks the data with the contents of the files.
        """
        category = ArtifactCategory.PACKAGE_BUILD_LOG
        data = {
            "source": "test-source",
            "version": "1.2.3",
            "filename": "test",
        }
        file_name = "build.txt"

        # Try to create a PackageBuildLog. The file should end in .build
        # but it ends in .txt: invalid.
        form_data = {
            "data": {
                "category": category,
                "workspace": Workspace.objects.first().id,
                "data": data,
                "expire_at": None,
            },
            "files": {"files": [SimpleUploadedFile(file_name, b"content.txt")]},
        }

        form = ArtifactForm(**form_data, user=self.user)
        self.assertFalse(form.is_valid())

        expected_error = None
        try:
            PackageBuildLog(
                category=category,
                data=data,
                files={file_name: Path("not-used.txt")},
            )
        except pydantic.ValidationError as exc:
            expected_error = exc

        self.assertEqual(
            form.errors, {"files": [str(expected_error.args[0][0].exc)]}
        )


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

    user: ClassVar[User]

    @classmethod
    def setUpTestData(cls):
        """Initialize test objects."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass"
        )

    def test_label(self):
        """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.first().name,
            ],
        )

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

        self.assertEqual(
            workspace_choice_field.queryset.query.order_by, ("name",)
        )

    def test_choices_no_user(self):
        """Choices for if no user: only the public workspaces."""
        default_workspace = Workspace.objects.first()
        default_workspace.public = False
        default_workspace.save()

        workspace = self.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),
            [
                ("", workspace_choice_field.empty_label),
                (workspace.id, workspace.name),
            ],
        )

    def test_choices_with_user(self):
        """Private workspace included in the choices: user has access."""
        workspace = Workspace.objects.first()
        workspace.public = False
        workspace.save()

        workspace_choice_field = WorkspaceChoiceField(user=self.user)

        self.assertEqual(
            list(workspace_choice_field.choices),
            [
                ("", workspace_choice_field.empty_label),
                (workspace.pk, workspace.name),
            ],
        )
