#!/usr/bin/env python3
# --------------------( LICENSE                            )--------------------
# Copyright (c) 2014-2024 Beartype authors.
# See "LICENSE" for further details.

'''
**Beartype decorator PEP-compliant code wrapper scope utilities** (i.e.,
functions handling the possibly nested lexical scopes enclosing wrapper
functions generated by the :func:`beartype.beartype` decorator).

This private submodule is *not* intended for importation by downstream callers.
'''

# ....................{ TODO                               }....................
#FIXME: Hah-hah! Finally figured out how to do recursive type hints... mostly.
#It's a two-parter consisting of:
#* *PART I.* In the first part:
#  * Refactor our code generation algorithm to additionally maintain a stack of
#    all parent type hints of the currently visited type hint. Note that we need
#    to do this anyway to support the __beartype_hint__() protocol. See "FIXME:"
#    comments in the "beartype.plug._plughintable" submodule pertaining to that
#    protocol for further details on properly building out this stack.
#  * When that algorithm visits a forward reference:
#    * That algorithm calls the express_func_scope_type_ref() function
#      generating type-checking code for that reference. Refactor that call to
#      additionally pass that stack of parent hints to that function.
#    * Refactor the express_func_scope_type_ref() function to:
#      * If the passed forward reference is relative, additionally return that
#        stack in the returned 3-tuple
#        "(forwardref_expr, forwardrefs_class_basename, forwardref_parent_hints)",
#        where "forwardref_parent_hints" is that stack.
#* *PART II.* In the second part:
#  * Refactor the beartype._decor.wrap.wrapmain._unmemoize_func_wrapper_code()
#    function to additionally:
#    * If the passed forward reference is relative *AND* the unqualified
#      basename of an existing attribute in a local or global scope of the
#      currently decorated callable *AND* the value of that attribute is a
#      parent type hint on the stack of parent type hints returned by the
#      previously called express_func_scope_type_ref() function, then
#      *THIS REFERENCE INDICATES A RECURSIVE TYPE HINT.* In this case:
#      * Replace this forward reference with a new recursive type-checking
#        "beartype._check.forward.reference.fwdrefabc.BeartypeForwardRef_{forwardref}"
#        subclass whose is_instance() tester method recursively calls itself
#        indefinitely. If doing so generates a "RecursionError", @beartype
#        considers that the user's problem. *wink*
#
#Done and done. Phew!

# ....................{ IMPORTS                            }....................
from beartype.roar import BeartypeDecorHintNonpepException
from beartype.typing import (
    Dict,
    Optional,
    Tuple,
)
from beartype._cave._cavemap import NoneTypeOr
from beartype._check.forward.reference.fwdrefmake import (
    make_forwardref_indexable_subtype)
from beartype._check.code.codesnip import (
    CODE_HINT_REF_TYPE_BASENAME_PLACEHOLDER_PREFIX,
    CODE_HINT_REF_TYPE_BASENAME_PLACEHOLDER_SUFFIX,
)
from beartype._data.cls.datacls import TYPES_SET_OR_TUPLE
from beartype._data.hint.datahinttyping import (
    LexicalScope,
    Pep484585ForwardRef,
    SetOrTupleTypes,
    TypeOrTupleTypes,
    TupleTypes,
)
from beartype._util.cls.pep.utilpep3119 import (
    die_unless_type_isinstanceable,
    die_unless_type_or_types_isinstanceable,
)
from beartype._util.cls.utilclstest import is_type_builtin
from beartype._util.func.utilfuncscope import add_func_scope_attr
from beartype._util.hint.pep.proposal.pep484585.utilpep484585ref import (
    get_hint_pep484585_ref_names)
from beartype._util.utilobject import get_object_type_basename
from collections.abc import Set

# ....................{ ADDERS ~ type                      }....................
#FIXME: Unit test us up, please.
def add_func_scope_ref(
    # Mandatory parameters.
    func_scope: LexicalScope,
    ref_module_name: Optional[str],
    ref_name: str,

    # Optional parameters.
    exception_prefix: str = 'Globally or locally scoped forward reference ',
) -> str:
    '''
    Add a new **scoped forward reference proxy** (i.e., new key-value pair of
    the passed dictionary mapping from the name to value of each globally or
    locally scoped attribute externally accessed elsewhere, whose key is a
    machine-readable name internally generated by this function to uniquely
    refer to a new forward reference proxy proxying the class with the passed
    attribute name residing in the module with the passed module name) to the
    passed scope *and* return that name.

    Parameters
    ----------
    func_scope : LexicalScope
        Local or global scope to add this class or tuple of classes to.
    ref_module_name : Optional[str]
        Possibly undefined fully-qualified module name referred to by this
        forward reference.
    ref_name : str
        Possibly unqualified classname referred to by this forward reference.
    exception_prefix : str, optional
        Human-readable label prefixing the representation of this object in the
        exception message. Defaults to a sensible string.

    Returns
    -------
    str
        Name of this forward reference proxy in this scope generated by this
        function.

    Raises
    ------
    _BeartypeUtilCallableException
        If an attribute with the same name as that internally generated by this
        adder but having a different value already exists in this scope. This
        adder uniquifies names by object identifier and should thus *never*
        generate name collisions. This exception is thus intentionally raised
        as a private rather than public exception.
    '''

    # Forward reference proxy referring to this class.
    hint_ref = make_forwardref_indexable_subtype(ref_module_name, ref_name)

    # Name of a new parameter passing this forward reference proxy.
    hint_ref_arg_name = add_func_scope_attr(
        func_scope=func_scope, attr=hint_ref)

    # Return this name.
    return hint_ref_arg_name

# ....................{ ADDERS ~ type                      }....................
#FIXME: Unit test us up, please.
def add_func_scope_type_or_types(
    # Mandatory parameters.
    func_scope: LexicalScope,
    type_or_types: TypeOrTupleTypes,

    # Optional parameters.
    exception_prefix: str = (
        'Globally or locally scoped class or tuple of classes '),
) -> str:
    '''
    Add a new **scoped class or tuple of classes** (i.e., new key-value pair of
    the passed dictionary mapping from the name to value of each globally or
    locally scoped attribute externally accessed elsewhere, whose key is a
    machine-readable name internally generated by this function to uniquely
    refer to the passed class or tuple of classes and whose value is that class
    or tuple) to the passed scope *and* return that name.

    This function additionally caches this tuple with the beartypistry
    singleton to reduce space consumption for tuples duplicated across the
    active Python interpreter.

    Parameters
    ----------
    func_scope : LexicalScope
        Local or global scope to add this class or tuple of classes to.
    type_or_types : TypeOrTupleTypes
        Arbitrary class or tuple of classes to be added to this scope.
    exception_prefix : str, optional
        Human-readable label prefixing the representation of this object in the
        exception message. Defaults to a sensible string.

    Returns
    -------
    str
        Name of this class or tuple in this scope generated by this function.

    Raises
    ------
    BeartypeDecorHintNonpepException
        If this hint is either:

        * Neither a class nor tuple.
        * A tuple that is empty.
    BeartypeDecorHintPep3119Exception
        If hint is:

        * A class that is *not* isinstanceable (i.e., passable as the second
          argument to the :func:`isinstance` builtin).
        * A tuple of one or more items that are *not* isinstanceable classes.
    _BeartypeUtilCallableException
        If an attribute with the same name as that internally generated by this
        adder but having a different value already exists in this scope. This
        adder uniquifies names by object identifier and should thus *never*
        generate name collisions. This exception is thus intentionally raised
        as a private rather than public exception.
    '''

    # Return either...
    return (
        # If this hint is a class, the name of a new parameter passing this
        # class;
        add_func_scope_type(
            func_scope=func_scope,
            cls=type_or_types,
            exception_prefix=exception_prefix,
        )
        if isinstance(type_or_types, type) else
        # Else, this hint is *NOT* a class. In this case:
        # * If this hint is a tuple of classes, the name of a new parameter
        #   passing this tuple.
        # * Else, raise an exception.
        add_func_scope_types(
            func_scope=func_scope,
            types=type_or_types,
            exception_prefix=exception_prefix,
        )
    )


def add_func_scope_type(
    # Mandatory parameters.
    func_scope: LexicalScope,
    cls: type,

    # Optional parameters.
    exception_prefix: str = 'Globally or locally scoped class ',
) -> str:
    '''
    Add a new **scoped class** (i.e., new key-value pair of the passed
    dictionary mapping from the name to value of each globally or locally scoped
    attribute externally accessed elsewhere, whose key is a machine-readable
    name internally generated by this function to uniquely refer to the passed
    class and whose value is that class) to the passed scope *and* return that
    name.

    Parameters
    ----------
    func_scope : LexicalScope
        Local or global scope to add this class to.
    cls : type
        Arbitrary class to be added to this scope.
    exception_prefix : str, optional
        Human-readable label prefixing the representation of this object in the
        exception message. Defaults to a sensible string.

    Returns
    -------
    str
        Name of this class in this scope generated by this function.

    Raises
    ------
    BeartypeDecorHintPep3119Exception
        If this class is *not* isinstanceable (i.e., passable as the second
        argument to the :func:`isinstance` builtin).
    _BeartypeUtilCallableException
        If an attribute with the same name as that internally generated by this
        adder but having a different value already exists in this scope. This
        adder uniquifies names by object identifier and should thus *never*
        generate name collisions. This exception is thus intentionally raised
        as a private rather than public exception.
    '''

    # If this object is *NOT* an isinstanceable class, raise an exception.
    die_unless_type_isinstanceable(cls=cls, exception_prefix=exception_prefix)
    # Else, this object is an isinstanceable class.

    # Return either...
    return (
        # If this type is a builtin (i.e., globally accessible C-based type
        # requiring *no* explicit importation), the unqualified basename of
        # this type as is, as this type requires no parametrization;
        get_object_type_basename(cls)
        if is_type_builtin(cls) else
        # Else, the name of a new parameter passing this class.
        add_func_scope_attr(
            func_scope=func_scope, attr=cls, exception_prefix=exception_prefix)
    )


def add_func_scope_types(
    # Mandatory parameters.
    func_scope: LexicalScope,
    types: SetOrTupleTypes,

    # Optional parameters.
    is_unique: bool = False,
    exception_prefix: str = (
        'Globally or locally scoped set or tuple of classes '),
) -> str:
    '''
    Add a new **scoped tuple of classes** (i.e., new key-value pair of the
    passed dictionary mapping from the name to value of each globally or locally
    scoped attribute externally accessed elsewhere, whose key is a
    machine-readable name internally generated by this function to uniquely
    refer to the passed set or tuple of classes and whose value is that tuple)
    to the passed scope *and* return that machine-readable name.

    This function additionally caches this tuple with the beartypistry
    singleton to reduce space consumption for tuples duplicated across the
    active Python interpreter.

    Design
    ------
    Unlike types, tuples are commonly dynamically constructed on-the-fly by
    various tuple factories (e.g., :attr:`beartype.cave.NoneTypeOr`,
    :attr:`typing.Optional`) and hence have no reliable fully-qualified names.
    Instead, this function caches this tuple into the beartypistry under a
    string synthesized as the unique concatenation of:

    * The magic substring :data:`TYPISTRY_HINT_NAME_TUPLE_PREFIX`. Since
      fully-qualified classnames uniquely identifying types as beartypistry keys
      are guaranteed to *never* contain this substring, this substring prevents
      collisions between tuple and type names.
    * This tuple's hash. Note that this tuple's object ID is intentionally *not*
      embedded in this string. Two tuples with the same items are typically
      different objects and thus have different object IDs, despite producing
      identical hashes: e.g.,

      >>> ('Das', 'Kapitel',) is ('Das', 'Kapitel',)
      False
      >>> id(('Das', 'Kapitel',)) == id(('Das', 'Kapitel',))
      False
      >>> hash(('Das', 'Kapitel',)) == hash(('Das', 'Kapitel',))
      True

    The exception is the empty tuple, which is a singleton and thus *always* has
    the same object ID and hash: e.g.,

        >>> () is ()
        True
        >>> id(()) == id(())
        True
        >>> hash(()) == hash(())
        True

    Identifying tuples by their hashes enables the beartypistry singleton to
    transparently cache duplicate class tuples with distinct object IDs as the
    same underlying object, reducing space consumption. While hashing tuples
    does impact time performance, the gain in space is worth the cost.

    Parameters
    ----------
    func_scope : LexicalScope
        Local or global scope to add this object to.
    types : _SetOrTupleOfTypes
        Set or tuple of arbitrary types to be added to this scope.
    is_unique : bool, optional
        ``True`` only if the caller guarantees this tuple to contain *no*
        duplicate types. This boolean is ignored if ``types`` is a set rather
        than tuple. Defaults to :data:`False`. :data:`False`, this function
        assumes this tuple to contain duplicate types by internally:

        #. Coercing this tuple into a set, thus implicitly ignoring both
           duplicates and ordering of types in this tuple.
        #. Coercing that set back into another tuple.
        #. If these two tuples differ, the passed tuple contains one or more
           duplicates; in this case, the duplicate-free tuple is cached and
           passed.
        #. Else, the passed tuple contains no duplicates; in this case, the
           passed tuple is cached and passed.

        This boolean does *not* simply enable an edge-case optimization, though
        it certainly does that; this boolean enables callers to guarantee that
        this function caches and passes the passed tuple rather than a new
        tuple internally created by this function.
    exception_prefix : str, optional
        Human-readable label prefixing the representation of this object in the
        exception message. Defaults to a sensible string.

    Returns
    -------
    str
        Name of this tuple in this scope generated by this function.

    Raises
    ------
    BeartypeDecorHintNonpepException
        If this hint is either:

        * Neither a set nor tuple.
        * A set or tuple that is empty.
    BeartypeDecorHintPep3119Exception
        If one or more items of this hint are *not* isinstanceable classes
        (i.e., classes passable as the second argument to the
        :func:`isinstance` builtin).
    _BeartypeUtilCallableException
        If an attribute with the same name as that internally generated by this
        adder but having a different value already exists in this scope. This
        adder uniquifies names by object identifier and should thus *never*
        generate name collisions. This exception is thus intentionally raised
        as a private rather than public exception.
    '''
    assert is_unique.__class__ is bool, f'{repr(is_unique)} not bool.'

    # If this container is neither a set nor tuple, raise an exception.
    if not isinstance(types, TYPES_SET_OR_TUPLE):
        raise BeartypeDecorHintNonpepException(
            f'{exception_prefix}{repr(types)} neither set nor tuple.')
    # Else, this container is either a set or tuple.
    #
    # If this container is empty, raise an exception.
    elif not types:
        raise BeartypeDecorHintNonpepException(f'{exception_prefix}empty.')
    # Else, this container is non-empty.
    #
    # If this container only contains one type, register only this type.
    elif len(types) == 1:
        return add_func_scope_type(
            # The first and only item of this container, accessed as either:
            # * If this container is a tuple, that item with fast indexing.
            # * If this container is a set, that item with slow iteration.
            cls=types[0] if isinstance(types, tuple) else next(iter(types)),
            func_scope=func_scope,
            exception_prefix=exception_prefix,
        )
    # Else, this container either contains two or more types.

    # If this container is a frozenset, coerce this frozenset into a tuple.
    if isinstance(types, Set):
        types = tuple(types)
    # Else, this container is *NOT* a frozenset. By elimination, this container
    # should now be a tuple.
    #
    # In either case, this container should now be a tuple.

    # If either this container is *NOT* a tuple or is a tuple containing one or
    # more items that are *NOT* isinstanceable classes, raise an exception.
    die_unless_type_or_types_isinstanceable(
        type_or_types=types, exception_prefix=exception_prefix)
    # Else, this container is a tuple containing only isinstanceable classes.

    # If this container is a tuple *AND* the caller failed to guarantee this
    # tuple to be duplicate-free, coerce this tuple into (in order):
    # * A set, thus ignoring duplicates and ordering.
    # * Back into a duplicate-free tuple.
    if isinstance(types, tuple) and not is_unique:
        types = tuple(set(types))
    # In either case, this container is now guaranteed to be a tuple
    # containing only duplicate-free classes.
    assert isinstance(types, tuple), (
        f'{exception_prefix}{repr(types)} not tuple.')

    # If this tuple has *NOT* already been cached, do so.
    if types not in _tuple_union_to_tuple_union:
        _tuple_union_to_tuple_union[types] = types
    # Else, this tuple has already been cached. In this case, deduplicate this
    # tuple by reusing the previously cached tuple.
    else:
        types = _tuple_union_to_tuple_union[types]

    # Return the name of a new parameter passing this tuple.
    return add_func_scope_attr(
        attr=types, func_scope=func_scope, exception_prefix=exception_prefix)

# ....................{ EXPRESSERS ~ type                  }....................
def express_func_scope_type_ref(
    # Mandatory parameters.
    func_scope: LexicalScope,
    forwardref: Pep484585ForwardRef,
    forwardrefs_class_basename: Optional[set],

    # Optional parameters.
    exception_prefix: str = 'Globally or locally scoped forward reference ',
) -> Tuple[str, Optional[set]]:
    '''
    Express the passed :pep:`484`- or :pep:`585`-compliant **forward reference**
    (i.e., fully-qualified or unqualified name of an arbitrary class that
    typically has yet to be declared) as a Python expression evaluating to this
    forward reference when accessed via the beartypistry singleton added as a
    new key-value pair of the passed dictionary, whose key is the string
    :attr:`beartype._check.checkmagic.ARG_NAME_TYPISTRY` and whose value is the
    beartypistry singleton.

    Parameters
    ----------
    func_scope : LexicalScope
        Local or global scope to add this forward reference to.
    forwardref : Pep484585ForwardRef
        Forward reference to be expressed relative to this scope.
    forwardrefs_class_basename : Optional[set]
        Set of all existing **relative forward references** (i.e., unqualified
        basenames of all types referred to by all relative forward references
        relative to this scope) if any *or* :data:`None` otherwise (i.e., if no
        relative forward references have been expressed relative to this scope).
    exception_prefix : str, optional
        Human-readable substring describing this forward reference in exception
        exception message. Defaults to a sensible string.

    Returns
    -------
    Tuple[str, Optional[set]]
        2-tuple ``(forwardref_expr, forwardrefs_class_basename)``, where:

        * ``forwardref_expr`` is the Python expression evaluating to this
          forward reference when accessed via the beartypistry singleton added
          to this scope.
        * ``forwardrefs_class_basename`` is either:

          * If this forward reference is a fully-qualified classname, the
            passed ``forwardrefs_class_basename`` set as is.
          * If this forward reference is an unqualified classname, either:

            * If the passed ``forwardrefs_class_basename`` set is *not*
              :data:`None`, this set with this classname added to it.
            * Else, a new set containing only this classname.

    Raises
    ------
    BeartypeDecorHintForwardRefException
        If this forward reference is *not* actually a forward reference.
    '''

    # Possibly undefined fully-qualified module name and possibly unqualified
    # classname referred to by this forward reference.
    ref_module_name, ref_name = get_hint_pep484585_ref_names(
        hint=forwardref, exception_prefix=exception_prefix)

    # If either...
    if (
        # This reference was instantiated with a module name...
        ref_module_name or
        # This classname contains one or more "." characters and is thus already
        # (...hopefully) fully-qualified...
        '.' in ref_name
    # Then this classname is either absolute *OR* relative to some module. In
    # either case, the class referred to by this reference can now be
    # dynamically imported at a later time. In this case...
    ):
        # Name of the hidden parameter providing this forward reference
        # proxy to be passed to this wrapper function.
        ref_expr = add_func_scope_ref(
            func_scope=func_scope,
            ref_module_name=ref_module_name,
            ref_name=ref_name,
            exception_prefix=exception_prefix,
        )
    # Else, this classname is unqualified. In this case...
    else:
        assert isinstance(forwardrefs_class_basename, NoneTypeOr[set]), (
            f'{repr(forwardrefs_class_basename)} neither set nor "None".')

        # If this set of unqualified classnames referred to by all relative
        # forward references has yet to be instantiated, do so.
        if forwardrefs_class_basename is None:
            forwardrefs_class_basename = set()
        # In any case, this set now exists.

        # Add this unqualified classname to this set.
        forwardrefs_class_basename.add(ref_name)

        # Placeholder substring to be replaced by the caller with a Python
        # expression evaluating to this unqualified classname canonicalized
        # relative to the module declaring the currently decorated callable
        # when accessed via the private "__beartypistry" parameter.
        ref_expr = (
            f'{CODE_HINT_REF_TYPE_BASENAME_PLACEHOLDER_PREFIX}'
            f'{ref_name}'
            f'{CODE_HINT_REF_TYPE_BASENAME_PLACEHOLDER_SUFFIX}'
        )

    # Return a 2-tuple of this expression and set of unqualified classnames.
    return ref_expr, forwardrefs_class_basename

# ....................{ PRIVATE ~ globals                  }....................
_tuple_union_to_tuple_union: Dict[TupleTypes, TupleTypes] = {}
'''
**Tuple union cache** (i.e., dictionary mapping from each tuple union passed to
the :func:`.add_func_scope_types` adder to that same union, preventing tuple
unions from being duplicated across calls to that adder).

This cache serves a dual purpose. Notably, this cache both enables:

* External callers to iterate over all previously instantiated forward reference
  proxies. This is particularly useful when responding to module reloading,
  which requires that *all* previously cached types be uncached.
* A minor reduction in space complexity by de-duplicating duplicating tuple
  unions. Since the existing ``callable_cached`` decorator could trivially do so
  as well, however, this is only a negligible side effect.
'''
