import sys
from enum import Enum
import networkx as nx
from lxml import etree
# This is imported in print_graph() to make it optional
# import matplotlib.pyplot as plt
from . import nodes as n
from .edges import Edge
from . import enums as e
from .xml_processing import convert_string_to_bool, parse_file

LINK_GRAPH_REPOSITORY: str = "'\033[1;36mhttps://github.com/precice/config-graph\033[0m'"


def get_graph(root: etree.Element) -> nx.Graph:
    assert root.tag == "precice-configuration"

    # Taken from config-visualizer. Modified to also return postfix.
    def find_all_with_prefix(e: etree.Element, prefix: str):
        for child in e.iterchildren():
            if child.tag.startswith(prefix):
                postfix = child.tag[child.tag.find(":") + 1:]
                yield child, postfix

    def find_all_with_postfix(e: etree.Element, postfix: str):
        for child in e.iterchildren():
            if child.tag.endswith(postfix):
                prefix: str = child.tag.removesuffix(postfix)
                yield child, prefix

    def error(message: str):
        sys.exit(
            "\033[1;31m[ERROR]\033[0m Exiting graph generation."
            + "\n"
            + message
            + "\nPlease run 'precice-tools check' (preCICE version <3.30) "
              "or 'precice-config-validate' (preCICE version ≥3.30) to check for syntax errors."
            + "\n\nIf you are sure this behaviour is incorrect, please leave a report at "
            + LINK_GRAPH_REPOSITORY
        )

    def error_missing_attribute(e: etree.Element, key: str):
        message: str = 'Missing attribute "' + key + '" for element "' + e.tag + '".'
        error(message)

    def get_enum_values(enum: Enum) -> list:
        return list(map(lambda x: x.value, enum._member_map_.values()))

    def list_to_string(values: list) -> str:
        string: str = ""
        size = len(values)
        for i in range(size - 2):
            string += f'"{values[i]}", '
        string += f'"{values[size - 2]}" or "{values[size - 1]}".'
        return string

    def error_unknown_type(e: etree.Element, type: str, possible_types_list: list):
        possible_types = list_to_string(possible_types_list)
        message: str = (
                'Unknown type "'
                + type
                + '" for element "'
                + e.tag
                + '".\nUse one of '
                + possible_types
        )
        error(message)

    def get_attribute(e: etree.Element, key: str):
        attribute = e.get(key)
        if not attribute:
            error_missing_attribute(e, key)
        return attribute

    # FIND NODES

    # Keep track of these types of nodes, since we cannot construct them on demand when referenced,
    # since the reference does not contain relevant data.
    data_nodes: dict[str, n.DataNode] = {}
    mesh_nodes: dict[str, n.MeshNode] = {}
    participant_nodes: dict[str, n.ParticipantNode] = {}
    write_data_nodes: list[n.WriteDataNode] = []
    read_data_nodes: list[n.ReadDataNode] = []
    receive_mesh_nodes: list[n.ReceiveMeshNode] = []
    coupling_nodes: list[n.CouplingSchemeNode] = []
    multi_coupling_nodes: list[n.MultiCouplingSchemeNode] = []
    mapping_nodes: list[n.MappingNode] = []
    export_nodes: list[n.ExportNode] = []
    exchange_nodes: list[n.ExchangeNode] = []
    acceleration_nodes: list[n.AccelerationNode] = []
    acceleration_data_nodes: list[n.AccelerationDataNode] = []
    convergence_measure_nodes: list[n.ConvergenceMeasureNode] = []
    action_nodes: list[n.ActionNode] = []
    m2n_nodes: list[n.M2NNode] = []
    watch_point_nodes: list[n.WatchPointNode] = []
    watch_integral_nodes: list[n.WatchIntegralNode] = []

    # Data items – <data:… />
    for (data_el, kind) in find_all_with_prefix(root, "data"):
        name = get_attribute(data_el, "name")
        try:
            type = e.DataType(kind)
        except ValueError:
            possible_types_list = get_enum_values(e.DataType)
            error_unknown_type(data_el, kind, possible_types_list)
        line: int = data_el.sourceline
        node = n.DataNode(name, type, line=line)
        data_nodes[name] = node

    # Meshes – <mesh />
    for mesh_el in root.findall("mesh"):
        name = get_attribute(mesh_el, "name")
        line: int = mesh_el.sourceline
        mesh = n.MeshNode(name, line=line)

        # Data usages – <use-data />: Will be mapped to edges
        for use_data in mesh_el.findall("use-data"):
            data_name = get_attribute(use_data, "name")
            data_node = data_nodes[data_name]
            mesh.use_data.append(data_node)

        # Now that mesh_node is completely built, add it to our dictionary
        mesh_nodes[name] = mesh

    # Participants – <participant />
    for participant_el in root.findall("participant"):
        name = get_attribute(participant_el, "name")
        line: int = participant_el.sourceline
        participant = n.ParticipantNode(name, line=line)

        # Provide- and Receive-Mesh
        # <provide-mesh />
        for provide_mesh_el in participant_el.findall("provide-mesh"):
            mesh_name = get_attribute(provide_mesh_el, "name")
            participant.provide_meshes.append(mesh_nodes[mesh_name])

        # Read and write data
        # <write-data />
        for write_data_el in participant_el.findall("write-data"):
            data_name = get_attribute(write_data_el, "name")
            data = data_nodes[data_name]
            mesh_name = get_attribute(write_data_el, "mesh")
            mesh = mesh_nodes[mesh_name]
            line: int = write_data_el.sourceline

            write_data = n.WriteDataNode(participant, data, mesh, line=line)
            participant.write_data.append(write_data)
            write_data_nodes.append(write_data)

        # <read-data />
        # TODO: Refactor to reduce code duplication
        for read_data_el in participant_el.findall("read-data"):
            data_name = get_attribute(read_data_el, "name")
            data = data_nodes[data_name]
            mesh_name = get_attribute(read_data_el, "mesh")
            mesh = mesh_nodes[mesh_name]
            line: int = read_data_el.sourceline

            read_data = n.ReadDataNode(participant, data, mesh, line=line)
            participant.read_data.append(read_data)
            read_data_nodes.append(read_data)

        # Mapping
        for (mapping_el, kind) in find_all_with_prefix(participant_el, "mapping"):
            direction = get_attribute(mapping_el, "direction")
            # From mesh might not exist due to just-in-time mapping
            from_mesh_name = mapping_el.get("from")
            from_mesh = mesh_nodes[from_mesh_name] if from_mesh_name else None
            # From mesh might not exist due to just-in-time mapping
            to_mesh_name = mapping_el.get("to")
            to_mesh = mesh_nodes[to_mesh_name] if to_mesh_name else None

            try:
                method = e.MappingMethod(kind)
            except ValueError:
                possible_method_list = get_enum_values(e.MappingMethod)
                possible_methods: str = list_to_string(possible_method_list)
                message: str = (
                        'Unknown method "'
                        + kind
                        + '" for element "'
                        + mapping_el.tag
                        + '".\nUse one of '
                        + possible_methods
                )
                error(message)
            constraint = e.MappingConstraint(get_attribute(mapping_el, "constraint"))

            if not from_mesh and not to_mesh:
                error_missing_attribute(mapping_el, 'from" or "to')
            just_in_time = not (from_mesh and to_mesh)
            line: int = mapping_el.sourceline

            mapping = n.MappingNode(
                participant,
                e.Direction(direction),
                just_in_time,
                method,
                constraint,
                from_mesh,
                to_mesh,
                line=line,
            )

            participant.mappings.append(mapping)
            mapping_nodes.append(mapping)

        # Exports
        # <export:… />
        for (_, kind) in find_all_with_prefix(participant_el, "export"):
            try:
                type = e.ExportFormat(kind)
            except ValueError:
                possible_types_list = get_enum_values(e.ExportFormat)
                error_unknown_type(_, kind, possible_types_list)
            line: int = _.sourceline
            export = n.ExportNode(participant, type, line=line)
            export_nodes.append(export)

        # Actions
        # <action:… />
        for (action_el, kind) in find_all_with_prefix(participant_el, "action"):
            mesh = mesh_nodes[get_attribute(action_el, "mesh")]
            timing = e.TimingType(get_attribute(action_el, "timing"))

            target_data = None
            if kind in ["multiply-by-area", "divide-by-area", "summation", "python"]:
                target_data_el = action_el.find("target-data")
                if target_data_el is not None:
                    target_data = data_nodes[get_attribute(target_data_el, "name")]

            source_data: list[n.DataNode] = []
            if kind in ["summation", "python"]:
                source_data_els = action_el.findall("source-data")
                for source_data_el in source_data_els:
                    source_data.append(
                        data_nodes[get_attribute(source_data_el, "name")]
                    )

            try:
                type = e.ActionType(kind)
            except ValueError:
                possible_types_list = get_enum_values(e.ActionType)
                error_unknown_type(action_el, kind, possible_types_list)
            line: int = action_el.sourceline

            action = n.ActionNode(
                participant, type, mesh, timing, target_data, source_data, line=line
            )
            participant.actions.append(action)
            action_nodes.append(action)

        # Watch-Points
        # <watch-point />
        for watch_point_el in participant_el.findall("watch-point"):
            point_name = get_attribute(watch_point_el, "name")
            mesh = mesh_nodes[get_attribute(watch_point_el, "mesh")]
            line: int = watch_point_el.sourceline

            watch_point = n.WatchPointNode(point_name, participant, mesh, line=line)
            watch_point_nodes.append(watch_point)

        # Watch-Integral
        # <watch-integral />
        for watch_integral_el in participant_el.findall("watch-integral"):
            integral_name = get_attribute(watch_integral_el, "name")
            mesh = mesh_nodes[get_attribute(watch_integral_el, "mesh")]
            line: int = watch_integral_el.sourceline

            watch_integral = n.WatchIntegralNode(integral_name, participant, mesh, line=line)
            watch_integral_nodes.append(watch_integral)

        # Now that participant_node is completely built, add it and children to the graph and our dictionary
        participant_nodes[name] = participant

    # Receive Mesh Participants
    # This can't be done in the participants loop, since it references participants which might not yet be created
    # <participant />
    for participant_el in root.findall("participant"):
        name = get_attribute(participant_el, "name")
        participant = participant_nodes[
            name
        ]  # This should not fail, because we created participants before

        # <receive-mesh />
        for receive_mesh_el in participant_el.findall("receive-mesh"):
            mesh_name = get_attribute(receive_mesh_el, "name")
            mesh = mesh_nodes[mesh_name]

            from_participant_name = get_attribute(receive_mesh_el, "from")
            from_participant = participant_nodes[from_participant_name]

            api_access_str = receive_mesh_el.get("api-access")
            if api_access_str:
                api_access = convert_string_to_bool(api_access_str)
            else:
                api_access = False
            line: int = receive_mesh_el.sourceline

            receive_mesh = n.ReceiveMeshNode(
                participant, mesh, from_participant, api_access, line=line
            )
            participant.receive_meshes.append(receive_mesh)
            receive_mesh_nodes.append(receive_mesh)

    # Coupling Scheme – <coupling-scheme:… />
    for (coupling_scheme_el, kind) in find_all_with_prefix(root, "coupling-scheme"):
        coupling_scheme = None
        line: int = coupling_scheme_el.sourceline
        match kind:
            case "serial-explicit" | "serial-implicit" | "parallel-explicit" | "parallel-implicit":
                # <participants />
                participants_list = coupling_scheme_el.findall("participants")
                if len(participants_list) > 1:
                    message: str = (
                            "Multiple 'participants' tags in '"
                            + coupling_scheme_el.tag
                            + "'"
                    )
                    error(message)
                elif len(participants_list) < 1:
                    error_missing_attribute(coupling_scheme_el, "participants")
                participants = participants_list[0]
                first_participant_name = get_attribute(participants, "first")
                first_participant = participant_nodes[first_participant_name]
                second_participant_name = get_attribute(participants, "second")
                second_participant = participant_nodes[second_participant_name]

                type = e.CouplingSchemeType(kind)

                coupling_scheme = n.CouplingSchemeNode(
                    type, first_participant, second_participant, line=line
                )
            case "multi":
                control_participant = None
                participants = []
                # <participant name="..." />
                for participant_el in coupling_scheme_el.findall("participant"):
                    name = get_attribute(participant_el, "name")
                    participant = participant_nodes[name]
                    participants.append(participant)

                    control = (
                            "control" in participant_el.attrib
                            and convert_string_to_bool(participant_el.get("control"))
                    )
                    if control:
                        assert (
                                control_participant is None
                        )  # there must not be multiple control participants
                        control_participant = participant

                assert (
                        control_participant is not None
                ), "There must be a control participant"

                coupling_scheme = n.MultiCouplingSchemeNode(
                    control_participant, participants, line=line
                )
            case _:
                possible_types_list = [
                    "serial-explicit",
                    "serial-implicit",
                    "parallel-explicit",
                    "parallel-implicit",
                    "multi",
                ]
                error_unknown_type(coupling_scheme_el, kind, possible_types_list)

        assert (
                coupling_scheme is not None
        )  # there must always be one participant that is in control

        # Exchanges – <exchange />
        for exchange_el in coupling_scheme_el.findall("exchange"):
            data_name = get_attribute(exchange_el, "data")
            data = data_nodes[data_name]
            mesh_name = get_attribute(exchange_el, "mesh")
            mesh = mesh_nodes[mesh_name]
            from_participant_name = get_attribute(exchange_el, "from")
            from_participant = participant_nodes[from_participant_name]
            to_participant_name = get_attribute(exchange_el, "to")
            to_participant = participant_nodes[to_participant_name]
            line: int = exchange_el.sourceline

            exchange = n.ExchangeNode(
                coupling_scheme, data, mesh, from_participant, to_participant, line=line
            )
            coupling_scheme.exchanges.append(exchange)
            exchange_nodes.append(exchange)

        for (acceleration_el, a_kind) in find_all_with_prefix(
                coupling_scheme_el, "acceleration"
        ):
            if kind in ["serial-explicit", "parallel-explicit"]:
                possible_types = list_to_string(
                    ["serial-implicit", "parallel-implicit", "multi"]
                )
                message: str = (
                        f"The coupling scheme of type '{kind}' does not support acceleration.\nUse one of "
                        + possible_types
                        + "\nOtherwise remove the acceleration tag."
                )
                error(message)

            try:
                type = e.AccelerationType(a_kind)
            except ValueError:
                possible_types_list = get_enum_values(e.AccelerationType)
                error_unknown_type(acceleration_el, a_kind, possible_types_list)
            line: int = acceleration_el.sourceline

            acceleration = n.AccelerationNode(coupling_scheme, type, line=line)

            possible_types_list = ["aitken", "IQN-ILS", "IQN-IMVJ"]

            if a_kind == "constant" and acceleration_el.find("data"):
                possible_types: str = list_to_string(possible_types_list)
                message: str = (
                        "No data tag is expected for 'constant' acceleration.\nUse one of "
                        + possible_types
                        + "\nOtherwise remove the acceleration tag."
                )
                error(message)

            if a_kind in possible_types_list:
                for a_data in acceleration_el.findall("data"):
                    a_data_name = get_attribute(a_data, "name")
                    data = data_nodes[a_data_name]
                    a_mesh_name = get_attribute(a_data, "mesh")
                    mesh = mesh_nodes[a_mesh_name]
                    line: int = a_data.sourceline
                    a_data_node = n.AccelerationDataNode(acceleration, data, mesh, line=line)
                    acceleration.data.append(a_data_node)
                    acceleration_data_nodes.append(a_data_node)

            coupling_scheme.acceleration = acceleration
            acceleration_nodes.append(acceleration)

        for (convergence_measure_el, c_kind) in find_all_with_postfix(
                coupling_scheme_el, "-convergence-measure"
        ):
            match kind:
                case "serial-implicit" | "parallel-implicit" | "multi":
                    try:
                        type = e.ConvergenceMeasureType(c_kind)
                    except ValueError:
                        possible_types_list = get_enum_values(e.ConvergenceMeasureType)
                        error_unknown_type(
                            convergence_measure_el, c_kind, possible_types_list
                        )

                    c_data_name = get_attribute(convergence_measure_el, "data")
                    c_data = data_nodes[c_data_name]
                    c_mesh_name = get_attribute(convergence_measure_el, "mesh")
                    c_mesh = mesh_nodes[c_mesh_name]
                    line: int = convergence_measure_el.sourceline

                    convergence_measure = n.ConvergenceMeasureNode(
                        coupling_scheme, type, c_data, c_mesh, line=line
                    )
                    coupling_scheme.convergence_measures.append(convergence_measure)
                    convergence_measure_nodes.append(convergence_measure)

                case "parallel-explicit" | "serial-explicit":
                    possible_types: str = list_to_string(
                        ["serial-implicit", "parallel-implicit", "multi"]
                    )
                    message: str = (
                            f"The coupling scheme of type '{kind}' does not support convergence-measure.\nUse one of "
                            + possible_types
                            + f"\nOtherwise remove the {c_kind}-convergence-measure tag."
                    )
                    error(message)

        match kind:
            case "serial-explicit" | "serial-implicit" | "parallel-explicit" | "parallel-implicit":
                coupling_nodes.append(coupling_scheme)
            case "multi":
                multi_coupling_nodes.append(coupling_scheme)

    # M2N – <m2n:… />
    for (m2n_el, kind) in find_all_with_prefix(root, "m2n"):
        try:
            type = e.M2NType(kind)
        except ValueError:
            possible_types_list = get_enum_values(e.M2NType)
            error_unknown_type(m2n_el, kind, possible_types_list)
        acceptor_name = get_attribute(m2n_el, "acceptor")
        acceptor = participant_nodes[acceptor_name]
        connector_name = get_attribute(m2n_el, "connector")
        connector = participant_nodes[connector_name]
        line: int = m2n_el.sourceline

        m2n = n.M2NNode(type, acceptor, connector, line=line)
        m2n_nodes.append(m2n)

    # BUILD GRAPH
    # from found nodes and inferred edges

    # Use an undirected graph
    g = nx.Graph()

    for data in data_nodes.values():
        add_node_with_attributes(g, data)

    for mesh in mesh_nodes.values():
        add_node_with_attributes(g, mesh)
        for data in mesh.use_data:
            g.add_edge(data, mesh, attr=Edge.USE_DATA)

    for participant in participant_nodes.values():
        add_node_with_attributes(g, participant)
        for mesh in participant.provide_meshes:
            g.add_edge(participant, mesh, attr=Edge.PROVIDE_MESH__PARTICIPANT_PROVIDES)
        # Use data and write data, as well as receive mesh nodes are added later

    for read_data in read_data_nodes:
        add_node_with_attributes(g, read_data)
        g.add_edge(read_data, read_data.data, attr=Edge.READ_DATA__DATA_READ_BY)
        g.add_edge(read_data, read_data.mesh, attr=Edge.READ_DATA__MESH_READ_BY)
        g.add_edge(
            read_data,
            read_data.participant,
            attr=Edge.READ_DATA__PARTICIPANT__BELONGS_TO,
        )

    for write_data in write_data_nodes:
        add_node_with_attributes(g, write_data)
        g.add_edge(write_data, write_data.data, attr=Edge.WRITE_DATA__WRITES_TO_DATA)
        g.add_edge(write_data, write_data.mesh, attr=Edge.WRITE_DATA__WRITES_TO_MESH)
        g.add_edge(
            write_data,
            write_data.participant,
            attr=Edge.WRITE_DATA__PARTICIPANT__BELONGS_TO,
        )

    for receive_mesh in receive_mesh_nodes:
        add_node_with_attributes(g, receive_mesh)
        g.add_edge(receive_mesh, receive_mesh.mesh, attr=Edge.RECEIVE_MESH__MESH)
        g.add_edge(
            receive_mesh,
            receive_mesh.from_participant,
            attr=Edge.RECEIVE_MESH__PARTICIPANT_RECEIVED_FROM,
        )
        g.add_edge(
            receive_mesh,
            receive_mesh.participant,
            attr=Edge.RECEIVE_MESH__PARTICIPANT__BELONGS_TO,
        )

    for mapping in mapping_nodes:
        add_node_with_attributes(g, mapping)
        if mapping.from_mesh:
            g.add_edge(mapping, mapping.from_mesh, attr=Edge.MAPPING__FROM_MESH)
        if mapping.to_mesh:
            g.add_edge(mapping, mapping.to_mesh, attr=Edge.MAPPING__TO_MESH)
        g.add_edge(
            mapping,
            mapping.parent_participant,
            attr=Edge.MAPPING__PARTICIPANT__BELONGS_TO,
        )

    for export in export_nodes:
        add_node_with_attributes(g, export)
        g.add_edge(
            export, export.participant, attr=Edge.EXPORT__PARTICIPANT__BELONGS_TO
        )

    for action in action_nodes:
        add_node_with_attributes(g, action)
        g.add_edge(
            action, action.participant, attr=Edge.ACTION__PARTICIPANT__BELONGS_TO
        )
        g.add_edge(action, action.mesh, attr=Edge.ACTION__MESH)
        if action.target_data is not None:
            g.add_edge(action, action.target_data, attr=Edge.ACTION__TARGET_DATA)
        for source_data in action.source_data:
            g.add_edge(action, source_data, attr=Edge.ACTION__SOURCE_DATA)

    for watch_point in watch_point_nodes:
        add_node_with_attributes(g, watch_point)
        g.add_edge(
            watch_point,
            watch_point.participant,
            attr=Edge.WATCH_POINT__PARTICIPANT__BELONGS_TO,
        )
        g.add_edge(watch_point, watch_point.mesh, attr=Edge.WATCH_POINT__MESH)

    for watch_integral in watch_integral_nodes:
        add_node_with_attributes(g, watch_integral)
        g.add_edge(
            watch_integral,
            watch_integral.participant,
            attr=Edge.WATCH_INTEGRAL__PARTICIPANT__BELONGS_TO,
        )
        g.add_edge(watch_integral, watch_integral.mesh, attr=Edge.WATCH_INTEGRAL__MESH)

    for coupling in coupling_nodes:
        add_node_with_attributes(g, coupling)
        # Edges to and from exchanges will be added by exchange nodes
        g.add_edge(
            coupling,
            coupling.first_participant,
            attr=Edge.COUPLING_SCHEME__PARTICIPANT_FIRST,
        )
        g.add_edge(
            coupling,
            coupling.second_participant,
            attr=Edge.COUPLING_SCHEME__PARTICIPANT_SECOND,
        )

    for coupling in multi_coupling_nodes:
        add_node_with_attributes(g, coupling)
        for participant in coupling.participants:
            g.add_edge(
                coupling, participant, attr=Edge.MULTI_COUPLING_SCHEME__PARTICIPANT
            )
        # Previous, “regular” multi-coupling scheme participant edge, gets overwritten
        g.add_edge(
            coupling,
            coupling.control_participant,
            attr=Edge.MULTI_COUPLING_SCHEME__PARTICIPANT__CONTROL,
        )

    for exchange in exchange_nodes:
        add_node_with_attributes(g, exchange)
        g.add_edge(
            exchange, exchange.from_participant, attr=Edge.EXCHANGE__EXCHANGED_FROM
        )
        g.add_edge(exchange, exchange.to_participant, attr=Edge.EXCHANGE__EXCHANGES_TO)
        g.add_edge(exchange, exchange.data, attr=Edge.EXCHANGE__DATA)
        g.add_edge(exchange, exchange.mesh, attr=Edge.EXCHANGE__MESH)
        g.add_edge(
            exchange,
            exchange.coupling_scheme,
            attr=Edge.EXCHANGE__COUPLING_SCHEME__BELONGS_TO,
        )

    for acceleration in acceleration_nodes:
        add_node_with_attributes(g, acceleration)
        g.add_edge(
            acceleration,
            acceleration.coupling_scheme,
            attr=Edge.ACCELERATION__COUPLING_SCHEME__BELONGS_TO,
        )

    for acceleration_data in acceleration_data_nodes:
        add_node_with_attributes(g, acceleration_data)
        g.add_edge(
            acceleration_data,
            acceleration_data.acceleration,
            attr=Edge.ACCELERATION_DATA__ACCELERATION__BELONGS_TO,
        )
        g.add_edge(
            acceleration_data, acceleration_data.data, attr=Edge.ACCELERATION_DATA__DATA
        )
        g.add_edge(
            acceleration_data, acceleration_data.mesh, attr=Edge.ACCELERATION_DATA__MESH
        )

    for convergence_measure in convergence_measure_nodes:
        add_node_with_attributes(g, convergence_measure)
        g.add_edge(
            convergence_measure,
            convergence_measure.coupling_scheme,
            attr=Edge.CONVERGENCE_MEASURE__COUPLING_SCHEME__BELONGS_TO,
        )
        g.add_edge(
            convergence_measure,
            convergence_measure.data,
            attr=Edge.CONVERGENCE_MEASURE__DATA,
        )
        g.add_edge(
            convergence_measure,
            convergence_measure.mesh,
            attr=Edge.CONVERGENCE_MEASURE__MESH,
        )

    for m2n in m2n_nodes:
        add_node_with_attributes(g, m2n)
        g.add_edge(m2n, m2n.acceptor, attr=Edge.M2N__PARTICIPANT_ACCEPTOR)
        g.add_edge(m2n, m2n.connector, attr=Edge.M2N__PARTICIPANT_CONNECTOR)

    return g


def add_node_with_attributes(g: nx.Graph, node_obj) -> None:
    """
    Add the given node to the given graph, alongside a dict of the nodes attributes.
    :param g: The graph to add the node to.
    :param node_obj: The node to add to the graph.
    :return: None
    """
    # Get all the attributes of the node and copy them, as to not modify the originals
    attributes: dict = vars(node_obj).copy()
    clean_attributes: dict[str, str | int | list[str]] = {}

    # A set to keep track of keys that reference other nodes
    # To later ignore attributes which contain named elements,
    # e.g., "name=Generator", we need to keep track of these attributes
    ref_keys: set[str] = {"name"}

    for key, value in attributes.items():
        # Ignore "line" attribute to make the check location/order independent
        if key == "line":
            continue

        # Store the enum value as a string
        if hasattr(value, "value") and isinstance(value, Enum):
            clean_attributes[key] = value.value

        # Handle lists of attributes
        elif isinstance(value, list):
            if len(value) == 0:
                # Store 0 to prove the list is empty
                clean_attributes[key] = 0
            # If the list is not empty, check if the contained object has a "name" attribute
            elif hasattr(value[0], "name"):
                # If so, then store the name and add the key to the "reference_keys"
                clean_attributes[key] = sorted([item.name for item in value])
                ref_keys.add(key)
            else:
                # Store the count of other nodes
                clean_attributes[key] = len(value)

        # Handle single node attributes
        elif hasattr(value, "name"):
            # If they have a name, store the key in the "reference_keys"
            clean_attributes[key] = value.name
            ref_keys.add(key)

        elif hasattr(value, "__dict__"):
            # Otherwise, save the class name
            clean_attributes[key] = value.__class__.__name__

        # Primitive data types such as bool, int, str can be added directly
        else:
            clean_attributes[key] = value

    # Add metadata
    clean_attributes["_class_type"] = node_obj.__class__.__name__

    # Store sorted list of reference keys (safe for export/serialization)
    clean_attributes["_ref_keys"] = sorted(list(ref_keys))

    g.add_node(node_obj, **clean_attributes)


def print_graph(graph: nx.Graph):
    try:
        import matplotlib.pyplot as plt
    except ImportError:
        print(f"\033[1;31m[ERROR]\033[0m 'matplotlib' is required to plot the graph.")
        print(f"Please install it with: pip install '.[viz]'")
        return

    SIZE:int = 300

    def color_for_node(node):
        match node:
            case n.DataNode():
                return [1, 0.3, 0]
            case n.ReadDataNode() | n.WriteDataNode():
                return [1, 0.5, 0.5]
            case n.MeshNode():
                return [0.9, 0.6, 0]
            case n.ReceiveMeshNode():
                return [0.95, 0.75, 0]
            case n.ParticipantNode():
                return [0.3, 0.6, 1.0]
            case n.ExchangeNode():
                return [0.8, 0.8, 0.8]
            case n.CouplingSchemeNode() | n.MultiCouplingSchemeNode():
                return [0.65, 0.65, 0.65]
            case n.WriteDataNode():
                return [0.7, 0, 1.0]
            case n.MappingNode():
                return [0.1, 0.7, 0.1]
            case n.ExportNode():
                return [0.5, 0.8, 1.0]
            case n.ActionNode():
                return [0.3, 0.5, 0.8]
            case n.WatchPointNode() | n.WatchIntegralNode():
                return [0.5, 0.0, 1.0]
            case _:
                return [0.5, 0.5, 0.5]

    def append_list(list, node, color, size=SIZE):
        list[2].append(node)
        list[3].append(color)
        list[4].append(size * list[1])

    nodes_circle = ["o", 1, [], [], []]
    nodes_circle_small = [".", 1, [], [], []]
    nodes_triangle_up = ["^", 1, [], [], []]
    nodes_triangle_down = ["v", 1, [], [], []]
    nodes_triangle_left = ["<", 1, [], [], []]
    nodes_triangle_right = [">", 1, [], [], []]
    nodes_square = ["s", 0.9, [], [], []]
    nodes_diamond = ["d", 0.9, [], [], []]
    nodes_diamond_wide = ["D", 0.8, [], [], []]
    nodes_pentagon = ["p", 1.4, [], [], []]
    nodes_hexagon_vertical = ["h", 1.4, [], [], []]
    nodes_hexagon_horizontal = ["H", 1.4, [], [], []]
    nodes_octagon = ["8", 1.2, [], [], []]
    nodes_plus = ["P", 1.1, [], [], []]
    nodes_cross = ["X", 1, [], [], []]
    nodes_star = ["*", 1.5, [], [], []]
    node_lists = [
        nodes_circle,
        nodes_circle_small,
        nodes_triangle_up,
        nodes_triangle_down,
        nodes_triangle_left,
        nodes_triangle_right,
        nodes_square,
        nodes_diamond,
        nodes_diamond_wide,
        nodes_pentagon,
        nodes_hexagon_vertical,
        nodes_hexagon_horizontal,
        nodes_octagon,
        nodes_plus,
        nodes_cross,
        nodes_star,
    ]

    for node in graph.nodes():
        color = color_for_node(node)
        match node:
            case n.ParticipantNode():
                append_list(nodes_star, node, color)
            case n.MeshNode():
                append_list(nodes_square, node, color)
            case n.CouplingSchemeNode() | n.MultiCouplingSchemeNode():
                append_list(nodes_octagon, node, color)
            case n.DataNode():
                append_list(nodes_diamond_wide, node, color)
            case n.AccelerationNode():
                append_list(nodes_plus, node, color)
            case _:
                append_list(nodes_circle, node, color)

    def label_for_edge(edge):
        match edge["attr"]:
            case (
            Edge.RECEIVE_MESH__PARTICIPANT__BELONGS_TO
            | Edge.MAPPING__PARTICIPANT__BELONGS_TO
            | Edge.EXCHANGE__COUPLING_SCHEME__BELONGS_TO
            | Edge.WRITE_DATA__PARTICIPANT__BELONGS_TO
            | Edge.READ_DATA__PARTICIPANT__BELONGS_TO
            | Edge.EXPORT__PARTICIPANT__BELONGS_TO
            | Edge.ACTION__PARTICIPANT__BELONGS_TO
            | Edge.WATCH_POINT__PARTICIPANT__BELONGS_TO
            | Edge.WATCH_INTEGRAL__PARTICIPANT__BELONGS_TO
            | Edge.ACCELERATION__COUPLING_SCHEME__BELONGS_TO
            | Edge.CONVERGENCE_MEASURE__COUPLING_SCHEME__BELONGS_TO
            ):
                return "belongs to"
            case Edge.ACCELERATION_DATA__ACCELERATION__BELONGS_TO:
                return "accelerates"
            case Edge.RECEIVE_MESH__PARTICIPANT_RECEIVED_FROM:
                return "received from"
            case Edge.PROVIDE_MESH__PARTICIPANT_PROVIDES:
                return "provides"
            case Edge.MAPPING__TO_MESH | Edge.EXCHANGE__EXCHANGES_TO:
                return "to"
            case Edge.MAPPING__FROM_MESH | Edge.EXCHANGE__EXCHANGED_FROM:
                return "from"
            case Edge.ACTION__SOURCE_DATA:
                return "source data"
            case Edge.ACTION__TARGET_DATA:
                return "target data"
            case (
            Edge.WATCH_POINT__MESH
            | Edge.WATCH_INTEGRAL__MESH
            | Edge.ACTION__MESH
            | Edge.ACCELERATION_DATA__MESH
            | Edge.CONVERGENCE_MEASURE__MESH
            ):
                return "mesh"
            case Edge.ACCELERATION_DATA__DATA | Edge.CONVERGENCE_MEASURE__DATA:
                return "data"
            case Edge.M2N__PARTICIPANT_ACCEPTOR:
                return "acceptor"
            case Edge.M2N__PARTICIPANT_CONNECTOR:
                return "connector"
            case Edge.COUPLING_SCHEME__PARTICIPANT_FIRST:
                return "first"
            case Edge.COUPLING_SCHEME__PARTICIPANT_SECOND:
                return "second"
            case Edge.MULTI_COUPLING_SCHEME__PARTICIPANT:
                return "participant"
            case Edge.MULTI_COUPLING_SCHEME__PARTICIPANT__CONTROL:
                return "control"
            case Edge.USE_DATA:
                return "uses"
            case Edge.WRITE_DATA__WRITES_TO_MESH | Edge.WRITE_DATA__WRITES_TO_DATA:
                return "writes to"
            case Edge.READ_DATA__DATA_READ_BY | Edge.READ_DATA__MESH_READ_BY:
                return "read by"
            case _:
                return ""

    node_labels = dict()
    for node in graph.nodes():
        match node:
            case n.ParticipantNode() | n.MeshNode() | n.DataNode() | n.WatchPointNode() | n.WatchIntegralNode():
                node_labels[node] = node.name
            case n.CouplingSchemeNode():
                node_labels[node] = f"Coupling Scheme ({node.type.value})"
            case n.MultiCouplingSchemeNode():
                node_labels[node] = "Multi Coupling Scheme"
            case n.ExchangeNode():
                node_labels[node] = "Exchange"
            case n.MappingNode():
                node_labels[node] = f"Mapping ({node.direction.name})"
            case n.ExportNode():
                node_labels[node] = f"Export ({node.format.value})"
            case n.ActionNode():
                node_labels[node] = f"Action ({node.type.value})"
            case n.WriteDataNode():
                node_labels[node] = f"Write {node.data.name}"
            case n.ReadDataNode():
                node_labels[node] = f"Read {node.data.name}"
            case n.ReceiveMeshNode():
                node_labels[node] = f"Receive {node.mesh.name}"
            case n.M2NNode():
                node_labels[node] = f"M2N {node.type.value}"
            case n.AccelerationNode():
                node_labels[node] = f"Acceleration {node.type.value}"
            case n.AccelerationDataNode():
                node_labels[node] = f"Accelerate {node.data.name}"
            case n.ConvergenceMeasureNode():
                node_labels[node] = f"{node.type.value}-convergence-measure"
            case _:
                node_labels[node] = ""

    pos = nx.spring_layout(
        graph, seed=1
    )  # set the seed so that generated graph always has same layout

    for list in node_lists:
        if len(list[2]) == 0:
            continue
        nx.draw_networkx_nodes(
            graph,
            pos,
            node_shape=list[0],
            nodelist=list[2],
            node_color=list[3],
            node_size=list[4],
        )
    nx.draw_networkx_labels(graph, pos, labels=node_labels)
    nx.draw_networkx_edges(graph, pos)
    nx.draw_networkx_edge_labels(
        graph,
        pos,
        edge_labels={
            tuple(edge): label_for_edge(d) for *edge, d in graph.edges(data=True)
        },
    )

    # Create a plot for the debugging view of the graph
    handles = []
    unique_types = []
    for list in node_lists:
        marker_type = list[0]
        marker_mult = list[1]
        for node in list[2]:
            name = node.__class__.__name__
            # Only display each node type once
            if name not in unique_types:
                unique_types.append(name)
                # Remove the 'Node' suffix
                label = name[:-4]
                handles.append(
                    plt.Line2D(
                        [],
                        [],
                        marker=marker_type,
                        color="w",
                        markerfacecolor=color_for_node(node),
                        markersize=12 * marker_mult,
                        label=label,
                    )
                )

    plt.legend(handles=handles, loc="upper left", title="Node types:")

    plt.show()


def check_graph_equivalence(expected: nx.Graph, actual: nx.Graph, ignore_names: bool = False) -> bool:
    """
    Check if two graphs are equivalent, with the option to ignore names.
    This check is done by comparing the attributes of the nodes and edges of the graphs.
    If names are ignored, any attribute that is a reference to another node will be ignored,
    as it may contain a different name.
    :param expected: The expected graph.
    :param actual: The actual graph.
    :param ignore_names: A flag indicating whether to ignore names when comparing the graphs.
    :return: True if the graphs are equivalent, False otherwise.
    """

    def node_match(node_a: dict[str, str | int | list[str]], node_b: dict[str, str | int | list[str]]) -> bool:
        """
        Compare two nodes for equivalence based on their attributes.
        :param node_a: A node represented by a dict of attributes.
        :param node_b: A node represented by a dict of attributes.
        :return:
        """
        # If the nodes match completely, then they will also match if names are ignored
        if node_a == node_b:
            return True

        if not ignore_names:
            return False

        # Get the attributes of the nodes that are references to named nodes
        refs_a: set[str] = set(node_a.get("_ref_keys", []))
        refs_b: set[str] = set(node_b.get("_ref_keys", []))

        # Combine all known reference keys and the meta-key itself to be on the safe side
        all_refs: set[str] = refs_a.union(refs_b).union({"_ref_keys"})

        def transform_attributes(attributes: dict[str, str | int | list[str]], refs_to_transform: set[str]):
            """
            Transform the given attributes by manipulating attributes whose keys are contained in "refs_to_transform".
            :param attributes: The attributes to transform.
            :param refs_to_transform: The keys of attributes that should be transformed.
            :return: A dict of the updated attributes.
            """
            new_attributes: dict[str, str | int | list[str]] = {}
            for key, value in attributes.items():
                if key in refs_to_transform:
                    # The attribute is a list of named values. Store the count of values instead of the names.
                    if isinstance(value, list):
                        new_attributes[key] = len(value)
                    else:
                        # This key is a named value.
                        # Store "1" to assert that the value existed, but ignore the name
                        new_attributes[key] = 1
                # Keep other values
                else:
                    new_attributes[key] = value
            return new_attributes

        attributes_a: dict[str, str | int | list[str]] = transform_attributes(node_a, all_refs)
        attributes_b: dict[str, str | int | list[str]] = transform_attributes(node_b, all_refs)

        return attributes_a == attributes_b

    def edge_match(edge_a: dict[str, Edge], edge_b: dict[str, Edge]) -> bool:
        """
        Compare two edges for equivalence based on their attributes.
        Edges have only one attribute, namely their edge type.
        :param edge_a: An edge represented by a dict of its attributes.
        :param edge_b: An edge represented by a dict of its attributes.
        :return: True if the edges are equivalent, False otherwise.
        """
        return edge_a["attr"] == edge_b["attr"]

    return nx.is_isomorphic(expected, actual, node_match=node_match, edge_match=edge_match)


def check_config_equivalence(path_expected: str, path_actual: str, ignore_names: bool = False) -> bool:
    """
    Check if two precice-config.xml files are equivalent.
    This check is done for the graph structure, including nodes and edges.
    :param path_expected: Path to the precice-config.xml file with the expected results
    :param path_actual: Path to the precice-config.xml file with the actual results
    :param ignore_names: A flag indicating whether to ignore names when comparing the graphs.
    This is helpful when checking only for "isomorphism" or equivalent structure, not equivalent values.
    :return: True if the two configs are equivalent, False otherwise
    """
    file_expected = parse_file(path_expected)
    graph_expected: nx.Graph = get_graph(file_expected)
    file_actual = parse_file(path_actual)
    graph_actual: nx.Graph = get_graph(file_actual)
    return check_graph_equivalence(graph_expected, graph_actual, ignore_names=ignore_names)
