"""Manage deprecation for Python features for common use

    All deprecation functions can be accessed from the top module level.

    The :py:class:`omni.graph.tools.DeprecateMessage` class provides a simple way of logging a message that will only
    show up once per session.

    The :py:class:`omni.graph.tools.DeprecatedClass` decorator provides a method to emit a deprecation message when the
    deprecated class is accessed.

    The :py:class:`omni.graph.tools.RenamedClass` decorator is a slightly more sophisticated method of deprecating a
    class when the deprecation is simply a name change.

    The :py:function:`omni.graph.tools.deprecated_function` decorator provides a method to emit a deprecation message
    when the old function is called.

    The :py:function:`omni.graph.tools.DeprecatedImport` decorator provides a method to emit a deprecation message
    when an entire deprecated file is imported for use. This should not be used for imports that will be included
    in the API for backward compatibility, nor should these files be moved as they must continue to exist at the
    same import location in order to remain compatible.
"""
import functools
import inspect
import re
import traceback
from typing import Optional, Set

from carb import log_warn, settings

__all__ = []


# ==============================================================================================================
class DeprecationError(Exception):
    """Exception to raise when a hard-deprecated import, class, or function is attempted to be used.
    Exists to provide a last bit of information to users who have been ignoring previous deprecation errors.
    """


# ==============================================================================================================
# begin-deprecate-message
class DeprecateMessage:
    """Manager for deprecation messages, to make it efficient to prevent multiple logging of the same
    deprecation messages.

    The default settings for output is usually enough to help you find where deprecated code is referenced.
    If more information is desired these per-class variables can be set to reduce the filtering being done. The
    message should contains an action item for the user to upgrade from the deprecated functionality:

    .. code-block:: python

        DeprecateMessage.deprecated("Install the latest version instead")

        # Although it's not usually necessary the class can be tuned using these class variable

        SILENCE_LOG = False  # When set the output does not go to the console log; useful to disable for testing
        SHOW_STACK = True  # Report stack trace in the deprecation message - can be turned off if it is too verbose
        MAX_STACK_LEVELS = 3  # Maximum number of stack levels to report, after filtering
        RE_IGNORE = re.compile("deprecate.py|bindings-python|importlib")  # Ignore stack levels matching these patterns

    You can use some Python features to handle simple deprecation cases directly such as:

    .. code-block:: python

        # Rename constant from A to B
        A = (DeprecateMessage("A has been renamed to B") and False) or B

        # Constant A will be removed
        A = (DeprecateMessage("A will be removed, use B instead) and False) or B
    """

    # end-deprecate-message
    MESSAGES_LOGGED = set()
    SILENCE_LOG = False
    SHOW_STACK = True
    MAX_STACK_LEVELS = 3
    RE_IGNORE = re.compile("deprecate.py|bindings-python|importlib")

    class NoLogging:
        """Context manager class to let you import a bunch of known deprecated functions without logging warnings.
        Typical use would be in providing backward compatibility in a module where submodules have moved.

            with DeprecateMessage.NoLogging():
                import .v1_0.my_old_function as my_old_function
        """

        def __init__(self, *args, **kwargs):
            self.__original_logging = None

        def __enter__(self):
            """Disable logging for the duration of the context"""
            self.__original_logging = DeprecateMessage.SILENCE_LOG
            DeprecateMessage.SILENCE_LOG = True

        def __exit__(self, exit_type, value, exit_traceback):
            """Restore the original logging state"""
            DeprecateMessage.SILENCE_LOG = self.__original_logging

    # --------------------------------------------------------------------------------------------------------------
    @classmethod
    def messages_logged(cls) -> Set[str]:
        """Returns the set of messages that have been logged so far"""
        return cls.MESSAGES_LOGGED

    # --------------------------------------------------------------------------------------------------------------
    @classmethod
    def clear_messages(cls):
        """Clear the logged messages so that they can be logged again"""
        cls.MESSAGES_LOGGED = set()

    # --------------------------------------------------------------------------------------------------------------
    @classmethod
    def deprecations_are_errors(cls) -> bool:
        """Returns True if deprecations are currently being treated as errors"""
        return settings.get_settings().get("/persistent/omnigraph/deprecationsAreErrors")

    @classmethod
    def set_deprecations_are_errors(cls, make_errors: bool):
        """Enable or disable treating deprecations as errors instead of warnings"""
        settings.get_settings().set("/persistent/omnigraph/deprecationsAreErrors", make_errors)

    # --------------------------------------------------------------------------------------------------------------
    @classmethod
    def deprecated(cls, message: str):
        """Log the deprecation message if it has not yet been logged, otherwise do nothing

        Args:
            message: Message to display; only displays once even if this is called many times

        Adds stack trace information if the class member SHOW_STACK is True.
        Skips the Carbonite logging if the class member SILENCE_LOG is True (mostly useful for testing when a
        warning is the expected result).
        """
        if message in cls.MESSAGES_LOGGED:
            return

        stack = ""
        try:
            try:
                full_stack = traceback.format_stack() if cls.SHOW_STACK else []
            except SyntaxError as error:
                full_stack = [f"Error encountered when retrieving call stack - {error}"]
            if full_stack:
                filtered_stack = filter(lambda stack: not cls.RE_IGNORE.search(stack), full_stack)
                stack = "\n" + "".join(list(filtered_stack)[-cls.MAX_STACK_LEVELS :])
        except SyntaxError as error:
            stack = f"Stack trace not accessible - {error}"

        if cls.deprecations_are_errors():
            raise DeprecationError(f"{message}{stack}")
        _ = cls.SILENCE_LOG or log_warn(f"{message}{stack}")
        cls.MESSAGES_LOGGED.add(message)


# ==============================================================================================================
# begin-deprecated-class
class DeprecatedClass:
    """Decorator to deprecate a class. Takes one argument that is a string to describe the action the user is to
    take to avoid the deprecated class. A deprecation message will be shown once, the first time the deprecated
    class is instantiated.

    .. code-block:: python

        @DeprecatedClass("After version 1.5.0 use og.NewerClass instead")
        class OlderClass:
            pass
    """

    # end-deprecated-class
    def __init__(self, deprecation_message: str):
        """Remember the message and only report it on initialization
        Args:
            deprecation_message: A description of the action the user is to take to avoid the deprecated class.
        """
        self.__deprecation_message = deprecation_message

    def message(self, deprecated_cls, deprecated_member: Optional[str] = None):
        """Emit a deprecation message with useful information attached"""
        try:
            old_name = deprecated_cls.__old_name__
        except AttributeError:
            old_name = deprecated_cls.__name__
        what_is_deprecated = old_name if deprecated_member is None else f"{old_name}.{deprecated_member}"
        DeprecateMessage.deprecated(f"{what_is_deprecated} is deprecated: {self.__deprecation_message}")

    def __call__(self, deprecated_cls):
        """Report the deprecation message if it hasn't already been reported"""

        def wrapper(*args, **kwargs):
            """Redirect function calls to the real class"""
            self.message(deprecated_cls)
            result = deprecated_cls(*args, **kwargs)
            return result

        # Do some magic here by copying any static methods on the class to the wrapper function object.
        # This handles the case where a deprecated class has static or class methods.
        for member_name in dir(deprecated_cls):
            if isinstance(inspect.getattr_static(deprecated_cls, member_name), staticmethod):

                def static_function(cls, method, *sf_args, **sf_kwargs):
                    """Wrapper that will give deprecation messages for calling static methods too"""
                    self.message(cls, method)
                    return getattr(cls, method)(*sf_args, **sf_kwargs)

                setattr(wrapper, member_name, functools.partial(static_function, deprecated_cls, member_name))
            elif isinstance(inspect.getattr_static(deprecated_cls, member_name), classmethod):

                def class_function(cls, method, *cl_args, **cl_kwargs):
                    """Wrapper that will give deprecation messages for calling class methods too"""
                    self.message(cls, method)
                    return getattr(cls, method)(*cl_args, **cl_kwargs)

                setattr(wrapper, member_name, functools.partial(class_function, deprecated_cls, member_name))
        return wrapper


# ==============================================================================================================
# begin-renamed-class
def RenamedClass(cls, old_class_name: str, rename_message: Optional[str] = None) -> object:  # noqa: N802
    """Syntactic sugar to provide a class deprecation that is a simple renaming, where all of the functions in
    the old class are still present in backwards compatible form in the new class.

    Args:
        old_class_name: The name of the class that was renamed
        rename_message: If not None, what to use instead of the old class. If None then assume the new class is used.

    Usage:

    .. code-block:: python

        MyDeprecatedClass = RenamedClass(MyNewClass, "MyDeprecatedClass")
    """
    # end-renamed-class
    @DeprecatedClass(f"Use {cls.__name__ if rename_message is None else rename_message} instead")
    class _RenamedClass(cls):
        __old_name__ = old_class_name

    return _RenamedClass


# ==============================================================================================================
# begin-deprecated-function
def deprecated_function(deprecation_message: str, is_property: bool = False):
    """Decorator to deprecate a function.

    Args:
        deprecation_message: A description of the action the user is to take to avoid the deprecated function.
        is_property: Set this True if the function is a property getter or setter.

    A deprecation message will only be shown once, the first time the deprecated function is called.

    .. code-block:: python

        @deprecated_function("After version 1.5.0 use og.newer_function() instead")
        def older_function():
            pass

    For property getters/setters use this decorator *after* the property decorator.

    .. code-block:: python

        @property
        @deprecated_function("use 'your_prop' instead.", is_property=True)
        def my_prop(self):
            return self.your_prop

        @my_prop.setter
        @deprecated_function("use 'your_prop' instead.", is_property=True)
        def my_prop(self, value):
            self.your_prop = value
    """
    # end-deprecated-function
    def decorator_deprecated(func):
        """Remember the message"""
        # The functools internal decorator lets the help functions drill down into the actual function when asked,
        # rather that
        @functools.wraps(func)
        def wrapper_deprecated(*args, **kwargs):
            func_str = f"'{func.__name__}'" if is_property else f"{func.__name__}()"
            DeprecateMessage.deprecated(f"{func_str} is deprecated: {deprecation_message}")
            return func(*args, **kwargs)

        return wrapper_deprecated

    return decorator_deprecated


# ==============================================================================================================
# begin-deprecated-import
def DeprecatedImport(deprecation_message: str):  # noqa: N802
    """Decorator to deprecate a specific file or module import. Usually the functionality has been deprecated and
    moved to a different file.

    Args:
        deprecation_message: String with the action the user is to perform to avoid the deprecated import

    Usage:

    .. code-block:: python

        '''This is the top line of the imported file'''
        import omni.graph.tools as og
        og.DeprecatedImport("Import 'omni.graph.tools as og' and use og.new_function() instead")

        # The rest of the file can be left as-is for best backward compatibility, or import non-deprecated versions
        # of objects from their new location to avoid duplication.
    """
    # end-deprecated-import
    this_module = inspect.currentframe().f_back.f_locals["__name__"]
    DeprecateMessage.deprecated(f"{this_module} is deprecated: {deprecation_message}")
