const validator = require('@tryghost/validator');
const ObjectId = require('bson-objectid').default;
const ghostBookshelf = require('./base');
const baseUtils = require('./base/utils');
const limitService = require('../services/limits');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const security = require('@tryghost/security');
const {pipeline} = require('@tryghost/promise');
const validatePassword = require('../lib/validate-password');
const permissions = require('../services/permissions');
const urlUtils = require('../../shared/url-utils');
const {setIsRoles} = require('./role-utils');
const activeStates = ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4'];
const ASSIGNABLE_ROLES = ['Administrator', 'Super Editor', 'Editor', 'Author', 'Contributor'];
const DEFAULT_ACCESSIBILITY_PREFERENCES = JSON.stringify({nightShift: 'system'});

const messages = {
    valueCannotBeBlank: 'Value in [{tableName}.{columnKey}] cannot be blank.',
    onlyOneRolePerUserSupported: 'Only one role per user is supported at the moment.',
    methodDoesNotSupportOwnerRole: 'This method does not support assigning the owner role',
    invalidRoleValue: {
        message: 'Role should be an existing role id or a role name',
        context: 'Invalid role assigned to the user',
        help: 'Change the provided role to a role id or use a role name.'
    },
    userNotFound: 'User not found',
    ownerNotFound: 'Owner not found',
    notEnoughPermission: 'You do not have permission to perform this action',
    cannotChangeStatus: 'You cannot change your own status.',
    cannotChangeOwnRole: 'You cannot change your own role.',
    cannotChangeOwnersRole: 'Cannot change Owner\'s role',
    noUserWithEnteredEmailAddr: 'There is no user with that email address.',
    accountSuspended: 'Your account was suspended.',
    passwordRequiredForOperation: 'Password is required for this operation',
    incorrectPassword: 'Your password is incorrect.',
    onlyOwnerCanTransferOwnerRole: 'Only owners are able to transfer the owner role.',
    onlyAdmCanBeAssignedOwnerRole: 'Only administrators can be assigned the owner role.',
    onlyActiveAdmCanBeAssignedOwnerRole: 'Only active administrators can be assigned the owner role.',
    userUpdateError: {
        emailIsAlreadyInUse: 'Email is already in use',
        help: 'Visit and save your profile after logging in to check for problems.'
    }
};

/**
 * inactive: owner user before blog setup, suspended users
 * locked user: imported users, they get a random password
 */
const inactiveStates = ['inactive', 'locked'];

const allStates = activeStates.concat(inactiveStates);
let User;
let Users;

User = ghostBookshelf.Model.extend({

    tableName: 'users',

    actionsCollectCRUD: true,
    actionsResourceType: 'user',

    defaults: function defaults() {
        return {
            // secretlint-disable-next-line @secretlint/secretlint-rule-pattern
            password: security.identifier.uid(50),
            visibility: 'public',
            status: 'active',
            comment_notifications: true,
            free_member_signup_notification: true,
            paid_subscription_started_notification: true,
            paid_subscription_canceled_notification: false,
            mention_notifications: true,
            recommendation_notifications: true,
            milestone_notifications: true,
            donation_notifications: true,
            gift_subscription_notifications: true
        };
    },

    format(options) {
        if (options.website &&
            !validator.isURL(options.website, {
                require_protocol: true,
                protocols: ['http', 'https']
            })) {
            options.website = 'http://' + options.website;
        }

        const attrs = ghostBookshelf.Model.prototype.format.call(this, options);

        ['profile_image', 'cover_image'].forEach((attr) => {
            if (attrs[attr]) {
                attrs[attr] = urlUtils.toTransformReady(attrs[attr]);
            }
        });

        return attrs;
    },

    parse() {
        const attrs = ghostBookshelf.Model.prototype.parse.apply(this, arguments);

        ['profile_image', 'cover_image'].forEach((attr) => {
            if (attrs[attr]) {
                attrs[attr] = urlUtils.transformReadyToAbsolute(attrs[attr]);
            }
        });

        return attrs;
    },

    filterRelations: function filterRelations() {
        return {
            roles: {
                tableName: 'roles',
                type: 'manyToMany',
                joinTable: 'roles_users',
                joinFrom: 'user_id',
                joinTo: 'role_id'
            }
        };
    },

    emitChange: function emitChange(event, options) {
        const eventToTrigger = 'user' + '.' + event;
        ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options);
    },

    /**
     * @TODO:
     *
     * The user model does not use bookshelf-relations yet.
     * Therefore we have to remove the relations manually.
     */
    onDestroying(model, options) {
        ghostBookshelf.Model.prototype.onDestroying.apply(this, arguments);

        return (options.transacting || ghostBookshelf.knex)('roles_users')
            .where('user_id', model.id)
            .del();
    },

    onDestroyed: function onDestroyed(model, options) {
        const result = ghostBookshelf.Model.prototype.onDestroyed.apply(this, arguments);

        if (activeStates.includes(model.previous('status'))) {
            model.emitChange('deactivated', options);
        }

        model.emitChange('deleted', options);

        return result;
    },

    onCreated: function onCreated(model, options) {
        const result = ghostBookshelf.Model.prototype.onCreated.apply(this, arguments);

        model.emitChange('added', options);

        // active is the default state, so if status isn't provided, this will be an active user
        if (!model.get('status') || activeStates.includes(model.get('status'))) {
            model.emitChange('activated', options);
        }

        return result;
    },

    onUpdated: function onUpdated(model, options) {
        const result = ghostBookshelf.Model.prototype.onUpdated.apply(this, arguments);

        model.statusChanging = model.get('status') !== model.previous('status');
        model.isActive = activeStates.includes(model.get('status'));

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

        model.emitChange('edited', options);

        return result;
    },

    isActive: function isActive() {
        return activeStates.indexOf(this.get('status')) !== -1;
    },

    isLocked: function isLocked() {
        return this.get('status') === 'locked';
    },

    /**
     * Replace this user's password with an opaque random value, and mark
     * them as locked unless they are already inactive (suspended). Suspended
     * users must not be transitioned out of `inactive` — they retain the
     * suspended-signin path — but their password is still rotated so a
     * compromised credential cannot survive a future unsuspend.
     */
    lock: function lock(options) {
        const update = {
            // secretlint-disable-next-line @secretlint/secretlint-rule-pattern
            password: security.identifier.uid(50)
        };
        if (this.get('status') !== 'inactive') {
            update.status = 'locked';
        }
        return this.save(update, {...options, patch: true});
    },

    isInactive: function isInactive() {
        return this.get('status') === 'inactive';
    },

    /**
     * Lookup Gravatar if email changes to update image url
     * Generating a slug requires a db call to look for conflicting slugs
     */
    onSaving: function onSaving(newPage, attr, options) {
        const self = this;
        const tasks = [];
        let passwordValidation = {};

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

        /**
         * Bookshelf call order:
         *   - onSaving
         *   - onValidate (validates the model against the schema)
         *
         * Before we can generate a slug, we have to ensure that the name is not blank.
         */
        if (!this.get('name')) {
            throw new errors.ValidationError({
                message: tpl(messages.valueCannotBeBlank, {
                    tableName: this.tableName,
                    columnKey: 'name'
                })
            });
        }

        // If the user's email is set & has changed & we are not importing
        if (self.hasChanged('email') && self.get('email') && !options.importing) {
            tasks.push((function lookUpGravatar() {
                const {gravatar} = require('../lib/image');

                return gravatar.lookup({
                    email: self.get('email')
                }).then(function (response) {
                    if (response && response.image) {
                        self.set('profile_image', response.image);
                    }
                });
            })());
        }

        if (this.hasChanged('slug') || !this.get('slug')) {
            tasks.push((function generateSlug() {
                return ghostBookshelf.Model.generateSlug(
                    User,
                    self.get('slug') || self.get('name'),
                    {
                        status: 'all',
                        transacting: options.transacting,
                        shortSlug: !self.get('slug')
                    })
                    .then(function then(slug) {
                        self.set({slug: slug});
                    });
            })());
        }

        /**
         * CASE: add model, hash password
         * CASE: update model, hash password
         *
         * Important:
         *   - Password hashing happens when we import a database
         *   - we do some pre-validation checks, because onValidate is called AFTER onSaving
         *   - when importing, we set the password to a random uid and don't validate, just hash it and lock the user
         *   - when importing with `importPersistUser` we check if the password is a bcrypt hash already and fall back to
         *     normal behavior if not (set random password, lock user, and hash password)
         *   - no validations should run, when importing
         */
        if (self.hasChanged('password')) {
            this.set('password', String(this.get('password')));

            // CASE: import with `importPersistUser` should always be an bcrypt password already,
            // and won't re-hash or overwrite it.
            // In case the password is not bcrypt hashed we fall back to the standard behavior.
            if (options.importPersistUser && this.get('password').match(/^\$2[ayb]\$.{56}$/i)) {
                return;
            }

            if (options.importing) {
                // always set password to a random uid when importing
                this.set('password', security.identifier.uid(50));

                // lock users so they have to follow the password reset flow
                if (this.get('status') !== 'inactive') {
                    this.set('status', 'locked');
                }
            } else {
                // CASE: we're not importing data, validate the data
                passwordValidation = validatePassword(this.get('password'), this.get('email'));

                if (!passwordValidation.isValid) {
                    return Promise.reject(new errors.ValidationError({
                        message: passwordValidation.message
                    }));
                }
            }

            tasks.push((function hashPassword() {
                return security.password.hash(self.get('password'))
                    .then(function (hash) {
                        self.set('password', hash);
                    });
            })());
        }

        return Promise.all(tasks);
    },

    toJSON: function toJSON(unfilteredOptions) {
        const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, unfilteredOptions);

        // remove password hash for security reasons
        delete attrs.password;

        return attrs;
    },

    sessions: function sessions() {
        return this.hasMany('Session');
    },

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

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

    apiKeys() {
        return this.hasMany('ApiKey', 'user_id');
    },

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

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

    updateLastSeen: function updateLastSeen() {
        this.set({last_seen: new Date()});
        return this.save();
    },

    hasLoggedIn: function hasLoggedIn() {
        return Boolean(this.get('last_seen'));
    },

    enforcedFilters: function enforcedFilters(options) {
        if (options.context && options.context.internal) {
            return null;
        }

        return options.context && options.context.public ? 'status:[' + allStates.join(',') + ']' : null;
    },

    defaultFilters: function defaultFilters(options) {
        if (options.context && options.context.internal) {
            return null;
        }

        return options.context && options.context.public ? null : 'status:[' + allStates.join(',') + ']';
    },

    /**
     * You can pass an extra `status=VALUES` field.
     * Long-Term: We should deprecate these short cuts and force users to use the filter param.
     */
    extraFilters: function extraFilters(options) {
        if (!options.status) {
            return null;
        }

        let filter = null;

        // CASE: Check if the incoming status value is valid, otherwise fallback to "active"
        if (options.status !== 'all') {
            options.status = allStates.indexOf(options.status) > -1 ? options.status : 'active';
        }

        if (options.status === 'active') {
            filter = `status:[${activeStates}]`;
        } else if (options.status === 'all') {
            filter = `status:[${allStates}]`;
        } else {
            filter = `status:${options.status}`;
        }

        delete options.status;

        return filter;
    }
}, {
    orderDefaultOptions: function orderDefaultOptions() {
        return {
            last_seen: 'DESC',
            name: 'ASC',
            created_at: 'DESC'
        };
    },

    /**
     * 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, options) {
        let permittedOptionsToReturn = ghostBookshelf.Model.permittedOptions.call(this, methodName);

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

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

        // CASE: The `withRelated` parameter is allowed when using the public API, but not the `roles` value.
        // Otherwise we expose too much information.
        // @TODO: the target controller should define the allowed includes, but not the model layer O_O (https://github.com/TryGhost/Ghost/issues/10106)
        if (options && options.context && options.context.public) {
            if (options.withRelated && options.withRelated.indexOf('roles') !== -1) {
                options.withRelated.splice(options.withRelated.indexOf('roles'), 1);
            }
        }

        return permittedOptionsToReturn;
    },

    countRelations() {
        return {
            posts(modelOrCollection, options) {
                modelOrCollection.query('columns', 'users.*', (qb) => {
                    qb.count('posts.id')
                        .from('posts')
                        .join('posts_authors', 'posts.id', 'posts_authors.post_id')
                        .whereRaw('posts_authors.author_id = users.id')
                        .as('count__posts');

                    if (options.context && options.context.public) {
                        // @TODO use the filter behavior for posts
                        qb.andWhere('posts.type', '=', 'post');
                        qb.andWhere('posts.status', '=', 'published');
                    }
                });
            }
        };
    },

    /**
     * ### Find One
     *
     * We have to clone the data, because we remove values from this object.
     * This is not expected from outside!
     *
     * @TODO: use base class
     *
     * @extends ghostBookshelf.Model.findOne to include roles
     * **See:** [ghostBookshelf.Model.findOne](base.js.html#Find%20One)
     */
    findOne: function findOne(dataToClone, unfilteredOptions) {
        const options = this.filterOptions(unfilteredOptions, 'findOne');
        let query;
        let status;
        let data = JSON.parse(JSON.stringify(dataToClone));
        const lookupRole = data.role;

        // Ensure only valid fields/columns are added to query
        if (options.columns) {
            options.columns = options.columns.filter(col => this.prototype.permittedAttributes().includes(col));
        }

        delete data.role;
        data = Object.assign({}, {status: 'all'}, data || {});

        status = data.status;
        delete data.status;

        data = this.filterData(data);

        // Support finding by role
        if (lookupRole) {
            options.withRelated = [...new Set([...(options.withRelated || []), 'roles'])];
            query = this.forge(data);

            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 {
            query = this.forge(data);
        }

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

        return query.fetch(options);
    },

    /**
     * Returns users who should receive a specific type of alert
     * @param {'free-signup'|'paid-started'|'paid-canceled'} type The type of alert to fetch users for
     * @param {any} options
     * @return {Promise<[Object]>} Array of users
     */
    getEmailAlertUsers(type, options) {
        options = options || {};

        let filter = 'status:active';
        if (type === 'free-signup') {
            filter += '+free_member_signup_notification:true';
        } else if (type === 'paid-started') {
            filter += '+paid_subscription_started_notification:true';
        } else if (type === 'paid-canceled') {
            filter += '+paid_subscription_canceled_notification:true';
        } else if (type === 'mention-received') {
            filter += '+mention_notifications:true';
        } else if (type === 'milestone-received') {
            filter += '+milestone_notifications:true';
        } else if (type === 'donation') {
            filter += '+donation_notifications:true';
        } else if (type === 'recommendation-received') {
            filter += '+recommendation_notifications:true';
        } else if (type === 'gift-subscriptions') {
            filter += '+gift_subscription_notifications:true';
        }
        const updatedOptions = Object.assign({}, options, {filter, withRelated: ['roles']});
        return this.findAll(updatedOptions).then((users) => {
            return users.toJSON().filter((user) => {
                return user?.roles?.some((role) => {
                    return ['Owner', 'Administrator'].includes(role.name);
                });
            });
        });
    },

    /**
     * ### Edit
     *
     * Note: In case of login the last_seen attribute gets updated.
     *
     * @extends ghostBookshelf.Model.edit to handle returning the full object
     * **See:** [ghostBookshelf.Model.edit](base.js.html#edit)
     */
    edit: function edit(data, unfilteredOptions) {
        const options = this.filterOptions(unfilteredOptions, 'edit');
        const self = this;
        const ops = [];

        if (data.roles && data.roles.length > 1) {
            return Promise.reject(
                new errors.ValidationError({
                    message: tpl(messages.onlyOneRolePerUserSupported)
                })
            );
        }

        if (data.email) {
            ops.push(function checkForDuplicateEmail() {
                return self.getByEmail(data.email, options).then(function then(user) {
                    if (user && user.id !== options.id) {
                        return Promise.reject(new errors.ValidationError({
                            message: tpl(messages.userUpdateError.emailIsAlreadyInUse)
                        }));
                    }
                });
            });
        }

        ops.push(function update() {
            return ghostBookshelf.Model.edit.call(self, data, options).then(async (user) => {
                let roleId;

                if (!data.roles || !data.roles.length) {
                    return user;
                }

                roleId = data.roles[0].id || data.roles[0];

                return user.roles().fetch().then((roles) => {
                    // return if the role is already assigned
                    if (roles.models[0].id === roleId) {
                        return;
                    }

                    if (ASSIGNABLE_ROLES.includes(roleId)) {
                        // return if the role is already assigned
                        if (roles.models[0].get('name') === roleId) {
                            return;
                        }

                        return ghostBookshelf.model('Role').findOne({
                            name: roleId
                        });
                    } else if (ObjectId.isValid(roleId)){
                        return ghostBookshelf.model('Role').findOne({
                            id: roleId
                        });
                    } else {
                        return Promise.reject(
                            new errors.ValidationError({
                                message: tpl(messages.invalidRoleValue.message),
                                context: tpl(messages.invalidRoleValue.context),
                                help: tpl(messages.invalidRoleValue.help)
                            })
                        );
                    }
                }).then((roleToAssign) => {
                    if (roleToAssign && roleToAssign.get('name') === 'Owner') {
                        return Promise.reject(
                            new errors.ValidationError({
                                message: tpl(messages.methodDoesNotSupportOwnerRole)
                            })
                        );
                    } else if (roleToAssign) {
                        // assign all other roles
                        return user.roles().updatePivot({role_id: roleToAssign.id});
                    }
                }).then(() => {
                    options.status = 'all';
                    return self.findOne({id: user.id}, options);
                }).then((model) => {
                    model._changed = user._changed;
                    return model;
                });
            });
        });

        return pipeline(ops);
    },

    /**
     * ## Add
     * Naive user add
     * Hashes the password provided before saving to the database.
     *
     * We have to clone the data, because we remove values from this object.
     * This is not expected from outside!
     *
     * @param {object} dataToClone
     * @param {object} unfilteredOptions
     * @extends ghostBookshelf.Model.add to manage all aspects of user signup
     * **See:** [ghostBookshelf.Model.add](base.js.html#Add)
     */
    add: function add(dataToClone, unfilteredOptions) {
        const options = this.filterOptions(unfilteredOptions, 'add');
        const self = this;
        const data = JSON.parse(JSON.stringify(dataToClone));
        let userData = this.filterData(data);
        let roles;

        // check for too many roles
        if (data.roles && data.roles.length > 1) {
            return Promise.reject(new errors.ValidationError({
                message: tpl(messages.onlyOneRolePerUserSupported)
            }));
        }

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

        /**
         * We need this default author role because of the following Ghost feature:
         * You setup your blog and you can invite people instantly, but without choosing a role.
         * roles: [] -> no default role (used for owner creation, see fixtures.json)
         * roles: undefined -> default role
         */
        roles = data.roles;
        delete data.roles;

        if (!options.importing && typeof userData.accessibility === 'undefined') {
            userData.accessibility = DEFAULT_ACCESSIBILITY_PREFERENCES;
        }

        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;
            })
            .then(function () {
                if (!roles) {
                    return getAuthorRole();
                }

                return Promise.resolve(roles);
            })
            .then(function (_roles) {
                roles = _roles;

                // CASE: it is possible to add roles by name, by id or by object
                if (typeof roles[0] === 'string' && !ObjectId.isValid(roles[0])) {
                    const rolePromises = roles.map((roleName) => {
                        return ghostBookshelf.model('Role').findOne({
                            name: roleName
                        }, options);
                    });
                    return Promise.all(rolePromises)
                        .then((roleModels) => {
                            roles = roleModels.map((roleModel) => {
                                return roleModel.id;
                            });
                        });
                }

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

    destroy: function destroy(unfilteredOptions) {
        const options = this.filterOptions(unfilteredOptions, 'destroy', {extraAllowedProperties: ['id']});

        const destroyUser = () => {
            return ghostBookshelf.Model.destroy.call(this, options);
        };

        if (!options.transacting) {
            return ghostBookshelf.transaction((transacting) => {
                options.transacting = transacting;
                return destroyUser();
            });
        }

        return destroyUser();
    },

    /**
     * We override the owner!
     * Owner already has a slug -> force setting a new one by setting slug to null
     * @TODO: kill setup function?
     */
    setup: function setup(data, unfilteredOptions) {
        const options = this.filterOptions(unfilteredOptions, 'setup');
        const self = this;
        const userData = this.filterData(data);
        let passwordValidation = {};

        passwordValidation = validatePassword(userData.password, userData.email, data.blogTitle);

        if (!passwordValidation.isValid) {
            return Promise.reject(new errors.ValidationError({
                message: passwordValidation.message
            }));
        }

        userData.slug = null;
        if (typeof userData.accessibility === 'undefined') {
            userData.accessibility = DEFAULT_ACCESSIBILITY_PREFERENCES;
        }

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

    /**
     * Right now the setup of the blog depends on the user status.
     * Only if the owner user is `inactive`, then the blog is not setup.
     * e.g. if you transfer ownership to a locked user, you blog is still setup.
     *
     * @TODO: Rename `inactive` status to something else, it's confusing. e.g. requires-setup
     * @TODO: Depending on the user status results in https://github.com/TryGhost/Ghost/issues/8003
     */
    isSetup: function isSetup(options) {
        return this.getOwnerUser(options)
            .then(function (owner) {
                return owner.get('status') !== 'inactive';
            });
    },

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

        return this.findOne({
            role: 'Owner',
            status: 'all'
        }, options).then(function (owner) {
            if (!owner) {
                return Promise.reject(new errors.NotFoundError({
                    message: tpl(messages.ownerNotFound)
                }));
            }

            return owner;
        });
    },

    ownerIdCache: {
        value: null,
        set(value) {
            this.value = value;
        },
        get() {
            return this.value;
        },
        clear() {
            this.value = null;
        }
    },

    getOwnerId: function getOwnerId(options) {
        if (this.ownerIdCache.value !== null) {
            return Promise.resolve(this.ownerIdCache.value);
        }

        return this.getOwnerUser(options).then((owner) => {
            this.ownerIdCache.set(owner.id);

            return owner.id;
        });
    },

    generateId: function generateId() {
        return ObjectId().toHexString();
    },

    /**
     * Checks if a user has permission to perform an action on another user
     *
     * @param {Object|string|number} userModelOrId - The user model or ID being acted upon
     * @param {'edit'|'destroy'} action - The action being performed:
     *                                     - 'edit': Edit user details, status, or role
     *                                     - 'destroy': Delete a user (Owner cannot be deleted)
     * @param {Object} context - The context of the request, containing the current user's ID
     * @param {Object} unsafeAttrs - The attributes being modified in the action
     * @param {Object} loadedPermissions - The permissions of the user making the request
     * @param {boolean} hasUserPermission - Whether the user has permission based on user roles
     * @param {boolean} hasApiKeyPermission - Whether the user has permission based on API key
     * @returns {Promise<boolean>} Resolves if the action is permitted, rejects with NoPermissionError if not
     * @throws {errors.NotFoundError} When the target user is not found
     * @throws {errors.NoPermissionError} When the action is not permitted
     * @throws {errors.ValidationError} When role changes are invalid
     */
    permissible: async function permissible(userModelOrId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasApiKeyPermission) {
        const self = this;
        const userModel = userModelOrId;
        let origArgs;
        const {isOwner, isEitherEditor} = setIsRoles(loadedPermissions);

        // If we passed in a model without its related roles, we need to fetch it again
        if (typeof userModelOrId === 'object' && !(typeof userModelOrId.related('roles') === 'object')) {
            userModelOrId = userModelOrId.id;
        }
        // If we passed in an id instead of a model get the model first
        if (typeof userModelOrId === 'number' || typeof userModelOrId === 'string') {
            // Grab the original args without the first one
            origArgs = Array.from(arguments).slice(1);

            // Get the actual user model
            return this.findOne({
                id: userModelOrId,
                status: 'all'
            }, {withRelated: ['roles']}).then(function then(foundUserModel) {
                if (!foundUserModel) {
                    throw new errors.NotFoundError({
                        message: tpl(messages.userNotFound)
                    });
                }

                // Build up the original args but substitute with actual model
                const newArgs = [foundUserModel].concat(origArgs);

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

        const isUnsuspending = unsafeAttrs.status && unsafeAttrs.status === 'active' && userModel.get('status') === 'inactive';

        // If we have a staff user limit & the staff user is being unsuspended (don't count contributors)
        if (limitService.isLimited('staff') && action === 'edit' && isUnsuspending && !userModel.hasRole('Contributor')) {
            await limitService.errorIfWouldGoOverLimit('staff');
        }

        if (action === 'edit') {
            if (context.user === userModel.get('id')) {
                // If this is the same user that requests the operation allow it.
                hasUserPermission = true;
            } else if (loadedPermissions.user && userModel.hasRole('Owner')) {
                // Owner can only be edited by owner
                hasUserPermission = isOwner;
            } else if (isEitherEditor) {
                // If the user we are trying to edit is an Author or Contributor, allow it
                hasUserPermission = userModel.hasRole('Author') || userModel.hasRole('Contributor');
            }
        }

        if (action === 'destroy') {
            // Owner cannot be deleted EVER
            if (userModel.hasRole('Owner')) {
                return Promise.reject(new errors.NoPermissionError({
                    message: tpl(messages.notEnoughPermission)
                }));
            }

            // Users with the role 'Editor' have complex permissions when the action === 'destroy'
            if (isEitherEditor) {
                // Alternatively, if the user we are trying to edit is an Author, allow it
                hasUserPermission = context.user === userModel.get('id') || userModel.hasRole('Author') || userModel.hasRole('Contributor');
            }
        }

        // CASE: can't edit my own status to inactive or locked
        if (action === 'edit' && userModel.id === context.user) {
            if (User.inactiveStates.indexOf(unsafeAttrs.status) !== -1) {
                return Promise.reject(new errors.NoPermissionError({
                    message: tpl(messages.cannotChangeStatus)
                }));
            }
        }

        // CASE: i want to edit roles
        if (action === 'edit' && unsafeAttrs.roles && unsafeAttrs.roles[0]) {
            let role = unsafeAttrs.roles[0];
            let roleId = role.id || role;
            let editedUserId = userModel.id;
            // @NOTE: role id of logged in user
            let contextRoleId = loadedPermissions.user.roles[0].id;

            if (roleId !== contextRoleId && editedUserId === context.user) {
                return Promise.reject(new errors.NoPermissionError({
                    message: tpl(messages.cannotChangeOwnRole)
                }));
            }

            if (limitService.isLimited('staff') && userModel.hasRole('Contributor') && role.name !== 'Contributor') {
                // CASE: if your site is limited to a certain number of staff users
                // Trying to change the role of a contributor, who doesn't count towards the limit, to any other role requires a limit check
                // To check if it's OK to add one more staff user
                await limitService.errorIfWouldGoOverLimit('staff');
            }

            return User.getOwnerUser()
                .then((owner) => {
                    // CASE: owner can assign role to any user
                    if (context.user === owner.id) {
                        if (hasUserPermission && hasApiKeyPermission) {
                            return Promise.resolve();
                        }

                        return Promise.reject(new errors.NoPermissionError({
                            message: tpl(messages.notEnoughPermission)
                        }));
                    }

                    // CASE: You try to change the role of the owner user
                    if (editedUserId === owner.id) {
                        if (owner.related('roles').at(0).id !== roleId) {
                            return Promise.reject(new errors.NoPermissionError({
                                message: tpl(messages.cannotChangeOwnersRole)
                            }));
                        }
                    } else if (roleId !== contextRoleId) {
                        // CASE: you are trying to change a role, but you are not owner
                        // @NOTE: your role is not the same than the role you try to change (!)
                        // e.g. admin can assign admin role to a user, but not owner

                        return permissions.canThis(context).assign.role(role)
                            .then(() => {
                                if (hasUserPermission && hasApiKeyPermission) {
                                    return Promise.resolve();
                                }

                                return Promise.reject(new errors.NoPermissionError({
                                    message: tpl(messages.notEnoughPermission)
                                }));
                            });
                    }

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

                    return Promise.reject(new errors.NoPermissionError({
                        message: tpl(messages.notEnoughPermission)
                    }));
                });
        }

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

        return Promise.reject(new errors.NoPermissionError({
            message: tpl(messages.notEnoughPermission)
        }));
    },

    // Finds the user by email, and checks the password
    // @TODO: shorten this function and rename...
    check: function check(object) {
        const self = this;

        return this.getByEmail(object.email)
            .then((user) => {
                if (!user) {
                    throw new errors.NotFoundError({
                        message: tpl(messages.noUserWithEnteredEmailAddr)
                    });
                }

                if (user.isLocked()) {
                    throw new errors.PasswordResetRequiredError();
                }

                if (user.isInactive()) {
                    throw new errors.NoPermissionError({
                        message: tpl(messages.accountSuspended)
                    });
                }

                return self.isPasswordCorrect({plainPassword: object.password, hashedPassword: user.get('password')})
                    .then(() => {
                        return user.updateLastSeen();
                    })
                    .then(() => {
                        user.set({status: 'active'});
                        return user.save();
                    });
            })
            .catch((err) => {
                if (err.message === 'NotFound' || err.message === 'EmptyResponse') {
                    throw new errors.NotFoundError({
                        message: tpl(messages.noUserWithEnteredEmailAddr)
                    });
                }

                throw err;
            });
    },

    isPasswordCorrect: function isPasswordCorrect(object) {
        const plainPassword = object.plainPassword;
        const hashedPassword = object.hashedPassword;

        if (!plainPassword || !hashedPassword) {
            return Promise.reject(new errors.ValidationError({
                message: tpl(messages.passwordRequiredForOperation)
            }));
        }

        return security.password.compare(plainPassword, hashedPassword)
            .then(function (matched) {
                if (matched) {
                    return;
                }

                return Promise.reject(new errors.ValidationError({
                    context: tpl(messages.incorrectPassword),
                    message: tpl(messages.incorrectPassword),
                    help: tpl(messages.userUpdateError.help),
                    code: 'PASSWORD_INCORRECT'
                }));
            });
    },

    /**
     * Naive change password method
     * @param {Object} object
     * @param {Object} unfilteredOptions
     */
    changePassword: async function changePassword(object, unfilteredOptions) {
        const options = this.filterOptions(unfilteredOptions, 'changePassword');
        const newPassword = object.newPassword;
        const userId = object.user_id;
        const oldPassword = object.oldPassword;
        const isLoggedInUser = userId === options.context.user;

        options.require = true;
        options.withRelated = ['sessions'];

        const user = await this.forge({id: userId}).fetch(options);

        if (isLoggedInUser) {
            await this.isPasswordCorrect({
                plainPassword: oldPassword,
                hashedPassword: user.get('password')
            });
        }

        const updatedUser = await user.save({password: newPassword});

        // Destroy every active session for this user. The caller must mint a
        // fresh session (with a new session_id) for self password-changes so
        // that stolen or cloned cookies are invalidated alongside distinct
        // concurrent sessions.
        const sessions = user.related('sessions');
        for (const session of sessions) {
            await session.destroy(options);
        }

        return updatedUser;
    },

    transferOwnership: function transferOwnership(object, unfilteredOptions) {
        const options = ghostBookshelf.Model.filterOptions(unfilteredOptions, 'transferOwnership');
        let ownerRole;
        let contextUser;

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

                // check if user has the owner role
                const currentRoles = contextUser.toJSON(options).roles;
                if (!currentRoles.some(role => role.id === ownerRole.id)) {
                    return Promise.reject(new errors.NoPermissionError({
                        message: tpl(messages.onlyOwnerCanTransferOwnerRole)
                    }));
                }

                return Promise.all([
                    ghostBookshelf.model('Role').findOne({name: 'Administrator'}),
                    User.findOne({id: object.id}, {withRelated: ['roles']})
                ]);
            })
            .then((results) => {
                const adminRole = results[0];
                const user = results[1];

                if (!user) {
                    return Promise.reject(new errors.NotFoundError({
                        message: tpl(messages.userNotFound)
                    }));
                }

                const {roles: currentRoles, status} = user.toJSON(options);

                if (!currentRoles.some(role => role.id === adminRole.id)) {
                    return Promise.reject(new errors.ValidationError({
                        message: tpl(messages.onlyAdmCanBeAssignedOwnerRole)
                    }));
                }

                if (status !== 'active') {
                    return Promise.reject(new errors.ValidationError({
                        message: tpl(messages.onlyActiveAdmCanBeAssignedOwnerRole)
                    }));
                }

                // convert owner to admin
                return Promise.all([
                    contextUser.roles().updatePivot({role_id: adminRole.id}),
                    user.roles().updatePivot({role_id: ownerRole.id}),
                    user.id
                ]);
            })
            .then((results) => {
                this.ownerIdCache.clear();

                return Users.forge()
                    .query('whereIn', 'id', [contextUser.id, results[2]])
                    .fetch({withRelated: ['roles']});
            });
    },

    // 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, unfilteredOptions) {
        const options = ghostBookshelf.Model.filterOptions(unfilteredOptions, 'getByEmail');

        // 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().fetch(options).then(function then(users) {
            const userWithEmail = users.find(function findUser(user) {
                return user.get('email').toLowerCase() === email.toLowerCase();
            });

            if (userWithEmail) {
                return userWithEmail;
            }
        });
    },
    inactiveStates: inactiveStates
});

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

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