import { extend, times, uniq, result, map } from 'underscore';
import { reverse } from './underscore-compat';
import { View, Model } from 'backbone';
import { Spy } from 'jasmine-core';

import CompositeView, {
    SubView,
    SubViewDescription,
    _removeSubview,
    _detachSubview,
    _placeSubview,
} from './composite-view';

class DerivedModel extends Model {
    different: boolean = true;
}

class Sub<M extends Model> extends View<M> {
    text: string;
    constructor(text: string) {
        super();
        let self = this as Sub<M>;
        spyOn(self, 'remove').and.callThrough();
        spyOn(self.$el, 'detach').and.callThrough();
        this.text = text;
        this.render();
    }
    render() {
        this.$el.text(this.text);
        return this;
    }
    remove() {
        // this is a trick to prevent an aliasing effect
        return super.remove();
    }
}

extend(Sub.prototype, {
    tagName: 'p',
});

const subviewSelector = Sub.prototype.tagName;

const template = `
a<div id=sub1>b
    <div id=sub4></div>
    <div id=sub5>
        <div id=sub7></div>
        <div id=sub8></div>
    </div>
</div>
1<div id=sub2>2</div>3
<div id=sub3>
    <div id=sub6></div>
c</div>d
`;

class SpyingCompositeView extends CompositeView {
    subview1: Sub<DerivedModel>;
    subview2: Sub<DerivedModel>;
    subview3: Sub<DerivedModel>;
    subview4: Sub<Model>;
    subview5: Sub<Model>;
    subview6: Sub<Model>;
    subview7: Sub<Model>;
    subview8: Sub<Model>;
    items: Sub<Model>[];
    _subviews: SubView[];

    preinitialize(options) {
        let parentPrototype = Object.getPrototypeOf(CompositeView.prototype);
        spyOn(parentPrototype, 'remove').and.callThrough();
        let self = this as SpyingCompositeView;
        spyOn(self, 'render').and.callThrough();
        spyOn(self, 'beforeRender').and.callThrough();
        spyOn(self, 'renderContainer').and.callThrough();
        spyOn(self, 'afterRender').and.callThrough();
        spyOn(self, 'placeSubviews').and.callThrough();
        spyOn(self, 'detachSubviews').and.callThrough();
        spyOn(self, 'clearSubviews').and.callThrough();
    }

    initialize(options) {
        this.subview1 = new Sub<DerivedModel>('one');
        this.subview2 = new Sub<DerivedModel>('two');
        this.subview3 = new Sub<DerivedModel>('three');
        this.subview4 = new Sub<Model>('four');
        this.subview5 = new Sub<Model>('five');
        this.subview6 = new Sub<Model>('six');
        this.subview7 = new Sub<Model>('seven');
        this.subview8 = new Sub<Model>('eight');
        this.items = times(8, i => this[`subview${i + 1}`]);
        this._subviews = [
            'subview1',
            this.subview2,
            this.getSubview3,
            {
                view: 'subview4'
            },
            this.describeSubview5,
            {
                view: this.subview6,
                selector: '#sub6',
                method: 'prepend',
                place: 'placeSubview6',
            },
            {
                view: this.getSubview7,
                selector: this.getSelector7,
                method: this.getMethod7,
                place: true,
            },
            {
                view: 'subview8',
                place: this.placeSubview8,
            },
        ] as SubView[];
    }

    subviews(): SubView[] {
        return this._subviews;
    }

    renderContainer(): this {
        this.$el.html(template);
        return this;
    }

    getSubview3() { return this.subview3; }
    describeSubview5() {
        return {
            view: 'subview5',
            selector: '#sub5',
            method: this.getMethod5,
            place: this.placeSubview5,
        };
    }
    getMethod5() { return 'after'; }
    placeSubview5() { return true; }
    placeSubview6() { return true; }
    getSubview7() { return this.subview7; }
    getSelector7() { return '#sub7'; }
    getMethod7() { return 'before'; }
    placeSubview8() { return true; }
}

const whitespace = /\s+/g;

function expectElementText<M extends Model>(view: View<M>, text: string) {
    expect(view.$el.text().replace(whitespace, ' ')).toBe(text);
}

describe('CompositeView', function() {
    let view: SpyingCompositeView;
    let baseInstance: CompositeView;

    function disableSubviews() {
        delete view.subview1;
        view.placeSubview5 = view.placeSubview6 = () => false;
        (view._subviews[7] as SubViewDescription).place = false;
    }

    beforeEach(function() {
        view = new SpyingCompositeView();
        baseInstance = new CompositeView();
    });

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

        it('detaches and reinserts the subviews', function() {
            let {
                beforeRender, detachSubviews,
                renderContainer, placeSubviews,
                afterRender,
            } = view as { [T in keyof SpyingCompositeView]: Spy };
            expect(afterRender).toHaveBeenCalled();
            expect(placeSubviews).toHaveBeenCalledBefore(afterRender);
            expect(renderContainer).toHaveBeenCalledBefore(placeSubviews);
            expect(detachSubviews).toHaveBeenCalledBefore(renderContainer);
            expect(beforeRender).toHaveBeenCalledBefore(detachSubviews);
        });

        it('correctly preserves DOM element references', function() {
            let subElements = view.$(subviewSelector).get();
            expect(subElements.length).toBe(view.items.length);
            expect(uniq(subElements).length).toBe(subElements.length);
            view.items.forEach(
                subview => expect(subElements).toContain(subview.el)
            );
            view.render();
            expect(view.$(subviewSelector).get()).toEqual(subElements);
        });

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

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

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

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

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

    describe('.subviews', function() {
        it('by default is just an empty array', function() {
            expect(result(baseInstance, 'subviews')).toEqual([]);
        });
    });

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

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

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

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

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

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

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

    describe('.placeSubviews()', function() {
        beforeEach(function() {
            view.renderContainer();
        });

        it('puts the subviews in the DOM', function() {
            view.placeSubviews();
            expectElementText(
                view,
                ' ab seven five 123 six cd onetwothreefoureight',
            );
        });

        it('respects the .defaultPlacement', function() {
            view.defaultPlacement = 'prepend';
            view.placeSubviews();
            expectElementText(
                view,
                'eightfourthreetwoone ab seven five 123 six cd ',
            );
        });

        it('moves the subviews in the right order', function() {
            view.placeSubviews();
            reverse(result(view, 'subviews'));
            view.placeSubviews();
            expectElementText(
                view,
                ' ab seven five 123 six cd eightfourthreetwoone',
            );
        });

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

    describe('.forEachSubview()', function() {
        let words: string[];
        let spy: Spy;
        let root: JQuery<Element>;

        function appendText<V extends View>(subview: V): void {
            words.push(subview.$el.text());
        }

        function expectAppendedText(options, text: string): void {
            view.forEachSubview(appendText, options);
            expect(words.join('')).toBe(text);
        }

        beforeEach(function() {
            words = [];
            spy = jasmine.createSpy();
            root = view.$el;
        });

        it('lets you iterate over the subviews', function() {
            expectAppendedText(undefined, 'onetwothreefourfivesixseveneight');
        });

        it('passes view, element and method to the iteratee', function() {
            view.forEachSubview(spy);
            expect(map(spy.calls.all(), 'args')).toEqual([
                [view.subview1, root, 'append'],
                [view.subview2, root, 'append'],
                [view.subview3, root, 'append'],
                [view.subview4, root, 'append'],
                [view.subview5, view.$('#sub5').first(), 'after'],
                [view.subview6, view.$('#sub6').first(), 'prepend'],
                [view.subview7, view.$('#sub7').first(), 'before'],
                [view.subview8, root, 'append'],
            ]);
        });

        it('binds this to the view (if not already bound)', function() {
            view.forEachSubview(spy);
            expect(map(spy.calls.all(), 'object')).toEqual([
                view, view, view, view, view, view, view, view,
            ]);
        });

        it('can run in reverse', function() {
            expectAppendedText(
                { reverse: true },
                'eightsevensixfivefourthreetwoone',
            );
        });

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

        describe('with disabled subviews', function() {
            beforeEach(disableSubviews);

            it('will not include subviews that stopped existing', function() {
                expectAppendedText(undefined, 'twothreefourfivesixseveneight');
            });

            it('can skip subviews that fail the placement check', function() {
                expectAppendedText({ placeOnly: true }, 'twothreefourseven');
            });

            it('can run in reverse as well', function() {
                expectAppendedText({
                    placeOnly: true,
                    reverse: true,
                }, 'sevenfourthreetwo');
            });
        });
    });

    describe('._getSubviews()', function() {
        let root: JQuery<Element>;

        beforeEach(function() {
            root = view.$el;
        });

        it('returns a standardized list of subviews', function(){
            expect(view._getSubviews(true)).toEqual([
                [view.subview1, root, 'append'],
                [view.subview2, root, 'append'],
                [view.subview3, root, 'append'],
                [view.subview4, root, 'append'],
                [view.subview5, view.$('#sub5').first(), 'after'],
                [view.subview6, view.$('#sub6').first(), 'prepend'],
                [view.subview7, view.$('#sub7').first(), 'before'],
                [view.subview8, root, 'append'],
            ]);
        });

        describe('with disabled subviews', function() {
            beforeEach(disableSubviews);

            it('returns only the ones that currently exist', function() {
                expect(view._getSubviews(false)).toEqual([
                    [view.subview2, root, 'append'],
                    [view.subview3, root, 'append'],
                    [view.subview4, root, 'append'],
                    [view.subview5, view.$('#sub5').first(), 'after'],
                    [view.subview6, view.$('#sub6').first(), 'prepend'],
                    [view.subview7, view.$('#sub7').first(), 'before'],
                    [view.subview8, root, 'append'],
                ]);
            });

            it('can also omit ones that fail the placement check', function() {
                expect(view._getSubviews(true)).toEqual([
                    [view.subview2, root, 'append'],
                    [view.subview3, root, 'append'],
                    [view.subview4, root, 'append'],
                    [view.subview7, view.$('#sub7').first(), 'before'],
                ]);
            });
        });
    });

    describe('._normalizeSubview()', function() {
        let root: JQuery<Element>;

        beforeEach(function() {
            this.normalize = view._normalizeSubview.bind(view, true);
            view._resetContainer();
            root = view.$el;
        });

        it('coerces various ways to specify subviews to one format', function(){
            expect(map(result(view, 'subviews'), this.normalize)).toEqual([
                [view.subview1, root, 'append'],
                [view.subview2, root, 'append'],
                [view.subview3, root, 'append'],
                [view.subview4, root, 'append'],
                [view.subview5, view.$('#sub5').first(), 'after'],
                [view.subview6, view.$('#sub6').first(), 'prepend'],
                [view.subview7, view.$('#sub7').first(), 'before'],
                [view.subview8, root, 'append'],
            ]);
        });

        it('respects the .defaultPlacement', function(){
            view.defaultPlacement = 'prepend';
            expect(map(result(view, 'subviews'), this.normalize)).toEqual([
                [view.subview1, root, 'prepend'],
                [view.subview2, root, 'prepend'],
                [view.subview3, root, 'prepend'],
                [view.subview4, root, 'prepend'],
                [view.subview5, view.$('#sub5').first(), 'after'],
                [view.subview6, view.$('#sub6').first(), 'prepend'],
                [view.subview7, view.$('#sub7').first(), 'before'],
                [view.subview8, root, 'prepend'],
            ]);
        });

        it('allows only safe insertion methods without a selector', function() {
            let risky = method => () => this.normalize({
                view: 'subview1',
                method,
            });
            ['append', 'prepend'].forEach(method => {
                expect(risky(method)).not.toThrow();
            });
            ['after', 'before', 'replaceWith'].forEach(method => {
                expect(risky(method)).toThrowError(TypeError);
            });
        });

        describe('for disabled subviews', function() {
            beforeEach(disableSubviews);

            it('returns null if the subview does not exist', function() {
                this.normalize = view._normalizeSubview.bind(view, false);
                expect(map(result(view, 'subviews'), this.normalize)).toEqual([
                    null,
                    [view.subview2, root, 'append'],
                    [view.subview3, root, 'append'],
                    [view.subview4, root, 'append'],
                    [view.subview5, view.$('#sub5').first(), 'after'],
                    [view.subview6, view.$('#sub6').first(), 'prepend'],
                    [view.subview7, view.$('#sub7').first(), 'before'],
                    [view.subview8, root, 'append'],
                ]);
            });

            it('can also return null for no-place subviews', function() {
                expect(map(result(view, 'subviews'), this.normalize)).toEqual([
                    null,
                    [view.subview2, root, 'append'],
                    [view.subview3, root, 'append'],
                    [view.subview4, root, 'append'],
                    null,
                    null,
                    [view.subview7, view.$('#sub7').first(), 'before'],
                    null,
                ]);
            });
        });
    });

    describe('._resolve()', function() {
        it('returns the value of a property when given the name', function() {
            expect(view._resolve('subview1')).toBe(view.subview1);
        });

        it('returns the result of a method when given the name', function() {
            expect(view._resolve('getMethod5')).toBe('after');
        });

        it('returns the same value when given a plain value', function() {
            expect(view._resolve(view.subview1)).toBe(view.subview1);
        });

        it('returns the result when given a function', function() {
            expect(view._resolve(() => 'bla')).toBe('bla');
        });

        it('binds this to the view when invoking a function', function() {
            function tester() { return this.subview1; }
            expect(view._resolve(tester)).toBe(view.subview1);
        });
    });

    describe('utility functions', function() {
        beforeEach(function() {
            this.sub = view.subview1;
        });

        describe('_removeSubview', function() {
            it('takes a View and calls .remove on it', function() {
                _removeSubview(this.sub);
                expect(this.sub.remove).toHaveBeenCalled();
            });
        });

        describe('_detachSubview', function() {
            it('takes a View and calls .$el.detach on it', function() {
                _detachSubview(this.sub);
                expect(this.sub.$el.detach).toHaveBeenCalled();
            });
        });

        describe('_placeSubview()', function() {
            let sub2: JQuery<Element>;

            beforeEach(function() {
                view._resetContainer();
                sub2 = view.$('#sub2').first();
            });

            it('applies the method relative to the container', function(){
                _placeSubview.call(view, this.sub, sub2, 'append');
                expectElementText(view, ' ab 12one3 cd ');
                expect(sub2.children().get()).toEqual([this.sub.el]);

                _placeSubview.call(view, this.sub, sub2, 'prepend');
                expectElementText(view, ' ab 1one23 cd ');
                expect(sub2.children().get()).toEqual([this.sub.el]);

                _placeSubview.call(view, this.sub, sub2, 'after');
                expectElementText(view, ' ab 12one3 cd ');
                expect(sub2.children().get()).toEqual([]);

                _placeSubview.call(view, this.sub, sub2, 'before');
                expectElementText(view, ' ab 1one23 cd ');
                expect(sub2.children().get()).toEqual([]);

                _placeSubview.call(view, this.sub, sub2, 'replaceWith');
                expectElementText(view, ' ab 1one3 cd ');
                expect(view.$('#sub2').get()).toEqual([]);
            });
        });
    });
});
