import { assert } from "../../../assert.js";
import { isArrayEqual } from "../../../collection/array/isArrayEqual.js";
import List from "../../../collection/list/List.js";
import Signal from "../../../events/signal/Signal.js";
import { objectDeepEquals } from "../../object/objectDeepEquals.js";
import { NodeInstancePortReference } from "./NodeInstancePortReference.js";
import { PortDirection } from "./PortDirection.js";

/**
 *
 * @type {number}
 */
let id_counter = 0;

/**
 * Represents a node instance in a graph, its main purpose is to provide connection medium for the graph.
 * A useful analogy is that of a "class" and an "object", there can be multiple instances (objects) of the same class. In this analogy `NodeInstance` is the object, and `NodeDescription` is a class.
 * There can be multiple `NodeInstance`s referencing the same `NodeDescription`.
 */
export class NodeInstance {

    /**
     * Unique identifier
     * @readonly
     * @type {number}
     */
    id = id_counter++;

    /**
     *
     * @type {NodeDescription}
     */
    description = null;

    /**
     *
     * @type {NodeInstancePortReference[]}
     */
    endpoints = [];

    /**
     * Internal instance data
     * @type {Object}
     */
    parameters = {};

    /**
     * @transient
     * @type {Array}
     */
    outputsValues = [];

    /**
     * @readonly
     * @type {List<Connection>}
     */
    connections = new List();

    /**
     * Extra ports that are present just on this instance, these exist in addition to ports from the description
     * IDs of these ports must not collide with IDs of ports on the description
     * TODO implement functionality
     * @protected
     * @type {Port[]}
     */
    dynamicPorts = [];

    /**
     * @readonly
     */
    on = {
        /**
         * @readonly
         * @type {Signal<string, *, *>}
         */
        parameterChanged: new Signal(),
        /**
         * @readonly
         * @type {Signal<string, *>}
         */
        parameterAdded: new Signal(),
        /**
         * @readonly
         * @type {Signal<string, *>}
         */
        parameterRemoved: new Signal(),

        /**
         * fires: (newDescription:NodeDescription, oldDescription:NodeDescription|null, this)
         * @readonly
         * @type {Signal<NodeDescription, NodeDescription, NodeInstance>}
         */
        descriptionChanged: new Signal()
    };

    /**
     *
     * @param {number} port_id
     * @param {PortDirection} direction
     * @param {Connection[]} result
     * @returns {number} number of connections matched
     */
    getConnectionsByPort(port_id, direction, result) {
        let count = 0;

        const connections = this.connections;
        const l = connections.length;

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

            if (
                (direction === PortDirection.In && connection.target.port.id === port_id)
                || (direction === PortDirection.Out && connection.source.port.id === port_id)
            ) {
                result[count] = connection;

                count++;
            }
        }

        return count;
    }

    /**
     * Output port references
     * @returns {NodeInstancePortReference[]}
     */
    get outEndpoints() {
        return this.endpoints.filter(ref => ref.port.direction === PortDirection.Out);
    }

    /**
     * Input port references
     * @returns {NodeInstancePortReference[]}
     */
    get inEndpoints() {
        return this.endpoints.filter(ref => ref.port.direction === PortDirection.In);
    }


    /**
     * Outgoing connections from this node
     * @return {Connection[]}
     */
    get outConnections() {
        return this.connections.filter(c => c.source.instance === this);
    }

    /**
     *
     * Incoming connections to this node
     * @return {Connection[]}
     */
    get inConnections() {
        return this.connections.filter(c => c.target.instance === this);
    }

    /**
     *
     * @param {number} id
     * @param {*} value
     */
    setOutputValue(id, value) {
        this.outputsValues[id] = value;
    }

    /**
     *
     * @param {number} id
     * @returns {*}
     */
    getOutputValue(id) {
        return this.outputsValues[id];
    }

    /**
     * @template T
     * @param {string} id
     * @returns {T|undefined}
     */
    getParameterValue(id) {
        assert.isString(id, 'id');

        return this.parameters[id];
    }

    /**
     * @template T
     * @param {string} id
     * @param {T} value
     */
    setParameterValue(id, value) {
        assert.isString(id, 'id');
        assert.defined(value, 'value');

        const parameters = this.parameters;

        const old_value = parameters[id];

        if (old_value === value) {
            return;
        }

        parameters[id] = value;


        if (old_value === undefined) {
            this.on.parameterAdded.send2(id, value);
        }

        // perform .equals check if available
        if (
            value !== undefined
            && old_value !== undefined
            && typeof value === "object"
            && typeof value.equals === "function"
            && value.equals(old_value)
        ) {
            // parameter value has not changed
            return;
        }

        this.on.parameterChanged.send3(id, value, old_value);
    }

    /**
     *
     * @param {string} id
     * @returns {boolean}
     */
    hasParameter(id) {
        return this.parameters.hasOwnProperty(id)
    }

    /**
     *
     * @param {string} id
     * @returns {boolean}
     */
    deleteParameter(id) {
        assert.isString(id, 'id');

        if (!this.hasParameter(id)) {
            return false;
        }

        const parameters = this.parameters;

        const existing = parameters[id];

        delete parameters[id];

        this.on.parameterRemoved.send2(id, existing);

        return true;
    }

    /**
     * Will overwrite only those properties that are present in the hash
     * @param {Object} partial_hash
     */
    setParameters(partial_hash) {
        assert.defined(partial_hash, 'parameters')

        for (const key in partial_hash) {

            this.setParameterValue(key, partial_hash[key]);

        }
    }

    clearParameters() {
        for (const key in this.parameters) {
            this.deleteParameter(key);
        }
    }

    /**
     *
     * @param {NodeDescription} description
     */
    setDescription(description) {
        assert.defined(description, 'description');
        assert.isObject(description, 'description');
        assert.equal(description.isNodeDescription, true, 'description.isNodeDescription !== true');

        if (!this.connections.isEmpty()) {
            throw new Error("Node is has connections, can only change description for unconnected nodes");
        }

        const old_description = this.description;

        this.description = description;

        description.configureNode(this);

        //generate endpoints
        const ports = description.getPorts();
        const port_count = ports.length;
        this.endpoints = new Array(port_count);

        for (let i = 0; i < port_count; i++) {
            const port = ports[i];

            this.endpoints[i] = NodeInstancePortReference.from(this, port);
        }

        //clear parameters
        this.clearParameters();

        // TODO address parameters in NodeDescription as well
        //populate parameter defaults
        description.parameters.forEach(pd => {
            this.parameters[pd.id] = pd.defaultValue;
        });

        this.on.descriptionChanged.send3(description, old_description, this);
    }

    /**
     *
     * @param {number} port_id Port ID
     * @returns {NodeInstancePortReference|undefined}
     */
    getEndpoint(port_id) {
        assert.isNonNegativeInteger(port_id, 'port');

        const endpoints = this.endpoints;
        const endpoint_count = endpoints.length;

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

            const endpoint = endpoints[i];

            if (endpoint.port.id === port_id) {
                return endpoint;
            }

        }

        //not found
        return undefined;
    }

    /**
     *
     * @param {string} name
     * @returns {undefined|NodeInstancePortReference}
     */
    getFirstEndpointByName(name) {
        assert.isString(name, 'name');

        const endpoints = this.endpoints;
        const endpoint_count = endpoints.length;

        for (let i = 0; i < endpoint_count; i++) {
            const ref = endpoints[i];

            if (ref.port.name === name) {
                return ref;
            }
        }

        // not found
        return undefined;

    }

    /**
     *
     * @return {number}
     */
    hash() {
        return this.id;
    }

    /**
     *
     * @param {NodeInstance} other
     * @returns {boolean}
     */
    equals(other) {
        return this.id === other.id
            && this.description === other.description
            && isArrayEqual(this.endpoints, other.endpoints)
            && objectDeepEquals(this.parameters, other.parameters)
            ;
    }

    toString() {
        let result = `NodeInstance{ id = ${this.id}`;

        // add description
        const d = this.description;
        if (d !== undefined && d !== null) {
            result += `, description = ${d.id}(${d.name})`
        }

        result += ' }';

        return result;
    }
}


/**
 * @readonly
 * @type {boolean}
 */
NodeInstance.prototype.isNodeInstance = true;
