# Source: core/range-controller.js

    /**
     * @module montage/core/range-controller
     */
    var Montage = require("./core").Montage;
    var GenericCollection = require("collections/generic-collection");
    var observableArrayProperties = require("collections/listen/array-changes").observableArrayProperties;
    var deprecate = require("core/deprecate");

    // The content controller is responsible for determining which content from a
    // source collection are visible, their order of appearance, and whether they
    // are selected. Multiple repetitions may share a single content controller
    // and thus their selection state.

    // The controller manages a series of visible iterations. Each iteration has a
    // corresponding "object" and whether that iteration is "selected". The
    // controller uses a bidirectional binding to ensure that the controller's
    // "selections" collection and the "selected" property of each iteration are in
    // sync.

    // The controller can determine which content to display and the order in which
    // to render them in a variety of ways. You can use a "selector" to
    // filter and sort the content. The controller binds the content of
    // "organizedContent" depending on which strategy you use.

    // The content of "organizedContent" is then reflected with corresponding
    // incremental changes to "iterations". The "iterations" array will always
    // have an "iteration" corresponding to the "object" in "organizedContent" at
    // the same position.

    /**
     * @const {Array}
     */
    var EMPTY_ARRAY = Object.freeze([]);

    /**
     * A `_RangeSelection` is a special kind of `Array` that knows about a `RangeController`
     * and maintains invariants about itself relative to the properties of the
     * `RangeController`. A `_RangeSelection` should only be modified using the `splice`
     * method. Changes by directly using other `Array` methods can break the invariants.
     *
     * @class _RangeSelection
     * @private
     */

    var _RangeSelection = function(content, rangeController) {
        var self = content;
        //Moved to RangeSelection.prototype for optimization
        //self.makeObservable();
        self.__proto__ = _RangeSelection.prototype;

        self.rangeController = rangeController;
        self.contentEquals = content && content.contentEquals || Object.is;

        //Moved to _RangeSelection.prototype for optimization
        // Object.defineProperty(self, "clone", {
        //     value: function(){
        //         return this.slice();
        //     }
        // });
         // Object.defineProperty(self, "swap", {
         //     configurable: false,
         //     value: _RangeSelection.prototype.swap
         // });
         // Object.defineProperty(self, "push", {
         //     configurable: false,
         //     value: _RangeSelection.prototype.push
         // });
        return self;
    };
    _RangeSelection.prototype = Object.create(Array.prototype, observableArrayProperties);
    Object.defineProperty(_RangeSelection.prototype, "clone", {
        value: function(){
            return this.slice();
        }
    });
    _RangeSelection.prototype.oldSwap = observableArrayProperties.swap.value;
    Object.defineProperty(_RangeSelection.prototype, "swap", {
        configurable: false,
        value: function(start, howMany, itemsToAdd) {
            return this.swap_or_push(start, howMany, itemsToAdd);
        }
    });
    _RangeSelection.prototype.oldPush = observableArrayProperties.push.value;
    Object.defineProperty(_RangeSelection.prototype, "push", {
        configurable: false,
        value: function() {
              var i = -1,
                  l = arguments.length,
                  x = Array(l);

              while (++i < l) {
                x[i] = arguments[i];
              }

            this.swap_or_push(this.length, 0, x);
        }
    });

    /**
     * A custom version of swap to ensure that changes obey the RangeController
     * invariants:
     *  - if rC.allowsMultipleSelection is false, only allow one item in set.
     *  - if rC.avoidsEmtySelection is true, require at least one item in set.
     *  - only add items that are present in rC.content
     *  - enforce uniqueness of items according to the contentEquals of the content
     *
     * @function swap
     * @param {number} start
     * @param {number} howMany
     * @param {Object} itemsToAdd
     *
     */
    Object.defineProperty(_RangeSelection.prototype, "swap_or_push", {
        configurable: false,
        value: function(start, howMany, itemsToAdd) {
            var content = this.rangeController.content;
            this.contentEquals = content && content.contentEquals || Object.is;
            start = start >= 0 ? start : this.length + start;
            var plus;
            var oldLength = this.length;
            var minusLength = Math.min(howMany, oldLength - start);

    		if(itemsToAdd) {

                itemsToAdd.contentEquals = this.contentEquals;

                plus = itemsToAdd.filter(function(item, index){
                    // do not add items to the selection if they aren't in content
                    if (content && !content.has(item)) {
                        return false;
                    }

                    // if the same item appears twice in the add list, only add it once
                    if (itemsToAdd.findLast(item) > index) {
                        return false;
                    }

                    // if the item is already in the selection, don't add it
                    // unless it's in the part that we're about to delete.
                    var indexInSelection = this.find(item);
                    return indexInSelection < 0 ||
                            (indexInSelection >= start && indexInSelection < start + minusLength);

                }, this);
    		}
    		else {
    			plus = EMPTY_ARRAY;
    		}

            var minus;
            if (minusLength === 0) {
                // minus will be empty
                minus = EMPTY_ARRAY;
            } else {
                minus = Array.prototype.slice.call(this, start, start + minusLength);
            }
            var diff = plus.length - minus.length;
            var newLength = Math.max(this.length + diff, start + plus.length);

            if (!this.rangeController.allowsMultipleSelection && newLength > 1) {
                // use the last-supplied item as the sole element of the set
                var last = plus.length ? plus[plus.length-1] : this.one();
                if(oldLength === 0) {
                    this.oldPush(last);
                    return EMPTY_ARRAY;
                }
                else {
                    return this.oldSwap(0, oldLength, [last]);
                }
            } else if (this.rangeController.avoidsEmptySelection && newLength === 0) {
                // use the first item in the selection, unless it is no longer in the content
                if (content.has(this[0])) {
                    if((this.length-1) === 0) {
                        return EMPTY_ARRAY;
                    }
                    else {
                        return this.oldSwap(1, this.length-1);
                    }
                } else {
                    if(this.length === 0) {
                        this.oldPush(content.one());
                        return EMPTY_ARRAY;
                    }
                    else {
                        return this.oldSwap(0, this.length, [content.one()]);
                    }
                }
            } else {
                return this.oldSwap(start, howMany, plus);
            }

        }
    });

    /**
     * A `RangeController` is responsible for managing "ranged content", typically
     * an array, but any collection that implements ranged content change dispatch,
     * `(plus, minus, index)`, would suffice. The controller manages selection and
     * governs the filtering and ordering of the content. `RangeController` is not
     * affiliated with a number range input.
     *
     * A `RangeController` receives a `content` collection, manages what portition
     * of that content is visible and the order of its appearance
     * (`organizedContent`), and projects changes to the the organized content into
     * an array of iteration controllers (`iterations`, containing instances of
     * `Iteration`).
     *
     * The `RangeController` provides a variety of knobs for how to project the
     * content into the organized content, all of which are optional, and the
     * default behavior is to preserve the content and its order.
     * You can use the bindings path expression language (from FRB) to determine
     * the `sortPath` and `filterPath`.
     * There is a `reversed` flag to invert the order of appearance.
     *
     * The `RangeController` is also responsible for managing which content is
     * selected and provides a variety of knobs for that purpose.
     *
     * @class RangeController
     * @classdesc Manages the selection and visible portion of given content,
     * typically an Array for for a [Repetition]{@link Repetition}.
     * @extends Montage
     */
    var RangeController = exports.RangeController = Montage.specialize( /** @lends RangeController.prototype # */ {
        /**
         * @constructs RangeController
         */
        constructor: {
            value: function RangeController(content) {
                this.content = null;
                this._selection = new _RangeSelection([], this);

                this.sortPath = null;
                this.filterPath = null;
                this.reversed = false;

                this.selectAddedContent = false;
                this.deselectInvisibleContent = false;
                this.clearSelectionOnOrderChange = false;
                this.avoidsEmptySelection = false;

                // The following establishes a pipeline for projecting the
                // underlying content into organizedContent.
                // The filterPath, sortedPath and reversed are all optional stages
                // in that pipeline and used if non-null and in that order.
                // The _filteredContent and _sortedContent are intermediate variables
                // from which organizedContent is generated.
                this.organizedContent = [];
                // dispatches handleOrganizedContentRangeChange
                this.organizedContent.addRangeChangeListener(this, "organizedContent");
                this.defineBinding("_filteredContent", {
                    "<-": "$filterPath.defined() ? content.filter{path($filterPath)} : content"
                });
                this.defineBinding("_sortedContent", {
                    "<-": "$sortPath.defined() ? _filteredContent.sorted{path($sortPath)} : _filteredContent"
                });
                this.defineBinding("organizedContent.rangeContent()", {
                    "<-": "$reversed ?? 0 ? _sortedContent.reversed() : _sortedContent"
                });

                this.addRangeAtPathChangeListener("content", this, "handleContentRangeChange");
                this.addPathChangeListener("sortPath", this, "handleOrderChange");
                this.addPathChangeListener("reversed", this, "handleOrderChange");
                this.addOwnPropertyChangeListener("allowsMultipleSelection", this);

                this.iterations = [];

                if (content) {
                    this.initWithContent(content);
                }
            }
        },

        /**
         * Initializes a range controller with a backing collection.
         *
         * @function
         * @param {Array|SortedSet} content - Any collection that produces range change events
         * @returns this
         */
        initWithContent: {
            value: function (content) {
                this.content = content;
                return this;
            }
        },

        // Organizing Content
        // ------------------

        /**
         * An FRB expression that determines how to sort the content, like "name"
         * to sort by name.
         * If the `sortPath` is null, the content is not sorted.
         *
         * @property {string} value
         */
        sortPath: {value: null},

        /**
         * Whether to reverse the order of the sorted content.
         *
         * @property {boolean} value
         */
        reversed: {value: null},

        /**
         * An FRB expression that determines how to filter content like
         * "name.startsWith('A')" to see only names starting with 'A'.
         * If the `filterPath` is null, all content is accepted.
         *
         * @property {string} value
         */
        filterPath: {value: null},

        // Managing Selection
        // ------------------

        /**
         * Whether to select new content automatically.
         * @property {boolean}
         * @default false
         *
         * @todo make this work
         */
        selectAddedContent: {value: false},

        /**
         * Whether to automatically deselect content that disappears from the
         * `organizedContent`.
         *
         * @default false
         * @property {boolean}
         */
        deselectInvisibleContent: {value: false},

        /**
         * Whether to automatically clear the selection whenever the
         * `sortPath`, `filterPath`, or `reversed`
         * knobs change.
         *
         * @default false
         * @property {boolean}
         */
        clearSelectionOnOrderChange: {value: false},

        /**
         * Whether to automatically reselect a value if it is the last value
         * removed from the selection.
         *
         * @default false
         * @property {boolean}
         */
        avoidsEmptySelection: {value: false},

        /**
         * Whether to automatically deselect all previously selected content when a
         * new selection is made.
         *
         * @default false
         * @property {boolean}
         */
        allowsMultipleSelection: {value: false},

        /**
         * @deprecated
         */
        multiSelect: {
            set: function (multiSelect) {
                deprecate.deprecationWarning("multiSelect", "allowsMultipleSelection");
                this.allowsMultipleSelection = !!multiSelect;
            },
            get: function () {
                deprecate.deprecationWarning("multiSelect", "allowsMultipleSelection");
                return this.allowsMultipleSelection;
            }
        },

        // Properties managed by the controller
        // ------------------------------------

        /**
         * An array incrementally projected from `content` through sort,
         * reversed and filter.
         *
         * @property {Array.<Object>}
         */
        organizedContent: {value: null},

        /**
         * An array of iterations corresponding to each of the values in
         * `organizedContent`, providing an interface for getting or
         * setting whether each is selected.
         *
         * @property {Array.<Iteration>}
         */
        iterations: {value: null},

        _selection: {value: null},

        /**
         * A subset of the `content` that are selected.
         * The user may safely reassign this property and all iterations will react
         * to the change.
         * The selection may be `null`.
         * The selection may be any collection that supports range change events
         * like `Array` or `SortedSet`.
         *
         * Deprecation warning: setting the `selection` will not replace it with the provided.
         * collection. Instead, it will empty the selection and then shallow-copy the
         * contents of the argument into the existing selection array. This is done to
         * maintain the complicated invariants about what the selection can be.
         *
         * @property {?Array|Set|SortedSet}
         */
        selection: {
            get: function () {
                return this._selection;
            },
            set: function (collection) {
                var args = [0, this._selection.length];
                if (collection && collection.toArray) {
                    args = args.concat(collection.toArray());
                }
                this._selection.splice.apply(this._selection, args);
            }
        },

        /**
         * A managed interface for adding values to the selection, accounting for
         * `allowsMultipleSelection`.
         * You can however directly manipulate the selection, but that will update
         * the selection asynchronously because the controller cannot change the
         * selection while handling a selection change event.
         *
         * @function
         * @param value
         */
        select: {
            value: function (value) {
                if (!this.allowsMultipleSelection && this.selection.length >= 1) {
                    this.selection.clear();
                }
                this.selection.add(value);
            }
        },

        /**
         * A managed interface for removing values from the selection, accounting
         * for `avoidsEmptySelection`.
         * You can however directly manipulate the selection, but that will update
         * the selection asynchronously because the controller cannot change the
         * selection while handling a selection change event.
         *
         * @function
         * @param value
         */
        deselect: {
            value: function (value) {
                if (!this.avoidsEmptySelection || this.selection.length > 1) {
                    this.selection["delete"](value);
                }
            }
        },

        /**
         * A managed interface for clearing the selection, accounting for
         * `avoidsEmptySelection`.
         * You can however directly manipulate the selection, but that will update
         * the selection asynchronously because the controller cannot change the
         * selection while handling a selection change event.
         *
         * @function
         */
        clearSelection: {
            value: function () {
                if (!this.avoidsEmptySelection || this.selection.length > 1) {
                    this.selection.clear();
                }
            }
        },

        /**
         * Proxies adding content to the underlying collection, accounting for
         * `selectAddedContent`.
         *
         * @function
         * @param value
         * @returns {boolean} whether the value was added
         */
        add: {
            value: function (value) {
                var result;

                if (!this.content) {
                    this.content = [];
                }
                result = this.content.add(value);
                if (result) {
                    this.handleAdd(value);
                }
                return result;
            }
        },

        /**
         * Proxies pushing content to the underlying collection, accounting for
         * `selectAddedContent`.
         *
         * @function
         * @param ...values
         * @returns {boolean} whether the value was added
         */
        push: {
            value: function () {
                var result = this.content.push.apply(this.content, arguments);
                for (var index = 0; index < arguments.length; index++) {
                    this.handleAdd(arguments[index]);
                }
                return result;
            }
        },

        /**
         * Proxies popping content from the underlying collection.
         *
         * @function
         * @returns the popped value
         */
        pop: {
            value: function () {
                return this.content.pop();
            }
        },

        /**
         * Proxies shifting content from the underlying collection.
         *
         * @function
         * @returns the shifted value
         */
        shift: {
            value: function () {
                return this.content.shift();
            }
        },

        /**
         * Proxies unshifting content to the underlying collection, accounting for
         * `selectAddedContent`.
         *
         * @function
         * @param ...values
         * @returns {boolean} whether the value was added
         */
        unshift: {
            value: function () {
                var result = this.content.unshift.apply(this.content, arguments);
                for (var index = 0; index < arguments.length; index++) {
                    this.handleAdd(arguments[index]);
                }
                return result;
            }
        },

        /**
         * Proxies splicing values into the underlying collection.
         * Accounts for * `selectAddedContent`.
         *
         * @function
         * @returns the resulting content
         */
        splice: {
            value: function () {
                var result = this.content.splice.apply(this.content, arguments);
                for (var index = 2; index < arguments.length; index++) {
                    this.handleAdd(arguments[index]);
                }
                return result;
            }
        },

        /**
         * Proxies swapping values in the underlying collection.
         * Accounts for * `selectAddedContent`
         *
         * @function
         * @param {number} index the position at which to remove values
         * @param {number} minusLength the number of values to remove
         * @param {Array} plus the values to add
         * @returns {Array} `minus`, the removed values from the content
         */
        swap: {
            value: function (index, length, values) {
                var result = this.content.swap.apply(this.content, arguments);
                if (values) {
                    // TODO WTF index vs index
                    for (index = 2; index < values.length; index++) {
                        this.handleAdd(values[index]);
                    }
                }
                return result;
            }
        },

        /**
         * Proxies deleting content from the underlying collection.
         *
         * @function
         * @param value
         * @returns {boolean} whether the value was found and deleted successfully
         */
        "delete": {
            value: function (value) {
                return this.content["delete"](value);
            }
        },

        /**
         * Does the value exist in the content?
         *
         * @function
         * @param {object} value the value to test for
         * @returns {boolean}
         */
        has: {
            value: function (value) {
                if (this.content) {
                    return this.content.has(value);
                } else {
                    return false;
                }
            }
        },

        /**
         * Proxies adding each value into the underlying collection.
         *
         * @function
         * @param {...object} values
         */
        addEach: {
            value: GenericCollection.prototype.addEach
        },

        /**
         * Proxies deleting each value out from the underlying collection.
         * @function
         * @param {...object} values
         */
        deleteEach: {
            value: GenericCollection.prototype.deleteEach
        },

        /**
         * Proxies clearing the underlying content collection.
         * @function
         */
        clear: {
            value: function () {
                this.content.clear();
            }
        },

        /**
         * Creates content and adds it to the controller and its backing
         * collection.
         * Uses `add` and `contentConstructor`.
         * @function
         * @returns the value constructed and added
         */
        addContent: {
            value: function () {
                var content = new this.contentConstructor();
                this.add(content);
                return content;
            }
        },

        /**
         * @private
         */
        _contentConstructor: {
            value: null
        },

        /**
         * Creates a content value for this range controller.
         * If the backing
         * collection has an intrinsic type, uses its `contentConstructor`.
         * Otherwise, creates and returns simple, empty objects.
         *
         * This property can be set to an alternate content constructor, which will
         * take precedence over either of the above defaults.
         *
         * @type {function}
         */
        contentConstructor: {
            get: function () {
                if (this._contentConstructor) {
                    return this._contentConstructor;
                } else if (this.content && this.content.contentConstructor) {
                    return this.content.contentConstructor;
                } else {
                    return Object;
                }
            },
            set: function (contentConstructor) {
                this._contentConstructor = contentConstructor;
            }
        },

        /**
         * Dispatched by range changes to the controller's content, arranged in
         * constructor.
         *
         * Reacts to content changes to ensure that content that no
         * longer exists is removed from the selection, regardless of whether it is
         * from the user or any other entity modifying the backing collection.
         * @private
         */
        handleContentRangeChange: {
            value: function (plus, minus, index) {
                if (this.selection.length > 0) {
                    var equals = this.content && this.content.contentEquals || Object.is;
                    // remove all values from the selection that were removed (but
                    // not added back)
                    minus.deleteEach(plus, equals);

                    if (this.selection.length) {
                        this.selection.deleteEach(minus);

                        // ensure selection always has content
                        if (this.selection.length === 0 && this.content && this.content.length &&
                            this.avoidsEmptySelection && !this.allowsMultipleSelection) {
                            // selection can't contain previous content value as content already changed
                            this.selection.add(this.content[this.content.length - 1]);
                        }
                    }
                }
            }
        },

        /**
         * Watches changes to the private reflection of the public selection,
         * enforcing the `allowsMultipleSelection` and `avoidsEmptySelection` invariants.
         *
         * @private
         * @todo this doesn't seem to be used anywhere
         */
        handleSelectionRangeChange : {
            value: function (plus, minus, index) {
                if (this.selection) {
                    if (this.content) {
                        var notInContent = [];
                        for (var i=0;i<plus.length;i++) {
                            if (!this.content.has(plus[i])) {
                                notInContent.push(plus[i]);
                            }
                        }
                        this._selection.deleteEach(notInContent);
                        if (!this.allowsMultipleSelection && this._selection.length > 1) {
                            var last = this._selection.pop();
                            this._selection.clear();
                            this._selection.add(last);
                        }
                        if (this.avoidsEmptySelection && this._selection.length === 0) {
                            this._selection.add(minus[0]);
                        }
                    } else {
                        this._selection.clear();
                    }
                }
            }
        },

        /**
         * Dispatched by a range-at-path change listener arranged in constructor.
         * Synchronizes the `iterations` with changes to `organizedContent`.
         * Also manages the `deselectInvisibleContent` invariant.
         * @private
         */
        handleOrganizedContentRangeChange: {
            value: function (plus, minus, index) {
                if (this.deselectInvisibleContent && this.selection) {
                    var diff = minus.clone(1);
                    diff.deleteEach(plus);
                    this.selection.deleteEach(minus);
                }
            }
        },

        /**
         * Dispatched by changes to sortPath, filterPath, and reversed to maintain
         * the `clearSelectionOnOrderChange` invariant.
         * @private
         */
        handleOrderChange: {
            value: function () {
                if (this.clearSelectionOnOrderChange && this.selection) {
                    this.selection.clear();
                }
            }
        },

        /**
         * Dispatched manually by all of the managed methods for adding values to
         * the underlying content, like `add` and `push`, to support `allowsMultipleSelection`.
         * @private
         */
        handleAdd: {
            value: function (value) {
                if (this.selectAddedContent && this.selection) {
                    if (!this.allowsMultipleSelection && this.selection.length) {
                        this.selection.swap(0, this.selection.length, [value]);
                    } else {
                        this.selection.add(value);
                    }
                }
            }
        },

        /**
         * Enforces the `allowsMultipleSelection` invariant when that property becomes true.
         * @private
         */
        handleAllowsMultipleSelectionChange: {
            value: function () {
                if (this.selection) {
                    var length = this.selection.length;
                    if (!this.allowsMultipleSelection && length > 1) {
                        var last = this._selection.pop();
                        this._selection.clear();
                        this._selection.add(last);
                    }
                }
            }
        }

    }, /** @lends RangeController. */ {

        objectDescriptorModuleId:require("./core")._objectDescriptorModuleIdDescriptor,

        objectDescriptor:require("./core")._objectDescriptorDescriptor,

        blueprintModuleId:require("./core")._blueprintModuleIdDescriptor,

        blueprint:require("./core")._blueprintDescriptor

    });

    // TODO @kriskowal scrollIndex, scrollDelegate -> scrollDelegate.scrollBy(offset)

    // TODO allowsMultipleSelectionWithModifiers to support ctrl/command/shift selection such
    // that individual values and ranges of values.

    // TODO @kriskowal decouple such that content controllers can be chained using
    // adapter pattern