"""
NOTE: The contents of this file have been inlined from the wrapt package's source code
https://github.com/GrahamDumpleton/wrapt/blob/1.12.1/src/wrapt/importer.py.
Some modifications, have been made in order to:
    - avoid duplicate registration of import hooks
    - inline functions from dependent wrapt submodules rather than importing them.

This module implements a post import hook mechanism styled after what is described in PEP-369.
Note that it doesn't cope with modules being reloaded.
It also extends the functionality to support custom hooks for import errors
(as opposed to only successful imports).
"""

import sys
import threading
import importlib  # noqa: F401

string_types = (str,)


# from .decorators import synchronized
# NOTE: Instead of using this import (from wrapt's decorator module, see
# https://github.com/GrahamDumpleton/wrapt/blob/68316bea668fd905a4acb21f37f12596d8c30d80/src/wrapt/decorators.py#L430-L456),
# we define a decorator with similar behavior that acquires a lock while calling the decorated
# function
def synchronized(lock):
    def decorator(f):
        # See e.g. https://www.python.org/dev/peps/pep-0318/#examples
        def new_fn(*args, **kwargs):
            with lock:
                return f(*args, **kwargs)

        return new_fn

    return decorator


# The dictionary registering any post import hooks to be triggered once
# the target module has been imported. Once a module has been imported
# and the hooks fired, the list of hooks recorded against the target
# module will be truncated but the list left in the dictionary. This
# acts as a flag to indicate that the module had already been imported.

_post_import_hooks = {}
_post_import_hooks_lock = threading.RLock()

# A dictionary for any import hook error handlers to be triggered when the
# target module import fails.

_import_error_hooks = {}
_import_error_hooks_lock = threading.RLock()

_import_hook_finder_init = False

# Register a new post import hook for the target module name. This
# differs from the PEP-369 implementation in that it also allows the
# hook function to be specified as a string consisting of the name of
# the callback in the form 'module:function'. This will result in a
# proxy callback being registered which will defer loading of the
# specified module containing the callback function until required.


def _create_import_hook_from_string(name):
    def import_hook(module):
        module_name, function = name.split(":")
        attrs = function.split(".")
        __import__(module_name)
        callback = sys.modules[module_name]
        for attr in attrs:
            callback = getattr(callback, attr)
        return callback(module)

    return import_hook


def register_generic_import_hook(hook, name, hook_dict, overwrite):
    # Create a deferred import hook if hook is a string name rather than
    # a callable function.

    if isinstance(hook, string_types):
        hook = _create_import_hook_from_string(hook)

    # Automatically install the import hook finder if it has not already
    # been installed.

    global _import_hook_finder_init
    if not _import_hook_finder_init:
        _import_hook_finder_init = True
        sys.meta_path.insert(0, ImportHookFinder())

    # Determine if any prior registration of an import hook for
    # the target modules has occurred and act appropriately.

    hooks = hook_dict.get(name, None)

    if hooks is None:
        # No prior registration of import hooks for the target
        # module. We need to check whether the module has already been
        # imported. If it has we fire the hook immediately and add an
        # empty list to the registry to indicate that the module has
        # already been imported and hooks have fired. Otherwise add
        # the post import hook to the registry.

        module = sys.modules.get(name, None)

        if module is not None:
            hook_dict[name] = []
            hook(module)

        else:
            hook_dict[name] = [hook]

    elif hooks == []:
        # A prior registration of import hooks for the target
        # module was done and the hooks already fired. Fire the hook
        # immediately.

        module = sys.modules[name]
        hook(module)

    else:
        # A prior registration of import hooks for the target
        # module was done but the module has not yet been imported.

        def hooks_equal(existing_hook, hook):
            if hasattr(existing_hook, "__name__") and hasattr(hook, "__name__"):
                return existing_hook.__name__ == hook.__name__
            else:
                return False

        if overwrite:
            hook_dict[name] = [
                existing_hook
                for existing_hook in hook_dict[name]
                if not hooks_equal(existing_hook, hook)
            ]

        hook_dict[name].append(hook)


@synchronized(_import_error_hooks_lock)
def register_import_error_hook(hook, name, overwrite=True):
    """
    :param hook: A function or string entrypoint to invoke when the specified module is imported
                 and an error occurs.
    :param name: The name of the module for which to fire the hook at import error detection time.
    :param overwrite: Specifies the desired behavior when a preexisting hook for the same
                      function / entrypoint already exists for the specified module. If `True`,
                      all preexisting hooks matching the specified function / entrypoint will be
                      removed and replaced with a single instance of the specified `hook`.
    """
    register_generic_import_hook(hook, name, _import_error_hooks, overwrite)


@synchronized(_post_import_hooks_lock)
def register_post_import_hook(hook, name, overwrite=True):
    """
    :param hook: A function or string entrypoint to invoke when the specified module is imported.
    :param name: The name of the module for which to fire the hook at import time.
    :param overwrite: Specifies the desired behavior when a preexisting hook for the same
                      function / entrypoint already exists for the specified module. If `True`,
                      all preexisting hooks matching the specified function / entrypoint will be
                      removed and replaced with a single instance of the specified `hook`.
    """
    register_generic_import_hook(hook, name, _post_import_hooks, overwrite)


# Register post import hooks defined as package entry points.


def _create_import_hook_from_entrypoint(entrypoint):
    def import_hook(module):
        __import__(entrypoint.module_name)
        callback = sys.modules[entrypoint.module_name]
        for attr in entrypoint.attrs:
            callback = getattr(callback, attr)
        return callback(module)

    return import_hook


def discover_post_import_hooks(group):
    # New in 3.9: https://docs.python.org/3/library/importlib.resources.html#importlib.resources.files
    if sys.version_info.major > 2 and sys.version_info.minor > 8:
        from importlib.resources import files  # pylint: disable=lazy-builtin-import

        for entrypoint in (
            resource.name for resource in files(group).iterdir() if resource.is_file()
        ):
            callback = _create_import_hook_from_entrypoint(entrypoint)
            register_post_import_hook(callback, entrypoint.name)
    else:
        from importlib.resources import contents  # pylint: disable=lazy-builtin-import

        for entrypoint in contents(group):
            callback = _create_import_hook_from_entrypoint(entrypoint)
            register_post_import_hook(callback, entrypoint.name)


# Indicate that a module has been loaded. Any post import hooks which
# were registered against the target module will be invoked. If an
# exception is raised in any of the post import hooks, that will cause
# the import of the target module to fail.


@synchronized(_post_import_hooks_lock)
def notify_module_loaded(module):
    name = getattr(module, "__name__", None)
    hooks = _post_import_hooks.get(name, None)

    if hooks:
        _post_import_hooks[name] = []

        for hook in hooks:
            hook(module)


@synchronized(_import_error_hooks_lock)
def notify_module_import_error(module_name):
    hooks = _import_error_hooks.get(module_name, None)

    if hooks:
        # Error hooks differ from post import hooks, in that we don't clear the
        # hook as soon as it fires.
        for hook in hooks:
            hook(module_name)


# A custom module import finder. This intercepts attempts to import
# modules and watches out for attempts to import target modules of
# interest. When a module of interest is imported, then any post import
# hooks which are registered will be invoked.


class _ImportHookChainedLoader:
    def __init__(self, loader):
        self.loader = loader

    def load_module(self, fullname):
        try:
            module = self.loader.load_module(fullname)
            notify_module_loaded(module)
        except (ImportError, AttributeError):
            notify_module_import_error(fullname)
            raise

        return module


class ImportHookFinder:
    def __init__(self):
        self.in_progress = {}

    @synchronized(_post_import_hooks_lock)
    @synchronized(_import_error_hooks_lock)
    def find_module(self, fullname, path=None):
        # If the module being imported is not one we have registered
        # import hooks for, we can return immediately. We will
        # take no further part in the importing of this module.

        if fullname not in _post_import_hooks and fullname not in _import_error_hooks:
            return None

        # When we are interested in a specific module, we will call back
        # into the import system a second time to defer to the import
        # finder that is supposed to handle the importing of the module.
        # We set an in progress flag for the target module so that on
        # the second time through we don't trigger another call back
        # into the import system and cause a infinite loop.

        if fullname in self.in_progress:
            return None

        self.in_progress[fullname] = True

        # Now call back into the import system again.

        try:
            # For Python 3 we need to use find_spec().loader
            # from the importlib.util module. It doesn't actually
            # import the target module and only finds the
            # loader. If a loader is found, we need to return
            # our own loader which will then in turn call the
            # real loader to import the module and invoke the
            # post import hooks.
            try:
                import importlib.util  # pylint: disable=lazy-builtin-import

                loader = importlib.util.find_spec(fullname).loader
            # If an ImportError (or AttributeError) is encountered while finding the module,
            # notify the hooks for import errors
            except (ImportError, AttributeError):
                notify_module_import_error(fullname)
                loader = importlib.find_loader(fullname, path)  # pylint: disable=deprecated-method
            if loader:
                return _ImportHookChainedLoader(loader)
        finally:
            del self.in_progress[fullname]

    @synchronized(_post_import_hooks_lock)
    @synchronized(_import_error_hooks_lock)
    def find_spec(self, fullname, path, target=None):  # pylint: disable=unused-argument
        # If the module being imported is not one we have registered
        # import hooks for, we can return immediately. We will
        # take no further part in the importing of this module.

        if fullname not in _post_import_hooks and fullname not in _import_error_hooks:
            return None

        # When we are interested in a specific module, we will call back
        # into the import system a second time to defer to the import
        # finder that is supposed to handle the importing of the module.
        # We set an in progress flag for the target module so that on
        # the second time through we don't trigger another call back
        # into the import system and cause a infinite loop.

        if fullname in self.in_progress:
            return None

        self.in_progress[fullname] = True

        # Now call back into the import system again.

        try:
            import importlib.util  # pylint: disable=lazy-builtin-import

            spec = importlib.util.find_spec(fullname)
            # Replace the module spec's loader with a wrapped version that executes import
            # hooks when the module is loaded
            spec.loader = _ImportHookChainedLoader(spec.loader)
            return spec
        except (ImportError, AttributeError):
            notify_module_import_error(fullname)
        finally:
            del self.in_progress[fullname]


# Decorator for marking that a function should be called as a post
# import hook when the target module is imported.
# If error_handler is True, then apply the marked function as an import hook
# for import errors (instead of successful imports).
# It is assumed that all error hooks are added during driver start-up,
# and thus added prior to any import calls. If an error hook is added
# after a module has already failed the import, there's no guarantee
# that the hook will fire.


def when_imported(name, error_handler=False):
    def register(hook):
        if error_handler:
            register_import_error_hook(hook, name)
        else:
            register_post_import_hook(hook, name)
        return hook

    return register
