import functools
import importlib
import sys
import unittest

from ddtrace.vendor import wrapt
from tests.subprocesstest import SubprocessTestCase
from tests.subprocesstest import run_in_subprocess


class PatchMixin(unittest.TestCase):
    """
    TestCase for testing the patch logic of an integration.
    """

    def module_imported(self, modname):
        """
        Returns whether a module is imported or not.
        """
        return modname in sys.modules

    def assert_module_imported(self, modname):
        """
        Asserts that the module, given its name is imported.
        """
        assert self.module_imported(modname), "{} module not imported".format(modname)

    def assert_not_module_imported(self, modname):
        """
        Asserts that the module, given its name is not imported.
        """
        assert not self.module_imported(modname), "{} module is imported".format(modname)

    def is_wrapped(self, obj):
        return isinstance(obj, wrapt.ObjectProxy)

    def assert_wrapped(self, obj):
        """
        Helper to assert that a given object is properly wrapped by wrapt.
        """
        self.assertTrue(self.is_wrapped(obj), "{} is not wrapped".format(obj))

    def assert_not_wrapped(self, obj):
        """
        Helper to assert that a given object is not wrapped by wrapt.
        """
        self.assertFalse(self.is_wrapped(obj), "{} is wrapped".format(obj))

    def assert_not_double_wrapped(self, obj):
        """
        Helper to assert that a given already wrapped object is not wrapped twice.

        This is useful for asserting idempotence.
        """
        self.assert_wrapped(obj)
        self.assert_not_wrapped(obj.__wrapped__)


def raise_if_no_attrs(f):
    """
    A helper for PatchTestCase test methods that will check if there are any
    modules to use else raise a NotImplementedError.

    :param f: method to wrap with a check
    """
    required_attrs = [
        "__module_name__",
        "__integration_name__",
        "__patch_func__",
    ]

    @functools.wraps(f)
    def checked_method(self, *args, **kwargs):
        for attr in required_attrs:
            if not getattr(self, attr):
                raise NotImplementedError(f.__doc__)
        return f(self, *args, **kwargs)

    return checked_method


def noop_if_no_unpatch(f):
    """
    A helper for PatchTestCase test methods that will no-op the test if the
    __unpatch_func__ attribute is None
    """

    @functools.wraps(f)
    def wrapper(self, *args, **kwargs):
        if getattr(self, "__unpatch_func__") is None:
            self.skipTest(reason="No unpatch function given")
        return f(self, *args, **kwargs)

    return wrapper


class PatchTestCase(object):
    """
    unittest or other test runners will pick up the base test case as a testcase
    since it inherits from unittest.TestCase unless we wrap it with this empty
    parent class.
    """

    @run_in_subprocess
    class Base(SubprocessTestCase, PatchMixin):
        """Provides default test methods to be used for testing common integration patching logic.
        Each test method provides a default implementation which will use the
        provided attributes (described below). If the attributes are not
        provided a NotImplementedError will be raised for each method that is
        not overridden.

        Attributes:
            __integration_name__ the name of the integration.
            __module_name__ module which the integration patches.
            __patch_func__ patch function from the integration.
            __unpatch_func__ unpatch function from the integration.

        Example:
        A simple implementation inheriting this TestCase looks like::

            from ddtrace.contrib.redis import patch, unpatch

            class RedisPatchTestCase(PatchTestCase.Base):
                __integration_name__ = 'redis'
                __module_name__ 'redis'
                __patch_func__ = patch
                __unpatch_func__ = unpatch

                def assert_module_patched(self, redis):
                    # assert patching logic
                    # self.assert_wrapped(...)

                def assert_not_module_patched(self, redis):
                    # assert patching logic
                    # self.assert_not_wrapped(...)

                def assert_not_module_double_patched(self, redis):
                    # assert patching logic
                    # self.assert_not_double_wrapped(...)

                # override this particular test case
                def test_patch_import(self):
                    # custom patch before import check

                # optionally override other test methods...
        """

        __integration_name__ = None
        __module_name__ = None
        __patch_func__ = None
        __unpatch_func__ = None

        def __init__(self, *args, **kwargs):
            # DEV: Python will wrap a function when assigning it to a class as an
            # attribute. So we cannot call self.__unpatch_func__() because the `self`
            # reference will be passed as an argument.
            # So we need to unwrap the function and then wrap it in a function
            # that will absorb the unpatch function.
            if self.__unpatch_func__:
                unpatch_func = self.__unpatch_func__.__func__

                def unpatch():
                    unpatch_func()

                self.__unpatch_func__ = unpatch

            # Same for __patch_func__()
            if self.__patch_func__:
                patch_func = self.__patch_func__.__func__

                def patch():
                    patch_func()

                self.__patch_func__ = patch
            super(PatchTestCase.Base, self).__init__(*args, **kwargs)

        def _gen_test_attrs(self, ops):
            """
            A helper to return test names for tests given a list of different
            operations.
            :return:
            """
            from itertools import permutations

            return ["test_{}".format("_".join(c)) for c in permutations(ops, len(ops))]

        def test_verify_test_coverage(self):
            """
            This TestCase should cover a variety of combinations of importing,
            patching and unpatching.
            """
            tests = []
            tests += self._gen_test_attrs(["import", "patch"])
            tests += self._gen_test_attrs(["import", "patch", "patch"])
            tests += self._gen_test_attrs(["import", "patch", "unpatch"])
            tests += self._gen_test_attrs(["import", "patch", "unpatch", "unpatch"])

            # TODO: it may be possible to generate test cases dynamically. For
            # now focus on the important ones.
            test_ignore = set(
                [
                    "test_unpatch_import_patch",
                    "test_import_unpatch_patch_unpatch",
                    "test_import_unpatch_unpatch_patch",
                    "test_patch_import_unpatch_unpatch",
                    "test_unpatch_import_patch_unpatch",
                    "test_unpatch_import_unpatch_patch",
                    "test_unpatch_patch_import_unpatch",
                    "test_unpatch_patch_unpatch_import",
                    "test_unpatch_unpatch_import_patch",
                    "test_unpatch_unpatch_patch_import",
                ]
            )

            for test_attr in tests:
                if test_attr in test_ignore:
                    continue
                assert hasattr(self, test_attr), "{} not found in expected test attrs".format(test_attr)

        def assert_module_patched(self, module):
            """
            Asserts that the given module is patched.

            For example, the redis integration patches the following methods:
                - redis.StrictRedis.execute_command
                - redis.StrictRedis.pipeline
                - redis.Redis.pipeline
                - redis.client.BasePipeline.execute
                - redis.client.BasePipeline.immediate_execute_command

            So an appropriate assert_module_patched would look like::

                def assert_module_patched(self, redis):
                    self.assert_wrapped(redis.StrictRedis.execute_command)
                    self.assert_wrapped(redis.StrictRedis.pipeline)
                    self.assert_wrapped(redis.Redis.pipeline)
                    self.assert_wrapped(redis.client.BasePipeline.execute)
                    self.assert_wrapped(redis.client.BasePipeline.immediate_execute_command)

            :param module: module to check
            :return: None
            """
            raise NotImplementedError(self.assert_module_patched.__doc__)

        def assert_not_module_patched(self, module):
            """
            Asserts that the given module is not patched.

            For example, the redis integration patches the following methods:
                - redis.StrictRedis.execute_command
                - redis.StrictRedis.pipeline
                - redis.Redis.pipeline
                - redis.client.BasePipeline.execute
                - redis.client.BasePipeline.immediate_execute_command

            So an appropriate assert_not_module_patched would look like::

                def assert_not_module_patched(self, redis):
                    self.assert_not_wrapped(redis.StrictRedis.execute_command)
                    self.assert_not_wrapped(redis.StrictRedis.pipeline)
                    self.assert_not_wrapped(redis.Redis.pipeline)
                    self.assert_not_wrapped(redis.client.BasePipeline.execute)
                    self.assert_not_wrapped(redis.client.BasePipeline.immediate_execute_command)

            :param module:
            :return: None
            """
            raise NotImplementedError(self.assert_not_module_patched.__doc__)

        def assert_not_module_double_patched(self, module):
            """
            Asserts that the given module is not patched twice.

            For example, the redis integration patches the following methods:
                - redis.StrictRedis.execute_command
                - redis.StrictRedis.pipeline
                - redis.Redis.pipeline
                - redis.client.BasePipeline.execute
                - redis.client.BasePipeline.immediate_execute_command

            So an appropriate assert_not_module_double_patched would look like::

                def assert_not_module_double_patched(self, redis):
                    self.assert_not_double_wrapped(redis.StrictRedis.execute_command)
                    self.assert_not_double_wrapped(redis.StrictRedis.pipeline)
                    self.assert_not_double_wrapped(redis.Redis.pipeline)
                    self.assert_not_double_wrapped(redis.client.BasePipeline.execute)
                    self.assert_not_double_wrapped(redis.client.BasePipeline.immediate_execute_command)

            :param module: module to check
            :return: None
            """
            raise NotImplementedError(self.assert_not_module_double_patched.__doc__)

        @raise_if_no_attrs
        def test_import_patch(self):
            """
            The integration should test that each class, method or function that
            is to be patched is in fact done so when ddtrace.patch() is called
            before the module is imported.

            For example:

            an appropriate ``test_patch_import`` would be::

                import redis
                ddtrace.patch(redis=True)
                self.assert_module_patched(redis)
            """
            module = importlib.import_module(self.__module_name__)
            self.assert_not_module_patched(module)
            self.__patch_func__()
            self.assert_module_patched(module)

        @raise_if_no_attrs
        def test_patch_import(self):
            """
            The integration should test that each class, method or function that
            is to be patched is in fact done so when ddtrace.patch() is called
            after the module is imported.

            an appropriate ``test_patch_import`` would be::

                import redis
                ddtrace.patch(redis=True)
                self.assert_module_patched(redis)
            """
            module = importlib.import_module(self.__module_name__)
            self.__patch_func__()
            self.assert_module_patched(module)

        @raise_if_no_attrs
        def test_import_patch_patch(self):
            """
            Proper testing should be done to ensure that multiple calls to the
            integration.patch() method are idempotent. That is, that the
            integration does not patch its library more than once.

            An example for what this might look like for the redis integration::

                import redis
                ddtrace.patch(redis=True)
                self.assert_module_patched(redis)
                ddtrace.patch(redis=True)
                self.assert_not_module_double_patched(redis)
            """
            self.__patch_func__()
            module = importlib.import_module(self.__module_name__)
            self.assert_module_patched(module)
            self.__patch_func__()
            self.assert_not_module_double_patched(module)

        @raise_if_no_attrs
        def test_patch_import_patch(self):
            """
            Proper testing should be done to ensure that multiple calls to the
            integration.patch() method are idempotent. That is, that the
            integration does not patch its library more than once.

            An example for what this might look like for the redis integration::

                ddtrace.patch(redis=True)
                import redis
                self.assert_module_patched(redis)
                ddtrace.patch(redis=True)
                self.assert_not_module_double_patched(redis)
            """
            self.__patch_func__()
            module = importlib.import_module(self.__module_name__)
            self.assert_module_patched(module)
            self.__patch_func__()
            self.assert_not_module_double_patched(module)

        @raise_if_no_attrs
        def test_patch_patch_import(self):
            """
            Proper testing should be done to ensure that multiple calls to the
            integration.patch() method are idempotent. That is, that the
            integration does not patch its library more than once.

            An example for what this might look like for the redis integration::

                ddtrace.patch(redis=True)
                ddtrace.patch(redis=True)
                import redis
                self.assert_not_double_wrapped(redis.StrictRedis.execute_command)
            """
            self.__patch_func__()
            self.__patch_func__()
            module = importlib.import_module(self.__module_name__)
            self.assert_module_patched(module)
            self.assert_not_module_double_patched(module)

        @noop_if_no_unpatch
        @raise_if_no_attrs
        def test_import_patch_unpatch_patch(self):
            """
            To ensure that we can thoroughly test the installation/patching of
            an integration we must be able to unpatch it and then subsequently
            patch it again.

            For example::

                import redis
                from ddtrace.contrib.redis import unpatch

                ddtrace.patch(redis=True)
                unpatch()
                ddtrace.patch(redis=True)
                self.assert_module_patched(redis)
            """
            module = importlib.import_module(self.__module_name__)
            self.__patch_func__()
            self.assert_module_patched(module)
            self.__unpatch_func__()
            self.assert_not_module_patched(module)
            self.__patch_func__()
            self.__patch_func__()
            self.assert_module_patched(module)

        @noop_if_no_unpatch
        @raise_if_no_attrs
        def test_patch_import_unpatch_patch(self):
            """
            To ensure that we can thoroughly test the installation/patching of
            an integration we must be able to unpatch it and then subsequently
            patch it again.

            For example::

                from ddtrace.contrib.redis import unpatch

                ddtrace.patch(redis=True)
                import redis
                unpatch()
                ddtrace.patch(redis=True)
                self.assert_module_patched(redis)
            """
            self.__patch_func__()
            module = importlib.import_module(self.__module_name__)
            self.assert_module_patched(module)
            self.__unpatch_func__()
            self.assert_not_module_patched(module)
            self.__patch_func__()
            self.assert_module_patched(module)

        @noop_if_no_unpatch
        @raise_if_no_attrs
        def test_patch_unpatch_import_patch(self):
            """
            To ensure that we can thoroughly test the installation/patching of
            an integration we must be able to unpatch it and then subsequently
            patch it again.

            For example::

                from ddtrace.contrib.redis import unpatch

                ddtrace.patch(redis=True)
                import redis
                unpatch()
                ddtrace.patch(redis=True)
                self.assert_module_patched(redis)
            """
            self.__patch_func__()
            self.__unpatch_func__()
            module = importlib.import_module(self.__module_name__)
            self.assert_not_module_patched(module)
            self.__patch_func__()
            self.assert_module_patched(module)

        @noop_if_no_unpatch
        @raise_if_no_attrs
        def test_patch_unpatch_patch_import(self):
            """
            To ensure that we can thoroughly test the installation/patching of
            an integration we must be able to unpatch it and then subsequently
            patch it again.

            For example::

                from ddtrace.contrib.redis import unpatch

                ddtrace.patch(redis=True)
                unpatch()
                ddtrace.patch(redis=True)
                import redis
                self.assert_module_patched(redis)
            """
            self.__patch_func__()
            self.__unpatch_func__()
            self.__patch_func__()
            module = importlib.import_module(self.__module_name__)
            self.assert_module_patched(module)

        @noop_if_no_unpatch
        @raise_if_no_attrs
        def test_unpatch_patch_import(self):
            """
            Make sure unpatching before patch does not break patching.

            For example::

                from ddtrace.contrib.redis import unpatch
                unpatch()
                ddtrace.patch(redis=True)
                import redis
                self.assert_not_module_patched(redis)
            """
            self.__unpatch_func__()
            self.__patch_func__()
            self.__patch_func__()
            module = importlib.import_module(self.__module_name__)
            self.assert_module_patched(module)

        @noop_if_no_unpatch
        @raise_if_no_attrs
        def test_patch_unpatch_import(self):
            """
            To ensure that we can thoroughly test the installation/patching of
            an integration we must be able to unpatch it before importing the
            library.

            For example::

                ddtrace.patch(redis=True)
                from ddtrace.contrib.redis import unpatch
                unpatch()
                import redis
                self.assert_not_module_patched(redis)
            """
            self.__patch_func__()
            self.__unpatch_func__()
            module = importlib.import_module(self.__module_name__)
            self.assert_not_module_patched(module)

        @noop_if_no_unpatch
        @raise_if_no_attrs
        def test_import_unpatch_patch(self):
            """
            To ensure that we can thoroughly test the installation/patching of
            an integration we must be able to unpatch it before patching.

            For example::

                import redis
                from ddtrace.contrib.redis import unpatch
                ddtrace.patch(redis=True)
                unpatch()
                self.assert_not_module_patched(redis)
            """
            module = importlib.import_module(self.__module_name__)
            self.__unpatch_func__()
            self.assert_not_module_patched(module)
            self.__patch_func__()
            self.assert_module_patched(module)

        @noop_if_no_unpatch
        @raise_if_no_attrs
        def test_import_patch_unpatch(self):
            """
            To ensure that we can thoroughly test the installation/patching of
            an integration we must be able to unpatch it after patching.

            For example::

                import redis
                from ddtrace.contrib.redis import unpatch
                ddtrace.patch(redis=True)
                unpatch()
                self.assert_not_module_patched(redis)
            """
            module = importlib.import_module(self.__module_name__)
            self.assert_not_module_patched(module)
            self.__patch_func__()
            self.assert_module_patched(module)
            self.__unpatch_func__()
            self.assert_not_module_patched(module)

        @noop_if_no_unpatch
        @raise_if_no_attrs
        def test_patch_import_unpatch(self):
            """
            To ensure that we can thoroughly test the installation/patching of
            an integration we must be able to unpatch it after patching.

            For example::

                from ddtrace.contrib.redis import unpatch
                ddtrace.patch(redis=True)
                import redis
                unpatch()
                self.assert_not_module_patched(redis)
            """
            self.__patch_func__()
            module = importlib.import_module(self.__module_name__)
            self.assert_module_patched(module)
            self.__unpatch_func__()
            self.assert_not_module_patched(module)

        @noop_if_no_unpatch
        @raise_if_no_attrs
        def test_import_patch_unpatch_unpatch(self):
            """
            Unpatching twice should be a no-op.

            For example::

                import redis
                from ddtrace.contrib.redis import unpatch

                ddtrace.patch(redis=True)
                self.assert_module_patched(redis)
                unpatch()
                self.assert_not_module_patched(redis)
                unpatch()
                self.assert_not_module_patched(redis)
            """
            module = importlib.import_module(self.__module_name__)
            self.__patch_func__()
            self.assert_module_patched(module)
            self.__unpatch_func__()
            self.assert_not_module_patched(module)
            self.__unpatch_func__()
            self.assert_not_module_patched(module)

        @noop_if_no_unpatch
        @raise_if_no_attrs
        def test_patch_unpatch_import_unpatch(self):
            """
            Unpatching twice should be a no-op.

            For example::

                from ddtrace.contrib.redis import unpatch

                ddtrace.patch(redis=True)
                unpatch()
                import redis
                self.assert_not_module_patched(redis)
                unpatch()
                self.assert_not_module_patched(redis)
            """
            self.__patch_func__()
            self.__unpatch_func__()
            module = importlib.import_module(self.__module_name__)
            self.assert_not_module_patched(module)
            self.__unpatch_func__()
            self.assert_not_module_patched(module)

        @noop_if_no_unpatch
        @raise_if_no_attrs
        def test_patch_unpatch_unpatch_import(self):
            """
            Unpatching twice should be a no-op.

            For example::

                from ddtrace.contrib.redis import unpatch

                ddtrace.patch(redis=True)
                unpatch()
                unpatch()
                import redis
                self.assert_not_module_patched(redis)
            """
            self.__patch_func__()
            self.__unpatch_func__()
            self.__unpatch_func__()
            module = importlib.import_module(self.__module_name__)
            self.assert_not_module_patched(module)
