"""
Python dependency injection framework.

Usage:
- Create an optional configuration::
    def my_config(binder):
        binder.bind(Cache, RedisCache('localhost:1234'))
        binder.bind_to_provider(CurrentUser, get_current_user)

- Create a shared injector::
    inject.configure(my_config)

- Use `inject.instance`, `inject.attr` or `inject.param` to inject dependencies::
    class User(object):
        cache = inject.attr(Cache)

        @classmethod
        def load(cls, id):
            return cls.cache.load('user', id)

        def save(self):
            self.cache.save(self)

    def foo(bar):
        cache = inject.instance(Cache)
        cache.save('bar', bar)

    @inject.params(cache=Cache)
    def bar(foo, cache=None):
        cache.save('foo', foo)

Binding types:
- Instance bindings configured via `bind(cls, instance) which always return the same instance.
- Constructor bindings `bind_to_constructor(cls, callable)` which create a singleton
  on first access.
- Provider bindings `bind_to_provider(cls, callable)` which call the provider
  for each injection.
- Runtime bindings which automatically create class singletons.

Thread-safety:
After configuration the injector is thread-safe and can be safely reused by multiple threads.

Unit testing:
In tests use `inject.clear_and_configure(callable)` to create a new injector on setup,
and `inject.clear()` to clean-up on tear down.

Runtime bindings greatly reduce the required configuration by automatically creating singletons
on first access. For example, below only the Config class requires binding configuration,
all other classes are runtime bindings::
    class Cache(object):
        config = inject.attr(Config)

        def __init__(self):
            self._redis = connect(self.config.redis_address)

    class Db(object):
        pass

    class UserRepo(object):
        cache = inject.attr(Cache)
        db = inject.attr(Db)

        def load(self, user_id):
            return cache.load('user', user_id) or db.load('user', user_id)

    class Config(object):
        def __init__(self, redis_address):
            self.redis_address = redis_address

    def my_config(binder):
        binder.bind(Config, load_config_file())

    inject.configure(my_config)

"""  # noqa: E501

from __future__ import annotations

import contextlib
import functools
import inspect
import logging
import sys
import threading
import typing as t

from inject._version import __version__ as __version__

# PEP 604
_HAS_PEP604_SUPPORT = sys.version_info[:3] >= (3, 10, 0)
# PEP 560
_HAS_PEP560_SUPPORT = _HAS_PEP604_SUPPORT or sys.version_info[:3] >= (3, 7, 0)

_RETURN = "return"
_MISSING = object()

if _HAS_PEP604_SUPPORT:
    from types import UnionType
    from typing import _GenericAlias  # noqa: ICN003
elif _HAS_PEP560_SUPPORT:
    from typing import _GenericAlias  # noqa: ICN003, PLC2701
else:
    from typing import _Union  # noqa: ICN003, PLC2701

if t.TYPE_CHECKING:
    from typing_extensions import ParamSpec

    P = ParamSpec("P")
else:
    P = object()

logger = logging.getLogger("inject")

_INJECTOR = None  # Shared injector instance.
_INJECTOR_LOCK = threading.RLock()  # Guards injector initialization.
_BINDING_LOCK = threading.RLock()  # Guards runtime bindings.

Injectable = t.Union[object, t.Any]
T = t.TypeVar("T", bound=Injectable)
Binding = t.Union[type[Injectable], t.Hashable]
Constructor = t.Callable[
    [],
    t.Union[
        Injectable,
        contextlib.AbstractContextManager[Injectable],
        contextlib.AbstractAsyncContextManager[Injectable],
    ],
]
Provider = Constructor
BinderCallable = t.Callable[["Binder"], t.Optional["Binder"]]


class ConstructorTypeError(TypeError):
    def __init__(self, constructor: t.Callable, previous_error: TypeError) -> None:
        super().__init__(f"{constructor} raised an error: {previous_error}")


class Binder:
    _bindings: dict[Binding, Constructor]

    def __init__(self, allow_override: bool = False) -> None:  # noqa: FBT001, FBT002
        self._bindings = {}
        self.allow_override = allow_override

    def install(self, config: BinderCallable) -> Binder:
        """Install another callable configuration."""
        config(self)
        return self

    def bind(self, cls: Binding, instance: T) -> Binder:
        """Bind a class to an instance."""
        self._check_class(cls)

        b = lambda: instance  # noqa: E731
        self._bindings[cls] = b
        self._maybe_bind_forward(cls, b)

        logger.debug("Bound %s to an instance %s", cls, instance)
        return self

    def bind_to_constructor(self, cls: Binding, constructor: Constructor) -> Binder:
        """
        Bind a class to a callable singleton constructor.

        Raises:
            InjectorException: if no constructor

        """
        self._check_class(cls)
        if constructor is None:
            raise InjectorException(f"Constructor cannot be None, key={cls}")

        b = _ConstructorBinding(constructor)
        self._bindings[cls] = b
        self._maybe_bind_forward(cls, b)

        logger.debug("Bound %s to a constructor %s", cls, constructor)
        return self

    def bind_to_provider(self, cls: Binding, provider: Provider) -> Binder:
        """
        Bind a class to a callable instance provider executed for each injection.

        A provider can be a normal function or a context manager.
        Both sync and async are supported.

        Raises:
            InjectorException: if no provider

        """
        self._check_class(cls)
        if provider is None:
            raise InjectorException(f"Provider cannot be None, key={cls}")

        b = provider
        self._bindings[cls] = b
        self._maybe_bind_forward(cls, b)

        logger.debug("Bound %s to a provider %s", cls, provider)
        return self

    def _check_class(self, cls: Binding) -> None:
        if cls is None:
            raise InjectorException("Binding key cannot be None")

        if not self.allow_override and cls in self._bindings:
            raise InjectorException(f"Duplicate binding, key={cls}")

        if self._is_forward_str(cls):
            ref = t.ForwardRef(cls)
            if not self.allow_override and ref in self._bindings:
                msg = f'Duplicate forward binding, i.e. "int" and int, key={cls}'
                raise InjectorException(msg)

    def _maybe_bind_forward(self, cls: Binding, binding: t.Any) -> None:  # noqa: ANN401
        """Bind a string forward reference."""
        if not _HAS_PEP560_SUPPORT:
            return
        if not isinstance(cls, str):
            return

        ref = t.ForwardRef(cls)
        self._bindings[ref] = binding
        logger.debug('Bound forward ref "%s"', cls)

    @staticmethod
    def _is_forward_str(kls: Binding) -> bool:
        return _HAS_PEP560_SUPPORT and isinstance(kls, str)


class Injector:
    _bindings: dict[Binding, Constructor]

    def __init__(
        self,
        config: t.Optional[BinderCallable] = None,
        # TODO(pyctrl): force following flags to be kwargs
        bind_in_runtime: bool = True,  # noqa: FBT001, FBT002
        allow_override: bool = False,  # noqa: FBT001, FBT002
    ) -> None:
        self._bind_in_runtime = bind_in_runtime
        if config:
            binder = Binder(allow_override)
            config(binder)
            self._bindings = binder._bindings  # noqa: SLF001
        else:
            self._bindings = {}

    # NOTE(pyctrl): only since 3.12
    # @t.overload
    # def get_instance(self, cls: type[T]) -> T: ...

    @t.overload
    def get_instance(self, cls: Binding) -> T: ...

    @t.overload
    def get_instance(self, cls: t.Hashable) -> Injectable: ...

    def get_instance(self, cls: Binding) -> Injectable:
        """
        Return an instance for a class.

        Raises:
            InjectorException: on errors
            ConstructorTypeError: over TypeError

        """
        binding = self._bindings.get(cls)
        if binding:
            return binding()

        # Try to create a runtime binding.
        with _BINDING_LOCK:
            binding = self._bindings.get(cls)
            if binding:
                return binding()

            if not self._bind_in_runtime:
                msg = f"No binding was found for key={cls}"
                raise InjectorException(msg)

            if not callable(cls):
                msg = (
                    "Cannot create a runtime binding, the key is not callable,"
                    f" key={cls}",
                )
                raise InjectorException(msg)

            try:
                instance = cls()
            except TypeError as previous_error:
                raise ConstructorTypeError(cls, previous_error)  # noqa: B904

            self._bindings[cls] = lambda: instance

            msg = "Created a runtime binding for key=%s, instance=%s"
            logger.debug(msg, cls, instance)
            return instance


class InjectorException(Exception):
    pass


class _ConstructorBinding(t.Generic[T]):
    _instance: t.Optional[T]

    def __init__(self, constructor: t.Callable[[], T]) -> None:
        self._constructor = constructor
        self._created = False
        self._instance = None

    def __call__(self) -> T:
        if self._created and self._instance is not None:
            return self._instance

        with _BINDING_LOCK:
            if self._created and self._instance is not None:
                return self._instance
            self._instance = self._constructor()
            self._created = True
        return self._instance


# NOTE(pyctrl): we MUST inherit `_AttributeInjection` from `property`
#   0. (personal opinion, based on a bunch of cases including this one)
#      dataclasses are mess
#   1. dataclasses treat all non-`property` descriptors by the very specific logic
#     https://docs.python.org/3/library/dataclasses.html#descriptor-typed-fields
#   2. and treat `property` descriptors in a special way — like we used to know:
#      ```
#          @dataclass
#          class MyDataclass:
#              @property
#              def my_prop(self) -> int:
#                  return 42
#          MyDataclass.my_prop    # gives '<property at 0x73055337f150>' on class
#          MyDataclass().my_prop  # and on instance will show you '42'
#      ```
#      it behaves the same in the case of alternative notation:
#      ```
#          @dataclass
#          class MyDataclass2:
#              my_prop = property(fget=lambda _: 42)
#          MyDataclass2.my_prop    # gives '<property at 0x73055337ec00>' on class
#          MyDataclass2().my_prop  # and on instance will show you '42'
#      ```
#      which is more relevant to the `inject.attr` case
#   3. but the behavior around `property`-ies has an exception
#        - you can't annotate `property` attribute when using the second notation
#          (this one `my_prop: int = property(fget=lambda _: 42)` will fail)
#        - so the type hinting the very matters
#        - in this case dataclasses don't treat class member as property
#          (even if it's inherited from `property` or used directly)
#        - dataclasses behave greedy when discover their attributes
#          and class member annotations are "must have" markers
#   4. so for `inject.attr`s case we should follow 2 rules:
#        - `attr` implementation is inherited from `property`
#        - `attr` class member is not annotated
class _AttributeInjection(property):
    def __init__(self, cls: t.Union[type[T], t.Hashable]) -> None:
        self._cls = cls
        super().__init__(
            fget=lambda _: instance(self._cls),
            doc="Return an attribute injection",
        )

    def __set_name__(self, owner: type[T], name: str) -> None:
        if self._cls is _MISSING:
            self._cls = _unwrap_cls_annotation(owner, name)


class _ParameterInjection(t.Generic[T]):
    __slots__ = ("_cls", "_name")

    def __init__(self, name: str, cls: t.Optional[Binding] = None) -> None:
        self._name = name
        self._cls = cls

    def __call__(
        self, func: t.Callable[..., t.Union[T, t.Awaitable[T]]]
    ) -> t.Callable[..., t.Union[T, t.Awaitable[T]]]:
        if inspect.iscoroutinefunction(func):

            @functools.wraps(func)
            async def async_injection_wrapper(*args: t.Any, **kwargs: t.Any) -> T:  # noqa: ANN401
                if self._name not in kwargs:
                    kwargs[self._name] = instance(self._cls or self._name)
                async_func = t.cast("t.Callable[..., t.Awaitable[T]]", func)
                return await async_func(*args, **kwargs)

            return async_injection_wrapper

        @functools.wraps(func)
        def injection_wrapper(*args: t.Any, **kwargs: t.Any) -> T:  # noqa: ANN401
            if self._name not in kwargs:
                kwargs[self._name] = instance(self._cls or self._name)
            sync_func = t.cast("t.Callable[..., T]", func)
            return sync_func(*args, **kwargs)

        return injection_wrapper


class _ParametersInjection(t.Generic[T]):
    __slots__ = ("_params",)

    def __init__(self, **kwargs: Binding) -> None:
        self._params = kwargs

    @staticmethod
    def _aggregate_sync_stack(
        sync_stack: contextlib.ExitStack,
        provided_params: frozenset[str],
        kwargs: dict[str, t.Any],
    ) -> None:
        """
        Manage context stack.

        Extracts context managers, aggregate them in an ExitStack
        and swap out the param value with results of running `__enter__()`.
        The result is equivalent to using `with` multiple times.
        """
        executed_kwargs = {
            param: sync_stack.enter_context(inst)
            for param, inst in kwargs.items()
            if param not in provided_params
            and isinstance(inst, contextlib.AbstractContextManager)
        }
        kwargs.update(executed_kwargs)

    @staticmethod
    async def _aggregate_async_stack(
        async_stack: contextlib.AsyncExitStack,
        provided_params: frozenset[str],
        kwargs: dict[str, t.Any],
    ) -> None:
        """Similar to _aggregate_sync_stack, but for async context managers."""
        executed_kwargs = {
            param: await async_stack.enter_async_context(inst)
            for param, inst in kwargs.items()
            if param not in provided_params
            and isinstance(inst, contextlib.AbstractAsyncContextManager)
        }
        kwargs.update(executed_kwargs)

    def __call__(
        self, func: t.Callable[..., t.Union[t.Awaitable[T], T]]
    ) -> t.Callable[..., t.Union[t.Awaitable[T], T]]:
        arg_names = inspect.getfullargspec(func).args
        params_to_provide = self._params

        if inspect.iscoroutinefunction(func):

            @functools.wraps(func)
            async def async_injection_wrapper(*args: t.Any, **kwargs: t.Any) -> T:  # noqa: ANN401
                provided_params = frozenset(arg_names[: len(args)]) | frozenset(
                    kwargs.keys()
                )
                for param, cls in params_to_provide.items():
                    if param not in provided_params:
                        kwargs[param] = instance(cls)
                async_func = t.cast("t.Callable[..., t.Awaitable[T]]", func)
                try:
                    with contextlib.ExitStack() as sync_stack:
                        async with contextlib.AsyncExitStack() as async_stack:
                            self._aggregate_sync_stack(
                                sync_stack, provided_params, kwargs
                            )
                            await self._aggregate_async_stack(
                                async_stack, provided_params, kwargs
                            )
                            return await async_func(*args, **kwargs)
                except TypeError as previous_error:
                    raise ConstructorTypeError(func, previous_error)  # noqa: B904

            return async_injection_wrapper

        @functools.wraps(func)
        def injection_wrapper(*args: t.Any, **kwargs: t.Any) -> T:  # noqa: ANN401
            provided_params = frozenset(arg_names[: len(args)]) | frozenset(
                kwargs.keys()
            )
            for param, cls in params_to_provide.items():
                if param not in provided_params:
                    kwargs[param] = instance(cls)
            sync_func = t.cast("t.Callable[..., T]", func)
            try:
                with contextlib.ExitStack() as sync_stack:
                    self._aggregate_sync_stack(sync_stack, provided_params, kwargs)
                    return sync_func(*args, **kwargs)
            except TypeError as previous_error:
                raise ConstructorTypeError(func, previous_error)  # noqa: B904

        return injection_wrapper


def configure(
    config: t.Optional[BinderCallable] = None,
    # TODO(pyctrl): force following flags to be kwargs
    bind_in_runtime: bool = True,  # noqa: FBT001, FBT002
    allow_override: bool = False,  # noqa: FBT001, FBT002
    clear: bool = False,  # noqa: FBT001, FBT002
    once: bool = False,  # noqa: FBT001, FBT002
) -> Injector:
    """
    Create an injector using callable config.

    Raises:
        InjectorException: if already configured.

    """
    global _INJECTOR  # noqa: PLW0603

    if clear and once:
        msg = "clear and once are mutually exclusive, only one can be True"
        raise InjectorException(msg)

    with _INJECTOR_LOCK:
        if _INJECTOR:
            if clear:
                _clear_injector()
            elif once:
                return _INJECTOR
            else:
                raise InjectorException("Injector is already configured")

        _INJECTOR = Injector(
            config,
            bind_in_runtime=bind_in_runtime,
            allow_override=allow_override,
        )
        logger.debug("Created and configured an injector, config=%s", config)
        return _INJECTOR


def configure_once(
    config: t.Optional[BinderCallable] = None,
    # TODO(pyctrl): force following flags to be kwargs
    bind_in_runtime: bool = True,  # noqa: FBT001, FBT002
    allow_override: bool = False,  # noqa: FBT001, FBT002
) -> Injector:
    """
    Create an injector with a callable config if not present, otherwise, do nothing.

    Deprecated, use `configure(once=True)` instead.
    """
    with _INJECTOR_LOCK:
        if _INJECTOR:
            return _INJECTOR

        return configure(
            config, bind_in_runtime=bind_in_runtime, allow_override=allow_override
        )


def clear_and_configure(
    config: t.Optional[BinderCallable] = None,
    # TODO(pyctrl): force following flags to be kwargs
    bind_in_runtime: bool = True,  # noqa: FBT001, FBT002
    allow_override: bool = False,  # noqa: FBT001, FBT002
) -> Injector:
    """
    Clear an existing injector and create another one with a callable config.

    Deprecated, use configure(clear=True) instead.
    """
    with _INJECTOR_LOCK:
        _clear_injector()
        return configure(
            config,
            bind_in_runtime=bind_in_runtime,
            allow_override=allow_override,
        )


def is_configured() -> bool:
    """Return true if an injector is already configured."""
    with _INJECTOR_LOCK:
        return _INJECTOR is not None


def clear() -> None:
    """Clear an existing injector if present."""
    _clear_injector()


def _clear_injector() -> None:
    """Clear an existing injector if present."""
    global _INJECTOR  # noqa: PLW0603

    with _INJECTOR_LOCK:
        if _INJECTOR is None:
            return

        _INJECTOR = None
        logger.debug("Cleared an injector")


@t.overload
def instance(cls: type[T]) -> T: ...


@t.overload
def instance(cls: t.Hashable) -> Injectable: ...


def instance(cls: Binding) -> Injectable:
    """Inject an instance of a class."""
    return get_injector_or_die().get_instance(cls)


@t.overload
def attr() -> Injectable: ...


@t.overload
def attr(cls: type[T]) -> T: ...


@t.overload
def attr(cls: t.Hashable) -> Injectable: ...


def attr(cls=_MISSING):
    """Return an attribute injection (descriptor)."""
    return _AttributeInjection(cls)


# Deprecated, use `attr`
attr_dc = attr


def param(name: str, cls: t.Optional[Binding] = None) -> t.Callable:
    """
    Return a decorator which injects an arg into a function.

    Deprecated, use @inject.params.
    """
    return _ParameterInjection(name, cls)


def params(**args_to_classes: Binding) -> t.Callable:
    """
    Return a decorator which injects args into a function.

    For example::

        @inject.params(cache=RedisCache, db=DbInterface)
        def sign_up(name, email, cache, db):
            pass
    """
    return _ParametersInjection(**args_to_classes)


@t.overload
def autoparams(fn: t.Callable[P, T]) -> t.Callable[P, T]: ...


@t.overload
def autoparams(*selected: str) -> t.Callable[[T], T]: ...


def autoparams(*selected: t.Callable[P, T] | str) -> t.Callable[..., T]:
    """
    Return a decorator injecting args based on function type hints, only since 3.5.

    For example::

        @inject.autoparams
        def refresh_cache(cache: RedisCache, db: DbInterface):
            pass

    There is an option to specify which arguments we want to
    inject without attempts of injecting everything:

    For example::

        @inject.autoparams('cache', 'db')
        def sign_up(name, email, cache: RedisCache, db: DbInterface):
            pass
    """
    only_these: set[str] = set()

    def autoparams_decorator(fn: t.Callable[..., T]) -> t.Callable[..., T]:
        if inspect.isclass(fn):
            types = t.get_type_hints(fn.__init__)
        else:
            types = t.get_type_hints(fn)

        # Skip the return annotation.
        types = {name: typ for name, typ in types.items() if name != _RETURN}

        # Convert Union types into single types, i.e. Union[A, None] => A.
        types = {name: _unwrap_union_arg(typ) for name, typ in types.items()}

        # Filter types if selected args present.
        if only_these:
            types = {name: typ for name, typ in types.items() if name in only_these}

        wrapper: _ParametersInjection[T] = _ParametersInjection(**types)
        return wrapper(fn)

    target = selected[0] if selected else None
    if len(selected) == 1 and callable(target):
        return autoparams_decorator(target)

    only_these.update(selected)
    return autoparams_decorator


def get_injector() -> t.Optional[Injector]:
    """Return the current injector or None."""
    return _INJECTOR


def get_injector_or_die() -> Injector:
    """
    Return the current injector or raise an InjectorException.

    Raises:
        InjectorException: If injector is not configured.

    Returns:
      Configured injector.

    """
    injector = _INJECTOR
    if not injector:
        raise InjectorException("No injector is configured")

    return injector


def _unwrap_union_arg(typ: type) -> type:
    """Return the first type A in typing.Union[A, B] or typ if not Union."""
    if not _is_union_type(typ):
        return typ
    return typ.__args__[0]


def _is_union_type(typ: type) -> bool:
    """
    Test if the type is a union type.

    Examples::
        is_union_type(int) == False
        is_union_type(Union) == True
        is_union_type(Union[int, int]) == False
        is_union_type(Union[T, int]) == True

    Source: https://github.com/ilevkivskyi/typing_inspect/blob/master/typing_inspect.py
    """
    if _HAS_PEP604_SUPPORT:
        return (
            typ is t.Union
            or isinstance(typ, UnionType)
            or (isinstance(typ, _GenericAlias) and typ.__origin__ is t.Union)
        )
    if _HAS_PEP560_SUPPORT:
        return typ is t.Union or (
            isinstance(typ, _GenericAlias) and typ.__origin__ is t.Union
        )
    return type(typ) is _Union


def _unwrap_cls_annotation(cls: type, attr_name: str) -> type:
    types = t.get_type_hints(cls)
    try:
        attr_type = types[attr_name]
    except KeyError:
        msg = f"Couldn't find type annotation for {attr_name}"
        raise InjectorException(msg) from None

    return _unwrap_union_arg(attr_type)
