"""Module with the executor for executable cases."""

import sys
import logging
import asyncio
import contextlib

from asteex.abstract.executor import Executor, ExecutionError
from asteex.core.context.source.strategy import Strategy
from asteex.core.result.status import Status
from asteex.core.result.static import StaticResult
from asteex.core.case.executable.abstract import (
    ExecutableCase,
    ExecutionError as CaseExecutionError,
)


class InstanceIterationError(ExecutionError):
    """Exception class for errors raised during iteration through test instances."""


class ContextError(ExecutionError):
    """Exception class for errors raised during life cycle of a context."""


class ExecutableExecutor(Executor):
    """Class for the executor for executable cases."""

    def execute(self, instances):
        """Execute test instances.

        :param instances:
            async iterable with :class:`Instances <asteex.abstract.instance.Instance>`.
        :returns: async iterable with :class:`Results <asteex.abstract.result.Result>`.
        :raises: :class:`ExecutionError <asteex.abstract.executor.ExecutionError>`.
        """
        try:
            return self.create_results_generator(instances)
        except Exception as error:
            logger = logging.getLogger(__name__)
            logger.exception("Failed to create a results generator.")
            raise ExecutionError("Failed to create a results generator") from error

    async def create_results_generator(self, instances):
        """Async generator producing results by executing test instances.

        :param instances:
            async iterable with :class:`Instances <asteex.abstract.instance.Instance>`.
        :returns: async iterable with :class:`Results <asteex.abstract.result.Result>`.
        :raises: :class:`InstanceIterationError` if an error occurs during iteration of instances.
        :raises: :class:`ExecutionError <asteex.abstract.executor.ExecutionError>` for other
            errors during execution.
        """
        logger = logging.getLogger(__name__)
        logger.debug("Start executing test instances with %s.", self)

        try:
            instances_iterator = aiter(instances)
        except TypeError as error:
            logger.exception("Failed to create an iterator for test instances.")
            raise InstanceIterationError(
                "Failed to create an iterator for test instances",
            ) from error

        scheduler_task = asyncio.create_task(anext(instances_iterator))

        tasks = {scheduler_task}

        try:
            while tasks:
                completed_tasks, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)

                for task in completed_tasks:
                    tasks.remove(task)

                    if task is scheduler_task:
                        try:
                            instance = task.result()
                        except StopAsyncIteration:
                            continue
                        except Exception as error:
                            logger.exception("Failed to iterate over test instances.")
                            raise InstanceIterationError(
                                "Failed to iterate over test instances",
                            ) from error

                        try:
                            execution_task = asyncio.create_task(self.execute_single(instance))
                        except Exception as error:
                            logger.exception(
                                "Failed to schedule execution for the instance %s.",
                                instance,
                            )
                            raise ExecutionError(
                                f"Failed to schedule execution for the instance {instance}",
                            ) from error

                        scheduler_task = asyncio.create_task(anext(instances_iterator))

                        tasks.update((execution_task, scheduler_task))
                    else:
                        try:
                            result = task.result()
                        except Exception as error:
                            logger.exception("Failed to execute an instance.")
                            raise ExecutionError("Failed to execute an instance") from error

                        yield result

            logger.info("Executed all test instances with %s.", self)
        finally:
            for task in tasks:
                task.cancel()

            await asyncio.gather(*tasks, return_exceptions=True)

    async def execute_single(self, instance):   # pylint: disable=too-many-return-statements
        """Execute a single test instance.

        :param instance: :class:`Instance <asteex.abstract.instance.Instance>`.
        :returns: :class:`StaticResult <asteex.core.result.static.StaticResult>`.
        """
        logger = logging.getLogger(__name__)
        logger.debug("Start executing test instance %s with %s.", instance, self)

        try:
            case = instance.get_case()
        except Exception:   # pylint: disable=broad-except
            logger.exception("Can't get a case from the instance %s.", instance)
            return StaticResult(
                instance=instance,
                status=Status.ERROR.value,
                reason="An error occurred on an attempt to get a case from the instance.",
                exception_information=sys.exc_info(),
            )

        if not isinstance(case, ExecutableCase):
            logger.debug("Executor %s does not support the case %s.", self, case)
            return StaticResult(
                instance=instance,
                status=Status.SKIPPED.value,
                reason="Case is not executable.",
                case=case,
            )

        try:
            context_sources = instance.get_context_sources()
        except Exception:   # pylint: disable=broad-except
            logger.exception("Can't get context sources from the instance %s.", instance)
            return StaticResult(
                instance=instance,
                status=Status.ERROR.value,
                reason="An error occurred on an attempt to get context sources from the instance.",
                case=case,
                exception_information=sys.exc_info(),
            )

        try:
            context_manager = self.create_context_manager(
                instance=instance,
                context_sources=context_sources,
                case=case,
            )
        except Exception:   # pylint: disable=broad-except
            logger.exception("Can't create a context manager from the instance %s.", instance)
            return StaticResult(
                instance=instance,
                status=Status.ERROR.value,
                reason="An error occurred on an attempt to create a context manager.",
                case=case,
                exception_information=sys.exc_info(),
            )

        context = None
        try:
            async with context_manager as context:
                try:
                    execution = case.execute(context)
                except Exception:   # pylint: disable=broad-except
                    logger.exception(
                        "Can't create an execution coroutine from the case of the instance %s.",
                        instance,
                    )
                    reason = "An error occurred on an attempt to create an execution coroutine."
                    return StaticResult(
                        instance=instance,
                        status=Status.ERROR.value,
                        reason=reason,
                        case=case,
                        context=context,
                        exception_information=sys.exc_info(),
                    )

                try:
                    await execution
                except CaseExecutionError:
                    status = Status.FAILED.value
                    reason = "Execution error was raised."
                    exception_information = sys.exc_info()
                except Exception:   # pylint: disable=broad-except
                    status = Status.ERROR.value
                    reason = "Unexpected error occurred during execution."
                    exception_information = sys.exc_info()
                else:
                    status = Status.PASSED.value
                    reason="Execution succeeded."
                    exception_information = (None, None, None)

                logger.info("Executor %s finished execution of test instance %s.", self, instance)
                return StaticResult(
                    instance=instance,
                    status=status,
                    reason=reason,
                    case=case,
                    context=context,
                    exception_information=exception_information,
                )
        except Exception:   # pylint: disable=broad-except
            logger.exception(
                "An error occurred during life cycle of the context of the instance %s.",
                instance,
            )
            return StaticResult(
                instance=instance,
                status=Status.ERROR.value,
                reason="An error occurred during life cycle of the context.",
                case=case,
                context=context,
                exception_information=sys.exc_info(),
            )

    def create_context_manager(self, instance, context_sources, case):
        """Create a context manager from the test instance.

        :param instance: :class:`Instance <asteex.abstract.instance.Instance>` from which
            context sources and case originate.
        :param context_sources: dictionary with string names as keys and
            :class:`Source <asteex.abstract.context.Source>` as values.
        :param case: :class:`Case <asteex.abstract.case.Case>` to execute.
        :returns: async context manager which returns a context on entering. The manager may raise
            :class:`ContextError` on entering and/or exiting.
        :raises: :class:`ContextError` if the context sources are invalid.
        """
        logger = logging.getLogger(__name__)
        logger.debug(
            "Try to create a context manager from test instance %s with %s.",
            instance,
            self,
        )

        try:
            context_sources_dict = dict(context_sources)
        except (TypeError, ValueError) as error:
            logger.exception("Context sources are not valid.")
            raise ContextError("Context sources are not valid") from error

        refined_context_sources = {
            str(name): source for name, source in context_sources_dict.items()
        }

        if len(context_sources) > len(refined_context_sources):
            logger.error("Several context sources use the same name.")
            raise ContextError("Several context sources use the same name")

        source_groups = _get_source_groups(context_sources=refined_context_sources)

        source_value_group = source_groups.pop(Strategy.GET_SOURCE_VALUE.value, {})
        fixture_group = source_groups.pop(Strategy.EVALUATE_FIXTURE.value, {})

        if source_groups:
            logger.error("There is a source with an unsupported strategy in the context.")
            raise ContextError("There is a source with an unsupported strategy in the context")

        partial_context = _build_context_from_values(context_sources=source_value_group)

        fixtures_context_manager = _build_context_manager_from_fixtures(
            context_sources=fixture_group,
            partial_context=partial_context,
            case=case,
        )

        @contextlib.asynccontextmanager
        async def _context_manager():
            """Async context manager for values of the context.

            :returns: async context manager for the context.
            :raises: propagates errors from the with-block. Also raises :class:`ContextError`
                if an exception is raised by fixtures.
            """
            context = partial_context.copy()

            async with fixtures_context_manager as partial_fixture_context:
                context.update(partial_fixture_context)
                yield context

        context_manager = _context_manager()
        logger.info("Created a context manager from test instance %s with %s.", instance, self)

        return context_manager


def _get_source_groups(context_sources):
    """Group context sources by strategies.

    :param context_sources: dictionary with string names as keys and
        :class:`Source <asteex.abstract.context.Source>` as values.
    :returns: dictionary mapping a strategy to the group with the sources with compatible
        strategies. Each group is a dictionary that maps string names to
        a :class:`Source <asteex.abstract.context.Source>`.
    :raises: :class:`ContextError` if the strategy of a source can't be retrieved.
    """
    logger = logging.getLogger(__name__)
    logger.debug("Try to get source groups from %s.", context_sources)

    groups = {}
    for name, source in context_sources.items():
        try:
            strategy = source.get_strategy()
        except Exception as error:
            logger.exception(
                "Can't get strategy of the source for the name '%s' in the context.",
                name,
            )
            raise ContextError(
                f"Can't get strategy of the source for the name '{name}' in the context",
            ) from error

        groups.setdefault(strategy, {})[name] = source

    logger.debug("Got source groups from %s.", context_sources)
    return groups


def _build_context_from_values(context_sources):
    """Collect values from sources and build the partial context from them.

    :param context_sources: dictionary with string names as keys and
        :class:`Source <asteex.abstract.context.Source>` as values.
    :returns: dictionary with string names of the context sources as keys and source values.
    :raises: :class:`ContextError` if an error occurs on an attempt to get values from
        the sources.
    """
    logger = logging.getLogger(__name__)
    logger.debug("Try to build a context from values in %s.", context_sources)

    partial_context = {}
    for name, source in context_sources.items():
        try:
            partial_context[name] = source.get_value()
        except Exception as error:
            logger.exception(
                "Can't get value from the source for the name '%s' in the context.",
                name,
            )
            raise ContextError(
                f"Can't get value from the source for the name '{name}' in the context",
            ) from error

    logger.debug("Built a context from values in %s.", context_sources)
    return partial_context


def _build_context_manager_from_fixtures(context_sources, partial_context, case):
    """Create a context manager from sources with fixtures.

    :param context_sources: dictionary with string names as keys and
        :class:`Source <asteex.abstract.context.Source>` as values.
    :param partial_context: dictionary with string names as keys and arbitrary values.
        The context must contain all dependencies of the fixtures.
    :param case: :class:`Case <asteex.abstract.case.Case>` to execute.
    :returns: async context manager which returns a context on entering. The manager raises
        :class:`ContextError` on entering and/or exiting.
    :raises: :class:`ContextError` if an error occurs on an attempt to arrange the evaluation
        of the fixtures.
    """
    logger = logging.getLogger(__name__)
    logger.debug("Try to build a context manager for fixtures in %s.", context_sources)

    fixtures = {}
    for name, source in context_sources.items():
        try:
            fixtures[name] = source.get_fixture()
        except Exception as error:
            logger.exception(
                "Can't get fixture from the source for the name '%s' in the context.",
                name,
            )
            raise ContextError(
                f"Can't get fixture from the source for the name '{name}' in the context",
            ) from error

    fixtures_evaluation_order = _arrange_fixture_evaluation(
        fixtures=fixtures,
        available_dependencies=partial_context.keys(),
    )

    @contextlib.asynccontextmanager
    async def _context_manager():
        """Async context manager for values of the context.

        :returns: async context manager for the context.
        :raises: propagates errors from the with-block. Also raises :class:`ContextError`
            if an exception is raised by fixtures.
        """
        context = partial_context.copy()

        deferred_error = None
        try:
            async with contextlib.AsyncExitStack() as fixture_stack:
                for name in fixtures_evaluation_order:
                    fixture = fixtures[name]

                    logger.debug(
                        "Try to evaluate the fixture %s for the name '%s'.",
                        fixture,
                        name,
                    )

                    value_context_manager = fixture.evaluate(context, case)
                    value = await fixture_stack.enter_async_context(value_context_manager)
                    logger.debug("Evaluated the fixture %s for the name '%s'.", fixture, name)

                    context[name] = value

                try:
                    yield context
                except Exception as error:  # pylint: disable=broad-except
                    deferred_error = error
        except Exception as error:
            logger.exception("An error occurred during life cycle of a fixture.")
            raise ContextError("An error occurred during life cycle of a fixture") from error

        if deferred_error is not None:
            raise deferred_error

    context_manager = _context_manager()
    logger.debug("Built a context manager for fixtures in %s.", context_sources)

    return context_manager


def _arrange_fixture_evaluation(fixtures, available_dependencies):
    """Collect information for the correct evaluation of the fixtures.

    :params fixtures: dictionary with string keys
        and :class:`Fixtures <asteex.core.fixture.abstract.Fixture>` as values.
    :params available_dependencies: iterable with string names of the external dependencies that
        the fixtures can rely on.
    :returns: a list of all keys of the dictionary in an order that guarantees availability of
        values for the dependencies during evaluation.
    :raises: :class:`ContextError` if there is an error caused by the dependencies of the fixtures.
    """
    logger = logging.getLogger(__name__)
    logger.debug("Try to create evaluation order of fixtures '%s'.", fixtures)

    available_dependencies = set(available_dependencies)
    resolvable_dependencies = set(fixtures.keys())

    dependencies = {}
    for name, fixture in fixtures.items():
        try:
            dependency_set = set(str(dependency) for dependency in fixture.get_dependencies())
        except Exception as error:
            logger.exception("Can't iterate over dependencies of the fixture %s.", fixture)
            raise ContextError(
                f"Can't iterate over dependencies of the fixture {fixture}",
            ) from error

        dependency_set.difference_update(available_dependencies)

        if not dependency_set.issubset(resolvable_dependencies):
            logger.error("Can't resolve dependencies of the fixture %s.", fixture)
            raise ContextError(f"Can't resolve dependencies of the fixture {fixture}")

        dependencies[name] = dependency_set

    evaluation_order = []

    while dependencies:
        for name, dependency_set in dependencies.items():
            dependency_set.difference_update(evaluation_order)

            if not dependency_set:
                break
        else:
            logger.error("There is a circular dependency among fixtures.")
            raise ContextError("There is a circular dependency among fixtures")

        dependencies.pop(name)
        evaluation_order.append(name)

    logger.debug("Created evaluation order of fixtures '%s'.", fixtures)
    return evaluation_order
