'use strict';

/*@
Ox.List <f> List constructor
    options <o> Options object
        centered <b|false> if true, and orientation is 'horizontal', then keep the selected item centered
        construct   <f|null> (data) returns the list item HTML
        draggable <b|false> If true, items can be dragged
        format <[]> ???
        itemHeight <n|16> item height
        items <a|f|null> <a> list of items, <f> (data) returns {items, size, ...}, (data, callback) returns [items]
        itemWidth <n|16> item width
        keys <a|[]> keys of the list items
        max <n|-1> Maximum number of items that can be selected (-1 for all)
        min <n|0> Minimum number of items that must be selected
        orientation <s|vertical> 'horizontal', 'vertical' or 'both'
        pageLength <n|100> number of items per page
        query <o> Query
            conditions <[o]> Conditions
                key <s> Key
                operator <s> Operator (like `'='` or `'!='`)
                    Can be `'='` (contains) `'=='` (is), `'^'` (starts with),
                    `'$'` (ends with), `'<'`, `'<='`,  `'>'`, `'>='`, optionally
                    prefixed with `'!'` (not)
                value <*> Value
            operator <s> Operator (`'&'` or `'|'`)
        selectAsYouType <s|''> If set to a key, enables select-as-you-type
        selected <a|[]> ids of the selected elements
        sort <a|[]> sort order
        sortable <b|false> If true, items can be re-ordered
        sums <[s]|[]> sums to be included in totals
        type <s|'text'> type
        unique <s|''> name of the key that acts as unique id
    self <o> shared private variable
    ([options[, self]]) -> <o:Ox.Container> List object
        init <!> init
        add <!> item added
        delete <!> item removed
        draganddrop <!> Fires during drag
        draganddropend <!> Fires on drop
        draganddropenter <!> Fires when entering an item during drag
        draganddropleave <!> Fires when leaving an item during drag
        draganddroppause <!> Fires when the mouse stops during drag
        draganddropstart <!> Fires when drag starts
        copy <!> copy
        paste <!> paste
        move <!> move item
        load <!> list loaded
        openpreview <!> preview of selected item opened
        closepreview <!> preview closed
        select <!> select item
        selectafter <!> selectafter
        selectbefore <!> selectbefore
        toggle <!> toggle
@*/

// fixme: rename the add event to new, or the delete event to remove

Ox.List = function(options, self) {

    self = self || {};
    var that = Ox.Container({}, self)
            .defaults({
                _tree: false,
                centered: false,
                construct: null,
                draggable: false,
                format: [],
                itemHeight: 16,
                items: null,
                itemWidth: 16,
                keys: [],
                max: -1,
                min: 0,
                orientation: 'vertical',
                pageLength: 100,
                query: {conditions: [], operator: '&'},
                selected: [],
                sort: [],
                sortable: false,
                sums: [],
                type: 'text',
                unique: ''
            })
            .options(options || {})
            .update({
                items: function() {
                    if (!self.isAsync) {
                        updateItems();
                    } else {
                        if (Ox.isArray(self.options.items)) {
                            // FIXME: duplicated
                            self.options.items = Ox.api(self.options.items, {
                                cache: true,
                                sort: self.options.sort,
                                sums: self.options.sums,
                                unique: self.options.unique
                            });                
                            /*
                            self.listLength = self.options.items.length;
                            updateSelected();
                            updateSort();
                            */
                        }
                        updateQuery();
                    }
                },
                query: function() {
                    that.reloadList();
                },
                selected: function() {
                    setSelected(self.options.selected);
                },
                sort: function() {
                    updateSort();
                }
            })
            .scroll(scroll);

    self.options.sort = self.options.sort.map(function(sort) {
        return Ox.isString(sort) ? {
            key: sort.replace(/^[\+\-]/, ''),
            operator: sort[0] == '-' ? '-' : '+'
        } : sort;
    });

    if (Ox.isArray(self.options.items) && !self.options._tree) {
        self.options.items = Ox.api(self.options.items, {
            cache: true,
            sort: self.options.sort,
            sums: self.options.sums,
            unique: self.options.unique
        });
    }

    that.$content.bindEvent({
        mousedown: mousedown,
        singleclick: singleclick,
        doubleclick: doubleclick
    });
    if (self.options.draggable) {
        that.$content.bindEvent({
            dragstart: dragstart,
            drag: drag,
            dragpause: dragpause,
            dragenter: dragenter,
            dragleave: dragleave,
            dragend: dragend
        });
    } else if (self.options.sortable) {
        that.$content.bindEvent({
            dragstart: movestart,
            drag: move,
            dragend: moveend
        });
    }

    // fixme: without this, horizontal lists don't get their full width
    self.options.orientation == 'horizontal' && that.$content.css({height: '1px'});

    Ox.extend(self, {
        $items: [],
        $pages: [],
        format: {},
        //isAsync: true,
        isAsync: !self.options._tree,
        itemMargin: self.options.type == 'text' ? 0 : 8, // 2 x 4 px margin ... fixme: the 2x should be computed later
        keyboardEvents: {
            key_control_c: copyItems,
            key_control_e: editItems,
            key_control_n: function() {
                addItem('');
            },
            key_alt_control_n: function() {
                addItem('alt');
            },
            key_alt_shift_control_n: function() {
                addItem('alt_shift');
            },
            key_shift_control_n: function() {
                addItem('shift');
            },
            key_control_v: pasteItems,
            key_control_x: cutItems,
            key_delete: deleteItems,
            key_end: scrollToLast,
            key_enter: open,
            key_home: scrollToFirst,
            key_pagedown: scrollPageDown,
            key_pageup: scrollPageUp,
            key_section: preview, // fixme: firefox gets keyCode 0 when pressing space
            key_space: preview
        },
        listMargin: self.options.type == 'text' ? 0 : 8, // 2 x 4 px padding
        page: 0,
        preview: false,
        requests: [],
        scrollTimeout: 0,
        selected: []
    });
    if (!self.isAsync) {
        self.selected = self.options.items.map(function(item, i) {
            return Ox.extend(item, {_index: i})
        }).filter(function(item) {
            return self.options.selected.indexOf(item[self.options.unique]) > -1;
        }).map(function(item) {
            return item['_index'];
        });
    }
    self.options.max == -1 && Ox.extend(self.keyboardEvents, {
        key_alt_control_a: invertSelection,
        key_control_a: selectAll
    });
    self.options.min == 0 && Ox.extend(self.keyboardEvents, {
        key_control_shift_a: selectNone
    });
    self.keyboardEvents[
        'key_' + (self.options.orientation == 'vertical' ? 'up' : 'left')
    ] = selectPrevious;
    self.keyboardEvents[
        'key_' + (self.options.orientation == 'vertical' ? 'down' : 'right')
    ] = selectNext;
    if (self.options.max == -1) {
        self.keyboardEvents[
            'key_' + (
                self.options.orientation == 'vertical'
                ? 'shift_up' : 'shift_left'
            )
        ] = addPreviousToSelection;
        self.keyboardEvents[
            'key_' + (
                self.options.orientation == 'vertical'
                ? 'shift_down' : 'shift_right'
            )
        ] = addNextToSelection;
    }
    if (self.options.orientation == 'vertical') {
        Ox.extend(self.keyboardEvents, {
            key_left: function() {
                triggerToggleEvent(false);
            },
            key_right: function() {
                triggerToggleEvent(true);
            }
        });
    } else if (self.options.orientation == 'both') {
        Ox.extend(self.keyboardEvents, {
            key_down: selectBelow,
            key_up: selectAbove
        });
        if (self.options.max == -1) {
            Ox.extend(self.keyboardEvents, {
                key_shift_down: addBelowToSelection,
                key_shift_up: addAboveToSelection
            });
        }
        self.pageLengthByRowLength = [
            0, 60, 60, 60, 60, 60, 60, 63, 64, 63, 60,
            66, 60, 65, 70, 60, 64, 68, 72, 76, 60
        ];
    }
    if (self.options.selectAsYouType) {
        Ox.extend(self.keyboardEvents, {keys: selectAsYouType});
    }

    that.bindEvent(self.keyboardEvents);

    !self.isAsync ? updateItems() : updateQuery();

    //Ox.UI.$window.resize(that.size); // fixme: this is not the widget's job

    function addAboveToSelection() {
        var pos = getAbove();
        if (pos > -1) {
            addToSelection(pos);
            scrollToPosition(pos);
        }
    }

    function addAllToSelection(pos) {
        var arr, i, len = self.$items.length;
        if (!isSelected(pos)) {
            if (self.selected.length == 0) {
                addToSelection(pos);
            } else {
                arr = [pos];
                if (Ox.min(self.selected) < pos) {
                    for (i = pos - 1; i >= 0; i--) {
                        if (isSelected(i)) {
                            break;
                        }
                        arr.push(i);
                    }
                }
                if (Ox.max(self.selected) > pos) {
                    for (i = pos + 1; i < len; i++) {
                        if (isSelected(i)) {
                            break;
                        }
                        arr.push(i);
                    }
                }
                addToSelection(arr);
            }
        }
    }

    function addBelowToSelection() {
        var pos = getBelow();
        if (pos > -1) {
            addToSelection(pos);
            scrollToPosition(pos);
        }
    }

    function addItem(keys) {
        that.triggerEvent('add', {keys: keys});
    }

    function addNextToSelection() {
        var pos = getNext();
        if (pos > -1) {
            addToSelection(pos);
            scrollToPosition(pos);
        }
    }

    function addPreviousToSelection() {
        var pos = getPrevious();
        if (pos > -1) {
            addToSelection(pos);
            scrollToPosition(pos);
        }
    }

    function addToSelection(pos) {
        var triggerEvent = false;
        Ox.makeArray(pos).forEach(function(pos) {
            if (!isSelected(pos)) {
                self.selected.push(pos);
                if (!Ox.isUndefined(self.$items[pos])) {
                    self.$items[pos].addClass('OxSelected');
                }
                triggerEvent = true;
            } else {
                // allow for 'cursor navigation' if orientation == 'both'
                self.selected.splice(self.selected.indexOf(pos), 1);
                self.selected.push(pos);
            }
        });
        triggerEvent && triggerSelectEvent();
    }

    function clear() {
        self.requests.forEach(function(request) {
            Ox.Request.cancel(request);
        });
        Ox.extend(self, {
            $items: [],
            $pages: [],
            page: 0,
            requests: []
        });
    }

    function constructEmptyPage(page) {
        var i, $page = Ox.ListPage().css(getPageCSS(page));
        Ox.loop(getPageLength(page), function() {
            Ox.ListItem({
                construct: self.options.construct
            }).appendTo($page);
        });
        return $page;
    }

    function copyItems() {
        // fixme: both copy and paste should just deal with Ox.Clipboard,
        // and use a "type"
        self.options.selected.length && that.triggerEvent('copy', {
            ids: self.options.selected
        });
        /*
        ids.length && self.options.copy && Ox.Clipboard.copy(
            self.options.copy(
                ids.map(function(id) {
                    return that.value(id);
                })
            )
        );
        */
    }

    function cutItems() {
        copyItems();
        deleteItems();
    }

    function deleteItems() {
        self.options.selected.length && that.triggerEvent('delete', {
            ids: self.options.selected
        });
    }

    function deselect(pos) {
        var triggerEvent = false;
        Ox.makeArray(pos).forEach(function(pos) {
            if (isSelected(pos)) {
                self.selected.splice(self.selected.indexOf(pos), 1);
                !Ox.isUndefined(self.$items[pos])
                    && self.$items[pos].removeClass('OxSelected');
                triggerEvent = true;
            }
        });
        triggerEvent && triggerSelectEvent();
    }

    function doubleclick(data) {
        open(isSpecialTarget(data));
    }

    function dragstart(data) {
        var $target = $(data.target),
            $parent = $target.parent();
        if ((
            $target.is('.OxTarget') // icon lists
            || $parent.is('.OxTarget') // table lists
            || $parent.parent().is('.OxTarget') // table lists with div inside cell
        ) && !$target.is('.OxSpecialTarget')) {
            self.drag = {
                ids: self.options.selected
            };
            // fixme: shouldn't the target have been
            // automatically passed already, somewhere?
            that.triggerEvent('draganddropstart', {
                event: data,
                ids: self.drag.ids
            });
        }
    }

    function drag(data) {
        self.drag && that.triggerEvent('draganddrop', {
            event: data,
            ids: self.drag.ids
        });
    }

    function dragpause(data) {
        self.drag && that.triggerEvent('draganddroppause', {
            event: data,
            ids: self.drag.ids
        });
    }

    function dragenter(data) {
        self.drag && that.triggerEvent('draganddropenter', {
            event: data,
            ids: self.drag.ids
        });
    }

    function dragleave(data) {
        self.drag && that.triggerEvent('draganddropleave', {
            event: data,
            ids: self.drag.ids
        });
    }

    function dragend(data) {
        if (self.drag) {
            that.triggerEvent('draganddropend', {
                event: data,
                ids: self.drag.ids
            });
            delete self.drag;
        }
    }

    function editItems() {
        /*
        self.options.selected.length && that.triggerEvent('edit', {
            ids: self.options.selected
        });
        */
    }

    function emptyFirstPage() {
        if (self.$pages[0]) {
            if (self.options.type == 'text') {
                self.$pages[0].find('.OxEmpty').remove();
            } else if (self.options.orientation == 'both') {
                that.$content.css({height: getListSize() + 'px'});
            }
        }
    }

    function fillFirstPage() {
        if (self.$pages[0]) {
            if (self.options.type == 'text') {
                var height = getHeight(),
                    lastItemHeight = height % self.options.itemHeight || self.options.itemHeight,
                    visibleItems = Math.ceil(height / self.options.itemHeight);
                if (self.listLength < visibleItems) {
                    Ox.range(self.listLength, visibleItems).forEach(function(i) {
                        var $item = Ox.ListItem({
                            construct: self.options.construct
                        });
                        $item.addClass('OxEmpty').removeClass('OxTarget');
                        if (i == visibleItems - 1) {
                            $item.$element.css({
                                height: lastItemHeight + 'px',
                                overflowY: 'hidden'
                            });
                        }
                        $item.appendTo(self.$pages[0]);
                    });
                }
            } else if (self.options.orientation == 'both') {
                var height = getHeight(),
                    listSize = getListSize();
                if (listSize < height) {
                    that.$element.css({overflowY: 'hidden'});
                    that.$content.css({height: height + 'px'});
                } else {
                    that.$element.css({overflowY: 'auto'});
                }
            }
        }
    }

    function findCell(e) {
        var $element = $(e.target);
        while (!$element.is('.OxCell') && !$element.is('.OxPage') && !$element.is('body')) {
            $element = $element.parent();
        }
        return $element.is('.OxCell') ? $element : null;
    }

    function findItemPosition(e) {
        var $element = $(e.target),
            $parent,
            position = -1;
        while (
            !$element.is('.OxTarget') && !$element.is('.OxPage')
            && ($parent = $element.parent()).length
        ) {
            $element = $parent;
        }
        if ($element.is('.OxTarget')) {
            while (
                !$element.is('.OxItem') && !$element.is('.OxPage')
                && ($parent = $element.parent()).length
            ) {
                $element = $parent;
            }
            if ($element.is('.OxItem')) {
                position = $element.data('position');
            }
        }
        return position;
    }

    function getAbove() {
        var pos = -1;
        if (self.selected.length) {
            pos = self.selected[self.selected.length - 1] - self.rowLength;
            if (pos < 0) {
                pos = -1;
            }
        }
        return pos;
    }

    function getBelow() {
        var pos = -1;
        if (self.selected.length) {
            pos = self.selected[self.selected.length - 1] + self.rowLength;
            if (pos >= self.$items.length) {
                pos = -1;
            }
        }
        return pos;
    }

    function getHeight() {
        return that.height() - (that.$content.width() > that.width() ? Ox.UI.SCROLLBAR_SIZE : 0);
    }

    function getListSize() {
        return Math.ceil(self.listLength / self.rowLength) * (self.options[
                self.options.orientation == 'horizontal' ? 'itemWidth' : 'itemHeight'
            ] + self.itemMargin);
    }

    function getNext() {
        var pos = -1;
        if (self.selected.length) {
            pos = (self.options.orientation == 'both'
                ? self.selected[self.selected.length - 1]
                : Ox.max(self.selected)) + 1;
            if (pos == self.$items.length) {
                pos = -1;
            }
        }
        return pos;
    }

    function getPage() {
        return Math.max(
            Math.floor(self.options.orientation == 'horizontal' ?
            (that.scrollLeft() - self.listMargin / 2) / self.pageWidth :
            (that.scrollTop() - self.listMargin / 2) / self.pageHeight
        ), 0);
    }

    function getPageByPosition(pos) {
        return Math.floor(pos / self.options.pageLength);
    }

    function getPageByScrollPosition(pos) {
        return getPageByPosition(pos / (
            self.options.orientation == 'vertical'
            ? self.options.itemHeight
            : self.options.itemWidth
        ));
    }

    function getPageCSS(page) {
        return self.options.orientation == 'horizontal' ? {
            left: (page * self.pageWidth + self.listMargin / 2) + 'px',
            top: (self.listMargin / 2) + 'px',
            width: (page < self.pages - 1 ? self.pageWidth :
                getPageLength(page) * (self.options.itemWidth + self.itemMargin)) + 'px'
        } : {
            top: (page * self.pageHeight + self.listMargin / 2) + 'px',
            width: self.pageWidth + 'px'
        };
    }

    function getPageHeight() {
         return Math.ceil(self.pageLength * (self.options.itemHeight + self.itemMargin) / self.rowLength);
    }

    function getPageLength(page) {
        var mod = self.listLength % self.pageLength;
        return page < self.pages - 1 || (self.listLength && mod == 0) ? self.pageLength : mod;
    }

    function getPositionById(id) {
        // fixme: is this really needed?
        var pos = -1;
        Ox.forEach(self.$items, function($item, i) {
            if ($item.options('data')[self.options.unique] == id) {
                pos = i;
                Ox.Break();
            }
        });
        return pos;
    }

    function getPositions(callback) {
        // fixme: optimize: send non-selected ids if more than half of the items are selected
        if (self.options.selected.length/* && ids.length < self.listLength*/) {
            self.requests.push(self.options.items({
                positions: self.options.selected,
                query: self.options.query,
                sort: self.options.sort
            }, function(result) {
                getPositionsCallback(result, callback);
            }));
        } else {
            getPositionsCallback(null, callback);
        }
    }

    function getPositionsCallback(result, callback) {
        //Ox.Log('List', 'getPositionsCallback', result);
        var pos = 0, previousSelected = self.options.selected;
        if (result) {
            self.options.selected = [];
            self.positions = {};
            self.selected = [];
            Ox.forEach(result.data.positions, function(pos, id) {
                // fixme: in case the order of self.options.selected
                // is important - it may get lost here
                if (pos > -1) {
                    self.options.selected.push(id);
                    self.selected.push(pos);
                }
            });
            if (self.selected.length) {
                pos = Ox.min(self.selected);
                self.page = getPageByPosition(pos);
            }
            if (!Ox.isEqual(self.options.selected, previousSelected)) {
                that.triggerEvent('select', {ids: self.options.selected});
            }
        } else if (self.stayAtPosition) {
            self.page = getPageByScrollPosition(self.stayAtPosition);
        }
        that.$content.empty();
        loadPages(self.page, function() {
            scrollToPosition(pos, true);
            callback && callback();
        });
    }

    function getPrevious() {
        var pos = -1;
        if (self.selected.length) {
            pos = (self.options.orientation == 'both'
                ? self.selected[self.selected.length - 1]
                : Ox.min(self.selected)) - 1;
        }
        return pos;
    }

    function getRow(pos) {
        return Math.floor(pos / self.rowLength);
    }

    function getRowLength() {
        return self.options.orientation == 'both' ? Math.floor(
            (getWidth() - self.listMargin)
            / (self.options.itemWidth + self.itemMargin)
        ) : 1;
    }

    function getScrollPosition() {
        // if orientation is both, this returns the
        // element position at the current scroll position
        return Math.floor(
            that.scrollTop() / (self.options.itemHeight + self.itemMargin)
        ) * self.rowLength;
    }

    function getSelectedIds(callback) {
        var ids = [], notFound = false;
        if (self.$items.length == 0) {
            callback(self.options.selected);
        } else {
            Ox.forEach(self.selected, function(pos) {
                if (self.$items[pos]) {
                    ids.push(self.$items[pos].options('data')[self.options.unique]);
                } else {
                    notFound = true;
                    Ox.Break();
                }
            });
            if (notFound) {
                // selection across items that are not in the DOM
                self.options.items({
                    keys: [self.options.unique],
                    query: self.options.query,
                    range: [0, self.listLength],
                    sort: self.options.sort
                }, function(result) {
                    var ids = [], rest = [],
                        useRest = self.selected.length > self.listLength / 2;
                    result.data.items.forEach(function(item, i) {
                        if (self.selected.indexOf(i) > -1) {
                            ids.push(item[self.options.unique]);
                        } else if (useRest) {
                            rest.push(item[self.options.unique]);
                        }
                    });
                    useRest ? callback(ids, rest) : callback(ids);
                });
            } else {
                callback(ids);
            }
        }
    }

    function getWidth() {
        //Ox.Log('List', 'LIST THAT.WIDTH()', that.width())
        return that.width() - (
            that.$content.height() > that.height() ? Ox.UI.SCROLLBAR_SIZE : 0
        );
    }

    function invertSelection() {
        var arr = Ox.range(self.listLength).filter(function(pos) {
            return !isSelected(pos);
        });
        selectNone();
        addToSelection(arr);
    }

    function isSelected(pos) {
        return self.selected.indexOf(pos) > -1;
    }

    function isSpecialTarget(e) {
        var $element = $(e.target),
            $parent;
        while (
            !$element.is('.OxSpecialTarget') && !$element.is('.OxPage')
            && ($parent = $element.parent()).length
        ) {
            $element = $parent;
        }
        return $element.is('.OxSpecialTarget');
    }

    function loadItems() {
        self.$pages[0].empty();
        self.$items = [];
        var timeC = 0, timeA = 0;
        self.options.items.forEach(function(item, pos) {
            // fixme: duplicated
            var time0 = +new Date();
            self.$items[pos] = Ox.ListItem({
                construct: self.options.construct,
                data: item,
                position: pos,
                unique: self.options.unique
            });
            timeC += +new Date() - time0;
            isSelected(pos) && self.$items[pos].addClass('OxSelected');
            var time0 = +new Date();
            self.$items[pos].appendTo(self.$pages[0]);
            timeA += +new Date() - time0;
        });
        // timeout needed so that height is present
        setTimeout(fillFirstPage, 0);
        self.selected.length && scrollToPosition(self.selected[0]);
        Ox.Log('List', 'CONSTRUCT:', timeC, 'APPEND:', timeA);
        that.triggerEvent('init', {items: self.options.items.length});
        // fixme: do sync lists need to trigger init?
    }

    function loadPage(page, callback) {
        if (page < 0 || page >= self.pages) {
            !Ox.isUndefined(callback) && callback();
            return;
        }
        Ox.Log('List', that.oxid, 'loadPage', page);
        var keys = Ox.unique(self.options.keys.concat(self.options.unique)),
            offset = page * self.pageLength,
            range = [offset, offset + getPageLength(page)];
        if (Ox.isUndefined(self.$pages[page])) { // fixme: unload will have made this undefined already
            self.$pages[page] = constructEmptyPage(page);
            page == 0 && fillFirstPage();
            self.$pages[page].appendTo(that.$content);
            self.requests.push(self.options.items({
                keys: keys,
                query: self.options.query,
                range: range,
                sort: self.options.sort
            }, function(result) {
                var $emptyPage = self.$pages[page];
                self.$pages[page] = Ox.ListPage().css(getPageCSS(page));
                result.data.items.forEach(function(v, i) {
                    var pos = offset + i;
                    self.$items[pos] = Ox.ListItem({
                            construct: self.options.construct,
                            data: v,
                            //format: self.options.format,
                            position: pos,
                            unique: self.options.unique
                        });
                    isSelected(pos) && self.$items[pos].addClass('OxSelected');
                    self.$items[pos].appendTo(self.$pages[page]);
                });
                page == 0 && fillFirstPage();
                // FIXME: why does emptyPage sometimes have no methods?
                //Ox.Log('List', 'emptyPage', $emptyPage)
                $emptyPage && $emptyPage.remove && $emptyPage.remove();
                self.$pages[page].appendTo(that.$content);
                !Ox.isUndefined(callback) && callback(); // fixme: callback necessary? why not bind to event?
            }));
        } else {
            //Ox.Log('List', 'loading a page from cache, this should probably not happen -----------')
            self.$pages[page].appendTo(that.$content);
        }
    }

    function loadPages(page, callback) {
        Ox.Log('List', 'loadPages', page)
        var counter = 0,
            fn = function() {
                if (++counter == 3) {
                    !Ox.isUndefined(callback) && callback();
                    that.triggerEvent('load');
                }
            };
        // fixme: find out which option is better
        /*
        loadPage(page, function() {
            loadPage(page - 1, fn);
            loadPage(page + 1, fn);
        });
        */
        loadPage(page, fn);
        loadPage(page - 1, fn);
        loadPage(page + 1, fn);
    }

    function mousedown(data) {
        var pos = findItemPosition(data);
        that.gainFocus();
        self.mousedownOnSelectedCell = false;
        if (pos > -1) {
            if (data.metaKey) {
                if (!isSelected(pos) && (self.options.max == -1 || self.options.max > self.selected.length)) {
                    // meta-click on unselected item
                    addToSelection(pos);
                } else if (isSelected(pos) && self.options.min < self.selected.length) {
                    // meta-click on selected item
                    deselect(pos);
                }
            } else if (data.shiftKey) {
                if (self.options.max == -1) {
                    // shift-click on item
                    addAllToSelection(pos);
                }
            } else if (!isSelected(pos) && self.options.max != 0) {
                // click on unselected item
                select(pos);
            } else if (self.options.type == 'text') {
                // click on a selected table list cell
                self.mousedownOnSelectedCell = true;
            }
        } else if (!$(data.target).is('.OxToggle') && self.options.min == 0) {
            // click on empty area
            selectNone();
        }
        // note: we have to save if the mousedown was on a selected cell
        // since otherwise, mousedown would select a previously unselected item,
        // and the subsequent singleclick might trigger an unwanted edit event. 
    }

    function movestart(data) {
        self.drag = {
            pos: findItemPosition(data)
        };
        Ox.extend(self.drag, {
            id: self.$items[self.drag.pos].options('data')[self.options.unique],
            startPos: self.drag.pos,
            startY: data.clientY,
            stopPos: self.drag.pos
        });
        self.$items[self.drag.pos]
            .addClass('OxDrag')
            .css({
                cursor: 'move'
            });
    }

    function move(data) {
        var clientY = data.clientY - that.offset()['top'],
            offset = clientY % 16,
            position = Ox.limit(Math.floor(clientY / 16), 0, self.$items.length - 1);
        if (position < self.drag.pos) {
            self.drag.stopPos = position + (offset > 8 ? 1 : 0);
        } else if (position > self.drag.pos) {
            self.drag.stopPos = position - (offset <= 8 ? 1 : 0);
        }
        if (self.drag.stopPos != self.drag.pos) {
            moveItem(self.drag.pos, self.drag.stopPos);
            self.drag.pos = self.drag.stopPos;
        }
    }

    function moveend(data) {
        var $item = self.$items[self.drag.pos];
        $item.removeClass('OxDrag')
            .css({
                cursor: 'default'
            });
        that.triggerEvent('move', {
            //id: id,
            ids: self.$items.map(function($item) {
                return $item.options('data')[self.options.unique];
            })
            //position: pos
        });
        delete self.drag;
    }

    function moveItem(startPos, stopPos) {
        var $item = self.$items[startPos],
            insert = startPos < stopPos ? 'insertAfter' : 'insertBefore';
        $item.detach()[insert](self.$items[stopPos].$element); // fixme: why do we need .$element here?
        //Ox.Log('List', 'moveItem', startPos, stopPos, insert, self.ids);
        var $item = self.$items.splice(startPos, 1)[0];
        self.$items.splice(stopPos, 0, $item);
        self.$items.forEach(function($item, pos) {
            $item.data({position: pos});
        });
        self.selected = [stopPos];
    }

    function open(isSpecialTarget) {
        self.options.selected.length && that.triggerEvent('open', {
            ids: self.options.selected,
            isSpecialTarget: isSpecialTarget == true
        });
    }

    function pasteItems() {
        that.triggerEvent('paste', Ox.Clipboard.paste());
    }

    function preview() {
        if (self.options.selected.length) {
            self.preview = !self.preview;
            if (self.preview) {
                that.triggerEvent('openpreview', {
                    ids: self.options.selected
                });
            } else {
                that.triggerEvent('closepreview');
            }
        }
    }

    function scroll() {
        if (self.isAsync) {
            var page = self.page;
            self.scrollTimeout && clearTimeout(self.scrollTimeout);
            self.scrollTimeout = setTimeout(function() {
                self.scrollTimeout = 0;
                self.page = getPage();
                if (self.page == page - 1) {
                    unloadPage(self.page + 2);
                    loadPage(self.page - 1);
                } else if (self.page == page + 1) {
                    unloadPage(self.page - 2);
                    loadPage(self.page + 1);
                } else if (self.page == page - 2) {
                    unloadPage(self.page + 3);
                    unloadPage(self.page + 2);
                    loadPage(self.page);
                    loadPage(self.page - 1);
                } else if (self.page == page + 2) {
                    unloadPage(self.page - 3);
                    unloadPage(self.page - 2);
                    loadPage(self.page);
                    loadPage(self.page + 1);
                } else if (self.page != page) {
                    unloadPages(page);
                    loadPages(self.page);
                }
            }, 250);
        }
        //that.gainFocus();
    }

    function scrollPageDown() {
        that.scrollTop(that.scrollTop() + getHeight());
    }

    function scrollPageUp() {
        that.scrollTop(that.scrollTop() - getHeight());
    }

    function scrollTo(value) {
        that.animate(self.options.orientation == 'horizontal' ? {
            scrollLeft: (self.listSize * value) + 'px'
        } : {
            scrollTop: (self.listSize * value) + 'px'
        }, 0);
    }

    function scrollToFirst() {
        that[self.options.orientation == 'horizontal' ? 'scrollLeft' : 'scrollTop'](0);
    }

    function scrollToLast() {
        that[self.options.orientation == 'horizontal' ? 'scrollLeft' : 'scrollTop'](self.listSize);
    }

    function scrollToPosition(pos, leftOrTopAlign) {
        //Ox.Log('List', 'scrollToPosition', pos)
        var itemHeight = self.options.itemHeight + self.itemMargin,
            itemWidth = self.options.itemWidth + self.itemMargin,
            positions = [],
            scroll,
            size;
        if (self.options.orientation == 'horizontal') {
            if (self.options.centered) {
                that.animate({
                    scrollLeft: self.listMargin / 2 + (pos + 0.5) * itemWidth
                        - that.width() / 2 + 'px'
                }, 250);
            } else {
                positions[0] = pos * itemWidth + self.listMargin / 2;
                positions[1] = positions[0] + itemWidth + self.itemMargin / 2;
                scroll = that.scrollLeft();
                size = getWidth();
                // fixme: css instead of animate(0)?
                if (positions[0] < scroll || leftOrTopAlign) {
                    that.animate({scrollLeft: positions[0] + 'px'}, 0);
                } else if (positions[1] > scroll + size) {
                    that.animate({scrollLeft: (positions[1] - size) + 'px'}, 0);
                }
            }
        } else {
            positions[0] = (
                self.options.orientation == 'vertical' ? pos : getRow(pos)
            ) * itemHeight;
            positions[1] = positions[0] + itemHeight + (
                self.options.orientation == 'vertical' ? 0 : self.itemMargin
            );
            scroll = that.scrollTop();
            size = getHeight();
            // fixme: css instead of animate(0)?
            if (positions[0] < scroll || leftOrTopAlign) {
                that.animate({scrollTop: positions[0] + 'px'}, 0);
            } else if (positions[1] > scroll + size) {
                that.animate({scrollTop: (positions[1] - size) + 'px'}, 0);
            }
        }
    }

    function select(pos) {
        if (!isSelected(pos) || self.selected.length > 1) {
            selectNone();
            addToSelection(pos);
            self.options.centered && scrollToPosition(pos);
        }
    }

    function selectAbove() {
        var pos = getAbove();
        if (pos > -1) {
            select(pos);
            scrollToPosition(pos);
        }
    }

    function selectAll() {
        addToSelection(Ox.range(self.listLength));
    }

    function selectAsYouType(data) {
        self.options.items({
            keys: [self.options.unique],
            query: {
                conditions: [
                    self.options.query,
                    {
                        key: self.options.selectAsYouType,
                        operator: '^',
                        value: data.keys
                    },
                ],
                operator: '&'
            },
            range: [0, 1],
            sort: [{key: self.options.selectAsYouType, operator: '+'}]
        }, function(result) {
            result.data.items.length && that.options({
                selected: [result.data.items[0][self.options.unique]]
            });
        });
    }

    function selectBelow() {
        var pos = getBelow();
        if (pos > -1) {
            select(pos);
            scrollToPosition(pos);
        }
    }

    function selectNext() {
        var pos = getNext();
        if (pos > -1) {
            select(pos);
            scrollToPosition(pos);
        } else if (self.selected.length) {
            that.triggerEvent('selectafter');
        }
    }

    function selectNone() {
        deselect(Ox.range(self.listLength));
    }

    function selectPrevious() {
        var pos = getPrevious();
        if (pos > -1) {
            select(pos);
            scrollToPosition(pos);
        } else if (self.selected.length) {
            that.triggerEvent('selectbefore');
        }
    }

    function selectQuery(str) {
        Ox.forEach(self.$items, function(v, i) {
            if (Ox.toLatin(v.title).toUpperCase().indexOf(str) == 0) {
                select(i);
                scrollToPosition(i);
                Ox.Break();
            }
        });
    }

    function setSelected(ids, callback) {
        // fixme: callback is never used
        // note: can't use selectNone here,
        // since it'd trigger a select event
        Ox.Log('List', 'SET SELECTED', ids)
        var counter = 0;
        self.$items.forEach(function($item, pos) {
            if (isSelected(pos)) {
                self.selected.splice(self.selected.indexOf(pos), 1);
                if (!Ox.isUndefined(self.$items[pos])) {
                    self.$items[pos].removeClass('OxSelected');
                }
            }
        });
        ids.forEach(function(id, i) {
            var pos = getPositionById(id);
            if (pos > -1) {
                select(pos, i);
            } else if (self.isAsync) {
                // async and id not in current view
                self.options.items({
                    positions: [id],
                    query: self.options.query,
                    sort: self.options.sort
                }, function(result) {
                    pos = result.data.positions[id];
                    select(pos, i);
                });
            }
        });
        function select(pos, i) {
            self.selected.push(pos);
            if (!Ox.isUndefined(self.$items[pos])) {
                self.$items[pos].addClass('OxSelected');
            }
            i == 0 && scrollToPosition(pos);
            ++counter == ids.length && callback && callback();
        }
    }

    function singleclick(data) {
        // these can't trigger on mousedown, since the mousedown
        // could still be the start of a doubleclick or drag
        var pos = findItemPosition(data),
            $cell, clickable, editable;
        if (pos > -1) {
            if (
                !data.metaKey && !data.shiftKey
                && isSelected(pos) && self.selected.length > 1
            ) {
                // click on one of multiple selected items
                select(pos);
            } else if (self.mousedownOnSelectedCell) {
                $cell = findCell(data);
                if ($cell) {
                    clickable = $cell.is('.OxClickable');
                    editable = $cell.is('.OxEditable') && !$cell.is('.OxEdit');
                    if (clickable || editable) {
                        // click on a clickable or editable cell
                        triggerClickEvent(clickable ? 'click' : 'edit', self.$items[pos], $cell);
                    }
                }
            }
        }
    }

    function toggleSelection(pos) {
        // FIXME: unused
        if (!isSelected(pos)) {
            addToSelection(pos);
        } else {
            deselect(pos);
        }
    }

    function triggerClickEvent(event, $item, $cell) {
        // event can be 'click' or 'edit'
        that.triggerEvent(event, Ox.extend({
            id: $item.data('id')
        }, $cell ? {
            key: $cell.attr('class').split('OxColumn')[1].split(' ')[0].toLowerCase()
        } : {}));
    }

    function triggerSelectEvent() {
        // FIXME: the first select event should fire immediately
        // see ArrayEditable
        getSelectedIds(function(ids) {
            self.options.selected = ids;
            setTimeout(function() {
                getSelectedIds(function(ids_, rest) {
                    self.options.selected = ids_;
                    if (Ox.isEqual(ids, ids_)) {
                        that.triggerEvent('select', Ox.extend({
                            ids: ids
                        }, rest ? {
                            rest: rest
                        } : {}));
                        if (self.preview) {
                            if (ids.length) {
                                that.triggerEvent('openpreview', {
                                    ids: ids
                                });
                            } else {
                                that.triggerEvent('closepreview');
                            }
                        }
                    }
                });
            }, 100);
        })
    }

    function triggerToggleEvent(expanded) {
        that.triggerEvent('toggle', {
            expanded: expanded,
            ids: self.options.selected
        });
    }

    function unloadPage(page) {
        if (page < 0 || page >= self.pages) {
            return;
        }
        //Ox.Log('List', 'unloadPage', page)
        //Ox.Log('List', 'self.$pages', self.$pages)
        //Ox.Log('List', 'page not undefined', !Ox.isUndefined(self.$pages[page]))
        if (!Ox.isUndefined(self.$pages[page])) {
            self.$pages[page].remove();
            delete self.$pages[page];
        }
    }

    function unloadPages(page) {
        unloadPage(page);
        unloadPage(page - 1);
        unloadPage(page + 1)
    }

    function updateItems() {
        clear();
        that.$content.empty();
        self.$pages = [];
        self.$pages[0] = Ox.Element()
            .addClass('OxPage')
            .css({
                left: self.listMargin / 2 + 'px',
                top: self.listMargin / 2 + 'px'
            })
            .appendTo(that.$content);
        self.listLength = self.options.items.length;
        loadItems();
    }

    function updatePages(pos, scroll) {
        // only used if orientation is both
        clear();
        self.pageLength = self.pageLengthByRowLength[self.rowLength]
        Ox.extend(self, {
            listSize: getListSize(),
            pages: Math.ceil(self.listLength / self.pageLength),
            pageWidth: (self.options.itemWidth + self.itemMargin) * self.rowLength,
            pageHeight: getPageHeight()
        });
        that.$content.css({
            height: self.listSize + 'px'
        });
        self.page = getPageByPosition(pos);
        //that.scrollTop(0);
        that.$content.empty();
        loadPages(self.page, function() {
            scrollTo(scroll);
        });
    }

    function updatePositions() {
        self.$items.forEach(function(item, pos) {
            item.data('position', pos);
        });
    }

    function updateQuery(callback) { // fixme: shouldn't this be setQuery?
        clear(); // fixme: bad function name ... clear what?
        self.requests.push(self.options.items({
            query: self.options.query
        }, function(result) {
            var keys = {};
            //Ox.Log('List', 'INIT!!!', result.data)
            // timeout needed since a synchronous items function
            // will reach here before one can bind to the init event,
            // and before any sizes can be determined via the DOM
            setTimeout(function() {
                that.triggerEvent('init', result.data);
                self.rowLength = getRowLength();
                self.pageLength = self.options.orientation == 'both'
                    ? self.pageLengthByRowLength[self.rowLength]
                    : self.options.pageLength;
                Ox.extend(self, {
                    listLength: result.data.items,
                    pages: Math.max(Math.ceil(result.data.items / self.pageLength), 1),
                    pageWidth: self.options.orientation == 'vertical'
                            ? 0 : (self.options.itemWidth + self.itemMargin) * (
                                self.options.orientation == 'horizontal'
                                ? self.pageLength : self.rowLength
                            ),
                    pageHeight: self.options.orientation == 'horizontal'
                        ? 0 : Math.ceil(self.pageLength * (
                            self.options.itemHeight + self.itemMargin
                        ) / self.rowLength)
                });
                self.listSize = getListSize();
                that.$content.css(
                    self.options.orientation == 'horizontal' ? 'width' : 'height',
                    self.listSize + 'px'
                );
                getPositions(callback);
            });
        }));
    }

    function updateSelected() {
        //Ox.Log('List', 'updateSelected')
        getSelectedIds(function(oldIds) {
            var newIds = [];
            Ox.forEach(self.options.items, function(item) {
                if (oldIds.indexOf(item.id) > -1) {
                    newIds.push(item.id);
                }
                newIds.length == oldIds.length && Ox.Break();
            });
            setSelected(newIds);
        });
    }

    function updateSort() {
        var length = self.options.sort.length,
            operator = [],
            sort = [];
        //if (self.listLength > 1) {
            if (!self.isAsync) {
                getSelectedIds(function(selectedIds) {
                    self.options.sort.forEach(function(v, i) {
                        operator.push(v.operator);
                        sort.push({});
                        self.options.items.forEach(function(item) {
                            sort[i][item.id] = v.map
                                ? v.map(item[v.key], item)
                                : item[v.key]
                        });
                    });                        
                    self.options.items.sort(function(a, b) {
                        var aValue, bValue, index = 0, ret = 0;
                        while (ret == 0 && index < length) {
                            aValue = sort[index][a.id];
                            bValue = sort[index][b.id];
                            if (aValue < bValue) {
                                ret = operator[index] == '+' ? -1 : 1;
                            } else if (aValue > bValue) {
                                ret = operator[index] == '+' ? 1 : -1;
                            } else {
                                index++;
                            }
                        }
                        return ret;
                    });
                    if (selectedIds.length) {
                        self.selected = [];
                        self.options.items.forEach(function(item, i) {
                            if (selectedIds.indexOf(item.id) > -1) {
                                self.selected.push(i);
                            }
                        });
                    }
                    loadItems();
                });
            } else {
                clear(); // fixme: bad function name
                getPositions();
            }
        //}
    }

    /*@
    addItems <f> add item to list
        (pos, items) -> <u> add items to list at position
        pos <n> position to add items
        items <a> array of items to add
    @*/
    that.addItems = function(pos, items) {
        var $items = [],
            length = items.length;
        self.selected.forEach(function(v, i) {
            if (v >= pos) {
                self.selected[i] += length;
            }
        });
        items.forEach(function(item, i) {
            var $item;
            $items.push($item = Ox.ListItem({
                construct: self.options.construct,
                data: item,
                position: pos + i,
                unique: self.options.unique
            }));
            if (i == 0) {
                if (pos == 0) {
                    $item.insertBefore(self.$items[0]);
                } else {
                    $item.insertAfter(self.$items[pos - 1]);
                }
            } else {
                $item.insertAfter($items[i - 1]);
            }
        });
        self.options.items.splice.apply(self.options.items, [pos, 0].concat(items));
        self.$items.splice.apply(self.$items, [pos, 0].concat($items));
        self.listLength = self.options.items.length;
        //loadItems();
        updatePositions();
        emptyFirstPage();
        fillFirstPage();
    };

    /*@
    closePreview <f> to be called when preview is closed externally
    () -> <o> the list
    @*/
    that.closePreview = function() {
        self.preview = false;
        that.triggerEvent('closepreview');
        return that;
    };

    /*@
    clearCache <f> empty list cache
        () -> <o> the list
    @*/
    that.clearCache = function() { // fixme: was used by TableList resizeColumn, now probably no longer necessary
        self.$pages = [];
        return that;
    };

    /*@
    openPreview <f> to be called when preview is opened externally
    () -> <o> the list
    @*/
    that.openPreview = function() {
        self.preview = true;
        that.triggerEvent('openpreview', {ids: self.options.selected});
        return that;
    };

    /*@
    paste <f> paste data
        (data) -> <o> the list
        data <o> paste object
    @*/
    that.paste = function(data) {
        pasteItems(data);
        return that;
    };

    /*@
    reloadList <f> reload list contents
        () -> <o> the list
    @*/
    that.reloadList = function(stayAtPosition) {
        var scrollTop = that.scrollTop();
        if (!self.isAsync) {
            loadItems();
            scrollList();
        } else {
            updateQuery(scrollList);
        }
        function scrollList() {
            stayAtPosition && that.scrollTop(scrollTop);
        }
        return that;
    };

    /*@
    reloadPages <f> reload list pages
        () -> <o> the list
    @*/
    that.reloadPages = function() {
        // this is called by TableList when the column layout changes
        var page, scrollLeft, scrollTop;
        if (!self.isAsync) {
            scrollLeft = that.scrollLeft();
            scrollTop = that.scrollTop();
            loadItems();
            that.scrollLeft(scrollLeft).scrollTop(scrollTop);
        } else {
            page = self.page;
            clear();
            self.page = page
            that.$content.empty();
            loadPages(self.page);
        }
        return that;
    };

    /*@
    removeItems <f> remove items from list
        (ids) -> <u> remove items
        (pos, length) -> <u> remove items
        ids <a> array of item ids
        pos <n> delete items starting at this position
        length <n> number of items to remove
    @*/
    that.removeItems = function(pos, length) {
        Ox.Log('List', 'removeItems', pos, length)
        if (Ox.isUndefined(length)) { // pos is list of ids
            pos.forEach(function(id) {
                var p = getPositionById(id);
                that.removeItems(p, 1);
            });
        } else { // remove items from pos to pos+length
            Ox.range(pos, pos + length).forEach(function(i) {
                self.selected.indexOf(i) > -1 && deselect(i);
                self.$items[i].remove();
            });
            self.options.items.splice(pos, length);
            self.$items.splice(pos, length);
            self.selected.forEach(function(v, i) {
                if (v >= pos + length) {
                    self.selected[i] -= length;
                }
            });
            self.listLength = self.options.items.length;
            updatePositions();
        }
        emptyFirstPage();
        fillFirstPage();
    };

    /*@
    scrollToSelection <f> scroll list to current selection
        () -> <f>  returns List Element
    @*/
    that.scrollToSelection = function() {
        self.selected.length && scrollToPosition(self.selected[0]);
        return that;
    };

    /*@
    size <f> fixme: not a good function name
        () -> <f>  returns List Element
    @*/
    that.size = function() { // fixme: not a good function name
        if (self.options.orientation == 'both') {
            var rowLength = getRowLength(),
                pageLength = self.pageLengthByRowLength[rowLength],
                pos = getScrollPosition(),
                scroll = that.scrollTop() / self.listSize;
            if (pageLength != self.pageLength) {
                self.pageLength = pageLength;
                self.rowLength = rowLength;
                updatePages(pos, scroll);
            } else if (rowLength != self.rowLength) {
                self.rowLength = rowLength;
                self.pageWidth = (self.options.itemWidth + self.itemMargin) * self.rowLength; // fixme: make function
                self.listSize = getListSize();
                self.pageHeight = getPageHeight();
                self.$pages.forEach(function($page, i) {
                    !Ox.isUndefined($page) && $page.css({
                        width: self.pageWidth + 'px',
                        top: (i * self.pageHeight + self.listMargin / 2) + 'px'
                    });
                });
                that.$content.css({
                    height: self.listSize + 'px'
                });
                scrollTo(scroll);
            }
            emptyFirstPage();
            fillFirstPage();
        } else if (self.options.type == 'text') {
            emptyFirstPage();
            fillFirstPage();
        }
        return that;
    };

    /*@
    sort <f> sort
    @*/
    // needed when a value has changed
    // but, fixme: better function name
    that.sort = function() {
        updateSort();
    };

    /*@
    sortList <f> sort list
        (key, operator) -> <f>  returns List Element
        key <s> key to sort list by
        operator <s> +/- sort ascending or descending
        map <f> function that maps values to sort values
    @*/
    // fixme: this (and others) should be deprecated,
    // one should set options instead
    that.sortList = function(key, operator, map) {
        // Ox.Log('List', 'sortList', key, operator, map)
        if (key != self.options.sort[0].key || operator != self.options.sort[0].operator) {
            self.options.sort[0] = {key: key, operator: operator, map: map};
            updateSort();
            that.triggerEvent('sort', self.options.sort[0]);
        }
        return that;
    };

    /*@
    value <f> get/set list value
        (id, key, value) -> <f> sets value, returns List Element
        (id, key) -> <a> returns value
        (id) -> <o> returns all values of id
        id <s> id of item
        key <s> key if item property
        value <*> value, can be whatever that property is
    @*/
    that.value = function(id, key, value) {
        // id can be a number and will then be interpreted as position
        var pos = Ox.isNumber(id) ? id : getPositionById(id),
            $item = self.$items[pos],
            data = $item ? $item.options('data') : {};
        if (arguments.length == 1) {
            return data;
        } else if (arguments.length == 2) {
            return data[key];
        } else if ($item) {
            if (key == self.options.unique) {
                // unique id has changed
                self.options.selected = self.options.selected.map(function(id_) {
                    return id_ == data[key] ? value : id_
                });
            }
            if (!self.isAsync) {
                self.options.items[pos][key] = value;
            }
            data[key] = value;
            $item.options({data: data});
            return that;
        }
    };

    return that;

};
