# 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 database aware task field resolver."""

import re
from collections.abc import Callable
from types import SimpleNamespace
from typing import Any, ClassVar, cast, override
from unittest import mock

from django.test import override_settings
from pgpy import PGPKey, PGPKeyring
from pgpy.constants import EllipticCurveOID, PubKeyAlgorithm

from debusine.artifacts.models import ArtifactCategory, CollectionCategory
from debusine.assets.asset_utils import ExportablePGPKeyring
from debusine.assets.models import KeyPurpose
from debusine.client.models import LookupChildType, RelationType
from debusine.db.context import context
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    Collection,
    WorkRequest,
)
from debusine.db.permissioncontext import PermissionContext
from debusine.db.playground import scenarios
from debusine.db.tasks import DBWorkerProxyTask
from debusine.db.tasks.models import DBArtifactInfo, DBBaseTaskInputArtifact
from debusine.db.tasks.resolver import DBFieldResolver
from debusine.tasks import BaseTaskWithExecutor, TaskConfigError
from debusine.tasks.executors import ExecutorImageCategory
from debusine.tasks.inputs import FieldResolver, inputs
from debusine.tasks.models import (
    BackendType,
    ExtraDebusineRepository,
    ExtraExternalRepository,
    LookupMultiple,
)
from debusine.test.django import TestCase
from debusine.test.test_utils import create_system_tarball_data


class DBFieldResolverTests(TestCase):
    """Test :py:class:`DBFieldResolver`."""

    scenario = scenarios.DefaultContext()
    work_request: ClassVar[WorkRequest]

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up common data for tests."""
        super().setUpTestData()
        cls.work_request = cls.playground.create_worker_task(
            task_name="noop", workspace=cls.scenario.workspace
        )

    def resolver(self) -> DBFieldResolver:
        """Instantiate the DBFieldResolver to test."""
        task = self.work_request.get_task()
        return DBFieldResolver(task)

    @context.disable_permission_checks()
    def test_lookup_single_artifact(self) -> None:
        """Look up a single artifact."""
        collection = self.playground.create_collection(
            "debian",
            CollectionCategory.ENVIRONMENTS,
            workspace=self.scenario.workspace,
        )
        artifact, _ = self.playground.create_artifact(
            category=ArtifactCategory.SYSTEM_TARBALL,
            data=create_system_tarball_data(
                codename="bookworm", architecture="amd64"
            ),
        )
        collection.manager.add_artifact(
            artifact, user=self.playground.get_default_user()
        )
        resolver = self.resolver()

        lookup = "debian@debian:environments/match:codename=bookworm"
        result = resolver.lookup_single_artifact(lookup, label="test")
        self.assertEqual(
            result,
            DBArtifactInfo.from_artifact(artifact, label="test", lookup=lookup),
        )
        with self.assertNumQueries(0):
            self.assertEqual(result.artifact, artifact)

        lookup = "debian/match:codename=bookworm"
        result = resolver.lookup_single_artifact(
            lookup,
            default_category=CollectionCategory.ENVIRONMENTS,
            label="test",
        )
        self.assertEqual(
            result,
            DBArtifactInfo.from_artifact(artifact, lookup=lookup, label="test"),
        )
        with self.assertNumQueries(0):
            self.assertEqual(result.artifact, artifact)
        with self.assertRaisesRegex(
            LookupError,
            "'debian' does not specify a category and the context does not "
            "supply a default",
        ):
            resolver.lookup_single_artifact(
                "debian/match:codename=bookworm", label="test"
            )

    def test_lookup_multiple_artifacts(self) -> None:
        collection = self.playground.create_collection(
            "debian",
            CollectionCategory.ENVIRONMENTS,
            workspace=self.scenario.workspace,
        )
        artifacts: list[Artifact] = []
        for codename in "bookworm", "trixie":
            artifact, _ = self.playground.create_artifact(
                category=ArtifactCategory.SYSTEM_TARBALL,
                data=create_system_tarball_data(
                    codename=codename, architecture="amd64"
                ),
            )
            collection.manager.add_artifact(
                artifact, user=self.playground.get_default_user()
            )
            artifacts.append(artifact)
        resolver = self.resolver()

        lookup = LookupMultiple.model_validate(
            {"collection": "debian@debian:environments"}
        )
        result = resolver.lookup_multiple_artifacts(lookup, label="test")
        self.assertCountEqual(
            result.artifacts,
            [
                DBBaseTaskInputArtifact.from_artifact(artifact)
                for artifact in artifacts
            ],
        )
        with self.assertNumQueries(0):
            self.assertCountEqual(
                [r.artifact for r in result.artifacts], artifacts
            )

        self.assertEqual(result.label, "test")
        self.assertEqual(result.lookup, lookup)

        lookup = LookupMultiple.model_validate({"collection": "debian"})
        result = resolver.lookup_multiple_artifacts(
            lookup,
            default_category=CollectionCategory.ENVIRONMENTS,
            label="test",
        )
        self.assertCountEqual(
            result.artifacts,
            [
                DBBaseTaskInputArtifact.from_artifact(artifact)
                for artifact in artifacts
            ],
        )
        with self.assertNumQueries(0):
            self.assertCountEqual(
                [r.artifact for r in result.artifacts], artifacts
            )
        self.assertEqual(result.label, "test")
        self.assertEqual(result.lookup, lookup)

        with self.assertRaisesRegex(
            LookupError,
            "'debian' does not specify a category and the context does not "
            "supply a default",
        ):
            resolver.lookup_multiple_artifacts(
                LookupMultiple.model_validate({"collection": "debian"}),
                label="test",
            )

    def test_find_related_artifacts(self) -> None:
        """Find artifacts via relations."""
        upload_artifacts = self.playground.create_upload_artifacts(
            binaries=[("libhello1", "amd64"), ("hello", "all")]
        )
        resolver = self.resolver()
        empty_lookup_multiple = LookupMultiple(cast(tuple[str], []))

        self.assertCountEqual(
            resolver.find_related_artifacts(
                [upload_artifacts.upload.id],
                target_category=ArtifactCategory.SOURCE_PACKAGE,
                label="test",
            ).artifacts,
            [],
        )

        result = resolver.find_related_artifacts(
            [upload_artifacts.upload.id],
            target_category=ArtifactCategory.SOURCE_PACKAGE,
            relation_type=RelationType.EXTENDS,
            label="test",
        )
        self.assertCountEqual(
            result.artifacts,
            [DBBaseTaskInputArtifact.from_artifact(upload_artifacts.source)],
        )
        with self.assertNumQueries(0):
            self.assertEqual(
                result.artifacts[0].artifact, upload_artifacts.source
            )
        self.assertEqual(result.lookup, empty_lookup_multiple)
        self.assertEqual(result.label, "test")

        result = resolver.find_related_artifacts(
            [upload_artifacts.upload.id],
            target_category=ArtifactCategory.BINARY_PACKAGE,
            label="test",
        )
        self.assertCountEqual(result.artifacts, [])
        self.assertEqual(result.lookup, empty_lookup_multiple)
        self.assertEqual(result.label, "test")

        result = resolver.find_related_artifacts(
            [upload_artifacts.upload.id],
            target_category=ArtifactCategory.BINARY_PACKAGE,
            relation_type=RelationType.EXTENDS,
            label="test",
        )
        self.assertCountEqual(
            result.artifacts,
            [
                DBBaseTaskInputArtifact.from_artifact(artifact)
                for artifact in upload_artifacts.binaries
            ],
        )
        with self.assertNumQueries(0):
            self.assertCountEqual(
                [r.artifact for r in result.artifacts],
                upload_artifacts.binaries,
            )
        self.assertEqual(result.lookup, empty_lookup_multiple)
        self.assertEqual(result.label, "test")

        self.playground.create_artifact_relation(
            artifact=upload_artifacts.upload,
            target=upload_artifacts.source,
            relation_type=ArtifactRelation.Relations.RELATES_TO,
        )
        result = resolver.find_related_artifacts(
            [upload_artifacts.upload.id],
            target_category=ArtifactCategory.SOURCE_PACKAGE,
            label="test",
        )
        self.assertCountEqual(
            result.artifacts,
            [DBBaseTaskInputArtifact.from_artifact(upload_artifacts.source)],
        )
        with self.assertNumQueries(0):
            self.assertEqual(
                result.artifacts[0].artifact, upload_artifacts.source
            )
        self.assertEqual(result.lookup, empty_lookup_multiple)
        self.assertEqual(result.label, "test")

    def test_get_fallback_architecture(self) -> None:
        resolver = self.resolver()
        with mock.patch(
            "debusine.db.models.WorkRequest.get_fallback_architecture",
            return_value=mock.sentinel.arch,
        ):
            self.assertEqual(
                resolver.get_fallback_architecture(), mock.sentinel.arch
            )

    def test_export_suite_signing_keys_none(self) -> None:
        collection = self.playground.create_collection(
            "trixie",
            CollectionCategory.SUITE,
            workspace=self.scenario.workspace,
        )
        resolver = self.resolver()

        self.assertIsNone(resolver.export_suite_signing_keys(collection.id))

    def test_export_suite_signing_keys(self) -> None:
        keys = [
            PGPKey.new(PubKeyAlgorithm.EdDSA, EllipticCurveOID.Ed25519).pubkey
            for _ in range(2)
        ]
        for key in keys:
            self.playground.create_asset_usage(
                resource=self.playground.create_signing_key_asset(
                    purpose=KeyPurpose.OPENPGP_REPOSITORY,
                    fingerprint=key.fingerprint,
                    public_key=str(key),
                    workspace=self.scenario.workspace,
                ),
                workspace=self.scenario.workspace,
            )
        collection = self.playground.create_collection(
            "trixie",
            CollectionCategory.SUITE,
            workspace=self.scenario.workspace,
            data={"signing_keys": [key.fingerprint for key in keys]},
        )
        resolver = self.resolver()

        signing_keys = resolver.export_suite_signing_keys(collection.id)
        assert signing_keys is not None
        self.assertTrue(
            signing_keys.startswith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n")
        )
        self.assertEqual(
            PGPKeyring(signing_keys).fingerprints(),
            {key.fingerprint for key in keys},
        )


class EnvironmentInputTests(TestCase):
    """Test EnvironmentInput and OptionalEnvironmentInput field resolution."""

    def setUp(self) -> None:
        super().setUp()
        # Set up the mock task
        self.task = mock.MagicMock(spec=DBWorkerProxyTask)
        self.task.name = mock.sentinel.task_name
        self.task.data = mock.MagicMock()
        self.task.work_request = mock.MagicMock(spec=WorkRequest)
        self.task.task = mock.MagicMock(spec=BaseTaskWithExecutor)
        self.task.task.name = mock.sentinel.task_name
        self.task.task.backend = mock.sentinel.backend
        # Fields are resolved in order, and this needs to have been already
        # resolved
        self.task.task.build_architecture = mock.sentinel.build_architecture

        # Set up the resolver
        self.resolver = DBFieldResolver(self.task)
        self.resolver_lookup_environment = self.enterContext(
            mock.patch.object(
                self.resolver,
                "lookup_environment",
                return_value=mock.sentinel.environment,
            )
        )
        self.resolver_get_field = self.enterContext(
            mock.patch.object(
                self.resolver, "get_field", return_value=mock.sentinel.lookup
            )
        )

    def setup[F: inputs.DataFieldInput[Any, Any]](self, field: F) -> F:
        """Set up the field to test."""
        field.field = mock.sentinel.field_name
        self.field_check_category = self.enterContext(
            mock.patch.object(field, "check_category")
        )
        return field

    def assertResolves[F](
        self,
        method: Callable[[F], Any],
        field: F,
        *,
        backend: BackendType | None = mock.sentinel.backend,
        image_category: ExecutorImageCategory | None = None,
        try_variant: str | None = mock.sentinel.task_name,
    ) -> None:
        result = method(field)
        self.assertIs(result, mock.sentinel.environment)
        self.field_check_category.assert_called_once_with(
            mock.sentinel.environment
        )
        self.resolver_lookup_environment.assert_called_once_with(
            mock.sentinel.lookup,
            architecture=mock.sentinel.build_architecture,
            backend=backend,
            default_category=CollectionCategory.ENVIRONMENTS,
            image_category=image_category,
            try_variant=try_variant,
            label=mock.sentinel.field_name,
        )

    def test_nonopt_defaults(self) -> None:
        field = self.setup(inputs.EnvironmentInput(field="field"))
        self.assertResolves(self.resolver.resolve_environmentinput, field)

    def test_nonopt_set_backend_false(self) -> None:
        field = self.setup(
            inputs.EnvironmentInput(set_backend=False, field="field")
        )
        self.assertResolves(
            self.resolver.resolve_environmentinput, field, backend=None
        )

    def test_nonopt_try_variant_false(self) -> None:
        field = self.setup(
            inputs.EnvironmentInput(try_variant=False, field="field")
        )
        self.assertResolves(
            self.resolver.resolve_environmentinput, field, try_variant=None
        )

    def test_nonopt_image_category(self) -> None:
        field = self.setup(
            inputs.EnvironmentInput(
                image_category=ExecutorImageCategory.TARBALL, field="field"
            )
        )
        self.assertResolves(
            self.resolver.resolve_environmentinput,
            field,
            image_category=ExecutorImageCategory.TARBALL,
        )

    def test_nonopt_field_is_required(self) -> None:
        field = self.setup(inputs.EnvironmentInput(field="field"))
        self.resolver_get_field.return_value = None
        with self.assertRaises(AssertionError):
            self.resolver.resolve_environmentinput(field)

    def test_opt_defaults(self) -> None:
        field = self.setup(inputs.OptionalEnvironmentInput(field="field"))
        self.assertResolves(
            self.resolver.resolve_optionalenvironmentinput, field
        )

    def test_opt_missing(self) -> None:
        field = self.setup(inputs.OptionalEnvironmentInput(field="field"))
        self.resolver_get_field.return_value = None
        self.assertIsNone(self.resolver.resolve_optionalenvironmentinput(field))
        self.field_check_category.assert_not_called()
        self.resolver_lookup_environment.assert_not_called()


class SuiteArchiveInputTests(TestCase):
    """Test SuiteArchiveInput field resolution."""

    scenario = scenarios.DefaultContext()

    work_request: ClassVar[WorkRequest]
    sid: ClassVar[Collection]
    archive: ClassVar[Collection]

    @classmethod
    @override
    def setUpTestData(cls) -> None:
        """Set up common data for tests."""
        super().setUpTestData()
        cls.work_request = cls.playground.create_worker_task()
        cls.sid = cls.playground.create_collection(
            name="sid", category=CollectionCategory.SUITE
        )
        cls.archive = cls.playground.create_singleton_collection(
            category=CollectionCategory.ARCHIVE
        )

    def setUp(self) -> None:
        super().setUp()
        self.task = self.work_request.get_task()

        # Set up the resolver
        self.resolver = DBFieldResolver(self.task)
        self.resolver_get_field = self.enterContext(
            mock.patch.object(
                self.resolver, "get_field", return_value="sid@debian:suite"
            )
        )

        # Set up the Field
        self.field = inputs.SuiteArchiveInput(field="field_name")

        self.wr_lookup_single = self.enterContext(
            self.spy("debusine.db.models.WorkRequest.lookup_single")
        )
        self.lookup_single = self.enterContext(
            self.spy("debusine.server.collections.lookup.lookup_single")
        )

    def test_suite_only(self) -> None:
        self.archive.delete()
        suite, archive = self.resolver.resolve_suitearchiveinput(self.field)
        self.assertEqual(suite.collection_id, self.sid.id)
        self.assertIsNone(archive)
        self.wr_lookup_single.assert_has_calls(
            [
                mock.call(
                    self.work_request,
                    lookup=self.resolver_get_field.return_value,
                    expect_type=LookupChildType.COLLECTION,
                ),
            ]
        )
        self.lookup_single.assert_has_calls(
            [
                mock.call(
                    "_@debian:archive",
                    workspace=self.scenario.workspace,
                    pc=PermissionContext.for_work_request(
                        work_request=self.work_request
                    ),
                    expect_type=LookupChildType.COLLECTION,
                ),
            ]
        )

    def test_suite_archive(self) -> None:
        suite, archive = self.resolver.resolve_suitearchiveinput(self.field)
        self.assertEqual(suite.collection_id, self.sid.id)
        assert archive is not None
        self.assertEqual(archive.collection_id, self.archive.id)
        self.wr_lookup_single.assert_has_calls(
            [
                mock.call(
                    self.work_request,
                    lookup=self.resolver_get_field.return_value,
                    expect_type=LookupChildType.COLLECTION,
                ),
            ]
        )
        self.lookup_single.assert_has_calls(
            [
                mock.call(
                    "_@debian:archive",
                    workspace=self.scenario.workspace,
                    pc=PermissionContext.for_work_request(
                        work_request=self.work_request
                    ),
                    expect_type=LookupChildType.COLLECTION,
                ),
            ]
        )

    def test_suite_archive_have_cached_models(self) -> None:
        suite, archive = self.resolver.resolve_suitearchiveinput(self.field)
        assert archive is not None
        with self.assertNumQueries(0):
            self.assertEqual(suite.collection, self.sid)
        with self.assertNumQueries(0):
            self.assertEqual(archive.collection, self.archive)

    def test_lookup_archive_in_suite_workspace(self) -> None:
        ws_other = self.playground.create_workspace(name="other", public=True)
        sid_other = self.playground.create_collection(
            name="sid", category=CollectionCategory.SUITE, workspace=ws_other
        )
        archive_other = self.playground.create_singleton_collection(
            category=CollectionCategory.ARCHIVE, workspace=ws_other
        )

        self.resolver_get_field.return_value = f"{sid_other.id}@collections"

        suite, archive = self.resolver.resolve_suitearchiveinput(self.field)
        self.assertEqual(suite.collection_id, sid_other.id)
        assert archive is not None
        self.assertEqual(archive.collection_id, archive_other.id)
        self.wr_lookup_single.assert_has_calls(
            [
                mock.call(
                    self.work_request,
                    lookup=self.resolver_get_field.return_value,
                    expect_type=LookupChildType.COLLECTION,
                ),
            ]
        )
        self.lookup_single.assert_has_calls(
            [
                mock.call(
                    "_@debian:archive",
                    workspace=ws_other,
                    pc=PermissionContext.for_work_request(
                        work_request=self.work_request
                    ),
                    expect_type=LookupChildType.COLLECTION,
                ),
            ]
        )

    def test_wrong_suite_category(self) -> None:
        self.sid.category = CollectionCategory.TEST
        self.sid.save()
        self.resolver_get_field.return_value = "sid@debusine:test"
        with self.assertRaisesRegex(
            TaskConfigError,
            rf"^field_name: unexpected collection category: "
            rf"'{CollectionCategory.TEST}'\. "
            rf"Expected: '{CollectionCategory.SUITE}'$",
        ):
            self.resolver.resolve_suitearchiveinput(self.field)


@override_settings(DEBUSINE_DEBIAN_ARCHIVE_PRIMARY_FQDN="deb.example.com")
class ExtraRepositoriesInputTests(TestCase):
    """Test ExtraRepositoriesInput field resolution."""

    scenario = scenarios.DefaultContext()
    work_request: ClassVar[WorkRequest]
    sid: ClassVar[Collection]
    keys: ClassVar[list[PGPKey]]

    @classmethod
    @override
    def setUpTestData(cls) -> None:
        """Set up common data for tests."""
        super().setUpTestData()
        cls.work_request = cls.playground.create_worker_task()

        cls.keys = [
            PGPKey.new(PubKeyAlgorithm.EdDSA, EllipticCurveOID.Ed25519).pubkey
            for _ in range(2)
        ]
        for key in cls.keys:
            cls.playground.create_asset_usage(
                resource=cls.playground.create_signing_key_asset(
                    purpose=KeyPurpose.OPENPGP_REPOSITORY,
                    fingerprint=key.fingerprint,
                    public_key=str(key),
                    workspace=cls.scenario.workspace,
                ),
                workspace=cls.scenario.workspace,
            )
        cls.sid = cls.playground.create_collection(
            name="sid",
            category=CollectionCategory.SUITE,
            workspace=cls.scenario.workspace,
            data={
                "components": ["main"],
                "signing_keys": [cls.keys[0].fingerprint],
            },
        )

    def setUp(self) -> None:
        super().setUp()
        self.task = self.work_request.get_task()

        # Set up the resolver
        self.resolver = DBFieldResolver(self.task)
        self.resolver_get_field = self.enterContext(
            mock.patch.object(
                self.resolver, "get_field", return_value=mock.sentinel.lookup
            )
        )

        # Set up the Field
        self.field = inputs.ExtraRepositoriesInput(field="field_name")

        self.lookup_single = self.enterContext(
            self.spy("debusine.db.models.WorkRequest.lookup_single")
        )

    def test_resolve_none(self) -> None:
        self.resolver_get_field.return_value = None
        resolve_extra_repository = self.enterContext(
            mock.patch.object(
                self.resolver,
                "_resolve_extra_repository",
                return_value=mock.sentinel.resolved,
            )
        )
        self.assertIsNone(
            self.resolver.resolve_extrarepositoriesinput(self.field)
        )
        resolve_extra_repository.assert_not_called()

    def test_resolve_empty_list(self) -> None:
        self.resolver_get_field.return_value = []
        resolve_extra_repository = self.enterContext(
            mock.patch.object(
                self.resolver,
                "_resolve_extra_repository",
                return_value=mock.sentinel.resolved,
            )
        )
        self.assertEqual(
            self.resolver.resolve_extrarepositoriesinput(self.field), []
        )
        resolve_extra_repository.assert_not_called()

    def test_resolve_list_items(self) -> None:
        self.resolver_get_field.return_value = [
            mock.sentinel.first,
            mock.sentinel.second,
        ]
        resolve_extra_repository = self.enterContext(
            mock.patch.object(
                self.resolver,
                "_resolve_extra_repository",
                return_value=mock.sentinel.resolved,
            )
        )
        self.assertEqual(
            self.resolver.resolve_extrarepositoriesinput(self.field),
            [mock.sentinel.resolved, mock.sentinel.resolved],
        )
        resolve_extra_repository.assert_has_calls(
            [
                mock.call(mock.sentinel.first),
                mock.call(mock.sentinel.second),
            ]
        )

    def test_resolve_extra_repo_external(self) -> None:
        external = ExtraExternalRepository(
            url="https://example.org", suite="sid", components=["main"]
        )
        self.assertIs(
            self.resolver._resolve_extra_repository(external),
            external,
        )

    def test_debusine(self) -> None:
        debusine = ExtraDebusineRepository(
            suite="sid@debian:suite", components=["main"]
        )
        resolved = self.resolver._resolve_extra_repository(debusine)
        self.assertEqual(
            resolved,
            ExtraExternalRepository(
                url="http://deb.example.com/debusine/System",
                suite="sid",
                components=["main"],
                signing_key=str(ExportablePGPKeyring(self.keys[0])),
            ),
        )

    def test_missing_components(self) -> None:
        del self.sid.data["components"]
        self.sid.save()

        debusine = ExtraDebusineRepository(
            suite="sid@debian:suite", components=None
        )
        with self.assertRaisesRegex(
            TaskConfigError,
            "'components' not set for sid@debian:suite",
        ):
            self.resolver._resolve_extra_repository(debusine)

    def test_default_components(self) -> None:
        self.sid.data["components"] = ["main", "contrib", "non-free"]
        self.sid.save()

        debusine = ExtraDebusineRepository(
            suite="sid@debian:suite", components=None
        )
        resolved = self.resolver._resolve_extra_repository(debusine)
        self.assertEqual(
            resolved,
            ExtraExternalRepository(
                url="http://deb.example.com/debusine/System",
                suite="sid",
                components=["main", "contrib", "non-free"],
                signing_key=str(ExportablePGPKeyring(self.keys[0])),
            ),
        )

    def test_explicit_components(self) -> None:
        self.sid.data["components"] = ["main", "contrib", "non-free"]
        self.sid.save()

        debusine = ExtraDebusineRepository(
            suite="sid@debian:suite", components=["main", "contrib"]
        )
        resolved = self.resolver._resolve_extra_repository(debusine)
        self.assertEqual(
            resolved,
            ExtraExternalRepository(
                url="http://deb.example.com/debusine/System",
                suite="sid",
                components=["main", "contrib"],
                signing_key=str(ExportablePGPKeyring(self.keys[0])),
            ),
        )


class ConcreteDataFieldInput(inputs.DataFieldInput[int, None]):
    """Concrete version of DataFieldInput."""

    @override
    def resolve(self, field_resolver: FieldResolver) -> None:
        raise NotImplementedError()


class GetFieldTests(TestCase):
    """Test :py:func:`get_field`."""

    def setUp(self) -> None:
        super().setUp()
        # Set up the mock task
        self.task = mock.MagicMock()
        self.task.data = SimpleNamespace()
        self.task.work_request = mock.MagicMock(spec=WorkRequest)
        self.resolver = DBFieldResolver(self.task)

    def test_get_field(self) -> None:
        self.task.data.value = 1
        self.task.data.unset = None
        self.task.data.nested = SimpleNamespace()
        self.task.data.nested.value = 2

        for field, expected in (
            ("value", 1),
            ("nested.value", 2),
            ("unset", None),
        ):
            with self.subTest(field=field):
                f = ConcreteDataFieldInput(field=field)
                self.assertEqual(self.resolver.get_field(f), expected)

    def test_get_field_fails(self) -> None:
        for field in ("value", "nested.value"):
            with (
                self.subTest(Field=field),
                self.assertRaisesRegex(
                    TaskConfigError,
                    re.escape(f"Task input attribute {field!r} not found"),
                ),
            ):
                f = ConcreteDataFieldInput(field=field)
                self.resolver.get_field(f)

    def test_get_field_invalid_type(self) -> None:
        f = ConcreteDataFieldInput(field="value")
        value: Any
        for value in ("foo", [], {}):
            with self.subTest(value=repr(value)):
                self.task.data.value = value
                with self.assertRaisesRegex(
                    TaskConfigError,
                    re.escape(
                        f"Task input attribute 'value' is {value!r}"
                        f" which is not of type <class 'int'>"
                    ),
                ):
                    self.resolver.get_field(f)
