import { assert } from "../../assert.js";
import Signal from "../../events/signal/Signal.js";
import { Edge, EdgeDirectionType } from "../Edge.js";
import { NodeContainer } from "./NodeContainer.js";

/**
 * Reconstruct path from search metadata
 * @template T
 * @param {NodeContainer<T>} goal_node_container
 * @param {Map<NodeContainer<T>,NodeContainer<T>>} came_from
 * @returns {T[]} Nodes comprising the path from start to goal
 */
function construct_path(goal_node_container, came_from) {
    const result = [];

    let c = goal_node_container;

    do {
        result.unshift(c.node);
        c = came_from.get(c);
    } while (c !== undefined);

    return result;
}

/**
 * Graph structure, consisting of nodes(vertices) and directed edges.
 * @see https://en.wikipedia.org/wiki/Graph_(discrete_mathematics)
 * @see https://en.wikipedia.org/wiki/Graph_(abstract_data_type)
 * @template N
 */
export class Graph {

    /**
     *
     * @type {Map<N, NodeContainer<N>>}
     * @readonly
     * @private
     */
    __nodes = new Map();

    /**
     *
     * @type {Set<Edge<N>>}
     * @readonly
     * @private
     */
    __edges = new Set();

    /**
     * @readonly
     */
    on = {
        /**
         * @type {Signal<N,this>}
         */
        nodeAdded: new Signal(),
        /**
         * @type {Signal<N,this>}
         */
        nodeRemoved: new Signal(),
        edgeAdded: new Signal(),
        edgeRemoved: new Signal()
    };


    /**
     *
     * @param {N} node
     * @returns {boolean}
     */
    hasNode(node) {
        assert.defined(node, 'node');

        return this.__nodes.has(node);
    }

    /**
     *
     * @param {N} node
     * @returns {boolean} true if node was added, false if node already exists
     */
    addNode(node) {
        if (this.hasNode(node)) {
            return false;
        }

        const container = new NodeContainer();
        container.node = node;

        this.__nodes.set(node, container);

        this.on.nodeAdded.send2(node, this);

        return true;
    }

    /**
     *
     * @param {N} node
     * @returns {boolean}
     */
    removeNode(node) {
        assert.defined(node, 'node');

        const context = this.__nodes.get(node);
        if (context === undefined) {
            // not found
            return false;
        }

        // remove connected edges
        context.traverseEdges(this.removeEdge, this);

        this.__nodes.delete(node);
        this.on.nodeRemoved.send2(node, this);

        return true;
    }

    /**
     *
     * @param {function(N)} visitor
     * @param {*} [thisArg]
     */
    traverseNodes(visitor, thisArg) {
        for (const [node, container] of this.__nodes) {
            visitor.call(thisArg, node);
        }
    }

    /**
     * Number of nodes in the graph
     * @return {number}
     */
    get nodeCount() {
        return this.__nodes.size;
    }

    /**
     * @deprecated use {@link nodeCount} property instead
     * @returns {number}
     */
    getNodeCount() {
        return this.__nodes.size;
    }

    /**
     *
     * @param {function(N):boolean} filter
     * @param {*} [thisArg]
     * @returns {N|undefined} first node that is matched by the filter
     */
    findNode(filter, thisArg) {
        const nodes = this.__nodes;

        for (const [node, container] of nodes) {

            const pass = filter.call(thisArg, node);

            if (pass) {
                return node;
            }

        }

        return undefined;
    }

    /**
     * Access internal representation of a node.
     * Useful for performance optimization.
     * Do not modify obtained value.
     * @param {N} node
     * @returns {NodeContainer<N>|undefined}
     */
    getNodeContainer(node) {
        assert.defined(node, 'node');

        return this.__nodes.get(node);
    }

    /**
     * Node degree is the number of attached edges
     * @param {N} node
     * @return {number}
     */
    getNodeDegree(node) {
        assert.defined(node, 'node');

        const container = this.__nodes.get(node);

        if (container === undefined) {
            return 0;
        }

        return container.getEdgeCount();
    }

    /**
     *
     * @returns {N[]}
     */
    get nodes() {
        return Array.from(this.getNodes());
    }

    /**
     * Do not modify this set directly
     * @return {Iterable<N>}
     */
    getNodes() {
        return this.__nodes.keys();
    }

    /**
     * Do not modify this set directly
     * @return {Set<Edge<N>>}
     */
    getEdges() {
        return this.__edges;
    }

    /**
     * Introduce a new edge into the graph. Nodes must already be a part of the graph.
     * @param {N} source
     * @param {N} target
     * @param {EdgeDirectionType} [direction] Undirected by default
     * @returns {Edge<N>}
     * @throws {Error} if one or both nodes are not part of the graph
     */
    createEdge(
        source,
        target,
        direction = EdgeDirectionType.Undirected
    ) {
        assert.defined(source, 'source');
        assert.defined(target, 'target');

        assert.enum(direction, EdgeDirectionType, 'direction');

        const edge = new Edge(source, target);

        edge.direction = direction;

        this.addEdge(edge);

        return edge;
    }

    /**
     * Both nodes that the edge is attached to must be present
     * @param {Edge<N>} edge
     * @returns {boolean} true if edge was added, false if edge was already present
     * @throws {Error} if one or both nodes are not contained in the graph
     */
    addEdge(edge) {
        if (this.hasEdge(edge)) {
            return false;
        }


        // find nodes
        const context_0 = this.__nodes.get(edge.first);
        const context_1 = this.__nodes.get(edge.second);

        if (context_0 === undefined) {
            throw new Error(`First node(=${edge.first}) of the edge is not part of the graph`);
        } else if (context_1 === undefined) {
            throw new Error(`Second node(=${edge.second}) of the edge is not part of the graph`);
        }

        context_0.addEdge(edge);
        context_1.addEdge(edge);

        this.__edges.add(edge);

        this.on.edgeAdded.send2(edge, this);

        return true;

    }

    /**
     *
     * @param {Edge<N>} edge
     * @returns {boolean} true if edge was removed, false if edge was not found
     */
    removeEdge(edge) {
        if (!this.hasEdge(edge)) {
            return false;
        }

        const context_0 = this.__nodes.get(edge.first);
        const context_1 = this.__nodes.get(edge.second);

        if (context_0 === undefined || context_1 === undefined) {
            // this is a critical error, it should never happen as long as the API is used correctly
            throw new Error('One or both nodes of the edge are not present on the graph. This is a critical error');
        }

        // remove edge from contexts
        context_0.removeEdge(edge);
        context_1.removeEdge(edge);

        this.__edges.delete(edge);

        this.on.edgeRemoved.send2(edge, this);

        return true;
    }

    /**
     *
     * @param {Edge<N>} edge
     * @returns {boolean}
     */
    hasEdge(edge) {
        assert.defined(edge, 'edge');
        assert.notNull(edge, 'edge');
        assert.equal(edge.isEdge, true, '!edge.isEdge');

        return this.__edges.has(edge);
    }

    /**
     *
     * @param {function(Edge<N>)} visitor
     * @param {*} [thisArg]
     */
    traverseEdges(visitor, thisArg) {
        for (const edge of this.__edges) {
            visitor.call(thisArg, edge);
        }
    }

    /**
     * Number of edges in the graph
     * @return {number}
     */
    get edgeCount() {
        return this.__edges.size;
    }

    /**
     * checks if there is an edge between two given nodes.
     * Direction is ignored.
     * @param {N} a
     * @param {N} b
     * @returns {boolean}
     */
    edgeExistsBetween(a, b) {
        assert.defined(a, 'a');
        assert.defined(b, 'b');

        const context_a = this.__nodes.get(a);

        if (context_a === undefined) {
            // 'a' is not in the graph
            return false;
        }

        return context_a.edgeWithNodeExists(b);
    }

    /**
     *
     * @param {N} a
     * @param {N} b
     * @returns {Edge<N>|undefined}
     */
    getAnyEdgeBetween(a, b) {
        assert.defined(a, 'a');
        assert.defined(b, 'b');

        const context_a = this.__nodes.get(a);

        if (context_a === undefined) {
            // A is not in the graph
            return undefined;
        }

        if (!this.hasNode(b)) {
            // check if B is part of the graph
            return undefined;
        }

        return context_a.getAnyEdgeWith(b);
    }

    /**
     *
     * @param {N} from
     * @param {N} to
     * @returns {Edge<N>|undefined}
     */
    getAnyDirectedEdge(from, to) {
        assert.defined(from, 'from');
        assert.defined(to, 'to');

        const ctx_a = this.__nodes.get(from);

        if (ctx_a === undefined) {
            // 'from' doesn't exist
            return undefined;
        }

        return ctx_a.getAnyDirectionEdgeTo(to);
    }

    /**
     *
     * @param {Edge<N>[]} result found edges will be put here
     * @param {N} node
     * @returns {number} number of edges found
     */
    getAttachedEdges(result, node) {
        assert.defined(result, 'result');
        assert.isArray(result, 'result');
        assert.defined(node, 'node');

        const context = this.__nodes.get(node);

        if (context === undefined) {
            // node doesn't exist
            return 0;
        }

        /**
         *
         * @type {Edge[]}
         */
        const edges = context.getEdges();

        const edge_count = edges.length;

        for (let i = 0; i < edge_count; i++) {
            result[i] = edges[i];
        }

        return edge_count;
    }

    /**
     *
     * @param {N} node
     * @returns {N[]}
     */
    getNeighbours(node) {
        assert.defined(node, 'node');

        const container = this.__nodes.get(node);

        if (container === undefined) {
            return [];
        }

        return container.neighbours;
    }

    /**
     *
     * @param {N} node
     * @returns {boolean}
     */
    nodeHasEdges(node) {
        assert.defined(node, 'node');

        const context = this.__nodes.get(node);

        if (context === undefined) {
            // node doesn't exist
            return false;
        }

        return context.getEdgeCount() > 0;
    }

    /**
     * Find a path through the graph
     * @param {N} start
     * @param {N} goal
     * @returns {null|N[]} null if no path exists
     */
    findPath(start, goal) {
        const start_node_container = this.__nodes.get(start);

        if (start_node_container === undefined) {
            throw new Error(`Start node not found in the graph '${start}'`);
        }

        const goal_node_container = this.__nodes.get(goal);

        if (goal_node_container === undefined) {
            throw new Error(`Goal node not found in the graph '${goal}'`);
        }

        const open = new Set();
        open.add(start_node_container);

        const closed = new Set();

        const cameFrom = new Map();


        while (open.size > 0) {
            /**
             *
             * @type {NodeContainer<N>}
             */
            const current = open.values().next().value;
            if (current === goal_node_container) {
                //reached the goal
                return construct_path(goal_node_container, cameFrom);
            }
            open.delete(current);
            closed.add(current);

            //expand node neighbours
            const edges = current.getEdges();

            const current_node = current.node;

            const edge_count = edges.length;
            for (let i = 0; i < edge_count; i++) {
                const edge = edges[i];

                const a = edge.first;
                const b = edge.second;

                let other = null;

                if (a === current_node && (edge.direction === EdgeDirectionType.Forward || edge.direction === EdgeDirectionType.Undirected)) {
                    other = b;
                } else if (b === current_node && (edge.direction === EdgeDirectionType.Backward || edge.direction === EdgeDirectionType.Undirected)) {
                    other = a;
                } else {
                    // non-traversable edge
                    continue;
                }

                const other_container = this.__nodes.get(other);

                if (closed.has(other_container)) {
                    continue;
                }

                if (open.has(other_container)) {
                    continue;
                }

                open.add(other_container);
                cameFrom.set(other_container, current);
            }
        }

        //no path found
        return null;
    }

    /**
     * Remove all data from the graph, resetting it to empty state
     */
    clear() {
        this.__nodes.clear();
        this.__edges.clear();
    }

    /**
     *
     * @param {Graph<N>} other
     */
    copy(other) {
        this.clear();

        other.traverseNodes(this.addNode, this);
        other.traverseEdges(this.addEdge, this);
    }

    /**
     * @returns {Graph<N>}
     */
    clone() {
        const r = new Graph();

        r.copy(this);

        return r;
    }
}
