import { suite } from '@beanbag/jasmine-suites';
import {
    beforeEach,
    describe,
    expect,
    expectAsync,
    it,
    spyOn,
} from 'jasmine-core';

import { BaseResource } from 'reviewboard/common';


suite('rb/resources/models/BaseResource', function() {
    let model;
    let parentObject;

    beforeEach(function() {
        model = new BaseResource();
        model.rspNamespace = 'foo';

        parentObject = new BaseResource({
            links: {
                foos: {
                    href: '/api/foos/',
                },
            },
        });
    });

    describe('ensureCreated', function() {
        beforeEach(function() {
            spyOn(model, 'save').and.resolveTo();
            spyOn(model, 'fetch').and.resolveTo();
            spyOn(model, 'ready').and.callThrough();
        });

        it('With loaded=true', async function() {
            model.set('loaded', true);

            await model.ensureCreated();

            expect(model.ready).toHaveBeenCalled();
            expect(model.fetch).not.toHaveBeenCalled();
            expect(model.save).not.toHaveBeenCalled();
        });

        it('With loaded=false, isNew=true', async function() {
            model.set('loaded', false);

            await model.ensureCreated();

            expect(model.ready).toHaveBeenCalled();
            expect(model.fetch).not.toHaveBeenCalled();
            expect(model.save).toHaveBeenCalled();
        });

        it('With loaded=false, isNew=false', async function() {
            model.set({
                id: 1,
                loaded: false,
            });

            await model.ensureCreated();

            expect(model.ready).toHaveBeenCalled();
            expect(model.fetch).toHaveBeenCalled();
            expect(model.save).toHaveBeenCalled();
        });

        it('With callbacks', function(done) {
            model.set({
                id: 1,
                loaded: false,
            });
            spyOn(console, 'warn');

            model.ensureCreated({
                error: () => done.fail(),
                success: () => {
                    expect(model.ready).toHaveBeenCalled();
                    expect(model.fetch).toHaveBeenCalled();
                    expect(model.save).toHaveBeenCalled();
                    expect(console.warn).toHaveBeenCalled();

                    done();
                },
            });
        });
    });

    describe('fetch', function() {
        describe('Basic functionality', function() {
            beforeEach(function() {
                spyOn(Backbone.Model.prototype, 'fetch')
                    .and.callFake(options => {
                        if (options && _.isFunction(options.success)) {
                            options.success();
                        }
                    });
            });

            it('With isNew=true', async function() {
                expect(model.isNew()).toBe(true);

                await expectAsync(model.fetch()).toBeRejectedWith(Error(
                    'fetch cannot be used on a resource without an ID'));
                expect(Backbone.Model.prototype.fetch)
                    .not.toHaveBeenCalled();
            });

            it('With isNew=false and no parentObject', async function() {
                model.set('id', 123);

                await model.fetch();
                expect(Backbone.Model.prototype.fetch).toHaveBeenCalled();
            });

            it('With isNew=false and parentObject', async function() {
                model.set({
                    id: 123,
                    parentObject: parentObject,
                });

                spyOn(parentObject, 'ready').and.resolveTo();

                await model.fetch();

                expect(parentObject.ready).toHaveBeenCalled();
                expect(Backbone.Model.prototype.fetch).toHaveBeenCalled();
            });

            it('With isNew=false and parentObject with error', async function() {
                model.set({
                    id: 123,
                    parentObject: parentObject,
                });

                spyOn(parentObject, 'ready').and.rejectWith(new BackboneError(
                    parentObject,
                    { errorText: 'Oh nosers.' },
                    {}));

                await expectAsync(model.fetch()).toBeRejectedWith(
                    Error('Oh nosers.'));
                expect(parentObject.ready).toHaveBeenCalled();
                expect(Backbone.Model.prototype.fetch)
                    .not.toHaveBeenCalled();
            });

            it('With callbacks', function(done) {
                model.set({
                    id: 123,
                    parentObject: parentObject,
                });

                spyOn(parentObject, 'ready').and.resolveTo();
                spyOn(console, 'warn');

                model.fetch({
                    error: () => done.fail(),
                    success: () => {
                        expect(parentObject.ready).toHaveBeenCalled();
                        expect(Backbone.Model.prototype.fetch)
                            .toHaveBeenCalled();
                        expect(console.warn).toHaveBeenCalled();

                        done();
                    },
                });
            });
        });

        describe('Response handling', function() {
            beforeEach(function() {
                model.set({
                    id: 123,
                    links: {
                        self: {
                            href: '/api/foo/',
                        },
                    },
                });
            });

            it('Custom response parsing', async function() {
                spyOn(model, 'parse').and.callFake(rsp => ({
                    a: rsp.a + 1,
                    b: rsp.b,
                    c: true,
                }));

                spyOn($, 'ajax').and.callFake(request => {
                    request.success({
                        a: 10,
                        b: 20,
                        d: 30,
                    });
                });

                await model.fetch();

                expect(model.get('a')).toBe(11);
                expect(model.get('b')).toBe(20);
                expect(model.get('c')).toBe(true);
                expect(model.get('d')).toBe(undefined);
            });

            it('Default response parsing', async function() {
                spyOn(model, 'parse').and.callThrough();

                spyOn($, 'ajax').and.callFake(request => {
                    request.success({
                        foo: {
                            a: 20,
                            id: 42,
                            links: {
                                foo: {
                                    href: 'bar',
                                },
                            },
                        },
                        stat: 'ok',
                    });
                });

                await model.fetch();

                expect(model.get('a')).toBe(undefined);
                expect(model.id).toBe(42);
                expect(model.get('links').foo).not.toBe(undefined);
                expect(model.get('loaded')).toBe(true);
            });
        });

        describe('Request payload', function() {
            beforeEach(function() {
                model.set({
                    id: 123,
                    links: {
                        self: {
                            href: '/api/foo/',
                        },
                    },
                });
            });

            describe('GET', function() {
                it('No contentType sent', async function() {
                    spyOn(Backbone, 'sync')
                        .and.callFake((method, model, options) => {
                            expect(options.contentType).toBe(undefined);
                            options.success.call(model, {});
                        });

                    await model.fetch();

                    expect(Backbone.sync).toHaveBeenCalled();
                });

                it('No model data sent', async function() {
                    spyOn(Backbone, 'sync')
                        .and.callFake((method, model, options) => {
                            expect(_.isEmpty(options.data)).toBe(true);
                            options.success.call(model, {});
                        });

                    model.toJSON = () => ({
                        a: 1,
                        b: 2,
                    });

                    await model.fetch();

                    expect(Backbone.sync).toHaveBeenCalled();
                });

                it('Query attributes sent', async function() {
                    spyOn(Backbone, 'sync')
                        .and.callFake((method, model, options) => {
                            expect(_.isEmpty(options.data)).toBe(false);
                            expect(options.data.foo).toBe('bar');
                            options.success.call(model, {});
                        });

                    model.toJSON = () => ({
                        a: 1,
                        b: 2,
                    });

                    await model.fetch({
                        data: {
                            foo: 'bar',
                        },
                    });

                    expect(Backbone.sync).toHaveBeenCalled();
                });
            });
        });
    });

    describe('ready', function() {
        beforeEach(function() {
            spyOn(model, 'fetch').and.resolveTo();
        });

        it('With loaded=true', async function() {
            model.set('loaded', true);

            await model.ready();

            expect(model.fetch).not.toHaveBeenCalled();
        });

        it('With loaded=false and isNew=true', async function() {
            model.set('loaded', false);
            expect(model.isNew()).toBe(true);

            await model.ready();
            expect(model.fetch).not.toHaveBeenCalled();
        });

        it('With loaded=false and isNew=false', async function() {
            model.set({
                id: 123,
                loaded: false,
            });
            expect(model.isNew()).toBe(false);

            await model.ready();
            expect(model.fetch).toHaveBeenCalled();
        });

        it('With callbacks', function(done) {
            model.set({
                id: 123,
                loaded: false,
            });
            expect(model.isNew()).toBe(false);
            spyOn(console, 'warn');

            model.ready({
                error: () => done.fail(),
                success: () => {
                    expect(model.fetch).toHaveBeenCalled();
                    expect(console.warn).toHaveBeenCalled();

                    done();
                },
            });
        });
    });

    describe('save', function() {
        beforeEach(function() {
            /* This is needed for any ready() calls. */
            spyOn(Backbone.Model.prototype, 'fetch')
                .and.callFake(options => {
                    if (options && _.isFunction(options.success)) {
                        options.success();
                    }
                });
            spyOn(model, 'trigger');
        });

        it('With isNew=true and parentObject', async function() {
            const responseData = {
                foo: {},
                stat: 'ok',
            };

            spyOn(parentObject, 'ensureCreated').and.resolveTo();
            spyOn(parentObject, 'ready').and.resolveTo();

            spyOn(Backbone.Model.prototype, 'save').and.callThrough();

            model.set('parentObject', parentObject);

            spyOn(RB, 'apiCall').and.callThrough();
            spyOn($, 'ajax').and.callFake(request => {
                expect(request.type).toBe('POST');

                request.success(responseData);
            });

            await model.save();

            expect(Backbone.Model.prototype.save).toHaveBeenCalled();
            expect(parentObject.ensureCreated).toHaveBeenCalled();
            expect(RB.apiCall).toHaveBeenCalled();
            expect($.ajax).toHaveBeenCalled();

            expect(model.trigger).toHaveBeenCalledWith('saved', {});
        });

        it('With isNew=true and no parentObject', async function() {
            spyOn(Backbone.Model.prototype, 'save').and.callThrough();
            spyOn(RB, 'apiCall').and.callThrough();
            spyOn($, 'ajax');

            try {
                await model.save();
                throw new Error();
            } catch (err) {
                expect(Backbone.Model.prototype.save)
                    .not.toHaveBeenCalled();
                expect(RB.apiCall).not.toHaveBeenCalled();
                expect($.ajax).not.toHaveBeenCalled();

                expect(err.message).toBe(
                    'The object must either be loaded from the server ' +
                    'or have a parent object before it can be saved');

                expect(model.trigger).not.toHaveBeenCalledWith('saved', {});
            }
        });

        it('With isNew=false and no parentObject', async function() {
            model.set('id', 123);
            model.url = '/api/foos/1/';

            spyOn(Backbone.Model.prototype, 'save')
                .and.callFake((attrs, options) => {
                    if (options && _.isFunction(options.success)) {
                        options.success();
                    }
                });

            await model.save();
            expect(Backbone.Model.prototype.save).toHaveBeenCalled();
            expect(model.trigger).toHaveBeenCalledWith('saved', {});
        });

        it('With isNew=false and parentObject', async function() {
            spyOn(parentObject, 'ensureCreated').and.resolveTo();
            spyOn(Backbone.Model.prototype, 'save').and.callThrough();

            model.set({
                id: 123,
                parentObject: parentObject,
            });

            spyOn(parentObject, 'ready').and.resolveTo();

            spyOn(RB, 'apiCall').and.callThrough();
            spyOn($, 'ajax').and.callFake(request => {
                expect(request.type).toBe('PUT');

                request.success({
                    foo: {},
                    stat: 'ok',
                });
            });

            await model.save();

            expect(parentObject.ready).toHaveBeenCalled();
            expect(Backbone.Model.prototype.save).toHaveBeenCalled();
            expect(RB.apiCall).toHaveBeenCalled();
            expect($.ajax).toHaveBeenCalled();
            expect(model.trigger).toHaveBeenCalledWith('saved', {});
        });

        it('With isNew=false and parentObject with error', async function() {
            model.set({
                id: 123,
                parentObject: parentObject,
            });

            spyOn(parentObject, 'ready').and.rejectWith(new BackboneError(
                parentObject,
                { errorText: 'Oh nosers.' },
                {}));

            spyOn(Backbone.Model.prototype, 'save')
                .and.callFake((attrs, options) => {
                    if (options && _.isFunction(options.success)) {
                        options.success();
                    }
                });

            await expectAsync(model.save()).toBeRejectedWith(
                Error('Oh nosers.'));

            expect(parentObject.ready).toHaveBeenCalled();
            expect(Backbone.Model.prototype.save)
                .not.toHaveBeenCalled();
            expect(model.trigger).not.toHaveBeenCalledWith('saved');
        });

        it('With callbacks', function(done) {
            const responseData = {
                foo: {},
                stat: 'ok',
            };

            spyOn(parentObject, 'ensureCreated').and.resolveTo();
            spyOn(parentObject, 'ready').and.resolveTo();

            spyOn(Backbone.Model.prototype, 'save').and.callThrough();

            model.set('parentObject', parentObject);

            spyOn(RB, 'apiCall').and.callThrough();
            spyOn($, 'ajax').and.callFake(request => {
                expect(request.type).toBe('POST');

                request.success(responseData);
            });
            spyOn(console, 'warn');

            model.save({
                error: () => done.fail(),
                success: () => {
                    expect(Backbone.Model.prototype.save).toHaveBeenCalled();
                    expect(parentObject.ensureCreated).toHaveBeenCalled();
                    expect(RB.apiCall).toHaveBeenCalled();
                    expect($.ajax).toHaveBeenCalled();

                    expect(model.trigger).toHaveBeenCalledWith('saved', {});
                    expect(console.warn).toHaveBeenCalled();

                    done();
                },
            });
        });

        describe('Request payload', function() {
            it('Saved data', async function() {
                model.set('id', 1);
                model.url = '/api/foos/';

                expect(model.isNew()).toBe(false);

                spyOn(model, 'toJSON').and.callFake(() => ({
                    a: 10,
                    b: 20,
                    c: 30,
                }));

                spyOn(model, 'ready').and.resolveTo();

                spyOn(RB, 'apiCall').and.callThrough();
                spyOn($, 'ajax').and.callFake(request => {
                    expect(request.url).toBe(model.url);
                    expect(request.contentType)
                        .toBe('application/x-www-form-urlencoded');
                    expect(request.processData).toBe(true);

                    expect(request.data.a).toBe(10);
                    expect(request.data.b).toBe(20);
                    expect(request.data.c).toBe(30);

                    request.success({
                        foo: {
                            a: 10,
                            b: 20,
                            c: 30,
                            id: 1,
                            links: {},
                        },
                        stat: 'ok',
                    });
                });

                await model.save();

                expect(model.toJSON).toHaveBeenCalled();
                expect(RB.apiCall).toHaveBeenCalled();
                expect($.ajax).toHaveBeenCalled();
            });
        });

        describe('With file upload support', function() {
            beforeEach(function() {
                model.payloadFileKeys = ['file'];
                model.url = '/api/foos/';

                model.toJSON = function() {
                    return {
                        file: this.get('file'),
                        myfield: 'myvalue',
                    };
                };

                spyOn(Backbone.Model.prototype, 'save').and.callThrough();
                spyOn(RB, 'apiCall').and.callThrough();
            });

            it('With file', async function() {
                const boundary = '-----multipartformboundary';
                const blob = new File(['Hello world!'], 'myfile', {
                    type: 'text/plain',
                });

                spyOn($, 'ajax').and.callFake(request => {
                    const fileReader = new FileReader();

                    expect(request.type).toBe('POST');
                    expect(request.processData).toBe(false);
                    expect(request.contentType.indexOf(
                        'multipart/form-data; boundary=')).toBe(0);

                    fileReader.onload = function() {
                        const array = new Uint8Array(this.result);
                        const data = [];

                        for (let i = 0; i < array.length; i++) {
                            data.push(String.fromCharCode(array[i]));
                        }

                        expect(data.join('')).toBe(
                            '--' + boundary + '\r\n' +
                            'Content-Disposition: form-data; name="file"' +
                            '; filename="myfile"\r\n' +
                            'Content-Type: text/plain\r\n\r\n' +
                            'Hello world!' +
                            '\r\n' +
                            '--' + boundary + '\r\n' +
                            'Content-Disposition: form-data; ' +
                            'name="myfield"\r\n\r\n' +
                            'myvalue\r\n' +
                            '--' + boundary + '--\r\n\r\n');

                        request.success({
                            foo: {
                                id: 42,
                            },
                            stat: 'ok',
                        });
                    };

                    fileReader.readAsArrayBuffer(request.data);
                });

                model.set('file', blob);
                await model.save({ boundary });

                expect(Backbone.Model.prototype.save).toHaveBeenCalled();
                expect(RB.apiCall).toHaveBeenCalled();
                expect($.ajax).toHaveBeenCalled();
            });

            it('With multiple files', async function() {
                const boundary = '-----multipartformboundary';

                const blob1 = new File(['Hello world!'], 'myfile1', {
                    type: 'text/plain',
                });

                const blob2 = new File(['Goodbye world!'], 'myfile2', {
                    type: 'text/plain',
                });

                model.payloadFileKeys = ['file1', 'file2'];

                model.toJSON = function() {
                    return {
                        file1: this.get('file1'),
                        file2: this.get('file2'),
                        myfield: 'myvalue',
                    };
                };

                spyOn($, 'ajax').and.callFake(request => {
                    const fileReader = new FileReader();

                    expect(request.type).toBe('POST');
                    expect(request.processData).toBe(false);
                    expect(request.contentType.indexOf(
                        'multipart/form-data; boundary=')).toBe(0);

                    fileReader.onload = function() {
                        const array = new Uint8Array(this.result);
                        const data = [];

                        for (let i = 0; i < array.length; i++) {
                            data.push(String.fromCharCode(array[i]));
                        }

                        expect(data.join('')).toBe(
                            '--' + boundary + '\r\n' +
                            'Content-Disposition: form-data; name="file1"' +
                            '; filename="myfile1"\r\n' +
                            'Content-Type: text/plain\r\n\r\n' +
                            'Hello world!' +
                            '\r\n' +
                            '--' + boundary + '\r\n' +
                            'Content-Disposition: form-data; name="file2"' +
                            '; filename="myfile2"\r\n' +
                            'Content-Type: text/plain\r\n\r\n' +
                            'Goodbye world!' +
                            '\r\n' +
                            '--' + boundary + '\r\n' +
                            'Content-Disposition: form-data; ' +
                            'name="myfield"\r\n\r\n' +
                            'myvalue\r\n' +
                            '--' + boundary + '--\r\n\r\n');

                        request.success({
                            foo: {
                                id: 42,
                            },
                            stat: 'ok',
                        });
                    };

                    fileReader.readAsArrayBuffer(request.data);
                });

                model.set('file1', blob1);
                model.set('file2', blob2);
                await model.save({ boundary });

                expect(Backbone.Model.prototype.save).toHaveBeenCalled();
                expect(RB.apiCall).toHaveBeenCalled();
                expect($.ajax).toHaveBeenCalled();
            });

            it('Without file', async function() {
                spyOn($, 'ajax').and.callFake(request => {
                    expect(request.type).toBe('POST');
                    expect(request.processData).toBe(true);
                    expect(request.contentType).toBe(
                        'application/x-www-form-urlencoded');

                    request.success({
                        foo: {
                            id: 42,
                        },
                        stat: 'ok',
                    });
                });

                await model.save();

                expect(Backbone.Model.prototype.save).toHaveBeenCalled();
                expect(RB.apiCall).toHaveBeenCalled();
                expect($.ajax).toHaveBeenCalled();
            });
        });

        describe('With form upload support', function() {
            beforeEach(function() {
                model.url = '/api/foos/';
            });

            it('Overriding toJSON attributes', async function() {
                const form = $('<form>')
                    .append($('<input name="foo">'));

                model.toJSON = () => ({
                    myfield: 'myvalue',
                });

                spyOn(Backbone, 'sync').and.callThrough();
                spyOn(RB, 'apiCall').and.callThrough();
                spyOn($, 'ajax');
                spyOn(form, 'ajaxSubmit').and.callFake(
                    request => request.success({}));

                await model.save({ form: form });

                expect(RB.apiCall).toHaveBeenCalled();
                expect(form.ajaxSubmit).toHaveBeenCalled();
                expect($.ajax).not.toHaveBeenCalled();
                expect(Backbone.sync.calls.argsFor(0)[2].data).toBe(null);
                expect(RB.apiCall.calls.argsFor(0)[0].data).toBe(null);
            });

            it('Overriding file attributes', async function() {
                const form = $('<form>')
                    .append($('<input name="foo">'));

                model.payloadFileKey = 'file';

                model.toJSON = function() {
                    return {
                        file: this.get('file'),
                    };
                };

                spyOn(model, '_saveWithFiles').and.callThrough();
                spyOn(Backbone, 'sync').and.callThrough();
                spyOn(RB, 'apiCall').and.callThrough();
                spyOn($, 'ajax');
                spyOn(form, 'ajaxSubmit').and.callFake(
                    request => request.success({}));

                await model.save({ form: form });

                expect(model._saveWithFiles).not.toHaveBeenCalled();
                expect(RB.apiCall).toHaveBeenCalled();
                expect(form.ajaxSubmit).toHaveBeenCalled();
                expect($.ajax).not.toHaveBeenCalled();
                expect(Backbone.sync.calls.argsFor(0)[2].data).toBe(null);
                expect(RB.apiCall.calls.argsFor(0)[0].data).toBe(null);
            });
        });
    });

    describe('url', function() {
        it('With self link', function() {
            const url = '/api/base-resource/';

            model.set('links', {
                self: {
                    href: url,
                },
            });

            expect(model.url()).toBe(url);
        });

        it('With parentObject and model ID', function() {
            model.set({
                id: 123,
                parentObject: parentObject,
            });

            expect(model.url()).toBe('/api/foos/123/');
        });

        it('With parentObject, no links', function() {
            model.set('parentObject', parentObject);

            expect(model.url()).toBe('/api/foos/');
        });

        it('With no parentObject, no links', function() {
            expect(model.url()).toBe(null);
        });
    });
});
