# 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 types import SimpleNamespace
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.models.lookups import LookupResult
from debusine.db.playground import scenarios
from debusine.db.tasks.inputs import (
    ArtifactInput,
    CollectionInSameWorkspace,
    CollectionInput,
    TaskConfigurationCollection,
)
from debusine.db.tasks.models import (
    DBArtifactInfo,
    DBInputCollectionSingle,
    DBInputLookupResult,
)
from debusine.db.tasks.resolver import DBFieldResolver
from debusine.tasks import TaskConfigError
from debusine.tasks.inputs.inputs import DataFieldInput
from debusine.tasks.models import BaseTaskData, LookupSingle
from debusine.test.django import TestCase
from debusine.utils import extract_generic_type_arguments


class InputTests[FieldType: DataFieldInput[Any, 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]

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

        # 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.resolver.get_field(self.field)
        assert lookup is not None
        label = label or self.field.field
        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.resolver.get_field(self.field)
        assert lookup is not None
        label = label or self.field.field
        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 assertIsDBInputLookupResult(
        self, value: Any, result: LookupResult, label: str | None = None
    ) -> None:
        """Ensure the value is a matching DBInputLookupResult."""
        label = label or self.field.field
        assert isinstance(value, DBInputLookupResult)
        self.assertEqual(
            value, DBInputLookupResult.from_lookup_result(result, label=label)
        )
        with self.assertNumQueries(0):
            self.assertEqual(value.result, result)

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

    @override
    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.task.data = SimpleNamespace(field=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.task.data = SimpleNamespace(field=self.artifact.pk)
        self.assertIsDBArtifactInfo(self.resolve(), self.artifact)

    def test_lookup_malformed(self) -> None:
        self.task.data = SimpleNamespace(field="invalid")
        with self.assertRaisesRegex(
            TaskConfigError,
            rf"Lookup for field 'field' in {self.work_request.workspace}"
            rf" failed: 'invalid' does not specify a category",
        ):
            self.resolve()

    def test_lookup_fails(self) -> None:
        self.task.data = SimpleNamespace(field="invalid@debian:upload")
        with self.assertRaisesRegex(
            TaskConfigError,
            rf"Lookup for field 'field' in {self.work_request.workspace}"
            rf" failed: 'invalid@debian:upload' does not exist or is hidden",
        ):
            self.resolve()

    def test_invalid_category(self) -> None:
        self.field = self.field_class(
            field="field", categories=[ArtifactCategory.TEST]
        )
        self.task.data = SimpleNamespace(field=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
        )

    @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.task.data = SimpleNamespace(field=None)
        with self.assertRaises(AssertionError):
            self.resolve()
        lookup_single.assert_not_called()

    def test_has_value(self) -> None:
        self.task.data = SimpleNamespace(field=self.collection.pk)
        self.assertIsDBInputCollectionSingle(self.resolve(), self.collection)

    def test_lookup_malformed(self) -> None:
        self.task.data = SimpleNamespace(field="invalid")
        with self.assertRaisesRegex(
            TaskConfigError,
            rf"Lookup for field 'field' in {self.work_request.workspace}"
            rf" failed: 'invalid' does not specify a category",
        ):
            self.resolve()

    def test_lookup_fails(self) -> None:
        self.task.data = SimpleNamespace(field="invalid@debian:suite")
        with self.assertRaisesRegex(
            TaskConfigError,
            rf"Lookup for field 'field' in {self.work_request.workspace}"
            rf" failed: 'invalid@debian:suite' does not exist or is hidden",
        ):
            self.resolve()

    def test_valid_category(self) -> None:
        self.field = self.field_class(
            field="field", category=CollectionCategory.SUITE
        )
        self.task.data = SimpleNamespace(field=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.task.data = SimpleNamespace(field=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.task.data = SimpleNamespace(field=None)
        with self.assertRaises(AssertionError):
            self.resolve()
        lookup_single.assert_not_called()

    def test_same_workspace(self) -> None:
        self.task.data = SimpleNamespace(
            field=f"sid@{CollectionCategory.SUITE}"
        )
        self.assertIsDBInputCollectionSingle(self.resolve(), self.collection)

    def test_different_workspace(self) -> None:
        lookup = f"//workspace=other/sid@{CollectionCategory.SUITE}"
        self.task.data = SimpleNamespace(field=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()

    @override
    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.task.data = SimpleNamespace(field=None)
        result = self.resolve()
        self.assertIsNone(result)

    def test_failed_default(self) -> None:
        self.task.data = SimpleNamespace(
            field=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.task.data = SimpleNamespace(field="custom")
        self.enterContext(
            mock.patch.object(
                self.work_request,
                "lookup_single",
                side_effect=LookupError("expected error"),
            )
        )
        with self.assertRaisesRegex(
            TaskConfigError,
            rf"Lookup for field 'field' in {self.work_request.workspace}"
            rf" failed: expected error",
        ):
            self.resolve()

    def test_success(self) -> None:
        self.task.data = SimpleNamespace(
            field=BaseTaskData.DEFAULT_TASK_CONFIGURATION_LOOKUP
        )
        self.assertIsDBInputCollectionSingle(
            self.resolve(),
            self.scenario.default_task_configuration_collection,
        )
