############################################################
# graph and display helpers
############################################################


def neighbors(graph, node):
    return graph.get(node, [])


def path_to_string(path):
    """path is a list of nodes"""
    if path is None:
        return "None"
    node_strs = []
    for node in path:
        node_strs.append(str(node))
    return "-->".join(node_strs)


def pathlist_to_string(queue):
    """path is a list of nodes"""
    path_strs = []
    for path in queue:
        path_strs.append(path_to_string(path))
    return "[" + ", ".join(path_strs) + "]"


############################################################
# unifying dfs and bfs with lifo and fifo queues
############################################################


def dfs_lifo(graph, start, goal):
    stack = [[start]]

    while len(stack) > 0:
        print("Current stack:", pathlist_to_string(stack))

        # simulate running the body of a recursive dfs() call
        path = stack.pop(-1)
        print("  Current DFS path:", path_to_string(path))

        current_node = path[-1]
        if current_node == goal:
            return path

        # put children on stack in reverse order of which
        # we intend to explore them, because stack is lifo
        for next_node in reversed(neighbors(graph, current_node)):
            if next_node in path:
                continue

            # prepare to simulate running dfs() on next_node
            stack.append(path + [next_node])

    return None


def bfs_fifo(graph, start, goal):
    if start == goal:
        return [start]
    queue = [[start]]
    visited = {start}

    while len(queue) > 0:
        print("Current queue:", pathlist_to_string(queue))

        # simulate iterating through current frontier
        path = queue.pop(0)
        print("  Current BFS path:", path_to_string(path))

        current_node = path[-1]
        for next_node in neighbors(graph, current_node):
            if next_node in visited:
                continue
            visited.add(next_node)
            new_path = path + [next_node]
            if next_node == goal:
                return new_path

            # simulate building next frontier
            queue.append(new_path)
    return None


def demo_dfs_bfs_queue():
    graph = {
        "A": ["B", "C", "D"],
        "B": ["E"],
        "C": ["E", "F", "G"],
        "F": ["H"],
        "G": ["H"],
    }

    print(dfs_lifo(graph, "A", "H"))
    print()
    print(bfs_fifo(graph, "A", "H"))
    print()

# demo_dfs_bfs_queue()


############################################################
# Dijkstra's on flight cost graph
############################################################


def remove_min(queue):
    assert len(queue) > 0
    best_idx = 0
    min_so_far = queue[0]
    for idx in range(1, len(queue)):
        item = queue[idx]
        if item < min_so_far:
            best_idx = idx
            min_so_far = item
    return queue.pop(best_idx)


def find_node(queue, node):
    for idx in range(len(queue)):
        (cost, path) = queue[idx]
        if path[-1] == node:
            return idx
    return None


def update_node(queue, node, new_cost, new_path):
    idx = find_node(queue, node)
    if idx is None:
        print(f"  Adding path to {node!r}")
        queue.append((new_cost, new_path))
        print(f"  Current queue: {queue}")
    else:
        old_cost, old_path = queue[idx]
        if new_cost < old_cost:
            print(f"  Updating path to {node!r}")
            queue[idx] = (new_cost, new_path)
            print(f"  Current queue: {queue}")
        else:
            print(f"  No change to queue as new cost ({new_cost}) is not smaller ({old_cost})")


def dijkstra(graph, start, goal):
    # store best cost and path for discovered nodes that are
    # on present or future frontier
    queue = [(0, [start])]
    # separately, store nodes that are on past frontiers
    finished = set()

    while len(queue) > 0:
        print(f"Current queue: {queue}")

        # get a path off the true frontier
        cost, path = remove_min(queue)
        current_node = path[-1]
        finished.add(current_node)
        print(f"  Finished {current_node!r} with cost {cost}. Finished queue : {finished}")

        # optimality guaranteed for current node, return if goal
        if current_node == goal:
            return (cost, path)

        print(f"Expanding to neighbors from: {current_node}")
        # update paths to neighbors on priority queue
        for edge in neighbors(graph, current_node):
            (next_node, weight) = edge
            if next_node not in finished:
                print(f" Processing {current_node!r}-->{next_node!r} with weight {weight}")
                new_cost = cost + weight
                new_path = path + [next_node]
                update_node(queue, next_node, new_cost, new_path)

        print()

    return None


def demo_dijkstra():
    flights = {
        "Boston": [("Providence", 1), ("New York", 2)],
        "Providence": [("Boston", 1), ("New York", 2)],
        "New York": [("Chicago", 3)],
        "Chicago": [("Phoenix", 5), ("Denver", 3)],
        "Denver": [("New York", 4), ("Phoenix", 1)],
    }
    print(dijkstra(flights, "Boston", "Phoenix"))


# demo_dijkstra()
