"""Module with helpers for decorators for callables with arbitrary signatures."""

import inspect
import logging


class CopyError(Exception):
    """Exception for errors raised on attempt to copy helpers."""


class ArgumentAdapter:
    """Base class for helpers that have to remap arguments to invoke callables."""

    def __init__(self):
        self.__positional_arguments = None
        self.__keyword_arguments = None

    def set_positional_arguments(self, arguments):
        """Set names of positional arguments for a call.

        :param arguments: iterable with string names of the positional arguments or None.
            None value implies that arguments have to be deduced from callables.
        :returns: self.
        :raises: :class:`ValueError` if the list of arguments is not a valid iterable.
        """
        logger = logging.getLogger(__name__)
        logger.debug("Try to set positional arguments in %s.", self)

        if arguments is None:
            refined_positional_arguments = None
        else:
            try:
                refined_positional_arguments = tuple(str(argument) for argument in arguments)
            except TypeError as error:
                logger.exception("Can't iterate over positional arguments.")
                raise ValueError("Can't iterate over positional arguments") from error

        self.__positional_arguments = refined_positional_arguments

        logger.info("Set positional arguments in %s.", self)

        return self

    def get_positional_arguments(self):
        """Get names of positional arguments for a call.

        :returns: tuple with string names of the positional arguments or None.
        """
        return self.__positional_arguments

    def set_keyword_arguments(self, arguments):
        """Set names of keyword arguments for a call.

        :param arguments: iterable with string names of the keyword arguments or None.
            None value implies that arguments have to be deduced from callables.
        :returns: self.
        :raises: :class:`ValueError` if the list of arguments is not a valid iterable or
            if several arguments have the same name.
        """
        logger = logging.getLogger(__name__)
        logger.debug("Try to set keyword arguments in %s.", self)

        if arguments is None:
            refined_keyword_arguments = None
        else:
            try:
                refined_keyword_arguments = frozenset(str(argument) for argument in arguments)
            except TypeError as error:
                logger.exception("Can't iterate over keyword arguments.")
                raise ValueError("Can't iterate over keyword arguments") from error

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

        self.__keyword_arguments = refined_keyword_arguments

        logger.info("Set keyword arguments in %s.", self)

        return self

    def get_keyword_arguments(self):
        """Get names of keyword arguments for a call.

        :returns: set with string names of the keyword arguments or None.
        """
        return self.__keyword_arguments

    def build_call_arguments(self, target):
        """Build arguments to call the target.

        :param target: a callable to invoke.
        :returns: a pair with (positional_arguments, keyword_arguments). First value is a tuple
            with string names for positional arguments. The second is a set with string names
            for keyword arguments.
        :raises: :class:`ValueError` on a failure to get a signature of the target.
        """
        logger = logging.getLogger(__name__)
        logger.debug("Try to build arguments for %s with %s.", target, self)

        positional_arguments = self.get_positional_arguments()
        keyword_arguments = self.get_keyword_arguments()

        if positional_arguments is None or keyword_arguments is None:
            try:
                signature = inspect.signature(target)
            except Exception as error:
                logger.exception("Can't get a signature of the target.")
                raise ValueError("Can't get a signature of the target") from error

            signature_positional_arguments = []
            signature_keyword_arguments = []

            for name, parameter in signature.parameters.items():
                if parameter.kind == parameter.POSITIONAL_ONLY:
                    signature_positional_arguments.append(name)
                elif parameter.kind in (parameter.POSITIONAL_OR_KEYWORD, parameter.KEYWORD_ONLY):
                    signature_keyword_arguments.append(name)

            if positional_arguments is None:
                positional_arguments = signature_positional_arguments

            if keyword_arguments is None:
                keyword_arguments = signature_keyword_arguments

        logger.info("Build arguments for %s with %s.", target, self)

        return tuple(positional_arguments), frozenset(keyword_arguments)

    def copy(self):
        """Create a copy of the instance.

        :returns: a new instance of the same class.
        :raises: :class:`CopyError` if a new instance can't be created or data can't be copied.
        """
        logger = logging.getLogger(__name__)
        logger.debug("Try to create a copy of %s.", self)

        try:
            instance = self.__class__()
        except Exception as error:
            logger.exception("Can't create a new instance.")
            raise CopyError("Can't create a new instance") from error

        try:
            self._copy_data(instance)
        except CopyError:
            raise
        except Exception as error:
            logger.exception("Can't copy data to the new instance.")
            raise CopyError("Can't copy data to the new instance") from error

        logger.info("Created a copy of %s.", self)

        return instance

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

        :param other: instance of the same type.
        :raises: :class:`CopyError` if data can't be copied.
        """
        logger = logging.getLogger(__name__)

        try:
            other.set_positional_arguments(self.get_positional_arguments())
        except Exception as error:
            logger.exception("Can't copy positional arguments to another instance.")
            raise CopyError("Can't copy positional arguments to another instance") from error

        try:
            other.set_keyword_arguments(self.get_keyword_arguments())
        except Exception as error:
            logger.exception("Can't copy keyword arguments to another instance.")
            raise CopyError("Can't copy keyword arguments to another instance") from error


class ContextMapper(ArgumentAdapter):
    """Class to remap arguments to a asteex context or its components."""

    def __init__(self):
        super().__init__()
        self.__arguments_mapped_to_context_names = {}
        self.__arguments_mapped_to_context = frozenset()

    def map_arguments_to_context_names(self, spec):
        """Map argument names for a call to names in a context.

        :param spec: map with string names of arguments as keys and string names
            from a context as values.
        :returns: self.
        :raises: :class:`ValueError` if the map is not a valid dictionary or if one of the arguments
            is already mapped to something else.
        """
        logger = logging.getLogger(__name__)
        logger.debug("Try to map arguments to context names in %s.", self)

        try:
            spec_dict = dict(spec)
        except (TypeError, ValueError) as error:
            logger.exception("Map with arguments and context names is not a valid dictionary.")
            raise ValueError(
                "Map with arguments and context names is not a valid dictionary",
            ) from error

        refined_spec = {str(argument): str(name) for argument, name in spec_dict.items()}

        if len(spec) > len(refined_spec):
            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_context_names.keys(),
        )

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

        self.__arguments_mapped_to_context_names = refined_spec

        logger.info("Mapped arguments to context names in %s.", self)

        return self

    def get_arguments_mapped_to_context_names(self):
        """Get the map between arguments and context names.

        :returns: map with string names of arguments as keys and string names from a context
            as values.
        """
        return self.__arguments_mapped_to_context_names.copy()

    def map_arguments_to_context(self, arguments):
        """Set arguments that should receive an entire context as the value.

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

        try:
            refined_arguments = frozenset(str(argument) for argument in arguments)
        except TypeError as error:
            logger.exception("Can't iterate over context arguments.")
            raise ValueError("Can't iterate over context 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_context,
        )

        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_context = refined_arguments

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

        return self

    def get_arguments_mapped_to_context(self):
        """Get arguments mapped to a context.

        :returns: set with string names of arguments that will receive
            an entire context as its value.
        """
        return self.__arguments_mapped_to_context

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

        :returns: set with string names of the mapped arguments.
        """
        return self.__arguments_mapped_to_context.union(
            self.__arguments_mapped_to_context_names.keys(),
        )

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

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

        logger = logging.getLogger(__name__)

        try:
            other.map_arguments_to_context_names(self.get_arguments_mapped_to_context_names())
        except Exception as error:
            logger.exception("Can't copy arguments mapped to context names to another instance.")
            raise CopyError(
                "Can't copy arguments mapped to context names to another instance",
            ) from error

        try:
            other.map_arguments_to_context(self.get_arguments_mapped_to_context())
        except Exception as error:
            logger.exception("Can't copy arguments mapped to a context to another instance.")
            raise CopyError(
                "Can't copy arguments mapped to a context to another instance",
            ) from error
