from types import MappingProxyType
from array import array
from frozendict import frozendict
import warnings
from collections.abc import MutableMapping, MutableSequence, MutableSet


# fix for python 3.9-

if not issubclass(array, MutableSequence):
    MutableSequence.register(array)


def isIterableNotString(o):
    from collections import abc
    
    return (
        isinstance(o, abc.Iterable) and 
        not isinstance(o, memoryview) and 
        not hasattr(o, "isalpha")
    )


def getItems(o):
    from collections import abc
    
    if not isinstance(o, abc.Iterable):
        raise TypeError("object must be an iterable")
    
    if isinstance(o, abc.Mapping):
        return dict.items
    
    return enumerate


_freeze_conversion_map = frozendict({
    MutableMapping: frozendict, 
    bytearray: bytes, 
    MutableSequence: tuple, 
    MutableSet: frozenset, 
})

_freeze_conversion_map_custom = {}


class FreezeError(Exception):
    pass


class FreezeWarning(UserWarning):
    pass


def register(to_convert, converter, *, inverse = False):
    r"""
    Adds a `converter` for a type `to_convert`. `converter`
    must be callable. The new converter will be used by `deepfreeze()`
    and has precedence over any previous converter.
    
    If `to_covert` has already a converter, a FreezeWarning is raised.
    
    If `inverse` is True, the conversion is considered from an immutable 
    type to a mutable one. This make it possible to convert mutable
    objects nested in the registered immutable one.
    """
    
    if not issubclass(type(to_convert), type):
        raise ValueError(
            f"`to_convert` parameter must be a type, {to_convert} found"
        )
    
    try:
        converter.__call__
    except AttributeError:
        raise ValueError(
            f"`converter` parameter must be a callable, {converter}" +
            "found"
        )
    
    if inverse:
        freeze_conversion_map = getFreezeConversionInverseMap()
    else:
        freeze_conversion_map = getFreezeConversionMap()
    
    if to_convert in freeze_conversion_map:
        warnings.warn(
            f"{to_convert.__name__} is already in the conversion map",
            FreezeWarning
        )
    
    if inverse:
        freeze_conversion_map = _freeze_conversion_inverse_map_custom
    else:
        freeze_conversion_map = _freeze_conversion_map_custom
    
    freeze_conversion_map[to_convert] = converter


def unregister(type, inverse = False):
    r"""
    Unregister a type from custom conversion. If `inverse` is `True`,
    the unregistered conversion is an inverse conversion
    (see `register()`).
    """
    
    if inverse:
        freeze_conversion_map = _freeze_conversion_inverse_map_custom
    else:
        freeze_conversion_map = _freeze_conversion_map_custom
    
    try:
        del freeze_conversion_map[type]
    except KeyError:
        raise FreezeError(f"{type.__name__} is not registered")


def getFreezeConversionMap():
    return _freeze_conversion_map | _freeze_conversion_map_custom


_freeze_conversion_inverse_map = frozendict({
    frozendict: dict, 
    MappingProxyType: dict, 
    tuple: list, 
})

_freeze_conversion_inverse_map_custom = {}


def getFreezeConversionInverseMap():
    return (
        _freeze_conversion_inverse_map |
        _freeze_conversion_inverse_map_custom
    )


_freeze_types = (
    [x for x in _freeze_conversion_map] +
    [x for x in _freeze_conversion_inverse_map]
)


def getFreezeTypes():
    return (tuple(
        _freeze_types + 
        [x for x in _freeze_conversion_map_custom] + 
        [x for x in _freeze_conversion_inverse_map_custom]
    ))


_freeze_types_plain = (MutableSet, bytearray, array)


def deepfreeze(
        o,
        custom_converters = None,
        custom_inverse_converters = None
):
    r"""
    Converts the object and all the objects nested in it in its
    immutable counterparts.
    
    The conversion map is in getFreezeConversionMap().
    
    You can register a new conversion using `register()` You can also
    pass a map of custom converters with `custom_converters` and a map
    of custom inverse converters with `custom_inverse_converters`,
    without using `register()`.
    
    By default, if the type is not registered and has a `__dict__`
    attribute, it's converted to the `frozendict` of that `__dict__`.
    
    This function assumes that hashable == immutable (that is not
    always true).
    
    This function uses recursion, with all the limits of recursions in
    Python.
    
    Where is a good old tail call when you need it?
    """
    
    from frozendict import frozendict
    
    if custom_converters is None:
        custom_converters = frozendict()
    
    if custom_inverse_converters is None:
        custom_inverse_converters = frozendict()
    
    for type_i, converter in custom_converters.items():
        if not issubclass(type(type_i), type):
            raise ValueError(
                f"{type_i} in `custom_converters` parameter is not a " +
                "type"
            )
        
        try:
            converter.__call__
        except AttributeError:
            raise ValueError(
                f"converter for {type_i} in `custom_converters` " + 
                "parameter is not a callable"
            )
    
    for type_i, converter in custom_inverse_converters.items():
        if not issubclass(type(type_i), type):
            raise ValueError(
                f"{type_i} in `custom_inverse_converters` parameter " +
                "is not a type"
            )
        
        try:
            converter.__call__
        except AttributeError:
            raise ValueError(
                f"converter for {type_i} in  " +
                "`custom_inverse_converters`parameter is not a callable"
            )
    
    type_o = type(o)
    
    freeze_types = tuple(custom_converters.keys()) + getFreezeTypes()
    
    base_type_o = None
    
    for freeze_type in freeze_types:
        if isinstance(o, freeze_type):
            base_type_o = freeze_type
            break
    
    if base_type_o is None:
        try:
            o.__dict__
        except AttributeError:
            pass
        else:
            return frozendict(o.__dict__)
        
        try:
            hash(o)
        except TypeError:
            pass
        else:
            # without a converter, we can only hope that
            # hashable == immutable
            return o
        
        supported_types = ", ".join((x.__name__ for x in freeze_types))
        
        err = (
            f"type {type_o} is not hashable or is not equal or a " +
            f"subclass of the supported types: {supported_types}"
        )
        
        raise TypeError(err)
    
    freeze_conversion_map = getFreezeConversionMap()
    
    freeze_conversion_map = freeze_conversion_map | custom_converters
    
    if base_type_o in _freeze_types_plain:
        return freeze_conversion_map[base_type_o](o)
    
    if not isIterableNotString(o):
        return freeze_conversion_map[base_type_o](o)
    
    freeze_conversion_inverse_map = getFreezeConversionInverseMap()
    
    freeze_conversion_inverse_map = (
        freeze_conversion_inverse_map |
        custom_inverse_converters
    )
    
    frozen_type = base_type_o in freeze_conversion_inverse_map
    
    if frozen_type:
        o = freeze_conversion_inverse_map[base_type_o](o)
    
    from copy import copy
    
    o_copy = copy(o)
    
    for k, v in getItems(o_copy)(o_copy):
        o_copy[k] = deepfreeze(
            v,
            custom_converters = custom_converters,
            custom_inverse_converters = custom_inverse_converters
        )
    
    try:
        freeze = freeze_conversion_map[base_type_o]
    except KeyError:
        if frozen_type:
            freeze = type_o
        else:
            raise
    
    return freeze(o_copy)


__all__ = (
    deepfreeze.__name__, 
    register.__name__, 
    unregister.__name__, 
    getFreezeConversionMap.__name__, 
    getFreezeConversionInverseMap.__name__, 
    FreezeError.__name__, 
    FreezeWarning.__name__, 
)

del MappingProxyType
del array
del frozendict
