function getKey(evt) { return evt.which || evt.keyCode; }
function isKey(evt, val) { return getKey(evt) === val; }
function isAltAnd(evt, val) { return evt.altKey && isKey(evt, val); }
function isAltShiftAnd(evt, val) { return evt.shiftKey && isAltAnd(evt, val); }

var PdView = (function() {

    var ƒ = Rpd.unit,
        not = Rpd.not;

    var stopPropagation = Rpd.Render.stopPropagation;

    var editorNode;
    var startEditing = Kefir.emitter(),
        finishEditing = Kefir.emitter();

    var selection = null; //var changeSelection = Kefir.emitter(); TODO
    var startSelection = Kefir.emitter(),
        finishSelection = Kefir.emitter();

    var switchEditMode = Kefir.emitter();

    function PdView(defaultSize, getRoot) { // FIXME: a) create view only when document is ready;
                                            //        b) 'root' should be a canvas of the patch... probably;
        this.getRoot = (typeof getRoot === 'function') ? getRoot : function() { return getRoot; };
        this.inEditMode = switchEditMode.map(ƒ(true))
                                        .scan(Rpd.not, false) // will switch between `true` and `false`
                                        .toProperty(ƒ(false));
        editorNode = d3.select(document.createElement('input'))
                           .attr('type', 'text')
                           .attr('class', 'rpd-wpd-text-editor')
                           .style('width', '300px')
                           .style('height', (defaultSize.height + 1) + 'px')
                           .node();
        var view = this;
        document.addEventListener('DOMContentLoaded', function() {
            d3.select(view.getRoot())
              .append(ƒ(d3.select(editorNode)
                          .style('display', 'none')
                          .style('position', 'absolute').node()));

            Kefir.fromEvents(view.getRoot(), 'keydown')
                          .filter(function(evt) { return isAltAnd(evt, 69/*E*/) })
                          .onValue(function() { switchEditMode.emit(); });

            Kefir.fromEvents(view.getRoot(), 'keydown')
                          .filter(function(evt) { return isAltAnd(evt, 8/*Delete*/) && selection; })
                          .onValue(function() {
                              var node = selection.node;
                              if (node) {
                                  node.patch.removeNode(node);
                                  finishEditing.emit(); // FIXME: use filterBy?
                              }
                          });

            Kefir.fromEvents(view.getRoot(), 'keydown')
                 .filter(function() { return PdModel.lastPatch; }) // there's some current patch
                 .map(function(evt) { return PdModel.getNodeTypeByKey(evt); })
                 .filter(function(type) { return type; }) // type is defined
                 .onValue(function(type) {
                     PdModel.lastPatch.addNode(type);
                 });

        });
    }

    PdView.prototype.addSelection = function(selectNode, node) {
        var view = this;
        Kefir.fromEvents(selectNode, 'click')
             .filterBy(this.inEditMode.map(not))
             .map(stopPropagation)
             .onValue(function() {

                 startSelection.emit();
                 if (selection) d3.select(selection.element).classed('rpd-wpd-selected', false);
                 selection = { element: selectNode, node : node };
                 d3.select(selectNode).classed('rpd-wpd-selected', true);

                 Kefir.fromEvents(view.getRoot(), 'click').take(1)
                      .onValue(function() {

                          d3.select(selectNode).classed('rpd-wpd-selected', false);
                          selection = null;
                          finishSelection.emit();

                      });
             });
    }

    PdView.prototype.addEditor = function(selectNode, textNode, onSubmit) {
        var view = this;
        var text = d3.select(textNode);
        var editor = d3.select(editorNode);
        Kefir.fromEvents(selectNode, 'click')
             .filterBy(this.inEditMode.map(not))
             .map(stopPropagation)
             .map(function() { return text.text(); })
             .onValue(function(textValue) {
                 startEditing.emit();

                 var brect = textNode.getBoundingClientRect();
                 editor.classed('rpd-wpd-selected', true)
                       .style('top', (brect.top - 5) + 'px')
                       .style('left', (brect.left - 1) + 'px');
                 editor.node().value = textValue || '';
                 editorNode.setSelectionRange(0, editorNode.value.length);
                 text.text('').style('display', 'none');
                 editor.style('display', 'block')
                       .node().focus();

                 Kefir.merge([ Kefir.fromEvents(view.getRoot(), 'click'),
                               Kefir.fromEvents(editorNode, 'keydown')
                                    .filter(function(evt) { return isKey(evt, 13/*Enter*/); }),
                               startSelection
                             ]).take(1)
                      .onValue(function() {

                          var value = editor.node().value;

                          editorNode.blur();
                          text.text(value);
                          editor.node().value = '';
                          editor.style('display', 'none')
                                .classed('rpd-wpd-selected', false);
                          text.style('display', 'block');

                          finishEditing.emit();

                          if (onSubmit) onSubmit(value);
                      });
             });
    }

    PdView.prototype.addEditModeSwitch = function(switchNode, targetNode) {
        Kefir.fromEvents(switchNode, 'click')
             .map(stopPropagation)
             .onValue(function() { switchEditMode.emit(); });

        this.inEditMode.onValue(function(val) {
            d3.select(targetNode).classed('rpd-wpd-enabled', val);
        });
    }

    PdView.prototype.addNodeAppender = function(buttonNode, nodeType, targetPatch) {
        Kefir.fromEvents(buttonNode, 'click')
             .map(stopPropagation)
             .onValue(function() {
                 targetPatch.addNode(nodeType);
             });
    }

    PdView.prototype.addValueSend = function(sourceNode, node, inlet, getValue) {
        var model = node.patch.model;
        var styleTimeout;
        Kefir.fromEvents(sourceNode, 'click')
             .filterBy(this.inEditMode)
             .map(stopPropagation)
             .onValue(function() {
                 if (styleTimeout) clearTimeout(styleTimeout);
                 d3.select(sourceNode).classed('rpd-wpd-send-value', true);
                 styleTimeout = setTimeout(function() {
                     d3.select(sourceNode).classed('rpd-wpd-send-value', false);
                 }, 300);
                 //node.inlets[inlet].receive(getValue());
                 model.sendPdMessageValue(node, getValue());
             });
    }

    PdView.prototype.addSpinner = function(sourceNode) {
        return new Spinner(this.getRoot(), sourceNode, this.inEditMode);
    }

    PdView.prototype.measureText = function(textHolder) {
        var bbox = textHolder.node().getBBox();
        return { width: bbox.width, height: bbox.height };
    }

    PdView.EDIT_MODE_KEY_LABEL = '⌥E';
    PdView.NODE_TYPE_TO_KEY_LABEL = {
        'wpd/object':  '⌥1',
        'wpd/message': '⌥2',
        'wpd/number':  '⌥3',
        'wpd/symbol':  '⌥4',
        'wpd/comment': '⌥5',
        'wpd/bang': '⌥⇧B',
        'wpd/toggle': '⌥⇧T'
    };

    // ================================ Spinner ====================================

    function extractPos(evt) { return { x: evt.clientX,
                                        y: evt.clientY }; };
    function Spinner(root, element, editMode) {
        this.element = element;
        this.value = 0;

        var spinner = this;

        var changes = Kefir.emitter();

        changes.onValue(function(value) {
            spinner.value = value;
        });

        Kefir.fromEvents(element, 'mousedown')
             .filterBy(editMode)
             .map(stopPropagation)
             .map(extractPos)
             .flatMap(function(startPos) {
                 var start = spinner.value;
                 return Kefir.fromEvents(root, 'mousemove')
                             .map(extractPos)
                             .takeUntilBy(Kefir.fromEvents(root, 'mouseup'))
                             .map(function(newPos) { return start + (startPos.y - newPos.y); })
                             .onValue(function(num) { changes.emit(num); })
             }).onEnd(function() {});
        //this.changes.onValue(function() {});

        this.changes = changes;
    }
    Spinner.prototype.getChangesStream = function() {
        return this.changes;
    }

    return PdView;

})();

var PdModel = (function() {

    var WebPd = window.Pd || null;
    if (!WebPd) throw new Error('Building PD Model requires WebPd present');

    function PdModel(patch, webPdPatch) {
        webPdPatch = webPdPatch || WebPd.createPatch()

        this.patch = patch;
        this.webPdPatch = webPdPatch;

        this.nodeToInlets = {};
        this.nodeToOutlets = {};
        //this.nodeToCommand = {};

        this.webPdDummy = { patch: webPdPatch };
        PdModel.lastPatch = patch; // FIXME: it's no good

        this._requestResolve = Kefir.emitter();
        this._alreadyResolved = Kefir.emitter();
        this._requestApply = Kefir.emitter();
        this._sendMessage = Kefir.emitter();

        var events = {
            'wpd/request-resolve': this._requestResolve,
            'wpd/is-resolved': null,
            'wpd/request-apply': this._requestApply,
            'wpd/is-applied': null,
            'wpd/send-message': null
        };

        var model = this;

        events['wpd/is-resolved'] = this._requestResolve.map(function(value) {
            var node = value.node, patch = node.patch,
                command = value.command, _arguments = value['arguments'];
            var webPdObject;
            try {
                webPdObject = webPdPatch.createObject(command, _arguments);
            } catch (e) {
                if (!(e instanceof WebPd.core.errors.UnkownObjectError)) {
                    console.error(e, command, _arguments); // FIXME: should be a stream error (#13)
                }
            }
            return {
                node: node, command: command, 'arguments': _arguments,
                webPdObject: webPdObject
            }
        }).merge(this._alreadyResolved);

        var allResolved = events['wpd/is-resolved'].scan(function(all, current) {
                                                      all[current.node.id] = current;
                                                      return all;
                                                  }, {});

        events['wpd/is-applied'] = allResolved.sampledBy(this._requestApply.map(function(value) { return value.node; }),
                                                        function(all, requested) {
                                                            return all[requested.id];
                                                        })
                                             .map(function(value) {
                                                 var node = value.node;
                                                 var webPdObject = value.webPdObject;
                                                 if (node.type === 'wpd/object') {
                                                     model.updatePdObject(node, value.command, value['arguments'], webPdObject);
                                                     // model.connectToWebPd is called from inside of updatePdObject
                                                 } else if (node.type !== 'wpd/comment') {
                                                    try {
                                                        model.connectToWebPd([ PdModel.getReceivingInlet(node) ], [ PdModel.getSendingOutlet(node) ],
                                                                             webPdObject.inlets, webPdObject.outlets, '<'+node.id+'> ' + node.type);
                                                    } catch (err) {
                                                        console.error(err); // FIXME: use Kefir error stream
                                                    }
                                                }
                                                if (node.type === 'wpd/message') {
                                                    model.initPdMessage(node, value['arguments']);
                                                }
                                            }).onValue(function() {});

        events['wpd/send-message'] =  allResolved.sampledBy(this._sendMessage,
                                                           function(all, messagePair) {
                                                               return { node: messagePair.node,
                                                                        data: all[messagePair.node.id],
                                                                        message: messagePair.message }
                                                           })
                                                .map(function(value) {
                                                    if (value.data && value.data.webPdObject) {
                                                        value.data.webPdObject.inlets[0].message(value.message);
                                                    } else {
                                                        console.error('Message node is not connected to WebPd'); // FIXME: use Kefir error stream
                                                    }
                                                }).onValue(function() {});


        this.events = events;
    };

    PdModel.prototype.listenForNewNodes = function() {
        var typeToCmd = PdModel.TYPE_TO_COMMAND;
        var model = this,
            patch = this.patch;
        patch.event['patch/add-node'].onValue(function(node) {
            node.event['node/is-ready'].onValue(function() {
                if ((node.type !== 'wpd/object') && typeToCmd[node.type]) {
                    model.requestResolve(node, typeToCmd[node.type], []);
                    model.requestApply(node);
                }
            });
        });
        patch.event['patch/remove-node'].onValue(function(update) {

        });
    };

    PdModel.prototype.requestResolve = function(node, command, _arguments) {
        this._requestResolve.emit({ node: node,
                                    command: command,
                                    arguments: _arguments });
    };

    PdModel.prototype.requestApply = function(node) {
        this._requestApply.emit({ node: node });
    };

    PdModel.prototype.markResolved = function(node, command, _arguments, webPdObject) {
        this._alreadyResolved.emit({ node: node, patch: node.patch,
                                     command: command, 'arguments': _arguments,
                                     webPdObject: webPdObject });
    };

    PdModel.prototype.markResolvedAndApply = function(node, command, _arguments, webPdObject) {
        this.markResolved(node, command, _arguments, webPdObject);
        this.requestApply(node);
    };

    PdModel.prototype.whenResolved = function(node, callback) {
        var model = this;
        this.events['wpd/is-resolved'].filter(function(value) {
            return value.node.id === node.id;
        }).onValue(function(value) {
            if (callback(value)) model._requestApply.emit({ node: value.node });
        });
    };

    PdModel.prototype.switchAudioChannel = function(id, val) {
        if (!Pd._glob.audio) return; // FIXME: add volume changes stream
        Pd._glob.audio.channels[id].gain.value = val ? 1 : 0;
    };

    PdModel.prototype.isAudioChannelOn = function(id) {
        if (!Pd._glob.audio) return false; // FIXME: add volume changes stream
        return (Pd._glob.audio.channels[id].gain.value > 0) ? true : false;
    };

    var DspInlet = Pd.core.portlets.DspInlet,
        DspOutlet = Pd.core.portlets.DspOutlet;

    function getInletType(pdInlet) {
        return (pdInlet instanceof DspInlet) ? 'wpd/dsp' : 'wpd/value';
    }
    function getOutletType(pdOutlet) {
        return (pdOutlet instanceof DspOutlet) ? 'wpd/dsp' : 'wpd/value';
    }

    // add required inlets and outlets to the node using the properties from resolve-event
    PdModel.prototype.updatePdObject = function(node, command, _arguments, webPdObject) {
        //this.nodeToCommand[node.id] = command;

        var nodeInlets = this.nodeToInlets[node.id],
            nodeOutlets = this.nodeToOutlets[node.id];

        //if (this.nodeToCommand[node.id] === command) return;

        if (nodeInlets) {
            nodeInlets.forEach(function(inlet) { node.removeInlet(inlet); });
            this.nodeToInlets[node.id] = null;
        }
        if (nodeOutlets) {
            nodeOutlets.forEach(function(outlet) { node.removeOutlet(outlet); });
            this.nodeToOutlets[node.id] = null;
        }
        if (!webPdObject) return;

        var pdInlets = webPdObject.inlets || {},
            pdOutlets = webPdObject.outlets || {};
        var savedInlets = [], savedOutlets = [];
        pdInlets.forEach(function(pdInlet, idx) {
            savedInlets.push(node.addInlet(getInletType(pdInlet), idx.toString()));
        });
        pdOutlets.forEach(function(pdOutlet, idx) {
            savedOutlets.push(node.addOutlet(getOutletType(pdOutlet), idx.toString()));
        });
        this.nodeToInlets[node.id] = savedInlets.length ? savedInlets : null;
        this.nodeToOutlets[node.id] = savedOutlets.length ? savedOutlets : null;

        this.connectToWebPd(savedInlets, savedOutlets,
                            webPdObject.inlets, webPdObject.outlets,
                            '<'+node.id+'> ' + (webPdObject.prefix || command || node.type));
    };

    PdModel.prototype.connectToWebPd = function(inlets, outlets, webPdInlets, webPdOutlets) { // FIXME: remove label
        if ((inlets && !webPdInlets) ||
            (inlets && (inlets.length !== webPdInlets.length))) throw new Error('inlets number not matches to WebPd inlets number');
        if ((outlets && !webPdOutlets) ||
            (outlets && (outlets.length !== webPdOutlets.length))) throw new Error('outlets number not matches to WebPd outlets number');
        if (inlets && webPdInlets) {
            inlets.forEach(function(inlet, idx) {
                if (inlet.type === 'wpd/dsp') return;
                // TODO: disconnect/unsubscribe previously connected links
                var webPdInlet = webPdInlets[idx];
                if (!webPdInlet) throw new Error('Failed to find WebPd inlet corresponding to inlet ' + idx);
                inlet.event['inlet/update'].onValue(function(val) {
                    webPdInlet.message(val.get());
                });
            });
        }
        if (outlets && webPdOutlets) {
            var dummy = this.webPdDummy;
            outlets.forEach(function(outlet, idx) {
                if (outlet.type === 'wpd/dsp') return;
                var receiver = new WebPd.core.portlets.Inlet(dummy);
                receiver.message = function(args) {
                    outlet.send(PdValue.from(args));
                };
                // TODO: disconnect previously connected links
                if (webPdOutlets[idx]) {
                    webPdOutlets[idx].connect(receiver);
                } else throw new Error('Failed to find WebPd outlet corresponding to outlet ' + idx);
            });
        }
    };

    PdModel.prototype.initPdMessage = function(node, _arguments) {
        node.inlets['init'].receive(PdValue.from(_arguments));
    };

    PdModel.prototype.sendPdMessageValue = function(node, message) {
        this._sendMessage.emit({ node: node, message: message });
        /*if (node.webPdObject) {
            node.webPdObject.inlets[0].message(value);
        } else throw new Error('Message node is not connected to WebPd');*/
    };

    PdModel.getReceivingInlet = function(node) {
        return node.inlets['receive'];
    };

    PdModel.getSendingOutlet = function(node) {
        return node.outlets['send'];
    };

    PdModel.COMMAND_TO_TYPE = {
        'obj':        'wpd/object',
        'msg':        'wpd/message',
        'floatatom':  'wpd/number',
        'symbolatom': 'wpd/symbol',
        'text':       'wpd/comment',
        'bng':        'wpd/bang',
        'tgl':        'wpd/toggle',
        'nbx':        null, // 'wpd/number-2'
        'vsl':        null, // 'wpd/vslider'
        'hsl':        null, // 'wpd/hslider'
        'vradio':     null, // 'wpd/vradio'
        'hradio':     null, // 'wpd/hradio'
        'vu':         null, // 'wpd/vumeter'
        'cnv':        null, // 'wpd/canvas'
        'graph':      null, // 'wpd/graph'
        'array':      null  // 'wpd/array'
    };
    PdModel.TYPE_TO_COMMAND = {};
    Object.keys(PdModel.COMMAND_TO_TYPE).forEach(function(cmd) {
        var type = PdModel.COMMAND_TO_TYPE[cmd];
        if (type) PdModel.TYPE_TO_COMMAND[type] = cmd;
    });

    PdModel.getNodeTypeByKey = function(evt) {
        if (!evt.altKey) return;
        if (isAltAnd(evt, 49/*1*/)) return 'wpd/object';
        if (isAltAnd(evt, 50/*2*/)) return 'wpd/message';
        if (isAltAnd(evt, 51/*3*/)) return 'wpd/number';
        if (isAltAnd(evt, 52/*4*/)) return 'wpd/symbol';
        if (isAltAnd(evt, 53/*5*/)) return 'wpd/comment';
        if (isAltShiftAnd(evt, 66/*B*/)) return 'wpd/bang';
        if (isAltShiftAnd(evt, 84/*T*/)) return 'wpd/toggle';
    };

    return PdModel;

})();

PdValue = (function() {

    function PdValue(vals) {
        this.value = vals;
    };

    PdValue.prototype.get = function() {
        return this.value;
    };

    PdValue.prototype.getByIndex = function(idx) {
        return this.value[idx];
    };

    PdValue.prototype.isBang = function() {
        return (this.value[0] === 'bang');
    };

    PdValue.isPdValue = function(test) {
        return test instanceof PdValue;
    };

    PdValue.from = function(vals) {
        return new PdValue(vals);
    };

    PdValue.extract = function(test) {
        if (!test) return PdValue.from([]);
        if (Array.isArray(test)) return PdValue.from(test);
        //if (test === 'bang') return PdValue.bang();
        //if (!Number.isNaN(parseFloat(test))) return PdValue.from([test]);
        var parts = test.split(' ');
        return PdValue.from(parts.map(function(part) {
            return !Number.isNaN(parseFloat(part)) ? parseFloat(part) : part;
        }));
    };

    PdValue.bang = function() {
        return new PdValue(['bang']);
    };

    return PdValue;

})();
