import { extend, map, template, property, CompiledTemplate } from 'underscore';
import { reverse } from './underscore-compat';
import { Collection, View } from 'backbone';

import CollectionView from './collection-view';

const itemSelector = 'li';

const mockModels = [{
    id: 1,
    name: 'Ada',
}, {
    id: 2,
    name: 'Brian',
}, {
    id: 3,
    name: 'Cleo',
}, {
    id: 4,
    name: 'Duncan',
}, {
    id: 5,
    name: 'Eliza',
}];

class ItemView extends View {
    template: CompiledTemplate;

    initialize() {
        this.render();
    }

    render() {
        this.$el.html(this.template(this.model.toJSON()));
        return this;
    }

    remove() {
        // this is a trick to prevent an aliasing effect
        return super.remove();
    }
}

extend(ItemView.prototype, {
    tagName: 'li',
    template: template('<%= name %>'),
});

class SpyingCollectionView extends CollectionView<ItemView> {
    preinitialize(options) {
        let parentPrototype = Object.getPrototypeOf(CollectionView.prototype);
        spyOn(parentPrototype, 'remove').and.callThrough();
        let self = this as SpyingCollectionView;
        spyOn(self, 'render').and.callThrough();
        spyOn(self, 'beforeRender').and.callThrough();
        spyOn(self, 'renderContainer').and.callThrough();
        spyOn(self, 'afterRender').and.callThrough();
        spyOn(self, 'makeItem').and.callThrough();
        spyOn(self, 'initItems').and.callThrough();
        spyOn(self, 'insertItem').and.callThrough();
        spyOn(self, 'removeItem').and.callThrough();
        spyOn(self, 'sortItems').and.callThrough();
        spyOn(self, 'placeItems').and.callThrough();
        spyOn(self, 'resetItems').and.callThrough();
        spyOn(self, 'detachItems').and.callThrough();
        spyOn(self, 'clearItems').and.callThrough();
    }

    makeItem(model) {
        let item = super.makeItem(model);
        spyOn(item, 'remove').and.callThrough();
        spyOn(item.$el, 'detach').and.callThrough();
        return item;
    }
}

extend(SpyingCollectionView.prototype, {
    tagName: 'ul',
    subview: ItemView,
});

function expectSameOrder(collection, view, should: boolean = true): void {
    let collectionOrder = map(collection.models, 'id'),
        itemOrder = map(view.items, property(['model', 'id']));
    let expectation = expect(collectionOrder);
    if (!should) expectation = expectation.not;
    expectation.toEqual(itemOrder);
}

function expectSameOrderDOM(view, should: boolean = true): void {
    let itemOrder = map(view.items, property(['model', 'attributes', 'name'])),
        elementOrder = view.$(itemSelector).map(
            (i, e) => e.textContent
        ).get();
    let expectation = expect(itemOrder);
    if (!should) expectation = expectation.not;
    expectation.toEqual(elementOrder);
}

describe('CollectionView', function() {
    beforeEach(function() {
        this.collection = new Collection(mockModels);
        this.spareModel = this.collection.pop();
        this.view = new SpyingCollectionView({collection: this.collection});
    });

    describe('.render()', function() {
        beforeEach(function() {
            this.view.initItems().render();
        });

        it('detaches and reinserts the subviews', function() {
            let {
                beforeRender, detachItems,
                renderContainer, placeItems,
                afterRender,
            } = this.view;
            expect(afterRender).toHaveBeenCalled();
            expect(placeItems).toHaveBeenCalledBefore(afterRender);
            expect(renderContainer).toHaveBeenCalledBefore(placeItems);
            expect(detachItems).toHaveBeenCalledBefore(renderContainer);
            expect(beforeRender).toHaveBeenCalledBefore(detachItems);
        });

        it('correctly preserves DOM element references', function() {
            let firstChild = this.view.items[0],
                firstNameInList = () => this.view.$(itemSelector).get(0),
                nameFromFirst = () => firstChild.el;
            expect(firstNameInList()).toBe(nameFromFirst());
            this.view.render();
            expect(firstNameInList()).toBe(nameFromFirst());
        });

        it('returns this', function() {
            expect(this.view.render()).toBe(this.view);
        });
    });

    describe('.remove()', function() {
        beforeEach(function() {
            this.view.initItems().render().initCollectionEvents();
            this.returnValue = this.view.remove();
        });

        it('clears out all the subviews', function() {
            expect(this.view.clearItems).toHaveBeenCalled();
        });

        it('calls super.remove() after that', function() {
            let parentPrototype = Object.getPrototypeOf(CollectionView.prototype);
            expect(parentPrototype.remove).toHaveBeenCalled();
            expect(
                parentPrototype.remove.calls.mostRecent().object
            ).toBe(this.view);
        });

        it('returns this', function() {
            expect(this.returnValue).toBe(this.view);
        });
    });

    describe('.makeItem()', function() {
        beforeEach(function() {
            this.view.initItems().render();
            this.item = this.view.makeItem(this.spareModel);
        });

        it('returns a new ItemView with the passed model', function() {
            expect(Object.getPrototypeOf(this.item).constructor).toBe(ItemView);
            expect(this.item.model).toBe(this.spareModel);
        });

        it('does not register the created item as a side effect', function() {
            expect(this.collection.includes(this.spareModel)).toBeFalsy();
            expectSameOrder(this.collection, this.view);
            expectSameOrderDOM(this.view);
        });
    });

    describe('.initItems()', function() {
        beforeEach(function() {
            this.view.initItems();
        });

        it('creates a subview for each model in the collection', function() {
            expect(
                this.view.makeItem.calls.count()
            ).toBe(this.collection.length);
            expect(this.view.items).toBeDefined();
            expect(this.view.items.length).toBe(this.collection.length);
            expectSameOrder(this.collection, this.view);
        });

        it('leaves the DOM unchanged', function() {
            this.view.render().clearItems();
            this.view.initItems();
            expect(this.view.$(itemSelector).length).toBe(0);
        });

        it('returns this', function() {
            this.view.clearItems();
            expect(this.view.initItems()).toBe(this.view);
        });
    });

    describe('.clearItems()', function() {
        beforeEach(function() {
            this.view.initItems().render();
            this.returnValue = this.view.clearItems();
        });

        it('preserves the references to the subviews', function() {
            expectSameOrder(this.collection, this.view);
        });

        it('calls .remove on each of the subviews', function() {
            this.view.items.forEach(
                item => expect(item.remove).toHaveBeenCalled()
            );
            expect(this.view.$(itemSelector).length).toBe(0);
        });

        it('returns this', function() {
            expect(this.returnValue).toBe(this.view);
        });
    });

    describe('.insertItem()', function() {
        beforeEach(function() {
            this.view.initItems().render().initCollectionEvents();
        });

        it('creates a subview and registers it', function() {
            let priorCalls = this.view.makeItem.calls.count();
            this.view.insertItem(this.spareModel);
            let posteriorCalls = this.view.makeItem.calls.count();
            expect(posteriorCalls - priorCalls).toBe(1);
            let lastIndex = this.view.items.length - 1;
            expect(this.view.items[lastIndex].model).toBe(this.spareModel);
        });

        it('leaves the DOM unchanged', function() {
            let priorLength = this.view.$(itemSelector).length;
            this.view.insertItem(this.spareModel);
            let posteriorNames = this.view.$(itemSelector);
            expect(posteriorNames.length).toBe(priorLength);
            expect(
                posteriorNames.last().text()
            ).not.toEqual(this.spareModel.get('name'));
        });

        it('executes when a model is added to the collection', function() {
            expect(this.view.insertItem).not.toHaveBeenCalled();
            this.collection.add(this.spareModel, {sort: false});
            expect(this.view.insertItem).toHaveBeenCalled();
            expectSameOrder(this.collection, this.view);
        });

        it('inserts in the same position as in the collection', function() {
            let targetIndex = 2;
            expect(this.collection.length).toBeGreaterThan(targetIndex + 1);
            this.collection.add(this.spareModel, {at: targetIndex, sort: false});
            expect(this.view.insertItem).toHaveBeenCalled();
            expectSameOrder(this.collection, this.view);
        });

        it('works at index=0', function() {
            this.collection.add(this.spareModel, {at: 0, sort: false});
            expect(this.view.insertItem).toHaveBeenCalled();
            expectSameOrder(this.collection, this.view);
        });

        it('returns this', function() {
            expect(this.view.insertItem(this.spareModel)).toBe(this.view);
        });
    });

    describe('.removeItem()', function() {
        beforeEach(function() {
            this.view.initItems().render().initCollectionEvents();
        });

        it('completely removes the subview at the given index', function() {
            let targetIndex = 2;
            expect(this.view.items.length).toBeGreaterThan(targetIndex);
            let removedItem = this.view.items[targetIndex];
            let removedName = removedItem.model.get('name');
            this.view.removeItem(null, null, {index: targetIndex});
            expect(removedItem.remove).toHaveBeenCalled();
            expect(this.view.items).not.toContain(removedItem);
            expect(
                this.view.$(itemSelector).text()
            ).not.toContain(removedName);
            expectSameOrderDOM(this.view);
        });

        function checkRemoveFromCollection(model) {
            let priorLength = this.collection.length;
            this.collection.remove(model);
            expect(priorLength - this.collection.length).toBe(1);
            expect(this.view.removeItem).toHaveBeenCalled();
            expectSameOrder(this.collection, this.view);
            expectSameOrderDOM(this.view);
        }

        it('executes when a model is removed from the collection', function() {
            expect(this.view.removeItem).not.toHaveBeenCalled();
            checkRemoveFromCollection.call(this, this.collection.at(-1));
        });

        it('works at index=0', function() {
            checkRemoveFromCollection.call(this, this.collection.at(0));
        });

        it('works in the middle', function() {
            let index = 2;
            expect(this.collection.length).toBeGreaterThan(index + 1);
            checkRemoveFromCollection.call(this, this.collection.at(index));
        });

        it('returns this', function() {
            expect(
                this.view.removeItem(null, null, {index: 0})
            ).toBe(this.view);
        });
    });

    describe('.sortItems()', function() {
        beforeEach(function() {
            this.view.initItems().render().initCollectionEvents();
        });

        it('syncs the subview order with the collection order', function() {
            reverse(this.view.items);
            expectSameOrder(this.collection, this.view, false);
            this.view.sortItems();
            expectSameOrder(this.collection, this.view);
        });

        it('leaves the DOM unchanged', function() {
            reverse(this.view.items);
            this.view.placeItems();
            let priorCalls = this.view.placeItems.calls.count();
            expectSameOrderDOM(this.view);
            this.view.sortItems();
            expectSameOrderDOM(this.view, false);
            let posteriorCalls = this.view.placeItems.calls.count();
            expect(posteriorCalls).toBe(priorCalls);
        });

        it('executes when the collection is sorted', function() {
            expect(this.view.sortItems).not.toHaveBeenCalled();
            this.collection.trigger('sort');
            expect(this.view.sortItems).toHaveBeenCalled();
        });

        it('returns this', function() {
            expect(this.view.sortItems()).toBe(this.view);
        });
    });

    describe('.detachItems()', function() {
        beforeEach(function() {
            this.view.initItems().render();
            this.returnValue = this.view.detachItems();
        });

        it('leaves the registered subviews in place', function() {
            expectSameOrder(this.collection, this.view);
        });

        it('calls .$el.detach() on each subview', function() {
            this.view.items.forEach(
                item => expect(item.$el.detach).toHaveBeenCalled()
            );
            expect(this.view.$(itemSelector).length).toBe(0);
        });

        it('does not call .remove() on any subview', function() {
            this.view.items.forEach(
                item => expect(item.remove).not.toHaveBeenCalled()
            );
        });

        it('returns this', function() {
            expect(this.returnValue).toBe(this.view);
        });
    });

    describe('.placeItems()', function() {
        beforeEach(function() {
            this.view.initItems().render().initCollectionEvents();
        });

        it('puts the subviews in the DOM', function() {
            this.view.detachItems();
            this.view.placeItems();
            expectSameOrderDOM(this.view);
        });

        it('moves the subviews in the right order', function() {
            reverse(this.view.items);
            this.view.placeItems();
            expectSameOrderDOM(this.view);
        });

        it('executes on collection update events', function() {
            let priorCalls = this.view.placeItems.calls.count();
            this.collection.trigger('update');
            let posteriorCalls = this.view.placeItems.calls.count();
            expect(posteriorCalls - priorCalls).toBe(1);
        });

        it('returns this', function() {
            expect(this.view.placeItems()).toBe(this.view);
        });
    });

    describe('.resetItems()', function() {
        beforeEach(function() {
            this.view.initItems().render().initCollectionEvents();
        });

        it('rebuilds the subviews from scratch', function() {
            let {
                initItems, clearItems, sortItems, detachItems, placeItems,
            } = this.view;
            expect(clearItems).not.toHaveBeenCalled();
            this.view.resetItems();
            expect(sortItems).not.toHaveBeenCalled();
            expect(detachItems).toHaveBeenCalledTimes(1);
            expect(placeItems).toHaveBeenCalledTimes(2);
            expect(initItems).toHaveBeenCalledTimes(2);
            expect(clearItems).toHaveBeenCalled();
            let lastClear = clearItems.calls.mostRecent().invocationOrder,
                lastInit = initItems.calls.mostRecent().invocationOrder,
                lastPlace = placeItems.calls.mostRecent().invocationOrder;
            expect(lastClear).toBeLessThan(lastInit);
            expect(lastInit).toBeLessThan(lastPlace);
            expectSameOrder(this.collection, this.view);
            expectSameOrderDOM(this.view);
        });

        it('executes on collection reset events', function() {
            expect(this.view.resetItems).not.toHaveBeenCalled();
            this.collection.reset();
            expect(this.view.resetItems).toHaveBeenCalled();
            expect(this.view.items).toBeDefined();
            expect(this.view.items.length).toBe(0);
            expect(this.view.$(itemSelector).length).toBe(0);
        });

        it('can efficiently replace all subviews', function() {
            this.collection.reset([this.spareModel]);
            expectSameOrder(this.collection, this.view);
            expectSameOrderDOM(this.view);
        });

        it('returns this', function() {
            expect(this.view.resetItems()).toBe(this.view);
        });
    });
});
