"""
Helper code for implementing unittests.

This module is unsupported and is primairily used in the PyObjC
testsuite.
"""

import contextlib
import gc as _gc
import os as _os
import re as _re
import struct as _struct
import sys as _sys
import unittest as _unittest
import subprocess as _subprocess
import pickle as _pickle
from sysconfig import get_config_var as _get_config_var

import objc


# Ensure that methods in this module get filtered in the tracebacks
# from unittest
__unittest = False

# Have a way to disable the autorelease pool behaviour
_usepool = not _os.environ.get("PYOBJC_NO_AUTORELEASE")

# XXX: Python 2 Compatibility for the PyObjC Test Suite
try:
    unicode
except NameError:
    unicode = str

try:
    long
except NameError:
    long = int

try:
    basestring
except NameError:
    basestring = str

try:
    unichr
except NameError:
    unichr = chr


def _typemap(tp):
    if tp is None:
        return None
    return (
        tp.replace(b"_NSRect", b"CGRect")
        .replace(b"_NSPoint", b"CGPoint")
        .replace(b"_NSSize", b"CGSize")
    )


@contextlib.contextmanager
def pyobjc_options(**kwds):
    orig = {}
    try:
        for k in kwds:
            orig[k] = getattr(objc.options, k)
            setattr(objc.options, k, kwds[k])

        yield

    finally:
        for k in orig:
            setattr(objc.options, k, orig[k])


def sdkForPython(_cache=[]):  # noqa: B006, M511
    """
    Return the SDK version used to compile Python itself,
    or None if no framework was used
    """
    if not _cache:
        cflags = _get_config_var("CFLAGS")
        m = _re.search(r"-isysroot\s+([^ ]*)(\s|$)", cflags)
        if m is None:
            _cache.append(None)
            return None

        path = m.group(1)
        if path == "/":
            result = tuple(map(int, os_release().split(".")))
            _cache.append(result)
            return result

        bn = _os.path.basename(path)
        version = bn[6:-4]
        if version.endswith("u"):
            version = version[:-1]

        result = tuple(map(int, version.split(".")))
        _cache.append(result)
        return result

    return _cache[0]


def fourcc(v):
    """
    Decode four-character-code integer definition

    (e.g. 'abcd')
    """
    return _struct.unpack(">i", v)[0]


def cast_int(value):
    """
    Cast value to 32bit integer

    Usage:
        cast_int(1 << 31) == -1

    (where as: 1 << 31 == 2147483648)
    """
    value = value & 0xFFFFFFFF
    if value & 0x80000000:
        value = ~value + 1 & 0xFFFFFFFF
        return -value
    else:
        return value


def cast_longlong(value):
    """
    Cast value to 64bit integer

    Usage:
        cast_longlong(1 << 63) == -1
    """
    value = value & 0xFFFFFFFFFFFFFFFF
    if value & 0x8000000000000000:
        value = ~value + 1 & 0xFFFFFFFFFFFFFFFF
        return -value
    else:
        return value


def cast_uint(value):
    """
    Cast value to 32bit integer

    Usage:
        cast_int(1 << 31) == 2147483648

    """
    value = value & 0xFFFFFFFF
    return value


def cast_ulonglong(value):
    """
    Cast value to 64bit integer
    """
    value = value & 0xFFFFFFFFFFFFFFFF
    return value


_os_release = None


def os_release():
    """
    Returns the release of macOS (for example 10.5.1).
    """
    global _os_release
    if _os_release is not None:
        return _os_release

    _os_release = (
        _subprocess.check_output(["sw_vers", "-productVersion"]).decode().strip()
    )

    return _os_release


def arch_only(arch):
    """
    Usage::
        class Tests (unittest.TestCase):

            @arch_only("arm64")
            def testArm64(self):
                pass

    The test runs only when the specified architecture matches
    """

    def decorator(function):
        return _unittest.skipUnless(objc.arch == arch, f"{arch} only")(function)

    return decorator


def min_python_release(version):
    """
    Usage::

        class Tests (unittest.TestCase):

            @min_python_release('3.2')
            def test_python_3_2(self):
                pass
    """
    parts = tuple(map(int, version.split(".")))
    return _unittest.skipUnless(
        _sys.version_info[:2] >= parts, f"Requires Python {version} or later"
    )


def _sort_key(version):
    parts = version.split(".")
    if len(parts) == 2:
        parts.append("0")

    if len(parts) != 3:
        raise ValueError(f"Invalid version: {version!r}")

    return tuple(int(x) for x in parts)


def os_level_key(release):
    """
    Return an object that can be used to compare two releases.
    """
    return _sort_key(release)


def min_sdk_level(release):
    """
    Usage::

        class Tests (unittest.TestCase):
            @min_sdk_level('10.6')
            def testSnowLeopardSDK(self):
                pass
    """
    v = (objc.PyObjC_BUILD_RELEASE // 100, objc.PyObjC_BUILD_RELEASE % 100, 0)
    return _unittest.skipUnless(
        v >= os_level_key(release), f"Requires build with SDK {release} or later"
    )


def max_sdk_level(release):
    """
    Usage::

        class Tests (unittest.TestCase):
            @max_sdk_level('10.5')
            def testUntilLeopardSDK(self):
                pass
    """
    v = (objc.PyObjC_BUILD_RELEASE // 100, objc.PyObjC_BUILD_RELEASE % 100, 0)
    return _unittest.skipUnless(
        v <= os_level_key(release), f"Requires build with SDK {release} or later"
    )


def min_os_level(release):
    """
    Usage::

        class Tests (unittest.TestCase):

            @min_os_level('10.6')
            def testSnowLeopardCode(self):
                pass
    """
    return _unittest.skipUnless(
        os_level_key(os_release()) >= os_level_key(release),
        f"Requires macOS {release} or later",
    )


def max_os_level(release):
    """
    Usage::

        class Tests (unittest.TestCase):

            @max_os_level('10.5')
            def testUntilLeopard(self):
                pass
    """
    return _unittest.skipUnless(
        os_level_key(os_release()) <= os_level_key(release),
        f"Requires macOS up to {release}",
    )


def os_level_between(min_release, max_release):
    """
    Usage::

        class Tests (unittest.TestCase):

            @os_level_between('10.5', '10.8')
            def testUntilLeopard(self):
                pass
    """
    return _unittest.skipUnless(
        os_level_key(min_release)
        <= os_level_key(os_release())
        <= os_level_key(max_release),
        f"Requires macOS {min_release} up to {max_release}",
    )


_poolclass = objc.lookUpClass("NSAutoreleasePool")

# NOTE: On at least macOS 10.8 there are multiple proxy classes for CFTypeRef...
_nscftype = tuple(cls for cls in objc.getClassList(1) if "NSCFType" in cls.__name__)

_typealias = {}

_typealias[objc._C_LNG_LNG] = objc._C_LNG
_typealias[objc._C_ULNG_LNG] = objc._C_ULNG

_idlike_cache = set()


class TestCase(_unittest.TestCase):
    """
    A version of TestCase that wraps every test into its own
    autorelease pool.

    This also adds a number of useful assertion methods
    """

    # New API for testing function/method signatures, with one assert for
    # the callable and one assert each for every return value and argument.
    #
    # Primary reason for the new API is to ensure that all metadata overrides
    # are explicitly tested.

    def assertManualBinding(self, func):
        if hasattr(func, "__metadata__"):
            self.fail(f"{func} has automatic bindings")

    def assertIsCFType(self, tp, message=None):
        if not isinstance(tp, objc.objc_class):
            self.fail(message or f"{tp!r} is not a CFTypeRef type")

        if any(x is tp for x in _nscftype):
            self.fail(message or f"{tp!r} is not a unique CFTypeRef type")

        for cls in tp.__bases__:
            if "NSCFType" in cls.__name__:
                return

        self.fail(message or f"{tp!r} is not a CFTypeRef type")

        # NOTE: Don't test if this is a subclass of one of the known
        #       CF roots, this tests is mostly used to ensure that the
        #       type is distinct from one of those roots.
        # NOTE: With the next two lines enabled there are spurious test
        #       failures when a CF type is toll-free bridged to an
        #       (undocumented) Cocoa class. It might be worthwhile to
        #       look for these, but not in the test suite.
        # if not issubclass(tp, _nscftype):
        #    self.fail(message or "%r is not a CFTypeRef subclass"%(tp,))

    def assertIsEnumType(self, tp):
        if not hasattr(tp, "__supertype__"):
            # Ducktyping for compatibility with Python 3.7
            # or earlier.
            self.fail(f"{tp!r} is not a typing.NewType")

        if tp.__supertype__ != int:
            self.fail(f"{tp!r} is not a typing.NewType based on 'int'")

    def assertIsTypedEnum(self, tp, base):
        if not hasattr(tp, "__supertype__"):
            # Ducktyping for compatibility with Python 3.7
            # or earlier.
            self.fail(f"{tp!r} is not a typing.NewType")

        if tp.__supertype__ != base:
            self.fail(f"{tp!r} is not a typing.NewType based on {base.__name__!r}")

    def assertIsOpaquePointer(self, tp, message=None):
        if not hasattr(tp, "__pointer__"):
            self.fail(message or f"{tp!r} is not an opaque-pointer")

        if not hasattr(tp, "__typestr__"):
            self.fail(message or f"{tp!r} is not an opaque-pointer")

    def assertResultIsNullTerminated(self, method, message=None):
        info = method.__metadata__()
        if not info.get("retval", {}).get("c_array_delimited_by_null"):
            self.fail(message or f"result of {method!r} is not a null-terminated array")

    def assertIsNullTerminated(self, method, message=None):
        info = method.__metadata__()
        if not info.get("c_array_delimited_by_null") or not info.get("variadic"):
            self.fail(
                message
                or "%s is not a variadic function with a "
                "null-terminated list of arguments" % (method,)
            )

    def assertArgIsIDLike(self, method, argno, message=None):
        global _idlike_cache

        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        tp = info["arguments"][argno + offset].get("type")

        if tp in {b"@", b"^@", b"n^@", b"N^@", b"o^@"}:
            return

        if tp in _idlike_cache:
            return
        elif tp.startswith(b"^") and tp[1:] in _idlike_cache:
            return
        elif tp.startswith(b"o^") and tp[2:] in _idlike_cache:
            return
        elif tp.startswith(b"n^") and tp[2:] in _idlike_cache:
            return
        elif tp.startswith(b"N^") and tp[2:] in _idlike_cache:
            return

        # Assume that tests are supposed to pass,
        # our cache may be out of date
        tmp = set(objc._idSignatures())
        _idlike_cache = set(tmp)

        if tp in _idlike_cache:
            return
        elif tp.startswith(b"^") and tp[1:] in _idlike_cache:
            return
        elif tp.startswith(b"o^") and tp[2:] in _idlike_cache:
            return
        elif tp.startswith(b"n^") and tp[2:] in _idlike_cache:
            return
        elif tp.startswith(b"N^") and tp[2:] in _idlike_cache:
            return

        self.fail(
            message or "argument %d of %r is not IDLike (%r)" % (argno, method, tp)
        )

    def assertResultIsIDLike(self, method, message=None):
        global _idlike_cache

        info = method.__metadata__()
        tp = info["retval"].get("type")

        if tp in {b"@", b"^@", b"n^@", b"N^@", b"o^@"}:
            return

        if tp in _idlike_cache:
            return
        elif tp.startswith(b"^") and tp[1:] in _idlike_cache:
            return
        elif tp.startswith(b"o^") and tp[2:] in _idlike_cache:
            return
        elif tp.startswith(b"n^") and tp[2:] in _idlike_cache:
            return
        elif tp.startswith(b"N^") and tp[2:] in _idlike_cache:
            return

        # Assume that tests are supposed to pass,
        # our cache may be out of date
        tmp = set(objc._idSignatures())
        _idlike_cache = set(tmp)

        if tp in _idlike_cache:
            return
        elif tp.startswith(b"^") and tp[1:] in _idlike_cache:
            return
        elif tp.startswith(b"o^") and tp[2:] in _idlike_cache:
            return
        elif tp.startswith(b"n^") and tp[2:] in _idlike_cache:
            return
        elif tp.startswith(b"N^") and tp[2:] in _idlike_cache:
            return

        self.fail(message or f"result of {method!r} is not IDLike ({tp!r})")

    def assertArgIsNullTerminated(self, method, argno, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        try:
            if not info["arguments"][argno + offset].get("c_array_delimited_by_null"):
                self.fail(
                    message
                    or "argument %d of %r is not a null-terminated array"
                    % (argno, method)
                )
        except (KeyError, IndexError):
            self.fail(
                message
                or "argument %d of %r is not a null-terminated array" % (argno, method)
            )

    def assertArgIsVariableSize(self, method, argno, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        try:
            if not info["arguments"][argno + offset].get("c_array_of_variable_length"):
                self.fail(
                    message
                    or "argument %d of %r is not a variable sized array"
                    % (argno, method)
                )
        except (KeyError, IndexError):
            self.fail(
                message
                or "argument %d of %r is not a variable sized array" % (argno, method)
            )

    def assertResultIsVariableSize(self, method, message=None):
        info = method.__metadata__()
        if not info.get("retval", {}).get("c_array_of_variable_length", False):
            self.fail(message or f"result of {method!r} is not a variable sized array")

    def assertArgSizeInResult(self, method, argno, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        try:
            if not info["arguments"][argno + offset].get("c_array_length_in_result"):
                self.fail(
                    message
                    or "argument %d of %r does not have size in result"
                    % (argno, method)
                )
        except (KeyError, IndexError):
            self.fail(
                message
                or "argument %d of %r does not have size in result" % (argno, method)
            )

    def assertArgIsPrintf(self, method, argno, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        if not info.get("variadic"):
            self.fail(message or f"{method!r} is not a variadic function")

        try:
            if not info["arguments"][argno + offset].get("printf_format"):
                self.fail(
                    message
                    or "%r argument %d is not a printf format string" % (method, argno)
                )
        except (KeyError, IndexError):
            self.fail(
                message
                or "%r argument %d is not a printf format string" % (method, argno)
            )

    def assertArgIsCFRetained(self, method, argno, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()

        try:
            if not info["arguments"][argno + offset]["already_cfretained"]:
                self.fail(
                    message or f"Argument {argno} of {method!r} is not cfretained"
                )
        except (KeyError, IndexError):
            self.fail(message or f"Argument {argno} of {method!r} is not cfretained")

    def assertArgIsNotCFRetained(self, method, argno, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        try:
            if info["arguments"][argno + offset]["already_cfretained"]:
                self.fail(message or f"Argument {argno} of {method!r} is cfretained")
        except (KeyError, IndexError):
            pass

    def assertResultIsCFRetained(self, method, message=None):
        info = method.__metadata__()

        if not info.get("retval", {}).get("already_cfretained", False):
            self.fail(message or f"{method!r} is not cfretained")

    def assertResultIsNotCFRetained(self, method, message=None):
        info = method.__metadata__()
        if info.get("retval", {}).get("already_cfretained", False):
            self.fail(message or f"{method!r} is cfretained")

    def assertArgIsRetained(self, method, argno, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()

        try:
            if not info["arguments"][argno + offset]["already_retained"]:
                self.fail(message or f"Argument {argno} of {method!r} is not retained")
        except (KeyError, IndexError):
            self.fail(message or f"Argument {argno} of {method!r} is not retained")

    def assertArgIsNotRetained(self, method, argno, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        try:
            if info["arguments"][argno + offset]["already_retained"]:
                self.fail(message or f"Argument {argno} of {method!r} is retained")
        except (KeyError, IndexError):
            pass

    def assertResultIsRetained(self, method, message=None):
        info = method.__metadata__()
        if not info.get("retval", {}).get("already_retained", False):
            self.fail(message or f"Result of {method!r} is not retained")

    def assertResultIsNotRetained(self, method, message=None):
        info = method.__metadata__()
        if info.get("retval", {}).get("already_retained", False):
            self.fail(message or f"Result of {method!r} is retained")

    def assertResultHasType(self, method, tp, message=None):
        info = method.__metadata__()
        typestr = info.get("retval").get("type", b"v")
        if (
            typestr != tp
            and _typemap(typestr) != _typemap(tp)
            and _typealias.get(typestr, typestr) != _typealias.get(tp, tp)
        ):
            self.fail(
                message
                or f"result of {method!r} is not of type {tp!r}, but {typestr!r}"
            )

    def assertArgHasType(self, method, argno, tp, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        try:
            i = info["arguments"][argno + offset]

        except (KeyError, IndexError):
            self.fail(
                message
                or "arg %d of %s has no metadata (or doesn't exist)" % (argno, method)
            )

        else:
            typestr = i.get("type", b"@")

        if (
            typestr != tp
            and _typemap(typestr) != _typemap(tp)
            and _typealias.get(typestr, typestr) != _typealias.get(tp, tp)
        ):
            self.fail(
                message
                or "arg %d of %s is not of type %r, but %r"
                % (argno, method, tp, typestr)
            )

    def assertArgIsFunction(self, method, argno, sel_type, retained, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()

        try:
            i = info["arguments"][argno + offset]
        except (KeyError, IndexError):
            self.fail(
                message
                or "arg %d of %s has no metadata (or doesn't exist)" % (argno, method)
            )

        else:
            typestr = i.get("type", b"@")

        if typestr != b"^?":
            self.fail(
                message
                or "arg %d of %s is not of type function_pointer" % (argno, method)
            )

        st = i.get("callable")
        if st is None:
            self.fail(
                message
                or "arg %d of %s is not of type function_pointer" % (argno, method)
            )

        try:
            iface = st["retval"]["type"]
            for a in st["arguments"]:
                iface += a["type"]
        except KeyError:
            self.fail(
                message
                or "arg %d of %s is a function pointer with incomplete type information"
                % (argno, method)
            )

        if iface != sel_type:
            self.fail(
                message
                or "arg %d of %s is not a function_pointer with type %r, but %r"
                % (argno, method, sel_type, iface)
            )

        st = info["arguments"][argno + offset].get("callable_retained", False)
        if bool(st) != bool(retained):
            self.fail(
                message
                or "arg %d of %s; retained: %r, expected: %r"
                % (argno, method, st, retained)
            )

    def assertResultIsFunction(self, method, sel_type, message=None):
        info = method.__metadata__()

        try:
            i = info["retval"]
        except (KeyError, IndexError):
            self.fail(
                message or f"result of {method} has no metadata (or doesn't exist)"
            )

        else:
            typestr = i.get("type", b"@")

        if typestr != b"^?":
            self.fail(message or f"result of {method} is not of type function_pointer")

        st = i.get("callable")
        if st is None:
            self.fail(message or f"result of {method} is not of type function_pointer")

        try:
            iface = st["retval"]["type"]
            for a in st["arguments"]:
                iface += a["type"]
        except KeyError:
            self.fail(
                message
                or "result of %s is a function pointer with incomplete type information"
                % (method,)
            )

        if iface != sel_type:
            self.fail(
                message
                or "result of %s is not a function_pointer with type %r, but %r"
                % (method, sel_type, iface)
            )

    def assertArgIsBlock(self, method, argno, sel_type, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        try:
            typestr = info["arguments"][argno + offset]["type"]
        except (IndexError, KeyError):
            self.fail("arg %d of %s does not exist" % (argno, method))

        if typestr != b"@?":
            self.fail(
                message
                or "arg %d of %s is not of type block: %s" % (argno, method, typestr)
            )

        st = info["arguments"][argno + offset].get("callable")
        if st is None:
            self.fail(
                message
                or "arg %d of %s is not of type block: no callable" % (argno, method)
            )

        try:
            iface = st["retval"]["type"]
            if st["arguments"][0]["type"] != b"^v":
                self.fail(
                    message
                    or "arg %d of %s has an invalid block signature %r for argument 0"
                    % (argno, method, st["arguments"][0]["type"])
                )
            for a in st["arguments"][1:]:
                iface += a["type"]
        except KeyError:
            self.fail(
                message
                or "result of %s is a block pointer with incomplete type information"
                % (method,)
            )

        if iface != sel_type:
            self.fail(
                message
                or "arg %d of %s is not a block with type %r, but %r"
                % (argno, method, sel_type, iface)
            )

    def assertResultIsBlock(self, method, sel_type, message=None):
        info = method.__metadata__()

        try:
            typestr = info["retval"]["type"]
            if typestr != b"@?":
                self.fail(
                    message or f"result of {method} is not of type block: {typestr}"
                )
        except KeyError:
            self.fail(
                message or "result of {} is not of type block: {}".format(method, b"v")
            )

        st = info["retval"].get("callable")
        if st is None:
            self.fail(
                message
                or "result of %s is not of type block: no callable specified" % (method)
            )

        try:
            iface = st["retval"]["type"]
            if st["arguments"][0]["type"] != b"^v":
                self.fail(
                    message
                    or "result %s has an invalid block signature %r for argument 0"
                    % (method, st["arguments"][0]["type"])
                )
            for a in st["arguments"][1:]:
                iface += a["type"]
        except KeyError:
            self.fail(
                message
                or "result of %s is a block pointer with incomplete type information"
                % (method,)
            )

        if iface != sel_type:
            self.fail(
                message
                or "result of %s is not a block with type %r, but %r"
                % (method, sel_type, iface)
            )

    def assertResultIsSEL(self, method, sel_type, message=None):
        info = method.__metadata__()
        try:
            i = info["retval"]
        except (KeyError, IndexError):
            self.fail(
                message or f"result of {method} has no metadata (or doesn't exist)"
            )

        typestr = i.get("type", b"@")
        if typestr != objc._C_SEL:
            self.fail(message or f"result of {method} is not of type SEL")

        st = i.get("sel_of_type")
        if st != sel_type and _typemap(st) != _typemap(sel_type):
            self.fail(
                message
                or "result of %s doesn't have sel_type %r but %r"
                % (method, sel_type, st)
            )

    def assertArgIsSEL(self, method, argno, sel_type, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        try:
            i = info["arguments"][argno + offset]
        except (KeyError, IndexError):
            self.fail(
                message
                or "arg %d of %s has no metadata (or doesn't exist)" % (argno, method)
            )

        typestr = i.get("type", b"@")
        if typestr != objc._C_SEL:
            self.fail(message or "arg %d of %s is not of type SEL" % (argno, method))

        st = i.get("sel_of_type")
        if st != sel_type and _typemap(st) != _typemap(sel_type):
            self.fail(
                message
                or "arg %d of %s doesn't have sel_type %r but %r"
                % (argno, method, sel_type, st)
            )

    def assertResultIsBOOL(self, method, message=None):
        info = method.__metadata__()
        typestr = info["retval"]["type"]
        if typestr not in (objc._C_NSBOOL, objc._C_BOOL):
            self.fail(
                message or f"result of {method} is not of type BOOL, but {typestr!r}"
            )

    def assertArgIsBOOL(self, method, argno, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        typestr = info["arguments"][argno + offset]["type"]
        if typestr not in (objc._C_NSBOOL, objc._C_BOOL):
            self.fail(
                message
                or "arg %d of %s is not of type BOOL, but %r" % (argno, method, typestr)
            )

    def assertArgIsFixedSize(self, method, argno, count, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        try:
            cnt = info["arguments"][argno + offset]["c_array_of_fixed_length"]
            if cnt != count:
                self.fail(
                    message
                    or "arg %d of %s is not a C-array of length %d"
                    % (argno, method, count)
                )
        except (KeyError, IndexError):
            self.fail(
                message
                or "arg %d of %s is not a C-array of length %d" % (argno, method, count)
            )

    def assertResultIsFixedSize(self, method, count, message=None):
        info = method.__metadata__()
        try:
            cnt = info["retval"]["c_array_of_fixed_length"]
            if cnt != count:
                self.fail(
                    message
                    or "result of %s is not a C-array of length %d" % (method, count)
                )
        except (KeyError, IndexError):
            self.fail(
                message
                or "result of %s is not a C-array of length %d" % (method, count)
            )

    def assertArgSizeInArg(self, method, argno, count, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        try:
            cnt = info["arguments"][argno + offset]["c_array_length_in_arg"]
        except (KeyError, IndexError):
            self.fail(
                message
                or "arg %d of %s is not a C-array of with length in arg %s"
                % (argno, method, count)
            )

        if isinstance(count, (list, tuple)):
            count2 = tuple(x + offset for x in count)
        else:
            count2 = count + offset
        if cnt != count2:
            self.fail(
                message
                or "arg %d of %s is not a C-array of with length in arg %s"
                % (argno, method, count)
            )

    def assertResultSizeInArg(self, method, count, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        cnt = info["retval"]["c_array_length_in_arg"]
        if cnt != count + offset:
            self.fail(
                message
                or "result %s is not a C-array of with length in arg %d"
                % (method, count)
            )

    def assertArgIsOut(self, method, argno, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        typestr = info["arguments"][argno + offset]["type"]
        if not typestr.startswith(b"o^") and not typestr.startswith(b"o*"):
            self.fail(
                message or "arg %d of %s is not an 'out' argument" % (argno, method)
            )

    def assertArgIsInOut(self, method, argno, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        typestr = info["arguments"][argno + offset]["type"]
        if not typestr.startswith(b"N^") and not typestr.startswith(b"N*"):
            self.fail(
                message or "arg %d of %s is not an 'inout' argument" % (argno, method)
            )

    def assertArgIsIn(self, method, argno, message=None):
        if isinstance(method, objc.selector):
            offset = 2
        else:
            offset = 0
        info = method.__metadata__()
        typestr = info["arguments"][argno + offset]["type"]
        if not typestr.startswith(b"n^") and not typestr.startswith(b"n*"):
            self.fail(
                message or "arg %d of %s is not an 'in' argument" % (argno, method)
            )

    def assertStartswith(self, value, test, message=None):
        if not value.startswith(test):
            self.fail(message or f"{value!r} does not start with {test!r}")

    def assertHasAttr(self, value, key, message=None):
        if not hasattr(value, key):
            self.fail(message or f"{key} is not an attribute of {value!r}")

    def assertNotHasAttr(self, value, key, message=None):
        if hasattr(value, key):
            self.fail(message or f"{key} is an attribute of {value!r}")

    def assertIsSubclass(self, value, types, message=None):
        if not issubclass(value, types):
            self.fail(message or f"{value} is not a subclass of {types!r}")

    def assertIsNotSubclass(self, value, types, message=None):
        if issubclass(value, types):
            self.fail(message or f"{value} is a subclass of {types!r}")

    def assertClassIsFinal(self, cls):
        if not isinstance(cls, objc.objc_class):
            self.fail(f"{cls} is not an Objective-C class")
        elif not cls.__objc_final__:
            self.fail(f"{cls} is not a final class")

    def assertProtocolExists(self, name):
        ok = True
        try:
            proto = objc.protocolNamed(name)

        except objc.ProtocolError:
            ok = False

        if not ok:
            self.fail(f"Protocol {name!r} does not exist")

        if not isinstance(proto, objc.formal_protocol):
            # Should never happen
            self.fail(f"Protocol {name!r} is not a protocol, but {type(proto)}")

    def assertPickleRoundTrips(self, value):
        try:
            buf = _pickle.dumps(value)
            clone = _pickle.loads(buf)
        except Exception:
            self.fail(f"{value} cannot be pickled")

        self.assertEqual(clone, value)
        self.assertIsInstance(clone, type(value))

    def _validateCallableMetadata(
        self, value, class_name=None, skip_simple_charptr_check=False
    ):
        if False and isinstance(value, objc.selector):
            # Check if the signature might contain types that are interesting
            # for this method. This avoids creating a metadata dict for 'simple'
            # methods.
            # XXX: Disabled this shortcut due to adding already_retained tests
            signature = value.signature
            if objc._C_PTR not in signature and objc._C_CHARPTR not in signature:
                return

        callable_meta = value.__metadata__()
        argcount = len(callable_meta["arguments"])

        for idx, meta in [("retval", callable_meta["retval"])] + list(
            enumerate(callable_meta["arguments"])
        ):
            if meta.get("already_retained", False) and meta.get(
                "already_cfretained", False
            ):
                self.fail(
                    f"{value}: {idx}: both already_retained and already_cfretained"
                )

            if meta["type"].endswith(objc._C_PTR + objc._C_CHR) or meta[
                "type"
            ].endswith(objc._C_CHARPTR):
                if meta.get("c_array_delimited_by_null", False):
                    self.fail(
                        f"{value}: {idx}: null-delimited 'char*', use _C_CHAR_AS_TEXT instead {class_name or ''}"
                    )
                if not skip_simple_charptr_check:
                    self.fail(f"{value}: {idx}: 'char*' {class_name or ''}")

            v = meta.get("c_array_size_in_arg", None)
            if isinstance(v, int):
                if not (0 <= v < argcount):
                    self.fail(
                        f"{value}: {idx}: c_array_size_in_arg out of range {v} {class_name or ''}"
                    )
            elif isinstance(v, tuple):
                b, e = v
                if not (0 <= b < argcount):
                    self.fail(
                        f"{value}: {idx}: c_array_size_in_arg out of range {b} {class_name or ''}"
                    )
                if not (0 <= e < argcount):
                    self.fail(
                        f"{value}: {idx}: c_array_size_in_arg out of range {e} {class_name or ''}"
                    )

            tp = meta["type"]
            if any(
                tp.startswith(pfx) for pfx in (objc._C_IN, objc._C_OUT, objc._C_INOUT)
            ):
                rest = tp[1:]
                if not rest.startswith(objc._C_PTR) and not rest.startswith(
                    objc._C_CHARPTR
                ):
                    self.fail(
                        f"{value}: {idx}: byref specifier on non-pointer: {tp} {class_name or ''}"
                    )

                rest = rest[1:]

                if rest.startswith(objc._C_STRUCT_B):
                    name, fields = objc.splitStructSignature(rest)
                    if not fields:
                        self.fail(
                            f"{value}: {idx}: byref to empty struct (handle/CFType?): {tp} {class_name or ''}"
                        )

            if not isinstance(value, objc.selector):
                # This gives too many false positives for selectors (sadly)
                if (
                    tp.startswith(objc._C_PTR)
                    and tp not in (b"^v", b"^?")
                    and tp != b"^{AudioBufferList=I[1{AudioBuffer=II^v}]}"
                    and tp != b"^{_CFArrayCallBacks=q^?^?^?^?}"
                ):
                    if tp[1:].startswith(objc._C_STRUCT_B):
                        name, fields = objc.splitStructSignature(tp[1:])
                        if not fields:
                            continue

                    if idx == "retval":
                        if any(
                            x in meta
                            for x in {
                                "deref_result_pointer",
                                "c_array_delimited_by_null",
                                "c_array_of_variable_length",
                                "c_array_length_in_arg",
                                "c_array_size_in_arg",
                            }
                        ):
                            continue
                    self.fail(
                        f"{value}: {idx}: pointer argument, but no by-ref annotation:{tp!r} {class_name or ''}"
                    )

    def assertCallableMetadataIsSane(
        self, module, *, exclude_cocoa=True, exclude_attrs=()
    ):
        # Do some sanity checking on module metadata for
        # callables.
        #
        # This test is *very* expensive, made slightly
        # better by excluding CoreFoundation/Foundation/AppKit
        # by default
        #
        # XXX: exclude_cocoa may exclude too much depending on
        #      import order.

        if hasattr(module, "__bundle__"):
            with self.subTest("validate framework identifier"):
                self.assertHasAttr(module, "__framework_identifier__")
                self.assertEqual(
                    module.__bundle__.bundleIdentifier(),
                    module.__framework_identifier__,
                )

        if exclude_cocoa:
            import Cocoa

            exclude_names = set(dir(Cocoa))

            # Don't exclude NSObject' because a number
            # of frameworks define categories on this class.
            exclude_names -= {"NSObject"}
        else:
            exclude_names = set()

        exclude_method_names = {
            "copyRenderedTextureForCGLContext_pixelFormat_bounds_isFlipped_",
            "newTaggedNSStringWithASCIIBytes__length__",
            "utf8ValueSafe",
            "utf8ValueSafe_",
            "isKeyExcludedFromWebScript_",
        }

        exclude_attrs = set(exclude_attrs)
        exclude_attrs.add("FBSMutableSceneClientSettings")
        exclude_attrs.add("FBSSceneClientSettings")
        exclude_attrs.add(("NSColor", "scn_C3DColorIgnoringColorSpace_success_"))
        exclude_attrs.add(
            ("AVKitPlatformColorClass", "scn_C3DColorIgnoringColorSpace_success_")
        )
        exclude_attrs.add(
            ("PDFKitPlatformColor", "scn_C3DColorIgnoringColorSpace_success_")
        )
        exclude_attrs.add(("SCNColor", "scn_C3DColorIgnoringColorSpace_success_"))
        exclude_attrs.add(("SKColor", "scn_C3DColorIgnoringColorSpace_success_"))
        exclude_attrs.add(
            (
                "NSObject",
                "copyRenderedTextureForCGLContext_pixelFormat_bounds_isFlipped_",
            )
        )
        exclude_attrs.add(
            (
                "NSObject",
                "newTaggedNSStringWithASCIIBytes__length__",
            )
        )
        exclude_attrs.add(
            (
                "NSObject",
                "utf8ValueSafe",
            )
        )
        exclude_attrs.add(
            (
                "NSObject",
                "utf8ValueSafe_",
            )
        )
        exclude_attrs.add(
            (
                "NSObject",
                "isKeyExcludedFromWebScript_",
            )
        )

        # Two (private) classes that end up being found in
        # test runs on macOS 10.12 and 10.13
        exclude_attrs.add("ABCDContact_ABCDContact_")
        exclude_attrs.add("ABCDGroup_ABCDGroup_")

        # Some bindings in CoreAudio with false positives
        exclude_attrs.add("AudioHardwareClaimAudioDeviceID")
        exclude_attrs.add("AudioHardwareClaimAudioStreamID")
        exclude_attrs.add("AudioHardwareDevicePropertyChanged")
        exclude_attrs.add("AudioHardwareDevicesCreated")
        exclude_attrs.add("AudioHardwareDevicesDied")
        exclude_attrs.add("AudioHardwareStreamPropertyChanged")
        exclude_attrs.add("AudioHardwareStreamsCreated")
        exclude_attrs.add("AudioHardwareStreamsDied")
        exclude_attrs.add("AudioObjectCreate")
        exclude_attrs.add("AudioObjectPropertiesChanged")
        exclude_attrs.add("AudioObjectsPublishedAndDied")

        # Calculate all (interesting) names in the module. This pokes into
        # the implementation details of objc.ObjCLazyModule to avoid loading
        # all attributes (which is expensive for larger bindings).
        if isinstance(module, objc.ObjCLazyModule) and False:
            module_names = []
            module_names.extend(
                cls.__name__
                for cls in objc.getClassList()
                if (not cls.__name__.startswith("_")) and ("." not in cls.__name__)
            )
            module_names.extend(module._ObjCLazyModule__funcmap or [])
            module_names.extend(module.__dict__.keys())
            todo = list(module._ObjCLazyModule__parents or [])
            while todo:
                parent = todo.pop()
                if isinstance(parent, objc.ObjCLazyModule):
                    module_names.extend(parent._ObjCLazyModule__funcmap or ())
                    todo.extend(parent._ObjCLazyModule__parents or ())
                    module_names.extend(parent.__dict__.keys())
                else:
                    module_names.extend(dir(module))

            # The module_names list might contain duplicates
            module_names = sorted(set(module_names))
        else:
            module_names = sorted(set(dir(module)))

        for _idx, nm in enumerate(module_names):
            # print(f"{_idx}/{len(module_names)} {nm}")
            if nm in exclude_names:
                continue
            if nm in exclude_attrs:
                continue

            try:
                value = getattr(module, nm)
            except AttributeError:
                continue
            if isinstance(value, objc.objc_class):
                if value.__name__ == "Object":
                    # Root class, does not conform to the NSObject
                    # protocol and useless to test.
                    continue
                for attr_name, attr in value.pyobjc_instanceMethods.__dict__.items():
                    if attr_name in exclude_method_names:
                        continue
                    if (nm, attr_name) in exclude_attrs:
                        continue
                    if attr_name.startswith("_"):
                        # Skip private names
                        continue

                    with self.subTest(classname=nm, instance_method=attr_name):
                        if isinstance(attr, objc.selector):  # pragma: no branch
                            self._validateCallableMetadata(
                                attr, nm, skip_simple_charptr_check=not exclude_cocoa
                            )

                for attr_name, attr in value.pyobjc_classMethods.__dict__.items():
                    if attr_name in exclude_method_names:
                        continue
                    if (nm, attr_name) in exclude_attrs:
                        continue
                    if attr_name.startswith("_"):
                        # Skip private names
                        continue

                    with self.subTest(classname=nm, instance_method=attr_name):
                        attr = getattr(value.pyobjc_classMethods, attr_name, None)
                        if isinstance(attr, objc.selector):  # pragma: no branch
                            self._validateCallableMetadata(
                                attr, nm, skip_simple_charptr_check=not exclude_cocoa
                            )
            elif isinstance(value, objc.function):
                with self.subTest(function=nm):
                    self._validateCallableMetadata(value)

            else:
                continue

    def __init__(self, methodName="runTest"):
        super().__init__(methodName)

        testMethod = getattr(self, methodName)

        if getattr(testMethod, "_no_autorelease_pool", False):
            self._skip_usepool = True
        else:
            self._skip_usepool = False

    def run(self, *args, **kwds):
        """
        Run the test, same as unittest.TestCase.run, but every test is
        run with a fresh autorelease pool.
        """
        try:
            cls = objc.lookUpClass("NSApplication")
        except objc.error:
            pass
        else:
            cls.sharedApplication()

        if _usepool and not self._skip_usepool:
            p = _poolclass.alloc().init()
        else:
            p = 1

        try:
            _unittest.TestCase.run(self, *args, **kwds)
        finally:
            _gc.collect()
            del p
            _gc.collect()


main = _unittest.main
expectedFailure = _unittest.expectedFailure
skipUnless = _unittest.skipUnless
SkipTest = _unittest.SkipTest


def expectedFailureIf(condition):
    if condition:
        return expectedFailure
    else:
        return lambda func: func


def no_autorelease_pool(func):
    func._no_autorelease_pool = True
    return func
