"""
quirks: sourdough mixin architecture
Corey Rayburn Yung <coreyrayburnyung@gmail.com>
Copyright 2020-2021, Corey Rayburn Yung
License: Apache-2.0 (https://www.apache.org/licenses/LICENSE-2.0)

sourdough quirks are not technically mixins because some have required 
attributes. Traditionally, mixins do not have any attributes and only add 
functionality. quirks are designed for multiple inheritance and easy addition
to other classes. Despite not meeting the traditional definition of "mixin,"
they are internally referred to as "mixins" because their design and goals are
otherwise similar to mixins.

Although python doesn't require the separation of interfaces in the same way
that more structured languages do, some of the includes quirks in this module
are designed to make it clearer for users trying to expand sourdough's 
functionality. These quirks show the required and included methods for core
classes in sourdough. So, whether you intend to directly subclass or write 
alternate classes for use in sourdough, these quirks show how to survive 
static type-checkers and other internal checks made by sourdough.

Contents:
    Base (ABC): base class to be used for all subclasses that wish to use
        sourdough's automatic subclass registration system.
    Quirk (Base, ABC): base class for all sourdough quirks (described above). 
        Its 'library' class attribute stores all subclasses.
    Element (Quirk): quirk that automatically assigns a 'name' attribute if none 
        is passed. The default 'name' will be the snakecase name of the class.
    Importer (Quirk): quirk that supports lazy importation of modules and items 
        stored within them.

ToDo:
    Add in Validator mixins.
    Fix ProxyMixin as explained in its docs.

"""
from __future__ import annotations
import abc
import copy
import dataclasses
import inspect
import types
from typing import (Any, Callable, ClassVar, Dict, Iterable, List, Mapping, 
                    Optional, Sequence, Tuple, Type, Union, get_args, 
                    get_origin)

import more_itertools

import sourdough
       

@dataclasses.dataclass
class Base(abc.ABC):
    """Abstract base class for automatic registration of subclasses. 
    
    Even though not technically a Quirk subclass (because Quirk inherits from
    Base), it should be used in the same manner. Base is used throughout the
    project subpackage as a Quirk to automatically store base subclasses.
    
    Any non-abstract subclass will automatically store itself in the class 
    attribute 'library' using the snakecase name of the class as the key.
    
    Any direct subclass will automatically store itself in the class attribute 
    'bases' using the snakecase name of the class as the key.
    
    Args:
        bases (ClassVar[sourdough.Library]): related Library instance that will 
            store direct subclasses (those with Base in their '__bases__'
            attribute) and allow runtime construction and instancing of those
            stored subclasses.
    
    Attributes:
        library (ClassVar[sourdough.Library]): related Library instance that 
            will store concrete subclasses and allow runtime construction and 
            instancing of those stored subclasses. 'library' is automatically
            created when a direct Base subclass (Base is in its '__bases__') is 
            instanced.
            
    Namespaces: library, bases, and __init_subclass__.
    
    """
    bases: ClassVar[sourdough.Library] = sourdough.Library()
    
    """ Initialization Methods """
    
    def __init_subclass__(cls, **kwargs):
        """Adds 'cls' to 'library' if it is a concrete class."""
        super().__init_subclass__(**kwargs)
        # Creates a snakecase key of the class name.
        key = sourdough.tools.snakify(cls.__name__)
        if Base in cls.__bases__:
            # Adds this class to 'bases' using 'key'.
            setattr(cls.bases, key, cls)
            # Creates a library on this class base for storing subclasses.
            cls.library = sourdough.Library()
        # Adds concrete subclasses to 'library' using 'key'.
        if not abc.ABC in cls.__bases__:
            cls.library[key] = cls

    
@dataclasses.dataclass
class Quirk(abc.ABC):
    """Base class for sourdough quirks (mixin-approximations).
    
    Args:
        library (ClassVar[sourdough.Library]): related Library instance that 
            will store concrete subclasses and allow runtime construction and 
            instancing of those stored subclasses.
    
    Namespaces: library, bases
    
    """
    library: ClassVar[sourdough.Library] = sourdough.Library(
        contents = {'base': Base})
    
    """ Initialization Methods """
    
    def __init_subclass__(cls, **kwargs):
        """Adds 'cls' to 'library' if it is a concrete class."""
        super().__init_subclass__(**kwargs)
        # Creates a snakecase key of the class name.
        key = sourdough.tools.snakify(cls.__name__)
        # Adds base classes to 'bases' using 'key'.
        if not hasattr(Base.bases, 'quirk'):
            setattr(Base.bases, 'quirks', cls)
            setattr(cls, 'bases', Base.bases)
            setattr(cls.bases, key, cls)
        # Adds concrete subclasses to 'library' using 'key'.
        if not abc.ABC in cls.__bases__:
            cls.library[key] = cls
   
    
@dataclasses.dataclass
class Element(Quirk):
    """Mixin for classes that need a 'name' attribute.
    
    Automatically provides a 'name' attribute to a subclass, if it isn't 
    otherwise passed. This is important for parts of sourdough composite objects 
    like trees and graphs.

    Args:
        name (str): designates the name of a class instance that is used for 
            internal referencing throughout sourdough. For example, if a 
            sourdough instance needs settings from a Configuration instance, 
            'name' should match the appropriate section name in a Configuration 
            instance. Defaults to None. 

    Namespaces: library, bases, name, __post_init__, and _get_name

    """
    name: str = None
    
    """ Initialization Methods """

    def __post_init__(self) -> None:
        """Initializes class instance attributes."""
        # Sets 'name' attribute.
        if not hasattr(self, 'name') or self.name is None:  
            self.name = self._get_name()
        # Calls parent and/or mixin initialization method(s).
        try:
            super().__post_init__()
        except AttributeError:
            pass

    """ Private Methods """
    
    def _get_name(self) -> str:
        """Returns snakecase of the class name.

        If a user wishes to use an alternate naming system, a subclass should
        simply override this method. 
        
        Returns:
            str: name of class for internal referencing and some access methods.
        
        """
        return sourdough.tools.snakify(self.__class__.__name__)


@dataclasses.dataclass
class Importer(Quirk):
    """Faciliates lazy importing from modules.

    Subclasses with attributes storing strings containing import paths 
    (indicated by having a '.' in their text) will automatically have those
    attribute values turned into the corresponding stored classes.

    The 'importify' method also allows this process to be performed manually.

    Subclasses should not have custom '__getattribute__' methods or properties
    to avoid errors.

    Namespaces: 'importify', '__getattribute__'
    
    """
 
    """ Public Methods """

    def importify(self, path: str, instance: bool = False, 
             **kwargs) -> Union[object, Type]:
        """Returns object named by 'key'.

        Args:
            key (str): name of class, function, or variable to try to import 
                from modules listed in 'modules'.

        Returns:
            object: imported from a python module.

        """
        item = path.split('.')[-1]
        module = path[:-len(item) - 1]
        imported = sourdough.tools.importify(module = module, key = item)
        if kwargs or instance:
            return imported(**kwargs)
        else:
            return imported

    """ Dunder Methods """

    def __getattribute__(self, name: str) -> Any:
        """Converts stored import paths into the corresponding objects.

        If an import path is stored, that attribute is permanently converted
        from a str to the imported object or class.
        
        Args:
            name (str): name of attribute sought.

        Returns:
            Any: the stored value or, if the value is an import path, the
                class or object stored at the designated import path.
            
        """
        value = super().__getattribute__(name)
        if (isinstance(value, str) and '.' in value):
            value = self.importify(path = value)
            super().__setattr__(name, value)
        return value


@dataclasses.dataclass
class Needy(Quirk):
    """
    
    Args:
        needs (ClassVar[Union[Sequence[str], str]]): attributes needed from 
            another instance for some method within a subclass. Defaults to an
            empty list.
            
    """
    needs: ClassVar[Union[Sequence[str], str]] = []
    
    """ Class Methods """

    @classmethod
    def needify(cls, instance: object) -> Mapping[str, Any]:
        """[summary]

        Args:
            instance (object): [description]

        Raises:
            KeyError: [description]

        Returns:
            Mapping[str, Any]: [description]
            
        """
        kwargs = {}
        for need in more_itertools.always_iterable(cls.needs):
            try:
                kwargs[need] = getattr(instance, need)
            except AttributeError:
                try:
                    kwargs[need] = instance.contents[need]
                except (AttributeError, KeyError):
                    raise KeyError(
                        f'{need} could not be found in order to call a '
                        f'method of {cls.__name__}')
        return kwargs

 
# @dataclasses.dataclass
# class Registrar(Quirk):
#     """Registry interface for core sourdough classes.
    
#     A Registrar automatically registers all concrete (non-abstract) subclasses
#     using the 'registry' class attribute which must be provided by the class 
#     using this quirk.

#     Namespaces: 'registry', 'register', 'acquire'

#     Args:
#         registry (ClassVar[Mapping[str, Type]]):
    
#     """
#     registry: ClassVar[Mapping[str, Type]] = sourdough.Catalog()

#     """ Initialization Methods """
    
#     def __init_subclass__(cls, **kwargs):
#         """Adds 'cls' to 'registry' if it is a concrete class."""
#         super().__init_subclass__(**kwargs)
#         if not abc.ABC in cls.__bases__:
#             cls.register()
        
#     """ Class Methods """

#     @classmethod
#     def register(cls) -> None:
#         """Registers a subclass in a Catalog.
        
#         The default 'register' method uses the snake-case name of the class as
#         the key for the stored subclass.
        
#         """
#         key = sourdough.tools.snakify(cls.__name__)
#         cls.registry[key] = cls
#         return cls

#     @classmethod
#     def acquire(cls, key: str) -> Any:
#         """Returns the stored class matching 'key'.

#         Args:
#             key (str): name of stored class to returned, as defined by the
#                 'register' method.

#         Returns:
#             Any: stored subclass.
            
#         """
#         return cls.registry.select(key = key)
    
    
# @dataclasses.dataclass
# class Librarian(Quirk):
#     """Store interface for core sourdough classes.
    
#     Librarian automatically registers all subclass instances using the 'deposit' 
#     method which stores the subclass instances in 'store'.
    
#     To use this quirk, the '__post_init__' method must be called.
    
#     """
#     store: ClassVar[Mapping[str, object]] = sourdough.Catalog()

#     """ Initialization Methods """

#     def __post_init__(self) -> None:
#         """Initializes class instance attributes."""
#         # Calls parent and/or mixin initialization method(s).
#         try:
#             super().__post_init__()
#         except AttributeError:
#             pass
#         # Stores subclass in Store.
#         self.deposit()

#     """ Public Methods """

#     def deposit(self) -> None:
#         """Stores a subclass instance in a Catalog.
        
#         The 'deposit' method with use the 'name' attribute of an instance, if
#         it exists. Otherwise, it will use the snake-case '__name__' or 
#         '__class_name__' of the instance.
        
#         """
#         try:
#             self.store[self.name] = self
#         except (AttributeError, TypeError):
#             try:
#                 self.store[sourdough.tools.snakify(self.__name__)] = self
#             except (AttributeError, TypeError):
#                 self.store[sourdough.tools.snakify(
#                     self.__class__.__name__)] = self 
#         return self
    
#     """ Class Methods """

#     @classmethod
#     def borrow(cls, key: str) -> object:
#         """Returns the stored class matching 'key'.

#         Args:
#             key (str): name of stored subclass instance to be returned, as 
#                 defined by the 'deposit' method.

#         Returns:
#             object: stored subclass isntance.
            
#         """
#         return copy.deepcopy(cls.store.select(key))


# @dataclasses.dataclass
# class ProxyMixin(object):
#     """ which creates a proxy name for a Element subclass attribute.

#     The 'proxify' method dynamically creates a property to access the stored
#     attribute. This allows class instances to customize names of stored
#     attributes while still maintaining the interface of the base sourdough
#     classes.

#     Only one proxy should be created per class. Otherwise, the created proxy
#     properties will all point to the same attribute.

#     Namespaces: 'proxify', '_proxy_getter', '_proxy_setter', 
#         '_proxy_deleter', '_proxify_attribute', '_proxify_method', the name of
#         the proxy property set by the user with the 'proxify' method.
       
#     To Do:
#         Add property to class instead of instance to prevent return of property
#             object.
#         Implement '__set_name__' in a secondary class to simplify the code and
#             namespace usage.
        
#     """

#     """ Public Methods """

#     def proxify(self,
#             proxy: str,
#             attribute: str,
#             default_value: Any = None,
#             proxify_methods: bool = True) -> None:
#         """Adds a proxy property to refer to class attribute.

#         Args:
#             proxy (str): name of proxy property to create.
#             attribute (str): name of attribute to link the proxy property to.
#             default_value (Any): default value to use when deleting 'attribute' 
#                 with '__delitem__'. Defaults to None.
#             proxify_methods (bool): whether to create proxy methods replacing 
#                 'attribute' in the original method name with the string passed 
#                 in 'proxy'. So, for example, 'add_chapter' would become 
#                 'add_recipe' if 'proxy' was 'recipe' and 'attribute' was
#                 'chapter'. The original method remains as well as the proxy.
#                 This does not change the rest of the signature of the method so
#                 parameter names remain the same. Defaults to True.

#         """
#         self._proxied_attribute = attribute
#         self._default_proxy_value = default_value
#         self._proxify_attribute(proxy = proxy)
#         if proxify_methods:
#             self._proxify_methods(proxy = proxy)
#         return self

#     """ Proxy Property Methods """

#     def _proxy_getter(self) -> Any:
#         """Proxy getter for '_proxied_attribute'.

#         Returns:
#             Any: value stored at '_proxied_attribute'.

#         """
#         return getattr(self, self._proxied_attribute)

#     def _proxy_setter(self, value: Any) -> None:
#         """Proxy setter for '_proxied_attribute'.

#         Args:
#             value (Any): value to set attribute to.

#         """
#         setattr(self, self._proxied_attribute, value)
#         return self

#     def _proxy_deleter(self) -> None:
#         """Proxy deleter for '_proxied_attribute'."""
#         setattr(self, self._proxied_attribute, self._default_proxy_value)
#         return self

#     """ Other Private Methods """

#     def _proxify_attribute(self, proxy: str) -> None:
#         """Creates proxy property for '_proxied_attribute'.

#         Args:
#             proxy (str): name of proxy property to create.

#         """
#         setattr(self, proxy, property(
#             fget = self._proxy_getter,
#             fset = self._proxy_setter,
#             fdel = self._proxy_deleter))
#         return self

#     def _proxify_methods(self, proxy: str) -> None:
#         """Creates proxy method with an alternate name.

#         Args:
#             proxy (str): name of proxy to repalce in method names.

#         """
#         for item in dir(self):
#             if (self._proxied_attribute in item
#                     and not item.startswith('__')
#                     and callable(item)):
#                 self.__dict__[item.replace(self._proxied_attribute, proxy)] = (
#                     getattr(self, item))
#         return self
 
      