"""Simple singly linked list implementation"""

class LinkedList:
    """
    Implements a "recursive list" data structure
     - Each LinkedList object stores one value
     - A list contains a sequence of individual LinkedList objects
     - To find an element, you start at the LinkedList object and then
       proceed one at a time through consecutive elements until you
       find the element you are searching for (or run out of elements!)
    """

    __slots__ = ["_value", "_rest", "_current"] # limits attributes we can use
    
    def __init__(self, value=None, rest=None):
        """ If no value or rest given, use None"""
        self._value = value
        self._rest = rest
        self._current = self
       
    # *****************
    # getters/setters
    # *****************
    def get_rest(self):
        return self._rest
    
    def get_value(self):
        return self._value

    def set_value(self, val):
        self._value = val

    # *****************
    # special methods
    # *****************
    def _str_elements(self):
        """ helper function for __str__() """
        if self._rest is None:
            return str(self._value)
        else:
            return str(self._value) + ", " + self._rest._str_elements()
        
    def __str__(self):
        """ str representation of object"""
        return "[" + self._str_elements() + "]"
    
    def __repr__(self):
        """ repr() function calls __repr__() method
        return value should be a string that is a valid Python 
        expression that can be used to recreate the LinkedList
        """
        return "LinkedList({}, {})".format(self._value, repr(self._rest))

    def __len__(self):
        """ len() function calls __len__() method """
        # base case: i'm the last item
        if self._rest is None:
            return 1
        #recursive case
        else:
            # same as return 1 + self._rest.__len__()
            return 1 + len(self._rest)  
    
    def __contains__(self, val):
        """ in operator calls __contains__() method """
        if self._value == val:
            return True
        elif self._rest is None:
            return False
        else:
            # same as calling self.rest.___contains__(val)
            return val in self._rest
                        
    def __getitem__(self, index):
        """ [] list index notation calls __getitem__() method
        index specifies which item we want
        """
        # if index is 0, we found the item we need to return
        if index == 0:
            return self._value
        else:
            # else we recurse until index reaches 0
            # remember that this implicitly calls __getitem__
            return self._rest[index - 1]
        
    def __setitem__(self, index, val):
        """ [] list index notation also calls __setitem__() method
        index specifies which item we want, val is new value"""
        # if index is 0, we found the item we need to update
        if index == 0:
            self._value = val
        else:
            # else we recurse until index reaches 0
            # remember that this implicitly calls __setitem__
            self._rest[index - 1] = val
            
    def __eq__(self, other):
        """ == operator calls __eq__() method
        if we want to test two LinkedLists for equality, we test 
        if all items are the same
        other is another LinkedList
        """
        # If both lists are empty
        if self._rest is None and other.get_rest() is None:
            return self._value == other.get_value()

        # If both lists are not empty, then value of current list elements 
        # must match, and same should be recursively true for 
        # rest of the list
        elif self._rest is not None and other.get_rest() is not None :
            return self._value == other.get_value() and self._rest == other.get_rest()

        # If we reach here, then one of the lists is empty and other is not
        else:
            return False
        
    # *****************
    # methods
    # *****************   
    def append(self, val):
        """ append is not a special method, but it is a method
        that we know and love from the Python list class.
        unlike __add__, we do not return a new LinkedList instance
        """
        # if this is the last item
        if self._rest is None:
            # add a new LinkedList to the end
            self._rest = LinkedList(val, None)
        else:
            # else recurse until we find the end
            self._rest.append(val)

    def prepend(self, val):
        """ prepend allows us to add an element to the beginning of our list.
        like append, it will mutate the LinkedList instance it is called on
        LinkedLists are really fast at doing prepend operations -- you can
        see that there's no loop or recursion required, just a few variable re-assignments!
        """
        old_val = self._value
        old_rest = self._rest
        self._value = val
        self._rest = LinkedList(old_val, old_rest)    
    
    def insert(self, val, index): 
        """ insert val into list at position index """   
       # if index is 0, we found the item we need to return                                                                
        if index == 0:
            self.prepend(val)
        # elif we reach the end of the list just append item
        elif self._rest is None:
            self._rest = LinkedList(val)
        else:
            # else we recurse until index reaches 0
            self._rest.insert(val, index - 1)

    def insert_iterative(self, val, index):
        """ here is an iterative version of insert """
        if index == 0:
            self.prepend(val)
        else:
            curr_list = self
            while index > 1:
                index -= 1
                curr_list = curr_list._rest
            curr_list._rest = LinkedList(val, curr_list._rest)


    # ***********************
    # Iterators Class
    # ***********************
    def __iter__(self):
        """ set current attribute to head (front of list) """
        self._current = self
        return self
 
    def __next__(self):
        if self._current is None:
            # we have reached the end of the list
            raise StopIteration
        else:
            # advance current to the next element in the list
            val = self._current._value
            self._current = self._current._rest
            return val

    # ***********************
    # didn't get to in class
    # ***********************
    def __add__(self, other):
        """ + operator calls __add__() method
        + operator returns a mutated LinkedList (not a new instance!)
        (so this actually behaves more like Python list.extend() method) 
        """
        # other is another instance of LinkedList
        # if we are the last item in the list
        if self._rest is None:
            # set _rest to other
            self._rest = other
        else:
            # else, recurse until we reach the last item
            self._rest.__add__(other)
        return self

    def __add2__(self, other):
        """ + operator calls __add__() method
        + operator returns a new instance of LinkedList
        trickier implementation but more consistent with Python lists!
        """
        # other is another instance of LinkedList
        
        # if we've reached the last two items
        if self.get_rest() is None and other.get_rest() is None:
            return LinkedList(self.get_value(), LinkedList(other.get_value()))

        # else if only one list is empty, move on to other
        elif self.get_rest() is None and other.get_rest() is not None:
             return LinkedList(self.get_value(), LinkedList(other.get_value())+other.get_rest())

        # else, recurse until we reach the last item in first list,
        # creating list elements as we go
        else:
            return LinkedList(self.get_value(), (self.get_rest()+other))

# ********************************************
# Only called when program is run as a script
if __name__ == "__main__":
    my_list = LinkedList("a") # a
    my_list = LinkedList("b", my_list) # b -> a
    my_list = LinkedList("c", my_list) # c -> b -> a

    # same as:
    ml = LinkedList("3", LinkedList("2", LinkedList("1")))

    print("my_list:", my_list)
    print(len(my_list))
    print(my_list[2])
    # print("value:",my_list.get_value())
    # print("rest:", my_list.get_rest())

    my_list2 = LinkedList("d")
    my_list2 = LinkedList("e", my_list2)
    print("my_list2:", my_list2)
    
    my_list3 = my_list + my_list2
    print("my_list3 = my_list + my_list2: ",my_list3)
    print("my_list",my_list)
    print("my_list2",my_list2)
    
    # print("Length:", len(my_list))
    # print("c in list?", "c" in my_list)
    # print("x in list?", "x" in my_list)
    # print("my_list[1]:", my_list[1])

    # my_list2 = LinkedList("c", LinkedList("b", LinkedList("a")))
    # print("my_list2:", my_list2)

    # print("my_list == my_list2?", my_list2 == my_list)
    # my_list3 = my_list + my_list2
    # print("my_list3:", my_list3)
    # print("my_list2 == my_list3?", my_list2 == my_list3)
    # print("my_list == my_list3?", my_list3 == my_list)

    # my_list3[1] = "d"
    # print("my_list3 with d:", my_list3)

    # my_list3.append("e")
    # print("my_list3 with e:", my_list3)

    # print("my_list2:", my_list2)
    # my_list2.insert("g", 2)
    # print("my_list2 after insert:", my_list2)
    
    print("my_list3:", my_list3)
    print("testing iterator on my_list3")
    for item in my_list3:
        print(item)

