"""
Helper module that is used by objc_class.setattr and objc_class.__new__
to transform class attributes and process the class dictionary (for the
latter).

The C code relies on this code to do most semantic checks, and only
performs checks that are needed to avoid crashes.
"""

import objc
import types
import inspect
import dis
import keyword
import warnings
import sys
from ._informal_protocol import _informal_protocol_for_selector
from . import _new

# only public objc_method until the python_method implementation
# in C is gone
__all__ = ("objc_method", "python_method")


NO_VALUE = object()


def _selectorToKeywords(selector):
    if not selector.startswith("init"):
        raise ValueError("selector is not a valid init selector")
    if len(selector) > 4 and not selector[4].isupper():
        raise ValueError("selector is a valid init selector")
    selector = selector[4:]
    if not selector:
        return ()

    parts = selector.split(":")[:-1]
    if not parts:
        return None
    if parts[0].startswith("With"):
        parts[0] = parts[0][4:]
    if len(parts[0]) == 1 or len(parts[0]) > 1 and not parts[0][1].isupper():
        parts[0] = parts[0][:1].lower() + parts[0][1:]

    return tuple(parts)


def setDunderNew(
    class_object,
):
    class_object.__new__ = _new.make_generic_new(class_object)


def processClassDict(
    class_name,
    class_dict,
    meta_dict,
    class_object,
    protocols,
    hidden_instance_methods,
    hidden_class_methods,
):
    """
    First step into creating a subclass for a Cocoa class:

    - Transform attributes
    - Return (instance_variablees, instanstance_methods, class_methods)
    """
    instance_variables = {}
    instance_methods = set()
    class_methods = set()

    for p in protocols:
        if not isinstance(p, (objc.formal_protocol, objc.informal_protocol)):
            raise TypeError(
                "protocols list contains object that isn't an Objective-C "
                f"protocol, but type {type(p).__name__}"
            )

    if "__classcell__" in class_dict and "__module__" in class_dict:
        mod = sys.modules.get(class_dict["__module__"], None)
        if mod is not None:
            try:
                ok = mod.super is objc.super
            except AttributeError:
                ok = False

            if not ok:
                warnings.warn(
                    "Objective-C subclass uses super(), but super is not objc.super",
                    category=objc.ObjCSuperWarning,
                    stacklevel=2,
                )

    super_ivars = set()
    for v in class_object.__dict__.values():
        if isinstance(v, objc.ivar):
            super_ivars.add(v.__name__)

    class_dict["__objc_python_subclass__"] = True
    process_slots(class_dict, instance_variables, class_object)

    # First call all class setup hooks. Those can
    # update the class dictiory, which is why this
    # loop cannot be merged into the next one.
    for key, value in list(class_dict.items()):
        setup = getattr(value, "__pyobjc_class_setup__", NO_VALUE)
        if setup is not NO_VALUE:
            setup(key, class_dict, instance_methods, class_methods)

    for key, value in list(class_dict.items()):
        old_value = value
        new_value = transformAttribute(key, value, class_object, protocols)
        if new_value is not value:
            class_dict[key] = new_value
            value = new_value

        if isinstance(value, objc.selector):
            if isinstance(old_value, python_method):
                continue

        if isinstance(value, objc.selector):
            canonical = value.selector.decode().replace(":", "_")

            if value.isClassMethod:
                del class_dict[key]
                if not value.isHidden:
                    meta_dict[key] = value
                    if canonical != key:
                        meta_dict[canonical] = value

                class_methods.add(value)
            else:
                if value.isHidden:
                    del class_dict[key]
                elif canonical != key:
                    class_dict[canonical] = value

                instance_methods.add(value)

        elif isinstance(value, objc.ivar):
            if value.__name__ in instance_variables:
                raise objc.error(f"{key!r} reimplements objc.ivar {value.__name__!r}")
            if value.__name__ in super_ivars:
                raise objc.error(
                    f"objc.ivar {key!r} overrides instance variable in super class"
                )
            instance_variables[value.__name__] = value

    # Convert the collections to tuples for easier
    # post-processing in the C code.
    #
    # The results are sorted for easier testing, this should
    # be harmless because classes tend to be fairly simple.
    instance_variables = tuple(
        sorted(instance_variables.values(), key=lambda x: x.__name__)
    )
    instance_methods = tuple(
        sorted(
            instance_methods,
            key=lambda x: x.selector if isinstance(x, objc.selector) else x,
        )
    )
    class_methods = tuple(
        sorted(
            class_methods,
            key=lambda x: x.selector if isinstance(x, objc.selector) else x,
        )
    )

    validate_protocols(class_object, instance_methods, class_methods, protocols)

    for sel in instance_methods:
        if isinstance(sel, bytes):
            hidden_instance_methods[sel] = None
        elif sel.isHidden:
            hidden_instance_methods[sel.selector] = sel

    for sel in class_methods:
        if isinstance(sel, bytes):
            hidden_class_methods[sel] = None
        elif sel.isHidden:
            hidden_class_methods[sel.selector] = sel

    new_kw_map = {}

    for name, value in class_dict.items():
        if not isinstance(name, str) or not name.startswith("init"):
            continue
        if len(name) > 4 and not name[4].isupper():
            continue
        if value is None and name == "init":
            new_kw_map[()] = None
            continue
        elif not isinstance(value, objc.selector):
            continue

        new_kw_map[_selectorToKeywords(value.selector.decode())] = name
    if new_kw_map:
        _new.NEW_MAP[class_name] = new_kw_map

    return (
        instance_variables,
        instance_methods,
        class_methods,
        # Either __new__ in this class, or a custom new in a parent class
        ("__new__" in class_dict)
        or type(class_object.__new__) == type(lambda: 1),  # noqa: E721
    )


def validate_protocols(class_object, instance_methods, class_methods, protocols):
    instance_map = {
        sel.selector: sel for sel in instance_methods if isinstance(sel, objc.selector)
    }
    class_map = {
        sel.selector: sel for sel in class_methods if isinstance(sel, objc.selector)
    }

    for proto in protocols:
        for is_class_method, method_list in [
            (False, proto.instanceMethods()),
            (True, proto.classMethods()),
        ]:
            for sel in method_list:
                if is_class_method:
                    found = class_map.get(sel["selector"], None)
                    if found is None:
                        found = getattr(
                            class_object.pyobjc_classMethods,
                            sel["selector"].decode().replace(":", "_"),
                            None,
                        )
                else:
                    found = instance_map.get(sel["selector"], None)
                    if found is None:
                        found = getattr(
                            class_object.pyobjc_instanceMethods,
                            sel["selector"].decode().replace(":", "_"),
                            None,
                        )

                if found is None:
                    if sel["required"]:
                        raise TypeError(
                            f"class does not fully implemented protocol {proto.__name__!r}: "
                            "no implementation for "
                            f"{'class' if is_class_method else 'instance'} method "
                            f"{sel['selector'].decode()!r}"
                        )
                    continue

                if sel["typestr"] != found.signature:
                    raise TypeError(
                        f"class does not correctly implement protocol {proto.__name__!r}: "
                        f"the signature for method {sel['selector'].decode()} is "
                        f"{found.signature.decode()!r} instead of {sel['typestr'].decode()!r}"
                    )


def process_slots(class_dict, instance_variables, class_object):
    """
    Process the ``__slots__`` attribute, if any.
    """
    slots = class_dict.get("__slots__", NO_VALUE)
    if slots is NO_VALUE:
        if not class_object.__hasdict__:
            instance_variables["__dict__"] = objc.ivar(
                "__dict__", objc._C_PythonObject, isSlot=True
            )
    else:
        if isinstance(slots, str):
            if slots in class_dict:
                raise objc.error(f"slot {slots!r} redefines {class_dict[slots]!r}")

            class_dict[slots] = objc.ivar(slots, objc._C_PythonObject, isSlot=True)

        else:
            for nm in slots:
                if nm in class_dict:
                    raise objc.error(f"slot {nm!r} redefines {class_dict[nm]!r}")

                class_dict[nm] = objc.ivar(nm, objc._C_PythonObject, isSlot=True)

    class_dict["__slots__"] = ()


def transformAttribute(name, value, class_object, protocols):
    """
    Transform 'value' before it will be added to a class

    For callables this will convert the value to an
    'objc.selector' object where appropriate, other values
    are returned as-is.
    """
    if not callable(value) and not isinstance(value, classmethod):
        return value
    elif isinstance(value, (objc.native_selector, staticmethod, type)):
        return value
    elif isinstance(value, objc.selector):
        if value.callable is None:
            raise ValueError(f"{name!r}: selector object without callable")
        return objc.selector(
            value.callable,
            value.selector,
            value.signature,
            isClassMethod=value.isClassMethod,
            isRequired=value.isRequired,
            isHidden=value.isHidden,
        )
    elif is_generator_or_async(value):
        return value
    elif isinstance(value, python_method):
        return value.__wrapped__

    isclass = None

    if not isinstance(name, str):
        selname = None
    else:
        selname = default_selector(name)
    signature = None

    if selname is not None and isinstance(class_object, objc.objc_class):
        registered = objc._registeredMetadataForSelector(class_object, selname)
    else:
        registered = None

    if selname in {b"copy", b"copyWithZone:", b"mutableCopy", b"mutableCopyWithZone:"}:
        # Specials: NSObject defines copy and copyWithZone as class methods,
        # while most users would expect to define an instance method.
        isclass = False
    helper_signature = NO_VALUE

    # DWIM: Copy classmethod-ness and signature from
    # a pre-existing method on the class, but prefer
    # using an instance method over a class method, as
    # this makes it a lot easier to implement methods from
    # the NSObject protocol (amongst others)
    if isinstance(name, str) and helper_signature is NO_VALUE:
        current = getattr(class_object.pyobjc_instanceMethods, name, NO_VALUE)
        if current is NO_VALUE:
            current = getattr(class_object.pyobjc_classMethods, name, NO_VALUE)
        if isinstance(current, objc.selector):
            if isclass is None:
                isclass = current.isClassMethod
            # ishidden = current.isHidden
            signature = current.signature
            selname = current.selector

    if isinstance(value, classmethod):
        # Unwrap "classmethod" instances.
        # XXX: Switch to __wrapped__ when support for 3.9 is dropped
        value = value.__func__
        isclass = True

        if isinstance(value, python_method):
            # Deal with:
            #   @classmethod
            #   @python_method
            #   def ...
            #
            return classmethod(value.__wrapped__)

        assert not isinstance(value, classmethod)

    if isinstance(value, objc_method):
        # Unwrap "objc_method" instances
        if value.isclass is not None:
            isclass = value.isclass

        if value.selector is not None:
            selname = value.selector
            registered = objc._registeredMetadataForSelector(class_object, selname)

        if value.signature is not None:
            signature = value.signature
            registered = None

        if selname is None:
            raise ValueError(
                f"{name!r} is an objc_method instance, but cannot determine Objective-C selector"
            )

        if isinstance(value.__wrapped__, classmethod):
            # Deal with:
            #    @objc_method
            #    @classsmethod
            #    def ...
            #
            if value.isclass is not None:
                raise ValueError(
                    f"{name!r} is objc_method with isclass specified wraps classmethod"
                )
            isclass = True

            # XXX switch from __func__ to __wrapped-__ when support
            # for 3.9 ends.
            value = value.__wrapped__.__func__
        else:
            value = value.__wrapped__

    if registered and "full_signature" in registered:
        signature = registered["full_signature"]

    if selname is None:
        # The 'name' does not fit into the selector naming
        # convention, and nothing provided a better name.
        #
        # Assume this is meant to be a regular python method.
        if not isinstance(name, str):
            raise TypeError(
                f"method name is of type {type(name).__name__}, not a string"
            )
        if isclass:
            return classmethod(value)
        return value

    argcount = selname.count(b":")

    if signature is None:
        # Look for the signature in an informal protocol when it
        # isn't set by some other means.
        informal = _informal_protocol_for_selector(selname)
        if informal is not None:
            for meth in informal.selectors:
                if meth.selector == selname:
                    if isclass is not None and meth.isClassMethod != isclass:
                        # "classmethod"-ness was explicitly set (inherit,
                        # classmethod, or objc_method), ignore the informal
                        # protocol if it doesn't match.
                        continue

                    signature = meth.signature
                    isclass = meth.isClassMethod

    if isclass is None:
        isclass = False

    if signature is None and protocols:
        for prot in protocols:
            if isclass:
                info = prot.descriptionForClassMethod_(selname)
            else:
                info = prot.descriptionForInstanceMethod_(selname)
        if info is not None:
            signature = info[1]

    if signature is None:
        # Calculate a default signature based on the selector shape
        if isinstance(value, (types.FunctionType, types.MethodType)):
            returns_object = returns_value(value)
        else:
            returns_object = True

        signature = (
            (objc._C_ID if returns_object else objc._C_VOID)
            + objc._C_ID
            + objc._C_SEL
            + (objc._C_ID * argcount)
        )

        if registered:
            overridden_rval = (
                registered["retval"].get("type", None)
                if registered["retval"] is not None
                else None
            )
            overridden_args = {}
            for idx, a in enumerate(registered["arguments"]):
                if a and "type" in a:
                    overridden_args[idx] = a["type"]

            if overridden_rval or overridden_args:
                signature_parts = list(objc.splitSignature(signature))
                if overridden_rval:
                    signature_parts[0] = overridden_rval

                for idx in overridden_args:
                    if idx >= len(signature_parts) - 1:
                        raise objc.error(
                            f"{name!r} has invalid metadata, index {idx} out of range"
                        )
                    signature_parts[idx + 1] = overridden_args[idx]
                signature = b"".join(signature_parts)

    # All information for creating a selector is available, do a
    # last consistency check.
    try:
        pysig = inspect.signature(value)
    except (AttributeError, ValueError, TypeError):
        pass
    else:
        pos = 0
        pos_default = 0
        kwonly = 0
        kwonly_default = 0
        has_var_pos = False

        for p in pysig.parameters.values():
            if p.kind == inspect.Parameter.KEYWORD_ONLY:
                kwonly += 1
                if p.default is not inspect.Parameter.empty:
                    kwonly_default += 1

            elif p.kind == inspect.Parameter.VAR_KEYWORD:
                pass

            elif p.kind == inspect.Parameter.VAR_POSITIONAL:
                has_var_pos = True

            else:
                pos += 1
                if p.default is not inspect.Parameter.empty:
                    pos_default += 1

        if kwonly - kwonly_default:
            # Calls from Obejctive-C to Python will never have keyword arguments,
            # therefore this callable is not compatible with use as a selector.
            raise objc.BadPrototypeError(
                f"{value!r} has {kwonly - kwonly_default} keyword-only arguments without a default"
            )

        if not has_var_pos and (
            (pos < argcount + 1) or (pos - pos_default > argcount + 1)
        ):
            # 'value' does not have a '*arg' argument, and does not accept calls
            # with exactly 'argcount' arguments, therefore this callable is not
            # compatible with us as a selector.
            #
            # 'argcount' does not count the implicit self argument
            if pos_default:
                raise objc.BadPrototypeError(
                    f"{selname.decode()!r} expects {argcount} arguments, "
                    f"{value!r} has between {pos - pos_default - 1} and {pos - 1} positional arguments"
                )
            else:
                raise objc.BadPrototypeError(
                    f"{selname.decode()!r} expects {argcount} arguments, "
                    f"{value!r} has {pos - 1} positional arguments"
                )

    # XXX: This is needed because SomeClass.pyobjc_instanceMethods.hiddenSelector.isHidden
    #      is false :-(
    ishidden = False
    for cls in class_object.mro():  # pragma: no branch
        if not issubclass(cls, objc.objc_object):
            # Skip all non-objc class in the MRO to ignore
            # mixin classes.
            continue
        if selname in cls.pyobjc_hiddenSelectors(isclass):
            ishidden = True
            break

    return objc.selector(
        value, selname, signature, isClassMethod=isclass, isHidden=ishidden
    )


def is_generator_or_async(value):
    """
    Returns true for generators and async functions
    """
    # This guard is there to deal with some edge-case testing the
    # PyObjC test suite, should never trigger in production code.
    if not isinstance(value, types.FunctionType):
        return False
    if not isinstance(value.__code__, types.CodeType):
        return False

    if inspect.iscoroutine(value) or inspect.iscoroutinefunction(value):
        return True
    elif inspect.isgenerator(value) or inspect.isgeneratorfunction(value):
        return True
    return False


def returns_value(func):
    """
    Return True if 'func' explicitly returns a value

    """
    # This will return False for functions where all explicit
    # returns are of the form "return" or "return None". The
    # latter is a false negative, but cannot be avoided with
    # bytecode inspection.
    # XXX: This will give a false positive for functions
    #      that only contain "return None" paths for
    #      returning a value.
    #
    # Until Python 3.14 constant 0 was always None, due to
    # changes in the bytecode compiler that's no longer true.

    if not isinstance(func.__code__, types.CodeType):
        return True

    prev = None
    consts = func.__code__.co_consts

    for inst in dis.get_instructions(func):
        if inst.opname == "RETURN_VALUE":
            assert prev is not None
            if prev.opname == "LOAD_CONST" and consts[prev.arg] is not None:
                return True
            elif prev.opname != "LOAD_CONST":
                return True

        elif inst.opname == "RETURN_CONST" and consts[inst.arg] is not None:
            # New in Python 3.12.
            return True
        prev = inst

    return False


def default_selector(name):
    """
    Returns a byte string with the selector for 'name',
    or None if there is no default selector
    """
    if name.endswith("__") and keyword.iskeyword(name[:-2]):
        name = name[:-2]
    if name.startswith("__") and name.endswith("__"):
        return None

    if "_" in name[1:] and not name.endswith("_"):
        return None
    value = name.replace("_", ":").encode()
    if value.startswith(b":"):
        value = b"_" + value[1:]
    return value


class objc_method:
    """
    Decorator that marks a method that should definitely
    be represented as an Objective-C selector

    This decorator can be used in two ways:

    - Without parens:

        @objc_method
        def someMethod(self): ...

    - With parens:

        @objc_method()
        def someMethod(self): ...

    In the second form a number of keyword-only arguments can
    be used:

    - selname [bytes]: Objective-C selector name for the method
    - signature [bytes]: Objective-C type encoding for the method
    - isclass [bool]: Determines if the value is a class method

    When these attributes are not provided they will be calculated
    from the context (name, callable, class to which this
    method is added)
    """

    __slots__ = ("__wrapped__", "selector", "signature", "isclass")

    def __init__(self, value=None, *, selector=None, signature=None, isclass=None):
        self.__wrapped__ = value
        if self.__wrapped__ is not None:
            if not callable(self.__wrapped__) and not isinstance(
                self.__wrapped__, classmethod
            ):
                raise TypeError("'value' is not a callable")

        self.selector = selector
        self.signature = signature
        self.isclass = isclass

    def __call__(self, *args, **kwds):
        if self.__wrapped__ is None:
            if len(kwds) != 0:
                raise TypeError("Unexpected keyword arguments")
            if len(args) != 1:
                raise TypeError("Expecting exactly 1 argument")
            self.__wrapped__ = args[0]
            if not callable(self.__wrapped__) and not isinstance(
                self.__wrapped__, classmethod
            ):
                raise TypeError("'value' is not a callable")
            return self

        return self.__wrapped__(*args, **kwds)


class python_method:
    """
    Decorator that marks a method that should definitely
    not be represented as an Objective-C selector

    This decorator can be used in two ways:

    - Without parens:

        @python_method
        def someMethod(self): ...

    - With parens:

        @python_method()
        def someMethod(self): ...

    The second form has no keyword arguments and is
    provided for compatibility with objc_method.
    """

    __slots__ = ("__wrapped__",)

    def __init__(self, value=None):
        self.__wrapped__ = value
        if self.__wrapped__ is not None:
            if not callable(self.__wrapped__) and not isinstance(
                self.__wrapped__, classmethod
            ):
                raise TypeError(f"{value!r} is not a callable")

    def __get__(self, instance, owner):
        return self.__wrapped__.__get__(instance, owner)

    def __call__(self, *args, **kwds):
        if self.__wrapped__ is None:
            if len(kwds) != 0:
                raise TypeError("Unexpected keyword arguments")
            if len(args) != 1:
                raise TypeError("Expecting exactly 1 argument")
            self.__wrapped__ = args[0]
            if not callable(self.__wrapped__) and not isinstance(
                self.__wrapped__, classmethod
            ):
                raise TypeError("'value' is not a callable")
            return self

        return self.__wrapped__(*args, **kwds)

    @property
    def callable(self):  # noqa: A003
        # Compatibility with older version of PyObjC
        warnings.warn(
            "python_method.callable is deprecated, use __wrapped__ instead",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.__wrapped__


objc.options._transformAttribute = transformAttribute
objc.options._processClassDict = processClassDict
objc.options._setDunderNew = setDunderNew
