"""
Helper module that will enable lazy imports of Cocoa wrapper items.

This improves startup times and memory usage, at the cost
of not being able to use 'from Cocoa import *'
"""

__all__ = ("ObjCLazyModule", "createFrameworkDirAndGetattr")

import re
import struct
import sys
import warnings

import objc
import types
from collections import ChainMap
from objc import getClassList, loadBundle, lookUpClass, nosuchclass_error

ModuleType = types.ModuleType

_name_re = re.compile("^[A-Za-z_][A-Za-z_0-9]*$")

_DEFAULT_ALIASES = {
    "ULONG_MAX": (sys.maxsize * 2) + 1,
    "LONG_MAX": sys.maxsize,
    "LONG_MIN": -sys.maxsize - 1,
    "DBL_MAX": sys.float_info.max,
    "DBL_MIN": sys.float_info.min,
    "DBL_EPSILON": sys.float_info.epsilon,
    "FLT_MAX": objc._FLT_MAX,
    "FLT_MIN": objc._FLT_MIN,
    "objc.NULL": objc.NULL,
    "UINT32_MAX": 0xFFFFFFFF,
}


def _deprecation_level(value):
    major, _, minor = value.partition(".")
    return int(major) * 100 + int(minor)


def _check_deprecated(name, deprecation_version):
    if (
        objc.options.deprecation_warnings != "0.0"
        and _deprecation_level(objc.options.deprecation_warnings) >= deprecation_version
    ):
        warnings.warn(
            "%r is deprecated in macOS %d.%d"
            % (name, deprecation_version / 100, deprecation_version % 100),
            objc.ApiDeprecationWarning,
            stacklevel=2,
        )


def _prs_enum(val):
    if val.startswith("'"):
        (val,) = struct.unpack(">l", val[1:-1].encode("latin1"))

    elif "." in val or "e" in val:
        val = float(val)

    else:
        val = int(val)

    return val


def _loadBundle(frameworkName, frameworkIdentifier, frameworkPath):
    if frameworkIdentifier is None:
        bundle = loadBundle(
            frameworkName, {}, bundle_path=frameworkPath, scan_classes=False
        )

    else:
        try:
            bundle = loadBundle(
                frameworkName,
                {},
                bundle_identifier=frameworkIdentifier,
                scan_classes=False,
            )

        except ImportError:
            bundle = loadBundle(
                frameworkName, {}, bundle_path=frameworkPath, scan_classes=False
            )

    return bundle


class _GetAttrMap:
    __slots__ = ("_getattr",)

    def __init__(self, getattr_func):
        self._getattr = getattr_func

    def __getitem__(self, key):
        if key == "CFSTR":
            return lambda v: v.decode()
        try:
            return self._getattr(key)
        except AttributeError:
            raise KeyError(key)


def createFrameworkDirAndGetattr(
    *,
    name,
    frameworkIdentifier,
    frameworkPath,
    globals_dict,
    metadict,
    inline_list,
    parents,
):
    """
    Load the specified framework and return ``__dir__`` and ``__getattr__`` for the
    framework bindings.

    The result should be assigned to module constants, e.g. in SomeFramework.py use:

       __dir__, __getattr__ = objc.createFrameworkDirAndGetattr("SomeFramework", "...", "...", globals(), ...)
    """

    # XXX: Implementation can be cleaned up, I've basically moved the old implementation
    #      from ObjCLazyModule into this function with minor touch ups.

    if frameworkIdentifier is not None or frameworkPath is not None:
        bundle = globals_dict["__bundle__"] = _loadBundle(
            name, frameworkIdentifier, frameworkPath
        )
    else:
        bundle = None

    if metadict is None:
        metadict = {}

    globals_dict["__framework_identifier__"] = frameworkIdentifier
    globals_dict.update(metadict.get("misc", {}))

    varmap = metadict.get("constants")
    varmap_deprecated = metadict.get("deprecated_constants", {})
    varmap_dct = metadict.get("constants_dict", {})
    enummap = metadict.get("enums")
    enum_deprecated = metadict.get("deprecated_enums", {})
    funcmap = metadict.get("functions")
    aliases = metadict.get("aliases")
    aliases_deprecated = metadict.get("deprecated_aliases", {})

    # XXX: informal protocols are not exposed, but added here
    # for completeness sake.
    # informal_protocols = metadict.get("protocols")

    expressions = metadict.get("expressions")

    def __dir__():
        if "__all__" in globals_dict:
            return globals_dict["__all__"]

        globals_dict["__all__"] = calc_all()
        return globals_dict["__all__"]

    def __getattr__(name):
        if name == "__all__":
            # Load everything immediately
            value = calc_all()
            globals_dict[name] = value
            return value

        # First try parent module, as if we had done
        # 'from parents import *' (andh hence don't
        # look for private names in parent modules)
        if not name.startswith("_"):
            for p in parents:
                try:
                    value = getattr(p, name)
                    globals_dict[name] = value
                    return value
                except AttributeError:
                    pass

        if not _name_re.match(name):
            # Name is not a valid identifier and cannot
            # match.
            raise AttributeError(name)

        # Check if the name is a constant from
        # the metadata files
        try:
            value = get_constant(name)
        except AttributeError:
            pass

        else:
            globals_dict[name] = value
            return value

        # Then check if the name is class
        try:
            value = lookUpClass(name)
        except nosuchclass_error:
            pass

        else:
            globals_dict[name] = value
            return value

        # Finally give up and raise AttributeError
        raise AttributeError(name)

    def calc_all():
        # Ensure that all dynamic entries get loaded
        #
        # The code tries to resolve through 'expressions_mapping'
        # to avoid code duplication, and to get some edge cases correct
        nonlocal varmap, enummap, inline_list, expressions, aliases

        if varmap_dct:
            for nm in list(varmap_dct):
                try:
                    expressions_mapping[nm]
                except KeyError:
                    pass

            varmap_dct.clear()

        if varmap:
            for nm, _tp in re.findall(r"\$([A-Z0-9a-z_]*)(@[^$]*)?(?=\$)", varmap):
                # An empty name can happen if the 'constants' definition
                # is effectively happened (e.g. "$$").
                if not nm:
                    continue

                try:
                    expressions_mapping[nm]
                except KeyError:
                    continue

            varmap = ""

        if enummap:
            for nm, _val in re.findall(r"\$([A-Z0-9a-z_]*)@([^$]*)(?=\$)", enummap):
                try:
                    expressions_mapping[nm]
                except KeyError:  # pragma: no cover
                    pass  # pragma: no cover

            enummap = ""

        if funcmap:
            for nm in list(funcmap):
                try:
                    expressions_mapping[nm]
                except KeyError:
                    pass

            funcmap.clear()

        inline_list = None

        if expressions:
            for nm in list(expressions):
                try:
                    expressions_mapping[nm]
                except KeyError:
                    pass
            expressions = []

        if aliases:
            for nm in list(aliases):
                try:
                    expressions_mapping[nm]
                except KeyError:
                    pass
            aliases = []

        all_names = set()

        # Add all names that are already globals_dict__
        all_names.update(globals_dict)

        # Merge __all__of parents ('from parent import *')
        for p in parents:
            try:
                all_names.update(p.__all__)
            except AttributeError:
                all_names.update(dir(p))

        # Add all class names, ignoring names that aren't valid identifiers
        all_names.update(
            cls.__name__ for cls in getClassList(True) if cls.__name__.isidentifier()
        )

        return sorted({v for v in all_names if not v.startswith("_")})

    def get_constant(name):
        if varmap_dct:
            if name in varmap_dct:
                tp = varmap_dct[name]
                if tp.startswith("=="):
                    tp = tp[2:]
                    magic = 2
                elif tp.startswith("="):
                    tp = tp[1:]
                    magic = 1
                else:
                    magic = 0
                result = objc._loadConstant(name, tp, magic)
                varmap_dct.pop(name)
                if name in varmap_deprecated:
                    _check_deprecated(name, varmap_deprecated[name])

                return result

        if varmap:
            m = re.search(rf"\${name}(@[^$]*)?\$", varmap)
            if m is not None:
                tp = m.group(1)
                if not tp:
                    tp = "@"
                else:
                    tp = tp[1:]

                d = {}
                if tp.startswith("=="):
                    magic = 2
                    tp = tp[2:]
                elif tp.startswith("="):
                    tp = tp[1:]
                    magic = 1
                else:
                    magic = 0

                result = objc._loadConstant(name, tp, magic)

                if name in varmap_deprecated:
                    _check_deprecated(name, varmap_deprecated[name])

                return result

        if enummap:
            m = re.search(rf"\${name}@([^$]*)\$", enummap)
            if m is not None:
                result = _prs_enum(m.group(1))
                if name in enum_deprecated:
                    _check_deprecated(name, enum_deprecated[name])
                return result

        if funcmap:
            if name in funcmap:
                info = funcmap[name]

                func_list = [(name,) + info]

                d = {}
                objc.loadBundleFunctions(bundle, d, func_list)
                if name in d:
                    funcmap.pop(name)
                    return d[name]

                if inline_list is not None:
                    objc.loadFunctionList(
                        inline_list, d, func_list, skip_undefined=True
                    )
                    if name in d:
                        return d[name]

        if expressions:
            if name in expressions:
                info = expressions[name]
                try:
                    result = eval(info, {}, expressions_mapping)
                    expressions.pop(name)
                    return result
                except:  # noqa: E722, B001. Ignore all errors in evaluation the expression.
                    pass

        if aliases:
            if name in aliases:
                alias = aliases[name]
                aliases.pop(name)

                if alias in _DEFAULT_ALIASES:
                    result = _DEFAULT_ALIASES[alias]
                else:
                    try:
                        result = expressions_mapping[alias]
                    except KeyError:
                        raise AttributeError(name)

                if name in aliases_deprecated:
                    _check_deprecated(name, aliases_deprecated[name])
                return result

        raise AttributeError(name)

    def load_cftypes(cftypes):
        if not cftypes:
            return

        for name, typestr, gettypeid_func, tollfree in cftypes:
            if tollfree:
                for nm in tollfree.split(","):  # pragma: no branch
                    try:
                        objc.lookUpClass(nm)
                    except objc.error:
                        pass
                    else:
                        tollfree = nm
                        break
                try:
                    v = objc.registerCFSignature(name, typestr, None, tollfree)
                    globals_dict[name] = v
                    continue
                except objc.nosuchclass_error:
                    pass

            if gettypeid_func is None:
                func = None

            else:
                try:
                    func = expressions_mapping[gettypeid_func]
                except KeyError:
                    func = None

            if func is None:
                # GetTypeID function not found, this is either
                # a CFType that isn't present on the current
                # platform, or a CFType without a public GetTypeID
                # function. Proxy using the generic CFType
                if tollfree is None:
                    v = objc.registerCFSignature(name, typestr, None, "NSCFType")
                    globals_dict[name] = v

                continue

            v = objc.registerCFSignature(name, typestr, func())
            globals_dict[name] = v

    expressions_mapping = ChainMap(globals_dict, _GetAttrMap(__getattr__))
    load_cftypes(metadict.get("cftypes"))

    return __dir__, __getattr__


class ObjCLazyModule(ModuleType):
    """
    A module type that loads PyObjC metadata lazily, that is constants, global
    variables and functions are created from the metadata as needed. This
    reduces the resource usage of PyObjC (both in time and memory), as most
    symbols exported by frameworks are never used in programs.

    The loading code assumes that the metadata dictionary is valid, and invalid
    metadata may cause exceptions other than AttributeError when accessing module
    attributes.
    """

    def __init__(
        self,
        name,
        frameworkIdentifier,
        frameworkPath,
        metadict=None,
        inline_list=None,
        initialdict=None,
        parents=(),
    ):
        # warnings.warn(
        #    "'ObjCLazyModule' is deprecated, use 'createFrameworkDirAndGetattr' instead",
        #    DeprecationWarning,
        #    stacklevel=2,
        # )
        super().__init__(name)
        self.__dir__, self.__getattr__ = createFrameworkDirAndGetattr(
            name=name,
            frameworkIdentifier=frameworkIdentifier,
            frameworkPath=frameworkPath,
            globals_dict=self.__dict__,
            inline_list=inline_list,
            parents=parents,
            metadict=metadict,
        )

        pfx = name + "."
        for nm in list(sys.modules.keys()):
            # See issue #95: there can be objects that aren't strings in
            # sys.modules.
            if hasattr(nm, "startswith") and nm.startswith(pfx):
                rest = nm[len(pfx) :]  # noqa: E203
                if "." in rest:
                    continue
                if sys.modules[nm] is not None:
                    self.__dict__[rest] = sys.modules[nm]

        if initialdict:
            self.__dict__.update(initialdict)
