"""Module for the generator of tests from Python packages."""

import logging
import collections

from asteex.core.context.source.strategy import Strategy
from asteex.core.context.source.static import StaticSource
from asteex.core.context.source.fixture import FixtureSource
from asteex.core.instance.static import StaticInstance
from asteex.core.generator.group.abstract import InstanceGenerationError
from asteex.core.generator.group.module import ModuleGenerator


class SourceError(Exception):
    """Exception raised on errors during retrieval of source information."""


class ResolutionError(Exception):
    """Exception raised on errors during resolution of dependencies."""


class RunPackageGenerator(ModuleGenerator):
    """Class for a generator that creates test instances from python modules."""

    def __init__(self, root, value_storage, fixture_storage, case_storage):
        super().__init__(root=root)
        self.__value_storage = value_storage
        self.__fixture_storage = fixture_storage
        self.__case_storage = case_storage
        self.__chained_sources = {}

    def create_group_instances_generator(self, group):
        # pylint: disable=line-too-long
        """Async generator producing test instances for a single module or package.

        :param group: string name of the Python module or package that determines the scope.
        :returns: async iterable with :class:`Instances <asteex.abstract.instance.Instance>`.
        :raises:
            :class:`InstanceGenerationError <asteex.core.generator.group.abstract.InstanceGenerationError>`
            if the module does not belong to the hierarchy or instances can't be produced.
        """
        # pylint: enable=line-too-long
        group = str(group)

        logger = logging.getLogger(__name__)
        logger.debug("Generating test instances for the module '%s'.", group)

        if (group != self.root) and (not group.startswith(f"{self.root}.")):
            logger.error("Module '%s' does not belong to the hierarchy.", group)
            raise InstanceGenerationError(f"Module '{group}' does not belong to the hierarchy")

        try:
            cases = tuple(self.__case_storage.get_cases(group=group))
        except Exception as error:
            logger.exception("Can't get test cases for the module '%s'.", group)
            raise InstanceGenerationError(
                f"Can't get test cases for the module '{group}'",
            ) from error

        async def _instances():
            for case in cases:
                try:
                    dependencies = set(str(name) for name in case.get_dependencies())
                except Exception as error:
                    logger.exception(
                        "Can't get dependencies of a test case for the module '%s'.",
                        group,
                    )
                    raise InstanceGenerationError(
                        f"Can't get dependencies of a test case for the module '{group}'",
                    ) from error

                try:
                    context_sources_iterator = self.resolve_dependencies(
                        group=group,
                        dependencies=dependencies,
                    )
                except Exception as error:
                    logger.exception(
                        "Can't create context sources for a test case for the module '%s'.",
                        group,
                    )
                    raise InstanceGenerationError(
                        f"Can't create context sources for a test case for the module '{group}'",
                    ) from error

                try:
                    for context_sources in context_sources_iterator:
                        try:
                            instance = self._create_instance(
                                group=group,
                                case=case,
                                context_sources=context_sources,
                            )
                        except Exception as error:
                            logger.exception(
                                "Can't create a test instance for the module '%s'.",
                                group,
                            )
                            raise InstanceGenerationError(
                                f"Can't create a test instance for the module '{group}'",
                            ) from error

                        yield instance
                except InstanceGenerationError:
                    raise
                except Exception as error:
                    logger.exception(
                        "Can't iterate over context sources for a test case for the module '%s'.",
                        group,
                    )
                    error_message = (
                        "Can't iterate over context sources "
                        f"for a test case for the module '{group}'"
                    )
                    raise InstanceGenerationError(error_message) from error

        logger.info("Generated test instances for the module '%s'.", group)

        return _instances()

    def resolve_dependencies(self, group, dependencies):
        """Generate sets of context sources to resolve the dependencies.

        :param group: string name of the Python module or package that determines the scope.
        :param dependencies: iterable with string names of dependencies to resolve.
        :returns: iterator with dictionaries. Each dictionary contains string names as keys and
            :class:`Source <asteex.abstract.context.Source>` as values.
        :raises: :class:`ResolutionError` if module does not belong to the hierarchy or
            dependencies can't get resolved.
        """
        group = str(group)

        logger = logging.getLogger(__name__)
        logger.debug("Try to resolve dependencies in the module '%s'", group)

        if (group != self.root) and (not group.startswith(f"{self.root}.")):
            logger.error("Module '%s' does not belong to the hierarchy.", group)
            raise ResolutionError(f"Module '{group}' does not belong to the hierarchy")

        try:
            unresolved_dependencies = set(str(name) for name in dependencies)
        except Exception as error:
            logger.exception("Can't iterate over dependencies.")
            raise ResolutionError("Can't iterate over dependencies") from error

        try:
            available_sources = self._get_available_sources(group)
        except Exception as error:
            logger.exception("Can't get available sources for the module '%s'.", group)
            raise ResolutionError(
                f"Can't get available sources for the module '{group}'",
            ) from error

        # We use deque instead of list to try to preserve the order of resolution.
        # That is: if we have 2 values (let's say "value1" and "value2")
        # for the dependency "something", we would like to start with test instances with
        # "value1", and then have test instances with "value2".
        tasks = collections.deque([(unresolved_dependencies, {})])

        while tasks:
            unresolved_dependencies, context_sources = tasks.popleft()

            if unresolved_dependencies:
                name = unresolved_dependencies.pop()

                try:
                    sources = available_sources[name]
                except Exception as error:
                    logger.exception(
                        "Can't resolve dependency '%s' in the module '%s'.",
                        name,
                        group,
                    )
                    raise ResolutionError(
                        f"Can't resolve dependency '{name}' in the module '{group}'"
                    ) from error

                try:
                    sources = tuple(sources)
                except (TypeError, ValueError) as error:
                    logger.exception(
                        "Can't iterate over sources for dependency '%s' in the module '%s'.",
                        name,
                        group,
                    )
                    raise ResolutionError(
                        (
                            f"Can't iterate over sources for dependency '{name}' "
                            f"in the module '{group}'"
                        ),
                    ) from error

                for source in sources:
                    task_context_sources = context_sources.copy()
                    task_context_sources[name] = source

                    try:
                        if source.get_strategy() == Strategy.EVALUATE_FIXTURE.value:
                            extra_dependencies = source.get_fixture().get_dependencies()
                        else:
                            extra_dependencies = ()
                    except Exception as error:
                        logger.exception(
                            (
                                "Can't get dependencies of a source for dependency '%s' "
                                "in the module '%s'."
                            ),
                            name,
                            group,
                        )
                        raise ResolutionError(
                            (
                                f"Can't get dependencies of a source for dependency '{name}' "
                                f"in the module '{group}'"
                            ),
                        ) from error

                    task_unresolved_dependencies = unresolved_dependencies.union(
                        extra_dependencies,
                    ).difference(
                        task_context_sources.keys(),
                    )

                    tasks.append((task_unresolved_dependencies, task_context_sources))
            else:
                yield context_sources

        logger.info("Resolved dependencies in the module '%s'", group)

    def _get_available_sources(self, group):
        """Get available sources for context values.

        :param group: string name of the Python module or package.
        :returns: dictionary with string keys and tuples with
            :class:`Sources <asteex.abstract.context.Source>` as values.
        :raises: :class:`SourceError` if information can't be retrieved.
        """
        group = str(group)

        if group not in self.__chained_sources:
            module_sources = {}

            try:
                for name, values in self.__value_storage.get_values(group=group).items():
                    module_sources[str(name)] = tuple(StaticSource(value=value) for value in values)
            except Exception as error:
                raise SourceError(
                    f"Can't get sources from values of the module '{group}'",
                ) from error

            try:
                module_fixtures = self.__fixture_storage.get_fixtures(group=group)
                for name, fixtures in module_fixtures.items():
                    fixture_sources = tuple(FixtureSource(fixture=fixture) for fixture in fixtures)
                    module_sources[str(name)] = module_sources.get(name, ()) + fixture_sources
            except Exception as error:
                raise SourceError(
                    f"Can't get sources from fixtures of the module '{group}'",
                ) from error

            if len(group) <= len(self.root):
                parent_module_name = None
            else:
                parent_module_name = group.rsplit(".", 1)[0]

            if parent_module_name is None:
                chained_sources = collections.ChainMap(module_sources)
            else:
                parent_sources = self._get_available_sources(parent_module_name)
                chained_sources = parent_sources.new_child(module_sources)

            self.__chained_sources[group] = chained_sources

        return self.__chained_sources[group]

    def _create_instance(self, group, case, context_sources):
        """Create a test instance.

        :param group: string name of the Python module or package.
        :param case: a test `Case <asteex.abstract.case.Case>` to build a test instance from.
        :param context_sources: dictionary with string names as keys and
            :class:`Source <asteex.abstract.context.Source>` as values.
        :raises: :class:`ValueError` if the case or context sources are invalid.
        """
        try:
            context_labels = {}
            for name, source in context_sources.items():
                strategy = source.get_strategy()
                if strategy == Strategy.GET_SOURCE_VALUE.value:
                    value = source.get_value()
                    # In contrast to other strategies, we can't guess the type of the value.
                    # Even though it is reasonable to expect that repr(value) or str(value) won't
                    # raise an error, we don't want to enforce this constraint. Otherwise, it would
                    # not be possible to use values for negative tests for __str__ and __repr__
                    # methods.
                    try:
                        value_representation = repr(value)
                    except Exception:   # pylint: disable=broad-except
                        value_representation = f"object_with_id({id(value)})"
                    label = f"from_value({value_representation})"
                elif strategy == Strategy.EVALUATE_FIXTURE.value:
                    fixture = source.get_fixture()
                    label = f"from_fixture({fixture})"
                else:
                    label = f"from_unknown_source({source})"

                context_labels[str(name)] = label
        except Exception as error:
            raise ValueError("Context sources are invalid") from error

        try:
            case_id = case.get_id()
        except Exception as error:
            raise ValueError("Can't get id from the test case") from error

        instance_id=f"{group}::{case_id}{context_labels}"

        return StaticInstance(instance_id=instance_id, case=case, context_sources=context_sources)
