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

"""Unit tests for the DBTask input fields."""

import inspect
from typing import Any, ClassVar, override
from unittest import mock

from debusine.artifacts.models import ArtifactCategory, CollectionCategory
from debusine.db.models import Artifact, Collection, WorkRequest, Workspace
from debusine.db.playground import scenarios
from debusine.db.tasks.inputs import (
    ArtifactInput,
    CollectionInSameWorkspace,
    CollectionInput,
    TaskConfigurationCollection,
)
from debusine.db.tasks.models import DBArtifactInfo, DBInputCollectionSingle
from debusine.db.tasks.resolver import DBFieldResolver
from debusine.tasks import TaskConfigError
from debusine.tasks.inputs import TaskInput
from debusine.tasks.models import BaseTaskData, LookupSingle
from debusine.test.django import TestCase
from debusine.utils import extract_generic_type_arguments


class InputTests[FieldType: TaskInput[Any]](TestCase):
    """Test case that can work as a container for task input fields."""

    #: Field class to be tested, filled by __init_subclass__
    field_class: type[FieldType]

    #: Work request supporting the task
    work_request: ClassVar[WorkRequest]

    def __init_subclass__(cls, **kwargs: Any) -> None:
        """Fill lookup_type from typing information."""
        super().__init_subclass__(**kwargs)
        [cls.field_class] = extract_generic_type_arguments(cls, InputTests)

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.work_request = cls.playground.create_server_task()

    @override
    def setUp(self) -> None:
        super().setUp()
        # self.field is typed here, but set later by test methods
        self.field: FieldType

        # Task supporting the resolution
        self.task = self.work_request.get_task()

        # Field resolver
        self.resolver = DBFieldResolver(self.task)

        # Mocked get_field, to avoid setting up task data for the field to look
        # up
        self.get_field = self.enterContext(
            mock.patch.object(self.resolver, "get_field")
        )

        # Mock check_category
        self.check_category = self.enterContext(
            mock.patch(
                "debusine.tasks.inputs.inputs.BaseArtifactInput.check_category"
            )
        )

    def resolve(self) -> Any:
        """Call self.field.resolve and return its result."""
        return self.field.resolve(self.resolver)

    def assertIsDBArtifactInfo(
        self,
        value: Any,
        artifact: Artifact,
        lookup: LookupSingle | None = None,
        label: str | None = None,
    ) -> None:
        """Ensure the given value is a DBArtifactInfo matching the arguments."""
        lookup = lookup or self.get_field.return_value
        # field.field is only defined for DataFieldInput subclasses. Using this
        # assertion in other types of fields will raise an exception, and label
        # can be provided explicitly
        label = label or self.field.field  # type: ignore[attr-defined]
        assert isinstance(value, DBArtifactInfo)
        self.assertEqual(
            value,
            DBArtifactInfo.from_artifact(artifact, lookup=lookup, label=label),
        )
        with self.assertNumQueries(0):
            self.assertEqual(value.artifact, artifact)

    def assertIsDBInputCollectionSingle(
        self,
        value: Any,
        collection: Collection,
        lookup: LookupSingle | None = None,
        label: str | None = None,
    ) -> None:
        """Ensure the value is a matching DBInputCollectionSingle."""
        lookup = lookup or self.get_field.return_value
        # field.field is only defined for DataFieldInput subclasses. Using this
        # assertion in other types of fields will raise an exception, and label
        # can be provided explicitly
        label = label or self.field.field  # type: ignore[attr-defined]
        assert isinstance(value, DBInputCollectionSingle)
        self.assertEqual(
            value,
            DBInputCollectionSingle.from_collection(
                collection, lookup=lookup, label=label
            ),
        )
        with self.assertNumQueries(0):
            self.assertEqual(value.collection, collection)

    def test_has_serializer(self) -> None:
        if not hasattr(self, "field_class") or inspect.isabstract(
            self.field_class
        ):
            return
        if not hasattr(self.field_class, "serializer"):
            self.fail(
                f"{self.field_class} has no serializer defined"
            )  # pragma: no cover


class ArtifactInputTests(InputTests[ArtifactInput]):
    """Tests for :py:class:`ArtifactInput`."""

    artifact: ClassVar[Artifact]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.artifact = cls.playground.create_minimal_source_package_artifact()

    def setUp(self) -> None:
        super().setUp()
        self.field = self.field_class(field="field")

    def test_none(self) -> None:
        lookup_single_artifact = self.enterContext(
            self.spy_object(self.resolver, "lookup_single_artifact")
        )
        self.get_field.return_value = None
        with self.assertRaises(AssertionError):
            self.resolve()
        lookup_single_artifact.assert_not_called()
        self.check_category.assert_not_called()

    def test_has_value(self) -> None:
        self.get_field.return_value = self.artifact.pk
        self.assertIsDBArtifactInfo(self.resolve(), self.artifact)

    def test_lookup_fails(self) -> None:
        self.get_field.return_value = "invalid@debian:upload"
        with self.assertRaisesRegex(
            TaskConfigError,
            "Artifact 'invalid@debian:upload' in debusine/System"
            " not found for field 'field'",
        ):
            self.resolve()

    def test_invalid_category(self) -> None:
        self.field = self.field_class(
            field="field", categories=[ArtifactCategory.TEST]
        )
        self.get_field.return_value = self.artifact.pk
        with self.assertRaisesRegex(
            TaskConfigError,
            "unexpected artifact category: 'debian:source-package'."
            r" Valid categories: \['debusine:test'\]",
        ):
            self.resolve()

    def test_get_artifact_ids(self) -> None:
        self.assertEqual(
            self.field.get_artifact_ids(
                DBArtifactInfo.from_artifact(
                    self.artifact, lookup=1, label="field"
                )
            ),
            [self.artifact.id],
        )


class CollectionInputTests(InputTests[CollectionInput]):
    """Tests for :py:class:`CollectionInput`."""

    collection: ClassVar[Collection]

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

    def setUp(self) -> None:
        super().setUp()
        self.field = self.field_class(field="field")

    def test_none(self) -> None:
        lookup_single = self.enterContext(
            self.spy("debusine.db.models.WorkRequest.lookup_single")
        )
        self.get_field.return_value = None
        with self.assertRaises(AssertionError):
            self.resolve()
        lookup_single.assert_not_called()

    def test_has_value(self) -> None:
        self.get_field.return_value = self.collection.pk
        self.assertIsDBInputCollectionSingle(self.resolve(), self.collection)

    def test_lookup_fails(self) -> None:
        self.get_field.return_value = "invalid@debian:suite"
        with self.assertRaisesRegex(
            TaskConfigError,
            "Collection 'invalid@debian:suite' in debusine/System"
            " not found for field 'field'",
        ):
            self.resolve()

    def test_valid_category(self) -> None:
        self.field = self.field_class(
            field="field", category=CollectionCategory.SUITE
        )
        self.get_field.return_value = self.collection.pk
        self.assertIsDBInputCollectionSingle(self.resolve(), self.collection)

    def test_invalid_category(self) -> None:
        self.field = self.field_class(
            field="field", category=CollectionCategory.TEST
        )
        self.get_field.return_value = self.collection.pk
        with self.assertRaisesRegex(
            TaskConfigError,
            "unexpected collection category: 'debian:suite'."
            " Expected: 'debusine:test'",
        ):
            self.resolve()


class CollectionInSameWorkspaceTests(InputTests[CollectionInSameWorkspace]):
    """Tests for :py:class:`CollectionInSameWorkspace`."""

    other_workspace: Workspace
    collection: Collection
    other_collection: Collection

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.other_workspace = cls.playground.create_workspace(
            name="other", public=True
        )
        cls.collection = cls.playground.create_collection(
            name="sid",
            category=CollectionCategory.SUITE,
            workspace=cls.work_request.workspace,
        )
        cls.other_collection = cls.playground.create_collection(
            name="sid",
            category=CollectionCategory.SUITE,
            workspace=cls.other_workspace,
        )

    @override
    def setUp(self) -> None:
        super().setUp()
        self.field = self.field_class(field="field")

    def test_none(self) -> None:
        lookup_single = self.enterContext(
            self.spy("debusine.db.models.WorkRequest.lookup_single")
        )
        self.get_field.return_value = None
        with self.assertRaises(AssertionError):
            self.resolve()
        lookup_single.assert_not_called()

    def test_same_workspace(self) -> None:
        self.get_field.return_value = f"sid@{CollectionCategory.SUITE}"
        self.assertIsDBInputCollectionSingle(self.resolve(), self.collection)

    def test_different_workspace(self) -> None:
        lookup = f"//workspace=other/sid@{CollectionCategory.SUITE}"
        self.get_field.return_value = lookup
        with self.assertRaisesRegex(
            TaskConfigError,
            f"{lookup} is in workspace {self.other_workspace},"
            f" not {self.work_request.workspace}",
        ):
            self.resolve()


class TaskConfigurationCollectionTests(InputTests[TaskConfigurationCollection]):
    """Tests for :py:class:`TaskConfigurationCollection`."""

    scenario = scenarios.DefaultContext()

    def setUp(self) -> None:
        super().setUp()
        self.field = self.field_class(field="field")

    def test_model_default(self) -> None:
        data = BaseTaskData()
        self.assertEqual(
            data.task_configuration,
            BaseTaskData.DEFAULT_TASK_CONFIGURATION_LOOKUP,
        )

    def test_model_explicit_none(self) -> None:
        data = BaseTaskData(task_configuration=None)
        self.assertIsNone(data.task_configuration)

    def test_lookup_none(
        self,
    ) -> None:
        self.get_field.return_value = None
        result = self.resolve()
        self.assertIsNone(result)

    def test_failed_default(self) -> None:
        self.get_field.return_value = (
            BaseTaskData.DEFAULT_TASK_CONFIGURATION_LOOKUP
        )
        self.enterContext(
            mock.patch.object(
                self.work_request, "lookup_single", side_effect=KeyError()
            )
        )
        self.assertIsNone(self.resolve())

    def test_failed_custom(self) -> None:
        self.get_field.return_value = "custom"
        self.enterContext(
            mock.patch.object(
                self.work_request,
                "lookup_single",
                side_effect=KeyError("expected error"),
            )
        )
        with self.assertRaisesRegex(KeyError, r"expected error"):
            self.resolve()

    def test_success(self) -> None:
        self.get_field.return_value = (
            BaseTaskData.DEFAULT_TASK_CONFIGURATION_LOOKUP
        )
        self.assertIsDBInputCollectionSingle(
            self.resolve(),
            self.scenario.default_task_configuration_collection,
        )
