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


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


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


def pathlist_to_string(pathlist):
    """
    Convert a list of paths to a str representation. Used to inspect
    intermediate states of graph search.
    """
    path_strs = []
    for path in pathlist:
        path_strs.append(path_to_string(path))
    return "[" + ", ".join(path_strs) + "]"


############################################################
# bfs review
############################################################


filesystem = {
    "home": ["school", "personal", "downloads"],
    "school": ["spring25", "fall25", "spring26"],
    "spring26": ["6.100", "8.02"],
    "personal": ["photos", "bills"],
    "downloads": ["ps1.zip", "ps1", "beach.jpg"],
    "ps1": ["pset.py", "test.py"],
}
flights = {
    "Boston": ["Providence", "New York"],
    "Providence": ["Boston"],
    "New York": ["Chicago", "Phoenix"],
    "Chicago": ["Providence", "Denver"],
    "Phoenix": ["Chicago"],
    "Denver": ["New York", "Phoenix"],
}


def bfs_graph(graph, start, goal):
    # handle case where goal is in frontier 0
    if start == goal:
        return [start]

    # set up frontiers as lists of paths to previously unseen/unvisited nodes
    current_frontier = [[start]]
    next_frontier = []
    visited = {start}

    # iterate over non-empty frontiers
    while len(current_frontier) > 0:

        # expand each node in current frontier
        for path in current_frontier:
            node = path[-1]

            # consider each node's unseen neighbors
            for next_node in neighbors(graph, node):
                if next_node in visited:
                    continue
                visited.add(next_node)

                # return path if found goal, otherwise populate next frontier
                new_path = path + [next_node]
                if next_node == goal:
                    return new_path
                next_frontier.append(new_path)

        current_frontier, next_frontier = next_frontier, []

    return None


# print()
# print(bfs_graph(filesystem, "home", "pset.py"))
# print(bfs_graph(flights, "Boston", "Phoenix"))


############################################################
# depth-first search
############################################################


def dfs_tree(graph, start, goal):
    # base case: found the goal
    if start == goal:
        return True

    # recursive case: start can reach goal if goal is reachable from any
    # of start's neighbors
    for next_node in neighbors(graph, start):
        if dfs_tree(graph, next_node, goal):
            return True

    return False


# same code as above but instrumented with print()s
def dfs_tree(graph, start, goal, indent=""):
    print(indent + f"Call dfs({start!r})")

    if start == goal:
        print(indent + f"found goal, remove frame for dfs({start!r})")
        return True

    for next_node in neighbors(graph, start):
        if dfs_tree(graph, next_node, goal, indent + "  "):
            print(indent + f"found goal, remove frame for dfs({start!r})")
            return True

    print(indent + f"goal unreachable, remove frame for dfs({start!r})")
    return False


def demo_dfs_tree():
    print()
    print(dfs_tree(filesystem, "home", "pset.py"))

    # make "downloads" first child of "home"
    home_folders = filesystem["home"]
    home_folders.insert(0, home_folders.pop())

    print()
    print(dfs_tree(filesystem, "home", "pset.py"))

    # undo
    home_folders.append(home_folders.pop(0))


# demo_dfs_tree()


# return a path or None instead of just True or False
def dfs_tree(graph, start, goal):
    if start == goal:
        return [start]

    for next_node in neighbors(graph, start):
        path_to_goal = dfs_tree(graph, next_node, goal)
        if path_to_goal is not None:
            return [start] + path_to_goal

    return None


# print()
# print(dfs_tree(filesystem, "home", "pset.py"))


# on general graphs, use a visited set to avoid previously seen nodes
def dfs_graph_helper(graph, start, goal, visited):
    # mark node as visited upon calling dfs() on it
    visited.add(start)

    if start == goal:
        return [start]

    # make recursive calls only on unvisited neighbors
    for next_node in neighbors(graph, start):
        if next_node in visited:
            continue

        path_to_goal = dfs_graph_helper(graph, next_node, goal, visited)
        if path_to_goal is not None:
            return [start] + path_to_goal

    return None


def dfs_graph(graph, start, goal):
    return dfs_graph_helper(graph, start, goal, visited=set())


# same code as above but instrumented with print()s
def dfs_graph_helper(graph, start, goal, visited, indent=""):
    print(indent + f"Call dfs({start!r})")
    visited.add(start)

    if start == goal:
        print(indent + f"found goal, remove frame for dfs({start!r})")
        return [start]

    for next_node in neighbors(graph, start):
        if next_node in visited:
            print(indent + f"avoid re-calling dfs({next_node!r})")
            continue

        path_to_goal = dfs_graph_helper(
            graph, next_node, goal, visited, indent + "  "
        )
        if path_to_goal is not None:
            print(indent + f"found goal, remove frame for dfs({start!r})")
            return [start] + path_to_goal

    print(indent + f"goal unreachable, remove frame for dfs({start!r})")
    return None


def demo_dfs_graph():
    print()
    print(dfs_graph(flights, "Boston", "Phoenix"))

    # change the ordering in which we explore New York's neighbors
    flights["New York"].reverse()

    print()
    print(dfs_graph(flights, "Boston", "Phoenix"))

    # undo
    flights["New York"].reverse()


# demo_dfs_graph()


############################################################
# printing nested lists recursively
############################################################


subjects = [
    ["6.100", 12, []],
    ["6.101", 12, ["6.100"]],
    ["6.120", 12, ["18.01"]],
    ["6.121", 12, ["6.120", "6.100A"]],
]


# print()
# print(subjects)


def count_objects(sequence):
    # base case
    if type(sequence) != list:
        return 1

    # recursive case
    count = 0
    for item in sequence:
        count += count_objects(item)
    return count


# print()
# print(count_objects(subjects))


def format_lines(sequence):
    # base case: single line for displaying a non-list object
    # (assume only lists, ints, and strs; no dicts, tuples, or sets)
    if type(sequence) != list:
        return ...  # TODO

    # recursive case: display list elements on new lines with indentation
    lines = []
    lines.append("[")

    for item in sequence:
        lines_to_add = ...  # TODO

        # add indentation
        for i in range(len(lines_to_add)):
            lines_to_add[i] = "  " + lines_to_add[i]

        lines.extend(lines_to_add)

    lines.append("]")
    return lines


def pprint_list(sequence):
    print("\n".join(format_lines(sequence)))


# print()
# pprint_list(subjects)


############################################################
# unifying bfs and dfs with a queue/stack
############################################################


def bfs_fifo(graph, start, goal):
    if start == goal:
        return [start]

    # use a fifo queue of paths to simulate current and next frontier
    queue = [[start]]
    visited = {start}
    while len(queue) > 0:
        print()
        print(f"Current queue: {pathlist_to_string(queue)}")

        # simulate iterating through current frontier
        path = queue.pop(0)
        print(f"  BFS path: {path_to_string(path)}")
        node = path[-1]

        for next_node in neighbors(graph, node):
            if next_node in visited:
                print(f"  skip edge {node} --> {next_node}")
                continue
            print(f"  follow edge {node} --> {next_node}")
            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 dfs_lifo(graph, start, goal):
    # use a lifo stack of paths to simulate recursive dfs() call frames
    stack = [[start]]
    visited = {start}
    while len(stack) > 0:
        print()
        print(f"Current stack: {pathlist_to_string(stack)}")

        # simulate running the body of a recursive dfs() call
        path = stack.pop(-1)
        print(f"  DFS path: {path_to_string(path)}")
        node = path[-1]
        visited.add(node)

        if 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, node)):
            if next_node in visited:
                print(f"  skip edge {node} --> {next_node}")
                continue
            print(f"  follow edge {node} --> {next_node}")

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

    return None


# print()
# print(bfs_fifo(flights, "Boston", "Denver"))
# print()
# print(dfs_lifo(flights, "Boston", "Phoenix"))
