import functools
import types
import warnings
import importlib
import sys

from gi import PyGIDeprecationWarning
from gi._gi import CallableInfo, pygobject_new_full
from gi._constants import TYPE_NONE, TYPE_INVALID

# support overrides in different directories than our gi module
from pkgutil import extend_path

__path__ = extend_path(__path__, __name__)


# namespace -> (attr, replacement)
_deprecated_attrs = {}


class OverridesProxyModule(types.ModuleType):
    """Wraps a introspection module and contains all overrides"""

    __slots__ = ("_deprecations", "_introspection_module")

    def __init__(self, introspection_module):
        super().__init__(introspection_module.__name__)
        self._introspection_module = introspection_module
        self._deprecations = {}

    def __getattr__(self, name):
        if name in self._deprecations:
            value, warning = self._deprecations[name]
            warnings.warn(warning, stacklevel=2)
            return value
        return getattr(self._introspection_module, name)

    def __delattr__(self, name):
        found = False
        if name in self.__dict__:
            del self.__dict__[name]
            found = True
        if name in self._deprecations:
            del self._deprecations[name]
            found = True
        try:
            delattr(self._introspection_module, name)
        except AttributeError:
            # Silence the exception if the attribute was previously found.
            if not found:
                raise

    def __dir__(self):
        result = set(super().__dir__())
        result.update(self._deprecations.keys())
        result.update(dir(self._introspection_module))
        return sorted(result)

    def __repr__(self):
        return f"<{type(self).__name__} {self._introspection_module!r}>"


class _DeprecatedAttribute:
    """A deprecation descriptor for OverridesProxyModule subclasses.

    Emits a PyGIDeprecationWarning on every access and tries to act as a
    normal instance attribute (can be replaced and deleted).
    """

    def __init__(self, namespace, attr, value, replacement):
        self._attr = attr
        self._value = value
        self._warning = PyGIDeprecationWarning(
            f"{namespace}.{attr} is deprecated; use {replacement} instead"
        )

    def __get__(self, instance, owner):
        if instance is None:
            raise AttributeError(self._attr)
        warnings.warn(self._warning, stacklevel=2)
        return self._value

    def __set__(self, instance, value):
        attr = self._attr
        # delete the descriptor, then set the instance value
        delattr(type(instance), attr)
        setattr(instance, attr, value)

    def __delete__(self, instance):
        # delete the descriptor
        delattr(type(instance), self._attr)


def load_overrides(introspection_module):
    """Loads overrides for an introspection module.

    Either returns the same module again in case there are no overrides or a
    proxy module including overrides. Doesn't cache the result.
    """
    namespace = introspection_module.__name__.rsplit(".", 1)[-1]
    module_key = "gi.repository." + namespace

    # We use sys.modules so overrides can import from gi.repository
    # but restore everything at the end so this doesn't have any side effects
    has_old = module_key in sys.modules
    old_module = sys.modules.get(module_key)

    proxy = OverridesProxyModule(introspection_module)
    sys.modules[module_key] = proxy

    # backwards compat:
    # gedit uses gi.importer.modules['Gedit']._introspection_module
    from ..importer import modules

    assert hasattr(proxy, "_introspection_module")
    modules[namespace] = proxy

    try:
        override_package_name = "gi.overrides." + namespace
        spec = importlib.util.find_spec(override_package_name)
        override_loader = spec.loader if spec is not None else None

        # Avoid checking for an ImportError, an override might
        # depend on a missing module thus causing an ImportError
        if override_loader is None:
            return introspection_module

        override_mod = importlib.import_module(override_package_name)

    finally:
        del modules[namespace]
        del sys.modules[module_key]
        if has_old:
            sys.modules[module_key] = old_module

    # backwards compat: for gst-python/gstmodule.c,
    # which tries to access Gst.Fraction through
    # Gst._overrides_module.Fraction. We assign the proxy instead as that
    # contains all overridden classes like Fraction during import anyway and
    # there is no need to keep the real override module alive.
    proxy._overrides_module = proxy

    override_all = []
    if hasattr(override_mod, "__all__"):
        override_all = override_mod.__all__

    for var in override_all:
        try:
            item = getattr(override_mod, var)
        except (AttributeError, TypeError):
            # Gedit puts a non-string in __all__, so catch TypeError here
            continue
        setattr(proxy, var, item)

    # Replace deprecated module level attributes with a descriptor
    # which emits a warning when accessed.
    for attr, replacement in _deprecated_attrs.pop(namespace, []):
        try:
            value = getattr(proxy, attr)
        except AttributeError:
            raise AssertionError(
                f"{attr} was set deprecated but wasn't added to __all__"
            )
        delattr(proxy, attr)
        proxy._deprecations[attr] = (
            value,
            PyGIDeprecationWarning(
                f"{namespace}.{attr} is deprecated; use {replacement} instead"
            ),
        )

    return proxy


def override(type_):
    """Decorator for registering an override.

    Other than objects added to __all__, these can get referenced in the same
    override module via the gi.repository module (get_parent_for_object() does
    for example), so they have to be added to the module immediately.
    """
    if isinstance(type_, CallableInfo):
        func = type_
        namespace = func.__module__.rsplit(".", 1)[-1]
        module = sys.modules["gi.repository." + namespace]

        def wrapper(func):
            setattr(module, func.__name__, func)
            return func

        return wrapper
    if isinstance(type_, types.FunctionType):
        raise TypeError(f"func must be a gi function, got {type_}")
    try:
        info = getattr(type_, "__info__")
    except AttributeError:
        raise TypeError(
            f"Can not override a type {type_.__name__}, which is not in a gobject "
            "introspection typelib"
        )

    if not type_.__module__.startswith("gi.overrides"):
        raise KeyError(
            "You have tried override outside of the overrides module. "
            f"This is not allowed ({type_}, {type_.__module__})"
        )

    g_type = info.get_g_type()
    assert g_type != TYPE_NONE
    if g_type != TYPE_INVALID:
        g_type.pytype = type_

    namespace = type_.__module__.rsplit(".", 1)[-1]
    module = sys.modules["gi.repository." + namespace]
    setattr(module, type_.__name__, type_)

    return type_


overridefunc = override
"""Deprecated"""


def deprecated(fn, replacement):
    """Decorator for marking methods and classes as deprecated."""

    @functools.wraps(fn)
    def wrapped(*args, **kwargs):
        warnings.warn(
            f"{fn.__name__} is deprecated; use {replacement} instead",
            PyGIDeprecationWarning,
            stacklevel=2,
        )
        return fn(*args, **kwargs)

    return wrapped


def deprecated_attr(namespace, attr, replacement):
    """Marks a module level attribute as deprecated. Accessing it will emit
    a PyGIDeprecationWarning warning.

    e.g. for ``deprecated_attr("GObject", "STATUS_FOO", "GLib.Status.FOO")``
    accessing GObject.STATUS_FOO will emit:

        "GObject.STATUS_FOO is deprecated; use GLib.Status.FOO instead"

    :param str namespace:
        The namespace of the override this is called in.
    :param str namespace:
        The attribute name (which gets added to __all__).
    :param str replacement:
        The replacement text which will be included in the warning.
    """
    _deprecated_attrs.setdefault(namespace, []).append((attr, replacement))


def deprecated_init(
    super_init_func,
    arg_names,
    ignore=(),
    deprecated_aliases={},
    deprecated_defaults={},
    category=PyGIDeprecationWarning,
    stacklevel=2,
):
    """Wrapper for deprecating GObject based __init__ methods which specify
    defaults already available or non-standard defaults.

    :param callable super_init_func:
        Initializer to wrap.
    :param list arg_names:
        Ordered argument name list.
    :param list ignore:
        List of argument names to ignore when calling the wrapped function.
        This is useful for function which take a non-standard keyword that is munged elsewhere.
    :param dict deprecated_aliases:
        Dictionary mapping a keyword alias to the actual g_object_newv keyword.
    :param dict deprecated_defaults:
        Dictionary of non-standard defaults that will be used when the
        keyword is not explicitly passed.
    :param Exception category:
        Exception category of the error.
    :param int stacklevel:
        Stack level for the deprecation passed on to warnings.warn
    :returns: Wrapped version of ``super_init_func`` which gives a deprecation
        warning when non-keyword args or aliases are used.
    :rtype: callable
    """

    # We use a list of argument names to maintain order of the arguments
    # being deprecated. This allows calls with positional arguments to
    # continue working but with a deprecation message.
    def new_init(self, *args, **kwargs):
        """Initializer for a GObject based classes with support for property
        sets through the use of explicit keyword arguments.
        """
        # Print warnings for calls with positional arguments.
        if args:
            warnings.warn(
                "Using positional arguments with the GObject constructor has been deprecated. "
                'Please specify keyword(s) for "{}" or use a class specific constructor. '
                "See: https://wiki.gnome.org/Projects/PyGObject/InitializerDeprecations".format(
                    ", ".join(arg_names[: len(args)])
                ),
                category,
                stacklevel=stacklevel,
            )
            new_kwargs = dict(zip(arg_names, args))
        else:
            new_kwargs = {}
        new_kwargs.update(kwargs)

        # Print warnings for alias usage and transfer them into the new key.
        aliases_used = []
        for key, alias in deprecated_aliases.items():
            if alias in new_kwargs:
                new_kwargs[key] = new_kwargs.pop(alias)
                aliases_used.append(key)

        if aliases_used:
            warnings.warn(
                'The keyword(s) "{}" have been deprecated in favor of "{}" respectively. '
                "See: https://wiki.gnome.org/Projects/PyGObject/InitializerDeprecations".format(
                    ", ".join(deprecated_aliases[k] for k in sorted(aliases_used)),
                    ", ".join(sorted(aliases_used)),
                ),
                category,
                stacklevel=stacklevel,
            )

        # Print warnings for defaults different than what is already provided by the property
        defaults_used = []
        for key in deprecated_defaults:
            if key not in new_kwargs:
                new_kwargs[key] = deprecated_defaults[key]
                defaults_used.append(key)

        if defaults_used:
            warnings.warn(
                "Initializer is relying on deprecated non-standard "
                "defaults. Please update to explicitly use: {} "
                "See: https://wiki.gnome.org/Projects/PyGObject/InitializerDeprecations".format(
                    ", ".join(
                        f"{k}={deprecated_defaults[k]}" for k in sorted(defaults_used)
                    )
                ),
                category,
                stacklevel=stacklevel,
            )

        # Remove keywords that should be ignored.
        for key in ignore:
            if key in new_kwargs:
                new_kwargs.pop(key)

        return super_init_func(self, **new_kwargs)

    return new_init


def strip_boolean_result(method, exc_type=None, exc_str=None, fail_ret=None):
    """Translate method's return value for stripping off success flag.

    There are a lot of methods which return a "success" boolean and have
    several out arguments. Translate such a method to return the out arguments
    on success and None on failure.
    """

    @functools.wraps(method)
    def wrapped(*args, **kwargs):
        ret = method(*args, **kwargs)
        if ret[0]:
            if len(ret) == 2:
                return ret[1]
            return ret[1:]
        if exc_type:
            raise exc_type(exc_str or "call failed")
        return fail_ret

    return wrapped


def wrap_list_store_sort_func(func):
    def wrap(a, b, *user_data):
        a = pygobject_new_full(a, False)
        b = pygobject_new_full(b, False)
        return func(a, b, *user_data)

    return wrap


def wrap_list_store_equal_func(func):
    def wrap(a, b, *user_data):
        a = pygobject_new_full(a, False)
        b = pygobject_new_full(b, False)
        return func(a, b, *user_data)

    return wrap
