var _              = require('lodash'),
    Promise        = require('bluebird'),
    errors         = require('../errors'),
    utils          = require('../utils'),
    gravatar       = require('../utils/gravatar'),
    bcrypt         = require('bcryptjs'),
    ghostBookshelf = require('./base'),
    crypto         = require('crypto'),
    validator      = require('validator'),
    validation     = require('../data/validation'),
    events         = require('../events'),
    i18n           = require('../i18n'),

    bcryptGenSalt  = Promise.promisify(bcrypt.genSalt),
    bcryptHash     = Promise.promisify(bcrypt.hash),
    bcryptCompare  = Promise.promisify(bcrypt.compare),

    tokenSecurity  = {},
    activeStates   = ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4', 'locked'],
    invitedStates  = ['invited', 'invited-pending'],
    User,
    Users;

function validatePasswordLength(password) {
    return validator.isLength(password, 8);
}

function generatePasswordHash(password) {
    // Generate a new salt
    return bcryptGenSalt().then(function (salt) {
        // Hash the provided password with bcrypt
        return bcryptHash(password, salt);
    });
}

User = ghostBookshelf.Model.extend({

    tableName: 'users',

    emitChange: function emitChange(event) {
        events.emit('user' + '.' + event, this);
    },

    initialize: function initialize() {
        ghostBookshelf.Model.prototype.initialize.apply(this, arguments);

        this.on('created', function onCreated(model) {
            model.emitChange('added');

            // active is the default state, so if status isn't provided, this will be an active user
            if (!model.get('status') || _.includes(activeStates, model.get('status'))) {
                model.emitChange('activated');
            }
        });
        this.on('updated', function onUpdated(model) {
            model.statusChanging = model.get('status') !== model.updated('status');
            model.isActive = _.includes(activeStates, model.get('status'));

            if (model.statusChanging) {
                model.emitChange(model.isActive ? 'activated' : 'deactivated');
            } else {
                if (model.isActive) {
                    model.emitChange('activated.edited');
                }
            }

            model.emitChange('edited');
        });
        this.on('destroyed', function onDestroyed(model) {
            if (_.includes(activeStates, model.previous('status'))) {
                model.emitChange('deactivated');
            }

            model.emitChange('deleted');
        });
    },

    saving: function saving(newPage, attr, options) {
        /*jshint unused:false*/

        var self = this;

        ghostBookshelf.Model.prototype.saving.apply(this, arguments);

        if (this.hasChanged('slug') || !this.get('slug')) {
            // Generating a slug requires a db call to look for conflicting slugs
            return ghostBookshelf.Model.generateSlug(User, this.get('slug') || this.get('name'),
                {status: 'all', transacting: options.transacting, shortSlug: !this.get('slug')})
                .then(function then(slug) {
                    self.set({slug: slug});
                });
        }
    },

    // For the user model ONLY it is possible to disable validations.
    // This is used to bypass validation during the credential check, and must never be done with user-provided data
    // Should be removed when #3691 is done
    validate: function validate() {
        var opts = arguments[1],
            userData;

        if (opts && _.has(opts, 'validate') && opts.validate === false) {
            return;
        }

        // use the base toJSON since this model's overridden toJSON
        // removes fields and we want everything to run through the validator.
        userData = ghostBookshelf.Model.prototype.toJSON.call(this);

        return validation.validateSchema(this.tableName, userData);
    },

    // Get the user from the options object
    contextUser: function contextUser(options) {
        // Default to context user
        if (options.context && options.context.user) {
            return options.context.user;
            // Other wise use the internal override
        } else if (options.context && options.context.internal) {
            return 1;
            // This is the user object, so try using this user's id
        } else if (this.get('id')) {
            return this.get('id');
        } else {
            errors.logAndThrowError(new errors.NotFoundError(i18n.t('errors.models.user.missingContext')));
        }
    },

    toJSON: function toJSON(options) {
        options = options || {};

        var attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options);
        // remove password hash for security reasons
        delete attrs.password;

        if (!options || !options.context || (!options.context.user && !options.context.internal)) {
            delete attrs.email;
        }

        return attrs;
    },

    format: function format(options) {
        if (!_.isEmpty(options.website) &&
            !validator.isURL(options.website, {
            require_protocol: true,
            protocols: ['http', 'https']})) {
            options.website = 'http://' + options.website;
        }
        return ghostBookshelf.Model.prototype.format.call(this, options);
    },

    posts: function posts() {
        return this.hasMany('Posts', 'created_by');
    },

    roles: function roles() {
        return this.belongsToMany('Role');
    },

    permissions: function permissions() {
        return this.belongsToMany('Permission');
    },

    hasRole: function hasRole(roleName) {
        var roles = this.related('roles');

        return roles.some(function getRole(role) {
            return role.get('name') === roleName;
        });
    },

    enforcedFilters: function enforcedFilters() {
        if (this.isInternalContext()) {
            return null;
        }

        return this.isPublicContext() ? 'status:[' + activeStates.join(',') + ']' : null;
    },

    defaultFilters: function defaultFilters() {
        return this.isPublicContext() ? null : 'status:[' + activeStates.join(',') + ']';
    }
}, {
    orderDefaultOptions: function orderDefaultOptions() {
        return {
            last_login: 'DESC',
            name: 'ASC',
            created_at: 'DESC'
        };
    },

    /**
     * @deprecated in favour of filter
     */
    processOptions: function processOptions(options) {
        if (!options.status) {
            return options;
        }

        // This is the only place that 'options.where' is set now
        options.where = {statements: []};

        var allStates = activeStates.concat(invitedStates),
            value;

        // Filter on the status.  A status of 'all' translates to no filter since we want all statuses
        if (options.status !== 'all') {
            // make sure that status is valid
            options.status = allStates.indexOf(options.status) > -1 ? options.status : 'active';
        }

        if (options.status === 'active') {
            value = activeStates;
        } else if (options.status === 'invited') {
            value = invitedStates;
        } else if (options.status === 'all') {
            value = allStates;
        } else {
            value = options.status;
        }

        options.where.statements.push({prop: 'status', op: 'IN', value: value});
        delete options.status;

        return options;
    },

    /**
    * Returns an array of keys permitted in a method's `options` hash, depending on the current method.
    * @param {String} methodName The name of the method to check valid options for.
    * @return {Array} Keys allowed in the `options` hash of the model's method.
    */
    permittedOptions: function permittedOptions(methodName) {
        var options = ghostBookshelf.Model.permittedOptions(),

            // whitelists for the `options` hash argument on methods, by method name.
            // these are the only options that can be passed to Bookshelf / Knex.
            validOptions = {
                findOne: ['withRelated', 'status'],
                setup: ['id'],
                edit: ['withRelated', 'id'],
                findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status'],
                findAll: ['filter']
            };

        if (validOptions[methodName]) {
            options = options.concat(validOptions[methodName]);
        }

        return options;
    },

    /**
     * ### Find One
     * @extends ghostBookshelf.Model.findOne to include roles
     * **See:** [ghostBookshelf.Model.findOne](base.js.html#Find%20One)
     */
    findOne: function findOne(data, options) {
        var query,
            status,
            optInc,
            lookupRole = data.role;

        delete data.role;

        data = _.defaults(data || {}, {
            status: 'active'
        });

        status = data.status;
        delete data.status;

        options = options || {};
        optInc = options.include;
        options.withRelated = _.union(options.withRelated, options.include);
        data = this.filterData(data);

        // Support finding by role
        if (lookupRole) {
            options.withRelated = _.union(options.withRelated, ['roles']);
            options.include = _.union(options.include, ['roles']);

            query = this.forge(data, {include: options.include});

            query.query('join', 'roles_users', 'users.id', '=', 'roles_users.user_id');
            query.query('join', 'roles', 'roles_users.role_id', '=', 'roles.id');
            query.query('where', 'roles.name', '=', lookupRole);
        } else {
            // We pass include to forge so that toJSON has access
            query = this.forge(data, {include: options.include});
        }

        if (status === 'active') {
            query.query('whereIn', 'status', activeStates);
        } else if (status === 'invited') {
            query.query('whereIn', 'status', invitedStates);
        } else if (status !== 'all') {
            query.query('where', {status: options.status});
        }

        options = this.filterOptions(options, 'findOne');
        delete options.include;
        options.include = optInc;

        return query.fetch(options);
    },

    /**
     * ### Edit
     * @extends ghostBookshelf.Model.edit to handle returning the full object
     * **See:** [ghostBookshelf.Model.edit](base.js.html#edit)
     */
    edit: function edit(data, options) {
        var self = this,
            roleId;

        if (data.roles && data.roles.length > 1) {
            return Promise.reject(
                new errors.ValidationError(i18n.t('errors.models.user.onlyOneRolePerUserSupported'))
            );
        }

        options = options || {};
        options.withRelated = _.union(options.withRelated, options.include);

        return ghostBookshelf.Model.edit.call(this, data, options).then(function then(user) {
            if (!data.roles) {
                return user;
            }

            roleId = parseInt(data.roles[0].id || data.roles[0], 10);

            return user.roles().fetch().then(function then(roles) {
                // return if the role is already assigned
                if (roles.models[0].id === roleId) {
                    return;
                }
                return ghostBookshelf.model('Role').findOne({id: roleId});
            }).then(function then(roleToAssign) {
                if (roleToAssign && roleToAssign.get('name') === 'Owner') {
                    return Promise.reject(
                        new errors.ValidationError(i18n.t('errors.models.user.methodDoesNotSupportOwnerRole'))
                    );
                } else {
                    // assign all other roles
                    return user.roles().updatePivot({role_id: roleId});
                }
            }).then(function then() {
                options.status = 'all';
                return self.findOne({id: user.id}, options);
            });
        });
    },

    /**
     * ## Add
     * Naive user add
     * Hashes the password provided before saving to the database.
     *
     * @param {object} data
     * @param {object} options
     * @extends ghostBookshelf.Model.add to manage all aspects of user signup
     * **See:** [ghostBookshelf.Model.add](base.js.html#Add)
     */
    add: function add(data, options) {
        var self = this,
            userData = this.filterData(data),
            roles;

        userData.password = _.toString(userData.password);

        options = this.filterOptions(options, 'add');
        options.withRelated = _.union(options.withRelated, options.include);

        // check for too many roles
        if (data.roles && data.roles.length > 1) {
            return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.onlyOneRolePerUserSupported')));
        }

        if (!validatePasswordLength(userData.password)) {
            return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.passwordDoesNotComplyLength')));
        }

        function getAuthorRole() {
            return ghostBookshelf.model('Role').findOne({name: 'Author'}, _.pick(options, 'transacting')).then(function then(authorRole) {
                return [authorRole.get('id')];
            });
        }

        roles = data.roles || getAuthorRole();
        delete data.roles;

        return generatePasswordHash(userData.password).then(function then(hash) {
            // Assign the hashed password
            userData.password = hash;
            // LookupGravatar
            return gravatar.lookup(userData);
        }).then(function then(userData) {
            // Save the user with the hashed password
            return ghostBookshelf.Model.add.call(self, userData, options);
        }).then(function then(addedUser) {
            // Assign the userData to our created user so we can pass it back
            userData = addedUser;
            // if we are given a "role" object, only pass in the role ID in place of the full object
            return Promise.resolve(roles).then(function then(roles) {
                roles = _.map(roles, function mapper(role) {
                    if (_.isString(role)) {
                        return parseInt(role, 10);
                    } else if (_.isNumber(role)) {
                        return role;
                    } else {
                        return parseInt(role.id, 10);
                    }
                });

                return addedUser.roles().attach(roles, options);
            });
        }).then(function then() {
            // find and return the added user
            return self.findOne({id: userData.id, status: 'all'}, options);
        });
    },

    setup: function setup(data, options) {
        var self = this,
            userData = this.filterData(data);

        if (!validatePasswordLength(userData.password)) {
            return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.passwordDoesNotComplyLength')));
        }

        options = this.filterOptions(options, 'setup');
        options.withRelated = _.union(options.withRelated, options.include);
        options.shortSlug = true;

        return generatePasswordHash(data.password).then(function then(hash) {
            // Assign the hashed password
            userData.password = hash;

            return Promise.join(
                gravatar.lookup(userData),
                ghostBookshelf.Model.generateSlug.call(this, User, userData.name, options)
            );
        }).then(function then(results) {
            userData = results[0];
            userData.slug = results[1];

            return self.edit.call(self, userData, options);
        });
    },

    permissible: function permissible(userModelOrId, action, context, loadedPermissions, hasUserPermission, hasAppPermission) {
        var self = this,
            userModel = userModelOrId,
            origArgs;

        // If we passed in a model without its related roles, we need to fetch it again
        if (_.isObject(userModelOrId) && !_.isObject(userModelOrId.related('roles'))) {
            userModelOrId = userModelOrId.id;
        }
        // If we passed in an id instead of a model get the model first
        if (_.isNumber(userModelOrId) || _.isString(userModelOrId)) {
            // Grab the original args without the first one
            origArgs = _.toArray(arguments).slice(1);
            // Get the actual user model
            return this.findOne({id: userModelOrId, status: 'all'}, {include: ['roles']}).then(function then(foundUserModel) {
                // Build up the original args but substitute with actual model
                var newArgs = [foundUserModel].concat(origArgs);

                return self.permissible.apply(self, newArgs);
            }, errors.logAndThrowError);
        }

        if (action === 'edit') {
            // Owner can only be editted by owner
            if (loadedPermissions.user && userModel.hasRole('Owner')) {
                hasUserPermission = _.some(loadedPermissions.user.roles, {name: 'Owner'});
            }
            // Users with the role 'Editor' and 'Ideator' have complex permissions when the action === 'edit'
            // We now have all the info we need to construct the permissions
            if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Ideator'})) {
                // If this is the same user that requests the operation allow it.
                hasUserPermission = hasUserPermission || context.user === userModel.get('id');
            }

            if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Editor'})) {
                // If this is the same user that requests the operation allow it.
                hasUserPermission = context.user === userModel.get('id');

                // Alternatively, if the user we are trying to edit is an Ideator, allow it
                hasUserPermission = hasUserPermission || userModel.hasRole('Ideator');

                // Alternatively, if the user we are trying to edit is an Builder, allow it
                hasUserPermission = hasUserPermission || userModel.hasRole('Builder');

                // Alternatively, if the user we are trying to edit is an Brand Owner, allow it
                hasUserPermission = hasUserPermission || userModel.hasRole('Brand Owner');
            }
        }

        if (action === 'destroy') {
            // Owner cannot be deleted EVER
            if (loadedPermissions.user && userModel.hasRole('Owner')) {
                return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.user.notEnoughPermission')));
            }

            // Users with the role 'Editor' have complex permissions when the action === 'destroy'
            if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Editor'})) {
                // If this is the same user that requests the operation allow it.
                hasUserPermission = context.user === userModel.get('id');

                // Alternatively, if the user we are trying to edit is an Ideator, allow it
                hasUserPermission = hasUserPermission || userModel.hasRole('Ideator');

                // Alternatively, if the user we are trying to edit is an Builder, allow it
                hasUserPermission = hasUserPermission || userModel.hasRole('Builder');

                // Alternatively, if the user we are trying to edit is an Brand Owner, allow it
                hasUserPermission = hasUserPermission || userModel.hasRole('Brand Owner');
            }
        }

        if (hasUserPermission && hasAppPermission) {
            return Promise.resolve();
        }

        return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.user.notEnoughPermission')));
    },

    setWarning: function setWarning(user, options) {
        var status = user.get('status'),
            regexp = /warn-(\d+)/i,
            level;

        if (status === 'active') {
            user.set('status', 'warn-1');
            level = 1;
        } else {
            level = parseInt(status.match(regexp)[1], 10) + 1;
            if (level > 4) {
                user.set('status', 'locked');
            } else {
                user.set('status', 'warn-' + level);
            }
        }
        return Promise.resolve(user.save(options)).then(function then() {
            return 5 - level;
        });
    },

    // Finds the user by email, and checks the password
    check: function check(object) {
        var self = this,
            s;
        return this.getByEmail(object.email).then(function then(user) {
            if (!user) {
                return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.user.noUserWithEnteredEmailAddr')));
            }
            if (user.get('status') === 'invited' || user.get('status') === 'invited-pending' ||
                    user.get('status') === 'inactive'
                ) {
                return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.user.userIsInactive')));
            }
            if (user.get('status') !== 'locked') {
                return bcryptCompare(object.password, user.get('password')).then(function then(matched) {
                    if (!matched) {
                        return Promise.resolve(self.setWarning(user, {validate: false})).then(function then(remaining) {
                            s = (remaining > 1) ? 's' : '';
                            return Promise.reject(new errors.UnauthorizedError(i18n.t('errors.models.user.incorrectPasswordAttempts', {remaining: remaining, s: s})));

                            // Use comma structure, not .catch, because we don't want to catch incorrect passwords
                        }, function handleError(error) {
                            // If we get a validation or other error during this save, catch it and log it, but don't
                            // cause a login error because of it. The user validation is not important here.
                            errors.logError(
                                error,
                                i18n.t('errors.models.user.userUpdateError.context'),
                                i18n.t('errors.models.user.userUpdateError.help')
                            );
                            return Promise.reject(new errors.UnauthorizedError(i18n.t('errors.models.user.incorrectPassword')));
                        });
                    }

                    return Promise.resolve(user.set({status: 'active', last_login: new Date()}).save({validate: false}))
                        .catch(function handleError(error) {
                            // If we get a validation or other error during this save, catch it and log it, but don't
                            // cause a login error because of it. The user validation is not important here.
                            errors.logError(
                                error,
                                i18n.t('errors.models.user.userUpdateError.context'),
                                i18n.t('errors.models.user.userUpdateError.help')
                            );
                            return user;
                        });
                }, errors.logAndThrowError);
            }
            return Promise.reject(new errors.NoPermissionError(
                i18n.t('errors.models.user.accountLocked')));
        }, function handleError(error) {
            if (error.message === 'NotFound' || error.message === 'EmptyResponse') {
                return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.user.noUserWithEnteredEmailAddr')));
            }

            return Promise.reject(error);
        });
    },

    /**
     * Naive change password method
     * @param {Object} object
     * @param {Object} options
     */
    changePassword: function changePassword(object, options) {
        var self = this,
            newPassword = object.newPassword,
            ne2Password = object.ne2Password,
            userId = parseInt(object.user_id),
            oldPassword = object.oldPassword,
            user;

        // If the two passwords do not match
        if (newPassword !== ne2Password) {
            return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.newPasswordsDoNotMatch')));
        }

        // If the old password is empty when changing current user's password
        if (userId === options.context.user && _.isEmpty(oldPassword)) {
            return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.passwordRequiredForOperation')));
        }

        // If password is not complex enough
        if (!validatePasswordLength(newPassword)) {
            return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.passwordDoesNotComplyLength')));
        }

        return self.forge({id: userId}).fetch({require: true}).then(function then(_user) {
            user = _user;
            // If the user is the current user, check old password
            if (userId === options.context.user) {
                return bcryptCompare(oldPassword, user.get('password'));
            }
            // If user is admin and changing another user's password, old password isn't compared to the old one
            return true;
        }).then(function then(matched) {
            if (!matched) {
                return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.incorrectPassword')));
            }

            return generatePasswordHash(newPassword);
        }).then(function then(hash) {
            return user.save({password: hash});
        });
    },

    generateResetToken: function generateResetToken(email, expires, dbHash) {
        return this.getByEmail(email).then(function then(foundUser) {
            if (!foundUser) {
                return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.user.noUserWithEnteredEmailAddr')));
            }

            var hash = crypto.createHash('sha256'),
                text = '';

            // Token:
            // BASE64(TIMESTAMP + email + HASH(TIMESTAMP + email + oldPasswordHash + dbHash ))
            hash.update(String(expires));
            hash.update(email.toLocaleLowerCase());
            hash.update(foundUser.get('password'));
            hash.update(String(dbHash));

            text += [expires, email, hash.digest('base64')].join('|');
            return new Buffer(text).toString('base64');
        });
    },

    validateToken: function validateToken(token, dbHash) {
        /*jslint bitwise:true*/
        // TODO: Is there a chance the use of ascii here will cause problems if oldPassword has weird characters?
        var tokenText = new Buffer(token, 'base64').toString('ascii'),
            parts,
            expires,
            email;

        parts = tokenText.split('|');

        // Check if invalid structure
        if (!parts || parts.length !== 3) {
            return Promise.reject(new errors.BadRequestError(i18n.t('errors.models.user.invalidTokenStructure')));
        }

        expires = parseInt(parts[0], 10);
        email = parts[1];

        if (isNaN(expires)) {
            return Promise.reject(new errors.BadRequestError(i18n.t('errors.models.user.invalidTokenExpiration')));
        }

        // Check if token is expired to prevent replay attacks
        if (expires < Date.now()) {
            return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.expiredToken')));
        }

        // to prevent brute force attempts to reset the password the combination of email+expires is only allowed for
        // 10 attempts
        if (tokenSecurity[email + '+' + expires] && tokenSecurity[email + '+' + expires].count >= 10) {
            return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.user.tokenLocked')));
        }

        return this.generateResetToken(email, expires, dbHash).then(function then(generatedToken) {
            // Check for matching tokens with timing independent comparison
            var diff = 0,
                i;

            // check if the token length is correct
            if (token.length !== generatedToken.length) {
                diff = 1;
            }

            for (i = token.length - 1; i >= 0; i = i - 1) {
                diff |= token.charCodeAt(i) ^ generatedToken.charCodeAt(i);
            }

            if (diff === 0) {
                return email;
            }

            // increase the count for email+expires for each failed attempt
            tokenSecurity[email + '+' + expires] = {
                count: tokenSecurity[email + '+' + expires] ? tokenSecurity[email + '+' + expires].count + 1 : 1
            };
            return Promise.reject(new errors.BadRequestError(i18n.t('errors.models.user.invalidToken')));
        });
    },

    resetPassword: function resetPassword(options) {
        var self = this,
            token = options.token,
            newPassword = options.newPassword,
            ne2Password = options.ne2Password,
            dbHash = options.dbHash;

        if (newPassword !== ne2Password) {
            return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.newPasswordsDoNotMatch')));
        }

        if (!validatePasswordLength(newPassword)) {
            return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.passwordDoesNotComplyLength')));
        }

        // Validate the token; returns the email address from token
        return self.validateToken(utils.decodeBase64URLsafe(token), dbHash).then(function then(email) {
            // Fetch the user by email, and hash the password at the same time.
            return Promise.join(
                self.getByEmail(email),
                generatePasswordHash(newPassword)
            );
        }).then(function then(results) {
            if (!results[0]) {
                return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.user.userNotFound')));
            }

            // Update the user with the new password hash
            var foundUser = results[0],
                passwordHash = results[1];

            return foundUser.save({password: passwordHash, status: 'active'});
        });
    },

    transferOwnership: function transferOwnership(object, options) {
        var ownerRole,
            contextUser;

        return Promise.join(ghostBookshelf.model('Role').findOne({name: 'Owner'}),
                            User.findOne({id: options.context.user}, {include: ['roles']}))
        .then(function then(results) {
            ownerRole = results[0];
            contextUser = results[1];

            // check if user has the owner role
            var currentRoles = contextUser.toJSON(options).roles;
            if (!_.some(currentRoles, {id: ownerRole.id})) {
                return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.user.onlyOwnerCanTransferOwnerRole')));
            }

            return Promise.join(ghostBookshelf.model('Role').findOne({name: 'Administrator'}),
                                User.findOne({id: object.id}, {include: ['roles']}));
        }).then(function then(results) {
            var adminRole = results[0],
                user = results[1],
                currentRoles = user.toJSON(options).roles;

            if (!_.some(currentRoles, {id: adminRole.id})) {
                return Promise.reject(new errors.ValidationError('errors.models.user.onlyAdmCanBeAssignedOwnerRole'));
            }

            // convert owner to admin
            return Promise.join(contextUser.roles().updatePivot({role_id: adminRole.id}),
                                user.roles().updatePivot({role_id: ownerRole.id}),
                                user.id);
        }).then(function then(results) {
            return Users.forge()
                .query('whereIn', 'id', [contextUser.id, results[2]])
                .fetch({withRelated: ['roles']});
        }).then(function then(users) {
            options.include = ['roles'];
            return users.toJSON(options);
        });
    },

    // Get the user by email address, enforces case insensitivity rejects if the user is not found
    // When multi-user support is added, email addresses must be deduplicated with case insensitivity, so that
    // joe@bloggs.com and JOE@BLOGGS.COM cannot be created as two separate users.
    getByEmail: function getByEmail(email, options) {
        options = options || {};
        // We fetch all users and process them in JS as there is no easy way to make this query across all DBs
        // Although they all support `lower()`, sqlite can't case transform unicode characters
        // This is somewhat mute, as validator.isEmail() also doesn't support unicode, but this is much easier / more
        // likely to be fixed in the near future.
        options.require = true;

        return Users.forge(options).fetch(options).then(function then(users) {
            var userWithEmail = users.find(function findUser(user) {
                return user.get('email').toLowerCase() === email.toLowerCase();
            });
            if (userWithEmail) {
                return userWithEmail;
            }
        });
    }
});

Users = ghostBookshelf.Collection.extend({
    model: User
});

module.exports = {
    User: ghostBookshelf.model('User', User),
    Users: ghostBookshelf.collection('Users', Users)
};
