import { assert } from "../../assert.js";
import { array_push_if_unique } from "../../collection/array/array_push_if_unique.js";
import { array_remove_first } from "../../collection/array/array_remove_first.js";
import List from "../../collection/list/List.js";
import IdPool from "../../IdPool.js";
import { Connection } from "./Connection.js";
import { NodeInstance } from "./node/NodeInstance.js";
import { PortDirection } from "./node/PortDirection.js";

/**
 * Graph of nodes, implements node-based programming pattern
 * Notable contemporary examples include:
 *  * SideFX Houdini
 *  * Blender - geometry nodes, shaders
 *  * Unreal Engine - blueprints
 *  * Grasshopper
 * @see https://en.wikipedia.org/wiki/Node_graph_architecture
 */
export class NodeGraph {

    /**
     * @private
     * @readonly
     * @type {List<NodeInstance>}
     */
    nodes = new List();

    /**
     * Flat hierarchy of all connections between nodes
     * @private
     * @readonly
     * @type {List<Connection>}
     */
    connections = new List();

    /**
     *
     * @type {IdPool}
     * @readonly
     * @private
     */
    __idpNodes = new IdPool();

    /**
     *
     * @type {IdPool}
     * @readonly
     * @private
     */
    __idpConnections = new IdPool();

    /**
     * Gets incremented every time structure of the graph changes
     * @type {number}
     */
    #version = 0;

    /**
     * Gets incremented every time structure of the graph changes, meaning nodes or connections are added/removed
     * Unsigned integer value
     * @readonly
     * @return {number}
     */
    get version() {
        return this.#version;
    }

    /**
     * @readonly
     */
    on = {
        /**
         * @readonly
         * @type {Signal<NodeInstance,number>}
         */
        nodeAdded: this.nodes.on.added,

        /**
         * @readonly
         * @type {Signal<NodeInstance,number>}
         */
        nodeRemoved: this.nodes.on.removed,

        /**
         * @readonly
         * @type {Signal<Connection,number>}
         */
        connectionAdded: this.connections.on.added,

        /**
         * @readonly
         * @type {Signal<Connection,number>}
         */
        connectionRemoved: this.connections.on.removed
    };

    /**
     * Clear out all data from the graph
     */
    reset() {
        this.nodes.reset();
        this.connections.reset();

        this.__idpNodes.reset();
        this.__idpConnections.reset();

        this.#version++;
    }

    /**
     * Perform a deep copy
     * @param {NodeGraph} other
     */
    copy(other) {
        if (this === other) {
            // pointless operation
            return;
        }

        this.reset();

        this.merge(other);

    }

    /**
     *
     * @returns {NodeGraph}
     */
    clone() {
        const r = new NodeGraph();

        r.copy(this);

        return r;
    }

    /**
     * Merge another graph into this one
     * Supplied graph does not change as a result
     * @see {@link #mergeFragment}
     * @param {NodeGraph} other
     * @returns {{connections:Connection[], nodes:NodeInstance[]}}
     */
    merge(other) {
        assert.defined(other, 'other');
        assert.notNull(other, 'other');

        if (other === this) {
            // can't merge with self, invalid operation
            throw new Error("Can't merge with self, invalid operation");
        }

        return this.mergeFragment({
            nodes: other.nodes.asArray(),
            connections: other.connections.asArray()
        });
    }

    /**
     * Merge foreign nodes and associated connections into this graph
     * New node instances and connections will be created to reflect these inside this graph
     * NOTE: parameters on merged nodes are shallow copies
     * NOTE: if IDs are available - copied nodes will have the same IDs as the originals
     * @param {NodeInstance[]} nodes
     * @param {Connection[]} [connections]
     * @returns {{connections:Connection[], nodes:NodeInstance[]}} local created instances
     */
    mergeFragment({
                      nodes,
                      connections = []
                  }) {

        assert.defined(nodes, 'nodes');
        assert.notNull(nodes, 'nodes');
        assert.isArray(nodes, 'nodes');

        assert.defined(connections, 'connections');
        assert.notNull(connections, 'connections');
        assert.isArray(connections, 'connections');

        const previous_node_count = this.nodes.length;
        const previous_connection_count = this.connections.length;

        const additional_node_count = nodes.length;

        /**
         * Mapping from original IDs to IDs in this graph
         * @type {Object<number,number>}
         */
        const this_nodes = {};

        for (let i = 0; i < additional_node_count; i++) {
            const other_node = nodes[i];

            const other_node_id = other_node.id;

            const this_node = new NodeInstance();
            this_node.setDescription(other_node.description);


            // attempt to gain the same ID
            const can_use_same_id = !this.__idpNodes.isUsed(other_node_id);

            let this_node_id;
            if (can_use_same_id) {
                this_node_id = other_node_id;
            } else {
                this_node_id = this.__idpNodes.peek();
            }

            this_node.id = this_node_id;

            this.addNode(this_node);


            this_nodes[other_node_id] = this_node_id;

            // copy parameters
            this_node.setParameters(other_node.parameters);

        }

        // create connections
        const additional_connection_count = connections.length;

        for (let i = 0; i < additional_connection_count; i++) {
            const other_connection = connections[i];

            const other_source = other_connection.source;

            const this_source_node_id = this_nodes[other_source.instance.id];
            const this_source_port_id = other_source.port.id;

            const other_target = other_connection.target;

            const this_target_node_id = this_nodes[other_target.instance.id];
            const this_target_port_id = other_target.port.id;

            this.createConnection(
                this_source_node_id, this_source_port_id,
                this_target_node_id, this_target_port_id
            );
        }

        return {
            connections: this.connections.asArray().slice(previous_connection_count, previous_connection_count + additional_connection_count),
            nodes: this.nodes.asArray().slice(previous_node_count, previous_node_count + additional_node_count)
        };
    }

    /**
     *
     * @param {function(NodeInstance):*} visitor
     * @param [thisArg]
     */
    traverseNodes(visitor, thisArg) {
        this.nodes.forEach(visitor, thisArg);
    }

    /**
     *
     * @param {function(Connection):*} visitor
     * @param [thisArg]
     */
    traverseConnections(visitor, thisArg) {
        this.connections.forEach(visitor, thisArg);
    }

    /**
     * Returns an array of all node instances
     * NOTE: this array is a copy
     * @return {NodeInstance[]}
     */
    getNodes() {
        return this.nodes.asArray().slice();
    }

    /**
     * Returns an array of all connections
     * NOTE: this array is a copy
     * @return {Connection[]}
     */
    getConnections() {
        return this.connections.asArray().slice();
    }

    /**
     *
     * @param {NodeInstance} node
     * @returns {boolean}
     */
    hasNode(node) {

        const existing_node = this.getNode(node.id);

        if (existing_node === undefined) {
            return false;
        }

        if (existing_node !== node) {
            // another node with this ID was found
            return false;
        }

        return true;
    }

    /**
     *
     * @param {NodeDescription} description
     * @returns {NodeInstance[]}
     */
    getNodesByDescription(description) {

        assert.defined(description, 'description');
        assert.notNull(description, 'description');
        assert.equal(description.isNodeDescription, true, 'description.isNodeDescription !== true');

        const result = [];

        const nodes = this.nodes;
        const n = nodes.length;

        for (let i = 0; i < n; i++) {
            const node = nodes.get(i);

            if (node.description === description) {
                result.push(node);
            }
        }

        return result;
    }

    /**
     *
     * @param {Type<NodeDescription>} Klass
     * @returns {NodeInstance[]}
     */
    getNodesByDescriptionClass(Klass) {

        assert.defined(Klass, 'Klass');
        assert.notNull(Klass, 'Klass');

        const result = [];

        const nodes = this.nodes;
        const n = nodes.length;

        for (let i = 0; i < n; i++) {
            const node = nodes.get(i);

            if (node.description.constructor === Klass) {
                result.push(node);
            }
        }

        return result;
    }

    /**
     *
     * @param {number} id
     * @returns {NodeInstance|undefined}
     */
    getNode(id) {
        assert.isNonNegativeInteger(id, 'id');

        const nodes = this.nodes;
        const n = nodes.length;

        for (let i = 0; i < n; i++) {
            const node = nodes.get(i);

            if (node.id === id) {
                return node;
            }
        }

        // node not found, return undefined
        return undefined;
    }

    /**
     * Same as getNode but throw exception when node doesn't exist
     * @param {number} id
     * @returns {NodeInstance}
     * @throws if node doesn't exist
     */
    getNodeSafe(id) {
        const result = this.getNode(id);

        if (result === undefined) {
            throw new Error(`Node ${id} not found`);
        }

        return result;
    }

    /**
     *
     * @param {number} id
     * @returns {Connection|undefined}
     */
    getConnection(id) {
        const connections = this.connections;
        const n = connections.length;

        for (let i = 0; i < n; i++) {
            const connection = connections.get(i);

            if (connection.id === id) {
                return connection;
            }
        }

        // nothing found, undefined will be returned
        return undefined;
    }

    /**
     *
     * @param {number} node_id
     * @param {number} port_id
     * @returns {NodeInstancePortReference|undefined}
     */
    getConnectionEndpoint(node_id, port_id) {

        const nodeInstance = this.getNode(node_id);

        if (nodeInstance === undefined) {
            // no node
            return undefined;
        }

        return nodeInstance.getEndpoint(port_id);
    }

    /**
     *
     * @param {NodeDescription} node
     * @returns {number} ID of the new node
     */
    createNode(node) {
        const nodeInstance = new NodeInstance();

        const id = this.__idpNodes.peek();

        nodeInstance.id = id;
        nodeInstance.setDescription(node);

        //record the node
        this.addNode(nodeInstance);

        return id;
    }

    /**
     *
     * @param {NodeInstance} node
     */
    addNode(node) {

        assert.defined(node, 'node');
        assert.notNull(node, 'node');
        assert.equal(node.isNodeInstance, true, 'node.isNodeInstance !== true');

        const id_obtained = this.__idpNodes.getSpecific(node.id);

        if (id_obtained === false) {
            throw new Error(`Node with id '${node.id}' already exists`);
        }

        //record the node
        this.nodes.add(node);

        this.#version++;
    }

    /**
     *
     * @param {number} id
     * @returns {boolean} True if deleted, false if node was not found
     */
    deleteNode(id) {

        const instance = this.getNode(id);

        if (instance === undefined) {
            //not found
            return false;
        }

        //find attached connections
        const deadConnections = [];

        this.getConnectionsAttachedToNode(id, deadConnections);

        //remove connections
        for (const deadConnection of deadConnections) {
            this.deleteConnection(deadConnection);
        }

        //delete the node
        this.nodes.removeOneOf(instance);

        //release id
        this.__idpNodes.release(id);

        this.#version++;

        return true;
    }

    /**
     * Utility method to help in creation of connections
     * Same as {@link #createConnection}, but ports are identified by their named instead
     * @param {number} sourceNode
     * @param {string} sourcePort
     * @param {number} targetNode
     * @param {string} targetPort
     * @returns {number} connection ID
     */
    createConnectionByPortName(
        sourceNode, sourcePort,
        targetNode, targetPort
    ) {
        const source_node_instance = this.getNodeSafe(sourceNode);
        const target_node_instance = this.getNodeSafe(targetNode);

        const source_ports = source_node_instance.description.getPortsByName(sourcePort).filter(p => p.direction === PortDirection.Out);

        if (source_ports.length > 1) {
            throw new Error(`Multiple source ports match name '${sourcePort}'`);
        }

        const source_port_object = source_ports[0];

        const target_ports = target_node_instance.description.getPortsByName(targetPort).filter(p => p.direction === PortDirection.In);

        if (target_ports.length > 1) {
            throw new Error(`Multiple target ports match name '${targetPort}'`);
        }

        const target_port_object = target_ports[0];

        if (source_port_object === undefined) {
            throw new Error(`Source port '${sourcePort}' not found`);
        }

        if (target_port_object === undefined) {
            throw new Error(`Target port '${targetPort}' not found`);
        }

        return this.createConnection(
            sourceNode, source_port_object.id,
            targetNode, target_port_object.id,
        );
    }

    /**
     *
     * @param {number} sourceNode
     * @param {number} sourcePort
     * @param {number} targetNode
     * @param {number} targetPort
     * @returns {number} ID of created or already existing connection
     * @throws if any node or port are not found
     */
    createConnection(sourceNode, sourcePort, targetNode, targetPort) {
        assert.isNonNegativeInteger(sourceNode, 'sourceNode');
        assert.isNonNegativeInteger(sourcePort, 'sourcePort');
        assert.isNonNegativeInteger(targetNode, 'targetNode');
        assert.isNonNegativeInteger(targetPort, 'targetPort');

        const sourceNodeInstance = this.getNode(sourceNode);

        if (sourceNodeInstance === undefined) {
            throw new Error(`Source node '${sourceNode}' not found`);
        }

        const targetNodeInstance = this.getNode(targetNode);

        if (targetNodeInstance === undefined) {
            throw new Error(`Target node '${targetNode}' not found`);
        }

        //get endpoints
        const sourceEndpoint = sourceNodeInstance.getEndpoint(sourcePort);

        if (sourceEndpoint === undefined) {
            throw new Error(`Source port '${sourcePort}' not found on ${sourceNodeInstance}`);
        }

        const targetEndpoint = targetNodeInstance.getEndpoint(targetPort);

        if (targetEndpoint === undefined) {
            throw new Error(`Target port '${targetPort}' not found on ${targetNodeInstance}`);
        }

        //create connection
        const connection = new Connection();

        connection.setSource(sourceEndpoint);
        connection.setTarget(targetEndpoint);

        const id = this.__idpConnections.get();
        connection.id = id;

        this.connections.add(connection);

        // add connection links to the nodes
        sourceNodeInstance.connections.addUnique(connection);
        targetNodeInstance.connections.addUnique(connection);

        array_push_if_unique(sourceEndpoint.connections, connection);
        array_push_if_unique(targetEndpoint.connections, connection);

        this.#version++;

        return id;
    }

    /**
     *
     * @param {number} id
     * @returns {boolean} True if deleted, false if connection was not found
     */
    deleteConnection(id) {

        const connection = this.getConnection(id);

        if (connection === undefined) {
            return false;
        }

        this.connections.removeOneOf(connection);

        // remove from end-point nodes
        const sourceEndpoint = connection.source;
        const targetEndpoint = connection.target;

        sourceEndpoint.instance.connections.removeOneOf(connection);
        targetEndpoint.instance.connections.removeOneOf(connection);

        array_remove_first(sourceEndpoint.connections, connection);
        array_remove_first(targetEndpoint.connections, connection);

        this.#version++;

        return true;
    }

    /**
     *
     * @param {number} id
     * @param {number[]} result IDs of attached connections
     * @returns {number} number of found connections
     */
    getConnectionsAttachedToNode(id, result) {
        assert.isNonNegativeInteger(id, 'id');

        assert.defined(result, 'result');
        assert.isArray(result, 'result');

        let count = 0;

        const connections = this.connections;
        const connection_count = connections.length;

        for (let i = 0; i < connection_count; i++) {

            const connection = connections.get(i);

            if (connection.isAttachedToNode(id)) {
                result[count] = connection.id;

                count++;
            }
        }

        return count;
    }

    toString() {
        return `NodeGraph{ nodes=${this.nodes.length}, connections=${this.connections.length}, version=${this.version} }`
    }
}

/**
 * Useful for type checks
 * @example
 *      if(graph.isNodeGraph === true){
 *          // yep, that's a NodeGraph alright!
 *      }
 * @readonly
 * @type {boolean}
 */
NodeGraph.prototype.isNodeGraph = true;
