#    Copyright (C) 2012-2013 Yahoo! Inc. All Rights Reserved.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

"""
Reflection module.

.. versionadded:: 1.1
"""

import inspect
import logging
import operator
import types
from typing import Any
from collections.abc import Generator, Sequence

try:
    _TYPE_TYPE = types.TypeType  # type: ignore
except AttributeError:
    _TYPE_TYPE = type

# See: https://docs.python.org/2/library/__builtin__.html#module-__builtin__
# and see https://docs.python.org/2/reference/executionmodel.html (and likely
# others)...
_BUILTIN_MODULES = ('builtins', '__builtin__', '__builtins__', 'exceptions')


LOG = logging.getLogger(__name__)


Parameter = inspect.Parameter
Signature = inspect.Signature
get_signature = inspect.signature


def get_members(
    obj: Any, exclude_hidden: bool = True
) -> Generator[tuple[str, Any], None, None]:
    """Yields the members of an object, filtering by hidden/not hidden.

    .. versionadded:: 2.3
    """
    for name, value in inspect.getmembers(obj):
        if name.startswith("_") and exclude_hidden:
            continue
        yield (name, value)


def get_member_names(obj: Any, exclude_hidden: bool = True) -> list[str]:
    """Get all the member names for a object."""
    return [
        name
        for (name, _obj) in get_members(obj, exclude_hidden=exclude_hidden)
    ]


def get_class_name(
    obj: Any, fully_qualified: bool = True, truncate_builtins: bool = True
) -> str:
    """Get class name for object.

    If object is a type, returns name of the type. If object is a bound
    method or a class method, returns its ``self`` object's class name.
    If object is an instance of class, returns instance's class name.
    Else, name of the type of the object is returned. If fully_qualified
    is True, returns fully qualified name of the type. For builtin types,
    just name is returned. TypeError is raised if can't get class name from
    object.
    """
    if inspect.isfunction(obj):
        raise TypeError("Can't get class name.")

    if inspect.ismethod(obj):
        obj = get_method_self(obj)
    if not isinstance(obj, type):
        obj = type(obj)
    if truncate_builtins:
        try:
            built_in = obj.__module__ in _BUILTIN_MODULES
        except AttributeError:  # nosec
            pass
        else:
            if built_in:
                return str(obj.__name__)
    if fully_qualified and hasattr(obj, '__module__'):
        return f'{obj.__module__}.{obj.__name__}'
    else:
        return str(obj.__name__)


def get_all_class_names(
    obj: Any,
    up_to: type = object,
    fully_qualified: bool = True,
    truncate_builtins: bool = True,
) -> Generator[str, None, None]:
    """Get class names of object parent classes.

    Iterate over all class names object is instance or subclass of,
    in order of method resolution (mro). If up_to parameter is provided,
    only name of classes that are sublcasses to that class are returned.
    """
    if not isinstance(obj, type):
        obj = type(obj)
    for cls in obj.mro():
        if issubclass(cls, up_to):
            yield get_class_name(
                cls,
                fully_qualified=fully_qualified,
                truncate_builtins=truncate_builtins,
            )


def get_callable_name(function: Any) -> str:
    """Generate a name from callable.

    Tries to do the best to guess fully qualified callable name.
    """
    method_self = get_method_self(function)
    parts: Sequence[str]
    if method_self is not None:
        # This is a bound method.
        if isinstance(method_self, type):
            # This is a bound class method.
            im_class = method_self
        else:
            im_class = type(method_self)
        try:
            parts = (im_class.__module__, function.__qualname__)
        except AttributeError:
            parts = (im_class.__module__, im_class.__name__, function.__name__)
    elif inspect.ismethod(function) or inspect.isfunction(function):
        # This could be a function, a static method, a unbound method...
        try:
            parts = (function.__module__, function.__qualname__)
        except AttributeError:
            if hasattr(function, 'im_class'):
                # This is a unbound method, which exists only in python 2.x
                im_class = function.im_class
                parts = (
                    im_class.__module__,
                    im_class.__name__,
                    function.__name__,
                )
            else:
                parts = (function.__module__, function.__name__)
    else:
        im_class = type(function)
        if im_class is _TYPE_TYPE:
            im_class = function
        try:
            parts = (im_class.__module__, im_class.__qualname__)
        except AttributeError:
            parts = (im_class.__module__, im_class.__name__)
    return '.'.join(parts)


def get_method_self(method: Any) -> Any:
    """Gets the ``self`` object attached to this method (or none)."""
    if not inspect.ismethod(method):
        return None
    try:
        return operator.attrgetter("__self__")(method)
    except AttributeError:
        return None


def is_same_callback(callback1: Any, callback2: Any) -> bool:
    """Returns if the two callbacks are the same."""
    if callback1 is callback2:
        # This happens when plain methods are given (or static/non-bound
        # methods).
        return True
    if callback1 == callback2:
        # NOTE(gmann): python3.8 onward, comparison of bound methods is
        # changed. It no longer decide the bound method's equality based
        # on their bounded objects equality instead it checks the identity
        # of their '__self__'.
        return True
    return False


def is_bound_method(method: Any) -> bool:
    """Returns if the given method is bound to an object."""
    return get_method_self(method) is not None


def is_subclass(obj: Any, cls: type) -> bool:
    """Returns if the object is class and it is subclass of a given class."""
    return inspect.isclass(obj) and issubclass(obj, cls)


def get_callable_args(function: Any, required_only: bool = False) -> list[str]:
    """Get names of callable arguments.

    Special arguments (like ``*args`` and ``**kwargs``) are not included into
    output.

    If required_only is True, optional arguments (with default values)
    are not included into output.
    """
    sig = get_signature(function)
    function_args = list(sig.parameters.keys())
    for param_name, p in sig.parameters.items():
        if p.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD) or (
            required_only and p.default is not Parameter.empty
        ):
            function_args.remove(param_name)
    return function_args


def accepts_kwargs(function: Any) -> bool:
    """Returns ``True`` if function accepts kwargs otherwise ``False``."""
    sig = get_signature(function)
    return any(
        p.kind == Parameter.VAR_KEYWORD for p in sig.parameters.values()
    )
