// editor elements (gates, wires...)
import * as editorElements from './editorElements';

// svg elements
import { Pattern, Rectangle, PolyLinePoint, PolyLinePoints, PolyLine } from './svgObjects';

// network logic and simulation
import Logic from './Logic';
import Simulation from './Simulation';
import { SimulationDummy } from './Simulation';

// ui stuff
import ContextMenu from './ui/ContextMenu';
import FloatingMenu from './ui/FloatingMenu';
import Tutorial from './ui/Tutorial';
import Messages from './ui/Messages';
import ViewBox from './ui/ViewBox';

// mouse scroll event listerer for ui, manhattan distance for importData
import { addMouseScrollEventListener, manhattanDistance } from './other/helperFunctions';

// used in importData
// note: imported from a node module
import { PriorityQueue } from 'libstl';

const ctrlKey = 17,
    cmdKey = 91;

/** @module App */
/**
 * Main class of the application. It represents an instance of the whole editor and holds
 * references to all its elements.
 */
export default class App {
    /**
     * Initialize the Svg class
     * @param {string} canvas   query selector of the SVG element, that will contain all SVG content of the application
     * @param {number} gridSize initial size of the grid in SVG pixels
     */
    constructor(canvas, gridSize) {
        /**
         * jQuery element for the SVG document
         */
        this.$svg = $(canvas);

        /**
         * space between grid lines in SVG pixels
         * @type {number}
         */
        this.gridSize = gridSize;

        /**
         * Array of all boxes (instances of objects derived from editorElements.Box) used on canvas
         * @type {Array}
         */
        this.boxes = []; // stores all boxes

        /**
         * Array of all wires (instances of editorElements.Wire) used on canvas
         * @type {Array}
         */
        this.wires = []; // stores all wires

        /**
         * Interface for showing messages to the user
         * @type {Messages}
         */
        this.messages = new Messages();

        this.simulationEnabled = true;
        this.simulation = new SimulationDummy(); // dummy, will be overwritten on startNewSimulation

        /**
         * distance from the left top corner to the first element in the imported network
         * and distance from the left top corner to the imported black box _in grid pixels_
         * @type {number}
         */
        this.leftTopPadding = 4;

        // create the defs element, used for patterns
        this.$defs = $('<defs>');
        this.$svg.prepend(this.$defs);

        // BACKGROUND PATTERN
        let pattern = new Pattern('grid', this.gridSize, this.gridSize);

        let patternPoints = new PolyLinePoints()
            .append(new PolyLinePoint(0, 0))
            .append(new PolyLinePoint(this.gridSize, 0))
            .append(new PolyLinePoint(this.gridSize, this.gridSize));

        pattern.addChild(new PolyLine(patternPoints, 2, '#c2c3e4'));
        this.addPattern(pattern.get());

        this.background = new Rectangle(0, 0, this.width, this.height, 'url(#grid)', 'none');
        this.appendJQueryObject(this.background.get());
        this.refresh();

        // set the viewbox for future zooming and moving of the canvas
        this.$svg.attr('preserveAspectRatio', 'xMinYMin slice');
        this.viewbox = new ViewBox(0, 0, this.width, this.height);
        this.applyViewbox();

        // CONSTRUCT CONTEXT MENU
        this.contextMenu = new ContextMenu(this);

        // CONSTRUCT FLOATING MENU
        this.floatingMenu = new FloatingMenu(this);

        let target;

        // ALL EVENT CALLBACKS
        this.$svg
            .on('mousedown', event => {
                target = this.getRealTarget(event.target);

                if (target !== undefined) {
                    // propagate mousedown to the real target
                    target.onMouseDown(event);
                } else {
                    // mousedown happened directly on the svg
                    this.onMouseDown(event);
                }

                this.hideContextMenu();
                event.preventDefault();
            })
            .on('mousemove', event => {
                if (target !== undefined) {
                    target.onMouseMove(event);
                } else {
                    // mousemove happened directly on the svg
                    this.onMouseMove(event);
                }

                event.preventDefault();
            })
            .on('mouseup', event => {
                if (target !== undefined) {
                    target.onMouseUp(event);
                } else {
                    // mouseup happened directly on the svg
                    this.onMouseUp(event);
                }

                target = undefined;

                event.preventDefault();
            })
            .on('contextmenu', event => {
                this.displayContextMenu(
                    event.pageX,
                    event.pageY,
                    this.getRealJQueryTarget(event.target)
                );
                event.preventDefault();
            });

        $(document)
            .on('keydown', event => {
                this.onKeyDown(event);
            })
            .on('keyup', event => {
                this.onKeyUp(event);
            });

        // update the viewbox on window resize
        $(window).on('resize', () => {
            this.viewbox.newDimensions(this.width, this.height);
            this.applyViewbox();
        });

        addMouseScrollEventListener(canvas, event => {
            // zoom only if the ctrl key is not pressed
            if (!event.ctrlKey) {
                this.zoom += event.delta * 0.1;

                event.preventDefault();
            }
        });

        $(window).on('keydown', event => {
            const actions = {
                '+': 0.1,
                '-': -0.1
            };

            if (actions[event.key]) {
                this.zoom += actions[event.key];
            }
        });

        /**
         * property containing an instance of [Tutorial](./module-Tutorial.html), if there is any
         * @type {Tutorial}
         */
        this.tutorial;

        // check if the user visits for the first time, if so, start the tutorial
        try {
            if (!localStorage.userHasVisited) {
                this.startTutorial();
            }
        } catch (e) {
            console.warn(e);
        }
    }

    /**
     * Get the width of the main SVG element
     * @return {number} width of the SVG element in pixels
     */
    get width() {
        return this.$svg.width();
    }

    /**
     * Get the height of the main SVG element
     * @return {number} height of the SVG element in pixels
     */
    get height() {
        return this.$svg.height();
    }

    /**
     * Process all keydown events that are connected to the app
     * @param  {jquery.KeyboardEvent} event KeyboardEvent generated by a listener
     */
    onKeyDown(event) {
        if (event.keyCode === ctrlKey || event.keyCode === cmdKey) {
            this.$svg.addClass('grabbable');
        }
    }

    /**
     * Process all keyup events that are connected to the app
     * @param  {jquery.KeyboardEvent} event KeyboardEvent generated by a listener
     */
    onKeyUp(event) {
        if (event.keyCode === ctrlKey || event.keyCode === cmdKey) {
            this.$svg.removeClass('grabbable');
        }
    }

    /**
     * Process all mousedown events that are happening directly on the canvas
     * @param  {jquery.MouseEvent} event MouseEvent generated by a listener
     */
    onMouseDown(event) {
        // any click on canvas cancels the wire creation
        this.cancelWireCreation();

        // middle mouse or left mouse + ctrl moves the canvas
        if (event.which === 2 || (event.which === 1 && event.ctrlKey)) {
            this.$svg.addClass('grabbed');
            this.moveCanvas = {
                left: event.pageX,
                top: event.pageY
            };
        }
    }

    /**
     * Process all mousemove events that are happening directly on the canvas
     * @param  {jquery.MouseEvent} event MouseEvent generated by a listener
     */
    onMouseMove(event) {
        if (this.moveCanvas) {
            let left = event.pageX - this.moveCanvas.left;
            let top = event.pageY - this.moveCanvas.top;

            this.viewbox.move(left, top);

            this.applyViewbox();

            this.moveCanvas = {
                left: event.pageX,
                top: event.pageY
            };
        }
    }

    /**
     * Process all mouseup events that are happening directly on the canvas
     */
    onMouseUp() {
        if (this.moveCanvas) {
            this.$svg.removeClass('grabbed');
            this.moveCanvas = undefined;

            // if tutorial exists, call tutorial callback
            if (this.tutorial) {
                this.tutorial.onCanvasMoved();
            }
        }
    }

    /**
     * Set the viewBox attribute of the SVG element and size and position attributes
     * of the rectangle with the background grid to match the values in this.viewbox
     */
    applyViewbox() {
        // adjust background
        this.background.addAttr({
            x: this.viewbox.left,
            y: this.viewbox.top,
            width: this.viewbox.width,
            height: this.viewbox.height
        });

        // set the viewBox attribute
        this.$svg.attr('viewBox', this.viewbox.str);
    }

    /**
     * Get the current zoom multiplier of the canvas
     * @return {number}
     */
    get zoom() {
        return this.viewbox.zoom;
    }

    /**
     * Set the zoom multiplier of the canvas.
     * I sets the viewbox zoom and then applies the new value by calling this.applyViewbox()
     * @param  {number} value set the zoom to this value
     */
    set zoom(value) {
        this.viewbox.zoom = value;
        this.applyViewbox();

        // if tutorial exists, call tutorial callback
        if (this.tutorial) {
            this.tutorial.onCanvasZoomed();
        }
    }

    /**
     * start the tutorial
     */
    startTutorial() {
        // instantiate the tutorial
        this.tutorial = new Tutorial(this, () => {
            // set userHasVisited to true when user closes (or finishes) the tutorial
            localStorage.userHasVisited = true;

            // unset the this.tutorial property
            this.tutorial = undefined;
        });

        // start the tutorial
        this.tutorial.start();
    }

    /**
     * Generate an object containing export data for the canvas and all elements.
     * Data from this function should cover all important information needed to import the
     * network in a different session.
     * @return {object} object containing infomration about the network
     */
    get exportData() {
        this.exportWireIdMap = new Map();
        this.exportWireId = 0;

        let data = {
            boxes: []
        };

        for (const box of this.boxes) {
            data.boxes.push(box.exportData);
        }

        return data;
    }

    /**
     * Recreate a logic network from the data provided
     * @param  {object} data object containing information about the imported network
     * @param  {number} [x]  horizontal position of the left top corner of the network in grid pixels
     * @param  {number} [y]  vertical position of the left top corner of the network in grid pixels
     */
    importData(data, x, y) {
        return new Promise(resolve => {
            let warnings = [];

            // if the x or y is undefined, set it to leftTopPadding instead
            // (cannot use x || leftTopPadding because of 0)
            x = x !== undefined ? x : this.leftTopPadding;
            y = y !== undefined ? y : this.leftTopPadding;

            this.simulationEnabled = false;

            // list of wires to be added
            let newWires = new Map();

            // find the leftmost and topmost coordinate of any box, save them to leftTopCorner
            let leftTopCorner = {
                x: 0,
                y: 0
            };

            for (const boxData of data.boxes) {
                if (boxData.transform && boxData.transform.items) {
                    for (const transformInfo of boxData.transform.items) {
                        if (transformInfo.name === 'translate') {
                            if (leftTopCorner) {
                                leftTopCorner = {
                                    x: Math.min(leftTopCorner.x, transformInfo.args[0]),
                                    y: Math.min(leftTopCorner.y, transformInfo.args[1])
                                };
                            } else {
                                leftTopCorner = {
                                    x: transformInfo.args[0],
                                    y: transformInfo.args[1]
                                };
                            }
                        }
                    }
                }
            }

            for (let boxData of data.boxes) {
                // mapping of dataBox.name of the objects that have category "other"
                const otherMap = {
                    input: () => this.newInput(0, 0, boxData.isOn, false),
                    output: () => this.newOutput(0, 0, false)
                };

                // mapping of dataBox.category
                const boxMap = {
                    gate: () => this.newGate(boxData.name, 0, 0, false),
                    blackbox: () =>
                        this.newBlackbox(
                            boxData.inputs,
                            boxData.outputs,
                            boxData.table,
                            boxData.name,
                            0,
                            0,
                            false
                        ),
                    other: () => {
                        if (!boxData.name) throw `This network contains a box without a name.`;

                        if (!otherMap[boxData.name])
                            throw `This network contains unknown box names. (${boxData.name})`;

                        return otherMap[boxData.name]();
                    }
                };

                const createBox = () => {
                    if (!boxData.category) throw `This network a box without a category.`;

                    if (!boxMap[boxData.category])
                        throw `This network contains unknown box categories. (${boxData.category})`;

                    return boxMap[boxData.category]();
                };

                let box;

                try {
                    box = createBox();
                } catch (e) {
                    warnings.push(e);
                }

                if (box) {
                    // proccess box transforms (translation and rotation)
                    let transform = new editorElements.Transform();
                    let rotationCount = 0;

                    const transformItemMap = {
                        translate: args => {
                            transform.setTranslate(
                                args[0] -
                                leftTopCorner.x + // make it the relative distance from the leftmost element
                                    x, // apply the position
                                args[1] -
                                leftTopCorner.y + // make it the relative distance from the topmost element
                                    y // apply the position
                            );
                        },
                        rotate: args => {
                            rotationCount = (args[0] % 360) / 90;
                        }
                    };

                    if (boxData.transform && boxData.transform.items) {
                        for (const transformItem of boxData.transform.items) {
                            const { name, args } = transformItem;

                            if (!name) {
                                warnings.push(
                                    `This network contains unnamed transform properties.`
                                );
                                break;
                            }

                            if (!transformItemMap[name]) {
                                warnings.push(
                                    `This network contains unknown transform properties. (${
                                        transformItem.name
                                    })`
                                );
                                break;
                            }

                            transformItemMap[name](args);
                        }
                    }

                    transform.toSVGPixels(this);
                    box.setTransform(transform);

                    for (let i = 0; i < rotationCount; ++i) {
                        box.rotate(true);
                    }

                    // add all wires to the list of wires to be added
                    if (boxData.connections) {
                        for (const connection of boxData.connections) {
                            // get the artificial wire id
                            let wireId = connection.wireId;

                            // pass the values got from json into a variable that will be added into the map
                            let value = {
                                index: connection.index,
                                boxId: box.id
                            };

                            // add the value to the map
                            if (newWires.has(wireId)) {
                                // if there already is a wire with this id in the map,
                                // add the value to the end of the array of values
                                let mapValue = newWires.get(wireId);
                                mapValue.push(value);
                                newWires.set(wireId, mapValue);
                            } else {
                                // if there is no wire with this id in the map
                                // add the wire and set the value to be the first element in the array
                                newWires.set(wireId, [value]);
                            }
                        }
                    }
                }
            }

            // refresh the SVG document (needed for wiring)
            this.refresh();

            // with all boxes added, we can now connect them with wires

            // priority queue for the new wires, priority being (1 / manhattanDistance) between the conenctors, higher is better
            let wireQueue = new PriorityQueue();

            // get all ids for lal the
            for (const wireInfo of newWires.values()) {
                let connectorIds = [];

                // create an array [connector1Id, connector2Id]
                for (const { boxId, index } of wireInfo) {
                    connectorIds.push(this.getBoxById(boxId).connectors[index].id);
                }

                // create and array [{x, y}, {x, y}] containing positions for connectors 1 and 2
                const connectorsPositions = connectorIds.map(connectorId =>
                    this.getConnectorPosition(this.getConnectorById(connectorId), true)
                );

                if (connectorsPositions.length === 2) {
                    let wire = this.newWire(...connectorIds, false, false);

                    // get the manhattan distance between these two connectors
                    const distance = manhattanDistance(...connectorsPositions);

                    // add connectorids to the priority queue
                    wireQueue.enqueue(wire, 1 / distance);
                } else {
                    warnings.push(
                        `Found a wire that does not have two endings. (It had ${
                            connectorsPositions.length
                        } instead.)`
                    );
                }
            }

            if (window.Worker) {
                let wirePoints = [];
                let wireReferences = [];

                // convert the queue to an array (this is needed by the web worker)
                while (!wireQueue.isEmpty()) {
                    const wire = wireQueue.dequeue();

                    let wireStart = this.getConnectorPosition(wire.connection.from.connector, true);
                    let wireEnd = this.getConnectorPosition(wire.connection.to.connector, true);

                    wirePoints.push([
                        {
                            x: wireStart.x / this.gridSize,
                            y: wireStart.y / this.gridSize
                        },
                        {
                            x: wireEnd.x / this.gridSize,
                            y: wireEnd.y / this.gridSize
                        }
                    ]);

                    wireReferences.push(wire);
                }

                // [routeWorkerFileName] replaced in the build process (defined in gulpfile) depending on devel / prod build
                let myWorker = new Worker('js/[routeWorkerFileName]');

                let loadingMessage = this.messages.newLoadingMessage(
                    'looking for the best wiring…'
                );

                myWorker.onmessage = event => {
                    const { paths } = event.data;
                    // iterate wireReferences and paths synchronously
                    wireReferences.forEach((wire, key) => {
                        wire.setWirePath(wire.pathToPolyLine(paths[key]));
                        wire.updateWireState();
                    });

                    loadingMessage.hide();
                };

                const message = {
                    wires: wirePoints,
                    nonRoutableNodes: this.getNonRoutableNodes(),
                    inconvenientNodes: this.getInconvenientNodes()
                };

                myWorker.postMessage(message);
            } else {
                // web worker is not supported: use an interval to make the import a bit slower
                // by dividing it into chunks, so the browser window is not entirely frozen when the wiring is happening

                const wiresToBeRoutedAtOnce = 10;
                const delayBetweenIterations = 200;

                // add wires in the order from short to long
                let wirePlacingInterval = window.setInterval(() => {
                    if (!wireQueue.isEmpty()) {
                        for (let i = 0; i < wiresToBeRoutedAtOnce; ++i) {
                            if (wireQueue.isEmpty()) {
                                break;
                            }

                            const wire = wireQueue.dequeue();
                            wire.routeWire(true, false);
                            wire.updateWireState();
                        }
                    } else {
                        console.log('finished');
                        clearInterval(wirePlacingInterval);
                    }
                }, delayBetweenIterations);
            }

            // refresh the SVG document
            this.refresh();

            this.simulationEnabled = true;

            resolve(warnings);
        });
    }

    /**
     * When user clicks on a connector, remember it until they click on some other connector.
     * Than call newWire with the last two connectors ids as arguments.
     * Visualize the process by displaying a grey wire between the first conenctor and the mouse pointer.
     * @param  {string} connectorId id of the connector that the user clicked on
     */
    wireCreationHelper(connectorId, mousePosition) {
        if (!this.wireCreation) {
            this.wireCreation = {
                fromId: connectorId
            };

            this.displayCreatedWire(mousePosition);
        } else {
            if (this.wireCreation.fromId !== connectorId) {
                this.hideCreatedWire();

                this.newWire(this.wireCreation.fromId, connectorId);

                this.wireCreation = undefined;
            }
        }
    }

    /**
     * helper for wireCreationHelper that displays a grey wire between the first connector and the specified mousePosition
     * @param  {Object} mousePosition object with x and y coordinates in SVG pixels
     */
    displayCreatedWire(mousePosition) {
        this.wireCreation.tempWire = new editorElements.HelperWire(
            this,
            this.wireCreation.fromId,
            mousePosition
        );

        $(window).on('mousemove.wireCreation', event => {
            event = this.viewbox.transformEvent(event);

            mousePosition = {
                x: event.pageX,
                y: event.pageY
            };

            this.wireCreation.tempWire.updateMousePosition(mousePosition);
        });

        this.appendElement(this.wireCreation.tempWire);
        this.moveToBackById(this.wireCreation.tempWire.id);
    }

    /**
     * helper for wireCreationHelper that hides the temporary wire when wire creation is done
     */
    hideCreatedWire() {
        $(window).off('mousemove.wireCreation');

        this.wireCreation.tempWire.get().remove();
        this.wireCreation.tempWire = undefined;
    }

    /**
     * helper for wireCreationHelper that cancels the wire creation process
     */
    cancelWireCreation() {
        if (this.wireCreation) {
            this.hideCreatedWire();
            this.wireCreation = undefined;
        }
    }

    /**
     * Run a logic simulation from the startingConnector.
     * This refreshes the states of all elements in the network whose inputs are
     * directly (or by transition) connected to startingConnector's output
     * @param  {OutputConnector} startingConnector run simulation from this output connector
     * @param  {Logic.state} state new state of the startingConnector
     */
    startNewSimulation(startingConnector, state) {
        if (this.simulationEnabled) {
            this.simulation = new Simulation(this);
            this.simulation.notifyChange(startingConnector.id, state);
            this.simulation.run();
        }
    }

    /**
     * Create a new gate on the specified position
     * @param  {string}  name           type of the gate (and, or ...)
     * @param  {number}  x              horizontal position of the gate in SVG pixels
     * @param  {number}  y              vertical position of the gate in SVG pixels
     * @param  {boolean} [refresh=true] if true, this.refresh() will be called after adding the gate
     * @return {editorElements.Gate}    instance of Gate that has been newly added
     */
    newGate(name, x, y, refresh = true) {
        return this.newBox(x, y, new editorElements.Gate(this, name, x, y), refresh);
    }

    /**
     * Create an input box on the specified position
     * @param  {number}  x              horizontal position of the gate in SVG pixels
     * @param  {number}  y              vertical position of the gate in SVG pixels
     * @param  {boolean} [isOn=false]   state of the input box (default is false (off))
     * @param  {boolean} [refresh=true] if true, this.refresh() will be called after adding the input box
     * @return {editorElements.InputBox}    instance of the InputBox that has been newly added
     */
    newInput(x, y, isOn = false, refresh = true) {
        return this.newBox(x, y, new editorElements.InputBox(this, isOn), refresh);
    }

    /**
     * Create an output box on the specified position
     * @param  {number}  x              horizontal position of the gate in SVG pixels
     * @param  {number}  y              vertical position of the gate in SVG pixels
     * @param  {boolean} [refresh=true] if true, this.refresh() will be called after adding the output box
     * @return {editorElements.InputBox}    instance of the OutputBox that has been newly added
     */
    newOutput(x, y, refresh = true) {
        return this.newBox(x, y, new editorElements.OutputBox(this), refresh);
    }

    /**
     * Add a new Box to the canvas
     * @param  {number}  x              horizontal position of the box in SVG pixels
     * @param  {number}  y              vertical position of the box in SVG pixels
     * @param  {editorElements.Box}  object         instance of an object derived from the editorElements.Box class
     * @param  {Boolean} [refresh=true] if true, this.refresh() will be called after adding the box
     * @return {editorElements.Box}                 return the instance of the newly added object
     */
    newBox(x, y, object, refresh = true) {
        let index = this.boxes.length;

        this.boxes[index] = object;

        // translate the gate if x and y has been specified
        if (x && y) {
            let tr = new editorElements.Transform();
            tr.setTranslate(x, y);

            this.boxes[index].svgObj.addAttr({ transform: tr.get() });
        }

        this.appendElement(this.boxes[index], refresh);

        // if tutorial exists, call tutorial callback
        if (this.tutorial) {
            this.tutorial.onElementAdded(this.boxes[index].name);
        }

        return this.boxes[index];
    }

    /**
     * Remove a box from canvas based on the provided ID
     * @param {string} boxId id of the box that should be removed
     */
    removeBox(boxId) {
        let $gate = $('#' + boxId);

        // find the gate in svg's list of gates
        let gateIndex = -1;
        for (let i = 0; i < this.boxes.length; i++) {
            if (this.boxes[i].svgObj.id === boxId) {
                gateIndex = i;
                break;
            }
        }

        if (gateIndex > -1) {
            // remove all wires connected to this gate
            for (let i = 0; i < this.boxes[gateIndex].connectors.length; i++) {
                this.removeWiresByConnectorId(this.boxes[gateIndex].connectors[i].id);
            }

            // remove the gate
            this.boxes.splice(gateIndex, 1);
            $gate.remove();

            // if tutorial exists, call tutorial callback
            if (this.tutorial) {
                this.tutorial.onElementRemoved();
            }
        } else {
            console.error('Trying to remove an nonexisting box. Box id:', boxId);
        }
    }

    /**
     * Remove all boxes from the canvas
     */
    cleanCanvas() {
        // cannot simply iterate through the array because removeBox works with it

        // create an array of ids
        const ids = this.boxes.map(box => box.id);

        // remove all boxes by their ids
        for (const id of ids) {
            this.removeBox(id);
        }
    }

    /**
     * Create a new wire connecting the provided connectors
     * @param  {string}  fromId         id of the connector that the wire is attached to
     * @param  {string}  toId           id of the connector that the wire is attached to
     * @param  {Boolean} [refresh=true] if refresh is set to true, the SVG document will be reloaded after adding the wire
     * @return {editorElements.Wire}    instance of editorElements.Wire that has been added to the canvas
     */
    newWire(fromId, toId, refresh = true, route = true) {
        // wire must connect two distinct connectors
        if (fromId === toId) return undefined;

        let connectors = [this.getConnectorById(fromId), this.getConnectorById(toId)];

        // input connectors can be connected to one wire max
        connectors.forEach(conn => {
            if (conn.isInputConnector) this.removeWiresByConnectorId(conn.id);
        });
        let index = this.wires.length;

        try {
            this.wires[index] = new editorElements.Wire(this, fromId, toId, refresh, route);
        } catch (e) {
            this.messages.newErrorMessage(e);
            return undefined;
        }

        connectors.forEach(conn => {
            conn.addWireId(this.wires[index].svgObj.id);
        });

        this.appendElement(this.wires[index], refresh);
        this.moveToBackById(this.wires[index].svgObj.id);

        if (refresh) this.wires[index].updateWireState();

        return this.wires[index];
    }

    /**
     * get the coordinates of the specified connector
     * @param  {Connector}  connector      instance of {@link Connector}
     * @param  {Boolean} [snapToGrid=true] if true, the connector position will be snapped to the grid
     * @return {Object}                    point - object containing numeric attributes `x` and `y`
     */
    getConnectorPosition(connector, snapToGrid = true) {
        // connector.svgObj.id has to be called, else the getCoordinates does not work on the first call in Firefox 55
        const dummy = connector.svgObj.id; // eslint-disable-line no-unused-vars

        let $connector = connector.svgObj.$el;

        let position = $connector.position();

        position.left = this.viewbox.transformX(position.left);
        position.top = this.viewbox.transformY(position.top);

        let width = $connector.attr('width');
        let height = $connector.attr('height');

        let x = position.left + width / 2;
        let y = position.top + height / 2;
        if (snapToGrid) {
            x = this.snapToGrid(x);
            y = this.snapToGrid(y);
        }

        return { x: x, y: y };
    }

    /**
     * creates a new blackbox
     * @param  {number} x       horizontal position of the blackbox in SVG pixels
     * @param  {number} y       vertical position of the gate in SVG pixels
     * @param  {number} inputs  number of input pins of this blackbox
     * @param  {number} outputs number of output pins of this blackbox
     * @param  {Array} table   Array of arrays, each inner array contains list of [Logic.state](./module-Logic.html#.state)s,
     *                          that describe the combination of input pin and output pin states in the order from the top to bottom for both input and output connectors.
     *                          If we had an AND array as a blackbox, one of the states could be `[Logic.state.on, Logic.state.off, Logic.state.off]`
     *                          which means that if the first input connector is in the `on` state and the second connector is in the `off` state,
     *                          the state of the output connector will be `off`.
     *                          The array can be described as `[state for input conn 1, state for input conn 2, ..., state for output conn 1, state for output conn 2 ...]`.
     * @param  {string}  name   a name that will be displayed on the blackbox
     * @param  {boolean} [refresh=true] if true, this.refresh() will be called after adding the gate
     *
     * @return {editorElements.Blackbox} instance of {@link Blackbox} that has been added to the canvas
     */
    newBlackbox(inputs, outputs, table, name, x, y, refresh = true) {
        const index = this.boxes.length;

        this.boxes[index] = new editorElements.Blackbox(
            this,
            inputs,
            outputs,
            (...inputStates) => {
                for (const line of table) {
                    const lineInputStates = line.slice(0, inputs);

                    // if every input state matches the corresponding input state in this line of the truth table
                    if (inputStates.every((value, index) => value === lineInputStates[index])) {
                        // return the rest of the line as output
                        return line.slice(inputs);
                    }
                }
                // if nothing matches, set all outputs to undefined
                return Array.from(new Array(outputs), () => Logic.state.unknown);
            },
            name
        );

        if (x && y) {
            let tr = new editorElements.Transform();
            tr.setTranslate(x, y);

            this.boxes[index].svgObj.addAttr({ transform: tr.get() });
        }

        this.appendElement(this.boxes[index], refresh);

        return this.boxes[index];
    }

    /**
     * Find the correct instance of editorElements.Wire in the app's wires by the provided id
     * @param  {string} wireId id of the wire
     * @return {editorElements.Wire} instance of the wire
     */
    getWireById(wireId) {
        for (const wire of this.wires) {
            if (wire.svgObj.id === wireId) {
                return wire;
            }
        }

        return false;
    }

    /**
     * Find all wires that are connected to the specified connector
     * @param  {string} connectorId id of the connector
     * @return {Set} set of ID's of the wires connected to this connector
     */
    getWiresByConnectorId(connectorId) {
        let connector = this.getConnectorById(connectorId);
        return connector.wireIds;
    }

    /**
     * Remove wire that has the provided ID
     * @param  {string} wireId ID of the wire that should be removed
     */
    removeWireById(wireId) {
        for (let i = 0; i < this.wires.length; ++i) {
            if (this.wires[i].svgObj.id === wireId) {
                let { connectors } = this.wires[i];

                for (let connector of connectors) {
                    connector.removeWireIdAndUpdate(wireId);
                }

                // start simulation from the input connector to
                // refresh the network after this wire

                let inputConnector = this.wires[i].connection.to.connector;
                this.startNewSimulation(inputConnector, inputConnector.state);

                this.wires[i].svgObj.$el.remove();
                this.wires.splice(i, 1);

                break;
            }
        }
    }

    /**
     * Remove all wires that are connected to the connector provided by its ID
     * @param  {string} connectorId ID of the connector
     */
    removeWiresByConnectorId(connectorId) {
        let connector = this.getConnectorById(connectorId);

        connector.wireIds.forEach(wireId => {
            let wire = this.getWireById(wireId);

            let { from, to } = wire.connection;

            // get the other connector that is the wire connected to
            let otherConnector = connectorId === from.id ? to.connector : from.connector;

            // delete the wire record from the other connector
            otherConnector.wireIds.delete(wireId);

            // remove the wire representation using jQuery
            $('#' + wireId).remove();

            // if otherConnector is an input connector, set its state to unknown
            if (otherConnector.isInputConnector) {
                otherConnector.setState(Logic.state.unknown);
                this.startNewSimulation(otherConnector, Logic.state.unknown);
            }
        });

        // clear the list of wire Ids
        connector.wireIds.clear();
        // if connector is an input connector, set its state to unknown
        if (connector.isInputConnector) {
            connector.setState(Logic.state.unknown);
            this.startNewSimulation(connector, Logic.state.unknown);
        }
    }

    /**
     * Find the correct instance of editorElements.Box in the app's boxes by the provided id
     * @param  {string} boxId id of the box
     * @return {editorElements.Box} instance of the box
     */
    getBoxById(boxId) {
        for (let i = 0; i < this.boxes.length; i++) {
            if (this.boxes[i].svgObj.id === boxId) {
                return this.boxes[i];
            }
        }
        return undefined;
    }

    /**
     * Find the correct instance of editorElements.Box in the app's boxes by ID of a connector that belongs to this box
     * @param  {string} boxId id of the connector
     * @return {editorElements.Box} instance of the box
     */
    getBoxByConnectorId(connectorId) {
        for (let i = 0; i < this.boxes.length; i++) {
            if (this.boxes[i].getConnectorById(connectorId) !== undefined) {
                return this.boxes[i];
            }
        }
        return false;
    }

    /**
     * Get instance of a connector based on it's ID (and also on an instance of editorElements.Wire if provided)
     *
     * The wire variable is used as heuristic: When we know the wire, we have to check only
     * two gates instead of all of them
     * @param  {string} connectorId id of the connector
     * @param  {editorElements.Wire} [wire]      instance of the Wire that is connected to this connector
     * @return {editorElements.Connector}        instance of the connector
     */
    getConnectorById(connectorId, wire = undefined) {
        if (wire !== undefined) {
            // we know the wire -- we can check only gates at the ends of this wire
            const { from, to } = wire.connection;

            if (from.id === connectorId) return from.connector;

            if (to.id === connectorId) return to.connector;
        } else {
            // we do not know the wire -- we have to check all gates
            for (const box of this.boxes) {
                const connector = box.getConnectorById(connectorId);
                if (connector) {
                    return connector;
                }
            }
        }

        return undefined;
    }

    /**
     * Get the logical jQuery target based on the factual jQuery target.
     *
     * If the object, that user interacted with, is not a connector and is in a group,
     * return the group jQuery object instead of the original jQuery object.
     * @param  {target} target jQuery target of the object user interacted with
     * @return {target}        jQuery target of the object user wanted to interact with
     */
    getRealJQueryTarget(target) {
        let $target = $(target);
        if (!$target.hasClass('connector') && $target.parents('g').length > 0) {
            $target = $target.parent();
            while ($target.prop('tagName') !== 'G' && $target.prop('tagName') !== 'g') {
                $target = $target.parent();
            }
        }
        return $target;
    }

    // returns the editorElement that user interacted with, the "target" argument is a jQuery element
    /**
     * Get instance of some object from editorElement based on the jQuery target
     * @param  {target} target jQuery target that user interacted with
     * @return {editorElements.NetworkElement} instance of an object derived from editorElements.NetworkElement that the user interacted with
     */
    getRealTarget(target) {
        if (target === undefined) {
            return undefined;
        }

        // eventy se museji zpracovat tady, protoze v SVG se eventy nepropaguji
        let $target = $(target);

        if ($target.hasClass('connector')) {
            // this is a connector, don't traverse groups
            return this.getConnectorById($target.attr('id'));
        } else if ($target.parents('g').length > 0) {
            // this element is in a group and it is not a connector

            // traversing up the DOM tree until we find the closest group
            let $parentGroup = $target.parent();
            while ($parentGroup.prop('tagName') !== 'G' && $parentGroup.prop('tagName') !== 'g') {
                $parentGroup = $parentGroup.parent();
            }

            // try to match the jQuery element to the logical element using DOM classes

            if ($parentGroup.hasClass('box')) {
                // return the corresponding box
                return this.getBoxById($parentGroup.attr('id'));
            } else if ($parentGroup.hasClass('wire')) {
                // return the corresponding wire
                return this.getWireById($parentGroup.attr('id'));
            } else {
                // found a group that contains the target, but this group does not match any known element types
                return undefined;
            }
        } else {
            // element does not match any known element types
            return undefined;
        }
    }

    /**
     * Add an element to the canvas
     * @param  {editorElements.NetworkElement}  element Element that will be added on the canvas
     * @param  {Boolean} [refresh=true] if true, the SVG document will be reloaded after adding this element
     */
    appendElement(element, refresh = true) {
        this.appendJQueryObject(element.get(), refresh);
    }

    /**
     * Append a jQuery element to the SVG document (helper for this.appendElement)
     * @param  {object}  object         jQuery element that will be added to the SVG document
     * @param  {Boolean} [refresh=true] if true, the SVG document will be reloaded after adding this element
     */
    appendJQueryObject(object, refresh = true) {
        this.$svg.append(object);
        if (refresh) this.refresh();
    }

    /**
     * Add a new pattern to the definitions element in the SVG document
     * @param {svgObj.Pattern} pattern pattern that will be added to the <devs> element in the SVG document
     */
    addPattern(pattern) {
        this.$defs.append(pattern);
        this.refresh();
    }

    /**
     * Reload the SVG document (needed to display a newly appended jQuery object)
     */
    refresh() {
        this.$svg.html(this.$svg.html());
        console.log('SVG document has been reloaded.');
    }

    /**
     * Display the context menu on the specified position
     * @param  {number} x       horizontal position in CSS pixels
     * @param  {number} y       vertical position in CSS pixels
     * @param  {jQuery.element} $target the item user clicked on (used to display "remove this element"-type items in the menu)
     */
    displayContextMenu(x, y, $target) {
        this.contextMenu.display(x, y, $target);

        // if tutorial exists, call tutorial callback
        if (this.tutorial) {
            this.tutorial.onContextMenuOpened();
        }
    }

    /**
     * hide the context menu
     */
    hideContextMenu() {
        this.contextMenu.hide();
    }

    /**
     * snap a value to a grid
     * @param  {number} value value in SVG pixels
     * @return {number}       the value rounded to the closest number divisible by the grid size
     */
    snapToGrid(value) {
        return Math.round(value / this.gridSize) * this.gridSize;
    }

    /**
     * convert grid pixels to SVG pixels
     * @param  {number} value distance in grid pixels
     * @return {number}       distance in SVG pixels
     */
    gridToSVG(value) {
        return value * this.gridSize;
    }

    /**
     * convert SVG pixels to grid pixels
     * @param {number} value distance in SVG pixels
     * @return {number}      distance in grud pixels
     */
    SVGToGrid(value) {
        return value / this.gridSize;
    }

    /**
     * static function for snapping a value to a grid
     * @param  {number} value value in SVG pixels
     * @param  {number} gridSize size of the grid in SVG pixels
     * @return {number}       the value rounded to the closest number divisible by the grid size
     */
    static snapToGrid(value, gridSize) {
        return Math.round(value / gridSize) * gridSize;
    }

    /**
     * move an element to the front in the canvas
     * @param  {string} objId id of the element
     */
    moveToFrontById(objId) {
        this.$svg.append($('#' + objId));
    }

    /**
     * move an element to the back in the canvas
     * @param  {string} objId id of the element
     */
    moveToBackById(objId) {
        $('#' + this.background.id).after($('#' + objId));
    }

    /**
     * get set of nodes, that cannot be used for wiring at any circumstances
     * @return {Set} set of nodes (objects containing x and y coordinates) that are not suitable for wiring
     */
    getNonRoutableNodes() {
        let blockedNodes = new Set();
        // for each box
        for (const box of this.boxes) {
            const translate = box.getGridPixelTransform().getTranslate();

            // for each item in blockedNodes (set of blocked nodes with coordinates relative
            // to the left upper corner of rect; unit used is "one gridSize") convert the coordinates
            // to absolute (multiple with gridSize and add position of rect) and add the result to the set
            for (const node of box.blockedNodes) {
                blockedNodes.add({
                    x: translate.x + node.x,
                    y: translate.y + node.y
                });
            }
        }

        // FOR DEBUG ONLY: display the non routable nodes
        /*

        if(this.nodeDisplay) {
            for (const rectangleId of this.nodeDisplay) {
                $(`#${rectangleId}`).remove();
            }
        }

        this.nodeDisplay = [];

        let first = true;

        for (const node of blockedNodes) {
            const x = this.gridToSVG(node.x);
            const y = this.gridToSVG(node.y);

            const w = 4;
            const p = w / 2;

            const nodeRectangle = new Rectangle(x - p, y - p, w, w, first ? "blue" : "red", "none")
            this.nodeDisplay.push(nodeRectangle.id);
            this.appendElement(nodeRectangle, false);

            first = false;
        }

        this.refresh();

        // */
        // END FOR DEBUG ONLY

        // return the set
        return blockedNodes;
    }

    /**
     * get set of nodes, that are inconvenient for wiring, but can be used, just are not preferred
     * @return {Set} set of nodes (objects containing x and y coordinates) that are not preferred for wiring
     */
    getInconvenientNodes(ignoreWireId) {
        let inconvenientNodes = new Set();
        // for each wire

        for (const wire of this.wires) {
            if (ignoreWireId === undefined || ignoreWireId !== wire.id) {
                if (wire.inconvenientNodes) {
                    for (const node of wire.inconvenientNodes) {
                        inconvenientNodes.add(node);
                    }
                }
            }
        }

        // FOR DEBUG ONLY: display the inconvenient nodes
        /*

        if(this.inconvenientNodeDisplay) {
            for (const rectangleId of this.inconvenientNodeDisplay) {
                $(`#${rectangleId}`).remove();
            }
        }

        this.inconvenientNodeDisplay = [];

        for (const node of inconvenientNodes) {
            const x = this.gridToSVG(node.x);
            const y = this.gridToSVG(node.y);

            const w = 4;
            const p = w / 2;

            const nodeRectangle = new Rectangle(x - p, y - p, w, w, "orange", "none")
            this.inconvenientNodeDisplay.push(nodeRectangle.id);
            this.appendElement(nodeRectangle, false);
        }

        this.refresh();

        // */
        // END FOR DEBUG ONLY

        // return the set
        return inconvenientNodes;
    }
}