import heapq

def dijkstra(graph, start, verbose = False):
    '''Example implementation of Dijkstra's Algorithm using a heap.
       Input: graph: the graph in adjacency-list form
              start: the node ID of the starting point
              verbose: whether to print out debugging information (default False)
       Assumes: start node is actually in the graph. 
       Returns: A tuple containing a dictionary of finalized estimates per node, and 
                                   a dictionary storing the shortest-path tree.'''
    
    # Initialize estimates to infinity for each node
    distances = {node: float('inf') for node in graph}
    # Initialize estimate for starting point to be 0
    distances[start] = 0
    # Store the shortest-path tree explicitly, so we can reconstruct paths
    predecessors = {node: None for node in graph}
    # Initialize the priority queue 
    pq = [(0, start)]
    
    # While we have reachable nodes left to process
    while pq:
        # remove the next node (which should have a minimal remaining estimate)
        curr_dist, curr = heapq.heappop(pq)
        if verbose: print(f'Handling heap entry: {curr} at cost: {curr_dist}')

        # Skip if we've already processed this node. We need this check 
        # because we're not _updating_ keys, just _re-adding_. 
        if curr_dist > distances[curr]:
            if verbose: print(' Ignoring this entry.')
            continue

        # Handle every out-edge from the current node:    
        for neighbor, weight in graph[curr]:
            dist = curr_dist + weight # we know we can get to the neighbor this way at worst
            if dist < distances[neighbor]: # if that's actually an improvement:
                # Update the upper-bound estimate for the neighbor 
                distances[neighbor] = dist
                # Update the predecessor in the shortest-path tree 
                predecessors[neighbor] = curr
                # Update the priority queue to reflect this new estimate
                heapq.heappush(pq, (dist, neighbor))
    
    return distances, predecessors

def reconstruct_path(predecessors, start, end):
    '''Reconstruct the shortest path from start to end by using the tree in predecessors'''
    if predecessors[end] is None and start != end:
        return None  # No path exists
    
    path = [] # Otherwise, walk backwards from the end node
    current = end
    while current is not None:
        path.append(current)
        current = predecessors[current]
    
    path.reverse()
    return path

# Example graph, represented as an adjacency list 
graph_from_class = {
    'G': [('A', 3), ('D', 4), ('C', 3)],
    'A': [('G', 3), ('B', 1), ('D', 3)],
    'D': [('A', 3), ('G', 4), ('C', 2), ('B', 5)],
    'B': [('D', 5), ('A', 1), ('E', 2)],
    'E': [('B', 2), ('F', 2)],
    'F': [('E', 2), ('C', 6)],
    'C': [('G', 3), ('D', 2), ('F', 6)],
    # and we'll add an unreachable node for demo purposes
    'X': []
}

# This part only runs when this file is invoked directly. 
if __name__ == '__main__':
    distances, predecessors = dijkstra(graph_from_class, 'G')
    # In testing, we'll want to make sure that the actual path costs 
    # equal what's in the distance dictionary.
    print('Paths from G: ')
    for node in sorted(graph_from_class.keys()):
        path = reconstruct_path(predecessors, 'G', node)
        if path:
            print(f" to {node}: {' -> '.join(path)} (distance: {distances[node]})")
        else:
            print(f" to {node}: No path")
