# Task 1.1
# State representation: (Left_Missionaries, Left_Cannibals, Right_Missionaries, Right_Cannibals, Direction)

# This could be done with only Right_Missionaries and Right_Cannibals, and derive the Left_Missionaries and 
# Left_Cannibals, but that would make the code slightly more difficult to write. 

# possible_actions = [(2, 0), (1, 0), (1, 1), (0, 1), (0, 2)], where the left number in each tuple represents the 
# number of missionaries on the boat and the right number represents the number of cannibals on the boat. The boat 
# can only go in the corresponding direction looking at the previous state. 

# Task 1.2
# Initial State: All the cannibals and missionaries are on the left side of the river.
# Goal State: All the cannibals and missionaries are on the right side of the river.

# Task 1.3
# The number of missionaries must always be greater than or equal to the number of cannibals on both sides of the river.
# The people on either side cannot be lesser than 0
# The boat can only carry 2 people at a time
# The boat can only go in the opposite direction of the previous state

# Task 1.4
# I used BFS to solve this problem. Using DFS, we might get a solution, but it might not be the most optimal, as it 
# doesn't give the shortest path. Since this is effectively a graph with uniform weight, using BFS will give us a shortest path to the goal.  

# Task 1.5
# 

# Task 1.6
def mnc_tree_search(m, c):
    '''
    Solution should be the action taken from the root node (initial state) to 
    the leaf node (goal state) in the search tree.

    Parameters
    ----------    
    m: no. of missionaries
    c: no. of cannibals
    
    Returns
    ----------    
    Returns the solution to the problem as a tuple of steps. Each step is a tuple of two numbers x and y, indicating the number of missionaries and cannibals on the boat respectively as the boat moves from one side of the river to another. If there is no solution, return False.
    '''
    from collections import deque
    # the state holds ((L_missionary, L_cannibal), (R_missionary, R_cannibal), nextBoatDirection, current_path)
    initial_state = ((m, c), (0, 0), 'right', ())
    possible_actions = [(2, 0), (1, 0), (1, 1), (0, 1), (0, 2)] # total of 2 people on the boat

    def is_valid_state(state) -> bool:
        if state[0][0] < 0 or state[0][1] < 0 or state[1][0] < 0 or state[1][1] < 0:
            return False
        if state[0][0] == 0 or state[1][0] == 0:
            return True
        return state[0][0] >= state[0][1] and state[1][0] >= state[1][1]

    def is_goal_state(state) -> bool:
        return state[1][0] == m and state[1][1] == c

    def transition(state, action):
        if state[2] == 'right':
            return ((state[0][0] - action[0], state[0][1] - action[1]), 
                    (state[1][0] + action[0], state[1][1] + action[1]), 
                    'left', state[3] + (action, ))
        else:
            return ((state[0][0] + action[0], state[0][1] + action[1]), 
                    (state[1][0] - action[0], state[1][1] - action[1]), 
                    'right', state[3] + (action, ))


    frontier = deque()
    frontier.append(initial_state)
    while frontier:
        state = frontier.popleft()
        for action in possible_actions:
            next_state = transition(state, action)
            if not is_valid_state(next_state):
                continue
            if is_goal_state(next_state):
                return next_state[3]
            frontier.append(next_state)
    return False

# Test cases for Task 1.6
def test_16():
    expected = ((2, 0), (1, 0), (1, 1))
    assert(mnc_tree_search(2,1) == expected)

    expected = ((1, 1), (1, 0), (2, 0), (1, 0), (1, 1))
    print(mnc_tree_search(2,2))
    assert(mnc_tree_search(2,2) == expected)

    expected = ((1, 1), (1, 0), (0, 2), (0, 1), (2, 0), (1, 1), (2, 0), (0, 1), (0, 2), (1, 0), (1, 1))
    assert(mnc_tree_search(3,3) == expected)

# test_16()

# Task 1.7
def mnc_graph_search(m, c):
    '''
    Graph search requires to deal with the redundant path: cycle or loopy path.
    Modify the above implemented tree search algorithm to accelerate your AI.

    Parameters
    ----------    
    m: no. of missionaries
    c: no. of cannibals
    
    Returns
    ----------    
    Returns the solution to the problem as a tuple of steps. Each step is a tuple of two numbers x and y, indicating the number of missionaries and cannibals on the boat respectively as the boat moves from one side of the river to another. If there is no solution, return False.
    '''
    from collections import deque
    # the state holds ((L_missionary, L_cannibal), (R_missionary, R_cannibal), nextBoatDirection, current_path)
    initial_state = ((m, c), (0, 0), 'right', ())
    possible_actions = [(2, 0), (1, 0), (1, 1), (0, 1), (0, 2)] # total of 2 people on the boat

    def is_valid_state(state) -> bool:
        if state[0][0] < 0 or state[0][1] < 0 or state[1][0] < 0 or state[1][1] < 0:
            return False
        if state[0][0] == 0 or state[1][0] == 0:
            return True
        return state[0][0] >= state[0][1] and state[1][0] >= state[1][1]

    def is_goal_state(state) -> bool:
        return state[1][0] == m and state[1][1] == c

    def transition(state, action):
        if state[2] == 'right':
            return ((state[0][0] - action[0], state[0][1] - action[1]), 
                    (state[1][0] + action[0], state[1][1] + action[1]), 
                    'left', state[3] + (action, ))
        else:
            return ((state[0][0] + action[0], state[0][1] + action[1]), 
                    (state[1][0] - action[0], state[1][1] - action[1]), 
                    'right', state[3] + (action, ))


    visited = set()
    frontier = deque()
    frontier.append(initial_state)

    while frontier:
        state = frontier.popleft()
        if (state[0], state[1], state[2]) in visited:
            continue
        visited.add((state[0], state[1], state[2]))
        for action in possible_actions:
            next_state = transition(state, action)
            if not is_valid_state(next_state):
                continue
            if is_goal_state(next_state):
                return next_state[3]
            frontier.append(next_state)
    return False


# Test cases for Task 1.7
def test_17():
    expected = ((2, 0), (1, 0), (1, 1))
    assert(mnc_graph_search(2,1) == expected)

    expected = ((1, 1), (1, 0), (2, 0), (1, 0), (1, 1))
    assert(mnc_graph_search(2,2) == expected)

    expected = ((1, 1), (1, 0), (0, 2), (0, 1), (2, 0), (1, 1), (2, 0), (0, 1), (0, 2), (1, 0), (1, 1))
    assert(mnc_graph_search(3,3) == expected)   

    assert(mnc_graph_search(4, 4) == False)

# test_17()

# Task 1.8
# For the 4 Missionaries and 4 Cannibals case, the tree search does not terminate. Whereas the graph search does 
# terminate and return if no valid solutions can be found. In this situation, the graph search is much faster than 
# the tree search. The graph search should run a lot faster than the tree search also in normal situations where the 
# tree search could make unnecessary visits to states it has been to before. 

# 2.1 State representation for the Pitcher Problem
# State representation: (P1, P2, P3) where P1, P2, P3 are the amount of water in pitcher 1, 2, 3 respectively.

# Actions: Fill P_i, Empty P_i, P_i=>P_j where i, j are the pitcher numbers
# Possible actions: Fill P_i
# Empty P_i
# P_1=>P_2, P_1=>P_3, P_2=>P_1, P_2=>P_3, P_3=>P_1, P_3=>P_2

# 2.2 Initial and Goal State for the Pitcher Problem
# Initial State: (0, 0, 0)
# Goal State: (a, 0, 0) | (0, a, 0) | (0, 0, a) where a, b, c are the amount of water we want to measure

    

# Task 2.3
def pitcher_search(p1,p2,p3,a):
    '''
    Solution should be the action taken from the root node (initial state) to 
    the leaf node (goal state) in the search tree.

    Parameters
    ----------    
    p1: capacity of pitcher 1
    p2: capacity of pitcher 2
    p3: capacity of pitcher 3
    a: amount of water we want to measure
    
    Returns
    ----------    
    Returns the solution to the problem as a tuple of steps. Each step is a string: "Fill Pi", "Empty Pi", "Pi=>Pj". 
    If there is no solution, return False.
    '''
    # Initial state is represented as ((P1, P2, P3), current_path)
    capacities = (p1, p2, p3)
    initial_state = ((0, 0, 0), ())
    actions = (('FILL', 1), ('FILL', 2), ('FILL', 3),
               ('EMPTY', 1), ('EMPTY', 2), ('EMPTY', 3),
               ('POUR', 1, 2), ('POUR', 1, 3), ('POUR', 2, 1), ('POUR', 2, 3), ('POUR', 3, 1), ('POUR', 3, 2))
    from collections import deque
    queue = deque()
    queue.append(initial_state)
    visited = set()
    while queue:
        current_state, steps = queue.popleft()

        # goal finding
        for idx, state in enumerate(current_state):
            if state == a:
                return steps
        if current_state in visited:
            continue
        print(current_state, steps)
        visited.add(current_state)

        for action in actions:
            next_state = list(current_state)
            if action[0] == 'POUR':
                idx1 = action[1] - 1
                idx2 = action[2] - 1
                if current_state[idx1] == 0 or current_state[idx2] == capacities[idx2]:
                    continue
                # If water in current pitcher is more than the space left in the other pitcher
                if current_state[idx1] >= (capacities[idx2] - current_state[idx2]):
                    next_state[idx1] -= (capacities[idx2] - current_state[idx2])
                    next_state[idx2] = capacities[idx2]
                else:
                    next_state[idx2] += next_state[idx1]
                    next_state[idx1] = 0
                queue.append((tuple(next_state), steps + (f'P{action[1]}=>P{action[2]}', )))
            if action[0] == 'FILL':
                idx = action[1] - 1
                if next_state[idx] == capacities[idx]:
                    continue
                next_state[idx] = capacities[idx]
                queue.append((tuple(next_state), steps + (f'Fill P{action[1]}', )))
                continue
            if action[0] == 'EMPTY':
                idx = action[1] - 1
                if next_state[idx] == 0:
                    continue
                next_state[idx] = 0
                queue.append((tuple(next_state), steps +(f'Empty P{action[1]}', ) ))
                continue
    return False

# Test cases for Task 2.3
def test_23():
    expected = ('Fill P2', 'P2=>P1')
    assert(pitcher_search(2,3,4,1) == expected)

    expected = ('Fill P3', 'P3=>P1', 'Empty P1', 'P3=>P1')
    assert(pitcher_search(1,4,9,7) == expected)

    assert(pitcher_search(2,3,7,8) == False)

test_23()
