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

import re
from contextlib import ExitStack
from typing import Any, Self, override
from unittest import mock

from debusine.tasks import TaskConfigError
from debusine.tasks.inputs import inputs
from debusine.tasks.inputs.mock_resolver import ConcreteFieldResolver
from debusine.tasks.inputs.resolver import FieldResolver
from debusine.test import TestCase


class ConcreteDataListFieldInput(inputs.DataListFieldInput[int, None]):
    """Concrete version of DataListFieldInput."""

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


class FieldResolverTests(TestCase):
    """Test :py:class:`FieldResolver`."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.resolver = ConcreteFieldResolver()
        self.get_field = self.enterContext(
            mock.patch.object(self.resolver, "get_field")
        )

    def test_get_field_collection(self) -> None:
        field = ConcreteDataListFieldInput(field="name")
        for value in ([], [1], (2, 3), {3, 4}):
            with self.subTest(value=repr(value)):
                self.get_field.return_value = value
                self.assertEqual(
                    self.resolver.get_field_collection(field), value
                )

    def test_get_field_collection_none(self) -> None:
        field = ConcreteDataListFieldInput(field="name")
        self.get_field.return_value = None
        self.assertEqual(self.resolver.get_field_collection(field), [])

    def test_get_field_invalid_item_type(self) -> None:
        field = ConcreteDataListFieldInput(field="name")
        value: Any
        for value in ([None], ["foo"], [[], 1]):
            with self.subTest(value=repr(value)):
                self.get_field.return_value = value
                with self.assertRaisesRegex(
                    TaskConfigError,
                    re.escape(
                        f"Task data field name[0] is {value[0]!r}"
                        f" which is not of type <class 'int'>"
                    ),
                ):
                    self.resolver.get_field_collection(field)


class MockResolve(ExitStack):
    """Mock TaskInput.resolve_to_task to track invocations."""

    def __init__(self) -> None:
        """Initialize an empty callee list."""
        super().__init__()
        self.called: list[str] = []

    def __call__(
        self,
        inp: inputs.SingleInput,
        resolver: FieldResolver,  # noqa: ARG002
    ) -> None:
        """Replace TaskInput.resolve_to_task."""
        self.called.append(inp.field)

    @override
    def __enter__(self) -> Self:
        """Install mock resolution for SingleInput."""
        super().__enter__()
        self.enter_context(
            mock.patch(
                "debusine.tasks.inputs.SingleInput.resolve",
                side_effect=self,
                autospec=True,
            )
        )
        return self


class ResolveInputsTest(TestCase):
    """Test :py:func:`resolve_inputs`."""

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

        class _Container(inputs.TaskFieldContainer):
            all = inputs.SingleInput(field="all")
            config = inputs.SingleInput(
                field="config", stage=inputs.Stage.PENDING
            )

        self.task = _Container()
        self.resolver = ConcreteFieldResolver()

    def test_resolve_inputs_default(self) -> None:
        """Calling without stage runs all tasks."""
        with MockResolve() as resolve_to_task:
            self.resolver.resolve_inputs(self.task)
        self.assertEqual(resolve_to_task.called, ["all", "config"])

    def test_resolve_inputs_partial_stage(self) -> None:
        """Calling with a stage runs only on the tasks of that stage."""
        with MockResolve() as resolve_to_task:
            self.resolver.resolve_inputs(self.task, stage=inputs.Stage.PENDING)
        self.assertEqual(resolve_to_task.called, ["config"])

    def test_resolve_inputs_running(self) -> None:
        """Calling with stage RUNNING runs all tasks."""
        with MockResolve() as resolve_to_task:
            self.resolver.resolve_inputs(self.task, stage=inputs.Stage.RUNNING)
        self.assertEqual(resolve_to_task.called, ["all", "config"])

    def test_resolve_inputs_multiple_times(self) -> None:
        with MockResolve() as resolve_to_task:
            self.resolver.resolve_inputs(self.task, stage=inputs.Stage.PENDING)
        self.assertEqual(resolve_to_task.called, ["config"])

        # Previously resolved inputs are re-resolved
        with MockResolve() as resolve_to_task:
            self.resolver.resolve_inputs(self.task, stage=inputs.Stage.RUNNING)
        self.assertEqual(resolve_to_task.called, ["all", "config"])
