const Promise = require('bluebird'),
    {extend, merge, omit, cloneDeep, assign} = require('lodash'),
    validator = require('validator'),
    config = require('../../config'),
    common = require('../../lib/common'),
    security = require('../../lib/security'),
    constants = require('../../lib/constants'),
    pipeline = require('../../lib/promise/pipeline'),
    mail = require('../../services/mail'),
    urlService = require('../../services/url'),
    localUtils = require('./utils'),
    models = require('../../models'),
    web = require('../../web'),
    mailAPI = require('./mail'),
    settingsAPI = require('./settings'),
    tokenSecurity = {};

let authentication;

/**
 * Returns setup status
 *
 * @return {Promise<Boolean>}
 */
function checkSetup() {
    return authentication.isSetup().then((result) => {
        return result.setup[0].status;
    });
}

/**
 * Allows an assertion to be made about setup status.
 *
 * @param  {Boolean} status True: setup must be complete. False: setup must not be complete.
 * @return {Function} returns a "task ready" function
 */
function assertSetupCompleted(status) {
    return function checkPermission(__) {
        return checkSetup().then((isSetup) => {
            if (isSetup === status) {
                return __;
            }

            const completed = common.i18n.t('errors.api.authentication.setupAlreadyCompleted'),
                notCompleted = common.i18n.t('errors.api.authentication.setupMustBeCompleted');

            function throwReason(reason) {
                throw new common.errors.NoPermissionError({message: reason});
            }

            if (isSetup) {
                throwReason(completed);
            } else {
                throwReason(notCompleted);
            }
        });
    };
}

function setupTasks(setupData) {
    let tasks;

    function validateData(setupData) {
        return localUtils.checkObject(setupData, 'setup').then((checked) => {
            const data = checked.setup[0];

            return {
                name: data.name,
                email: data.email,
                password: data.password,
                blogTitle: data.blogTitle,
                status: 'active'
            };
        });
    }

    function setupUser(userData) {
        const context = {context: {internal: true}},
            User = models.User;

        return User.findOne({role: 'Owner', status: 'all'}).then((owner) => {
            if (!owner) {
                throw new common.errors.GhostError({
                    message: common.i18n.t('errors.api.authentication.setupUnableToRun')
                });
            }

            return User.setup(userData, extend({id: owner.id}, context));
        }).then((user) => {
            return {
                user: user,
                userData: userData
            };
        });
    }

    function doSettings(data) {
        const user = data.user,
            blogTitle = data.userData.blogTitle,
            context = {context: {user: data.user.id}};

        let userSettings;

        if (!blogTitle || typeof blogTitle !== 'string') {
            return user;
        }

        userSettings = [
            {key: 'title', value: blogTitle.trim()},
            {key: 'description', value: common.i18n.t('common.api.authentication.sampleBlogDescription')}
        ];

        return settingsAPI.edit({settings: userSettings}, context).return(user);
    }

    function formatResponse(user) {
        return user.toJSON({context: {internal: true}});
    }

    tasks = [
        validateData,
        setupUser,
        doSettings,
        formatResponse
    ];

    return pipeline(tasks, setupData);
}

/**
 * ## Authentication API Methods
 *
 * **See:** [API Methods](events.js.html#api%20methods)
 */
authentication = {
    /**
     * @description generate a reset token for a given email address
     * @param {Object} object
     * @returns {Promise<Object>} message
     */
    generateResetToken(object) {
        let tasks;

        function validateRequest(object) {
            return localUtils.checkObject(object, 'passwordreset').then((data) => {
                const email = data.passwordreset[0].email;

                if (typeof email !== 'string' || !validator.isEmail(email)) {
                    throw new common.errors.BadRequestError({
                        message: common.i18n.t('errors.api.authentication.noEmailProvided')
                    });
                }

                return email;
            });
        }

        function generateToken(email) {
            const options = {context: {internal: true}};
            let dbHash, token;

            return settingsAPI.read(merge({key: 'db_hash'}, options))
                .then((response) => {
                    dbHash = response.settings[0].value;

                    return models.User.getByEmail(email, options);
                })
                .then((user) => {
                    if (!user) {
                        throw new common.errors.NotFoundError({message: common.i18n.t('errors.api.users.userNotFound')});
                    }

                    token = security.tokens.resetToken.generateHash({
                        expires: Date.now() + constants.ONE_DAY_MS,
                        email: email,
                        dbHash: dbHash,
                        password: user.get('password')
                    });

                    return {
                        email: email,
                        resetToken: token
                    };
                });
        }

        function sendResetNotification(data) {
            const adminUrl = urlService.utils.urlFor('admin', true),
                resetUrl = urlService.utils.urlJoin(adminUrl, 'reset', security.url.encodeBase64(data.resetToken), '/');

            return mail.utils.generateContent({
                data: {
                    resetUrl: resetUrl
                },
                template: 'reset-password'
            }).then((content) => {
                const payload = {
                    mail: [{
                        message: {
                            to: data.email,
                            subject: common.i18n.t('common.api.authentication.mail.resetPassword'),
                            html: content.html,
                            text: content.text
                        },
                        options: {}
                    }]
                };

                return mailAPI.send(payload, {context: {internal: true}});
            });
        }

        function formatResponse() {
            return {
                passwordreset: [
                    {message: common.i18n.t('common.api.authentication.mail.checkEmailForInstructions')}
                ]
            };
        }

        tasks = [
            assertSetupCompleted(true),
            validateRequest,
            generateToken,
            sendResetNotification,
            formatResponse
        ];

        return pipeline(tasks, object);
    },

    /**
     * ## Reset Password
     * reset password if a valid token and password (2x) is passed
     * @param {Object} object
     * @returns {Promise<Object>} message
     */
    resetPassword(object, opts) {
        let tasks,
            tokenIsCorrect,
            dbHash,
            tokenParts;
        const options = {context: {internal: true}};

        function validateRequest() {
            return localUtils.validate('passwordreset')(object, options)
                .then((options) => {
                    const data = options.data.passwordreset[0];

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

                    return Promise.resolve(options);
                });
        }

        function extractTokenParts(options) {
            options.data.passwordreset[0].token = security.url.decodeBase64(options.data.passwordreset[0].token);

            tokenParts = security.tokens.resetToken.extract({
                token: options.data.passwordreset[0].token
            });

            if (!tokenParts) {
                return Promise.reject(new common.errors.UnauthorizedError({
                    message: common.i18n.t('errors.api.common.invalidTokenStructure')
                }));
            }

            return Promise.resolve(options);
        }

        // @TODO: use brute force middleware (see https://github.com/TryGhost/Ghost/pull/7579)
        function protectBruteForce(options) {
            if (tokenSecurity[`${tokenParts.email}+${tokenParts.expires}`] &&
                tokenSecurity[`${tokenParts.email}+${tokenParts.expires}`].count >= 10) {
                return Promise.reject(new common.errors.NoPermissionError({
                    message: common.i18n.t('errors.models.user.tokenLocked')
                }));
            }

            return Promise.resolve(options);
        }

        function doReset(options) {
            const data = options.data.passwordreset[0],
                resetToken = data.token,
                oldPassword = data.oldPassword,
                newPassword = data.newPassword;

            return settingsAPI.read(merge({key: 'db_hash'}, omit(options, 'data')))
                .then((response) => {
                    dbHash = response.settings[0].value;

                    return models.User.getByEmail(tokenParts.email, options);
                })
                .then((user) => {
                    if (!user) {
                        throw new common.errors.NotFoundError({message: common.i18n.t('errors.api.users.userNotFound')});
                    }

                    tokenIsCorrect = security.tokens.resetToken.compare({
                        token: resetToken,
                        dbHash: dbHash,
                        password: user.get('password')
                    });

                    if (!tokenIsCorrect) {
                        return Promise.reject(new common.errors.BadRequestError({
                            message: common.i18n.t('errors.api.common.invalidTokenStructure')
                        }));
                    }

                    web.shared.middlewares.api.spamPrevention.userLogin()
                        .reset(opts.ip, `${tokenParts.email}login`);

                    return models.User.changePassword({
                        oldPassword: oldPassword,
                        newPassword: newPassword,
                        user_id: user.id
                    }, options);
                })
                .then((updatedUser) => {
                    updatedUser.set('status', 'active');
                    return updatedUser.save(options);
                })
                .catch(common.errors.ValidationError, (err) => {
                    return Promise.reject(err);
                })
                .catch((err) => {
                    if (common.errors.utils.isIgnitionError(err)) {
                        return Promise.reject(err);
                    }
                    return Promise.reject(new common.errors.UnauthorizedError({err: err}));
                });
        }

        function formatResponse() {
            return {
                passwordreset: [
                    {message: common.i18n.t('common.api.authentication.mail.passwordChanged')}
                ]
            };
        }

        tasks = [
            validateRequest,
            assertSetupCompleted(true),
            extractTokenParts,
            protectBruteForce,
            doReset,
            formatResponse
        ];

        return pipeline(tasks, object, options);
    },

    /**
     * ### Accept Invitation
     * @param {Object} invitation an invitation object
     * @returns {Promise<Object>}
     */
    acceptInvitation(invitation) {
        let tasks,
            invite;
        const options = {context: {internal: true}};

        function validateInvitation(invitation) {
            return localUtils.checkObject(invitation, 'invitation')
                .then(() => {
                    if (!invitation.invitation[0].token) {
                        return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noTokenProvided')}));
                    }

                    if (!invitation.invitation[0].email) {
                        return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noEmailProvided')}));
                    }

                    if (!invitation.invitation[0].password) {
                        return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noPasswordProvided')}));
                    }

                    if (!invitation.invitation[0].name) {
                        return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noNameProvided')}));
                    }

                    return invitation;
                });
        }

        function processInvitation(invitation) {
            const data = invitation.invitation[0],
                inviteToken = security.url.decodeBase64(data.token);

            return models.Invite.findOne({token: inviteToken, status: 'sent'}, options)
                .then((_invite) => {
                    invite = _invite;

                    if (!invite) {
                        throw new common.errors.NotFoundError({message: common.i18n.t('errors.api.invites.inviteNotFound')});
                    }

                    if (invite.get('expires') < Date.now()) {
                        throw new common.errors.NotFoundError({message: common.i18n.t('errors.api.invites.inviteExpired')});
                    }

                    return models.User.add({
                        email: data.email,
                        name: data.name,
                        password: data.password,
                        roles: [invite.toJSON().role_id]
                    }, options);
                })
                .then(() => {
                    return invite.destroy(options);
                });
        }

        function formatResponse() {
            return {
                invitation: [
                    {message: common.i18n.t('common.api.authentication.mail.invitationAccepted')}
                ]
            };
        }

        tasks = [
            assertSetupCompleted(true),
            validateInvitation,
            processInvitation,
            formatResponse
        ];

        return pipeline(tasks, invitation);
    },

    /**
     * ### Check for invitation
     * @param {Object} options
     * @returns {Promise<Object>} An invitation status
     */
    isInvitation(options) {
        let tasks;
        const localOptions = cloneDeep(options || {});

        function processArgs(options) {
            const email = options.email;

            if (typeof email !== 'string' || !validator.isEmail(email)) {
                throw new common.errors.BadRequestError({
                    message: common.i18n.t('errors.api.authentication.invalidEmailReceived')
                });
            }

            return email;
        }

        function checkInvitation(email) {
            return models.Invite
                .findOne({email: email, status: 'sent'}, options)
                .then((invite) => {
                    if (!invite) {
                        return {invitation: [{valid: false}]};
                    }

                    return {invitation: [{valid: true}]};
                });
        }

        tasks = [
            processArgs,
            assertSetupCompleted(true),
            checkInvitation
        ];

        return pipeline(tasks, localOptions);
    },

    /**
     * Checks the setup status
     * @return {Promise}
     */
    isSetup() {
        let tasks;

        function checkSetupStatus() {
            return models.User.isSetup();
        }

        function formatResponse(isSetup) {
            return {
                setup: [
                    {
                        status: isSetup,
                        // Pre-populate from config if, and only if the values exist in config.
                        title: config.title || undefined,
                        name: config.user_name || undefined,
                        email: config.user_email || undefined
                    }
                ]
            };
        }

        tasks = [
            checkSetupStatus,
            formatResponse
        ];

        return pipeline(tasks);
    },

    /**
     * Executes the setup tasks and sends an email to the owner
     * @param  {Object} setupDetails
     * @return {Promise<Object>} a user api payload
     */
    setup(setupDetails) {
        let tasks;

        function doSetup(setupDetails) {
            return setupTasks(setupDetails);
        }

        function sendNotification(setupUser) {
            const data = {
                ownerEmail: setupUser.email
            };

            common.events.emit('setup.completed', setupUser);

            if (config.get('sendWelcomeEmail')) {
                return mail.utils.generateContent({data: data, template: 'welcome'})
                    .then((content) => {
                        const message = {
                                to: setupUser.email,
                                subject: common.i18n.t('common.api.authentication.mail.yourNewGhostBlog'),
                                html: content.html,
                                text: content.text
                            },
                            payload = {
                                mail: [{
                                    message: message,
                                    options: {}
                                }]
                            };

                        mailAPI.send(payload, {context: {internal: true}})
                            .catch((err) => {
                                err.context = common.i18n.t('errors.api.authentication.unableToSendWelcomeEmail');
                                common.logging.error(err);
                            });
                    })
                    .return(setupUser);
            }

            return setupUser;
        }

        function formatResponse(setupUser) {
            return {users: [setupUser]};
        }

        tasks = [
            assertSetupCompleted(false),
            doSetup,
            sendNotification,
            formatResponse
        ];

        return pipeline(tasks, setupDetails);
    },

    /**
     * Updates the blog setup
     * @param  {Object} setupDetails request payload with setup details
     * @param  {Object} options
     * @return {Promise<Object>} a User API response payload
     */
    updateSetup(setupDetails, options) {
        let tasks;
        const localOptions = cloneDeep(options || {});

        function processArgs(setupDetails, options) {
            if (!options.context || !options.context.user) {
                throw new common.errors.NoPermissionError({message: common.i18n.t('errors.api.authentication.notTheBlogOwner')});
            }

            return assign({setupDetails: setupDetails}, options);
        }

        function checkPermission(options) {
            return models.User.findOne({role: 'Owner', status: 'all'})
                .then((owner) => {
                    if (owner.id !== options.context.user) {
                        throw new common.errors.NoPermissionError({message: common.i18n.t('errors.api.authentication.notTheBlogOwner')});
                    }

                    return options.setupDetails;
                });
        }

        function formatResponse(user) {
            return {users: [user]};
        }

        tasks = [
            processArgs,
            assertSetupCompleted(true),
            checkPermission,
            setupTasks,
            formatResponse
        ];

        return pipeline(tasks, setupDetails, localOptions);
    },

    /**
     * Revokes a bearer token.
     * @param {Object} tokenDetails
     * @param {Object} options
     * @return {Promise<Object>} an object containing the revoked token.
     */
    revoke(tokenDetails, options) {
        let tasks;
        const localOptions = cloneDeep(options || {});

        function processArgs(tokenDetails, options) {
            return assign({}, tokenDetails, options);
        }

        function revokeToken(options) {
            const providers = [
                    models.Refreshtoken,
                    models.Accesstoken
                ],
                response = {token: options.token};

            function destroyToken(provider, options, providers) {
                return provider.destroyByToken(options)
                    .return(response)
                    .catch(provider.NotFoundError, () => {
                        if (!providers.length) {
                            return {
                                token: tokenDetails.token,
                                error: common.i18n.t('errors.api.authentication.invalidTokenProvided')
                            };
                        }

                        return destroyToken(providers.pop(), options, providers);
                    })
                    .catch(() => {
                        throw new common.errors.TokenRevocationError({
                            message: common.i18n.t('errors.api.authentication.tokenRevocationFailed')
                        });
                    });
            }

            return destroyToken(providers.pop(), options, providers);
        }

        tasks = [
            processArgs,
            revokeToken
        ];

        return pipeline(tasks, tokenDetails, localOptions);
    }
};

module.exports = authentication;