import typing
import types

def dot_set(instance, attribute: str, value, error_list: list[str] = None) -> None:
    """
    #### Inputs:
        -@instance: the instance of a class that you're trying to set a value on
        -@attribute: the attribute on that instance, that you're trying to set
        -@value: the value to set in the attribute
        -@error_list: if provided will add errors to the list, else will throw exception
        -@skip_none: if True, ignore comparing or setting Nones (though check attribute is present)
    #### Expected Behaviour:
        - a very strict version of setattr
        - if the attribute is not found in the annotations of the class,
            it will not set it, and cause error ("cause error" result depends on error_list being set)
        - if the atribute is found, it's type is checked
        - if it's a mismatching type then cause error and it's not set
        - if it's not found within a Literal list of values, cause error and it's not set
        - it is ONLY set, if it's found in annotations, the type matches (including inside Literals)
        - if you pass in error_list, "cause error" would mean the error is added to the error_list, 
            otherwise it will throw an exception
    #### Returns:
        - None
    #### Side Effects:
        - if error_list is passed in, and an error happens, modifies the list
    #### Exceptions:
        - (Exceptions only thrown if error_list not passed in)
        - 'Attribute {} expected type of {}...' thrown if the value is not the same type as the attribute's annotated type in the instance
        - 'Attribute {} expected value in ....' thrown if the value is not within a typehinted Literal list
        - 'Attribute {} not found in...' thrown if the attribute value is 
    """
    annotation_dict = instance.__class__.__annotations__
    if attribute not in annotation_dict: #error case 1 
        return(_handle_error(f'Attribute "{attribute}" not found in "{instance.__class__.__name__}" annotations', error_list))

    hint = annotation_dict[attribute]
    if(not _value_matches_typehint(value, hint)):
        if(typing.get_origin(hint) is typing.Literal):
            return(_handle_error(f'Attribute "{attribute}" expected value in "{typing.get_args(hint)}", got value "{value}"', error_list))
        else:
            return(_handle_error(f'Attribute "{attribute}" expected type of "{hint}", got value "{value}" of type "{type(value)}"', error_list))
    setattr(instance, attribute, value) #safe case
    
        
def dot_get(instance, attribute: str, error_list: list[str] | None = None, generic_error: str = '',
            unannotated_error: str = '', missing_error: str = '', is_none_error: str = ''):
    """
    #### Inputs:
        -@instance: class to get from
        -@attribute: attribute to retrieve
        -@error_list: (optional) if provided will add errors to the list, else with throw exception
        -@generic_error: (optional) if provided, will be the default error message (overrideable by below options)
        -@unannotated_error: (optional) if provided, will override inbuilt and generic_error when the attribute is 
            not in the instance's class annotations
        -@missing_error: (optional) if provided, will override inbuilt and generic_error when the attribute is on the
            annotations of the class, but not found on the instance
        -@error_if_none: (optional) if provided and found value is None, will raise this error
    #### Expected Behaviour:
        - a very strict version of getattr
        - if the attribute is not found in the annotations of the instance's class, error
        - if the attibue exists on the class, but is not found on the instance, error
        - if is_none_error is passed in, and the value is found but is none, error. If not passed in None is returned
            without an error
        - if none of the above errors, returns the value found
    #### Returns:
        - Either None, or the value found in the attribute 
    #### Side Effects:
        - if error_list is passed in, adds to it
    #### Exceptions:
        (none will be thrown if error_list is passed in)
        - (unless overriden by unannotated_error or generic error)
            'Attribute "{attribute}" not found in ... annotations': raised if the attribute isn't on the instance's class
        - (unless overriden by missing_error or generic error)
            'Attribute "{atribute}" not found on instance..' raised if the attribute is on the class, but not this instance
        - (if is_none_error passed in) {is_none_error}: if is_none_error is passed in and found value is None
        (other exceptions can be thrown by dot_get)
    """
    annotation_dict = instance.__class__.__annotations__
    if(attribute not in annotation_dict): #not annotated error 
        error_message = unannotated_error or generic_error or f'Attribute "{attribute}" not found in class "{instance.__class__.__name__}" annotations' 
        return(_handle_error(error_message, error_list))
    if(not hasattr(instance, attribute)): #missing error
        print('missing error')
        error_message = missing_error or generic_error or f'Attribute "{attribute}" not found on instance of "{instance.__class__.__name__}"' 
        return(_handle_error(error_message, error_list))
    found_val = getattr(instance, attribute)
    if(is_none_error and found_val is None): #is none error
        return(_handle_error(is_none_error, error_list))
    return(found_val) #PASS


class ChainLink:
    
    def __init__(self, attribute: str, index: int = None, generic_error: str = '',
                 unannotated_error: str = '', missing_error: str ='', is_none_error: str = ''):
        """
        Class used to access nested classes and provide error custom error messages if issues are encountered
            -@attribute: The attribute of a class to access
            -@index: (optional), if the value found at the above attribute is a list, find the value in it at this index
            -@generic_error: (optional) if provided, will be the default error message (overrideable by below options)
            -@unannotated_error: (optional) custom error if the attribute is not present on the class annotations 
            -@missing_error: (optional) custom error if the attribute is present on the class annotations but not as an attribute of the found value
            -@is_none_error: (optional) custom error if the attribute is present, but returns None (not overriden by generic error)
        """
        self.attribute = attribute
        self.index = index
        self.generic_error = generic_error
        self.unannotated_error = unannotated_error
        self.missing_error = missing_error
        self.is_none_error = is_none_error
    
    accessor: str | int
    index: int
    generic_error: str
    unannotated_error: str
    missing_error: str 
    is_none_error: str
    

def chain_get(instance, chain_list: list[ChainLink], error_list: list[str] = None):
    """
    #### Inputs:
        -@instance: class to get from
        -@chain_list: list of ChainLink instances used to access instance
        -@error_list: (optional), if passed in errors don't throw exceptions they add to the list
    #### Expected Behaviour:
        - for each chainLink in the chain_list use it to safe_get the next attribute
        - chain_link attributes are passed through to the safe_get and error behaviour follows this
        - if an index is passed in, checks that the value found in the attribute is a list and that the index is in bounds
            (e.g if attribute 'classList' is a on a class, use one ChainLink('classList'...), and the next
            ChainLink is ChainLink(0...)
        - if there is no error, the value found is used, and either the next ChainLink is used to access this,
            or if there is no further Link, the value is returned
    #### Returns:
        - the found value or None
    #### Side Effects:
        - if error_list is passed in and error encountered, error_list is modified
    #### Exceptions:
        - (none will be thrown if error_list is passed in)
        - (unless overridden by ChainLink.missing_error)
            'Accessor "Value found at {curr_link.attribute}" is non-iterable': raised if chainLink.index is used  but current
            value is not iterable
        - (unless overriden by ChainLink.missing_error)
            - 'Accessor "{curr_link.attribute}" attempted index out of bounds': raised if chainLink.Accessor is an index but current
                value would have it out of bounds
        - (if is_none_error passed in) {is_none_error}: if is_none_error is passed in and found value is None
        (other exceptions can be thrown by dot_get)
    """
    curr_instance = instance
    temp_error_list = []
    while len(chain_list):
        curr_link = chain_list.pop(0)
        curr_instance = dot_get(curr_instance, curr_link.attribute, temp_error_list, curr_link.generic_error, curr_link.unannotated_error, curr_link.missing_error, curr_link.is_none_error)
        if(len(temp_error_list)) > 0:
            return(_handle_error(temp_error_list[0], error_list)) 
        if(curr_link.index is not None): # int accessor
            if(not isinstance(curr_instance, list) and not isinstance(curr_instance, tuple)):
                error_message = curr_link.missing_error or curr_link.generic_error or f'Value found at Attribute "{curr_link.attribute}" is non-iterable but index was provided'
                return(_handle_error(error_message, error_list))
            elif(len(curr_instance) <= curr_link.index):
                error_message = curr_link.missing_error or curr_link.generic_error or f'Value found at Attribute "{curr_link.attribute}" attempted index out of bounds'
                return(_handle_error(error_message, error_list))
            curr_instance = curr_instance[curr_link.index]
        if(curr_link.is_none_error and curr_instance is None):
            return(_handle_error(curr_link.is_none_error, error_list))
    return(curr_instance)


def _value_matches_typehint(value, typehint):
    """
    #### Inputs:
        -@value: value to compare to the typehint
        -@typehint: a typehint to compare to the value
    #### Expected Behaviour:
        - (version of isinstance that covers non-primitive typehints)
        - has 3 cases
        - 1. primitive isinstance; if the typing.get_origin returns None it's not 
            a complex typehint, so just check if the val is an instance of the typehint
        - 2. non-primitive Literal typehint; if the value is not in this Literal list, return False
        - 3. non-primitive Union typehint; (covers | or Union()), check if the val matches the type of
            any of the types in the union
    #### Returns:
        - True if the val maches the typehint, else False
    #### Side Effects:
        - None
    """
    origin = typing.get_origin(typehint)
    if(origin is None and not isinstance(value, typehint)): #primitive isinstance
        return(False)
    elif(origin is typing.Literal): ### Literal typehint
        expected_vals = typing.get_args(typehint)
        if(value not in expected_vals): 
            return(False)
    elif(typing.get_origin(typehint) is typing.Union or typing.get_origin(typehint) is types.UnionType): #union typehint
        for unionval in typing.get_args(typehint):
            if(_value_matches_typehint(value, unionval) == True):
                return(True)
        return(False)
    return(True)


def _handle_error(error_string: str, error_list: list[str] | None) -> None:
    """
    #### Inputs:
        -@error_string: the error 
        -@error_list: either a list, (indicating to not throw an exception), or None
    #### Expected Behaviour:
        - if error_list is a list, 
        - otherwise, throw an exception with the error_string
    #### Returns:
        - None
    #### Side Effects:
        - if error_list is a list, adds to it
    #### Exceptions:
        - Raised if error_list not passed in 
    """
    if(error_list is None):
        raise(Exception(error_string))
    else:
        error_list.append(error_string)
    