"""Travelling Salesman Problem (TSP)"""

import itertools
import math


class InvalidGraphError(ValueError):
    """Custom error for invalid graph inputs."""


def euclidean_distance(point1: list[float], point2: list[float]) -> float:
    """
    Calculate the Euclidean distance between two points in 2D space.

    :param point1: Coordinates of the first point [x, y]
    :param point2: Coordinates of the second point [x, y]
    :return: The Euclidean distance between the two points

    >>> euclidean_distance([0, 0], [3, 4])
    5.0
    >>> euclidean_distance([1, 1], [1, 1])
    0.0
    >>> euclidean_distance([1, 1], ['a', 1])
    Traceback (most recent call last):
        ...
    ValueError: Invalid input: Points must be numerical coordinates
    """
    try:
        return math.sqrt((point2[0] - point1[0]) ** 2 + (point2[1] - point1[1]) ** 2)
    except TypeError:
        raise ValueError("Invalid input: Points must be numerical coordinates")


def validate_graph(graph_points: dict[str, list[float]]) -> None:
    """
    Validate the input graph to ensure it has valid nodes and coordinates.

    :param graph_points: A dictionary where the keys are node names,
                         and values are 2D coordinates as [x, y]
    :raises InvalidGraphError: If the graph points are not valid

    >>> validate_graph({"A": [10, 20], "B": [30, 21], "C": [15, 35]})  # Valid graph
    >>> validate_graph(  # doctest: +IGNORE_EXCEPTION_DETAIL
    ...     {"A": [10, 20], "B": [30, "invalid"], "C": [15, 35]}
    ... )
    Traceback (most recent call last):
        ...
    InvalidGraphError: Each node must have a valid 2D coordinate [x, y]

    >>> validate_graph([10, 20])  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
        ...
    InvalidGraphError: Graph must be a dictionary with node names and coordinates

    >>> validate_graph(  # doctest: +IGNORE_EXCEPTION_DETAIL
    ...     {"A": [10, 20], "B": [30, 21], "C": [15]}
    ... )  # Missing coordinate
    Traceback (most recent call last):
        ...
    InvalidGraphError: Each node must have a valid 2D coordinate [x, y]
    """
    if not isinstance(graph_points, dict):
        raise InvalidGraphError(
            "Graph must be a dictionary with node names and coordinates"
        )

    for node, coordinates in graph_points.items():
        if (
            not isinstance(node, str)
            or not isinstance(coordinates, list)
            or len(coordinates) != 2
            or not all(isinstance(c, (int, float)) for c in coordinates)
        ):
            raise InvalidGraphError("Each node must have a valid 2D coordinate [x, y]")


# TSP in Brute Force Approach
def travelling_salesman_brute_force(
    graph_points: dict[str, list[float]],
) -> tuple[list[str], float]:
    """
    Solve the Travelling Salesman Problem using brute force.

    :param graph_points: A dictionary of nodes and their coordinates {node: [x, y]}
    :return: The shortest path and its total distance

    >>> graph = {"A": [10, 20], "B": [30, 21], "C": [15, 35]}
    >>> travelling_salesman_brute_force(graph)
    (['A', 'B', 'C', 'A'], 56.35465722402588)
    """
    validate_graph(graph_points)

    nodes = list(graph_points.keys())  # Extracting the node names (keys)

    # There should be at least 2 nodes for a valid TSP
    if len(nodes) < 2:
        raise InvalidGraphError("Graph must have at least two nodes")

    min_path = []  # List that stores shortest path
    min_distance = float("inf")  # Initialize minimum distance to infinity

    start_node = nodes[0]
    other_nodes = nodes[1:]

    # Iterating over all permutations of the other nodes
    for perm in itertools.permutations(other_nodes):
        path = [start_node, *perm, start_node]

        # Calculating the total distance
        total_distance = sum(
            euclidean_distance(graph_points[path[i]], graph_points[path[i + 1]])
            for i in range(len(path) - 1)
        )

        # Update minimum distance if shorter path found
        if total_distance < min_distance:
            min_distance = total_distance
            min_path = path

    return min_path, min_distance


# TSP in Dynamic Programming approach
def travelling_salesman_dynamic_programming(
    graph_points: dict[str, list[float]],
) -> tuple[list[str], float]:
    """
    Solve the Travelling Salesman Problem using dynamic programming.

    :param graph_points: A dictionary of nodes and their coordinates {node: [x, y]}
    :return: The shortest path and its total distance

    >>> graph = {"A": [10, 20], "B": [30, 21], "C": [15, 35]}
    >>> travelling_salesman_dynamic_programming(graph)
    (['A', 'C', 'B', 'A'], 56.35465722402587)
    """
    validate_graph(graph_points)

    n = len(graph_points)  # Extracting the node names (keys)

    # There should be at least 2 nodes for a valid TSP
    if n < 2:
        raise InvalidGraphError("Graph must have at least two nodes")

    nodes = list(graph_points.keys())  # Extracting the node names (keys)

    # Initialize distance matrix with float values
    dist = [
        [
            euclidean_distance(graph_points[nodes[i]], graph_points[nodes[j]])
            for j in range(n)
        ]
        for i in range(n)
    ]

    # Initialize a dynamic programming table with infinity
    dp = [[float("inf")] * n for _ in range(1 << n)]
    dp[1][0] = 0  # Only visited node is the starting point at node 0

    # Iterate through all masks of visited nodes
    for mask in range(1 << n):
        for u in range(n):
            # If current node 'u' is visited
            if mask & (1 << u):
                # Traverse nodes 'v' such that u->v
                for v in range(n):
                    if mask & (1 << v) == 0:  # If v is not visited
                        next_mask = mask | (1 << v)  # Upodate mask to include 'v'
                        # Update dynamic programming table with minimum distance
                        dp[next_mask][v] = min(
                            dp[next_mask][v], dp[mask][u] + dist[u][v]
                        )

    final_mask = (1 << n) - 1
    min_cost = float("inf")
    end_node = -1  # Track the last node in the optimal path

    for u in range(1, n):
        if min_cost > dp[final_mask][u] + dist[u][0]:
            min_cost = dp[final_mask][u] + dist[u][0]
            end_node = u

    path = []
    mask = final_mask
    while end_node != 0:
        path.append(nodes[end_node])
        for u in range(n):
            # If current state corresponds to optimal state before visiting end node
            if (
                mask & (1 << u)
                and dp[mask][end_node]
                == dp[mask ^ (1 << end_node)][u] + dist[u][end_node]
            ):
                mask ^= 1 << end_node  # Update mask to remove end node
                end_node = u  # Set the previous node as end node
                break

    path.append(nodes[0])  # Bottom-up Order
    path.reverse()  # Top-Down Order
    path.append(nodes[0])

    return path, min_cost


# Demo Graph
#        C (15, 35)
#        |
#        |
#        |
# F (5, 15) --- A (10, 20)
#        |         |
#        |         |
#        |         |
#        |         |
# E (25, 5) --- B (30, 21)
#        |
#        |
#        |
#       D (40, 10)
#        |
#        |
#        |
#       G (50, 25)


if __name__ == "__main__":
    demo_graph = {
        "A": [10.0, 20.0],
        "B": [30.0, 21.0],
        "C": [15.0, 35.0],
        "D": [40.0, 10.0],
        "E": [25.0, 5.0],
        "F": [5.0, 15.0],
        "G": [50.0, 25.0],
    }

    # Brute force
    brute_force_result = travelling_salesman_brute_force(demo_graph)
    print(f"Brute force result: {brute_force_result}")

    # Dynamic programming
    dp_result = travelling_salesman_dynamic_programming(demo_graph)
    print(f"Dynamic programming result: {dp_result}")
