"""Module for a asteex fixture based on callable objects."""

import contextlib
import logging

from asteex.core.fixture.abstract import Fixture, FixtureError
from asteex.core.decorator.argument import ContextMapper, CopyError


class BuildError(Exception):
    """Exception for errors raised on attempt to build a fixture."""


class ProducerFixture(Fixture):
    """Class for fixtures based on a producer callable."""

    def __init__(self, producer, fixture_id, dependencies=()):
        super().__init__()

        if not callable(producer):
            raise ValueError("Can't create a producer fixture from a non-callable object")
        self.__producer = producer

        self.__id = str(fixture_id)

        logger = logging.getLogger(__name__)
        logger.debug("Try to refine dependencies for a new producer fixture.")
        try:
            dependencies = tuple(str(dependency) for dependency in dependencies)
        except TypeError as error:
            logger.exception("Can't iterate over dependencies of a new producer fixture.")
            raise ValueError("Can't iterate over dependencies of a new producer fixture") from error
        self.__dependencies = dependencies
        logger.debug("Refined dependencies for a new producer fixture.")

    def get_id(self):
        """Get the unique identifier of the fixture.

        :returns: string id.
        """
        return self.__id

    def get_dependencies(self):
        """Get names of the required fixtures.

        :returns: iterable with string names of the required fixtures.
        """
        return self.__dependencies

    def evaluate(self, context, case):
        """Evaluate the fixture for the context and case.

        :param context: dictionary with values for the dependencies.
        :param case: test :class:`case <asteex.abstract.case.Case>`.
        :returns: async context manager for the fixture value.
            The manager may raise :class:`FixtureError` on entering and/or exiting.
        """
        logger = logging.getLogger(__name__)

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

            :returns: async context manager for the fixture value.
            :raises: propagates errors from the with-block. Also raises :class:`FixtureError` if
                the underlying producer or the produced context manager raise an exception.
            """
            logger.debug("Try to get value from the producer of the fixture %s.", self)
            try:
                context_manager = self.__producer(context=context, case=case)
            except Exception as error:
                logger.exception("Failed to create a context manager with the producer.")
                raise FixtureError(
                    "Failed to create a context manager with the producer",
                ) from error

            context_stack = contextlib.AsyncExitStack()
            try:
                fixture_value = await context_stack.enter_async_context(context_manager)
            except Exception as error:
                logger.exception("An error occurred on value setup.")
                raise FixtureError("An error occurred on value setup") from error

            deferred_error = None
            try:
                async with context_stack:
                    logger.info("Got value from the producer of the fixture %s.", self)
                    try:
                        yield fixture_value
                    except Exception as error:  # pylint: disable=broad-except
                        deferred_error = error
            except Exception as error:
                logger.exception("An error occurred on value teardown.")
                raise FixtureError("An error occurred on value teardown") from error

            if deferred_error is not None:
                raise deferred_error

        return create_value_manager()


class ProducerFixtureBuilder(ContextMapper):
    """Class to create producer fixtures from callables."""

    def __init__(self):
        super().__init__()
        self.__fixture_id = None
        self.__additional_dependencies = frozenset()
        self.__arguments_mapped_to_case = frozenset()
        self.__enhancers = ()

    def set_fixture_id(self, fixture_id):
        """Set identifier of new fixtures.

        :param fixture_id: string identifier or None.
            None value implies that fixture identifier has to be deduced from callables.
        :returns: self.
        """
        if fixture_id is not None:
            fixture_id = str(fixture_id)
        self.__fixture_id = fixture_id

        logger = logging.getLogger(__name__)
        logger.info("Set fixture identifier in %s.", self)

        return self

    def get_fixture_id(self):
        """Get identifier of new fixtures.

        :returns: string identifier or None.
        """
        return self.__fixture_id

    def add_dependencies(self, dependencies, overwrite=False):
        """Add additional dependencies.

        :param dependencies: iterable with string names of the dependencies.
        :param overwrite: boolean whether the new dependencies should overwrite the existing ones.
        :returns: self.
        :raises: :class:`ValueError` if dependencies is not a valid iterable.
        """
        logger = logging.getLogger(__name__)
        logger.debug("Try to set additional fixture dependencies in %s.", self)

        try:
            refined_fixture_dependencies = frozenset(str(dependency) for dependency in dependencies)
        except TypeError as error:
            logger.exception("Can't iterate over fixture dependencies.")
            raise ValueError("Can't iterate over fixture dependencies") from error

        if overwrite:
            self.__additional_dependencies = refined_fixture_dependencies
        else:
            self.__additional_dependencies = self.__additional_dependencies.union(
                refined_fixture_dependencies,
            )

        logger.info("Set additional fixture dependencies in %s.", self)

        return self

    def get_additional_dependencies(self):
        """Get addition dependencies.

        :returns: set with string names of the dependencies.
        """
        return self.__additional_dependencies

    def map_arguments_to_case(self, arguments):
        """Set arguments that should receive a case as the value.

        :param arguments: iterable with string names of the arguments that will receive
            a case as its value.
        :returns: self.
        :raises: :class:`ValueError` if arguments is not a valid iterable or if one of
            the arguments is already mapped.
        """
        logger = logging.getLogger(__name__)
        logger.debug("Try to map arguments to a case in %s.", self)

        try:
            refined_arguments = frozenset(str(argument) for argument in arguments)
        except TypeError as error:
            logger.exception("Can't iterate over case arguments.")
            raise ValueError("Can't iterate over case arguments") from error

        if len(arguments) > len(refined_arguments):
            logger.error("Some arguments are specified multiple times.")
            raise ValueError("Some arguments are specified multiple times")

        arguments_mapped_to_other_sources = self.get_mapped_arguments().difference(
            self.__arguments_mapped_to_case,
        )

        if arguments_mapped_to_other_sources.intersection(refined_arguments):
            logger.error("Some arguments are already mapped.")
            raise ValueError("Some arguments are already mapped")

        self.__arguments_mapped_to_case = refined_arguments

        logger.info("Mapped arguments to a case in %s.", self)

        return self

    def get_arguments_mapped_to_case(self):
        """Get arguments mapped to a case.

        :returns: set with string names of the arguments that will receive a case as the value.
        """
        return self.__arguments_mapped_to_case

    def add_enhancers(self, enhancers, overwrite=False):
        """Set enhancers for new fixtures.

        :param enhancers: iterable with callables that accepts two parameters. Each callable will
            receive a :class:`ProducerFixture` for the first parameter and a callable for
            the second parameter. The function must return :class:`ProducerFixture`.
        :param overwrite: boolean whether the new enhancers should overwrite the existing ones.
        :returns: self.
        :raises: :class:`ValueError` if enhancers is not a valid iterable.
        """
        logger = logging.getLogger(__name__)
        logger.debug("Try to set fixture enhancers in %s.", self)

        try:
            refined_fixture_enhancers = tuple(enhancers)
        except TypeError as error:
            logger.exception("Can't iterate over fixture enhancers.")
            raise ValueError("Can't iterate over fixture enhancers") from error

        if overwrite:
            self.__enhancers = refined_fixture_enhancers
        else:
            self.__enhancers = self.__enhancers + refined_fixture_enhancers

        logger.info("Set fixture enhancers in %s.", self)

        return self

    def get_enhancers(self):
        """Get enhancers for new fixtures.

        :returns: tuple with callables that accepts two parameters. Each callable accepts
            a :class:`ProducerFixture` for the first parameter and a callable
            for the second parameter. Each callable returns :class:`ProducerFixture`.
        """
        return self.__enhancers

    def build_from_callable(self, callable_):
        """Create a producer fixture from a callable.

        :param callable_: a callable to use as a base for the producer fixture.
        :returns: a new :class`ProducerFixture`
        :raises: :class:`BuildError` if an error occurs on attempt to create or enhance a fixture.
        """
        logger = logging.getLogger(__name__)
        logger.debug("Try to create a producer fixture from '%s'.", callable_)

        try:
            fixture = self._create_fixture(callable_)
        except Exception as error:
            logger.exception("Can't create a producer fixture.")
            raise BuildError("Can't create a producer fixture") from error

        for enhancer in self.get_enhancers():
            try:
                fixture = enhancer(fixture, callable_)
            except Exception as error:
                logger.exception("Can't enhance a producer fixture.")
                raise BuildError("Can't enhance a producer fixture") from error

        logger.info("Created a producer fixture from '%s'.", callable_)
        return fixture

    def get_mapped_arguments(self):
        """Get all mapped arguments.

        :returns: set with string names of the mapped arguments.
        """
        return super().get_mapped_arguments().union(self.get_arguments_mapped_to_case())

    def _copy_data(self, other):
        """Copy data from this instance to another.

        :param other: instance of the same type.
        :raises: :class:`CopyError <asteex.core.decorator.argument.CopyError>`
            if data can't be copied.
        """
        super()._copy_data(other)

        logger = logging.getLogger(__name__)

        try:
            other.set_fixture_id(self.get_fixture_id())
        except Exception as error:
            logger.exception("Can't copy fixture identifier to another instance.")
            raise CopyError("Can't copy fixture identifier to another instance") from error

        try:
            other.add_dependencies(self.get_additional_dependencies(), overwrite=True)
        except Exception as error:
            logger.exception("Can't copy fixture dependencies to another instance.")
            raise CopyError("Can't copy fixture dependencies to another instance") from error

        try:
            other.map_arguments_to_case(self.get_arguments_mapped_to_case())
        except Exception as error:
            logger.exception("Can't copy arguments mapped to a case to another instance.")
            raise CopyError("Can't copy arguments mapped to a case to another instance") from error

        try:
            other.add_enhancers(self.get_enhancers(), overwrite=True)
        except Exception as error:
            logger.exception("Can't copy fixture enhancers to another instance.")
            raise CopyError("Can't copy fixture enhancers to another instance") from error

    def _create_fixture_id(self, callable_):
        """Create an identifier for a producer fixture from a callable.

        :param callable_: callable for a producer for the fixture.
        :returns: string id.
        """
        fixture_id = self.get_fixture_id()
        if fixture_id is None:
            refined_fixture_id = str(callable_)
        else:
            refined_fixture_id = fixture_id

        return refined_fixture_id

    def _create_fixture_dependencies(self, callable_):
        """Create dependencies for a producer fixture from a callable.

        :param callable_: callable for a producer for the fixture.
        :returns: tuple with string names.
        """
        fixture_dependencies = set(self.get_additional_dependencies())

        positional_arguments, keyword_arguments = self.build_call_arguments(callable_)

        arguments_with_dependencies = set().union(
            positional_arguments,
        ).union(
            keyword_arguments,
        ).difference(
            self.get_arguments_mapped_to_context()
        ).difference(
            self.get_arguments_mapped_to_case()
        )

        dependency_map = self.get_arguments_mapped_to_context_names()

        fixture_dependencies.update(
            dependency_map.get(argument, argument) for argument in arguments_with_dependencies
        )

        return fixture_dependencies

    def _create_fixture_producer(self, callable_):
        """Create a producer for a producer fixture from a callable.

        :param callable_: callable for a producer for the fixture.
        :returns: callable, suitable for :class:`ProducerFixture`.
        """
        arguments_mapped_to_context_names = self.get_arguments_mapped_to_context_names().copy()
        arguments_mapped_to_context = set(self.get_arguments_mapped_to_context())
        arguments_mapped_to_case = set(self.get_arguments_mapped_to_case())

        positional_arguments, keyword_arguments = self.build_call_arguments(callable_)

        def _producer(context, case):
            def _get_producer_value(argument):
                if argument in arguments_mapped_to_context:
                    value = context
                elif argument in arguments_mapped_to_case:
                    value = case
                else:
                    mapped_argument = arguments_mapped_to_context_names.get(argument, argument)
                    value = context[mapped_argument]

                return value

            arg_values = [_get_producer_value(argument) for argument in positional_arguments]
            kwarg_values = {
                argument: _get_producer_value(argument)
                for argument in keyword_arguments
            }

            return callable_(*arg_values, **kwarg_values)

        return _producer

    def _create_fixture(self, callable_):
        """Create a producer fixture from a callable.

        :param callable_: callable for a producer for the fixture.
        :returns: :class:`ProducerFixture`.
        """
        return ProducerFixture(
            producer=self._create_fixture_producer(callable_),
            fixture_id=self._create_fixture_id(callable_),
            dependencies=self._create_fixture_dependencies(callable_),
        )
