/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */

define([
    'ko',
    'underscore',
    'mage/utils/wrapper',
    'uiEvents'
], function (ko, _, wrapper, Events) {
    'use strict';

    var nodesMap = new WeakMap();

    /**
     * Returns a array of nodes associated with a specified model.
     *
     * @param {Object} model
     * @returns {Undefined|Array}
     */
    function getBounded(model) {
        return nodesMap.get(model);
    }

    /**
     * Removes specified node to models' associations list, if it's
     * a root node (node is not a descendant of any previously added nodes).
     * Triggers 'addNode' event.
     *
     * @param {Object} model
     * @param {HTMLElement} node
     */
    function addBounded(model, node) {
        var nodes = getBounded(model),
            isRoot;

        if (!nodes) {
            nodesMap.set(model, [node]);

            Events.trigger.call(model, 'addNode', node);

            return;
        }

        isRoot = nodes.every(function (bounded) {
            return !bounded.contains(node);
        });

        if (isRoot) {
            nodes.push(node);

            Events.trigger.call(model, 'addNode', node);
        }
    }

    /**
     * Removes specified node from models' associations list.
     * Triggers 'removeNode' event.
     *
     * @param {Object} model
     * @param {HTMLElement} node
     */
    function removeBounded(model, node) {
        var nodes = getBounded(model),
            index;

        if (!nodes) {
            return;
        }

        index = nodes.indexOf(node);

        if (~index) {
            nodes.splice(index, 0);

            Events.trigger.call(model, 'removeNode', node);
        }

        if (!nodes.length) {
            nodesMap.delete(model);
        }
    }

    /**
     * Returns node's first sibling of 'element' type within the common component scope
     *
     * @param {HTMLElement} node
     * @param {*} data
     * @returns {HTMLElement}
     */
    function getElement(node, data) {
        var elem;

        while (node.nextElementSibling) {
            node = node.nextElementSibling;

            if (node.nodeType === 1 && ko.dataFor(node) === data) {
                elem = node;
                break;
            }
        }

        return elem;
    }

    wrapper.extend(ko, {

        /**
         * Extends knockouts' 'applyBindings'
         * to track nodes associated with model.
         *
         * @param {Function} orig - Original 'applyBindings' method.
         * @param {Object} ctx
         * @param {HTMLElement} node - Original 'applyBindings' method.
         */
        applyBindings: function (orig, ctx, node) {
            var result = orig(),
                data = ctx && (ctx.$data || ctx);

            if (node && node.nodeType === 8) {
                node = getElement(node, data);
            }

            if (!node || node.nodeType !== 1) {
                return result;
            }

            if (data && data.registerNodes) {
                addBounded(data, node);
            }

            return result;
        },

        /**
         * Extends knockouts' cleanNode
         * to track nodes associated with model.
         *
         * @param {Function} orig - Original 'cleanNode' method.
         * @param {HTMLElement} node - Original 'cleanNode' method.
         */
        cleanNode: function (orig, node) {
            var result = orig(),
                data;

            if (node.nodeType !== 1) {
                return result;
            }

            data = ko.dataFor(node);

            if (data && data.registerNodes) {
                removeBounded(data, node);
            }

            return result;
        }
    });

    return {

        /**
         * Returns root nodes associated with a model. If callback is provided,
         * will iterate through all of the present nodes triggering callback
         * for each of it. Also it will subscribe to the 'addNode' event.
         *
         * @param {Object} model
         * @param {Function} [callback]
         * @returns {Array|Undefined}
         */
        get: function (model, callback) {
            var nodes = getBounded(model) || [];

            if (!_.isFunction(callback)) {
                return nodes;
            }

            nodes.forEach(function (node) {
                callback(node);
            });

            this.add.apply(this, arguments);
        },

        /**
         * Subscribes to adding of nodes associated with a model.
         *
         * @param {Object} model
         */
        add: function (model) {
            var args = _.toArray(arguments).slice(1);

            args.unshift('addNode');

            Events.on.apply(model, args);
        },

        /**
         * Subscribes to removal of nodes associated with a model.
         *
         * @param {Object} model
         */
        remove: function (model) {
            var args = _.toArray(arguments).slice(1);

            args.unshift('removeNode');

            Events.on.apply(model, args);
        },

        /**
         * Removes subscriptions from the model.
         *
         * @param {Object} model
         */
        off: function (model) {
            var args = _.toArray(arguments).slice(1);

            Events.off.apply(model, args);
        }
    };
});
